From 48d0c4ef864390e444e6274669ddab915053c9df Mon Sep 17 00:00:00 2001 From: dev_agent <17+dev_agent@code.cubecraftcreations.com> Date: Wed, 23 Sep 2026 16:39:36 -0400 Subject: [PATCH 01/13] Task 0df715b5: React scaffold, build and serve (#187) --- admin/.gitignore | 2 + admin/index.html | 12 + admin/package-lock.json | 3024 +++++++++++++++++++++++++++++++++++++++ admin/package.json | 27 + admin/src/App.css | 70 + admin/src/App.test.tsx | 33 + admin/src/App.tsx | 49 + admin/src/index.css | 39 + admin/src/main.tsx | 10 + admin/tsconfig.json | 16 + admin/vite.config.ts | 9 + 11 files changed, 3291 insertions(+) create mode 100644 admin/.gitignore create mode 100644 admin/index.html create mode 100644 admin/package-lock.json create mode 100644 admin/package.json create mode 100644 admin/src/App.css create mode 100644 admin/src/App.test.tsx create mode 100644 admin/src/App.tsx create mode 100644 admin/src/index.css create mode 100644 admin/src/main.tsx create mode 100644 admin/tsconfig.json create mode 100644 admin/vite.config.ts diff --git a/admin/.gitignore b/admin/.gitignore new file mode 100644 index 0000000..04c01ba --- /dev/null +++ b/admin/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +dist/ \ No newline at end of file diff --git a/admin/index.html b/admin/index.html new file mode 100644 index 0000000..2ecc421 --- /dev/null +++ b/admin/index.html @@ -0,0 +1,12 @@ + + + + + + NestQuest Admin + + +
+ + + \ No newline at end of file diff --git a/admin/package-lock.json b/admin/package-lock.json new file mode 100644 index 0000000..1baf4ca --- /dev/null +++ b/admin/package-lock.json @@ -0,0 +1,3024 @@ +{ + "name": "nestquest-admin", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "nestquest-admin", + "version": "0.0.0", + "dependencies": { + "react": "^19.2.0", + "react-dom": "^19.2.0" + }, + "devDependencies": { + "@testing-library/dom": "^10.4.1", + "@testing-library/react": "^16.3.0", + "@types/react": "^19.2.0", + "@types/react-dom": "^19.2.0", + "@vitejs/plugin-react": "^5.0.0", + "jsdom": "^30.1.1", + "typescript": "^5.9.2", + "vite": "^7.1.5", + "vitest": "^3.2.4" + } + }, + "node_modules/@asamuzakjp/css-color": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-7.0.1.tgz", + "integrity": "sha512-C9duntabagkBZ1LebM7FKmphR4Q1pBclLxVbZETQV0akkFjV0ooFxo8FlvAQyKj9F6l8rEnmidgcTlpyxFYizg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^3.4.0", + "@csstools/css-color-parser": "^4.2.3", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.1", + "lru-cache": "^11.5.3" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { + "version": "11.5.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz", + "integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "9.2.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-9.2.1.tgz", + "integrity": "sha512-NT4s3yZLjovPpliRpTvdsdzyPjqRqiCZj9MxnarBihbaY5MbAG7DWAJcrLlhmC7xKTNCXsTELcqB8YsqpLnUFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "bidi-js": "^1.1.0", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.5.3" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector/node_modules/lru-cache": { + "version": "11.5.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz", + "integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@babel/code-frame": { + "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.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "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": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "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.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", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "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.8", + "@babel/types": "^7.29.8", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "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.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "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": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "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.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "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.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "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": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "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": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "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": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "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.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.9", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.9.tgz", + "integrity": "sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "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.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "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.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": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "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.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.1.tgz", + "integrity": "sha512-gLNsunvwf3mCi5u5o46/Z/JcJMnhbHSaZ69rkgPzNM3J4s8hWwpPUQB6/tt0EDFyCiWzxANlx+2LJwpYj4zS1w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.4.0.tgz", + "integrity": "sha512-XQKj5B7QiZcHiegCOCAzcAOJdhGgWOHbbu62h5e5mkHnn8lWcfiJhllkqWmxu5zWR9jucPHuo1iTB56P033hcg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.2.3.tgz", + "integrity": "sha512-y4LpL+lmpuyKDiEFq2PnZUVFdAjsoB/qQJod79yLNokXyW7jewi+/WJ69EfItj8A2unWtxXnGjw6LYXgXu5ZjA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.1.1", + "@csstools/css-calc": "^3.4.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.14.tgz", + "integrity": "sha512-HpbVXyrofRXpHpgkNIjU/3EWR4WJvOkO3emNK/L6X/mTJU7bGUI3AkkpoTNXznQLp0KRjLHELTGeKI5dIkI9JQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.1.tgz", + "integrity": "sha512-bPlN9S9O1A0euCpEWE4qnvB5YDuyYVsUTrxSgmAM1Is0j4tICHoVyOVAXfWMP/kS9ZrjvyIXWV2PmomiAXXqOw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "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" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "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" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "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" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "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" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "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" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "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" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "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" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "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" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "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" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "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" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "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" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "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" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "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" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "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" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "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" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "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" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "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" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "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" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@exodus/bytes": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.16.0.tgz", + "integrity": "sha512-IcpW84uEn3N7ETtNZMlxKhfl6Pec8rUNGOTBtWbK1FKhJxIFAptZyVrvVRVBimAJxJCgc3PxepxkdWWG4DVzfA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.3", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz", + "integrity": "sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.63.4.tgz", + "integrity": "sha512-I+BSHzTAhKN2n7ZwGZsegGcZjDpLqFOMAtJz/u6uFGe0pUFbq56dEHjqJV/ZUdRJtNXNxA+hREUatZBvMR3Oiw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.63.4.tgz", + "integrity": "sha512-pu3BdjS2LtEzRu2elmGzS3fIeWSZy4BMDIaLNwjorO76+k2d0LMluijhsDx3KQyQBQ/lLUZCQA9/s6csvUfuhw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.63.4.tgz", + "integrity": "sha512-xfSrj9MHnWK9GaSqT9U0ImHtH/N8WZlHLx4cZHiuLcqs640hvZ3hLPd5UR2AZS57FaE8HrRUSpltbZdWRxHiDA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.63.4.tgz", + "integrity": "sha512-bqU99PLJb/dqb3S0GIMdeuyAEETSUgZBoqXYd3Sd+WCsV+MmPhnN6JrotWyir31+QgH7EvvE5/mwGJlEoci8Fw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.63.4.tgz", + "integrity": "sha512-JinsFZ5G40oXQb+sUuiA5x689vhr6dDYK0H0NL+rwKdL6CqnmYN8PE4ZwfRSoIjrCxqTQG/SLfTtSvHeGxoVlw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.63.4.tgz", + "integrity": "sha512-GAdA4UxpiNm27cLHr2GqXBpAD0x9FqwYBY7/YSP0Ss0/PNi4k8gbviqpIpYbVSRBaS2ZcegXEzgTQMbRNCwxCw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.63.4.tgz", + "integrity": "sha512-qDd6NoA1znaLjp4jR5U/KWCdLAKDJNB8W9ChbbDaKbo0xA+Atln5HK6LFCZ4oJQpemtRZA288DCirFRjrspptw==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.63.4.tgz", + "integrity": "sha512-WtB5Tz5KTNINb8ZA+8sQ7bmjuS1JrRT7YverYIhUGdWWDlpzVWmIwuZE+jidkEXUn1l0zrEkaIMa8dHF3NGcsA==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.63.4.tgz", + "integrity": "sha512-VcQ3L1tjnkKzWjryAVaFhHEWcqOfICX9uxVVoDzm2t0DpgKRHd2zOpVrJc0xsWeBZcBFyYROCIBdyR/fS174pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.63.4.tgz", + "integrity": "sha512-6+ZQX6P5s0cMDN2Ypb8Lbm2+/sZYmZjdaYny992ujUU9UKi/4CWoJWsl1pNvjWJHNHGK51m+jKGLlh1ylb2ifQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.63.4.tgz", + "integrity": "sha512-D72ZnvkFkBXOfzMMQLcwfPLyGkKb7HZ9/mf97B7v6/P5Lbv4oFOtSY/uHbS8lH6uKUOxoKiuokdb50XZSzzbJw==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.63.4.tgz", + "integrity": "sha512-piU6BxeqA3O9KSu3kRCIQQtNqFFaTu21SEV4FwaRZowpnj3bLaWPZHw+xFqCs0XlJ+aOH3PTRWGoglH+mKA/OA==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.63.4.tgz", + "integrity": "sha512-/5PGpHwqt2EEEOUs1XwzubE/ucr0dWDQ+to3zqi4Ds7EWpwtQ79wXc4JBoxqj/OwpawTsKWzJxHfSuBOq3DrWA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.63.4.tgz", + "integrity": "sha512-cX3beZDLWt7G2oJF+nhChiT+qtaihs+S2xi7ziGmVB+2pwPng6D0Ed0HmElQOgv2UsUmSJJLGwpBao/3TDx3VA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.63.4.tgz", + "integrity": "sha512-1uz2mGWHyptR7DgHHrlbdRAjXK7v7elGZ9lMja910/RP+ZYbX6xAmCiU9UZSX4hqmgtHMv6lr5l3kq1HIOpcag==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.63.4.tgz", + "integrity": "sha512-nLS8topojxyz7SRpKR2IODRpQ0XPZ+xaOXvT3+hqK/Uy8Lo5HFgkkIBiIrCu5tL5YqzTvgovGw55PwpahTAGig==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.63.4.tgz", + "integrity": "sha512-gs7DRKotr3l3q+jGPQBjH0ng1FjlEDm5ueQrkw5JtQvtLyEIcLASqAEaor56BhkKRzk+IcQzrcanBdb/bBQn8g==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.63.4.tgz", + "integrity": "sha512-791ET7W17NnScOZM7h4dX5hYspxE28htPFsb1awY/NRR8+PRNkS53e475rDdxXXDrP+kwnCcNWg9CX5ztn/Aqw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.63.4.tgz", + "integrity": "sha512-iwZQRcmj7g88g3tzefIrQY7qvmuA/cfYwhrDtTBhsmukO4U2huVO5W+86XacUMRvdSFVAc6kZUZy21JaRwiB9w==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.63.4.tgz", + "integrity": "sha512-dVHFp9gRWrdTpnqQuGfCwd7hOQDatK1VCP2iWhLY/cGrOQs/ucFzJ6A5SRqbXX12ZDI8EUuejSM5kwg+ja7Png==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.63.4.tgz", + "integrity": "sha512-t3NlauOW6gxZVVFcBEnO62Cb4wbyDFL416gTg1uFI/2tgqYQlf69FbSE115Ajre9I+c26Lk4mcmdFUsS/DGifQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.63.4.tgz", + "integrity": "sha512-xWuIaSye5FWZF8+UYtVEcHtRJDN5kN9Kfgxx3Kq8XIov9KSKbc1fiqQCm90SKrgQbUXZelbnUhnlUJmfSE7P9A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.63.4.tgz", + "integrity": "sha512-9ALJJUOg/ZflMJepVo2PlgsGxSaxN7SQ4Z8GoZfVlarWr6r3rkHUNsd/zAio7p4YMtChSMXPionxej4Hkf6CXQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.63.4.tgz", + "integrity": "sha512-blj9z5qx/Pv4WU0W1NMFDB97e0JH5ed+aZGywW8WCvp/NhWX/4PFAq5uu6Q0AebNn+Vo6KzUYDT++JzTT5ojlQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.63.4.tgz", + "integrity": "sha512-Erx822VRBwLa124shbj+wNXe//BOgMEctDV0m1aqTQdNO1S69DgNUCFKC1RCeZfixs1J31l6igk1ziyXErbigQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@testing-library/dom": { + "version": "10.4.2", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.2.tgz", + "integrity": "sha512-yzr2S9HyAIdhz2/6qHgbs665Q7PKVcDF05vsOlHPxG1mo36gKVesdYVeDLnXgfjJ03CrKRk08knc6+E/9m8v2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/react": { + "version": "16.3.3", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.3.tgz", + "integrity": "sha512-Uo193NgQbPMz6lrrhtRQQFcMC6Re/ELLFbbuVL30WDlZxlpZf9/lMHTAVxPRLw1q1iu9OJmR1c2BLiENRstdBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "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", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz", + "integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.3.0.tgz", + "integrity": "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.3.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.2.0.tgz", + "integrity": "sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.29.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-rc.3", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.18.0" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/@vitest/expect": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.7.tgz", + "integrity": "sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.7", + "@vitest/utils": "3.2.7", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.7.tgz", + "integrity": "sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "3.2.7", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.7.tgz", + "integrity": "sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.7.tgz", + "integrity": "sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "3.2.7", + "pathe": "^2.0.3", + "strip-literal": "^3.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.7.tgz", + "integrity": "sha512-7C+MwShwtBSI5Buwoyg3s/iY1eHL9PKAf+O1wVh/TdnjXUtkoL/9YQtre90i4MtNXM6edP1wJ2zOBpfCyhIS7g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.7", + "magic-string": "^0.30.17", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.7.tgz", + "integrity": "sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^4.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.7.tgz", + "integrity": "sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.7", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "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/baseline-browser-mapping": { + "version": "2.11.25", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.25.tgz", + "integrity": "sha512-gMmEShwwq7FJqMwvfRwvCl00v4kN+KOfJqXn+f4nrufak5gNHJOksd/60Dvjuz7sI8Y5WiSFBa8FEYr+zoyqCw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/bidi-js": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.1.0.tgz", + "integrity": "sha512-fX1Onk0tdVPC7obPWB5EbJ1z7NVhLq4m2xZLq2YXBkxzMXIGRpNMU88n0EPgWseKl12J7zXs7qrDxPK4sRs2fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, + "node_modules/browserslist": { + "version": "4.29.0", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.29.0.tgz", + "integrity": "sha512-3GSvyjvDI4Dur1Meg2BekJquu5uF+9R9a1+5M1Mde192eZoXbeXjzgOsgqPS2V8D5wrrip0gR5Hf/GhWQ9ZzaA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.11.23", + "caniuse-lite": "^1.0.30001810", + "electron-to-chromium": "^1.5.427", + "node-releases": "^2.0.55", + "update-browserslist-db": "^1.3.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001810", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001810.tgz", + "integrity": "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/data-urls/node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.438", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.438.tgz", + "integrity": "sha512-AN9xMU1hJiT65LkCUPL1DZm5TCbOPb2Qsm5pYwFLEXp6/qj9SdT4yMiqs7Qqstwvkn1zF7l36SRGt+s6XcJ0FA==", + "dev": true, + "license": "ISC" + }, + "node_modules/entities": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.1.0.tgz", + "integrity": "sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "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", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@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": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "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/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/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "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/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/html-encoding-sniffer": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-7.0.0.tgz", + "integrity": "sha512-UikN5yr7xsCDAq87Or5or0PAlD3HJJOKVzM05az588WnpDJ4Ux7a2A53Qi6gofGg2/EtvF/H4hCi/TXfCW4Y6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.15.1" + }, + "engines": { + "node": "^22.13.0 || >=24.0.0" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsdom": { + "version": "30.1.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-30.1.1.tgz", + "integrity": "sha512-FahmoPK5vbPc+jxV1iErMHmAZypCZ942NHF4+qqaWAuvaKKTBZxawnmAtrbGWLU7MtlxfqIP0qw6aSI+aWGtLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^7.0.0", + "@asamuzakjp/dom-selector": "^9.2.1", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.13", + "@exodus/bytes": "^1.15.1", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^7.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.5.2", + "parse5": "^8.0.1", + "saxes": "^6.0.0", + "tough-cookie": "^6.0.2", + "undici": "^8.10.2", + "w3c-xmlserializer": "^6.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^17.1.1", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + }, + "peerDependencies": { + "canvas": "^3.2.3" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/lru-cache": { + "version": "11.5.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz", + "integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "bin": { + "lz-string": "bin/bin.js" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.19", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz", + "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.57", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.57.tgz", + "integrity": "sha512-kQK9LGGFiHtrWiNhZtA7Qbw17AQz+dmsEKODRIVTXA9+e5MS/2gZEBhYJt13GrAz5/IOZKddH/0Z3TP/Zgo+yw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "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/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "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/postcss": { + "version": "8.5.28", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", + "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", + "dev": true, + "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.18", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/react": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/react/-/react-19.3.0.tgz", + "integrity": "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.3.0.tgz", + "integrity": "sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.28.0" + }, + "peerDependencies": { + "react": "^19.3.0" + } + }, + "node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT" + }, + "node_modules/react-refresh": { + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", + "integrity": "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.63.4.tgz", + "integrity": "sha512-4U0liVayNIoLp3GFl1FcI8561WepLnZ1rqfraGh7S9B3Ur5F9S283y8Futii7RUU2C/97tOBmBy7nYvhoiOpbQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.63.4", + "@rollup/rollup-android-arm64": "4.63.4", + "@rollup/rollup-darwin-arm64": "4.63.4", + "@rollup/rollup-darwin-x64": "4.63.4", + "@rollup/rollup-freebsd-arm64": "4.63.4", + "@rollup/rollup-freebsd-x64": "4.63.4", + "@rollup/rollup-linux-arm-gnueabihf": "4.63.4", + "@rollup/rollup-linux-arm-musleabihf": "4.63.4", + "@rollup/rollup-linux-arm64-gnu": "4.63.4", + "@rollup/rollup-linux-arm64-musl": "4.63.4", + "@rollup/rollup-linux-loong64-gnu": "4.63.4", + "@rollup/rollup-linux-loong64-musl": "4.63.4", + "@rollup/rollup-linux-ppc64-gnu": "4.63.4", + "@rollup/rollup-linux-ppc64-musl": "4.63.4", + "@rollup/rollup-linux-riscv64-gnu": "4.63.4", + "@rollup/rollup-linux-riscv64-musl": "4.63.4", + "@rollup/rollup-linux-s390x-gnu": "4.63.4", + "@rollup/rollup-linux-x64-gnu": "4.63.4", + "@rollup/rollup-linux-x64-musl": "4.63.4", + "@rollup/rollup-openbsd-x64": "4.63.4", + "@rollup/rollup-openharmony-arm64": "4.63.4", + "@rollup/rollup-win32-arm64-msvc": "4.63.4", + "@rollup/rollup-win32-ia32-msvc": "4.63.4", + "@rollup/rollup-win32-x64-gnu": "4.63.4", + "@rollup/rollup-win32-x64-msvc": "4.63.4", + "fsevents": "~2.3.2" + } + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "node_modules/scheduler": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz", + "integrity": "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "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", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "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": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/strip-literal": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", + "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/strip-literal/node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, + "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": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.6.tgz", + "integrity": "sha512-u8KszXvGfU68hVcZpRHKG28T0krMuv2G5nDhiHaMLen/gIuFEgIJhaJuO69qjnXg5paSrbPMFfx3brNuN8eVSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "7.4.15", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.15.tgz", + "integrity": "sha512-SJVBeHOxDbNoq14CvpAoA2mLEWdbldGK8nR+yumpjY5nrlZxOflcA7p1Qj1gbTGmbu9DY9qLj/bENSWhBzjxRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.4.15" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.4.15", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.15.tgz", + "integrity": "sha512-ERuv0p98XgSzmlSLJr8vDNxX+uATGInlN97V3R+JpYWaSqn5IG3ewWgCwczk4bkOpgth/o8Nt5FOi4B4w0n/1A==", + "dev": true, + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz", + "integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici": { + "version": "8.11.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.11.0.tgz", + "integrity": "sha512-AdzuGcAkzhbha3GgCSoUBx2P0quwym6qdFNCQz/fDGZc8w9XJ741wHUbt2kTmfPz+ZIOMkySFkiY7RXmiDDC8w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/update-browserslist-db": { + "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": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/vite": { + "version": "7.3.6", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz", + "integrity": "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0 || ^0.28.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "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", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.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 + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "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": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.4.1", + "es-module-lexer": "^1.7.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.7.tgz", + "integrity": "sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/expect": "3.2.7", + "@vitest/mocker": "3.2.7", + "@vitest/pretty-format": "^3.2.7", + "@vitest/runner": "3.2.7", + "@vitest/snapshot": "3.2.7", + "@vitest/spy": "3.2.7", + "@vitest/utils": "3.2.7", + "chai": "^5.2.0", + "debug": "^4.4.1", + "expect-type": "^1.2.1", + "magic-string": "^0.30.17", + "pathe": "^2.0.3", + "picomatch": "^4.0.2", + "std-env": "^3.9.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.14", + "tinypool": "^1.1.1", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", + "vite-node": "3.2.4", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.2.7", + "@vitest/ui": "3.2.7", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/debug": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/w3c-xmlserializer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-6.0.0.tgz", + "integrity": "sha512-4Nsy8K5Tr6SPDH9jhKJOHf7ChDrc1zufZTVSF7x72hwuEXBqxqk9G6cK+K2NRUtB3iELRJqjXb4JPDMBjMTl2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "17.1.2", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-17.1.2.tgz", + "integrity": "sha512-TEZA+Zqxin7Jjsm2cjRohCmen5awh+hT6Zi3VZdqZlNRk7zvOI/9WpBFg/DWlA56bWnzwm6DuB8NS0EsxQH9uQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.15.1", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^22.14.0 || >=24.0.0" + } + }, + "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/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + } + } +} diff --git a/admin/package.json b/admin/package.json new file mode 100644 index 0000000..9726bfe --- /dev/null +++ b/admin/package.json @@ -0,0 +1,27 @@ +{ + "name": "nestquest-admin", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview", + "test": "vitest run" + }, + "dependencies": { + "react": "^19.2.0", + "react-dom": "^19.2.0" + }, + "devDependencies": { + "@testing-library/dom": "^10.4.1", + "@testing-library/react": "^16.3.0", + "@types/react": "^19.2.0", + "@types/react-dom": "^19.2.0", + "@vitejs/plugin-react": "^5.0.0", + "jsdom": "^30.1.1", + "typescript": "^5.9.2", + "vite": "^7.1.5", + "vitest": "^3.2.4" + } +} \ No newline at end of file diff --git a/admin/src/App.css b/admin/src/App.css new file mode 100644 index 0000000..fb95e11 --- /dev/null +++ b/admin/src/App.css @@ -0,0 +1,70 @@ +/* Shell layout — mobile-first (390x844 reference), plain CubeCraft styling. */ + +.admin-shell { + display: flex; + flex-direction: column; + min-height: 100%; +} + +.admin-header { + background: var(--nq-a-surface); + border-bottom: 1px solid var(--nq-a-border); + padding: 14px 16px; +} + +.admin-title { + margin: 0; + font-size: 24px; + font-weight: 800; + letter-spacing: -0.02em; +} + +.admin-subline { + margin: 2px 0 0; + font-size: 12px; + font-weight: 600; + color: var(--nq-a-ink-secondary); +} + +.admin-panel { + flex: 1; + padding: 14px 16px 92px; /* bottom padding clears the 70px tab bar */ +} + +.admin-placeholder { + margin: 0; + padding: 13px; + background: var(--nq-a-surface); + border: 1px solid var(--nq-a-border); + border-radius: 8px; + font-size: 13.5px; + font-weight: 600; + color: var(--nq-a-ink-secondary); +} + +.admin-tabbar { + position: fixed; + inset: auto 0 0; + display: flex; + min-height: calc(var(--nq-a-tabbar-height) + 12px); + padding: 8px 4px calc(12px + env(safe-area-inset-bottom)); + background: var(--nq-a-surface); + border-top: 1px solid var(--nq-a-border); +} + +.admin-tab { + flex: 1; + border: 0; + background: none; + font-family: inherit; + font-size: 12px; + font-weight: 600; + color: var(--nq-a-ink-secondary); + cursor: pointer; + padding: 6px 4px; +} + +.admin-tab.active { + font-weight: 700; + color: var(--nq-brand-blue); +} \ No newline at end of file diff --git a/admin/src/App.test.tsx b/admin/src/App.test.tsx new file mode 100644 index 0000000..077faa7 --- /dev/null +++ b/admin/src/App.test.tsx @@ -0,0 +1,33 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import App from "./App"; + +describe("NestQuest Admin shell", () => { + afterEach(cleanup); + + it("renders the title and all four tab labels", () => { + render(); + + expect(screen.getByRole("heading", { name: "NestQuest Admin" })).toBeTruthy(); + for (const label of ["Today", "Tasks", "Schedule", "History"]) { + expect(screen.getByRole("tab", { name: label })).toBeTruthy(); + } + }); + + it("starts on the Today placeholder and switches on tab click", () => { + render(); + + expect(screen.getByRole("tabpanel").textContent).toContain( + "Today placeholder", + ); + + fireEvent.click(screen.getByRole("tab", { name: "History" })); + + expect(screen.getByRole("tabpanel").textContent).toContain( + "History placeholder", + ); + expect(screen.getByRole("tab", { name: "History" }).getAttribute("aria-selected")).toBe( + "true", + ); + }); +}); \ No newline at end of file diff --git a/admin/src/App.tsx b/admin/src/App.tsx new file mode 100644 index 0000000..6d491c3 --- /dev/null +++ b/admin/src/App.tsx @@ -0,0 +1,49 @@ +import { useState } from "react"; +import "./App.css"; + +const TABS = [ + { id: "today", label: "Today" }, + { id: "tasks", label: "Tasks" }, + { id: "schedule", label: "Schedule" }, + { id: "history", label: "History" }, +] as const; + +type TabId = (typeof TABS)[number]["id"]; + +export default function App() { + const [active, setActive] = useState("today"); + + return ( +
+
+

NestQuest Admin

+

Admin console

+
+
+

+ {TABS.find((tab) => tab.id === active)?.label} placeholder — screens + arrive in later tasks. +

+
+ +
+ ); +} \ No newline at end of file diff --git a/admin/src/index.css b/admin/src/index.css new file mode 100644 index 0000000..c23ad87 --- /dev/null +++ b/admin/src/index.css @@ -0,0 +1,39 @@ +/* NestQuest Admin — global tokens + base. + Plain CubeCraft styling, mobile-first, 390x844 reference + (design/ADMIN-SPEC.md). Values mirror design/tokens/nestquest-admin-tokens.css, + re-scoped to :root for the standalone PWA. */ + +:root { + --nq-brand-blue: #3b3ab8; + --nq-brand-purple: #a035cc; + + --nq-a-page: #f4f4f6; + --nq-a-surface: #ffffff; + --nq-a-border: #e4e4ea; + --nq-a-divider: #ededf1; + + --nq-a-ink: #18181d; + --nq-a-ink-secondary: #52525e; + --nq-a-ink-tertiary: #70707e; + + --nq-a-font: Nunito, system-ui, sans-serif; + --nq-a-tabbar-height: 70px; +} + +* { + box-sizing: border-box; +} + +html, +body, +#root { + margin: 0; + height: 100%; +} + +body { + font-family: var(--nq-a-font); + background: var(--nq-a-page); + color: var(--nq-a-ink); + -webkit-font-smoothing: antialiased; +} \ No newline at end of file diff --git a/admin/src/main.tsx b/admin/src/main.tsx new file mode 100644 index 0000000..e9c7e64 --- /dev/null +++ b/admin/src/main.tsx @@ -0,0 +1,10 @@ +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import App from "./App"; +import "./index.css"; + +createRoot(document.getElementById("root")!).render( + + + , +); \ No newline at end of file diff --git a/admin/tsconfig.json b/admin/tsconfig.json new file mode 100644 index 0000000..a63985a --- /dev/null +++ b/admin/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "jsx": "react-jsx", + "types": ["vite/client"], + "strict": true, + "skipLibCheck": true, + "noEmit": true, + "isolatedModules": true, + "useDefineForClassFields": true + }, + "include": ["src"] +} \ No newline at end of file diff --git a/admin/vite.config.ts b/admin/vite.config.ts new file mode 100644 index 0000000..e331a53 --- /dev/null +++ b/admin/vite.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from "vitest/config"; +import react from "@vitejs/plugin-react"; + +export default defineConfig({ + plugins: [react()], + test: { + environment: "jsdom", + }, +}); \ No newline at end of file -- 2.55.0 From 75ad9892b8ec3882235bb201dcb5eb1ea23218a7 Mon Sep 17 00:00:00 2001 From: dev_agent <17+dev_agent@code.cubecraftcreations.com> Date: Wed, 23 Sep 2026 18:30:07 -0400 Subject: [PATCH 02/13] Task 630c3472: Authentik OIDC PKCE login and refusal handling (#188) --- admin/.env.example | 13 ++ admin/src/App.css | 49 +++++++- admin/src/AppRoutes.test.tsx | 73 ++++++++++++ admin/src/AppRoutes.tsx | 176 +++++++++++++++++++++++++++ admin/src/api/client.test.ts | 94 +++++++++++++++ admin/src/api/client.ts | 93 +++++++++++++++ admin/src/auth/oidc.test.ts | 173 +++++++++++++++++++++++++++ admin/src/auth/oidc.ts | 225 +++++++++++++++++++++++++++++++++++ admin/src/auth/pkce.test.ts | 41 +++++++ admin/src/auth/pkce.ts | 55 +++++++++ admin/src/config.test.ts | 215 +++++++++++++++++++++++++++++++++ admin/src/config.ts | 221 ++++++++++++++++++++++++++++++++++ admin/src/main.tsx | 4 +- 13 files changed, 1429 insertions(+), 3 deletions(-) create mode 100644 admin/.env.example create mode 100644 admin/src/AppRoutes.test.tsx create mode 100644 admin/src/AppRoutes.tsx create mode 100644 admin/src/api/client.test.ts create mode 100644 admin/src/api/client.ts create mode 100644 admin/src/auth/oidc.test.ts create mode 100644 admin/src/auth/oidc.ts create mode 100644 admin/src/auth/pkce.test.ts create mode 100644 admin/src/auth/pkce.ts create mode 100644 admin/src/config.test.ts create mode 100644 admin/src/config.ts diff --git a/admin/.env.example b/admin/.env.example new file mode 100644 index 0000000..706fd43 --- /dev/null +++ b/admin/.env.example @@ -0,0 +1,13 @@ +# NestQuest Admin PWA — runtime configuration (Vite env). +# Copy to .env (or .env.local) and fill in real values. NEVER commit .env. + +# Authentik OIDC provider. Defaults to the issuer below when unset. +VITE_AUTHENTIK_ISSUER=https://auth.cubecraftlabs.com/application/o/nestquest/ + +# REQUIRED. OAuth2/OIDC client id of the PUBLIC (PKCE) Authentik application. +# No default: the app shows a configuration error screen when this is missing. +# Never set a client_secret — this is a browser SPA using PKCE only. +VITE_AUTHENTIK_CLIENT_ID= + +# NestQuest API base URL. +VITE_API_BASE_URL=https://nestquest.cubecraftlabs.com diff --git a/admin/src/App.css b/admin/src/App.css index fb95e11..590a28f 100644 --- a/admin/src/App.css +++ b/admin/src/App.css @@ -67,4 +67,51 @@ .admin-tab.active { font-weight: 700; color: var(--nq-brand-blue); -} \ No newline at end of file +} +/* Auth screens (login, errors, refusal). */ + +.admin-login, +.admin-error, +.admin-refusal, +.admin-loading { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 10px; + min-height: 100%; + padding: 32px 16px; + text-align: center; +} + +.admin-login-button { + margin-top: 8px; + padding: 12px 24px; + border: none; + border-radius: 8px; + background: var(--nq-a-accent, #2563eb); + color: #fff; + font-size: 15px; + font-weight: 700; + cursor: pointer; +} + +.admin-error, +.admin-refusal { + color: var(--nq-a-ink, inherit); +} + +.admin-refusal h1, +.admin-error h1 { + margin: 0; + font-size: 20px; +} + +.admin-refusal p, +.admin-error p { + margin: 0; + max-width: 34em; + font-size: 14px; + font-weight: 600; + color: var(--nq-a-ink-secondary); +} diff --git a/admin/src/AppRoutes.test.tsx b/admin/src/AppRoutes.test.tsx new file mode 100644 index 0000000..abb0fc4 --- /dev/null +++ b/admin/src/AppRoutes.test.tsx @@ -0,0 +1,73 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { cleanup, render, screen, waitFor } from "@testing-library/react"; +import AppRoutes from "./AppRoutes"; +import { storeTokens } from "./auth/oidc"; +import { config } from "./config"; + +// The bundled config reads real env at import time; seed the shared module's +// clientId directly so AppRoutes sees a configured app in these tests. +(config as { clientId: string | undefined }).clientId = "test-client-id"; + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }); +} + +describe("AppRoutes auth gating", () => { + afterEach(() => { + cleanup(); + sessionStorage.clear(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + window.history.replaceState({}, "", "/"); + }); + + it("renders the login screen when unauthenticated at /", () => { + render(); + expect(screen.getByTestId("login-screen")).toBeTruthy(); + expect( + screen.getByRole("button", { name: /sign in with authentik/i }), + ).toBeTruthy(); + }); + + it("renders the four-tab shell after a successful authenticated probe", async () => { + storeTokens({ access_token: "at-123", expires_in: 600 }); + const fetchMock = vi.fn().mockResolvedValue(jsonResponse([])); + vi.stubGlobal("fetch", fetchMock); + + render(); + + await waitFor(() => { + expect(screen.getByRole("heading", { name: "NestQuest Admin" })).toBeTruthy(); + }); + for (const label of ["Today", "Tasks", "Schedule", "History"]) { + expect(screen.getByRole("tab", { name: label })).toBeTruthy(); + } + }); + + it("renders the explicit refusal when the API answers 403", async () => { + storeTokens({ access_token: "at-123", expires_in: 600 }); + const fetchMock = vi.fn().mockResolvedValue(new Response("", { status: 403 })); + vi.stubGlobal("fetch", fetchMock); + + render(); + + await waitFor(() => { + expect(screen.getByTestId("refusal-screen")).toBeTruthy(); + }); + expect(screen.getByText(/not in the nestquest-admins group/i)).toBeTruthy(); + }); + + it("shows the configuration error screen when the client id is missing", () => { + const original = config.clientId; + (config as { clientId: string | undefined }).clientId = undefined; + try { + render(); + expect(screen.getByTestId("config-error-screen")).toBeTruthy(); + } finally { + (config as { clientId: string | undefined }).clientId = original; + } + }); +}); diff --git a/admin/src/AppRoutes.tsx b/admin/src/AppRoutes.tsx new file mode 100644 index 0000000..5d6b1ab --- /dev/null +++ b/admin/src/AppRoutes.tsx @@ -0,0 +1,176 @@ +/** + * Minimal path-based routing without react-router-dom: the app only needs two + * paths (`/` shell and `/auth/callback`), so a tiny window.location switch is + * enough and keeps the bundle small. Change screens listen to popstate. + */ +import { useEffect, useState } from "react"; +import { ConfigError, config } from "./config"; +import { + AuthError, + buildAuthorizeUrl, + clearAuth, + handleCallback, + isAuthenticated, +} from "./auth/oidc"; +import { ApiForbiddenError, apiFetch } from "./api/client"; +import AdminShell from "./App"; + +type Screen = + | { kind: "loading" } + | { kind: "login" } + | { kind: "config-error"; message: string } + | { kind: "auth-error"; message: string } + | { kind: "refusal"; message: string } + | { kind: "app" }; + +function currentPath(): string { + return window.location.pathname.replace(/\/+$/, "") || "/"; +} + +async function probeApi(setScreen: (screen: Screen) => void) { + // A cheap authenticated probe so a 403 surfaces as an explicit refusal + // right after login instead of a silent empty shell. + try { + await apiFetch("/api/v1/children", {}, config); + setScreen({ kind: "app" }); + } catch (error) { + if (error instanceof ConfigError) { + setScreen({ kind: "config-error", message: error.message }); + return; + } + if (error instanceof ApiForbiddenError) { + setScreen({ kind: "refusal", message: error.message }); + return; + } + // Network or other failures still allow the shell to render; API screens + // handle their own errors in later tasks. + setScreen({ kind: "app" }); + } +} + +function LoginScreen({ onLogin }: { onLogin: () => void }) { + return ( +
+

NestQuest Admin

+

Sign in to continue

+ +
+ ); +} + +export default function AppRoutes() { + const [screen, setScreen] = useState({ kind: "loading" }); + + useEffect(() => { + // Re-evaluate on navigation (popstate) — routing is path based. + const onPop = () => setScreen({ kind: "loading" }); + window.addEventListener("popstate", onPop); + return () => window.removeEventListener("popstate", onPop); + }, []); + + useEffect(() => { + let cancelled = false; + const path = currentPath(); + + if (!config.clientId) { + setScreen({ + kind: "config-error", + message: + "Missing VITE_AUTHENTIK_CLIENT_ID configuration. Set it in your .env before building the admin PWA.", + }); + return; + } + + if (path === "/auth/callback") { + (async () => { + try { + await handleCallback(window.location.search, config); + if (cancelled) return; + window.history.replaceState({}, "", "/"); + await probeApi((next) => !cancelled && setScreen(next)); + } catch (error) { + if (cancelled) return; + const message = + error instanceof AuthError || error instanceof Error + ? error.message + : "Authentication failed."; + setScreen({ kind: "auth-error", message }); + } + })(); + return () => { + cancelled = true; + }; + } + + if (isAuthenticated()) { + void probeApi((next) => !cancelled && setScreen(next)); + } else { + setScreen({ kind: "login" }); + } + return () => { + cancelled = true; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const startLogin = async () => { + try { + const url = await buildAuthorizeUrl(config); + window.location.assign(url); + } catch (error) { + setScreen({ + kind: "config-error", + message: error instanceof Error ? error.message : "Login failed.", + }); + } + }; + + switch (screen.kind) { + case "loading": + return ( +
+ Loading… +
+ ); + case "login": + return ; + case "config-error": + return ( +
+

Configuration error

+

{screen.message}

+
+ ); + case "auth-error": + return ( +
+

Sign-in failed

+

{screen.message}

+ +
+ ); + case "refusal": + return ( +
+

Access refused

+

{screen.message}

+ +
+ ); + case "app": + return ; + } +} diff --git a/admin/src/api/client.test.ts b/admin/src/api/client.test.ts new file mode 100644 index 0000000..9cd828a --- /dev/null +++ b/admin/src/api/client.test.ts @@ -0,0 +1,94 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + ApiForbiddenError, + ApiUnauthorizedError, + apiFetch, +} from "./client"; +import { storeTokens } from "../auth/oidc"; +import type { AppConfig } from "../config"; + +const app: AppConfig = { + issuer: "https://auth.example.com/application/o/nestquest", + clientId: "test-client-id", + apiBaseUrl: "https://api.example.com", + authorizeEndpoint: "https://auth.example.com/application/o/authorize/", + tokenEndpoint: "https://auth.example.com/application/o/token/", + userinfoEndpoint: "https://auth.example.com/application/o/userinfo/", + endSessionEndpoint: "https://auth.example.com/application/o/nestquest/end-session/", + redirectUri: "http://localhost:5173/auth/callback", +}; + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }); +} + +describe("API client", () => { + beforeEach(() => { + sessionStorage.clear(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + it("attaches the Bearer token to API calls", async () => { + storeTokens({ access_token: "at-123", expires_in: 600 }); + const fetchMock = vi.fn().mockResolvedValue(jsonResponse([])); + + await apiFetch("/api/v1/children", {}, app, fetchMock as any); + + expect(fetchMock).toHaveBeenCalledTimes(1); + const [url, init] = fetchMock.mock.calls[0]; + expect(url).toBe("https://api.example.com/api/v1/children"); + expect(init.headers.Authorization).toBe("Bearer at-123"); + }); + + it("throws ApiForbiddenError on 403 (authenticated but refused)", async () => { + storeTokens({ access_token: "at-123", expires_in: 600 }); + const fetchMock = vi.fn().mockResolvedValue(new Response("", { status: 403 })); + + await expect( + apiFetch("/api/v1/children", {}, app, fetchMock as any), + ).rejects.toBeInstanceOf(ApiForbiddenError); + }); + + it("triggers a login redirect on 401 with no refresh token", async () => { + storeTokens({ access_token: "stale", expires_in: 600 }); + const assign = vi.fn(); + vi.stubGlobal("location", { ...window.location, assign }); + const fetchMock = vi.fn().mockResolvedValue(new Response("", { status: 401 })); + + await expect( + apiFetch("/api/v1/children", {}, app, fetchMock as any), + ).rejects.toBeInstanceOf(ApiUnauthorizedError); + expect(assign).toHaveBeenCalledTimes(1); + const url = new URL(assign.mock.calls[0][0] as string); + expect(url.origin + url.pathname).toBe( + "https://auth.example.com/application/o/authorize/", + ); + expect(url.searchParams.get("code_challenge_method")).toBe("S256"); + }); + + it("preserves non-401/403 statuses on the post-refresh retry (500 is not mislabeled)", async () => { + storeTokens({ access_token: "stale", refresh_token: "rt-1", expires_in: 600 }); + const fetchMock = vi + .fn() + .mockResolvedValueOnce(new Response("", { status: 401 })) // initial API call + .mockResolvedValueOnce( + jsonResponse({ access_token: "fresh", expires_in: 600 }), // refresh grant + ) + .mockResolvedValueOnce(new Response("", { status: 500 })); // retry + + const response = await apiFetch("/api/v1/children", {}, app, fetchMock as any); + + expect(fetchMock).toHaveBeenCalledTimes(3); + expect(fetchMock.mock.calls[1][0]).toBe(app.tokenEndpoint); + expect(fetchMock.mock.calls[2][1].headers.Authorization).toBe("Bearer fresh"); + // A server failure must surface as the real status, not ApiUnauthorizedError. + expect(response.status).toBe(500); + }); +}); diff --git a/admin/src/api/client.ts b/admin/src/api/client.ts new file mode 100644 index 0000000..4ad468b --- /dev/null +++ b/admin/src/api/client.ts @@ -0,0 +1,93 @@ +/** + * Minimal fetch wrapper for the NestQuest API. + * + * - Attaches `Authorization: Bearer ` when a token is stored. + * - 401: not authenticated (or token expired) -> trigger a new login. + * - 403: authenticated but refused -> throws ApiForbiddenError so the UI can + * render an explicit refusal message. + */ +import { config, type AppConfig } from "../config"; +import { + buildAuthorizeUrl, + getToken, + isExpired, + refreshAccessToken, +} from "../auth/oidc"; + +export class ApiForbiddenError extends Error { + constructor(message = "Your account is not in the nestquest-admins group.") { + super(message); + this.name = "ApiForbiddenError"; + } +} + +export class ApiUnauthorizedError extends Error { + constructor(message = "Not authenticated.") { + super(message); + this.name = "ApiUnauthorizedError"; + } +} + +export interface ApiRequestOptions extends Omit { + headers?: Record; +} + +/** + * Redirect the browser to Authentik to start a new login. + * Exported for testing injection. + */ +export async function triggerLogin(app: AppConfig = config): Promise { + const url = await buildAuthorizeUrl(app); + window.location.assign(url); +} + +export async function apiFetch( + path: string, + options: ApiRequestOptions = {}, + app: AppConfig = config, + fetchImpl: typeof fetch = fetch, +): Promise { + if (isExpired() && (await refreshAccessToken(app, fetchImpl))) { + // token renewed; fall through and use the fresh one + } + + const token = getToken(); + const headers = { + ...(options.headers ?? {}), + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }; + + const response = await fetchImpl(`${app.apiBaseUrl}${path}`, { + ...options, + headers, + }); + + if (response.status === 401) { + // Not authenticated: either no token or an unusable one. + if (await refreshAccessToken(app, fetchImpl)) { + const retryToken = getToken(); + const retryResponse = await fetchImpl(`${app.apiBaseUrl}${path}`, { + ...options, + headers: { + ...(options.headers ?? {}), + ...(retryToken ? { Authorization: `Bearer ${retryToken}` } : {}), + }, + }); + if (retryResponse.status === 403) throw new ApiForbiddenError(); + if (retryResponse.ok) return retryResponse; + // Only 401 means "unauthorized"; preserve every other status (e.g. a + // 500 from the API) so callers can react to the real response. + if (retryResponse.status === 401) throw new ApiUnauthorizedError(); + return retryResponse; + } + await triggerLogin(app); + throw new ApiUnauthorizedError(); + } + + if (response.status === 403) { + // Authenticated, but Authentik's token does not belong to nestquest-admins. + throw new ApiForbiddenError(); + } + + return response; +} diff --git a/admin/src/auth/oidc.test.ts b/admin/src/auth/oidc.test.ts new file mode 100644 index 0000000..cd63b02 --- /dev/null +++ b/admin/src/auth/oidc.test.ts @@ -0,0 +1,173 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + buildAuthorizeUrl, + clearAuth, + getToken, + handleCallback, + logout, + storeTokens, +} from "./oidc"; +import type { AppConfig } from "../config"; + +function testApp(): AppConfig { + return { + issuer: "https://auth.example.com/application/o/nestquest", + clientId: "test-client-id", + apiBaseUrl: "https://api.example.com", + authorizeEndpoint: "https://auth.example.com/application/o/authorize/", + tokenEndpoint: "https://auth.example.com/application/o/token/", + userinfoEndpoint: "https://auth.example.com/application/o/userinfo/", + endSessionEndpoint: "https://auth.example.com/application/o/nestquest/end-session/", + redirectUri: "http://localhost:5173/auth/callback", + }; +} + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }); +} + +describe("OIDC flow", () => { + beforeEach(() => { + sessionStorage.clear(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("buildAuthorizeUrl carries all required params and stores state/verifier", async () => { + const app = testApp(); + const url = new URL(await buildAuthorizeUrl(app)); + + expect(url.origin + url.pathname).toBe(app.authorizeEndpoint); + expect(url.searchParams.get("response_type")).toBe("code"); + expect(url.searchParams.get("client_id")).toBe("test-client-id"); + expect(url.searchParams.get("redirect_uri")).toBe(app.redirectUri); + expect(url.searchParams.get("scope")).toBe("openid email profile"); + expect(url.searchParams.get("code_challenge_method")).toBe("S256"); + expect(url.searchParams.get("code_challenge")).toMatch(/^[A-Za-z0-9_-]{43}$/); + expect(url.searchParams.get("state")).toMatch(/^[A-Za-z0-9_-]+$/); + + expect(sessionStorage.getItem("nestquest.oidc_state")).toBe( + url.searchParams.get("state"), + ); + expect(sessionStorage.getItem("nestquest.code_verifier")).toMatch(/^[A-Za-z0-9-._~]{43,128}$/); + }); + + it("handleCallback rejects a mismatched state", async () => { + await buildAuthorizeUrl(testApp()); + sessionStorage.setItem("nestquest.oidc_state", "different-state"); + + const fetchMock = vi.fn(); + await expect( + handleCallback("?code=abc&state=tampered", testApp(), fetchMock as any), + ).rejects.toThrow(/state mismatch/i); + expect(fetchMock).not.toHaveBeenCalled(); + expect(getToken()).toBeUndefined(); + }); + + it("handleCallback exchanges the code with the correct form body and stores the token", async () => { + await buildAuthorizeUrl(testApp()); + const verifier = sessionStorage.getItem("nestquest.code_verifier")!; + const state = sessionStorage.getItem("nestquest.oidc_state")!; + + const fetchMock = vi.fn().mockResolvedValue( + jsonResponse({ + access_token: "at-123", + refresh_token: "rt-456", + id_token: "id-789", + expires_in: 300, + }), + ); + await handleCallback(`?code=abc&state=${state}`, testApp(), fetchMock as any); + + expect(fetchMock).toHaveBeenCalledTimes(1); + const [url, init] = fetchMock.mock.calls[0]; + expect(url).toBe("https://auth.example.com/application/o/token/"); + expect(init.method).toBe("POST"); + expect(init.headers["Content-Type"]).toBe("application/x-www-form-urlencoded"); + + const body = new URLSearchParams(init.body); + expect(body.get("grant_type")).toBe("authorization_code"); + expect(body.get("code")).toBe("abc"); + expect(body.get("redirect_uri")).toBe(testApp().redirectUri); + expect(body.get("client_id")).toBe("test-client-id"); + expect(body.get("code_verifier")).toBe(verifier); + expect(body.has("client_secret")).toBe(false); + + expect(getToken()).toBe("at-123"); + // one-time values consumed + expect(sessionStorage.getItem("nestquest.oidc_state")).toBeNull(); + expect(sessionStorage.getItem("nestquest.code_verifier")).toBeNull(); + }); + + it("handleCallback surfaces provider errors", async () => { + await expect( + handleCallback("?error=access_denied&error_description=nope", testApp(), vi.fn() as any), + ).rejects.toThrow(/nope|access_denied/); + }); + + it("clearAuth removes all stored auth data", () => { + storeTokens({ access_token: "a", refresh_token: "r", expires_in: 60 }); + clearAuth(); + expect(getToken()).toBeUndefined(); + expect(sessionStorage.length).toBe(0); + }); + + it("logout clears auth and redirects to the end-session endpoint", async () => { + storeTokens({ access_token: "a", id_token: "idt", expires_in: 60 }); + const assign = vi.fn(); + const original = window.location; + vi.stubGlobal("location", { ...original, assign, origin: "http://localhost:5173" }); + + await logout(testApp()); + + expect(assign).toHaveBeenCalledTimes(1); + const url = new URL(assign.mock.calls[0][0] as string); + expect(url.toString()).toBe( + "https://auth.example.com/application/o/nestquest/end-session/?id_token_hint=idt&client_id=test-client-id&post_logout_redirect_uri=http%3A%2F%2Flocalhost%3A5173", + ); + expect(getToken()).toBeUndefined(); + vi.unstubAllGlobals(); + }); + + it("buildAuthorizeUrl resolves the authorize endpoint via discovery for a non-default issuer", async () => { + const issuer = "https://sso.discovery.example/application/o/discovered"; + const discoveryDoc = { + issuer, + authorization_endpoint: "https://sso.discovery.example/application/o/authorize/", + token_endpoint: "https://sso.discovery.example/application/o/token/", + userinfo_endpoint: "https://sso.discovery.example/application/o/userinfo/", + end_session_endpoint: `${issuer}/end-session/`, + jwks_uri: `${issuer}/jwks/`, + }; + const fetchMock = vi.fn().mockImplementation((url: string) => + Promise.resolve( + String(url).endsWith("/.well-known/openid-configuration") + ? jsonResponse(discoveryDoc) + : jsonResponse({}), + ), + ); + const app: AppConfig = { + issuer, + clientId: "test-client-id", + apiBaseUrl: "https://api.example.com", + redirectUri: "http://localhost:5173/auth/callback", + }; + + const url = new URL(await buildAuthorizeUrl(app, fetchMock as any)); + + // The authorize endpoint comes from the discovery document — Authentik + // serves it outside the app-specific issuer path, so it is NOT + // `${issuer}/authorize/` and must never be a hardcoded production host. + expect(url.origin + url.pathname).toBe(discoveryDoc.authorization_endpoint); + expect(url.searchParams.get("client_id")).toBe("test-client-id"); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(String(fetchMock.mock.calls[0][0])).toBe( + `${issuer}/.well-known/openid-configuration`, + ); + }); +}); diff --git a/admin/src/auth/oidc.ts b/admin/src/auth/oidc.ts new file mode 100644 index 0000000..f2fe723 --- /dev/null +++ b/admin/src/auth/oidc.ts @@ -0,0 +1,225 @@ +/** + * OIDC authorization-code + PKCE flow for a PUBLIC client. + * + * Never sends a client_secret anywhere. Tokens are stored in sessionStorage + * (per-tab, cleared on tab close) — never localStorage. + */ +import { config, resolveEndpoints, type AppConfig, type ResolvedAppConfig } from "../config"; +import { + computeCodeChallenge, + generateCodeVerifier, + generateState, +} from "./pkce"; + +const STORAGE_KEYS = { + accessToken: "nestquest.access_token", + refreshToken: "nestquest.refresh_token", + expiresAt: "nestquest.expires_at", + idToken: "nestquest.id_token", + state: "nestquest.oidc_state", + codeVerifier: "nestquest.code_verifier", +} as const; + +export class AuthError extends Error {} + +export interface TokenResponse { + access_token?: string; + refresh_token?: string; + id_token?: string; + expires_in?: number; + token_type?: string; +} + +function makeScopes(): string { + return "openid email profile"; +} + +/** + * Build the Authentik authorization redirect URL, storing state + verifier in + * sessionStorage so the callback can validate them. Endpoints are resolved + * from the issuer's OIDC discovery document when not set on the config. + */ +export async function buildAuthorizeUrl( + app: AppConfig = config, + fetchImpl: typeof fetch = fetch, +): Promise { + if (!app.clientId) { + throw new AuthError( + "Missing VITE_AUTHENTIK_CLIENT_ID configuration. Set it in your .env before building the admin PWA.", + ); + } + const resolved = await resolveEndpoints(app, fetchImpl); + const state = generateState(); + const codeVerifier = generateCodeVerifier(); + const codeChallenge = await computeCodeChallenge(codeVerifier); + + sessionStorage.setItem(STORAGE_KEYS.state, state); + sessionStorage.setItem(STORAGE_KEYS.codeVerifier, codeVerifier); + + const url = new URL(resolved.authorizeEndpoint); + url.searchParams.set("response_type", "code"); + url.searchParams.set("client_id", app.clientId); + url.searchParams.set("redirect_uri", app.redirectUri); + url.searchParams.set("scope", makeScopes()); + url.searchParams.set("state", state); + url.searchParams.set("code_challenge", codeChallenge); + url.searchParams.set("code_challenge_method", "S256"); + return url.toString(); +} + +/** + * Handle the OAuth callback: validate state, exchange the code at the token + * endpoint (PKCE, form-urlencoded, no client_secret), store tokens. + */ +export async function handleCallback( + query: URLSearchParams | string = window.location.search, + app: AppConfig = config, + fetchImpl: typeof fetch = fetch, +): Promise { + const params = + typeof query === "string" ? new URLSearchParams(query) : query; + + const providerError = params.get("error"); + if (providerError) { + throw new AuthError( + params.get("error_description") || `Provider returned error: ${providerError}`, + ); + } + + const code = params.get("code"); + const state = params.get("state"); + if (!code || !state) { + throw new AuthError("Callback is missing the code or state parameter."); + } + + const storedState = sessionStorage.getItem(STORAGE_KEYS.state); + const codeVerifier = sessionStorage.getItem(STORAGE_KEYS.codeVerifier); + if (!storedState || storedState !== state || !codeVerifier) { + throw new AuthError("State mismatch — possible CSRF; refusing to log in."); + } + + if (!app.clientId) { + throw new AuthError( + "Missing VITE_AUTHENTIK_CLIENT_ID configuration. Set it in your .env before building the admin PWA.", + ); + } + + const resolved: ResolvedAppConfig = await resolveEndpoints(app, fetchImpl); + + const body = new URLSearchParams({ + grant_type: "authorization_code", + code, + redirect_uri: app.redirectUri, + client_id: app.clientId, + code_verifier: codeVerifier, + }); + + const response = await fetchImpl(resolved.tokenEndpoint, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: body.toString(), + }); + if (!response.ok) { + throw new AuthError(`Token exchange failed with HTTP ${response.status}`); + } + const tokens = (await response.json()) as TokenResponse; + if (!tokens.access_token) { + throw new AuthError("Token response did not include an access token."); + } + + storeTokens(tokens); + // One-time use values are consumed on success. + sessionStorage.removeItem(STORAGE_KEYS.state); + sessionStorage.removeItem(STORAGE_KEYS.codeVerifier); +} + +export function storeTokens(tokens: TokenResponse): void { + sessionStorage.setItem(STORAGE_KEYS.accessToken, tokens.access_token!); + if (tokens.refresh_token) { + sessionStorage.setItem(STORAGE_KEYS.refreshToken, tokens.refresh_token); + } + if (tokens.id_token) { + sessionStorage.setItem(STORAGE_KEYS.idToken, tokens.id_token); + } + if (typeof tokens.expires_in === "number") { + sessionStorage.setItem( + STORAGE_KEYS.expiresAt, + String(Date.now() + tokens.expires_in * 1000), + ); + } +} + +export function getToken(): string | undefined { + return sessionStorage.getItem(STORAGE_KEYS.accessToken) ?? undefined; +} + +export function getRefreshToken(): string | undefined { + return sessionStorage.getItem(STORAGE_KEYS.refreshToken) ?? undefined; +} + +export function getIdToken(): string | undefined { + return sessionStorage.getItem(STORAGE_KEYS.idToken) ?? undefined; +} + +export function isAuthenticated(): boolean { + return Boolean(getToken()); +} + +/** True once the stored access token has passed its (approximate) expiry. */ +export function isExpired(): boolean { + const expiresAt = sessionStorage.getItem(STORAGE_KEYS.expiresAt); + if (!expiresAt) return false; + return Date.now() >= Number(expiresAt); +} + +/** + * Refresh the access token with the refresh token grant. Returns false when + * no refresh token is stored or the grant fails (caller should re-login). + */ +export async function refreshAccessToken( + app: AppConfig = config, + fetchImpl: typeof fetch = fetch, +): Promise { + const refreshToken = getRefreshToken(); + if (!refreshToken || !app.clientId) return false; + + const resolved = await resolveEndpoints(app, fetchImpl); + + const body = new URLSearchParams({ + grant_type: "refresh_token", + refresh_token: refreshToken, + client_id: app.clientId, + }); + + const response = await fetchImpl(resolved.tokenEndpoint, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: body.toString(), + }); + if (!response.ok) return false; + const tokens = (await response.json()) as TokenResponse; + if (!tokens.access_token) return false; + storeTokens(tokens); + return true; +} + +export function clearAuth(): void { + for (const key of Object.values(STORAGE_KEYS)) { + sessionStorage.removeItem(key); + } +} + +/** Sign out: clear local auth, then send the browser to the end-session endpoint. */ +export async function logout( + app: AppConfig = config, + fetchImpl: typeof fetch = fetch, +): Promise { + const resolved = await resolveEndpoints(app, fetchImpl); + const idTokenHint = getIdToken(); + clearAuth(); + const params = new URLSearchParams(); + if (idTokenHint) params.set("id_token_hint", idTokenHint); + if (app.clientId) params.set("client_id", app.clientId); + params.set("post_logout_redirect_uri", window.location.origin); + window.location.assign(`${resolved.endSessionEndpoint}?${params.toString()}`); +} diff --git a/admin/src/auth/pkce.test.ts b/admin/src/auth/pkce.test.ts new file mode 100644 index 0000000..7b7f686 --- /dev/null +++ b/admin/src/auth/pkce.test.ts @@ -0,0 +1,41 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + base64UrlEncode, + computeCodeChallenge, + generateCodeVerifier, + generateState, + isLikelyValidVerifier, +} from "./pkce"; + +describe("PKCE helpers", () => { + it("base64UrlEncode has no padding and url-safe characters", () => { + // bytes chosen to force "+" and "/" and padding in plain base64 + const bytes = new Uint8Array([0xfb, 0xff, 0xbf, 0xff]); + const encoded = base64UrlEncode(bytes); + expect(encoded).not.toMatch(/[+/=]/); + }); + + it("computes the RFC 7636 appendix-B S256 vector", async () => { + const verifier = + "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"; + const challenge = await computeCodeChallenge(verifier); + expect(challenge).toBe("E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"); + }); + + it("generates verifiers with valid charset and length", () => { + for (let i = 0; i < 20; i++) { + const verifier = generateCodeVerifier(); + expect(isLikelyValidVerifier(verifier)).toBe(true); + } + }); + + it("rejects verifiers outside 43-128 chars or with reserved chars", () => { + expect(isLikelyValidVerifier("short")).toBe(false); + expect(isLikelyValidVerifier("a".repeat(129))).toBe(false); + expect(isLikelyValidVerifier("a".repeat(43) + "+")).toBe(false); + }); + + it("generates distinct random states", () => { + expect(generateState()).not.toBe(generateState()); + }); +}); diff --git a/admin/src/auth/pkce.ts b/admin/src/auth/pkce.ts new file mode 100644 index 0000000..de04c5f --- /dev/null +++ b/admin/src/auth/pkce.ts @@ -0,0 +1,55 @@ +/** + * PKCE (RFC 7636) helpers for a public SPA client. + * + * Uses the Web Crypto API, which requires a SECURE CONTEXT: the app must be + * served over https or http://localhost, otherwise crypto.subtle is + * unavailable and login cannot work. + */ + +const VERIFIER_CHARSET = + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~"; + +/** base64url-encode bytes with NO padding. */ +export function base64UrlEncode(bytes: Uint8Array): string { + let binary = ""; + for (const byte of bytes) { + binary += String.fromCharCode(byte); + } + return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); +} + +function randomBase64Url(byteLength: number): string { + const bytes = new Uint8Array(byteLength); + crypto.getRandomValues(bytes); + return base64UrlEncode(bytes); +} + +/** + * Random code_verifier: 43-128 chars, unreserved charset only. + * 32 random bytes -> 43 base64url chars, which is the minimum allowed length. + */ +export function generateCodeVerifier(): string { + return randomBase64Url(32); +} + +/** S256 code_challenge = base64url(SHA-256(verifier)), no padding. */ +export async function computeCodeChallenge(verifier: string): Promise { + const digest = await crypto.subtle.digest( + "SHA-256", + new TextEncoder().encode(verifier), + ); + return base64UrlEncode(new Uint8Array(digest)); +} + +/** Random opaque state value for CSRF protection. */ +export function generateState(): string { + return randomBase64Url(16); +} + +export function isLikelyValidVerifier(verifier: string): boolean { + return ( + verifier.length >= 43 && + verifier.length <= 128 && + [...verifier].every((char) => VERIFIER_CHARSET.includes(char)) + ); +} diff --git a/admin/src/config.test.ts b/admin/src/config.test.ts new file mode 100644 index 0000000..9fcf9bf --- /dev/null +++ b/admin/src/config.test.ts @@ -0,0 +1,215 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + ConfigError, + discoveryUrlForIssuer, + readConfig, + resolveEndpoints, + resolveEndpointsFromDiscovery, + type AppConfig, +} from "./config"; + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }); +} + +// Mirrors Authentik's layout: authorize/token/userinfo live OUTSIDE the +// application-specific issuer path, end-session/jwks under it. +function discoveryDocument(issuer: string) { + const normalized = issuer.replace(/\/+$/, ""); + const origin = new URL(normalized).origin; + return { + issuer, + authorization_endpoint: `${origin}/application/o/authorize/`, + token_endpoint: `${origin}/application/o/token/`, + userinfo_endpoint: `${origin}/application/o/userinfo/`, + end_session_endpoint: `${normalized}/end-session/`, + jwks_uri: `${normalized}/jwks/`, + }; +} + +describe("config", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("readConfig keeps the production issuer as the default", () => { + const cfg = readConfig({}); + expect(cfg.issuer).toBe("https://auth.cubecraftlabs.com/application/o/nestquest"); + expect(cfg.clientId).toBeUndefined(); + }); + + it("readConfig normalizes a trailing slash off a custom issuer", () => { + expect( + readConfig({ VITE_AUTHENTIK_ISSUER: "https://sso.custom.example/application/o/custom/" }) + .issuer, + ).toBe("https://sso.custom.example/application/o/custom"); + }); + + it("readConfig hardcodes no OIDC endpoints — they come from discovery", () => { + const cfg = readConfig({}); + expect(cfg.authorizeEndpoint).toBeUndefined(); + expect(cfg.tokenEndpoint).toBeUndefined(); + expect(cfg.userinfoEndpoint).toBeUndefined(); + expect(cfg.endSessionEndpoint).toBeUndefined(); + expect(cfg.jwksUri).toBeUndefined(); + }); + + it("builds the discovery URL for issuers with and without a trailing slash", () => { + expect(discoveryUrlForIssuer("https://sso.example.com/application/o/app/")).toBe( + "https://sso.example.com/application/o/app/.well-known/openid-configuration", + ); + expect(discoveryUrlForIssuer("https://sso.example.com/application/o/app")).toBe( + "https://sso.example.com/application/o/app/.well-known/openid-configuration", + ); + }); + + it("resolves every endpoint from the discovery document of a non-default issuer", async () => { + const issuer = "https://sso.other-host.example/application/o/other-app"; + const doc = discoveryDocument(issuer); + const fetchMock = vi.fn().mockResolvedValue(jsonResponse(doc)); + const app: AppConfig = { + issuer, + clientId: "client-id", + apiBaseUrl: "https://api.example.com", + redirectUri: "http://localhost:5173/auth/callback", + }; + + const resolved = await resolveEndpoints(app, fetchMock as unknown as typeof fetch); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenCalledWith( + "https://sso.other-host.example/application/o/other-app/.well-known/openid-configuration", + ); + // Every endpoint comes verbatim from the (non-default) discovery document. + expect(resolved.authorizeEndpoint).toBe(doc.authorization_endpoint); + expect(resolved.tokenEndpoint).toBe(doc.token_endpoint); + expect(resolved.userinfoEndpoint).toBe(doc.userinfo_endpoint); + expect(resolved.endSessionEndpoint).toBe(doc.end_session_endpoint); + expect(resolved.jwksUri).toBe(doc.jwks_uri); + // Proves no hardcoded production host leaked into the resolved endpoints. + for (const endpoint of [ + resolved.authorizeEndpoint, + resolved.tokenEndpoint, + resolved.userinfoEndpoint, + resolved.endSessionEndpoint, + resolved.jwksUri, + ]) { + expect(endpoint).not.toMatch(/cubecraftlabs/); + } + }); + + it("resolves discovery for an issuer configured with a trailing slash", async () => { + const issuer = "https://sso.slashy.example/application/o/slashy/"; + const doc = discoveryDocument(issuer); + const fetchMock = vi.fn().mockResolvedValue(jsonResponse(doc)); + + const resolved = await resolveEndpointsFromDiscovery(issuer, fetchMock as any); + + expect(fetchMock).toHaveBeenCalledWith( + "https://sso.slashy.example/application/o/slashy/.well-known/openid-configuration", + ); + expect(resolved.authorizeEndpoint).toBe(doc.authorization_endpoint); + expect(resolved.endSessionEndpoint).toBe(doc.end_session_endpoint); + }); + + it("caches discovery per issuer for the session", async () => { + const issuer = "https://sso.cached.example/application/o/cached"; + const fetchMock = vi.fn().mockResolvedValue(jsonResponse(discoveryDocument(issuer))); + + const first = await resolveEndpointsFromDiscovery(issuer, fetchMock as any); + const second = await resolveEndpointsFromDiscovery(issuer, fetchMock as any); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(second).toBe(first); + }); + + it("surfaces a clear configuration error when discovery returns an HTTP error", async () => { + const issuer = "https://sso.http-error.example/application/o/broken"; + const fetchMock = vi.fn().mockResolvedValue(new Response("", { status: 500 })); + + const error = await resolveEndpointsFromDiscovery(issuer, fetchMock as any).then( + () => null, + (error: unknown) => error, + ); + + expect(error).toBeInstanceOf(ConfigError); + expect((error as Error).message).toContain(issuer); + expect((error as Error).message).toContain("HTTP 500"); + }); + + it("surfaces a clear configuration error when discovery is unreachable", async () => { + const issuer = "https://sso.unreachable.example/application/o/unreachable"; + const fetchMock = vi.fn().mockRejectedValue(new TypeError("network down")); + + await expect( + resolveEndpointsFromDiscovery(issuer, fetchMock as any), + ).rejects.toBeInstanceOf(ConfigError); + }); + + it("surfaces a clear configuration error when the discovery document is incomplete", async () => { + const issuer = "https://sso.incomplete.example/application/o/incomplete"; + const doc = discoveryDocument(issuer) as Record; + delete doc.end_session_endpoint; + const fetchMock = vi.fn().mockResolvedValue(jsonResponse(doc)); + + await expect( + resolveEndpointsFromDiscovery(issuer, fetchMock as any), + ).rejects.toThrow(/end_session_endpoint/); + }); + + it("rejects a discovery document advertising a different issuer", async () => { + const issuer = "https://sso.mismatch.example/application/o/mismatch"; + const doc = { + ...discoveryDocument(issuer), + issuer: "https://sso.elsewhere.example/application/o/elsewhere", + }; + const fetchMock = vi.fn().mockResolvedValue(jsonResponse(doc)); + + await expect( + resolveEndpointsFromDiscovery(issuer, fetchMock as any), + ).rejects.toThrow(/issuer mismatch/i); + }); + + it("the bundled config resolves its endpoints from discovery at runtime", async () => { + const issuer = "https://sso.bundled.example/application/o/bundled"; + const fetchMock = vi.fn().mockResolvedValue(jsonResponse(discoveryDocument(issuer))); + const bundled = readConfig({ VITE_AUTHENTIK_ISSUER: `${issuer}/`, VITE_AUTHENTIK_CLIENT_ID: "bundled-client" }); + expect(bundled.authorizeEndpoint).toBeUndefined(); + + const resolved = await resolveEndpoints(bundled, fetchMock as any); + + expect(resolved.authorizeEndpoint).toBe( + "https://sso.bundled.example/application/o/authorize/", + ); + expect(resolved.tokenEndpoint).toBe("https://sso.bundled.example/application/o/token/"); + expect(resolved.userinfoEndpoint).toBe( + "https://sso.bundled.example/application/o/userinfo/", + ); + expect(resolved.endSessionEndpoint).toBe( + "https://sso.bundled.example/application/o/bundled/end-session/", + ); + expect(resolved.jwksUri).toBe("https://sso.bundled.example/application/o/bundled/jwks/"); + }); + + it("does not re-fetch discovery when the config already carries endpoints", async () => { + const app: AppConfig = { + issuer: "https://auth.example.com/application/o/nestquest", + clientId: "client-id", + apiBaseUrl: "https://api.example.com", + authorizeEndpoint: "https://auth.example.com/application/o/authorize/", + tokenEndpoint: "https://auth.example.com/application/o/token/", + userinfoEndpoint: "https://auth.example.com/application/o/userinfo/", + endSessionEndpoint: "https://auth.example.com/application/o/nestquest/end-session/", + redirectUri: "http://localhost:5173/auth/callback", + }; + const fetchMock = vi.fn(); + + const resolved = await resolveEndpoints(app, fetchMock as any); + + expect(fetchMock).not.toHaveBeenCalled(); + expect(resolved).toBe(app); + }); +}); diff --git a/admin/src/config.ts b/admin/src/config.ts new file mode 100644 index 0000000..9880be9 --- /dev/null +++ b/admin/src/config.ts @@ -0,0 +1,221 @@ +/** + * Runtime configuration, read from Vite env (import.meta.env). + * See .env.example for the variables and their defaults. + * + * OIDC endpoints are NOT hardcoded here. They are resolved lazily from the + * issuer's OIDC discovery document (see resolveEndpoints), because Authentik + * serves authorize/token/userinfo OUTSIDE the application-specific issuer + * path (issuer is /application/o//, authorize is /application/o/authorize/). + * Both hardcoding the production host and naively concatenating onto the + * issuer would break non-default issuers; discovery is the only correct way. + */ + +export interface AppConfig { + issuer: string; + clientId: string | undefined; + apiBaseUrl: string; + /** Resolved from the issuer's OIDC discovery document — see resolveEndpoints(). */ + authorizeEndpoint?: string; + tokenEndpoint?: string; + userinfoEndpoint?: string; + endSessionEndpoint?: string; + jwksUri?: string; + redirectUri: string; +} + +/** AppConfig whose OIDC endpoints have been filled in by resolveEndpoints(). */ +export type ResolvedAppConfig = AppConfig & + Required< + Pick< + AppConfig, + "authorizeEndpoint" | "tokenEndpoint" | "userinfoEndpoint" | "endSessionEndpoint" + > + >; + +export interface DiscoveredEndpoints { + issuer: string; + authorizeEndpoint: string; + tokenEndpoint: string; + userinfoEndpoint: string; + endSessionEndpoint: string; + jwksUri: string; +} + +/** Raised when OIDC discovery fails or the configuration is unusable. */ +export class ConfigError extends Error { + constructor(message: string) { + super(message); + this.name = "ConfigError"; + } +} + +const REQUIRED_ENDPOINT_NAMES = [ + "authorizeEndpoint", + "tokenEndpoint", + "userinfoEndpoint", + "endSessionEndpoint", +] as const; + +export function readConfig(env: Record = import.meta.env as any): AppConfig { + const issuer = ( + env.VITE_AUTHENTIK_ISSUER || "https://auth.cubecraftlabs.com/application/o/nestquest/" + ).replace(/\/$/, ""); + const clientId = env.VITE_AUTHENTIK_CLIENT_ID; + const apiBaseUrl = env.VITE_API_BASE_URL || "https://nestquest.cubecraftlabs.com"; + + return { + issuer, + // No default: a missing client id must surface as a configuration error + // screen, not a broken redirect to Authentik. + clientId: clientId || undefined, + apiBaseUrl, + // authorize/token/userinfo/end-session endpoints are resolved from the + // issuer's discovery document at runtime — never hardcoded here. + redirectUri: `${window.location.origin}/auth/callback`, + }; +} + +export const config: AppConfig = readConfig(); + +interface DiscoveryDocument { + issuer?: unknown; + authorization_endpoint?: unknown; + token_endpoint?: unknown; + userinfo_endpoint?: unknown; + end_session_endpoint?: unknown; + jwks_uri?: unknown; +} + +function normalizeIssuer(issuer: string): string { + return issuer.replace(/\/+$/, ""); +} + +function nonEmptyString(value: unknown): string | undefined { + return typeof value === "string" && value.length > 0 ? value : undefined; +} + +/** Discovery document location for an issuer, tolerant of a trailing slash. */ +export function discoveryUrlForIssuer(issuer: string): string { + return `${normalizeIssuer(issuer)}/.well-known/openid-configuration`; +} + +async function fetchDiscoveredEndpoints( + issuer: string, + fetchImpl: typeof fetch, +): Promise { + const normalizedIssuer = normalizeIssuer(issuer); + const discoveryUrl = `${normalizedIssuer}/.well-known/openid-configuration`; + + let response: Response; + try { + response = await fetchImpl(discoveryUrl); + } catch (error) { + const reason = error instanceof Error ? error.message : String(error); + throw new ConfigError( + `OIDC discovery request to ${discoveryUrl} failed (${reason}). ` + + "Check VITE_AUTHENTIK_ISSUER and network reachability.", + ); + } + if (!response.ok) { + throw new ConfigError( + `OIDC discovery failed: ${discoveryUrl} returned HTTP ${response.status}. ` + + "Check VITE_AUTHENTIK_ISSUER.", + ); + } + + let doc: DiscoveryDocument; + try { + doc = (await response.json()) as DiscoveryDocument; + } catch { + throw new ConfigError( + `OIDC discovery document at ${discoveryUrl} is not valid JSON. Check VITE_AUTHENTIK_ISSUER.`, + ); + } + + const authorizationEndpoint = nonEmptyString(doc.authorization_endpoint); + const tokenEndpoint = nonEmptyString(doc.token_endpoint); + const userinfoEndpoint = nonEmptyString(doc.userinfo_endpoint); + const endSessionEndpoint = nonEmptyString(doc.end_session_endpoint); + const jwksUri = nonEmptyString(doc.jwks_uri); + const discoveredIssuer = nonEmptyString(doc.issuer); + + const endpointFields: Array<[string, string | undefined]> = [ + ["authorization_endpoint", authorizationEndpoint], + ["token_endpoint", tokenEndpoint], + ["userinfo_endpoint", userinfoEndpoint], + ["end_session_endpoint", endSessionEndpoint], + ["jwks_uri", jwksUri], + ]; + const missing = endpointFields.filter(([, value]) => !value).map(([name]) => name); + if (missing.length > 0) { + throw new ConfigError( + `OIDC discovery document at ${discoveryUrl} is missing: ${missing.join(", ")}. ` + + "The issuer must point at an OIDC provider that publishes these endpoints.", + ); + } + + if (discoveredIssuer && normalizeIssuer(discoveredIssuer) !== normalizedIssuer) { + throw new ConfigError( + `OIDC discovery issuer mismatch: ${discoveryUrl} advertises "${discoveredIssuer}" ` + + `but VITE_AUTHENTIK_ISSUER is "${normalizedIssuer}".`, + ); + } + + return { + issuer: normalizedIssuer, + authorizeEndpoint: authorizationEndpoint!, + tokenEndpoint: tokenEndpoint!, + userinfoEndpoint: userinfoEndpoint!, + endSessionEndpoint: endSessionEndpoint!, + jwksUri: jwksUri!, + }; +} + +/** + * Session cache: one discovery fetch per issuer per SPA session. Failed + * lookups are evicted so a transient network error can be retried instead of + * poisoning the whole session. + */ +const discoveryCache = new Map>(); + +export function resolveEndpointsFromDiscovery( + issuer: string, + fetchImpl: typeof fetch = fetch, +): Promise { + const normalizedIssuer = normalizeIssuer(issuer); + const cached = discoveryCache.get(normalizedIssuer); + if (cached) return cached; + + const pending = fetchDiscoveredEndpoints(normalizedIssuer, fetchImpl); + discoveryCache.set(normalizedIssuer, pending); + pending.catch(() => { + discoveryCache.delete(normalizedIssuer); + }); + return pending; +} + +/** + * Return `app` with all OIDC endpoints filled in. When the config already + * carries them (pre-resolved config, tests) this is a no-op; otherwise the + * issuer's discovery document is fetched (cached for the session) and its + * authorization/token/userinfo/end-session/jwks endpoints are used. + */ +export async function resolveEndpoints( + app: AppConfig, + fetchImpl: typeof fetch = fetch, +): Promise { + const missing = REQUIRED_ENDPOINT_NAMES.filter((name) => !app[name]); + if (missing.length === 0) { + return app as ResolvedAppConfig; + } + + const discovered = await resolveEndpointsFromDiscovery(app.issuer, fetchImpl); + return { + ...app, + authorizeEndpoint: discovered.authorizeEndpoint, + tokenEndpoint: discovered.tokenEndpoint, + userinfoEndpoint: discovered.userinfoEndpoint, + endSessionEndpoint: discovered.endSessionEndpoint, + jwksUri: discovered.jwksUri, + }; +} diff --git a/admin/src/main.tsx b/admin/src/main.tsx index e9c7e64..35e1ff7 100644 --- a/admin/src/main.tsx +++ b/admin/src/main.tsx @@ -1,10 +1,10 @@ import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; -import App from "./App"; +import AppRoutes from "./AppRoutes"; import "./index.css"; createRoot(document.getElementById("root")!).render( - + , ); \ No newline at end of file -- 2.55.0 From f476821e8ee89445f38c09dd6f734d8465ba57ed Mon Sep 17 00:00:00 2001 From: dev_agent <17+dev_agent@code.cubecraftcreations.com> Date: Wed, 23 Sep 2026 19:42:27 -0400 Subject: [PATCH 03/13] Task 96ab3c0b: PWA manifest and service worker (#189) --- admin/index.html | 3 + admin/public/icons/icon-192.png | Bin 0 -> 2580 bytes admin/public/icons/icon-512.png | Bin 0 -> 8815 bytes admin/public/icons/icon-maskable-512.png | Bin 0 -> 8371 bytes admin/public/manifest.webmanifest | 14 + admin/public/sw.js | 88 +++++ admin/scripts/generate-icons.mjs | 80 +++++ admin/src/main.tsx | 5 +- admin/src/pwa/pwa.test.ts | 396 +++++++++++++++++++++++ admin/src/pwa/register.ts | 13 + admin/vite-sw-plugin.ts | 93 ++++++ admin/vite.config.ts | 5 +- 12 files changed, 694 insertions(+), 3 deletions(-) create mode 100644 admin/public/icons/icon-192.png create mode 100644 admin/public/icons/icon-512.png create mode 100644 admin/public/icons/icon-maskable-512.png create mode 100644 admin/public/manifest.webmanifest create mode 100644 admin/public/sw.js create mode 100644 admin/scripts/generate-icons.mjs create mode 100644 admin/src/pwa/pwa.test.ts create mode 100644 admin/src/pwa/register.ts create mode 100644 admin/vite-sw-plugin.ts diff --git a/admin/index.html b/admin/index.html index 2ecc421..8f95fb8 100644 --- a/admin/index.html +++ b/admin/index.html @@ -3,6 +3,9 @@ + + + NestQuest Admin diff --git a/admin/public/icons/icon-192.png b/admin/public/icons/icon-192.png new file mode 100644 index 0000000000000000000000000000000000000000..4435870c8891af50b9eff2d5fd05a1aed156abe4 GIT binary patch literal 2580 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 h1DF)UO+@U)|e`|4713pZaR_@yScK{tuRSZ{q*} literal 0 HcmV?d00001 diff --git a/admin/public/icons/icon-maskable-512.png b/admin/public/icons/icon-maskable-512.png new file mode 100644 index 0000000000000000000000000000000000000000..ae98c76043bc1c19dde093220bf2e8289683a0be GIT binary patch literal 8371 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 { + event.waitUntil( + caches + .open(SHELL_CACHE) + .then((cache) => cache.addAll(SHELL_URLS)) + .then(() => self.skipWaiting()), + ); +}); + +self.addEventListener("activate", (event) => { + event.waitUntil( + caches + .keys() + .then((keys) => + Promise.all( + keys + .filter((key) => key.startsWith(CACHE_PREFIX) && !CURRENT_CACHES.includes(key)) + .map((key) => caches.delete(key)), + ), + ) + .then(() => self.clients.claim()), + ); +}); + +self.addEventListener("fetch", (event) => { + const request = event.request; + if (request.method !== "GET") return; + if (new URL(request.url).origin !== self.location.origin) return; + + if (request.mode === "navigate") { + event.respondWith( + fetch(request).catch(() => + caches.match("/index.html", { cacheName: SHELL_CACHE }).then( + (cached) => cached || Response.error(), + ), + ), + ); + return; + } + + if (STATIC_DESTINATIONS.includes(request.destination)) { + event.respondWith(cacheFirst(request)); + } +}); + +async function cacheFirst(request) { + const cached = await caches.match(request); + if (cached) return cached; + const response = await fetch(request); + if (response.ok && response.type === "basic") { + const cache = await caches.open(RUNTIME_CACHE); + await cache.put(request, response.clone()); + } + return response; +} diff --git a/admin/scripts/generate-icons.mjs b/admin/scripts/generate-icons.mjs new file mode 100644 index 0000000..89cd01f --- /dev/null +++ b/admin/scripts/generate-icons.mjs @@ -0,0 +1,80 @@ +// Generates the committed PWA icons under public/icons/ as genuine PNGs. +// Pure Node (zlib only) — no network, no image dependencies. +// Run: node scripts/generate-icons.mjs +import { writeFileSync } from "node:fs"; +import { deflateSync } from "node:zlib"; +import { fileURLToPath } from "node:url"; + +// Brand tokens (design/tokens/nestquest-admin-tokens.css). +const BLUE = [0x3b, 0x3a, 0xb8]; +const PURPLE = [0xa0, 0x35, 0xcc]; +const WHITE = [0xff, 0xff, 0xff]; + +const CRC_TABLE = Array.from({ length: 256 }, (_, n) => { + let c = n; + for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; + return c >>> 0; +}); + +function crc32(buf) { + let c = 0xffffffff; + for (const b of buf) c = CRC_TABLE[(c ^ b) & 0xff] ^ (c >>> 8); + return (c ^ 0xffffffff) >>> 0; +} + +function chunk(type, data) { + const len = Buffer.alloc(4); + len.writeUInt32BE(data.length); + const body = Buffer.concat([Buffer.from(type, "ascii"), data]); + const crc = Buffer.alloc(4); + crc.writeUInt32BE(crc32(body)); + return Buffer.concat([len, body, crc]); +} + +function encodePng(size, pixel) { + const ihdr = Buffer.alloc(13); + ihdr.writeUInt32BE(size, 0); + ihdr.writeUInt32BE(size, 4); + ihdr[8] = 8; // bit depth + ihdr[9] = 2; // colour type: RGB + const raw = Buffer.alloc(size * (size * 3 + 1)); + for (let y = 0; y < size; y++) { + const row = y * (size * 3 + 1); + raw[row] = 0; // filter: none + for (let x = 0; x < size; x++) raw.set(pixel(x, y), row + 1 + x * 3); + } + return Buffer.concat([ + Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), + chunk("IHDR", ihdr), + chunk("IDAT", deflateSync(raw, { level: 9 })), + chunk("IEND", Buffer.alloc(0)), + ]); +} + +// Flat brand mark: diagonal blue→purple gradient with a white ring ("nest") +// around a white dot ("quest"). `scale` shrinks the mark for the maskable +// safe zone (inner 80% circle). +function brandMark(size, scale) { + const c = size / 2; + const outer = size * 0.34 * scale; + const inner = size * 0.24 * scale; + const dot = size * 0.1 * scale; + return (x, y) => { + const d = Math.hypot(x + 0.5 - c, y + 0.5 - c); + if ((d <= outer && d >= inner) || d <= dot) return WHITE; + const t = (x + y) / (2 * (size - 1)); + return BLUE.map((b, i) => Math.round(b + (PURPLE[i] - b) * t)); + }; +} + +const outDir = new URL("../public/icons/", import.meta.url); +const icons = [ + ["icon-192.png", 192, 1], + ["icon-512.png", 512, 1], + ["icon-maskable-512.png", 512, 0.75], +]; +for (const [name, size, scale] of icons) { + const path = fileURLToPath(new URL(name, outDir)); + writeFileSync(path, encodePng(size, brandMark(size, scale))); + console.log(`wrote ${path}`); +} diff --git a/admin/src/main.tsx b/admin/src/main.tsx index 35e1ff7..5f81c94 100644 --- a/admin/src/main.tsx +++ b/admin/src/main.tsx @@ -2,9 +2,12 @@ import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; import AppRoutes from "./AppRoutes"; import "./index.css"; +import { registerServiceWorker } from "./pwa/register"; createRoot(document.getElementById("root")!).render( , -); \ No newline at end of file +); + +void registerServiceWorker(); diff --git a/admin/src/pwa/pwa.test.ts b/admin/src/pwa/pwa.test.ts new file mode 100644 index 0000000..4089ab4 --- /dev/null +++ b/admin/src/pwa/pwa.test.ts @@ -0,0 +1,396 @@ +import { existsSync, readFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + REVISION_MARKER, + STATIC_PRECACHE_FILES, + injectServiceWorker, +} from "../../vite-sw-plugin"; +import { registerServiceWorker } from "./register"; + +const adminRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../.."); +const adminPath = (rel: string) => resolve(adminRoot, rel); +const publicPath = (urlPath: string) => adminPath(`public${urlPath}`); + +interface ManifestIcon { + src: string; + sizes: string; + type: string; + purpose?: string; +} + +const manifest = JSON.parse(readFileSync(publicPath("/manifest.webmanifest"), "utf8")); +const icons: ManifestIcon[] = manifest.icons; +const swSource = readFileSync(publicPath("/sw.js"), "utf8"); + +const sizeOf = (icon: ManifestIcon) => Number(icon.sizes.split("x")[0]); +const purposes = (icon: ManifestIcon) => (icon.purpose ?? "any").split(/\s+/); + +describe("web app manifest", () => { + it("meets the installability criteria", () => { + expect(manifest.name).toBe("NestQuest Admin"); + expect(manifest.short_name).toBe("NestQuest"); + expect(manifest.start_url).toBe("/"); + expect(manifest.scope).toBe("/"); + expect(manifest.display).toBe("standalone"); + expect(manifest.background_color).toMatch(/^#[0-9a-f]{6}$/i); + expect(manifest.theme_color).toMatch(/^#[0-9a-f]{6}$/i); + + const any = icons.filter((i) => purposes(i).includes("any")); + expect(any.some((i) => sizeOf(i) >= 192)).toBe(true); + expect(any.some((i) => sizeOf(i) >= 512)).toBe(true); + expect(icons.some((i) => purposes(i).includes("maskable"))).toBe(true); + }); + + it("uses the admin design token colours", () => { + const css = readFileSync(adminPath("src/index.css"), "utf8"); + expect(css).toContain(`--nq-brand-blue: ${manifest.theme_color};`); + expect(css).toContain(`--nq-a-page: ${manifest.background_color};`); + }); + + it.each(icons.map((i) => [i.src, i] as const))( + "icon %s is a real PNG of its declared size", + (_src, icon) => { + const path = publicPath(icon.src); + expect(existsSync(path)).toBe(true); + const png = readFileSync(path); + expect([...png.subarray(0, 8)]).toEqual([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); + expect(png.toString("ascii", 12, 16)).toBe("IHDR"); + const [w, h] = icon.sizes.split("x").map(Number); + expect(png.readUInt32BE(16)).toBe(w); + expect(png.readUInt32BE(20)).toBe(h); + expect(icon.type).toBe("image/png"); + }, + ); +}); + +describe("index.html and entry point", () => { + const html = readFileSync(adminPath("index.html"), "utf8"); + + it("links the manifest, theme colour and apple touch icon", () => { + expect(html).toContain(''); + expect(html).toContain(``); + expect(html).toContain(''); + }); + + it("main.tsx registers the service worker", () => { + const main = readFileSync(adminPath("src/main.tsx"), "utf8"); + expect(main).toMatch(/import \{ registerServiceWorker \} from "\.\/pwa\/register"/); + expect(main).toMatch(/registerServiceWorker\(\)/); + }); +}); + +// Minimal service-worker global scope: runs public/sw.js against in-memory +// caches and a stubbed network, so handler behaviour is checked without I/O. +function loadServiceWorker( + network: (url: string) => Promise, + source: string = swSource, +) { + const listeners: Record void> = {}; + const stores = new Map>(); + const openStore = (name: string) => { + if (!stores.has(name)) stores.set(name, new Map()); + return stores.get(name)!; + }; + const keyOf = (req: string | { url: string }) => + new URL(typeof req === "string" ? req : req.url, "https://admin.test").href; + const fetchFn = vi.fn((req: string | { url: string }) => network(keyOf(req))); + + const caches = { + open: async (name: string) => { + const store = openStore(name); + return { + addAll: async (urls: string[]) => { + for (const u of urls) store.set(keyOf(u), await network(keyOf(u))); + }, + put: async (req: { url: string }, res: FakeResponse) => void store.set(keyOf(req), res), + }; + }, + keys: async () => [...stores.keys()], + delete: async (name: string) => stores.delete(name), + match: async (req: string | { url: string }, opts?: { cacheName?: string }) => { + const names = opts?.cacheName ? [opts.cacheName] : [...stores.keys()]; + for (const n of names) { + const hit = stores.get(n)?.get(keyOf(req)); + if (hit) return hit; + } + return undefined; + }, + }; + const self = { + location: new URL("https://admin.test/"), + addEventListener: (type: string, fn: (event: any) => void) => (listeners[type] = fn), + skipWaiting: vi.fn(async () => undefined), + clients: { claim: vi.fn(async () => undefined) }, + }; + const ResponseStub = { error: () => new FakeResponse("", false, "error") }; + new Function("self", "caches", "fetch", "Response", source)(self, caches, fetchFn, ResponseStub); + + const dispatch = async (type: string, init: object = {}) => { + let pending: Promise | undefined; + const event = { + ...init, + waitUntil: (p: Promise) => (pending = p), + respondWith: (p: Promise) => (pending = p), + }; + listeners[type](event); + return { responded: pending !== undefined, result: await pending }; + }; + return { listeners, stores, self, fetchFn, dispatch, openStore }; +} + +class FakeResponse { + constructor( + readonly body: string, + readonly ok = true, + readonly type = "basic", + ) {} + clone() { + return new FakeResponse(this.body, this.ok, this.type); + } +} + +const request = (url: string, extra: object = {}) => ({ + url: new URL(url, "https://admin.test").href, + method: "GET", + mode: "cors", + destination: "", + ...extra, +}); + +describe("service worker (public/sw.js)", () => { + it("is a static asset served from the site root", () => { + expect(existsSync(publicPath("/sw.js"))).toBe(true); + expect(swSource).toMatch(/const SHELL_CACHE = /); + expect(swSource).toMatch(/addEventListener\("install"/); + expect(swSource).toMatch(/addEventListener\("activate"/); + expect(swSource).toMatch(/addEventListener\("fetch"/); + }); + + it("precaches the shell on install, prunes stale caches on activate", async () => { + const sw = loadServiceWorker(async (url) => new FakeResponse(`net:${url}`)); + sw.openStore("nestquest-admin-shell-v0"); + + await sw.dispatch("install"); + expect(sw.self.skipWaiting).toHaveBeenCalled(); + const precached = [...sw.stores.values()].flatMap((s) => [...s.keys()]); + for (const path of ["/", "/index.html", "/manifest.webmanifest", ...icons.map((i) => i.src)]) { + expect(precached).toContain(`https://admin.test${path}`); + } + + await sw.dispatch("activate"); + expect(sw.stores.has("nestquest-admin-shell-v0")).toBe(false); + expect(sw.self.clients.claim).toHaveBeenCalled(); + }); + + it("serves the cached shell for navigations when offline", async () => { + let online = true; + const sw = loadServiceWorker(async (url) => { + if (!online) throw new TypeError("Failed to fetch"); + return new FakeResponse(`net:${url}`); + }); + await sw.dispatch("install"); + online = false; + + const { result } = await sw.dispatch("fetch", { + request: request("/children/42", { mode: "navigate", destination: "document" }), + }); + expect((result as FakeResponse).body).toBe("net:https://admin.test/index.html"); + }); + + it("serves static assets cache-first and fills the runtime cache", async () => { + const sw = loadServiceWorker(async (url) => new FakeResponse(`net:${url}`)); + const asset = request("/assets/index-abc123.js", { destination: "script" }); + + const first = await sw.dispatch("fetch", { request: asset }); + expect((first.result as FakeResponse).body).toBe("net:https://admin.test/assets/index-abc123.js"); + expect(sw.fetchFn).toHaveBeenCalledTimes(1); + + const second = await sw.dispatch("fetch", { request: asset }); + expect((second.result as FakeResponse).body).toBe("net:https://admin.test/assets/index-abc123.js"); + expect(sw.fetchFn).toHaveBeenCalledTimes(1); + }); + + it("never handles cross-origin, non-GET or non-static requests", async () => { + const sw = loadServiceWorker(async (url) => new FakeResponse(`net:${url}`)); + const cases = [ + request("https://auth.example.com/logo.png", { destination: "image" }), + request("/assets/app.js", { destination: "script", method: "POST" }), + request("/api/admin/children"), + ]; + for (const req of cases) { + const { responded } = await sw.dispatch("fetch", { request: req }); + expect(responded).toBe(false); + } + expect(sw.fetchFn).not.toHaveBeenCalled(); + }); +}); + +// A Vite-style built index.html referencing hashed bundles. +const builtIndexHtml = (hash: string) => ` + + + + + + + + + +
+`; + +// Same-origin script/link URLs of an HTML document, parsed independently of +// the build plugin so the regression check does not trust its own extractor. +const referencedAssets = (html: string) => + [...new DOMParser().parseFromString(html, "text/html").querySelectorAll("script[src], link[href]")] + .map((el) => new URL(el.getAttribute("src") ?? el.getAttribute("href")!, "https://admin.test/")) + .filter((url) => url.origin === "https://admin.test") + .map((url) => url.href); + +// The unhashed files the build hashes into the revision, read from public/. +const staticFiles = STATIC_PRECACHE_FILES.map((path) => ({ + path, + bytes: readFileSync(publicPath(`/${path}`)), +})); +const revisionOf = (src: string) => /const REVISION = "([0-9a-f]+)";/.exec(src)?.[1]; + +describe("build-time service worker injection", () => { + it("precaches every asset the cached index.html references on install (PWA-001)", async () => { + const html = builtIndexHtml("Ab12Cd34"); + const built = injectServiceWorker(html, swSource, staticFiles); + const sw = loadServiceWorker(async (url) => + new FakeResponse(url.endsWith("/index.html") ? html : `net:${url}`), + built); + + await sw.dispatch("install"); + const shell = [...sw.stores.entries()].find(([name]) => name.startsWith("nestquest-admin-shell-")); + expect(shell).toBeDefined(); + const [, shellStore] = shell!; + + const cachedHtml = shellStore.get("https://admin.test/index.html")!.body; + const assets = referencedAssets(cachedHtml); + expect(assets).toEqual( + expect.arrayContaining([ + "https://admin.test/assets/index-Ab12Cd34.js", + "https://admin.test/assets/vendor-Ab12Cd34.js", + "https://admin.test/assets/index-Ab12Cd34.css", + ]), + ); + for (const asset of assets) expect([...shellStore.keys()]).toContain(asset); + expect([...shellStore.keys()]).not.toContain("https://auth.example.com/"); + }); + + it("derives a new revision for different index.html content (PWA-002)", () => { + const a = injectServiceWorker(builtIndexHtml("aaaa1111"), swSource, staticFiles); + const b = injectServiceWorker(builtIndexHtml("bbbb2222"), swSource, staticFiles); + + expect(a).not.toContain(REVISION_MARKER); + expect(b).not.toContain(REVISION_MARKER); + expect(revisionOf(a)).toMatch(/^[0-9a-f]{12}$/); + expect(revisionOf(b)).toMatch(/^[0-9a-f]{12}$/); + expect(revisionOf(a)).not.toBe(revisionOf(b)); + expect(injectServiceWorker(builtIndexHtml("aaaa1111"), swSource, staticFiles)).toBe(a); + }); + + it("derives a new revision when only a precached static file changes (PWA-002)", () => { + const html = builtIndexHtml("aaaa1111"); + const base = revisionOf(injectServiceWorker(html, swSource, staticFiles)); + const withChanged = (path: string) => + staticFiles.map((f) => + f.path === path ? { path, bytes: Buffer.concat([f.bytes, Buffer.from([0])]) } : f, + ); + + expect(revisionOf(injectServiceWorker(html, swSource, [...staticFiles].reverse()))).toBe(base); + for (const path of STATIC_PRECACHE_FILES) { + const changed = revisionOf(injectServiceWorker(html, swSource, withChanged(path))); + expect(changed, path).toMatch(/^[0-9a-f]{12}$/); + expect(changed, path).not.toBe(base); + } + }); + + it("hashes every unhashed URL sw.js precaches (PWA-002)", () => { + const shellUrls = [...swSource.matchAll(/^\s*"(\/[^"]*)",$/gm)].map((m) => m[1]); + const fixed = shellUrls.filter((url) => url !== "/" && url !== "/index.html"); + expect(fixed.map((url) => url.slice(1)).sort()).toEqual([...STATIC_PRECACHE_FILES].sort()); + }); + + it("names both caches after the build revision (PWA-002)", async () => { + const built = injectServiceWorker(builtIndexHtml("aaaa1111"), swSource, staticFiles); + const revision = /const REVISION = "([0-9a-f]+)";/.exec(built)![1]; + const sw = loadServiceWorker(async (url) => new FakeResponse(`net:${url}`), built); + + await sw.dispatch("install"); + await sw.dispatch("fetch", { request: request("/assets/late.js", { destination: "script" }) }); + expect([...sw.stores.keys()].sort()).toEqual([ + `nestquest-admin-runtime-${revision}`, + `nestquest-admin-shell-${revision}`, + ]); + }); + + it("fails fast when sw.js lacks the placeholders", () => { + expect(() => injectServiceWorker(builtIndexHtml("x"), "const SHELL_URLS = [];", staticFiles)).toThrow(); + }); + + it("activation deletes only stale nestquest-admin caches (PWA-003)", async () => { + const built = injectServiceWorker(builtIndexHtml("aaaa1111"), swSource, staticFiles); + const sw = loadServiceWorker(async (url) => new FakeResponse(`net:${url}`), built); + for (const name of [ + "nestquest-admin-shell-old", + "nestquest-admin-runtime-old", + "some-other-app-cache", + ]) { + sw.openStore(name); + } + + await sw.dispatch("install"); + await sw.dispatch("activate"); + const names = [...sw.stores.keys()]; + expect(names).not.toContain("nestquest-admin-shell-old"); + expect(names).not.toContain("nestquest-admin-runtime-old"); + expect(names).toContain("some-other-app-cache"); + expect(names.some((n) => n.startsWith("nestquest-admin-shell-") && n !== "nestquest-admin-shell-old")).toBe(true); + }); +}); + +describe("registerServiceWorker", () => { + afterEach(() => { + vi.unstubAllEnvs(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + const stubServiceWorker = (register: ReturnType) => + vi.stubGlobal("navigator", { ...navigator, serviceWorker: { register } }); + + it("does not register outside production builds", async () => { + const register = vi.fn(); + stubServiceWorker(register); + expect(import.meta.env.PROD).toBe(false); + await registerServiceWorker(); + expect(register).not.toHaveBeenCalled(); + }); + + it("registers /sw.js in production", async () => { + vi.stubEnv("PROD", true); + const register = vi.fn().mockResolvedValue({}); + stubServiceWorker(register); + await registerServiceWorker(); + expect(register).toHaveBeenCalledWith("/sw.js", { scope: "/" }); + }); + + it("logs instead of throwing when registration fails", async () => { + vi.stubEnv("PROD", true); + stubServiceWorker(vi.fn().mockRejectedValue(new Error("boom"))); + const error = vi.spyOn(console, "error").mockImplementation(() => {}); + await expect(registerServiceWorker()).resolves.toBeUndefined(); + expect(error).toHaveBeenCalled(); + }); + + it("does nothing when the browser lacks service workers", async () => { + vi.stubEnv("PROD", true); + vi.stubGlobal("navigator", {}); + await expect(registerServiceWorker()).resolves.toBeUndefined(); + }); +}); diff --git a/admin/src/pwa/register.ts b/admin/src/pwa/register.ts new file mode 100644 index 0000000..14f00f6 --- /dev/null +++ b/admin/src/pwa/register.ts @@ -0,0 +1,13 @@ +/** + * Service worker registration for the installable admin PWA. + * Registers /sw.js only in production builds, so dev servers and tests never + * run a worker. Failures are logged, never thrown — the app works without it. + */ +export async function registerServiceWorker(): Promise { + if (!import.meta.env.PROD || !("serviceWorker" in navigator)) return; + try { + await navigator.serviceWorker.register("/sw.js", { scope: "/" }); + } catch (error) { + console.error("Service worker registration failed:", error); + } +} diff --git a/admin/vite-sw-plugin.ts b/admin/vite-sw-plugin.ts new file mode 100644 index 0000000..4db9f9e --- /dev/null +++ b/admin/vite-sw-plugin.ts @@ -0,0 +1,93 @@ +/* Build-time service worker injection. After Vite writes the bundle, the + hashed asset URLs referenced by dist/index.html and a content-derived + revision are written into dist/sw.js, so the offline shell is complete right + after install and every release ships a new worker with fresh caches. + Build tooling only — never imported by the app bundle. */ +import { createHash } from "node:crypto"; +import { readFileSync, writeFileSync } from "node:fs"; +import { resolve } from "node:path"; +import type { Plugin } from "vite"; + +export const REVISION_MARKER = "__NQ_BUILD_REVISION__"; +const PRECACHE_PLACEHOLDER = "const PRECACHE_ASSETS = [];"; +// Unhashed files sw.js precaches by fixed URL, relative to the build output. +export const STATIC_PRECACHE_FILES = [ + "manifest.webmanifest", + "icons/icon-192.png", + "icons/icon-512.png", + "icons/icon-maskable-512.png", +]; + +export interface PrecacheFile { + path: string; + bytes: string | Uint8Array; +} + +// Every same-origin URL an index.html loads via