From e9e28a243f6718ed6db52b06049cbc3559574618 Mon Sep 17 00:00:00 2001 From: Stefan Lazic Date: Sun, 6 Sep 2026 20:14:25 +0100 Subject: [PATCH] Installation install token now shown in dashboard until install completes. --- manager/package-lock.json | 1080 ++++++++++++++++- manager/package.json | 6 +- manager/src/__tests__/proxy.test.ts | 66 + .../[slug]/__tests__/actions.test.ts | 133 ++ manager/src/app/dashboard/[slug]/actions.ts | 58 + .../[slug]/installation-token-panel.tsx | 169 +++ manager/src/app/dashboard/[slug]/page.tsx | 6 + manager/src/app/dashboard/page.tsx | 31 +- .../lib/__tests__/compose-template.test.ts | 37 + .../lib/__tests__/installation-status.test.ts | 146 +++ manager/src/lib/__tests__/random.test.ts | 16 + manager/src/lib/installation-status.ts | 94 ++ manager/src/lib/provisioning.ts | 8 +- manager/src/lib/random.ts | 14 + manager/vitest.config.mts | 13 + src/app/api/health/__tests__/route.test.ts | 92 ++ src/app/api/health/route.ts | 17 +- src/lib/installation/installation-state.ts | 6 +- 18 files changed, 1977 insertions(+), 15 deletions(-) create mode 100644 manager/src/__tests__/proxy.test.ts create mode 100644 manager/src/app/dashboard/[slug]/__tests__/actions.test.ts create mode 100644 manager/src/app/dashboard/[slug]/installation-token-panel.tsx create mode 100644 manager/src/lib/__tests__/compose-template.test.ts create mode 100644 manager/src/lib/__tests__/installation-status.test.ts create mode 100644 manager/src/lib/__tests__/random.test.ts create mode 100644 manager/src/lib/installation-status.ts create mode 100644 manager/src/lib/random.ts create mode 100644 manager/vitest.config.mts create mode 100644 src/app/api/health/__tests__/route.test.ts diff --git a/manager/package-lock.json b/manager/package-lock.json index 14f7b8f..85293a9 100644 --- a/manager/package-lock.json +++ b/manager/package-lock.json @@ -27,7 +27,8 @@ "eslint": "^9.39.5", "eslint-config-next": "^16.2.12", "tailwindcss": "^4.3.3", - "typescript": "^6.0.3" + "typescript": "^6.0.3", + "vitest": "^4.1.10" } }, "node_modules/@alloc/quick-lru": { @@ -1384,6 +1385,296 @@ "node": ">=12.4.0" } }, + "node_modules/@oxc-project/types": { + "version": "0.148.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.148.0.tgz", + "integrity": "sha512-Nm4s/jB+4FpFsPhWGEC4h7rzksesmtnMXomo6rCMcg/b8zLQuOziRgkCS1fxDCXOlJB/6Q8oABOZ/OP6RIPj9A==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/oxc-project" + } + }, + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.7.tgz", + "integrity": "sha512-EypzgnYCwyVY4NDHKzGmNJT5b+XaQEBniHxsMdeIQLB/tcCzZnhqrzHpZFbX9iaxx+5RiB8caATBtfvZP7zVxQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.7.tgz", + "integrity": "sha512-l17HE9EweWaqJZhuUuNBN/FzM62xw+DECVnJyvMsxn8vJFAGLy5QfLDoYAcronkAN8VxKZHezDpulHDPx95vFw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.7.tgz", + "integrity": "sha512-8ED8ELFvHXc6OCETIn4gXObPiaR6bckM/ipXtbzlPVDRMBfEGjCKgO90F9YtfdpDatVx/ZQw7aZ1vUMf/+T3Mw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.7.tgz", + "integrity": "sha512-/WPripjtiAIZ2tWY7ddijORT0Ujg87wxWW/qcoFVCKAWVDPhtY0xr7Dj0M3GyNGz60jGwTElhro/mkF9dT7dDQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.7.tgz", + "integrity": "sha512-14DI4NcqpvbICxSnGLx3PmtDaWqRP/KGSGb6C+JLLVPeZRl6dKdHba3pGsqT3vpdTqhEYIPG0MMQ8c0xYqoJxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.7.tgz", + "integrity": "sha512-bxrWIRvHWQvbJwi+VIie/kDJmQxcNE6xxWwZdqF/ExVAigtHkv54WTLQPb+QsZdnFy18fg7JPfWGL0RH6vwIlQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.7.tgz", + "integrity": "sha512-toOY2BChBZyuxU7OYX6Tn389di4IzAqPTycVcci0O7FSfBqzRB3RZn+K5Is6ANf4tmgRd/K1yZTsNTXbkXsnLg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.7.tgz", + "integrity": "sha512-lAIXTH/aiLRLxsTgQvfhjo4K1ydWIp00+V0voOr9beb/9ZmkUFrSIb03dXNFRgMNvkE6oGsF10ioQ6UsI+vS5Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.7.tgz", + "integrity": "sha512-kdnwS28Pkenp/mZMRwjXXXwxQ7pIsm+bF919LUK93BOyhcLsrVKdP2p9fxpiPNPAbNuch8ypQt0pm2P2LYCAGg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.7.tgz", + "integrity": "sha512-516OdsyLdr5E65paF3yBF55t8mfm9+gmtCsK3xI7XKXIT7EfRlHhxL8K/NR6Hu8BWSgF5+1w74lTL0+nxcc8Qw==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.7.tgz", + "integrity": "sha512-r8/z8n7GFaYRln3xmP1Cxy0HH/HLM0uBUPkEuSVEfKGDA89M0FsZRZJRSwe/tJjRx+fpH/gjorfhB8tmEbSFLA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.7.tgz", + "integrity": "sha512-pAsE8iiDxUg1xBqdhrTfg45AVDVpirjz00sblEYClGNNcMnDb+e8beQgqIAw6LvauX/APvgxUnwrgun/YYGBhw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.7.tgz", + "integrity": "sha512-lTcIYmmnQQA8Or/2DatS6oSqcdLHvendjS+zLu+FwgToynWMRSmQdpM65fTANJgIS4mjbMOo5KT2lnT9SAb96w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.7.tgz", + "integrity": "sha512-e3Gu3WxbNk/UqQhxqU7YIYO+9ZBvWNz3U+h/qRFosscMFzdRPbXYSaSWgSnklv2fz1TgzBTcti2z35c/7irsHw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.7.tgz", + "integrity": "sha512-W/jg5qoRSqjsEv0+dZi4e687mcHqmVuU0P4fK6qS/xjetW2Gmc1W8j//z5nAeNcC8Ttm0hV46IjcYeuVwYhuiw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, "node_modules/@rtsao/scc": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", @@ -1391,6 +1682,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, "node_modules/@swc/helpers": { "version": "0.5.23", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.23.tgz", @@ -1701,6 +1999,24 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", @@ -2426,6 +2742,119 @@ "win32" ] }, + "node_modules/@vitest/expect": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.11.tgz", + "integrity": "sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.11", + "@vitest/utils": "4.1.11", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.11.tgz", + "integrity": "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.11", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.11.tgz", + "integrity": "sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.11.tgz", + "integrity": "sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.11", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.11.tgz", + "integrity": "sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.11", + "@vitest/utils": "4.1.11", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.11.tgz", + "integrity": "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.11.tgz", + "integrity": "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.11", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/acorn": { "version": "8.18.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", @@ -2680,6 +3109,16 @@ "safer-buffer": "^2.1.0" } }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/ast-types-flow": { "version": "0.0.8", "resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz", @@ -2911,6 +3350,16 @@ ], "license": "CC-BY-4.0" }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -3331,6 +3780,13 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", + "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", + "dev": true, + "license": "MIT" + }, "node_modules/es-object-atoms": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", @@ -3814,6 +4270,16 @@ "node": ">=4.0" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", @@ -3824,6 +4290,16 @@ "node": ">=0.10.0" } }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -3965,6 +4441,21 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -5705,6 +6196,20 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/obug": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz", + "integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -5814,6 +6319,13 @@ "dev": true, "license": "MIT" }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -6052,6 +6564,40 @@ "node": ">=0.10.0" } }, + "node_modules/rolldown": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.7.tgz", + "integrity": "sha512-g0EtLvBjTUB7jhyV0S/TCup3v/XSVl45vUIGbOGU4QPiyjTenCe4mKuFvW9fEgYmS2Fo42AUssRmNuMziXdrig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.148.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm-eabi": "1.2.7", + "@rolldown/binding-android-arm64": "1.2.7", + "@rolldown/binding-darwin-arm64": "1.2.7", + "@rolldown/binding-darwin-x64": "1.2.7", + "@rolldown/binding-freebsd-x64": "1.2.7", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.7", + "@rolldown/binding-linux-arm64-gnu": "1.2.7", + "@rolldown/binding-linux-arm64-musl": "1.2.7", + "@rolldown/binding-linux-ppc64-gnu": "1.2.7", + "@rolldown/binding-linux-s390x-gnu": "1.2.7", + "@rolldown/binding-linux-x64-gnu": "1.2.7", + "@rolldown/binding-linux-x64-musl": "1.2.7", + "@rolldown/binding-openharmony-arm64": "1.2.7", + "@rolldown/binding-win32-arm64-msvc": "1.2.7", + "@rolldown/binding-win32-x64-msvc": "1.2.7" + } + }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -6384,6 +6930,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -6400,6 +6953,20 @@ "dev": true, "license": "MIT" }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", + "dev": true, + "license": "MIT" + }, "node_modules/stop-iteration-iterator": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", @@ -6621,6 +7188,23 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.1.tgz", + "integrity": "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { "version": "0.2.17", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", @@ -6669,6 +7253,16 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/tinyrainbow": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.1.tgz", + "integrity": "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -6960,6 +7554,473 @@ "punycode": "^2.1.0" } }, + "node_modules/vite": { + "version": "8.2.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.2.tgz", + "integrity": "sha512-cFKLV/PRgAUlIRm5WjMjJ86jrftzpqcgH+Us+DS8mI3CDNiH30Whrz8uHL3+MOLPAgqbMBAqWdAHAphOAM+z/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.26", + "rolldown": "~1.2.4", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.4.0 || ^0.5.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/vite/node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/vitest": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.11.tgz", + "integrity": "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.11", + "@vitest/mocker": "4.1.11", + "@vitest/pretty-format": "4.1.11", + "@vitest/runner": "4.1.11", + "@vitest/snapshot": "4.1.11", + "@vitest/spy": "4.1.11", + "@vitest/utils": "4.1.11", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.11", + "@vitest/browser-preview": "4.1.11", + "@vitest/browser-webdriverio": "4.1.11", + "@vitest/coverage-istanbul": "4.1.11", + "@vitest/coverage-v8": "4.1.11", + "@vitest/ui": "4.1.11", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/vitest/node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/web-push": { "version": "3.6.7", "resolved": "https://registry.npmjs.org/web-push/-/web-push-3.6.7.tgz", @@ -7084,6 +8145,23 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", diff --git a/manager/package.json b/manager/package.json index 215663a..239e0ef 100644 --- a/manager/package.json +++ b/manager/package.json @@ -8,7 +8,8 @@ "build": "next build", "start": "next start", "lint": "eslint .", - "typecheck": "tsc --noEmit" + "typecheck": "tsc --noEmit", + "test": "vitest run" }, "dependencies": { "bcryptjs": "^3.0.3", @@ -30,6 +31,7 @@ "eslint": "^9.39.5", "eslint-config-next": "^16.2.12", "tailwindcss": "^4.3.3", - "typescript": "^6.0.3" + "typescript": "^6.0.3", + "vitest": "^4.1.10" } } diff --git a/manager/src/__tests__/proxy.test.ts b/manager/src/__tests__/proxy.test.ts new file mode 100644 index 0000000..2d5fdf0 --- /dev/null +++ b/manager/src/__tests__/proxy.test.ts @@ -0,0 +1,66 @@ +import { NextRequest } from "next/server"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { createSessionToken, SESSION_COOKIE_NAME } from "@/lib/session"; +import proxy from "../proxy"; + +/** + * The installation-token panel (and its regenerate action) lives under + * /dashboard/[slug] like every other Manager page — it has no auth check + * of its own, relying entirely on this proxy gating every non-/login + * route (see proxy.ts's own header, and manager/README.md's "How auth + * works"). This test exists specifically because this feature is the + * first thing in this app sensitive enough that "only authenticated + * Manager users can view it" is a named requirement, not just incidental. + */ +describe("proxy — Manager auth gate", () => { + const originalSecret = process.env.MANAGER_SESSION_SECRET; + + beforeEach(() => { + process.env.MANAGER_SESSION_SECRET = "test-session-secret-value"; + }); + + afterEach(() => { + if (originalSecret === undefined) delete process.env.MANAGER_SESSION_SECRET; + else process.env.MANAGER_SESSION_SECRET = originalSecret; + }); + + it("redirects to /login when a business dashboard page is requested without a session cookie", () => { + const request = new NextRequest(new URL("http://localhost/dashboard/acme-barbers")); + + const response = proxy(request); + + expect(response.status).toBe(307); + expect(response.headers.get("location")).toBe("http://localhost/login"); + }); + + it("redirects to /login for an invalid/tampered session cookie", () => { + const request = new NextRequest(new URL("http://localhost/dashboard/acme-barbers"), { + headers: { cookie: `${SESSION_COOKIE_NAME}=not-a-real-token` }, + }); + + const response = proxy(request); + + expect(response.status).toBe(307); + expect(response.headers.get("location")).toBe("http://localhost/login"); + }); + + it("passes through to the business dashboard page (where the installation token lives) with a valid session cookie", () => { + const token = createSessionToken("operator@example.com"); + const request = new NextRequest(new URL("http://localhost/dashboard/acme-barbers"), { + headers: { cookie: `${SESSION_COOKIE_NAME}=${token}` }, + }); + + const response = proxy(request); + + // NextResponse.next() carries no redirect / error status. + expect(response.status).toBe(200); + }); + + it("never gates /login itself (or a signed-out operator could never reach the sign-in form)", () => { + const request = new NextRequest(new URL("http://localhost/login")); + + const response = proxy(request); + + expect(response.status).toBe(200); + }); +}); diff --git a/manager/src/app/dashboard/[slug]/__tests__/actions.test.ts b/manager/src/app/dashboard/[slug]/__tests__/actions.test.ts new file mode 100644 index 0000000..c8bf420 --- /dev/null +++ b/manager/src/app/dashboard/[slug]/__tests__/actions.test.ts @@ -0,0 +1,133 @@ +import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const composeUpMock = vi.fn().mockResolvedValue(undefined); +vi.mock("@/lib/docker", () => ({ + composeUp: (...args: unknown[]) => composeUpMock(...args), + composeDown: vi.fn(), + composeLogs: vi.fn(), + composeRestart: vi.fn(), +})); + +const fetchInstallationStatusMock = vi.fn(); +vi.mock("@/lib/installation-status", () => ({ + fetchInstallationStatus: (...args: unknown[]) => fetchInstallationStatusMock(...args), +})); + +vi.mock("next/cache", () => ({ revalidatePath: vi.fn() })); + +import { regenerateInstallationTokenAction } from "../actions"; + +const TOKEN_HEX_PATTERN = /^[a-f0-9]{64}$/; + +describe("regenerateInstallationTokenAction", () => { + let stacksDir: string; + const originalStacksDir = process.env.STACKS_DIR; + + beforeEach(async () => { + stacksDir = await mkdtemp(path.join(tmpdir(), "nc-manager-actions-test-")); + process.env.STACKS_DIR = stacksDir; + composeUpMock.mockClear(); + fetchInstallationStatusMock.mockReset(); + fetchInstallationStatusMock.mockResolvedValue({ status: "not_installed", installedAt: null }); + }); + + afterEach(async () => { + if (originalStacksDir === undefined) delete process.env.STACKS_DIR; + else process.env.STACKS_DIR = originalStacksDir; + await rm(stacksDir, { recursive: true, force: true }); + }); + + async function createStack(slug: string, envContent: string) { + const dir = path.join(stacksDir, slug); + await mkdir(dir, { recursive: true }); + await writeFile(path.join(dir, "compose.yaml"), "name: test\n", "utf8"); + await writeFile(path.join(dir, ".env"), envContent, "utf8"); + return dir; + } + + function formData(fields: Record): FormData { + const fd = new FormData(); + for (const [key, value] of Object.entries(fields)) fd.set(key, value); + return fd; + } + + it("requires confirmation on the first call — does not touch the .env or the container yet", async () => { + await createStack("acme", 'INSTALLATION_TOKEN="old-token"\n'); + + const result = await regenerateInstallationTokenAction("acme", { error: null, needsConfirmation: false, token: null }, formData({})); + + expect(result).toEqual({ error: null, needsConfirmation: true, token: null }); + expect(composeUpMock).not.toHaveBeenCalled(); + const envAfter = await readFile(path.join(stacksDir, "acme", ".env"), "utf8"); + expect(envAfter).toContain("old-token"); + }); + + it("on confirmation, replaces the token, recreates the container, and returns the new token", async () => { + await createStack("acme", 'BUSINESS_NAME="Acme"\nINSTALLATION_TOKEN="old-token"\nAUTH_SECRET="unrelated-secret"\n'); + + const result = await regenerateInstallationTokenAction( + "acme", + { error: null, needsConfirmation: true, token: null }, + formData({ confirmRegenerate: "true" }), + ); + + expect(result.error).toBeNull(); + expect(result.token).toMatch(TOKEN_HEX_PATTERN); + expect(result.token).not.toBe("old-token"); + + const envAfter = await readFile(path.join(stacksDir, "acme", ".env"), "utf8"); + expect(envAfter).toContain(`INSTALLATION_TOKEN="${result.token}"`); + expect(envAfter).not.toContain("old-token"); + // Rewriting the token must not touch unrelated secrets already in the file. + expect(envAfter).toContain('AUTH_SECRET="unrelated-secret"'); + + expect(composeUpMock).toHaveBeenCalledWith(path.join(stacksDir, "acme"), { forceRecreate: true }); + }); + + it("invalidates the previous token — the old value no longer appears anywhere in the business's config", async () => { + await createStack("acme", 'INSTALLATION_TOKEN="old-token-to-invalidate"\n'); + + await regenerateInstallationTokenAction("acme", { error: null, needsConfirmation: true, token: null }, formData({ confirmRegenerate: "true" })); + + const envAfter = await readFile(path.join(stacksDir, "acme", ".env"), "utf8"); + expect(envAfter).not.toContain("old-token-to-invalidate"); + }); + + it("refuses to regenerate once installation is complete, even if the form is (re)submitted", async () => { + await createStack("acme", 'INSTALLATION_TOKEN="old-token"\n'); + fetchInstallationStatusMock.mockResolvedValue({ status: "installed", installedAt: "2026-01-01T00:00:00.000Z" }); + + const result = await regenerateInstallationTokenAction( + "acme", + { error: null, needsConfirmation: true, token: null }, + formData({ confirmRegenerate: "true" }), + ); + + expect(result.error).toMatch(/already completed installation/i); + expect(result.token).toBeNull(); + expect(composeUpMock).not.toHaveBeenCalled(); + const envAfter = await readFile(path.join(stacksDir, "acme", ".env"), "utf8"); + expect(envAfter).toContain("old-token"); + }); + + it("tenant isolation: regenerating one business's token never touches a sibling business's .env", async () => { + await createStack("tenant-a", 'INSTALLATION_TOKEN="token-a"\n'); + await createStack("tenant-b", 'INSTALLATION_TOKEN="token-b"\n'); + + await regenerateInstallationTokenAction("tenant-a", { error: null, needsConfirmation: true, token: null }, formData({ confirmRegenerate: "true" })); + + const envB = await readFile(path.join(stacksDir, "tenant-b", ".env"), "utf8"); + expect(envB).toContain('INSTALLATION_TOKEN="token-b"'); + expect(composeUpMock).toHaveBeenCalledTimes(1); + expect(composeUpMock).toHaveBeenCalledWith(path.join(stacksDir, "tenant-a"), { forceRecreate: true }); + }); + + it("rejects an unknown/nonexistent slug", async () => { + await expect( + regenerateInstallationTokenAction("does-not-exist", { error: null, needsConfirmation: true, token: null }, formData({ confirmRegenerate: "true" })), + ).rejects.toThrow(/no such stack/i); + }); +}); diff --git a/manager/src/app/dashboard/[slug]/actions.ts b/manager/src/app/dashboard/[slug]/actions.ts index 82841e4..bd59c7d 100644 --- a/manager/src/app/dashboard/[slug]/actions.ts +++ b/manager/src/app/dashboard/[slug]/actions.ts @@ -1,7 +1,11 @@ "use server"; +import path from "node:path"; import { revalidatePath } from "next/cache"; import { composeDown, composeLogs, composeRestart, composeUp } from "@/lib/docker"; +import { upsertEnvValues } from "@/lib/env-file"; +import { fetchInstallationStatus } from "@/lib/installation-status"; +import { randomHex } from "@/lib/random"; import { isValidSlug, stackDir, stackExists } from "@/lib/stacks"; async function assertStack(slug: string): Promise { @@ -40,3 +44,57 @@ export async function fetchLogsAction(slug: string): Promise { const dir = await assertStack(slug); return composeLogs(dir, 200); } + +export interface RegenerateTokenState { + error: string | null; + needsConfirmation: boolean; + /** The freshly-generated token, present only right after a successful regenerate — the panel re-reads it fresh on next load otherwise, same as every other value on this page. */ + token: string | null; +} + +/** + * Re-issues INSTALLATION_TOKEN for a business that hasn't completed + * installation yet — same two-step confirm shape as + * dashboard/settings/actions.ts's generateVapidKeysAction: the first + * submit (no `confirmRegenerate` field) comes back with + * `needsConfirmation: true` instead of acting, and only a second submit + * with `confirmRegenerate=true` actually rotates the token. + * + * Refuses once installation is complete (checked here too, not just + * hidden in the UI — defense in depth against a stale/re-submitted + * form). Uses the exact same `randomHex` generator provisioning.ts + * itself uses, so this is a rotation of the one existing token + * mechanism, not a second one; `upsertEnvValues` (also already used by + * updateBusiness) rewrites only the one line, leaving every other + * secret in this business's .env untouched. A container recreate is + * required afterward — env vars are only read at container start (see + * docker/entrypoint.sh), never hot-reloaded. + */ +export async function regenerateInstallationTokenAction( + slug: string, + _prevState: RegenerateTokenState, + formData: FormData, +): Promise { + const dir = await assertStack(slug); + + const { status } = await fetchInstallationStatus(slug); + if (status === "installed") { + return { + error: "This business has already completed installation — its installation token can no longer be regenerated.", + needsConfirmation: false, + token: null, + }; + } + + const confirmRegenerate = formData.get("confirmRegenerate") === "true"; + if (!confirmRegenerate) { + return { error: null, needsConfirmation: true, token: null }; + } + + const token = randomHex(32); + await upsertEnvValues(path.join(dir, ".env"), { INSTALLATION_TOKEN: token }); + await composeUp(dir, { forceRecreate: true }); + revalidatePath(`/dashboard/${slug}`); + + return { error: null, needsConfirmation: false, token }; +} diff --git a/manager/src/app/dashboard/[slug]/installation-token-panel.tsx b/manager/src/app/dashboard/[slug]/installation-token-panel.tsx new file mode 100644 index 0000000..892425f --- /dev/null +++ b/manager/src/app/dashboard/[slug]/installation-token-panel.tsx @@ -0,0 +1,169 @@ +"use client"; + +import { useActionState, useState } from "react"; +import type { InstallationView } from "@/lib/installation-status"; +import { regenerateInstallationTokenAction, type RegenerateTokenState } from "./actions"; + +const regenerateInitialState: RegenerateTokenState = { error: null, needsConfirmation: false, token: null }; + +function formatInstalledAt(installedAt: string | null): string | null { + if (!installedAt) return null; + const date = new Date(installedAt); + if (Number.isNaN(date.getTime())) return null; + return date.toLocaleString(); +} + +function CopyButton({ value }: { value: string }) { + const [copied, setCopied] = useState(false); + + return ( + + ); +} + +/** + * Renders the per-business "Installation" section on the dashboard — + * see installation-status.ts's resolveInstallationView for how `view` is + * computed. Everything here is keyed off `view`/`slug`, both scoped to + * this one business — there is no code path here that could render one + * tenant's token under another tenant's page. + */ +export function InstallationTokenPanel({ slug, view }: { slug: string; view: InstallationView }) { + const [regenerateState, regenerateAction, isRegenerating] = useActionState( + regenerateInstallationTokenAction.bind(null, slug), + regenerateInitialState, + ); + + const currentToken = regenerateState.token ?? (view.kind !== "installed" ? view.token : null); + + return ( +
+

