diff --git a/.env.docker b/.env.docker index f7433aa..16291b2 100644 --- a/.env.docker +++ b/.env.docker @@ -32,8 +32,7 @@ NODE_ENV=production HOST=0.0.0.0 PORT=4000 -JWT_SECRET=dev-super-secret-jwt-key-change-in-production-32chars -JWT_EXPIRES_IN=7d +SESSION_ABSOLUTE_TIMEOUT_HOURS=168 # Required in production. Generate once, store securely, and keep it stable: # openssl rand -hex 32 diff --git a/.env.example b/.env.example index eff2a38..fd129f4 100644 --- a/.env.example +++ b/.env.example @@ -29,9 +29,9 @@ NODE_ENV=development # Database (PostgreSQL) DATABASE_URL="postgresql://doktainer:doktainerdb@localhost:5432/doktainer?schema=public" -# JWT settings -JWT_SECRET=dev-super-secret-jwt-key-change-in-production-32chars -JWT_EXPIRES_IN=7d +# Absolute lifetime for database-backed login sessions. User settings control +# the shorter sliding inactivity timeout. +SESSION_ABSOLUTE_TIMEOUT_HOURS=168 # /* -------------------------- Reverse proxy / rate limiting ------------------------- */ diff --git a/package-lock.json b/package-lock.json index 9a1b12a..28d173d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,8 +10,8 @@ "license": "MIT", "dependencies": { "@aws-sdk/client-s3": "^3.1031.0", + "@fastify/cookie": "^11.1.2", "@fastify/cors": "^11.2.0", - "@fastify/jwt": "^10.0.0", "@fastify/multipart": "^10.0.0", "@fastify/rate-limit": "^10.3.0", "@fastify/websocket": "^11.2.0", @@ -35,7 +35,7 @@ "fastify": "^5.3.2", "js-yaml": "^4.1.1", "lucide-react": "^1.8.0", - "next": "^16.2.6", + "next": "16.3.4", "node-ssh": "^13.2.0", "node-xlsx": "^0.24.0", "nodemailer": "^8.0.5", @@ -61,7 +61,7 @@ "@types/react-dom": "^19", "concurrently": "^9.1.2", "eslint": "^9", - "eslint-config-next": "16.2.3", + "eslint-config-next": "16.3.4", "pino-pretty": "^13.1.3", "prisma": "6.19.3", "tailwindcss": "^4", @@ -580,13 +580,13 @@ } }, "node_modules/@babel/code-frame": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", - "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-validator-identifier": "^7.28.5", + "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" }, @@ -595,9 +595,9 @@ } }, "node_modules/@babel/compat-data": { - "version": "7.29.3", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.3.tgz", - "integrity": "sha512-LIVqM46zQWZhj17qA8wb4nW/ixr2y1Nw+r1etiAWgRM6U1IqP+LNhL1yg440jYZR72jCWcWbLWzIosH+uP1fqg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", "dev": true, "license": "MIT", "engines": { @@ -605,21 +605,21 @@ } }, "node_modules/@babel/core": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", - "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.29.0", - "@babel/generator": "^7.29.0", - "@babel/helper-compilation-targets": "^7.28.6", - "@babel/helper-module-transforms": "^7.28.6", - "@babel/helpers": "^7.28.6", - "@babel/parser": "^7.29.0", - "@babel/template": "^7.28.6", - "@babel/traverse": "^7.29.0", - "@babel/types": "^7.29.0", + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", @@ -636,14 +636,14 @@ } }, "node_modules/@babel/generator": { - "version": "7.29.1", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", - "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/parser": "^7.29.0", - "@babel/types": "^7.29.0", + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" @@ -653,14 +653,14 @@ } }, "node_modules/@babel/helper-compilation-targets": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", - "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", "dev": true, "license": "MIT", "dependencies": { - "@babel/compat-data": "^7.28.6", - "@babel/helper-validator-option": "^7.27.1", + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" @@ -670,9 +670,9 @@ } }, "node_modules/@babel/helper-globals": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", - "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", "dev": true, "license": "MIT", "engines": { @@ -680,29 +680,29 @@ } }, "node_modules/@babel/helper-module-imports": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", - "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", "dev": true, "license": "MIT", "dependencies": { - "@babel/traverse": "^7.28.6", - "@babel/types": "^7.28.6" + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-module-transforms": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", - "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-module-imports": "^7.28.6", - "@babel/helper-validator-identifier": "^7.28.5", - "@babel/traverse": "^7.28.6" + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -712,9 +712,9 @@ } }, "node_modules/@babel/helper-string-parser": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", - "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", "dev": true, "license": "MIT", "engines": { @@ -722,9 +722,9 @@ } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", - "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", "dev": true, "license": "MIT", "engines": { @@ -732,9 +732,9 @@ } }, "node_modules/@babel/helper-validator-option": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", - "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", "dev": true, "license": "MIT", "engines": { @@ -742,27 +742,27 @@ } }, "node_modules/@babel/helpers": { - "version": "7.29.2", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", - "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/template": "^7.28.6", - "@babel/types": "^7.29.0" + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/parser": { - "version": "7.29.3", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.3.tgz", - "integrity": "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.29.0" + "@babel/types": "^7.29.8" }, "bin": { "parser": "bin/babel-parser.js" @@ -772,33 +772,33 @@ } }, "node_modules/@babel/template": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", - "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.28.6", - "@babel/parser": "^7.28.6", - "@babel/types": "^7.28.6" + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/traverse": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", - "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz", + "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.29.0", - "@babel/generator": "^7.29.0", - "@babel/helper-globals": "^7.28.0", - "@babel/parser": "^7.29.0", - "@babel/template": "^7.28.6", - "@babel/types": "^7.29.0", + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.8", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.8", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8", "debug": "^4.3.1" }, "engines": { @@ -806,14 +806,14 @@ } }, "node_modules/@babel/types": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", - "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.28.5" + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -841,6 +841,7 @@ "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": { @@ -859,9 +860,9 @@ } }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz", - "integrity": "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", "cpu": [ "ppc64" ], @@ -876,9 +877,9 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.0.tgz", - "integrity": "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", "cpu": [ "arm" ], @@ -893,9 +894,9 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz", - "integrity": "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", "cpu": [ "arm64" ], @@ -910,9 +911,9 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.0.tgz", - "integrity": "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", "cpu": [ "x64" ], @@ -927,9 +928,9 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz", - "integrity": "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", "cpu": [ "arm64" ], @@ -944,9 +945,9 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz", - "integrity": "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", "cpu": [ "x64" ], @@ -961,9 +962,9 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz", - "integrity": "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", "cpu": [ "arm64" ], @@ -978,9 +979,9 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz", - "integrity": "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", "cpu": [ "x64" ], @@ -995,9 +996,9 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz", - "integrity": "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", "cpu": [ "arm" ], @@ -1012,9 +1013,9 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz", - "integrity": "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", "cpu": [ "arm64" ], @@ -1029,9 +1030,9 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz", - "integrity": "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", "cpu": [ "ia32" ], @@ -1046,9 +1047,9 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz", - "integrity": "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", "cpu": [ "loong64" ], @@ -1063,9 +1064,9 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz", - "integrity": "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", "cpu": [ "mips64el" ], @@ -1080,9 +1081,9 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz", - "integrity": "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", "cpu": [ "ppc64" ], @@ -1097,9 +1098,9 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz", - "integrity": "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", "cpu": [ "riscv64" ], @@ -1114,9 +1115,9 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz", - "integrity": "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", "cpu": [ "s390x" ], @@ -1131,9 +1132,9 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz", - "integrity": "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", "cpu": [ "x64" ], @@ -1148,9 +1149,9 @@ } }, "node_modules/@esbuild/netbsd-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz", - "integrity": "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", "cpu": [ "arm64" ], @@ -1165,9 +1166,9 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz", - "integrity": "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", "cpu": [ "x64" ], @@ -1182,9 +1183,9 @@ } }, "node_modules/@esbuild/openbsd-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz", - "integrity": "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", "cpu": [ "arm64" ], @@ -1199,9 +1200,9 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz", - "integrity": "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", "cpu": [ "x64" ], @@ -1216,9 +1217,9 @@ } }, "node_modules/@esbuild/openharmony-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz", - "integrity": "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", "cpu": [ "arm64" ], @@ -1233,9 +1234,9 @@ } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz", - "integrity": "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", "cpu": [ "x64" ], @@ -1250,9 +1251,9 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz", - "integrity": "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", "cpu": [ "arm64" ], @@ -1267,9 +1268,9 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz", - "integrity": "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", "cpu": [ "ia32" ], @@ -1284,9 +1285,9 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz", - "integrity": "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", "cpu": [ "x64" ], @@ -1493,6 +1494,55 @@ "integrity": "sha512-m9FVDXU3GT2ITSe0UaMA5rU3QkfC/UXtCU8y0gSN/GugTqtVldOBWIB5V6V3sbmenVZUIpU6f+mPEO2+m5iTaA==", "license": "MIT" }, + "node_modules/@fastify/cookie": { + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/@fastify/cookie/-/cookie-11.1.2.tgz", + "integrity": "sha512-Dtrpk/YOGUsbRMvP/8ZqPpwnMRv0qSqodFdoQ2B589Obc7jw4s4Qla+cV72Bsm7WsZJnqlYFX/i7uSBq0xzg6g==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "cookie": "^2.0.0", + "fastify-plugin": "^6.0.0" + } + }, + "node_modules/@fastify/cookie/node_modules/cookie": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-2.0.1.tgz", + "integrity": "sha512-yuToqVvRrj6pfDXREyQAAv8SkAEk/8GS3jQRTiUMm66TVtBYmqQeoEjL2Lmq8Rpo6271vH76InTChTitEAm65w==", + "license": "MIT", + "engines": { + "node": ">=22" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@fastify/cookie/node_modules/fastify-plugin": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/fastify-plugin/-/fastify-plugin-6.0.0.tgz", + "integrity": "sha512-fZOty7z3O7vOliF6d8bHE3wiEh1KcNnKEQensSgTk9C1DvN6nRLS++XVd86v33Hw/8u9Un8A1zDrQ8ujcQDHEg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, "node_modules/@fastify/cors": { "version": "11.2.0", "resolved": "https://registry.npmjs.org/@fastify/cors/-/cors-11.2.0.tgz", @@ -1580,29 +1630,6 @@ ], "license": "MIT" }, - "node_modules/@fastify/jwt": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/@fastify/jwt/-/jwt-10.1.0.tgz", - "integrity": "sha512-U1y8ZbxoH1Pjon3euzPJmbCkuYBM+hrQlFWLQWvKmJGCNT6mVsAolnVJdEWfXeQOKpgmuRVCIsPll5RLZxj10A==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "MIT", - "dependencies": { - "@fastify/error": "^4.2.0", - "@lukeed/ms": "^2.0.2", - "fast-jwt": "^6.2.0", - "fastify-plugin": "^5.0.1", - "steed": "^1.1.3" - } - }, "node_modules/@fastify/merge-json-schemas": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/@fastify/merge-json-schemas/-/merge-json-schemas-0.2.1.tgz", @@ -1871,9 +1898,9 @@ } }, "node_modules/@img/sharp-darwin-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", - "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.4.tgz", + "integrity": "sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA==", "cpu": [ "arm64" ], @@ -1883,19 +1910,19 @@ "darwin" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-darwin-arm64": "1.2.4" + "@img/sharp-libvips-darwin-arm64": "1.3.3" } }, "node_modules/@img/sharp-darwin-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", - "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.4.tgz", + "integrity": "sha512-hWniXY3bG5qKpkKrAwPe4y+VTPmf086YQAnkxWh7uA1YrlRouWGa0M0Mxj3ZjnXFkv7/TD1bTy9lGUK26vRvWw==", "cpu": [ "x64" ], @@ -1905,19 +1932,38 @@ "darwin" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-darwin-x64": "1.2.4" + "@img/sharp-libvips-darwin-x64": "1.3.3" + } + }, + "node_modules/@img/sharp-freebsd-wasm32": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.4.tgz", + "integrity": "sha512-lIsKw/BU+kjB4eZjxrYrZmwOJYi3Ajrv66iAlBmUPyKc3HpnloevB1g3wxGD9P/5BbQ1brBGl65VRRrCvQDEqA==", + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "dependencies": { + "@img/sharp-wasm32": "0.35.4" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" } }, "node_modules/@img/sharp-libvips-darwin-arm64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", - "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.3.tgz", + "integrity": "sha512-suTBPTDGrI9WodccaDdwZItTSaBYASlBk1NSfElSHrUfzu3szG6lvIF58+WiFvnfzuK8ZBFS5zE00PxqxnRiPg==", "cpu": [ "arm64" ], @@ -1931,9 +1977,9 @@ } }, "node_modules/@img/sharp-libvips-darwin-x64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", - "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.3.tgz", + "integrity": "sha512-FVJZ5mITMobmXIz/hPDTw0EintTW5H3WfrxwLqEqjiIihlu+hVRyGrFQ60xl0Lxn7Bt3zdpevPaQi0HEzqz9fw==", "cpu": [ "x64" ], @@ -1947,9 +1993,9 @@ } }, "node_modules/@img/sharp-libvips-linux-arm": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", - "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.3.tgz", + "integrity": "sha512-3rbU4vqXXc3hY/OiXdl52xZvT0F1yEngWfvqudtPJg/KkyiaQw2DRsFrNzpmLvfavbwOq3qXn36GP8obHRULQA==", "cpu": [ "arm" ], @@ -1966,9 +2012,9 @@ } }, "node_modules/@img/sharp-libvips-linux-arm64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", - "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.3.tgz", + "integrity": "sha512-0DaL0A6Xu6sQSQFwe4iVCrKWU2cCTItnRsYsCdxAMm9NF6twAA9BKnoqy4hqz4+azQ0JHuA26qiUKsf1XJ/v5A==", "cpu": [ "arm64" ], @@ -1985,9 +2031,9 @@ } }, "node_modules/@img/sharp-libvips-linux-ppc64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", - "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.3.tgz", + "integrity": "sha512-cdn1OvUBwsXhbC0zSzJnNzf5MZ/mTrobawDvNXBTxe8VtqKAm0sRuEY2Evzovb/w9JMk4TvRxqt1mekSuJz64w==", "cpu": [ "ppc64" ], @@ -2004,9 +2050,9 @@ } }, "node_modules/@img/sharp-libvips-linux-riscv64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", - "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.3.tgz", + "integrity": "sha512-HjPVx7yKz+0lqdhDlTw1tt90wamBoxhiXpvl1XZpJLiHH4RCJ5yDTqH+VlYPv2fwFs89JFw4c1IexYOcQUi4IQ==", "cpu": [ "riscv64" ], @@ -2023,9 +2069,9 @@ } }, "node_modules/@img/sharp-libvips-linux-s390x": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", - "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.3.tgz", + "integrity": "sha512-neWLh+3yCNThxnfy3c4BbVBeGgt9aftno+XbT56iK28RgeDs3UOFWviLWlUu0bArYVYJaFDK+RRohbicUNCm8Q==", "cpu": [ "s390x" ], @@ -2042,9 +2088,9 @@ } }, "node_modules/@img/sharp-libvips-linux-x64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", - "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.3.tgz", + "integrity": "sha512-4vKmvAst9nrowcqquKFAyZJUDolUaIp8uRiN0mWFguJ1IplC9/pitXtlnnlU4aa/eJw3J7i67V+pwUL+wZGdsA==", "cpu": [ "x64" ], @@ -2061,9 +2107,9 @@ } }, "node_modules/@img/sharp-libvips-linuxmusl-arm64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", - "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.3.tgz", + "integrity": "sha512-Y9kQaLMuNoB0bPYOOdcZMaseNrFpPodIWWMrx+CZyydf2xn68j9WYc6sWWRrDwNkzCQjKYfc68L7jKjGlHMibw==", "cpu": [ "arm64" ], @@ -2080,9 +2126,9 @@ } }, "node_modules/@img/sharp-libvips-linuxmusl-x64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", - "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.3.tgz", + "integrity": "sha512-fj8Mv0HHfD1Rr+4I68+3agJynxDWtBFgicTbSOb9Bke6pIwzGcJ+RX/yHjmiEGFMCavY/dxvem7MyNaJF+wDiw==", "cpu": [ "x64" ], @@ -2099,9 +2145,9 @@ } }, "node_modules/@img/sharp-linux-arm": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", - "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.4.tgz", + "integrity": "sha512-7OAS8gI0EReKGVN2HssHlM6umJgxF5VI3xN0p9FA91p/YO+ou5hiNghLdZ5BEHztwaaK5+bLKRf8x/o2L2nk9A==", "cpu": [ "arm" ], @@ -2114,19 +2160,19 @@ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-arm": "1.2.4" + "@img/sharp-libvips-linux-arm": "1.3.3" } }, "node_modules/@img/sharp-linux-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", - "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.4.tgz", + "integrity": "sha512-De4jpEnAU8Hd5oT0j1G3uL4ZvTuipVMn7YC6vPaJhy6/7EwEae0SVAoBrUMYQbkLGDm85taVWwuPc1a44LTzCQ==", "cpu": [ "arm64" ], @@ -2139,19 +2185,19 @@ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-arm64": "1.2.4" + "@img/sharp-libvips-linux-arm64": "1.3.3" } }, "node_modules/@img/sharp-linux-ppc64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", - "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.4.tgz", + "integrity": "sha512-2oYZJeIl4kCcMGk4ouZVjnkCtFrpQFlNEtJ6GbxzhHQchwH0NH/qEb9ykmOl29dqwMq+JhFdZn+1ak2FKhI9fQ==", "cpu": [ "ppc64" ], @@ -2164,19 +2210,19 @@ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-ppc64": "1.2.4" + "@img/sharp-libvips-linux-ppc64": "1.3.3" } }, "node_modules/@img/sharp-linux-riscv64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", - "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.4.tgz", + "integrity": "sha512-cPbNChoRURAWdebDIHSenxRpgEdy7JkPydSnUxRm9VvKD7m0/xVaR/8Fzlu81pk5nHEvHH87UZUA7cTtwnbJSA==", "cpu": [ "riscv64" ], @@ -2189,19 +2235,19 @@ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-riscv64": "1.2.4" + "@img/sharp-libvips-linux-riscv64": "1.3.3" } }, "node_modules/@img/sharp-linux-s390x": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", - "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.4.tgz", + "integrity": "sha512-RY0JFY8Fd6RonCBtHz+DvadaPkXDSI1AUn6yWL9TipqkZ1vY8w8evqdgyDFnkm4/K1ve1TvZiaePP5oSd4+WVQ==", "cpu": [ "s390x" ], @@ -2214,19 +2260,19 @@ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-s390x": "1.2.4" + "@img/sharp-libvips-linux-s390x": "1.3.3" } }, "node_modules/@img/sharp-linux-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", - "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.4.tgz", + "integrity": "sha512-9qvvEAuk8k89TfWUoX2htWjbAMX8p+NxCppjpcg5k6xMsjhBQPTsoIh36h9Qde4WRuGpJeYnOjdosDn/cnv+OA==", "cpu": [ "x64" ], @@ -2239,19 +2285,19 @@ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-x64": "1.2.4" + "@img/sharp-libvips-linux-x64": "1.3.3" } }, "node_modules/@img/sharp-linuxmusl-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", - "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.4.tgz", + "integrity": "sha512-KB5jxpfWQTr0nc3xdHtWChdbifHrBGsd2SM62Eyxrl8afikm+f5qGBU75SJIZBT/S1MC8XyacdlXBMSWq6OURA==", "cpu": [ "arm64" ], @@ -2264,19 +2310,19 @@ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" + "@img/sharp-libvips-linuxmusl-arm64": "1.3.3" } }, "node_modules/@img/sharp-linuxmusl-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", - "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.4.tgz", + "integrity": "sha512-f+eZJZIQNEEd26RPSW+76chwOf1XtA2Y/O+5ocVyLliHkeih3e+jhLVBdNTd2rS3IbNXK8+ug93Vf5ZXtF5Lxg==", "cpu": [ "x64" ], @@ -2289,38 +2335,64 @@ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-x64": "1.2.4" + "@img/sharp-libvips-linuxmusl-x64": "1.3.3" } }, "node_modules/@img/sharp-wasm32": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", - "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", - "cpu": [ - "wasm32" - ], + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.4.tgz", + "integrity": "sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA==", "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", "optional": true, "dependencies": { - "@emnapi/runtime": "^1.7.0" + "@emnapi/runtime": "^1.11.3" }, "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-wasm32/node_modules/@emnapi/runtime": { + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", + "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@img/sharp-webcontainers-wasm32": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.4.tgz", + "integrity": "sha512-ESfNkywmCfPNyaZjxooddJQiQ+l/nTpGEOGthxiLnIHXC/CmcBixnfwUleX9mCz9ovrUUvKMap/pm8RYbzfwaA==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@img/sharp-wasm32": "0.35.4" + }, + "engines": { + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" } }, "node_modules/@img/sharp-win32-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", - "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.4.tgz", + "integrity": "sha512-iNdlBX9gLVvqe2I3uIJSIKTq6wckP/DYxZtcqxm09x5Gi24DnFBmPAWZmr60ZyYMG0xlzo6goG3670ar+RXvRw==", "cpu": [ "arm64" ], @@ -2330,16 +2402,16 @@ "win32" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" } }, "node_modules/@img/sharp-win32-ia32": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", - "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.4.tgz", + "integrity": "sha512-kqRsbaa5CS6KHlpxnN7WhE6vAAugXyZButpRdvDWetlv6Qv4N9WTcrWzF7tXfB9T7MsoadqdI8hmwLq6UlLvtw==", "cpu": [ "ia32" ], @@ -2349,16 +2421,16 @@ "win32" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": "^20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" } }, "node_modules/@img/sharp-win32-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", - "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.4.tgz", + "integrity": "sha512-XtmnYhBcrORsJ4XJngyzr/EWP0hRZLAZRFaApdKuviyqF78+ylxh2y06ZmtULAMOnObJ3ucpN0AcwSWnMowTRg==", "cpu": [ "x64" ], @@ -2368,7 +2440,7 @@ "win32" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" @@ -2463,25 +2535,26 @@ } }, "node_modules/@next/env": { - "version": "16.2.6", - "resolved": "https://registry.npmjs.org/@next/env/-/env-16.2.6.tgz", - "integrity": "sha512-gd8HoHN4ufj73WmR3JmVolrpJR47ILK6LouP5xElPglaVxir6e1a7VzvTvDWkOoPXT9rkkTzyCxBu4yeZfZwcw==", + "version": "16.3.4", + "resolved": "https://registry.npmjs.org/@next/env/-/env-16.3.4.tgz", + "integrity": "sha512-cjWZnUUa6jZq2kFaNe/ZyJdZonOZ/QoN0Zka2nz/FLOrfx14pQuM9c5RaSVkWMqgdt4ksgPAMWPyHSs/CyV48Q==", "license": "MIT" }, "node_modules/@next/eslint-plugin-next": { - "version": "16.2.3", - "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-16.2.3.tgz", - "integrity": "sha512-nE/b9mht28XJxjTwKs/yk7w4XTaU3t40UHVAky6cjiijdP/SEy3hGsnQMPxmXPTpC7W4/97okm6fngKnvCqVaA==", + "version": "16.3.4", + "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-16.3.4.tgz", + "integrity": "sha512-szW9y2Aumu4z88YXfTzcFsgUAg2k64uzbtcO5L9f1AKS4w/GUKJcbFllRflROVyNPgJtGOnvNxiyp3v6b+prIA==", "dev": true, "license": "MIT", "dependencies": { + "@eslint-community/eslint-utils": "4.9.1", "fast-glob": "3.3.1" } }, "node_modules/@next/swc-darwin-arm64": { - "version": "16.2.6", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.2.6.tgz", - "integrity": "sha512-ZJGkkcNfYgrrMkqOdZ7zoLa1TOy0qpcMfk/z4Mh/FKUz40gVO+HNQWqmLxf67Z5WB64DRp0dhEbyHfel+6sJUg==", + "version": "16.3.4", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.3.4.tgz", + "integrity": "sha512-iBr3I5LZNk5/bgl5//iTgD2tcym14MX0Xo7fD//u9dYAEgGzza1y9oywluPtf74YnOswVdH1908aK9xVz7zQTw==", "cpu": [ "arm64" ], @@ -2495,9 +2568,9 @@ } }, "node_modules/@next/swc-darwin-x64": { - "version": "16.2.6", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.2.6.tgz", - "integrity": "sha512-v/YLBHIY132Ced3puBJ7YJKw1lqsCrgcNo2aRJlCEyQrrCeRJlvGlnmxhPxNQI3KE3N1DN5r9TPNPvka3nq5RQ==", + "version": "16.3.4", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.3.4.tgz", + "integrity": "sha512-2dpiSyl2Jw/NrBPaU2MAKGSa+2MR82pJIn4Sm5Rjr+gxAeuh0z158Su3Z2O8zn7UNNq+ej4bToed6RcRN/Lydg==", "cpu": [ "x64" ], @@ -2511,9 +2584,9 @@ } }, "node_modules/@next/swc-linux-arm64-gnu": { - "version": "16.2.6", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.2.6.tgz", - "integrity": "sha512-RPOvqlYBbcQjkz9VQQDZ2T2bARIjXZV1KFlt+V2Mr6SW/e4I9fcKsaA0hdyf2FHoTlsV2xnBd5Y912rP/1Ce6w==", + "version": "16.3.4", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.3.4.tgz", + "integrity": "sha512-+t+U8HZT+fApePCS5h89CSH3datz29MkzyfCn+6fpsZBG/oiEOhINcb9rtkv6sdpToLGFn2e6146NzaKCXkqrA==", "cpu": [ "arm64" ], @@ -2530,9 +2603,9 @@ } }, "node_modules/@next/swc-linux-arm64-musl": { - "version": "16.2.6", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.2.6.tgz", - "integrity": "sha512-URUTu1+dMkxJsPFgm+OeEvq9wf5sujw0EvgYy80TDGHTSLTnIHeqb0Eu8A3sC95IRgjejQL+kC4mw+4yPxiAXA==", + "version": "16.3.4", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.3.4.tgz", + "integrity": "sha512-mx03GNs1ocQA5JQ4FxDMmIsNkdrZh8cuezKCrId28e5/gIPU/l7Kcy2+vmCCzdjnnmXJy+iOAu+7K0QppO6Urg==", "cpu": [ "arm64" ], @@ -2549,9 +2622,9 @@ } }, "node_modules/@next/swc-linux-x64-gnu": { - "version": "16.2.6", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.2.6.tgz", - "integrity": "sha512-DOj182mPV8G3UkrayLoREM5YEYI+Dk5wv7Ox9xl1fFibAELEsFD0lDPfHIeILlutMMfdyhlzYPELG3peuKaurw==", + "version": "16.3.4", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.3.4.tgz", + "integrity": "sha512-YIhGY6fSMfha52bnVxnzc9zaVBzJg+cqQTOD8tXIBSx4fuv0pVMxQTE0PaS59YhnMOiYiG09IMwxJAf/CFm/Dw==", "cpu": [ "x64" ], @@ -2568,9 +2641,9 @@ } }, "node_modules/@next/swc-linux-x64-musl": { - "version": "16.2.6", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.2.6.tgz", - "integrity": "sha512-HKQ5SP/V/ub73UvF7n/zeJlxk2kLmtL7Wzrg4WfmkjmNos5onJ2tKu7yZOPdL18A6Svfn3max29ym+ry7NkK4g==", + "version": "16.3.4", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.3.4.tgz", + "integrity": "sha512-+eaaX6axpDb0yF1GCpiERe6njplvdC+nks/fKfcHu3XPGRrald8P3/X7yv7QLdjA51knnxwl9pxdIJsg+w1L+Q==", "cpu": [ "x64" ], @@ -2587,9 +2660,9 @@ } }, "node_modules/@next/swc-win32-arm64-msvc": { - "version": "16.2.6", - "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.2.6.tgz", - "integrity": "sha512-LZXpTlPyS5v7HhSmnvsLGP3iIYgYOBnc8r8ArlT55sGHV89bR2HlDdBjWQ+PY6SJMmk8TuVGFuxalnP3k/0Dwg==", + "version": "16.3.4", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.3.4.tgz", + "integrity": "sha512-0jcXW7Xs/uzICrmgV3MhDYDeRy++1CqnpDIerlPIqYO4bhzB4WNbX/aRnQclustsAyTkFKB0z6rbcjmNg5tR8A==", "cpu": [ "arm64" ], @@ -2603,9 +2676,9 @@ } }, "node_modules/@next/swc-win32-x64-msvc": { - "version": "16.2.6", - "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.2.6.tgz", - "integrity": "sha512-F0+4i0h9J6C4eE3EAPWsoCk7UW/dbzOjyzxY0qnDUOYFu6FFmdZ6l97/XdV3/Nz3VYyO7UWjyEJUXkGqcoXfMA==", + "version": "16.3.4", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.3.4.tgz", + "integrity": "sha512-vvBzwu1pYQCp92maZCFCIw/XgOTMR5tur9GjakwIo2cmwRTMKajRZZDS9+e4KsUZWKu1E007WUeAFXRRjZeuzw==", "cpu": [ "x64" ], @@ -2876,12 +2949,6 @@ "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", "license": "BSD-3-Clause" }, - "node_modules/@protobufjs/inquire": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.2.tgz", - "integrity": "sha512-pa0vFRuws4wkvaXKK1uXZMAwAX4/t8ANaJo45iw/oQHNQ9q5xUzwgFmVJGXiga2BeN+zpX7Vf9vmsiIa2J+MUw==", - "license": "BSD-3-Clause" - }, "node_modules/@protobufjs/path": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", @@ -4321,9 +4388,9 @@ "license": "MIT" }, "node_modules/@swc/helpers": { - "version": "0.5.15", - "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", - "integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==", + "version": "0.5.23", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.23.tgz", + "integrity": "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==", "license": "Apache-2.0", "dependencies": { "tslib": "^2.8.0" @@ -5039,16 +5106,16 @@ } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { - "version": "5.0.6", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", - "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" }, "engines": { - "node": "18 || 20 || >=22" + "node": "20 || >=22" } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { @@ -5529,9 +5596,9 @@ } }, "node_modules/adm-zip": { - "version": "0.5.17", - "resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.5.17.tgz", - "integrity": "sha512-+Ut8d9LLqwEvHHJl1+PIHqoyDxFgVN847JTVM3Izi3xHDWPE4UtzzXysMZQs64DMcrJfBeS/uoEP4AD3HQHnQQ==", + "version": "0.5.18", + "resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.5.18.tgz", + "integrity": "sha512-ufJnssQGbxzLNS1Ho9bCtX4rQKCCvoVuDLHoJyc3F9dOGDB4BkWs2Ci0kv53lqocAEQ/Cbi+I2XCsNYGqVYqng==", "license": "MIT", "engines": { "node": ">=12.0" @@ -5814,18 +5881,6 @@ "safer-buffer": "~2.1.0" } }, - "node_modules/asn1.js": { - "version": "5.4.1", - "resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-5.4.1.tgz", - "integrity": "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==", - "license": "MIT", - "dependencies": { - "bn.js": "^4.0.0", - "inherits": "^2.0.1", - "minimalistic-assert": "^1.0.0", - "safer-buffer": "^2.1.0" - } - }, "node_modules/ast-types-flow": { "version": "0.0.8", "resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz", @@ -5936,9 +5991,9 @@ "license": "MIT" }, "node_modules/baseline-browser-mapping": { - "version": "2.10.32", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.32.tgz", - "integrity": "sha512-wbPvpyjJPC0zdfdKXxqEL3Ea+bOMD/87X4lftiJkkaBiuG6ALQy1SLmEd7BSmVCuwCQsBrCamgBoLyfFDD1EPg==", + "version": "2.11.22", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.22.tgz", + "integrity": "sha512-pWc4w51fBFd7mav43/zKRC+RI6f4yfzQoVlfvE8dECePyfkn1bzLp01Fj0QACcyCZyFhiEMyD2qScfKRWgWibA==", "license": "Apache-2.0", "bin": { "baseline-browser-mapping": "dist/cli.cjs" @@ -5976,12 +6031,6 @@ "readable-stream": "^3.4.0" } }, - "node_modules/bn.js": { - "version": "4.12.3", - "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.3.tgz", - "integrity": "sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==", - "license": "MIT" - }, "node_modules/bowser": { "version": "2.14.1", "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.14.1.tgz", @@ -5989,9 +6038,9 @@ "license": "MIT" }, "node_modules/brace-expansion": { - "version": "1.1.14", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", - "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", "dev": true, "license": "MIT", "dependencies": { @@ -6013,9 +6062,9 @@ } }, "node_modules/browserslist": { - "version": "4.28.2", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", - "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "version": "4.28.9", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.9.tgz", + "integrity": "sha512-EWazOblFYUvlGZcfGhPUPmYh3nikUxBVb+y9MJun5f3hBi812X+8MSQTujLBtgK3cf51fJWbWfOjyeO954d+Eg==", "dev": true, "funding": [ { @@ -6033,11 +6082,11 @@ ], "license": "MIT", "dependencies": { - "baseline-browser-mapping": "^2.10.12", - "caniuse-lite": "^1.0.30001782", - "electron-to-chromium": "^1.5.328", - "node-releases": "^2.0.36", - "update-browserslist-db": "^1.2.3" + "baseline-browser-mapping": "^2.11.20", + "caniuse-lite": "^1.0.30001810", + "electron-to-chromium": "^1.5.420", + "node-releases": "^2.0.54", + "update-browserslist-db": "^1.3.2" }, "bin": { "browserslist": "cli.js" @@ -6178,9 +6227,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001793", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001793.tgz", - "integrity": "sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==", + "version": "1.0.30001810", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001810.tgz", + "integrity": "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==", "funding": [ { "type": "opencollective", @@ -6333,15 +6382,15 @@ "license": "MIT" }, "node_modules/concurrently": { - "version": "9.2.1", - "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.1.tgz", - "integrity": "sha512-fsfrO0MxV64Znoy8/l1vVIjjHa29SZyyqPgQBwhiDcaW8wJc2W3XWVOGx4M3oJBnv/zdUZIIp1gDeS98GzP8Ng==", + "version": "9.2.4", + "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.4.tgz", + "integrity": "sha512-TZ0CEhyzvFjgtAvHTusDMgj7wNdihCh7LLLrzdUOXIhdlnL2JBBGA9eJxR24rtqgmdjh3OA3hrN1rCHj6HM8qA==", "dev": true, "license": "MIT", "dependencies": { "chalk": "4.1.2", "rxjs": "7.8.2", - "shell-quote": "1.8.3", + "shell-quote": "1.9.0", "supports-color": "8.1.1", "tree-kill": "1.2.2", "yargs": "17.7.2" @@ -6836,15 +6885,6 @@ "stream-shift": "^1.0.2" } }, - "node_modules/ecdsa-sig-formatter": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", - "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", - "license": "Apache-2.0", - "dependencies": { - "safe-buffer": "^5.0.1" - } - }, "node_modules/effect": { "version": "3.21.0", "resolved": "https://registry.npmjs.org/effect/-/effect-3.21.0.tgz", @@ -6857,9 +6897,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.361", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.361.tgz", - "integrity": "sha512-Q6Hts7N9FnJc5LeGRINFvLhCI9xZmNtTDe5ZbcVezQz7cU4a8Aua3GH1b8J2XY8Al9PF+OCwYqhgsOOheMdvkA==", + "version": "1.5.427", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.427.tgz", + "integrity": "sha512-n14zb3FdsChZ2BNobqNHAJMcP3ifFv4paox2LvCrfVAQcqGiSURgbJl+PfMpHVCNFkStnNc+RRVtPBTVW5PDgw==", "dev": true, "license": "ISC" }, @@ -7091,9 +7131,9 @@ ] }, "node_modules/esbuild": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.0.tgz", - "integrity": "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -7104,32 +7144,32 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.28.0", - "@esbuild/android-arm": "0.28.0", - "@esbuild/android-arm64": "0.28.0", - "@esbuild/android-x64": "0.28.0", - "@esbuild/darwin-arm64": "0.28.0", - "@esbuild/darwin-x64": "0.28.0", - "@esbuild/freebsd-arm64": "0.28.0", - "@esbuild/freebsd-x64": "0.28.0", - "@esbuild/linux-arm": "0.28.0", - "@esbuild/linux-arm64": "0.28.0", - "@esbuild/linux-ia32": "0.28.0", - "@esbuild/linux-loong64": "0.28.0", - "@esbuild/linux-mips64el": "0.28.0", - "@esbuild/linux-ppc64": "0.28.0", - "@esbuild/linux-riscv64": "0.28.0", - "@esbuild/linux-s390x": "0.28.0", - "@esbuild/linux-x64": "0.28.0", - "@esbuild/netbsd-arm64": "0.28.0", - "@esbuild/netbsd-x64": "0.28.0", - "@esbuild/openbsd-arm64": "0.28.0", - "@esbuild/openbsd-x64": "0.28.0", - "@esbuild/openharmony-arm64": "0.28.0", - "@esbuild/sunos-x64": "0.28.0", - "@esbuild/win32-arm64": "0.28.0", - "@esbuild/win32-ia32": "0.28.0", - "@esbuild/win32-x64": "0.28.0" + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" } }, "node_modules/escalade": { @@ -7215,13 +7255,13 @@ } }, "node_modules/eslint-config-next": { - "version": "16.2.3", - "resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-16.2.3.tgz", - "integrity": "sha512-Dnkrylzjof/Az7iNoIQJqD18zTxQZcngir19KJaiRsMnnjpQSVoa6aEg/1Q4hQC+cW90uTlgQYadwL1CYNwFWA==", + "version": "16.3.4", + "resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-16.3.4.tgz", + "integrity": "sha512-35/8RM10huEL9vlr8hUZMERMENHBrnyHN3ZZkF9efSgzGaqK34jIqry44A956//zriUhUAUW0XSkcolhrryqAA==", "dev": true, "license": "MIT", "dependencies": { - "@next/eslint-plugin-next": "16.2.3", + "@next/eslint-plugin-next": "16.3.4", "eslint-import-resolver-node": "^0.3.6", "eslint-import-resolver-typescript": "^3.5.2", "eslint-plugin-import": "^2.32.0", @@ -7698,22 +7738,6 @@ "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", "license": "MIT" }, - "node_modules/fast-jwt": { - "version": "6.2.4", - "resolved": "https://registry.npmjs.org/fast-jwt/-/fast-jwt-6.2.4.tgz", - "integrity": "sha512-IoQa53wI6TbARU2yelb0L44ggFQnP2qVcwswCSYHbCAWuwpr70icDb3QjG0v01I8Tt01rVGDkN/rRvpk0lKFTA==", - "license": "Apache-2.0", - "dependencies": { - "@lukeed/ms": "^2.0.2", - "asn1.js": "^5.4.1", - "ecdsa-sig-formatter": "^1.0.11", - "mnemonist": "^0.40.0", - "safe-regex2": "^5.1.0" - }, - "engines": { - "node": ">=20" - } - }, "node_modules/fast-levenshtein": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", @@ -7738,9 +7762,9 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", - "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.7.tgz", + "integrity": "sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==", "funding": [ { "type": "github", @@ -7790,22 +7814,10 @@ "fxparser": "src/cli/cli.js" } }, - "node_modules/fastfall": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/fastfall/-/fastfall-1.5.1.tgz", - "integrity": "sha512-KH6p+Z8AKPXnmA7+Iz2Lh8ARCMr+8WNPVludm1LGkZoD2MjY6LVnRMtTKhkdzI+jr0RzQWXKzKyBJm1zoHEL4Q==", - "license": "MIT", - "dependencies": { - "reusify": "^1.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/fastify": { - "version": "5.8.5", - "resolved": "https://registry.npmjs.org/fastify/-/fastify-5.8.5.tgz", - "integrity": "sha512-Yqptv59pQzPgQUSIm87hMqHJmdkb1+GPxdE6vW6FRyVE9G86mt7rOghitiU4JHRaTyDUk9pfeKmDeu70lAwM4Q==", + "version": "5.12.3", + "resolved": "https://registry.npmjs.org/fastify/-/fastify-5.12.3.tgz", + "integrity": "sha512-reZ8wce5VNCcufIt9AVtzZa3L4u1j8esikn7OEgHWLVpRpL5R7Y2+Xzj70OUkv5zDfzUAxXZT6cu4Rt0zr3EKA==", "funding": [ { "type": "github", @@ -7824,11 +7836,11 @@ "@fastify/proxy-addr": "^5.0.0", "abstract-logging": "^2.0.1", "avvio": "^9.0.0", - "fast-json-stringify": "^6.0.0", - "find-my-way": "^9.0.0", + "fast-json-stringify": "^7.0.0", + "find-my-way": "^9.6.0", "light-my-request": "^6.0.0", "pino": "^9.14.0 || ^10.1.0", - "process-warning": "^5.0.0", + "process-warning": "^5.1.0", "rfdc": "^1.3.1", "secure-json-parse": "^4.0.0", "semver": "^7.6.0", @@ -7851,6 +7863,68 @@ ], "license": "MIT" }, + "node_modules/fastify/node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/fastify/node_modules/fast-json-stringify": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/fast-json-stringify/-/fast-json-stringify-7.0.1.tgz", + "integrity": "sha512-eRSayARSbbwlBjpP4vnTTIRD5QPcIrmihPxDeN1DtKnHPg66UuJLx+8hlK1kaFdjvzyQ/dzALoi4vwAQ+T+iZA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/merge-json-schemas": "^0.2.0", + "ajv": "^8.12.0", + "ajv-formats": "^3.0.1", + "fast-uri": "^4.0.0", + "json-schema-ref-resolver": "^3.0.0", + "rfdc": "^1.2.0" + } + }, + "node_modules/fastify/node_modules/fast-json-stringify/node_modules/fast-uri": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-4.1.4.tgz", + "integrity": "sha512-dODXrIxlS9JSdgAnhIUKOosKV1oMtU2VtVw87QRaHzyl5jxO290Ii5tEZfCfzfWNHi3jKWwBSdQj0qIyshdZdQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fastify/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, "node_modules/fastify/node_modules/semver": { "version": "7.8.1", "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz", @@ -7863,16 +7937,6 @@ "node": ">=10" } }, - "node_modules/fastparallel": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/fastparallel/-/fastparallel-2.4.1.tgz", - "integrity": "sha512-qUmhxPgNHmvRjZKBFUNI0oZuuH9OlSIOXmJ98lhKPxMZZ7zS/Fi0wRHOihDSz0R1YiIOjxzOY4bq65YTcdBi2Q==", - "license": "ISC", - "dependencies": { - "reusify": "^1.0.4", - "xtend": "^4.0.2" - } - }, "node_modules/fastq": { "version": "1.20.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", @@ -7882,16 +7946,6 @@ "reusify": "^1.0.4" } }, - "node_modules/fastseries": { - "version": "1.7.2", - "resolved": "https://registry.npmjs.org/fastseries/-/fastseries-1.7.2.tgz", - "integrity": "sha512-dTPFrPGS8SNSzAt7u/CbMKCJ3s01N04s4JFbORHcmyvVfVKmbhMD1VtRbh5enGHxkaQDqWyLefiKOGGmohGDDQ==", - "license": "ISC", - "dependencies": { - "reusify": "^1.0.0", - "xtend": "^4.0.0" - } - }, "node_modules/file-entry-cache": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", @@ -7919,9 +7973,9 @@ } }, "node_modules/find-my-way": { - "version": "9.6.0", - "resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-9.6.0.tgz", - "integrity": "sha512-Zf4Xve4RymLl7NgaavNebZ01joJ8MfVerOG43wy7SHLO+r+K0C6d/SE0BiR7AV5V1VOCFlOP7ecdo+I4qmiHrQ==", + "version": "9.9.0", + "resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-9.9.0.tgz", + "integrity": "sha512-sJsgZ1sQH2UDuowPuMKg8az7Qc8F0jnj+SKkFWU/+T0xcFlgV5skgXOGUqmQzOdmW6ALA7AhJINWx3qFBkbLHA==", "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.3", @@ -8957,9 +9011,19 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", - "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", + "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], "license": "MIT", "dependencies": { "argparse": "^2.0.1" @@ -9530,12 +9594,6 @@ "node": ">=8.6" } }, - "node_modules/minimalistic-assert": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", - "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==", - "license": "ISC" - }, "node_modules/minimatch": { "version": "3.1.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", @@ -9565,15 +9623,6 @@ "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", "license": "MIT" }, - "node_modules/mnemonist": { - "version": "0.40.4", - "resolved": "https://registry.npmjs.org/mnemonist/-/mnemonist-0.40.4.tgz", - "integrity": "sha512-ZAv+KNavneRVzu4tUeOgzkScI3W5BGwZ3rkxIpKtzzVgfTtWQFN1CgX0U72cyvyh3iTuHL3SiSmrQxTlryEIcw==", - "license": "MIT", - "dependencies": { - "obliterator": "^2.0.4" - } - }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -9588,9 +9637,9 @@ "optional": true }, "node_modules/nanoid": { - "version": "3.3.12", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", - "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "version": "3.3.19", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz", + "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==", "funding": [ { "type": "github", @@ -9629,16 +9678,16 @@ "license": "MIT" }, "node_modules/next": { - "version": "16.2.6", - "resolved": "https://registry.npmjs.org/next/-/next-16.2.6.tgz", - "integrity": "sha512-qOVgKJg1+At15NpeUP+eJgCHvTCgXsogweq87Ri/Ix7PkqQHg4sdaXmSFqKlgaIXE4kW0g25LE68W87UANlHtw==", + "version": "16.3.4", + "resolved": "https://registry.npmjs.org/next/-/next-16.3.4.tgz", + "integrity": "sha512-/Ztf6CeRH+ejEXUrYtqI4gkS66eFIHuSwqi60RgcpWKodxFZx2/dqVCMKBwILfAHXQ+F1b1vAudgj3mnxqtoIA==", "license": "MIT", "dependencies": { - "@next/env": "16.2.6", - "@swc/helpers": "0.5.15", + "@next/env": "16.3.4", + "@swc/helpers": "0.5.23", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", - "postcss": "8.4.31", + "postcss": "8.5.23", "styled-jsx": "5.1.6" }, "bin": { @@ -9648,15 +9697,15 @@ "node": ">=20.9.0" }, "optionalDependencies": { - "@next/swc-darwin-arm64": "16.2.6", - "@next/swc-darwin-x64": "16.2.6", - "@next/swc-linux-arm64-gnu": "16.2.6", - "@next/swc-linux-arm64-musl": "16.2.6", - "@next/swc-linux-x64-gnu": "16.2.6", - "@next/swc-linux-x64-musl": "16.2.6", - "@next/swc-win32-arm64-msvc": "16.2.6", - "@next/swc-win32-x64-msvc": "16.2.6", - "sharp": "^0.34.5" + "@next/swc-darwin-arm64": "16.3.4", + "@next/swc-darwin-x64": "16.3.4", + "@next/swc-linux-arm64-gnu": "16.3.4", + "@next/swc-linux-arm64-musl": "16.3.4", + "@next/swc-linux-x64-gnu": "16.3.4", + "@next/swc-linux-x64-musl": "16.3.4", + "@next/swc-win32-arm64-msvc": "16.3.4", + "@next/swc-win32-x64-msvc": "16.3.4", + "sharp": "^0.35.4" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", @@ -9681,34 +9730,6 @@ } } }, - "node_modules/next/node_modules/postcss": { - "version": "8.4.31", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", - "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==", - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "nanoid": "^3.3.6", - "picocolors": "^1.0.0", - "source-map-js": "^1.0.2" - }, - "engines": { - "node": "^10 || ^12 || >=14" - } - }, "node_modules/node-exports-info": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/node-exports-info/-/node-exports-info-1.6.0.tgz", @@ -9736,9 +9757,9 @@ "license": "MIT" }, "node_modules/node-releases": { - "version": "2.0.46", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.46.tgz", - "integrity": "sha512-GYVXHE2KnrzAfsAjl4uP++evGFCrAU1jta4ubEjIG7YWt/64Gqv66a30yKwWczVjA6j3bM4nBwH7Pk1JmDHaxQ==", + "version": "2.0.55", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.55.tgz", + "integrity": "sha512-mIrE/Cw9y+9Au6dS5vDKDhQza9YvG6w+ZrS6X+ZzA7yFW/soAeaups4Qzn1bL6g5FVy8WtP79+0j82oPIbqRjQ==", "dev": true, "license": "MIT", "engines": { @@ -9778,9 +9799,9 @@ } }, "node_modules/nodemailer": { - "version": "8.0.7", - "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.7.tgz", - "integrity": "sha512-pkjE4mkBzQjdJT4/UmlKl3pX0rC9fZmjh7c6C9o7lv66Ac6w9WCnzPzhbPNxwZAzlF4mdq4CSWB5+FbK6FWCow==", + "version": "8.0.11", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.11.tgz", + "integrity": "sha512-nrO/pDAUKl+wXX+lx16tDLbnm0fW6sK/x8mgohaCpg+CdCEl482bD4tCuAZk2DyliruiNTIZxRCoWkDqJEnAiA==", "license": "MIT-0", "engines": { "node": ">=6.0.0" @@ -9934,12 +9955,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/obliterator": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/obliterator/-/obliterator-2.0.5.tgz", - "integrity": "sha512-42CPE9AhahZRsMNslczq0ctAEtqk8Eka26QofnqC346BZdHDySk3LWka23LI7ULIw11NmltpiLagIq8gBozxTw==", - "license": "MIT" - }, "node_modules/ohash": { "version": "2.0.11", "resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.11.tgz", @@ -10250,10 +10265,9 @@ } }, "node_modules/postcss": { - "version": "8.5.15", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", - "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", - "dev": true, + "version": "8.5.23", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.23.tgz", + "integrity": "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==", "funding": [ { "type": "opencollective", @@ -10270,7 +10284,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.12", + "nanoid": "^3.3.16", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -10315,9 +10329,9 @@ } }, "node_modules/process-warning": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz", - "integrity": "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==", + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.1.0.tgz", + "integrity": "sha512-jQSaVHsPgtyw60e1rQ/A+/ArPEj/S8pS/vFnyGa/gYFXrKk/6RuDkoqVDQ5NI5MmS01698ltlAk0NoDBNLujRw==", "funding": [ { "type": "github", @@ -10350,9 +10364,9 @@ "license": "MIT" }, "node_modules/protobufjs": { - "version": "7.6.1", - "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.1.tgz", - "integrity": "sha512-4K0myLaWL5EteuSAro91EGFgcfVgxb64Jx+7oDAY6GOkXD4M69yuSEljNcInGVCA5sOPxmZ/EqDLj2x0Q0+Ygg==", + "version": "7.6.6", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.6.tgz", + "integrity": "sha512-dYDWdjSl5RNb7SgPxGQcRU+GtvP7s2fpkrY0r432PcOIaZ0/rBcxEZnQN67iJhFuQiVw754JDoPruPCNdGsbjg==", "hasInstallScript": true, "license": "BSD-3-Clause", "dependencies": { @@ -10362,7 +10376,6 @@ "@protobufjs/eventemitter": "^1.1.1", "@protobufjs/fetch": "^1.1.1", "@protobufjs/float": "^1.0.2", - "@protobufjs/inquire": "^1.1.2", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.1", @@ -11188,54 +11201,59 @@ } }, "node_modules/sharp": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", - "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", - "hasInstallScript": true, + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.4.tgz", + "integrity": "sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA==", "license": "Apache-2.0", "optional": true, "dependencies": { - "@img/colour": "^1.0.0", + "@img/colour": "^1.1.0", "detect-libc": "^2.1.2", - "semver": "^7.7.3" + "semver": "^7.8.5" }, "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-darwin-arm64": "0.34.5", - "@img/sharp-darwin-x64": "0.34.5", - "@img/sharp-libvips-darwin-arm64": "1.2.4", - "@img/sharp-libvips-darwin-x64": "1.2.4", - "@img/sharp-libvips-linux-arm": "1.2.4", - "@img/sharp-libvips-linux-arm64": "1.2.4", - "@img/sharp-libvips-linux-ppc64": "1.2.4", - "@img/sharp-libvips-linux-riscv64": "1.2.4", - "@img/sharp-libvips-linux-s390x": "1.2.4", - "@img/sharp-libvips-linux-x64": "1.2.4", - "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", - "@img/sharp-libvips-linuxmusl-x64": "1.2.4", - "@img/sharp-linux-arm": "0.34.5", - "@img/sharp-linux-arm64": "0.34.5", - "@img/sharp-linux-ppc64": "0.34.5", - "@img/sharp-linux-riscv64": "0.34.5", - "@img/sharp-linux-s390x": "0.34.5", - "@img/sharp-linux-x64": "0.34.5", - "@img/sharp-linuxmusl-arm64": "0.34.5", - "@img/sharp-linuxmusl-x64": "0.34.5", - "@img/sharp-wasm32": "0.34.5", - "@img/sharp-win32-arm64": "0.34.5", - "@img/sharp-win32-ia32": "0.34.5", - "@img/sharp-win32-x64": "0.34.5" + "@img/sharp-darwin-arm64": "0.35.4", + "@img/sharp-darwin-x64": "0.35.4", + "@img/sharp-freebsd-wasm32": "0.35.4", + "@img/sharp-libvips-darwin-arm64": "1.3.3", + "@img/sharp-libvips-darwin-x64": "1.3.3", + "@img/sharp-libvips-linux-arm": "1.3.3", + "@img/sharp-libvips-linux-arm64": "1.3.3", + "@img/sharp-libvips-linux-ppc64": "1.3.3", + "@img/sharp-libvips-linux-riscv64": "1.3.3", + "@img/sharp-libvips-linux-s390x": "1.3.3", + "@img/sharp-libvips-linux-x64": "1.3.3", + "@img/sharp-libvips-linuxmusl-arm64": "1.3.3", + "@img/sharp-libvips-linuxmusl-x64": "1.3.3", + "@img/sharp-linux-arm": "0.35.4", + "@img/sharp-linux-arm64": "0.35.4", + "@img/sharp-linux-ppc64": "0.35.4", + "@img/sharp-linux-riscv64": "0.35.4", + "@img/sharp-linux-s390x": "0.35.4", + "@img/sharp-linux-x64": "0.35.4", + "@img/sharp-linuxmusl-arm64": "0.35.4", + "@img/sharp-linuxmusl-x64": "0.35.4", + "@img/sharp-webcontainers-wasm32": "0.35.4", + "@img/sharp-win32-arm64": "0.35.4", + "@img/sharp-win32-ia32": "0.35.4", + "@img/sharp-win32-x64": "0.35.4" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } } }, "node_modules/sharp/node_modules/semver": { - "version": "7.8.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz", - "integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==", + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", "license": "ISC", "optional": true, "bin": { @@ -11275,9 +11293,9 @@ "license": "MIT" }, "node_modules/shell-quote": { - "version": "1.8.3", - "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz", - "integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==", + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.9.0.tgz", + "integrity": "sha512-Iov+JwFv/2HcTpcwNMKd8+IWNb8tboQJNQTkAY/LLVK7gGH9jy+LGkVqPxfekHl+yMmiqXszdGWXgkfml7hjqA==", "dev": true, "license": "MIT", "engines": { @@ -11420,19 +11438,6 @@ "dev": true, "license": "MIT" }, - "node_modules/steed": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/steed/-/steed-1.1.3.tgz", - "integrity": "sha512-EUkci0FAUiE4IvGTSKcDJIQ/eRUP2JJb56+fvZ4sdnguLTqIdKjSxUe138poW8mkvKWXW2sFPrgTsxqoISnmoA==", - "license": "MIT", - "dependencies": { - "fastfall": "^1.5.0", - "fastparallel": "^2.2.0", - "fastq": "^1.3.0", - "fastseries": "^1.7.0", - "reusify": "^1.0.0" - } - }, "node_modules/stop-iteration-iterator": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", @@ -12148,9 +12153,9 @@ } }, "node_modules/update-browserslist-db": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", - "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.3.tgz", + "integrity": "sha512-pJ2sYawQS0R/WI928Gj5GlPhTGzbMelq0+4INtSYNDV9ErKJcX6xjGWkoG/VnB3dpUm00zALaqkrUD77pO5TDQ==", "dev": true, "funding": [ { @@ -12460,15 +12465,6 @@ "node": ">=16.0.0" } }, - "node_modules/xtend": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", - "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", - "license": "MIT", - "engines": { - "node": ">=0.4" - } - }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", diff --git a/package.json b/package.json index a747ce5..5da4b2c 100644 --- a/package.json +++ b/package.json @@ -27,8 +27,8 @@ }, "dependencies": { "@aws-sdk/client-s3": "^3.1031.0", + "@fastify/cookie": "^11.1.2", "@fastify/cors": "^11.2.0", - "@fastify/jwt": "^10.0.0", "@fastify/multipart": "^10.0.0", "@fastify/rate-limit": "^10.3.0", "@fastify/websocket": "^11.2.0", @@ -52,7 +52,7 @@ "fastify": "^5.3.2", "js-yaml": "^4.1.1", "lucide-react": "^1.8.0", - "next": "^16.2.6", + "next": "16.3.4", "node-ssh": "^13.2.0", "node-xlsx": "^0.24.0", "nodemailer": "^8.0.5", @@ -78,7 +78,7 @@ "@types/react-dom": "^19", "concurrently": "^9.1.2", "eslint": "^9", - "eslint-config-next": "16.2.3", + "eslint-config-next": "16.3.4", "pino-pretty": "^13.1.3", "prisma": "6.19.3", "tailwindcss": "^4", diff --git a/prisma/migrations/20260909_add_database_sessions/migration.sql b/prisma/migrations/20260909_add_database_sessions/migration.sql new file mode 100644 index 0000000..de3ac7c --- /dev/null +++ b/prisma/migrations/20260909_add_database_sessions/migration.sql @@ -0,0 +1,44 @@ +CREATE TYPE "SessionEventType" AS ENUM ('LOGIN', 'LOGOUT', 'REVOKED', 'EXPIRED'); + +CREATE TABLE "user_sessions" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "tokenHash" TEXT NOT NULL, + "createdIp" TEXT, + "lastIp" TEXT, + "userAgent" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "lastSeenAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "idleExpiresAt" TIMESTAMP(3) NOT NULL, + "absoluteExpiresAt" TIMESTAMP(3) NOT NULL, + "revokedAt" TIMESTAMP(3), + "revokeReason" TEXT, + + CONSTRAINT "user_sessions_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE "user_session_events" ( + "id" TEXT NOT NULL, + "sessionId" TEXT NOT NULL, + "type" "SessionEventType" NOT NULL, + "ipAddress" TEXT, + "userAgent" TEXT, + "details" JSONB, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "user_session_events_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "user_sessions_tokenHash_key" ON "user_sessions"("tokenHash"); +CREATE INDEX "user_sessions_userId_revokedAt_idx" ON "user_sessions"("userId", "revokedAt"); +CREATE INDEX "user_sessions_idleExpiresAt_idx" ON "user_sessions"("idleExpiresAt"); +CREATE INDEX "user_sessions_absoluteExpiresAt_idx" ON "user_sessions"("absoluteExpiresAt"); +CREATE INDEX "user_session_events_sessionId_createdAt_idx" ON "user_session_events"("sessionId", "createdAt"); + +ALTER TABLE "user_sessions" ADD CONSTRAINT "user_sessions_userId_fkey" +FOREIGN KEY ("userId") REFERENCES "users"("id") +ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE "user_session_events" ADD CONSTRAINT "user_session_events_sessionId_fkey" +FOREIGN KEY ("sessionId") REFERENCES "user_sessions"("id") +ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 010eec5..a0dd21f 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -14,31 +14,32 @@ datasource db { // Auth // ======================== model User { - id String @id @default(cuid()) - email String @unique - name String - passwordHash String - role UserRole @default(VIEWER) - isActive Boolean @default(true) - allServersAccess Boolean @default(true) + id String @id @default(cuid()) + email String @unique + name String + passwordHash String + role UserRole @default(VIEWER) + isActive Boolean @default(true) + allServersAccess Boolean @default(true) activeOrganizationId String? - lastLogin DateTime? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + lastLogin DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt - activeOrganization Organization? @relation("UserActiveOrganization", fields: [activeOrganizationId], references: [id], onDelete: SetNull) + activeOrganization Organization? @relation("UserActiveOrganization", fields: [activeOrganizationId], references: [id], onDelete: SetNull) organizationMemberships OrganizationMembership[] - createdOrganizations Organization[] @relation("OrganizationCreator") - apiKeys ApiKey[] - auditLogs AuditLog[] - deployments Deployment[] - settings UserSettings? - notificationProviders UserNotificationProvider[] - integrations UserIntegration[] - gitProviders UserGitProvider[] - storageDestinations UserStorageDestination[] - serverAssignments UserServerAccess[] - sentInvitations UserInvitation[] @relation("InvitationCreator") + createdOrganizations Organization[] @relation("OrganizationCreator") + apiKeys ApiKey[] + auditLogs AuditLog[] + deployments Deployment[] + settings UserSettings? + notificationProviders UserNotificationProvider[] + integrations UserIntegration[] + gitProviders UserGitProvider[] + storageDestinations UserStorageDestination[] + serverAssignments UserServerAccess[] + sentInvitations UserInvitation[] @relation("InvitationCreator") + sessions UserSession[] @@index([activeOrganizationId]) @@map("users") @@ -55,7 +56,7 @@ model Organization { updatedAt DateTime @updatedAt members OrganizationMembership[] - activeUsers User[] @relation("UserActiveOrganization") + activeUsers User[] @relation("UserActiveOrganization") servers Server[] projects Project[] domains Domain[] @@ -86,17 +87,17 @@ model OrganizationMembership { // Projects // ======================== model Project { - id String @id @default(cuid()) + id String @id @default(cuid()) organizationId String name String slug String description String? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt - organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) - environments Environment[] - deployments Deployment[] + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + environments Environment[] + deployments Deployment[] @@unique([organizationId, slug]) @@index([organizationId]) @@ -104,18 +105,18 @@ model Project { } model Environment { - id String @id @default(cuid()) + id String @id @default(cuid()) projectId String serverId String name String slug String kind EnvironmentKind description String? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt - project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) - server Server @relation(fields: [serverId], references: [id], onDelete: Restrict) + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) + server Server @relation(fields: [serverId], references: [id], onDelete: Restrict) containers Container[] deployments Deployment[] @@ -135,51 +136,51 @@ enum EnvironmentKind { } model UserSettings { - id String @id @default(cuid()) - userId String @unique - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - panelName String @default("DOKTAINER") - panelUrl String @default("http://localhost:3000") - timezone String @default("Asia/Jakarta") - theme String @default("dark") - sessionTimeoutMinutes Int @default(30) - twoFactorEnabled Boolean @default(false) - twoFactorSecretEnc String? + id String @id @default(cuid()) + userId String @unique + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + panelName String @default("DOKTAINER") + panelUrl String @default("http://localhost:3000") + timezone String @default("Asia/Jakarta") + theme String @default("dark") + sessionTimeoutMinutes Int @default(30) + twoFactorEnabled Boolean @default(false) + twoFactorSecretEnc String? twoFactorPendingSecretEnc String? - ipWhitelistEnabled Boolean @default(false) - ipWhitelist String[] @default([]) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + ipWhitelistEnabled Boolean @default(false) + ipWhitelist String[] @default([]) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt @@map("user_settings") } model UserNotificationProvider { - id String @id @default(cuid()) - userId String - organizationId String - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - type NotificationProviderType - name String - enabled Boolean @default(true) - actions String[] @default([]) - channel String? - webhookUrlEnc String? - smtpHost String? - smtpPort Int @default(587) - smtpSecure Boolean @default(false) - smtpUsername String? - smtpPasswordEnc String? - smtpFromEmail String? - smtpFromName String? - telegramChatId String? + id String @id @default(cuid()) + userId String + organizationId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + type NotificationProviderType + name String + enabled Boolean @default(true) + actions String[] @default([]) + channel String? + webhookUrlEnc String? + smtpHost String? + smtpPort Int @default(587) + smtpSecure Boolean @default(false) + smtpUsername String? + smtpPasswordEnc String? + smtpFromEmail String? + smtpFromName String? + telegramChatId String? telegramBotTokenEnc String? - serverUrl String? - topic String? - userKey String? - apiKeyEnc String? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + serverUrl String? + topic String? + userKey String? + apiKeyEnc String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt @@index([userId, organizationId, type]) @@index([organizationId]) @@ -187,20 +188,20 @@ model UserNotificationProvider { } model UserIntegration { - id String @id @default(cuid()) - userId String - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - provider IntegrationProviderType - connected Boolean @default(false) - zoneId String? - apiTokenEnc String? - accessKeyId String? - secretAccessKeyEnc String? - region String? - bucket String? - tokenEnc String? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + id String @id @default(cuid()) + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + provider IntegrationProviderType + connected Boolean @default(false) + zoneId String? + apiTokenEnc String? + accessKeyId String? + secretAccessKeyEnc String? + region String? + bucket String? + tokenEnc String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt @@unique([userId, provider]) @@index([userId]) @@ -235,30 +236,30 @@ model UserStorageDestination { } model UserGitProvider { - id String @id @default(cuid()) - userId String - organizationId String - user User @relation(fields: [userId], references: [id], onDelete: Cascade) + id String @id @default(cuid()) + userId String + organizationId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) containerDeploymentSources ContainerDeploymentSource[] - provider GitProviderType - name String - enabled Boolean @default(true) - appName String - appId String? - clientId String? - clientSecretEnc String? - webhookSecretEnc String? - appUrl String? - installationUrl String? - providerUrl String? - internalUrl String? - accountUsername String? - accountEmail String? - namespace String? - organizationScoped Boolean @default(false) - organizationName String? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + provider GitProviderType + name String + enabled Boolean @default(true) + appName String + appId String? + clientId String? + clientSecretEnc String? + webhookSecretEnc String? + appUrl String? + installationUrl String? + providerUrl String? + internalUrl String? + accountUsername String? + accountEmail String? + namespace String? + organizationScoped Boolean @default(false) + organizationName String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt @@index([userId, organizationId, provider]) @@index([organizationId]) @@ -279,22 +280,22 @@ model UserServerAccess { } model UserInvitation { - id String @id @default(cuid()) + id String @id @default(cuid()) organizationId String email String name String role UserRole - tokenHash String @unique - allServersAccess Boolean @default(true) + tokenHash String @unique + allServersAccess Boolean @default(true) serverIds String[] invitedById String? organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) - invitedBy User? @relation("InvitationCreator", fields: [invitedById], references: [id], onDelete: SetNull) + invitedBy User? @relation("InvitationCreator", fields: [invitedById], references: [id], onDelete: SetNull) expiresAt DateTime acceptedAt DateTime? revokedAt DateTime? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt @@index([email]) @@index([expiresAt]) @@ -354,20 +355,20 @@ enum S3StorageProvider { } model ApiKey { - id String @id @default(cuid()) + id String @id @default(cuid()) organizationId String - name String - keyHash String @unique - keyPrefix String - permissions String[] - userId String - organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - lastUsed DateTime? - expiresAt DateTime? - isActive Boolean @default(true) - requestCount Int @default(0) - createdAt DateTime @default(now()) + name String + keyHash String @unique + keyPrefix String + permissions String[] + userId String + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + lastUsed DateTime? + expiresAt DateTime? + isActive Boolean @default(true) + requestCount Int @default(0) + createdAt DateTime @default(now()) @@index([organizationId]) @@map("api_keys") @@ -377,36 +378,36 @@ model ApiKey { // Servers // ======================== model Server { - id String @id @default(cuid()) + id String @id @default(cuid()) organizationId String - name String - ip String - sshPort Int @default(22) - username String @default("root") - authType SshAuthType @default(PASSWORD) - sshKeyEnc String? // encrypted private key - passwordEnc String? // encrypted password - status ServerStatus @default(UNKNOWN) - os String? - location String? - tags String[] - lastHealthAt DateTime? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + name String + ip String + sshPort Int @default(22) + username String @default("root") + authType SshAuthType @default(PASSWORD) + sshKeyEnc String? // encrypted private key + passwordEnc String? // encrypted password + status ServerStatus @default(UNKNOWN) + os String? + location String? + tags String[] + lastHealthAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt - organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) - containers Container[] - environments Environment[] - networks Network[] + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + containers Container[] + environments Environment[] + networks Network[] storageDestinations UserStorageDestination[] - domains Domain[] - auditLogs AuditLog[] - deployments Deployment[] - metrics ServerMetric[] - appInstalls AppInstall[] - backups Backup[] + domains Domain[] + auditLogs AuditLog[] + deployments Deployment[] + metrics ServerMetric[] + appInstalls AppInstall[] + backups Backup[] firewallRulePresets FirewallRulePreset[] - userAssignments UserServerAccess[] + userAssignments UserServerAccess[] @@index([organizationId]) @@map("servers") @@ -425,20 +426,20 @@ enum ServerStatus { } model ServerMetric { - id String @id @default(cuid()) - serverId String - server Server @relation(fields: [serverId], references: [id], onDelete: Cascade) - cpuPct Float - ramPct Float - diskPct Float - ramUsed BigInt - ramTotal BigInt - diskUsed BigInt - diskTotal BigInt + id String @id @default(cuid()) + serverId String + server Server @relation(fields: [serverId], references: [id], onDelete: Cascade) + cpuPct Float + ramPct Float + diskPct Float + ramUsed BigInt + ramTotal BigInt + diskUsed BigInt + diskTotal BigInt networkRxBps BigInt? networkTxBps BigInt? - uptimeSec BigInt - recordedAt DateTime @default(now()) + uptimeSec BigInt + recordedAt DateTime @default(now()) @@index([serverId, recordedAt]) @@map("server_metrics") @@ -448,30 +449,30 @@ model ServerMetric { // Containers // ======================== model Container { - id String @id @default(cuid()) - dockerId String? - name String - image String - status ContainerStatus @default(STOPPED) - sourceType ContainerSourceType @default(MANUAL) - deployMode ContainerDeployMode? - serverId String - server Server @relation(fields: [serverId], references: [id], onDelete: Cascade) - environmentId String? - environment Environment? @relation(fields: [environmentId], references: [id], onDelete: SetNull) - domains Domain[] + id String @id @default(cuid()) + dockerId String? + name String + image String + status ContainerStatus @default(STOPPED) + sourceType ContainerSourceType @default(MANUAL) + deployMode ContainerDeployMode? + serverId String + server Server @relation(fields: [serverId], references: [id], onDelete: Cascade) + environmentId String? + environment Environment? @relation(fields: [environmentId], references: [id], onDelete: SetNull) + domains Domain[] deploymentSource ContainerDeploymentSource? - deployments Deployment[] - deploymentLock DeploymentLock? - ports Json @default("[]") // [{host: 80, container: 80, protocol: "tcp"}] - envVars Json @default("[]") // [{key: "NODE_ENV", value: "production"}] - volumes Json @default("[]") - labels Json @default("{}") - restartPolicy String @default("unless-stopped") - cpuUsage String? - ramUsage String? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + deployments Deployment[] + deploymentLock DeploymentLock? + ports Json @default("[]") // [{host: 80, container: 80, protocol: "tcp"}] + envVars Json @default("[]") // [{key: "NODE_ENV", value: "production"}] + volumes Json @default("[]") + labels Json @default("{}") + restartPolicy String @default("unless-stopped") + cpuUsage String? + ramUsage String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt @@index([environmentId]) @@index([serverId]) @@ -479,76 +480,76 @@ model Container { } model ContainerDeploymentSource { - id String @id @default(cuid()) - containerId String @unique - container Container @relation(fields: [containerId], references: [id], onDelete: Cascade) - projectName String? - gitProviderId String? - gitProvider UserGitProvider? @relation(fields: [gitProviderId], references: [id], onDelete: SetNull) - repoUrl String? - repoBranch String? - repoVisibility RepositoryVisibility? - buildType String? - buildPath String? - startCommand String? - portOverride String? - publishDirectory String? - imageTag String? - accessTokenEnc String? + id String @id @default(cuid()) + containerId String @unique + container Container @relation(fields: [containerId], references: [id], onDelete: Cascade) + projectName String? + gitProviderId String? + gitProvider UserGitProvider? @relation(fields: [gitProviderId], references: [id], onDelete: SetNull) + repoUrl String? + repoBranch String? + repoVisibility RepositoryVisibility? + buildType String? + buildPath String? + startCommand String? + portOverride String? + publishDirectory String? + imageTag String? + accessTokenEnc String? composeEnvOverrides Json? - projectPath String? - composeFilePath String? - dockerfilePath String? - dockerContextPath String? - deploymentPath String? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + projectPath String? + composeFilePath String? + dockerfilePath String? + dockerContextPath String? + deploymentPath String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt @@index([gitProviderId]) @@map("container_deployment_sources") } model Deployment { - id String @id @default(cuid()) - containerId String - organizationId String - serverId String - projectId String? - environmentId String? - userId String? - idempotencyKey String? - status DeploymentStatus - trigger DeploymentTrigger - version String? - commitSha String? - branch String? - image String? - imageDigest String? - sourceSnapshot Json? - configSnapshot Json - configRevision String? - rollbackSnapshotEnc String? - strategy String? - candidateRef String? + id String @id @default(cuid()) + containerId String + organizationId String + serverId String + projectId String? + environmentId String? + userId String? + idempotencyKey String? + status DeploymentStatus + trigger DeploymentTrigger + version String? + commitSha String? + branch String? + image String? + imageDigest String? + sourceSnapshot Json? + configSnapshot Json + configRevision String? + rollbackSnapshotEnc String? + strategy String? + candidateRef String? previousDeploymentId String? - failureReason String? - rollbackReason String? - logReference String? - error String? - startedAt DateTime? - completedAt DateTime? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + failureReason String? + rollbackReason String? + logReference String? + error String? + startedAt DateTime? + completedAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt - container Container @relation(fields: [containerId], references: [id], onDelete: Cascade) - organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) - server Server @relation(fields: [serverId], references: [id], onDelete: Cascade) - project Project? @relation(fields: [projectId], references: [id], onDelete: SetNull) - environment Environment? @relation(fields: [environmentId], references: [id], onDelete: SetNull) - user User? @relation(fields: [userId], references: [id], onDelete: SetNull) - previousDeployment Deployment? @relation("DeploymentPredecessor", fields: [previousDeploymentId], references: [id], onDelete: SetNull) - nextDeployments Deployment[] @relation("DeploymentPredecessor") - events DeploymentEvent[] + container Container @relation(fields: [containerId], references: [id], onDelete: Cascade) + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + server Server @relation(fields: [serverId], references: [id], onDelete: Cascade) + project Project? @relation(fields: [projectId], references: [id], onDelete: SetNull) + environment Environment? @relation(fields: [environmentId], references: [id], onDelete: SetNull) + user User? @relation(fields: [userId], references: [id], onDelete: SetNull) + previousDeployment Deployment? @relation("DeploymentPredecessor", fields: [previousDeploymentId], references: [id], onDelete: SetNull) + nextDeployments Deployment[] @relation("DeploymentPredecessor") + events DeploymentEvent[] @@unique([containerId, idempotencyKey]) @@index([containerId, createdAt]) @@ -561,14 +562,57 @@ model Deployment { @@map("deployments") } +model UserSession { + id String @id @default(cuid()) + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + tokenHash String @unique + createdIp String? + lastIp String? + userAgent String? + createdAt DateTime @default(now()) + lastSeenAt DateTime @default(now()) + idleExpiresAt DateTime + absoluteExpiresAt DateTime + revokedAt DateTime? + revokeReason String? + events UserSessionEvent[] + + @@index([userId, revokedAt]) + @@index([idleExpiresAt]) + @@index([absoluteExpiresAt]) + @@map("user_sessions") +} + +model UserSessionEvent { + id String @id @default(cuid()) + sessionId String + session UserSession @relation(fields: [sessionId], references: [id], onDelete: Cascade) + type SessionEventType + ipAddress String? + userAgent String? + details Json? + createdAt DateTime @default(now()) + + @@index([sessionId, createdAt]) + @@map("user_session_events") +} + +enum SessionEventType { + LOGIN + LOGOUT + REVOKED + EXPIRED +} + model DeploymentEvent { - id String @id @default(cuid()) - deploymentId String - status DeploymentStatus - level DeploymentEventLevel @default(INFO) - message String - metadata Json? - createdAt DateTime @default(now()) + id String @id @default(cuid()) + deploymentId String + status DeploymentStatus + level DeploymentEventLevel @default(INFO) + message String + metadata Json? + createdAt DateTime @default(now()) deployment Deployment @relation(fields: [deploymentId], references: [id], onDelete: Cascade) @@ -651,8 +695,8 @@ model FirewallRulePreset { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - @@index([serverId]) @@unique([serverId, rule, action, direction, source]) + @@index([serverId]) @@map("firewall_rule_presets") } @@ -687,30 +731,30 @@ enum RepositoryVisibility { // Domains & SSL // ======================== model Domain { - id String @id @default(cuid()) - name String @unique - organizationId String - domainNameType DomainNameType @default(EXACT) - configMode DomainConfigMode @default(ISOLATED) - reviewStatus DomainReviewStatus @default(CONFIRMED) - isPrimary Boolean @default(false) - type DnsType @default(A) - value String // IP or CNAME target - serverId String? - organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) - server Server? @relation(fields: [serverId], references: [id]) + id String @id @default(cuid()) + name String @unique + organizationId String + domainNameType DomainNameType @default(EXACT) + configMode DomainConfigMode @default(ISOLATED) + reviewStatus DomainReviewStatus @default(CONFIRMED) + isPrimary Boolean @default(false) + type DnsType @default(A) + value String // IP or CNAME target + serverId String? + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + server Server? @relation(fields: [serverId], references: [id]) targetContainerId String? - targetContainer Container? @relation(fields: [targetContainerId], references: [id], onDelete: SetNull) - targetPort Int? - proxy ProxyType @default(NONE) - discoverySource DomainDiscoverySource @default(MANUAL) - sslEnabled Boolean @default(false) - autoRenew Boolean @default(true) - isActive Boolean @default(true) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + targetContainer Container? @relation(fields: [targetContainerId], references: [id], onDelete: SetNull) + targetPort Int? + proxy ProxyType @default(NONE) + discoverySource DomainDiscoverySource @default(MANUAL) + sslEnabled Boolean @default(false) + autoRenew Boolean @default(true) + isActive Boolean @default(true) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt - sslCert SslCert? + sslCert SslCert? @@index([organizationId]) @@index([targetContainerId]) @@ -757,18 +801,18 @@ enum DomainReviewStatus { } model SslCert { - id String @id @default(cuid()) - domainId String @unique - domain Domain @relation(fields: [domainId], references: [id], onDelete: Cascade) - issuer String @default("Let's Encrypt") - certPem String? // certificate PEM - keyPem String? // private key PEM (encrypted) - issuedAt DateTime? - expiresAt DateTime? - status SslStatus @default(PENDING) - autoRenew Boolean @default(true) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + id String @id @default(cuid()) + domainId String @unique + domain Domain @relation(fields: [domainId], references: [id], onDelete: Cascade) + issuer String @default("Let's Encrypt") + certPem String? // certificate PEM + keyPem String? // private key PEM (encrypted) + issuedAt DateTime? + expiresAt DateTime? + status SslStatus @default(PENDING) + autoRenew Boolean @default(true) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt @@map("ssl_certs") } @@ -785,19 +829,19 @@ enum SslStatus { // Audit Log // ======================== model AuditLog { - id String @id @default(cuid()) - userId String? + id String @id @default(cuid()) + userId String? organizationId String? - user User? @relation(fields: [userId], references: [id]) - organization Organization? @relation(fields: [organizationId], references: [id], onDelete: SetNull) - serverId String? - server Server? @relation(fields: [serverId], references: [id]) - action String - category LogCategory - level LogLevel @default(INFO) - message String - meta Json? - createdAt DateTime @default(now()) + user User? @relation(fields: [userId], references: [id]) + organization Organization? @relation(fields: [organizationId], references: [id], onDelete: SetNull) + serverId String? + server Server? @relation(fields: [serverId], references: [id]) + action String + category LogCategory + level LogLevel @default(INFO) + message String + meta Json? + createdAt DateTime @default(now()) @@index([createdAt]) @@index([organizationId]) @@ -828,17 +872,17 @@ enum LogLevel { // App Installer // ======================== model AppInstall { - id String @id @default(cuid()) - appId String // e.g. "wordpress", "nginx" - appName String - serverId String - server Server @relation(fields: [serverId], references: [id], onDelete: Cascade) - containerName String? // docker container name - port String? // host port used - status AppInstallStatus @default(PENDING) - error String? - installedAt DateTime @default(now()) - updatedAt DateTime @updatedAt + id String @id @default(cuid()) + appId String // e.g. "wordpress", "nginx" + appName String + serverId String + server Server @relation(fields: [serverId], references: [id], onDelete: Cascade) + containerName String? // docker container name + port String? // host port used + status AppInstallStatus @default(PENDING) + error String? + installedAt DateTime @default(now()) + updatedAt DateTime @updatedAt @@map("app_installs") } @@ -860,23 +904,23 @@ enum AppInstallStatus { // Backups // ======================== model Backup { - id String @id @default(cuid()) - name String - type BackupType - databaseEngine DatabaseEngine? - serverId String - server Server @relation(fields: [serverId], references: [id], onDelete: Cascade) - target String @default("Local") - sizeMb Float? - filePath String? - storageBucket String? - storageKey String? - storageEtag String? + id String @id @default(cuid()) + name String + type BackupType + databaseEngine DatabaseEngine? + serverId String + server Server @relation(fields: [serverId], references: [id], onDelete: Cascade) + target String @default("Local") + sizeMb Float? + filePath String? + storageBucket String? + storageKey String? + storageEtag String? storageDestinationId String? - status BackupStatus @default(PENDING) - error String? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + status BackupStatus @default(PENDING) + error String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt @@map("backups") } diff --git a/src/app/git/github/callback/page.tsx b/src/app/git/github/callback/page.tsx index 6dce489..cda6d0c 100644 --- a/src/app/git/github/callback/page.tsx +++ b/src/app/git/github/callback/page.tsx @@ -2,7 +2,7 @@ import { useEffect, useRef } from "react"; import { useRouter } from "next/navigation"; -import { getToken, gitProvidersApi, redirectToLogin } from "@/lib/api"; +import { gitProvidersApi, redirectToLogin } from "@/lib/api"; import { decodeGithubManifestState } from "@/lib/github-manifest-state"; export default function GithubCallbackPage() { @@ -67,7 +67,7 @@ export default function GithubCallbackPage() { }, ); - if (conversionResponse.status === 401 && getToken()) { + if (conversionResponse.status === 401) { redirectToLogin("session-expired"); return; } diff --git a/src/app/invite/[token]/page.tsx b/src/app/invite/[token]/page.tsx index f1e5efc..fdf9e1c 100644 --- a/src/app/invite/[token]/page.tsx +++ b/src/app/invite/[token]/page.tsx @@ -11,7 +11,7 @@ import { Shield, User, } from "lucide-react"; -import { auth, setToken, setUser, UserRole } from "@/lib/api"; +import { auth, setUser, UserRole } from "@/lib/api"; function roleLabel(role: UserRole) { switch (role) { @@ -92,7 +92,6 @@ export default function InvitationPage() { password: form.password, }); - setToken(response.token); setUser(response.user); router.replace("/"); } catch (err: unknown) { diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx index f5f2f76..2a20ce7 100644 --- a/src/app/login/page.tsx +++ b/src/app/login/page.tsx @@ -11,7 +11,7 @@ import { Lock, Mail, } from "lucide-react"; -import { auth, getToken, setToken, setUser } from "@/lib/api"; +import { auth, loadCurrentSession, setUser } from "@/lib/api"; export default function LoginPage() { const router = useRouter(); @@ -38,8 +38,10 @@ export default function LoginPage() { if (reasonParam === "session-expired") { setError("The login session has expired. Please log in again."); - } else if (getToken()) { - router.replace("/"); + } else { + void loadCurrentSession().then((user) => { + if (user) router.replace("/"); + }); } let cancelled = false; @@ -100,11 +102,10 @@ export default function LoginPage() { return; } - if (!("token" in res) || !("user" in res)) { + if (!("user" in res)) { throw new Error("Authentication failed"); } - setToken(res.token); setUser(res.user); router.replace("/"); } catch (err: unknown) { diff --git a/src/app/page.tsx b/src/app/page.tsx index 5e20b98..b760052 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -16,7 +16,6 @@ import { AlertTriangle, CheckCircle, Clock, - Zap, } from "lucide-react"; import { AreaChart, diff --git a/src/app/servers/components/ServerConfigModal.tsx b/src/app/servers/components/ServerConfigModal.tsx index 9529e54..fcbd665 100644 --- a/src/app/servers/components/ServerConfigModal.tsx +++ b/src/app/servers/components/ServerConfigModal.tsx @@ -11,7 +11,6 @@ import { X, } from "lucide-react"; import { - getUser, type Server as ServerType, type ServerConfigSnapshot, type ServerSshAccessUpdateBody, @@ -19,6 +18,7 @@ import { type ServerSystemUserCreateBody, servers as serversApi, } from "@/lib/api"; +import { useCurrentUser } from "@/lib/auth-state"; import { createUnavailableServerConfigSnapshot, getServiceRestartDescription, @@ -49,6 +49,7 @@ export default function ServerConfigModal({ onActionComplete, title = "Server Config", }: ServerConfigModalProps) { + const currentUser = useCurrentUser(); const [activeTab, setActiveTab] = useState("overview"); const [snapshot, setSnapshot] = useState(null); const [loading, setLoading] = useState(true); @@ -94,7 +95,7 @@ export default function ServerConfigModal({ ); const canManageSystemAccounts = ["OPERATOR", "SUPER_ADMIN"].includes( - getUser()?.role ?? "VIEWER", + currentUser?.role ?? "VIEWER", ); const getSystemAccountActionKey = useCallback( diff --git a/src/app/sessions/page.tsx b/src/app/sessions/page.tsx new file mode 100644 index 0000000..6cd65b4 --- /dev/null +++ b/src/app/sessions/page.tsx @@ -0,0 +1,442 @@ +"use client"; + +import ConfirmActionDialog from "@/components/ConfirmActionDialog"; +import GuardedPage from "@/components/GuardedPage"; +import IssueDetailsSummary from "@/components/IssueDetailsSummary"; +import SearchField from "@/components/SearchField"; +import TablePagination from "@/components/TablePagination"; +import { useCurrentUser } from "@/lib/auth-state"; +import { + auth, + clearSessionState, + redirectToLogin, + type UserSessionRecord, +} from "@/lib/api"; +import { useTablePagination } from "@/lib/use-table-pagination"; +import { + ArrowDown, + ArrowUp, + ArrowUpDown, + Loader2, + LogOut, + MonitorSmartphone, + RefreshCw, +} from "lucide-react"; +import { useEffect, useMemo, useState } from "react"; + +type StatusFilter = "all" | "active" | "expired" | "revoked"; +type SortKey = "user" | "ip" | "device" | "status" | "createdAt" | "expiresAt"; + +function describeDevice(userAgent: string | null) { + if (!userAgent) return { browser: "Unknown browser", platform: "Unknown device" }; + const browser = userAgent.includes("Edg/") + ? "Edge" + : userAgent.includes("Firefox/") + ? "Firefox" + : userAgent.includes("Chrome/") + ? "Chrome" + : userAgent.includes("Safari/") + ? "Safari" + : "Other browser"; + const platform = userAgent.includes("Windows") + ? "Windows" + : userAgent.includes("Mac OS") + ? "macOS" + : userAgent.includes("Android") + ? "Android" + : userAgent.includes("iPhone") || userAgent.includes("iPad") + ? "iOS" + : userAgent.includes("Linux") + ? "Linux" + : "Unknown device"; + return { browser, platform }; +} + +function getSessionStatus(session: UserSessionRecord): Exclude { + if (!session.revokedAt) return "active"; + return session.revokeReason === "EXPIRED" ? "expired" : "revoked"; +} + +function getEffectiveExpiry(session: UserSessionRecord) { + return new Date( + Math.min( + new Date(session.idleExpiresAt).getTime(), + new Date(session.absoluteExpiresAt).getTime(), + ), + ).toISOString(); +} + +function formatDate(value: string) { + return new Intl.DateTimeFormat(undefined, { + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + }).format(new Date(value)); +} + +function SortButton({ + label, + column, + sortKey, + direction, + onSort, +}: { + label: string; + column: SortKey; + sortKey: SortKey; + direction: "asc" | "desc"; + onSort: (column: SortKey) => void; +}) { + const active = sortKey === column; + const Icon = !active ? ArrowUpDown : direction === "asc" ? ArrowUp : ArrowDown; + return ( + + ); +} + +export default function SessionsPage() { + const currentUser = useCurrentUser(); + const [sessions, setSessions] = useState([]); + const [loading, setLoading] = useState(true); + const [refreshing, setRefreshing] = useState(false); + const [error, setError] = useState(""); + const [search, setSearch] = useState(""); + const [statusFilter, setStatusFilter] = useState("all"); + const [userFilter, setUserFilter] = useState("all"); + const [sortKey, setSortKey] = useState("createdAt"); + const [sortDirection, setSortDirection] = useState<"asc" | "desc">("desc"); + const [pendingSession, setPendingSession] = useState(null); + const [revoking, setRevoking] = useState(false); + + useEffect(() => { + let cancelled = false; + void auth + .sessions() + .then((response) => { + if (!cancelled) setSessions(response.data); + }) + .catch((loadError) => { + if (!cancelled) { + setError(loadError instanceof Error ? loadError.message : "Failed to load sessions"); + } + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + return () => { + cancelled = true; + }; + }, []); + + const users = useMemo(() => { + const unique = new Map(); + for (const session of sessions) unique.set(session.user.id, session.user); + return [...unique.values()].sort((left, right) => left.name.localeCompare(right.name)); + }, [sessions]); + + const filteredSessions = useMemo(() => { + const query = search.trim().toLowerCase(); + const filtered = sessions.filter((session) => { + const device = describeDevice(session.userAgent); + const status = getSessionStatus(session); + const matchesSearch = + !query || + [ + session.user.name, + session.user.email, + session.lastIp, + session.createdIp, + device.browser, + device.platform, + ].some((value) => value?.toLowerCase().includes(query)); + return ( + matchesSearch && + (statusFilter === "all" || status === statusFilter) && + (userFilter === "all" || session.user.id === userFilter) + ); + }); + + const direction = sortDirection === "asc" ? 1 : -1; + return filtered.sort((left, right) => { + const leftDevice = describeDevice(left.userAgent); + const rightDevice = describeDevice(right.userAgent); + const values: Record = { + user: [left.user.name.toLowerCase(), right.user.name.toLowerCase()], + ip: [left.lastIp ?? left.createdIp ?? "", right.lastIp ?? right.createdIp ?? ""], + device: [leftDevice.browser, rightDevice.browser], + status: [getSessionStatus(left), getSessionStatus(right)], + createdAt: [new Date(left.createdAt).getTime(), new Date(right.createdAt).getTime()], + expiresAt: [ + new Date(getEffectiveExpiry(left)).getTime(), + new Date(getEffectiveExpiry(right)).getTime(), + ], + }; + const [leftValue, rightValue] = values[sortKey]; + return typeof leftValue === "number" && typeof rightValue === "number" + ? (leftValue - rightValue) * direction + : String(leftValue).localeCompare(String(rightValue)) * direction; + }); + }, [search, sessions, sortDirection, sortKey, statusFilter, userFilter]); + + const pagination = useTablePagination({ + items: filteredSessions, + pageSize: 10, + resetKey: `${search}|${statusFilter}|${userFilter}|${sortKey}|${sortDirection}`, + }); + + const handleSort = (column: SortKey) => { + if (column === sortKey) { + setSortDirection((current) => (current === "asc" ? "desc" : "asc")); + return; + } + setSortKey(column); + setSortDirection(column === "createdAt" || column === "expiresAt" ? "desc" : "asc"); + }; + + const refresh = async () => { + setRefreshing(true); + setError(""); + try { + const response = await auth.sessions(); + setSessions(response.data); + } catch (refreshError) { + setError(refreshError instanceof Error ? refreshError.message : "Failed to refresh sessions"); + } finally { + setRefreshing(false); + } + }; + + const revoke = async () => { + if (!pendingSession || revoking) return; + setRevoking(true); + setError(""); + try { + const response = await auth.revokeSession(pendingSession.id); + const revokedCurrent = response.data.revokedCurrent; + setPendingSession(null); + if (revokedCurrent) { + clearSessionState(); + redirectToLogin(); + return; + } + await refresh(); + } catch (revokeError) { + setError(revokeError instanceof Error ? revokeError.message : "Failed to revoke session"); + } finally { + setRevoking(false); + } + }; + + return ( + + !revoking && setPendingSession(null)} + onConfirm={() => void revoke()} + /> + +
+ {error ? ( + + ) : null} + +
+ setSearch(event.target.value)} + containerStyle={{ flex: "1 1 360px", minWidth: 240 }} + /> + + +
+ +
+
+ +
+
+
+
+

