diff --git a/.gitea/workflows/deploy-admin.yml b/.gitea/workflows/deploy-admin.yml new file mode 100644 index 0000000..d86cf5d --- /dev/null +++ b/.gitea/workflows/deploy-admin.yml @@ -0,0 +1,126 @@ +name: deploy admin (hub fleet) + +# Rolls Hub releases that include the org-admin SPA (`admin-web` → registerStaticSpa) +# onto the managed Alpha host. Two fleets share one machine and are separated by +# the middle DNS label of each Silo's public URL: +# +# dev → https://.educraft-dev.paradigm-edu.net (every push + PR) +# prod → https://.educraft.paradigm-edu.net (main + tags) +# +# Example prod tenant: +# https://para-26071100.educraft.paradigm-edu.net/auth/feishu/para-26071100 +# +# Required Gitea secret: +# DEPLOY_SSH_KEY — private key for root@39.107.254.4 +# +# Optional secrets / vars (defaults match NEW_SILO_RUNBOOK): +# DEPLOY_HOST, DEPLOY_USER, DEPLOY_SSH_PORT, DEPLOY_BASE +# vars.ALLOW_EMPTY_FLEET=1 — green when the fleet has no silos yet + +on: + push: + paths: + - "hub/**" + - ".gitea/workflows/deploy-admin.yml" + pull_request: + paths: + - "hub/**" + - ".gitea/workflows/deploy-admin.yml" + workflow_dispatch: + inputs: + fleet: + description: "Which fleet to deploy (dev | prod | both)" + required: true + default: both + allow_empty_fleet: + description: "Succeed if no silo matches (1/0)" + required: false + default: "0" + +concurrency: + group: deploy-admin-host + cancel-in-progress: false + +jobs: + deploy-dev: + name: deploy fleet dev (educraft-dev) + runs-on: ubuntu-latest + if: > + github.event_name == 'pull_request' || + (github.event_name == 'push' && !startsWith(github.ref, 'refs/tags/')) || + (github.event_name == 'workflow_dispatch' && + (github.event.inputs.fleet == 'dev' || github.event.inputs.fleet == 'both')) + steps: + - uses: actions/checkout@v5 + + - name: Install rsync + ssh + run: sudo apt-get update && sudo apt-get install -y rsync openssh-client + + - name: Deploy Hub + admin SPA to educraft-dev fleet + env: + CPH_FLEET: dev + PLATFORM_DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }} + PLATFORM_DEPLOY_USER: ${{ secrets.DEPLOY_USER }} + PLATFORM_DEPLOY_PORT: ${{ secrets.DEPLOY_SSH_PORT }} + PLATFORM_DEPLOY_BASE: ${{ secrets.DEPLOY_BASE }} + PLATFORM_DEPLOY_RELEASE: ${{ github.sha }} + ALLOW_EMPTY_FLEET: ${{ github.event.inputs.allow_empty_fleet || vars.ALLOW_EMPTY_FLEET || '0' }} + DEPLOY_SSH_KEY_BODY: ${{ secrets.DEPLOY_SSH_KEY }} + run: | + set -euo pipefail + if [ -z "${DEPLOY_SSH_KEY_BODY:-}" ]; then + echo "missing secret DEPLOY_SSH_KEY" >&2 + exit 1 + fi + key="$(mktemp)" + trap 'rm -f "$key"' EXIT + printf '%s\n' "$DEPLOY_SSH_KEY_BODY" >"$key" + chmod 600 "$key" + export PLATFORM_DEPLOY_SSH_KEY="$key" + # Apply managed-host defaults when secrets are unset. + export PLATFORM_DEPLOY_HOST="${PLATFORM_DEPLOY_HOST:-39.107.254.4}" + export PLATFORM_DEPLOY_USER="${PLATFORM_DEPLOY_USER:-root}" + export PLATFORM_DEPLOY_PORT="${PLATFORM_DEPLOY_PORT:-22}" + export PLATFORM_DEPLOY_BASE="${PLATFORM_DEPLOY_BASE:-/srv/curriculum-project-hub}" + bash hub/deploy/deploy_fleet_release.sh + + deploy-prod: + name: deploy fleet prod (educraft) + runs-on: ubuntu-latest + if: > + (github.event_name == 'push' && github.ref == 'refs/heads/main') || + (github.event_name == 'push' && startsWith(github.ref, 'refs/tags/')) || + (github.event_name == 'workflow_dispatch' && + (github.event.inputs.fleet == 'prod' || github.event.inputs.fleet == 'both')) + steps: + - uses: actions/checkout@v5 + + - name: Install rsync + ssh + run: sudo apt-get update && sudo apt-get install -y rsync openssh-client + + - name: Deploy Hub + admin SPA to educraft fleet + env: + CPH_FLEET: prod + PLATFORM_DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }} + PLATFORM_DEPLOY_USER: ${{ secrets.DEPLOY_USER }} + PLATFORM_DEPLOY_PORT: ${{ secrets.DEPLOY_SSH_PORT }} + PLATFORM_DEPLOY_BASE: ${{ secrets.DEPLOY_BASE }} + PLATFORM_DEPLOY_RELEASE: ${{ github.sha }} + ALLOW_EMPTY_FLEET: ${{ github.event.inputs.allow_empty_fleet || vars.ALLOW_EMPTY_FLEET || '0' }} + DEPLOY_SSH_KEY_BODY: ${{ secrets.DEPLOY_SSH_KEY }} + run: | + set -euo pipefail + if [ -z "${DEPLOY_SSH_KEY_BODY:-}" ]; then + echo "missing secret DEPLOY_SSH_KEY" >&2 + exit 1 + fi + key="$(mktemp)" + trap 'rm -f "$key"' EXIT + printf '%s\n' "$DEPLOY_SSH_KEY_BODY" >"$key" + chmod 600 "$key" + export PLATFORM_DEPLOY_SSH_KEY="$key" + export PLATFORM_DEPLOY_HOST="${PLATFORM_DEPLOY_HOST:-39.107.254.4}" + export PLATFORM_DEPLOY_USER="${PLATFORM_DEPLOY_USER:-root}" + export PLATFORM_DEPLOY_PORT="${PLATFORM_DEPLOY_PORT:-22}" + export PLATFORM_DEPLOY_BASE="${PLATFORM_DEPLOY_BASE:-/srv/curriculum-project-hub}" + bash hub/deploy/deploy_fleet_release.sh diff --git a/hub/.gitignore b/hub/.gitignore index 12419b1..f02a5e5 100644 --- a/hub/.gitignore +++ b/hub/.gitignore @@ -4,3 +4,7 @@ dist/ .env .env.* !.env.example +.secrets/ +admin-web/node_modules/ +admin-web/build/ +admin-web/.svelte-kit/ diff --git a/hub/admin-web/.gitignore b/hub/admin-web/.gitignore new file mode 100644 index 0000000..3b462cb --- /dev/null +++ b/hub/admin-web/.gitignore @@ -0,0 +1,23 @@ +node_modules + +# Output +.output +.vercel +.netlify +.wrangler +/.svelte-kit +/build + +# OS +.DS_Store +Thumbs.db + +# Env +.env +.env.* +!.env.example +!.env.test + +# Vite +vite.config.js.timestamp-* +vite.config.ts.timestamp-* diff --git a/hub/admin-web/.npmrc b/hub/admin-web/.npmrc new file mode 100644 index 0000000..b6f27f1 --- /dev/null +++ b/hub/admin-web/.npmrc @@ -0,0 +1 @@ +engine-strict=true diff --git a/hub/admin-web/.prettierignore b/hub/admin-web/.prettierignore new file mode 100644 index 0000000..491d6b0 --- /dev/null +++ b/hub/admin-web/.prettierignore @@ -0,0 +1,4 @@ +build +.svelte-kit +node_modules +package-lock.json diff --git a/hub/admin-web/.prettierrc.json b/hub/admin-web/.prettierrc.json new file mode 100644 index 0000000..5b31130 --- /dev/null +++ b/hub/admin-web/.prettierrc.json @@ -0,0 +1,9 @@ +{ + "useTabs": true, + "singleQuote": true, + "semi": true, + "trailingComma": "all", + "printWidth": 120, + "plugins": ["prettier-plugin-svelte"], + "overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }] +} diff --git a/hub/admin-web/.vscode/extensions.json b/hub/admin-web/.vscode/extensions.json new file mode 100644 index 0000000..28d1e67 --- /dev/null +++ b/hub/admin-web/.vscode/extensions.json @@ -0,0 +1,3 @@ +{ + "recommendations": ["svelte.svelte-vscode"] +} diff --git a/hub/admin-web/README.md b/hub/admin-web/README.md new file mode 100644 index 0000000..7957afe --- /dev/null +++ b/hub/admin-web/README.md @@ -0,0 +1,42 @@ +# sv + +Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli). + +## Creating a project + +If you're seeing this, you've probably already done this step. Congrats! + +```sh +# create a new project +npx sv create my-app +``` + +To recreate this project with the same configuration: + +```sh +# recreate this project +npx sv@0.16.2 create --template minimal --types ts --install npm D:/Projects/curriculum-project-hub/hub/admin-web +``` + +## Developing + +Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server: + +```sh +npm run dev + +# or start the server and open the app in a new browser tab +npm run dev -- --open +``` + +## Building + +To create a production version of your app: + +```sh +npm run build +``` + +You can preview the production build with `npm run preview`. + +> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment. diff --git a/hub/admin-web/package-lock.json b/hub/admin-web/package-lock.json new file mode 100644 index 0000000..16b46cd --- /dev/null +++ b/hub/admin-web/package-lock.json @@ -0,0 +1,2641 @@ +{ + "name": "admin-web", + "version": "0.0.1", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "admin-web", + "version": "0.0.1", + "devDependencies": { + "@skeletonlabs/skeleton": "^4.15.2", + "@skeletonlabs/skeleton-svelte": "^4.15.2", + "@sveltejs/adapter-auto": "^7.0.1", + "@sveltejs/adapter-static": "^3.0.10", + "@sveltejs/kit": "^2.63.0", + "@sveltejs/vite-plugin-svelte": "^7.1.2", + "@tailwindcss/vite": "^4.3.2", + "bits-ui": "^2.18.1", + "prettier": "^3.9.5", + "prettier-plugin-svelte": "^4.1.1", + "svelte": "^5.56.1", + "svelte-check": "^4.6.0", + "tailwindcss": "^4.3.2", + "typescript": "^6.0.3", + "vite": "^8.0.16" + } + }, + "node_modules/@emnapi/core": { + "version": "1.11.2", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.2.tgz", + "integrity": "sha512-TC8MkTuZUtcTSiFeuC0ksCh9QIJ5+F21MvZ4Wn4ORfYaFJ/0dsiudv5tVkejgwZlwQ39jL9WWDe2lz8x0WglOA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.2", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.2.tgz", + "integrity": "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "resolved": "https://registry.npmmirror.com/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@floating-ui/core": { + "version": "1.7.5", + "resolved": "https://registry.npmmirror.com/@floating-ui/core/-/core-1.7.5.tgz", + "integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.7.6", + "resolved": "https://registry.npmmirror.com/@floating-ui/dom/-/dom-1.7.6.tgz", + "integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.7.5", + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.11", + "resolved": "https://registry.npmmirror.com/@floating-ui/utils/-/utils-0.2.11.tgz", + "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@internationalized/date": { + "version": "3.12.2", + "resolved": "https://registry.npmmirror.com/@internationalized/date/-/date-3.12.2.tgz", + "integrity": "sha512-FY1Y+H64NDs+HAF6omlnWxm3mEpfgaCSWtL5l551ZZfImA+kGjPFgrnJrGjH6lfmLL0g8Z/mBu1R3kufeCp6Jw==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@swc/helpers": "^0.5.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmmirror.com/@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.npmmirror.com/@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.npmmirror.com/@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.5.5", + "resolved": "https://registry.npmmirror.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmmirror.com/@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/wasm-runtime": { + "version": "1.1.6", + "resolved": "https://registry.npmmirror.com/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", + "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.3" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.139.0", + "resolved": "https://registry.npmmirror.com/@oxc-project/types/-/types-0.139.0.tgz", + "integrity": "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@polka/url": { + "version": "1.0.0-next.29", + "resolved": "https://registry.npmmirror.com/@polka/url/-/url-1.0.0-next.29.tgz", + "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmmirror.com/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz", + "integrity": "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmmirror.com/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.5.tgz", + "integrity": "sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.1.5", + "resolved": "https://registry.npmmirror.com/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.5.tgz", + "integrity": "sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.1.5", + "resolved": "https://registry.npmmirror.com/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.5.tgz", + "integrity": "sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.1.5", + "resolved": "https://registry.npmmirror.com/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.5.tgz", + "integrity": "sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmmirror.com/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.5.tgz", + "integrity": "sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.1.5", + "resolved": "https://registry.npmmirror.com/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.5.tgz", + "integrity": "sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmmirror.com/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.5.tgz", + "integrity": "sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmmirror.com/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.5.tgz", + "integrity": "sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmmirror.com/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.5.tgz", + "integrity": "sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.1.5", + "resolved": "https://registry.npmmirror.com/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.5.tgz", + "integrity": "sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmmirror.com/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.5.tgz", + "integrity": "sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.1.5", + "resolved": "https://registry.npmmirror.com/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.5.tgz", + "integrity": "sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.11.1", + "@emnapi/runtime": "1.11.1", + "@napi-rs/wasm-runtime": "^1.1.6" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.11.1", + "resolved": "https://registry.npmmirror.com/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.11.1", + "resolved": "https://registry.npmmirror.com/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.1.5", + "resolved": "https://registry.npmmirror.com/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.5.tgz", + "integrity": "sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.1.5", + "resolved": "https://registry.npmmirror.com/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.5.tgz", + "integrity": "sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@skeletonlabs/skeleton": { + "version": "4.15.2", + "resolved": "https://registry.npmmirror.com/@skeletonlabs/skeleton/-/skeleton-4.15.2.tgz", + "integrity": "sha512-5O23Py76nw56aoieV2b2T7MJ6xS0DwDjUULpwLnCXxXOnmzADEoWoQxb/ABbqg04IMOygtRzK3HO22I1+kFsog==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "tailwindcss": "^4.0.0" + } + }, + "node_modules/@skeletonlabs/skeleton-common": { + "version": "4.15.2", + "resolved": "https://registry.npmmirror.com/@skeletonlabs/skeleton-common/-/skeleton-common-4.15.2.tgz", + "integrity": "sha512-y7KZn++Av8UHdoeaaguQ7zIS1HlK7Y5jyPDnHy65wJ60iS97fMtQV0kGhqVoYCWhor+xrjbAFjWtaPOPPDEMYA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@skeletonlabs/skeleton-svelte": { + "version": "4.15.2", + "resolved": "https://registry.npmmirror.com/@skeletonlabs/skeleton-svelte/-/skeleton-svelte-4.15.2.tgz", + "integrity": "sha512-vZkRhR701EOHVXVKh/NFRSY8D9LPBvmdNFdtfgqO7TEg77sypJUvERl2dxErTLY1QjVklVpjsHRCTaSLn+1Ntg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@internationalized/date": "3.12.0", + "@skeletonlabs/skeleton-common": "4.15.2", + "@zag-js/accordion": "1.39.1", + "@zag-js/avatar": "1.39.1", + "@zag-js/carousel": "1.39.1", + "@zag-js/collapsible": "1.39.1", + "@zag-js/collection": "1.39.1", + "@zag-js/combobox": "1.39.1", + "@zag-js/date-picker": "1.39.1", + "@zag-js/dialog": "1.39.1", + "@zag-js/file-upload": "1.39.1", + "@zag-js/floating-panel": "1.39.1", + "@zag-js/listbox": "1.39.1", + "@zag-js/menu": "1.39.1", + "@zag-js/pagination": "1.39.1", + "@zag-js/popover": "1.39.1", + "@zag-js/progress": "1.39.1", + "@zag-js/radio-group": "1.39.1", + "@zag-js/rating-group": "1.39.1", + "@zag-js/slider": "1.39.1", + "@zag-js/steps": "1.39.1", + "@zag-js/svelte": "1.39.1", + "@zag-js/switch": "1.39.1", + "@zag-js/tabs": "1.39.1", + "@zag-js/tags-input": "1.39.1", + "@zag-js/toast": "1.39.1", + "@zag-js/toggle-group": "1.39.1", + "@zag-js/tooltip": "1.39.1", + "@zag-js/tree-view": "1.39.1" + }, + "peerDependencies": { + "svelte": "^5.29.0" + } + }, + "node_modules/@skeletonlabs/skeleton-svelte/node_modules/@internationalized/date": { + "version": "3.12.0", + "resolved": "https://registry.npmmirror.com/@internationalized/date/-/date-3.12.0.tgz", + "integrity": "sha512-/PyIMzK29jtXaGU23qTvNZxvBXRtKbNnGDFD+PY6CZw/Y8Ex8pFUzkuCJCG9aOqmShjqhS9mPqP6Dk5onQY8rQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@swc/helpers": "^0.5.0" + } + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sveltejs/acorn-typescript": { + "version": "1.0.11", + "resolved": "https://registry.npmmirror.com/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.11.tgz", + "integrity": "sha512-LFuZUkjJ9iF7JZye/aG5XM0SFcQ5VyL0oVX4WJ9dc0Va3R3s0OauX1BESVCb+YN/ol8TAfqGDDAQsTG627Y5kw==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^8.9.0" + } + }, + "node_modules/@sveltejs/adapter-auto": { + "version": "7.0.1", + "resolved": "https://registry.npmmirror.com/@sveltejs/adapter-auto/-/adapter-auto-7.0.1.tgz", + "integrity": "sha512-dvuPm1E7M9NI/+canIQ6KKQDU2AkEefEZ2Dp7cY6uKoPq9Z/PhOXABe526UdW2mN986gjVkuSLkOYIBnS/M2LQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@sveltejs/kit": "^2.0.0" + } + }, + "node_modules/@sveltejs/adapter-static": { + "version": "3.0.10", + "resolved": "https://registry.npmmirror.com/@sveltejs/adapter-static/-/adapter-static-3.0.10.tgz", + "integrity": "sha512-7D9lYFWJmB7zxZyTE/qxjksvMqzMuYrrsyh1f4AlZqeZeACPRySjbC3aFiY55wb1tWUaKOQG9PVbm74JcN2Iew==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@sveltejs/kit": "^2.0.0" + } + }, + "node_modules/@sveltejs/kit": { + "version": "2.69.2", + "resolved": "https://registry.npmmirror.com/@sveltejs/kit/-/kit-2.69.2.tgz", + "integrity": "sha512-CMdPDbYjRwRu4KXTxBVMuOpFPCt1i/v0ANennotec+K9Cmb2e3w2yYzJiC6Vh/WSvm9Khi5sJMZa0rJPqfHlDw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@sveltejs/acorn-typescript": "^1.0.9", + "@types/cookie": "^0.6.0", + "acorn": "^8.16.0", + "cookie": "^0.6.0", + "devalue": "^5.8.1", + "esm-env": "^1.2.2", + "kleur": "^4.1.5", + "magic-string": "^0.30.5", + "mrmime": "^2.0.0", + "set-cookie-parser": "^3.0.0", + "sirv": "^3.0.0" + }, + "bin": { + "svelte-kit": "svelte-kit.js" + }, + "engines": { + "node": ">=18.13" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.0.0", + "@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0 || ^7.0.0", + "svelte": "^4.0.0 || ^5.0.0-next.0", + "typescript": "^5.3.3 || ^6.0.0", + "vite": "^5.0.3 || ^6.0.0 || ^7.0.0-beta.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/@sveltejs/load-config": { + "version": "0.2.0", + "resolved": "https://registry.npmmirror.com/@sveltejs/load-config/-/load-config-0.2.0.tgz", + "integrity": "sha512-1LgZ/qUqSoq+QorD83lk2hka79Px0wXNW2q5V1nZlxGhQgw1jrsIbVz5YiCeucVLo4XvFLjXukUaQjIiqowkcg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 18.0.0" + } + }, + "node_modules/@sveltejs/vite-plugin-svelte": { + "version": "7.2.0", + "resolved": "https://registry.npmmirror.com/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-7.2.0.tgz", + "integrity": "sha512-1SpkuMSRLfugrVX+IrKfE1RUegzo8AQzKQ6qQPfVzbcWi5IhuTPaKb5ZrLpucleFznkc4/RTeSPoRnGWFxX+EQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "deepmerge": "^4.3.1", + "magic-string": "^0.30.21", + "obug": "^2.1.0", + "vitefu": "^1.1.2" + }, + "engines": { + "node": "^20.19 || ^22.12 || >=24" + }, + "peerDependencies": { + "svelte": "^5.46.4", + "vite": "^8.0.0-beta.7 || ^8.0.0" + } + }, + "node_modules/@swc/helpers": { + "version": "0.5.23", + "resolved": "https://registry.npmmirror.com/@swc/helpers/-/helpers-0.5.23.tgz", + "integrity": "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.8.0" + } + }, + "node_modules/@tailwindcss/node": { + "version": "4.3.2", + "resolved": "https://registry.npmmirror.com/@tailwindcss/node/-/node-4.3.2.tgz", + "integrity": "sha512-yWP/sqEcBLaD8JuA6zNwxoYKr75qxTioYwlRwekj5Jr/I5GXnoJfjetH/psLUIv74cYTH2lBUEzBkinthoYcBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "5.21.6", + "jiti": "^2.7.0", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.3.2" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.3.2", + "resolved": "https://registry.npmmirror.com/@tailwindcss/oxide/-/oxide-4.3.2.tgz", + "integrity": "sha512-z8ZgnzX8gdNoWLBLqBPoh/sjnxkwvf9ZuWjnO0l0yIzbLa5/9S+eC5QxGZKRobVHIC3/1BoMWjHblqWjcgFgag==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.3.2", + "@tailwindcss/oxide-darwin-arm64": "4.3.2", + "@tailwindcss/oxide-darwin-x64": "4.3.2", + "@tailwindcss/oxide-freebsd-x64": "4.3.2", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.2", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.2", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.2", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.2", + "@tailwindcss/oxide-linux-x64-musl": "4.3.2", + "@tailwindcss/oxide-wasm32-wasi": "4.3.2", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.2", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.2" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.3.2", + "resolved": "https://registry.npmmirror.com/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.2.tgz", + "integrity": "sha512-WHxqIuHpvZ5VtdX6GTl1Ik/Vp2YuN42Et+0CdeaVd/frQ9jAvGmvR8vLT+jk3e8/Q3x8kECB9+R17pgpp2BulA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.3.2", + "resolved": "https://registry.npmmirror.com/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.2.tgz", + "integrity": "sha512-GZypeUY/IDJW3877KeM+O67vbXr3MBnbtEL4aYhNErv/JWZhye2vGSWWG9tB6iiqR2MqRNkY8IOUy4NdSZV26w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.3.2", + "resolved": "https://registry.npmmirror.com/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.2.tgz", + "integrity": "sha512-UIIzmefR6KO1sDU7MzRqAxC8iBpft/VhkGjTjnhoS6k7Z3rQ9wEgA1ODSiyH/tcSYssulNm4Ci3hOeK1jH7ccQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.3.2", + "resolved": "https://registry.npmmirror.com/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.2.tgz", + "integrity": "sha512-GN+uAmcI6DNspnCDwtOAZrTz6oukJnp337qZvxqCGLd3BHBzJpO0ZbTLRvJNdztOeAmTzewewGIMPb0tk2R4WA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.3.2", + "resolved": "https://registry.npmmirror.com/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.2.tgz", + "integrity": "sha512-4ABn7qSbdHRwTiDiuWNegCyb5+2FJ4vKIKc3DmKrvAFw7MU1Lm11dIkTPwUaFdTzc7IsOpDbqBrlh0x6y36U/w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.3.2", + "resolved": "https://registry.npmmirror.com/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.2.tgz", + "integrity": "sha512-wDgEIGwoM8w8pufh9LVt1PahDgNdKXrLC2qfAnV3vAmococ9RWbxeAw4pxPttd/TsJfwjyLf90Dg1y9y8I6Emw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.3.2", + "resolved": "https://registry.npmmirror.com/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.2.tgz", + "integrity": "sha512-J5Nuk0uZQIiMTJj3LEx4sAA9tMFUoXQZFv1J6An+QGYe53HKRJuFDi0rpq/tuouCZeAbOBY3kQ6g8qeD4TUjtA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.3.2", + "resolved": "https://registry.npmmirror.com/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.2.tgz", + "integrity": "sha512-kqCZpSKOBEJO4mz7OqWoofBZeXTAwaVGPj0ErAj7CojmhKpWVWVOnrt9dE8odoIraZq4oj3ausM37kXi+Tow8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.3.2", + "resolved": "https://registry.npmmirror.com/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.2.tgz", + "integrity": "sha512-cixpqbh2toJDmkuCRI68nXA8ZxNmdK9Y+9v5h3MC3ZQKy/0BO8AWzlkWyRM7JAFSGBlfig4YVTPsK6MVgqz1uw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.3.2", + "resolved": "https://registry.npmmirror.com/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.2.tgz", + "integrity": "sha512-4ec2Z/LOmRsAgU23CS4xeJfcJlmRg94A/XrbGRCF1gyU/zdDfRLYDVsS+ynSZCmGNxQ1jQriQOKMQeQxBA3Isw==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.11.1", + "@emnapi/runtime": "^1.11.1", + "@emnapi/wasi-threads": "^1.2.2", + "@napi-rs/wasm-runtime": "^1.1.4", + "@tybys/wasm-util": "^0.10.2", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.3.2", + "resolved": "https://registry.npmmirror.com/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.2.tgz", + "integrity": "sha512-Zyr/M0+XcYZu3bZrUytc7TXvrk0ftWfl8gN2MwekNDzhqhKRUucMPSeOzM0o0wH5AWOU49BsKRrfKxI2atCPMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.3.2", + "resolved": "https://registry.npmmirror.com/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.2.tgz", + "integrity": "sha512-QI9BO7KlNZsp2GuO0jwAAj5jCDABOKXRkCk2XuKTSaNEFSdfzqswYVTtCHBNKHLsqyjFyFkqlDiwkNbTYSssMQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.3.2", + "resolved": "https://registry.npmmirror.com/@tailwindcss/vite/-/vite-4.3.2.tgz", + "integrity": "sha512-eHpMeX4JXfVNJDEcsouTeCBubJBTcTLigeaw/NTUW6PB5ATKKXdyonnXgTBX2VuRbjz1hjfz6C5XAhr52ImQXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.3.2", + "@tailwindcss/oxide": "4.3.2", + "tailwindcss": "4.3.2" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7 || ^8" + } + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.3", + "resolved": "https://registry.npmmirror.com/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/cookie": { + "version": "0.6.0", + "resolved": "https://registry.npmmirror.com/@types/cookie/-/cookie-0.6.0.tgz", + "integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmmirror.com/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmmirror.com/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@zag-js/accordion": { + "version": "1.39.1", + "resolved": "https://registry.npmmirror.com/@zag-js/accordion/-/accordion-1.39.1.tgz", + "integrity": "sha512-GA3m7gRTm3weSe1eMlHIsTNztcjZ6joIaRgxxKil7q/UX0xIVVGDy0aCr6oo7FAuoMiOOBVurYXILpFZ30nOXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@zag-js/anatomy": "1.39.1", + "@zag-js/core": "1.39.1", + "@zag-js/dom-query": "1.39.1", + "@zag-js/types": "1.39.1", + "@zag-js/utils": "1.39.1" + } + }, + "node_modules/@zag-js/anatomy": { + "version": "1.39.1", + "resolved": "https://registry.npmmirror.com/@zag-js/anatomy/-/anatomy-1.39.1.tgz", + "integrity": "sha512-p2iFAs2pVQgv5iCDAftA7g9Z/fUYXW94dRIGk415TSbkp/YDENydm/JtRoNctp302UIx4Eeuc5QBR+7h5kuISA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@zag-js/aria-hidden": { + "version": "1.39.1", + "resolved": "https://registry.npmmirror.com/@zag-js/aria-hidden/-/aria-hidden-1.39.1.tgz", + "integrity": "sha512-wiwcz3N086qBMEU3VKfHhcvGm6Jm1PIcDXys/jEqiKPtHoYZhDip0n0cPOoasss/A1oS39QFVdk3WpLXGu3Izw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@zag-js/dom-query": "1.39.1" + } + }, + "node_modules/@zag-js/auto-resize": { + "version": "1.39.1", + "resolved": "https://registry.npmmirror.com/@zag-js/auto-resize/-/auto-resize-1.39.1.tgz", + "integrity": "sha512-ditIo9mW7fapq+4yx3/8hMpMZlWaoOy66EOzUz8dSVqnxnTWAjnTICu/9zFh8pkWerlzGTtDOJPP1oZ8S/rgVg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@zag-js/dom-query": "1.39.1" + } + }, + "node_modules/@zag-js/avatar": { + "version": "1.39.1", + "resolved": "https://registry.npmmirror.com/@zag-js/avatar/-/avatar-1.39.1.tgz", + "integrity": "sha512-LWrgJ0bebnXPSL+uehA9z6BlCD/MZEOQBJqH/F2QQFSAAZXUUDKtzVDmc+UtwjDsHXqqTghi+v2atQJHNMcJ2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@zag-js/anatomy": "1.39.1", + "@zag-js/core": "1.39.1", + "@zag-js/dom-query": "1.39.1", + "@zag-js/types": "1.39.1", + "@zag-js/utils": "1.39.1" + } + }, + "node_modules/@zag-js/carousel": { + "version": "1.39.1", + "resolved": "https://registry.npmmirror.com/@zag-js/carousel/-/carousel-1.39.1.tgz", + "integrity": "sha512-5z5z3IldUgZ/R+KZLNQDoJFNTXzYd28YOmgfWH61Vvyv+RarX8kwZW8ajW/fNiqcWXyhW3/VMU0lArrfjbQVtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@zag-js/anatomy": "1.39.1", + "@zag-js/core": "1.39.1", + "@zag-js/dom-query": "1.39.1", + "@zag-js/scroll-snap": "1.39.1", + "@zag-js/types": "1.39.1", + "@zag-js/utils": "1.39.1" + } + }, + "node_modules/@zag-js/collapsible": { + "version": "1.39.1", + "resolved": "https://registry.npmmirror.com/@zag-js/collapsible/-/collapsible-1.39.1.tgz", + "integrity": "sha512-Zgccg/t7M8i0JVwZPPgW7XB7kGhTO475hsmwkF/8CYLqBBckVDHUARp2we24hENCm/98eez6R0eDEmE+tldFWA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@zag-js/anatomy": "1.39.1", + "@zag-js/core": "1.39.1", + "@zag-js/dom-query": "1.39.1", + "@zag-js/types": "1.39.1", + "@zag-js/utils": "1.39.1" + } + }, + "node_modules/@zag-js/collection": { + "version": "1.39.1", + "resolved": "https://registry.npmmirror.com/@zag-js/collection/-/collection-1.39.1.tgz", + "integrity": "sha512-fyOyKmP7MRo0/U8mBmB7KgHRXHhXP27LCcasy3x+qTAQtuEfYG1EPhKuj07oBWlX/2qfcKYn2R3YopHcqFcCiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@zag-js/utils": "1.39.1" + } + }, + "node_modules/@zag-js/combobox": { + "version": "1.39.1", + "resolved": "https://registry.npmmirror.com/@zag-js/combobox/-/combobox-1.39.1.tgz", + "integrity": "sha512-fmStpG+k4xrxCzqUX0ssnOMeoSietWm5ir3qmEZcagzNqNycAXMvOELAIeyXi87Kut6aDGhxLOV7o395HVXl/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@zag-js/anatomy": "1.39.1", + "@zag-js/collection": "1.39.1", + "@zag-js/core": "1.39.1", + "@zag-js/dismissable": "1.39.1", + "@zag-js/dom-query": "1.39.1", + "@zag-js/focus-visible": "1.39.1", + "@zag-js/live-region": "1.39.1", + "@zag-js/popper": "1.39.1", + "@zag-js/types": "1.39.1", + "@zag-js/utils": "1.39.1" + } + }, + "node_modules/@zag-js/core": { + "version": "1.39.1", + "resolved": "https://registry.npmmirror.com/@zag-js/core/-/core-1.39.1.tgz", + "integrity": "sha512-Yp0r49QLYXe2j7fgyAiilH4umXFydCnr5hcRDwJU+sxvUAlq00JQIJIEK2pT6k8cJiNNsFEV5WkOX7jsqpAX2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@zag-js/dom-query": "1.39.1", + "@zag-js/utils": "1.39.1" + } + }, + "node_modules/@zag-js/date-picker": { + "version": "1.39.1", + "resolved": "https://registry.npmmirror.com/@zag-js/date-picker/-/date-picker-1.39.1.tgz", + "integrity": "sha512-t9q1H0aZQJkbzKTR2Bn5vMwaoFoirxekiSxw8ju0F0vr4Kg4BJ9yueOQm5I2wALKnJbZu4Ua5MgzlrDF3CQt3A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@zag-js/anatomy": "1.39.1", + "@zag-js/core": "1.39.1", + "@zag-js/date-utils": "1.39.1", + "@zag-js/dismissable": "1.39.1", + "@zag-js/dom-query": "1.39.1", + "@zag-js/live-region": "1.39.1", + "@zag-js/popper": "1.39.1", + "@zag-js/types": "1.39.1", + "@zag-js/utils": "1.39.1" + }, + "peerDependencies": { + "@internationalized/date": ">=3.0.0" + } + }, + "node_modules/@zag-js/date-utils": { + "version": "1.39.1", + "resolved": "https://registry.npmmirror.com/@zag-js/date-utils/-/date-utils-1.39.1.tgz", + "integrity": "sha512-i4SvBhru2Yz/zsHT0XvyFhf4a+pAKYkWXeVfU0RvF2S6mPTfgaMFF9ZNPq5Sy8K31EtAa6AVXcybYaYnibn1FA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@internationalized/date": ">=3.0.0" + } + }, + "node_modules/@zag-js/dialog": { + "version": "1.39.1", + "resolved": "https://registry.npmmirror.com/@zag-js/dialog/-/dialog-1.39.1.tgz", + "integrity": "sha512-q+HTmfuRDRZthln9mb7i52wdltQOZlw3+nw3a2uygEe9xuEtHBwUz31XJzkn2UWQqhAt7cC39OwykhNLKrfkqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@zag-js/anatomy": "1.39.1", + "@zag-js/aria-hidden": "1.39.1", + "@zag-js/core": "1.39.1", + "@zag-js/dismissable": "1.39.1", + "@zag-js/dom-query": "1.39.1", + "@zag-js/focus-trap": "1.39.1", + "@zag-js/remove-scroll": "1.39.1", + "@zag-js/types": "1.39.1", + "@zag-js/utils": "1.39.1" + } + }, + "node_modules/@zag-js/dismissable": { + "version": "1.39.1", + "resolved": "https://registry.npmmirror.com/@zag-js/dismissable/-/dismissable-1.39.1.tgz", + "integrity": "sha512-7/soy93Ersd5qedhSL/+CDcZ9gNTQV0ooDcqKtM8b4IxwD4rgWwGsewJY+tbKmOqaZobwa0YcWV2+YGgI23ESw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@zag-js/dom-query": "1.39.1", + "@zag-js/interact-outside": "1.39.1", + "@zag-js/utils": "1.39.1" + } + }, + "node_modules/@zag-js/dom-query": { + "version": "1.39.1", + "resolved": "https://registry.npmmirror.com/@zag-js/dom-query/-/dom-query-1.39.1.tgz", + "integrity": "sha512-k01aXeUWLyJfB61CODaXj4PLhYmVpnVMFrC+3nk/XCn1MW7my8L/8KVg0m4W8n+X9MhpaLWsZDmK/dwED/3qSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@zag-js/types": "1.39.1" + } + }, + "node_modules/@zag-js/file-upload": { + "version": "1.39.1", + "resolved": "https://registry.npmmirror.com/@zag-js/file-upload/-/file-upload-1.39.1.tgz", + "integrity": "sha512-cErPOnPwPyneUXpelsfm75DKn0/4SI8aqQnlbrqo522PEqAQyDfDdBsqebGgKWG3F0A++kKFp9LO9A5zCrw5gA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@zag-js/anatomy": "1.39.1", + "@zag-js/core": "1.39.1", + "@zag-js/dom-query": "1.39.1", + "@zag-js/file-utils": "1.39.1", + "@zag-js/i18n-utils": "1.39.1", + "@zag-js/types": "1.39.1", + "@zag-js/utils": "1.39.1" + } + }, + "node_modules/@zag-js/file-utils": { + "version": "1.39.1", + "resolved": "https://registry.npmmirror.com/@zag-js/file-utils/-/file-utils-1.39.1.tgz", + "integrity": "sha512-ll/W5o74SMmoAS+l7PkmmGjPj4PLCSG/cwQh1Y/+LpaSev0YiR3Nk2OzRIIPtm3NivYVxKGawaCOf1RvT/82LQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@zag-js/i18n-utils": "1.39.1" + } + }, + "node_modules/@zag-js/floating-panel": { + "version": "1.39.1", + "resolved": "https://registry.npmmirror.com/@zag-js/floating-panel/-/floating-panel-1.39.1.tgz", + "integrity": "sha512-IfPbf3pwJGqBWHec/rPzpdPjfMCLed59LlEophvRy49FEdksv8eN6nr9DXl2wWZEoQhH99scXfLMbtEZsPsFWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@zag-js/anatomy": "1.39.1", + "@zag-js/core": "1.39.1", + "@zag-js/dom-query": "1.39.1", + "@zag-js/popper": "1.39.1", + "@zag-js/rect-utils": "1.39.1", + "@zag-js/store": "1.39.1", + "@zag-js/types": "1.39.1", + "@zag-js/utils": "1.39.1" + } + }, + "node_modules/@zag-js/focus-trap": { + "version": "1.39.1", + "resolved": "https://registry.npmmirror.com/@zag-js/focus-trap/-/focus-trap-1.39.1.tgz", + "integrity": "sha512-2ZzVefHMotvtxUo/gP4R45Szw/EPaPkTKEHaug6/il62SPDbkFODF+5r1zXyLbLuwCHq0apvQasg/ONLihwlXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@zag-js/dom-query": "1.39.1" + } + }, + "node_modules/@zag-js/focus-visible": { + "version": "1.39.1", + "resolved": "https://registry.npmmirror.com/@zag-js/focus-visible/-/focus-visible-1.39.1.tgz", + "integrity": "sha512-iEuTOYHE8HRn/7ULC9c9BTTWo0C0MJRCbYVxbh/d7v8qAuq4CS76pdfceNo3KeWbb968T+yiG6q0AjiHsr8IOw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@zag-js/dom-query": "1.39.1" + } + }, + "node_modules/@zag-js/i18n-utils": { + "version": "1.39.1", + "resolved": "https://registry.npmmirror.com/@zag-js/i18n-utils/-/i18n-utils-1.39.1.tgz", + "integrity": "sha512-TKRLQQlHgJ4cxsHo3tZPtbFjGu9m1UPtfezRGFKq7A8czhdqRhaCpaWF849cd6dI7x6rWvvTan858gOFpyANnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@zag-js/dom-query": "1.39.1" + } + }, + "node_modules/@zag-js/interact-outside": { + "version": "1.39.1", + "resolved": "https://registry.npmmirror.com/@zag-js/interact-outside/-/interact-outside-1.39.1.tgz", + "integrity": "sha512-LnSbA+txMsFmzNPn84QKH01x2yJv4At/eKHn6rT2PyxXkJQIh8PvCTS3zVz4Syw11cmhcXt2eRwhzx8yImV92w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@zag-js/dom-query": "1.39.1", + "@zag-js/utils": "1.39.1" + } + }, + "node_modules/@zag-js/listbox": { + "version": "1.39.1", + "resolved": "https://registry.npmmirror.com/@zag-js/listbox/-/listbox-1.39.1.tgz", + "integrity": "sha512-Mz0UpdXobdTQTyjM+Avgi7pDVB2dKyaUHqw3TloeleQL3VwTqClclkwHXtLYYE+oXa0zOet37wI9mzfaYx9iZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@zag-js/anatomy": "1.39.1", + "@zag-js/collection": "1.39.1", + "@zag-js/core": "1.39.1", + "@zag-js/dom-query": "1.39.1", + "@zag-js/focus-visible": "1.39.1", + "@zag-js/types": "1.39.1", + "@zag-js/utils": "1.39.1" + } + }, + "node_modules/@zag-js/live-region": { + "version": "1.39.1", + "resolved": "https://registry.npmmirror.com/@zag-js/live-region/-/live-region-1.39.1.tgz", + "integrity": "sha512-E7YNd0QGzJ2n1ZhnI2smv+klwifsNRf9QaDCx7quVJCVYywpupsBK4R25KN75S1z8XaK+jAy6HYKj8DIhYjYeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@zag-js/menu": { + "version": "1.39.1", + "resolved": "https://registry.npmmirror.com/@zag-js/menu/-/menu-1.39.1.tgz", + "integrity": "sha512-bRDGLGkiGhzNtORBXkbBQV/xp2zEkwpYIepfWCaUoFwKUmx7GGnShTBFxJyq0u2D4IkS9GOwcqm20EhMv6V+TA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@zag-js/anatomy": "1.39.1", + "@zag-js/core": "1.39.1", + "@zag-js/dismissable": "1.39.1", + "@zag-js/dom-query": "1.39.1", + "@zag-js/focus-visible": "1.39.1", + "@zag-js/popper": "1.39.1", + "@zag-js/rect-utils": "1.39.1", + "@zag-js/types": "1.39.1", + "@zag-js/utils": "1.39.1" + } + }, + "node_modules/@zag-js/pagination": { + "version": "1.39.1", + "resolved": "https://registry.npmmirror.com/@zag-js/pagination/-/pagination-1.39.1.tgz", + "integrity": "sha512-3Q1B9/g3ajhvXjuGffJ7otyXcXK5+uhdbE5A9CZa4bsW3pf25L9Cp+ZAjdXQMDc8T4jhZJAKFmDJfQgtr1oEIw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@zag-js/anatomy": "1.39.1", + "@zag-js/core": "1.39.1", + "@zag-js/dom-query": "1.39.1", + "@zag-js/types": "1.39.1", + "@zag-js/utils": "1.39.1" + } + }, + "node_modules/@zag-js/popover": { + "version": "1.39.1", + "resolved": "https://registry.npmmirror.com/@zag-js/popover/-/popover-1.39.1.tgz", + "integrity": "sha512-aO3ExO/O7Sa3ovdozFI6SujhNOpYdCca4bImnAiovDL8DY8zN3UNQebu35IQvw9/aRsx9VKSJL1AqzJJUImFRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@zag-js/anatomy": "1.39.1", + "@zag-js/aria-hidden": "1.39.1", + "@zag-js/core": "1.39.1", + "@zag-js/dismissable": "1.39.1", + "@zag-js/dom-query": "1.39.1", + "@zag-js/focus-trap": "1.39.1", + "@zag-js/popper": "1.39.1", + "@zag-js/remove-scroll": "1.39.1", + "@zag-js/types": "1.39.1", + "@zag-js/utils": "1.39.1" + } + }, + "node_modules/@zag-js/popper": { + "version": "1.39.1", + "resolved": "https://registry.npmmirror.com/@zag-js/popper/-/popper-1.39.1.tgz", + "integrity": "sha512-h0UMY2dXJNfM3OvMQ9t9LzlmwvpCgjloz2IvU1txY3r32UIy7ve1H70zkKagLtLRxFTuWmhumYUPULPo/6a1DA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@floating-ui/dom": "^1.7.6", + "@zag-js/dom-query": "1.39.1", + "@zag-js/utils": "1.39.1" + } + }, + "node_modules/@zag-js/progress": { + "version": "1.39.1", + "resolved": "https://registry.npmmirror.com/@zag-js/progress/-/progress-1.39.1.tgz", + "integrity": "sha512-1IHyOw8DqPs3YH149Oj7W9a5oEfY5pc9GAVOPGbzYxVK/W8d/NIjVxa565I3J5cDJ0s6z3FrMSXMWUwr1ML4tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@zag-js/anatomy": "1.39.1", + "@zag-js/core": "1.39.1", + "@zag-js/dom-query": "1.39.1", + "@zag-js/types": "1.39.1", + "@zag-js/utils": "1.39.1" + } + }, + "node_modules/@zag-js/radio-group": { + "version": "1.39.1", + "resolved": "https://registry.npmmirror.com/@zag-js/radio-group/-/radio-group-1.39.1.tgz", + "integrity": "sha512-+sC9xcAyY/GbY+8HpKlbPgSyOxBLUSB18s6fe6K1wdmyom4PM0nmhLouuxisbFZYHOyfQwAOMo+ainRENB2hzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@zag-js/anatomy": "1.39.1", + "@zag-js/core": "1.39.1", + "@zag-js/dom-query": "1.39.1", + "@zag-js/focus-visible": "1.39.1", + "@zag-js/types": "1.39.1", + "@zag-js/utils": "1.39.1" + } + }, + "node_modules/@zag-js/rating-group": { + "version": "1.39.1", + "resolved": "https://registry.npmmirror.com/@zag-js/rating-group/-/rating-group-1.39.1.tgz", + "integrity": "sha512-IfdxWmM+3zpztx/HcE3bWob72sZNb1+BzK4tSySLVyjeqs8OzLDzrCbKqt10DmibnNOvpbjbq4eX4P5hV9YN7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@zag-js/anatomy": "1.39.1", + "@zag-js/core": "1.39.1", + "@zag-js/dom-query": "1.39.1", + "@zag-js/types": "1.39.1", + "@zag-js/utils": "1.39.1" + } + }, + "node_modules/@zag-js/rect-utils": { + "version": "1.39.1", + "resolved": "https://registry.npmmirror.com/@zag-js/rect-utils/-/rect-utils-1.39.1.tgz", + "integrity": "sha512-5gJ0PzeUme76xTWG+4XythWgmGgDKV4XAxEUaB3KKDtXgjDHwtu7PwKLIzFtlaaSf/U23PY+RNVBVCYg1GmZog==", + "dev": true, + "license": "MIT" + }, + "node_modules/@zag-js/remove-scroll": { + "version": "1.39.1", + "resolved": "https://registry.npmmirror.com/@zag-js/remove-scroll/-/remove-scroll-1.39.1.tgz", + "integrity": "sha512-uZfPR3Gl9sQFo+tJ7kbuwsBhw+RIZwWFnMDgrz5LIwSNGN6hsyC4HGOxe29clkWQ2X2AjqqmEMETwgX7Jg+wxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@zag-js/dom-query": "1.39.1" + } + }, + "node_modules/@zag-js/scroll-snap": { + "version": "1.39.1", + "resolved": "https://registry.npmmirror.com/@zag-js/scroll-snap/-/scroll-snap-1.39.1.tgz", + "integrity": "sha512-AzCc8MAAVqkiK5Y0cJZ24OIBZDQrUmEexACMuR6M5yZmlcEbS0EA/d6Wq+LSR1JMVTD4B+UwcMj1D3vJQ90ZTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@zag-js/dom-query": "1.39.1" + } + }, + "node_modules/@zag-js/slider": { + "version": "1.39.1", + "resolved": "https://registry.npmmirror.com/@zag-js/slider/-/slider-1.39.1.tgz", + "integrity": "sha512-OEA9R7Ly5cw+6ANofnMpuHH3rAo8gZEnxy7iEwePu11pq2RCnt8DSj2V+uqU+dTq15Uup1LSzRgJfTnAC4Z85A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@zag-js/anatomy": "1.39.1", + "@zag-js/core": "1.39.1", + "@zag-js/dom-query": "1.39.1", + "@zag-js/types": "1.39.1", + "@zag-js/utils": "1.39.1" + } + }, + "node_modules/@zag-js/steps": { + "version": "1.39.1", + "resolved": "https://registry.npmmirror.com/@zag-js/steps/-/steps-1.39.1.tgz", + "integrity": "sha512-DC6swMpwITTB0DyCSxlpWyPNSUN9ul9jz4N6aAyQ0L1IK/noF/YYTZRAcXNSRzN4iutO/2mFGGbwGq/oVf+gPA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@zag-js/anatomy": "1.39.1", + "@zag-js/core": "1.39.1", + "@zag-js/dom-query": "1.39.1", + "@zag-js/types": "1.39.1", + "@zag-js/utils": "1.39.1" + } + }, + "node_modules/@zag-js/store": { + "version": "1.39.1", + "resolved": "https://registry.npmmirror.com/@zag-js/store/-/store-1.39.1.tgz", + "integrity": "sha512-zFpwP4lhiBVD9987rwAfZNVa2/f/xx4mhbCE1EEw31zxLAozY2jONeJ3UzPP05VbzKlRHBcvkaXAJQQGegTwFA==", + "dev": true, + "license": "MIT", + "dependencies": { + "proxy-compare": "3.0.1" + } + }, + "node_modules/@zag-js/svelte": { + "version": "1.39.1", + "resolved": "https://registry.npmmirror.com/@zag-js/svelte/-/svelte-1.39.1.tgz", + "integrity": "sha512-ZOyZjyvjePZdrkNTy5fa92ijeeID9e+3LRGziKGIII3JSEvwfkG/Buf8W84N8VHFxi0G0GcKmgVCDgKHyGQYoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@zag-js/core": "1.39.1", + "@zag-js/types": "1.39.1", + "@zag-js/utils": "1.39.1" + }, + "peerDependencies": { + "svelte": ">=5" + } + }, + "node_modules/@zag-js/switch": { + "version": "1.39.1", + "resolved": "https://registry.npmmirror.com/@zag-js/switch/-/switch-1.39.1.tgz", + "integrity": "sha512-ikeQ42c0vyyPLeyW9U0dvcqTV1Ekpx5jZ050R905HGJ2GeWE0uBGuHbMpTG5U6Pwb0a+TMzqAr+jMsquVTCwzg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@zag-js/anatomy": "1.39.1", + "@zag-js/core": "1.39.1", + "@zag-js/dom-query": "1.39.1", + "@zag-js/focus-visible": "1.39.1", + "@zag-js/types": "1.39.1", + "@zag-js/utils": "1.39.1" + } + }, + "node_modules/@zag-js/tabs": { + "version": "1.39.1", + "resolved": "https://registry.npmmirror.com/@zag-js/tabs/-/tabs-1.39.1.tgz", + "integrity": "sha512-P2RThO1gX9SFsNqrAGPsXJxrjn5YqP6MFs9mdExU+tzzZyVjJQADkAmh98C0eEaCb6HKLpJZ/17hrnLDhm1Tig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@zag-js/anatomy": "1.39.1", + "@zag-js/core": "1.39.1", + "@zag-js/dom-query": "1.39.1", + "@zag-js/types": "1.39.1", + "@zag-js/utils": "1.39.1" + } + }, + "node_modules/@zag-js/tags-input": { + "version": "1.39.1", + "resolved": "https://registry.npmmirror.com/@zag-js/tags-input/-/tags-input-1.39.1.tgz", + "integrity": "sha512-tc0+bd9FiUJwa+wY2hSVVGHLIBC3C3rOZX/4zjchRMs1xgl92c1/tYbytXny7ABB8ZMHveG7MtgDppVF4VkwBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@zag-js/anatomy": "1.39.1", + "@zag-js/auto-resize": "1.39.1", + "@zag-js/core": "1.39.1", + "@zag-js/dom-query": "1.39.1", + "@zag-js/interact-outside": "1.39.1", + "@zag-js/live-region": "1.39.1", + "@zag-js/types": "1.39.1", + "@zag-js/utils": "1.39.1" + } + }, + "node_modules/@zag-js/toast": { + "version": "1.39.1", + "resolved": "https://registry.npmmirror.com/@zag-js/toast/-/toast-1.39.1.tgz", + "integrity": "sha512-K7ndEfBTKDds10iQKCQUmin74s6V4BEIypAIyQxs18gQB9TCn5+wff886JAzecIKPY97PDQHDKjYR71yzRC7/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@zag-js/anatomy": "1.39.1", + "@zag-js/core": "1.39.1", + "@zag-js/dismissable": "1.39.1", + "@zag-js/dom-query": "1.39.1", + "@zag-js/types": "1.39.1", + "@zag-js/utils": "1.39.1" + } + }, + "node_modules/@zag-js/toggle-group": { + "version": "1.39.1", + "resolved": "https://registry.npmmirror.com/@zag-js/toggle-group/-/toggle-group-1.39.1.tgz", + "integrity": "sha512-KS4Bo17foMKXVBhQjocRf4GQxMV4pMXclTo14IWjldaHs2HIrNJ0Ar0Ri+vo47BBKBNsXs4HuNvfbMdQj94wEA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@zag-js/anatomy": "1.39.1", + "@zag-js/core": "1.39.1", + "@zag-js/dom-query": "1.39.1", + "@zag-js/types": "1.39.1", + "@zag-js/utils": "1.39.1" + } + }, + "node_modules/@zag-js/tooltip": { + "version": "1.39.1", + "resolved": "https://registry.npmmirror.com/@zag-js/tooltip/-/tooltip-1.39.1.tgz", + "integrity": "sha512-IsxFj7l8kPciwIyYJWlmQ7mhXocbjXxLj3m9z099slYOF7lApA33/ndY32w9ptrI4/nUh2nldzw6eRfSpVnuOA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@zag-js/anatomy": "1.39.1", + "@zag-js/core": "1.39.1", + "@zag-js/dom-query": "1.39.1", + "@zag-js/focus-visible": "1.39.1", + "@zag-js/popper": "1.39.1", + "@zag-js/types": "1.39.1", + "@zag-js/utils": "1.39.1" + } + }, + "node_modules/@zag-js/tree-view": { + "version": "1.39.1", + "resolved": "https://registry.npmmirror.com/@zag-js/tree-view/-/tree-view-1.39.1.tgz", + "integrity": "sha512-sm6qUZjO0OaqBqO5s55KU+l5p1wXfUVScoen7BYVoFBuROH7qAZJi8YMclGvnnlyV506i8Hk0qqWnLg0F38jCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@zag-js/anatomy": "1.39.1", + "@zag-js/collection": "1.39.1", + "@zag-js/core": "1.39.1", + "@zag-js/dom-query": "1.39.1", + "@zag-js/types": "1.39.1", + "@zag-js/utils": "1.39.1" + } + }, + "node_modules/@zag-js/types": { + "version": "1.39.1", + "resolved": "https://registry.npmmirror.com/@zag-js/types/-/types-1.39.1.tgz", + "integrity": "sha512-w3vVpgxmdJvMDvv19DXTtFI6kJL6TXw//U0Z1BAc3rnDA9orcB9Ryw4uMNvIzFA607CgssyJcWDaQ/M3yAcbJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "3.2.3" + } + }, + "node_modules/@zag-js/utils": { + "version": "1.39.1", + "resolved": "https://registry.npmmirror.com/@zag-js/utils/-/utils-1.39.1.tgz", + "integrity": "sha512-9k741cH7L655Ua3tedTkuMblcXVXVgCLTB9svp9oTjA7oatpOpYF4z43kgAQVjyThNXMJ7AvtO4C80ajQLTScg==", + "dev": true, + "license": "MIT" + }, + "node_modules/acorn": { + "version": "8.17.0", + "resolved": "https://registry.npmmirror.com/acorn/-/acorn-8.17.0.tgz", + "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/aria-query": { + "version": "5.3.1", + "resolved": "https://registry.npmmirror.com/aria-query/-/aria-query-5.3.1.tgz", + "integrity": "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/axobject-query": { + "version": "4.1.0", + "resolved": "https://registry.npmmirror.com/axobject-query/-/axobject-query-4.1.0.tgz", + "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/bits-ui": { + "version": "2.18.1", + "resolved": "https://registry.npmmirror.com/bits-ui/-/bits-ui-2.18.1.tgz", + "integrity": "sha512-KkemzKFH4T3gt3H+P86JcnAWExjByv/6vlwjm/BoCwTPHu03yiCdxbghdJLvFReQTe0acCAiRcKfmixxD6XvlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.7.1", + "@floating-ui/dom": "^1.7.1", + "esm-env": "^1.1.2", + "runed": "^0.35.1", + "svelte-toolbelt": "^0.10.6", + "tabbable": "^6.2.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/huntabyte" + }, + "peerDependencies": { + "@internationalized/date": "^3.8.1", + "svelte": "^5.33.0" + } + }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmmirror.com/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmmirror.com/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/cookie": { + "version": "0.6.0", + "resolved": "https://registry.npmmirror.com/cookie/-/cookie-0.6.0.tgz", + "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmmirror.com/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmmirror.com/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmmirror.com/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmmirror.com/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/devalue": { + "version": "5.8.1", + "resolved": "https://registry.npmmirror.com/devalue/-/devalue-5.8.1.tgz", + "integrity": "sha512-4CXDYRBGqN+57wVJkuXBYmpAVUSg3L6JAQa/DFqm238G73E1wuyc/JhGQJzN7vUf/CMphYau2zXbfWzDR5aTEw==", + "dev": true, + "license": "MIT" + }, + "node_modules/enhanced-resolve": { + "version": "5.21.6", + "resolved": "https://registry.npmmirror.com/enhanced-resolve/-/enhanced-resolve-5.21.6.tgz", + "integrity": "sha512-aNnGCvbJ/RIyWo1IuhNdVjnNF+EjH9wpzpNHt+ci/m9He9LJvUN8wrCcXjp9cWsGNAuvSpVFTx/vraAFQ8qGjQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/esm-env": { + "version": "1.2.2", + "resolved": "https://registry.npmmirror.com/esm-env/-/esm-env-1.2.2.tgz", + "integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esrap": { + "version": "2.2.13", + "resolved": "https://registry.npmmirror.com/esrap/-/esrap-2.2.13.tgz", + "integrity": "sha512-m8jH5hZgJE2RRUK/jjkGPcJEDAV+dYnZYFkosQaPTcE+Yw4xynXHOo6FUdwaWBtdR3b1MMa7wEDTSHeR2VWsGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.4.15" + }, + "peerDependencies": { + "@typescript-eslint/types": "^8.2.0" + }, + "peerDependenciesMeta": { + "@typescript-eslint/types": { + "optional": true + } + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmmirror.com/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.npmmirror.com/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/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmmirror.com/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/inline-style-parser": { + "version": "0.2.7", + "resolved": "https://registry.npmmirror.com/inline-style-parser/-/inline-style-parser-0.2.7.tgz", + "integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-reference": { + "version": "3.0.3", + "resolved": "https://registry.npmmirror.com/is-reference/-/is-reference-3.0.3.tgz", + "integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.6" + } + }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmmirror.com/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/kleur": { + "version": "4.1.5", + "resolved": "https://registry.npmmirror.com/kleur/-/kleur-4.1.5.tgz", + "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmmirror.com/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmmirror.com/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmmirror.com/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmmirror.com/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmmirror.com/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmmirror.com/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmmirror.com/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmmirror.com/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmmirror.com/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmmirror.com/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmmirror.com/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmmirror.com/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/locate-character": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/locate-character/-/locate-character-3.0.0.tgz", + "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==", + "dev": true, + "license": "MIT" + }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmmirror.com/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.npmmirror.com/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/mri": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/mri/-/mri-1.2.0.tgz", + "integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/mrmime": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/mrmime/-/mrmime-2.0.1.tgz", + "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/nanoid": { + "version": "3.3.15", + "resolved": "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.15.tgz", + "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", + "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/obug": { + "version": "2.1.3", + "resolved": "https://registry.npmmirror.com/obug/-/obug-2.1.3.tgz", + "integrity": "sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmmirror.com/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.16", + "resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.5.16.tgz", + "integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==", + "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.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prettier": { + "version": "3.9.5", + "resolved": "https://registry.npmmirror.com/prettier/-/prettier-3.9.5.tgz", + "integrity": "sha512-/FVl766LpUfB5vXgCYOYa0MeV/441Ia99AeICQIQFTY/Nw0roZwULcXpku5i1/m5kt/baz+s4Zogspd839HSMg==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/prettier-plugin-svelte": { + "version": "4.1.1", + "resolved": "https://registry.npmmirror.com/prettier-plugin-svelte/-/prettier-plugin-svelte-4.1.1.tgz", + "integrity": "sha512-wXvbXMjSvb4C9ENWTHXyd+ihakKCsJ6rJhLP6/8HFNj4GkZr48jqL9PoKsl2sk7SyCZRTnJ7O2TTowUpOxP/KA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "prettier": "^3.0.0", + "svelte": "^5.0.0" + } + }, + "node_modules/proxy-compare": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/proxy-compare/-/proxy-compare-3.0.1.tgz", + "integrity": "sha512-V9plBAt3qjMlS1+nC8771KNf6oJ12gExvaxnNzN/9yVRLdTv/lc+oJlnSzrdYDAvBfTStPCoiaCOTmTs0adv7Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmmirror.com/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/rolldown": { + "version": "1.1.5", + "resolved": "https://registry.npmmirror.com/rolldown/-/rolldown-1.1.5.tgz", + "integrity": "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.139.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.1.5", + "@rolldown/binding-darwin-arm64": "1.1.5", + "@rolldown/binding-darwin-x64": "1.1.5", + "@rolldown/binding-freebsd-x64": "1.1.5", + "@rolldown/binding-linux-arm-gnueabihf": "1.1.5", + "@rolldown/binding-linux-arm64-gnu": "1.1.5", + "@rolldown/binding-linux-arm64-musl": "1.1.5", + "@rolldown/binding-linux-ppc64-gnu": "1.1.5", + "@rolldown/binding-linux-s390x-gnu": "1.1.5", + "@rolldown/binding-linux-x64-gnu": "1.1.5", + "@rolldown/binding-linux-x64-musl": "1.1.5", + "@rolldown/binding-openharmony-arm64": "1.1.5", + "@rolldown/binding-wasm32-wasi": "1.1.5", + "@rolldown/binding-win32-arm64-msvc": "1.1.5", + "@rolldown/binding-win32-x64-msvc": "1.1.5" + } + }, + "node_modules/runed": { + "version": "0.35.1", + "resolved": "https://registry.npmmirror.com/runed/-/runed-0.35.1.tgz", + "integrity": "sha512-2F4Q/FZzbeJTFdIS/PuOoPRSm92sA2LhzTnv6FXhCoENb3huf5+fDuNOg1LNvGOouy3u/225qxmuJvcV3IZK5Q==", + "dev": true, + "funding": [ + "https://github.com/sponsors/huntabyte", + "https://github.com/sponsors/tglide" + ], + "license": "MIT", + "dependencies": { + "dequal": "^2.0.3", + "esm-env": "^1.0.0", + "lz-string": "^1.5.0" + }, + "peerDependencies": { + "@sveltejs/kit": "^2.21.0", + "svelte": "^5.7.0" + }, + "peerDependenciesMeta": { + "@sveltejs/kit": { + "optional": true + } + } + }, + "node_modules/sade": { + "version": "1.8.1", + "resolved": "https://registry.npmmirror.com/sade/-/sade-1.8.1.tgz", + "integrity": "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "mri": "^1.1.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/set-cookie-parser": { + "version": "3.1.2", + "resolved": "https://registry.npmmirror.com/set-cookie-parser/-/set-cookie-parser-3.1.2.tgz", + "integrity": "sha512-5/r/lTwbJ3zQ+qwdUFZYeRNqda7P5HD8zQKqlSjdGt1/S0cjLAphHusj4Y58ahDtWn/g32xrIS58/ikOvwl0Lw==", + "dev": true, + "license": "MIT" + }, + "node_modules/sirv": { + "version": "3.0.2", + "resolved": "https://registry.npmmirror.com/sirv/-/sirv-3.0.2.tgz", + "integrity": "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@polka/url": "^1.0.0-next.24", + "mrmime": "^2.0.0", + "totalist": "^3.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmmirror.com/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/style-to-object": { + "version": "1.0.14", + "resolved": "https://registry.npmmirror.com/style-to-object/-/style-to-object-1.0.14.tgz", + "integrity": "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==", + "dev": true, + "license": "MIT", + "dependencies": { + "inline-style-parser": "0.2.7" + } + }, + "node_modules/svelte": { + "version": "5.56.4", + "resolved": "https://registry.npmmirror.com/svelte/-/svelte-5.56.4.tgz", + "integrity": "sha512-/d0QHehmRuJW8gVz395MTkPcPozxzdjBMBE8oEYGz8O3b9KTMzzQ9ZHJQLuFKOHOPQbU6kx/X4iid/EBBzH7iw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.4", + "@jridgewell/sourcemap-codec": "^1.5.0", + "@sveltejs/acorn-typescript": "^1.0.10", + "@types/estree": "^1.0.5", + "@types/trusted-types": "^2.0.7", + "acorn": "^8.12.1", + "aria-query": "5.3.1", + "axobject-query": "^4.1.0", + "clsx": "^2.1.1", + "devalue": "^5.8.1", + "esm-env": "^1.2.1", + "esrap": "^2.2.12", + "is-reference": "^3.0.3", + "locate-character": "^3.0.0", + "magic-string": "^0.30.11", + "zimmerframe": "^1.1.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/svelte-check": { + "version": "4.7.2", + "resolved": "https://registry.npmmirror.com/svelte-check/-/svelte-check-4.7.2.tgz", + "integrity": "sha512-GoS4XJdGswlq0rIT1vtFLzJY1bvHtY37McY9H9Gkm1Ggw/ICdZYn8J/Z8Yi0BEL0i3R4+jtaWVePjyppMlij/A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.25", + "@sveltejs/load-config": "^0.2.0", + "chokidar": "^4.0.1", + "fdir": "^6.2.0", + "picocolors": "^1.0.0", + "sade": "^1.7.4" + }, + "bin": { + "svelte-check": "bin/svelte-check" + }, + "engines": { + "node": ">= 18.0.0" + }, + "peerDependencies": { + "svelte": "^4.0.0 || ^5.0.0-next.0", + "typescript": ">=5.0.0" + } + }, + "node_modules/svelte-toolbelt": { + "version": "0.10.6", + "resolved": "https://registry.npmmirror.com/svelte-toolbelt/-/svelte-toolbelt-0.10.6.tgz", + "integrity": "sha512-YWuX+RE+CnWYx09yseAe4ZVMM7e7GRFZM6OYWpBKOb++s+SQ8RBIMMe+Bs/CznBMc0QPLjr+vDBxTAkozXsFXQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/huntabyte" + ], + "dependencies": { + "clsx": "^2.1.1", + "runed": "^0.35.1", + "style-to-object": "^1.0.8" + }, + "engines": { + "node": ">=18", + "pnpm": ">=8.7.0" + }, + "peerDependencies": { + "svelte": "^5.30.2" + } + }, + "node_modules/tabbable": { + "version": "6.5.0", + "resolved": "https://registry.npmmirror.com/tabbable/-/tabbable-6.5.0.tgz", + "integrity": "sha512-wieBHXygIm7OyQOu5hQlkk62/WyCFYGlWg7L6/ZCUZwx0o398Zkn4pVmMyfYhfMG8kGrj/Krt8eIk6UKC6VzwA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tailwindcss": { + "version": "4.3.2", + "resolved": "https://registry.npmmirror.com/tailwindcss/-/tailwindcss-4.3.2.tgz", + "integrity": "sha512-WtctNNSH8A9jlMIqxzuYumOHU5uGZyRv0Q5svQl+oEPy5w84YpBxdb7MdqyiSPQge5jTJ6zFQLq0PFygdccSBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmmirror.com/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmmirror.com/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/totalist": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/totalist/-/totalist-3.0.1.tgz", + "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmmirror.com/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD" + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmmirror.com/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/vite": { + "version": "8.1.4", + "resolved": "https://registry.npmmirror.com/vite/-/vite-8.1.4.tgz", + "integrity": "sha512-bTT9PsdWO+MQMNG9ZXIP/qM9wGh37DFxTV/sPq9cFpHr3w4jkgef032PkAL9jAqhk3Nz8NQw3O8n6/xFkqO4QQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.16", + "rolldown": "~1.1.4", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.3.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitefu": { + "version": "1.1.3", + "resolved": "https://registry.npmmirror.com/vitefu/-/vitefu-1.1.3.tgz", + "integrity": "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg==", + "dev": true, + "license": "MIT", + "workspaces": [ + "tests/deps/*", + "tests/projects/*", + "tests/projects/workspace/packages/*" + ], + "peerDependencies": { + "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "vite": { + "optional": true + } + } + }, + "node_modules/zimmerframe": { + "version": "1.1.4", + "resolved": "https://registry.npmmirror.com/zimmerframe/-/zimmerframe-1.1.4.tgz", + "integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/hub/admin-web/package.json b/hub/admin-web/package.json new file mode 100644 index 0000000..573d868 --- /dev/null +++ b/hub/admin-web/package.json @@ -0,0 +1,33 @@ +{ + "name": "admin-web", + "private": true, + "version": "0.0.1", + "type": "module", + "scripts": { + "dev": "vite dev", + "build": "vite build", + "preview": "vite preview", + "prepare": "svelte-kit sync || echo ''", + "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", + "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", + "format": "prettier --write .", + "format:check": "prettier --check ." + }, + "devDependencies": { + "@skeletonlabs/skeleton": "^4.15.2", + "@skeletonlabs/skeleton-svelte": "^4.15.2", + "@sveltejs/adapter-auto": "^7.0.1", + "@sveltejs/adapter-static": "^3.0.10", + "@sveltejs/kit": "^2.63.0", + "@sveltejs/vite-plugin-svelte": "^7.1.2", + "@tailwindcss/vite": "^4.3.2", + "bits-ui": "^2.18.1", + "prettier": "^3.9.5", + "prettier-plugin-svelte": "^4.1.1", + "svelte": "^5.56.1", + "svelte-check": "^4.6.0", + "tailwindcss": "^4.3.2", + "typescript": "^6.0.3", + "vite": "^8.0.16" + } +} diff --git a/hub/admin-web/src/app.d.ts b/hub/admin-web/src/app.d.ts new file mode 100644 index 0000000..da08e6d --- /dev/null +++ b/hub/admin-web/src/app.d.ts @@ -0,0 +1,13 @@ +// See https://svelte.dev/docs/kit/types#app.d.ts +// for information about these interfaces +declare global { + namespace App { + // interface Error {} + // interface Locals {} + // interface PageData {} + // interface PageState {} + // interface Platform {} + } +} + +export {}; diff --git a/hub/admin-web/src/app.html b/hub/admin-web/src/app.html new file mode 100644 index 0000000..97798b5 --- /dev/null +++ b/hub/admin-web/src/app.html @@ -0,0 +1,27 @@ + + + + + + + + + + + + + CPH Admin + %sveltekit.head% + + +
%sveltekit.body%
+ + diff --git a/hub/admin-web/src/lib/api.ts b/hub/admin-web/src/lib/api.ts new file mode 100644 index 0000000..5c35cbc --- /dev/null +++ b/hub/admin-web/src/lib/api.ts @@ -0,0 +1,345 @@ +/** + * Thin API client for the org admin backend. Same-origin cookie auth. + */ + +export class ApiError extends Error { + code: string; + status: number; + constructor(code: string, message: string, status: number) { + super(message); + this.name = 'ApiError'; + this.code = code; + this.status = status; + } +} + +async function request(method: string, url: string, body?: unknown): Promise { + const init: RequestInit = { + method, + credentials: 'same-origin', + headers: body !== undefined ? { 'content-type': 'application/json' } : undefined, + body: body !== undefined ? JSON.stringify(body) : undefined, + }; + const res = await fetch(url, init); + const text = await res.text(); + let data: unknown = null; + if (text !== '') { + try { + data = JSON.parse(text); + } catch { + data = text; + } + } + if (!res.ok) { + const err = (data as { error?: { code?: string; message?: string } } | null)?.error; + throw new ApiError(err?.code ?? 'http_error', err?.message ?? `HTTP ${res.status}`, res.status); + } + return data; +} + +const get = (u: string) => request('GET', u); +const post = (u: string, b?: unknown) => request('POST', u, b); +const put = (u: string, b?: unknown) => request('PUT', u, b); +const patch = (u: string, b?: unknown) => request('PATCH', u, b); +const del = (u: string) => request('DELETE', u); + +const orgBase = (slug: string) => `/api/org/${encodeURIComponent(slug)}`; + +// --- Types --- + +export interface OrgMembership { + id: string; + slug: string; + name: string; + status: string; + role: 'OWNER' | 'ADMIN' | 'MEMBER'; +} + +export interface MeResponse { + user: { + id: string; + feishuOpenId: string; + displayName: string; + avatarUrl: string | null; + }; + organizations: OrgMembership[]; +} + +export interface OrgMember { + userId: string; + feishuOpenId: string; + displayName: string; + avatarUrl: string | null; + role: 'OWNER' | 'ADMIN' | 'MEMBER'; + createdAt: string; +} + +export interface TeamRow { + id: string; + slug: string; + name: string; + description: string | null; + memberCount: number; + createdAt: string; +} + +export interface TeamMemberRow { + userId: string; + feishuOpenId: string; + displayName: string; + createdAt: string; +} + +export interface ExplorerFolder { + id: string; + name: string; + parentId: string | null; + sortKey: string; + projectCount: number; + childFolderCount: number; +} + +export interface ExplorerProject { + id: string; + name: string; + folderId: string | null; + createdAt: string; + binding: { chatId: string; createdAt: string } | null; +} + +export interface ExplorerData { + folders: ExplorerFolder[]; + projects: ExplorerProject[]; +} + +export interface ProjectDetail { + id: string; + name: string; + folderId: string | null; + folder: { id: string; name: string } | null; + workspaceDir: string; + createdAt: string; + archivedAt: string | null; + createdBy: { id: string; displayName: string; feishuOpenId: string } | null; + binding: { chatId: string; createdAt: string } | null; + actorIsOrgAdmin?: boolean; + actorCanManageProject?: boolean; +} + +export interface TeamAccessEntry { + grantId: string; + projectId: string; + organizationId: string; + teamId: string; + teamSlug: string; + teamName: string; + role: 'READ' | 'EDIT' | 'MANAGE'; +} + +export interface SessionSummary { + id: string; + provider: string; + roleId: string; + model: string; + title: string | null; + runCount: number; + createdAt: string; + updatedAt: string; +} + +export interface ProviderConnectionRow { + id: string; + providerId: string; + mode: 'BYOK' | 'PLATFORM_MANAGED'; + status: 'DRAFT' | 'ACTIVE' | 'DISABLED'; + activeVersion: number | null; + keyId: string | null; + createdAt: string; + updatedAt: string; +} + +export interface FeishuApplicationConnection { + id: string; + appFingerprint: string; + status: 'DRAFT' | 'ACTIVE' | 'DISABLED'; + activeVersion: number | null; + keyId: string | null; + createdAt: string; + updatedAt: string; +} + +export interface UsageTotals { + runCount: number; + runsWithCost: number; + runsWithoutCost: number; + inputTokens: number; + outputTokens: number; + costUsd: number | null; +} + +export interface ProjectUsageRow extends UsageTotals { + projectId: string; + projectName: string; + folderId: string | null; +} + +export interface UsageReport { + from: string | null; + to: string | null; + projects: ProjectUsageRow[]; + totals: UsageTotals; +} + +export type CapacityDimension = + | 'requestRate' + | 'requestBodySize' + | 'agentConcurrency' + | 'admissionQueueLength' + | 'admissionQueueWait' + | 'fileSize' + | 'attachmentCount' + | 'archiveExpansion' + | 'projectStorage' + | 'organizationStorage' + | 'memberCount' + | 'projectCount' + | 'teamCount' + | 'folderCount' + | 'sessionCount' + | 'runWallTime' + | 'runTurns' + | 'runToolCalls' + | 'toolWallTime' + | 'runOutputSize' + | 'processMemory' + | 'processCpu' + | 'processCount'; + +export interface CapacityDimensionRow { + dimension: CapacityDimension; + platformCeiling: number | null; + organizationLimit: number | null; + effective: number | null; +} + +export interface CapacityPolicyView { + dimensions: CapacityDimensionRow[]; +} + +// --- API --- + +export const api = { + me: () => get('/api/me') as Promise, + logout: () => post('/auth/logout'), + + org: (slug: string) => + get(orgBase(slug)) as Promise<{ + organization: { id: string; slug: string; name: string; status: string }; + actorRole: string; + }>, + settings: (slug: string) => get(`${orgBase(slug)}/settings`) as Promise<{ membersCanCreateProjects: boolean }>, + setSettings: (slug: string, body: { membersCanCreateProjects: boolean }) => + patch(`${orgBase(slug)}/settings`, body) as Promise<{ membersCanCreateProjects: boolean }>, + + members: (slug: string) => get(`${orgBase(slug)}/members`) as Promise<{ members: OrgMember[] }>, + addMember: (slug: string, body: { feishuOpenId: string; displayName?: string; role: string }) => + post(`${orgBase(slug)}/members`, body) as Promise, + setMemberRole: (slug: string, userId: string, role: string) => patch(`${orgBase(slug)}/members/${userId}`, { role }), + revokeMember: (slug: string, userId: string) => post(`${orgBase(slug)}/members/${userId}/revoke`), + + teams: (slug: string) => get(`${orgBase(slug)}/teams`) as Promise<{ teams: TeamRow[] }>, + createTeam: (slug: string, body: { slug: string; name: string; description?: string }) => + post(`${orgBase(slug)}/teams`, body) as Promise, + updateTeam: (slug: string, teamId: string, body: { name?: string; description?: string | null }) => + patch(`${orgBase(slug)}/teams/${teamId}`, body) as Promise, + archiveTeam: (slug: string, teamId: string) => post(`${orgBase(slug)}/teams/${teamId}/archive`), + teamMembers: (slug: string, teamId: string) => + get(`${orgBase(slug)}/teams/${teamId}/members`) as Promise<{ members: TeamMemberRow[] }>, + addTeamMember: (slug: string, teamId: string, body: { userId?: string; feishuOpenId?: string }) => + post(`${orgBase(slug)}/teams/${teamId}/members`, body) as Promise, + revokeTeamMember: (slug: string, teamId: string, userId: string) => + post(`${orgBase(slug)}/teams/${teamId}/members/${userId}/revoke`), + + explorer: (slug: string) => get(`${orgBase(slug)}/explorer`) as Promise, + myProjects: (slug: string) => + get(`${orgBase(slug)}/my-projects`) as Promise<{ projects: ExplorerProject[] }>, + createFolder: (slug: string, body: { name: string; parentId?: string; sortKey?: string }) => + post(`${orgBase(slug)}/folders`, body) as Promise<{ + id: string; + name: string; + parentId: string | null; + sortKey: string; + }>, + renameFolder: (slug: string, folderId: string, body: { name?: string; sortKey?: string; parentId?: string | null }) => + patch(`${orgBase(slug)}/folders/${folderId}`, body) as Promise<{ + id: string; + name: string; + parentId: string | null; + sortKey: string; + }>, + archiveFolder: (slug: string, folderId: string) => + post(`${orgBase(slug)}/folders/${folderId}/archive`) as Promise<{ archived: true; folderId: string }>, + createProject: (slug: string, body: { name: string; folderId?: string }) => + post(`${orgBase(slug)}/projects`, body) as Promise<{ id: string; name: string }>, + project: (slug: string, projectId: string) => get(`${orgBase(slug)}/projects/${projectId}`) as Promise, + renameProject: (slug: string, projectId: string, name: string) => + patch(`${orgBase(slug)}/projects/${projectId}`, { name }), + moveProject: (slug: string, projectId: string, folderId: string | null) => + patch(`${orgBase(slug)}/projects/${projectId}/folder`, { folderId }), + archiveProject: (slug: string, projectId: string) => post(`${orgBase(slug)}/projects/${projectId}/archive`), + archiveBinding: (slug: string, projectId: string) => post(`${orgBase(slug)}/projects/${projectId}/binding/archive`), + + teamAccess: (slug: string, projectId: string) => + get(`${orgBase(slug)}/projects/${projectId}/team-access`) as Promise<{ access: TeamAccessEntry[] }>, + grantTeamAccess: (slug: string, projectId: string, body: { teamId?: string; teamSlug?: string; role: string }) => + put(`${orgBase(slug)}/projects/${projectId}/team-access`, body) as Promise, + revokeTeamAccess: (slug: string, projectId: string, teamId: string) => + del(`${orgBase(slug)}/projects/${projectId}/team-access/${teamId}`), + + sessions: (slug: string, projectId: string, limit?: number) => + get(`${orgBase(slug)}/projects/${projectId}/sessions${limit !== undefined ? `?limit=${limit}` : ''}`) as Promise<{ + sessions: SessionSummary[]; + }>, + usage: (slug: string, params?: { from?: string; to?: string; folderId?: string }) => { + const q = new URLSearchParams(); + if (params?.from) q.set('from', params.from); + if (params?.to) q.set('to', params.to); + if (params?.folderId) q.set('folderId', params.folderId); + const qs = q.toString(); + return get(`${orgBase(slug)}/usage${qs ? `?${qs}` : ''}`) as Promise; + }, + + providerConnections: (slug: string) => + get(`${orgBase(slug)}/provider-connections`) as Promise<{ connections: ProviderConnectionRow[] }>, + rotateProviderConnection: ( + slug: string, + providerId: string, + body: { baseUrl: string; authToken: string; anthropicApiKey?: string }, + ) => + put( + `${orgBase(slug)}/provider-connections/${encodeURIComponent(providerId)}`, + body, + ) as Promise, + + feishuApplication: (slug: string) => + get(`${orgBase(slug)}/feishu-application-connection`) as Promise<{ + connection: FeishuApplicationConnection | null; + }>, + rotateFeishuApplication: ( + slug: string, + body: { + appId: string; + appSecret: string; + botOpenId: string; + verificationToken?: string; + encryptKey?: string; + }, + ) => put(`${orgBase(slug)}/feishu-application-connection`, body) as Promise, + disableFeishuApplication: (slug: string) => + del(`${orgBase(slug)}/feishu-application-connection`) as Promise, + + capacityPolicy: (slug: string) => + get(`${orgBase(slug)}/capacity-policy`) as Promise, + setCapacityPolicy: (slug: string, body: { limits: Partial> }) => + put(`${orgBase(slug)}/capacity-policy`, body) as Promise, +}; diff --git a/hub/admin-web/src/lib/assets/favicon.svg b/hub/admin-web/src/lib/assets/favicon.svg new file mode 100644 index 0000000..cc5dc66 --- /dev/null +++ b/hub/admin-web/src/lib/assets/favicon.svg @@ -0,0 +1 @@ +svelte-logo \ No newline at end of file diff --git a/hub/admin-web/src/lib/components/CheckboxControl.svelte b/hub/admin-web/src/lib/components/CheckboxControl.svelte new file mode 100644 index 0000000..c85806c --- /dev/null +++ b/hub/admin-web/src/lib/components/CheckboxControl.svelte @@ -0,0 +1,32 @@ + + + { + checked = next; + onchange?.(next); + }} +> + {#snippet children({ checked: isChecked })} + {#if isChecked} + + {/if} + {/snippet} + diff --git a/hub/admin-web/src/lib/components/EmptyState.svelte b/hub/admin-web/src/lib/components/EmptyState.svelte new file mode 100644 index 0000000..37be260 --- /dev/null +++ b/hub/admin-web/src/lib/components/EmptyState.svelte @@ -0,0 +1,32 @@ + + +
+
+ + + +
+