Installation

+ + {view.kind === "installed" && ( +
+
+
Status:
+
Installed
+
+ {formatInstalledAt(view.installedAt) && ( +
+
Installed at:
+
{formatInstalledAt(view.installedAt)}
+
+ )} +
+ )} + + {view.kind !== "installed" && ( +
+
+
+
Status:
+
Not installed
+
+ {view.kind === "unknown" && ( +

+ Can't confirm this business's status right now — its container may be stopped or still + starting. +

+ )} +
+ + {view.installUrl ? ( +
+

Installation URL:

+ + {view.installUrl} + +
+ ) : ( +

No domain set yet — set one in Edit before installing.

+ )} + +
+

Installation token:

+ {currentToken ? ( +
+ e.currentTarget.select()} + className="w-full rounded-md border border-line bg-stone px-3 py-2 font-mono text-xs" + /> + +
+ ) : ( +

+ No installation token found in this business's .env — generate one below. +

+ )} +
+ + {regenerateState.needsConfirmation && ( +
+

Regenerate installation token?

+

The existing installation token will stop working.

+
+ )} + +
+ {view.installUrl && ( + + Open installer + + )} + +
+ + +
+
+ + {regenerateState.error && ( +

+ {regenerateState.error} +

+ )} + {regenerateState.token && !regenerateState.error && ( +

New token generated and the container was recreated to pick it up.

+ )} +
+ )} +
+ ); +} diff --git a/manager/src/app/dashboard/[slug]/page.tsx b/manager/src/app/dashboard/[slug]/page.tsx index a08fbbd..e4b1342 100644 --- a/manager/src/app/dashboard/[slug]/page.tsx +++ b/manager/src/app/dashboard/[slug]/page.tsx @@ -1,7 +1,9 @@ import Link from "next/link"; import { notFound } from "next/navigation"; +import { resolveInstallationView } from "@/lib/installation-status"; import { listBusinessStacks } from "@/lib/stacks"; import { BusinessActions } from "./business-actions"; +import { InstallationTokenPanel } from "./installation-token-panel"; export const dynamic = "force-dynamic"; @@ -19,6 +21,8 @@ export default async function BusinessDetailPage({ const stack = stacks.find((s) => s.slug === slug); if (!stack) notFound(); + const installationView = await resolveInstallationView(slug, stack.domain); + return (
{justCreated && ( @@ -58,6 +62,8 @@ export default async function BusinessDetailPage({
+ + ); } diff --git a/manager/src/app/dashboard/page.tsx b/manager/src/app/dashboard/page.tsx index 72fb67e..3509161 100644 --- a/manager/src/app/dashboard/page.tsx +++ b/manager/src/app/dashboard/page.tsx @@ -1,4 +1,5 @@ import Link from "next/link"; +import { fetchInstallationStatus } from "@/lib/installation-status"; import { listBusinessStacks } from "@/lib/stacks"; export const dynamic = "force-dynamic"; @@ -12,6 +13,23 @@ function StatusPill({ status }: { status: "running" | "stopped" | "unknown" }) { return {status}; } +/** + * Only ever the two labels from the task's own example — the nuance + * between "confirmed not installed" and "container unreachable, can't + * confirm" (see installation-status.ts's three-state model) only matters + * on the business detail page where the token itself lives. Never shows + * the token — see resolveInstallationView, not used here. + */ +function InstallPill({ installed }: { installed: boolean }) { + return ( + + {installed ? "Installed" : "Setup required"} + + ); +} + export default async function DashboardPage({ searchParams, }: { @@ -20,6 +38,12 @@ export default async function DashboardPage({ const { justDeleted, warnings } = await searchParams; const stacks = await listBusinessStacks(); + // Each check is independently timeout-bounded (see + // installation-status.ts) so one stopped/dead business's container + // can't stall this whole page — resolved in parallel rather than one + // at a time. + const installStatuses = await Promise.all(stacks.map((stack) => fetchInstallationStatus(stack.slug))); + return (
{justDeleted && ( @@ -45,7 +69,7 @@ export default async function DashboardPage({

No businesses provisioned yet.

) : (
    - {stacks.map((stack) => ( + {stacks.map((stack, index) => (
  • {stack.name ?? stack.slug}

    {stack.domain ?? "no domain set"}

- +
+ + +
))} diff --git a/manager/src/lib/__tests__/compose-template.test.ts b/manager/src/lib/__tests__/compose-template.test.ts new file mode 100644 index 0000000..2fb7dfe --- /dev/null +++ b/manager/src/lib/__tests__/compose-template.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from "vitest"; +import { generateBusinessEnv, type BusinessEnvValues } from "../compose-template"; + +const baseValues: BusinessEnvValues = { + name: "Acme Barbers", + domain: "acme.example.com", + databaseName: "acme", + databaseUser: "acme", + databasePassword: "db-password", + authSecret: "auth-secret", + installationToken: "the-installation-token-value", + cronSecret: "cron-secret", +}; + +describe("generateBusinessEnv — INSTALLATION_TOKEN", () => { + it("writes the given installation token into the generated .env", () => { + const env = generateBusinessEnv(baseValues); + expect(env).toContain('INSTALLATION_TOKEN="the-installation-token-value"'); + }); + + it("never writes the token in a NEXT_PUBLIC_* (build-time-inlined) variable", () => { + const env = generateBusinessEnv(baseValues); + const publicLines = env.split("\n").filter((line) => line.startsWith("NEXT_PUBLIC_")); + for (const line of publicLines) { + expect(line).not.toContain("the-installation-token-value"); + } + }); + + it("is the only place a business's INSTALLATION_TOKEN gets written — every business gets a distinct token if given one", () => { + const envA = generateBusinessEnv({ ...baseValues, installationToken: "token-a" }); + const envB = generateBusinessEnv({ ...baseValues, domain: "other.example.com", installationToken: "token-b" }); + expect(envA).toContain('INSTALLATION_TOKEN="token-a"'); + expect(envB).toContain('INSTALLATION_TOKEN="token-b"'); + expect(envA).not.toContain("token-b"); + expect(envB).not.toContain("token-a"); + }); +}); diff --git a/manager/src/lib/__tests__/installation-status.test.ts b/manager/src/lib/__tests__/installation-status.test.ts new file mode 100644 index 0000000..d38c7be --- /dev/null +++ b/manager/src/lib/__tests__/installation-status.test.ts @@ -0,0 +1,146 @@ +import { mkdtemp, rm, writeFile, mkdir } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { fetchInstallationStatus, getInstallationToken, resolveInstallationView } from "../installation-status"; + +describe("fetchInstallationStatus", () => { + const originalFetch = globalThis.fetch; + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + it("reports installed with installedAt when the tenant's /api/health says installed: true", async () => { + globalThis.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ status: "healthy", database: "reachable", installed: true, installedAt: "2026-01-15T10:00:00.000Z" }), + }) as unknown as typeof fetch; + + const result = await fetchInstallationStatus("acme"); + expect(result).toEqual({ status: "installed", installedAt: "2026-01-15T10:00:00.000Z" }); + }); + + it("reports not_installed (with no installedAt) when installed: false", async () => { + globalThis.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ status: "healthy", database: "reachable", installed: false, installedAt: null }), + }) as unknown as typeof fetch; + + const result = await fetchInstallationStatus("acme"); + expect(result).toEqual({ status: "not_installed", installedAt: null }); + }); + + it("reports unknown — never not_installed — when the fetch throws (container down/unreachable)", async () => { + globalThis.fetch = vi.fn().mockRejectedValue(new Error("ECONNREFUSED")); + + const result = await fetchInstallationStatus("acme"); + expect(result).toEqual({ status: "unknown", installedAt: null }); + }); + + it("reports unknown when the response isn't ok (e.g. 503 unhealthy)", async () => { + globalThis.fetch = vi.fn().mockResolvedValue({ ok: false, json: async () => ({}) }) as unknown as typeof fetch; + + const result = await fetchInstallationStatus("acme"); + expect(result).toEqual({ status: "unknown", installedAt: null }); + }); + + it("reports unknown for a malformed body (no boolean `installed` field)", async () => { + globalThis.fetch = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ unexpected: true }) }) as unknown as typeof fetch; + + const result = await fetchInstallationStatus("acme"); + expect(result).toEqual({ status: "unknown", installedAt: null }); + }); + + it("targets the internal docker network hostname for the given slug, not the public domain", async () => { + const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ installed: false }) }); + globalThis.fetch = fetchMock as unknown as typeof fetch; + + await fetchInstallationStatus("acme-barbers"); + + expect(fetchMock).toHaveBeenCalledWith("http://next-chair-acme-barbers:3000/api/health", expect.any(Object)); + }); +}); + +describe("getInstallationToken / resolveInstallationView", () => { + let stacksDir: string; + const originalStacksDir = process.env.STACKS_DIR; + const originalFetch = globalThis.fetch; + + beforeEach(async () => { + stacksDir = await mkdtemp(path.join(tmpdir(), "nc-manager-test-")); + process.env.STACKS_DIR = stacksDir; + }); + + afterEach(async () => { + globalThis.fetch = originalFetch; + if (originalStacksDir === undefined) delete process.env.STACKS_DIR; + else process.env.STACKS_DIR = originalStacksDir; + await rm(stacksDir, { recursive: true, force: true }); + }); + + async function writeStackEnv(slug: string, envContent: string) { + const dir = path.join(stacksDir, slug); + await mkdir(dir, { recursive: true }); + await writeFile(path.join(dir, ".env"), envContent, "utf8"); + } + + it("reads the existing token straight from that business's own .env — an already-generated token is preserved, not regenerated", async () => { + await writeStackEnv("acme", 'BUSINESS_NAME="Acme"\nINSTALLATION_TOKEN="existing-token-value"\n'); + + const token = await getInstallationToken("acme"); + expect(token).toBe("existing-token-value"); + }); + + it("returns null (not an error) for a business with no token in its .env", async () => { + await writeStackEnv("legacy-biz", 'BUSINESS_NAME="Legacy"\n'); + + const token = await getInstallationToken("legacy-biz"); + expect(token).toBeNull(); + }); + + it("returns null for a business directory that doesn't exist", async () => { + const token = await getInstallationToken("does-not-exist"); + expect(token).toBeNull(); + }); + + it("tenant isolation: never returns another business's token", async () => { + await writeStackEnv("tenant-a", 'INSTALLATION_TOKEN="token-for-a"\n'); + await writeStackEnv("tenant-b", 'INSTALLATION_TOKEN="token-for-b"\n'); + + expect(await getInstallationToken("tenant-a")).toBe("token-for-a"); + expect(await getInstallationToken("tenant-b")).toBe("token-for-b"); + }); + + it("resolveInstallationView never includes a token once installed — the token is not even read from disk in that branch", async () => { + await writeStackEnv("acme", 'INSTALLATION_TOKEN="should-never-appear"\n'); + globalThis.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ installed: true, installedAt: "2026-01-15T10:00:00.000Z" }), + }) as unknown as typeof fetch; + + const view = await resolveInstallationView("acme", "acme.example.com"); + + expect(view).toEqual({ kind: "installed", installedAt: "2026-01-15T10:00:00.000Z" }); + expect(JSON.stringify(view)).not.toContain("should-never-appear"); + }); + + it("resolveInstallationView surfaces the token pre-install, with the public install URL", async () => { + await writeStackEnv("acme", 'INSTALLATION_TOKEN="real-token"\n'); + globalThis.fetch = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ installed: false }) }) as unknown as typeof fetch; + + const view = await resolveInstallationView("acme", "acme.example.com"); + + expect(view).toEqual({ kind: "not_installed", token: "real-token", installUrl: "https://acme.example.com/install" }); + }); + + it("resolveInstallationView still surfaces the token (not hidden) when status can't be confirmed", async () => { + await writeStackEnv("acme", 'INSTALLATION_TOKEN="real-token"\n'); + globalThis.fetch = vi.fn().mockRejectedValue(new Error("ECONNREFUSED")); + + const view = await resolveInstallationView("acme", "acme.example.com"); + + expect(view.kind).toBe("unknown"); + expect(view).toMatchObject({ token: "real-token" }); + }); +}); diff --git a/manager/src/lib/__tests__/random.test.ts b/manager/src/lib/__tests__/random.test.ts new file mode 100644 index 0000000..f7b9bae --- /dev/null +++ b/manager/src/lib/__tests__/random.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from "vitest"; +import { randomHex } from "../random"; + +describe("randomHex", () => { + it("returns a hex string of the requested byte length", () => { + const value = randomHex(32); + expect(value).toMatch(/^[a-f0-9]+$/); + expect(value).toHaveLength(64); + }); + + it("is sufficiently long/unpredictable — two calls never collide", () => { + const a = randomHex(32); + const b = randomHex(32); + expect(a).not.toBe(b); + }); +}); diff --git a/manager/src/lib/installation-status.ts b/manager/src/lib/installation-status.ts new file mode 100644 index 0000000..cb9db6c --- /dev/null +++ b/manager/src/lib/installation-status.ts @@ -0,0 +1,94 @@ +import path from "node:path"; +import { readEnvFile, readEnvValue } from "./env-file"; +import { stackDir } from "./stacks"; + +export type TenantInstallState = "installed" | "not_installed" | "unknown"; + +export interface InstallationStatusResult { + status: TenantInstallState; + installedAt: string | null; +} + +const HEALTH_CHECK_TIMEOUT_MS = 2500; + +/** + * Asks the tenant's OWN app whether it has completed installation, via + * the existing public `GET /api/health` route (see that route's own + * header) — fetched over the internal `next-chair-edge` Docker network, + * the exact same `next-chair-:3000` hostname:port Caddy itself + * reverse-proxies to (see compose-template.ts's `generateCaddySite`). + * Manager has no database of its own and must not gain one just to + * answer this question; `installed`/`installedAt` are already the + * tenant's own source of truth (`ApplicationInstallation`, that + * business's own MySQL DB) — this only reads it, never duplicates it. + * + * Deliberately three states, not two: an unreachable container (stopped, + * still starting, mid `--force-recreate`) must never be silently treated + * as "not installed" — that would be inferring installation state from + * container state, which is exactly the anti-pattern to avoid. + * `"unknown"` means genuinely "can't confirm right now", not "not + * installed". + */ +export async function fetchInstallationStatus(slug: string): Promise { + try { + const response = await fetch(`http://next-chair-${slug}:3000/api/health`, { + cache: "no-store", + signal: AbortSignal.timeout(HEALTH_CHECK_TIMEOUT_MS), + }); + if (!response.ok) return { status: "unknown", installedAt: null }; + + const body: unknown = await response.json(); + if (!body || typeof body !== "object" || typeof (body as { installed?: unknown }).installed !== "boolean") { + return { status: "unknown", installedAt: null }; + } + const { installed, installedAt } = body as { installed: boolean; installedAt?: unknown }; + if (!installed) return { status: "not_installed", installedAt: null }; + return { status: "installed", installedAt: typeof installedAt === "string" ? installedAt : null }; + } catch { + // Network error, DNS failure (container not running yet), or the + // AbortSignal timeout firing — all collapse to "unknown", never + // "not_installed". + return { status: "unknown", installedAt: null }; + } +} + +/** + * Only ever reads the one field it's asked for, from that one business's + * own `.env` — same isolation guarantee every other per-business read in + * this app already has (see stacks.ts's `listBusinessStacks`, which + * reads BUSINESS_NAME/DOMAIN/DATABASE_NAME the same way). Returns null + * for a business with no token at all (hand-set-up outside Manager, or a + * business provisioned before INSTALLATION_TOKEN existed) rather than + * throwing. + */ +export async function getInstallationToken(slug: string): Promise { + const envContent = await readEnvFile(path.join(stackDir(slug), ".env")); + if (!envContent) return null; + return readEnvValue(envContent, "INSTALLATION_TOKEN"); +} + +export type InstallationView = + | { kind: "installed"; installedAt: string | null } + | { kind: "not_installed"; token: string | null; installUrl: string | null } + | { kind: "unknown"; token: string | null; installUrl: string | null }; + +/** + * Combines the live health check with a direct `.env` read into exactly + * what the dashboard needs to render — pulled out as its own function + * (rather than inlined in the page component) so "the token never + * appears once installed" is a plain, unit-testable fact about this + * function's return value, not something that only shows up by + * inspecting rendered JSX. In the `"installed"` branch the token is + * never even read off disk — not just omitted from the return value. + */ +export async function resolveInstallationView(slug: string, domain: string | null): Promise { + const installUrl = domain ? `https://${domain}/install` : null; + const { status, installedAt } = await fetchInstallationStatus(slug); + + if (status === "installed") { + return { kind: "installed", installedAt }; + } + + const token = await getInstallationToken(slug); + return { kind: status === "unknown" ? "unknown" : "not_installed", token, installUrl }; +} diff --git a/manager/src/lib/provisioning.ts b/manager/src/lib/provisioning.ts index 7800f2d..0730e09 100644 --- a/manager/src/lib/provisioning.ts +++ b/manager/src/lib/provisioning.ts @@ -1,4 +1,3 @@ -import { randomBytes } from "node:crypto"; import { mkdir, rm, writeFile } from "node:fs/promises"; import path from "node:path"; import mariadb, { type Connection } from "mariadb"; @@ -6,17 +5,14 @@ import { generateBusinessEnv, generateCaddySite, generateComposeYaml } from "./c import { composeDown, composeUp, reloadCaddy, runMigrations } from "./docker"; import { readEnvFile, readEnvValue, upsertEnvValues } from "./env-file"; import { isEmailConfigComplete, isPushConfigComplete, resolveEmailSettings, resolvePushSettings } from "./global-settings"; +import { randomHex } from "./random"; import { isValidSlug, listBusinessStacks, stackDir, stackExists, stacksRoot } from "./stacks"; /** Hard safety check on top of isValidSlug (which already constrains the slug itself) — this is the one place that string ends up inside a raw SQL statement, where an identifier can't be parameter-bound the way a value can. */ const SAFE_DB_IDENTIFIER = /^[a-z][a-z0-9_]{1,48}[a-z0-9]$/; -/** Generated via randomBytes/hex below — always matches this, checked anyway as defense in depth before it goes into a SQL string literal. */ +/** Generated via randomHex below — always matches this, checked anyway as defense in depth before it goes into a SQL string literal. */ const SAFE_GENERATED_SECRET = /^[a-f0-9]+$/; -function randomHex(bytes: number): string { - return randomBytes(bytes).toString("hex"); -} - /** Free text (business name), but it ends up inside a "KEY="value"" .env line — reject anything that could break out of that. */ function assertSafeDisplayText(value: string, fieldLabel: string): void { if (!value.trim()) throw new Error(`${fieldLabel} is required.`); diff --git a/manager/src/lib/random.ts b/manager/src/lib/random.ts new file mode 100644 index 0000000..ff069e6 --- /dev/null +++ b/manager/src/lib/random.ts @@ -0,0 +1,14 @@ +import { randomBytes } from "node:crypto"; + +/** + * Cryptographically secure hex string of `bytes` random bytes (i.e. + * `bytes * 2` hex characters) — the one generator used for every secret + * this app creates on a business's behalf (database password, + * `AUTH_SECRET`, `INSTALLATION_TOKEN`, `CRON_SECRET`). Pulled out of + * provisioning.ts into its own module so a later rotate/regenerate + * action (e.g. re-issuing `INSTALLATION_TOKEN`) reuses this exact + * mechanism instead of a second, duplicate one. + */ +export function randomHex(bytes: number): string { + return randomBytes(bytes).toString("hex"); +} diff --git a/manager/vitest.config.mts b/manager/vitest.config.mts new file mode 100644 index 0000000..9afb12a --- /dev/null +++ b/manager/vitest.config.mts @@ -0,0 +1,13 @@ +import { defineConfig } from "vitest/config"; + +// Manager's own Vitest setup — separate from the root app's +// vitest.config.mts (which deliberately excludes manager/**), matching +// this app's own "fully separate, own package.json/build" convention +// (see manager/README.md). Same shape as the root config otherwise. +export default defineConfig({ + resolve: { tsconfigPaths: true }, + test: { + environment: "node", + globals: false, + }, +}); diff --git a/src/app/api/health/__tests__/route.test.ts b/src/app/api/health/__tests__/route.test.ts new file mode 100644 index 0000000..03cabac --- /dev/null +++ b/src/app/api/health/__tests__/route.test.ts @@ -0,0 +1,92 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const queryRawMock = vi.fn(); +vi.mock("@/lib/db/prisma", () => ({ + prisma: { $queryRaw: (...args: unknown[]) => queryRawMock(...args) }, +})); + +const getInstallationStateMock = vi.fn(); +vi.mock("@/lib/installation/installation-state", () => ({ + getInstallationState: () => getInstallationStateMock(), +})); + +import { GET } from "../route"; + +/** + * This is a public, unauthenticated endpoint (see the route's own header) + * — the one contract that matters is that it never leaks anything beyond + * status/database/installed/installedAt, in particular never the + * installation token or any other tenant data. Manager (a separate app + * with no database access of its own) relies on `installed`/`installedAt` + * here to decide whether to still show that business's installation + * token, so those two fields also need to behave correctly. + */ +describe("GET /api/health", () => { + beforeEach(() => { + queryRawMock.mockReset(); + getInstallationStateMock.mockReset(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("returns unhealthy with 503 when the database is unreachable, without calling installation-state", async () => { + queryRawMock.mockRejectedValueOnce(new Error("connection refused")); + + const response = await GET(); + + expect(response.status).toBe(503); + const body = await response.json(); + expect(body).toEqual({ status: "unhealthy", database: "unreachable" }); + expect(getInstallationStateMock).not.toHaveBeenCalled(); + }); + + it("reports installed: false and installedAt: null before installation", async () => { + queryRawMock.mockResolvedValueOnce([{ 1: 1 }]); + getInstallationStateMock.mockResolvedValueOnce({ installed: false, status: "NOT_INSTALLED", installedAt: null }); + + const response = await GET(); + + expect(response.status).toBe(200); + const body = await response.json(); + expect(body).toEqual({ status: "healthy", database: "reachable", installed: false, installedAt: null }); + }); + + it("reports installed: true and an ISO installedAt once installed", async () => { + queryRawMock.mockResolvedValueOnce([{ 1: 1 }]); + const installedAt = new Date("2026-01-15T10:30:00.000Z"); + getInstallationStateMock.mockResolvedValueOnce({ installed: true, status: "INSTALLED", installedAt }); + + const response = await GET(); + + const body = await response.json(); + expect(body).toEqual({ + status: "healthy", + database: "reachable", + installed: true, + installedAt: "2026-01-15T10:30:00.000Z", + }); + }); + + it("falls back to installed: false, installedAt: null rather than throwing if installation-state itself errors", async () => { + queryRawMock.mockResolvedValueOnce([{ 1: 1 }]); + getInstallationStateMock.mockRejectedValueOnce(new Error("boom")); + + const response = await GET(); + + expect(response.status).toBe(200); + const body = await response.json(); + expect(body).toEqual({ status: "healthy", database: "reachable", installed: false, installedAt: null }); + }); + + it("never returns anything beyond status/database/installed/installedAt — no token, no other tenant data", async () => { + queryRawMock.mockResolvedValueOnce([{ 1: 1 }]); + getInstallationStateMock.mockResolvedValueOnce({ installed: true, status: "INSTALLED", installedAt: new Date() }); + + const response = await GET(); + const body = await response.json(); + + expect(Object.keys(body).sort()).toEqual(["database", "installed", "installedAt", "status"]); + }); +}); diff --git a/src/app/api/health/route.ts b/src/app/api/health/route.ts index 665f9da..0ea2d92 100644 --- a/src/app/api/health/route.ts +++ b/src/app/api/health/route.ts @@ -9,6 +9,14 @@ import { getInstallationState } from "@/lib/installation/installation-state"; * show the installer), not unhealthy. Only a database connectivity * failure returns a non-200 status. Never returns secrets or a stack * trace — just enough for a health probe to act on. + * + * `installed`/`installedAt` are also this deployment's only externally + * observable installation-status signal — NextChair Manager (a separate + * app with no database access of its own) polls this over the internal + * Docker network to decide whether to still show that business's + * installation token, rather than Manager needing its own authenticated + * status endpoint. Deliberately still just a boolean + a timestamp: never + * the installation token itself, never anything else about the tenant. */ export async function GET() { try { @@ -17,7 +25,12 @@ export async function GET() { return NextResponse.json({ status: "unhealthy", database: "unreachable" }, { status: 503 }); } - const { installed } = await getInstallationState().catch(() => ({ installed: false })); + const { installed, installedAt } = await getInstallationState().catch(() => ({ installed: false, installedAt: null })); - return NextResponse.json({ status: "healthy", database: "reachable", installed }); + return NextResponse.json({ + status: "healthy", + database: "reachable", + installed, + installedAt: installedAt ? installedAt.toISOString() : null, + }); } diff --git a/src/lib/installation/installation-state.ts b/src/lib/installation/installation-state.ts index b868909..9260c58 100644 --- a/src/lib/installation/installation-state.ts +++ b/src/lib/installation/installation-state.ts @@ -12,6 +12,8 @@ export const INSTALLATION_SINGLETON_ID = "singleton"; export interface InstallationState { installed: boolean; status: InstallationStatus; + /** When `status === INSTALLED`, else null. Surfaced by GET /api/health so an external tool (e.g. NextChair Manager) can show it without ever needing DB access of its own — see that route's own header. */ + installedAt: Date | null; } /** @@ -25,10 +27,10 @@ export interface InstallationState { export const getInstallationState = cache(async (): Promise => { const row = await prisma.applicationInstallation.findUnique({ where: { id: INSTALLATION_SINGLETON_ID }, - select: { status: true }, + select: { status: true, installedAt: true }, }); const status = row?.status ?? InstallationStatus.NOT_INSTALLED; - return { installed: status === InstallationStatus.INSTALLED, status }; + return { installed: status === InstallationStatus.INSTALLED, status, installedAt: row?.installedAt ?? null }; }); /**