+ Review active and historical sign-ins. Revoke a session to force logout. +

+
+ + {loading ? ( +
+ Loading sessions… +
+ ) : pagination.totalItems === 0 ? ( +
+ {sessions.length === 0 ? "No login session history is available yet." : "No sessions match these filters."} +
+ ) : ( + <> +
+ + + + + + + + + + + + + + {pagination.paginatedItems.map((session) => { + const device = describeDevice(session.userAgent); + const status = getSessionStatus(session); + return ( + + + + + + + + + + ); + })} + +
Actions
+
+
+
{session.user.name}
+
{session.user.email}
+
+ {session.current ? ( + Current + ) : null} +
+
+ {session.lastIp ?? session.createdIp ?? "Unknown"} + +
{device.browser}
+
{device.platform}
+
+ + {status.charAt(0).toUpperCase() + status.slice(1)} + + {formatDate(session.createdAt)}{formatDate(getEffectiveExpiry(session))} + +
+
+ + + )} +
+
+
+ ); +} diff --git a/src/app/settings/components/GeneralSettingsPanel.tsx b/src/app/settings/components/GeneralSettingsPanel.tsx index bd531c9..b9d9211 100644 --- a/src/app/settings/components/GeneralSettingsPanel.tsx +++ b/src/app/settings/components/GeneralSettingsPanel.tsx @@ -103,6 +103,10 @@ export default function GeneralSettingsPanel({ ))} +

