diff --git a/.gitea/workflows/sonarqube.yml b/.gitea/workflows/sonarqube.yml index 78b86c2..d2c7310 100755 --- a/.gitea/workflows/sonarqube.yml +++ b/.gitea/workflows/sonarqube.yml @@ -227,6 +227,24 @@ jobs: if: always() run: node scripts/summarize-playwright.js frontend/playwright-results.json + # The measures, printed into the log because that is the only place this + # project can read them. SonarQube 9.9 Community has no Bearer auth, so the + # official MCP cannot connect, and the host is a CI secret — so security + # hotspots, duplication, debt and coverage lived solely on a dashboard, and + # "reduce the debt" was an instruction nobody could act on without opening a + # browser. The scanner masks the URL and token; the measures are not secret. + # + # Deliberately not guarded with continue-on-error. The script exits 0 on + # every path, so it cannot fail the job anyway, and guarding it would + # oblige it to appear in the gate below — which exists to fail the job, + # the opposite of what a report should do. See #261. + - name: Report SonarQube measures + if: always() + env: + SONAR_HOST_URL: ${{ secrets.SONAR_HOST_URL }} + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + run: node scripts/summarize-sonar.js + # Last, so a failing step still produces coverage, a scan and every # summary first. Without this step continue-on-error above would turn a # failing suite into a passing job, which is the one way this change could diff --git a/backend/package-lock.json b/backend/package-lock.json index 2668c3b..77e41bf 100755 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -26,8 +26,6 @@ }, "devDependencies": { "@eslint/js": "^9.39.5", - "@tinqerjs/pg-promise-adapter": "^0.0.27", - "@tinqerjs/tinqer": "^0.0.27", "@types/bcryptjs": "^2.4.6", "@types/cookie-parser": "^1.4.7", "@types/express": "^4.17.21", @@ -43,7 +41,6 @@ "eslint-plugin-sonarjs": "^4.2.0", "globals": "^17.11.0", "jest": "^29.7.0", - "pg-promise": "^12.7.1", "supertest": "^7.0.0", "ts-jest": "^29.2.4", "tsx": "^4.16.5", @@ -635,40 +632,6 @@ "dev": true, "license": "Apache-2.0" }, - "node_modules/@emnapi/core": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", - "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/wasi-threads": "1.2.1", - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/runtime": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", - "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/wasi-threads": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", - "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, "node_modules/@esbuild-kit/core-utils": { "version": "3.3.2", "resolved": "https://registry.npmjs.org/@esbuild-kit/core-utils/-/core-utils-3.3.2.tgz", @@ -2748,28 +2711,6 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, - "node_modules/@napi-rs/wasm-runtime": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.3.tgz", - "integrity": "sha512-UMduMbqO5s5zF2NkNacMT/yK5Y5QiKvWr2+50bzIIxFDwVJ2h49b+oyjaCGPhJxd2/gC2x39EHv/gHVuu36x2Q==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@tybys/wasm-util": "^0.10.3" - }, - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=23.5.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Brooooooklyn" - }, - "peerDependencies": { - "@emnapi/core": "^1.7.1 || ^2.0.0-alpha.4", - "@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.4" - } - }, "node_modules/@noble/hashes": { "version": "1.8.0", "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", @@ -2783,358 +2724,6 @@ "url": "https://paulmillr.com/funding/" } }, - "node_modules/@oxc-parser/binding-android-arm-eabi": { - "version": "0.134.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-android-arm-eabi/-/binding-android-arm-eabi-0.134.0.tgz", - "integrity": "sha512-N9Us7l/X9ZC3LA6eWSzPyduvBPXV1eRyDPwM6/UWpxwwXGsatb8131+d2L8UsmyHrixnKLHBd6UeH8wangV7fw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@oxc-parser/binding-android-arm64": { - "version": "0.134.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-android-arm64/-/binding-android-arm64-0.134.0.tgz", - "integrity": "sha512-Ic2oPZESeCaD4+9cKRqp1GMYsTO9Q3Yi9HdY2x9x75ozbnC20sybFHzeBklmaVD9PBzd8KbkmNN0gy+SVlm7zw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@oxc-parser/binding-darwin-arm64": { - "version": "0.134.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-darwin-arm64/-/binding-darwin-arm64-0.134.0.tgz", - "integrity": "sha512-1z9+nVJ1Awq4CPyHAthx5zOUrg5T1zc3dWt6juxwDcuejFGbdYzWJITkS1rv4DCdSTphDU3IW71MzyLV3BjRGQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@oxc-parser/binding-darwin-x64": { - "version": "0.134.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-darwin-x64/-/binding-darwin-x64-0.134.0.tgz", - "integrity": "sha512-MpofofaRZnxmYrY3lE8RTLHmE1KkX2q0elEeJ8sMcLbS8At76BjYSL6axssqhx29prGGDzIZ5lYFD+IXqaTzFA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@oxc-parser/binding-freebsd-x64": { - "version": "0.134.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-freebsd-x64/-/binding-freebsd-x64-0.134.0.tgz", - "integrity": "sha512-OqnPQY27vqWAbMnHfLcF8CVOUv2cCvdlTiqyK5qz5WCbH3XOLpYVQATv8S5UrR8bbEJCAqJLsI7W6cRFXAxCoA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@oxc-parser/binding-linux-arm-gnueabihf": { - "version": "0.134.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-0.134.0.tgz", - "integrity": "sha512-0eqWl+PWrcwGC3b8DCB58w3QINAuZfpX2ULTGpI01GUMBc6zDKSpttWxvqPIydxuQEkGQTQRAXLLvc+vTDZbQw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@oxc-parser/binding-linux-arm-musleabihf": { - "version": "0.134.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-0.134.0.tgz", - "integrity": "sha512-YToasuDmyzpyTC1ztrvkaSXz8tP+YUbx041M/4SGxaRGiyMzsKkQ869KPUTGA57A6aVsb1/DiPX8XZQQeSFkiw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@oxc-parser/binding-linux-arm64-gnu": { - "version": "0.134.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-0.134.0.tgz", - "integrity": "sha512-qMS7NLc2o8G7LLz53wisol+O7/YbMhtaGVhlfsTVLnrraf9kLFgzpiGjtQALLbdxX8uhx0Zmd4l+vY3s1/K4Gg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@oxc-parser/binding-linux-arm64-musl": { - "version": "0.134.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm64-musl/-/binding-linux-arm64-musl-0.134.0.tgz", - "integrity": "sha512-jUEsnxPXhrCYxswQLYvUOxZEE5UWFMkK5kBnrcPMj7TONz1pD0yKgPmOQCAx2LPoysJ/v2Sjg4RBUVoO2VXoZQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@oxc-parser/binding-linux-ppc64-gnu": { - "version": "0.134.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-0.134.0.tgz", - "integrity": "sha512-FU5xMUsXnMuWKVCGo43c6SJsnqHcsioqm32PHdDY39cIRJa/AZbo7RMYW0W5gYcNZqb8EMhELkMDwbJOFbUhtg==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@oxc-parser/binding-linux-riscv64-gnu": { - "version": "0.134.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-0.134.0.tgz", - "integrity": "sha512-tPc7OAhslHVmNebho1RSEGL//7i7Nm39gbQ+gYreBYwzvDVqNwdQH3S13ccM+R1TpimQ+3bIyFMfnilJIGRtjQ==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@oxc-parser/binding-linux-riscv64-musl": { - "version": "0.134.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-0.134.0.tgz", - "integrity": "sha512-TtWEC4MUAHodX5a5kGsmK8g5K49V5ewWfwGrXdbw+gXWLXWXuhi+ectNELmOvYIwaqPSTAry1HNS/hfXssaPHQ==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@oxc-parser/binding-linux-s390x-gnu": { - "version": "0.134.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-0.134.0.tgz", - "integrity": "sha512-mF4uls8TA8SPXSDLpclJ6z9S+vaeUnNp95iUEfqwv9oVJUAE7B2j4crOj8UvByRXpbO5N+aAlJadQEyFlnXU6g==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@oxc-parser/binding-linux-x64-gnu": { - "version": "0.134.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-x64-gnu/-/binding-linux-x64-gnu-0.134.0.tgz", - "integrity": "sha512-hieAQplyJeCvzqdSAxpOOGvVCBVXo/ioIBxioHfsUrQu93Et7Hy52cCG/GHEnjImVyeqEIViyyjuB0nKghxz/w==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@oxc-parser/binding-linux-x64-musl": { - "version": "0.134.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-x64-musl/-/binding-linux-x64-musl-0.134.0.tgz", - "integrity": "sha512-OQnJAK4sFuNSLgsh2s/K+14a3kwbmqf2yh1JiADU9XSfDuRooZYbAxmmBVPiyQ97+jBIIA1x2oPZeYNto3Ioow==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@oxc-parser/binding-openharmony-arm64": { - "version": "0.134.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-openharmony-arm64/-/binding-openharmony-arm64-0.134.0.tgz", - "integrity": "sha512-RYLooe6g31q/PqNFN0NjR80IK/ARGsfLasAXL42LXonL+5Cyy9Or76rjBKQLAjERikJwbRU/sYW9Q5Tnpt1A4Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@oxc-parser/binding-wasm32-wasi": { - "version": "0.134.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-wasm32-wasi/-/binding-wasm32-wasi-0.134.0.tgz", - "integrity": "sha512-h8bmHyvc0PxA811prUjfVpJlQAurOOiRbohY4QNGjCEz+L72G9CmWl28OOz3mevrYlURgUsprNuH8hDJXh1VOw==", - "cpu": [ - "wasm32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/core": "1.10.0", - "@emnapi/runtime": "1.10.0", - "@napi-rs/wasm-runtime": "^1.1.4" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@oxc-parser/binding-win32-arm64-msvc": { - "version": "0.134.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-0.134.0.tgz", - "integrity": "sha512-EPTfanpBMLNnSAWCDYpbJp1stmsf5x6hMsAwymf2J8ylRupIvO6FtKmHBMdc/wt43y08iz6ILlzFGIXe32/Kbw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@oxc-parser/binding-win32-ia32-msvc": { - "version": "0.134.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-0.134.0.tgz", - "integrity": "sha512-yAwetF+fTr9lTMtmqvkkWiiMXvH/yjMxGzUDG2rTL/LV1lL37eZ2enUGIlIo0gwhBIKWgabDEidtil+kiqNpgQ==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@oxc-parser/binding-win32-x64-msvc": { - "version": "0.134.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-win32-x64-msvc/-/binding-win32-x64-msvc-0.134.0.tgz", - "integrity": "sha512-4VY39G5tuGlBYiH13XbWqfLcwKygQEv0iyf8vtZ5NyLAGjI8M0MiYyLhj91GkWRznr+5VC0I+5R55HUDV/Rklw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@oxc-project/types": { - "version": "0.134.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.134.0.tgz", - "integrity": "sha512-T0xuRRKrQFmocH8y+jGfpmSkGcheaJExY9lEihmR1Gm2aH+75B8CzgU2rABRQSzzDxLjZ15Sc0bRVLj5lVeNXQ==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/Boshen" - } - }, "node_modules/@paralleldrive/cuid2": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.3.1.tgz", @@ -3177,37 +2766,6 @@ "resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz", "integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==" }, - "node_modules/@tinqerjs/pg-promise-adapter": { - "version": "0.0.27", - "resolved": "https://registry.npmjs.org/@tinqerjs/pg-promise-adapter/-/pg-promise-adapter-0.0.27.tgz", - "integrity": "sha512-GF2KDUWmavLDCrYRhisvwGR5cah5sSuo3loSunUVTITGvYivlrYl58zOwBKMkC0WfiVyrJ5nVAJ3E/Spt6NH9A==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "@tinqerjs/tinqer": "^0.0.27" - } - }, - "node_modules/@tinqerjs/tinqer": { - "version": "0.0.27", - "resolved": "https://registry.npmjs.org/@tinqerjs/tinqer/-/tinqer-0.0.27.tgz", - "integrity": "sha512-Rf9zNwstFD1mb8BO6RkRatPbx0rJ0VXHrS9pk8doeVv6sCTgo6wyFR/8hQPYe1LTblJUV3SFQNAfJpvsVbNgrQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "oxc-parser": "0.134.0" - } - }, - "node_modules/@tybys/wasm-util": { - "version": "0.10.3", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", - "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -4103,16 +3661,6 @@ "dev": true, "license": "MIT" }, - "node_modules/assert-options": { - "version": "0.8.3", - "resolved": "https://registry.npmjs.org/assert-options/-/assert-options-0.8.3.tgz", - "integrity": "sha512-s6v4HnA+vYSGO4eZX+F+I3gvF74wPk+m6Z1Q3w1Dsg4Pnv/R24vhKAasoMVZGvDpOOfTg1Qz4ptZnEbuy95XsQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", @@ -8279,44 +7827,6 @@ "node": ">= 0.8.0" } }, - "node_modules/oxc-parser": { - "version": "0.134.0", - "resolved": "https://registry.npmjs.org/oxc-parser/-/oxc-parser-0.134.0.tgz", - "integrity": "sha512-Hs8fRG6A94BzMrMkGOtrUS7JQjmslfF+IvIXslf3QURzK3ud0QmFJRiYZjTe4TzAQnTfvlk4AwZnqIbrUjiE4w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@oxc-project/types": "^0.134.0" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - }, - "funding": { - "url": "https://github.com/sponsors/Boshen" - }, - "optionalDependencies": { - "@oxc-parser/binding-android-arm-eabi": "0.134.0", - "@oxc-parser/binding-android-arm64": "0.134.0", - "@oxc-parser/binding-darwin-arm64": "0.134.0", - "@oxc-parser/binding-darwin-x64": "0.134.0", - "@oxc-parser/binding-freebsd-x64": "0.134.0", - "@oxc-parser/binding-linux-arm-gnueabihf": "0.134.0", - "@oxc-parser/binding-linux-arm-musleabihf": "0.134.0", - "@oxc-parser/binding-linux-arm64-gnu": "0.134.0", - "@oxc-parser/binding-linux-arm64-musl": "0.134.0", - "@oxc-parser/binding-linux-ppc64-gnu": "0.134.0", - "@oxc-parser/binding-linux-riscv64-gnu": "0.134.0", - "@oxc-parser/binding-linux-riscv64-musl": "0.134.0", - "@oxc-parser/binding-linux-s390x-gnu": "0.134.0", - "@oxc-parser/binding-linux-x64-gnu": "0.134.0", - "@oxc-parser/binding-linux-x64-musl": "0.134.0", - "@oxc-parser/binding-openharmony-arm64": "0.134.0", - "@oxc-parser/binding-wasm32-wasi": "0.134.0", - "@oxc-parser/binding-win32-arm64-msvc": "0.134.0", - "@oxc-parser/binding-win32-ia32-msvc": "0.134.0", - "@oxc-parser/binding-win32-x64-msvc": "0.134.0" - } - }, "node_modules/p-limit": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", @@ -8523,17 +8033,6 @@ "integrity": "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==", "license": "MIT" }, - "node_modules/pg-cursor": { - "version": "2.22.0", - "resolved": "https://registry.npmjs.org/pg-cursor/-/pg-cursor-2.22.0.tgz", - "integrity": "sha512-knzXLKqarTjOvb3qDSW0JiGsazmxwEKXrqHfWRte7XUsOYccQRafn3BLnQobWwInkzFJSyOej8y8cQRh2z3kGw==", - "dev": true, - "license": "MIT", - "peer": true, - "peerDependencies": { - "pg": "^8" - } - }, "node_modules/pg-int8": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", @@ -8543,16 +8042,6 @@ "node": ">=4.0.0" } }, - "node_modules/pg-minify": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/pg-minify/-/pg-minify-1.8.0.tgz", - "integrity": "sha512-jO/oJOununpx8DzKgvSsWm61P8JjwXlaxSlbbfTBo1nvSWoo/+I6qZYaSN96jm/KDwa5d+JMQwPGgcP6HXDRow==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=16.0.0" - } - }, "node_modules/pg-pool": { "version": "3.14.0", "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz", @@ -8562,45 +8051,12 @@ "pg": ">=8.0" } }, - "node_modules/pg-promise": { - "version": "12.7.1", - "resolved": "https://registry.npmjs.org/pg-promise/-/pg-promise-12.7.1.tgz", - "integrity": "sha512-tsemfuBRsHMGaFZKMpkmCAA9iLeHXuuTq+0DucJwJyftyXk7crlMFXq7SY0AOVelIrdSL8qwdl5omfj/R8JN6A==", - "dev": true, - "license": "MIT", - "dependencies": { - "assert-options": "0.8.3", - "pg": "8.23.0", - "pg-minify": "1.8.0", - "spex": "4.1.0" - }, - "engines": { - "node": ">=16.0" - }, - "peerDependencies": { - "pg-query-stream": "4.17.0" - } - }, "node_modules/pg-protocol": { "version": "1.16.0", "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.16.0.tgz", "integrity": "sha512-sILXutLVjCLjcDuOmvhX5e2Z4cS5qG/6Bu3VkpFwdf/633ElGLpEh9bgmuI5I4sqKqkifQiGyiCcx1HdtrK7tg==", "license": "MIT" }, - "node_modules/pg-query-stream": { - "version": "4.17.0", - "resolved": "https://registry.npmjs.org/pg-query-stream/-/pg-query-stream-4.17.0.tgz", - "integrity": "sha512-69bug2CH/AJi1BN8r08eZ8tAGdbZZVp9kB0FG9OC2VkJPHIyj7d3Nk9V8tmporO60wqeWvhn7qtQ5oNimwlFug==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "pg-cursor": "^2.22.0" - }, - "peerDependencies": { - "pg": "^8" - } - }, "node_modules/pg-types": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", @@ -9285,16 +8741,6 @@ "source-map": "^0.6.0" } }, - "node_modules/spex": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/spex/-/spex-4.1.0.tgz", - "integrity": "sha512-ktgNAQ1X9x1A3IMChM6XBDeVjhGPbLgPQ8aEzGOaUIhZTnLeJSBApvi3gXT789hee6h73N3jOeWkXDwoPbYT/A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18.0.0" - } - }, "node_modules/split2": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", diff --git a/backend/package.json b/backend/package.json index 04fbdd0..0fa2b68 100755 --- a/backend/package.json +++ b/backend/package.json @@ -45,8 +45,6 @@ }, "devDependencies": { "@eslint/js": "^9.39.5", - "@tinqerjs/pg-promise-adapter": "^0.0.27", - "@tinqerjs/tinqer": "^0.0.27", "@types/bcryptjs": "^2.4.6", "@types/cookie-parser": "^1.4.7", "@types/express": "^4.17.21", @@ -62,7 +60,6 @@ "eslint-plugin-sonarjs": "^4.2.0", "globals": "^17.11.0", "jest": "^29.7.0", - "pg-promise": "^12.7.1", "supertest": "^7.0.0", "ts-jest": "^29.2.4", "tsx": "^4.16.5", diff --git a/backend/src/backfillImageReencode.ts b/backend/src/backfillImageReencode.ts index 32bf5b4..3a38347 100644 --- a/backend/src/backfillImageReencode.ts +++ b/backend/src/backfillImageReencode.ts @@ -43,19 +43,11 @@ import { promises as fs } from 'fs'; import path from 'path'; import { pool } from './db'; import { needsProcessing, reencodeInPlace } from './imageProcessing'; +import { typeForExtension } from './uploadTypes'; const UPLOADS_DIR = process.env.UPLOADS_DIR || '/app/uploads'; const APPLY = process.argv.includes('--apply'); -// The stored extension is the file's real type — uploadTypes.ts derives it from -// the validated content type on the way in, so it can be trusted on the way -// back out. Anything else is a file this application would refuse to serve. -const TYPE_FOR_EXTENSION: Readonly> = { - '.jpg': 'image/jpeg', - '.png': 'image/png', - '.webp': 'image/webp' -}; - interface Totals { seen: number; missing: number; @@ -78,7 +70,12 @@ async function handleRow(imagePath: string, totals: Totals): Promise { // basename only: image_path is '/uploads/', and the directory it is // served from is a server constant rather than part of the stored value. const filePath = path.join(UPLOADS_DIR, path.basename(imagePath)); - const mimetype = TYPE_FOR_EXTENSION[path.extname(filePath).toLowerCase()]; + // typeForExtension rather than a copy of its table. The stored extension is + // the file's real type — uploadTypes.ts derives it from the validated content + // type on the way in — and this rewrites stored images, so a private copy + // drifting from the real one would silently skip files it should re-encode. + // It lowercases its own input, so the call site does not. + const mimetype = typeForExtension(path.extname(filePath)); if (!mimetype) { console.warn(`[backfill] unrecognised extension, skipping: ${imagePath}`); diff --git a/backend/src/db-drizzle/itemFilters.drizzle.ts b/backend/src/db-drizzle/itemFilters.drizzle.ts index ac03283..0f4b7b5 100644 --- a/backend/src/db-drizzle/itemFilters.drizzle.ts +++ b/backend/src/db-drizzle/itemFilters.drizzle.ts @@ -5,7 +5,7 @@ // match with a count equality, and array parameters. If this cannot be said // cleanly, nothing else in the conversion matters. -import { SQL, and, eq, gte, lte, sql, inArray, exists } from 'drizzle-orm'; +import { SQL, and, gte, lte, sql, inArray, exists } from 'drizzle-orm'; import { items, itemTags, favorites, categories } from './schema'; export interface SpikeFilters { diff --git a/backend/src/db-drizzle/schema.ts b/backend/src/db-drizzle/schema.ts index 8bd9138..b769a0e 100644 --- a/backend/src/db-drizzle/schema.ts +++ b/backend/src/db-drizzle/schema.ts @@ -77,7 +77,7 @@ export const tags = pgTable("tags", { name: text().notNull(), color: text().notNull(), createdAt: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), -}, (table) => [ +}, () => [ uniqueIndex("tags_name_uniq").using("btree", sql`lower(name)`), ]); diff --git a/backend/src/db-tinqer/probe.ts b/backend/src/db-tinqer/probe.ts deleted file mode 100644 index 2a84318..0000000 --- a/backend/src/db-tinqer/probe.ts +++ /dev/null @@ -1,71 +0,0 @@ -// Spike (#216): what Tinqer can and cannot express, recorded as runnable cases. -// -// Every case is wrapped in a function rather than evaluated at module level. -// defineSelect() parses the lambda eagerly and THROWS on a shape it cannot -// handle, so a top-level call turns an unsupported query into an import-time -// crash. That is itself a finding worth keeping: the failure is a runtime throw -// at load, not a type error, so an unsupported query compiles cleanly and takes -// the process down when the module is first required. - -import { createSchema, defineSelect } from '@tinqerjs/tinqer'; -import { toSql } from '@tinqerjs/pg-promise-adapter'; - -interface Db { - items: { id: number; name: string; price_cents: number; status: string; category_id: number | null }; - categories: { id: number; parent_id: number | null }; -} - -const schema = createSchema(); - -export type Outcome = { label: string; ok: boolean; sql?: string; error?: string }; - -function attempt(label: string, build: () => unknown, params: Record): Outcome { - try { - const r = toSql(build() as never, params as never); - return { label, ok: true, sql: r.sql }; - } catch (e) { - return { label, ok: false, error: (e as Error).message.split('\n')[0] }; - } -} - -export function runCases(): Outcome[] { - return [ - // Supported. - attempt( - 'compound condition', - () => - defineSelect(schema, (q, p: { min: number; max: number }) => - q.from('items').where((i) => i.price_cents >= p.min && i.price_cents <= p.max).select((i) => ({ id: i.id })) - ), - { min: 1, max: 2 } - ), - attempt( - 'array membership', - () => - defineSelect(schema, (q, p: { statuses: string[] }) => - q.from('items').where((i) => p.statuses.includes(i.status)).select((i) => ({ id: i.id })) - ), - { statuses: ['available', 'sold'] } - ), - - // NOT supported — and these are the two shapes buildItemFilterSql needs. - attempt( - 'ternary (clause optional at run time)', - () => - defineSelect(schema, (q, p: { apply: boolean; min: number }) => - q.from('items').where((i) => (p.apply ? i.price_cents >= p.min : true)).select((i) => ({ id: i.id })) - ), - { apply: true, min: 1 } - ), - attempt( - 'block body with if (clause optional at run time)', - () => - defineSelect(schema, (q, p: { apply: boolean; min: number }) => { - let x = q.from('items'); - if (p.apply) x = x.where((i) => i.price_cents >= p.min); - return x.select((i) => ({ id: i.id })); - }), - { apply: true, min: 1 } - ) - ]; -} diff --git a/backend/src/middleware/customerAuth.ts b/backend/src/middleware/customerAuth.ts index 793a0f4..37ffe84 100755 --- a/backend/src/middleware/customerAuth.ts +++ b/backend/src/middleware/customerAuth.ts @@ -2,6 +2,11 @@ import { Request, Response, NextFunction } from 'express'; import { pool } from '../db'; declare global { + // A namespace is the only way to spell an Express type augmentation — the + // interface has to merge into the one Express declares, and Express declares + // it inside a namespace. There is no ES module form of this, so the rule is + // disabled here rather than worked around. + // eslint-disable-next-line @typescript-eslint/no-namespace namespace Express { interface Request { customerId?: number; diff --git a/backend/src/routes/shippingAddresses.ts b/backend/src/routes/shippingAddresses.ts index 4b20ffc..108b157 100644 --- a/backend/src/routes/shippingAddresses.ts +++ b/backend/src/routes/shippingAddresses.ts @@ -118,6 +118,10 @@ router.post('/:id/set-default', requireCustomer, asyncRoute(async (req: Request, res.json(rows[0]); } catch (err) { await client.query('ROLLBACK'); + // Logged like the two catch blocks above it in this file, which this one + // was simply missing. Without it a failed default-address change rolls + // back and returns 500 leaving nothing behind to say why. + console.error(err); res.status(500).json({ error: 'internal error' }); } finally { client.release(); diff --git a/frontend/src/filters.ts b/frontend/src/filters.ts index a928915..180dce1 100644 --- a/frontend/src/filters.ts +++ b/frontend/src/filters.ts @@ -56,8 +56,15 @@ function isPublicStatus(value: string): value is ItemStatus { return value === 'available' || value === 'reserved' || value === 'sold'; } +// Sorted with an explicit comparator rather than a bare .sort(). The default +// sorts by UTF-16 code unit, which is a perfectly good total order for the +// ASCII status values passed here — but the rule exists because that stops +// being true the moment a non-ASCII value appears, and a set comparison that +// silently depends on its inputs staying ASCII is not worth keeping. const sameSet = (a: readonly string[], b: readonly string[]) => - a.length === b.length && [...a].sort().join() === [...b].sort().join(); + a.length === b.length && + [...a].sort((x, y) => x.localeCompare(y)).join() === + [...b].sort((x, y) => x.localeCompare(y)).join(); // Which preset a status list corresponds to, for showing the control's current // position. Null means no preference, which each screen renders as its default. diff --git a/scripts/summarize-sonar.js b/scripts/summarize-sonar.js new file mode 100644 index 0000000..ac3faae --- /dev/null +++ b/scripts/summarize-sonar.js @@ -0,0 +1,196 @@ +#!/usr/bin/env node +// Prints the project's SonarQube measures into the job log. +// +// This exists because the numbers are otherwise unreachable. The server is +// SonarQube 9.9 Community: it has no Bearer auth, so the official MCP cannot +// connect, and the host URL is a CI secret, so there is no way to query it from +// a developer's machine either. The measures lived only on a dashboard, which +// made "reduce the debt" an instruction nobody could act on without a browser +// open beside them. See #261. +// +// The scanner masks SONAR_HOST_URL and SONAR_TOKEN in the log. Measures are not +// secret, so they print fine — only the URL they came from is redacted. +// +// Authentication is HTTP Basic with the token as the username and an empty +// password. That is the documented scheme for 9.9; the `Authorization: Bearer` +// form was added later and returns 401 here. +// +// Runs with `if: always()` and deliberately WITHOUT `continue-on-error`. A +// cleanup report that can break a build is worse than no report, so instead of +// being guarded this script simply never fails: every path exits 0 and problems +// are described rather than thrown. Guarding it would also oblige it to be named +// in the workflow's final gate — workflowGate.test.ts asserts that pairing — and +// that gate exists to fail the job, which is the opposite of what is wanted for +// a report. Same shape as the summarize-jest.js steps beside it. + +const https = require('https'); +const http = require('http'); +const fs = require('fs'); +const path = require('path'); + +const HOST = process.env.SONAR_HOST_URL; +const TOKEN = process.env.SONAR_TOKEN; +const PROJECT = process.argv[2] || 'redefined-designs'; + +// Ordered so the four things the cleanup issue asks about come first, rather +// than alphabetically. Each row is [metric key, label, formatter]. +const METRICS = [ + ['security_hotspots', 'Security hotspots', (v) => v], + ['vulnerabilities', 'Vulnerabilities', (v) => v], + ['bugs', 'Bugs', (v) => v], + ['duplicated_lines_density', 'Duplicated lines', (v) => `${v}%`], + ['duplicated_blocks', 'Duplicated blocks', (v) => v], + ['sqale_index', 'Technical debt', formatDebt], + ['code_smells', 'Code smells', (v) => v], + ['coverage', 'Coverage', (v) => `${v}%`], + ['line_coverage', 'Line coverage', (v) => `${v}%`], + ['branch_coverage', 'Branch coverage', (v) => `${v}%`], + ['uncovered_lines', 'Uncovered lines', (v) => v], + ['ncloc', 'Lines of code', (v) => v], + ['new_coverage', 'Coverage on new code', (v) => `${v}%`], + ['new_duplicated_lines_density', 'Duplication on new code', (v) => `${v}%`], + ['new_code_smells', 'Code smells on new code', (v) => v] +]; + +/** sqale_index is minutes. Days here are Sonar's 8-hour working days. */ +function formatDebt(minutes) { + const total = Number(minutes); + if (!Number.isFinite(total)) return String(minutes); + const days = Math.floor(total / (8 * 60)); + const hours = Math.floor((total % (8 * 60)) / 60); + const mins = total % 60; + return `${total} min (${days}d ${hours}h ${mins}m)`; +} + +function get(url) { + return new Promise((resolve) => { + const client = url.startsWith('https:') ? https : http; + const request = client.get( + url, + // The empty password is deliberate: 9.9 expects `token:` rather than a + // password, and omitting the colon sends the token as a username with no + // password field at all, which it rejects. + { auth: `${TOKEN}:`, timeout: 30000 }, + (res) => { + let body = ''; + res.on('data', (chunk) => (body += chunk)); + res.on('end', () => resolve({ status: res.statusCode, body })); + } + ); + request.on('timeout', () => { + request.destroy(); + resolve({ status: 0, body: 'timed out after 30s' }); + }); + request.on('error', (err) => resolve({ status: 0, body: err.message })); + }); +} + +function parse(body) { + try { + return JSON.parse(body); + } catch { + return null; + } +} + +/** + * Waits for the server to finish processing this run's analysis. + * + * The workflow does not set `sonar.qualitygate.wait`, so the scan step returns + * as soon as the report is uploaded and the server computes measures in the + * background. Querying immediately therefore returns the *previous* analysis, + * which would be indistinguishable from the current one and quietly wrong — the + * worst kind of number to publish into a cleanup report. + * + * The scanner writes the task id to .scannerwork/report-task.txt. Polling it + * costs a few seconds and makes the difference between "these are this commit's + * numbers" and "these are probably this commit's numbers". + */ +async function waitForAnalysis(base) { + const reportPath = path.join(process.cwd(), '.scannerwork', 'report-task.txt'); + if (!fs.existsSync(reportPath)) { + return 'no report-task.txt — reporting the last completed analysis, which may predate this commit'; + } + + const taskId = /^ceTaskId=(.+)$/m.exec(fs.readFileSync(reportPath, 'utf8'))?.[1]?.trim(); + if (!taskId) { + return 'no ceTaskId in report-task.txt — reporting the last completed analysis'; + } + + // Twenty attempts at three seconds. A minute is far longer than this project + // takes to process and short enough not to stretch the job noticeably. + for (let attempt = 0; attempt < 20; attempt++) { + const res = await get(`${base}/api/ce/task?id=${encodeURIComponent(taskId)}`); + const status = parse(res.body)?.task?.status; + if (status === 'SUCCESS') return null; + if (status === 'FAILED' || status === 'CANCELED') { + return `analysis ${status} on the server — reporting the last completed analysis instead`; + } + await new Promise((resolve) => setTimeout(resolve, 3000)); + } + return 'analysis still processing after 60s — reporting the last completed analysis'; +} + +async function main() { + if (!HOST || !TOKEN) { + // Not an error. A fork or a run without the secrets configured reaches this + // legitimately, and saying so is more useful than a stack trace. + console.log('SonarQube measures: SONAR_HOST_URL or SONAR_TOKEN not set, skipping.'); + return; + } + + const base = HOST.replace(/\/+$/, ''); + + const staleness = await waitForAnalysis(base); + + const keys = METRICS.map(([key]) => key).join(','); + const measuresUrl = `${base}/api/measures/component?component=${encodeURIComponent(PROJECT)}&metricKeys=${keys}`; + + const res = await get(measuresUrl); + if (res.status !== 200) { + console.log(`SonarQube measures: request failed (status ${res.status}). ${res.body.slice(0, 300)}`); + return; + } + + const json = parse(res.body); + const measures = json?.component?.measures; + if (!Array.isArray(measures)) { + console.log('SonarQube measures: unexpected response shape.'); + console.log(res.body.slice(0, 300)); + return; + } + + // Keyed by metric so the display order above is what prints, rather than + // whatever order the API happened to return. + const byKey = new Map(measures.map((m) => [m.metric, m])); + + const lines = ['', '=== SonarQube measures ===', '']; + // Said plainly rather than omitted. A reader acting on these numbers needs to + // know whether they describe this commit. + if (staleness) lines.push(`NOTE: ${staleness}`, ''); + for (const [key, label, format] of METRICS) { + const measure = byKey.get(key); + // A metric can be legitimately absent — new-code metrics do not exist until + // a second analysis, and branch_coverage is missing when nothing branches. + if (!measure) { + lines.push(`${label.padEnd(26)} —`); + continue; + } + const value = measure.period?.value ?? measure.value; + lines.push(`${label.padEnd(26)} ${format(value)}`); + } + + const gate = await get( + `${base}/api/qualitygates/project_status?projectKey=${encodeURIComponent(PROJECT)}` + ); + const gateStatus = parse(gate.body)?.projectStatus?.status; + lines.push('', `Quality gate ${gateStatus ?? 'unknown'}`, ''); + + console.log(lines.join('\n')); +} + +main().catch((err) => { + // Reported rather than thrown, for the reason in the header: this step must + // not be able to fail the job. + console.log(`SonarQube measures: ${err instanceof Error ? err.message : String(err)}`); +});