diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 155c99d..74869de 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,6 +31,23 @@ jobs: make vet make test + ui-test: + name: Frontend tests (vitest) + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + cache-dependency-path: src/package-lock.json + + - name: Run frontend unit tests + run: make test-ui + docker: name: Build & Push Docker image (release only) runs-on: ubuntu-latest diff --git a/.gitignore b/.gitignore index 82c2d1c..98e2605 100644 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,7 @@ Dockerfile.local Makefile.local out/ static/ -src/node_modules/ \ No newline at end of file +src/node_modules/ +src/coverage/ +/browser-ui +*.prof \ No newline at end of file diff --git a/Makefile b/Makefile index 5b717f2..40451e2 100644 --- a/Makefile +++ b/Makefile @@ -6,8 +6,10 @@ VERSION ?= develop EXTRA_TAGS ?= PLATFORM ?= linux/amd64 CONTAINER_TOOL ?= docker +NPM ?= npm +UI_DIR ?= src -.PHONY: fmt vet tidy test docker-build docker-push deploy clean show-vars +.PHONY: fmt vet tidy test test-ui docker-build docker-push deploy clean show-vars fmt: go fmt ./... @@ -21,7 +23,10 @@ tidy: test: go test -race -count=1 -cover ./... -docker-build: tidy fmt vet test +test-ui: + cd $(UI_DIR) && $(NPM) ci && $(NPM) run test + +docker-build: tidy fmt vet test test-ui $(CONTAINER_TOOL) buildx build \ --platform $(PLATFORM) \ --build-arg VERSION=$(VERSION) \ @@ -50,3 +55,5 @@ show-vars: @echo "VERSION: $(VERSION)" @echo "PLATFORM: $(PLATFORM)" @echo "CONTAINER_TOOL: $(CONTAINER_TOOL)" + @echo "NPM: $(NPM)" + @echo "UI_DIR: $(UI_DIR)" diff --git a/README.md b/README.md index 5617c2a..9bac90f 100644 --- a/README.md +++ b/README.md @@ -1,143 +1,146 @@ -[![GitHub release](https://img.shields.io/github/v/release/alcounit/browser-ui)](https://github.com/alcounit/browser-ui/releases) [![Go Reference](https://pkg.go.dev/badge/github.com/alcounit/browser-ui.svg)](https://pkg.go.dev/github.com/alcounit/browser-ui) [![Docker Pulls](https://img.shields.io/docker/pulls/alcounit/browser-ui.svg)](https://hub.docker.com/r/alcounit/browser-ui) +# browser-ui -# Browser UI +**Web dashboard and live VNC viewer for the [Selenosis](https://github.com/alcounit/selenosis) ecosystem.** +A stateless Go server that serves a React frontend, lists live browser sessions, and proxies VNC to the browser pods — Kubernetes stays the source of truth. -Browser UI is a lightweight web application for the **Selenosis** ecosystem. -It provides a simple HTTP server that serves a static frontend and exposes a minimal backend API for browsing sessions and connecting to VNC. +[![GitHub release](https://img.shields.io/github/v/release/alcounit/browser-ui)](https://github.com/alcounit/browser-ui/releases) +[![Go Reference](https://pkg.go.dev/badge/github.com/alcounit/browser-ui.svg)](https://pkg.go.dev/github.com/alcounit/browser-ui) +[![Docker Pulls](https://img.shields.io/docker/pulls/alcounit/browser-ui.svg)](https://hub.docker.com/r/alcounit/browser-ui) +[![License](https://img.shields.io/badge/license-Apache--2.0-blue.svg)](./LICENSE)

- Browser UI dashboard + Browser UI dashboard - - Browser UI VNC view + Browser UI VNC view

--- -## Overview - -- **Frontend**: static assets built with Node and served under `/ui/` -- **Backend**: Go HTTP server providing a small API and VNC WebSocket proxy -- **Event collector**: subscribes to `browser-service` events and maintains an in-memory session store - -Browser UI is intentionally stateless: Kubernetes remains the source of truth and `browser-service` is the API boundary. +## How it fits ---- - -## Responsibilities +browser-ui is the dashboard of a small Kubernetes-native platform. It never talks to the cluster directly — it consumes **browser-service** and proxies VNC to the pods. -- Serve the web UI (static frontend) -- Provide a simple JSON API for listing and inspecting sessions -- Proxy VNC traffic from the UI to the underlying browser pod -- Track sessions in memory by consuming browser events +| Component | Role | +| --- | --- | +| **[selenosis](https://github.com/alcounit/selenosis)** | Stateless Selenium / Playwright / MCP hub. | +| **[seleniferous](https://github.com/alcounit/seleniferous)** | Sidecar proxy inside each browser pod (incl. the VNC endpoint). | +| **[browser-controller](https://github.com/alcounit/browser-controller)** | Operator reconciling `Browser` / `BrowserConfig` CRDs into pods. | +| **[browser-service](https://github.com/alcounit/browser-service)** | REST + SSE facade over the CRDs. **browser-ui talks only to this.** | +| **browser-ui** (this repo) | Web dashboard, live session list, in-browser VNC viewer. | +| **[selenosis-deploy](https://github.com/alcounit/selenosis-deploy)** | Helm chart that deploys the whole stack. | --- -## Dependency on browser-service - -Browser UI **depends on browser-service** for: +## How it works -- REST access to `Browser` resources -- Event stream (ADDED / MODIFIED / DELETED) used to populate the UI session store +- **Frontend** — React/TypeScript (noVNC, TanStack Query), built with Vite and served under `/ui/`. +- **Backend** — Go HTTP server (chi/v5, zerolog) exposing a small JSON API and a VNC WebSocket proxy. +- **Event collector** — subscribes to the `browser-service` SSE stream (ADDED / MODIFIED / DELETED) and keeps an **in-memory** session store derived from `Browser` resources. -The UI assumes that: -- `browser-service` is reachable at `BROWSER_SERVICE_URL` -- `browser-controller` and CRDs are already installed in the cluster (indirect dependency via browser-service) +browser-ui is stateless: restart it freely, run multiple replicas. It depends on `browser-service` being reachable at `BROWSER_SERVICE_URL` (and, indirectly, on the controller and CRDs being installed). --- -## HTTP Endpoints +## VNC viewer -### UI +The viewer (`GET /api/v1/browsers/{id}/vnc`) is a WebSocket proxy to the session pod's seleniferous VNC endpoint. The browser image's VNC server is password-protected, and vendors set that password differently (some bake it into the image), so there is **no global server-side password** — the user supplies it in the UI and it is resolved on the client from what was saved before: -- `GET /` → redirects to `/ui/` -- `GET /ui/` → UI entrypoint (`index.html`) -- `GET /ui/*` → static assets - -### API - -Base path: - -``` -/api/v1 -``` +1. password saved for this browser **name + version** (`localStorage`, e.g. `chrome@146.0`), +2. password saved for this browser **name** (`localStorage`, any version). -Endpoints: +**On first use (nothing saved) or when the password doesn't match**, the viewer shows an inline prompt so the user types the password. After a successful connect it offers to remember it: -- `GET /api/v1/status/` - Returns active sessions and supported browsers from the in-memory store +- **For all browsers of this name** — reused for every version of that browser (e.g. all `chrome`). +- **Only this browser version** — reused only for that exact `name + version` (e.g. `chrome 146.0`). +- **Don't save** — used once, nothing stored. -- `POST /api/v1/browsers/` - Create and start a browser session. Body: `{"browserName":"chrome","browserVersion":"146.0","selenosisOptions":{}}` +Both scopes persist in `localStorage` (the session id is random and dies with the pod, so it is never used as a key). The version-scoped password takes priority over the name-scoped one. -- `GET /api/v1/browsers/{browserId}/` - Get a single session by Browser ID +Wrong passwords surface a clear `securityfailure` message and re-prompt; a hard attempt cap prevents retry loops. This keeps a single deployment usable across mixed browser vendors without forcing one shared VNC password. -- `DELETE /api/v1/browsers/{browserId}/` - Delete a manually-started session (only sessions created via `POST /browsers/`) +--- -- `GET /api/v1/browsers/{browserId}/vnc` - WebSocket proxy to the browser pod VNC endpoint +## Configuration -- `GET /api/v1/browsers/{browserId}/vnc/settings` - Returns VNC settings (currently returns the password) +Configured via environment variables (read in `cmd/browser-ui/main.go`): -### Health +| Variable | Default | Description | +| --- | --- | --- | +| `LISTEN_ADDR` | `:8080` | HTTP listen address. | +| `BROWSER_SERVICE_URL` | `http://browser-service:8080` | `browser-service` base URL. | +| `BROWSER_NAMESPACE` | `default` | Namespace for session subscriptions. | +| `BROWSER_STARTUP_TIMEOUT` | `3m` | Max wait for a manually started browser to become ready. | +| `UI_STATIC_PATH` | `/app/static` | Path to the built frontend assets. | +| `BASIC_AUTH_FILE` | | Path to a JSON users file; when set, the UI requires login. | -- `GET /health` → returns `{"status":"ok"}` +Basic Auth is optional. When `BASIC_AUTH_FILE` is set, the UI gates the API behind a login (`/auth/login` issues an HttpOnly cookie) and the file is watched for hot reload. --- -## VNC Connectivity +## Endpoints -Browser UI exposes a WebSocket endpoint (`/vnc`) that proxies traffic to the browser pod VNC WebSocket: +
+UI, API, auth, and health routes -- Backend target (resolved from session data): - - `ws://:4445/selenosis/v1/vnc/` +**UI** +- `GET /` → redirects to `/ui/` +- `GET /ui/`, `GET /ui/*` → frontend entrypoint and static assets -The UI also exposes `/vnc/settings` for clients that need the VNC password. +**Auth** +- `GET /api/v1/auth/config` → whether auth is enabled +- `POST /api/v1/auth/login` / `POST /api/v1/auth/logout` ---- +**Sessions** (under `/api/v1`, auth-gated when enabled) +- `GET /status/` → active sessions + supported browsers from the in-memory store +- `POST /browsers/` → create/start a session — body `{"browserName":"chrome","browserVersion":"146.0","selenosisOptions":{}}` +- `GET /browsers/{browserId}/` → single session +- `DELETE /browsers/{browserId}/` → delete a manually started session +- `GET /browsers/{browserId}/vnc` → VNC WebSocket proxy to the pod -## Configuration +**Health** +- `GET /health` → `{"status":"ok"}` -Browser UI is configured using environment variables: - -- `LISTEN_ADDR` — address to listen on (default `:8080`) -- `BROWSER_SERVICE_URL` — browser-service base URL (default `http://browser-service:8080`) -- `BROWSER_NAMESPACE` — namespace used for subscriptions (default `default`) -- `VNC_PASSWORD` — VNC password exposed via `/vnc/settings` (default `secret`) -- `UI_STATIC_PATH` — path to static UI assets (default `/app/static`) +
--- -## Build and image workflow +## Build + +The project builds and packages entirely via Docker (multi-stage: Node for the frontend, Go for the backend) — a local Go/Node install is not required for the final image. -The project is built and packaged entirely via Docker. Local Go installation is not required for producing the final artifact. +```bash +make test # go tests +make test-ui # frontend unit tests (vitest) +make docker-build +``` -## Build variables +
+Makefile variables -The build process is controlled via the following Makefile variables: +| Variable | Description | +| --- | --- | +| `BINARY_NAME` | Produced binary name (fixed: `browser-ui`). | +| `REGISTRY` | Docker registry prefix (default `localhost:5000`). | +| `IMAGE_NAME` | Full image name, `$(REGISTRY)/$(BINARY_NAME)`. | +| `VERSION` | Image tag (default `develop`). | +| `EXTRA_TAGS` | Additional `-t` tags for `docker-push`. | +| `PLATFORM` | Target platform (default `linux/amd64`). | +| `CONTAINER_TOOL` | Container build tool (default `docker`). | +| `NPM` / `UI_DIR` | npm binary and frontend directory for `test-ui` (defaults `npm` / `src`). | -Variable Description -- BINARY_NAME Name of the produced binary (browser-ui). -- REGISTRY Docker registry prefix (default: localhost:5000). -- IMAGE_NAME Full image name (/browser-ui). -- VERSION Image version/tag (default: develop). -- PLATFORM Target platform (default: linux/amd64). -- CONTAINER_TOOL docker cmd +`REGISTRY` and `VERSION` are expected to be supplied externally so the same Makefile works locally and in CI. -REGISTRY, VERSION is expected to be provided externally, which allows the same Makefile to be used locally and in CI. +
--- ## Deployment -Helm chart [selenosis-deploy](https://github.com/alcounit/selenosis-deploy) +Deployed as part of the full stack via the [selenosis-deploy](https://github.com/alcounit/selenosis-deploy) Helm chart. + +## License + +[Apache-2.0](./LICENSE) diff --git a/cmd/browser-ui/main.go b/cmd/browser-ui/main.go index f8e8013..8458fd6 100644 --- a/cmd/browser-ui/main.go +++ b/cmd/browser-ui/main.go @@ -2,11 +2,15 @@ package main import ( "context" + "encoding/base64" "encoding/json" "mime" "net/http" "os" + "os/signal" "path/filepath" + "strings" + "syscall" "time" "github.com/alcounit/browser-service/pkg/broadcast" @@ -18,6 +22,7 @@ import ( "github.com/alcounit/browser-ui/pkg/types" "github.com/alcounit/browser-ui/service" "github.com/alcounit/seleniferous/v2/pkg/store" + "github.com/alcounit/selenosis/v2/pkg/auth" "github.com/alcounit/selenosis/v2/pkg/env" "github.com/rs/zerolog" @@ -33,18 +38,55 @@ func init() { mime.AddExtensionType(".css", "text/css") } +func cookieAuthMiddleware(authStore *auth.AuthStore, log zerolog.Logger) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + cookie, err := req.Cookie("browser_ui_auth") + if err != nil { + http.Error(rw, "authentication required", http.StatusUnauthorized) + return + } + decoded, err := base64.StdEncoding.DecodeString(cookie.Value) + if err != nil { + http.Error(rw, "authentication required", http.StatusUnauthorized) + return + } + parts := strings.SplitN(string(decoded), ":", 2) + if len(parts) != 2 || !authStore.Authenticate(parts[0], parts[1]) { + log.Error().Msg("request authentication failed") + http.Error(rw, "authentication failed", http.StatusUnauthorized) + return + } + req = req.WithContext(auth.WithOwner(req.Context(), auth.Owner{Name: parts[0]})) + next.ServeHTTP(rw, req) + }) + } +} + func main() { zerolog.TimeFieldFormat = time.RFC3339 zerolog.SetGlobalLevel(zerolog.InfoLevel) log := zerolog.New(os.Stdout).With().Timestamp().Logger() + ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer stop() + addr := env.GetEnvOrDefault("LISTEN_ADDR", ":8080") apiURL := env.GetEnvOrDefault("BROWSER_SERVICE_URL", "http://browser-service:8080") browserStartTimeout := env.GetEnvDurationOrDefault("BROWSER_STARTUP_TIMEOUT", 3*time.Minute) namespace := env.GetEnvOrDefault("BROWSER_NAMESPACE", "default") - vncPassword := env.GetEnvOrDefault("VNC_PASSWORD", "secret") staticPath := env.GetEnvOrDefault("UI_STATIC_PATH", "/app/static") + var authStore *auth.AuthStore + if authFilePath := env.GetEnvOrDefault("BASIC_AUTH_FILE", ""); authFilePath != "" { + var err error + if authStore, err = auth.LoadFromJSONFile(authFilePath); err != nil { + log.Fatal().Err(err).Str("path", authFilePath).Msg("BASIC_AUTH_FILE load error") + } + go auth.Watch(ctx, authStore) + log.Info().Str("path", authFilePath).Msg("basic auth enabled") + } + sessionStore := store.NewDefaultStore[*types.Session]() browserStore := store.NewDefaultStore[types.BrowserVersions]() @@ -67,7 +109,7 @@ func main() { broadcaster := broadcast.NewBroadcaster[event.BrowserEvent](10) col := collector.NewCollector(browserClient, browserConfigClient, namespace, sessionStore, browserStore, broadcaster) - collectorCtx := logctx.IntoContext(context.Background(), log) + collectorCtx := logctx.IntoContext(ctx, log) go func() { for { err := col.Run(collectorCtx) @@ -104,22 +146,70 @@ func main() { } router.Route("/api/v1", func(r chi.Router) { - r.Route("/status", func(r chi.Router) { - r.Get("/", svc.GetStatus) + r.Get("/auth/config", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(map[string]bool{"authEnabled": authStore != nil}); err != nil { + http.Error(w, "failed to encode response", http.StatusInternalServerError) + } }) - r.Route("/browsers", func(r chi.Router) { - r.Post("/", svc.CreateBrowser) - r.Route("/{browserId}", func(r chi.Router) { - r.Get("/", svc.GetBrowser) - r.Delete("/", svc.DeleteBrowser) - r.HandleFunc("/vnc", svc.RouteVNC) - r.HandleFunc("/vnc/settings", func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - if err := json.NewEncoder(w).Encode(map[string]string{"password": vncPassword}); err != nil { - log.Error().Err(err).Msg("failed to encode password response") - http.Error(w, "failed to encode response", http.StatusInternalServerError) - return - } + + r.Post("/auth/login", func(w http.ResponseWriter, req *http.Request) { + if req.Body == nil { + http.Error(w, "request body required", http.StatusBadRequest) + return + } + defer req.Body.Close() + var creds struct { + Username string `json:"username"` + Password string `json:"password"` + } + if err := json.NewDecoder(req.Body).Decode(&creds); err != nil { + http.Error(w, "invalid request", http.StatusBadRequest) + return + } + if authStore != nil && !authStore.Authenticate(creds.Username, creds.Password) { + log.Error().Str("username", creds.Username).Msg("login failed") + http.Error(w, "invalid credentials", http.StatusUnauthorized) + return + } + if authStore != nil { + token := base64.StdEncoding.EncodeToString([]byte(creds.Username + ":" + creds.Password)) + http.SetCookie(w, &http.Cookie{ + Name: "browser_ui_auth", + Value: token, + Path: "/", + HttpOnly: true, + SameSite: http.SameSiteStrictMode, + }) + } + w.WriteHeader(http.StatusOK) + }) + + r.Post("/auth/logout", func(w http.ResponseWriter, _ *http.Request) { + http.SetCookie(w, &http.Cookie{ + Name: "browser_ui_auth", + Value: "", + Path: "/", + HttpOnly: true, + MaxAge: -1, + SameSite: http.SameSiteStrictMode, + }) + w.WriteHeader(http.StatusOK) + }) + + r.Group(func(r chi.Router) { + if authStore != nil { + r.Use(cookieAuthMiddleware(authStore, log)) + } + r.Route("/status", func(r chi.Router) { + r.Get("/", svc.GetStatus) + }) + r.Route("/browsers", func(r chi.Router) { + r.Post("/", svc.CreateBrowser) + r.Route("/{browserId}", func(r chi.Router) { + r.Get("/", svc.GetBrowser) + r.Delete("/", svc.DeleteBrowser) + r.HandleFunc("/vnc", svc.RouteVNC) }) }) }) @@ -143,7 +233,25 @@ func main() { w.Write([]byte(`{"status":"ok"}`)) }) - if err := http.ListenAndServe(addr, router); err != nil { - log.Fatal().Err(err).Msg("Server failed") + srv := &http.Server{ + Addr: addr, + Handler: router, + } + + go func() { + log.Info().Msgf("HTTP server listening %s", addr) + if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { + log.Fatal().Err(err).Msg("Server failed") + } + }() + + <-ctx.Done() + stop() + log.Info().Msg("Shutting down...") + + shutdownCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 10*time.Second) + defer cancel() + if err := srv.Shutdown(shutdownCtx); err != nil { + log.Error().Err(err).Msg("Server shutdown error") } } diff --git a/go.mod b/go.mod index 0290ad2..f6052b5 100644 --- a/go.mod +++ b/go.mod @@ -3,10 +3,10 @@ module github.com/alcounit/browser-ui go 1.25.0 require ( - github.com/alcounit/browser-controller v0.0.6 - github.com/alcounit/browser-service v0.0.6 - github.com/alcounit/seleniferous/v2 v2.0.6 - github.com/alcounit/selenosis/v2 v2.0.7 + github.com/alcounit/browser-controller v0.0.8 + github.com/alcounit/browser-service v0.0.7 + github.com/alcounit/seleniferous/v2 v2.0.7 + github.com/alcounit/selenosis/v2 v2.0.8 github.com/go-chi/chi v1.5.5 github.com/go-chi/chi/v5 v5.2.3 github.com/google/uuid v1.6.0 @@ -18,6 +18,7 @@ require ( require ( dario.cat/mergo v1.0.2 // indirect + github.com/fsnotify/fsnotify v1.10.1 // indirect github.com/fxamacker/cbor/v2 v2.9.0 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/json-iterator/go v1.1.12 // indirect diff --git a/go.sum b/go.sum index 3e4ad1f..ab56ffc 100644 --- a/go.sum +++ b/go.sum @@ -1,18 +1,20 @@ dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= -github.com/alcounit/browser-controller v0.0.6 h1:EJ7WAgxIwrn4DTpNVET/L67+WhRQDduSuHAMxcPw4gg= -github.com/alcounit/browser-controller v0.0.6/go.mod h1:tpj7lwi2UJ49xt/vnEsZKAu5HAvtQJ7xbbcJO3wn+dc= -github.com/alcounit/browser-service v0.0.6 h1:cDfYNPIOSzMKWgWOBwbhg3UH8JtL4aNj5vrj5uxJCd0= -github.com/alcounit/browser-service v0.0.6/go.mod h1:el3dWyMLDd/bT2vyC6Zcsl8P5u9QmZmaTf9RFp7O1+k= -github.com/alcounit/seleniferous/v2 v2.0.6 h1:FP+7ZZYFul1u0t54ZZeesqre3J1X0APkoZNhP/+bomg= -github.com/alcounit/seleniferous/v2 v2.0.6/go.mod h1:PCyVcVJ0eAFLASuU2bIaT3UFNACG5m9KsA5ii+ZIo2k= -github.com/alcounit/selenosis/v2 v2.0.7 h1:pHn8fqnZilH1F24gWjAKhkw7sPzhbOFHz3NjTKkSW/U= -github.com/alcounit/selenosis/v2 v2.0.7/go.mod h1:naZNwLby6Jz1RR15xcDRUZ0PAsqbhAN4QDJFNNSNlb8= +github.com/alcounit/browser-controller v0.0.8 h1:hauzkisDiUsGoIKUoQqvBmi4/MrixzGp55PnZIUA6rY= +github.com/alcounit/browser-controller v0.0.8/go.mod h1:tpj7lwi2UJ49xt/vnEsZKAu5HAvtQJ7xbbcJO3wn+dc= +github.com/alcounit/browser-service v0.0.7 h1:Pv33j3QrtjZaa4QxoohzCGn1urJ20kiMLgDwKeg1y1Q= +github.com/alcounit/browser-service v0.0.7/go.mod h1:Oozpap9DWLAu2ViuJua5Q9KHXziVZUUFhacC//SVlXw= +github.com/alcounit/seleniferous/v2 v2.0.7 h1:K5QDTkGjSdfG1j+fmpTymIVuaPhZk8pt1A+qxL8zl9U= +github.com/alcounit/seleniferous/v2 v2.0.7/go.mod h1:aDZlFozcD8WV9szb5z9rx/1AXmsTjSawvcA6yIGzptI= +github.com/alcounit/selenosis/v2 v2.0.8 h1:vv2oM1diZB78xdNzpd1w2zrzfeBQc7GyzCGD0e20ats= +github.com/alcounit/selenosis/v2 v2.0.8/go.mod h1:1hiSF5IDj9Zh7PuOetmk3+W6m9KJQbFp1FFzwfi/e2k= github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho= +github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo= github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= github.com/go-chi/chi v1.5.5 h1:vOB/HbEMt9QqBqErz07QehcOKHaWFtuj87tTDVz2qXE= diff --git a/pkg/collector/collector.go b/pkg/collector/collector.go index 8f9ba4d..7a3d991 100644 --- a/pkg/collector/collector.go +++ b/pkg/collector/collector.go @@ -168,17 +168,15 @@ func storeBrowserConfig(configName string, cfg *browserconfigv1.BrowserConfig, c func storeSession(sessionId string, browser *browserv1.Browser, c *Collector) { sess := &types.Session{ - SessionId: sessionId, - BrowserId: browser.Name, - BrowserIP: browser.Status.PodIP, - BrowserName: browser.Spec.BrowserName, - BrowserVersion: browser.Spec.BrowserVersion, - StartTime: browser.CreationTimestamp.DeepCopy(), - Phase: corev1.PodPhase(browser.Status.Phase), - } - - if _, exist := browser.Annotations["startedManually"]; exist { - sess.StartedManually = true + SessionId: sessionId, + BrowserId: browser.Name, + BrowserIP: browser.Status.PodIP, + BrowserName: browser.Spec.BrowserName, + BrowserVersion: browser.Spec.BrowserVersion, + Owner: browser.Labels[browserv1.SelenosisOwnerLabelKey], + StartedManually: browser.Annotations["startedManually"] == "true", + StartTime: browser.CreationTimestamp.DeepCopy(), + Phase: corev1.PodPhase(browser.Status.Phase), } c.sessionStore.Set(browser.Name, sess) diff --git a/pkg/collector/collector_test.go b/pkg/collector/collector_test.go index e02e126..8feb3ea 100644 --- a/pkg/collector/collector_test.go +++ b/pkg/collector/collector_test.go @@ -575,6 +575,65 @@ func TestCollectorRunInitialListBrowsersInvalidIP(t *testing.T) { } } +func TestStoreSessionSetsOwnerFromLabel(t *testing.T) { + stream := &fakeStream{ + eventsCh: make(chan *event.BrowserEvent, 1), + errorsCh: make(chan error, 1), + } + client := &fakeClient{stream: stream} + st := store.NewDefaultStore[*types.Session]() + col := NewCollector(client, &fakeConfigClient{}, "default", st, store.NewDefaultStore[types.BrowserVersions](), nil) + + now := metav1.NewTime(time.Unix(0, 0).UTC()) + ev := &event.BrowserEvent{ + EventType: event.EventTypeAdded, + Browser: &browserv1.Browser{ + ObjectMeta: metav1.ObjectMeta{ + Name: "browser-owned", + CreationTimestamp: now, + Labels: map[string]string{browserv1.SelenosisOwnerLabelKey: "alice"}, + }, + Spec: browserv1.BrowserSpec{BrowserName: "chrome", BrowserVersion: "123"}, + Status: browserv1.BrowserStatus{PodIP: "127.0.0.1", Phase: corev1.PodRunning}, + }, + } + stream.eventsCh <- ev + close(stream.eventsCh) + + col.Run(context.Background()) //nolint:errcheck + + sess, ok := st.Get("browser-owned") + if !ok { + t.Fatal("expected browser-owned to be stored") + } + if sess.Owner != "alice" { + t.Fatalf("expected owner alice, got %q", sess.Owner) + } +} + +func TestStoreSessionNoOwnerLabel(t *testing.T) { + stream := &fakeStream{ + eventsCh: make(chan *event.BrowserEvent, 1), + errorsCh: make(chan error, 1), + } + client := &fakeClient{stream: stream} + st := store.NewDefaultStore[*types.Session]() + col := NewCollector(client, &fakeConfigClient{}, "default", st, store.NewDefaultStore[types.BrowserVersions](), nil) + + stream.eventsCh <- newBrowserEvent(event.EventTypeAdded, "browser-noowner", "127.0.0.1") + close(stream.eventsCh) + + col.Run(context.Background()) //nolint:errcheck + + sess, ok := st.Get("browser-noowner") + if !ok { + t.Fatal("expected browser-noowner to be stored") + } + if sess.Owner != "" { + t.Fatalf("expected empty owner, got %q", sess.Owner) + } +} + func TestCollectorRunConfigEventsStreamError(t *testing.T) { // configClient.Events returns an error. stream := &fakeStream{ diff --git a/pkg/types/session.go b/pkg/types/session.go index 7c3b885..16b0bde 100644 --- a/pkg/types/session.go +++ b/pkg/types/session.go @@ -11,6 +11,7 @@ type Session struct { BrowserIP string `json:"-"` BrowserName string `json:"browserName"` BrowserVersion string `json:"browserVersion"` + Owner string `json:"owner,omitempty"` StartedManually bool `json:"startedManually"` StartTime *metav1.Time `json:"startTime"` Phase corev1.PodPhase `json:"phase"` diff --git a/service/service.go b/service/service.go index 7dca0ab..5387e41 100644 --- a/service/service.go +++ b/service/service.go @@ -15,6 +15,7 @@ import ( logctx "github.com/alcounit/browser-controller/pkg/log" "github.com/alcounit/browser-ui/pkg/types" "github.com/alcounit/seleniferous/v2/pkg/store" + "github.com/alcounit/selenosis/v2/pkg/auth" "github.com/alcounit/selenosis/v2/pkg/selenium" "github.com/google/uuid" "github.com/gorilla/websocket" @@ -93,6 +94,15 @@ func (s *Service) GetStatus(rw http.ResponseWriter, req *http.Request) { log := logctx.FromContext(req.Context()) activeSessions := s.sessionStore.List() + if owner, ok := auth.OwnerFrom(req.Context()); ok { + filtered := make([]*types.Session, 0, len(activeSessions)) + for _, sess := range activeSessions { + if sess.Owner == owner.Name { + filtered = append(filtered, sess) + } + } + activeSessions = filtered + } supportedBrowsers := s.configStore.List() response := struct { @@ -153,6 +163,12 @@ func (s *Service) CreateBrowser(rw http.ResponseWriter, req *http.Request) { }, } + if owner, ok := auth.OwnerFrom(req.Context()); ok { + template.Labels = map[string]string{ + browserv1.SelenosisOwnerLabelKey: owner.Name, + } + } + var err error template.ObjectMeta.Annotations, err = setSelenosisOptions(template.ObjectMeta.Annotations, request.SelenosisOptions) if err != nil { diff --git a/service/service_test.go b/service/service_test.go index 8a194d9..1e6ca67 100644 --- a/service/service_test.go +++ b/service/service_test.go @@ -16,6 +16,7 @@ import ( "github.com/alcounit/browser-service/pkg/event" "github.com/alcounit/browser-ui/pkg/types" "github.com/alcounit/seleniferous/v2/pkg/store" + "github.com/alcounit/selenosis/v2/pkg/auth" "github.com/go-chi/chi/v5" "github.com/gorilla/websocket" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -541,18 +542,19 @@ func TestIsNormalWSDisconnect(t *testing.T) { // fakeBrowserClient implements browserclient.Client for testing. type fakeBrowserClient struct { - createErr error - browser *browserv1.Browser + createErr error + browser *browserv1.Browser + lastCreated *browserv1.Browser } func (c *fakeBrowserClient) Create(ctx context.Context, namespace string, browser *browserv1.Browser) (*browserv1.Browser, error) { + c.lastCreated = browser if c.createErr != nil { return nil, c.createErr } if c.browser != nil { return c.browser, nil } - // Return the browser with the same name as provided. return browser, nil } @@ -929,10 +931,266 @@ func TestSetSelenosisOptionsExistingAnnotations(t *testing.T) { } func TestSetSelenosisOptionsMarshalError(t *testing.T) { - // Use a channel value inside opts to trigger json.Marshal error. opts := map[string]any{"key": make(chan int)} _, err := setSelenosisOptions(nil, opts) if err == nil { t.Fatalf("expected marshal error, got nil") } } + +func TestCreateBrowserSetsOwnerLabel(t *testing.T) { + st := store.NewDefaultStore[*types.Session]() + + now := metav1.Now() + returnedBrowser := &browserv1.Browser{ + ObjectMeta: metav1.ObjectMeta{ + Name: "browser-owner", + CreationTimestamp: now, + }, + } + + st.Set("browser-owner", &types.Session{ + SessionId: "sess-owner", + BrowserId: "browser-owner", + BrowserIP: "127.0.0.1", + }) + + cl := &fakeBrowserClient{browser: returnedBrowser} + svc := NewService(cl, "default", st, store.NewDefaultStore[types.BrowserVersions](), 5*time.Second) + + prevHTTPClient := httpClient + httpClient = &mockTransport{resp: &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader("")), + }} + defer func() { httpClient = prevHTTPClient }() + + body := `{"browserName":"chrome","browserVersion":"123"}` + req := httptest.NewRequest(http.MethodPost, "/browsers", strings.NewReader(body)) + req = req.WithContext(auth.WithOwner(req.Context(), auth.Owner{Name: "alice"})) + rw := httptest.NewRecorder() + + svc.CreateBrowser(rw, req) + + if rw.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d", rw.Code) + } + if cl.lastCreated == nil { + t.Fatal("expected browser to be captured") + } + if cl.lastCreated.Labels[browserv1.SelenosisOwnerLabelKey] != "alice" { + t.Fatalf("expected owner label alice, got %q", cl.lastCreated.Labels[browserv1.SelenosisOwnerLabelKey]) + } +} + +func TestCreateBrowserNoOwnerLabel(t *testing.T) { + st := store.NewDefaultStore[*types.Session]() + + now := metav1.Now() + returnedBrowser := &browserv1.Browser{ + ObjectMeta: metav1.ObjectMeta{ + Name: "browser-noowner", + CreationTimestamp: now, + }, + } + + st.Set("browser-noowner", &types.Session{ + SessionId: "sess-noowner", + BrowserId: "browser-noowner", + BrowserIP: "127.0.0.1", + }) + + cl := &fakeBrowserClient{browser: returnedBrowser} + svc := NewService(cl, "default", st, store.NewDefaultStore[types.BrowserVersions](), 5*time.Second) + + prevHTTPClient := httpClient + httpClient = &mockTransport{resp: &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader("")), + }} + defer func() { httpClient = prevHTTPClient }() + + body := `{"browserName":"chrome","browserVersion":"123"}` + req := httptest.NewRequest(http.MethodPost, "/browsers", strings.NewReader(body)) + rw := httptest.NewRecorder() + + svc.CreateBrowser(rw, req) + + if rw.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d", rw.Code) + } + if cl.lastCreated == nil { + t.Fatal("expected browser to be captured") + } + if len(cl.lastCreated.Labels) != 0 { + t.Fatalf("expected no labels, got %v", cl.lastCreated.Labels) + } +} + +func TestGetStatusFiltersSessionsByOwner(t *testing.T) { + st := store.NewDefaultStore[*types.Session]() + st.Set("b-alice", &types.Session{SessionId: "s1", BrowserId: "b-alice", Owner: "alice"}) + st.Set("b-bob", &types.Session{SessionId: "s2", BrowserId: "b-bob", Owner: "bob"}) + st.Set("b-alice2", &types.Session{SessionId: "s3", BrowserId: "b-alice2", Owner: "alice"}) + + svc := NewService(nil, "", st, store.NewDefaultStore[types.BrowserVersions](), 5*time.Second) + req := httptest.NewRequest(http.MethodGet, "/status", nil) + req = req.WithContext(auth.WithOwner(req.Context(), auth.Owner{Name: "alice"})) + rw := httptest.NewRecorder() + + svc.GetStatus(rw, req) + + if rw.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d", rw.Code) + } + + var got struct { + Sessions []*types.Session `json:"activeSessions"` + } + if err := json.Unmarshal(rw.Body.Bytes(), &got); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + if len(got.Sessions) != 2 { + t.Fatalf("expected 2 sessions for alice, got %d", len(got.Sessions)) + } + for _, s := range got.Sessions { + if s.Owner != "alice" { + t.Fatalf("expected owner alice, got %s", s.Owner) + } + } +} + +func TestGetStatusNoFilterWithoutOwner(t *testing.T) { + st := store.NewDefaultStore[*types.Session]() + st.Set("b-alice", &types.Session{SessionId: "s1", BrowserId: "b-alice", Owner: "alice"}) + st.Set("b-bob", &types.Session{SessionId: "s2", BrowserId: "b-bob", Owner: "bob"}) + + svc := NewService(nil, "", st, store.NewDefaultStore[types.BrowserVersions](), 5*time.Second) + req := httptest.NewRequest(http.MethodGet, "/status", nil) + rw := httptest.NewRecorder() + + svc.GetStatus(rw, req) + + if rw.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d", rw.Code) + } + + var got struct { + Sessions []*types.Session `json:"activeSessions"` + } + if err := json.Unmarshal(rw.Body.Bytes(), &got); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + if len(got.Sessions) != 2 { + t.Fatalf("expected 2 sessions (no filter), got %d", len(got.Sessions)) + } +} + +func TestDeleteBrowserNotFound(t *testing.T) { + svc := NewService(nil, "", store.NewDefaultStore[*types.Session](), store.NewDefaultStore[types.BrowserVersions](), 5*time.Second) + req := requestWithParam(http.MethodDelete, "/browsers/missing", "browserId", "missing") + rw := httptest.NewRecorder() + + svc.DeleteBrowser(rw, req) + + if rw.Code != http.StatusNotFound { + t.Fatalf("expected status 404, got %d", rw.Code) + } +} + +func TestDeleteBrowserNotManual(t *testing.T) { + st := store.NewDefaultStore[*types.Session]() + st.Set("b1", &types.Session{BrowserId: "b1", StartedManually: false}) + + svc := NewService(nil, "", st, store.NewDefaultStore[types.BrowserVersions](), 5*time.Second) + req := requestWithParam(http.MethodDelete, "/browsers/b1", "browserId", "b1") + rw := httptest.NewRecorder() + + svc.DeleteBrowser(rw, req) + + if rw.Code != http.StatusBadRequest { + t.Fatalf("expected status 400, got %d", rw.Code) + } +} + +func TestDeleteBrowserHTTPClientError(t *testing.T) { + st := store.NewDefaultStore[*types.Session]() + st.Set("b1", &types.Session{BrowserId: "b1", BrowserIP: "127.0.0.1", SessionId: "s1", StartedManually: true}) + + svc := NewService(nil, "", st, store.NewDefaultStore[types.BrowserVersions](), 5*time.Second) + + prevHTTPClient := httpClient + httpClient = &mockTransport{err: errors.New("http error")} + defer func() { httpClient = prevHTTPClient }() + + req := requestWithParam(http.MethodDelete, "/browsers/b1", "browserId", "b1") + rw := httptest.NewRecorder() + + svc.DeleteBrowser(rw, req) + + if rw.Code != http.StatusInternalServerError { + t.Fatalf("expected status 500, got %d", rw.Code) + } +} + +func TestDeleteBrowserBackend500(t *testing.T) { + st := store.NewDefaultStore[*types.Session]() + st.Set("b1", &types.Session{BrowserId: "b1", BrowserIP: "127.0.0.1", SessionId: "s1", StartedManually: true}) + + svc := NewService(nil, "", st, store.NewDefaultStore[types.BrowserVersions](), 5*time.Second) + + prevHTTPClient := httpClient + httpClient = &mockTransport{resp: &http.Response{ + StatusCode: http.StatusInternalServerError, + Body: io.NopCloser(strings.NewReader("")), + }} + defer func() { httpClient = prevHTTPClient }() + + req := requestWithParam(http.MethodDelete, "/browsers/b1", "browserId", "b1") + rw := httptest.NewRecorder() + + svc.DeleteBrowser(rw, req) + + if rw.Code != http.StatusInternalServerError { + t.Fatalf("expected status 500, got %d", rw.Code) + } +} + +func TestDeleteBrowserSuccess(t *testing.T) { + st := store.NewDefaultStore[*types.Session]() + st.Set("b1", &types.Session{BrowserId: "b1", BrowserIP: "127.0.0.1", SessionId: "s1", StartedManually: true}) + + svc := NewService(nil, "", st, store.NewDefaultStore[types.BrowserVersions](), 5*time.Second) + + prevHTTPClient := httpClient + httpClient = &mockTransport{resp: &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader("")), + }} + defer func() { httpClient = prevHTTPClient }() + + req := requestWithParam(http.MethodDelete, "/browsers/b1", "browserId", "b1") + rw := httptest.NewRecorder() + + svc.DeleteBrowser(rw, req) + + if rw.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d", rw.Code) + } +} + +func TestDeleteBrowserBuildRequestError(t *testing.T) { + st := store.NewDefaultStore[*types.Session]() + st.Set("b1", &types.Session{BrowserId: "b1", BrowserIP: "127.0.0.1\x01", SessionId: "s1", StartedManually: true}) + + svc := NewService(nil, "", st, store.NewDefaultStore[types.BrowserVersions](), 5*time.Second) + + req := requestWithParam(http.MethodDelete, "/browsers/b1", "browserId", "b1") + rw := httptest.NewRecorder() + + svc.DeleteBrowser(rw, req) + + if rw.Code != http.StatusInternalServerError { + t.Fatalf("expected status 500, got %d", rw.Code) + } +} diff --git a/src/App.tsx b/src/App.tsx index 6833f7f..7a8d8f2 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,20 +1,82 @@ import React from 'react'; -import { Routes, Route, Navigate } from 'react-router-dom'; +import { Routes, Route, Navigate, useNavigate } from 'react-router-dom'; import { Dashboard } from './pages/Dashboard'; import { VNCView } from './pages/VNCView'; import { StartBrowser } from './pages/StartBrowser'; +import { LoginPage } from './pages/LoginPage'; + +interface AuthConfig { + authEnabled: boolean; +} + +const AuthContext = React.createContext<{ authEnabled: boolean; onUnauthorized: () => void }>({ + authEnabled: false, + onUnauthorized: () => {}, +}); + +export function useAuth() { + return React.useContext(AuthContext); +} + +function ProtectedRoute({ children }: { children: React.ReactNode }) { + const { authEnabled } = useAuth(); + const [checked, setChecked] = React.useState(!authEnabled); + const [authed, setAuthed] = React.useState(false); + const navigate = useNavigate(); + + React.useEffect(() => { + if (!authEnabled) return; + fetch('/api/v1/status') + .then(res => { + if (res.status === 401) { + navigate('/ui/login', { replace: true }); + } else { + setAuthed(true); + } + }) + .catch(() => navigate('/ui/login', { replace: true })) + .finally(() => setChecked(true)); + }, [authEnabled, navigate]); + + if (!checked) return null; + if (authEnabled && !authed) return null; + return <>{children}; +} + +function AppRoutes({ authEnabled }: { authEnabled: boolean }) { + const navigate = useNavigate(); + + const onUnauthorized = React.useCallback(() => { + navigate('/ui/login', { replace: true }); + }, [navigate]); -function App() { return ( - - } /> - } /> - } /> - } /> - {/* Fallback */} - } /> - + + + } /> + } /> + } /> + } /> + } /> + } /> + + ); } -export default App; \ No newline at end of file +function App() { + const [config, setConfig] = React.useState(null); + + React.useEffect(() => { + fetch('/api/v1/auth/config') + .then(res => res.json()) + .then((data: AuthConfig) => setConfig(data)) + .catch(() => setConfig({ authEnabled: false })); + }, []); + + if (config === null) return null; + + return ; +} + +export default App; diff --git a/src/components/VncPasswordModals.test.tsx b/src/components/VncPasswordModals.test.tsx new file mode 100644 index 0000000..1630b03 --- /dev/null +++ b/src/components/VncPasswordModals.test.tsx @@ -0,0 +1,64 @@ +import { describe, it, expect, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { PasswordPromptModal, SavePasswordModal } from "./VncPasswordModals"; + +describe("PasswordPromptModal", () => { + it("disables Connect until a password is typed, then submits it", async () => { + const onSubmit = vi.fn(); + const onCancel = vi.fn(); + render(); + + const connect = screen.getByRole("button", { name: "Connect" }); + expect(connect).toBeDisabled(); + + await userEvent.type(screen.getByLabelText("VNC password"), "secret"); + expect(connect).toBeEnabled(); + + await userEvent.click(connect); + expect(onSubmit).toHaveBeenCalledWith("secret"); + }); + + it("does not submit an empty password via Enter", async () => { + const onSubmit = vi.fn(); + render( {}} />); + + await userEvent.type(screen.getByLabelText("VNC password"), "{Enter}"); + expect(onSubmit).not.toHaveBeenCalled(); + }); + + it("calls onCancel when Cancel is clicked", async () => { + const onCancel = vi.fn(); + render( {}} onCancel={onCancel} />); + + await userEvent.click(screen.getByRole("button", { name: "Cancel" })); + expect(onCancel).toHaveBeenCalledTimes(1); + }); + + it("shows the reuse warning and error reason when provided", () => { + render( + {}} + onCancel={() => {}} + warnReused + errorReason="authentication failed" + />, + ); + + expect(screen.getByText("This password was already tried.")).toBeInTheDocument(); + expect(screen.getByText("authentication failed")).toBeInTheDocument(); + }); +}); + +describe("SavePasswordModal", () => { + it("reports the chosen scope for each button", async () => { + const onChoose = vi.fn(); + render(); + + await userEvent.click(screen.getByRole("button", { name: 'Save for all "chrome"' })); + await userEvent.click(screen.getByRole("button", { name: 'Only "chrome 123"' })); + await userEvent.click(screen.getByRole("button", { name: "Don't save" })); + + expect(onChoose.mock.calls).toEqual([["name"], ["version"], [null]]); + }); +}); diff --git a/src/components/VncPasswordModals.tsx b/src/components/VncPasswordModals.tsx new file mode 100644 index 0000000..cc5f64d --- /dev/null +++ b/src/components/VncPasswordModals.tsx @@ -0,0 +1,85 @@ +import React, { useEffect, useRef, useState } from "react"; + +export type SaveScope = "name" | "version" | null; + +interface PasswordPromptModalProps { + onSubmit: (password: string) => void; + onCancel: () => void; + warnReused?: boolean; + errorReason?: string; +} + +export const PasswordPromptModal: React.FC = ({ + onSubmit, + onCancel, + warnReused, + errorReason, +}) => { + const [value, setValue] = useState(""); + const inputRef = useRef(null); + + useEffect(() => { + inputRef.current?.focus(); + }, []); + + const handleSubmit = (event: React.FormEvent) => { + event.preventDefault(); + onSubmit(value); + }; + + return ( +
+
+
VNC password required
+ {errorReason &&
{errorReason}
} + {warnReused && ( +
This password was already tried.
+ )} + setValue(e.target.value)} + aria-label="VNC password" + /> +
+ + +
+
+
+ ); +}; + +interface SavePasswordModalProps { + browserName: string; + browserVersion: string; + onChoose: (scope: SaveScope) => void; +} + +export const SavePasswordModal: React.FC = ({ browserName, browserVersion, onChoose }) => { + return ( +
+
+
Save this password?
+
+ + + +
+
+
+ ); +}; diff --git a/src/index.css b/src/index.css index 8a1e076..f3608da 100644 --- a/src/index.css +++ b/src/index.css @@ -441,6 +441,71 @@ body { .status-connected { color: var(--success); font-weight: 600; } .status-disconnected { color: #ff5f57; font-weight: 600; } +.logout-btn { + background: transparent; color: var(--text-secondary); + border: 1px solid var(--border); border-radius: 10px; + padding: 7px 14px; font-size: 13px; font-weight: 600; + cursor: pointer; white-space: nowrap; margin-left: 16px; + transition: background 0.2s, color 0.2s; +} +.logout-btn:hover:not(:disabled) { background: var(--border); color: var(--text); } +.logout-btn:disabled { opacity: 0.5; cursor: not-allowed; } + +.login-page { + height: 100vh; display: flex; align-items: center; justify-content: center; + background: var(--bg); +} + +.login-card { + background: var(--surface); backdrop-filter: blur(20px); + border-radius: 20px; padding: 40px 40px 36px; + box-shadow: var(--shadow-large); border: 1px solid var(--border); + width: 100%; max-width: 360px; +} + +.login-title { + font-size: 18px; font-weight: 700; letter-spacing: -0.4px; + text-align: center; margin-bottom: 28px; color: var(--text); +} + +.login-form { display: flex; flex-direction: column; gap: 16px; } + +.login-field { display: flex; flex-direction: column; gap: 6px; } + +.login-label { font-size: 13px; font-weight: 500; color: var(--text-secondary); } + +.login-input { + padding: 10px 14px; border-radius: 10px; + border: 1px solid var(--border); background: var(--surface); + font-size: 15px; color: var(--text); outline: none; + transition: border-color 0.2s; +} +.login-input:focus { border-color: var(--primary); } +.login-input:disabled { opacity: 0.6; } + +.login-error { + background: rgba(255, 95, 87, 0.1); color: #ff5f57; + border: 1px solid rgba(255, 95, 87, 0.3); border-radius: 10px; + padding: 10px 14px; font-size: 13px; +} + +.login-btn { + margin-top: 4px; padding: 11px; border-radius: 10px; + background: var(--primary); color: white; border: none; + font-size: 15px; font-weight: 600; cursor: pointer; + display: flex; align-items: center; justify-content: center; + min-height: 44px; transition: background 0.2s; +} +.login-btn:hover:not(:disabled) { background: var(--primary-hover); } +.login-btn:disabled { opacity: 0.6; cursor: not-allowed; } + +.login-spinner { + width: 18px; height: 18px; border-radius: 50%; + border: 2px solid rgba(255,255,255,0.4); + border-top-color: white; + animation: spin 0.7s linear infinite; +} + @media (max-width: 900px) { .vnc-window { width: 92%; @@ -448,3 +513,62 @@ body { transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); } } + +.vnc-modal-overlay { + position: fixed; inset: 0; z-index: 11000; + display: flex; align-items: center; justify-content: center; + background: rgba(0, 0, 0, 0.45); + backdrop-filter: blur(2px); + padding: 20px; +} + +.vnc-modal { + background: var(--surface); backdrop-filter: blur(20px); + border-radius: 16px; padding: 24px; + box-shadow: var(--shadow-large); border: 1px solid var(--border); + width: 100%; max-width: 340px; + display: flex; flex-direction: column; gap: 14px; +} + +.vnc-modal-title { + font-size: 16px; font-weight: 700; letter-spacing: -0.3px; + color: var(--text); +} + +.vnc-modal-error { + background: rgba(255, 95, 87, 0.1); color: #ff5f57; + border: 1px solid rgba(255, 95, 87, 0.3); border-radius: 10px; + padding: 8px 12px; font-size: 13px; +} + +.vnc-modal-warn { + background: rgba(255, 159, 10, 0.12); color: #b25b00; + border: 1px solid rgba(255, 159, 10, 0.3); border-radius: 10px; + padding: 8px 12px; font-size: 13px; +} + +.vnc-modal-input { + padding: 10px 14px; border-radius: 10px; + border: 1px solid var(--border); background: var(--surface); + font-size: 15px; color: var(--text); outline: none; + transition: border-color 0.2s; +} +.vnc-modal-input:focus { border-color: var(--primary); } + +.vnc-modal-actions { + display: flex; gap: 10px; flex-wrap: wrap; justify-content: flex-end; +} + +.vnc-modal-btn { + padding: 9px 16px; border-radius: 10px; + border: 1px solid var(--border); background: var(--surface); + font-size: 14px; font-weight: 600; color: var(--text); + cursor: pointer; transition: background 0.2s, border-color 0.2s; +} +.vnc-modal-btn:hover:not(:disabled) { background: rgba(0, 0, 0, 0.04); } +.vnc-modal-btn:disabled { opacity: 0.5; cursor: not-allowed; } + +.vnc-modal-btn[type="submit"] { + background: var(--primary); color: #fff; border-color: var(--primary); +} +.vnc-modal-btn[type="submit"]:hover:not(:disabled) { background: var(--primary-hover); } diff --git a/src/lib/vncAuth.test.ts b/src/lib/vncAuth.test.ts new file mode 100644 index 0000000..cde6971 --- /dev/null +++ b/src/lib/vncAuth.test.ts @@ -0,0 +1,138 @@ +import { describe, it, expect } from "vitest"; +import { + MAX_AUTH_ATTEMPTS, + newAttemptState, + buildLadder, + nextCandidate, + registerFailure, + markManual, + isReused, + isHardCapReached, + shouldPrompt, +} from "./vncAuth"; + +describe("buildLadder", () => { + it("drops empty and duplicate candidates preserving order", () => { + expect(buildLadder("a", null, "b", undefined, "a", "", "c")).toEqual(["a", "b", "c"]); + }); + + it("returns empty when no candidates", () => { + expect(buildLadder(null, undefined, "")).toEqual([]); + }); +}); + +describe("nextCandidate", () => { + it("returns first untried candidate", () => { + const tried = new Set(["a"]); + expect(nextCandidate(["a", "b", "c"], tried)).toBe("b"); + }); + + it("returns null when all tried", () => { + const tried = new Set(["a", "b"]); + expect(nextCandidate(["a", "b"], tried)).toBeNull(); + }); + + it("returns null for empty ladder", () => { + expect(nextCandidate([], new Set())).toBeNull(); + }); +}); + +describe("registerFailure", () => { + it("records the password and increments attempts without mutating input", () => { + const state = newAttemptState(); + const next = registerFailure(state, "a"); + expect(next.tried.has("a")).toBe(true); + expect(next.attempts).toBe(1); + expect(state.tried.has("a")).toBe(false); + expect(state.attempts).toBe(0); + }); +}); + +describe("markManual / isReused", () => { + it("marks manual mode", () => { + expect(markManual(newAttemptState()).manual).toBe(true); + }); + + it("detects reused passwords", () => { + const state = registerFailure(newAttemptState(), "a"); + expect(isReused(state, "a")).toBe(true); + expect(isReused(state, "b")).toBe(false); + }); +}); + +describe("isHardCapReached", () => { + it("is false below the cap and true at the cap", () => { + let state = newAttemptState(); + expect(isHardCapReached(state)).toBe(false); + for (let i = 0; i < MAX_AUTH_ATTEMPTS; i++) { + state = registerFailure(state, `p${i}`); + } + expect(isHardCapReached(state)).toBe(true); + }); + + it("honors a custom cap", () => { + const state = registerFailure(newAttemptState(), "a"); + expect(isHardCapReached(state, 1)).toBe(true); + }); +}); + +describe("shouldPrompt", () => { + it("is false while auto candidates remain", () => { + const state = registerFailure(newAttemptState(), "a"); + expect(shouldPrompt(state, ["a", "b"])).toBe(false); + }); + + it("is true when auto candidates are exhausted", () => { + let state = registerFailure(newAttemptState(), "a"); + state = registerFailure(state, "b"); + expect(shouldPrompt(state, ["a", "b"])).toBe(true); + }); + + it("is false once the hard cap is reached even with candidates left", () => { + let state = newAttemptState(); + for (let i = 0; i < MAX_AUTH_ATTEMPTS; i++) { + state = registerFailure(state, `p${i}`); + } + expect(shouldPrompt(state, ["fresh"], MAX_AUTH_ATTEMPTS)).toBe(false); + }); +}); + +describe("no infinite loop when every password fails", () => { + it("walks the finite auto ladder then asks for manual input exactly once", () => { + const ladder = buildLadder("byName", "current", "cookie", "default"); + let state = newAttemptState(); + let prompts = 0; + + let candidate = nextCandidate(ladder, state.tried); + let guard = 0; + while (candidate !== null) { + guard++; + expect(guard).toBeLessThanOrEqual(ladder.length + 1); + state = registerFailure(state, candidate); + if (shouldPrompt(state, ladder)) { + prompts++; + break; + } + candidate = nextCandidate(ladder, state.tried); + } + + expect(state.attempts).toBe(ladder.length); + expect(prompts).toBe(1); + expect(nextCandidate(ladder, state.tried)).toBeNull(); + }); + + it("terminates via the hard cap when manual entries keep failing", () => { + let state = markManual(newAttemptState()); + const ladder: string[] = []; + let manualSubmits = 0; + + while (!isHardCapReached(state)) { + manualSubmits++; + state = registerFailure(state, `manual-${manualSubmits}`); + } + + expect(isHardCapReached(state)).toBe(true); + expect(shouldPrompt(state, ladder)).toBe(false); + expect(manualSubmits).toBe(MAX_AUTH_ATTEMPTS); + }); +}); diff --git a/src/lib/vncAuth.ts b/src/lib/vncAuth.ts new file mode 100644 index 0000000..0698a7d --- /dev/null +++ b/src/lib/vncAuth.ts @@ -0,0 +1,53 @@ +export const MAX_AUTH_ATTEMPTS = 5; + +export interface AttemptState { + tried: Set; + attempts: number; + manual: boolean; +} + +export function newAttemptState(): AttemptState { + return { tried: new Set(), attempts: 0, manual: false }; +} + +export function buildLadder(...candidates: Array): string[] { + const out: string[] = []; + const seen = new Set(); + for (const candidate of candidates) { + if (!candidate) continue; + if (seen.has(candidate)) continue; + seen.add(candidate); + out.push(candidate); + } + return out; +} + +export function nextCandidate(ladder: string[], tried: Set): string | null { + for (const password of ladder) { + if (!tried.has(password)) return password; + } + return null; +} + +export function registerFailure(state: AttemptState, password: string): AttemptState { + const tried = new Set(state.tried); + tried.add(password); + return { ...state, tried, attempts: state.attempts + 1 }; +} + +export function markManual(state: AttemptState): AttemptState { + return { ...state, manual: true }; +} + +export function isReused(state: AttemptState, password: string): boolean { + return state.tried.has(password); +} + +export function isHardCapReached(state: AttemptState, max: number = MAX_AUTH_ATTEMPTS): boolean { + return state.attempts >= max; +} + +export function shouldPrompt(state: AttemptState, ladder: string[], max: number = MAX_AUTH_ATTEMPTS): boolean { + if (isHardCapReached(state, max)) return false; + return nextCandidate(ladder, state.tried) === null; +} diff --git a/src/lib/vncStore.test.ts b/src/lib/vncStore.test.ts new file mode 100644 index 0000000..0485ce6 --- /dev/null +++ b/src/lib/vncStore.test.ts @@ -0,0 +1,73 @@ +import { describe, it, expect } from "vitest"; +import { + NAME_PREFIX, + VERSION_PREFIX, + getSavedByName, + setSavedByName, + getSavedByVersion, + setSavedByVersion, +} from "./vncStore"; + +function memoryStorage(): Storage { + const map = new Map(); + return { + get length() { + return map.size; + }, + clear: () => map.clear(), + getItem: (k: string) => (map.has(k) ? map.get(k)! : null), + key: (i: number) => Array.from(map.keys())[i] ?? null, + removeItem: (k: string) => map.delete(k), + setItem: (k: string, v: string) => { + map.set(k, v); + }, + } as Storage; +} + +describe("saved-by-name", () => { + it("writes and reads under the name prefix", () => { + const s = memoryStorage(); + setSavedByName("chrome", "secret", s); + expect(s.getItem(NAME_PREFIX + "chrome")).toBe("secret"); + expect(getSavedByName("chrome", s)).toBe("secret"); + }); + + it("returns null for missing or empty name", () => { + const s = memoryStorage(); + expect(getSavedByName("firefox", s)).toBeNull(); + expect(getSavedByName("", s)).toBeNull(); + setSavedByName("", "x", s); + expect(s.length).toBe(0); + }); +}); + +describe("saved-by-version", () => { + it("writes and reads under the version prefix keyed by name@version", () => { + const s = memoryStorage(); + setSavedByVersion("chrome", "123", "pw", s); + expect(s.getItem(VERSION_PREFIX + "chrome@123")).toBe("pw"); + expect(getSavedByVersion("chrome", "123", s)).toBe("pw"); + }); + + it("returns null and skips writes when name or version is empty", () => { + const s = memoryStorage(); + expect(getSavedByVersion("chrome", "999", s)).toBeNull(); + expect(getSavedByVersion("", "123", s)).toBeNull(); + expect(getSavedByVersion("chrome", "", s)).toBeNull(); + setSavedByVersion("", "123", "x", s); + setSavedByVersion("chrome", "", "x", s); + expect(s.length).toBe(0); + }); +}); + +describe("default browser storage", () => { + it("uses localStorage when no storage is injected", () => { + localStorage.clear(); + + setSavedByName("chrome", "byname", localStorage); + expect(getSavedByName("chrome")).toBe("byname"); + + setSavedByVersion("chrome", "123", "byver", localStorage); + expect(getSavedByVersion("chrome", "123")).toBe("byver"); + }); +}); diff --git a/src/lib/vncStore.ts b/src/lib/vncStore.ts new file mode 100644 index 0000000..6779c0a --- /dev/null +++ b/src/lib/vncStore.ts @@ -0,0 +1,26 @@ +export const NAME_PREFIX = "vnc.pw.name."; +export const VERSION_PREFIX = "vnc.pw.ver."; + +export function getSavedByName(name: string, storage: Storage = localStorage): string | null { + if (!name) return null; + return storage.getItem(NAME_PREFIX + name); +} + +export function setSavedByName(name: string, password: string, storage: Storage = localStorage): void { + if (!name) return; + storage.setItem(NAME_PREFIX + name, password); +} + +export function versionKey(name: string, version: string): string { + return `${name}@${version}`; +} + +export function getSavedByVersion(name: string, version: string, storage: Storage = localStorage): string | null { + if (!name || !version) return null; + return storage.getItem(VERSION_PREFIX + versionKey(name, version)); +} + +export function setSavedByVersion(name: string, version: string, password: string, storage: Storage = localStorage): void { + if (!name || !version) return; + storage.setItem(VERSION_PREFIX + versionKey(name, version), password); +} diff --git a/src/package-lock.json b/src/package-lock.json index f447cf5..a880d30 100644 --- a/src/package-lock.json +++ b/src/package-lock.json @@ -15,13 +15,57 @@ "react-router-dom": "^6.20.0" }, "devDependencies": { + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^14.3.1", + "@testing-library/user-event": "^14.6.1", "@types/react": "^18.2.0", "@types/react-dom": "^18.2.0", "@vitejs/plugin-react": "^4.2.0", + "@vitest/coverage-v8": "^1.6.1", + "jsdom": "^24.1.3", "typescript": "^5.2.0", - "vite": "^5.0.0" + "vite": "^5.0.0", + "vitest": "^1.6.1" + } + }, + "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 + }, + "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, + "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, + "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 + }, "node_modules/@babel/code-frame": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", @@ -240,6 +284,15 @@ "@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, + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/template": { "version": "7.27.2", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", @@ -285,6 +338,122 @@ "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 + }, + "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" + } + ], + "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" + } + ], + "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" + } + ], + "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" + } + ], + "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" + } + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", @@ -653,6 +822,27 @@ "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, + "engines": { + "node": ">=8" + } + }, + "node_modules/@jest/schemas": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", + "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==", + "dev": true, + "dependencies": { + "@sinclair/typebox": "^0.27.8" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -1003,6 +1193,12 @@ "win32" ] }, + "node_modules/@sinclair/typebox": { + "version": "0.27.10", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.10.tgz", + "integrity": "sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==", + "dev": true + }, "node_modules/@tanstack/query-core": { "version": "5.90.11", "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.11.tgz", @@ -1027,6 +1223,116 @@ "react": "^18 || ^19" } }, + "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, + "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, + "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 + }, + "node_modules/@testing-library/react": { + "version": "14.3.1", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-14.3.1.tgz", + "integrity": "sha512-H99XjUhWQw0lTgyMN05W3xQG1Nh4lq574D8keFf1dDoNTJgp66VbJozRaczoF+wsiaPJNt/TcnfpLGufGxSrZQ==", + "dev": true, + "dependencies": { + "@babel/runtime": "^7.12.5", + "@testing-library/dom": "^9.0.0", + "@types/react-dom": "^18.0.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "react": "^18.0.0", + "react-dom": "^18.0.0" + } + }, + "node_modules/@testing-library/react/node_modules/@testing-library/dom": { + "version": "9.3.4", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-9.3.4.tgz", + "integrity": "sha512-FlS4ZWlp97iiNWig0Muq8p+3rVDjRiYE+YKGbAqXOu9nwJFFOdL00kFpz42M+4huzYi86vAK1sOOfyOG45muIQ==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.1.3", + "chalk": "^4.1.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@testing-library/react/node_modules/aria-query": { + "version": "5.1.3", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.1.3.tgz", + "integrity": "sha512-R5iJ5lkuHybztUfuOAznmboyjWq8O6sqNqtK7CLOqdydi54VNbORp49mb14KbWgG1QD3JFO9hJdZ+y4KutfdOQ==", + "dev": true, + "dependencies": { + "deep-equal": "^2.0.5" + } + }, + "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, + "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 + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -1119,413 +1425,2591 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, - "node_modules/baseline-browser-mapping": { - "version": "2.8.32", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.32.tgz", - "integrity": "sha512-OPz5aBThlyLFgxyhdwf/s2+8ab3OvT7AdTNvKHBwpXomIYeXqpUUuT8LrdtxZSsWJ4R4CU1un4XGh5Ez3nlTpw==", + "node_modules/@vitest/coverage-v8": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-1.6.1.tgz", + "integrity": "sha512-6YeRZwuO4oTGKxD3bijok756oktHSIm3eczVVzNe3scqzuhLwltIF3S9ZL/vwOVIpURmU6SnZhziXXAfw8/Qlw==", "dev": true, - "bin": { - "baseline-browser-mapping": "dist/cli.js" + "dependencies": { + "@ampproject/remapping": "^2.2.1", + "@bcoe/v8-coverage": "^0.2.3", + "debug": "^4.3.4", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-lib-source-maps": "^5.0.4", + "istanbul-reports": "^3.1.6", + "magic-string": "^0.30.5", + "magicast": "^0.3.3", + "picocolors": "^1.0.0", + "std-env": "^3.5.0", + "strip-literal": "^2.0.0", + "test-exclude": "^6.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "vitest": "1.6.1" } }, - "node_modules/browserslist": { - "version": "4.28.0", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.0.tgz", - "integrity": "sha512-tbydkR/CxfMwelN0vwdP/pLkDwyAASZ+VfWm4EOwlB6SWhx1sYnWLqo8N5j0rAzPfzfRaxt0mM/4wPU/Su84RQ==", + "node_modules/@vitest/expect": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-1.6.1.tgz", + "integrity": "sha512-jXL+9+ZNIJKruofqXuuTClf44eSpcHlgj3CiuNihUF3Ioujtmc0zIa3UJOW5RjDK1YLBJZnWBlPuqhYycLioog==", "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" - } - ], "dependencies": { - "baseline-browser-mapping": "^2.8.25", - "caniuse-lite": "^1.0.30001754", - "electron-to-chromium": "^1.5.249", - "node-releases": "^2.0.27", - "update-browserslist-db": "^1.1.4" - }, - "bin": { - "browserslist": "cli.js" + "@vitest/spy": "1.6.1", + "@vitest/utils": "1.6.1", + "chai": "^4.3.10" }, - "engines": { - "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + "funding": { + "url": "https://opencollective.com/vitest" } }, - "node_modules/caniuse-lite": { - "version": "1.0.30001757", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001757.tgz", - "integrity": "sha512-r0nnL/I28Zi/yjk1el6ilj27tKcdjLsNqAOZr0yVjWPrSQyHgKI2INaEWw21bAQSv2LXRt1XuCS/GomNpWOxsQ==", + "node_modules/@vitest/runner": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-1.6.1.tgz", + "integrity": "sha512-3nSnYXkVkf3mXFfE7vVyPmi3Sazhb/2cfZGGs0JRzFsPFvAMBEcrweV1V1GsrstdXeKCTXlJbvnQwGWgEIHmOA==", "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" - } - ] - }, - "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 + "dependencies": { + "@vitest/utils": "1.6.1", + "p-limit": "^5.0.0", + "pathe": "^1.1.1" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } }, - "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 + "node_modules/@vitest/snapshot": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-1.6.1.tgz", + "integrity": "sha512-WvidQuWAzU2p95u8GAKlRMqMyN1yOJkGHnx3M1PL9Raf7AQ1kwLKg04ADlCa3+OXUZE7BceOhVZiuWAbzCKcUQ==", + "dev": true, + "dependencies": { + "magic-string": "^0.30.5", + "pathe": "^1.1.1", + "pretty-format": "^29.7.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } }, - "node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "node_modules/@vitest/snapshot/node_modules/pretty-format": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", + "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", "dev": true, "dependencies": { - "ms": "^2.1.3" + "@jest/schemas": "^29.6.3", + "ansi-styles": "^5.0.0", + "react-is": "^18.0.0" }, "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/electron-to-chromium": { - "version": "1.5.262", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.262.tgz", - "integrity": "sha512-NlAsMteRHek05jRUxUR0a5jpjYq9ykk6+kO0yRaMi5moe7u0fVIOeQ3Y30A8dIiWFBNUoQGi1ljb1i5VtS9WQQ==", + "node_modules/@vitest/snapshot/node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", "dev": true }, - "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==", + "node_modules/@vitest/spy": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-1.6.1.tgz", + "integrity": "sha512-MGcMmpGkZebsMZhbQKkAf9CX5zGvjkBTqf8Zx3ApYWXr3wG+QvEu2eXWfnIIWYSJExIp4V9FCKDEeygzkYrXMw==", "dev": true, - "hasInstallScript": true, - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=12" + "dependencies": { + "tinyspy": "^2.2.0" }, - "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" + "funding": { + "url": "https://opencollective.com/vitest" } }, - "node_modules/escalade": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", - "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "node_modules/@vitest/utils": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-1.6.1.tgz", + "integrity": "sha512-jOrrUvXM4Av9ZWiG1EajNto0u96kWAhJ1LmPmJhXXQx/32MecEKd10pOLYgS2BQx1TgkGhloPU1ArDW2vvaY6g==", + "dev": true, + "dependencies": { + "diff-sequences": "^29.6.3", + "estree-walker": "^3.0.3", + "loupe": "^2.3.7", + "pretty-format": "^29.7.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils/node_modules/pretty-format": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", + "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", "dev": true, + "dependencies": { + "@jest/schemas": "^29.6.3", + "ansi-styles": "^5.0.0", + "react-is": "^18.0.0" + }, "engines": { - "node": ">=6" + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "node_modules/@vitest/utils/node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "dev": true + }, + "node_modules/acorn": { + "version": "8.17.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", + "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", "dev": true, - "hasInstallScript": true, - "optional": true, - "os": [ - "darwin" - ], + "bin": { + "acorn": "bin/acorn" + }, "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + "node": ">=0.4.0" } }, - "node_modules/gensync": { - "version": "1.0.0-beta.2", - "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", - "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "node_modules/acorn-walk": { + "version": "8.3.5", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz", + "integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==", "dev": true, + "dependencies": { + "acorn": "^8.11.0" + }, "engines": { - "node": ">=6.9.0" + "node": ">=0.4.0" } }, - "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==" + "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, + "engines": { + "node": ">= 14" + } }, - "node_modules/jsesc": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", - "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "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, - "bin": { - "jsesc": "bin/jsesc" - }, "engines": { - "node": ">=6" + "node": ">=8" } }, - "node_modules/json5": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", - "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "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, - "bin": { - "json5": "lib/cli.js" - }, "engines": { - "node": ">=6" + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "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==", + "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, "dependencies": { - "js-tokens": "^3.0.0 || ^4.0.0" + "dequal": "^2.0.3" + } + }, + "node_modules/array-buffer-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", + "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==", + "dev": true, + "dependencies": { + "call-bound": "^1.0.3", + "is-array-buffer": "^3.0.5" }, - "bin": { - "loose-envify": "cli.js" + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "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==", + "node_modules/assertion-error": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-1.1.0.tgz", + "integrity": "sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==", + "dev": true, + "engines": { + "node": "*" + } + }, + "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 + }, + "node_modules/available-typed-arrays": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", "dev": true, "dependencies": { - "yallist": "^3.0.2" + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "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==", + "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 }, - "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "node_modules/baseline-browser-mapping": { + "version": "2.8.32", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.32.tgz", + "integrity": "sha512-OPz5aBThlyLFgxyhdwf/s2+8ab3OvT7AdTNvKHBwpXomIYeXqpUUuT8LrdtxZSsWJ4R4CU1un4XGh5Ez3nlTpw==", + "dev": true, + "bin": { + "baseline-browser-mapping": "dist/cli.js" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", + "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/browserslist": { + "version": "4.28.0", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.0.tgz", + "integrity": "sha512-tbydkR/CxfMwelN0vwdP/pLkDwyAASZ+VfWm4EOwlB6SWhx1sYnWLqo8N5j0rAzPfzfRaxt0mM/4wPU/Su84RQ==", "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" } ], + "dependencies": { + "baseline-browser-mapping": "^2.8.25", + "caniuse-lite": "^1.0.30001754", + "electron-to-chromium": "^1.5.249", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.1.4" + }, "bin": { - "nanoid": "bin/nanoid.cjs" + "browserslist": "cli.js" }, "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, - "node_modules/node-releases": { - "version": "2.0.27", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", - "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", - "dev": true + "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, + "engines": { + "node": ">=8" + } }, - "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 + "node_modules/call-bind": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", + "integrity": "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==", + "dev": true, + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "get-intrinsic": "^1.3.0", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } }, - "node_modules/postcss": { - "version": "8.5.6", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", - "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "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, + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dev": true, + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001757", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001757.tgz", + "integrity": "sha512-r0nnL/I28Zi/yjk1el6ilj27tKcdjLsNqAOZr0yVjWPrSQyHgKI2INaEWw21bAQSv2LXRt1XuCS/GomNpWOxsQ==", "dev": true, "funding": [ { "type": "opencollective", - "url": "https://opencollective.com/postcss/" + "url": "https://opencollective.com/browserslist" }, { "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss" + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" }, { "type": "github", "url": "https://github.com/sponsors/ai" } - ], + ] + }, + "node_modules/chai": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/chai/-/chai-4.5.0.tgz", + "integrity": "sha512-RITGBfijLkBddZvnn8jdqoTypxvqbOLYQkGGxXzeFjVHvudaPw0HNFD9x928/eUwYWd2dPCugVqspGALTZZQKw==", + "dev": true, "dependencies": { - "nanoid": "^3.3.11", - "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" + "assertion-error": "^1.1.0", + "check-error": "^1.0.3", + "deep-eql": "^4.1.3", + "get-func-name": "^2.0.2", + "loupe": "^2.3.6", + "pathval": "^1.1.1", + "type-detect": "^4.1.0" }, "engines": { - "node": "^10 || ^12 || >=14" + "node": ">=4" } }, - "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==", + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, "dependencies": { - "loose-envify": "^1.1.0" + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chalk/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, + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/check-error": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-1.0.3.tgz", + "integrity": "sha512-iKEoDYaRmd1mxM90a2OEfWhjsjPpYPuQ+lMYsoxB126+t8fw7ySEO48nmDg5COTjxDI65/Y2OWpeEHk3ZOe8zg==", + "dev": true, + "dependencies": { + "get-func-name": "^2.0.2" + }, + "engines": { + "node": "*" + } + }, + "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, + "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 + }, + "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, + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true + }, + "node_modules/confbox": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz", + "integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==", + "dev": true + }, + "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 + }, + "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, + "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 + }, + "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, + "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 + }, + "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 + }, + "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, + "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, + "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 + }, + "node_modules/deep-eql": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-4.1.4.tgz", + "integrity": "sha512-SUwdGfqdKOwxCPeVYjwSyRpJ7Z+fhpwIAtmCUdZIWZ/YP5R9WAsyuSgpLVDi9bjWoN2LXHNss/dk3urXtdQxGg==", + "dev": true, + "dependencies": { + "type-detect": "^4.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/deep-equal": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-2.2.3.tgz", + "integrity": "sha512-ZIwpnevOurS8bpT4192sqAowWM76JDKSHYzMLty3BZGSswgq6pBaH3DhCSW5xVAZICZyKdOBPjwww5wfgT/6PA==", + "dev": true, + "dependencies": { + "array-buffer-byte-length": "^1.0.0", + "call-bind": "^1.0.5", + "es-get-iterator": "^1.1.3", + "get-intrinsic": "^1.2.2", + "is-arguments": "^1.1.1", + "is-array-buffer": "^3.0.2", + "is-date-object": "^1.0.5", + "is-regex": "^1.1.4", + "is-shared-array-buffer": "^1.0.2", + "isarray": "^2.0.5", + "object-is": "^1.1.5", + "object-keys": "^1.1.1", + "object.assign": "^4.1.4", + "regexp.prototype.flags": "^1.5.1", + "side-channel": "^1.0.4", + "which-boxed-primitive": "^1.0.2", + "which-collection": "^1.0.1", + "which-typed-array": "^1.1.13" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dev": true, + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "dev": true, + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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, + "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, + "engines": { + "node": ">=6" + } + }, + "node_modules/diff-sequences": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz", + "integrity": "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==", + "dev": true, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "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 + }, + "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, + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.262", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.262.tgz", + "integrity": "sha512-NlAsMteRHek05jRUxUR0a5jpjYq9ykk6+kO0yRaMi5moe7u0fVIOeQ3Y30A8dIiWFBNUoQGi1ljb1i5VtS9WQQ==", + "dev": true + }, + "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, + "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, + "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, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-get-iterator": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/es-get-iterator/-/es-get-iterator-1.1.3.tgz", + "integrity": "sha512-sPZmqHBe6JIiTfN5q2pEi//TwxmAFHwj/XEuYjTuse78i8KxaqMTTzxPoFKuzRpDpTJ+0NAbpfenkmH2rePtuw==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "get-intrinsic": "^1.1.3", + "has-symbols": "^1.0.3", + "is-arguments": "^1.1.1", + "is-map": "^2.0.2", + "is-set": "^2.0.2", + "is-string": "^1.0.7", + "isarray": "^2.0.5", + "stop-iteration-iterator": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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, + "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, + "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.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, + "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/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "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, + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/execa": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-8.0.1.tgz", + "integrity": "sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==", + "dev": true, + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^8.0.1", + "human-signals": "^5.0.0", + "is-stream": "^3.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^5.1.0", + "onetime": "^6.0.0", + "signal-exit": "^4.1.0", + "strip-final-newline": "^3.0.0" + }, + "engines": { + "node": ">=16.17" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/for-each": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", + "dev": true, + "dependencies": { + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/form-data": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "dev": true, + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "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, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/functions-have-names": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", + "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", + "dev": true, + "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, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-func-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.2.tgz", + "integrity": "sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ==", + "dev": true, + "engines": { + "node": "*" + } + }, + "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, + "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, + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-stream": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-8.0.1.tgz", + "integrity": "sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==", + "dev": true, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "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, + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "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, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-bigints": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", + "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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, + "engines": { + "node": ">=8" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dev": true, + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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, + "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, + "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, + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "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, + "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 + }, + "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, + "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, + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/human-signals": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-5.0.0.tgz", + "integrity": "sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==", + "dev": true, + "engines": { + "node": ">=16.17.0" + } + }, + "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, + "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, + "engines": { + "node": ">=8" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "dev": true, + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true + }, + "node_modules/internal-slot": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", + "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==", + "dev": true, + "dependencies": { + "es-errors": "^1.3.0", + "hasown": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/is-arguments": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.2.0.tgz", + "integrity": "sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA==", + "dev": true, + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-array-buffer": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", + "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-bigint": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz", + "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==", + "dev": true, + "dependencies": { + "has-bigints": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-boolean-object": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz", + "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==", + "dev": true, + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-date-object": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz", + "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==", + "dev": true, + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-map": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", + "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-number-object": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz", + "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==", + "dev": true, + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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 + }, + "node_modules/is-regex": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", + "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", + "dev": true, + "dependencies": { + "call-bound": "^1.0.2", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-set": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz", + "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-shared-array-buffer": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz", + "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==", + "dev": true, + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-stream": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-3.0.0.tgz", + "integrity": "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==", + "dev": true, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-string": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", + "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==", + "dev": true, + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-symbol": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz", + "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==", + "dev": true, + "dependencies": { + "call-bound": "^1.0.2", + "has-symbols": "^1.1.0", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakmap": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", + "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakset": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz", + "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==", + "dev": true, + "dependencies": { + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true + }, + "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, + "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, + "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, + "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, + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "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==" + }, + "node_modules/jsdom": { + "version": "24.1.3", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-24.1.3.tgz", + "integrity": "sha512-MyL55p3Ut3cXbeBEG7Hcv0mVM8pp8PBNWxRqchZnSfAiES1v1mRnMeFfaHWIPULpwsYfvO+ZmMZz5tGCnjzDUQ==", + "dev": true, + "dependencies": { + "cssstyle": "^4.0.1", + "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": "^4.1.4", + "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, + "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, + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/local-pkg": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-0.5.1.tgz", + "integrity": "sha512-9rrA30MRRP3gBD3HTGnC6cDFpaE1kVDWxWgqWJUN0RvDNAo+Nz/9GxB+nHOH0ifbVFy0hSA1V6vFDvnx54lTEQ==", + "dev": true, + "dependencies": { + "mlly": "^1.7.3", + "pkg-types": "^1.2.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "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==", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/loupe": { + "version": "2.3.7", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-2.3.7.tgz", + "integrity": "sha512-zSMINGVYkdpYSOBmLi0D1Uo7JU9nVdQKrHxC8eYlV+9YKK9WePqAlL7lSlorG/U2Fw1w0hTBmaa/jrQ3UbPHtA==", + "dev": true, + "dependencies": { + "get-func-name": "^2.0.1" + } + }, + "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, + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "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, + "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, + "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, + "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.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "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, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "dev": true + }, + "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, + "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, + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mimic-fn": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-4.0.0.tgz", + "integrity": "sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "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, + "engines": { + "node": ">=4" + } + }, + "node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/mlly": { + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.2.tgz", + "integrity": "sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==", + "dev": true, + "dependencies": { + "acorn": "^8.16.0", + "pathe": "^2.0.3", + "pkg-types": "^1.3.1", + "ufo": "^1.6.3" + } + }, + "node_modules/mlly/node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true + }, + "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 + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.27", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", + "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", + "dev": true + }, + "node_modules/npm-run-path": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-5.3.0.tgz", + "integrity": "sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==", + "dev": true, + "dependencies": { + "path-key": "^4.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/npm-run-path/node_modules/path-key": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", + "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "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 + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-is": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/object-is/-/object-is-1.1.6.tgz", + "integrity": "sha512-F8cZ+KfGlSGi09lJT7/Nd6KJZ9ygtvYC0/UYYLI9nmQKLMnydpB9yvbv9K1uSkEu7FU9vYPmVwLg328tX+ot3Q==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.assign": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", + "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0", + "has-symbols": "^1.1.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/onetime": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-6.0.0.tgz", + "integrity": "sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==", + "dev": true, + "dependencies": { + "mimic-fn": "^4.0.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-limit": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-5.0.0.tgz", + "integrity": "sha512-/Eaoq+QyLSiXQ4lyYV23f14mZRQcXnxfHrN0vCai+ak9G0pp9iEQukIIZq5NccEvwRB8PUnZT0KsOoDCINS1qQ==", + "dev": true, + "dependencies": { + "yocto-queue": "^1.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "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, + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "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, + "engines": { + "node": ">=8" + } + }, + "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 + }, + "node_modules/pathval": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-1.1.1.tgz", + "integrity": "sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ==", + "dev": true, + "engines": { + "node": "*" + } + }, + "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 + }, + "node_modules/pkg-types": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz", + "integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==", + "dev": true, + "dependencies": { + "confbox": "^0.1.8", + "mlly": "^1.7.4", + "pathe": "^2.0.1" + } + }, + "node_modules/pkg-types/node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true + }, + "node_modules/possible-typed-array-names": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", + "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", + "dev": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "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" + } + ], + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "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/psl": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz", + "integrity": "sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==", + "dev": true, + "dependencies": { + "punycode": "^2.3.1" + }, + "funding": { + "url": "https://github.com/sponsors/lupomontero" + } + }, + "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, + "engines": { + "node": ">=6" + } + }, + "node_modules/querystringify": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", + "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==", + "dev": true + }, + "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==", + "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==", + "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 + }, + "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, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-router": { + "version": "6.30.2", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.2.tgz", + "integrity": "sha512-H2Bm38Zu1bm8KUE5NVWRMzuIyAV8p/JrOaBJAwVmp37AXG72+CZJlEBw6pdn9i5TBgLMhNDgijS4ZlblpHyWTA==", + "dependencies": { + "@remix-run/router": "1.23.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8" + } + }, + "node_modules/react-router-dom": { + "version": "6.30.2", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.2.tgz", + "integrity": "sha512-l2OwHn3UUnEVUqc6/1VMmR1cvZryZ3j3NzapC2eUXO1dB0sYp5mvwdjiXhpUbRb21eFow3qSxpP8Yv6oAU824Q==", + "dependencies": { + "@remix-run/router": "1.23.1", + "react-router": "6.30.2" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/regexp.prototype.flags": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", + "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-errors": "^1.3.0", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/requires-port": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", + "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", + "dev": true + }, + "node_modules/rollup": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.53.3.tgz", + "integrity": "sha512-w8GmOxZfBmKknvdXU1sdM9NHcoQejwF/4mNgj2JuEEdRaHwwF12K7e9eXn1nLZ07ad+du76mkVsyeb2rKGllsA==", + "dev": true, + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.53.3", + "@rollup/rollup-android-arm64": "4.53.3", + "@rollup/rollup-darwin-arm64": "4.53.3", + "@rollup/rollup-darwin-x64": "4.53.3", + "@rollup/rollup-freebsd-arm64": "4.53.3", + "@rollup/rollup-freebsd-x64": "4.53.3", + "@rollup/rollup-linux-arm-gnueabihf": "4.53.3", + "@rollup/rollup-linux-arm-musleabihf": "4.53.3", + "@rollup/rollup-linux-arm64-gnu": "4.53.3", + "@rollup/rollup-linux-arm64-musl": "4.53.3", + "@rollup/rollup-linux-loong64-gnu": "4.53.3", + "@rollup/rollup-linux-ppc64-gnu": "4.53.3", + "@rollup/rollup-linux-riscv64-gnu": "4.53.3", + "@rollup/rollup-linux-riscv64-musl": "4.53.3", + "@rollup/rollup-linux-s390x-gnu": "4.53.3", + "@rollup/rollup-linux-x64-gnu": "4.53.3", + "@rollup/rollup-linux-x64-musl": "4.53.3", + "@rollup/rollup-openharmony-arm64": "4.53.3", + "@rollup/rollup-win32-arm64-msvc": "4.53.3", + "@rollup/rollup-win32-ia32-msvc": "4.53.3", + "@rollup/rollup-win32-x64-gnu": "4.53.3", + "@rollup/rollup-win32-x64-msvc": "4.53.3", + "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 + }, + "node_modules/safe-regex-test": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", + "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", + "dev": true, + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-regex": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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 + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "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==", + "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, + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "dev": true, + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-function-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", + "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", + "dev": true, + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "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, + "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, + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "dev": true, + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "dev": true, + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "dev": true, + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "dev": true, + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true + }, + "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, + "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, + "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 + }, + "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 + }, + "node_modules/stop-iteration-iterator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", + "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==", + "dev": true, + "dependencies": { + "es-errors": "^1.3.0", + "internal-slot": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/strip-final-newline": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-3.0.0.tgz", + "integrity": "sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "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, + "dependencies": { + "min-indent": "^1.0.0" }, "engines": { - "node": ">=0.10.0" + "node": ">=8" } }, - "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==", + "node_modules/strip-literal": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-2.1.1.tgz", + "integrity": "sha512-631UJ6O00eNGfMiWG78ck80dfBab8X6IVFB51jZK5Icd7XAs60Z5y7QdSd/wGIklnWvRbUNloVzhOKKmutxQ6Q==", + "dev": true, "dependencies": { - "loose-envify": "^1.1.0", - "scheduler": "^0.23.2" + "js-tokens": "^9.0.1" }, - "peerDependencies": { - "react": "^18.3.1" + "funding": { + "url": "https://github.com/sponsors/antfu" } }, - "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==", + "node_modules/strip-literal/node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true + }, + "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, + "dependencies": { + "has-flag": "^4.0.0" + }, "engines": { - "node": ">=0.10.0" + "node": ">=8" } }, - "node_modules/react-router": { - "version": "6.30.2", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.2.tgz", - "integrity": "sha512-H2Bm38Zu1bm8KUE5NVWRMzuIyAV8p/JrOaBJAwVmp37AXG72+CZJlEBw6pdn9i5TBgLMhNDgijS4ZlblpHyWTA==", + "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 + }, + "node_modules/test-exclude": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", + "integrity": "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==", + "dev": true, "dependencies": { - "@remix-run/router": "1.23.1" + "@istanbuljs/schema": "^0.1.2", + "glob": "^7.1.4", + "minimatch": "^3.0.4" }, "engines": { - "node": ">=14.0.0" - }, - "peerDependencies": { - "react": ">=16.8" + "node": ">=8" } }, - "node_modules/react-router-dom": { - "version": "6.30.2", - "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.2.tgz", - "integrity": "sha512-l2OwHn3UUnEVUqc6/1VMmR1cvZryZ3j3NzapC2eUXO1dB0sYp5mvwdjiXhpUbRb21eFow3qSxpP8Yv6oAU824Q==", - "dependencies": { - "@remix-run/router": "1.23.1", - "react-router": "6.30.2" - }, + "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 + }, + "node_modules/tinypool": { + "version": "0.8.4", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-0.8.4.tgz", + "integrity": "sha512-i11VH5gS6IFeLY3gMBQ00/MmLncVP7JLXOw1vlgkytLmJK7QnEr7NXf0LBdxfmNPAeyetukOk0bOYrJrFGjYJQ==", + "dev": true, "engines": { "node": ">=14.0.0" - }, - "peerDependencies": { - "react": ">=16.8", - "react-dom": ">=16.8" } }, - "node_modules/rollup": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.53.3.tgz", - "integrity": "sha512-w8GmOxZfBmKknvdXU1sdM9NHcoQejwF/4mNgj2JuEEdRaHwwF12K7e9eXn1nLZ07ad+du76mkVsyeb2rKGllsA==", + "node_modules/tinyspy": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-2.2.1.tgz", + "integrity": "sha512-KYad6Vy5VDWV4GH3fjpseMQ/XU2BhIYP7Vzd0LG44qRWm/Yt2WCOTicFdvmgo6gWaqooMQCawTtILVQJupKu7A==", "dev": true, - "dependencies": { - "@types/estree": "1.0.8" - }, - "bin": { - "rollup": "dist/bin/rollup" - }, "engines": { - "node": ">=18.0.0", - "npm": ">=8.0.0" - }, - "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.53.3", - "@rollup/rollup-android-arm64": "4.53.3", - "@rollup/rollup-darwin-arm64": "4.53.3", - "@rollup/rollup-darwin-x64": "4.53.3", - "@rollup/rollup-freebsd-arm64": "4.53.3", - "@rollup/rollup-freebsd-x64": "4.53.3", - "@rollup/rollup-linux-arm-gnueabihf": "4.53.3", - "@rollup/rollup-linux-arm-musleabihf": "4.53.3", - "@rollup/rollup-linux-arm64-gnu": "4.53.3", - "@rollup/rollup-linux-arm64-musl": "4.53.3", - "@rollup/rollup-linux-loong64-gnu": "4.53.3", - "@rollup/rollup-linux-ppc64-gnu": "4.53.3", - "@rollup/rollup-linux-riscv64-gnu": "4.53.3", - "@rollup/rollup-linux-riscv64-musl": "4.53.3", - "@rollup/rollup-linux-s390x-gnu": "4.53.3", - "@rollup/rollup-linux-x64-gnu": "4.53.3", - "@rollup/rollup-linux-x64-musl": "4.53.3", - "@rollup/rollup-openharmony-arm64": "4.53.3", - "@rollup/rollup-win32-arm64-msvc": "4.53.3", - "@rollup/rollup-win32-ia32-msvc": "4.53.3", - "@rollup/rollup-win32-x64-gnu": "4.53.3", - "@rollup/rollup-win32-x64-msvc": "4.53.3", - "fsevents": "~2.3.2" + "node": ">=14.0.0" } }, - "node_modules/scheduler": { - "version": "0.23.2", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", - "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "node_modules/tough-cookie": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.4.tgz", + "integrity": "sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==", + "dev": true, "dependencies": { - "loose-envify": "^1.1.0" + "psl": "^1.1.33", + "punycode": "^2.1.1", + "universalify": "^0.2.0", + "url-parse": "^1.5.3" + }, + "engines": { + "node": ">=6" } }, - "node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "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, - "bin": { - "semver": "bin/semver.js" + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" } }, - "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==", + "node_modules/type-detect": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.1.0.tgz", + "integrity": "sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw==", "dev": true, "engines": { - "node": ">=0.10.0" + "node": ">=4" } }, "node_modules/typescript": { @@ -1541,6 +4025,21 @@ "node": ">=14.17" } }, + "node_modules/ufo": { + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.4.tgz", + "integrity": "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==", + "dev": true + }, + "node_modules/universalify": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz", + "integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==", + "dev": true, + "engines": { + "node": ">= 4.0.0" + } + }, "node_modules/update-browserslist-db": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.4.tgz", @@ -1571,6 +4070,16 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/url-parse": { + "version": "1.5.10", + "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz", + "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==", + "dev": true, + "dependencies": { + "querystringify": "^2.1.1", + "requires-port": "^1.0.0" + } + }, "node_modules/vite": { "version": "5.4.21", "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", @@ -1630,11 +4139,297 @@ } } }, + "node_modules/vite-node": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-1.6.1.tgz", + "integrity": "sha512-YAXkfvGtuTzwWbDSACdJSg4A4DZiAqckWe90Zapc/sEX3XvHcw1NdurM/6od8J207tSDqNbSsgdCacBgvJKFuA==", + "dev": true, + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.3.4", + "pathe": "^1.1.1", + "picocolors": "^1.0.0", + "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/vitest": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-1.6.1.tgz", + "integrity": "sha512-Ljb1cnSJSivGN0LqXd/zmDbWEM0RNNg2t1QW/XUhYl/qPqyu7CsqeWtqQXHVaJsecLPuDoak2oJcZN2QoRIOag==", + "dev": true, + "dependencies": { + "@vitest/expect": "1.6.1", + "@vitest/runner": "1.6.1", + "@vitest/snapshot": "1.6.1", + "@vitest/spy": "1.6.1", + "@vitest/utils": "1.6.1", + "acorn-walk": "^8.3.2", + "chai": "^4.3.10", + "debug": "^4.3.4", + "execa": "^8.0.1", + "local-pkg": "^0.5.0", + "magic-string": "^0.30.5", + "pathe": "^1.1.1", + "picocolors": "^1.0.0", + "std-env": "^3.5.0", + "strip-literal": "^2.0.0", + "tinybench": "^2.5.1", + "tinypool": "^0.8.3", + "vite": "^5.0.0", + "vite-node": "1.6.1", + "why-is-node-running": "^2.2.2" + }, + "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": "1.6.1", + "@vitest/ui": "1.6.1", + "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/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, + "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, + "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, + "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, + "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, + "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, + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/which-boxed-primitive": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz", + "integrity": "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==", + "dev": true, + "dependencies": { + "is-bigint": "^1.1.0", + "is-boolean-object": "^1.2.1", + "is-number-object": "^1.1.1", + "is-string": "^1.1.1", + "is-symbol": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-collection": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz", + "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==", + "dev": true, + "dependencies": { + "is-map": "^2.0.3", + "is-set": "^2.0.3", + "is-weakmap": "^2.0.2", + "is-weakset": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-typed-array": { + "version": "1.1.22", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.22.tgz", + "integrity": "sha512-fvO4ExWMFsqyhG3AiPAObMuY1lxaqgYcxbc49CNdWDDECOJNgQyvsOWVwbZc+qf3rzRtxojBK+CMEv0Ld5CYpw==", + "dev": true, + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "for-each": "^0.3.5", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true + }, + "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, + "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, + "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 + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", "dev": true + }, + "node_modules/yocto-queue": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.2.2.tgz", + "integrity": "sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==", + "dev": true, + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } } } } diff --git a/src/package.json b/src/package.json index 0571d01..18b72f4 100644 --- a/src/package.json +++ b/src/package.json @@ -6,7 +6,9 @@ "scripts": { "dev": "vite", "build": "tsc && vite build", - "preview": "vite preview" + "preview": "vite preview", + "test": "vitest run", + "test:coverage": "vitest run --coverage" }, "dependencies": { "@novnc/novnc": "1.6.0", @@ -16,10 +18,16 @@ "react-router-dom": "^6.20.0" }, "devDependencies": { + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^14.3.1", + "@testing-library/user-event": "^14.6.1", "@types/react": "^18.2.0", "@types/react-dom": "^18.2.0", "@vitejs/plugin-react": "^4.2.0", + "@vitest/coverage-v8": "^1.6.1", + "jsdom": "^24.1.3", "typescript": "^5.2.0", - "vite": "^5.0.0" + "vite": "^5.0.0", + "vitest": "^1.6.1" } -} \ No newline at end of file +} diff --git a/src/pages/Dashboard.tsx b/src/pages/Dashboard.tsx index bff2217..086769c 100644 --- a/src/pages/Dashboard.tsx +++ b/src/pages/Dashboard.tsx @@ -2,6 +2,7 @@ import React from "react"; import { useQuery, useMutation } from '@tanstack/react-query'; import { Link, useNavigate } from 'react-router-dom'; import { formatUptime, getBrowserIcon } from '../utils'; +import { useAuth } from '../App'; const buildNumber = __BUILD_NUMBER__; @@ -25,8 +26,9 @@ interface BrowserGroup { versions: string[]; } -const fetchStatus = async (): Promise => { +const fetchStatus = async (onUnauthorized: () => void): Promise => { const res = await fetch('/api/v1/status'); + if (res.status === 401) { onUnauthorized(); throw new Error('unauthorized'); } if (!res.ok) throw new Error('Network response was not ok'); return res.json(); }; @@ -124,12 +126,20 @@ const VersionList: React.FC = ({ versions, browserName, starti export const Dashboard: React.FC = () => { const navigate = useNavigate(); + const { authEnabled, onUnauthorized } = useAuth(); const { data, isLoading } = useQuery({ queryKey: ['status'], - queryFn: fetchStatus, + queryFn: () => fetchStatus(onUnauthorized), refetchInterval: 2000, }); + + const logoutMutation = useMutation({ + mutationFn: async () => { + await fetch('/api/v1/auth/logout', { method: 'POST' }); + }, + onSuccess: () => navigate('/ui/login', { replace: true }), + }); const browsers = data?.activeSessions ?? []; const [, setNow] = React.useState(Date.now()); @@ -269,6 +279,16 @@ export const Dashboard: React.FC = () => { )} + {authEnabled && ( + + )} +
BROWSERS TOTAL: {stats.total}
diff --git a/src/pages/LoginPage.tsx b/src/pages/LoginPage.tsx new file mode 100644 index 0000000..4f54539 --- /dev/null +++ b/src/pages/LoginPage.tsx @@ -0,0 +1,77 @@ +import React from 'react'; +import { useNavigate } from 'react-router-dom'; + +export const LoginPage: React.FC = () => { + const navigate = useNavigate(); + const [username, setUsername] = React.useState(''); + const [password, setPassword] = React.useState(''); + const [error, setError] = React.useState(''); + const [loading, setLoading] = React.useState(false); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(''); + setLoading(true); + try { + const res = await fetch('/api/v1/auth/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username, password }), + }); + if (res.status === 401) { + setError('Invalid username or password'); + return; + } + if (!res.ok) { + setError('Login failed, please try again'); + return; + } + navigate('/ui/', { replace: true }); + } catch { + setError('Network error, please try again'); + } finally { + setLoading(false); + } + }; + + return ( +
+
+
SELENOSIS-UI
+
+ {error &&
{error}
} +
+ + setUsername(e.target.value)} + disabled={loading} + required + /> +
+
+ + setPassword(e.target.value)} + disabled={loading} + required + /> +
+ +
+
+
+ ); +}; diff --git a/src/pages/VNCView.test.tsx b/src/pages/VNCView.test.tsx new file mode 100644 index 0000000..93df5c4 --- /dev/null +++ b/src/pages/VNCView.test.tsx @@ -0,0 +1,540 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { render, screen, waitFor, act, cleanup, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { MemoryRouter, Routes, Route } from "react-router-dom"; +import { VNCView } from "./VNCView"; +import { NAME_PREFIX, VERSION_PREFIX } from "../lib/vncStore"; + +const navigateMock = vi.fn(); + +vi.mock("react-router-dom", async () => { + const actual = await vi.importActual("react-router-dom"); + return { ...actual, useNavigate: () => navigateMock }; +}); + +const rfb = vi.hoisted(() => { + const instances: any[] = []; + class FakeRFB { + url: string; + options: any; + listeners: Record void>> = {}; + sendCredentials = vi.fn(); + disconnect = vi.fn(); + clipboardPasteFrom = vi.fn(); + sendKey = vi.fn(); + requestDesktopSize = vi.fn(); + scaleViewport = false; + viewOnly = false; + localCursor = false; + clipViewport = false; + viewportDrag = false; + constructor(_target: any, url: string, options: any) { + this.url = url; + this.options = options; + instances.push(this); + } + addEventListener(type: string, cb: (e: any) => void) { + (this.listeners[type] ||= []).push(cb); + } + emit(type: string, detail?: any) { + (this.listeners[type] || []).forEach((cb) => cb({ detail })); + } + } + return { instances, FakeRFB }; +}); + +vi.mock("@novnc/novnc/lib/rfb", () => ({ default: rfb.FakeRFB })); +vi.mock("../assets/icons/clipboard-paste.svg", () => ({ default: "paste.svg" })); +vi.mock("../assets/icons/clipboard-copy.svg", () => ({ default: "copy.svg" })); +vi.mock("../assets/icons/send.svg", () => ({ default: "send.svg" })); +vi.mock("../assets/icons/circle-x.svg", () => ({ default: "x.svg" })); + +function mockFetch(opts: { browserName?: string | undefined; browserVersion?: string; startTime?: string; password?: string | null; sessionOk?: boolean }) { + if (opts.password && opts.browserName) { + localStorage.setItem(NAME_PREFIX + opts.browserName, opts.password); + } + global.fetch = vi.fn(async () => { + return { + ok: opts.sessionOk !== false, + json: async () => ({ browserId: "id-1", browserName: opts.browserName, browserVersion: opts.browserVersion ?? "123", startTime: opts.startTime ?? "2020-01-01T00:00:00Z" }), + } as any; + }) as any; +} + +function renderView(id = "id-1") { + return render( + + + } /> + + , + ); +} + +const latest = () => rfb.instances[rfb.instances.length - 1]; +const emit = (type: string, detail?: any) => act(() => latest().emit(type, detail)); + +beforeEach(() => { + rfb.instances.length = 0; + navigateMock.mockReset(); + localStorage.clear(); + sessionStorage.clear(); + vi.restoreAllMocks(); +}); + +afterEach(() => { + cleanup(); +}); + +describe("auto-connect with a saved password", () => { + it("sends the saved-by-name password and reports connected", async () => { + mockFetch({ browserName: "chrome", password: "secret" }); + renderView(); + + await waitFor(() => expect(rfb.instances.length).toBe(1)); + expect(latest().url).toContain("/api/v1/browsers/id-1/vnc"); + + await emit("credentialsrequired"); + expect(latest().sendCredentials).toHaveBeenCalledWith({ password: "secret" }); + + await emit("connect"); + expect(screen.getByText("Connected")).toBeInTheDocument(); + }); + + it("uses _rfb_credentials when sendCredentials is unavailable", async () => { + mockFetch({ browserName: "chrome", password: "secret" }); + renderView(); + await waitFor(() => expect(rfb.instances.length).toBe(1)); + + (latest() as any).sendCredentials = undefined; + await emit("credentialsrequired"); + expect((latest() as any)._rfb_credentials).toEqual({ password: "secret" }); + }); +}); + +describe("saved-password retry ladder", () => { + it("tries the version-saved password first, then falls through to the name-saved one on securityfailure", async () => { + localStorage.setItem(VERSION_PREFIX + "chrome@123", "byversion"); + localStorage.setItem(NAME_PREFIX + "chrome", "byname"); + mockFetch({ browserName: "chrome", browserVersion: "123" }); + renderView(); + + await waitFor(() => expect(rfb.instances.length).toBe(1)); + await emit("credentialsrequired"); + expect(latest().sendCredentials).toHaveBeenCalledWith({ password: "byversion" }); + + await emit("securityfailure", { reason: "bad" }); + await waitFor(() => expect(rfb.instances.length).toBe(2)); + + await emit("credentialsrequired"); + expect(latest().sendCredentials).toHaveBeenCalledWith({ password: "byname" }); + }); +}); + +describe("fetch failures", () => { + it("prompts for manual entry when the session lookup fails", async () => { + mockFetch({ browserName: "chrome", sessionOk: false, startTime: "" }); + renderView(); + + await waitFor(() => expect(screen.getByLabelText("VNC password")).toBeInTheDocument()); + expect(rfb.instances.length).toBe(0); + }); +}); + +describe("manual entry", () => { + it("prompts when no candidate exists, connects, and saves for all by name", async () => { + mockFetch({ browserName: "chrome", password: null }); + renderView(); + + await waitFor(() => expect(screen.getByLabelText("VNC password")).toBeInTheDocument()); + expect(rfb.instances.length).toBe(0); + + await userEvent.type(screen.getByLabelText("VNC password"), "typed"); + await userEvent.click(screen.getByRole("button", { name: "Connect" })); + + await waitFor(() => expect(rfb.instances.length).toBe(1)); + await emit("credentialsrequired"); + expect(latest().sendCredentials).toHaveBeenCalledWith({ password: "typed" }); + + await emit("connect"); + await userEvent.click(screen.getByRole("button", { name: 'Save for all "chrome"' })); + expect(localStorage.getItem(NAME_PREFIX + "chrome")).toBe("typed"); + }); + + it("saves for the current browser version", async () => { + mockFetch({ browserName: "chrome", browserVersion: "123", password: null }); + renderView(); + await waitFor(() => expect(screen.getByLabelText("VNC password")).toBeInTheDocument()); + + await userEvent.type(screen.getByLabelText("VNC password"), "typed"); + await userEvent.click(screen.getByRole("button", { name: "Connect" })); + await waitFor(() => expect(rfb.instances.length).toBe(1)); + await emit("connect"); + + await userEvent.click(screen.getByRole("button", { name: 'Only "chrome 123"' })); + expect(localStorage.getItem(VERSION_PREFIX + "chrome@123")).toBe("typed"); + }); + + it("prefers the version-saved password over the name-saved one", async () => { + localStorage.setItem(NAME_PREFIX + "chrome", "byname"); + localStorage.setItem(VERSION_PREFIX + "chrome@123", "byversion"); + mockFetch({ browserName: "chrome", browserVersion: "123" }); + renderView(); + + await waitFor(() => expect(rfb.instances.length).toBe(1)); + await emit("credentialsrequired"); + expect(latest().sendCredentials).toHaveBeenCalledWith({ password: "byversion" }); + }); + + it("does not persist when the user declines to save", async () => { + mockFetch({ browserName: "chrome", password: null }); + renderView(); + await waitFor(() => expect(screen.getByLabelText("VNC password")).toBeInTheDocument()); + + await userEvent.type(screen.getByLabelText("VNC password"), "typed"); + await userEvent.click(screen.getByRole("button", { name: "Connect" })); + await waitFor(() => expect(rfb.instances.length).toBe(1)); + await emit("connect"); + + await userEvent.click(screen.getByRole("button", { name: "Don't save" })); + expect(localStorage.length).toBe(0); + expect(sessionStorage.length).toBe(0); + }); + + it("cancelling the prompt disconnects with a notice", async () => { + mockFetch({ browserName: "chrome", password: null }); + renderView(); + await waitFor(() => expect(screen.getByLabelText("VNC password")).toBeInTheDocument()); + + await userEvent.click(screen.getByRole("button", { name: "Cancel" })); + expect(screen.getByText("VNC authentication cancelled")).toBeInTheDocument(); + }); + + it("does not require a browser name to fall back to manual save scope name", async () => { + mockFetch({ browserName: undefined, password: null }); + renderView(); + await waitFor(() => expect(screen.getByLabelText("VNC password")).toBeInTheDocument()); + + await userEvent.type(screen.getByLabelText("VNC password"), "typed"); + await userEvent.click(screen.getByRole("button", { name: "Connect" })); + await waitFor(() => expect(rfb.instances.length).toBe(1)); + await emit("connect"); + + await userEvent.click(screen.getByRole("button", { name: 'Save for all ""' })); + expect(localStorage.length).toBe(0); + }); +}); + +describe("securityfailure handling", () => { + it("exhausting the auto ladder opens the manual prompt with the failure reason", async () => { + mockFetch({ browserName: "chrome", password: "secret" }); + renderView(); + await waitFor(() => expect(rfb.instances.length).toBe(1)); + + await emit("securityfailure", { reason: "wrong password" }); + await waitFor(() => expect(screen.getByText("VNC auth failed: wrong password")).toBeInTheDocument()); + }); + + it("stops at the hard cap when manual entries keep failing", async () => { + mockFetch({ browserName: "chrome", password: "p0" }); + renderView(); + await waitFor(() => expect(rfb.instances.length).toBe(1)); + + await emit("securityfailure"); + await waitFor(() => expect(screen.getByLabelText("VNC password")).toBeInTheDocument()); + + for (let i = 1; i <= 4; i++) { + await userEvent.type(screen.getByLabelText("VNC password"), `m${i}`); + await userEvent.click(screen.getByRole("button", { name: "Connect" })); + await waitFor(() => expect(rfb.instances.length).toBe(i + 1)); + await emit("securityfailure", { reason: "nope" }); + } + + await waitFor(() => expect(screen.getByText("VNC auth failed: nope")).toBeInTheDocument()); + expect(screen.queryByLabelText("VNC password")).not.toBeInTheDocument(); + }); +}); + +describe("disconnect handling", () => { + it("reports connection lost on an unclean disconnect", async () => { + mockFetch({ browserName: "chrome", password: "secret" }); + renderView(); + await waitFor(() => expect(rfb.instances.length).toBe(1)); + + await emit("disconnect", { clean: false }); + expect(screen.getByText("Connection lost")).toBeInTheDocument(); + }); + + it("ignores the disconnect that follows a securityfailure", async () => { + mockFetch({ browserName: "chrome", password: "secret" }); + renderView(); + await waitFor(() => expect(rfb.instances.length).toBe(1)); + + await emit("securityfailure", { reason: "bad" }); + const firstInstance = rfb.instances[0]; + await act(() => firstInstance.emit("disconnect", { clean: false })); + expect(screen.queryByText("Connection lost")).not.toBeInTheDocument(); + }); + + it("shows a clean disconnect as disconnected", async () => { + mockFetch({ browserName: "chrome", password: "secret" }); + renderView(); + await waitFor(() => expect(rfb.instances.length).toBe(1)); + await emit("connect"); + await emit("disconnect", { clean: true }); + expect(screen.getAllByText("Disconnected").length).toBeGreaterThan(0); + }); +}); + +describe("window controls and clipboard", () => { + it("disconnects and navigates home on close", async () => { + mockFetch({ browserName: "chrome", password: "secret" }); + renderView(); + await waitFor(() => expect(rfb.instances.length).toBe(1)); + const inst = latest(); + + await userEvent.click(screen.getByTitle("Close")); + expect(inst.disconnect).toHaveBeenCalled(); + expect(navigateMock).toHaveBeenCalledWith("/ui/"); + }); + + it("requests desktop size when maximized", async () => { + mockFetch({ browserName: "chrome", password: "secret" }); + renderView(); + await waitFor(() => expect(rfb.instances.length).toBe(1)); + await emit("connect"); + + await userEvent.click(screen.getByTitle("Fullscreen")); + expect(latest().requestDesktopSize).toHaveBeenCalled(); + }); + + it("scales the viewport on window resize when not maximized", async () => { + mockFetch({ browserName: "chrome", password: "secret" }); + renderView(); + await waitFor(() => expect(rfb.instances.length).toBe(1)); + await emit("connect"); + + latest().scaleViewport = false; + await act(() => { + window.dispatchEvent(new Event("resize")); + }); + expect(latest().scaleViewport).toBe(true); + }); + + it("copies remote clipboard to local when available", async () => { + mockFetch({ browserName: "chrome", password: "secret" }); + const writeText = vi.fn().mockResolvedValue(undefined); + Object.defineProperty(navigator, "clipboard", { configurable: true, value: { writeText } }); + + renderView(); + await waitFor(() => expect(rfb.instances.length).toBe(1)); + await emit("connect"); + await emit("clipboard", { text: "remote-text" }); + + await userEvent.click(screen.getByTitle("Copy from VNC")); + expect(writeText).toHaveBeenCalledWith("remote-text"); + }); + + it("pastes local clipboard into the session when readText is available", async () => { + mockFetch({ browserName: "chrome", password: "secret" }); + const readText = vi.fn().mockResolvedValue("local-text"); + Object.defineProperty(navigator, "clipboard", { configurable: true, value: { readText } }); + + renderView(); + await waitFor(() => expect(rfb.instances.length).toBe(1)); + await emit("connect"); + + await userEvent.click(screen.getByTitle("Copy to VNC")); + await waitFor(() => expect(latest().clipboardPasteFrom).toHaveBeenCalledWith("local-text")); + }); + + it("falls back to the paste prompt when readText is unavailable", async () => { + mockFetch({ browserName: "chrome", password: "secret" }); + Object.defineProperty(navigator, "clipboard", { configurable: true, value: {} }); + + renderView(); + await waitFor(() => expect(rfb.instances.length).toBe(1)); + await emit("connect"); + + await userEvent.click(screen.getByTitle("Copy to VNC")); + const prompt = screen.getByText("Paste text to send to remote"); + expect(prompt).toBeInTheDocument(); + + const input = document.querySelector(".clipboard-prompt-input") as HTMLTextAreaElement; + await userEvent.type(input, "manual paste"); + await userEvent.click(screen.getByTitle("Send")); + expect(latest().clipboardPasteFrom).toHaveBeenCalledWith("manual paste"); + }); + + it("cancels the paste prompt", async () => { + mockFetch({ browserName: "chrome", password: "secret" }); + Object.defineProperty(navigator, "clipboard", { configurable: true, value: {} }); + + renderView(); + await waitFor(() => expect(rfb.instances.length).toBe(1)); + await emit("connect"); + + await userEvent.click(screen.getByTitle("Copy to VNC")); + const promptActions = document.querySelector(".clipboard-prompt-actions") as HTMLElement; + await userEvent.click(within(promptActions).getByTitle("Close")); + await waitFor(() => expect(screen.queryByText("Paste text to send to remote")).not.toBeInTheDocument()); + }); + + it("does nothing when copying from an empty remote clipboard", async () => { + mockFetch({ browserName: "chrome", password: "secret" }); + const writeText = vi.fn(); + Object.defineProperty(navigator, "clipboard", { configurable: true, value: { writeText } }); + + renderView(); + await waitFor(() => expect(rfb.instances.length).toBe(1)); + await emit("connect"); + + await userEvent.click(screen.getByTitle("Copy from VNC")); + expect(writeText).not.toHaveBeenCalled(); + }); + + it("writes to the clipboard via execCommand fallback when writeText is unavailable", async () => { + mockFetch({ browserName: "chrome", password: "secret" }); + Object.defineProperty(navigator, "clipboard", { configurable: true, value: {} }); + const execCommand = vi.fn().mockReturnValue(true); + (document as any).execCommand = execCommand; + + renderView(); + await waitFor(() => expect(rfb.instances.length).toBe(1)); + await emit("connect"); + await emit("clipboard", { text: "remote-text" }); + + await userEvent.click(screen.getByTitle("Copy from VNC")); + await waitFor(() => expect(execCommand).toHaveBeenCalledWith("copy")); + }); + + it("falls back to execCommand when writeText rejects", async () => { + mockFetch({ browserName: "chrome", password: "secret" }); + const writeText = vi.fn().mockRejectedValue(new Error("denied")); + Object.defineProperty(navigator, "clipboard", { configurable: true, value: { writeText } }); + const execCommand = vi.fn(() => { + throw new Error("execCommand unsupported"); + }); + (document as any).execCommand = execCommand; + + renderView(); + await waitFor(() => expect(rfb.instances.length).toBe(1)); + await emit("connect"); + await emit("clipboard", { text: "remote-text" }); + + await userEvent.click(screen.getByTitle("Copy from VNC")); + await waitFor(() => expect(execCommand).toHaveBeenCalledWith("copy")); + }); + + it("reads via the textarea fallback when readText rejects, tolerating sendKey errors", async () => { + mockFetch({ browserName: "chrome", password: "secret" }); + const readText = vi.fn().mockRejectedValue(new Error("denied")); + Object.defineProperty(navigator, "clipboard", { configurable: true, value: { readText } }); + + renderView(); + await waitFor(() => expect(rfb.instances.length).toBe(1)); + await emit("connect"); + latest().sendKey = vi.fn(() => { + throw new Error("boom"); + }); + + const hidden = document.querySelector('textarea[tabindex="-1"]') as HTMLTextAreaElement; + let pasteHandler: ((e: any) => void) | undefined; + const origAdd = hidden.addEventListener.bind(hidden); + vi.spyOn(hidden, "addEventListener").mockImplementation((type: string, handler: any, opts?: any) => { + if (type === "paste") pasteHandler = handler; + return origAdd(type, handler, opts); + }); + + void userEvent.click(screen.getByTitle("Copy to VNC")); + await waitFor(() => expect(pasteHandler).toBeDefined()); + + await act(async () => { + pasteHandler!({ clipboardData: { getData: () => "pasted-text" } }); + }); + + await waitFor(() => expect(latest().clipboardPasteFrom).toHaveBeenCalledWith("pasted-text")); + }); + + it("does not paste when the local clipboard is empty", async () => { + mockFetch({ browserName: "chrome", password: "secret" }); + const readText = vi.fn().mockResolvedValue(""); + Object.defineProperty(navigator, "clipboard", { configurable: true, value: { readText } }); + + renderView(); + await waitFor(() => expect(rfb.instances.length).toBe(1)); + await emit("connect"); + + await userEvent.click(screen.getByTitle("Copy to VNC")); + await waitFor(() => expect(readText).toHaveBeenCalled()); + expect(latest().clipboardPasteFrom).not.toHaveBeenCalled(); + }); + + it("captures pasted text in the paste prompt textarea", async () => { + mockFetch({ browserName: "chrome", password: "secret" }); + Object.defineProperty(navigator, "clipboard", { configurable: true, value: {} }); + + renderView(); + await waitFor(() => expect(rfb.instances.length).toBe(1)); + await emit("connect"); + + await userEvent.click(screen.getByTitle("Copy to VNC")); + const input = document.querySelector(".clipboard-prompt-input") as HTMLTextAreaElement; + await act(async () => { + const pasteEvent = new Event("paste", { bubbles: true }) as any; + pasteEvent.clipboardData = { getData: () => "from-paste" }; + input.dispatchEvent(pasteEvent); + }); + expect((input as HTMLTextAreaElement).value).toBe("from-paste"); + }); + + it("toggling fullscreen off scales the viewport again", async () => { + mockFetch({ browserName: "chrome", password: "secret" }); + renderView(); + await waitFor(() => expect(rfb.instances.length).toBe(1)); + await emit("connect"); + + await userEvent.click(screen.getByTitle("Fullscreen")); + latest().scaleViewport = false; + await userEvent.click(screen.getByTitle("Fullscreen")); + expect(latest().scaleViewport).toBe(true); + }); + + it("requests desktop size on window resize while maximized", async () => { + mockFetch({ browserName: "chrome", password: "secret" }); + renderView(); + await waitFor(() => expect(rfb.instances.length).toBe(1)); + await emit("connect"); + + await userEvent.click(screen.getByTitle("Fullscreen")); + latest().requestDesktopSize.mockClear(); + await act(async () => { + window.dispatchEvent(new Event("resize")); + }); + expect(latest().requestDesktopSize).toHaveBeenCalled(); + }); + + it("disconnects the session on page unload", async () => { + mockFetch({ browserName: "chrome", password: "secret" }); + renderView(); + await waitFor(() => expect(rfb.instances.length).toBe(1)); + const inst = latest(); + + await act(async () => { + window.dispatchEvent(new Event("beforeunload")); + }); + expect(inst.disconnect).toHaveBeenCalled(); + }); + + it("keeps ticking the uptime while connected", async () => { + mockFetch({ browserName: "chrome", password: "secret", startTime: new Date(Date.now() - 5000).toISOString() }); + renderView(); + await waitFor(() => expect(rfb.instances.length).toBe(1)); + await emit("connect"); + + await act(async () => { + await new Promise((r) => setTimeout(r, 1100)); + }); + expect(screen.getByText(/Uptime:/)).toBeInTheDocument(); + }); +}); diff --git a/src/pages/VNCView.tsx b/src/pages/VNCView.tsx index ea52241..caf04f8 100644 --- a/src/pages/VNCView.tsx +++ b/src/pages/VNCView.tsx @@ -1,7 +1,21 @@ import React, { useEffect, useRef, useState } from "react"; -import { useParams, Link, useNavigate, useLocation } from "react-router-dom"; +import { useParams, useNavigate, useLocation } from "react-router-dom"; import RFB from "@novnc/novnc/lib/rfb"; import { formatUptime } from "../utils"; +import { + newAttemptState, + buildLadder, + nextCandidate, + registerFailure, + isHardCapReached, +} from "../lib/vncAuth"; +import { + getSavedByName, + setSavedByName, + getSavedByVersion, + setSavedByVersion, +} from "../lib/vncStore"; +import { PasswordPromptModal, SavePasswordModal, type SaveScope } from "../components/VncPasswordModals"; import copyToVncIcon from "../assets/icons/clipboard-paste.svg"; import copyFromVncIcon from "../assets/icons/clipboard-copy.svg"; import sendIcon from "../assets/icons/send.svg"; @@ -31,42 +45,39 @@ export const VNCView: React.FC = () => { const [pasteText, setPasteText] = useState(""); const pasteInputRef = useRef(null); - useEffect(() => { - if (!sessionStartTime) return; - - const update = () => setUptime(formatUptime(sessionStartTime)); - update(); - - const interval = setInterval(() => { - if (status === "Disconnected") return; - update(); - }, 1000); - - return () => clearInterval(interval); - }, [sessionStartTime, status]); - - useEffect(() => { - if (!id) return; - - if (sessionStartTime) return; - - fetch(`/api/v1/browsers/${id}`) - .then((r) => { - if (!r.ok) throw new Error('Failed to fetch session'); - return r.json(); - }) - .then((session: { browserId: string; startTime: string }) => { - if (session.browserId === id) { - setSessionStartTime(session.startTime); - } - }) - .catch(() => { - }); - }, [id, sessionStartTime]); + const browserNameRef = useRef(""); + const browserVersionRef = useRef(""); + const attemptRef = useRef(newAttemptState()); + const ladderRef = useRef([]); + const pendingPwRef = useRef(""); + const manualRef = useRef(false); + const securityFailedRef = useRef(false); + const isMaximizedRef = useRef(false); + const createRfbRef = useRef<(password: string, manual: boolean) => void>(() => {}); + + const [showPrompt, setShowPrompt] = useState(false); + const [promptError, setPromptError] = useState(undefined); + const [showSave, setShowSave] = useState(false); + const [notice, setNotice] = useState(""); + + const teardownRfb = () => { + const current = rfbRef.current; + rfbRef.current = null; + if (current) { + try { + current.disconnect(); + } catch {} + } + }; - useEffect(() => { + createRfbRef.current = (password: string, manual: boolean) => { if (!id || !containerRef.current) return; - if (rfbRef.current) return; + + teardownRfb(); + manualRef.current = manual; + pendingPwRef.current = password; + securityFailedRef.current = false; + setStatus("Connecting"); const protocol = window.location.protocol === "https:" ? "wss" : "ws"; const host = window.location.host; @@ -77,37 +88,115 @@ export const VNCView: React.FC = () => { resizeSession: false, }); - rfb.addEventListener("credentialsrequired", async () => { - try { - const resp = await fetch(`/api/v1/browsers/${id}/vnc/settings`); - if (!resp.ok) throw new Error("failed to get password"); - const { password } = await resp.json(); - - if (typeof rfb.sendCredentials === "function") { - rfb.sendCredentials({ password }); - } else { - (rfb as any)._rfb_credentials = { password }; - } - } catch { - setStatus("Disconnected"); - try { rfb.disconnect(); } catch {} + rfb.addEventListener("credentialsrequired", () => { + const pw = pendingPwRef.current; + if (typeof rfb.sendCredentials === "function") { + rfb.sendCredentials({ password: pw }); + } else { + (rfb as any)._rfb_credentials = { password: pw }; } }); - rfb.viewOnly = false; rfb.localCursor = true; rfb.clipViewport = false; rfb.viewportDrag = false; - rfb.addEventListener("connect", () => setStatus("Connected")); - rfb.addEventListener("disconnect", () => setStatus("Disconnected")); + rfb.addEventListener("connect", () => { + setStatus("Connected"); + setNotice(""); + if (manualRef.current) { + setShowSave(true); + } + }); + + rfb.addEventListener("securityfailure", (event: any) => { + securityFailedRef.current = true; + const reason = event?.detail?.reason ?? "authentication failed"; + attemptRef.current = registerFailure(attemptRef.current, pendingPwRef.current); + if (isHardCapReached(attemptRef.current)) { + setStatus("Disconnected"); + setNotice(`VNC auth failed: ${reason}`); + return; + } + const next = nextCandidate(ladderRef.current, attemptRef.current.tried); + if (next !== null) { + createRfbRef.current(next, false); + return; + } + manualRef.current = true; + setPromptError(`VNC auth failed: ${reason}`); + setShowPrompt(true); + }); + + rfb.addEventListener("disconnect", (event: any) => { + if (securityFailedRef.current) return; + setStatus("Disconnected"); + if (event?.detail?.clean === false) { + setNotice("Connection lost"); + } + }); + rfb.addEventListener("clipboard", (event: any) => { const text = event?.detail?.text ?? ""; setLastRemoteClipboard(text); }); rfbRef.current = rfb; + }; + + useEffect(() => { + if (!sessionStartTime) return; + + const update = () => setUptime(formatUptime(sessionStartTime)); + update(); + + const interval = setInterval(() => { + if (status === "Disconnected") return; + update(); + }, 1000); + + return () => clearInterval(interval); + }, [sessionStartTime, status]); + + useEffect(() => { + if (!id || !containerRef.current) return; + if (rfbRef.current) return; + + let cancelled = false; + + const start = async () => { + try { + const resp = await fetch(`/api/v1/browsers/${id}`); + if (resp.ok) { + const session: { browserId: string; browserName?: string; browserVersion?: string; startTime?: string } = await resp.json(); + if (session.browserId === id) { + browserNameRef.current = session.browserName ?? ""; + browserVersionRef.current = session.browserVersion ?? ""; + if (!sessionStartTime && session.startTime) { + setSessionStartTime(session.startTime); + } + } + } + } catch {} + + if (cancelled) return; + + ladderRef.current = buildLadder( + getSavedByVersion(browserNameRef.current, browserVersionRef.current), + getSavedByName(browserNameRef.current), + ); + + const first = nextCandidate(ladderRef.current, attemptRef.current.tried); + if (first !== null) { + createRfbRef.current(first, false); + } else { + manualRef.current = true; + setShowPrompt(true); + } + }; + + start(); const resizeCanvas = () => { if (!rfbRef.current || !containerRef.current) return; @@ -117,32 +206,36 @@ export const VNCView: React.FC = () => { const h = Math.round(rect.height); try { - if (isMaximized && typeof rfbRef.current.requestDesktopSize === "function") { + if (isMaximizedRef.current && typeof rfbRef.current.requestDesktopSize === "function") { rfbRef.current.requestDesktopSize(w, h); } else { rfbRef.current.scaleViewport = true; } - } catch { } + } catch {} }; window.addEventListener("resize", resizeCanvas); - setTimeout(resizeCanvas, 200); + const resizeTimer = setTimeout(resizeCanvas, 200); const handleUnload = () => { try { rfbRef.current?.disconnect(); - } catch { } + } catch {} }; window.addEventListener("beforeunload", handleUnload); return () => { + cancelled = true; + clearTimeout(resizeTimer); window.removeEventListener("resize", resizeCanvas); window.removeEventListener("beforeunload", handleUnload); + teardownRfb(); }; }, [id]); useEffect(() => { + isMaximizedRef.current = isMaximized; if (!rfbRef.current || !containerRef.current) return; const rect = containerRef.current.getBoundingClientRect(); @@ -155,14 +248,34 @@ export const VNCView: React.FC = () => { } else { rfbRef.current.scaleViewport = true; } - } catch { } + } catch {} }, [isMaximized]); - const handleClose = () => { - try { - rfbRef.current?.disconnect(); - } catch { } + const handlePromptSubmit = (password: string) => { + setShowPrompt(false); + setPromptError(undefined); + createRfbRef.current(password, true); + }; + const handlePromptCancel = () => { + setShowPrompt(false); + teardownRfb(); + setStatus("Disconnected"); + setNotice("VNC authentication cancelled"); + }; + + const handleSaveChoose = (scope: SaveScope) => { + const pw = pendingPwRef.current; + if (scope === "name") { + setSavedByName(browserNameRef.current, pw); + } else if (scope === "version") { + setSavedByVersion(browserNameRef.current, browserVersionRef.current, pw); + } + setShowSave(false); + }; + + const handleClose = () => { + teardownRfb(); navigate("/ui/"); }; @@ -252,24 +365,6 @@ export const VNCView: React.FC = () => { input.select(); }, [showPastePrompt]); - const submitPasteText = (text: string) => { - const value = text.trim(); - if (value && rfbRef.current?.clipboardPasteFrom) { - rfbRef.current.clipboardPasteFrom(value); - try { - rfbRef.current.sendKey(0xFFE3, "ControlLeft", true); - rfbRef.current.sendKey(0x0076, "KeyV", true); - rfbRef.current.sendKey(0x0076, "KeyV", false); - rfbRef.current.sendKey(0xFFE3, "ControlLeft", false); - } catch { - } - setClipboardHint("Pasted to VNC"); - setTimeout(() => setClipboardHint(""), 2000); - } - setShowPastePrompt(false); - setPasteText(""); - }; - const handlePastePromptChange = (event: React.ChangeEvent) => { const value = event.target.value; setPasteText(value); @@ -378,7 +473,9 @@ export const VNCView: React.FC = () => {
{status !== "Connected" && (
- {status === "Connecting" ? "Session Loading..." : "Session Disconnected"} + {status === "Connecting" + ? "Session Loading..." + : notice || "Session Disconnected"}
)} {clipboardHint && ( @@ -409,6 +506,21 @@ export const VNCView: React.FC = () => {
+ + {showPrompt && ( + + )} + {showSave && ( + + )} ); }; diff --git a/src/vitest.config.ts b/src/vitest.config.ts new file mode 100644 index 0000000..b8bbfa3 --- /dev/null +++ b/src/vitest.config.ts @@ -0,0 +1,21 @@ +import { defineConfig } from "vitest/config"; +import react from "@vitejs/plugin-react"; + +export default defineConfig({ + plugins: [react()], + define: { + __BUILD_NUMBER__: JSON.stringify("test"), + }, + test: { + environment: "jsdom", + globals: true, + setupFiles: ["./vitest.setup.ts"], + css: false, + coverage: { + provider: "v8", + reporter: ["text", "text-summary"], + include: ["lib/**/*.ts", "components/**/*.tsx", "pages/VNCView.tsx"], + exclude: ["**/*.test.ts", "**/*.test.tsx"], + }, + }, +}); diff --git a/src/vitest.setup.ts b/src/vitest.setup.ts new file mode 100644 index 0000000..f149f27 --- /dev/null +++ b/src/vitest.setup.ts @@ -0,0 +1 @@ +import "@testing-library/jest-dom/vitest";