+ Activity extends the inactivity window. Every login still follows + the server's absolute session lifetime. +

diff --git a/src/app/settings/components/settings-config.ts b/src/app/settings/components/settings-config.ts index 9e2af5d..e26d058 100644 --- a/src/app/settings/components/settings-config.ts +++ b/src/app/settings/components/settings-config.ts @@ -16,7 +16,7 @@ export const SESSION_TIMEOUT_OPTIONS = [ { label: "1 hour", value: 60 }, { label: "4 hours", value: 240 }, { label: "24 hours", value: 1440 }, - { label: "Never", value: 0 }, + { label: "No inactivity timeout", value: 0 }, ]; export const SETTINGS_TABS: Array<{ diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index 643a534..a819309 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -4,13 +4,18 @@ import Image from "next/image"; import { useEffect, useRef, useState, type MouseEvent } from "react"; import Link from "next/link"; import { usePathname } from "next/navigation"; -import { auth, clearToken, redirectToLogin } from "@/lib/api"; +import { auth, clearSessionState, redirectToLogin } from "@/lib/api"; import { useCurrentUser } from "@/lib/auth-state"; import OrganizationSwitcher from "@/components/OrganizationSwitcher"; import { navigation } from "@/lib/navigation"; import { addPreferencesListener, getStoredPanelName } from "@/lib/preferences"; import { formatRoleLabel, hasMinimumRole } from "@/lib/permissions"; -import { LogOut, ChevronsUpDown, X, Crown } from "lucide-react"; +import { + LogOut, + ChevronsUpDown, + X, + Crown, +} from "lucide-react"; interface SidebarProps { /** Desktop: whether sidebar is in collapsed (icon-only) state */ @@ -44,11 +49,13 @@ export default function Sidebar({ ), })) .filter((section) => section.items.length > 0); - const accountMenuSections = visibleNavigation.filter( - (section) => section.label === "INTEGRATIONS" || section.label === "MANAGEMENT", + const organizationQuickAccessSections = visibleNavigation.filter( + (section) => + section.label === "INTEGRATIONS" || section.label === "MANAGEMENT", ); const sidebarNavigation = visibleNavigation.filter( - (section) => section.label !== "INTEGRATIONS" && section.label !== "MANAGEMENT", + (section) => + section.label !== "INTEGRATIONS" && section.label !== "MANAGEMENT", ); const handleLogout = async () => { @@ -61,7 +68,7 @@ export default function Sidebar({ } catch { // Best effort server-side logout; local session is cleared regardless. } finally { - clearToken(); + clearSessionState(); onMobileClose(); redirectToLogin(); } @@ -86,8 +93,11 @@ export default function Sidebar({ useEffect(() => { const handlePointerDown = (event: PointerEvent) => { - if (!accountMenuRef.current?.contains(event.target as Node)) setAccountOpen(false); + if (!accountMenuRef.current?.contains(event.target as Node)) { + setAccountOpen(false); + } }; + document.addEventListener("pointerdown", handlePointerDown); return () => document.removeEventListener("pointerdown", handlePointerDown); }, []); @@ -333,13 +343,19 @@ export default function Sidebar({ aria-haspopup="menu" aria-label="Open account menu" onClick={() => setAccountOpen((value) => !value)} + onKeyDown={(event) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + setAccountOpen((value) => !value); + } + }} style={{ padding: isCollapsed ? "12px 8px" : "12px 16px", borderTop: "1px solid var(--border)", - display: "flex", + position: "relative", alignItems: "center", gap: 10, - position: "relative", + display: "flex", cursor: "pointer", }} > @@ -385,31 +401,150 @@ export default function Sidebar({ )} {!isCollapsed && } {accountOpen && ( -
event.stopPropagation()} style={{ position: "absolute", bottom: "calc(100% + 8px)", left: isCollapsed ? "calc(100% + 14px)" : 8, width: 300, maxHeight: "min(520px, calc(100vh - 32px))", overflowY: "auto", padding: 12, background: "var(--bg-secondary)", border: "1px solid var(--border)", borderRadius: 8, boxShadow: "0 16px 40px rgba(0,0,0,0.22)", cursor: "default", zIndex: 60 }}> -
{currentUser?.name || currentUser?.email || "Authenticated User"} · {formatRoleLabel(currentUser?.role)}{currentUser?.role === "SUPER_ADMIN" && }
{currentUser?.email}
- {accountMenuSections.map((section) =>
{section.label}
{section.items.map((item) => { const Icon = item.icon; const active = pathname === item.href; return { setAccountOpen(false); onMobileClose(); }} style={{ alignItems: "center", background: active ? "rgba(59, 130, 246, 0.12)" : "transparent", borderRadius: 7, color: active ? "#3b82f6" : "var(--text-secondary)", display: "flex", fontSize: 13, fontWeight: 500, gap: 9, padding: "8px 6px", textDecoration: "none" }}>{item.label}; })}
)} -
- +
event.stopPropagation()} + style={{ + position: "absolute", + bottom: "calc(100% + 8px)", + left: isCollapsed ? "calc(100% + 14px)" : 8, + width: 300, + maxHeight: "min(520px, calc(100vh - 32px))", + overflowY: "auto", + padding: 12, + background: "var(--bg-secondary)", + border: "1px solid var(--border)", + borderRadius: 8, + boxShadow: "0 16px 40px rgba(0,0,0,0.22)", + cursor: "default", + zIndex: 60, + }} + > +
+
+ {currentUser?.name || + currentUser?.email || + "Authenticated User"}{" "} + + · {formatRoleLabel(currentUser?.role)} + {currentUser?.role === "SUPER_ADMIN" && ( + + + + )} + +
+
+ {currentUser?.email} +
+
+ {organizationQuickAccessSections.map((section) => ( +
+
+ {section.label} +
+ {section.items.map((item) => { + const Icon = item.icon; + const active = pathname === item.href; + return ( + { + setAccountOpen(false); + onMobileClose(); + }} + style={{ + alignItems: "center", + background: active + ? "rgba(59, 130, 246, 0.12)" + : "transparent", + borderRadius: 7, + color: active ? "#3b82f6" : "var(--text-secondary)", + display: "flex", + fontSize: 13, + fontWeight: 500, + gap: 9, + padding: "8px 6px", + textDecoration: "none", + }} + > + + {item.label} + + ); + })} +
+ ))} +
+
)} -
); diff --git a/src/components/ThemeController.tsx b/src/components/ThemeController.tsx index 94bfcbe..b862108 100644 --- a/src/components/ThemeController.tsx +++ b/src/components/ThemeController.tsx @@ -5,7 +5,6 @@ import { addPreferencesListener, applyTheme, getStoredTheme, - initializeSessionTimeoutWatcher, } from "@/lib/preferences"; export default function ThemeController() { @@ -25,14 +24,12 @@ export default function ThemeController() { window.addEventListener("pageshow", syncTheme); document.addEventListener("visibilitychange", syncTheme); const removePreferencesListener = addPreferencesListener(syncTheme); - const stopSessionWatcher = initializeSessionTimeoutWatcher(); return () => { media.removeEventListener("change", onThemeMediaChange); window.removeEventListener("pageshow", syncTheme); document.removeEventListener("visibilitychange", syncTheme); removePreferencesListener(); - stopSessionWatcher(); }; }, []); diff --git a/src/lib/api.ts b/src/lib/api.ts index 9670b4a..1913ff8 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -4,12 +4,7 @@ */ import { emitAuthStateChanged } from "@/lib/auth-events"; -import { - getSensitiveStorageItem, - removeSensitiveStorageItem, - sensitiveStorageKeys, - setSensitiveStorageItem, -} from "@/lib/browser-storage"; +import { clearLegacyAuthStorage } from "@/lib/browser-storage"; import { buildTerminalWebSocketUrl, resolveWebSocketBaseUrl, @@ -59,6 +54,7 @@ const PUBLIC_AUTH_PATHS = [ "/auth/register", "/auth/invitations", "/auth/registration-status", + "/auth/me", ] as const; const READ_ONLY_ALLOWED_MUTATION_PATHS = new Set(["/auth/logout"]); @@ -124,52 +120,80 @@ export function redirectToLogin(reason?: string): void { function handleUnauthorizedSession( path: string, - hadToken: boolean, ): never | void { - if (!hadToken || isPublicAuthPath(path)) { + if (isPublicAuthPath(path)) { return; } - clearToken(); + clearSessionState(); redirectToLogin("session-expired"); throw new Error("Unauthorized - session expired"); } -// ─── Token helpers ──────────────────────────────────────────────────────────── +// ─── Browser session state ──────────────────────────────────────────────────── -export function getToken(): string | null { - return getSensitiveStorageItem(sensitiveStorageKeys.token); -} +let currentUser: UserInfo | null = null; +let sessionRequest: Promise | null = null; +let sessionLoaded = false; +let sessionGeneration = 0; -export function setToken(token: string): void { - setSensitiveStorageItem(sensitiveStorageKeys.token, token); - emitAuthStateChanged(); -} - -export function clearToken(): void { - removeSensitiveStorageItem(sensitiveStorageKeys.token); - removeSensitiveStorageItem(sensitiveStorageKeys.user); +export function clearSessionState(): void { + currentUser = null; + sessionRequest = null; + sessionLoaded = false; + sessionGeneration += 1; + clearLegacyAuthStorage(); clearStoredOrganizationId(); - emitAuthStateChanged(); + emitAuthStateChanged(true); } export function getUser(): UserInfo | null { - try { - const raw = getSensitiveStorageItem(sensitiveStorageKeys.user); - return raw ? JSON.parse(raw) : null; - } catch { - return null; - } + return currentUser; } export function setUser(user: UserInfo): void { - setSensitiveStorageItem(sensitiveStorageKeys.user, JSON.stringify(user)); + currentUser = user; + sessionLoaded = true; + sessionGeneration += 1; if (user.activeOrganizationId) { setStoredOrganizationId(user.activeOrganizationId); } emitAuthStateChanged(); } +export function loadCurrentSession(force = false): Promise { + if (sessionRequest) return sessionRequest; + if (!force && sessionLoaded) return Promise.resolve(currentUser); + + clearLegacyAuthStorage(); + const requestGeneration = sessionGeneration; + const request = auth + .me() + .then((response) => { + if (requestGeneration !== sessionGeneration) { + return currentUser; + } + setUser(response.user); + return response.user; + }) + .catch(() => { + if (requestGeneration !== sessionGeneration) { + return currentUser; + } + currentUser = null; + sessionLoaded = true; + emitAuthStateChanged(); + return null; + }) + .finally(() => { + if (sessionRequest === request) { + sessionRequest = null; + } + }); + sessionRequest = request; + return request; +} + // ─── Types ──────────────────────────────────────────────────────────────────── export interface UserInfo { @@ -180,6 +204,43 @@ export interface UserInfo { activeOrganizationId?: string | null; } +export type UserSessionEventType = + | "LOGIN" + | "LOGOUT" + | "REVOKED" + | "EXPIRED"; + +export interface UserSessionEventRecord { + id: string; + type: UserSessionEventType; + ipAddress: string | null; + userAgent: string | null; + details: Record | null; + createdAt: string; +} + +export interface UserSessionRecord { + id: string; + createdIp: string | null; + lastIp: string | null; + userAgent: string | null; + createdAt: string; + lastSeenAt: string; + idleExpiresAt: string; + absoluteExpiresAt: string; + revokedAt: string | null; + revokeReason: string | null; + current: boolean; + canRevoke: boolean; + user: { + id: string; + name: string; + email: string; + role: string; + }; + events: UserSessionEventRecord[]; +} + export interface OrganizationRecord { id: string; name: string; @@ -706,8 +767,6 @@ async function requestRaw( ): Promise { enforceViewerReadOnlyClient(path, options.method); - const token = getToken(); - const hadToken = Boolean(token); const headers: Record = { ...(options.headers as Record), }; @@ -729,8 +788,8 @@ async function requestRaw( headers["Content-Type"] = "application/json"; } - if (token) { - headers["Authorization"] = `Bearer ${token}`; + if (methodRequiresWriteAccess(options.method)) { + headers["x-doktainer-request"] = "1"; } const organizationId = getStoredOrganizationId(); @@ -744,6 +803,7 @@ async function requestRaw( res = await fetch(`${BASE_URL}${path}`, { ...options, headers, + credentials: "include", signal: options.signal ?? controller.signal, }); } catch (error) { @@ -761,7 +821,7 @@ async function requestRaw( } if (res.status === 401) { - handleUnauthorizedSession(path, hadToken); + handleUnauthorizedSession(path); } return res; @@ -813,11 +873,7 @@ function del(path: string, body?: unknown, config?: { timeoutMs?: number }) { // ─── WebSocket helper ───────────────────────────────────────────────────────── export function createWs(path: string): WebSocket { - const token = getToken(); - const sep = path.includes("?") ? "&" : "?"; - return new WebSocket( - `${getWebSocketBaseUrl()}${path}${token ? `${sep}token=${encodeURIComponent(token)}` : ""}`, - ); + return new WebSocket(`${getWebSocketBaseUrl()}${path}`); } // ─── Auth ───────────────────────────────────────────────────────────────────── @@ -830,7 +886,6 @@ export const auth = { post< | { success: boolean; - token: string; user: UserInfo; requiresTwoFactor?: false; } @@ -842,18 +897,32 @@ export const auth = { }), register: (name: string, email: string, password: string) => - post<{ success: boolean; token: string; user: UserInfo }>( + post<{ success: boolean; user: UserInfo }>( "/auth/register", { name, email, password }, ), - me: () => get<{ success: boolean; data: UserInfo }>("/auth/me"), + me: () => get<{ success: boolean; user: UserInfo }>("/auth/me"), logout: () => request<{ success: boolean; message: string }>("/auth/logout", { method: "POST", }), + sessions: () => + get<{ success: boolean; data: UserSessionRecord[] }>("/auth/sessions"), + + revokeSession: (sessionId: string) => + del<{ success: boolean; data: { revokedCurrent: boolean } }>( + `/auth/sessions/${encodeURIComponent(sessionId)}`, + ), + + revokeOtherSessions: () => + post<{ success: boolean; data: { revokedCount: number } }>( + "/auth/sessions/revoke-others", + {}, + ), + changePassword: (currentPassword: string, newPassword: string) => patch<{ success: boolean; message: string }>("/auth/password", { currentPassword, @@ -908,7 +977,7 @@ export const auth = { token: string, body: { name?: string; password: string }, ) => - post<{ success: boolean; token: string; user: UserInfo }>( + post<{ success: boolean; user: UserInfo }>( `/auth/invitations/${token}/accept`, body, ), diff --git a/src/lib/auth-events.ts b/src/lib/auth-events.ts index 1df1394..ec57cf8 100644 --- a/src/lib/auth-events.ts +++ b/src/lib/auth-events.ts @@ -1,13 +1,39 @@ const AUTH_EVENT = "vps-auth-changed"; +const AUTH_CHANNEL = "doktainer-auth"; -export function emitAuthStateChanged() { +function getAuthChannel(): BroadcastChannel | null { + if (typeof window === "undefined" || !("BroadcastChannel" in window)) { + return null; + } + return new BroadcastChannel(AUTH_CHANNEL); +} + +export type AuthEventSource = "local" | "broadcast"; + +export function emitAuthStateChanged(broadcast = false) { if (typeof window === "undefined") return; window.dispatchEvent(new Event(AUTH_EVENT)); + if (broadcast) { + const channel = getAuthChannel(); + channel?.postMessage({ type: AUTH_EVENT }); + channel?.close(); + } } -export function addAuthStateListener(listener: () => void) { +export function addAuthStateListener( + listener: (source: AuthEventSource) => void, +) { if (typeof window === "undefined") return () => {}; - window.addEventListener(AUTH_EVENT, listener); - return () => window.removeEventListener(AUTH_EVENT, listener); + const onLocalEvent = () => listener("local"); + window.addEventListener(AUTH_EVENT, onLocalEvent); + const channel = getAuthChannel(); + const onChannelMessage = () => listener("broadcast"); + channel?.addEventListener("message", onChannelMessage); + + return () => { + window.removeEventListener(AUTH_EVENT, onLocalEvent); + channel?.removeEventListener("message", onChannelMessage); + channel?.close(); + }; } diff --git a/src/lib/auth-state.ts b/src/lib/auth-state.ts index 602956c..3819185 100644 --- a/src/lib/auth-state.ts +++ b/src/lib/auth-state.ts @@ -1,23 +1,36 @@ "use client"; import { useEffect, useState } from "react"; -import { getToken, getUser, redirectToLogin, type UserInfo } from "@/lib/api"; +import { + getUser, + loadCurrentSession, + redirectToLogin, + type UserInfo, +} from "@/lib/api"; import { addAuthStateListener } from "@/lib/auth-events"; export function useCurrentUser() { - const [currentUser, setCurrentUser] = useState(null); + const [currentUser, setCurrentUser] = useState(getUser()); useEffect(() => { - const syncCurrentUser = () => { + let active = true; + const syncCurrentUser = (source: "local" | "broadcast") => { + if (source === "broadcast") { + void loadCurrentSession(true).then((user) => { + if (active) setCurrentUser(user); + }); + return; + } setCurrentUser(getUser()); }; - syncCurrentUser(); - window.addEventListener("storage", syncCurrentUser); + void loadCurrentSession().then((user) => { + if (active) setCurrentUser(user); + }); const removeAuthStateListener = addAuthStateListener(syncCurrentUser); return () => { - window.removeEventListener("storage", syncCurrentUser); + active = false; removeAuthStateListener(); }; }, []); @@ -26,21 +39,34 @@ export function useCurrentUser() { } export function useRequireAuth() { - const [hasToken, setHasToken] = useState(false); + const [authenticated, setAuthenticated] = useState(null); useEffect(() => { - const syncAuth = () => { - const authenticated = Boolean(getToken()); - setHasToken(authenticated); - - if (!authenticated) { - redirectToLogin(); + let active = true; + const syncAuth = (source: "local" | "broadcast") => { + if (source === "broadcast") { + void loadCurrentSession(true).then((user) => { + if (!active) return; + setAuthenticated(Boolean(user)); + if (!user) redirectToLogin(); + }); + return; } + setAuthenticated(Boolean(getUser())); }; - syncAuth(); - return addAuthStateListener(syncAuth); + void loadCurrentSession().then((user) => { + if (!active) return; + setAuthenticated(Boolean(user)); + if (!user) redirectToLogin(); + }); + const removeAuthStateListener = addAuthStateListener(syncAuth); + + return () => { + active = false; + removeAuthStateListener(); + }; }, []); - return hasToken; + return authenticated === true; } diff --git a/src/lib/browser-storage.ts b/src/lib/browser-storage.ts index c1767db..8ede531 100644 --- a/src/lib/browser-storage.ts +++ b/src/lib/browser-storage.ts @@ -1,11 +1,13 @@ -const TOKEN_KEY = "doktainer_token"; -const USER_KEY = "doktainer_user"; const ORGANIZATION_STORAGE_KEY = "doktainer_active_organization"; -type SensitiveStorageKey = - | typeof TOKEN_KEY - | typeof USER_KEY - | typeof ORGANIZATION_STORAGE_KEY; +type SensitiveStorageKey = typeof ORGANIZATION_STORAGE_KEY; + +const LEGACY_AUTH_STORAGE_KEYS = [ + "doktainer_token", + "doktainer_user", + "vps_token", + "vps_user", +] as const; function getLocalStorage(): Storage | null { if (typeof window === "undefined") return null; @@ -34,8 +36,12 @@ export function removeSensitiveStorageItem(key: SensitiveStorageKey): void { getLocalStorage()?.removeItem(key); } +export function clearLegacyAuthStorage(): void { + const storage = getLocalStorage(); + if (!storage) return; + for (const key of LEGACY_AUTH_STORAGE_KEYS) storage.removeItem(key); +} + export const sensitiveStorageKeys = { - token: TOKEN_KEY, - user: USER_KEY, organization: ORGANIZATION_STORAGE_KEY, } as const; diff --git a/src/lib/navigation.ts b/src/lib/navigation.ts index 800b961..9973f13 100644 --- a/src/lib/navigation.ts +++ b/src/lib/navigation.ts @@ -16,6 +16,7 @@ import { LayoutDashboard, Lock, MessageCircle, + MonitorSmartphone, Network, Package, RotateCcw, @@ -181,10 +182,17 @@ export const navigation: NavSection[] = [ { label: "MANAGEMENT", items: [ + { + href: "/sessions", + icon: MonitorSmartphone, + label: "Sessions", + minRole: "OPERATOR", + keywords: ["login", "sessions", "devices", "security"], + }, { href: "/users", icon: Users, - label: "Users & RBAC", + label: "Users", minRole: "OPERATOR", keywords: ["roles", "permissions", "team"], }, diff --git a/src/lib/permissions.ts b/src/lib/permissions.ts index 0bbef06..7af188d 100644 --- a/src/lib/permissions.ts +++ b/src/lib/permissions.ts @@ -22,6 +22,7 @@ export function formatRoleLabel(role: string | undefined) { } export const managementRouteAccess = { + "/sessions": "OPERATOR", "/users": "OPERATOR", "/api-keys": "DEVELOPER", "/settings": "OPERATOR", diff --git a/src/lib/preferences.ts b/src/lib/preferences.ts index b1b2197..7cc4570 100644 --- a/src/lib/preferences.ts +++ b/src/lib/preferences.ts @@ -1,4 +1,3 @@ -import { clearToken, getToken } from "@/lib/api"; import { applyTheme, THEME_STORAGE_KEY, @@ -101,52 +100,3 @@ export function addPreferencesListener(listener: () => void) { window.removeEventListener(SETTINGS_EVENT, listener); }; } - -export function initializeSessionTimeoutWatcher() { - if (typeof window === "undefined") return () => undefined; - - let timer: number | undefined; - - const schedule = () => { - if (timer) { - window.clearTimeout(timer); - } - - const minutes = getStoredSessionTimeoutMinutes(); - if (!minutes || minutes <= 0) return; - if (!getToken()) return; - - timer = window.setTimeout( - () => { - clearToken(); - window.location.href = "/login?reason=session-expired"; - }, - minutes * 60 * 1000, - ); - }; - - const events: Array = [ - "click", - "keydown", - "mousemove", - "scroll", - "touchstart", - ]; - - for (const eventName of events) { - window.addEventListener(eventName, schedule, { passive: true }); - } - - const removePreferencesListener = addPreferencesListener(schedule); - schedule(); - - return () => { - if (timer) { - window.clearTimeout(timer); - } - for (const eventName of events) { - window.removeEventListener(eventName, schedule); - } - removePreferencesListener(); - }; -} diff --git a/src/server/index.ts b/src/server/index.ts index 4ee3034..d4742e1 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -1,7 +1,7 @@ import "dotenv/config"; import Fastify, { FastifyError } from "fastify"; import cors from "@fastify/cors"; -import jwt from "@fastify/jwt"; +import cookie from "@fastify/cookie"; import rateLimit from "@fastify/rate-limit"; import multipart from "@fastify/multipart"; import websocket from "@fastify/websocket"; @@ -34,19 +34,6 @@ import { startS3StorageRetentionScheduler } from "./services/s3-storage-retentio const PORT = parseInt(process.env.PORT || "4000"); const HOST = process.env.HOST || "0.0.0.0"; -export function getJwtSecretOrThrow(env = process.env): string { - const secret = env.JWT_SECRET?.trim(); - if (!secret) { - throw new Error( - "JWT_SECRET must be configured before starting the backend", - ); - } - if (secret.length < 32) { - throw new Error("JWT_SECRET must be at least 32 characters long"); - } - return secret; -} - function getRateLimitMax(env = process.env): number { const raw = Number.parseInt(env.RATE_LIMIT_MAX || "300", 10); return Number.isFinite(raw) && raw > 0 ? raw : 300; @@ -120,7 +107,6 @@ async function ensureDatabaseConnection() { } async function start() { - const jwtSecret = getJwtSecretOrThrow(); validateEncryptionConfiguration(); await ensureDatabaseConnection(); setDefaultSecurityHeaders(); @@ -149,11 +135,7 @@ async function start() { }), }); - await app.register(jwt, { - secret: jwtSecret, - sign: { expiresIn: process.env.JWT_EXPIRES_IN || "7d" }, - }); - + await app.register(cookie); await app.register(websocket); await app.register(multipart, { limits: { fileSize: 512 * 1024 * 1024, files: 1 } }); diff --git a/src/server/middleware/auth.ts b/src/server/middleware/auth.ts index 2653763..95f11dc 100644 --- a/src/server/middleware/auth.ts +++ b/src/server/middleware/auth.ts @@ -2,6 +2,14 @@ import crypto from "crypto"; import { isIP } from "node:net"; import { FastifyReply, FastifyRequest } from "fastify"; import prisma from "../lib/prisma"; +import { + clearSessionCookie, + expireUserSession, + getRequestUserAgent, + getSessionToken, + hashSessionToken, + touchUserSession, +} from "../services/auth-session.service"; declare module "fastify" { interface FastifyRequest { @@ -10,7 +18,8 @@ declare module "fastify" { organizationId?: string; apiKeyId?: string; apiKeyPermissions?: string[]; - authMethod?: "jwt" | "api-key"; + sessionId?: string; + authMethod?: "session" | "api-key"; } } @@ -24,6 +33,57 @@ function getHeaderValue(value: string | string[] | undefined): string | null { return null; } +const SAFE_HTTP_METHODS = new Set(["GET", "HEAD", "OPTIONS"]); +const BROWSER_MUTATION_HEADER = "x-doktainer-request"; + +function getAllowedBrowserOrigins(env = process.env): Set { + const origins = [env.FRONTEND_URL, ...(env.CORS_ORIGINS ?? "").split(",")] + .map((origin) => origin?.trim().replace(/\/$/, "")) + .filter((origin): origin is string => Boolean(origin)); + return new Set(origins.length > 0 ? origins : ["http://localhost:3000"]); +} + +export function enforceBrowserMutationProtection( + req: FastifyRequest, + reply: FastifyReply, +): boolean { + if (SAFE_HTTP_METHODS.has(req.method.toUpperCase())) return true; + + if (getHeaderValue(req.headers[BROWSER_MUTATION_HEADER]) !== "1") { + reply.status(403).send({ + success: false, + error: "Forbidden — missing browser request verification", + }); + return false; + } + + if (getHeaderValue(req.headers["sec-fetch-site"]) === "cross-site") { + reply.status(403).send({ + success: false, + error: "Forbidden — cross-site request rejected", + }); + return false; + } + + const origin = getHeaderValue(req.headers.origin)?.replace(/\/$/, ""); + if (origin && !getAllowedBrowserOrigins().has(origin)) { + reply.status(403).send({ + success: false, + error: "Forbidden — request origin is not allowed", + }); + return false; + } + + return true; +} + +export async function requireBrowserMutationVerification( + req: FastifyRequest, + reply: FastifyReply, +): Promise { + enforceBrowserMutationProtection(req, reply); +} + function extractApiKey(req: FastifyRequest): string | null { const headerKey = getHeaderValue(req.headers["x-api-key"]); if (headerKey) return headerKey; @@ -376,104 +436,113 @@ async function authenticateApiKey( enforceUserSecurityPolicies(req, reply, apiKey.user.settings); } -/** - * Middleware: verify JWT token from Authorization header - * Attaches userId and userRole to request - */ +/** Authenticate an API key or an opaque database-backed browser session. */ export async function authenticate( req: FastifyRequest, reply: FastifyReply, ): Promise { - const query = req.query as { token?: string } | undefined; - const queryToken = query?.token; - - if (queryToken && !req.headers.authorization) { - req.headers.authorization = `Bearer ${queryToken}`; - } - const apiKey = extractApiKey(req); if (apiKey) { await authenticateApiKey(apiKey, req, reply); return; } - try { - const payload = await req.jwtVerify<{ - sub: string; - role: string; - iat?: number; - }>(); + const rawToken = getSessionToken(req); + if (!rawToken) { + reply.status(401).send({ + success: false, + error: "Unauthorized — login session required", + }); + return; + } - const user = await prisma.user.findUnique({ - where: { id: payload.sub }, - select: { - id: true, - role: true, - isActive: true, - activeOrganizationId: true, - organizationMemberships: { - select: { - organizationId: true, - isDefault: true, + const session = await prisma.userSession.findUnique({ + where: { tokenHash: hashSessionToken(rawToken) }, + include: { + user: { + select: { + id: true, + role: true, + isActive: true, + activeOrganizationId: true, + organizationMemberships: { + select: { organizationId: true, isDefault: true }, }, - }, - settings: { - select: { - sessionTimeoutMinutes: true, - ipWhitelistEnabled: true, - ipWhitelist: true, + settings: { + select: { + sessionTimeoutMinutes: true, + ipWhitelistEnabled: true, + ipWhitelist: true, + }, }, }, }, - }); + }, + }); - if (!user || !user.isActive) { - reply.status(401).send({ - success: false, - error: "Unauthorized — invalid or expired token", - }); - return; - } - - const availableOrganizationIds = user.organizationMemberships.map( - (membership) => membership.organizationId, - ); - const requestedOrganizationId = extractOrganizationId(req); - - if ( - requestedOrganizationId && - !availableOrganizationIds.includes(requestedOrganizationId) - ) { - reply.status(403).send({ - success: false, - error: "Forbidden — you do not have access to that organization", - }); - return; - } - - const fallbackOrganizationId = - (user.activeOrganizationId && - availableOrganizationIds.includes(user.activeOrganizationId) - ? user.activeOrganizationId - : null) ?? - user.organizationMemberships.find((membership) => membership.isDefault) - ?.organizationId ?? - availableOrganizationIds[0]; - - req.userId = user.id; - req.userRole = user.role; - req.organizationId = requestedOrganizationId ?? fallbackOrganizationId; - req.authMethod = "jwt"; - - if (!enforceUserSecurityPolicies(req, reply, user.settings, payload.iat)) { - return; - } - } catch { + if (!session || session.revokedAt || !session.user.isActive) { + clearSessionCookie(reply); reply.status(401).send({ success: false, - error: "Unauthorized — invalid or expired token", + error: "Unauthorized — invalid or revoked login session", }); + return; } + + const now = new Date(); + const ipAddress = getClientIp(req) || null; + const userAgent = getRequestUserAgent(req); + if (session.idleExpiresAt <= now || session.absoluteExpiresAt <= now) { + await expireUserSession(session.id, ipAddress, userAgent); + clearSessionCookie(reply); + reply.status(401).send({ + success: false, + error: "Unauthorized — login session expired", + }); + return; + } + + if (!enforceBrowserMutationProtection(req, reply)) return; + if (!enforceUserSecurityPolicies(req, reply, session.user.settings)) return; + + const availableOrganizationIds = session.user.organizationMemberships.map( + (membership) => membership.organizationId, + ); + const requestedOrganizationId = extractOrganizationId(req); + if ( + requestedOrganizationId && + !availableOrganizationIds.includes(requestedOrganizationId) + ) { + reply.status(403).send({ + success: false, + error: "Forbidden — you do not have access to that organization", + }); + return; + } + + const fallbackOrganizationId = + (session.user.activeOrganizationId && + availableOrganizationIds.includes(session.user.activeOrganizationId) + ? session.user.activeOrganizationId + : null) ?? + session.user.organizationMemberships.find( + (membership) => membership.isDefault, + )?.organizationId ?? + availableOrganizationIds[0]; + + req.userId = session.user.id; + req.userRole = session.user.role; + req.organizationId = requestedOrganizationId ?? fallbackOrganizationId; + req.sessionId = session.id; + req.authMethod = "session"; + + await touchUserSession({ + sessionId: session.id, + lastSeenAt: session.lastSeenAt, + sessionTimeoutMinutes: session.user.settings?.sessionTimeoutMinutes ?? 30, + absoluteExpiresAt: session.absoluteExpiresAt, + ipAddress, + }); } /** diff --git a/src/server/routes/auth.ts b/src/server/routes/auth.ts index 125258c..33558a5 100644 --- a/src/server/routes/auth.ts +++ b/src/server/routes/auth.ts @@ -1,10 +1,16 @@ -import { FastifyInstance } from "fastify"; +import { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; import bcrypt from "bcryptjs"; import crypto from "crypto"; import { z } from "zod"; import prisma from "../lib/prisma"; import { auditLog } from "../services/audit.service"; -import { authenticate } from "../middleware/auth"; +import { + authenticate, + getClientIp, + ipMatchesWhitelist, + requireBrowserMutationVerification, + requireRole, +} from "../middleware/auth"; import { dispatchRuntimeNotification } from "../services/notification.service"; import { createTwoFactorSetup, @@ -12,6 +18,18 @@ import { verifyEncryptedTwoFactorToken, } from "../services/two-factor.service"; import { createOrganizationForUser } from "../lib/organizations"; +import { + clearSessionCookie, + canRevokeManagedSession, + changePasswordAndRevokeOtherSessions, + createUserSession, + expireUserSession, + getRequestUserAgent, + revokeOtherUserSessions, + revokeUserSession, + setSessionCookie, +} from "../services/auth-session.service"; +import { SessionEventType } from "@prisma/client"; const LoginSchema = z.object({ email: z.string().email(), @@ -69,7 +87,6 @@ async function ensureUserSettings(userId: string) { } function buildAuthResponse( - app: FastifyInstance, user: { id: string; role: string; @@ -78,15 +95,7 @@ function buildAuthResponse( activeOrganizationId?: string | null; }, ) { - const token = app.jwt.sign({ - sub: user.id, - role: user.role, - name: user.name, - email: user.email, - }); - return { - token, user: { id: user.id, name: user.name, @@ -97,6 +106,31 @@ function buildAuthResponse( }; } +async function establishLoginSession( + req: FastifyRequest, + reply: FastifyReply, + user: Parameters[0], +) { + const settings = await ensureUserSettings(user.id); + const ipAddress = getClientIp(req) || null; + const userAgent = getRequestUserAgent(req); + const created = await createUserSession({ + userId: user.id, + sessionTimeoutMinutes: settings.sessionTimeoutMinutes, + ipAddress, + userAgent, + }); + setSessionCookie( + reply, + created.rawToken, + created.session.absoluteExpiresAt, + ); + return { + ...buildAuthResponse(user), + sessionId: created.session.id, + }; +} + async function getPublicRegistrationStatus() { const userCount = await prisma.user.count(); @@ -118,7 +152,11 @@ export async function authRoutes(app: FastifyInstance) { app.post( "/login", - { bodyLimit: authBodyLimit, config: { rateLimit: authRateLimit } }, + { + preHandler: [requireBrowserMutationVerification], + bodyLimit: authBodyLimit, + config: { rateLimit: authRateLimit }, + }, async (req, reply) => { const body = LoginSchema.safeParse(req.body); if (!body.success) { @@ -135,6 +173,8 @@ export async function authRoutes(app: FastifyInstance) { select: { twoFactorEnabled: true, twoFactorSecretEnc: true, + ipWhitelistEnabled: true, + ipWhitelist: true, }, }, }, @@ -149,7 +189,11 @@ export async function authRoutes(app: FastifyInstance) { category: "AUTH", level: "WARNING", message: `Failed login attempt for \"${user.email}\"`, - meta: { reason: user.isActive ? "unknown" : "inactive_user" }, + meta: { + reason: user.isActive ? "unknown" : "inactive_user", + ipAddress: getClientIp(req) || null, + userAgent: getRequestUserAgent(req), + }, }); } return reply @@ -166,7 +210,11 @@ export async function authRoutes(app: FastifyInstance) { category: "AUTH", level: "WARNING", message: `Failed login attempt for \"${user.email}\"`, - meta: { reason: "invalid_password" }, + meta: { + reason: "invalid_password", + ipAddress: getClientIp(req) || null, + userAgent: getRequestUserAgent(req), + }, }); if (user.activeOrganizationId) { @@ -217,7 +265,11 @@ export async function authRoutes(app: FastifyInstance) { category: "AUTH", level: "WARNING", message: `Invalid two-factor code for \"${user.email}\"`, - meta: { reason: "invalid_totp" }, + meta: { + reason: "invalid_totp", + ipAddress: getClientIp(req) || null, + userAgent: getRequestUserAgent(req), + }, }); if (user.activeOrganizationId) { @@ -241,12 +293,35 @@ export async function authRoutes(app: FastifyInstance) { .send({ success: false, error: "Invalid authentication code" }); } + if ( + user.settings?.ipWhitelistEnabled && + user.settings.ipWhitelist.length > 0 && + !ipMatchesWhitelist(getClientIp(req), user.settings.ipWhitelist) + ) { + await auditLog({ + userId: user.id, + organizationId: user.activeOrganizationId ?? undefined, + action: "LOGIN_IP_BLOCKED", + category: "AUTH", + level: "WARNING", + message: `Login blocked by IP policy for "${user.email}"`, + meta: { + ipAddress: getClientIp(req) || null, + userAgent: getRequestUserAgent(req), + }, + }); + return reply.status(403).send({ + success: false, + error: "Login is not permitted from this IP address", + }); + } + await prisma.user.update({ where: { id: user.id }, data: { lastLogin: new Date() }, }); - const auth = buildAuthResponse(app, user); + const auth = await establishLoginSession(req, reply, user); await auditLog({ userId: user.id, @@ -254,16 +329,20 @@ export async function authRoutes(app: FastifyInstance) { category: "AUTH", level: "INFO", message: `User "${user.email}" logged in`, - meta: { twoFactorEnabled }, + meta: { twoFactorEnabled, sessionId: auth.sessionId }, }); - return reply.send({ success: true, ...auth }); + return reply.send({ success: true, user: auth.user }); }, ); app.post( "/register", - { bodyLimit: authBodyLimit, config: { rateLimit: registerRateLimit } }, + { + preHandler: [requireBrowserMutationVerification], + bodyLimit: authBodyLimit, + config: { rateLimit: registerRateLimit }, + }, async (req, reply) => { const body = RegisterSchema.safeParse(req.body); if (!body.success) { @@ -314,7 +393,7 @@ export async function authRoutes(app: FastifyInstance) { }); }); - const auth = buildAuthResponse(app, user); + const auth = await establishLoginSession(req, reply, user); await auditLog({ userId: user.id, @@ -324,7 +403,7 @@ export async function authRoutes(app: FastifyInstance) { message: `New user registered: "${email}"`, }); - return reply.status(201).send({ success: true, ...auth }); + return reply.status(201).send({ success: true, user: auth.user }); }, ); @@ -396,7 +475,11 @@ export async function authRoutes(app: FastifyInstance) { app.post( "/invitations/:token/accept", - { bodyLimit: authBodyLimit, config: { rateLimit: registerRateLimit } }, + { + preHandler: [requireBrowserMutationVerification], + bodyLimit: authBodyLimit, + config: { rateLimit: registerRateLimit }, + }, async (req, reply) => { const { token } = req.params as { token: string }; const body = InvitationAcceptSchema.safeParse(req.body); @@ -504,7 +587,7 @@ export async function authRoutes(app: FastifyInstance) { return createdUser; }); - const auth = buildAuthResponse(app, user); + const auth = await establishLoginSession(req, reply, user); await auditLog({ userId: user.id, @@ -515,7 +598,7 @@ export async function authRoutes(app: FastifyInstance) { message: `Invitation accepted for "${user.email}"`, }); - return reply.send({ success: true, ...auth }); + return reply.send({ success: true, user: auth.user }); }, ); @@ -541,6 +624,17 @@ export async function authRoutes(app: FastifyInstance) { }); app.post("/logout", { preHandler: [authenticate] }, async (req, reply) => { + if (req.sessionId && req.userId) { + await revokeUserSession({ + sessionId: req.sessionId, + userId: req.userId, + reason: "USER_LOGOUT", + eventType: SessionEventType.LOGOUT, + ipAddress: getClientIp(req) || null, + userAgent: getRequestUserAgent(req), + }); + } + clearSessionCookie(reply); await auditLog({ userId: req.userId, action: "LOGOUT", @@ -551,6 +645,224 @@ export async function authRoutes(app: FastifyInstance) { return reply.send({ success: true, message: "Logged out successfully" }); }); + app.get("/sessions", { preHandler: [requireRole("OPERATOR")] }, async (req, reply) => { + if (!req.sessionId) { + return reply.status(403).send({ + success: false, + error: "A browser login session is required", + }); + } + if (!req.organizationId) { + return reply.status(400).send({ + success: false, + error: "An active organization is required", + }); + } + + const organizationScope = { + user: { + organizationMemberships: { + some: { organizationId: req.organizationId }, + }, + }, + }; + const now = new Date(); + const expiredSessions = await prisma.userSession.findMany({ + where: { + ...organizationScope, + revokedAt: null, + OR: [ + { idleExpiresAt: { lte: now } }, + { absoluteExpiresAt: { lte: now } }, + ], + }, + take: 200, + select: { id: true }, + }); + await Promise.all( + expiredSessions.map((session) => + expireUserSession( + session.id, + getClientIp(req) || null, + getRequestUserAgent(req), + ), + ), + ); + + const sessions = await prisma.userSession.findMany({ + where: organizationScope, + orderBy: { createdAt: "desc" }, + take: 200, + select: { + id: true, + createdIp: true, + lastIp: true, + userAgent: true, + createdAt: true, + lastSeenAt: true, + idleExpiresAt: true, + absoluteExpiresAt: true, + revokedAt: true, + revokeReason: true, + user: { + select: { + id: true, + name: true, + email: true, + role: true, + }, + }, + events: { + orderBy: { createdAt: "desc" }, + take: 10, + select: { + id: true, + type: true, + ipAddress: true, + userAgent: true, + details: true, + createdAt: true, + }, + }, + }, + }); + + return reply.send({ + success: true, + data: sessions.map((session) => ({ + ...session, + current: session.id === req.sessionId, + canRevoke: + session.revokedAt === null && + (session.user.id === req.userId || req.userRole === "SUPER_ADMIN"), + })), + }); + }); + + app.delete( + "/sessions/:sessionId", + { preHandler: [requireRole("OPERATOR")] }, + async (req, reply) => { + if (!req.sessionId) { + return reply.status(403).send({ + success: false, + error: "A browser login session is required", + }); + } + const params = z + .object({ sessionId: z.string().trim().min(1).max(64) }) + .safeParse(req.params); + if (!params.success) { + return reply + .status(400) + .send({ success: false, error: params.error.flatten() }); + } + if (!req.organizationId) { + return reply.status(400).send({ + success: false, + error: "An active organization is required", + }); + } + + const targetSession = await prisma.userSession.findFirst({ + where: { + id: params.data.sessionId, + user: { + organizationMemberships: { + some: { organizationId: req.organizationId }, + }, + }, + }, + select: { + userId: true, + user: { select: { email: true } }, + }, + }); + if (!targetSession) { + return reply + .status(404) + .send({ success: false, error: "Session not found" }); + } + + if ( + !canRevokeManagedSession({ + actorUserId: req.userId!, + actorRole: req.userRole!, + targetUserId: targetSession.userId, + }) + ) { + return reply.status(403).send({ + success: false, + error: "Only a Super Admin can revoke another user's session", + }); + } + + const revoked = await revokeUserSession({ + sessionId: params.data.sessionId, + userId: targetSession.userId, + reason: + params.data.sessionId === req.sessionId + ? "CURRENT_SESSION_REVOKED" + : "USER_REVOKED", + ipAddress: getClientIp(req) || null, + userAgent: getRequestUserAgent(req), + }); + if (!revoked) { + return reply + .status(404) + .send({ success: false, error: "Active session not found" }); + } + + const revokedCurrent = params.data.sessionId === req.sessionId; + if (revokedCurrent) clearSessionCookie(reply); + await auditLog({ + userId: req.userId, + organizationId: req.organizationId, + action: "SESSION_REVOKE", + category: "AUTH", + level: "WARNING", + message: revokedCurrent + ? "Current login session revoked" + : `Login session revoked for "${targetSession.user.email}"`, + meta: { + sessionId: params.data.sessionId, + targetUserId: targetSession.userId, + }, + }); + return reply.send({ success: true, data: { revokedCurrent } }); + }, + ); + + app.post( + "/sessions/revoke-others", + { preHandler: [authenticate] }, + async (req, reply) => { + if (!req.sessionId) { + return reply.status(400).send({ + success: false, + error: "A browser login session is required", + }); + } + const revokedCount = await revokeOtherUserSessions({ + userId: req.userId!, + currentSessionId: req.sessionId, + reason: "USER_REVOKED_OTHER_SESSIONS", + ipAddress: getClientIp(req) || null, + userAgent: getRequestUserAgent(req), + }); + await auditLog({ + userId: req.userId, + organizationId: req.organizationId, + action: "SESSION_REVOKE_OTHERS", + category: "AUTH", + level: "WARNING", + message: `${revokedCount} other login session${revokedCount === 1 ? "" : "s"} revoked`, + meta: { revokedCount }, + }); + return reply.send({ success: true, data: { revokedCount } }); + }, + ); + app.patch( "/password", { @@ -586,9 +898,18 @@ export async function authRoutes(app: FastifyInstance) { } const passwordHash = await bcrypt.hash(body.data.newPassword, 12); - await prisma.user.update({ - where: { id: user.id }, - data: { passwordHash }, + if (!req.sessionId) { + return reply.status(403).send({ + success: false, + error: "A browser login session is required to change a password", + }); + } + const revokedCount = await changePasswordAndRevokeOtherSessions({ + userId: user.id, + currentSessionId: req.sessionId, + passwordHash, + ipAddress: getClientIp(req) || null, + userAgent: getRequestUserAgent(req), }); await auditLog({ @@ -597,6 +918,7 @@ export async function authRoutes(app: FastifyInstance) { category: "AUTH", level: "INFO", message: "Password changed", + meta: { revokedSessionCount: revokedCount }, }); return reply.send({ diff --git a/src/server/routes/containers.ts b/src/server/routes/containers.ts index a62467c..e3de1cf 100644 --- a/src/server/routes/containers.ts +++ b/src/server/routes/containers.ts @@ -1590,9 +1590,15 @@ function writeProcessJobStreamEvent( function buildInternalJobHeaders(headers: Record) { const nextHeaders: Record = { "content-type": "application/json", + "x-doktainer-request": "1", "x-process-job-sync": "true", }; + const cookie = headers.cookie; + if (typeof cookie === "string") { + nextHeaders.cookie = cookie; + } + const authorization = headers.authorization; if (typeof authorization === "string") { nextHeaders.authorization = authorization; diff --git a/src/server/services/auth-session.service.ts b/src/server/services/auth-session.service.ts new file mode 100644 index 0000000..7660538 --- /dev/null +++ b/src/server/services/auth-session.service.ts @@ -0,0 +1,292 @@ +import crypto from "node:crypto"; +import type { FastifyReply, FastifyRequest } from "fastify"; +import { SessionEventType } from "@prisma/client"; +import prisma from "../lib/prisma"; + +const DEVELOPMENT_SESSION_COOKIE = "doktainer_session"; +const PRODUCTION_SESSION_COOKIE = "__Host-doktainer_session"; +const DEFAULT_ABSOLUTE_SESSION_HOURS = 7 * 24; +const SESSION_TOUCH_INTERVAL_MS = 5 * 60 * 1000; +const MAX_USER_AGENT_LENGTH = 512; +const MAX_ACTIVE_SESSIONS_PER_USER = 20; + +export function canRevokeManagedSession(input: { + actorUserId: string; + actorRole: string; + targetUserId: string; +}): boolean { + return ( + input.actorUserId === input.targetUserId || + input.actorRole === "SUPER_ADMIN" + ); +} + +export function getSessionCookieName(env = process.env): string { + return env.NODE_ENV === "production" + ? PRODUCTION_SESSION_COOKIE + : DEVELOPMENT_SESSION_COOKIE; +} + +function getAbsoluteSessionHours(env = process.env): number { + const parsed = Number.parseInt(env.SESSION_ABSOLUTE_TIMEOUT_HOURS ?? "", 10); + return Number.isFinite(parsed) && parsed > 0 + ? parsed + : DEFAULT_ABSOLUTE_SESSION_HOURS; +} + +export function hashSessionToken(token: string): string { + return crypto.createHash("sha256").update(token).digest("hex"); +} + +export function normalizeSessionUserAgent(value: unknown): string | null { + if (typeof value !== "string") return null; + const normalized = value.replace(/[\u0000-\u001f\u007f]/g, "").trim(); + return normalized ? normalized.slice(0, MAX_USER_AGENT_LENGTH) : null; +} + +export function getRequestUserAgent(req: FastifyRequest): string | null { + return normalizeSessionUserAgent(req.headers["user-agent"]); +} + +function sessionCookieOptions(expires?: Date) { + return { + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: "lax" as const, + path: "/", + priority: "high" as const, + ...(expires ? { expires } : {}), + }; +} + +export function setSessionCookie( + reply: FastifyReply, + token: string, + expiresAt: Date, +): void { + reply.setCookie(getSessionCookieName(), token, sessionCookieOptions(expiresAt)); +} + +export function clearSessionCookie(reply: FastifyReply): void { + for (const cookieName of [ + DEVELOPMENT_SESSION_COOKIE, + PRODUCTION_SESSION_COOKIE, + ]) { + reply.clearCookie(cookieName, sessionCookieOptions()); + } +} + +export function getSessionToken(req: FastifyRequest): string | null { + return req.cookies?.[getSessionCookieName()]?.trim() || null; +} + +export async function createUserSession(input: { + userId: string; + sessionTimeoutMinutes: number; + ipAddress: string | null; + userAgent: string | null; +}) { + const now = new Date(); + const absoluteExpiresAt = new Date( + now.getTime() + getAbsoluteSessionHours() * 60 * 60 * 1000, + ); + const idleExpiresAt = + input.sessionTimeoutMinutes > 0 + ? new Date(now.getTime() + input.sessionTimeoutMinutes * 60 * 1000) + : absoluteExpiresAt; + const rawToken = crypto.randomBytes(32).toString("base64url"); + + const session = await prisma.userSession.create({ + data: { + userId: input.userId, + tokenHash: hashSessionToken(rawToken), + createdIp: input.ipAddress, + lastIp: input.ipAddress, + userAgent: input.userAgent, + idleExpiresAt, + absoluteExpiresAt, + events: { + create: { + type: SessionEventType.LOGIN, + ipAddress: input.ipAddress, + userAgent: input.userAgent, + }, + }, + }, + select: { + id: true, + absoluteExpiresAt: true, + }, + }); + + const overflowSessions = await prisma.userSession.findMany({ + where: { userId: input.userId, revokedAt: null }, + orderBy: { createdAt: "desc" }, + skip: MAX_ACTIVE_SESSIONS_PER_USER, + select: { id: true }, + }); + await Promise.all( + overflowSessions.map((overflowSession) => + revokeUserSession({ + sessionId: overflowSession.id, + userId: input.userId, + reason: "ACTIVE_SESSION_LIMIT", + ipAddress: input.ipAddress, + userAgent: input.userAgent, + }), + ), + ); + + return { rawToken, session }; +} + +export async function expireUserSession( + sessionId: string, + ipAddress: string | null, + userAgent: string | null, +): Promise { + await prisma.$transaction(async (tx) => { + const now = new Date(); + const updated = await tx.userSession.updateMany({ + where: { id: sessionId, revokedAt: null }, + data: { revokedAt: now, revokeReason: "EXPIRED" }, + }); + if (updated.count > 0) { + await tx.userSessionEvent.create({ + data: { + sessionId, + type: SessionEventType.EXPIRED, + ipAddress, + userAgent, + }, + }); + } + }); +} + +export async function revokeUserSession(input: { + sessionId: string; + userId: string; + reason: string; + eventType?: SessionEventType; + ipAddress?: string | null; + userAgent?: string | null; +}): Promise { + return prisma.$transaction(async (tx) => { + const now = new Date(); + const updated = await tx.userSession.updateMany({ + where: { id: input.sessionId, userId: input.userId, revokedAt: null }, + data: { revokedAt: now, revokeReason: input.reason }, + }); + if (updated.count === 0) return false; + + await tx.userSessionEvent.create({ + data: { + sessionId: input.sessionId, + type: input.eventType ?? SessionEventType.REVOKED, + ipAddress: input.ipAddress, + userAgent: input.userAgent, + details: { reason: input.reason }, + }, + }); + return true; + }); +} + +export async function revokeOtherUserSessions(input: { + userId: string; + currentSessionId: string; + reason: string; + ipAddress?: string | null; + userAgent?: string | null; +}): Promise { + return prisma.$transaction(async (tx) => { + const now = new Date(); + const sessions = await tx.userSession.updateManyAndReturn({ + where: { + userId: input.userId, + id: { not: input.currentSessionId }, + revokedAt: null, + }, + data: { revokedAt: now, revokeReason: input.reason }, + select: { id: true }, + }); + if (sessions.length === 0) return 0; + + await tx.userSessionEvent.createMany({ + data: sessions.map((session) => ({ + sessionId: session.id, + type: SessionEventType.REVOKED, + ipAddress: input.ipAddress, + userAgent: input.userAgent, + details: { reason: input.reason }, + })), + }); + return sessions.length; + }); +} + +export async function changePasswordAndRevokeOtherSessions(input: { + userId: string; + currentSessionId: string; + passwordHash: string; + ipAddress?: string | null; + userAgent?: string | null; +}): Promise { + return prisma.$transaction(async (tx) => { + await tx.user.update({ + where: { id: input.userId }, + data: { passwordHash: input.passwordHash }, + }); + + const now = new Date(); + const sessions = await tx.userSession.updateManyAndReturn({ + where: { + userId: input.userId, + id: { not: input.currentSessionId }, + revokedAt: null, + }, + data: { revokedAt: now, revokeReason: "PASSWORD_CHANGED" }, + select: { id: true }, + }); + if (sessions.length > 0) { + await tx.userSessionEvent.createMany({ + data: sessions.map((session) => ({ + sessionId: session.id, + type: SessionEventType.REVOKED, + ipAddress: input.ipAddress, + userAgent: input.userAgent, + details: { reason: "PASSWORD_CHANGED" }, + })), + }); + } + return sessions.length; + }); +} + +export async function touchUserSession(input: { + sessionId: string; + lastSeenAt: Date; + sessionTimeoutMinutes: number; + absoluteExpiresAt: Date; + ipAddress: string | null; +}): Promise { + const now = new Date(); + if (now.getTime() - input.lastSeenAt.getTime() < SESSION_TOUCH_INTERVAL_MS) { + return; + } + + await prisma.userSession.updateMany({ + where: { id: input.sessionId, revokedAt: null }, + data: { + lastSeenAt: now, + lastIp: input.ipAddress, + idleExpiresAt: + input.sessionTimeoutMinutes > 0 + ? new Date( + now.getTime() + input.sessionTimeoutMinutes * 60 * 1000, + ) + : input.absoluteExpiresAt, + }, + }); +} diff --git a/tests/backend/container-redeploy-routes.test.ts b/tests/backend/container-redeploy-routes.test.ts index 4d3d98c..fc99c7f 100644 --- a/tests/backend/container-redeploy-routes.test.ts +++ b/tests/backend/container-redeploy-routes.test.ts @@ -28,6 +28,16 @@ test("redeploy endpoints require container write authorization", () => { assert.match(routes.slice(previewStart, previewStart + 180), /containerReadAccess/); }); +test("internal deployment jobs retain browser session authentication", () => { + const helperStart = routes.indexOf("function buildInternalJobHeaders"); + assert.ok(helperStart >= 0, "internal job header builder should exist"); + + const helper = routes.slice(helperStart, helperStart + 900); + assert.match(helper, /"x-doktainer-request": "1"/); + assert.match(helper, /const cookie = headers\.cookie/); + assert.match(helper, /nextHeaders\.cookie = cookie/); +}); + test("redeploy restores the stored current revision without invoking source rebuild", () => { assert.match(redeployService, /redeployContainerCurrentRevision/); assert.match(redeployService, /getRollbackSnapshot\(input\)/); diff --git a/tests/backend/database-session-security.test.ts b/tests/backend/database-session-security.test.ts new file mode 100644 index 0000000..db5361d --- /dev/null +++ b/tests/backend/database-session-security.test.ts @@ -0,0 +1,152 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; + +import { + enforceBrowserMutationProtection, +} from "../../src/server/middleware/auth"; +import { + canRevokeManagedSession, + getSessionCookieName, + hashSessionToken, + normalizeSessionUserAgent, +} from "../../src/server/services/auth-session.service"; + +function createReplyRecorder() { + return { + statusCode: 200, + payload: undefined as unknown, + status(code: number) { + this.statusCode = code; + return this; + }, + send(payload: unknown) { + this.payload = payload; + return this; + }, + }; +} + +test("session tokens are stored as deterministic hashes", () => { + const rawToken = "raw-session-token"; + const tokenHash = hashSessionToken(rawToken); + + assert.equal(tokenHash.length, 64); + assert.equal(tokenHash, hashSessionToken(rawToken)); + assert.notEqual(tokenHash, rawToken); +}); + +test("production uses a host-bound session cookie name", () => { + assert.equal( + getSessionCookieName({ NODE_ENV: "production" } as NodeJS.ProcessEnv), + "__Host-doktainer_session", + ); + assert.equal( + getSessionCookieName({ NODE_ENV: "development" } as NodeJS.ProcessEnv), + "doktainer_session", + ); +}); + +test("session user agents drop control characters and remain bounded", () => { + const normalized = normalizeSessionUserAgent(`Browser\n${"x".repeat(600)}`); + + assert.equal(normalized?.includes("\n"), false); + assert.equal(normalized?.length, 512); +}); + +test("browser mutation protection requires the application header", () => { + const reply = createReplyRecorder(); + const allowed = enforceBrowserMutationProtection( + { method: "POST", headers: {} } as never, + reply as never, + ); + + assert.equal(allowed, false); + assert.equal(reply.statusCode, 403); +}); + +test("browser mutation protection rejects cross-site requests", () => { + const reply = createReplyRecorder(); + const allowed = enforceBrowserMutationProtection( + { + method: "DELETE", + headers: { + "x-doktainer-request": "1", + "sec-fetch-site": "cross-site", + }, + } as never, + reply as never, + ); + + assert.equal(allowed, false); + assert.equal(reply.statusCode, 403); +}); + +test("safe methods and verified same-origin mutations are accepted", () => { + assert.equal( + enforceBrowserMutationProtection( + { method: "GET", headers: {} } as never, + createReplyRecorder() as never, + ), + true, + ); + assert.equal( + enforceBrowserMutationProtection( + { + method: "PATCH", + headers: { + "x-doktainer-request": "1", + "sec-fetch-site": "same-origin", + origin: "http://localhost:3000", + }, + } as never, + createReplyRecorder() as never, + ), + true, + ); +}); + +test("only owners and super admins can revoke a managed login session", () => { + assert.equal( + canRevokeManagedSession({ + actorUserId: "operator-1", + actorRole: "OPERATOR", + targetUserId: "user-2", + }), + false, + ); + assert.equal( + canRevokeManagedSession({ + actorUserId: "operator-1", + actorRole: "OPERATOR", + targetUserId: "operator-1", + }), + true, + ); + assert.equal( + canRevokeManagedSession({ + actorUserId: "admin-1", + actorRole: "SUPER_ADMIN", + targetUserId: "user-2", + }), + true, + ); +}); + +test("session management is a dedicated protected page before Users and RBAC", () => { + const navigation = readFileSync("src/lib/navigation.ts", "utf8"); + const permissions = readFileSync("src/lib/permissions.ts", "utf8"); + const dashboard = readFileSync("src/app/page.tsx", "utf8"); + const page = readFileSync("src/app/sessions/page.tsx", "utf8"); + const routes = readFileSync("src/server/routes/auth.ts", "utf8"); + + assert.ok(navigation.indexOf('href: "/sessions"') < navigation.indexOf('href: "/users"')); + assert.match(permissions, /"\/sessions": "OPERATOR"/); + assert.doesNotMatch(dashboard, /LoginSessionsPanel/); + assert.match(page, /route="\/sessions"/); + assert.match(page, /All statuses/); + assert.match(page, /All users/); + assert.match(routes, /app\.get\("\/sessions", \{ preHandler: \[requireRole\("OPERATOR"\)\]/); + assert.match(routes, /"\/sessions\/:sessionId",\s+\{ preHandler: \[requireRole\("OPERATOR"\)\] \}/); + assert.match(routes, /A browser login session is required/); +}); diff --git a/tests/backend/role-permissions.test.ts b/tests/backend/role-permissions.test.ts index 3935a8f..1abe302 100644 --- a/tests/backend/role-permissions.test.ts +++ b/tests/backend/role-permissions.test.ts @@ -25,7 +25,7 @@ test("viewer role is blocked from write permissions", () => { const allowed = enforceUserRolePermissions( { - authMethod: "jwt", + authMethod: "session", userRole: "VIEWER", } as never, reply as never, @@ -45,7 +45,7 @@ test("viewer role keeps read permissions", () => { const allowed = enforceUserRolePermissions( { - authMethod: "jwt", + authMethod: "session", userRole: "VIEWER", } as never, reply as never, @@ -61,7 +61,7 @@ test("developer role keeps write permissions", () => { const allowed = enforceUserRolePermissions( { - authMethod: "jwt", + authMethod: "session", userRole: "DEVELOPER", } as never, reply as never, diff --git a/tests/security-hardening.test.ts b/tests/security-hardening.test.ts index 860eb87..3181505 100644 --- a/tests/security-hardening.test.ts +++ b/tests/security-hardening.test.ts @@ -2,12 +2,15 @@ import assert from "node:assert/strict"; import test, { afterEach, beforeEach } from "node:test"; import { - getSensitiveStorageItem, - removeSensitiveStorageItem, + clearLegacyAuthStorage, sensitiveStorageKeys, setSensitiveStorageItem, } from "../src/lib/browser-storage.ts"; -import { auth, getToken } from "../src/lib/api.ts"; +import { + auth, + getUser, + setUser, +} from "../src/lib/api.ts"; import { consumeGithubManifestState, encodeGithubManifestState, @@ -141,38 +144,33 @@ afterEach(() => { globalThis.window = originalWindow; }); -test("reads sensitive token storage from localStorage", () => { - window.localStorage.setItem(sensitiveStorageKeys.token, "legacy-token"); +test("removes legacy auth credentials without deleting organization preference", () => { + window.localStorage.setItem("doktainer_token", "legacy-token"); + window.localStorage.setItem("doktainer_user", '{"id":"legacy"}'); + setSensitiveStorageItem(sensitiveStorageKeys.organization, "org-1"); + clearLegacyAuthStorage(); + + assert.equal(window.localStorage.getItem("doktainer_token"), null); + assert.equal(window.localStorage.getItem("doktainer_user"), null); assert.equal( - getSensitiveStorageItem(sensitiveStorageKeys.token), - "legacy-token", + window.localStorage.getItem(sensitiveStorageKeys.organization), + "org-1", ); - assert.equal( - window.localStorage.getItem(sensitiveStorageKeys.token), - "legacy-token", - ); -}); - -test("writes and removes sensitive values in localStorage", () => { - setSensitiveStorageItem(sensitiveStorageKeys.user, '{"id":"new"}'); - - assert.equal( - window.localStorage.getItem(sensitiveStorageKeys.user), - '{"id":"new"}', - ); - - removeSensitiveStorageItem(sensitiveStorageKeys.user); - - assert.equal(window.localStorage.getItem(sensitiveStorageKeys.user), null); }); test("clears stored session and redirects to login on authenticated 401", async () => { - setSensitiveStorageItem(sensitiveStorageKeys.token, "stale-token"); - setSensitiveStorageItem(sensitiveStorageKeys.user, '{"id":"user-1"}'); + setUser({ + id: "user-1", + name: "User", + email: "user@example.com", + role: "OPERATOR", + }); + let requestInit: RequestInit | undefined; - globalThis.fetch = async () => - new Response( + globalThis.fetch = async (_input, init) => { + requestInit = init; + return new Response( JSON.stringify({ success: false, error: "Unauthorized - session expired", @@ -182,11 +180,16 @@ test("clears stored session and redirects to login on authenticated 401", async headers: { "Content-Type": "application/json" }, }, ); + }; - await assert.rejects(() => auth.me(), /Unauthorized - session expired/); + await assert.rejects(() => auth.logout(), /Unauthorized - session expired/); - assert.equal(getToken(), null); - assert.equal(getSensitiveStorageItem(sensitiveStorageKeys.user), null); + assert.equal(getUser(), null); + assert.equal(requestInit?.credentials, "include"); + assert.equal( + (requestInit?.headers as Record)["x-doktainer-request"], + "1", + ); assert.equal(window.location.pathname, "/login"); assert.equal(window.location.search, "?reason=session-expired"); }); diff --git a/tsconfig.json b/tsconfig.json index 1fcceef..78fca1b 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -26,10 +26,9 @@ "next-env.d.ts", "src/**/*.ts", "src/**/*.tsx", - "tests/**/*.ts", ".next/types/**/*.ts", ".next/dev/types/**/*.ts", "**/*.mts" ], - "exclude": ["node_modules", "dist", "**/*.original.tsx"] + "exclude": ["node_modules", "dist", "tests", "**/*.original.tsx"] }