diff --git a/.gitignore b/.gitignore index fa4b2456e..b1895b1c8 100644 --- a/.gitignore +++ b/.gitignore @@ -276,3 +276,6 @@ website/.docusaurus # Generated from testing /test/fixtures/test-package/package-lock.json + +# tsoa generated files (regenerated by `npm run build-tsoa`) +src/service/generatedRoutes.ts diff --git a/docs/Architecture.md b/docs/Architecture.md index 7f49ebe62..f7021a00c 100644 --- a/docs/Architecture.md +++ b/docs/Architecture.md @@ -432,7 +432,7 @@ Allows defining ways to authenticate to the API. This is useful for securing cus If `apiAuthentication` is left empty, API endpoints will be publicly accesible. -Currently, only JWT auth is supported. This is implemented via the [`jwtAuthHandler` middleware](/src/service/passport/jwtAuthHandler.ts). Aside of validating incoming access tokens, it can also assign roles based on the token payload. +Currently, only JWT auth is supported. This is implemented via the [`@Security('jwt')` decorator](/src/service/authentication.ts). Aside of validating incoming access tokens, it can also assign roles based on the token payload. ##### Setting up JWT Authentication diff --git a/package-lock.json b/package-lock.json index e35608362..4931b4dc1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -52,6 +52,7 @@ "react-html-parser": "^2.0.2", "react-router-dom": "6.30.3", "simple-git": "^3.30.0", + "tsoa": "^7.0.0-alpha.0", "uuid": "^13.0.0", "validator": "^13.15.26", "yargs": "^17.7.2" @@ -2544,6 +2545,322 @@ "dev": true, "license": "MIT" }, + "node_modules/@hapi/accept": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@hapi/accept/-/accept-6.0.3.tgz", + "integrity": "sha512-p72f9k56EuF0n3MwlBNThyVE5PXX40g+aQh+C/xbKrfzahM2Oispv3AXmOIU51t3j77zay1qrX7IIziZXspMlw==", + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/boom": "^10.0.1", + "@hapi/hoek": "^11.0.2" + } + }, + "node_modules/@hapi/ammo": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/@hapi/ammo/-/ammo-6.0.1.tgz", + "integrity": "sha512-pmL+nPod4g58kXrMcsGLp05O2jF4P2Q3GiL8qYV7nKYEh3cGf+rV4P5Jyi2Uq0agGhVU63GtaSAfBEZOlrJn9w==", + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/hoek": "^11.0.2" + } + }, + "node_modules/@hapi/b64": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/@hapi/b64/-/b64-6.0.1.tgz", + "integrity": "sha512-ZvjX4JQReUmBheeCq+S9YavcnMMHWqx3S0jHNXWIM1kQDxB9cyfSycpVvjfrKcIS8Mh5N3hmu/YKo4Iag9g2Kw==", + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/hoek": "^11.0.2" + } + }, + "node_modules/@hapi/boom": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@hapi/boom/-/boom-10.0.1.tgz", + "integrity": "sha512-ERcCZaEjdH3OgSJlyjVk8pHIFeus91CjKP3v+MpgBNp5IvGzP2l/bRiD78nqYcKPaZdbKkK5vDBVPd2ohHBlsA==", + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/hoek": "^11.0.2" + } + }, + "node_modules/@hapi/bounce": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@hapi/bounce/-/bounce-3.0.2.tgz", + "integrity": "sha512-d0XmlTi3H9HFDHhQLjg4F4auL1EY3Wqj7j7/hGDhFFe6xAbnm3qiGrXeT93zZnPH8gH+SKAFYiRzu26xkXcH3g==", + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/boom": "^10.0.1", + "@hapi/hoek": "^11.0.2" + } + }, + "node_modules/@hapi/bourne": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@hapi/bourne/-/bourne-3.0.0.tgz", + "integrity": "sha512-Waj1cwPXJDucOib4a3bAISsKJVb15MKi9IvmTI/7ssVEm6sywXGjVJDhl6/umt1pK1ZS7PacXU3A1PmFKHEZ2w==", + "license": "BSD-3-Clause" + }, + "node_modules/@hapi/call": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/@hapi/call/-/call-9.0.1.tgz", + "integrity": "sha512-uPojQRqEL1GRZR4xXPqcLMujQGaEpyVPRyBlD8Pp5rqgIwLhtveF9PkixiKru2THXvuN8mUrLeet5fqxKAAMGg==", + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/boom": "^10.0.1", + "@hapi/hoek": "^11.0.2" + } + }, + "node_modules/@hapi/catbox": { + "version": "12.1.1", + "resolved": "https://registry.npmjs.org/@hapi/catbox/-/catbox-12.1.1.tgz", + "integrity": "sha512-hDqYB1J+R0HtZg4iPH3LEnldoaBsar6bYp0EonBmNQ9t5CO+1CqgCul2ZtFveW1ReA5SQuze9GPSU7/aecERhw==", + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/boom": "^10.0.1", + "@hapi/hoek": "^11.0.2", + "@hapi/podium": "^5.0.0", + "@hapi/validate": "^2.0.1" + } + }, + "node_modules/@hapi/catbox-memory": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@hapi/catbox-memory/-/catbox-memory-6.0.2.tgz", + "integrity": "sha512-H1l4ugoFW/ZRkqeFrIo8p1rWN0PA4MDTfu4JmcoNDvnY975o29mqoZblqFTotxNHlEkMPpIiIBJTV+Mbi+aF0g==", + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/boom": "^10.0.1", + "@hapi/hoek": "^11.0.2" + } + }, + "node_modules/@hapi/content": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/@hapi/content/-/content-6.0.1.tgz", + "integrity": "sha512-lQ2vOoFMNYxwKVnKf+3Pi3PfoviM4EJYlT9JbrBPfEc0xKMiVDqqXF8UTE1S1oKhHQliWSP5t6zTKNlmaXBGcQ==", + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/boom": "^10.0.0" + } + }, + "node_modules/@hapi/cryptiles": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@hapi/cryptiles/-/cryptiles-6.0.3.tgz", + "integrity": "sha512-r6VKalpbMHz4ci3gFjFysBmhwCg70RpYZy6OkjEpdXzAYnYFX5XsW7n4YMJvuIYpnMwLxGUjK/cBhA7X3JDvXw==", + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/boom": "^10.0.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@hapi/file": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@hapi/file/-/file-3.0.0.tgz", + "integrity": "sha512-w+lKW+yRrLhJu620jT3y+5g2mHqnKfepreykvdOcl9/6up8GrQQn+l3FRTsjHTKbkbfQFkuksHpdv2EcpKcJ4Q==", + "license": "BSD-3-Clause" + }, + "node_modules/@hapi/hapi": { + "version": "21.4.8", + "resolved": "https://registry.npmjs.org/@hapi/hapi/-/hapi-21.4.8.tgz", + "integrity": "sha512-l93IrEG4iQyM+yKdngWmkPtajkJGM81yfinmSFmiaNHG+r1fgsWaewwcE1hhsFnqPrVZpU8Y3PiVJMb6uT+01Q==", + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/accept": "^6.0.3", + "@hapi/ammo": "^6.0.1", + "@hapi/boom": "^10.0.1", + "@hapi/bounce": "^3.0.2", + "@hapi/call": "^9.0.1", + "@hapi/catbox": "^12.1.1", + "@hapi/catbox-memory": "^6.0.2", + "@hapi/heavy": "^8.0.1", + "@hapi/hoek": "^11.0.7", + "@hapi/mimos": "^7.0.1", + "@hapi/podium": "^5.0.2", + "@hapi/shot": "^6.0.2", + "@hapi/somever": "^4.1.1", + "@hapi/statehood": "^8.2.1", + "@hapi/subtext": "^8.1.2", + "@hapi/teamwork": "^6.0.1", + "@hapi/topo": "^6.0.2", + "@hapi/validate": "^2.0.1" + }, + "engines": { + "node": ">=14.15.0" + } + }, + "node_modules/@hapi/heavy": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/@hapi/heavy/-/heavy-8.0.1.tgz", + "integrity": "sha512-gBD/NANosNCOp6RsYTsjo2vhr5eYA3BEuogk6cxY0QdhllkkTaJFYtTXv46xd6qhBVMbMMqcSdtqey+UQU3//w==", + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/boom": "^10.0.1", + "@hapi/hoek": "^11.0.2", + "@hapi/validate": "^2.0.1" + } + }, + "node_modules/@hapi/hoek": { + "version": "11.0.7", + "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-11.0.7.tgz", + "integrity": "sha512-HV5undWkKzcB4RZUusqOpcgxOaq6VOAH7zhhIr2g3G8NF/MlFO75SjOr2NfuSx0Mh40+1FqCkagKLJRykUWoFQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@hapi/iron": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/@hapi/iron/-/iron-7.0.1.tgz", + "integrity": "sha512-tEZnrOujKpS6jLKliyWBl3A9PaE+ppuL/+gkbyPPDb/l2KSKQyH4lhMkVb+sBhwN+qaxxlig01JRqB8dk/mPxQ==", + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/b64": "^6.0.1", + "@hapi/boom": "^10.0.1", + "@hapi/bourne": "^3.0.0", + "@hapi/cryptiles": "^6.0.1", + "@hapi/hoek": "^11.0.2" + } + }, + "node_modules/@hapi/mimos": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/@hapi/mimos/-/mimos-7.0.1.tgz", + "integrity": "sha512-b79V+BrG0gJ9zcRx1VGcCI6r6GEzzZUgiGEJVoq5gwzuB2Ig9Cax8dUuBauQCFKvl2YWSWyOc8mZ8HDaJOtkew==", + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/hoek": "^11.0.2", + "mime-db": "^1.52.0" + } + }, + "node_modules/@hapi/nigel": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@hapi/nigel/-/nigel-5.0.1.tgz", + "integrity": "sha512-uv3dtYuB4IsNaha+tigWmN8mQw/O9Qzl5U26Gm4ZcJVtDdB1AVJOwX3X5wOX+A07qzpEZnOMBAm8jjSqGsU6Nw==", + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/hoek": "^11.0.2", + "@hapi/vise": "^5.0.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@hapi/pez": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@hapi/pez/-/pez-6.1.1.tgz", + "integrity": "sha512-yg2OS1tC0S1sHXvhUtWsfRn6lrKl9jKtRhZ+EI0woOW/gqX5vM2PZ1459ypCvCYDRLJ9nIyueeEH5MJV1ZDqIg==", + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/b64": "^6.0.1", + "@hapi/boom": "^10.0.1", + "@hapi/content": "^6.0.1", + "@hapi/hoek": "^11.0.7", + "@hapi/nigel": "^5.0.1" + } + }, + "node_modules/@hapi/podium": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@hapi/podium/-/podium-5.0.2.tgz", + "integrity": "sha512-T7gf2JYHQQfEfewTQFbsaXoZxSvuXO/QBIGljucUQ/lmPnTTNAepoIKOakWNVWvo2fMEDjycu77r8k6dhreqHA==", + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/hoek": "^11.0.2", + "@hapi/teamwork": "^6.0.0", + "@hapi/validate": "^2.0.1" + } + }, + "node_modules/@hapi/shot": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@hapi/shot/-/shot-6.0.2.tgz", + "integrity": "sha512-WKK1ShfJTrL1oXC0skoIZQYzvLsyMDEF8lfcWuQBjpjCN29qivr9U36ld1z0nt6edvzv28etNMOqUF4klnHryw==", + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/hoek": "^11.0.2", + "@hapi/validate": "^2.0.1" + } + }, + "node_modules/@hapi/somever": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@hapi/somever/-/somever-4.1.1.tgz", + "integrity": "sha512-lt3QQiDDOVRatS0ionFDNrDIv4eXz58IibQaZQDOg4DqqdNme8oa0iPWcE0+hkq/KTeBCPtEOjDOBKBKwDumVg==", + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/bounce": "^3.0.1", + "@hapi/hoek": "^11.0.2" + } + }, + "node_modules/@hapi/statehood": { + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/@hapi/statehood/-/statehood-8.2.1.tgz", + "integrity": "sha512-xf72TG/QINW26jUu+uL5H+crE1o8GplIgfPWwPZhnAGJzetIVAQEQYvzq+C0aEVHg5/lMMtQ+L9UryuSa5Yjkg==", + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/boom": "^10.0.1", + "@hapi/bounce": "^3.0.1", + "@hapi/bourne": "^3.0.0", + "@hapi/cryptiles": "^6.0.1", + "@hapi/hoek": "^11.0.2", + "@hapi/iron": "^7.0.1", + "@hapi/validate": "^2.0.1" + } + }, + "node_modules/@hapi/subtext": { + "version": "8.1.2", + "resolved": "https://registry.npmjs.org/@hapi/subtext/-/subtext-8.1.2.tgz", + "integrity": "sha512-2x71YJHmFpCjhIhfiNZdKp63nh3xRPp7RrwH7JoO9R4Sd0DRzzRU/VfX2fMmUR7jcoS5qNET1WyGIaqKpMu/ng==", + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/boom": "^10.0.1", + "@hapi/bourne": "^3.0.0", + "@hapi/content": "^6.0.1", + "@hapi/file": "^3.0.0", + "@hapi/hoek": "^11.0.7", + "@hapi/pez": "^6.1.1", + "@hapi/wreck": "^18.1.0" + } + }, + "node_modules/@hapi/teamwork": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/@hapi/teamwork/-/teamwork-6.0.1.tgz", + "integrity": "sha512-52OXRslUfYwXAOG8k58f2h2ngXYQGP0x5RPOo+eWA/FtyLgHjGMrE3+e9LSXP/0q2YfHAK5wj9aA9DTy1K+kyQ==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@hapi/topo": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@hapi/topo/-/topo-6.0.2.tgz", + "integrity": "sha512-KR3rD5inZbGMrHmgPxsJ9dbi6zEK+C3ZwUwTa+eMwWLz7oijWUTWD2pMSNNYJAU6Qq+65NkxXjqHr/7LM2Xkqg==", + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/hoek": "^11.0.2" + } + }, + "node_modules/@hapi/validate": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@hapi/validate/-/validate-2.0.1.tgz", + "integrity": "sha512-NZmXRnrSLK8MQ9y/CMqE9WSspgB9xA41/LlYR0k967aSZebWr4yNrpxIbov12ICwKy4APSlWXZga9jN5p6puPA==", + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/hoek": "^11.0.2", + "@hapi/topo": "^6.0.1" + } + }, + "node_modules/@hapi/vise": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@hapi/vise/-/vise-5.0.1.tgz", + "integrity": "sha512-XZYWzzRtINQLedPYlIkSkUr7m5Ddwlu99V9elh8CSygXstfv3UnWIXT0QD+wmR0VAG34d2Vx3olqcEhRRoTu9A==", + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/hoek": "^11.0.2" + } + }, + "node_modules/@hapi/wreck": { + "version": "18.1.0", + "resolved": "https://registry.npmjs.org/@hapi/wreck/-/wreck-18.1.0.tgz", + "integrity": "sha512-0z6ZRCmFEfV/MQqkQomJ7sl/hyxvcZM7LtuVqN3vdAO4vM9eBbowl0kaqQj9EJJQab+3Uuh1GxbGIBFy4NfJ4w==", + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/boom": "^10.0.1", + "@hapi/bourne": "^3.0.0", + "@hapi/hoek": "^11.0.2" + } + }, "node_modules/@humanfs/core": { "version": "0.19.1", "dev": true, @@ -4154,6 +4471,339 @@ "dev": true, "license": "MIT" }, + "node_modules/@tsoa/cli": { + "version": "7.0.0-alpha.0", + "resolved": "https://registry.npmjs.org/@tsoa/cli/-/cli-7.0.0-alpha.0.tgz", + "integrity": "sha512-fCBWv6F20qrpwFh5X+vaZs38Bh/5cVwKPhvG/uArR+crZgEowyNl76unLCmYdvycES9Y6g/TKFLagG8qojSNuQ==", + "license": "MIT", + "dependencies": { + "@tsoa/runtime": "^7.0.0-alpha.0", + "@types/multer": "^1.4.12", + "fs-extra": "^11.2.0", + "glob": "^10.3.10", + "handlebars": "^4.7.8", + "merge-anything": "^5.1.7", + "minimatch": "^9.0.1", + "ts-deepmerge": "^7.0.2", + "typescript": "^5.7.2", + "validator": "^13.12.0", + "yaml": "^2.6.1", + "yargs": "^17.7.1" + }, + "bin": { + "tsoa": "dist/cli.js" + }, + "engines": { + "node": ">=18.0.0", + "yarn": ">=1.9.4" + } + }, + "node_modules/@tsoa/cli/node_modules/brace-expansion": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz", + "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@tsoa/cli/node_modules/fs-extra": { + "version": "11.3.4", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.4.tgz", + "integrity": "sha512-CTXd6rk/M3/ULNQj8FBqBWHYBVYybQ3VPBw0xGKFe3tuH7ytT6ACnvzpIQ3UZtB8yvUKC2cXn1a+x+5EVQLovA==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" + } + }, + "node_modules/@tsoa/cli/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@tsoa/runtime": { + "version": "7.0.0-alpha.0", + "resolved": "https://registry.npmjs.org/@tsoa/runtime/-/runtime-7.0.0-alpha.0.tgz", + "integrity": "sha512-zlWYz2bLfaN6WtFoIbLBEAyVhKG4IQKJ9QPzeRFFKeAsh5zYN4/ocnd14XyWs4ehY9TdtTnma2drW89aYNSRYw==", + "license": "MIT", + "dependencies": { + "@hapi/boom": "^10.0.1", + "@hapi/hapi": "^21.3.12", + "@types/koa": "^2.15.0", + "@types/multer": "^1.4.12", + "express": "^4.21.2", + "reflect-metadata": "^0.2.2", + "validator": "^13.12.0" + }, + "engines": { + "node": ">=18.0.0", + "yarn": ">=1.9.4" + } + }, + "node_modules/@tsoa/runtime/node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@tsoa/runtime/node_modules/body-parser": { + "version": "1.20.4", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", + "integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.14.0", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/@tsoa/runtime/node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@tsoa/runtime/node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "license": "MIT" + }, + "node_modules/@tsoa/runtime/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/@tsoa/runtime/node_modules/debug/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/@tsoa/runtime/node_modules/express": { + "version": "4.22.1", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", + "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.3", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.12", + "proxy-addr": "~2.0.7", + "qs": "~6.14.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@tsoa/runtime/node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/@tsoa/runtime/node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@tsoa/runtime/node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@tsoa/runtime/node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@tsoa/runtime/node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/@tsoa/runtime/node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@tsoa/runtime/node_modules/path-to-regexp": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", + "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", + "license": "MIT" + }, + "node_modules/@tsoa/runtime/node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/@tsoa/runtime/node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/@tsoa/runtime/node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@types/accepts": { + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/@types/accepts/-/accepts-1.3.7.tgz", + "integrity": "sha512-Pay9fq2lM2wXPWbteBsRAGiWH2hig4ZE2asK+mm7kUzlxRTfL961rj89I6zV/E3PcIkDqyuBEcMxFT7rccugeQ==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/activedirectory2": { "version": "1.2.6", "resolved": "https://registry.npmjs.org/@types/activedirectory2/-/activedirectory2-1.2.6.tgz", @@ -4203,7 +4853,6 @@ }, "node_modules/@types/body-parser": { "version": "1.19.5", - "dev": true, "license": "MIT", "dependencies": { "@types/connect": "*", @@ -4212,12 +4861,17 @@ }, "node_modules/@types/connect": { "version": "3.4.38", - "dev": true, "license": "MIT", "dependencies": { "@types/node": "*" } }, + "node_modules/@types/content-disposition": { + "version": "0.5.9", + "resolved": "https://registry.npmjs.org/@types/content-disposition/-/content-disposition-0.5.9.tgz", + "integrity": "sha512-8uYXI3Gw35MhiVYhG3s295oihrxRyytcRHjSjqnqZVDDy/xcGBRny7+Xj1Wgfhv5QzRtN2hB2dVRBUX9XW3UcQ==", + "license": "MIT" + }, "node_modules/@types/conventional-commits-parser": { "version": "5.0.1", "dev": true, @@ -4231,6 +4885,18 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/cookies": { + "version": "0.9.2", + "resolved": "https://registry.npmjs.org/@types/cookies/-/cookies-0.9.2.tgz", + "integrity": "sha512-1AvkDdZM2dbyFybL4fxpuNCaWyv//0AwsuUk2DWeXyM1/5ZKm6W3z6mQi24RZ4l2ucY+bkSHzbDVpySqPGuV8A==", + "license": "MIT", + "dependencies": { + "@types/connect": "*", + "@types/express": "*", + "@types/keygrip": "*", + "@types/node": "*" + } + }, "node_modules/@types/cors": { "version": "2.8.19", "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz", @@ -4273,7 +4939,6 @@ "version": "5.0.6", "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.6.tgz", "integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==", - "dev": true, "license": "MIT", "dependencies": { "@types/body-parser": "*", @@ -4293,7 +4958,6 @@ "version": "5.1.1", "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.1.tgz", "integrity": "sha512-v4zIMr/cX7/d2BpAEX3KNKL/JrT1s43s96lLvvdTmza1oEvDudCqK9aF/djc/SWgy8Yh0h30TZx5VpzqFCxk5A==", - "dev": true, "license": "MIT", "dependencies": { "@types/node": "*", @@ -4323,9 +4987,14 @@ "domhandler": "^2.4.0" } }, + "node_modules/@types/http-assert": { + "version": "1.5.6", + "resolved": "https://registry.npmjs.org/@types/http-assert/-/http-assert-1.5.6.tgz", + "integrity": "sha512-TTEwmtjgVbYAzZYWyeHPrrtWnfVkm8tQkP8P21uQifPgMRgjrow3XDEYqucuC8SKZJT7pUnhU/JymvjggxO9vw==", + "license": "MIT" + }, "node_modules/@types/http-errors": { "version": "2.0.4", - "dev": true, "license": "MIT" }, "node_modules/@types/istanbul-lib-coverage": { @@ -4351,6 +5020,37 @@ "@types/node": "*" } }, + "node_modules/@types/keygrip": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/@types/keygrip/-/keygrip-1.0.6.tgz", + "integrity": "sha512-lZuNAY9xeJt7Bx4t4dx0rYCDqGPW8RXhQZK1td7d4H6E9zYbLoOtjBvfwdTKpsyxQI/2jv+armjX/RW+ZNpXOQ==", + "license": "MIT" + }, + "node_modules/@types/koa": { + "version": "2.15.0", + "resolved": "https://registry.npmjs.org/@types/koa/-/koa-2.15.0.tgz", + "integrity": "sha512-7QFsywoE5URbuVnG3loe03QXuGajrnotr3gQkXcEBShORai23MePfFYdhz90FEtBBpkyIYQbVD+evKtloCgX3g==", + "license": "MIT", + "dependencies": { + "@types/accepts": "*", + "@types/content-disposition": "*", + "@types/cookies": "*", + "@types/http-assert": "*", + "@types/http-errors": "*", + "@types/keygrip": "*", + "@types/koa-compose": "*", + "@types/node": "*" + } + }, + "node_modules/@types/koa-compose": { + "version": "3.2.9", + "resolved": "https://registry.npmjs.org/@types/koa-compose/-/koa-compose-3.2.9.tgz", + "integrity": "sha512-BroAZ9FTvPiCy0Pi8tjD1OfJ7bgU1gQf0eR6e1Vm+JJATy9eKOG3hQMFtMciMawiSOVnLMdmUOC46s7HBhSTsA==", + "license": "MIT", + "dependencies": { + "@types/koa": "*" + } + }, "node_modules/@types/ldapjs": { "version": "3.0.6", "resolved": "https://registry.npmjs.org/@types/ldapjs/-/ldapjs-3.0.6.tgz", @@ -4387,7 +5087,6 @@ }, "node_modules/@types/mime": { "version": "1.3.5", - "dev": true, "license": "MIT" }, "node_modules/@types/ms": { @@ -4397,6 +5096,15 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/multer": { + "version": "1.4.13", + "resolved": "https://registry.npmjs.org/@types/multer/-/multer-1.4.13.tgz", + "integrity": "sha512-bhhdtPw7JqCiEfC9Jimx5LqX9BDIPJEh2q/fQ4bqbBPtyEZYr3cvF22NwG0DmPZNYA0CAf2CnqDB4KIGGpJcaw==", + "license": "MIT", + "dependencies": { + "@types/express": "*" + } + }, "node_modules/@types/node": { "version": "22.19.8", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.8.tgz", @@ -4445,12 +5153,10 @@ }, "node_modules/@types/qs": { "version": "6.9.18", - "dev": true, "license": "MIT" }, "node_modules/@types/range-parser": { "version": "1.2.7", - "dev": true, "license": "MIT" }, "node_modules/@types/react": { @@ -4496,7 +5202,6 @@ }, "node_modules/@types/send": { "version": "0.17.4", - "dev": true, "license": "MIT", "dependencies": { "@types/mime": "^1", @@ -4507,7 +5212,6 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-2.2.0.tgz", "integrity": "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==", - "dev": true, "license": "MIT", "dependencies": { "@types/http-errors": "*", @@ -5407,6 +6111,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, "node_modules/array-ify": { "version": "1.0.0", "dev": true, @@ -5731,26 +6441,6 @@ "url": "https://opencollective.com/express" } }, - "node_modules/body-parser/node_modules/http-errors": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", - "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", - "license": "MIT", - "dependencies": { - "depd": "~2.0.0", - "inherits": "~2.0.4", - "setprototypeof": "~1.2.0", - "statuses": "~2.0.2", - "toidentifier": "~1.0.1" - }, - "engines": { - "node": ">= 0.8" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, "node_modules/body-parser/node_modules/iconv-lite": { "version": "0.7.0", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.0.tgz", @@ -5782,15 +6472,6 @@ "node": ">= 0.10" } }, - "node_modules/body-parser/node_modules/statuses": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", - "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, "node_modules/bowser": { "version": "2.13.1", "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.13.1.tgz", @@ -6980,6 +7661,16 @@ "node": ">= 0.8" } }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, "node_modules/dezalgo": { "version": "1.0.4", "dev": true, @@ -8680,7 +9371,6 @@ }, "node_modules/graceful-fs": { "version": "4.2.11", - "dev": true, "license": "ISC" }, "node_modules/graphql": { @@ -8691,6 +9381,27 @@ "iterall": "1.1.3" } }, + "node_modules/handlebars": { + "version": "4.7.9", + "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.9.tgz", + "integrity": "sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ==", + "license": "MIT", + "dependencies": { + "minimist": "^1.2.5", + "neo-async": "^2.6.2", + "source-map": "^0.6.1", + "wordwrap": "^1.0.0" + }, + "bin": { + "handlebars": "bin/handlebars" + }, + "engines": { + "node": ">=0.4.7" + }, + "optionalDependencies": { + "uglify-js": "^3.1.4" + } + }, "node_modules/has-bigints": { "version": "1.1.0", "dev": true, @@ -8835,17 +9546,23 @@ } }, "node_modules/http-errors": { - "version": "2.0.0", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", "license": "MIT", "dependencies": { - "depd": "2.0.0", - "inherits": "2.0.4", - "setprototypeof": "1.2.0", - "statuses": "2.0.1", - "toidentifier": "1.0.1" + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" }, "engines": { "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/http-signature": { @@ -9466,6 +10183,18 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-what": { + "version": "4.1.16", + "resolved": "https://registry.npmjs.org/is-what/-/is-what-4.1.16.tgz", + "integrity": "sha512-ZhMwEosbFJkA0YhFnNDgTM4ZxDRsS6HqTo7qsZM08fehyRYIYa0yHu5R6mgo1n/8MgaPBXiPimPD77baVFYg+A==", + "license": "MIT", + "engines": { + "node": ">=12.13" + }, + "funding": { + "url": "https://github.com/sponsors/mesqueeb" + } + }, "node_modules/is-windows": { "version": "1.0.2", "dev": true, @@ -9859,7 +10588,6 @@ }, "node_modules/jsonfile": { "version": "6.1.0", - "dev": true, "license": "MIT", "dependencies": { "universalify": "^2.0.0" @@ -10816,6 +11544,21 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/merge-anything": { + "version": "5.1.7", + "resolved": "https://registry.npmjs.org/merge-anything/-/merge-anything-5.1.7.tgz", + "integrity": "sha512-eRtbOb1N5iyH0tkQDAoQ4Ipsp/5qSR79Dzrz8hEPxRX10RWWR/iQXdoKmBSRCThY1Fh5EhISDtpSc93fpxUniQ==", + "license": "MIT", + "dependencies": { + "is-what": "^4.1.8" + }, + "engines": { + "node": ">=12.13" + }, + "funding": { + "url": "https://github.com/sponsors/mesqueeb" + } + }, "node_modules/merge-descriptors": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", @@ -10852,7 +11595,6 @@ }, "node_modules/methods": { "version": "1.1.2", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.6" @@ -11073,6 +11815,12 @@ "node": ">= 0.6" } }, + "node_modules/neo-async": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", + "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", + "license": "MIT" + }, "node_modules/node-fetch": { "version": "2.7.0", "dev": true, @@ -12285,13 +13033,15 @@ } }, "node_modules/raw-body": { - "version": "2.5.2", + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", "license": "MIT", "dependencies": { - "bytes": "3.1.2", - "http-errors": "2.0.0", - "iconv-lite": "0.4.24", - "unpipe": "1.0.0" + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" }, "engines": { "node": ">= 0.8" @@ -12422,6 +13172,12 @@ "node": ">= 6" } }, + "node_modules/reflect-metadata": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", + "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==", + "license": "Apache-2.0" + }, "node_modules/reflect.getprototypeof": { "version": "1.0.10", "dev": true, @@ -13086,7 +13842,6 @@ }, "node_modules/source-map": { "version": "0.6.1", - "dev": true, "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" @@ -13183,7 +13938,9 @@ "license": "MIT" }, "node_modules/statuses": { - "version": "2.0.1", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", "license": "MIT", "engines": { "node": ">= 0.8" @@ -13828,6 +14585,15 @@ "typescript": ">=4.8.4" } }, + "node_modules/ts-deepmerge": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/ts-deepmerge/-/ts-deepmerge-7.0.3.tgz", + "integrity": "sha512-Du/ZW2RfwV/D4cmA5rXafYjBQVuvu4qGiEEla4EmEHVHgRdx68Gftx7i66jn2bzHPwSVZY36Ae6OuDn9el4ZKA==", + "license": "ISC", + "engines": { + "node": ">=14.13.1" + } + }, "node_modules/ts-node": { "version": "10.9.2", "dev": true, @@ -13904,6 +14670,23 @@ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" }, + "node_modules/tsoa": { + "version": "7.0.0-alpha.0", + "resolved": "https://registry.npmjs.org/tsoa/-/tsoa-7.0.0-alpha.0.tgz", + "integrity": "sha512-o5h2DD1IKa2GF728BHYDL2uSX57a44sX8BLFScR4KbD0xlOmgsSDECneJmouDxf9MJdjHHWc+I1S3sGK82B6kQ==", + "license": "MIT", + "dependencies": { + "@tsoa/cli": "^7.0.0-alpha.0", + "@tsoa/runtime": "^7.0.0-alpha.0" + }, + "bin": { + "tsoa": "dist/cli.js" + }, + "engines": { + "node": ">=18.0.0", + "yarn": ">=1.9.4" + } + }, "node_modules/tsscmp": { "version": "1.0.6", "license": "MIT", @@ -14076,7 +14859,6 @@ }, "node_modules/typescript": { "version": "5.9.3", - "dev": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -14118,6 +14900,19 @@ "node": ">=8" } }, + "node_modules/uglify-js": { + "version": "3.19.3", + "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz", + "integrity": "sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==", + "license": "BSD-2-Clause", + "optional": true, + "bin": { + "uglifyjs": "bin/uglifyjs" + }, + "engines": { + "node": ">=0.8.0" + } + }, "node_modules/uid-safe": { "version": "2.1.5", "license": "MIT", @@ -14185,7 +14980,6 @@ }, "node_modules/universalify": { "version": "2.0.1", - "dev": true, "license": "MIT", "engines": { "node": ">= 10.0.0" @@ -14833,7 +15627,6 @@ }, "node_modules/wordwrap": { "version": "1.0.0", - "dev": true, "license": "MIT" }, "node_modules/wordwrapjs": { @@ -14969,7 +15762,6 @@ "version": "2.8.3", "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz", "integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==", - "dev": true, "license": "ISC", "bin": { "yaml": "bin.mjs" diff --git a/package.json b/package.json index 4b5e6232f..f04c57e8d 100644 --- a/package.json +++ b/package.json @@ -54,8 +54,9 @@ "server": "ALLOWED_ORIGINS=* tsx index.ts", "start": "concurrently \"npm run server\" \"npm run client\"", "build": "npm run generate-config-types && npm run build-ui && npm run build-ts", - "build-ts": "tsc --project tsconfig.publish.json && node scripts/fix-shebang.js", + "build-ts": "npm run build-tsoa && tsc --project tsconfig.publish.json && node scripts/fix-shebang.js", "build-ui": "vite build", + "build-tsoa": "tsoa spec-and-routes", "check-types": "tsc", "check-types:server": "tsc --project tsconfig.publish.json --noEmit", "test-shuffle": "NODE_ENV=test vitest --run --dir ./test --sequence.shuffle", @@ -136,6 +137,7 @@ "react-html-parser": "^2.0.2", "react-router-dom": "6.30.3", "simple-git": "^3.30.0", + "tsoa": "^7.0.0-alpha.0", "uuid": "^13.0.0", "validator": "^13.15.26", "yargs": "^17.7.2" diff --git a/src/service/routes/healthcheck.ts b/src/app.ts similarity index 74% rename from src/service/routes/healthcheck.ts rename to src/app.ts index bdc79aa2e..6ad0f79c4 100644 --- a/src/service/routes/healthcheck.ts +++ b/src/app.ts @@ -14,14 +14,8 @@ * limitations under the License. */ -import express, { Request, Response } from 'express'; +import express from 'express'; -const router = express.Router(); - -router.get('/', (_req: Request, res: Response) => { - res.send({ - message: 'ok', - }); -}); - -export default router; +// Minimal entry point used by tsoa for controller/spec discovery. +// The actual configured Express app lives in src/service/index.ts. +export const app = express(); diff --git a/src/service/authentication.ts b/src/service/authentication.ts new file mode 100644 index 000000000..c0b4e4489 --- /dev/null +++ b/src/service/authentication.ts @@ -0,0 +1,93 @@ +/** + * Copyright 2026 GitProxy Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { Request } from 'express'; +import { getAPIAuthMethods } from '../config'; +import { assignRoles, validateJwt } from './passport/jwtUtils'; +import type { RoleMapping } from '../config/generated/config'; + +/** + * tsoa authentication handler called for every route decorated with @Security. + * + * Supported security names: + * - 'jwt': Bearer-token validation via OIDC JWT or an existing session. + */ +export async function expressAuthentication( + request: Request, + securityName: string, + _scopes?: string[], +): Promise { + if (securityName === 'jwt') { + // Already authenticated via session (e.g. passport local login) + if (request.isAuthenticated && request.isAuthenticated()) { + return request.user; + } + + const apiAuthMethods = getAPIAuthMethods(); + const jwtAuthMethod = apiAuthMethods.find((m) => m.type.toLowerCase() === 'jwt'); + + if (!jwtAuthMethod || !jwtAuthMethod.enabled) { + // JWT not configured — pass through (other middleware may enforce auth) + return; + } + + const token = request.header('Authorization'); + if (!token) { + throw Object.assign(new Error('No token provided'), { status: 401 }); + } + + if (!jwtAuthMethod.jwtConfig) { + console.log('JWT configuration is missing'); + throw Object.assign(new Error('JWT configuration is missing'), { status: 500 }); + } + + const { clientID, authorityURL, expectedAudience, roleMapping } = jwtAuthMethod.jwtConfig; + + if (!authorityURL) { + console.log('OIDC authority URL is not configured'); + throw Object.assign(new Error('OIDC authority URL is not configured'), { status: 500 }); + } + + if (!clientID) { + console.log('OIDC client ID is not configured'); + throw Object.assign(new Error('OIDC client ID is not configured'), { status: 500 }); + } + + const audience = expectedAudience || clientID; + const tokenParts = token.split(' '); + const accessToken = tokenParts.length === 2 ? tokenParts[1] : tokenParts[0]; + + const { verifiedPayload, error } = await validateJwt( + accessToken, + authorityURL, + audience, + clientID, + ); + + if (error || !verifiedPayload) { + console.log('JWT validation failed'); + throw Object.assign(new Error(error || 'JWT validation failed'), { status: 401 }); + } + + request.user = verifiedPayload; + assignRoles(roleMapping as RoleMapping, verifiedPayload, request.user); + + console.log('JWT validation successful'); + return verifiedPayload; + } + + throw Object.assign(new Error(`Unknown security scheme: ${securityName}`), { status: 401 }); +} diff --git a/src/service/controllers/AuthController.ts b/src/service/controllers/AuthController.ts new file mode 100644 index 000000000..c3fe1b14f --- /dev/null +++ b/src/service/controllers/AuthController.ts @@ -0,0 +1,303 @@ +/** + * Copyright 2026 GitProxy Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { Request as ExpressRequest, Response as ExpressResponse, NextFunction } from 'express'; +import { Body, Controller, Get, Middlewares, Post, Request, Res, Route, Tags } from 'tsoa'; +import { getPassport, authStrategies } from '../passport'; +import { getAuthMethods } from '../../config'; +import * as db from '../../db'; +import * as passportLocal from '../passport/local'; +import * as passportAD from '../passport/activeDirectory'; +import { User } from '../../db/types'; +import { AuthenticationElement } from '../../config/generated/config'; +import { isAdminUser, toPublicUser } from '../routes/utils'; +import { handleErrorAndLog } from '../../utils/errors'; +import { PublicUser } from '../../db/types'; +import { + AuthResources, + AuthConfigResponse, + LoginResponse, + LogoutResponse, + CreateUserResponse, + GitAccountBody, + CreateUserBody, +} from '../interfaces/auth.interfaces'; +import { + ForbiddenResponse, + InternalServerErrorResponse, + NotFoundResponse, + UnauthorisedResponse, + ValidationErrorResponse, +} from '../decorators/response.types'; + +const { GIT_PROXY_UI_HOST: uiHost = 'http://localhost', GIT_PROXY_UI_PORT: uiPort = 3000 } = + process.env; + +// login strategies that will work with /login e.g. take username and password +const appropriateLoginStrategies = [passportLocal.type, passportAD.type]; + +// getLoginStrategy fetches the enabled auth methods and identifies if there's an appropriate +// auth method for username and password login. If there isn't it returns null, if there is it +// returns the first. +const getLoginStrategy = () => { + // returns only enabled auth methods + // returns at least one enabled auth method + const enabledAppropriateLoginStrategies = getAuthMethods().filter((am: AuthenticationElement) => + appropriateLoginStrategies.includes(am.type.toLowerCase()), + ); + // for where no login strategies which work for /login are enabled + // just return null + if (enabledAppropriateLoginStrategies.length === 0) { + return null; + } + // return the first enabled auth method + return enabledAppropriateLoginStrategies[0].type.toLowerCase(); +}; + +/** + * Dynamically selects the login passport strategy and runs it as Express middleware. + * Used by `@Middlewares` on the POST /login route. + */ +export function dynamicLoginMiddleware( + req: ExpressRequest, + res: ExpressResponse, + next: NextFunction, +): void { + const authType = getLoginStrategy(); + if (authType === null) { + res.status(403).send('Username and Password based Login is not enabled at this time').end(); + return; + } + getPassport().authenticate(authType)(req, res, next); +} + +/** + * Handles the OIDC callback: authenticates the user and redirects on success. + * Used by @Middlewares on the GET /openidconnect/callback route. + */ +export function oidcCallbackMiddleware( + req: ExpressRequest, + res: ExpressResponse, + next: NextFunction, +): void { + getPassport().authenticate( + authStrategies['openidconnect'].type, + (err: unknown, user: Partial, info: unknown) => { + if (err) { + console.error('Authentication error:', err); + return res.status(500).end(); + } + if (!user) { + console.error('No user found:', info); + return res.status(401).end(); + } + req.logIn(user, (err) => { + if (err) { + console.error('Login error:', err); + return res.status(500).end(); + } + console.log('Logged in successfully. User:', user); + return res.redirect(`${uiHost}:${uiPort}/dashboard/profile`); + }); + }, + )(req, res, next); +} + +/** + * Authentication endpoints. + */ +@Route('api/auth') +@Tags('Auth') +export class AuthController extends Controller { + /** + * Returns links to the available authentication resource endpoints. + */ + @Get('/') + public getResources(): AuthResources { + return { + login: { action: 'post', uri: '/api/auth/login' }, + profile: { action: 'get', uri: '/api/auth/profile' }, + logout: { action: 'post', uri: '/api/auth/logout' }, + }; + } + + /** + * Returns the enabled authentication methods available to the UI. + */ + @Get('/config') + public getAuthConfig(): AuthConfigResponse { + const usernamePasswordMethod = getLoginStrategy(); + return { + // enabled username /password auth method + usernamePasswordMethod, + // other enabled auth methods + otherMethods: getAuthMethods() + .map((am) => am.type.toLowerCase()) + .filter((authType) => authType !== usernamePasswordMethod), + }; + } + + /** + * Authenticates the user with a username/password strategy. + * The appropriate passport strategy is selected dynamically based on configuration. + */ + @Post('/login') + @Middlewares(dynamicLoginMiddleware) + public async login(@Request() req: ExpressRequest): Promise { + // dynamicLoginMiddleware has already authenticated the user and set req.user. + // If strategy called next(), we can log in and return the user profile. + const user = req.user as User; + + await new Promise((resolve, reject) => { + req.logIn(user, (err) => (err ? reject(err) : resolve())); + }); + + const currentUser = toPublicUser(user); + console.log( + `service.routes.auth.login: user logged in, username=${currentUser.username} profile=${JSON.stringify(currentUser)}`, + ); + return { message: 'success', user: currentUser }; + } + + /** + * Initiates the OpenID Connect authentication flow (redirects to the OIDC provider). + * @hidden + */ + @Get('/openidconnect') + @Middlewares(getPassport().authenticate(authStrategies['openidconnect'].type)) + public initiateOIDC(): void { + // Passport middleware handles the redirect. This body is unreachable. + } + + /** + * OpenID Connect callback — exchanges the authorization code for a session. + * @hidden + */ + @Get('/openidconnect/callback') + @Middlewares(oidcCallbackMiddleware) + public handleOIDCCallback(): void { + // oidcCallbackMiddleware handles login and redirect. This body is unreachable. + } + + /** + * Logs out the current user and clears the session cookie. + */ + @Post('/logout') + public async logout(@Request() req: ExpressRequest): Promise { + await new Promise((resolve, reject) => { + req.logout((err: unknown) => (err ? reject(err) : resolve())); + }); + req.res?.clearCookie('connect.sid'); + return { isAuth: req.isAuthenticated(), user: req.user }; + } + + /** + * Returns the profile of the currently authenticated user. + */ + @Get('/profile') + public async getProfile( + @Request() req: ExpressRequest, + @Res() unauthorisedResponse: UnauthorisedResponse, + @Res() notFoundResponse: NotFoundResponse, + ): Promise { + if (!req.user) { + return unauthorisedResponse(401, { message: 'Not logged in' }); + } + + const userVal = await db.findUser((req.user as User).username); + if (!userVal) { + return notFoundResponse(404, { message: 'User not found' }); + } + + return toPublicUser(userVal); + } + + /** + * Updates the Git account (username) of a user. + * Admins may update any user; non-admins may only update their own account. + */ + @Post('/gitAccount') + public async updateGitAccount( + @Body() body: GitAccountBody, + @Request() req: ExpressRequest, + @Res() unauthorisedResponse: UnauthorisedResponse, + @Res() notFoundResponse: NotFoundResponse, + @Res() validationErrorResponse: ValidationErrorResponse, + @Res() forbiddenResponse: ForbiddenResponse, + @Res() internalServerErrorResponse: InternalServerErrorResponse, + ): Promise { + if (!req.user) { + return unauthorisedResponse(401, { message: 'Not logged in' }); + } + + try { + let username = + body.username == null || body.username === 'undefined' ? body.id : body.username; + username = username?.split('@')[0]; + + if (!username) { + return validationErrorResponse(400, { + message: 'Missing username. Git account not updated', + }); + } + + const reqUser = await db.findUser((req.user as User).username); + if (username !== reqUser?.username && !reqUser?.admin) { + return forbiddenResponse(403, { + message: 'Must be an admin to update a different account', + }); + } + + const user = await db.findUser(username); + if (!user) { + return notFoundResponse(404, { message: 'User not found' }); + } + + user.gitAccount = body.gitAccount; + await db.updateUser(user); + this.setStatus(200); + } catch (error: unknown) { + const msg = handleErrorAndLog(error, 'Failed to update git account'); + return internalServerErrorResponse(500, { message: msg }); + } + } + + /** + * Creates a new user. Requires admin privileges. + */ + @Post('/create-user') + public async createUser( + @Body() body: CreateUserBody, + @Request() req: ExpressRequest, + @Res() unauthorisedResponse: ForbiddenResponse, + @Res() internalServerErrorResponse: InternalServerErrorResponse, + ): Promise { + if (!isAdminUser(req.user)) { + return unauthorisedResponse(403, { message: 'Not authorized to create users' }); + } + + const { username, password, email, gitAccount, admin: isAdmin = false } = body; + + try { + await db.createUser(username, password, email, gitAccount, isAdmin); + this.setStatus(201); + return { message: 'User created successfully', username }; + } catch (error: unknown) { + const msg = handleErrorAndLog(error, 'Failed to create user'); + return internalServerErrorResponse(500, { message: msg }); + } + } +} diff --git a/src/service/controllers/ConfigController.ts b/src/service/controllers/ConfigController.ts new file mode 100644 index 000000000..6b7e6a854 --- /dev/null +++ b/src/service/controllers/ConfigController.ts @@ -0,0 +1,46 @@ +/** + * Copyright 2026 GitProxy Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Controller, Get, Route, Tags } from 'tsoa'; +import * as config from '../../config'; +import type { AttestationConfig, UIRouteAuth } from '../../config/generated/config'; + +/** + * Public configuration endpoints consumed by the UI. + */ +@Route('api/v1/config') +@Tags('Config') +export class ConfigController extends Controller { + @Get('/attestation') + public getAttestation(): AttestationConfig { + return config.getAttestationConfig(); + } + + @Get('/urlShortener') + public getUrlShortener(): string | undefined { + return config.getURLShortener(); + } + + @Get('/contactEmail') + public getContactEmail(): string | undefined { + return config.getContactEmail(); + } + + @Get('/uiRouteAuth') + public getUiRouteAuth(): UIRouteAuth { + return config.getUIRouteAuth(); + } +} diff --git a/src/service/controllers/HealthController.ts b/src/service/controllers/HealthController.ts new file mode 100644 index 000000000..76d8eac0b --- /dev/null +++ b/src/service/controllers/HealthController.ts @@ -0,0 +1,29 @@ +/** + * Copyright 2026 GitProxy Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Controller, Get, Route, Tags } from 'tsoa'; +import { HealthResponse } from '../interfaces/health.interfaces'; +/** + * Service health check. + */ +@Route('api/v1/healthcheck') +@Tags('Health') +export class HealthController extends Controller { + @Get('/') + public check(): HealthResponse { + return { message: 'ok' }; + } +} diff --git a/src/service/controllers/HomeController.ts b/src/service/controllers/HomeController.ts new file mode 100644 index 000000000..3f003ba13 --- /dev/null +++ b/src/service/controllers/HomeController.ts @@ -0,0 +1,34 @@ +/** + * Copyright 2026 GitProxy Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Controller, Get, Route, Tags } from 'tsoa'; +import { ApiResources } from '../interfaces/home.interfaces'; + +/** + * API home — lists available resource URIs. + */ +@Route('api') +@Tags('Home') +export class HomeController extends Controller { + @Get('/') + public getResources(): ApiResources { + return { + healthcheck: '/api/v1/healthcheck', + push: '/api/v1/push', + auth: '/api/auth', + }; + } +} diff --git a/src/service/controllers/PushController.ts b/src/service/controllers/PushController.ts new file mode 100644 index 000000000..313d931c7 --- /dev/null +++ b/src/service/controllers/PushController.ts @@ -0,0 +1,263 @@ +/** + * Copyright 2026 GitProxy Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Body, Controller, Get, Path, Post, Request, Res, Route, Security, Tags } from 'tsoa'; +import type { Request as ExpressRequest } from 'express'; +import * as db from '../../db'; +import { PushQuery } from '../../db/types'; +import { AttestationConfig } from '../../config/generated/config'; +import { getAttestationConfig } from '../../config'; +import { Action } from '../../proxy/actions/Action'; +import { AttestationAnswer, Rejection } from '../../proxy/processors/types'; +import { MessageResponse } from '../interfaces/common.interfaces'; +import { RejectBody, AuthoriseBody } from '../interfaces/push.interfaces'; +import { + ForbiddenResponse, + NotFoundResponse, + UnauthorisedResponse, + ValidationErrorResponse, +} from '../decorators/response.types'; + +/** + * Push request management. + */ +@Route('api/v1/push') +@Security('jwt') +@Tags('Push') +export class PushController extends Controller { + /** + * Returns push requests, optionally filtered by query parameters. + * Supported filters: any field from PushQuery (error, blocked, allowPush, authorised, canceled, rejected, type). + */ + @Get('/') + public async getPushes(@Request() req: ExpressRequest): Promise { + const query: Partial = { type: 'push' }; + + for (const key in req.query) { + if (!key) continue; + if (key === 'limit' || key === 'skip') continue; + + const rawValue = req.query[key]; + let parsedValue: boolean | undefined; + if (rawValue === 'false') parsedValue = false; + if (rawValue === 'true') parsedValue = true; + query[key] = parsedValue ?? rawValue?.toString(); + } + + return db.getPushes(query); + } + + /** + * Returns a single push request by ID. + */ + @Get('/{id}') + public async getPush( + @Path() id: string, + @Res() notFoundResponse: NotFoundResponse, + ): Promise { + const push = await db.getPush(id); + if (!push) { + return notFoundResponse(404, { message: 'not found' }); + } + return push; + } + + /** + * Rejects a pending push request. + */ + @Post('/{id}/reject') + public async rejectPush( + @Path() id: string, + @Body() body: RejectBody, + @Request() req: ExpressRequest, + @Res() unauthorisedResponse: UnauthorisedResponse, + @Res() validationErrorResponse: ValidationErrorResponse, + @Res() notFoundResponse: NotFoundResponse, + @Res() forbiddenResponse: ForbiddenResponse, + ): Promise { + if (!req.user) { + return unauthorisedResponse(401, { message: 'Not logged in' }); + } + + const { reason } = body; + if (!reason || !reason.trim()) { + return validationErrorResponse(400, { message: 'Rejection reason is required' }); + } + + const { username } = req.user as { username: string }; + + const push = await db.getPush(id); + if (!push) { + return notFoundResponse(404, { message: 'Push request not found' }); + } + + if (!push.userEmail) { + return validationErrorResponse(400, { message: 'Push request has no user email' }); + } + + const committerEmail = push.userEmail; + const list = await db.getUsers({ email: committerEmail }); + + if (list.length === 0) { + return notFoundResponse(404, { + message: `No user found with the committer's email address: ${committerEmail}`, + }); + } + + if (list[0].username.toLowerCase() === username.toLowerCase() && !list[0].admin) { + return forbiddenResponse(403, { message: 'Cannot reject your own changes' }); + } + + const isAllowed = await db.canUserApproveRejectPush(id, username); + if (!isAllowed) { + return forbiddenResponse(403, { + message: `User ${username} is not authorised to reject changes on this project`, + }); + } + + const reviewerList = await db.getUsers({ username }); + const reviewerEmail = reviewerList[0].email; + + if (!reviewerEmail) { + return notFoundResponse(404, { + message: `There was no registered email address for the reviewer: ${username}`, + }); + } + + const rejection: Rejection = { + reason, + timestamp: new Date(), + reviewer: { username, email: reviewerEmail }, + }; + + const result = await db.reject(id, rejection); + console.log( + `User ${username} rejected push request for ${id}${reason ? ` with reason: ${reason}` : ''}`, + ); + return result; + } + + /** + * Authorises (approves) a pending push request. + */ + @Post('/{id}/authorise') + public async authorisePush( + @Path() id: string, + @Body() body: AuthoriseBody, + @Request() req: ExpressRequest, + @Res() unauthorisedResponse: UnauthorisedResponse, + @Res() validationErrorResponse: ValidationErrorResponse, + @Res() notFoundResponse: NotFoundResponse, + @Res() forbiddenResponse: ForbiddenResponse, + ): Promise { + if (!req.user) { + return unauthorisedResponse(401, { message: 'Not logged in' }); + } + + const answers = body.params.attestation; + if (!validateAttestation(answers, getAttestationConfig())) { + return validationErrorResponse(400, { message: 'Attestation is not complete' }); + } + + const { username } = req.user as { username: string }; + + const push = await db.getPush(id); + if (!push) { + return notFoundResponse(404, { message: 'Push request not found' }); + } + + // Get the committer of the push via their email address + const committerEmail = push.userEmail; + const list = await db.getUsers({ email: committerEmail }); + + if (list.length === 0) { + return notFoundResponse(404, { + message: `No user found with the committer's email address: ${committerEmail}`, + }); + } + + if (list[0].username.toLowerCase() === username.toLowerCase() && !list[0].admin) { + return forbiddenResponse(403, { message: 'Cannot approve your own changes' }); + } + + // If we are not the author, now check that we are allowed to authorise on this + // repo + const isAllowed = await db.canUserApproveRejectPush(id, username); + if (!isAllowed) { + return forbiddenResponse(403, { + message: `User ${username} not authorised to approve pushes on this project`, + }); + } + + const reviewerList = await db.getUsers({ username }); + const reviewerEmail = reviewerList[0].email; + + if (!reviewerEmail) { + return notFoundResponse(404, { + message: `There was no registered email address for the reviewer: ${username}`, + }); + } + + const attestation = { + answers, + timestamp: new Date(), + reviewer: { username, email: reviewerEmail }, + }; + + console.log(`User ${username} approved push request for ${id}`); + return db.authorise(id, attestation); + } + + /** + * Cancels a pending push request. + */ + @Post('/{id}/cancel') + public async cancelPush( + @Path() id: string, + @Request() req: ExpressRequest, + @Res() unauthorisedResponse: UnauthorisedResponse, + @Res() forbiddenResponse: ForbiddenResponse, + ): Promise { + if (!req.user) { + return unauthorisedResponse(401, { message: 'Not logged in' }); + } + + const { username } = req.user as { username: string }; + const isAllowed = await db.canUserCancelPush(id, username); + + if (!isAllowed) { + console.log(`User ${username} not authorised to cancel push request for ${id}`); + return forbiddenResponse(403, { + message: `User ${username} not authorised to cancel push requests on this project`, + }); + } + + const result = await db.cancel(id); + console.log(`User ${username} canceled push request for ${id}`); + return result; + } +} + +function validateAttestation(answers: AttestationAnswer[], config: AttestationConfig): boolean { + const configQuestions = config.questions ?? []; + + if (!answers || answers.length !== configQuestions.length) { + return false; + } + + const configLabels = new Set(configQuestions.map((q) => q.label)); + return answers.every((answer) => configLabels.has(answer.label) && !!answer.checked); +} diff --git a/src/service/controllers/RepoController.ts b/src/service/controllers/RepoController.ts new file mode 100644 index 000000000..d6551f465 --- /dev/null +++ b/src/service/controllers/RepoController.ts @@ -0,0 +1,293 @@ +/** + * Copyright 2026 GitProxy Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + Body, + Controller, + Delete, + Get, + Patch, + Path, + Post, + Request, + Res, + Route, + Security, + Tags, +} from 'tsoa'; +import type { Request as ExpressRequest } from 'express'; +import * as db from '../../db'; +import { RepoQuery } from '../../db/types'; +import { getProxyURL } from '../urls'; +import { isAdminUser } from '../routes/utils'; +import { getProxy } from '../proxyStore'; +import { handleErrorAndLog } from '../../utils/errors'; +import { MessageResponse } from '../interfaces/common.interfaces'; +import { UsernameBody, CreateRepoBody, RepoWithProxy } from '../interfaces/repo.interfaces'; +import { + ConflictResponse, + InternalServerErrorResponse, + NotFoundResponse, + UnauthorisedResponse, + UserNotFoundResponse, + ValidationErrorResponse, +} from '../decorators/response.types'; + +/** + * Repository management. + */ +@Route('api/v1/repo') +@Security('jwt') +@Tags('Repositories') +export class RepoController extends Controller { + /** + * Returns repositories, optionally filtered by query parameters. + */ + @Get('/') + public async getRepos(@Request() req: ExpressRequest): Promise { + const proxyURL = getProxyURL(req); + const query: Partial = {}; + + for (const key in req.query) { + if (!key) continue; + if (key === 'limit' || key === 'skip') continue; + + const rawValue = req.query[key]; + let parsedValue: boolean | undefined; + if (rawValue === 'false') parsedValue = false; + if (rawValue === 'true') parsedValue = true; + query[key] = parsedValue ?? rawValue?.toString(); + } + + const repos = await db.getRepos(query); + return repos.map((d) => ({ ...d, proxyURL })); + } + + /** + * Returns a single repository by ID. + */ + @Get('/{id}') + public async getRepo( + @Path() id: string, + @Request() req: ExpressRequest, + @Res() notFoundResponse: NotFoundResponse, + ): Promise { + const proxyURL = getProxyURL(req); + const repo = await db.getRepoById(id); + if (!repo) { + return notFoundResponse(404, { message: `Repository ${id} not found` }); + } + return { ...repo, proxyURL }; + } + + /** + * Creates a new repository. May restart the proxy if a new origin is added. + */ + @Post('/') + public async createRepo( + @Body() body: CreateRepoBody, + @Request() req: ExpressRequest, + @Res() unauthorisedResponse: UnauthorisedResponse, + @Res() validationErrorResponse: ValidationErrorResponse, + @Res() conflictResponse: ConflictResponse, + @Res() internalServerErrorResponse: InternalServerErrorResponse, + ): Promise { + if (!isAdminUser(req.user)) { + return unauthorisedResponse(401, { + message: 'You are not authorised to perform this action.', + }); + } + + const repoUrl = body.url; + + if (!repoUrl) { + return validationErrorResponse(400, { message: 'Repository url is required' }); + } + + const existing = await db.getRepoByUrl(repoUrl); + if (existing) { + return conflictResponse(409, { message: `Repository ${repoUrl} already exists!` }); + } + + try { + // figure out if this represent a new domain to proxy + let newOrigin = true; + + const existingHosts = await db.getAllProxiedHosts(); + existingHosts.forEach((h) => { + // assume SSL is in use and that our origins are missing the protocol + if (repoUrl.startsWith(`https://${h}`)) { + newOrigin = false; + } + }); + + console.log( + `API request to proxy repository ${repoUrl} is for a new origin: ${newOrigin},\n\texisting origin list was: ${JSON.stringify(existingHosts)}`, + ); + + const repoDetails = await db.createRepo(body); + const proxyURL = getProxyURL(req); + + // restart the proxy if we're proxying a new domain + if (newOrigin) { + console.log('Restarting the proxy to handle an additional host'); + const proxy = getProxy(); + await proxy.stop(); + await proxy.start(); + } + + return { ...repoDetails, proxyURL, message: 'created' }; + } catch (error: unknown) { + const msg = handleErrorAndLog(error, 'Repository creation failed'); + return internalServerErrorResponse(500, { message: msg }); + } + } + + /** + * Grants a user push permission on a repository. + */ + @Patch('/{id}/user/push') + public async addPushUser( + @Path() id: string, + @Body() body: UsernameBody, + @Request() req: ExpressRequest, + @Res() unauthorisedResponse: UnauthorisedResponse, + @Res() userNotFoundResponse: UserNotFoundResponse, + ): Promise { + if (!isAdminUser(req.user)) { + return unauthorisedResponse(401, { + message: 'You are not authorised to perform this action.', + }); + } + + const username = body.username.toLowerCase(); + const user = await db.findUser(username); + if (!user) { + return userNotFoundResponse(400, { error: 'User does not exist' }); + } + + await db.addUserCanPush(id, username); + return { message: 'created' }; + } + + /** + * Grants a user authorise permission on a repository. + */ + @Patch('/{id}/user/authorise') + public async addAuthoriseUser( + @Path() id: string, + @Body() body: UsernameBody, + @Request() req: ExpressRequest, + @Res() unauthorisedResponse: UnauthorisedResponse, + @Res() userNotFoundResponse: UserNotFoundResponse, + ): Promise { + if (!isAdminUser(req.user)) { + return unauthorisedResponse(401, { + message: 'You are not authorised to perform this action.', + }); + } + + const user = await db.findUser(body.username); + if (!user) { + return userNotFoundResponse(400, { error: 'User does not exist' }); + } + + await db.addUserCanAuthorise(id, body.username); + return { message: 'created' }; + } + + /** + * Revokes a user's authorise permission on a repository. + */ + @Delete('/{id}/user/authorise/{username}') + public async removeAuthoriseUser( + @Path() id: string, + @Path() username: string, + @Request() req: ExpressRequest, + @Res() unauthorisedResponse: UnauthorisedResponse, + @Res() userNotFoundResponse: UserNotFoundResponse, + ): Promise { + if (!isAdminUser(req.user)) { + return unauthorisedResponse(401, { + message: 'You are not authorised to perform this action.', + }); + } + + const user = await db.findUser(username); + if (!user) { + return userNotFoundResponse(400, { error: 'User does not exist' }); + } + + await db.removeUserCanAuthorise(id, username); + return { message: 'created' }; + } + + /** + * Revokes a user's push permission on a repository. + */ + @Delete('/{id}/user/push/{username}') + public async removePushUser( + @Path() id: string, + @Path() username: string, + @Request() req: ExpressRequest, + @Res() unauthorisedResponse: UnauthorisedResponse, + @Res() userNotFoundResponse: UserNotFoundResponse, + ): Promise { + if (!isAdminUser(req.user)) { + return unauthorisedResponse(401, { + message: 'You are not authorised to perform this action.', + }); + } + + const user = await db.findUser(username); + if (!user) { + return userNotFoundResponse(400, { error: 'User does not exist' }); + } + + await db.removeUserCanPush(id, username); + return { message: 'created' }; + } + + /** + * Deletes a repository. May restart the proxy if a proxied host is removed. + */ + @Delete('/{id}/delete') + public async deleteRepo( + @Path() id: string, + @Request() req: ExpressRequest, + @Res() unauthorisedResponse: UnauthorisedResponse, + ): Promise { + if (!isAdminUser(req.user)) { + return unauthorisedResponse(401, { + message: 'You are not authorised to perform this action.', + }); + } + + // determine if we need to restart the proxy + const previousHosts = await db.getAllProxiedHosts(); + await db.deleteRepo(id); + const currentHosts = await db.getAllProxiedHosts(); + + if (currentHosts.length < previousHosts.length) { + console.log('Restarting the proxy to remove a host'); + const proxy = getProxy(); + await proxy.stop(); + await proxy.start(); + } + + return { message: 'deleted' }; + } +} diff --git a/src/service/controllers/UserController.ts b/src/service/controllers/UserController.ts new file mode 100644 index 000000000..82e596b59 --- /dev/null +++ b/src/service/controllers/UserController.ts @@ -0,0 +1,56 @@ +/** + * Copyright 2026 GitProxy Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Controller, Get, Path, Res, Route, Security, Tags } from 'tsoa'; +import * as db from '../../db'; +import { PublicUser } from '../../db/types'; +import { toPublicUser } from '../routes/utils'; +import { NotFoundResponse } from '../decorators/response.types'; + +/** + * User listing. + */ +@Route('api/v1/user') +@Security('jwt') +@Tags('Users') +export class UserController extends Controller { + /** + * Returns all registered users (public fields only). + */ + @Get('/') + public async getUsers(): Promise { + console.log('fetching users'); + const users = await db.getUsers(); + return users.map(toPublicUser); + } + + /** + * Returns a single user by username. + */ + @Get('/{id}') + public async getUser( + @Path() id: string, + @Res() notFoundResponse: NotFoundResponse, + ): Promise { + const username = id.toLowerCase(); + console.log(`Retrieving details for user: ${username}`); + const user = await db.findUser(username); + if (!user) { + return notFoundResponse(404, { message: `User ${username} not found` }); + } + return toPublicUser(user); + } +} diff --git a/src/service/decorators/response.types.ts b/src/service/decorators/response.types.ts new file mode 100644 index 000000000..478a145dd --- /dev/null +++ b/src/service/decorators/response.types.ts @@ -0,0 +1,25 @@ +/** + * Copyright 2026 GitProxy Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { TsoaResponse } from 'tsoa'; + +export type UnauthorisedResponse = TsoaResponse<401, { message: string }>; +export type ForbiddenResponse = TsoaResponse<403, { message: string }>; +export type NotFoundResponse = TsoaResponse<404, { message: string }>; +export type ValidationErrorResponse = TsoaResponse<400, { message: string }>; +export type ConflictResponse = TsoaResponse<409, { message: string }>; +export type InternalServerErrorResponse = TsoaResponse<500, { message: string }>; +export type UserNotFoundResponse = TsoaResponse<400, { error: string }>; diff --git a/src/service/index.ts b/src/service/index.ts index b8ee756b8..3b4e9f46f 100644 --- a/src/service/index.ts +++ b/src/service/index.ts @@ -26,8 +26,11 @@ import * as config from '../config'; import * as db from '../db'; import { serverConfig } from '../config/env'; import { Proxy } from '../proxy'; -import routes from './routes'; +import { RegisterRoutes } from './generatedRoutes'; +import { setProxy } from './proxyStore'; import { configure } from './passport'; +import { ValidateError } from 'tsoa'; +import type { Request, Response, NextFunction } from 'express'; const limiter = rateLimit(config.getRateLimit()); @@ -165,7 +168,33 @@ async function createApp(proxy: Proxy): Promise { app.use(passport.session()); app.use(express.json()); app.use(express.urlencoded({ extended: true })); - app.use('/', routes(proxy)); + + // Make the Proxy instance available to controllers before registering routes. + if (proxy) setProxy(proxy); + RegisterRoutes(app); + + // Error handlers — must be registered after routes. + // Handle tsoa validation errors (missing/invalid fields). + app.use((err: unknown, _req: Request, res: Response, next: NextFunction) => { + if (err instanceof ValidateError) { + return res.status(400).json({ + message: 'Validation failed', + details: err.fields, + }); + } + next(err); + }); + // Handle HTTP errors thrown from controllers (objects with a .status property). + app.use((err: unknown, _req: Request, res: Response, next: NextFunction) => { + const httpErr = err as { status?: number; message?: string }; + const status = typeof httpErr.status === 'number' ? httpErr.status : 500; + const message = httpErr.message ?? 'Internal server error'; + if (!res.headersSent) { + return res.status(status).json({ message }); + } + next(err); + }); + app.use('/', express.static(absBuildPath)); app.get('/*path', (_req, res) => { res.sendFile(path.join(`${absBuildPath}/index.html`)); diff --git a/src/service/interfaces/auth.interfaces.ts b/src/service/interfaces/auth.interfaces.ts new file mode 100644 index 000000000..6a00f490f --- /dev/null +++ b/src/service/interfaces/auth.interfaces.ts @@ -0,0 +1,57 @@ +/** + * Copyright 2026 GitProxy Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { PublicUser } from '../../db/types'; + +export interface AuthResources { + login: { action: 'post'; uri: string }; + profile: { action: 'get'; uri: string }; + logout: { action: 'post'; uri: string }; +} + +export interface AuthConfigResponse { + usernamePasswordMethod: string | null; + otherMethods: string[]; +} + +export interface LoginResponse { + message: 'success'; + user: PublicUser; +} + +export interface LogoutResponse { + isAuth: boolean; + user: Express.User | undefined; +} + +export interface CreateUserResponse { + message: string; + username: string; +} + +export interface GitAccountBody { + username?: string; + id?: string; + gitAccount: string; +} + +export interface CreateUserBody { + username: string; + password: string; + email: string; + gitAccount: string; + admin?: boolean; +} diff --git a/src/service/interfaces/common.interfaces.ts b/src/service/interfaces/common.interfaces.ts new file mode 100644 index 000000000..1e3efbb32 --- /dev/null +++ b/src/service/interfaces/common.interfaces.ts @@ -0,0 +1,20 @@ +/** + * Copyright 2026 GitProxy Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** Generic response carrying a human-readable message. */ +export interface MessageResponse { + message: string; +} diff --git a/src/service/interfaces/health.interfaces.ts b/src/service/interfaces/health.interfaces.ts new file mode 100644 index 000000000..e105259fe --- /dev/null +++ b/src/service/interfaces/health.interfaces.ts @@ -0,0 +1,19 @@ +/** + * Copyright 2026 GitProxy Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export interface HealthResponse { + message: 'ok'; +} diff --git a/src/service/interfaces/home.interfaces.ts b/src/service/interfaces/home.interfaces.ts new file mode 100644 index 000000000..5914707cf --- /dev/null +++ b/src/service/interfaces/home.interfaces.ts @@ -0,0 +1,21 @@ +/** + * Copyright 2026 GitProxy Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export interface ApiResources { + healthcheck: string; + push: string; + auth: string; +} diff --git a/src/service/interfaces/push.interfaces.ts b/src/service/interfaces/push.interfaces.ts new file mode 100644 index 000000000..94bb90344 --- /dev/null +++ b/src/service/interfaces/push.interfaces.ts @@ -0,0 +1,28 @@ +/** + * Copyright 2026 GitProxy Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { AttestationAnswer } from '../../proxy/processors/types'; + +export interface RejectBody { + /** The reason for rejecting the push request. */ + reason: string; +} + +export interface AuthoriseBody { + params: { + attestation: AttestationAnswer[]; + }; +} diff --git a/src/service/routes/home.ts b/src/service/interfaces/repo.interfaces.ts similarity index 67% rename from src/service/routes/home.ts rename to src/service/interfaces/repo.interfaces.ts index bfab99c46..4a6238a11 100644 --- a/src/service/routes/home.ts +++ b/src/service/interfaces/repo.interfaces.ts @@ -14,18 +14,18 @@ * limitations under the License. */ -import express, { Request, Response } from 'express'; +import { Repo } from '../../db/types'; -const router = express.Router(); +export interface UsernameBody { + username: string; +} -const resource = { - healthcheck: '/api/v1/healthcheck', - push: '/api/v1/push', - auth: '/api/auth', -}; +export interface CreateRepoBody { + url: string; + name: string; + project: string; +} -router.get('/', (_req: Request, res: Response) => { - res.send(resource); -}); - -export default router; +export interface RepoWithProxy extends Repo { + proxyURL: string; +} diff --git a/src/service/passport/jwtAuthHandler.ts b/src/service/passport/jwtAuthHandler.ts deleted file mode 100644 index d03fe5555..000000000 --- a/src/service/passport/jwtAuthHandler.ts +++ /dev/null @@ -1,101 +0,0 @@ -/** - * Copyright 2026 GitProxy Contributors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { assignRoles, validateJwt } from './jwtUtils'; -import type { Request, Response, NextFunction } from 'express'; -import { getAPIAuthMethods } from '../../config'; -import { - AuthenticationElement, - JwtConfig, - RoleMapping, - AuthenticationElementType, -} from '../../config/generated/config'; - -export const type = 'jwt'; - -export const jwtAuthHandler = (overrideConfig: JwtConfig | null = null) => { - return async (req: Request, res: Response, next: NextFunction): Promise => { - const apiAuthMethods: AuthenticationElement[] = overrideConfig - ? [{ type: 'jwt' as AuthenticationElementType, enabled: true, jwtConfig: overrideConfig }] - : getAPIAuthMethods(); - - const jwtAuthMethod = apiAuthMethods.find((method) => method.type.toLowerCase() === type); - - if (!jwtAuthMethod || !jwtAuthMethod.enabled) { - return next(); - } - - if (req.isAuthenticated && req.isAuthenticated()) { - return next(); - } - - const token = req.header('Authorization'); - if (!token) { - res.status(401).send('No token provided\n'); - return; - } - - if (!jwtAuthMethod.jwtConfig) { - res.status(500).send({ - message: 'JWT configuration is missing\n', - }); - console.log('JWT configuration is missing\n'); - return; - } - - const config = jwtAuthMethod.jwtConfig!; - const { clientID, authorityURL, expectedAudience, roleMapping } = config; - const audience = expectedAudience || clientID; - - if (!authorityURL) { - res.status(500).send({ - message: 'OIDC authority URL is not configured\n', - }); - console.log('OIDC authority URL is not configured\n'); - return; - } - - if (!clientID) { - res.status(500).send({ - message: 'OIDC client ID is not configured\n', - }); - console.log('OIDC client ID is not configured\n'); - return; - } - - const tokenParts = token.split(' '); - const accessToken = tokenParts.length === 2 ? tokenParts[1] : tokenParts[0]; - - const { verifiedPayload, error } = await validateJwt( - accessToken, - authorityURL, - audience, - clientID, - ); - - if (error || !verifiedPayload) { - res.status(401).send(error || 'JWT validation failed\n'); - console.log('JWT validation failed\n'); - return; - } - - req.user = verifiedPayload; - assignRoles(roleMapping as RoleMapping, verifiedPayload, req.user); - - console.log('JWT validation successful\n'); - next(); - }; -}; diff --git a/src/service/routes/config.ts b/src/service/proxyStore.ts similarity index 51% rename from src/service/routes/config.ts rename to src/service/proxyStore.ts index ad61ab422..f84704072 100644 --- a/src/service/routes/config.ts +++ b/src/service/proxyStore.ts @@ -14,25 +14,21 @@ * limitations under the License. */ -import express, { Request, Response } from 'express'; -import * as config from '../../config'; +import { Proxy } from '../proxy'; -const router = express.Router(); +let _proxy: Proxy | null = null; -router.get('/attestation', (_req: Request, res: Response) => { - res.send(config.getAttestationConfig()); -}); - -router.get('/urlShortener', (_req: Request, res: Response) => { - res.send(config.getURLShortener()); -}); - -router.get('/contactEmail', (_req: Request, res: Response) => { - res.send(config.getContactEmail()); -}); - -router.get('/uiRouteAuth', (_req: Request, res: Response) => { - res.send(config.getUIRouteAuth()); -}); +/** + * Store the Proxy instance so controllers can access it without constructor injection. + * Must be called before RegisterRoutes is invoked in src/service/index.ts. + */ +export function setProxy(proxy: Proxy): void { + _proxy = proxy; +} -export default router; +export function getProxy(): Proxy { + if (!_proxy) { + throw new Error('Proxy has not been initialised. Call setProxy() before using getProxy().'); + } + return _proxy; +} diff --git a/src/service/routes/auth.ts b/src/service/routes/auth.ts deleted file mode 100644 index a03c80480..000000000 --- a/src/service/routes/auth.ts +++ /dev/null @@ -1,282 +0,0 @@ -/** - * Copyright 2026 GitProxy Contributors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import express, { Request, Response, NextFunction } from 'express'; -import { getPassport, authStrategies } from '../passport'; -import { getAuthMethods } from '../../config'; - -import * as db from '../../db'; -import * as passportLocal from '../passport/local'; -import * as passportAD from '../passport/activeDirectory'; - -import { User } from '../../db/types'; -import { AuthenticationElement } from '../../config/generated/config'; - -import { isAdminUser, toPublicUser } from './utils'; -import { handleErrorAndLog } from '../../utils/errors'; - -const router = express.Router(); -const passport = getPassport(); - -const { GIT_PROXY_UI_HOST: uiHost = 'http://localhost', GIT_PROXY_UI_PORT: uiPort = 3000 } = - process.env; - -router.get('/', (_req: Request, res: Response) => { - res.status(200).json({ - login: { - action: 'post', - uri: '/api/auth/login', - }, - profile: { - action: 'get', - uri: '/api/auth/profile', - }, - logout: { - action: 'post', - uri: '/api/auth/logout', - }, - }); -}); - -// login strategies that will work with /login e.g. take username and password -const appropriateLoginStrategies = [passportLocal.type, passportAD.type]; -// getLoginStrategy fetches the enabled auth methods and identifies if there's an appropriate -// auth method for username and password login. If there isn't it returns null, if there is it -// returns the first. -const getLoginStrategy = () => { - // returns only enabled auth methods - // returns at least one enabled auth method - const enabledAppropriateLoginStrategies = getAuthMethods().filter((am: AuthenticationElement) => - appropriateLoginStrategies.includes(am.type.toLowerCase()), - ); - // for where no login strategies which work for /login are enabled - // just return null - if (enabledAppropriateLoginStrategies.length === 0) { - return null; - } - // return the first enabled auth method - return enabledAppropriateLoginStrategies[0].type.toLowerCase(); -}; - -const loginSuccessHandler = () => async (req: Request, res: Response) => { - try { - const currentUser = toPublicUser({ ...req.user } as User); - console.log( - `serivce.routes.auth.login: user logged in, username=${ - currentUser.username - } profile=${JSON.stringify(currentUser)}`, - ); - res.send({ - message: 'success', - user: currentUser, - }); - } catch (error: unknown) { - const msg = handleErrorAndLog(error, 'Error logging user in'); - res.status(500).send(`Failed to login: ${msg}`).end(); - } -}; - -router.get('/config', (req, res) => { - const usernamePasswordMethod = getLoginStrategy(); - res.send({ - // enabled username /password auth method - usernamePasswordMethod: usernamePasswordMethod, - // other enabled auth methods - otherMethods: getAuthMethods() - .map((am) => am.type.toLowerCase()) - .filter((authType) => authType !== usernamePasswordMethod), - }); -}); - -// TODO: provide separate auth endpoints for each auth strategy or chain compatibile auth strategies -// TODO: if providing separate auth methods, inform the frontend so it has relevant UI elements and appropriate client-side behavior -router.post( - '/login', - (req: Request, res: Response, next: NextFunction) => { - const authType = getLoginStrategy(); - if (authType === null) { - res.status(403).send('Username and Password based Login is not enabled at this time').end(); - return; - } - console.log('going to auth with', authType); - return passport.authenticate(authType)(req, res, next); - }, - loginSuccessHandler(), -); - -router.get('/openidconnect', passport.authenticate(authStrategies['openidconnect'].type)); - -router.get('/openidconnect/callback', (req: Request, res: Response, next: NextFunction) => { - passport.authenticate( - authStrategies['openidconnect'].type, - (err: unknown, user: Partial, info: unknown) => { - if (err) { - console.error('Authentication error:', err); - return res.status(500).end(); - } - if (!user) { - console.error('No user found:', info); - return res.status(401).end(); - } - req.logIn(user, (err) => { - if (err) { - console.error('Login error:', err); - return res.status(500).end(); - } - console.log('Logged in successfully. User:', user); - return res.redirect(`${uiHost}:${uiPort}/dashboard/profile`); - }); - }, - )(req, res, next); -}); - -router.post('/logout', (req: Request, res: Response, next: NextFunction) => { - req.logout((err: unknown) => { - if (err) return next(err); - }); - res.clearCookie('connect.sid'); - res.send({ isAuth: req.isAuthenticated(), user: req.user }); -}); - -router.get('/profile', async (req: Request, res: Response) => { - if (!req.user) { - res - .status(401) - .send({ - message: 'Not logged in', - }) - .end(); - return; - } - - const userVal = await db.findUser((req.user as User).username); - if (!userVal) { - res.status(404).send({ message: 'User not found' }).end(); - return; - } - - res.send(toPublicUser(userVal)); -}); - -router.post('/gitAccount', async (req: Request, res: Response) => { - if (!req.user) { - res - .status(401) - .send({ - message: 'Not logged in', - }) - .end(); - return; - } - - try { - let username = - req.body.username == null || req.body.username === 'undefined' - ? req.body.id - : req.body.username; - username = username?.split('@')[0]; - - if (!username) { - res - .status(400) - .send({ - message: 'Missing username. Git account not updated', - }) - .end(); - return; - } - - const reqUser = await db.findUser((req.user as User).username); - if (username !== reqUser?.username && !reqUser?.admin) { - res - .status(403) - .send({ - message: 'Must be an admin to update a different account', - }) - .end(); - return; - } - - const user = await db.findUser(username); - if (!user) { - res - .status(404) - .send({ - message: 'User not found', - }) - .end(); - return; - } - - user.gitAccount = req.body.gitAccount; - db.updateUser(user); - res.status(200).end(); - } catch (error: unknown) { - const msg = handleErrorAndLog(error, 'Failed to update git account'); - res - .status(500) - .send({ - message: msg, - }) - .end(); - } -}); - -router.post('/create-user', async (req: Request, res: Response) => { - if (!isAdminUser(req.user)) { - res - .status(403) - .send({ - message: 'Not authorized to create users', - }) - .end(); - return; - } - - try { - const { username, password, email, gitAccount, admin: isAdmin = false } = req.body; - - if (!username || !password || !email || !gitAccount) { - res - .status(400) - .send({ - message: - 'Missing required fields: username, password, email, and gitAccount are required', - }) - .end(); - return; - } - - await db.createUser(username, password, email, gitAccount, isAdmin); - res - .status(201) - .send({ - message: 'User created successfully', - username, - }) - .end(); - } catch (error: unknown) { - const msg = handleErrorAndLog(error, 'Failed to create user'); - res - .status(500) - .send({ - message: msg, - }) - .end(); - } -}); - -export default { router, loginSuccessHandler }; diff --git a/src/service/routes/index.ts b/src/service/routes/index.ts deleted file mode 100644 index 80d6c315d..000000000 --- a/src/service/routes/index.ts +++ /dev/null @@ -1,40 +0,0 @@ -/** - * Copyright 2026 GitProxy Contributors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import express from 'express'; -import auth from './auth'; -import push from './push'; -import home from './home'; -import repo from './repo'; -import users from './users'; -import healthcheck from './healthcheck'; -import config from './config'; -import { jwtAuthHandler } from '../passport/jwtAuthHandler'; -import { Proxy } from '../../proxy'; - -const routes = (proxy: Proxy) => { - const router = express.Router(); - router.use('/api', home); - router.use('/api/auth', auth.router); - router.use('/api/v1/healthcheck', healthcheck); - router.use('/api/v1/push', jwtAuthHandler(), push); - router.use('/api/v1/repo', jwtAuthHandler(), repo(proxy)); - router.use('/api/v1/user', jwtAuthHandler(), users); - router.use('/api/v1/config', config); - return router; -}; - -export default routes; diff --git a/src/service/routes/push.ts b/src/service/routes/push.ts deleted file mode 100644 index 1e900d6ea..000000000 --- a/src/service/routes/push.ts +++ /dev/null @@ -1,278 +0,0 @@ -/** - * Copyright 2026 GitProxy Contributors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import express, { Request, Response } from 'express'; -import * as db from '../../db'; -import { PushQuery } from '../../db/types'; -import { AttestationConfig } from '../../config/generated/config'; -import { getAttestationConfig } from '../../config'; -import { AttestationAnswer, Rejection } from '../../proxy/processors/types'; - -interface AuthoriseRequest { - params: { - attestation: AttestationAnswer[]; - }; -} - -const router = express.Router(); - -router.get('/', async (req: Request, res: Response) => { - const query: Partial = { - type: 'push', - }; - - for (const key in req.query) { - if (!key) continue; - if (key === 'limit' || key === 'skip') continue; - - const rawValue = req.query[key]; - let parsedValue: boolean | undefined; - if (rawValue === 'false') parsedValue = false; - if (rawValue === 'true') parsedValue = true; - query[key] = parsedValue ?? rawValue?.toString(); - } - - res.send(await db.getPushes(query)); -}); - -router.get('/:id', async (req: Request<{ id: string }>, res: Response) => { - const id = req.params.id; - const push = await db.getPush(id); - if (push) { - res.send(push); - } else { - res.status(404).send({ - message: 'not found', - }); - } -}); - -router.post('/:id/reject', async (req: Request<{ id: string }>, res: Response) => { - if (!req.user) { - res.status(401).send({ - message: 'Not logged in', - }); - return; - } - - const id = req.params.id; - const { username } = req.user as { username: string }; - const { reason } = req.body; - - if (!reason || !reason.trim()) { - res.status(400).send({ - message: 'Rejection reason is required', - }); - return; - } - - // Get the push request - const push = await getValidPushOrRespond(id, res); - if (!push) return; - - // Get the committer of the push via their email - const committerEmail = push.userEmail; - const list = await db.getUsers({ email: committerEmail }); - - if (list.length === 0) { - res.status(404).send({ - message: `No user found with the committer's email address: ${committerEmail}`, - }); - return; - } - - if (list[0].username.toLowerCase() === username.toLowerCase() && !list[0].admin) { - res.status(403).send({ - message: `Cannot reject your own changes`, - }); - return; - } - - const isAllowed = await db.canUserApproveRejectPush(id, username); - - if (isAllowed) { - const reviewerList = await db.getUsers({ username }); - const reviewerEmail = reviewerList[0].email; - - if (!reviewerEmail) { - res.status(404).send({ - message: `There was no registered email address for the reviewer: ${username}`, - }); - return; - } - - const rejection: Rejection = { - reason, - timestamp: new Date(), - reviewer: { - username, - email: reviewerEmail, - }, - }; - - const result = await db.reject(id, rejection); - console.log( - `User ${username} rejected push request for ${id}${reason ? ` with reason: ${reason}` : ''}`, - ); - res.send(result); - } else { - res.status(403).send({ - message: `User ${username} is not authorised to reject changes on this project`, - }); - } -}); - -router.post( - '/:id/authorise', - async (req: Request<{ id: string }, unknown, AuthoriseRequest>, res: Response) => { - if (!req.user) { - res.status(401).send({ - message: 'Not logged in', - }); - return; - } - - const answers = req.body.params?.attestation; - - const attestationComplete = validateAttestation(answers, getAttestationConfig()); - - if (!attestationComplete) { - res.status(400).send({ - message: 'Attestation is not complete', - }); - return; - } - - const id = req.params.id; - - const { username } = req.user as { username: string }; - - const push = await db.getPush(id); - if (!push) { - res.status(404).send({ - message: 'Push request not found', - }); - return; - } - - // Get the committer of the push via their email address - const committerEmail = push.userEmail; - - const list = await db.getUsers({ email: committerEmail }); - - if (list.length === 0) { - res.status(404).send({ - message: `No user found with the committer's email address: ${committerEmail}`, - }); - return; - } - - if (list[0].username.toLowerCase() === username.toLowerCase() && !list[0].admin) { - res.status(403).send({ - message: `Cannot approve your own changes`, - }); - return; - } - - // If we are not the author, now check that we are allowed to authorise on this - // repo - const isAllowed = await db.canUserApproveRejectPush(id, username); - if (isAllowed) { - console.log(`User ${username} approved push request for ${id}`); - - const reviewerList = await db.getUsers({ username }); - const reviewerEmail = reviewerList[0].email; - - if (!reviewerEmail) { - res.status(404).send({ - message: `There was no registered email address for the reviewer: ${username}`, - }); - return; - } - - const attestation = { - answers, - timestamp: new Date(), - reviewer: { - username, - email: reviewerEmail, - }, - }; - const result = await db.authorise(id, attestation); - res.send(result); - } else { - res.status(403).send({ - message: `User ${username} not authorised to approve pushes on this project`, - }); - } - }, -); - -router.post('/:id/cancel', async (req: Request<{ id: string }>, res: Response) => { - if (!req.user) { - res.status(401).send({ - message: 'Not logged in', - }); - return; - } - - const id = req.params.id; - const { username } = req.user as { username: string }; - - const isAllowed = await db.canUserCancelPush(id, username); - - if (isAllowed) { - const result = await db.cancel(id); - console.log(`User ${username} canceled push request for ${id}`); - res.send(result); - } else { - console.log(`User ${username} not authorised to cancel push request for ${id}`); - res.status(403).send({ - message: `User ${username} not authorised to cancel push requests on this project`, - }); - } -}); - -async function getValidPushOrRespond(id: string, res: Response) { - console.log('getValidPushOrRespond', { id }); - const push = await db.getPush(id); - - if (!push) { - res.status(404).send({ message: `Push request not found` }); - return null; - } - - if (!push.userEmail) { - res.status(400).send({ message: `Push request has no user email` }); - return null; - } - - return push; -} - -function validateAttestation(answers: AttestationAnswer[], config: AttestationConfig): boolean { - const configQuestions = config.questions ?? []; - - if (answers.length !== configQuestions.length) { - return false; - } - - const configLabels = new Set(configQuestions.map((q) => q.label)); - - return answers.every((answer) => configLabels.has(answer.label) && !!answer.checked); -} - -export default router; diff --git a/src/service/routes/repo.ts b/src/service/routes/repo.ts deleted file mode 100644 index 7e259d0aa..000000000 --- a/src/service/routes/repo.ts +++ /dev/null @@ -1,231 +0,0 @@ -/** - * Copyright 2026 GitProxy Contributors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import express, { Request, Response } from 'express'; - -import * as db from '../../db'; -import { getProxyURL } from '../urls'; -import { getAllProxiedHosts } from '../../db'; -import { RepoQuery } from '../../db/types'; -import { isAdminUser } from './utils'; -import { Proxy } from '../../proxy'; -import { handleErrorAndLog } from '../../utils/errors'; - -function repo(proxy: Proxy) { - const router = express.Router(); - - router.get('/', async (req: Request, res: Response) => { - const proxyURL = getProxyURL(req); - const query: Partial = {}; - - for (const key in req.query) { - if (!key) continue; - if (key === 'limit' || key === 'skip') continue; - - const rawValue = req.query[key]; - let parsedValue: boolean | undefined; - if (rawValue === 'false') parsedValue = false; - if (rawValue === 'true') parsedValue = true; - query[key] = parsedValue ?? rawValue?.toString(); - } - - const qd = await db.getRepos(query); - res.send(qd.map((d) => ({ ...d, proxyURL }))); - }); - - router.get('/:id', async (req: Request<{ id: string }>, res: Response) => { - const proxyURL = getProxyURL(req); - const _id = req.params.id; - const qd = await db.getRepoById(_id); - res.send({ ...qd, proxyURL }); - }); - - router.patch('/:id/user/push', async (req: Request<{ id: string }>, res: Response) => { - if (!isAdminUser(req.user)) { - res.status(401).send({ - message: 'You are not authorised to perform this action.', - }); - return; - } - - const _id = req.params.id; - const username = req.body.username.toLowerCase(); - const user = await db.findUser(username); - - if (!user) { - res.status(400).send({ error: 'User does not exist' }); - return; - } - - await db.addUserCanPush(_id, username); - res.send({ message: 'created' }); - }); - - router.patch('/:id/user/authorise', async (req: Request<{ id: string }>, res: Response) => { - if (!isAdminUser(req.user)) { - res.status(401).send({ - message: 'You are not authorised to perform this action.', - }); - return; - } - - const _id = req.params.id; - const username = req.body.username; - const user = await db.findUser(username); - - if (!user) { - res.status(400).send({ error: 'User does not exist' }); - return; - } - - await db.addUserCanAuthorise(_id, username); - res.send({ message: 'created' }); - }); - - router.delete( - '/:id/user/authorise/:username', - async (req: Request<{ id: string; username: string }>, res: Response) => { - if (!isAdminUser(req.user)) { - res.status(401).send({ - message: 'You are not authorised to perform this action.', - }); - return; - } - - const _id = req.params.id; - const username = req.params.username; - const user = await db.findUser(username); - - if (!user) { - res.status(400).send({ error: 'User does not exist' }); - return; - } - - await db.removeUserCanAuthorise(_id, username); - res.send({ message: 'created' }); - }, - ); - - router.delete( - '/:id/user/push/:username', - async (req: Request<{ id: string; username: string }>, res: Response) => { - if (!isAdminUser(req.user)) { - res.status(401).send({ - message: 'You are not authorised to perform this action.', - }); - return; - } - - const _id = req.params.id; - const username = req.params.username; - const user = await db.findUser(username); - - if (!user) { - res.status(400).send({ error: 'User does not exist' }); - return; - } - - await db.removeUserCanPush(_id, username); - res.send({ message: 'created' }); - }, - ); - - router.delete('/:id/delete', async (req: Request<{ id: string }>, res: Response) => { - if (!isAdminUser(req.user)) { - res.status(401).send({ - message: 'You are not authorised to perform this action.', - }); - return; - } - - const _id = req.params.id; - - // determine if we need to restart the proxy - const previousHosts = await getAllProxiedHosts(); - await db.deleteRepo(_id); - const currentHosts = await getAllProxiedHosts(); - - if (currentHosts.length < previousHosts.length) { - // restart the proxy - console.log('Restarting the proxy to remove a host'); - await proxy.stop(); - await proxy.start(); - } - - res.send({ message: 'deleted' }); - }); - - router.post('/', async (req: Request, res: Response) => { - if (!isAdminUser(req.user)) { - res.status(401).send({ - message: 'You are not authorised to perform this action.', - }); - return; - } - - if (!req.body.url) { - res.status(400).send({ - message: 'Repository url is required', - }); - return; - } - - const repo = await db.getRepoByUrl(req.body.url); - if (repo) { - res.status(409).send({ - message: `Repository ${req.body.url} already exists!`, - }); - } else { - try { - // figure out if this represent a new domain to proxy - let newOrigin = true; - - const existingHosts = await getAllProxiedHosts(); - existingHosts.forEach((h) => { - // assume SSL is in use and that our origins are missing the protocol - if (req.body.url.startsWith(`https://${h}`)) { - newOrigin = false; - } - }); - - console.log( - `API request to proxy repository ${req.body.url} is for a new origin: ${newOrigin},\n\texisting origin list was: ${JSON.stringify(existingHosts)}`, - ); - - // create the repository - const repoDetails = await db.createRepo(req.body); - const proxyURL = getProxyURL(req); - - // restart the proxy if we're proxying a new domain - if (newOrigin) { - console.log('Restarting the proxy to handle an additional host'); - await proxy.stop(); - await proxy.start(); - } - - // return data on the new repository (including it's _id and the proxyUrl) - res.send({ ...repoDetails, proxyURL, message: 'created' }); - } catch (error: unknown) { - const msg = handleErrorAndLog(error, 'Repository creation failed'); - res.status(500).send({ message: msg }); - } - } - }); - - return router; -} - -export default repo; diff --git a/src/service/routes/users.ts b/src/service/routes/users.ts deleted file mode 100644 index 7a20307e9..000000000 --- a/src/service/routes/users.ts +++ /dev/null @@ -1,45 +0,0 @@ -/** - * Copyright 2026 GitProxy Contributors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import express, { Request, Response } from 'express'; -const router = express.Router(); - -import * as db from '../../db'; -import { toPublicUser } from './utils'; - -router.get('/', async (req: Request, res: Response) => { - console.log('fetching users'); - const users = await db.getUsers(); - res.send(users.map(toPublicUser)); -}); - -router.get('/:id', async (req: Request<{ id: string }>, res: Response) => { - const username = req.params.id.toLowerCase(); - console.log(`Retrieving details for user: ${username}`); - const user = await db.findUser(username); - if (!user) { - res - .status(404) - .send({ - message: `User ${username} not found`, - }) - .end(); - return; - } - res.send(toPublicUser(user)); -}); - -export default router; diff --git a/test/services/routes/auth.test.ts b/test/services/routes/auth.test.ts index 4008cf9ed..34597919e 100644 --- a/test/services/routes/auth.test.ts +++ b/test/services/routes/auth.test.ts @@ -16,22 +16,51 @@ import { describe, it, expect, afterEach, beforeEach, vi } from 'vitest'; import request from 'supertest'; -import express, { Express, Request, Response } from 'express'; -import authRoutes from '../../../src/service/routes/auth'; +import express, { Express } from 'express'; +import { RegisterRoutes } from '../../../src/service/generatedRoutes'; import * as db from '../../../src/db'; +import { ValidateError } from 'tsoa'; -const newApp = (username?: string): Express => { +/** + * Builds a minimal Express app with tsoa routes registered. + * When `username` is supplied the request is pre-authenticated via middleware, + * simulating a session-authenticated user. + */ +const newApp = (username?: string, isAdmin = false): Express => { const app = express(); app.use(express.json()); if (username) { app.use((req, _res, next) => { - req.user = { username }; + req.user = { username, admin: isAdmin }; + next(); + }); + } else { + app.use((_req, _res, next) => { next(); }); } - app.use('/auth', authRoutes.router); + // Generic error handler so tsoa thrown errors propagate as HTTP responses. + RegisterRoutes(app); + + // tsoa validation errors + app.use((err: any, _req: any, res: any, next: any) => { + if (err instanceof ValidateError) { + return res.status(400).json({ + message: 'Validation failed', + details: err.fields, + }); + } + next(err); + }); + + // Generic error handler so tsoa thrown errors propagate as HTTP responses. + app.use((err: any, _req: any, res: any, next: any) => { + if (res.headersSent) return next(err); + res.status(err.status ?? 500).json({ message: err.message }); + }); + return app; }; @@ -40,7 +69,7 @@ describe('Auth API', () => { vi.restoreAllMocks(); }); - describe('POST /gitAccount', () => { + describe('POST /api/auth/gitAccount', () => { beforeEach(() => { vi.spyOn(db, 'findUser').mockImplementation((username: string) => { if (username === 'alice') { @@ -73,7 +102,7 @@ describe('Auth API', () => { }); it('should return 401 Unauthorized if authenticated user not in request', async () => { - const res = await request(newApp()).post('/auth/gitAccount').send({ + const res = await request(newApp()).post('/api/auth/gitAccount').send({ username: 'alice', gitAccount: '', }); @@ -82,7 +111,7 @@ describe('Auth API', () => { }); it('should return 400 Bad Request if username is missing', async () => { - const res = await request(newApp('alice')).post('/auth/gitAccount').send({ + const res = await request(newApp('alice', true)).post('/api/auth/gitAccount').send({ gitAccount: 'UPDATED_GIT_ACCOUNT', }); @@ -90,7 +119,7 @@ describe('Auth API', () => { }); it('should return 400 Bad Request if username is undefined', async () => { - const res = await request(newApp('alice')).post('/auth/gitAccount').send({ + const res = await request(newApp('alice', true)).post('/api/auth/gitAccount').send({ username: undefined, gitAccount: 'UPDATED_GIT_ACCOUNT', }); @@ -99,7 +128,7 @@ describe('Auth API', () => { }); it('should return 400 Bad Request if username is null', async () => { - const res = await request(newApp('alice')).post('/auth/gitAccount').send({ + const res = await request(newApp('alice', true)).post('/api/auth/gitAccount').send({ username: null, gitAccount: 'UPDATED_GIT_ACCOUNT', }); @@ -108,7 +137,7 @@ describe('Auth API', () => { }); it('should return 400 Bad Request if username is an empty string', async () => { - const res = await request(newApp('alice')).post('/auth/gitAccount').send({ + const res = await request(newApp('alice', true)).post('/api/auth/gitAccount').send({ username: '', gitAccount: 'UPDATED_GIT_ACCOUNT', }); @@ -117,7 +146,7 @@ describe('Auth API', () => { }); it('should return 403 Forbidden if user is not an admin', async () => { - const res = await request(newApp('bob')).post('/auth/gitAccount').send({ + const res = await request(newApp('bob')).post('/api/auth/gitAccount').send({ username: 'alice', gitAccount: 'UPDATED_GIT_ACCOUNT', }); @@ -126,7 +155,7 @@ describe('Auth API', () => { }); it('should return 404 Not Found if user is not found', async () => { - const res = await request(newApp('alice')).post('/auth/gitAccount').send({ + const res = await request(newApp('alice', true)).post('/api/auth/gitAccount').send({ username: 'non-existent-user', gitAccount: 'UPDATED_GIT_ACCOUNT', }); @@ -137,7 +166,7 @@ describe('Auth API', () => { it('should return 200 OK if user is an admin and updates git account for authenticated user', async () => { const updateUserSpy = vi.spyOn(db, 'updateUser').mockResolvedValue(); - const res = await request(newApp('alice')).post('/auth/gitAccount').send({ + const res = await request(newApp('alice', true)).post('/api/auth/gitAccount').send({ username: 'alice', gitAccount: 'UPDATED_GIT_ACCOUNT', }); @@ -158,7 +187,7 @@ describe('Auth API', () => { it("should prevent non-admin users from changing a different user's gitAccount", async () => { const updateUserSpy = vi.spyOn(db, 'updateUser').mockResolvedValue(); - const res = await request(newApp('bob')).post('/auth/gitAccount').send({ + const res = await request(newApp('bob')).post('/api/auth/gitAccount').send({ username: 'phil', gitAccount: 'UPDATED_GIT_ACCOUNT', }); @@ -170,7 +199,7 @@ describe('Auth API', () => { it("should allow admin users to change a different user's gitAccount", async () => { const updateUserSpy = vi.spyOn(db, 'updateUser').mockResolvedValue(); - const res = await request(newApp('alice')).post('/auth/gitAccount').send({ + const res = await request(newApp('alice', true)).post('/api/auth/gitAccount').send({ username: 'bob', gitAccount: 'UPDATED_GIT_ACCOUNT', }); @@ -191,7 +220,7 @@ describe('Auth API', () => { it('should allow non-admin users to update their own gitAccount', async () => { const updateUserSpy = vi.spyOn(db, 'updateUser').mockResolvedValue(); - const res = await request(newApp('bob')).post('/auth/gitAccount').send({ + const res = await request(newApp('bob')).post('/api/auth/gitAccount').send({ username: 'bob', gitAccount: 'UPDATED_GIT_ACCOUNT', }); @@ -210,46 +239,9 @@ describe('Auth API', () => { }); }); - describe('loginSuccessHandler', () => { - it('should log in user and return public user data', async () => { - const user = { - username: 'bob', - password: 'secret', - email: 'bob@example.com', - displayName: 'Bob', - admin: false, - gitAccount: '', - title: '', - }; - - const sendSpy = vi.fn(); - const res = { - send: sendSpy, - }; - - await authRoutes.loginSuccessHandler()( - { user } as unknown as Request, - res as unknown as Response, - ); - - expect(sendSpy).toHaveBeenCalledOnce(); - expect(sendSpy).toHaveBeenCalledWith({ - message: 'success', - user: { - admin: false, - displayName: 'Bob', - email: 'bob@example.com', - gitAccount: '', - title: '', - username: 'bob', - }, - }); - }); - }); - - describe('GET /profile', () => { + describe('GET /api/auth/profile', () => { it('should return 401 Unauthorized if user is not logged in', async () => { - const res = await request(newApp()).get('/auth/profile'); + const res = await request(newApp()).get('/api/auth/profile'); expect(res.status).toBe(401); }); @@ -265,7 +257,7 @@ describe('Auth API', () => { title: '', }); - const res = await request(newApp('alice')).get('/auth/profile'); + const res = await request(newApp('alice')).get('/api/auth/profile'); expect(res.status).toBe(200); expect(res.body).toEqual({ username: 'alice', @@ -278,15 +270,17 @@ describe('Auth API', () => { }); it('should return 404 Not Found if user is not found', async () => { - const res = await request(newApp('non-existent-user')).get('/auth/profile'); + vi.spyOn(db, 'findUser').mockResolvedValue(null); + + const res = await request(newApp('non-existent-user')).get('/api/auth/profile'); expect(res.status).toBe(404); expect(res.body).toEqual({ message: 'User not found' }); }); }); - describe('GET /', () => { + describe('GET /api/auth', () => { it('should return 200 OK and the auth endpoints', async () => { - const res = await request(newApp()).get('/auth'); + const res = await request(newApp()).get('/api/auth'); expect(res.status).toBe(200); expect(res.body).toEqual({ login: { @@ -305,9 +299,9 @@ describe('Auth API', () => { }); }); - describe('GET /config', () => { + describe('GET /api/auth/config', () => { it('should return 200 OK and the default auth config', async () => { - const res = await request(newApp()).get('/auth/config'); + const res = await request(newApp()).get('/api/auth/config'); expect(res.status).toBe(200); expect(res.body).toEqual({ usernamePasswordMethod: 'local', diff --git a/test/services/routes/config.test.ts b/test/services/routes/config.test.ts index 4caa53758..536e3d7ce 100644 --- a/test/services/routes/config.test.ts +++ b/test/services/routes/config.test.ts @@ -17,7 +17,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import express, { Express } from 'express'; import request from 'supertest'; -import configRouter from '../../../src/service/routes/config'; +import { RegisterRoutes } from '../../../src/service/generatedRoutes'; import * as config from '../../../src/config'; describe('Config API', () => { @@ -26,7 +26,7 @@ describe('Config API', () => { beforeEach(() => { app = express(); app.use(express.json()); - app.use('/config', configRouter); + RegisterRoutes(app); vi.spyOn(config, 'getAttestationConfig').mockReturnValue({ questions: [] }); vi.spyOn(config, 'getURLShortener').mockReturnValue('https://url-shortener.com'); @@ -38,26 +38,27 @@ describe('Config API', () => { vi.restoreAllMocks(); }); - it('GET /config/attestation should return 200 OK and the default attestation config', async () => { - const res = await request(app).get('/config/attestation'); + it('GET /api/v1/config/attestation should return 200 OK and the default attestation config', async () => { + const res = await request(app).get('/api/v1/config/attestation'); expect(res.status).toBe(200); expect(res.body).toEqual({ questions: [] }); }); - it('GET /config/urlShortener should return 200 OK and the default url shortener config', async () => { - const res = await request(app).get('/config/urlShortener'); + it('GET /api/v1/config/urlShortener should return 200 OK and the url shortener config', async () => { + const res = await request(app).get('/api/v1/config/urlShortener'); expect(res.status).toBe(200); - expect(res.text).toBe('https://url-shortener.com'); // Check res.text as it gets serialized as a string + // tsoa sends plain strings via res.send() so the value is in res.text, not res.body. + expect(res.text).toBe('https://url-shortener.com'); }); - it('GET /config/contactEmail should return 200 OK and the default contact email', async () => { - const res = await request(app).get('/config/contactEmail'); + it('GET /api/v1/config/contactEmail should return 200 OK and the contact email', async () => { + const res = await request(app).get('/api/v1/config/contactEmail'); expect(res.status).toBe(200); expect(res.text).toBe('test@example.com'); }); - it('GET /config/uiRouteAuth should return 200 OK and the default ui route auth config', async () => { - const res = await request(app).get('/config/uiRouteAuth'); + it('GET /api/v1/config/uiRouteAuth should return 200 OK and the ui route auth config', async () => { + const res = await request(app).get('/api/v1/config/uiRouteAuth'); expect(res.status).toBe(200); expect(res.body).toEqual({ enabled: false, rules: [] }); }); diff --git a/test/services/routes/healthCheck.test.ts b/test/services/routes/healthCheck.test.ts index 122203210..c1e6c02c6 100644 --- a/test/services/routes/healthCheck.test.ts +++ b/test/services/routes/healthCheck.test.ts @@ -17,7 +17,7 @@ import { describe, it, expect, beforeEach } from 'vitest'; import express, { Express } from 'express'; import request from 'supertest'; -import healthcheck from '../../../src/service/routes/healthcheck'; +import { RegisterRoutes } from '../../../src/service/generatedRoutes'; describe('Health Check API', () => { let app: Express; @@ -25,11 +25,11 @@ describe('Health Check API', () => { beforeEach(() => { app = express(); app.use(express.json()); - app.use('/healthCheck', healthcheck); + RegisterRoutes(app); }); - it('GET /healthCheck should return 200 OK and the health check message', async () => { - const res = await request(app).get('/healthCheck'); + it('GET /api/v1/healthcheck should return 200 OK and the health check message', async () => { + const res = await request(app).get('/api/v1/healthcheck'); expect(res.status).toBe(200); expect(res.body).toEqual({ message: 'ok' }); }); diff --git a/test/services/routes/users.test.ts b/test/services/routes/users.test.ts index 41ec52f58..c326e67e8 100644 --- a/test/services/routes/users.test.ts +++ b/test/services/routes/users.test.ts @@ -17,7 +17,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import express, { Express } from 'express'; import request from 'supertest'; -import usersRouter from '../../../src/service/routes/users'; +import { RegisterRoutes } from '../../../src/service/generatedRoutes'; import * as db from '../../../src/db'; describe('Users API', () => { @@ -26,7 +26,15 @@ describe('Users API', () => { beforeEach(() => { app = express(); app.use(express.json()); - app.use('/users', usersRouter); + app.use((req, _res, next) => { + req.user = { username: 'testuser' }; + next(); + }); + RegisterRoutes(app); + app.use((err: any, _req: any, res: any, next: any) => { + if (res.headersSent) return next(err); + res.status(err.status ?? 500).json({ message: err.message }); + }); vi.spyOn(db, 'getUsers').mockResolvedValue([ { @@ -53,8 +61,8 @@ describe('Users API', () => { vi.restoreAllMocks(); }); - it('GET /users only serializes public data needed for ui, not user secrets like password', async () => { - const res = await request(app).get('/users'); + it('GET /api/v1/user only serializes public data needed for ui, not user secrets like password', async () => { + const res = await request(app).get('/api/v1/user'); expect(res.status).toBe(200); expect(res.body).toEqual([ @@ -69,8 +77,8 @@ describe('Users API', () => { ]); }); - it('GET /users/:id does not serialize password', async () => { - const res = await request(app).get('/users/bob'); + it('GET /api/v1/user/:id does not serialize password', async () => { + const res = await request(app).get('/api/v1/user/bob'); expect(res.status).toBe(200); console.log(`Response body: ${JSON.stringify(res.body)}`); @@ -84,10 +92,10 @@ describe('Users API', () => { }); }); - it('GET /users/:id should return 404 Not Found if user is not found', async () => { + it('GET /api/v1/user/:id should return 404 Not Found if user is not found', async () => { vi.restoreAllMocks(); - const res = await request(app).get('/users/non-existent'); + const res = await request(app).get('/api/v1/user/non-existent'); expect(res.status).toBe(404); expect(res.body).toEqual({ message: 'User non-existent not found' }); }); diff --git a/test/testJwtAuthHandler.test.ts b/test/testJwtAuthHandler.test.ts index 7d41872e1..b2d14fade 100644 --- a/test/testJwtAuthHandler.test.ts +++ b/test/testJwtAuthHandler.test.ts @@ -16,12 +16,12 @@ import axios from 'axios'; import crypto from 'crypto'; -import { NextFunction } from 'express'; import jwt, { JwtPayload } from 'jsonwebtoken'; import { describe, it, expect, vi, beforeEach, afterEach, MockInstance } from 'vitest'; import { assignRoles, getJwks, validateJwt } from '../src/service/passport/jwtUtils'; -import { jwtAuthHandler } from '../src/service/passport/jwtAuthHandler'; +import { expressAuthentication } from '../src/service/authentication'; +import * as configModule from '../src/config'; import { JwtConfig, RoleMapping } from '../src/config/generated/config'; function generateRsaKeyPair() { @@ -189,17 +189,13 @@ describe('JWT', () => { }); }); - describe('jwtAuthHandler', () => { + describe('expressAuthentication', () => { let req: any; - let res: any; - let next: NextFunction; let jwtConfig: JwtConfig; - let validVerifyResponse: JwtPayload; + let mockGetAPIAuthMethods: MockInstance; beforeEach(() => { req = { header: vi.fn(), isAuthenticated: vi.fn(), user: {} }; - res = { status: vi.fn().mockReturnThis(), send: vi.fn() }; - next = vi.fn(); jwtConfig = { clientID: 'client-id', @@ -208,62 +204,57 @@ describe('JWT', () => { roleMapping: { admin: { admin: 'admin' } }, }; - validVerifyResponse = { - header: { kid: '123' }, - azp: 'client-id', - sub: 'user123', - admin: 'admin', - }; + mockGetAPIAuthMethods = vi + .spyOn(configModule, 'getAPIAuthMethods') + .mockReturnValue([{ type: 'jwt', enabled: true, jwtConfig }] as any); }); afterEach(() => vi.restoreAllMocks()); - it('should call next if user is authenticated', async () => { + it('should return user if already authenticated via session', async () => { req.isAuthenticated.mockReturnValue(true); - await jwtAuthHandler()(req, res, next); - expect(next).toHaveBeenCalledOnce(); + const result = await expressAuthentication(req, 'jwt'); + expect(result).toBe(req.user); }); - it('should return 401 if no token provided', async () => { - req.header.mockReturnValue(null); - await jwtAuthHandler(jwtConfig)(req, res, next); + it('should return undefined if JWT auth method is not configured', async () => { + mockGetAPIAuthMethods.mockReturnValue([]); + req.isAuthenticated.mockReturnValue(false); + const result = await expressAuthentication(req, 'jwt'); + expect(result).toBeUndefined(); + }); - expect(res.status).toHaveBeenCalledWith(401); - expect(res.send).toHaveBeenCalledWith('No token provided\n'); + it('should throw 401 if no token provided', async () => { + req.isAuthenticated.mockReturnValue(false); + req.header.mockReturnValue(null); + await expect(expressAuthentication(req, 'jwt')).rejects.toMatchObject({ status: 401 }); }); - it('should return 500 if authorityURL not configured', async () => { + it('should throw 500 if authorityURL not configured', async () => { + req.isAuthenticated.mockReturnValue(false); req.header.mockReturnValue('Bearer fake-token'); jwtConfig.authorityURL = null; - vi.spyOn(jwt, 'verify').mockReturnValue(validVerifyResponse); - - await jwtAuthHandler(jwtConfig)(req, res, next); - - expect(res.status).toHaveBeenCalledWith(500); - expect(res.send).toHaveBeenCalledWith({ message: 'OIDC authority URL is not configured\n' }); + await expect(expressAuthentication(req, 'jwt')).rejects.toMatchObject({ status: 500 }); }); - it('should return 500 if clientID not configured', async () => { + it('should throw 500 if clientID not configured', async () => { + req.isAuthenticated.mockReturnValue(false); req.header.mockReturnValue('Bearer fake-token'); jwtConfig.clientID = null; - vi.spyOn(jwt, 'verify').mockReturnValue(validVerifyResponse); - - await jwtAuthHandler(jwtConfig)(req, res, next); - - expect(res.status).toHaveBeenCalledWith(500); - expect(res.send).toHaveBeenCalledWith({ message: 'OIDC client ID is not configured\n' }); + await expect(expressAuthentication(req, 'jwt')).rejects.toMatchObject({ status: 500 }); }); - it('should return 401 if JWT validation fails', async () => { + it('should throw 401 if JWT validation fails', async () => { + req.isAuthenticated.mockReturnValue(false); req.header.mockReturnValue('Bearer fake-token'); vi.spyOn(jwt, 'verify').mockImplementation(() => { throw new Error('Invalid token'); }); + await expect(expressAuthentication(req, 'jwt')).rejects.toMatchObject({ status: 401 }); + }); - await jwtAuthHandler(jwtConfig)(req, res, next); - - expect(res.status).toHaveBeenCalledWith(401); - expect(res.send).toHaveBeenCalledWith(expect.stringMatching(/Invalid JWT:/)); + it('should throw 401 for unknown security scheme', async () => { + await expect(expressAuthentication(req, 'unknown')).rejects.toMatchObject({ status: 401 }); }); }); }); diff --git a/test/testLogin.test.ts b/test/testLogin.test.ts index 07ec1d2ba..472a253aa 100644 --- a/test/testLogin.test.ts +++ b/test/testLogin.test.ts @@ -197,9 +197,10 @@ describe('login', () => { }); expect(res.status).toBe(400); - expect(res.body.message).toBe( - 'Missing required fields: username, password, email, and gitAccount are required', - ); + // tsoa validates required body fields before the controller runs. + // The 'password' field is missing; tsoa prefixes body field errors with 'body.'. + expect(res.body.message).toBe('Validation failed'); + expect(res.body.details).toHaveProperty('body.password'); }); it('should successfully create a new user', async () => { diff --git a/test/testPush.test.ts b/test/testPush.test.ts index 8bf85788d..307a57727 100644 --- a/test/testPush.test.ts +++ b/test/testPush.test.ts @@ -123,7 +123,7 @@ describe('Push API', () => { await db.deletePush(TEST_PUSH.id); vi.resetModules(); - await Service.httpServer.close(); + await Service.httpServer?.close(); const res = await request(app).post('/api/auth/logout').set('Cookie', `${cookie}`); expect(res.status).toBe(200); @@ -292,7 +292,7 @@ describe('Push API', () => { it('should return 401 if not logged in when approving a push', async () => { const res = await request(app) .post(`/api/v1/push/${TEST_PUSH.id}/authorise`) - .send({ reason: 'Testing approval' }); + .send({ params: { attestation: [] } }); expect(res.status).toBe(401); expect(res.body.message).toBe('Not logged in'); }); @@ -315,7 +315,11 @@ describe('Push API', () => { .set('Cookie', `${cookie}`) .send({}); expect(res.status).toBe(400); - expect(res.body.message).toBe('Rejection reason is required'); + expect(res.body.message).toBe('Validation failed'); + expect(res.body.details).toBeDefined(); + console.log(res.body.details); + expect(res.body.details['body.reason']).toBeDefined(); + expect(res.body.details['body.reason'].message).toBe("'reason' is required"); }); it('should NOT allow an authorizer to reject a push with empty reason', async () => { diff --git a/test/testRepoApi.test.ts b/test/testRepoApi.test.ts index 11e4cf6bf..cd9eb8487 100644 --- a/test/testRepoApi.test.ts +++ b/test/testRepoApi.test.ts @@ -504,7 +504,10 @@ describe('repo routes - edge cases', () => { }); expect(res.status).toBe(400); - expect(res.body.message).toBe('Repository url is required'); + expect(res.body.message).toBe('Validation failed'); + expect(res.body.details).toBeDefined(); + expect(res.body.details['body.url']).toBeDefined(); + expect(res.body.details['body.url'].message).toBe("'url' is required"); }); it('should return 400 when repo url is invalid', async () => { @@ -678,6 +681,6 @@ describe('repo routes - edge cases', () => { await cleanupRepo(TEST_REPO.url); await db.deleteUser('testuser'); await db.deleteUser('nonadmin'); - await Service.httpServer.close(); + await Service.httpServer?.close(); }); }); diff --git a/tsconfig.json b/tsconfig.json index 331c876ef..6ba296d3b 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -19,7 +19,9 @@ "outDir": "./dist", "rootDir": "./src", "noEmit": false, - "types": ["node"] + "types": ["node"], + + "experimentalDecorators": true }, "include": ["src"], "exclude": ["node_modules", "dist", "**/*.test.ts"] diff --git a/tsoa.json b/tsoa.json new file mode 100644 index 000000000..8000b462f --- /dev/null +++ b/tsoa.json @@ -0,0 +1,26 @@ +{ + "entryFile": "src/app.ts", + "noImplicitAdditionalProperties": "ignore", + "controllerPathGlobs": ["src/service/controllers/**/*Controller.ts"], + "spec": { + "outputDirectory": "dist", + "specVersion": 3, + "securityDefinitions": { + "jwt": { + "type": "http", + "scheme": "bearer", + "bearerFormat": "JWT" + } + }, + "info": { + "title": "Git Proxy API", + "version": "1.0.0" + } + }, + "routes": { + "routesDir": "src/service", + "routesFileName": "generatedRoutes.ts", + "authenticationModule": "./src/service/authentication", + "middleware": "express" + } +}