{title}

+ {#if description} +

{description}

+ {/if} + {#if action} +
+ {@render action()} +
+ {/if} +
diff --git a/hub/admin-web/src/lib/components/ErrorBanner.svelte b/hub/admin-web/src/lib/components/ErrorBanner.svelte new file mode 100644 index 0000000..d5258f0 --- /dev/null +++ b/hub/admin-web/src/lib/components/ErrorBanner.svelte @@ -0,0 +1,26 @@ + + +
+ + + +
+

请求失败

+

{message}

+
+ {#if onretry} + + {/if} +
diff --git a/hub/admin-web/src/lib/components/FolderNode.svelte b/hub/admin-web/src/lib/components/FolderNode.svelte new file mode 100644 index 0000000..250422c --- /dev/null +++ b/hub/admin-web/src/lib/components/FolderNode.svelte @@ -0,0 +1,48 @@ + + +
+ + {#if open} +
+ +
+ {/if} +
diff --git a/hub/admin-web/src/lib/components/FolderTree.svelte b/hub/admin-web/src/lib/components/FolderTree.svelte new file mode 100644 index 0000000..d8ff45b --- /dev/null +++ b/hub/admin-web/src/lib/components/FolderTree.svelte @@ -0,0 +1,49 @@ + + +
+ {#each childProjects as p (p.id)} + + + + + {p.name} + {#if p.binding} + 已绑定 + {/if} + + + {/each} + + {#each childFolders as f (f.id)} + + {/each} +
diff --git a/hub/admin-web/src/lib/components/Icon.svelte b/hub/admin-web/src/lib/components/Icon.svelte new file mode 100644 index 0000000..f51370c --- /dev/null +++ b/hub/admin-web/src/lib/components/Icon.svelte @@ -0,0 +1,123 @@ + + +{#if name === 'overview'} + + + +{:else if name === 'members'} + + + +{:else if name === 'teams'} + + + +{:else if name === 'projects'} + + + +{:else if name === 'provider'} + + + + +{:else if name === 'feishu'} + + + + +{:else if name === 'menu'} + + + +{:else if name === 'logout'} + + + +{:else if name === 'org'} + + + +{:else if name === 'chevron'} + + + +{:else if name === 'folder'} + + + +{:else if name === 'file'} + + + +{:else if name === 'check'} + + + +{/if} diff --git a/hub/admin-web/src/lib/components/LoadingState.svelte b/hub/admin-web/src/lib/components/LoadingState.svelte new file mode 100644 index 0000000..aaa784c --- /dev/null +++ b/hub/admin-web/src/lib/components/LoadingState.svelte @@ -0,0 +1,15 @@ + + +
+ + + + +

{label}

+
diff --git a/hub/admin-web/src/lib/components/Modal.svelte b/hub/admin-web/src/lib/components/Modal.svelte new file mode 100644 index 0000000..0c3f354 --- /dev/null +++ b/hub/admin-web/src/lib/components/Modal.svelte @@ -0,0 +1,38 @@ + + + { + if (!next) onclose?.(); + }} +> + + + +
+ {title} + + + + + +
+ {@render children()} +
+
+
diff --git a/hub/admin-web/src/lib/components/PageHeader.svelte b/hub/admin-web/src/lib/components/PageHeader.svelte new file mode 100644 index 0000000..f8359f7 --- /dev/null +++ b/hub/admin-web/src/lib/components/PageHeader.svelte @@ -0,0 +1,27 @@ + + +
+
+

{title}

+ {#if description} +

{description}

+ {/if} +
+ {#if actions} +
+ {@render actions()} +
+ {/if} +
diff --git a/hub/admin-web/src/lib/components/SelectField.svelte b/hub/admin-web/src/lib/components/SelectField.svelte new file mode 100644 index 0000000..1297601 --- /dev/null +++ b/hub/admin-web/src/lib/components/SelectField.svelte @@ -0,0 +1,66 @@ + + + { + value = next; + onchange?.(next); + }} +> + + + + + + + + + + {#each items as item (item.value)} + + {#snippet children({ selected })} + {item.label} + {#if selected} + + {/if} + {/snippet} + + {/each} + + + + diff --git a/hub/admin-web/src/lib/components/StatCard.svelte b/hub/admin-web/src/lib/components/StatCard.svelte new file mode 100644 index 0000000..c4f210b --- /dev/null +++ b/hub/admin-web/src/lib/components/StatCard.svelte @@ -0,0 +1,19 @@ + + +
+
{label}
+
{value}
+ {#if hint} +
{hint}
+ {/if} +
diff --git a/hub/admin-web/src/lib/components/SwitchControl.svelte b/hub/admin-web/src/lib/components/SwitchControl.svelte new file mode 100644 index 0000000..a93842e --- /dev/null +++ b/hub/admin-web/src/lib/components/SwitchControl.svelte @@ -0,0 +1,27 @@ + + + { + checked = next; + onchange?.(next); + }} +> + + diff --git a/hub/admin-web/src/lib/components/ToastHost.svelte b/hub/admin-web/src/lib/components/ToastHost.svelte new file mode 100644 index 0000000..e81a584 --- /dev/null +++ b/hub/admin-web/src/lib/components/ToastHost.svelte @@ -0,0 +1,25 @@ + + +
+ {#each $toasts as t (t.id)} +
+

{t.message}

+ +
+ {/each} +
diff --git a/hub/admin-web/src/lib/constants.ts b/hub/admin-web/src/lib/constants.ts new file mode 100644 index 0000000..92af44b --- /dev/null +++ b/hub/admin-web/src/lib/constants.ts @@ -0,0 +1,39 @@ +export interface ToolOption { + id: string; + label: string; + group: string; +} + +export const TOOL_OPTIONS: ToolOption[] = [ + { id: 'read_file', label: '读取文件', group: '文件' }, + { id: 'write_file', label: '写入文件', group: '文件' }, + { id: 'list_files', label: '列目录', group: '文件' }, + { id: 'search_files', label: '搜索', group: '文件' }, + { id: 'bash', label: 'Bash 命令', group: 'Shell' }, + { id: 'cph_check', label: 'cph check', group: 'CPH' }, + { id: 'cph_build', label: 'cph build', group: 'CPH' }, + { id: 'send_file', label: '发送文件(飞书)', group: '飞书' }, + { id: 'feishu_read_context', label: '读飞书上下文', group: '飞书' }, + { id: 'feishu_download_resource', label: '下载飞书资源', group: '飞书' }, + { id: 'request_approval', label: '请求审批', group: '飞书' }, +]; + +/** 组织成员角色(接口枚举保持英文,界面用 orgRoleLabel) */ +export const ORG_ROLES = ['OWNER', 'ADMIN', 'MEMBER'] as const; +export type OrgRole = (typeof ORG_ROLES)[number]; + +export const ORG_ROLE_LABELS: Record = { + OWNER: '所有者', + ADMIN: '管理员', + MEMBER: '成员', +}; + +/** 项目团队授权角色(接口枚举保持英文,界面用 permissionRoleLabel) */ +export const PERMISSION_ROLES = ['READ', 'EDIT', 'MANAGE'] as const; +export type PermissionRole = (typeof PERMISSION_ROLES)[number]; + +export const PERMISSION_ROLE_LABELS: Record = { + READ: '只读', + EDIT: '编辑', + MANAGE: '管理', +}; diff --git a/hub/admin-web/src/lib/format.ts b/hub/admin-web/src/lib/format.ts new file mode 100644 index 0000000..07391b5 --- /dev/null +++ b/hub/admin-web/src/lib/format.ts @@ -0,0 +1,46 @@ +import { ORG_ROLE_LABELS, PERMISSION_ROLE_LABELS, type OrgRole, type PermissionRole } from './constants'; + +export function fmtDate(iso: string): string { + if (!iso) return '—'; + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return iso; + return d.toLocaleString(undefined, { + year: 'numeric', + month: 'short', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + }); +} + +export function fmtDateOnly(iso: string): string { + if (!iso) return '—'; + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return iso; + return d.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: '2-digit' }); +} + +export function fmtCost(usd: number | null): string { + if (usd === null) return '—'; + return `$${Number(usd).toFixed(4)}`; +} + +export function fmtNum(n: number): string { + return n.toLocaleString(); +} + +export function orgRoleLabel(role: string): string { + const key = role.toUpperCase() as OrgRole; + return ORG_ROLE_LABELS[key] ?? role; +} + +export function permissionRoleLabel(role: string): string { + const key = role.toUpperCase() as PermissionRole; + return PERMISSION_ROLE_LABELS[key] ?? role; +} + +export function providerModeLabel(mode: string): string { + if (mode === 'BYOK') return '自带密钥'; + if (mode === 'PLATFORM_MANAGED') return '平台托管'; + return mode; +} diff --git a/hub/admin-web/src/lib/index.ts b/hub/admin-web/src/lib/index.ts new file mode 100644 index 0000000..856f2b6 --- /dev/null +++ b/hub/admin-web/src/lib/index.ts @@ -0,0 +1 @@ +// place files you want to import through the `$lib` alias in this folder. diff --git a/hub/admin-web/src/lib/session.ts b/hub/admin-web/src/lib/session.ts new file mode 100644 index 0000000..a219994 --- /dev/null +++ b/hub/admin-web/src/lib/session.ts @@ -0,0 +1,43 @@ +import { writable } from 'svelte/store'; +import { api, type MeResponse } from './api'; + +interface SessionState { + loading: boolean; + me: MeResponse | null; + error: string | null; +} + +export const session = writable({ + loading: true, + me: null, + error: null, +}); + +export async function loadSession(): Promise { + session.update((s) => ({ ...s, loading: true, error: null })); + try { + const me = await api.me(); + session.set({ loading: false, me, error: null }); + } catch (err) { + const status = (err as { status?: number }).status; + if (status === 401) { + redirectToLogin(); + return; + } + session.set({ + loading: false, + me: null, + error: err instanceof Error ? err.message : String(err), + }); + } +} + +export function redirectToLogin(): void { + const ret = encodeURIComponent(window.location.pathname + window.location.hash); + window.location.href = `/auth/feishu?returnTo=${ret}`; +} + +export async function logout(): Promise { + await api.logout(); + redirectToLogin(); +} diff --git a/hub/admin-web/src/lib/toast.ts b/hub/admin-web/src/lib/toast.ts new file mode 100644 index 0000000..ad89770 --- /dev/null +++ b/hub/admin-web/src/lib/toast.ts @@ -0,0 +1,34 @@ +import { writable } from 'svelte/store'; + +export type ToastKind = 'info' | 'success' | 'error'; + +export interface ToastItem { + id: number; + message: string; + kind: ToastKind; +} + +let seq = 0; +export const toasts = writable([]); + +export function pushToast(message: string, kind: ToastKind = 'info', ms = 3200): void { + const id = ++seq; + toasts.update((list) => [...list, { id, message, kind }]); + if (ms > 0) { + setTimeout(() => { + toasts.update((list) => list.filter((t) => t.id !== id)); + }, ms); + } +} + +export function dismissToast(id: number): void { + toasts.update((list) => list.filter((t) => t.id !== id)); +} + +export function toastSuccess(message: string): void { + pushToast(message, 'success'); +} + +export function toastError(message: string): void { + pushToast(message, 'error', 5000); +} diff --git a/hub/admin-web/src/routes/+layout.svelte b/hub/admin-web/src/routes/+layout.svelte new file mode 100644 index 0000000..b58118e --- /dev/null +++ b/hub/admin-web/src/routes/+layout.svelte @@ -0,0 +1,389 @@ + + + + +{#if $session.loading || redirecting} +
+
+ + + + +

{redirecting ? '正在进入组织…' : '正在加载会话…'}

+
+
+{:else if $session.error} +
+
+
+ ! +
+

无法连接到后端

+

{$session.error}

+ +
+
+{:else if !$session.me} +
+
+
+ CPH +
+

Curriculum Project Hub

+

登录以管理组织、项目、团队与模型供应。

+ +
+
+{:else if currentOrg() && isAdmin(currentOrg()!)} + {@const org = currentOrg()!} + {@const me = $session.me!} +
+ {#if mobileNavOpen} + + {/if} + + + +
+
+ +
+
+ {org.name} + / + {pageTitle()} +
+
+ +
+
+
+ {@render children()} +
+
+
+
+{:else if currentOrg() && !isAdmin(currentOrg()!) && isOnProjectRoute()} + {@const org = currentOrg()!} + {@const me = $session.me!} +
+
+
+ + + +
+
+ {org.name} + / + 项目 +
+
+
+ /{org.slug} + +
+
+
+
+ {@render children()} +
+
+
+
+{:else if currentOrg() && !isAdmin(currentOrg()!)} + {@const denied = currentOrg()!} +
+
+

无权访问管理后台

+

+ 组织 {denied.name}(/{denied.slug})中你的角色是 + {orgRoleLabel(denied.role)}。普通成员仅可访问自己有授权的项目。 +

+ 查看我的项目 + {#if memberships().length > 1} +

切换到其他组织

+
+ +
+ {/if} + +
+
+{:else if memberships().length > 0} + {@const denied = pickHomeOrg()!} +
+
+

正在跳转…

+

+ 即将进入 {denied.name}(/{denied.slug})的项目。 +

+ 立即进入 + +
+
+{:else} +
+
+

未加入组织

+

飞书账号已登录,但当前账号尚未加入任何组织。

+

+ open_id: + {$session.me!.user.feishuOpenId} +

+ +
+
+{/if} diff --git a/hub/admin-web/src/routes/+page.svelte b/hub/admin-web/src/routes/+page.svelte new file mode 100644 index 0000000..5e72a6b --- /dev/null +++ b/hub/admin-web/src/routes/+page.svelte @@ -0,0 +1,43 @@ + + +
+
+ + + + +

正在进入工作台…

+
+
diff --git a/hub/admin-web/src/routes/admin/org/[slug]/+page.svelte b/hub/admin-web/src/routes/admin/org/[slug]/+page.svelte new file mode 100644 index 0000000..2231397 --- /dev/null +++ b/hub/admin-web/src/routes/admin/org/[slug]/+page.svelte @@ -0,0 +1,147 @@ + + +{#if loading} + +{:else if error} + +{:else if org && settings && usage} + + +
+
+

组织信息

+
+
+
Slug
+
/{org.slug}
+
+
+
状态
+
{org.status}
+
+
+
你的角色
+
{orgRoleLabel(org.role)}
+
+
+
+ +
+

项目自助创建策略

+

开启后,普通老师可在飞书群自助创建项目;关闭后仅所有者与管理员可建。

+
+ +
+
允许成员自助创建项目
+
普通成员在飞书群中自助建项
+
+
+
+
+ +
+
+

用量概览

+

全组织智能体运行汇总

+
+
+ +
+ + + + + + +
+ +
+
+

按项目用量

+
+ {#if usage.projects.length === 0} +
+

暂无项目用量数据

+
+ {:else} +
+ + + + + + + + + + + {#each usage.projects as p} + + + + + + + {/each} + +
项目运行in / out tokens成本
{p.projectName}{fmtNum(p.runCount)}{fmtNum(p.inputTokens)} / {fmtNum(p.outputTokens)}{fmtCost(p.costUsd)}
+
+ {/if} +
+{:else} +
+

组织数据不可用

+
+{/if} diff --git a/hub/admin-web/src/routes/admin/org/[slug]/capacity/+page.svelte b/hub/admin-web/src/routes/admin/org/[slug]/capacity/+page.svelte new file mode 100644 index 0000000..70191a4 --- /dev/null +++ b/hub/admin-web/src/routes/admin/org/[slug]/capacity/+page.svelte @@ -0,0 +1,310 @@ + + + + {#snippet actions()} + + {/snippet} + + +

+ 未配置平台上限的维度暂不可设置组织限制。输入框留空即沿用平台上限。 +

+ +{#if loading} + +{:else if error} + +{:else if view} + {#if view.dimensions.length === 0} + + {:else} + {@const policy = view} +
+ {#each GROUPS as group} + {@const rows = group.dims + .map((d) => policy.dimensions.find((r) => r.dimension === d)) + .filter((r): r is CapacityDimensionRow => r !== undefined)} + {#if rows.length > 0} +
+
+

{group.title}

+ {rows.length} 项 +
+
+ {#each rows as row (row.dimension)} + {@const err = draftError(row)} + {@const eff = liveEffective(row)} + {@const lowered = isLowered(row)} + {@const disabled = row.platformCeiling === null} +
+
{DIMENSION_LABELS[row.dimension] ?? row.dimension}
+
+ {#if disabled} + 未配置 + {:else} + 平台 ≤ {row.platformCeiling} + {/if} +
+ +
+ {#if eff === null} + + {:else if lowered} + 有效 {eff} + {:else} + 有效 {eff} + {/if} +
+ {#if err !== null} +
{err}
+ {/if} +
+ {/each} +
+
+ {/if} + {/each} +
+ {/if} +{:else} + +{/if} + + diff --git a/hub/admin-web/src/routes/admin/org/[slug]/feishu/+page.svelte b/hub/admin-web/src/routes/admin/org/[slug]/feishu/+page.svelte new file mode 100644 index 0000000..03a7a8b --- /dev/null +++ b/hub/admin-web/src/routes/admin/org/[slug]/feishu/+page.svelte @@ -0,0 +1,176 @@ + + + + +{#if loading} + +{:else if error} + +{:else} + {#if connection} +
+

当前连接

+
+
+
状态
+
{connection.status}
+
+
+
App 指纹
+
{connection.appFingerprint}
+
+
+
版本
+
{connection.activeVersion ?? '—'}
+
+
+
更新于
+
{fmtDate(connection.updatedAt)}
+
+
+ {#if connection.status !== 'DISABLED'} +
+ +
+ {/if} +
+ {:else} +
+

本组织尚未绑定飞书应用。填写下方凭据以创建连接。

+
+ {/if} + +
+

{connection ? '轮换凭据' : '创建连接'}

+

+ 密钥仅写入新版本,旧版本归档。{#if connection}App ID 不可变更,须与现有应用一致。{/if} +

+
+
+ App ID + +
+
+ App Secret + +
+
+ Bot Open ID + +
+
+ Verification Token(可选) + +
+
+ Encrypt Key(可选) + +
+
+
+
+ + +
+
+{/if} diff --git a/hub/admin-web/src/routes/admin/org/[slug]/members/+page.svelte b/hub/admin-web/src/routes/admin/org/[slug]/members/+page.svelte new file mode 100644 index 0000000..f693938 --- /dev/null +++ b/hub/admin-web/src/routes/admin/org/[slug]/members/+page.svelte @@ -0,0 +1,164 @@ + + + + +{#if loading} + +{:else if error} + +{:else} +
+

添加成员

+
+ + + + +
+
+ +
+
+

成员列表

+ {members.length} +
+ {#if members.length === 0} + + {:else} +
+ + + + + + + + + + + + {#each members as m} + + + + + + + + {/each} + +
用户open_id组织角色加入时间
+
+ {#if m.avatarUrl} + + {:else} +
+ {m.displayName.slice(0, 1)} +
+ {/if} + {m.displayName} +
+
{m.feishuOpenId} + { + if (role !== m.role) changeRole(m, role); + }} + /> + {fmtDate(m.createdAt)} + +
+
+ {/if} +

+ 组织角色控制后台访问;项目级权限由「项目」页团队授权({permHint})决定。 +

+
+{/if} diff --git a/hub/admin-web/src/routes/admin/org/[slug]/projects/+page.svelte b/hub/admin-web/src/routes/admin/org/[slug]/projects/+page.svelte new file mode 100644 index 0000000..d9829cb --- /dev/null +++ b/hub/admin-web/src/routes/admin/org/[slug]/projects/+page.svelte @@ -0,0 +1,205 @@ + + +{#if loading} + +{:else if error} + +{:else if isAdmin && data} + + {#snippet actions()} + + + {/snippet} + + +
+ {#if data.projects.filter((p) => !p.folderId).length === 0 && data.folders.filter((f) => !f.parentId).length === 0} + + {:else} + + {/if} +
+ + + 名称 + { + if (e.key === 'Enter') createFolder(); + }} + /> + {#if data && data.folders.length > 0} +

父文件夹(可选)

+
+ +
+ {/if} +
+ + +
+
+ + + 项目名 + { + if (e.key === 'Enter') createProject(); + }} + /> + {#if data && data.folders.length > 0} +

文件夹(可选)

+
+ +
+ {/if} +
+ + +
+
+{:else if myProjects !== null} + + {#snippet actions()} + 仅显示已授权项目 + {/snippet} + + +
+ {#if myProjects.length === 0} + + {:else} + + + + + + + + + + {#each myProjects as p} + (window.location.href = `/admin/org/${slug}/projects/${p.id}`)}> + + + + + {/each} + +
项目飞书群创建时间
{p.name}{p.binding ? `群 ${p.binding.chatId}` : '—'}{fmtDate(p.createdAt)}
+ {/if} +
+{:else} + +{/if} diff --git a/hub/admin-web/src/routes/admin/org/[slug]/projects/[projectId]/+page.svelte b/hub/admin-web/src/routes/admin/org/[slug]/projects/[projectId]/+page.svelte new file mode 100644 index 0000000..fa2d181 --- /dev/null +++ b/hub/admin-web/src/routes/admin/org/[slug]/projects/[projectId]/+page.svelte @@ -0,0 +1,304 @@ + + +{#if loading} + +{:else if error} + +{:else if proj} + + + {@const detail = proj} + + {#snippet actions()} + {#if actorIsOrgAdmin} + + {#if detail.binding} + + {/if} + + {/if} + {/snippet} + + +
+
+
+
工作区路径
+
{proj.workspaceDir}
+
+
+
创建者
+
+ {proj.createdBy ? `${proj.createdBy.displayName} (${proj.createdBy.feishuOpenId})` : '—'} +
+
+
+
文件夹
+
{proj.folder ? proj.folder.name : '(根)'}
+
+
+
飞书群
+
+ {#if proj.binding} + 已绑定 + 群 {proj.binding.chatId} + · {fmtDate(proj.binding.createdAt)} + {:else} + 未绑定 + {/if} +
+
+
+
创建时间
+
{fmtDate(proj.createdAt)}
+
+
+ + {#if explorer} +
+
+

移动到文件夹

+ +
+ +
+ {/if} +
+ +
+

团队授权

+

通过团队授权项目访问。一项目可授多团队,一团队可访问多项目。

+ + {#if actorCanManage} +
+ + + +
+ {:else} +

需要项目 MANAGE 授权才能增删团队访问。

+ {/if} + + {#if access.length === 0} + + {:else} + + + + + + + + + + + {#each access as g} + + + + + + + {/each} + +
团队标识角色
{g.teamName}/{g.teamSlug}{permissionRoleLabel(g.role)} + {#if actorCanManage} + + {:else} + + {/if} +
+ {/if} +
+ + {#if actorIsOrgAdmin} +
+
+

智能体会话

+
+ {#if sessions.length === 0} + + {:else} + + + + + + + + + + + {#each sessions as s} + + + + + + + {/each} + +
供应方 / 角色模型运行次数更新
{s.provider} / {s.roleId}{s.model}{s.runCount}{fmtDate(s.updatedAt)}
+ {/if} +
+ {/if} +{:else} +
+

项目数据不可用

+
+{/if} diff --git a/hub/admin-web/src/routes/admin/org/[slug]/provider/+page.svelte b/hub/admin-web/src/routes/admin/org/[slug]/provider/+page.svelte new file mode 100644 index 0000000..b253162 --- /dev/null +++ b/hub/admin-web/src/routes/admin/org/[slug]/provider/+page.svelte @@ -0,0 +1,169 @@ + + + + +{#if loading} + +{:else if error} + +{:else} +
+
+

连接

+
+ {#if connections.length === 0} +

尚无供应方连接

+ {:else} +
+ + + + + + + + + + + + + {#each connections as row} + + + + + + + + + {/each} + +
供应方凭据模式状态版本更新于
{row.providerId}{providerModeLabel(row.mode)}{row.status}{row.activeVersion ?? '—'}{fmtDate(row.updatedAt)} + {#if row.mode === 'BYOK'} + + {:else} + 平台管理 + {/if} +
+
+ {/if} +
+ +
+

轮换 BYOK 凭据

+

+ 密钥仅写入新版本,旧版本归档;保存时需重新填写接口地址与访问令牌。平台托管连接不在此处管理。 +

+
+
+ 供应方 ID + +
+
+ 接口地址 + +
+
+ 访问令牌 + +
+
+ Anthropic API Key(可选) + +
+
+
+
+ + +
+
+{/if} diff --git a/hub/admin-web/src/routes/admin/org/[slug]/teams/+page.svelte b/hub/admin-web/src/routes/admin/org/[slug]/teams/+page.svelte new file mode 100644 index 0000000..e552856 --- /dev/null +++ b/hub/admin-web/src/routes/admin/org/[slug]/teams/+page.svelte @@ -0,0 +1,233 @@ + + + + +{#if loading} + +{:else if error} + +{:else} +
+

新建团队

+
+ + + + +
+
+ + {#if teams.length === 0} +
+ +
+ {:else} +
+ {#each teams as t (t.id)} + onExpandChange(t, open)} + > +
+ {t.name} + /{t.slug} + {t.memberCount} 成员 +
+ + {expandedId === t.id ? '收起' : '管理成员'} + + +
+
+ {#if t.description} +

{t.description}

+ {/if} +

创建于 {fmtDate(t.createdAt)}

+ + +
+ {#if loadingMembers && expandedId === t.id} +

加载中…

+ {:else if expandedId === t.id} +
+ { + if (e.key === 'Enter') addMember(t); + }} + /> + +
+ {#if teamMembers.length === 0} +

团队暂无成员

+ {:else} + + + + + + + + + + + {#each teamMembers as m} + + + + + + + {/each} + +
成员open_id加入时间
{m.displayName}{m.feishuOpenId}{fmtDate(m.createdAt)} + +
+ {/if} + {/if} +
+
+
+ {/each} +
+ {/if} +

归档团队会同步撤销其活跃的项目授权。

+{/if} diff --git a/hub/admin-web/src/routes/app.css b/hub/admin-web/src/routes/app.css new file mode 100644 index 0000000..30c4fc0 --- /dev/null +++ b/hub/admin-web/src/routes/app.css @@ -0,0 +1,692 @@ +@import 'tailwindcss'; + +@import '@skeletonlabs/skeleton'; +@import '@skeletonlabs/skeleton/themes/hamlindigo'; + +@source './**/*.{html,js,svelte,ts}'; +@source '../lib/**/*.{html,js,svelte,ts}'; + +/* Flat industrial: zero radius, higher-contrast surfaces, CJK-first type */ +@theme { + --font-sans: + 'Noto Sans SC', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', 'Inter', ui-sans-serif, system-ui, + -apple-system, 'Segoe UI', sans-serif; + --font-mono: 'JetBrains Mono', ui-monospace, 'SF Mono', Menlo, Consolas, monospace; + + --radius-none: 0; + --radius-sm: 0; + --radius-md: 0; + --radius-lg: 0; + --radius-xl: 0; + --radius-2xl: 0; + --radius-3xl: 0; + --radius-full: 0; + --radius: 0; +} + +@layer base { + html { + height: 100%; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + text-rendering: optimizeLegibility; + font-feature-settings: + 'kern' 1, + 'liga' 1; + /* Prefer readable CJK metrics over Latin optical sizing */ + text-size-adjust: 100%; + } + + body { + min-height: 100%; + font-family: var(--font-sans); + font-size: 15px; + line-height: 1.7; + letter-spacing: 0.01em; + /* Slightly cooler industrial surface */ + background: var(--color-surface-100); + color: var(--color-surface-950, var(--color-surface-900)); + font-variant-east-asian: proportional-width; + } + + /* CJK headings: no negative tracking, breathing line-height */ + h1, + h2, + h3, + h4, + h5, + h6 { + font-weight: 600; + line-height: 1.45; + letter-spacing: 0; + color: var(--color-surface-950, var(--color-surface-900)); + } + + p { + line-height: 1.75; + } + + /* Harder focus ring for industrial UI */ + :focus-visible { + outline: 2px solid var(--color-primary-600); + outline-offset: 2px; + } + + ::selection { + background: color-mix(in oklab, var(--color-primary-600) 35%, transparent); + color: var(--color-surface-950, var(--color-surface-900)); + } + + /* Tables: high-contrast grid, flat */ + table.data-table { + width: 100%; + border-collapse: collapse; + font-size: 0.875rem; + line-height: 1.6; + } + + table.data-table thead th { + padding: 0.625rem 0.75rem; + text-align: left; + font-weight: 600; + color: var(--color-surface-700); + background: var(--color-surface-100); + border-bottom: 1px solid var(--color-surface-300); + white-space: nowrap; + letter-spacing: 0.02em; + } + + table.data-table tbody td { + padding: 0.75rem; + border-bottom: 1px solid var(--color-surface-200); + vertical-align: middle; + color: var(--color-surface-900); + } + + table.data-table tbody tr:hover td { + background: var(--color-surface-100); + } + + table.data-table tbody tr:last-child td { + border-bottom: none; + } +} + +@layer components { + .saas-card { + border-radius: 0; + border: 1px solid var(--color-surface-300); + background: var(--color-surface-50); + box-shadow: none; + } + + .saas-card-pad { + border-radius: 0; + border: 1px solid var(--color-surface-300); + background: var(--color-surface-50); + box-shadow: none; + padding: 1.25rem; + } + + .saas-page-title { + font-size: 1.375rem; + line-height: 1.4; + font-weight: 700; + letter-spacing: 0; + color: var(--color-surface-950, var(--color-surface-900)); + } + + .saas-section-title { + font-size: 1rem; + line-height: 1.5; + font-weight: 600; + letter-spacing: 0; + color: var(--color-surface-950, var(--color-surface-900)); + } + + .saas-muted { + font-size: 0.875rem; + line-height: 1.65; + color: var(--color-surface-700); + } + + .saas-label { + display: block; + margin-bottom: 0.375rem; + font-size: 0.875rem; + line-height: 1.5; + font-weight: 600; + color: var(--color-surface-800); + } + + .saas-help { + margin-top: 0.375rem; + font-size: 0.8125rem; + line-height: 1.6; + color: var(--color-surface-600); + } + + .saas-toolbar { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.75rem; + margin-bottom: 1.5rem; + } + + .saas-stat { + border-radius: 0; + border: 1px solid var(--color-surface-300); + background: var(--color-surface-50); + padding: 1rem; + box-shadow: none; + } + + .saas-stat-label { + font-size: 0.75rem; + font-weight: 600; + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--color-surface-600); + } + + .saas-stat-value { + margin-top: 0.25rem; + font-size: 1.5rem; + line-height: 1.3; + font-weight: 700; + font-variant-numeric: tabular-nums; + letter-spacing: 0; + color: var(--color-surface-950, var(--color-surface-900)); + } + + .saas-empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 0.5rem; + padding: 3rem 1rem; + text-align: center; + line-height: 1.7; + } + + .saas-shell { + display: flex; + height: 100vh; + overflow: hidden; + background: var(--color-surface-100); + } + + .saas-sidebar { + display: flex; + width: 16rem; + flex-shrink: 0; + flex-direction: column; + border-right: 1px solid var(--color-surface-300); + background: var(--color-surface-50); + } + + .saas-main { + display: flex; + min-width: 0; + flex: 1; + flex-direction: column; + overflow: hidden; + } + + .saas-topbar { + display: flex; + height: 3.5rem; + flex-shrink: 0; + align-items: center; + gap: 0.75rem; + border-bottom: 1px solid var(--color-surface-300); + background: var(--color-surface-50); + padding: 0 1.5rem; + /* flat: no glass */ + backdrop-filter: none; + } + + .saas-content { + flex: 1; + overflow-y: auto; + } + + .saas-content-inner { + margin-inline: auto; + width: 100%; + max-width: 72rem; + padding: 1.5rem; + } + + @media (min-width: 768px) { + .saas-content-inner { + padding: 2rem; + } + } + + .saas-nav-item { + display: flex; + align-items: center; + gap: 0.75rem; + border-radius: 0; + padding: 0.5rem 0.75rem; + font-size: 0.875rem; + line-height: 1.5; + font-weight: 500; + color: var(--color-surface-700); + border-left: 2px solid transparent; + transition: + color 0.1s, + background-color 0.1s, + border-color 0.1s; + } + + .saas-nav-item:hover { + background: var(--color-surface-100); + color: var(--color-surface-950, var(--color-surface-900)); + } + + .saas-nav-item[data-active='true'] { + background: var(--color-primary-50, var(--color-primary-100)); + color: var(--color-primary-800); + border-left-color: var(--color-primary-600); + font-weight: 600; + } + + .saas-badge { + display: inline-flex; + align-items: center; + border-radius: 0; + padding: 0.125rem 0.5rem; + font-size: 0.75rem; + line-height: 1.4; + font-weight: 600; + letter-spacing: 0.02em; + } + + .saas-badge-neutral { + display: inline-flex; + align-items: center; + border-radius: 0; + padding: 0.125rem 0.5rem; + font-size: 0.75rem; + line-height: 1.4; + font-weight: 600; + border: 1px solid var(--color-surface-300); + background: var(--color-surface-100); + color: var(--color-surface-800); + } + + .saas-badge-primary { + display: inline-flex; + align-items: center; + border-radius: 0; + padding: 0.125rem 0.5rem; + font-size: 0.75rem; + line-height: 1.4; + font-weight: 600; + border: 1px solid var(--color-primary-300); + background: var(--color-primary-100); + color: var(--color-primary-800); + } + + .saas-badge-success { + display: inline-flex; + align-items: center; + border-radius: 0; + padding: 0.125rem 0.5rem; + font-size: 0.75rem; + line-height: 1.4; + font-weight: 600; + border: 1px solid var(--color-success-300); + background: var(--color-success-100); + color: var(--color-success-800); + } + + .saas-badge-warning { + display: inline-flex; + align-items: center; + border-radius: 0; + padding: 0.125rem 0.5rem; + font-size: 0.75rem; + line-height: 1.4; + font-weight: 600; + border: 1px solid var(--color-warning-300); + background: var(--color-warning-100); + color: var(--color-warning-900); + } + + .saas-badge-error { + display: inline-flex; + align-items: center; + border-radius: 0; + padding: 0.125rem 0.5rem; + font-size: 0.75rem; + line-height: 1.4; + font-weight: 600; + border: 1px solid var(--color-error-300); + background: var(--color-error-100); + color: var(--color-error-800); + } + + .saas-input, + .saas-select, + .saas-textarea { + width: 100%; + border-radius: 0; + border: 1px solid var(--color-surface-400); + background: var(--color-surface-50); + color: var(--color-surface-950, var(--color-surface-900)); + padding: 0.5rem 0.75rem; + font-size: 0.875rem; + line-height: 1.5; + outline: none; + transition: + border-color 0.1s, + box-shadow 0.1s; + } + + .saas-input::placeholder, + .saas-textarea::placeholder { + color: var(--color-surface-500); + } + + .saas-input:focus, + .saas-select:focus, + .saas-textarea:focus { + border-color: var(--color-primary-600); + box-shadow: inset 0 0 0 1px var(--color-primary-600); + } + + .saas-textarea { + font-family: var(--font-mono); + line-height: 1.55; + resize: vertical; + } + + .saas-select-trigger { + display: inline-flex; + width: 100%; + align-items: center; + border-radius: 0; + border: 1px solid var(--color-surface-400); + background: var(--color-surface-50); + color: var(--color-surface-950, var(--color-surface-900)); + padding: 0.5rem 0.75rem; + font-size: 0.875rem; + line-height: 1.5; + outline: none; + transition: + border-color 0.1s, + box-shadow 0.1s; + cursor: pointer; + text-align: left; + } + + .saas-select-trigger:focus-visible, + .saas-select-trigger[data-state='open'] { + border-color: var(--color-primary-600); + box-shadow: inset 0 0 0 1px var(--color-primary-600); + } + + .saas-select-trigger:disabled, + .saas-select-trigger[data-disabled] { + cursor: not-allowed; + opacity: 0.55; + } + + .saas-select-trigger [data-placeholder] { + color: var(--color-surface-500); + } + + .saas-select-content { + z-index: 70; + max-height: min(18rem, var(--bits-select-content-available-height, 18rem)); + width: var(--bits-select-anchor-width); + min-width: var(--bits-select-anchor-width); + overflow: hidden; + border-radius: 0; + border: 1px solid var(--color-surface-400); + background: var(--color-surface-50); + box-shadow: 4px 4px 0 rgb(15 23 42 / 0.12); + outline: none; + } + + .saas-select-item { + display: flex; + align-items: center; + border-radius: 0; + padding: 0.45rem 0.65rem; + font-size: 0.875rem; + line-height: 1.5; + color: var(--color-surface-900); + cursor: pointer; + outline: none; + user-select: none; + } + + .saas-select-item[data-highlighted] { + background: var(--color-primary-100); + color: var(--color-primary-900); + } + + .saas-select-item[data-selected] { + color: var(--color-primary-900); + font-weight: 600; + } + + .saas-select-item[data-disabled] { + cursor: not-allowed; + opacity: 0.45; + } + + .saas-btn-primary, + .saas-btn-secondary, + .saas-btn-ghost, + .saas-btn-danger { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.375rem; + border-radius: 0; + padding: 0.5rem 0.875rem; + font-size: 0.875rem; + font-weight: 600; + line-height: 1.4; + letter-spacing: 0.01em; + border: 1px solid transparent; + cursor: pointer; + transition: + background-color 0.1s, + color 0.1s, + border-color 0.1s, + opacity 0.1s; + } + + .saas-btn-primary:disabled, + .saas-btn-secondary:disabled, + .saas-btn-ghost:disabled, + .saas-btn-danger:disabled { + opacity: 0.55; + cursor: not-allowed; + } + + .saas-btn-primary { + background: var(--color-primary-600); + border-color: var(--color-primary-700); + color: var(--color-primary-contrast-500, white); + } + + .saas-btn-primary:hover:not(:disabled) { + background: var(--color-primary-700); + border-color: var(--color-primary-800); + } + + .saas-btn-secondary { + background: var(--color-surface-100); + border-color: var(--color-surface-400); + color: var(--color-surface-900); + } + + .saas-btn-secondary:hover:not(:disabled) { + background: var(--color-surface-200); + border-color: var(--color-surface-500); + } + + .saas-btn-ghost { + background: transparent; + border-color: transparent; + color: var(--color-surface-800); + } + + .saas-btn-ghost:hover:not(:disabled) { + background: var(--color-surface-200); + color: var(--color-surface-950, var(--color-surface-900)); + } + + .saas-btn-danger { + background: var(--color-error-100); + border-color: var(--color-error-400); + color: var(--color-error-800); + } + + .saas-btn-danger:hover:not(:disabled) { + background: var(--color-error-200); + border-color: var(--color-error-500); + } + + .saas-checkbox { + display: inline-flex; + align-items: center; + justify-content: center; + width: 1.1rem; + height: 1.1rem; + flex-shrink: 0; + border-radius: 0; + border: 1px solid var(--color-surface-500); + background: var(--color-surface-50); + color: white; + cursor: pointer; + transition: + background 0.1s, + border-color 0.1s; + } + + .saas-checkbox[data-state='checked'] { + background: var(--color-primary-700); + border-color: var(--color-primary-700); + } + + .saas-checkbox:focus-visible { + outline: none; + box-shadow: + 0 0 0 2px var(--color-surface-50), + 0 0 0 4px var(--color-primary-600); + } + + .saas-checkbox[data-disabled] { + cursor: not-allowed; + opacity: 0.5; + } + + /* Square industrial switch (no pill) */ + .saas-switch { + position: relative; + display: inline-flex; + width: 2.5rem; + height: 1.35rem; + flex-shrink: 0; + align-items: center; + border-radius: 0; + border: 1px solid var(--color-surface-500); + background: var(--color-surface-300); + padding: 0.125rem; + cursor: pointer; + transition: + background 0.1s, + border-color 0.1s; + } + + .saas-switch[data-state='checked'] { + background: var(--color-primary-600); + border-color: var(--color-primary-700); + } + + .saas-switch:focus-visible { + outline: none; + box-shadow: + 0 0 0 2px var(--color-surface-50), + 0 0 0 4px var(--color-primary-600); + } + + .saas-switch[data-disabled] { + cursor: not-allowed; + opacity: 0.5; + } + + .saas-switch-thumb { + display: block; + width: 1rem; + height: 1rem; + border-radius: 0; + background: white; + border: 1px solid var(--color-surface-400); + box-shadow: none; + transition: transform 0.1s; + transform: translateX(0); + } + + .saas-switch[data-state='checked'] .saas-switch-thumb, + .saas-switch-thumb[data-state='checked'] { + transform: translateX(1.1rem); + border-color: var(--color-primary-800); + } + + .saas-modal-backdrop { + position: fixed; + inset: 0; + z-index: 50; + background: rgb(2 6 23 / 0.55); + backdrop-filter: none; + } + + .saas-modal { + position: fixed; + left: 50%; + top: 50%; + z-index: 51; + width: calc(100% - 2rem); + max-width: 28rem; + transform: translate(-50%, -50%); + border-radius: 0; + border: 1px solid var(--color-surface-400); + background: var(--color-surface-50); + padding: 1.5rem; + box-shadow: 6px 6px 0 rgb(15 23 42 / 0.15); + outline: none; + } + + .saas-status-panel { + display: flex; + min-height: 100vh; + flex-direction: column; + align-items: center; + justify-content: center; + background: var(--color-surface-100); + padding: 1rem; + } + + .saas-status-card { + width: 100%; + max-width: 28rem; + border-radius: 0; + border: 1px solid var(--color-surface-400); + background: var(--color-surface-50); + padding: 2rem; + text-align: center; + box-shadow: 4px 4px 0 rgb(15 23 42 / 0.1); + line-height: 1.7; + } +} diff --git a/hub/admin-web/static/favicon.svg b/hub/admin-web/static/favicon.svg new file mode 100644 index 0000000..43bc66d --- /dev/null +++ b/hub/admin-web/static/favicon.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/hub/admin-web/static/robots.txt b/hub/admin-web/static/robots.txt new file mode 100644 index 0000000..b6dd667 --- /dev/null +++ b/hub/admin-web/static/robots.txt @@ -0,0 +1,3 @@ +# allow crawling everything by default +User-agent: * +Disallow: diff --git a/hub/admin-web/svelte.config.js b/hub/admin-web/svelte.config.js new file mode 100644 index 0000000..230ed03 --- /dev/null +++ b/hub/admin-web/svelte.config.js @@ -0,0 +1,18 @@ +import adapter from '@sveltejs/adapter-static'; +import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; + +/** @type {import('@sveltejs/kit').Config} */ +const config = { + preprocess: vitePreprocess(), + kit: { + adapter: adapter({ + pages: 'build', + assets: 'build', + fallback: 'index.html', + precompress: false, + strict: false, + }), + }, +}; + +export default config; diff --git a/hub/admin-web/tsconfig.json b/hub/admin-web/tsconfig.json new file mode 100644 index 0000000..2c2ed3c --- /dev/null +++ b/hub/admin-web/tsconfig.json @@ -0,0 +1,20 @@ +{ + "extends": "./.svelte-kit/tsconfig.json", + "compilerOptions": { + "rewriteRelativeImportExtensions": true, + "allowJs": true, + "checkJs": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "sourceMap": true, + "strict": true, + "moduleResolution": "bundler" + } + // Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias + // except $lib which is handled by https://svelte.dev/docs/kit/configuration#files + // + // To make changes to top-level options such as include and exclude, we recommend extending + // the generated config; see https://svelte.dev/docs/kit/configuration#typescript +} diff --git a/hub/admin-web/vite.config.ts b/hub/admin-web/vite.config.ts new file mode 100644 index 0000000..6134e79 --- /dev/null +++ b/hub/admin-web/vite.config.ts @@ -0,0 +1,13 @@ +import { sveltekit } from '@sveltejs/kit/vite'; +import tailwindcss from '@tailwindcss/vite'; +import { defineConfig } from 'vite'; + +export default defineConfig({ + plugins: [tailwindcss(), sveltekit()], + server: { + proxy: { + '/api': 'http://127.0.0.1:8788', + '/auth': 'http://127.0.0.1:8788', + }, + }, +}); diff --git a/hub/deploy/README.md b/hub/deploy/README.md index 4e766e9..9bffc37 100644 --- a/hub/deploy/README.md +++ b/hub/deploy/README.md @@ -11,6 +11,32 @@ See [NEW_SILO_RUNBOOK.md](NEW_SILO_RUNBOOK.md). The remainder of this document describes the individual installer and maintenance primitives used by the generated bundle. +## CI: roll Hub + admin SPA by fleet (`educraft` / `educraft-dev`) + +`.gitea/workflows/deploy-admin.yml` deploys Hub releases (including the org-admin +SPA) to the managed Alpha host via `deploy_fleet_release.sh`. Fleets share the +host and are selected by the middle DNS label of `HUB_PUBLIC_BASE_URL`: + +| Fleet | Domain | CI trigger | +|-------|--------|------------| +| **dev** | `https://.educraft-dev.paradigm-edu.net` | every push + PR (paths under `hub/`) | +| **prod** | `https://.educraft.paradigm-edu.net` | push to `main` + tags | + +Example prod tenant: +`https://para-26071100.educraft.paradigm-edu.net/auth/feishu/para-26071100`. + +Required Gitea secret: `DEPLOY_SSH_KEY` (private key for `root@39.107.254.4`). +Optional: `DEPLOY_HOST` / `DEPLOY_USER` / `DEPLOY_SSH_PORT` / `DEPLOY_BASE`. +Set repository var `ALLOW_EMPTY_FLEET=1` until the first silo exists for a fleet. + +Local dry-run against the host: + +```sh +CPH_FLEET=dev \ + PLATFORM_DEPLOY_SSH_KEY=$HOME/.ssh/id_ed25519 \ + bash hub/deploy/deploy_fleet_release.sh +``` + The supervised alpha runs one Organization per named Silo. The supported host has systemd, PostgreSQL, Node.js 24+, `pg_isready`, `runuser`, `setpriv`, bubblewrap, `socat`, `pg_dump`, `tar`, `sha256sum`, and a compatible `cph`. diff --git a/hub/deploy/deploy_fleet_release.sh b/hub/deploy/deploy_fleet_release.sh new file mode 100755 index 0000000..ea88b3a --- /dev/null +++ b/hub/deploy/deploy_fleet_release.sh @@ -0,0 +1,222 @@ +#!/usr/bin/env bash +# Publish one immutable Hub release (tsc + admin-web SPA) to the managed host, +# then repoint every Silo in the chosen fleet to that release and restart it. +# +# Fleet is selected by the middle DNS label of each Silo's HUB_PUBLIC_BASE_URL: +# +# prod → https://.educraft.paradigm-edu.net +# dev → https://.educraft-dev.paradigm-edu.net +# +# Example tenant (prod): +# https://para-26071100.educraft.paradigm-edu.net/auth/feishu/para-26071100 +# +# Configure (CI secrets / env): +# CPH_FLEET required: dev | prod +# PLATFORM_DEPLOY_HOST default 39.107.254.4 +# PLATFORM_DEPLOY_USER default root +# PLATFORM_DEPLOY_SSH_KEY required (path to private key) +# PLATFORM_DEPLOY_PORT default 22 +# PLATFORM_DEPLOY_BASE default /srv/curriculum-project-hub +# PLATFORM_DEPLOY_RELEASE default git HEAD (must be safe for paths) +# ALLOW_EMPTY_FLEET optional: if 1, succeed when no silo matches +# +# This is a code-roll for existing silos. It does not create databases, domains, +# or Feishu apps — use new_silo.sh / apply_new_silo.sh for that. +set -euo pipefail + +FLEET="${CPH_FLEET:?CPH_FLEET required (dev|prod)}" +case "$FLEET" in + dev) FLEET_DOMAIN_SUFFIX="educraft-dev.paradigm-edu.net" ;; + prod) FLEET_DOMAIN_SUFFIX="educraft.paradigm-edu.net" ;; + *) + echo "CPH_FLEET must be dev or prod, got: $FLEET" >&2 + exit 1 + ;; +esac + +HOST="${PLATFORM_DEPLOY_HOST:-39.107.254.4}" +DEPLOY_USER="${PLATFORM_DEPLOY_USER:-root}" +PORT="${PLATFORM_DEPLOY_PORT:-22}" +SSH_KEY="${PLATFORM_DEPLOY_SSH_KEY:?PLATFORM_DEPLOY_SSH_KEY required}" +BASE="${PLATFORM_DEPLOY_BASE:-/srv/curriculum-project-hub}" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" +RELEASE_ID="${PLATFORM_DEPLOY_RELEASE:-$(git -C "$REPO_ROOT" rev-parse --verify HEAD)}" +[[ "$RELEASE_ID" =~ ^[A-Za-z0-9._-]+$ ]] || { + echo "invalid PLATFORM_DEPLOY_RELEASE: $RELEASE_ID" >&2 + exit 1 +} +ALLOW_EMPTY_FLEET="${ALLOW_EMPTY_FLEET:-0}" + +HUB_DIR="$BASE/releases/$RELEASE_ID/hub" +RELEASE_DIR="$BASE/releases/$RELEASE_ID" +SSH_OPTS=(-i "$SSH_KEY" -p "$PORT" -o BatchMode=yes -o StrictHostKeyChecking=accept-new) + +echo "[fleet] fleet=$FLEET domain=*.$FLEET_DOMAIN_SUFFIX host=$DEPLOY_USER@$HOST release=$RELEASE_ID" + +# --- 1. Publish immutable release (shared; flock so parallel fleet jobs do not race) --- +release_ready=false +if ssh "${SSH_OPTS[@]}" "$DEPLOY_USER@$HOST" "test -f '$RELEASE_DIR/.complete'"; then + release_ready=true + echo "[fleet] release already complete: $RELEASE_DIR" +fi + +if [ "$release_ready" = false ]; then + # Drop a prior incomplete tree for this release id (failed CI leave leftovers). + ssh "${SSH_OPTS[@]}" "$DEPLOY_USER@$HOST" bash -s <&2 + continue + fi + + # Prefer unit file ceilings (MemoryMax=16G) over systemctl's byte form. + memory_max="$(sed -n 's/^MemoryMax=//p' "/etc/systemd/system/$unit" | tail -n1)" + cpu_quota="$(sed -n 's/^CPUQuota=//p' "/etc/systemd/system/$unit" | tail -n1)" + tasks_max="$(sed -n 's/^TasksMax=//p' "/etc/systemd/system/$unit" | tail -n1)" + + if [ -z "$port" ] || [ -z "$workspace" ] || [ -z "$memory_max" ] || [ -z "$cpu_quota" ] || [ -z "$tasks_max" ]; then + echo "[fleet] error: $instance_id missing PORT/workspace/ceilings (port=$port workspace=$workspace memory=$memory_max cpu=$cpu_quota tasks=$tasks_max)" >&2 + exit 1 + fi + + echo "[fleet] roll $instance_id → $HUB_DIR ($public_url port=$port)" + BASE="$BASE" \ + HUB_DIR="$HUB_DIR" \ + INSTANCE_ID="$instance_id" \ + WORKSPACE_ROOT="$workspace" \ + PORT="$port" \ + MEMORY_MAX="$memory_max" \ + CPU_QUOTA="$cpu_quota" \ + TASKS_MAX="$tasks_max" \ + bash "$HUB_DIR/deploy/install_service.sh" + + systemctl restart "$unit" + systemctl is-active --quiet "$unit" + + health="http://127.0.0.1:${port}/api/healthz" + ok=false + for _ in $(seq 1 45); do + if curl --fail --silent "$health" >/dev/null 2>&1; then + ok=true + break + fi + sleep 1 + done + if [ "$ok" != true ]; then + curl --fail --silent --show-error "$health" >/dev/null + fi + echo "[fleet] healthy $instance_id ($health)" + matched=$((matched + 1)) +done + +if [ "$matched" -eq 0 ]; then + msg="[fleet] no silos matched *.$FLEET_DOMAIN_SUFFIX under $BASE/.secrets" + if [ "$ALLOW_EMPTY_FLEET" = "1" ]; then + echo "$msg (ALLOW_EMPTY_FLEET=1)" + exit 0 + fi + echo "$msg" >&2 + exit 1 +fi + +echo "[fleet] rolled $matched silo(s) on $FLEET" +REMOTE + +echo "[fleet] done fleet=$FLEET release=$RELEASE_ID hub=$HUB_DIR" diff --git a/hub/deploy/deploy_platform.sh b/hub/deploy/deploy_platform.sh index 01bebf5..80a7a16 100755 --- a/hub/deploy/deploy_platform.sh +++ b/hub/deploy/deploy_platform.sh @@ -60,9 +60,10 @@ if [ "$release_ready" = false ]; then -e "ssh ${SSH_OPTS[*]}" \ "$REPO_ROOT/hub/" "$DEPLOY_USER@$HOST:$HUB_DIR/" - # 2. Install deps, audit and build, then atomically mark the release complete. + # 2. Install deps (hub + admin-web), audit hub prod, build tsc + SPA, mark complete. + # `npm run build` → tsc then admin:build → admin-web/build for registerStaticSpa. ssh "${SSH_OPTS[@]}" "$DEPLOY_USER@$HOST" \ - "cd '$HUB_DIR' && npm ci && npm run audit:production && npm run build && touch '$RELEASE_DIR/.complete'" + "cd '$HUB_DIR' && npm ci && npm ci --prefix admin-web && npm run audit:production && npm run build && touch '$RELEASE_DIR/.complete'" fi # 3. Ensure the service is installed (idempotent), then restart. diff --git a/hub/package-lock.json b/hub/package-lock.json index 893906b..ca04c66 100644 --- a/hub/package-lock.json +++ b/hub/package-lock.json @@ -131,9 +131,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "SEE LICENSE IN LICENSE.md", "optional": true, "os": [ @@ -147,9 +144,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "SEE LICENSE IN LICENSE.md", "optional": true, "os": [ @@ -163,9 +157,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "SEE LICENSE IN LICENSE.md", "optional": true, "os": [ @@ -179,9 +170,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "SEE LICENSE IN LICENSE.md", "optional": true, "os": [ @@ -247,24 +235,26 @@ } }, "node_modules/@emnapi/core": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", - "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", + "version": "1.11.2", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.2.tgz", + "integrity": "sha512-TC8MkTuZUtcTSiFeuC0ksCh9QIJ5+F21MvZ4Wn4ORfYaFJ/0dsiudv5tVkejgwZlwQ39jL9WWDe2lz8x0WglOA==", "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" } }, "node_modules/@emnapi/runtime": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", - "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", + "version": "1.11.2", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.2.tgz", + "integrity": "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==", "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "tslib": "^2.4.0" } @@ -1199,9 +1189,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1219,9 +1206,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1239,9 +1223,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1259,9 +1240,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1279,9 +1257,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1299,9 +1274,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1347,6 +1319,29 @@ "node": "^20.19.0 || >=22.12.0" } }, + "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.11.1", + "resolved": "https://registry.npmmirror.com/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.11.1", + "resolved": "https://registry.npmmirror.com/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@rolldown/binding-win32-arm64-msvc": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.4.tgz", @@ -3157,9 +3152,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -3181,9 +3173,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -3205,9 +3194,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -3229,9 +3215,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ diff --git a/hub/package.json b/hub/package.json index 03fbed8..f21b044 100644 --- a/hub/package.json +++ b/hub/package.json @@ -30,19 +30,21 @@ "description": "Curriculum Project Hub — org-scoped Feishu collaboration and confined Agent runtime. Aligns to spec/System through ADR-0024.", "scripts": { "dev": "npm run prisma:migrate && tsx watch src/server.ts", - "build": "tsc -p tsconfig.json", + "build": "tsc -p tsconfig.json && npm run admin:build", "start": "npm run prisma:migrate && node dist/server.js", "check": "tsc -p tsconfig.json --noEmit", "audit:production": "npm audit --omit=dev --audit-level=high", "prisma:generate": "prisma generate --schema prisma/schema.prisma", - "prisma:validate": "DATABASE_URL=${DATABASE_URL:-postgresql://stub:stub@127.0.0.1:5432/stub} prisma validate --schema prisma/schema.prisma", - "prisma:migrate": "DATABASE_URL=${DATABASE_URL:-postgresql://paradigm:paradigm@127.0.0.1:5432/paradigm} prisma migrate deploy --schema prisma/schema.prisma", + "prisma:validate": "prisma validate --schema prisma/schema.prisma", + "prisma:migrate": "prisma migrate deploy --schema prisma/schema.prisma", "secrets:rotate-kek": "node dist/deployment/rotate-secret-kek.js", "agent-config": "node dist/deployment/agent-config-cli.js", "silo:bootstrap": "node dist/deployment/bootstrap-silo-cli.js", "silo:restore-preflight": "node dist/deployment/restore-preflight.js", "deploy": "bash deploy/deploy_platform.sh", "test": "vitest run", - "test:watch": "vitest" + "test:watch": "vitest", + "admin:dev": "npm run dev --prefix admin-web", + "admin:build": "npm run build --prefix admin-web" } } diff --git a/hub/prisma/migrations/20260714120000_drop_legacy_platform_role_assignment/migration.sql b/hub/prisma/migrations/20260714120000_drop_legacy_platform_role_assignment/migration.sql new file mode 100644 index 0000000..ed23cb6 --- /dev/null +++ b/hub/prisma/migrations/20260714120000_drop_legacy_platform_role_assignment/migration.sql @@ -0,0 +1,20 @@ +-- ADR-0023 rejected the legacy `PlatformRoleAssignment` / `PlatformRole`{ADMIN,TEACHER} +-- model: the platform administration control plane is a separate identity/session/ +-- audit surface (see `Spec.System.PlatformAdministration`), intentionally not built +-- in alpha (ADR-0025, `hub/deploy/README.md`). The legacy table has no runtime +-- reader — no guard, route, or service queries it for an authorization decision — +-- and ADR-0023 requires it to be migrated/replaced before the platform panel ships. +-- Drop the table, the `User.platformRoles` relation, and the enum. + +-- DropForeignKey +ALTER TABLE "PlatformRoleAssignment" DROP CONSTRAINT "PlatformRoleAssignment_userId_fkey"; + +-- DropIndex +DROP INDEX IF EXISTS "PlatformRoleAssignment_userId_revokedAt_idx"; +DROP INDEX IF EXISTS "PlatformRoleAssignment_role_revokedAt_idx"; + +-- DropTable +DROP TABLE IF EXISTS "PlatformRoleAssignment"; + +-- DropEnum +DROP TYPE IF EXISTS "PlatformRole"; diff --git a/hub/prisma/migrations/20260714130000_organization_capacity_policy/migration.sql b/hub/prisma/migrations/20260714130000_organization_capacity_policy/migration.sql new file mode 100644 index 0000000..61be42a --- /dev/null +++ b/hub/prisma/migrations/20260714130000_organization_capacity_policy/migration.sql @@ -0,0 +1,18 @@ +-- ADR-0022 layered capacity policy. Org admins may set per-dimension lower +-- `organizationLimit` overrides; platform ceilings come from deployment config +-- (see `src/capacity/ceilings.ts`). `limits` is a JSON map of +-- CapacityDimension → number (only the set dimensions); service enforces +-- `LayeredLimit.Valid` (org limit ≤ platform ceiling). + +-- CreateTable +CREATE TABLE "OrganizationCapacityPolicy" ( + "organizationId" TEXT NOT NULL, + "limits" JSONB NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "OrganizationCapacityPolicy_pkey" PRIMARY KEY ("organizationId") +); + +-- AddForeignKey +ALTER TABLE "OrganizationCapacityPolicy" ADD CONSTRAINT "OrganizationCapacityPolicy_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/hub/prisma/schema.prisma b/hub/prisma/schema.prisma index b7707e8..e5f080d 100644 --- a/hub/prisma/schema.prisma +++ b/hub/prisma/schema.prisma @@ -37,6 +37,7 @@ model Organization { memberships OrganizationMembership[] projectSettings OrganizationProjectSettings? + capacityPolicy OrganizationCapacityPolicy? folders Folder[] projects Project[] teams Team[] @@ -58,8 +59,9 @@ enum OrganizationStatus { ARCHIVED } -/// Org-scoped platform role. Distinct from project PermissionRole and from -/// global PlatformRoleAssignment, which is reserved for SaaS/platform control. +/// Org-scoped membership role. Distinct from project PermissionRole and from +/// the platform administrator surface (ADR-0023 / Spec.System.PlatformAdministration), +/// which is a separate control plane not modeled in alpha (ADR-0025). model OrganizationMembership { id String @id @default(cuid()) organizationId String @@ -159,6 +161,20 @@ model OrganizationProjectSettings { organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) } +/// ADR-0022 layered capacity policy. Org admins may set per-dimension lower +/// limits; `limits` is a JSON map of CapacityDimension → number (only the set +/// ones). Each set value must be ≤ the platform ceiling for that dimension +/// (validated in service; spec `LayeredLimit.Valid`). Dimensions with no entry +/// fall back to the platform ceiling (`LayeredLimit.effective`). +model OrganizationCapacityPolicy { + organizationId String @id + limits Json + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) +} + /// A person known to the Hub. `feishuOpenId` remains a legacy compatibility /// key; new customer-app identities live in FeishuUserIdentity and store a /// connection-scoped opaque USER principal here instead of a raw open_id. @@ -170,7 +186,6 @@ model User { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - platformRoles PlatformRoleAssignment[] organizationMemberships OrganizationMembership[] createdProjects Project[] @relation("projectCreator") requestedRuns AgentRun[] @relation("runRequester") @@ -311,26 +326,6 @@ model ProviderCredentialVersion { @@index([createdByUserId]) } -/// Platform-level role (admin/teacher). Distinct from ADR-0004 PermissionRole. -/// `admin` is the only override path for force-release (spec RequiresAdmin). -model PlatformRoleAssignment { - id String @id @default(cuid()) - userId String - role PlatformRole - createdAt DateTime @default(now()) - revokedAt DateTime? - - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - - @@index([userId, revokedAt]) - @@index([role, revokedAt]) -} - -enum PlatformRole { - ADMIN - TEACHER -} - /// ADR-0019: typed principals for permission grants and actor resolution. /// USER and Feishu external principals use connection-scoped opaque ids, never /// raw provider-local ids; TEAM uses Hub Team.id. @@ -647,8 +642,9 @@ model ProjectAgentLock { // --- Permission grants & settings (ADR-0004) ---------------------------- /// ADR-0004 PermissionRole: read ⊂ edit ⊂ manage (capability lattice). -/// Distinct from PlatformRole. Force-release is admin-only, outside this -/// lattice (spec RequiresAdmin). +/// Force-release is platform-admin-only, outside this lattice (spec +/// RequiresAdmin); platform admin is a separate control plane (ADR-0023), +/// not modeled in alpha (ADR-0025). enum PermissionRole { READ EDIT diff --git a/hub/scripts/dev-bootstrap-silo.ts b/hub/scripts/dev-bootstrap-silo.ts new file mode 100644 index 0000000..d0f74bd --- /dev/null +++ b/hub/scripts/dev-bootstrap-silo.ts @@ -0,0 +1,76 @@ +/** + * Local dev bootstrap for an Alpha Silo on Windows / non-systemd hosts. + * + * The production bootstrap-silo CLI is Linux-only (requires root uid 0, + * systemctl, root-owned files). This script calls the same + * `bootstrapAlphaSilo` invariants directly, sourcing credentials from the + * local `.env` and the dev keyring. Idempotent: re-running is a no-op once + * the Silo Organization exists. + * + * Usage: npx tsx scripts/dev-bootstrap-silo.ts + */ +import "dotenv/config"; +import { PrismaClient } from "@prisma/client"; +import { bootstrapAlphaSilo } from "../src/deployment/bootstrap-silo.js"; +import { loadLocalSecretKeyring, LocalSecretEnvelope } from "../src/security/secretEnvelope.js"; + +function requiredEnv(name: string): string { + const v = process.env[name]?.trim(); + if (v === undefined || v === "") throw new Error(`missing required env: ${name}`); + return v; +} + +async function main(): Promise { + const databaseUrl = requiredEnv("DATABASE_URL"); + const keyring = await loadLocalSecretKeyring(); + const secrets = new LocalSecretEnvelope(keyring); + const prisma = new PrismaClient({ datasources: { db: { url: databaseUrl } }, log: [] }); + + const organizationId = requiredEnv("HUB_SILO_ORGANIZATION_ID"); + const feishuAppId = requiredEnv("FEISHU_APP_ID"); + const feishuAppSecret = requiredEnv("FEISHU_APP_SECRET"); + const feishuBotOpenId = requiredEnv("FEISHU_BOT_OPEN_ID"); + const providerAuthToken = requiredEnv("ANTHROPIC_AUTH_TOKEN"); + const providerBaseUrl = process.env["ANTHROPIC_BASE_URL"]?.trim() || "https://openrouter.ai/api"; + const anthropicApiKey = process.env["ANTHROPIC_API_KEY"]?.trim() || ""; + + try { + const result = await bootstrapAlphaSilo( + prisma, + secrets, + { + organization: { + id: organizationId, + slug: "local-dev", + name: "Local Dev Silo", + }, + owner: { + openId: feishuBotOpenId, + displayName: "Local Dev Owner", + }, + feishu: { + appId: feishuAppId, + appSecret: feishuAppSecret, + botOpenId: feishuBotOpenId, + }, + provider: { + providerId: "openrouter", + baseUrl: providerBaseUrl, + authToken: providerAuthToken, + ...(anthropicApiKey !== "" ? { anthropicApiKey } : {}), + }, + }, + // Skip live network probes in local dev — Feishu/OpenRouter reachability + // is not required to seed the encrypted envelope rows. + { feishu: async () => {}, provider: async () => {} }, + ); + console.log("bootstrap result:", JSON.stringify(result, null, 2)); + } finally { + await prisma.$disconnect(); + } +} + +main().catch((error: unknown) => { + console.error("[dev-bootstrap-silo] failed:", error instanceof Error ? error.message : String(error)); + process.exitCode = 1; +}); diff --git a/hub/scripts/dev-seed-connections.ts b/hub/scripts/dev-seed-connections.ts new file mode 100644 index 0000000..158a098 --- /dev/null +++ b/hub/scripts/dev-seed-connections.ts @@ -0,0 +1,84 @@ +/** + * Local dev seed for an existing Organization that predates the ADR-0024 + * secret-envelope plane (e.g. the legacy `org_default` from the tenant-root + * migration). Creates ACTIVE Feishu Application + Provider (BYOK openrouter) + * connections with encrypted envelopes, using the local dev keyring. + * + * Idempotent: re-running rotates a new secret version if the connection + * already exists. + * + * Usage: npx tsx scripts/dev-seed-connections.ts + */ +import "dotenv/config"; +import { PrismaClient } from "@prisma/client"; +import { FeishuApplicationConnectionService } from "../src/connections/feishuApplicationConnections.js"; +import { ProviderConnectionService } from "../src/connections/providerConnections.js"; +import { loadLocalSecretKeyring, LocalSecretEnvelope } from "../src/security/secretEnvelope.js"; + +function requiredEnv(name: string): string { + const v = process.env[name]?.trim(); + if (v === undefined || v === "") throw new Error(`missing required env: ${name}`); + return v; +} + +async function main(): Promise { + const databaseUrl = requiredEnv("DATABASE_URL"); + const organizationId = requiredEnv("HUB_SILO_ORGANIZATION_ID"); + const keyring = await loadLocalSecretKeyring(); + const secrets = new LocalSecretEnvelope(keyring); + const prisma = new PrismaClient({ datasources: { db: { url: databaseUrl } }, log: [] }); + + const feishuAppId = requiredEnv("FEISHU_APP_ID"); + const feishuAppSecret = requiredEnv("FEISHU_APP_SECRET"); + const feishuBotOpenId = requiredEnv("FEISHU_BOT_OPEN_ID"); + const providerAuthToken = requiredEnv("ANTHROPIC_AUTH_TOKEN"); + const providerBaseUrl = process.env["ANTHROPIC_BASE_URL"]?.trim() || "https://openrouter.ai/api"; + const anthropicApiKey = process.env["ANTHROPIC_API_KEY"]?.trim() || ""; + + // Pick an existing active OWNER/ADMIN as the actor for the audit rows. + const actor = await prisma.organizationMembership.findFirst({ + where: { organizationId, role: { in: ["OWNER", "ADMIN"] }, revokedAt: null }, + select: { userId: true }, + }); + if (actor === null) throw new Error(`no active OWNER/ADMIN membership on ${organizationId}; seed a member first`); + const actorUserId = actor.userId; + console.log("actor:", actorUserId); + + try { + const feishuService = new FeishuApplicationConnectionService( + prisma, + secrets, + async () => {}, // skip live Feishu probe in local dev + ); + const feishuResult = await feishuService.rotateCustomerApplication({ + organizationId, + actorUserId, + appId: feishuAppId, + appSecret: feishuAppSecret, + botOpenId: feishuBotOpenId, + }); + console.log("feishu connection:", JSON.stringify(feishuResult, null, 2)); + + const providerService = new ProviderConnectionService( + prisma, + secrets, + async () => {}, // skip live OpenRouter probe in local dev + ); + const providerResult = await providerService.rotateByok({ + organizationId, + actorUserId, + providerId: "openrouter", + baseUrl: providerBaseUrl, + authToken: providerAuthToken, + ...(anthropicApiKey !== "" ? { anthropicApiKey } : {}), + }); + console.log("provider connection:", JSON.stringify(providerResult, null, 2)); + } finally { + await prisma.$disconnect(); + } +} + +main().catch((error: unknown) => { + console.error("[dev-seed-connections] failed:", error instanceof Error ? error.message : String(error)); + process.exitCode = 1; +}); diff --git a/hub/src/admin/auth/guards.ts b/hub/src/admin/auth/guards.ts index 3917561..b79b1ca 100644 --- a/hub/src/admin/auth/guards.ts +++ b/hub/src/admin/auth/guards.ts @@ -10,8 +10,10 @@ import { verifySession, type SessionPayload, } from "./session.js"; +import { createPermissionAuthorizer, type AuthorizationAction } from "../../permissions/authorizer.js"; export const ORG_ADMIN_ROLES: readonly OrganizationMemberRole[] = ["OWNER", "ADMIN"]; +const ANY_ORG_ROLE: readonly OrganizationMemberRole[] = ["OWNER", "ADMIN", "MEMBER"]; export interface AuthContext { readonly session: SessionPayload; @@ -175,3 +177,55 @@ export async function sendError( ): Promise { await reply.status(statusCode).send({ error: { code, message } }); } + +export interface ProjectAuthContext extends OrgAuthContext { + readonly projectId: string; +} + +/** + * Resolve an org member (any role) viewing/mutating a project in their org, then + * enforce project-level permission via the PermissionGrant authorizer. + * + * `allowOrgAdminOversight=true` lets org OWNER/ADMIN through without a project + * grant — reserved for *read* oversight (listing/viewing). Mutations that the + * spec pins to project `manage` (e.g. `collaborator.manage`) must pass + * `allowOrgAdminOversight=false`: org role alone is not a project authorization + * root (spec `Permission.lean` / ADR-0004; the only out-of-role override is + * platform-admin force-release `RequiresAdmin`, not org admin). + */ +export async function requireProjectPermission( + request: FastifyRequest, + reply: FastifyReply, + deps: GuardDeps, + options: { + readonly orgSlug: string; + readonly projectId: string; + readonly action: AuthorizationAction; + readonly allowOrgAdminOversight: boolean; + }, +): Promise { + const auth = await requireOrgRole(request, reply, deps, { + orgSlug: options.orgSlug, + roles: ANY_ORG_ROLE, + }); + if (auth === null) return null; + await requireOrgProject(deps, auth.organization.id, options.projectId); + if (options.allowOrgAdminOversight && ORG_ADMIN_ROLES.includes(auth.membershipRole)) { + return { ...auth, projectId: options.projectId }; + } + const decision = await createPermissionAuthorizer(deps.prisma).can({ + actor: { feishuOpenId: auth.feishuOpenId }, + action: options.action, + resource: { type: "PROJECT", id: options.projectId }, + }); + if (!decision.allowed) { + await sendError( + reply, + 403, + "forbidden", + `project ${options.projectId} requires ${decision.requiredRole} (${options.action}): ${decision.reason}`, + ); + return null; + } + return { ...auth, projectId: options.projectId }; +} diff --git a/hub/src/admin/errors.ts b/hub/src/admin/errors.ts index 01ecf26..ee9fbb7 100644 --- a/hub/src/admin/errors.ts +++ b/hub/src/admin/errors.ts @@ -35,9 +35,11 @@ export async function handleRouteError(reply: FastifyReply, err: unknown): Promi await sendError(reply, mapped.statusCode, mapped.code, mapped.message); return; } + reply.log.error({ err }, "unmapped route error"); await sendError(reply, 500, "internal_error", "internal error"); return; } + reply.log.error({ err }, "unmapped route error"); await sendError(reply, 500, "internal_error", "internal error"); } @@ -71,7 +73,8 @@ function mapDomainError(message: string): { statusCode: number; code: string; me lower.includes("is required") || lower.includes("already") || lower.includes("invalid") || - lower.includes("accepts only") + lower.includes("accepts only") || + lower.includes("must be") ) { return { statusCode: 400, code: "bad_request", message }; } diff --git a/hub/src/admin/plugin.ts b/hub/src/admin/plugin.ts index 5115942..744600c 100644 --- a/hub/src/admin/plugin.ts +++ b/hub/src/admin/plugin.ts @@ -1,11 +1,12 @@ /** - * Registers org-admin HTTP surface: auth, org APIs, (later) static SPA. + * Registers org-admin HTTP surface: auth, org APIs, and the static SPA shell. */ import cookie from "@fastify/cookie"; import type { PrismaClient } from "@prisma/client"; import type { FastifyInstance } from "fastify"; import { registerAuthRoutes } from "./routes/authRoutes.js"; import { registerOrgRoutes } from "./routes/orgRoutes.js"; +import { registerStaticSpa } from "./static.js"; import type { LocalSecretEnvelope } from "../security/secretEnvelope.js"; import type { ProviderReadinessProbe } from "../connections/providerReadiness.js"; import type { FeishuReadinessProbe } from "../connections/feishuReadiness.js"; @@ -62,4 +63,7 @@ export async function registerAdminPlugin( ? { feishuConnectionReadinessProbe: config.feishuConnectionReadinessProbe } : {}), }); + + // After API + /admin/login so SPA fallback does not shadow auth routes. + await registerStaticSpa(app); } diff --git a/hub/src/admin/routes/accessRoutes.ts b/hub/src/admin/routes/accessRoutes.ts index c1d0b63..0a5fb69 100644 --- a/hub/src/admin/routes/accessRoutes.ts +++ b/hub/src/admin/routes/accessRoutes.ts @@ -5,7 +5,7 @@ import { listProjectTeamAccess, revokeTeamProjectAccess, } from "../../permissions/projectTeamAccess.js"; -import { requireOrgProject, requireOrgRole, type GuardDeps } from "../auth/guards.js"; +import { requireProjectPermission, type GuardDeps } from "../auth/guards.js"; import { handleRouteError } from "../errors.js"; const ROLES: readonly PermissionRole[] = ["READ", "EDIT", "MANAGE"]; @@ -19,9 +19,13 @@ export async function registerAccessRoutes( app.get("/api/org/:orgSlug/projects/:projectId/team-access", async (request, reply) => { try { const { orgSlug, projectId } = request.params as { orgSlug: string; projectId: string }; - const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug }); + const auth = await requireProjectPermission(request, reply, guardDeps, { + orgSlug, + projectId, + action: "project.read", + allowOrgAdminOversight: true, + }); if (auth === null) return; - await requireOrgProject(guardDeps, auth.organization.id, projectId); return { access: await listProjectTeamAccess(config.prisma, projectId) }; } catch (err) { return handleRouteError(reply, err); @@ -31,9 +35,13 @@ export async function registerAccessRoutes( app.put("/api/org/:orgSlug/projects/:projectId/team-access", async (request, reply) => { try { const { orgSlug, projectId } = request.params as { orgSlug: string; projectId: string }; - const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug }); + const auth = await requireProjectPermission(request, reply, guardDeps, { + orgSlug, + projectId, + action: "collaborator.manage", + allowOrgAdminOversight: false, + }); if (auth === null) return; - await requireOrgProject(guardDeps, auth.organization.id, projectId); const body = request.body as { teamId?: unknown; teamSlug?: unknown; @@ -67,9 +75,13 @@ export async function registerAccessRoutes( projectId: string; teamId: string; }; - const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug }); + const auth = await requireProjectPermission(request, reply, guardDeps, { + orgSlug, + projectId, + action: "collaborator.manage", + allowOrgAdminOversight: false, + }); if (auth === null) return; - await requireOrgProject(guardDeps, auth.organization.id, projectId); const count = await revokeTeamProjectAccess(config.prisma, { projectId, teamId }); return { revoked: count }; } catch (err) { diff --git a/hub/src/admin/routes/capacityRoutes.ts b/hub/src/admin/routes/capacityRoutes.ts new file mode 100644 index 0000000..71c778f --- /dev/null +++ b/hub/src/admin/routes/capacityRoutes.ts @@ -0,0 +1,64 @@ +/** + * Org-admin capacity policy routes (ADR-0022). + */ +import type { PrismaClient } from "@prisma/client"; +import type { FastifyInstance } from "fastify"; +import { getCapacityPolicy, setCapacityPolicy } from "../../org/capacityPolicy.js"; +import { isCapacityDimension } from "../../capacity/dimensions.js"; +import { requireOrgRole, type GuardDeps } from "../auth/guards.js"; +import { handleRouteError } from "../errors.js"; + +export async function registerCapacityRoutes( + app: FastifyInstance, + config: { readonly prisma: PrismaClient; readonly sessionSecret: string }, +): Promise { + const guardDeps: GuardDeps = { prisma: config.prisma, sessionSecret: config.sessionSecret }; + + app.get("/api/org/:orgSlug/capacity-policy", async (request, reply) => { + try { + const { orgSlug } = request.params as { orgSlug: string }; + const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug }); + if (auth === null) return; + return await getCapacityPolicy(config.prisma, auth.organization.id); + } catch (err) { + return handleRouteError(reply, err); + } + }); + + app.put("/api/org/:orgSlug/capacity-policy", async (request, reply) => { + try { + const { orgSlug } = request.params as { orgSlug: string }; + const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug }); + if (auth === null) return; + const body = request.body as { limits?: unknown }; + if (body?.limits === null || typeof body.limits !== "object") { + return reply.status(400).send({ + error: { code: "bad_request", message: "limits object is required" }, + }); + } + const limits: Record = {}; + for (const [key, value] of Object.entries(body.limits as Record)) { + if (!isCapacityDimension(key)) { + return reply.status(400).send({ + error: { code: "bad_request", message: `unknown capacity dimension: ${key}` }, + }); + } + if (value === null) { + limits[key] = null; + } else if (typeof value === "number" && Number.isFinite(value)) { + limits[key] = value; + } else { + return reply.status(400).send({ + error: { code: "bad_request", message: `limit for ${key} must be a number or null` }, + }); + } + } + return await setCapacityPolicy(config.prisma, { + organizationId: auth.organization.id, + limits, + }); + } catch (err) { + return handleRouteError(reply, err); + } + }); +} diff --git a/hub/src/admin/routes/explorerRoutes.ts b/hub/src/admin/routes/explorerRoutes.ts index 7d28f2d..7861ad9 100644 --- a/hub/src/admin/routes/explorerRoutes.ts +++ b/hub/src/admin/routes/explorerRoutes.ts @@ -10,13 +10,15 @@ import { createOrgFolder, createOrgProject, getOrgProjectDetail, + listMyProjects, listOrgExplorer, moveOrgProjectToFolder, renameFolder, renameProject, } from "../../org/explorer.js"; -import { requireOrgRole, type GuardDeps } from "../auth/guards.js"; +import { requireOrgRole, requireProjectPermission, ORG_ADMIN_ROLES, type GuardDeps } from "../auth/guards.js"; import { handleRouteError } from "../errors.js"; +import { createPermissionAuthorizer } from "../../permissions/authorizer.js"; export interface ExplorerRouteConfig { readonly prisma: PrismaClient; @@ -41,6 +43,24 @@ export async function registerExplorerRoutes( } }); + app.get("/api/org/:orgSlug/my-projects", async (request, reply) => { + try { + const { orgSlug } = request.params as { orgSlug: string }; + const auth = await requireOrgRole(request, reply, guardDeps, { + orgSlug, + roles: ["OWNER", "ADMIN", "MEMBER"], + }); + if (auth === null) return; + const projects = await listMyProjects(config.prisma, { + organizationId: auth.organization.id, + actorFeishuOpenId: auth.feishuOpenId, + }); + return { projects }; + } catch (err) { + return handleRouteError(reply, err); + } + }); + app.post("/api/org/:orgSlug/folders", async (request, reply) => { try { const { orgSlug } = request.params as { orgSlug: string }; @@ -144,12 +164,27 @@ export async function registerExplorerRoutes( app.get("/api/org/:orgSlug/projects/:projectId", async (request, reply) => { try { const { orgSlug, projectId } = request.params as { orgSlug: string; projectId: string }; - const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug }); + const auth = await requireProjectPermission(request, reply, guardDeps, { + orgSlug, + projectId, + action: "project.read", + allowOrgAdminOversight: true, + }); if (auth === null) return; - return await getOrgProjectDetail(config.prisma, { + const detail = await getOrgProjectDetail(config.prisma, { organizationId: auth.organization.id, projectId, }); + const manageDecision = await createPermissionAuthorizer(config.prisma).can({ + actor: { feishuOpenId: auth.feishuOpenId }, + action: "collaborator.manage", + resource: { type: "PROJECT", id: projectId }, + }); + return { + ...detail, + actorIsOrgAdmin: ORG_ADMIN_ROLES.includes(auth.membershipRole), + actorCanManageProject: manageDecision.allowed, + }; } catch (err) { return handleRouteError(reply, err); } diff --git a/hub/src/admin/routes/orgRoutes.ts b/hub/src/admin/routes/orgRoutes.ts index 8bec133..cb6e342 100644 --- a/hub/src/admin/routes/orgRoutes.ts +++ b/hub/src/admin/routes/orgRoutes.ts @@ -15,6 +15,7 @@ import { registerMembersRoutes } from "./membersRoutes.js"; import { registerSessionsAndUsageRoutes } from "./sessionsRoutes.js"; import { registerTeamsRoutes } from "./teamsRoutes.js"; import { registerProviderConnectionRoutes } from "./providerConnectionRoutes.js"; +import { registerCapacityRoutes } from "./capacityRoutes.js"; import type { LocalSecretEnvelope } from "../../security/secretEnvelope.js"; import type { ProviderReadinessProbe } from "../../connections/providerReadiness.js"; import type { FeishuReadinessProbe } from "../../connections/feishuReadiness.js"; @@ -105,6 +106,10 @@ export async function registerOrgRoutes(app: FastifyInstance, config: OrgRouteCo prisma: config.prisma, sessionSecret: config.sessionSecret, }); + await registerCapacityRoutes(app, { + prisma: config.prisma, + sessionSecret: config.sessionSecret, + }); await registerSessionsAndUsageRoutes(app, { prisma: config.prisma, sessionSecret: config.sessionSecret, diff --git a/hub/src/admin/routes/teamsRoutes.ts b/hub/src/admin/routes/teamsRoutes.ts index 9a76af1..ac69d74 100644 --- a/hub/src/admin/routes/teamsRoutes.ts +++ b/hub/src/admin/routes/teamsRoutes.ts @@ -18,10 +18,16 @@ export async function registerTeamsRoutes( ): Promise { const guardDeps: GuardDeps = { prisma: config.prisma, sessionSecret: config.sessionSecret }; + // Read-only listing is open to any active org member so project MANAGE + // holders can pick a team when granting TEAM→PROJECT access (ADR-0004). + // Mutations below stay org-admin only. app.get("/api/org/:orgSlug/teams", async (request, reply) => { try { const { orgSlug } = request.params as { orgSlug: string }; - const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug }); + const auth = await requireOrgRole(request, reply, guardDeps, { + orgSlug, + roles: ["OWNER", "ADMIN", "MEMBER"], + }); if (auth === null) return; return { teams: await listOrgTeams(config.prisma, auth.organization.id) }; } catch (err) { @@ -34,7 +40,7 @@ export async function registerTeamsRoutes( const { orgSlug } = request.params as { orgSlug: string }; const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug }); if (auth === null) return; - const body = request.body as { slug?: unknown; name?: unknown; description?: unknown }; + const body = (request.body ?? {}) as { slug?: unknown; name?: unknown; description?: unknown }; if (typeof body.slug !== "string" || typeof body.name !== "string") { return reply.status(400).send({ error: { code: "bad_request", message: "slug and name are required" }, diff --git a/hub/src/admin/static.ts b/hub/src/admin/static.ts new file mode 100644 index 0000000..92fbbd8 --- /dev/null +++ b/hub/src/admin/static.ts @@ -0,0 +1,82 @@ +/** + * Serves the org-admin SPA (built by SvelteKit via `admin-web/build/`) and + * the SPA index fallback for client-side routes under `/admin/*`. + * + * The SvelteKit project lives in `hub/admin-web/`. Run `npm run build` there + * to produce the static output in `admin-web/build/`. In development, use + * `npm run dev` in `admin-web/` which proxies `/api` and `/auth` to the Hub. + * + * Override the UI directory with `CPH_ADMIN_UI_DIR` env if needed. + */ +import { readFile } from "node:fs/promises"; +import { existsSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, extname, join, resolve as resolvePath } from "node:path"; +import type { FastifyInstance } from "fastify"; + +const MIME: Record = { + ".html": "text/html; charset=utf-8", + ".js": "text/javascript; charset=utf-8", + ".mjs": "text/javascript; charset=utf-8", + ".css": "text/css; charset=utf-8", + ".svg": "image/svg+xml", + ".ico": "image/x-icon", + ".png": "image/png", + ".jpg": "image/jpeg", + ".woff": "font/woff", + ".woff2": "font/woff2", + ".json": "application/json; charset=utf-8", + ".txt": "text/plain; charset=utf-8", +}; + +function resolveUiDir(): string { + const override = process.env["CPH_ADMIN_UI_DIR"]; + if (override && override.trim() !== "") return resolvePath(override); + const here = dirname(fileURLToPath(import.meta.url)); + return resolvePath(join(here, "..", "..", "admin-web", "build")); +} + +export async function registerStaticSpa(app: FastifyInstance): Promise { + const uiDir = resolveUiDir(); + if (!existsSync(join(uiDir, "index.html"))) { + app.log.warn( + { uiDir }, + "admin-web/build not found; SPA shell disabled. Run `npm run build` in admin-web/ to enable. Org admin APIs remain fully functional.", + ); + return; + } + const indexHtml = await readFile(join(uiDir, "index.html"), "utf8"); + + // SvelteKit static assets (_app/*, favicon, etc.) + app.get("/_app/*", async (request, reply) => { + const rel = (request.params as { "*": string })["*"]; + const safe = rel.split("/").filter((p) => p !== ".." && p !== "").join("/"); + try { + const buf = await readFile(join(uiDir, "_app", safe)); + const mime = MIME[extname(safe)] ?? "application/octet-stream"; + return reply.type(mime).send(buf); + } catch { + return reply.status(404).send({ error: { code: "not_found", message: "asset not found" } }); + } + }); + + // Other top-level static assets (favicon.svg, robots.txt, etc.) + app.get("/favicon.svg", async (_request, reply) => { + try { + const buf = await readFile(join(uiDir, "favicon.svg")); + return reply.type("image/svg+xml").send(buf); + } catch { + return reply.status(404).send(); + } + }); + + // SPA client-side route fallback. Auth registers `/admin/login` first so it + // takes precedence; everything else under /admin/* serves the index so + // SvelteKit's client-side router can resolve the view. + app.get("/admin", async (_request, reply) => { + return reply.type("text/html; charset=utf-8").send(indexHtml); + }); + app.get("/admin/*", async (_request, reply) => { + return reply.type("text/html; charset=utf-8").send(indexHtml); + }); +} diff --git a/hub/src/capacity/ceilings.ts b/hub/src/capacity/ceilings.ts new file mode 100644 index 0000000..5c9b18d --- /dev/null +++ b/hub/src/capacity/ceilings.ts @@ -0,0 +1,78 @@ +/** + * ADR-0022 platform ceilings (spec `LayeredLimit.platformCeiling`). + * + * Platform ceilings are the unbreakable upper bounds; org policy may only + * configure lower limits (see `LayeredLimit.Valid`). Exact numeric values are + * `OPEN` per spec and calibrated by capacity testing — this module is the + * single place to wire them. + * + * Seven dimensions are sourced from existing runtime env vars + * (`HUB_HTTP_BODY_LIMIT_BYTES`, `HUB_MAX_FILES_PER_MESSAGE`, `HUB_MAX_FILE_BYTES`, + * `HUB_HTTP_REQUESTS_PER_MINUTE`, `HUB_AGENT_MAX_CONCURRENT_RUNS`, + * `HUB_AGENT_MAX_RUN_SECONDS`, `HUB_AGENT_MAX_TURNS`). The remaining dimensions + * are sourced from `HUB_CEILING_` env vars; until those are set the + * dimension has no configured ceiling and the policy UI surfaces it as + * "未配置" (cannot enforce a limit without a ceiling). + */ +import type { CapacityDimension } from "./dimensions.js"; + +function positiveIntEnv(name: string): number | undefined { + const raw = process.env[name]; + if (raw === undefined || raw === "") return undefined; + const n = Number.parseInt(raw, 10); + return Number.isFinite(n) && n > 0 ? n : undefined; +} + +const ENV_BACKED_CEILINGS: Partial> = { + requestBodySize: "HUB_HTTP_BODY_LIMIT_BYTES", + attachmentCount: "HUB_MAX_FILES_PER_MESSAGE", + fileSize: "HUB_MAX_FILE_BYTES", + requestRate: "HUB_HTTP_REQUESTS_PER_MINUTE", + agentConcurrency: "HUB_AGENT_MAX_CONCURRENT_RUNS", + runWallTime: "HUB_AGENT_MAX_RUN_SECONDS", + runTurns: "HUB_AGENT_MAX_TURNS", +}; + +const CEILING_DIMENSIONS: readonly CapacityDimension[] = [ + "requestRate", + "requestBodySize", + "agentConcurrency", + "admissionQueueLength", + "admissionQueueWait", + "fileSize", + "attachmentCount", + "archiveExpansion", + "projectStorage", + "organizationStorage", + "memberCount", + "projectCount", + "teamCount", + "folderCount", + "sessionCount", + "runWallTime", + "runTurns", + "runToolCalls", + "toolWallTime", + "runOutputSize", + "processMemory", + "processCpu", + "processCount", +]; + +function envNameFor(dimension: CapacityDimension): string { + return ENV_BACKED_CEILINGS[dimension] ?? `HUB_CEILING_${dimension.toUpperCase()}`; +} + +/** + * Returns the configured platform ceilings (only the dimensions currently + * wired via env). Unconfigured dimensions are absent — callers must surface + * them, not assume unlimited (spec `LayeredLimit` forbids unlimited ceilings). + */ +export function readPlatformCeilings(): Partial> { + const ceilings: Partial> = {}; + for (const dimension of CEILING_DIMENSIONS) { + const value = positiveIntEnv(envNameFor(dimension)); + if (value !== undefined) ceilings[dimension] = value; + } + return ceilings; +} diff --git a/hub/src/capacity/dimensions.ts b/hub/src/capacity/dimensions.ts new file mode 100644 index 0000000..6c774c3 --- /dev/null +++ b/hub/src/capacity/dimensions.ts @@ -0,0 +1,63 @@ +/** + * ADR-0022 capacity dimensions (spec `Spec.System.Capacity.CapacityDimension`). + * The 23 PINNED dimensions; exact numeric ceilings are `OPEN` and calibrated by + * capacity testing. This module is the single source of the dimension set shared + * by the platform-ceiling config and the org capacity-policy service. + */ +export const CAPACITY_DIMENSIONS = [ + "requestRate", + "requestBodySize", + "agentConcurrency", + "admissionQueueLength", + "admissionQueueWait", + "fileSize", + "attachmentCount", + "archiveExpansion", + "projectStorage", + "organizationStorage", + "memberCount", + "projectCount", + "teamCount", + "folderCount", + "sessionCount", + "runWallTime", + "runTurns", + "runToolCalls", + "toolWallTime", + "runOutputSize", + "processMemory", + "processCpu", + "processCount", +] as const; + +export type CapacityDimension = (typeof CAPACITY_DIMENSIONS)[number]; + +export const CAPACITY_DIMENSION_LABELS: Record = { + requestRate: "HTTP 请求速率", + requestBodySize: "请求体大小", + agentConcurrency: "智能体并发", + admissionQueueLength: "接纳队列长度", + admissionQueueWait: "接纳队列等待", + fileSize: "单文件大小", + attachmentCount: "每消息附件数", + archiveExpansion: "归档展开", + projectStorage: "项目存储", + organizationStorage: "组织存储", + memberCount: "成员数", + projectCount: "项目数", + teamCount: "团队数", + folderCount: "文件夹数", + sessionCount: "会话数", + runWallTime: "单次运行墙钟", + runTurns: "单次运行轮次", + runToolCalls: "单次运行工具调用", + toolWallTime: "工具墙钟", + runOutputSize: "运行输出大小", + processMemory: "进程内存", + processCpu: "进程 CPU", + processCount: "进程数", +}; + +export function isCapacityDimension(value: string): value is CapacityDimension { + return (CAPACITY_DIMENSIONS as readonly string[]).includes(value); +} diff --git a/hub/src/org/capacityPolicy.ts b/hub/src/org/capacityPolicy.ts new file mode 100644 index 0000000..7815aee --- /dev/null +++ b/hub/src/org/capacityPolicy.ts @@ -0,0 +1,102 @@ +/** + * Org capacity policy service (ADR-0022 / spec `Spec.System.Capacity`). + * + * Stores per-Organization lower `organizationLimit` overrides per + * `CapacityDimension`. Enforces `LayeredLimit.Valid`: a set limit must be ≤ the + * platform ceiling for that dimension. `LayeredLimit.effective` (min of the two) + * is the value capacity admission should use; dimensions with no org override + * fall back to the platform ceiling. + */ +import type { PrismaClient } from "@prisma/client"; +import { CAPACITY_DIMENSIONS, isCapacityDimension, type CapacityDimension } from "../capacity/dimensions.js"; +import { readPlatformCeilings } from "../capacity/ceilings.js"; +import { lockActiveOrganization } from "./status.js"; + +export interface CapacityPolicyView { + /** Per-dimension: platform ceiling (absent if not configured) + org limit. */ + readonly dimensions: ReadonlyArray<{ + readonly dimension: CapacityDimension; + readonly platformCeiling: number | null; + readonly organizationLimit: number | null; + readonly effective: number | null; + }>; +} + +export async function getCapacityPolicy( + prisma: PrismaClient, + organizationId: string, +): Promise { + const row = await prisma.organizationCapacityPolicy.findUnique({ + where: { organizationId }, + select: { limits: true }, + }); + const orgLimits = parseLimits(row?.limits); + const ceilings = readPlatformCeilings(); + const dimensions = CAPACITY_DIMENSIONS.map((dimension) => { + const platformCeiling = ceilings[dimension] ?? null; + const organizationLimit = orgLimits[dimension] ?? null; + const effective = effectiveLimit(platformCeiling, organizationLimit); + return { dimension, platformCeiling, organizationLimit, effective }; + }); + return { dimensions }; +} + +export async function setCapacityPolicy( + prisma: PrismaClient, + input: { + readonly organizationId: string; + /** Partial map of dimension → limit. null/absent clears a dimension. */ + readonly limits: Partial>; + }, +): Promise { + const ceilings = readPlatformCeilings(); + const cleaned: Record = {}; + for (const [dimension, value] of Object.entries(input.limits)) { + if (!isCapacityDimension(dimension)) { + throw new Error(`unknown capacity dimension: ${dimension}`); + } + if (value === null || value === undefined) continue; + if (!Number.isFinite(value) || value <= 0 || !Number.isInteger(value)) { + throw new Error(`limit for ${dimension} must be a positive integer`); + } + const ceiling = ceilings[dimension]; + if (ceiling === undefined) { + throw new Error( + `platform ceiling for ${dimension} is not configured; cannot set an organization limit (spec LayeredLimit.Valid)`, + ); + } + if (value > ceiling) { + throw new Error( + `organization limit ${value} for ${dimension} exceeds platform ceiling ${ceiling} (spec LayeredLimit.Valid)`, + ); + } + cleaned[dimension] = value; + } + + await prisma.$transaction(async (tx) => { + await lockActiveOrganization(tx, input.organizationId); + await tx.organizationCapacityPolicy.upsert({ + where: { organizationId: input.organizationId }, + create: { organizationId: input.organizationId, limits: cleaned }, + update: { limits: cleaned }, + }); + }); + return getCapacityPolicy(prisma, input.organizationId); +} + +function effectiveLimit(platformCeiling: number | null, organizationLimit: number | null): number | null { + if (platformCeiling === null) return null; + if (organizationLimit === null) return platformCeiling; + return Math.min(platformCeiling, organizationLimit); +} + +function parseLimits(raw: unknown): Partial> { + if (raw === null || typeof raw !== "object") return {}; + const result: Partial> = {}; + for (const [key, value] of Object.entries(raw as Record)) { + if (isCapacityDimension(key) && typeof value === "number" && Number.isFinite(value)) { + result[key] = value; + } + } + return result; +} diff --git a/hub/src/org/explorer.ts b/hub/src/org/explorer.ts index a7842eb..5018a58 100644 --- a/hub/src/org/explorer.ts +++ b/hub/src/org/explorer.ts @@ -12,6 +12,7 @@ import { moveProjectToFolder, } from "../projectOnboarding.js"; import { lockActiveOrganization } from "./status.js"; +import { PrismaPrincipalResolver } from "../permissions/principals.js"; export interface ExplorerFolderNode { readonly id: string; @@ -335,6 +336,64 @@ export async function archiveOrgProjectChatBinding( }); } +/** + * Projects an org member can see via their resolved principals' READ+ grants + * (spec `PermissionGrant` / ADR-0004). Used by the member-facing project list + * — org-admin oversight uses `listOrgExplorer` instead. + */ +export async function listMyProjects( + prisma: PrismaClient, + input: { readonly organizationId: string; readonly actorFeishuOpenId: string }, +): Promise { + const resolver = new PrismaPrincipalResolver(prisma); + const resolution = await resolver.resolveActor( + { feishuOpenId: input.actorFeishuOpenId }, + { organizationId: input.organizationId }, + ); + // Match exact (type, id) pairs — independent IN filters form a cartesian + // product and can attribute another principal's grants to this actor. + if (resolution.principals.length === 0) return []; + const grants = await prisma.permissionGrant.findMany({ + where: { + resourceType: "PROJECT", + revokedAt: null, + OR: resolution.principals.map((p) => ({ + principalType: p.type, + principalId: p.id, + })), + }, + select: { resourceId: true }, + distinct: ["resourceId"], + }); + const projectIds = grants.map((g) => g.resourceId); + if (projectIds.length === 0) return []; + const projects = await prisma.project.findMany({ + where: { id: { in: projectIds }, organizationId: input.organizationId, archivedAt: null }, + select: { + id: true, + name: true, + folderId: true, + createdAt: true, + groupBindings: { + where: { archivedAt: null }, + select: { chatId: true, createdAt: true }, + take: 1, + }, + }, + orderBy: { name: "asc" }, + }); + return projects.map((p) => { + const binding = p.groupBindings[0]; + return { + id: p.id, + name: p.name, + folderId: p.folderId, + createdAt: p.createdAt.toISOString(), + binding: binding === undefined ? null : { chatId: binding.chatId, createdAt: binding.createdAt.toISOString() }, + }; + }); +} + async function requireActiveFolder( prisma: PrismaClient | Prisma.TransactionClient, folderId: string, diff --git a/hub/src/org/teams.ts b/hub/src/org/teams.ts index 6684afa..e918b89 100644 --- a/hub/src/org/teams.ts +++ b/hub/src/org/teams.ts @@ -1,8 +1,9 @@ /** * Hub team lifecycle for org admin (ADR-0019 / ADR-0021). * - * Archiving a team soft-archives the team row and revokes active TEAM→PROJECT - * grants that use this team as principal (product pin). + * Archiving a team soft-archives the team row. Active TEAM→PROJECT grants and + * memberships are left in place as dead rows: principal resolution refuses + * archived teams (see `permissions/principals.ts`), so they confer no access. */ import type { Prisma, PrismaClient } from "@prisma/client"; import { lockActiveOrganization } from "./status.js"; @@ -125,37 +126,21 @@ export async function updateTeam( } /** - * Soft-archive team and revoke its active project grants (TEAM principal). + * Soft-archive team. Active grants/memberships are not cascade-revoked; the + * archived flag alone makes the team's principal unresolvable (no access). */ export async function archiveTeam( prisma: PrismaClient, input: { readonly organizationId: string; readonly teamId: string }, -): Promise<{ readonly archived: true; readonly teamId: string; readonly revokedGrants: number }> { +): Promise<{ readonly archived: true; readonly teamId: string }> { return prisma.$transaction(async (tx) => { await lockActiveOrganization(tx, input.organizationId); const team = await requireActiveTeam(tx, input.teamId, input.organizationId); - const now = new Date(); await tx.team.update({ where: { id: team.id }, - data: { archivedAt: now }, + data: { archivedAt: new Date() }, }); - await tx.teamMembership.updateMany({ - where: { teamId: team.id, revokedAt: null }, - data: { revokedAt: now }, - }); - const grants = await tx.permissionGrant.updateMany({ - where: { - principalType: "TEAM", - principalId: team.id, - revokedAt: null, - }, - data: { revokedAt: now }, - }); - return { - archived: true as const, - teamId: team.id, - revokedGrants: grants.count, - }; + return { archived: true as const, teamId: team.id }; }); } diff --git a/hub/src/permissions/externalSync.ts b/hub/src/permissions/externalSync.ts index 4b6db40..9b2b6e4 100644 --- a/hub/src/permissions/externalSync.ts +++ b/hub/src/permissions/externalSync.ts @@ -71,7 +71,6 @@ export async function syncExternalPrincipalMemberships( create: { feishuOpenId: item.feishuOpenId, displayName: item.displayName, - platformRoles: { create: { role: "TEACHER" } }, organizationMemberships: { create: { organizationId: input.organizationId, role: "MEMBER" } }, }, }); diff --git a/hub/src/permissions/projectTeamAccess.ts b/hub/src/permissions/projectTeamAccess.ts index fedd6eb..11db94e 100644 --- a/hub/src/permissions/projectTeamAccess.ts +++ b/hub/src/permissions/projectTeamAccess.ts @@ -131,9 +131,8 @@ export async function listProjectTeamAccess( where: { organizationId: project.organizationId, id: { in: grants.map((grant) => grant.principalId) }, - archivedAt: null, }, - select: { id: true, slug: true, name: true }, + select: { id: true, slug: true, name: true, archivedAt: true }, }); const teamsById = new Map(teams.map((team) => [team.id, team])); const missing = grants.filter((grant) => !teamsById.has(grant.principalId)); @@ -143,13 +142,15 @@ export async function listProjectTeamAccess( ); } - return grants.map((grant) => entryFromGrant({ - grantId: grant.id, - projectId: project.id, - organizationId: project.organizationId, - team: teamsById.get(grant.principalId)!, - role: grant.role, - })); + return grants + .filter((grant) => teamsById.get(grant.principalId)?.archivedAt === null) + .map((grant) => entryFromGrant({ + grantId: grant.id, + projectId: project.id, + organizationId: project.organizationId, + team: teamsById.get(grant.principalId)!, + role: grant.role, + })); } type ProjectForAccess = { diff --git a/hub/test/integration/admin-members-teams.test.ts b/hub/test/integration/admin-members-teams.test.ts index e24c459..4eea2c9 100644 --- a/hub/test/integration/admin-members-teams.test.ts +++ b/hub/test/integration/admin-members-teams.test.ts @@ -129,6 +129,18 @@ describe("admin members + teams API", () => { workspaceDir: "/tmp/p1", }, }); + // Team-access grant/revoke is gated to project MANAGE (no org-admin bypass). + // Seed the owner with a MANAGE grant on p1 so the org-owner flow still works. + await prisma.permissionGrant.create({ + data: { + resourceType: "PROJECT", + resourceId: "p1", + principalType: "USER", + principalId: "ou_owner", + role: "MANAGE", + createdByUserId: "u-owner", + }, + }); const app = await buildApp(); try { @@ -176,7 +188,7 @@ describe("admin members + teams API", () => { }); expect(archive.statusCode).toBe(200); expect(archive.json()).toEqual( - expect.objectContaining({ archived: true, revokedGrants: 1 }), + expect.objectContaining({ archived: true, teamId: team.id }), ); const after = await app.inject({ diff --git a/hub/test/integration/helpers.ts b/hub/test/integration/helpers.ts index e68f4b1..aca8e73 100644 --- a/hub/test/integration/helpers.ts +++ b/hub/test/integration/helpers.ts @@ -400,7 +400,6 @@ export async function seedProject( id: "u_" + projectId, feishuOpenId: principal, displayName: "Test User", - platformRoles: { create: { role: "TEACHER" } }, organizationMemberships: { create: { organizationId: DEFAULT_ORG_ID, role: "MEMBER" } }, permissionGrants: { create: {