diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..5737327 --- /dev/null +++ b/.env.example @@ -0,0 +1,22 @@ +# Required: password used to log into the web admin UI. +# Pick something long and random. +AGM_ADMIN_PASSWORD=change-me-please + +# Optional: if set, requests to the OpenAI/Anthropic proxy must include this +# key in `Authorization: Bearer …`, `x-api-key`, or `x-goog-api-key`. +# Leave empty/unset to run the proxy in open mode (anyone on the network can +# call it — only safe behind a firewall). +AGM_API_KEY= + +# Optional: where to put the management API server. +# Defaults: 8045 for the proxy (changed via the config UI), 8046 for management. +AGM_MANAGEMENT_PORT=8046 + +# Optional: which interface the proxy and management server bind to. +# Default is 0.0.0.0 (all interfaces). Set to 127.0.0.1 for production +# behind nginx so the Node servers are only reachable via the reverse proxy. +# AGM_BIND_HOST=127.0.0.1 + +# Optional: relocate persistent state (master key, sqlite, logs). +# Defaults to ~/.antigravity-manager-server when not running under Electron. +# AGM_DATA_DIR=/var/lib/antigravity-manager diff --git a/.gitignore b/.gitignore index c0e3e3d..e81df75 100644 --- a/.gitignore +++ b/.gitignore @@ -125,4 +125,10 @@ cli/aliases.json accounts_backup.json # Bun lock file -bun.lock \ No newline at end of file +bun.lock +# Antigravity Manager standalone build outputs +dist-web/ +dist-server/ + +# Claude Code internal +.claude/ diff --git a/deploy/README.md b/deploy/README.md new file mode 100644 index 0000000..0044cc8 --- /dev/null +++ b/deploy/README.md @@ -0,0 +1,54 @@ +# Deployment + +Reference configs for hosting the standalone server (`npm run server:dev` +or `npm run server:start`) on a Linux VM behind nginx with TLS. + +## Files + +- `nginx/agm.conf` — HTTP-only site config that fronts both ports on a + single domain. `certbot --nginx` rewrites it in place to add TLS, + the 443 server block, and the 80 → 443 redirect. Routes: + - `/admin/*` → management UI (port 8046) + - `/api/*` → management API (port 8046, with login rate limit) + - `/v1/*`, `/v1beta/*` → OpenAI/Anthropic/Gemini proxy (port 8045) + - `/` → 302 redirect to `/admin/` + +## Quick deploy + +```bash +# Replace YOUR_DOMAIN below. +sudo cp deploy/nginx/agm.conf /etc/nginx/sites-available/agm.conf +sudo sed -i 's/agm.example.com/YOUR_DOMAIN/g' /etc/nginx/sites-available/agm.conf +sudo ln -s /etc/nginx/sites-available/agm.conf /etc/nginx/sites-enabled/ +sudo nginx -t && sudo systemctl reload nginx + +# TLS via Let's Encrypt — rewrites agm.conf with the 443 block. +sudo certbot --nginx -d YOUR_DOMAIN +``` + +## Lock the upstreams to loopback + +The Node servers default to `0.0.0.0`, which means they're reachable +directly on `:8045` and `:8046` even with nginx in front. Two fixes — +ideally both: + +1. **Set `AGM_BIND_HOST=127.0.0.1`** in `.env`. Both the proxy and the + management server then bind to loopback only and are only reachable + via nginx. +2. **Firewall:** allow only ports 80 and 443 inbound. + ```bash + sudo ufw allow 22 && sudo ufw allow 80 && sudo ufw allow 443 + sudo ufw enable + ``` + +## Hardening checklist before pointing DNS at the VM + +- [ ] Strong `AGM_ADMIN_PASSWORD` (24+ random chars) in `.env` +- [ ] `AGM_API_KEY` set to a long random secret (clients send it as + `Authorization: Bearer …`) +- [ ] `.env` permissions: `chmod 600 .env` +- [ ] Run as a non-login system user (e.g. `adduser --system --group agm`) +- [ ] Backup `~/.antigravity-agent/cloud_accounts.db` and `~/.antigravity-agent/.mk` + (the master key — without it, the DB is unrecoverable) +- [ ] Process supervisor in place (systemd/pm2) so a crash auto-restarts +- [ ] HTTPS verified end-to-end (`curl -I https://YOUR_DOMAIN/admin/`) diff --git a/deploy/nginx/agm.conf b/deploy/nginx/agm.conf new file mode 100644 index 0000000..589cb85 --- /dev/null +++ b/deploy/nginx/agm.conf @@ -0,0 +1,114 @@ +# Antigravity Manager — nginx site config (HTTP only). +# +# Drop in /etc/nginx/sites-available/agm.conf, symlink it into +# /etc/nginx/sites-enabled/, then: +# +# nginx -t && systemctl reload nginx +# certbot --nginx -d agm.example.com +# +# certbot will rewrite this file in place to add the 443 server block, +# the TLS cert paths, and the 80 -> 443 redirect. +# +# Replace `agm.example.com` with your domain. +# +# Routing: +# /admin/, /admin/*, /api/* -> 127.0.0.1:8046 (management API + web UI) +# /v1/*, /v1beta/* -> 127.0.0.1:8045 (OpenAI/Anthropic proxy) +# / -> redirect to /admin/ +# +# Bind both Node servers to 127.0.0.1 so they're only reachable through +# nginx. In your .env: +# AGM_BIND_HOST=127.0.0.1 + +# Per-IP rate limits. The login bucket is intentionally tight; the proxy +# bucket should be generous enough not to throttle a real client. +limit_req_zone $binary_remote_addr zone=agm_login:10m rate=5r/m; +limit_req_zone $binary_remote_addr zone=agm_api:10m rate=20r/s; +limit_req_zone $binary_remote_addr zone=agm_proxy:10m rate=60r/s; + +upstream agm_management { server 127.0.0.1:8046; keepalive 16; } +upstream agm_proxy { server 127.0.0.1:8045; keepalive 16; } + +server { + listen 80; + listen [::]:80; + server_name agm.example.com; + + # Allow image uploads to /v1/images/edits and audio to /v1/audio/transcriptions. + client_max_body_size 25m; + + # ---------- Web UI ---------- + location = / { return 302 /admin/; } + + location /admin/ { + proxy_pass http://agm_management; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + # ---------- Management API ---------- + location = /api/auth/login { + # Brute-force gate. Burst lets a typo or two through, then queues. + limit_req zone=agm_login burst=3 nodelay; + + proxy_pass http://agm_management; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + location /api/ { + limit_req zone=agm_api burst=40 nodelay; + + proxy_pass http://agm_management; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_read_timeout 120s; + } + + # ---------- OpenAI / Anthropic proxy ---------- + # Streaming SSE responses need buffering off and a long read timeout. + location /v1/ { + limit_req zone=agm_proxy burst=80 nodelay; + + proxy_pass http://agm_proxy; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Connection ""; + proxy_buffering off; + proxy_cache off; + proxy_read_timeout 600s; + proxy_send_timeout 600s; + } + + # Gemini-native surface (used by some SDKs). + location /v1beta/ { + limit_req zone=agm_proxy burst=80 nodelay; + + proxy_pass http://agm_proxy; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Connection ""; + proxy_buffering off; + proxy_cache off; + proxy_read_timeout 600s; + proxy_send_timeout 600s; + } + + # Anything else: drop. (No bare /api or /v1 endpoints are public.) + location / { return 404; } +} diff --git a/package-lock.json b/package-lock.json index c52a7cd..6375c03 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "0.12.0", "license": "CC-BY-NC-SA-4.0", "dependencies": { + "@fastify/rate-limit": "^10.3.0", "@grpc/grpc-js": "^1.14.3", "@grpc/proto-loader": "^0.8.0", "@icons-pack/react-simple-icons": "^13.8.0", @@ -134,11 +135,16 @@ "sharp": "^0.34.5", "tailwindcss": "^4.1.16", "ts-node": "^10.9.2", + "tsx": "^4.21.0", "typescript": "^5.9.3", "typescript-eslint": "^8.46.3", "unplugin-swc": "^1.5.9", "vite": "^5.4.21", "vitest": "^4.0.8" + }, + "engines": { + "node": ">=22.14.0", + "npm": ">=10" } }, "node_modules/@acemir/cssom": { @@ -323,6 +329,7 @@ "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -1021,6 +1028,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" }, @@ -1064,6 +1072,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" } @@ -1594,6 +1603,7 @@ "integrity": "sha512-zx0EIq78WlY/lBb1uXlziZmDZI4ubcCXIMJ4uGjXzZW0nS19TjSPeXPAjzzTmKQlJUZm0SbmZhPKP7tuQ1SsEw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "chalk": "^4.1.1", "fs-extra": "^9.0.1", @@ -2791,6 +2801,27 @@ "ipaddr.js": "^2.1.0" } }, + "node_modules/@fastify/rate-limit": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/@fastify/rate-limit/-/rate-limit-10.3.0.tgz", + "integrity": "sha512-eIGkG9XKQs0nyynatApA3EVrojHOuq4l6fhB4eeCk4PIOeadvOJz9/4w3vGI44Go17uaXOWEcPkaD8kuKm7g6Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@lukeed/ms": "^2.0.2", + "fastify-plugin": "^5.0.0", + "toad-cache": "^3.7.0" + } + }, "node_modules/@floating-ui/core": { "version": "1.7.3", "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.3.tgz", @@ -2876,6 +2907,7 @@ "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.3.tgz", "integrity": "sha512-Iq8QQQ/7X3Sac15oB6p0FmUg/klxQvXLeileoqrTRGJYLV+/9tubbr9ipz0GKHjmXVsgFPo/+W+2cA8eNcR+XA==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@grpc/proto-loader": "^0.8.0", "@js-sdsl/ordered-map": "^4.4.2" @@ -3614,6 +3646,7 @@ "integrity": "sha512-yl43JD/86CIj3Mz5mvvLJqAOfIup7ncxfJ0Btnl0/v5TouVUyeEdcpknfgc+yMevS/48oH9WAkkw93m7otLb/A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@inquirer/checkbox": "^3.0.1", "@inquirer/confirm": "^4.0.1", @@ -3878,6 +3911,15 @@ "node": ">=8" } }, + "node_modules/@lukeed/ms": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@lukeed/ms/-/ms-2.0.2.tgz", + "integrity": "sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/@malept/cross-spawn-promise": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@malept/cross-spawn-promise/-/cross-spawn-promise-2.0.0.tgz", @@ -3938,6 +3980,7 @@ "resolved": "https://registry.npmjs.org/@nestjs/common/-/common-11.1.18.tgz", "integrity": "sha512-0sLq8Z+TIjLnz1Tqp0C/x9BpLbqpt1qEu0VcH4/fkE0y3F5JxhfK1AdKQ/SPbKhKgwqVDoY4gS8GQr2G6ujaWg==", "license": "MIT", + "peer": true, "dependencies": { "file-type": "21.3.4", "iterare": "1.2.1", @@ -3970,6 +4013,7 @@ "integrity": "sha512-wR3DtGyk/LUAiPtbXDuWJJwVkWElKBY0sqnTzf9d4uM3+X18FRZhK7WFc47czsIGOdWuRsMeLYV+1Z9dO4zDEQ==", "hasInstallScript": true, "license": "MIT", + "peer": true, "dependencies": { "@nuxt/opencollective": "0.4.1", "fast-safe-stringify": "2.1.1", @@ -4010,6 +4054,7 @@ "resolved": "https://registry.npmjs.org/@nestjs/microservices/-/microservices-11.1.18.tgz", "integrity": "sha512-sodzBqqKAcOv5GctTbb0j3vzgx+X3/IxmJ8pBKL9hEmsarsYPkig6dcOcVFmM2Pknm+o6Cxaqv1YZZETISNi+w==", "license": "MIT", + "peer": true, "dependencies": { "iterare": "1.2.1", "tslib": "2.8.1" @@ -4068,6 +4113,7 @@ "resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-11.1.18.tgz", "integrity": "sha512-s6GdHMTa3qx0fJewR74Xa30ysPHfBEqxIwZ7BGSTLoAEQ1vTP24urNl+b6+s49NFLEIOyeNho5fN/9/I17QlOw==", "license": "MIT", + "peer": true, "dependencies": { "cors": "2.8.6", "express": "5.2.1", @@ -4219,6 +4265,7 @@ "integrity": "sha512-/g2d4sW9nUDJOMz3mabVQvOGhVa4e/BN/Um7yca9Bb2XTzPPnfTWHWQg+IsEYO7M3Vx+EXvaM/I2pJWIMun1bg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@octokit/auth-token": "^4.0.0", "@octokit/graphql": "^7.1.0", @@ -4529,6 +4576,7 @@ "resolved": "https://registry.npmmirror.com/@opentelemetry/api/-/api-1.9.0.tgz", "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", "license": "Apache-2.0", + "peer": true, "engines": { "node": ">=8.0.0" } @@ -4550,6 +4598,7 @@ "resolved": "https://registry.npmmirror.com/@opentelemetry/context-async-hooks/-/context-async-hooks-2.5.0.tgz", "integrity": "sha512-uOXpVX0ZjO7heSVjhheW2XEPrhQAWr2BScDPoZ9UDycl5iuHG+Usyc3AIfG6kZeC1GyLpMInpQ6X5+9n69yOFw==", "license": "Apache-2.0", + "peer": true, "engines": { "node": "^18.19.0 || >=20.6.0" }, @@ -4562,6 +4611,7 @@ "resolved": "https://registry.npmmirror.com/@opentelemetry/core/-/core-2.5.0.tgz", "integrity": "sha512-ka4H8OM6+DlUhSAZpONu0cPBtPPTQKxbxVzC4CzVx5+K4JnroJVBtDzLAMx4/3CDTJXRvVFhpFjtl4SaiTNoyQ==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, @@ -4577,6 +4627,7 @@ "resolved": "https://registry.npmmirror.com/@opentelemetry/instrumentation/-/instrumentation-0.210.0.tgz", "integrity": "sha512-sLMhyHmW9katVaLUOKpfCnxSGhZq2t1ReWgwsu2cSgxmDVMB690H9TanuexanpFI94PJaokrqbp8u9KYZDUT5g==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@opentelemetry/api-logs": "0.210.0", "import-in-the-middle": "^2.0.0", @@ -4985,6 +5036,7 @@ "resolved": "https://registry.npmmirror.com/@opentelemetry/resources/-/resources-2.5.0.tgz", "integrity": "sha512-F8W52ApePshpoSrfsSk1H2yJn9aKjCrbpQF1M9Qii0GHzbfVeFUB+rc3X4aggyZD8x9Gu3Slua+s6krmq6Dt8g==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@opentelemetry/core": "2.5.0", "@opentelemetry/semantic-conventions": "^1.29.0" @@ -5001,6 +5053,7 @@ "resolved": "https://registry.npmmirror.com/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.5.0.tgz", "integrity": "sha512-VzRf8LzotASEyNDUxTdaJ9IRJ1/h692WyArDBInf5puLCjxbICD6XkHgpuudis56EndyS7LYFmtTMny6UABNdQ==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@opentelemetry/core": "2.5.0", "@opentelemetry/resources": "2.5.0", @@ -5018,6 +5071,7 @@ "resolved": "https://registry.npmmirror.com/@opentelemetry/semantic-conventions/-/semantic-conventions-1.39.0.tgz", "integrity": "sha512-R5R9tb2AXs2IRLNKLBJDynhkfmx7mX0vi8NkhZb3gUkPWHn6HXk5J8iQ/dql0U3ApfWym4kXXmBDRGO+oeOfjg==", "license": "Apache-2.0", + "peer": true, "engines": { "node": ">=14" } @@ -7956,6 +8010,7 @@ "integrity": "sha512-DhGl4xMVFGVIyMwswXeyzdL4uXD5OGILGX5N8Y+f6W7LhC1Ze2poSNrkF/fedpVDHEEZ+PHFW0vL14I+mm8K3Q==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@octokit/auth-token": "^6.0.0", "@octokit/graphql": "^9.0.3", @@ -9373,6 +9428,7 @@ "dev": true, "hasInstallScript": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "@swc/counter": "^0.1.3", "@swc/types": "^0.1.25" @@ -9921,6 +9977,7 @@ "resolved": "https://registry.npmjs.org/@tanstack/react-router/-/react-router-1.139.12.tgz", "integrity": "sha512-qrIxb8c6XXih6MERZKKwdnYg0OannsQLJ/s+4/wRqKqGCG+QmvAMvnmNP7bfYLgFKi+KsE27HqUkHaSpZSenwQ==", "license": "MIT", + "peer": true, "dependencies": { "@tanstack/history": "1.139.0", "@tanstack/react-store": "^0.8.0", @@ -9991,6 +10048,7 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -10095,6 +10153,7 @@ "resolved": "https://registry.npmjs.org/@tanstack/router-core/-/router-core-1.139.12.tgz", "integrity": "sha512-HCDi4fpnAFeDDogT0C61yd2nJn0FrIyFDhyHG3xJji8emdn8Ni4rfyrN4Av46xKkXTPUGdbsqih45+uuNtunew==", "license": "MIT", + "peer": true, "dependencies": { "@tanstack/history": "1.139.0", "@tanstack/store": "^0.8.0", @@ -10163,6 +10222,7 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -10557,8 +10617,7 @@ "resolved": "https://registry.npmmirror.com/@types/aria-query/-/aria-query-5.0.4.tgz", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@types/babel__core": { "version": "7.20.5", @@ -10611,6 +10670,7 @@ "integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "@types/node": "*" } @@ -10789,6 +10849,7 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.1.tgz", "integrity": "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ==", "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -10834,7 +10895,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "dependencies": { "@types/node": "*", "xmlbuilder": ">=11.0.1" @@ -10846,6 +10906,7 @@ "integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -10856,6 +10917,7 @@ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "devOptional": true, "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -10917,8 +10979,7 @@ "integrity": "sha512-RlDm9K7+o5stv0Co8i8ZRGxDbrTxhJtgjqjFyVh/tXQyl/rYtTKlnTvZ88oSTeYREWurwx20Js4kTuKCsFkUtg==", "dev": true, "license": "MIT", - "optional": true, - "peer": true + "optional": true }, "node_modules/@types/whatwg-mimetype": { "version": "3.0.2", @@ -10961,6 +11022,7 @@ "integrity": "sha512-XxXP5tL1txl13YFtrECECQYeZjBZad4fyd3cFV4a19LkAY/bIp9fev3US4S5fDVV2JaYFiKAZ/GRTOLer+mbyQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/regexpp": "^4.10.0", "@typescript-eslint/scope-manager": "8.48.0", @@ -11001,6 +11063,7 @@ "integrity": "sha512-jCzKdm/QK0Kg4V4IK/oMlRZlY+QOcdjv89U2NgKHZk1CYTj82/RVSx1mV/0gqCVMJ/DA+Zf/S4NBWNF8GQ+eqQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.48.0", "@typescript-eslint/types": "8.48.0", @@ -11586,6 +11649,7 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -11681,6 +11745,7 @@ "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -12158,7 +12223,6 @@ "integrity": "sha512-+25nxyyznAXF7Nef3y0EbBeqmGZgeN/BxHX29Rs39djAfaFalmQ89SE6CWyDCHzGL0yt/ycBtNOmGTW0FyGWNw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "archiver-utils": "^2.1.0", "async": "^3.2.4", @@ -12178,7 +12242,6 @@ "integrity": "sha512-bEL/yUb/fNNiNTuUz979Z0Yg5L+LzLxGJz8x79lYmR54fmTIb6ob/hNQgkQnIUDWIFjZVQwl9Xs356I6BAMHfw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "glob": "^7.1.4", "graceful-fs": "^4.2.0", @@ -12200,8 +12263,7 @@ "resolved": "https://registry.npmmirror.com/isarray/-/isarray-1.0.0.tgz", "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/archiver-utils/node_modules/readable-stream": { "version": "2.3.8", @@ -12209,7 +12271,6 @@ "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", @@ -12225,8 +12286,7 @@ "resolved": "https://registry.npmmirror.com/safe-buffer/-/safe-buffer-5.1.2.tgz", "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/archiver-utils/node_modules/string_decoder": { "version": "1.1.1", @@ -12234,7 +12294,6 @@ "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "safe-buffer": "~5.1.0" } @@ -12434,7 +12493,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "engines": { "node": ">=0.8" } @@ -12469,7 +12527,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "engines": { "node": ">=8" } @@ -12664,6 +12721,7 @@ "integrity": "sha512-WwCZ/5Diz7rsF29o27o0Gcc1Du+l7Zsv7SYtVPG0X3G/uUI1LqdxrQI7c9Hs2FWpqXXERjW9hp6g3/tH7DlVKg==", "hasInstallScript": true, "license": "MIT", + "peer": true, "dependencies": { "bindings": "^1.5.0", "prebuild-install": "^7.1.1" @@ -12843,6 +12901,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.25", "caniuse-lite": "^1.0.30001754", @@ -13324,13 +13383,15 @@ "version": "0.5.1", "resolved": "https://registry.npmjs.org/class-transformer/-/class-transformer-0.5.1.tgz", "integrity": "sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/class-validator": { "version": "0.14.3", "resolved": "https://registry.npmjs.org/class-validator/-/class-validator-0.14.3.tgz", "integrity": "sha512-rXXekcjofVN1LTOSw+u4u9WXVEUvNBVjORW154q/IdmYWy1nMbOU9aNtZB0t8m+FJQ9q91jlr2f9CwwUFdFMRA==", "license": "MIT", + "peer": true, "dependencies": { "@types/validator": "^13.15.3", "libphonenumber-js": "^1.11.1", @@ -13807,7 +13868,6 @@ "integrity": "sha512-D3uMHtGc/fcO1Gt1/L7i1e33VOvD4A9hfQLP+6ewd+BvG/gQ84Yh4oftEhAdjSMgBgwGL+jsppT7JYNpo6MHHg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "buffer-crc32": "^0.2.13", "crc32-stream": "^4.0.2", @@ -14224,7 +14284,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "dependencies": { "buffer": "^5.1.0" } @@ -14235,7 +14294,6 @@ "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "crc32": "bin/crc32.njs" }, @@ -14249,7 +14307,6 @@ "integrity": "sha512-NT7w2JVU7DFroFdYkeq8cywxrgjPHWkdX1wjpRQXPX5Asews3tA+Ght6lddQO5Mkumffp3X7GEqku3epj2toIw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "crc-32": "^1.2.0", "readable-stream": "^3.4.0" @@ -14428,7 +14485,8 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/data-urls": { "version": "6.0.0", @@ -14751,7 +14809,6 @@ "integrity": "sha512-rcJUkMfnJpfCboZoOOPf4L29TRtEieHNOeAbYPWPxlaBw/Z1RKrRA86dOI9rwaI4tQSc/RD82zTNHprfUHXsoQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "app-builder-lib": "24.13.3", "builder-util": "24.13.1", @@ -14770,7 +14827,6 @@ "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" }, @@ -14788,7 +14844,6 @@ "os": [ "darwin" ], - "peer": true, "dependencies": { "@types/plist": "^3.0.1", "@types/verror": "^1.10.3", @@ -14824,8 +14879,7 @@ "resolved": "https://registry.npmmirror.com/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/dot-prop": { "version": "5.3.0", @@ -16368,6 +16422,7 @@ "integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -16428,6 +16483,7 @@ "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", "dev": true, "license": "MIT", + "peer": true, "bin": { "eslint-config-prettier": "bin/cli.js" }, @@ -17005,8 +17061,7 @@ "node >=0.6.0" ], "license": "MIT", - "optional": true, - "peer": true + "optional": true }, "node_modules/fast-content-type-parse": { "version": "3.0.0", @@ -18601,6 +18656,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.28.4" }, @@ -19080,7 +19136,6 @@ "os": [ "darwin" ], - "peer": true, "dependencies": { "cli-truncate": "^2.1.0", "node-addon-api": "^1.6.3" @@ -19096,7 +19151,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "dependencies": { "slice-ansi": "^3.0.0", "string-width": "^4.2.0" @@ -19114,8 +19168,7 @@ "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", "dev": true, "license": "MIT", - "optional": true, - "peer": true + "optional": true }, "node_modules/iconv-corefoundation/node_modules/is-fullwidth-code-point": { "version": "3.0.0", @@ -19124,7 +19177,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "engines": { "node": ">=8" } @@ -19135,8 +19187,7 @@ "integrity": "sha512-ibPK3iA+vaY1eEjESkQkM0BbCqFOaZMiXRTtdB0u7b4djtY6JnsjvPdUHVMg6xQt3B8fpTTWHI9A+ADjM9frzg==", "dev": true, "license": "MIT", - "optional": true, - "peer": true + "optional": true }, "node_modules/iconv-corefoundation/node_modules/slice-ansi": { "version": "3.0.0", @@ -19145,7 +19196,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "dependencies": { "ansi-styles": "^4.0.0", "astral-regex": "^2.0.0", @@ -19162,7 +19212,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", @@ -20180,6 +20229,7 @@ "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", "devOptional": true, "license": "MIT", + "peer": true, "bin": { "jiti": "lib/jiti-cli.mjs" } @@ -20512,7 +20562,6 @@ "integrity": "sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "readable-stream": "^2.0.5" }, @@ -20525,8 +20574,7 @@ "resolved": "https://registry.npmmirror.com/isarray/-/isarray-1.0.0.tgz", "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/lazystream/node_modules/readable-stream": { "version": "2.3.8", @@ -20534,7 +20582,6 @@ "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", @@ -20550,8 +20597,7 @@ "resolved": "https://registry.npmmirror.com/safe-buffer/-/safe-buffer-5.1.2.tgz", "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/lazystream/node_modules/string_decoder": { "version": "1.1.1", @@ -20559,7 +20605,6 @@ "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "safe-buffer": "~5.1.0" } @@ -20637,6 +20682,7 @@ "integrity": "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==", "devOptional": true, "license": "MPL-2.0", + "peer": true, "dependencies": { "detect-libc": "^2.0.3" }, @@ -21101,16 +21147,14 @@ "resolved": "https://registry.npmmirror.com/lodash.defaults/-/lodash.defaults-4.2.0.tgz", "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/lodash.difference": { "version": "4.5.0", "resolved": "https://registry.npmmirror.com/lodash.difference/-/lodash.difference-4.5.0.tgz", "integrity": "sha512-dS2j+W26TQ7taQBGN8Lbbq04ssV3emRw4NY58WErlTO29pIqS0HmoT5aJ9+TUQ1N3G+JOZSji4eugsWwGp9yPA==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/lodash.escaperegexp": { "version": "4.1.2", @@ -21124,8 +21168,7 @@ "resolved": "https://registry.npmmirror.com/lodash.flatten/-/lodash.flatten-4.4.0.tgz", "integrity": "sha512-C5N2Z3DgnnKr0LOpv/hKCgKdb7ZZwafIrsesve6lmzvZIRZRGaZ/l6Q8+2W7NaT+ZwO3fFlSCzCzrDCFdJfZ4g==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/lodash.get": { "version": "4.4.2", @@ -21161,8 +21204,7 @@ "resolved": "https://registry.npmmirror.com/lodash.union/-/lodash.union-4.6.0.tgz", "integrity": "sha512-c4pB2CdGrGdjMKYLA+XiRDO7Y0PRQbm/Gzg8qMj+QH+pFVAoTp5sBpO0odL3FjoPCGjK96p6qsP+yQoiLoOBcw==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/lodash.uniqby": { "version": "4.7.0", @@ -21390,7 +21432,6 @@ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "dev": true, "license": "MIT", - "peer": true, "bin": { "lz-string": "bin/bin.js" } @@ -21557,6 +21598,7 @@ "integrity": "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA==", "dev": true, "license": "MIT", + "peer": true, "bin": { "marked": "bin/marked.js" }, @@ -24280,6 +24322,7 @@ "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -25583,6 +25626,7 @@ "integrity": "sha512-QgODejq9K3OzoBbuyobZlUhznP5SKwPqp+6Q6xw6o8gnhr4O85L2U915iM2IDcfF2NPXVaM9zlo9tdwipnYwzg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -25691,7 +25735,6 @@ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -25707,7 +25750,6 @@ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=10" }, @@ -26075,6 +26117,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz", "integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -26084,6 +26127,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz", "integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -26132,8 +26176,7 @@ "resolved": "https://registry.npmmirror.com/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/react-refresh": { "version": "0.17.0", @@ -26474,7 +26517,6 @@ "integrity": "sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA==", "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "minimatch": "^5.1.0" } @@ -26485,7 +26527,6 @@ "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "balanced-match": "^1.0.0" } @@ -26496,7 +26537,6 @@ "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", "dev": true, "license": "ISC", - "peer": true, "dependencies": { "brace-expansion": "^2.0.1" }, @@ -26584,7 +26624,8 @@ "version": "0.2.2", "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==", - "license": "Apache-2.0" + "license": "Apache-2.0", + "peer": true }, "node_modules/reflect.getprototypeof": { "version": "1.0.10", @@ -26945,6 +26986,7 @@ "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", "license": "Apache-2.0", + "peer": true, "dependencies": { "tslib": "^2.1.0" } @@ -27123,6 +27165,7 @@ "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -27176,6 +27219,7 @@ "integrity": "sha512-WRgl5GcypwramYX4HV+eQGzUbD7UUbljVmS+5G1uMwX/wLgYuJAxGeerXJDMO2xshng4+FXqCgyB5QfClV6WjA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@semantic-release/commit-analyzer": "^13.0.1", "@semantic-release/error": "^4.0.0", @@ -27725,6 +27769,7 @@ "resolved": "https://registry.npmmirror.com/seroval/-/seroval-1.5.0.tgz", "integrity": "sha512-OE4cvmJ1uSPrKorFIH9/w/Qwuvi/IMcGbv5RKgcJ/zjA/IohDLU6SVaxFN9FwajbP7nsX0dQqMDes1whk3y+yw==", "license": "MIT", + "peer": true, "engines": { "node": ">=10" } @@ -28943,7 +28988,8 @@ "version": "4.1.17", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.17.tgz", "integrity": "sha512-j9Ee2YjuQqYT9bbRTfTZht9W/ytp5H+jJpZKiYdP/bpnXARAuELt9ofP0lPnmHjbga7SNQIxdTAXCmtKVYjN+Q==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/tailwindcss-animate": { "version": "1.0.7", @@ -29153,6 +29199,7 @@ "integrity": "sha512-t/R3R/n0MSwnnazuPpPNVO60LX0SKL45pyl9YlvxIdkH0Of7D5qM2EVe+yASRIlY5pZ73nclYJfNANGWPwFDZw==", "devOptional": true, "license": "BSD-2-Clause", + "peer": true, "dependencies": { "@jridgewell/source-map": "^0.3.3", "acorn": "^8.15.0", @@ -29385,6 +29432,7 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -29681,13 +29729,14 @@ "license": "0BSD" }, "node_modules/tsx": { - "version": "4.20.6", - "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.20.6.tgz", - "integrity": "sha512-ytQKuwgmrrkDTFP4LjR0ToE2nqgy886GpvRSpU0JAnrdBYppuY5rLkRUYPU1yCryb24SsKBTL/hlDQAEFVwtZg==", + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", + "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { - "esbuild": "~0.25.0", + "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" }, "bin": { @@ -29700,76 +29749,560 @@ "fsevents": "~2.3.3" } }, - "node_modules/tunnel": { - "version": "0.0.6", - "resolved": "https://registry.npmjs.org/tunnel/-/tunnel-0.0.6.tgz", - "integrity": "sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg==", + "node_modules/tsx/node_modules/@esbuild/aix-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", + "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", + "cpu": [ + "ppc64" + ], "dev": true, "license": "MIT", + "optional": true, + "os": [ + "aix" + ], "engines": { - "node": ">=0.6.11 <=0.7.0 || >=0.7.3" + "node": ">=18" } }, - "node_modules/tunnel-agent": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", - "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", - "license": "Apache-2.0", - "dependencies": { - "safe-buffer": "^5.0.1" - }, + "node_modules/tsx/node_modules/@esbuild/android-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", + "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], "engines": { - "node": "*" + "node": ">=18" } }, - "node_modules/type-check": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", - "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "node_modules/tsx/node_modules/@esbuild/android-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", + "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", - "dependencies": { - "prelude-ls": "^1.2.1" - }, + "optional": true, + "os": [ + "android" + ], "engines": { - "node": ">= 0.8.0" + "node": ">=18" } }, - "node_modules/type-fest": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.5.0.tgz", - "integrity": "sha512-PlBfpQwiUvGViBNX84Yxwjsdhd1TUlXr6zjX7eoirtCPIr08NAmxwa+fcYBTeRQxHo9YC9wwF3m9i700sHma8g==", - "license": "(MIT OR CC0-1.0)", - "dependencies": { - "tagged-tag": "^1.0.0" - }, + "node_modules/tsx/node_modules/@esbuild/android-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", + "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=18" } }, - "node_modules/type-is": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", - "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", + "node_modules/tsx/node_modules/@esbuild/darwin-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", + "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", + "cpu": [ + "arm64" + ], + "dev": true, "license": "MIT", - "dependencies": { - "content-type": "^1.0.5", - "media-typer": "^1.1.0", - "mime-types": "^3.0.0" - }, + "optional": true, + "os": [ + "darwin" + ], "engines": { - "node": ">= 0.6" + "node": ">=18" } }, - "node_modules/type-is/node_modules/mime-db": { - "version": "1.54.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", - "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", - "license": "MIT", - "engines": { + "node_modules/tsx/node_modules/@esbuild/darwin-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", + "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", + "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/freebsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", + "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", + "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", + "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", + "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-loong64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", + "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-mips64el": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", + "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", + "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-riscv64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", + "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-s390x": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", + "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", + "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", + "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/netbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", + "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", + "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/openbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", + "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", + "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/sunos-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", + "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", + "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", + "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", + "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/esbuild": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", + "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", + "devOptional": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.7", + "@esbuild/android-arm": "0.27.7", + "@esbuild/android-arm64": "0.27.7", + "@esbuild/android-x64": "0.27.7", + "@esbuild/darwin-arm64": "0.27.7", + "@esbuild/darwin-x64": "0.27.7", + "@esbuild/freebsd-arm64": "0.27.7", + "@esbuild/freebsd-x64": "0.27.7", + "@esbuild/linux-arm": "0.27.7", + "@esbuild/linux-arm64": "0.27.7", + "@esbuild/linux-ia32": "0.27.7", + "@esbuild/linux-loong64": "0.27.7", + "@esbuild/linux-mips64el": "0.27.7", + "@esbuild/linux-ppc64": "0.27.7", + "@esbuild/linux-riscv64": "0.27.7", + "@esbuild/linux-s390x": "0.27.7", + "@esbuild/linux-x64": "0.27.7", + "@esbuild/netbsd-arm64": "0.27.7", + "@esbuild/netbsd-x64": "0.27.7", + "@esbuild/openbsd-arm64": "0.27.7", + "@esbuild/openbsd-x64": "0.27.7", + "@esbuild/openharmony-arm64": "0.27.7", + "@esbuild/sunos-x64": "0.27.7", + "@esbuild/win32-arm64": "0.27.7", + "@esbuild/win32-ia32": "0.27.7", + "@esbuild/win32-x64": "0.27.7" + } + }, + "node_modules/tunnel": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/tunnel/-/tunnel-0.0.6.tgz", + "integrity": "sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.6.11 <=0.7.0 || >=0.7.3" + } + }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/type-fest": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.5.0.tgz", + "integrity": "sha512-PlBfpQwiUvGViBNX84Yxwjsdhd1TUlXr6zjX7eoirtCPIr08NAmxwa+fcYBTeRQxHo9YC9wwF3m9i700sHma8g==", + "license": "(MIT OR CC0-1.0)", + "dependencies": { + "tagged-tag": "^1.0.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/type-is": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", + "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", + "license": "MIT", + "dependencies": { + "content-type": "^1.0.5", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/type-is/node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { "node": ">= 0.6" } }, @@ -29879,6 +30412,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -30350,7 +30884,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "dependencies": { "assert-plus": "^1.0.0", "core-util-is": "1.0.2", @@ -30366,8 +30899,7 @@ "integrity": "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==", "dev": true, "license": "MIT", - "optional": true, - "peer": true + "optional": true }, "node_modules/vite": { "version": "5.4.21", @@ -30375,6 +30907,7 @@ "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", @@ -30988,6 +31521,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -31001,6 +31535,7 @@ "integrity": "sha512-NL8jTlbo0Tn4dUEXEsUg8KeyG/Lkmc4Fnzb8JXN/Ykm9G4HNImjtABMJgkQoVjOBN/j2WAwDTRytdqJbZsah7w==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", @@ -31597,6 +32132,7 @@ "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", "devOptional": true, "license": "MIT", + "peer": true, "engines": { "node": ">=10.0.0" }, @@ -31677,6 +32213,7 @@ "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", "license": "ISC", + "peer": true, "bin": { "yaml": "bin.mjs" }, @@ -31809,7 +32346,6 @@ "integrity": "sha512-9qv4rlDiopXg4E69k+vMHjNN63YFMe9sZMrdlvKnCjlCRWeCBswPPMPUfx+ipsAWq1LXHe70RcbaHdJJpS6hyQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "archiver-utils": "^3.0.4", "compress-commons": "^4.1.2", @@ -31825,7 +32361,6 @@ "integrity": "sha512-KVgf4XQVrTjhyWmx6cte4RxonPLR9onExufI1jhvw/MQ4BB6IsZD5gT8Lq+u/+pRkWna/6JoHpiQioaqFP5Rzw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "glob": "^7.2.3", "graceful-fs": "^4.2.0", @@ -31847,6 +32382,7 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.13.tgz", "integrity": "sha512-AvvthqfqrAhNH9dnfmrfKzX5upOdjUVJYFqNSlkmGf64gRaTzlPwz99IHYnVs28qYAybvAlBV+H7pn0saFY4Ig==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/package.json b/package.json index 2854c33..5e8cdf9 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,12 @@ "test:unit": "vitest", "test:e2e": "playwright test", "test:all": "vitest run && playwright test", - "type-check": "tsc --noEmit" + "type-check": "tsc --noEmit", + "server:dev": "tsx src/standalone/main.ts", + "server:build": "tsc -p tsconfig.standalone.json", + "server:start": "node dist-server/standalone/main.js", + "web:dev": "vite --config vite.web-ui.config.mts", + "web:build": "vite build --config vite.web-ui.config.mts" }, "author": "Draculabo", "license": "CC-BY-NC-SA-4.0", @@ -88,6 +93,7 @@ "sharp": "^0.34.5", "tailwindcss": "^4.1.16", "ts-node": "^10.9.2", + "tsx": "^4.21.0", "typescript": "^5.9.3", "typescript-eslint": "^8.46.3", "unplugin-swc": "^1.5.9", @@ -95,6 +101,7 @@ "vitest": "^4.0.8" }, "dependencies": { + "@fastify/rate-limit": "^10.3.0", "@grpc/grpc-js": "^1.14.3", "@grpc/proto-loader": "^0.8.0", "@icons-pack/react-simple-icons": "^13.8.0", diff --git a/src/ipc/context.ts b/src/ipc/context.ts index 074db4d..19ef82d 100644 --- a/src/ipc/context.ts +++ b/src/ipc/context.ts @@ -1,5 +1,5 @@ import { os } from '@orpc/server'; -import { BrowserWindow } from 'electron'; +import type { BrowserWindow } from 'electron'; class IPCContext { public mainWindow: BrowserWindow | undefined; diff --git a/src/server/main.ts b/src/server/main.ts index 3a0d158..5eaaebd 100644 --- a/src/server/main.ts +++ b/src/server/main.ts @@ -28,9 +28,10 @@ export async function bootstrapNestServer(config: ProxyConfig): Promise // Enable CORS app.enableCors(); - await app.listen(port, '0.0.0.0'); + const bindHost = process.env.AGM_BIND_HOST?.trim() || '0.0.0.0'; + await app.listen(port, bindHost); currentPort = port; - logger.info(`NestJS Proxy Server running on http://localhost:${port}`); + logger.info(`NestJS Proxy Server running on http://${bindHost}:${port}`); return true; } catch (error) { logger.error('Failed to start NestJS server', error); diff --git a/src/services/CloudMonitorService.ts b/src/services/CloudMonitorService.ts index 95114bd..162bdc3 100644 --- a/src/services/CloudMonitorService.ts +++ b/src/services/CloudMonitorService.ts @@ -1,5 +1,5 @@ -import { Notification } from 'electron'; import { CloudAccountRepo } from '../ipc/database/cloudHandler'; +import { isElectronRuntime } from '../utils/electronShim'; import { GoogleAPIService, type TokenResponse } from './GoogleAPIService'; import { AutoSwitchService } from './AutoSwitchService'; import { logger } from '../utils/logger'; @@ -253,7 +253,9 @@ export class CloudMonitorService { return info.display_name || name.replace('models/', '').replace(/-/g, ' '); }); - if (lowQuotaModels.length > 0) { + if (lowQuotaModels.length > 0 && isElectronRuntime()) { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { Notification } = require('electron') as typeof import('electron'); new Notification({ title: notificationText.lowQuotaTitle, body: notificationText.lowQuotaBody(account.email, lowQuotaModels.join(', ')), diff --git a/src/standalone/auth.ts b/src/standalone/auth.ts new file mode 100644 index 0000000..35fc495 --- /dev/null +++ b/src/standalone/auth.ts @@ -0,0 +1,88 @@ +import crypto from 'crypto'; +import { FastifyReply, FastifyRequest } from 'fastify'; + +const TOKEN_TTL_MS = 12 * 60 * 60 * 1000; // 12 hours +const sessions = new Map(); + +function cleanupExpired() { + const now = Date.now(); + for (const [token, info] of sessions) { + if (info.expiresAt <= now) { + sessions.delete(token); + } + } +} + +export function getAdminPassword(): string { + const value = process.env.AGM_ADMIN_PASSWORD?.trim(); + if (!value) { + throw new Error('AGM_ADMIN_PASSWORD is not set'); + } + return value; +} + +export function verifyAdminPassword(candidate: string): boolean { + const expected = getAdminPassword(); + const a = Buffer.from(expected); + const b = Buffer.from(candidate); + if (a.length !== b.length) { + return false; + } + return crypto.timingSafeEqual(a, b); +} + +export function issueToken(): { token: string; expiresAt: number } { + cleanupExpired(); + const token = crypto.randomBytes(32).toString('hex'); + const expiresAt = Date.now() + TOKEN_TTL_MS; + sessions.set(token, { expiresAt }); + return { token, expiresAt }; +} + +export function revokeToken(token: string): void { + sessions.delete(token); +} + +export function isTokenValid(token: string | null): boolean { + if (!token) { + return false; + } + cleanupExpired(); + const info = sessions.get(token); + if (!info) { + return false; + } + return info.expiresAt > Date.now(); +} + +export function extractBearerToken(req: FastifyRequest): string | null { + const header = req.headers['authorization']; + if (typeof header !== 'string') { + return null; + } + const match = header.match(/^Bearer\s+(.+)$/i); + return match ? match[1].trim() : null; +} + +const PUBLIC_PREFIXES = ['/api/health', '/api/auth/login', '/api/auth/info']; + +function isPublicRoute(url: string): boolean { + const pathOnly = url.split('?')[0]; + return PUBLIC_PREFIXES.some((p) => pathOnly === p); +} + +export function requireAuth(req: FastifyRequest, reply: FastifyReply): boolean { + const url = req.url ?? ''; + if (!url.startsWith('/api/')) { + return true; + } + if (isPublicRoute(url)) { + return true; + } + const token = extractBearerToken(req); + if (!isTokenValid(token)) { + reply.status(401).send({ ok: false, error: 'Authentication required' }); + return false; + } + return true; +} diff --git a/src/standalone/env.ts b/src/standalone/env.ts new file mode 100644 index 0000000..baf92d0 --- /dev/null +++ b/src/standalone/env.ts @@ -0,0 +1,60 @@ +import fs from 'fs'; +import path from 'path'; + +/** + * Minimal .env loader. No deps, no surprises: + * - reads the first .env file found in cwd or its ancestors (up to repo root) + * - splits each line on the first `=` + * - strips matching surrounding quotes + * - skips blanks, comments, and keys already set in process.env + */ +export function loadDotEnv(): { path: string | null; loaded: number } { + const candidate = findDotEnvUp(process.cwd()); + if (!candidate) { + return { path: null, loaded: 0 }; + } + + const raw = fs.readFileSync(candidate, 'utf-8'); + let count = 0; + for (const line of raw.split(/\r?\n/)) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) { + continue; + } + const eq = trimmed.indexOf('='); + if (eq === -1) { + continue; + } + const key = trimmed.slice(0, eq).trim(); + if (!key || key in process.env) { + continue; + } + let value = trimmed.slice(eq + 1).trim(); + if ( + (value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'")) + ) { + value = value.slice(1, -1); + } + process.env[key] = value; + count++; + } + + return { path: candidate, loaded: count }; +} + +function findDotEnvUp(start: string): string | null { + let dir = start; + for (let i = 0; i < 6; i++) { + const candidate = path.join(dir, '.env'); + if (fs.existsSync(candidate)) { + return candidate; + } + const parent = path.dirname(dir); + if (parent === dir) { + return null; + } + dir = parent; + } + return null; +} diff --git a/src/standalone/main.ts b/src/standalone/main.ts new file mode 100644 index 0000000..310d583 --- /dev/null +++ b/src/standalone/main.ts @@ -0,0 +1,86 @@ +import 'reflect-metadata'; + +import { loadDotEnv } from './env'; + +const dotenv = loadDotEnv(); + +import { CloudAccountRepo } from '../ipc/database/cloudHandler'; +import { ConfigManager } from '../ipc/config/manager'; +import { bootstrapNestServer, stopNestServer } from '../server/main'; +import { CloudMonitorService } from '../services/CloudMonitorService'; +import { logger } from '../utils/logger'; +import { startManagementServer, stopManagementServer } from './managementServer'; + +const DEFAULT_MANAGEMENT_PORT = Number(process.env.AGM_MANAGEMENT_PORT ?? 8046); + +async function main() { + process.env.AGM_STANDALONE = '1'; + logger.info('[standalone] Antigravity Manager server starting'); + if (dotenv.path) { + logger.info(`[standalone] Loaded ${dotenv.loaded} env vars from ${dotenv.path}`); + } + + const adminPassword = process.env.AGM_ADMIN_PASSWORD?.trim(); + if (!adminPassword) { + throw new Error( + 'AGM_ADMIN_PASSWORD is required. Set it in your .env (see .env.example) before starting the server.', + ); + } + + await CloudAccountRepo.init(); + logger.info('[standalone] Cloud account repository ready'); + + const config = ConfigManager.loadConfig(); + const envApiKey = process.env.AGM_API_KEY?.trim(); + if (envApiKey && config.proxy) { + config.proxy.api_key = envApiKey; + logger.info('[standalone] Proxy API key loaded from AGM_API_KEY'); + } else if (config.proxy) { + logger.warn( + '[standalone] AGM_API_KEY not set — proxy is in open mode. Set it in .env to require Bearer auth.', + ); + } + + if (config.proxy) { + const ok = await bootstrapNestServer(config.proxy); + if (!ok) { + throw new Error('NestJS proxy server failed to start'); + } + logger.info(`[standalone] OpenAI/Anthropic proxy listening on port ${config.proxy.port ?? 8045}`); + } else { + logger.warn('[standalone] No proxy config present; proxy will not be started'); + } + + await startManagementServer(DEFAULT_MANAGEMENT_PORT); + logger.info(`[standalone] Management API listening on port ${DEFAULT_MANAGEMENT_PORT}`); + + if (CloudAccountRepo.getSetting('auto_switch_enabled', false)) { + CloudMonitorService.start(); + logger.info('[standalone] Auto-switch monitor started'); + } else { + CloudMonitorService.poll().catch((err) => + logger.warn('[standalone] Initial monitor poll failed', err), + ); + } +} + +async function shutdown(signal: string) { + logger.info(`[standalone] Received ${signal}, shutting down`); + try { + CloudMonitorService.stop(); + } catch (err) { + logger.warn('[standalone] CloudMonitor stop failed', err); + } + await Promise.allSettled([stopNestServer(), stopManagementServer()]); + process.exit(0); +} + +process.on('SIGINT', () => shutdown('SIGINT')); +process.on('SIGTERM', () => shutdown('SIGTERM')); +process.on('uncaughtException', (err) => logger.error('[standalone] uncaughtException', err)); +process.on('unhandledRejection', (reason) => logger.error('[standalone] unhandledRejection', reason)); + +main().catch((err) => { + logger.error('[standalone] Fatal startup error', err); + process.exit(1); +}); diff --git a/src/standalone/managementServer.ts b/src/standalone/managementServer.ts new file mode 100644 index 0000000..824ad6a --- /dev/null +++ b/src/standalone/managementServer.ts @@ -0,0 +1,420 @@ +import fs from 'fs'; +import path from 'path'; +import Fastify, { FastifyInstance } from 'fastify'; +import rateLimit from '@fastify/rate-limit'; +import { v4 as uuidv4 } from 'uuid'; +import { z } from 'zod'; + +import { CloudAccountRepo } from '../ipc/database/cloudHandler'; +import { ConfigManager } from '../ipc/config/manager'; +import { GoogleAPIService } from '../services/GoogleAPIService'; +import { bootstrapNestServer, getNestServerStatus, stopNestServer } from '../server/main'; +import { logger } from '../utils/logger'; +import { CloudAccount } from '../types/cloudAccount'; +import { AppConfigSchema } from '../types/config'; +import { + extractBearerToken, + issueToken, + isTokenValid, + requireAuth, + revokeToken, + verifyAdminPassword, +} from './auth'; + +let app: FastifyInstance | null = null; + +const OAuthStartBody = z.object({ + oauth_client_key: z.string().optional(), +}); + +const OAuthCompleteBody = z.object({ + // Either a raw code or the full localhost callback URL pasted by the user. + code: z.string().optional(), + redirect_url: z.string().optional(), + oauth_client_key: z.string().optional(), +}).refine((value) => Boolean(value.code || value.redirect_url), { + message: 'Provide `code` or `redirect_url`', +}); + +function extractCodeFromRedirectUrl(redirectUrl: string): string | null { + try { + const parsed = new URL(redirectUrl); + return parsed.searchParams.get('code'); + } catch { + return null; + } +} + +function buildAccountSummary(account: CloudAccount) { + const models = Object.entries(account.quota?.models ?? {}).map(([id, info]) => ({ + id: id.replace(/^models\//, ''), + display_name: info.display_name ?? null, + percentage: Number.isFinite(info.percentage) ? Math.round(info.percentage) : null, + reset_time: info.resetTime || null, + max_output_tokens: info.max_output_tokens ?? info.max_tokens ?? null, + supports_thinking: Boolean(info.supports_thinking), + supports_images: Boolean(info.supports_images), + recommended: Boolean(info.recommended), + })); + + return { + id: account.id, + provider: account.provider, + email: account.email, + name: account.name ?? null, + avatar_url: account.avatar_url ?? null, + status: account.status ?? 'active', + status_reason: account.status_reason ?? null, + created_at: account.created_at, + last_used: account.last_used, + proxy_url: account.proxy_url ?? null, + has_refresh_token: Boolean(account.token?.refresh_token), + subscription_tier: account.quota?.subscription_tier ?? null, + ai_credits: account.quota?.ai_credits ?? null, + is_forbidden: Boolean(account.quota?.is_forbidden ?? account.quota?.isForbidden), + models, + }; +} + +const LoginBody = z.object({ + password: z.string().min(1), +}); + +async function registerRoutes(instance: FastifyInstance) { + instance.get('/api/health', async () => ({ + ok: true, + timestamp: Date.now(), + })); + + instance.get('/api/auth/info', async () => { + const config = ConfigManager.loadConfig(); + return { + auth_required: true, + proxy_api_key_set: Boolean(config.proxy?.api_key?.trim()), + }; + }); + + instance.post( + '/api/auth/login', + { + config: { + rateLimit: { + max: 5, + timeWindow: '1 minute', + }, + }, + }, + async (req, reply) => { + const parsed = LoginBody.safeParse(req.body); + if (!parsed.success) { + reply.status(400); + return { ok: false, error: 'Password required' }; + } + if (!verifyAdminPassword(parsed.data.password)) { + reply.status(401); + return { ok: false, error: 'Invalid password' }; + } + const { token, expiresAt } = issueToken(); + return { ok: true, token, expires_at: expiresAt }; + }, + ); + + instance.post('/api/auth/logout', async (req) => { + const token = extractBearerToken(req); + if (token) { + revokeToken(token); + } + return { ok: true }; + }); + + instance.get('/api/auth/me', async (req, reply) => { + const token = extractBearerToken(req); + if (!isTokenValid(token)) { + reply.status(401); + return { ok: false }; + } + return { ok: true }; + }); + + instance.get('/api/proxy/status', async () => getNestServerStatus()); + + instance.post('/api/proxy/start', async () => { + const config = ConfigManager.loadConfig(); + if (!config.proxy) { + return { ok: false, error: 'No proxy config' }; + } + const ok = await bootstrapNestServer(config.proxy); + return { ok }; + }); + + instance.post('/api/proxy/stop', async () => { + const ok = await stopNestServer(); + return { ok }; + }); + + instance.get('/api/config', async () => ConfigManager.loadConfig()); + + instance.put('/api/config', async (req, reply) => { + const parsed = AppConfigSchema.safeParse(req.body); + if (!parsed.success) { + reply.status(400); + return { + ok: false, + error: parsed.error.issues[0]?.message ?? 'Invalid config payload', + }; + } + await ConfigManager.saveConfig(parsed.data); + return { ok: true, config: parsed.data }; + }); + + instance.get('/api/accounts', async () => { + const accounts = await CloudAccountRepo.getAccounts(); + return { accounts: accounts.map(buildAccountSummary) }; + }); + + instance.delete<{ Params: { id: string } }>('/api/accounts/:id', async (req) => { + await CloudAccountRepo.removeAccount(req.params.id); + return { ok: true }; + }); + + instance.post<{ Params: { id: string } }>( + '/api/accounts/:id/refresh-quota', + async (req, reply) => { + const account = await CloudAccountRepo.getAccount(req.params.id); + if (!account) { + reply.status(404); + return { ok: false, error: 'Account not found' }; + } + try { + const quota = await GoogleAPIService.fetchQuota( + account.token.access_token, + account.proxy_url, + ); + try { + const credits = await GoogleAPIService.fetchAICredits( + account.token.access_token, + account.proxy_url, + ); + if (credits) { + quota.ai_credits = credits; + } + } catch (err) { + logger.warn('[standalone] AI credits refresh failed', err); + } + await CloudAccountRepo.updateQuota(account.id, quota); + const refreshed = await CloudAccountRepo.getAccount(account.id); + return { ok: true, account: refreshed ? buildAccountSummary(refreshed) : null }; + } catch (err) { + const message = err instanceof Error ? err.message : 'Refresh failed'; + logger.warn('[standalone] Quota refresh failed', err); + reply.status(502); + return { ok: false, error: message }; + } + }, + ); + + instance.get('/api/proxy/api-key', async () => { + const config = ConfigManager.loadConfig(); + return { api_key: config.proxy?.api_key ?? '' }; + }); + + instance.post('/api/oauth/start', async (req) => { + const parsed = OAuthStartBody.safeParse(req.body ?? {}); + const oauthClientKey = parsed.success ? parsed.data.oauth_client_key : undefined; + const url = GoogleAPIService.getAuthUrl(oauthClientKey); + return { + url, + redirect_uri_hint: + 'After consenting, your browser will be redirected to http://localhost:8888/oauth-callback?code=… — copy the full URL or the code value and submit it to /api/oauth/complete.', + }; + }); + + instance.post('/api/oauth/complete', async (req, reply) => { + const parsed = OAuthCompleteBody.safeParse(req.body); + if (!parsed.success) { + reply.status(400); + return { ok: false, error: parsed.error.issues[0]?.message ?? 'Invalid payload' }; + } + + const { code: bodyCode, redirect_url: redirectUrl, oauth_client_key: oauthClientKey } = + parsed.data; + + const code = bodyCode ?? (redirectUrl ? extractCodeFromRedirectUrl(redirectUrl) : null); + if (!code) { + reply.status(400); + return { ok: false, error: 'Could not extract authorization code' }; + } + + try { + const tokenResp = await GoogleAPIService.exchangeCode(code, undefined, oauthClientKey); + const userInfo = await GoogleAPIService.getUserInfo(tokenResp.access_token); + + const existing = await CloudAccountRepo.getAccountByEmail(userInfo.email); + if (existing) { + reply.status(409); + return { ok: false, error: `Account ${userInfo.email} already exists` }; + } + + const now = Math.floor(Date.now() / 1000); + const account: CloudAccount = { + id: uuidv4(), + provider: 'google', + email: userInfo.email, + name: userInfo.name || userInfo.email, + avatar_url: userInfo.picture, + token: { + access_token: tokenResp.access_token, + refresh_token: tokenResp.refresh_token || '', + expires_in: tokenResp.expires_in, + expiry_timestamp: now + tokenResp.expires_in, + token_type: tokenResp.token_type, + email: userInfo.email, + oauth_client_key: tokenResp.oauth_client_key, + is_gcp_tos: false, + id_token: tokenResp.id_token, + }, + created_at: now, + last_used: now, + }; + + await CloudAccountRepo.addAccount(account); + + try { + const quota = await GoogleAPIService.fetchQuota(account.token.access_token); + account.quota = quota; + await CloudAccountRepo.updateQuota(account.id, quota); + } catch (err) { + logger.warn('[standalone] Initial quota fetch failed', err); + } + + return { ok: true, account: buildAccountSummary(account) }; + } catch (err) { + const message = err instanceof Error ? err.message : 'OAuth exchange failed'; + logger.error('[standalone] OAuth complete failed', err); + reply.status(400); + return { ok: false, error: message }; + } + }); +} + +function resolveWebUiDir(): string | null { + const explicit = process.env.AGM_WEB_DIR?.trim(); + if (explicit && fs.existsSync(path.join(explicit, 'index.html'))) { + return explicit; + } + const cwdCandidate = path.resolve(process.cwd(), 'dist-web'); + if (fs.existsSync(path.join(cwdCandidate, 'index.html'))) { + return cwdCandidate; + } + return null; +} + +function readMimeType(file: string): string { + const ext = path.extname(file).toLowerCase(); + switch (ext) { + case '.html': + return 'text/html; charset=utf-8'; + case '.js': + case '.mjs': + return 'application/javascript; charset=utf-8'; + case '.css': + return 'text/css; charset=utf-8'; + case '.json': + return 'application/json; charset=utf-8'; + case '.svg': + return 'image/svg+xml'; + case '.png': + return 'image/png'; + case '.ico': + return 'image/x-icon'; + case '.woff2': + return 'font/woff2'; + default: + return 'application/octet-stream'; + } +} + +export async function startManagementServer(port: number): Promise { + if (app) { + return; + } + + const instance = Fastify({ logger: false }); + + // Generous global default; tighter override on /api/auth/login below. + await instance.register(rateLimit, { + global: true, + max: 300, + timeWindow: '1 minute', + allowList: (req) => { + const url = req.url ?? ''; + return url === '/api/health' || url.startsWith('/admin') || url === '/'; + }, + }); + + await instance.register(async (scope) => { + scope.addHook('onRequest', (req, reply, done) => { + reply.header('Access-Control-Allow-Origin', '*'); + reply.header('Access-Control-Allow-Methods', 'GET,POST,PUT,DELETE,OPTIONS'); + reply.header('Access-Control-Allow-Headers', 'Content-Type,Authorization'); + if (req.method === 'OPTIONS') { + reply.status(204).send(); + return; + } + if (!requireAuth(req, reply)) { + return; + } + done(); + }); + await registerRoutes(scope); + }); + + const webDir = resolveWebUiDir(); + if (webDir) { + logger.info(`[standalone] Serving web UI from ${webDir} at /admin`); + + instance.get('/', (_req, reply) => { + reply.redirect('/admin/', 302); + }); + + instance.get('/admin', (_req, reply) => { + reply.redirect('/admin/', 302); + }); + + instance.get('/admin/*', (req, reply) => { + const pathOnly = req.url.split('?')[0]; + // Strip the /admin prefix so we resolve against the static build root. + const stripped = pathOnly.replace(/^\/admin/, '') || '/'; + const candidate = stripped === '/' ? '/index.html' : stripped; + const targetPath = path.normalize(path.join(webDir, candidate)); + if (!targetPath.startsWith(webDir)) { + reply.status(403).send('Forbidden'); + return; + } + const finalPath = + fs.existsSync(targetPath) && fs.statSync(targetPath).isFile() + ? targetPath + : path.join(webDir, 'index.html'); + reply.header('Content-Type', readMimeType(finalPath)); + reply.send(fs.readFileSync(finalPath)); + }); + } else { + instance.get('/', async () => ({ + ok: true, + hint: 'Build the web UI with `npm run web:build`, then it will be served at /admin.', + })); + logger.info('[standalone] No dist-web build found; web UI not served from this port'); + } + + const bindHost = process.env.AGM_BIND_HOST?.trim() || '0.0.0.0'; + await instance.listen({ port, host: bindHost }); + app = instance; +} + +export async function stopManagementServer(): Promise { + if (!app) { + return; + } + await app.close(); + app = null; +} diff --git a/src/utils/electronShim.ts b/src/utils/electronShim.ts new file mode 100644 index 0000000..36fd137 --- /dev/null +++ b/src/utils/electronShim.ts @@ -0,0 +1,74 @@ +import os from 'os'; +import path from 'path'; + +/** + * Lazy-loaded Electron bindings. Modules under src/utils, src/services, and the + * NestJS proxy import via this shim so they can run both inside Electron and as + * a plain Node.js server (no Electron present). + */ + +type ElectronAppLike = { + getPath: (name: string) => string; + getAppPath: () => string; + getName?: () => string; + isPackaged?: boolean; +}; + +type SafeStorageLike = { + isEncryptionAvailable: () => boolean; + encryptString: (text: string) => Buffer; + decryptString: (encrypted: Buffer) => string; +}; + +let cachedElectronModule: typeof import('electron') | null | undefined; + +function loadElectron(): typeof import('electron') | null { + if (cachedElectronModule !== undefined) { + return cachedElectronModule; + } + if (!process.versions.electron) { + cachedElectronModule = null; + return null; + } + try { + // eslint-disable-next-line @typescript-eslint/no-require-imports + cachedElectronModule = require('electron'); + return cachedElectronModule; + } catch { + cachedElectronModule = null; + return null; + } +} + +export function isElectronRuntime(): boolean { + return Boolean(process.versions.electron) && loadElectron() !== null; +} + +/** + * The base directory for non-Electron deployments. Mirrors what + * `app.getPath('userData')` would have returned. Override with + * `AGM_DATA_DIR` to relocate state on a server. + */ +export function getServerUserDataPath(): string { + const fromEnv = process.env.AGM_DATA_DIR?.trim(); + if (fromEnv) { + return fromEnv; + } + return path.join(os.homedir(), '.antigravity-manager-server'); +} + +export function getElectronApp(): ElectronAppLike | null { + const electron = loadElectron(); + if (!electron?.app) { + return null; + } + return electron.app as ElectronAppLike; +} + +export function getElectronSafeStorage(): SafeStorageLike | null { + const electron = loadElectron(); + if (!electron?.safeStorage) { + return null; + } + return electron.safeStorage as SafeStorageLike; +} diff --git a/src/utils/security.ts b/src/utils/security.ts index 2a4341f..0a3a928 100644 --- a/src/utils/security.ts +++ b/src/utils/security.ts @@ -1,8 +1,12 @@ import crypto from 'crypto'; import { logger } from './logger'; -import { safeStorage, app } from 'electron'; import path from 'path'; import fs from 'fs/promises'; +import { + getElectronApp, + getElectronSafeStorage, + getServerUserDataPath, +} from './electronShim'; const SERVICE_NAME = 'AntigravityManager'; const ACCOUNT_NAME = 'MasterKey'; @@ -44,7 +48,7 @@ function buildKeychainAccessHint(error: unknown): string | null { let appPath = ''; try { - appPath = app.getAppPath(); + appPath = getElectronApp()?.getAppPath() ?? ''; } catch { appPath = ''; } @@ -67,7 +71,8 @@ let keyGenerationInProgress: Promise | null = null; // Fallback key file path (used when keytar and safeStorage both fail) function getFallbackKeyPath(): string { - const userDataPath = app.getPath('userData'); + const electronApp = getElectronApp(); + const userDataPath = electronApp ? electronApp.getPath('userData') : getServerUserDataPath(); return path.join(userDataPath, '.mk'); } @@ -88,7 +93,8 @@ async function tryKeytar(): Promise { } async function readSafeStorageKey(keyPath: string): Promise { - if (!safeStorage.isEncryptionAvailable()) { + const safeStorage = getElectronSafeStorage(); + if (!safeStorage || !safeStorage.isEncryptionAvailable()) { return null; } @@ -120,7 +126,11 @@ async function readSafeStorageKey(keyPath: string): Promise { async function getOrCreateSafeStorageKey( keyPath: string, -): Promise<{ key: Buffer; created: boolean }> { +): Promise<{ key: Buffer; created: boolean } | null> { + const safeStorage = getElectronSafeStorage(); + if (!safeStorage || !safeStorage.isEncryptionAvailable()) { + return null; + } const existingKey = await readSafeStorageKey(keyPath); if (existingKey) { return { key: existingKey, created: false }; @@ -289,17 +299,22 @@ async function generatePrimaryMasterKey(): Promise { } const keyPath = getFallbackKeyPath(); + const userDataDir = path.dirname(keyPath); + await fs.mkdir(userDataDir, { recursive: true }).catch(() => undefined); - if (safeStorage.isEncryptionAvailable()) { + const safeStorage = getElectronSafeStorage(); + if (safeStorage?.isEncryptionAvailable()) { try { const result = await getOrCreateSafeStorageKey(keyPath); - cacheMasterKey(result.key, 'safeStorage'); - if (result.created) { - logger.info('Security: Generated new master key via safeStorage'); - } else { - logger.info('Security: Loaded master key via safeStorage'); + if (result) { + cacheMasterKey(result.key, 'safeStorage'); + if (result.created) { + logger.info('Security: Generated new master key via safeStorage'); + } else { + logger.info('Security: Loaded master key via safeStorage'); + } + return { key: result.key, source: 'safeStorage' }; } - return { key: result.key, source: 'safeStorage' }; } catch (error) { // If we failed to decrypt but the file exists, we should NOT proceed to other fallbacks // as they might overwrite the existing file and cause permanent data loss. diff --git a/src/web-ui/App.tsx b/src/web-ui/App.tsx new file mode 100644 index 0000000..5cad385 --- /dev/null +++ b/src/web-ui/App.tsx @@ -0,0 +1,659 @@ +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { + api, + auth, + AccountModelInfo, + AccountSummary, + ProxyStatus, + UnauthorizedError, +} from './api'; + +type LoadState = + | { status: 'idle' } + | { status: 'loading' } + | { status: 'ok'; data: T } + | { status: 'error'; error: string }; + +function relativeTime(unixSeconds: number): string { + const diffMs = Date.now() - unixSeconds * 1000; + const minutes = Math.round(diffMs / 60_000); + if (minutes < 1) return 'just now'; + if (minutes < 60) return `${minutes}m ago`; + const hours = Math.round(minutes / 60); + if (hours < 24) return `${hours}h ago`; + const days = Math.round(hours / 24); + return `${days}d ago`; +} + +function formatResetTime(iso: string | null): string | null { + if (!iso) return null; + const date = new Date(iso); + if (Number.isNaN(date.getTime())) return null; + const diffMs = date.getTime() - Date.now(); + if (diffMs <= 0) return 'available'; + const hours = Math.floor(diffMs / 3_600_000); + const minutes = Math.floor((diffMs % 3_600_000) / 60_000); + if (hours > 24) { + return `resets in ${Math.floor(hours / 24)}d`; + } + if (hours > 0) { + return `resets in ${hours}h ${minutes}m`; + } + return `resets in ${minutes}m`; +} + +function StatusBadge({ status }: { status: string }) { + const tone = + status === 'active' + ? 'bg-emerald-500/15 text-emerald-300 ring-emerald-500/30' + : status === 'rate_limited' + ? 'bg-amber-500/15 text-amber-300 ring-amber-500/30' + : 'bg-rose-500/15 text-rose-300 ring-rose-500/30'; + return ( + + {status} + + ); +} + +function QuotaBar({ percentage }: { percentage: number | null }) { + const value = percentage ?? 0; + const tone = + value >= 60 + ? 'bg-emerald-500' + : value >= 25 + ? 'bg-amber-500' + : value > 0 + ? 'bg-rose-500' + : 'bg-slate-700'; + return ( +
+
+
+ ); +} + +function ModelsTable({ models }: { models: AccountModelInfo[] }) { + if (models.length === 0) { + return ( +

+ No quota data yet — try refreshing this account. +

+ ); + } + return ( + + + + + + + + + + {models.map((model) => { + const reset = formatResetTime(model.reset_time); + return ( + + + + + + ); + })} + +
ModelQuotaReset
+
+ + {model.display_name ?? model.id} + + {model.recommended ? ( + + rec + + ) : null} + {model.supports_thinking ? ( + + think + + ) : null} +
+ {model.display_name && model.display_name !== model.id ? ( +
{model.id}
+ ) : null} +
+
+ + + {model.percentage ?? '–'}% + +
+
{reset ?? '—'}
+ ); +} + +function ProxyPanel({ + status, + apiKey, + refresh, +}: { + status: ProxyStatus | null; + apiKey: string | null; + refresh: () => void; +}) { + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const [revealed, setRevealed] = useState(false); + + const onToggle = useCallback(async () => { + setBusy(true); + setError(null); + try { + if (status?.running) { + await api.stopProxy(); + } else { + await api.startProxy(); + } + refresh(); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed'); + } finally { + setBusy(false); + } + }, [status?.running, refresh]); + + return ( +
+
+
+

OpenAI/Anthropic Proxy

+

+ Point your client's base_url at the address + below. +

+
+ +
+ +
+
+
State
+
+ {status ? (status.running ? 'Running' : 'Stopped') : '…'} +
+
+
+
Base URL
+
+ {status?.base_url || `http://localhost:${status?.port || '?'}`} +
+
+
+
Active accounts
+
{status?.active_accounts ?? 0}
+
+
+ +
+
+ + Authorization (clients send via Bearer / x-api-key) + + {apiKey ? ( + + ) : null} +
+
+ {apiKey + ? revealed + ? apiKey + : '•'.repeat(Math.min(apiKey.length, 32)) + : 'open mode — set AGM_API_KEY in .env to require auth'} +
+
+ + {error ?

{error}

: null} +
+ ); +} + +function AddAccountPanel({ onAdded }: { onAdded: () => void }) { + const [authUrl, setAuthUrl] = useState(null); + const [hint, setHint] = useState(null); + const [pasted, setPasted] = useState(''); + const [busy, setBusy] = useState(false); + const [message, setMessage] = useState<{ kind: 'ok' | 'err'; text: string } | null>(null); + + const onStart = useCallback(async () => { + setBusy(true); + setMessage(null); + try { + const result = await api.oauthStart(); + setAuthUrl(result.url); + setHint(result.redirect_uri_hint); + } catch (err) { + setMessage({ kind: 'err', text: err instanceof Error ? err.message : 'Failed' }); + } finally { + setBusy(false); + } + }, []); + + const onComplete = useCallback(async () => { + if (!pasted.trim()) { + setMessage({ kind: 'err', text: 'Paste the callback URL or code first' }); + return; + } + setBusy(true); + setMessage(null); + try { + const isUrl = pasted.trim().toLowerCase().startsWith('http'); + const payload = isUrl ? { redirect_url: pasted.trim() } : { code: pasted.trim() }; + const result = await api.oauthComplete(payload); + if (result.ok && result.account) { + setMessage({ + kind: 'ok', + text: `Added ${result.account.email}`, + }); + setPasted(''); + setAuthUrl(null); + onAdded(); + } else { + setMessage({ kind: 'err', text: result.error ?? 'Unknown error' }); + } + } catch (err) { + setMessage({ kind: 'err', text: err instanceof Error ? err.message : 'Failed' }); + } finally { + setBusy(false); + } + }, [pasted, onAdded]); + + return ( +
+

Add Google account

+

+ Sign in on your own machine, then paste the localhost callback URL back here. +

+ +
    +
  1. + + {authUrl ? ( + + Open in new tab + + ) : null} + {hint ?

    {hint}

    : null} +
  2. +
  3. +