diff --git a/README.md b/README.md index af75e81f..da3452d4 100644 --- a/README.md +++ b/README.md @@ -289,7 +289,7 @@ codex | OpenAI | `POST /v1/chat/completions` | | Codex | `POST /v1/responses` | | Gemini | `POST /v1beta/models/{model}:generateContent` | -| Project Proxy | `/{project-slug}/v1/messages` (etc.) | +| Project Proxy | `/project/{project-slug}/v1/messages` (etc.) | | Admin API | `/api/admin/*` | | WebSocket | `ws://localhost:9880/ws` | | Health Check | `GET /health` | @@ -304,8 +304,65 @@ codex | `MAXX_ADMIN_PASSWORD` | Enable admin authentication with JWT. Default username: `admin`, password: the value of this variable | | `MAXX_DSN` | Database connection string | | `MAXX_DATA_DIR` | Custom data directory path | +| `MAXX_DISABLE_UI` | Headless mode: when truthy (`1`/`true`/`yes`/`on`), do not serve the web UI — only the API and proxy endpoints are exposed. Equivalent to the `-no-ui` flag (the flag takes precedence when set). Project proxy routes (`/project/{slug}/...`) remain available. | +| `MAXX_CORS_ALLOW_ORIGINS` | Comma-separated list of allowed origins (or `*`) for cross-origin requests. Enables a separately-hosted frontend to point at this backend; unset disables CORS (same-origin only). | | `MAXX_ROUTING_SEED_SALT` | Optional shared secret for the `weighted_random` routing strategy. If unset, each process generates its own random salt — anti-grinding still holds and Redis sticky bindings still converge after the first successful request, but the pre-sticky first-pick order for the same `(token, session)` can differ across instances. Set the **same value on every instance** when you need consistent first-pick behavior in multi-instance deployments. | +### Headless Mode (API-only, no Web UI) + +Run maxx as a pure API gateway without serving the admin Web UI — useful for +server/production deployments where you configure everything through the Admin +API and want a smaller attack surface. + +Enable it with **either** the `-no-ui` flag **or** the `MAXX_DISABLE_UI` +environment variable (the flag wins if both are set): + +```bash +# Flag (local build) +maxx -no-ui + +# Env var (Docker / compose) +docker run -e MAXX_DISABLE_UI=true -p 9880:9880 ghcr.io/awsl-project/maxx +``` + +In headless mode: + +- `/` and all web UI routes return `404` (no static files are served). +- The API (`/api/admin/*`), proxy endpoints (`/v1/messages`, `/v1/chat/completions`, …), project proxy (`/project/{slug}/...`), `/health`, and `/ws` all keep working. +- Configure providers, routes, tokens, etc. via the Admin API. Set `MAXX_ADMIN_PASSWORD` to protect it. + +### Separately-hosted Frontend (point the UI at a remote backend) + +You can host the Web UI on one origin (e.g. a CDN, a dev server, or a headless +maxx's sibling) and have it talk to a backend on a **different** origin. + +**1. Allow the frontend's origin on the backend** via CORS (otherwise the +browser blocks cross-origin requests): + +```bash +# Single origin +MAXX_CORS_ALLOW_ORIGINS=https://ui.example.com maxx + +# Multiple origins (comma-separated), or "*" to allow any +MAXX_CORS_ALLOW_ORIGINS=https://ui.example.com,http://localhost:3000 maxx +``` + +> ⚠️ **CORS is not a substitute for authentication.** `*` lets *any* website read +> and call your API from a browser, including the admin API. Only use `*` for +> trusted/local setups, and always set `MAXX_ADMIN_PASSWORD` so the admin API +> requires a token. Prefer listing explicit origins over `*`. maxx logs a warning +> at startup if `*` is combined with an unauthenticated admin API. + +**2. Point the UI at the backend.** Open the Web UI and either: + +- On the **login screen**, expand **Connection settings** and enter the backend URL (e.g. `https://api.example.com`); or +- After login, go to **Settings → Backend address**. + +The value is stored in the browser (`localStorage`), so each user/browser can +target a different backend. Leave it empty to use the same origin that served +the page (the default). Build-time default: set `VITE_BACKEND_URL` when building +the frontend. + ### System Settings Configurable via Admin UI: diff --git a/README_CN.md b/README_CN.md index 3d117a79..88c4c136 100644 --- a/README_CN.md +++ b/README_CN.md @@ -287,7 +287,7 @@ codex | OpenAI | `POST /v1/chat/completions` | | Codex | `POST /v1/responses` | | Gemini | `POST /v1beta/models/{model}:generateContent` | -| 项目代理 | `/{project-slug}/v1/messages` (等) | +| 项目代理 | `/project/{project-slug}/v1/messages` (等) | | 管理 API | `/api/admin/*` | | WebSocket | `ws://localhost:9880/ws` | | 健康检查 | `GET /health` | @@ -302,8 +302,53 @@ codex | `MAXX_ADMIN_PASSWORD` | 启用管理员 JWT 认证。默认用户名:`admin`,密码为该变量的值 | | `MAXX_DSN` | 数据库连接字符串 | | `MAXX_DATA_DIR` | 自定义数据目录路径 | +| `MAXX_DISABLE_UI` | 无前端模式:取值为真(`1`/`true`/`yes`/`on`)时不再提供 Web UI,仅暴露 API 与代理接口。等价于 `-no-ui` 命令行参数(同时设置时以 flag 为准)。项目代理路由(`/project/{slug}/...`)仍然可用 | +| `MAXX_CORS_ALLOW_ORIGINS` | 允许跨源访问的来源列表(逗号分隔,或 `*`)。用于让单独托管的前端连接此后端;未设置时关闭 CORS(仅同源) | | `MAXX_ROUTING_SEED_SALT` | 可选共享密钥,用于 `weighted_random` 路由策略。未设置时每个进程会生成自己的随机盐——防 SessionID 枚举仍然成立,Redis sticky 绑定也会在首次成功后跨实例收敛;但相同 `(token, session)` 在 sticky 写入前的首选顺序在各实例间可能不一致。**多实例部署且需要一致首选顺序**时,请在所有实例上设置相同的值 | +### 无前端模式(仅 API,不提供 Web UI) + +把 maxx 作为纯 API 网关运行,不再提供管理 Web UI——适合服务端/生产部署:所有配置都通过 Admin API 完成,同时减小攻击面。 + +用 `-no-ui` 命令行参数**或** `MAXX_DISABLE_UI` 环境变量开启(两者同时设置时以 flag 为准): + +```bash +# 命令行参数(本地构建) +maxx -no-ui + +# 环境变量(Docker / compose) +docker run -e MAXX_DISABLE_UI=true -p 9880:9880 ghcr.io/awsl-project/maxx +``` + +无前端模式下: + +- `/` 及所有 Web UI 路由返回 `404`(不提供静态文件)。 +- API(`/api/admin/*`)、代理接口(`/v1/messages`、`/v1/chat/completions` 等)、项目代理(`/project/{slug}/...`)、`/health`、`/ws` 均照常工作。 +- 通过 Admin API 配置 provider、路由、token 等。建议设置 `MAXX_ADMIN_PASSWORD` 保护接口。 + +### 前端单独托管(让 UI 连接远程后端) + +可以把 Web UI 托管在一个来源(如 CDN、开发服务器),让它连接**另一个来源**上的后端。 + +**1. 在后端放行前端来源**(CORS,否则浏览器会拦截跨源请求): + +```bash +# 单个来源 +MAXX_CORS_ALLOW_ORIGINS=https://ui.example.com maxx + +# 多个来源(逗号分隔),或用 "*" 放行任意来源 +MAXX_CORS_ALLOW_ORIGINS=https://ui.example.com,http://localhost:3000 maxx +``` + +> ⚠️ **CORS 不能替代鉴权。** `*` 会让*任意*网站都能从浏览器读取并调用你的 API(包括管理 API)。只在受信/本地环境用 `*`,并务必设置 `MAXX_ADMIN_PASSWORD` 让管理 API 需要 token。尽量列举明确来源而非 `*`。当 `*` 与未鉴权的管理 API 同时出现时,maxx 启动时会打印告警。 + +**2. 让 UI 指向后端。** 打开 Web UI,二选一: + +- 在**登录页**展开 **连接设置**,填入后端地址(如 `https://api.example.com`);或 +- 登录后进入 **设置 → 后端地址**。 + +该值保存在浏览器(`localStorage`),所以不同用户/浏览器可以连接不同后端。留空则使用提供页面的同源地址(默认)。构建期默认值:构建前端时设置 `VITE_BACKEND_URL`。 + ### 系统设置 通过管理界面配置: diff --git a/cmd/maxx/main.go b/cmd/maxx/main.go index c53353fb..f0c7a08f 100644 --- a/cmd/maxx/main.go +++ b/cmd/maxx/main.go @@ -17,9 +17,9 @@ import ( "github.com/awsl-project/maxx/internal/adapter/client" "github.com/awsl-project/maxx/internal/adapter/provider/bedrock" - _ "github.com/awsl-project/maxx/internal/adapter/provider/claude" // Register claude adapter - _ "github.com/awsl-project/maxx/internal/adapter/provider/custom" // Register custom adapter - _ "github.com/awsl-project/maxx/internal/adapter/provider/kiro" // Register kiro adapter + _ "github.com/awsl-project/maxx/internal/adapter/provider/claude" // Register claude adapter + _ "github.com/awsl-project/maxx/internal/adapter/provider/custom" // Register custom adapter + _ "github.com/awsl-project/maxx/internal/adapter/provider/kiro" // Register kiro adapter "github.com/awsl-project/maxx/internal/converter" "github.com/awsl-project/maxx/internal/cooldown" "github.com/awsl-project/maxx/internal/core" @@ -51,13 +51,43 @@ func generateInstanceID() string { return fmt.Sprintf("%s-%d", hostname, time.Now().UnixNano()) } +// flagPassed reports whether the named flag was explicitly set on the command +// line, so an explicit flag can take precedence over an environment variable. +func flagPassed(name string) bool { + found := false + flag.Visit(func(f *flag.Flag) { + if f.Name == name { + found = true + } + }) + return found +} + +// isTruthyEnv interprets common truthy spellings of a boolean env var. +func isTruthyEnv(v string) bool { + switch strings.ToLower(strings.TrimSpace(v)) { + case "1", "true", "yes", "on": + return true + default: + return false + } +} + func main() { // Parse flags addr := flag.String("addr", ":9880", "Server address") dataDir := flag.String("data", "", "Data directory for database and logs (default: ~/.config/maxx)") showVersion := flag.Bool("version", false, "Show version information and exit") + noUI := flag.Bool("no-ui", false, "Headless mode: do not serve the web UI, expose API/proxy only (env: MAXX_DISABLE_UI)") flag.Parse() + // Headless mode: an explicit -no-ui flag wins; otherwise fall back to the + // MAXX_DISABLE_UI env var (truthy = 1/true/yes/on) for container deployments. + disableUI := *noUI + if !flagPassed("no-ui") { + disableUI = isTruthyEnv(os.Getenv("MAXX_DISABLE_UI")) + } + // Show version and exit if requested if *showVersion { fmt.Println("maxx", version.Full()) @@ -525,13 +555,32 @@ func main() { // WebSocket endpoint mux.HandleFunc("/ws", wsHub.HandleWebSocket) - // Serve static files (Web UI) with project proxy support - must be last (default route) - staticHandler := handler.NewStaticHandler() - combinedHandler := handler.NewCombinedHandler(projectProxyHandler, staticHandler) - mux.Handle("/", combinedHandler) + // Default route ("/"). In headless mode we skip the web UI entirely and + // only keep the project-prefixed proxy (/project/{slug}/...); everything + // else 404s. Otherwise serve the web UI with project proxy support. + if disableUI { + mux.Handle("/", projectProxyHandler) + log.Printf("Web UI: disabled (headless mode)") + } else { + staticHandler := handler.NewStaticHandler() + combinedHandler := handler.NewCombinedHandler(projectProxyHandler, staticHandler) + mux.Handle("/", combinedHandler) + } - // Wrap with logging middleware - loggedMux := handler.LoggingMiddleware(mux) + // Wrap with logging middleware, then CORS (outermost so preflight responses + // also carry the headers). CORS is a no-op unless MAXX_CORS_ALLOW_ORIGINS + // is set — this lets a separately-hosted frontend point at this backend. + corsConfig := handler.ParseCORSOrigins(os.Getenv("MAXX_CORS_ALLOW_ORIGINS")) + if corsConfig.Enabled() { + log.Printf("CORS: allowing origins %v", corsConfig.AllowOrigins) + // A wildcard origin makes every route — including the admin API — + // readable from any website. CORS is not a substitute for auth, so warn + // loudly when "*" is combined with an unauthenticated admin API. + if corsConfig.HasWildcard() && os.Getenv("MAXX_ADMIN_PASSWORD") == "" { + log.Printf("WARNING: MAXX_CORS_ALLOW_ORIGINS=* with no MAXX_ADMIN_PASSWORD — any website can read/modify the admin API from a browser. Set MAXX_ADMIN_PASSWORD or list explicit trusted origins instead of '*'.") + } + } + loggedMux := handler.CORSMiddleware(corsConfig, handler.LoggingMiddleware(mux)) // Create HTTP server server := &http.Server{ diff --git a/internal/core/server.go b/internal/core/server.go index 06f1b70d..70505cdf 100644 --- a/internal/core/server.go +++ b/internal/core/server.go @@ -27,6 +27,8 @@ type ServerConfig struct { SettingRepo repository.SystemSettingRepository ServeStatic bool AuthMiddleware *handler.AuthMiddleware + // CORS controls cross-origin access. Zero value (no origins) disables it. + CORS handler.CORSConfig } // ManagedServer 可管理的服务器(支持启动/停止) @@ -135,7 +137,7 @@ func (s *ManagedServer) Start(ctx context.Context) error { s.httpServer = &http.Server{ Addr: s.config.Addr, - Handler: s.mux, + Handler: handler.CORSMiddleware(s.config.CORS, s.mux), ErrorLog: nil, } diff --git a/internal/desktop/launcher.go b/internal/desktop/launcher.go index 94cf3439..ce2effc8 100644 --- a/internal/desktop/launcher.go +++ b/internal/desktop/launcher.go @@ -15,6 +15,7 @@ import ( "time" "github.com/awsl-project/maxx/internal/core" + "github.com/awsl-project/maxx/internal/handler" "github.com/awsl-project/maxx/internal/version" "github.com/wailsapp/wails/v2/pkg/options" "github.com/wailsapp/wails/v2/pkg/runtime" @@ -224,6 +225,7 @@ func (a *LauncherApp) startServerAsync() { Components: components, SettingRepo: dbRepos.SettingRepo, ServeStatic: true, // 关键:启用静态文件服务 + CORS: handler.ParseCORSOrigins(os.Getenv("MAXX_CORS_ALLOW_ORIGINS")), } server, err := core.NewManagedServer(serverConfig) diff --git a/internal/handler/cors.go b/internal/handler/cors.go new file mode 100644 index 00000000..26052961 --- /dev/null +++ b/internal/handler/cors.go @@ -0,0 +1,114 @@ +package handler + +import ( + "net/http" + "strings" +) + +// CORSConfig holds the cross-origin policy for the HTTP server. It is populated +// from the MAXX_CORS_ALLOW_ORIGINS environment variable so that a frontend +// hosted on a different origin (e.g. a static build served from a CDN) can talk +// to this backend. +type CORSConfig struct { + // AllowOrigins is the set of permitted request origins. A single "*" entry + // allows any origin. An empty slice disables CORS entirely (same-origin + // only), which is the default. + AllowOrigins []string +} + +// ParseCORSOrigins parses a comma-separated origin list (the value of +// MAXX_CORS_ALLOW_ORIGINS) into a CORSConfig. Whitespace around entries is +// trimmed and empty entries are dropped. A trailing slash is stripped from each +// entry so a configured "https://ui.example.com/" still matches the browser's +// slash-less "Origin: https://ui.example.com" header. +func ParseCORSOrigins(raw string) CORSConfig { + var origins []string + for _, part := range strings.Split(raw, ",") { + trimmed := strings.TrimSpace(part) + if trimmed == "" { + continue + } + if trimmed != "*" { + trimmed = strings.TrimRight(trimmed, "/") + } + origins = append(origins, trimmed) + } + return CORSConfig{AllowOrigins: origins} +} + +// HasWildcard reports whether any configured origin is the "*" wildcard. +func (c CORSConfig) HasWildcard() bool { + for _, o := range c.AllowOrigins { + if o == "*" { + return true + } + } + return false +} + +// Enabled reports whether any origins are configured. When false, CORSMiddleware +// is a no-op pass-through. +func (c CORSConfig) Enabled() bool { + return len(c.AllowOrigins) > 0 +} + +// allows reports whether the given request Origin is permitted. +func (c CORSConfig) allows(origin string) bool { + for _, allowed := range c.AllowOrigins { + if allowed == "*" || strings.EqualFold(allowed, origin) { + return true + } + } + return false +} + +// CORSMiddleware wraps a handler with cross-origin headers driven by cfg. When +// cfg has no origins it returns next unchanged so there is zero overhead in the +// common same-origin deployment. Requests carry a Bearer token rather than +// cookies, so the origin is reflected without Allow-Credentials. +func CORSMiddleware(cfg CORSConfig, next http.Handler) http.Handler { + if !cfg.Enabled() { + return next + } + + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + origin := r.Header.Get("Origin") + // Advertise that the response varies by Origin whenever one is present — + // even for disallowed origins — so a shared cache never reuses a no-CORS + // response for an allowlisted origin (or vice versa). + if origin != "" { + w.Header().Add("Vary", "Origin") + } + allowed := origin != "" && cfg.allows(origin) + isPreflight := r.Method == http.MethodOptions && r.Header.Get("Access-Control-Request-Method") != "" + + if allowed { + // Reflect the concrete origin (even for "*") so the response stays + // valid if a caller later adds credentials, and so Vary is honored. + w.Header().Set("Access-Control-Allow-Origin", origin) + w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS") + + reqHeaders := r.Header.Get("Access-Control-Request-Headers") + if reqHeaders == "" { + reqHeaders = "Authorization, Content-Type" + } + w.Header().Set("Access-Control-Allow-Headers", reqHeaders) + w.Header().Set("Access-Control-Max-Age", "86400") + } + + // Short-circuit only valid preflights from an allowed origin. Disallowed + // origins (and non-preflight OPTIONS) fall through to normal handling so + // they get no CORS headers — the browser then blocks the cross-origin + // read — and so unknown routes/methods still surface their real status. + if isPreflight && allowed { + // The Allow-Headers value is reflected from the request, so the + // response varies on the preflight request headers/method too. + w.Header().Add("Vary", "Access-Control-Request-Method") + w.Header().Add("Vary", "Access-Control-Request-Headers") + w.WriteHeader(http.StatusNoContent) + return + } + + next.ServeHTTP(w, r) + }) +} diff --git a/internal/handler/cors_test.go b/internal/handler/cors_test.go new file mode 100644 index 00000000..40924abb --- /dev/null +++ b/internal/handler/cors_test.go @@ -0,0 +1,206 @@ +package handler + +import ( + "net/http" + "net/http/httptest" + "testing" + "testing/fstest" +) + +func TestParseCORSOrigins(t *testing.T) { + cases := []struct { + raw string + want []string + enabled bool + }{ + {"", nil, false}, + {" ", nil, false}, + {"*", []string{"*"}, true}, + {"https://a.com, https://b.com ", []string{"https://a.com", "https://b.com"}, true}, + {"https://a.com,,", []string{"https://a.com"}, true}, + // Trailing slashes are stripped so they match the slash-less Origin header. + {"https://a.com/", []string{"https://a.com"}, true}, + {"https://a.com/// , https://b.com/", []string{"https://a.com", "https://b.com"}, true}, + } + for _, c := range cases { + got := ParseCORSOrigins(c.raw) + if got.Enabled() != c.enabled { + t.Errorf("ParseCORSOrigins(%q).Enabled() = %v, want %v", c.raw, got.Enabled(), c.enabled) + } + if len(got.AllowOrigins) != len(c.want) { + t.Errorf("ParseCORSOrigins(%q) = %v, want %v", c.raw, got.AllowOrigins, c.want) + continue + } + for i := range c.want { + if got.AllowOrigins[i] != c.want[i] { + t.Errorf("ParseCORSOrigins(%q)[%d] = %q, want %q", c.raw, i, got.AllowOrigins[i], c.want[i]) + } + } + } +} + +func TestCORSMiddlewareDisabledIsPassthrough(t *testing.T) { + next := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusTeapot) + }) + h := CORSMiddleware(CORSConfig{}, next) + // When disabled, the middleware must not add any CORS headers. + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.Header.Set("Origin", "https://x.com") + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Header().Get("Access-Control-Allow-Origin") != "" { + t.Fatalf("disabled CORS should not set Allow-Origin header") + } + if rec.Code != http.StatusTeapot { + t.Fatalf("disabled CORS should pass through to next handler, got %d", rec.Code) + } +} + +func TestCORSMiddlewareAllowsConfiguredOrigin(t *testing.T) { + next := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + }) + h := CORSMiddleware(ParseCORSOrigins("https://app.example.com"), next) + + // Allowed origin gets reflected. + req := httptest.NewRequest(http.MethodGet, "/api/admin/providers", nil) + req.Header.Set("Origin", "https://app.example.com") + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "https://app.example.com" { + t.Fatalf("Allow-Origin = %q, want reflected origin", got) + } + + // Disallowed origin gets no CORS header. + req2 := httptest.NewRequest(http.MethodGet, "/api/admin/providers", nil) + req2.Header.Set("Origin", "https://evil.example.com") + rec2 := httptest.NewRecorder() + h.ServeHTTP(rec2, req2) + if got := rec2.Header().Get("Access-Control-Allow-Origin"); got != "" { + t.Fatalf("Allow-Origin = %q, want empty for disallowed origin", got) + } +} + +func TestCORSMiddlewarePreflightShortCircuits(t *testing.T) { + called := false + next := http.HandlerFunc(func(http.ResponseWriter, *http.Request) { called = true }) + h := CORSMiddleware(ParseCORSOrigins("*"), next) + + req := httptest.NewRequest(http.MethodOptions, "/api/admin/providers", nil) + req.Header.Set("Origin", "https://anything.example.com") + req.Header.Set("Access-Control-Request-Method", "POST") + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + + if called { + t.Fatal("preflight should not reach the next handler") + } + if rec.Code != http.StatusNoContent { + t.Fatalf("preflight status = %d, want 204", rec.Code) + } + if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "https://anything.example.com" { + t.Fatalf("wildcard Allow-Origin = %q, want reflected origin", got) + } + // Preflight responses must vary on the request headers/method they reflect. + vary := rec.Header().Values("Vary") + for _, want := range []string{"Origin", "Access-Control-Request-Method", "Access-Control-Request-Headers"} { + if !containsStr(vary, want) { + t.Fatalf("preflight Vary=%v missing %q", vary, want) + } + } +} + +func TestParseCORSOriginsTrailingSlashMatches(t *testing.T) { + // A configured origin with a trailing slash still matches the slash-less + // Origin header the browser sends. + h := CORSMiddleware(ParseCORSOrigins("https://ui.example.com/"), http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + })) + req := httptest.NewRequest(http.MethodGet, "/health", nil) + req.Header.Set("Origin", "https://ui.example.com") + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "https://ui.example.com" { + t.Fatalf("Allow-Origin = %q, want match despite configured trailing slash", got) + } +} + +func TestCORSMiddlewareDisallowedPreflightFallsThrough(t *testing.T) { + // A preflight from a disallowed origin must NOT be short-circuited with 204; + // it falls through to the next handler with no CORS headers so the browser + // blocks it and the real route status is preserved. + next := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusTeapot) + }) + h := CORSMiddleware(ParseCORSOrigins("https://ui.example.com"), next) + + req := httptest.NewRequest(http.MethodOptions, "/api/admin/providers", nil) + req.Header.Set("Origin", "https://evil.example.com") + req.Header.Set("Access-Control-Request-Method", "DELETE") + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + + if rec.Code != http.StatusTeapot { + t.Fatalf("disallowed preflight status = %d, want pass-through 418", rec.Code) + } + if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "" { + t.Fatalf("disallowed preflight should have no Allow-Origin, got %q", got) + } +} + +func TestCORSMiddlewareVaryOriginForDisallowedOrigin(t *testing.T) { + // Even a disallowed origin must get Vary: Origin (but no Allow-Origin) so a + // cache can't reuse this no-CORS response for an allowlisted origin. + h := CORSMiddleware(ParseCORSOrigins("https://ui.example.com"), http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + })) + req := httptest.NewRequest(http.MethodGet, "/health", nil) + req.Header.Set("Origin", "https://evil.example.com") + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + + if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "" { + t.Fatalf("disallowed origin should have no Allow-Origin, got %q", got) + } + if !containsStr(rec.Header().Values("Vary"), "Origin") { + t.Fatalf("disallowed-origin response Vary=%v missing Origin", rec.Header().Values("Vary")) + } +} + +func TestCORSPreservesVaryWithStaticHandler(t *testing.T) { + // The static handler writes Vary: Accept-Encoding. Wrapped by CORSMiddleware, + // the response for an allowed origin must keep BOTH Origin (from CORS) and + // Accept-Encoding (from static) — i.e. static.go must Add, not Set. Uses the + // real NewStaticHandler so a regression in static.go is caught here. + prev := StaticFS + StaticFS = fstest.MapFS{"index.html": &fstest.MapFile{Data: []byte("")}} + t.Cleanup(func() { StaticFS = prev }) + + h := CORSMiddleware(ParseCORSOrigins("https://ui.example.com"), NewStaticHandler()) + + req := httptest.NewRequest(http.MethodGet, "/index.html", nil) + req.Header.Set("Origin", "https://ui.example.com") + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + + vary := rec.Header().Values("Vary") + if !containsStr(vary, "Origin") { + t.Fatalf("Vary lost Origin (static handler overwrote it): %v", vary) + } + if !containsStr(vary, "Accept-Encoding") { + t.Fatalf("Vary missing Accept-Encoding: %v", vary) + } + if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "https://ui.example.com" { + t.Fatalf("Allow-Origin = %q, want reflected origin", got) + } +} + +func containsStr(s []string, want string) bool { + for _, v := range s { + if v == want { + return true + } + } + return false +} diff --git a/internal/handler/static.go b/internal/handler/static.go index b5fc6744..ed92d8fd 100644 --- a/internal/handler/static.go +++ b/internal/handler/static.go @@ -197,8 +197,10 @@ func serveFromCache(w http.ResponseWriter, r *http.Request, cached *staticFileCa // Set content type w.Header().Set("Content-Type", cached.contentType) - // Always set Vary header to ensure caches differentiate by Accept-Encoding - w.Header().Set("Vary", "Accept-Encoding") + // Always advertise that the response varies by Accept-Encoding. Use Add (not + // Set) so we append to — rather than overwrite — any Vary value an upstream + // middleware already wrote (e.g. CORSMiddleware's Vary: Origin). + w.Header().Add("Vary", "Accept-Encoding") // Check if client accepts gzip and we have gzipped content if cached.gzipped != nil && strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") { diff --git a/tests/e2e/cors_e2e_test.go b/tests/e2e/cors_e2e_test.go new file mode 100644 index 00000000..feabf98f --- /dev/null +++ b/tests/e2e/cors_e2e_test.go @@ -0,0 +1,98 @@ +package e2e_test + +import ( + "net/http" + "testing" +) + +// originReq issues a request to env carrying an Origin header (and, when +// preflightMethod is set, the Access-Control-Request-Method preflight header +// plus the OPTIONS method). An empty token sends the request unauthenticated. +func originReq(t *testing.T, env *TestEnv, method, path, origin, token, preflightMethod string) *http.Response { + t.Helper() + req, err := http.NewRequest(method, env.URL(path), nil) + if err != nil { + t.Fatalf("Failed to create request: %v", err) + } + if origin != "" { + req.Header.Set("Origin", origin) + } + if preflightMethod != "" { + req.Header.Set("Access-Control-Request-Method", preflightMethod) + } + if token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("Request failed: %v", err) + } + return resp +} + +// TestCORS_DisabledByDefault verifies the default deployment adds no CORS +// headers, even when a request carries an Origin. +func TestCORS_DisabledByDefault(t *testing.T) { + env := NewTestEnv(t) + + resp := originReq(t, env, http.MethodGet, "/health", "https://ui.example.com", "", "") + AssertStatus(t, resp, http.StatusOK) + defer resp.Body.Close() + if got := resp.Header.Get("Access-Control-Allow-Origin"); got != "" { + t.Fatalf("CORS disabled by default, unexpected Allow-Origin=%q", got) + } +} + +// TestCORS_PreflightAndActualRequest verifies that, with an allowed origin +// configured, preflight requests short-circuit with 204 + reflected origin and +// actual API responses carry the Allow-Origin header. +func TestCORS_PreflightAndActualRequest(t *testing.T) { + const origin = "https://ui.example.com" + env := newTestEnv(t, testEnvOptions{corsOrigins: origin}) + + // Preflight OPTIONS against a real API route is handled by the middleware. + resp := originReq(t, env, http.MethodOptions, "/api/admin/providers", origin, "", http.MethodGet) + AssertStatus(t, resp, http.StatusNoContent) + if got := resp.Header.Get("Access-Control-Allow-Origin"); got != origin { + t.Fatalf("preflight Allow-Origin=%q, want %q", got, origin) + } + if got := resp.Header.Get("Access-Control-Allow-Methods"); got == "" { + t.Fatalf("preflight missing Access-Control-Allow-Methods") + } + resp.Body.Close() + + // The actual authenticated request succeeds and carries the CORS header. + resp = originReq(t, env, http.MethodGet, "/api/admin/providers", origin, env.Token, "") + AssertStatus(t, resp, http.StatusOK) + if got := resp.Header.Get("Access-Control-Allow-Origin"); got != origin { + t.Fatalf("response Allow-Origin=%q, want %q", got, origin) + } + resp.Body.Close() +} + +// TestCORS_DisallowedOrigin verifies a non-allowlisted origin receives no CORS +// headers (the browser blocks it) while the server still processes the request. +func TestCORS_DisallowedOrigin(t *testing.T) { + env := newTestEnv(t, testEnvOptions{corsOrigins: "https://ui.example.com"}) + + resp := originReq(t, env, http.MethodGet, "/health", "https://evil.example.com", "", "") + AssertStatus(t, resp, http.StatusOK) + defer resp.Body.Close() + if got := resp.Header.Get("Access-Control-Allow-Origin"); got != "" { + t.Fatalf("disallowed origin should get no Allow-Origin, got %q", got) + } +} + +// TestCORS_WildcardReflectsAnyOrigin verifies "*" reflects whatever origin the +// request presents. +func TestCORS_WildcardReflectsAnyOrigin(t *testing.T) { + env := newTestEnv(t, testEnvOptions{corsOrigins: "*"}) + + const origin = "https://anything.example.com" + resp := originReq(t, env, http.MethodGet, "/health", origin, "", "") + AssertStatus(t, resp, http.StatusOK) + defer resp.Body.Close() + if got := resp.Header.Get("Access-Control-Allow-Origin"); got != origin { + t.Fatalf("wildcard Allow-Origin=%q, want reflected %q", got, origin) + } +} diff --git a/tests/e2e/headless_test.go b/tests/e2e/headless_test.go new file mode 100644 index 00000000..d5089c8b --- /dev/null +++ b/tests/e2e/headless_test.go @@ -0,0 +1,81 @@ +package e2e_test + +import ( + "net/http" + "strings" + "testing" + "testing/fstest" + + "github.com/awsl-project/maxx/internal/handler" +) + +// withStubStaticFS installs an in-memory web/dist so static serving is +// deterministic regardless of the working directory, restoring the previous +// value on cleanup. These tests must not run in parallel because handler.StaticFS +// is process-global. +func withStubStaticFS(t *testing.T, index string) { + t.Helper() + prev := handler.StaticFS + handler.StaticFS = fstest.MapFS{ + "index.html": &fstest.MapFile{Data: []byte(index)}, + } + t.Cleanup(func() { handler.StaticFS = prev }) +} + +// TestServeMode_UIServesStaticFiles verifies that in the default (UI) mode the +// server serves the embedded web UI at "/" and falls back to index.html for +// unknown client-side routes, while the API keeps working alongside it. +func TestServeMode_UIServesStaticFiles(t *testing.T) { + const marker = "maxx-test-ui" + withStubStaticFS(t, marker) + env := newTestEnv(t, testEnvOptions{mountRoot: true, serveStatic: true}) + + // Root serves index.html. + resp := env.UnauthGet("/") + AssertStatus(t, resp, http.StatusOK) + if body := ReadBody(t, resp); !strings.Contains(body, "maxx-test-ui") { + t.Fatalf("expected index.html served at /, got: %q", body) + } + + // Unknown client-side route falls back to index.html (SPA routing). + resp = env.UnauthGet("/dashboard/settings") + AssertStatus(t, resp, http.StatusOK) + if body := ReadBody(t, resp); !strings.Contains(body, "maxx-test-ui") { + t.Fatalf("expected SPA fallback to index.html, got: %q", body) + } + + // The API still works alongside static serving. + resp = env.AdminGet("/api/admin/providers") + AssertStatus(t, resp, http.StatusOK) + resp.Body.Close() +} + +// TestServeMode_HeadlessDoesNotServeUI verifies that headless mode (no UI) never +// serves static files — even when a static FS is available — while the API, +// health, and project-proxy routes remain fully functional. +func TestServeMode_HeadlessDoesNotServeUI(t *testing.T) { + // A static FS is present but must NOT be served in headless mode. + withStubStaticFS(t, "should-not-be-served") + env := newTestEnv(t, testEnvOptions{mountRoot: true, serveStatic: false}) + + // "/" is handled by the project proxy, not the static handler → 404, no UI. + resp := env.UnauthGet("/") + AssertStatus(t, resp, http.StatusNotFound) + if body := ReadBody(t, resp); strings.Contains(body, "should-not-be-served") { + t.Fatalf("headless mode leaked UI content: %q", body) + } + + // A would-be client-side route is not served as the SPA shell either. + resp = env.UnauthGet("/dashboard") + AssertStatus(t, resp, http.StatusNotFound) + resp.Body.Close() + + // Health and API endpoints remain available in headless mode. + resp = env.UnauthGet("/health") + AssertStatus(t, resp, http.StatusOK) + resp.Body.Close() + + resp = env.AdminGet("/api/admin/providers") + AssertStatus(t, resp, http.StatusOK) + resp.Body.Close() +} diff --git a/tests/e2e/middleware_test.go b/tests/e2e/middleware_test.go index f27d6af7..4278269c 100644 --- a/tests/e2e/middleware_test.go +++ b/tests/e2e/middleware_test.go @@ -1,13 +1,42 @@ package e2e_test import ( + "net/http" "testing" ) +// TestCORS_Preflight verifies a cross-origin preflight (OPTIONS) request is +// answered by the CORS middleware with 204 and the appropriate allow headers. func TestCORS_Preflight(t *testing.T) { - t.Skip("CORS is not implemented at the Go level; skipping until CORS middleware is added") + const origin = "https://ui.example.com" + env := newTestEnv(t, testEnvOptions{corsOrigins: origin}) + + resp := originReq(t, env, http.MethodOptions, "/api/admin/providers", origin, "", http.MethodPost) + AssertStatus(t, resp, http.StatusNoContent) + defer resp.Body.Close() + + if got := resp.Header.Get("Access-Control-Allow-Origin"); got != origin { + t.Fatalf("preflight Allow-Origin=%q, want %q", got, origin) + } + if got := resp.Header.Get("Access-Control-Allow-Methods"); got == "" { + t.Fatalf("preflight missing Access-Control-Allow-Methods") + } + if got := resp.Header.Get("Access-Control-Allow-Headers"); got == "" { + t.Fatalf("preflight missing Access-Control-Allow-Headers") + } } +// TestCORS_ActualCrossOriginRequest verifies an actual cross-origin request to a +// real endpoint succeeds and carries the Access-Control-Allow-Origin header. func TestCORS_ActualCrossOriginRequest(t *testing.T) { - t.Skip("CORS is not implemented at the Go level; skipping until CORS middleware is added") + const origin = "https://ui.example.com" + env := newTestEnv(t, testEnvOptions{corsOrigins: origin}) + + resp := originReq(t, env, http.MethodGet, "/health", origin, "", "") + AssertStatus(t, resp, http.StatusOK) + defer resp.Body.Close() + + if got := resp.Header.Get("Access-Control-Allow-Origin"); got != origin { + t.Fatalf("response Allow-Origin=%q, want %q", got, origin) + } } diff --git a/tests/e2e/setup_test.go b/tests/e2e/setup_test.go index ba0ecfd7..5616e704 100644 --- a/tests/e2e/setup_test.go +++ b/tests/e2e/setup_test.go @@ -28,8 +28,26 @@ type TestEnv struct { Token string // Admin JWT token } +// testEnvOptions tunes the assembled server for feature-specific integration tests. +type testEnvOptions struct { + // mountRoot mounts the "/" route the way core.setupRoutes does, so tests can + // exercise headless vs. UI-serving behavior end-to-end. When false, "/" is + // left unmounted (the historical default, keeping unrelated tests untouched). + mountRoot bool + // serveStatic selects UI mode (serve web/dist + project proxy) vs. headless + // mode (project proxy only) for the "/" route. Only meaningful with mountRoot. + serveStatic bool + // corsOrigins, when non-empty, wraps the mux with CORSMiddleware configured + // from this MAXX_CORS_ALLOW_ORIGINS-style value (comma list or "*"). + corsOrigins string +} + // NewTestEnv creates a fully assembled test environment mirroring cmd/maxx/main.go. func NewTestEnv(t *testing.T) *TestEnv { + return newTestEnv(t, testEnvOptions{}) +} + +func newTestEnv(t *testing.T, opts testEnvOptions) *TestEnv { t.Helper() // Use a unique file-based DSN per test for full isolation. @@ -185,7 +203,26 @@ func NewTestEnv(t *testing.T) *TestEnv { w.Write([]byte(`{"status":"ok"}`)) }) - server := httptest.NewServer(mux) + // Optionally mount "/" the way core.setupRoutes does, so headless + // (project-proxy-only) vs. UI-serving (static files) behavior can be tested + // end-to-end. The project proxy safely 404s on non-project paths, so a nil + // proxy handler is fine for the routes these tests exercise. + if opts.mountRoot { + projectProxyHandler := handler.NewProjectProxyHandler(nil, modelsHandler, cachedProjectRepo) + if opts.serveStatic { + staticHandler := handler.NewStaticHandler() + mux.Handle("/", handler.NewCombinedHandler(projectProxyHandler, staticHandler)) + } else { + mux.Handle("/", projectProxyHandler) + } + } + + // Wrap with CORS exactly like the real entrypoints. ParseCORSOrigins("") + // yields a disabled config, so this is a no-op for the default env. + var rootHandler http.Handler = mux + rootHandler = handler.CORSMiddleware(handler.ParseCORSOrigins(opts.corsOrigins), rootHandler) + + server := httptest.NewServer(rootHandler) env := &TestEnv{ t: t, diff --git a/web/package.json b/web/package.json index e9fc18d2..f0cb4ecb 100644 --- a/web/package.json +++ b/web/package.json @@ -12,6 +12,8 @@ "build": "tsc -b && vite build", "lint": "eslint .", "typecheck": "tsc -b --pretty false", + "test": "vitest run", + "test:watch": "vitest", "preview": "vite preview", "format": "prettier --write \"src/**/*.{ts,tsx,js,jsx,json,css,md}\"", "format:check": "prettier --check \"src/**/*.{ts,tsx,js,jsx,json,css,md}\"", @@ -77,10 +79,12 @@ "eslint-plugin-react-refresh": "^0.4.24", "globals": "^16.5.0", "husky": "^9.1.7", + "jsdom": "^29.1.1", "lint-staged": "^16.2.7", "prettier": "^3.7.4", "typescript": "~5.9.3", "typescript-eslint": "^8.46.4", - "vite": "^7.3.2" + "vite": "^7.3.2", + "vitest": "^2" } } diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index ff275088..07b44482 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -138,6 +138,9 @@ importers: husky: specifier: ^9.1.7 version: 9.1.7 + jsdom: + specifier: ^29.1.1 + version: 29.1.1(@noble/hashes@1.8.0) lint-staged: specifier: ^16.2.7 version: 16.2.7 @@ -153,6 +156,9 @@ importers: vite: specifier: ^7.3.2 version: 7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.2) + vitest: + specifier: ^2 + version: 2.1.9(@types/node@24.10.8)(jsdom@29.1.1(@noble/hashes@1.8.0))(lightningcss@1.30.2)(msw@2.12.7(@types/node@24.10.8)(typescript@5.9.3)) packages: @@ -164,6 +170,21 @@ packages: resolution: {integrity: sha512-9q/yCljni37pkMr4sPrI3G4jqdIk074+iukc5aFJl7kmDCCsiJrbZ6zKxnES1Gwg+i9RcDZwvktl23puGslmvA==} hasBin: true + '@asamuzakjp/css-color@5.1.11': + resolution: {integrity: sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + '@asamuzakjp/dom-selector@7.1.1': + resolution: {integrity: sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + '@asamuzakjp/generational-cache@1.0.1': + resolution: {integrity: sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + '@asamuzakjp/nwsapi@2.3.9': + resolution: {integrity: sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==} + '@babel/code-frame@7.28.6': resolution: {integrity: sha512-JYgintcMjRiCvS8mMECzaEn+m3PfoQiyqukOMCCVQtoJGYJw8j/8LBJEiqkHLkfwCcs74E3pbAUFNg7d9VNJ+Q==} engines: {node: '>=6.9.0'} @@ -330,6 +351,46 @@ packages: '@types/react': optional: true + '@bramus/specificity@2.4.2': + resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==} + hasBin: true + + '@csstools/color-helpers@6.0.2': + resolution: {integrity: sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==} + engines: {node: '>=20.19.0'} + + '@csstools/css-calc@3.2.1': + resolution: {integrity: sha512-DtdHlgXh5ZkA43cwBcAm+huzgJiwx3ZTWVjBs94kwz2xKqSimDA3lBgCjphYgwgVUMWatSM0pDd8TILB1yrVVg==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-color-parser@4.1.1': + resolution: {integrity: sha512-eZ5XOtyhK+mggRafYUWzA0tvaYOFgdY8AkgQiCJF9qNAePnUo/zmsqqYubBBb3sQ8uNUaSKTY9s9klfRaAXL0g==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-parser-algorithms@4.0.0': + resolution: {integrity: sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.1.5': + resolution: {integrity: sha512-oNjBvzLq2GPZtJphCjLqXow/cHySHSgtxvKZb7OqSZ/xHgw6NWNhfad+6AB9cLeVm6eA9d/qMll3JdEHjy6M+A==} + peerDependencies: + css-tree: ^3.2.1 + peerDependenciesMeta: + css-tree: + optional: true + + '@csstools/css-tokenizer@4.0.0': + resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==} + engines: {node: '>=20.19.0'} + '@date-fns/tz@1.5.0': resolution: {integrity: sha512-lwYN/vDPeNRULcepoE/LO2Pgx+7/RV+S9ARfbc9lr2DtGkOD7pAiruHvbR1RX3Qyf6ja47EWJDMsNK5vK08DJg==} @@ -365,102 +426,204 @@ packages: peerDependencies: '@noble/ciphers': ^1.0.0 + '@esbuild/aix-ppc64@0.21.5': + resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [aix] + '@esbuild/aix-ppc64@0.27.7': resolution: {integrity: sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==} engines: {node: '>=18'} cpu: [ppc64] os: [aix] + '@esbuild/android-arm64@0.21.5': + resolution: {integrity: sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [android] + '@esbuild/android-arm64@0.27.7': resolution: {integrity: sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==} engines: {node: '>=18'} cpu: [arm64] os: [android] + '@esbuild/android-arm@0.21.5': + resolution: {integrity: sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==} + engines: {node: '>=12'} + cpu: [arm] + os: [android] + '@esbuild/android-arm@0.27.7': resolution: {integrity: sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==} engines: {node: '>=18'} cpu: [arm] os: [android] + '@esbuild/android-x64@0.21.5': + resolution: {integrity: sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==} + engines: {node: '>=12'} + cpu: [x64] + os: [android] + '@esbuild/android-x64@0.27.7': resolution: {integrity: sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==} engines: {node: '>=18'} cpu: [x64] os: [android] + '@esbuild/darwin-arm64@0.21.5': + resolution: {integrity: sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==} + engines: {node: '>=12'} + cpu: [arm64] + os: [darwin] + '@esbuild/darwin-arm64@0.27.7': resolution: {integrity: sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==} engines: {node: '>=18'} cpu: [arm64] os: [darwin] + '@esbuild/darwin-x64@0.21.5': + resolution: {integrity: sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==} + engines: {node: '>=12'} + cpu: [x64] + os: [darwin] + '@esbuild/darwin-x64@0.27.7': resolution: {integrity: sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==} engines: {node: '>=18'} cpu: [x64] os: [darwin] + '@esbuild/freebsd-arm64@0.21.5': + resolution: {integrity: sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==} + engines: {node: '>=12'} + cpu: [arm64] + os: [freebsd] + '@esbuild/freebsd-arm64@0.27.7': resolution: {integrity: sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==} engines: {node: '>=18'} cpu: [arm64] os: [freebsd] + '@esbuild/freebsd-x64@0.21.5': + resolution: {integrity: sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [freebsd] + '@esbuild/freebsd-x64@0.27.7': resolution: {integrity: sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==} engines: {node: '>=18'} cpu: [x64] os: [freebsd] + '@esbuild/linux-arm64@0.21.5': + resolution: {integrity: sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==} + engines: {node: '>=12'} + cpu: [arm64] + os: [linux] + '@esbuild/linux-arm64@0.27.7': resolution: {integrity: sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==} engines: {node: '>=18'} cpu: [arm64] os: [linux] + '@esbuild/linux-arm@0.21.5': + resolution: {integrity: sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==} + engines: {node: '>=12'} + cpu: [arm] + os: [linux] + '@esbuild/linux-arm@0.27.7': resolution: {integrity: sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==} engines: {node: '>=18'} cpu: [arm] os: [linux] + '@esbuild/linux-ia32@0.21.5': + resolution: {integrity: sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==} + engines: {node: '>=12'} + cpu: [ia32] + os: [linux] + '@esbuild/linux-ia32@0.27.7': resolution: {integrity: sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==} engines: {node: '>=18'} cpu: [ia32] os: [linux] + '@esbuild/linux-loong64@0.21.5': + resolution: {integrity: sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==} + engines: {node: '>=12'} + cpu: [loong64] + os: [linux] + '@esbuild/linux-loong64@0.27.7': resolution: {integrity: sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==} engines: {node: '>=18'} cpu: [loong64] os: [linux] + '@esbuild/linux-mips64el@0.21.5': + resolution: {integrity: sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==} + engines: {node: '>=12'} + cpu: [mips64el] + os: [linux] + '@esbuild/linux-mips64el@0.27.7': resolution: {integrity: sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==} engines: {node: '>=18'} cpu: [mips64el] os: [linux] + '@esbuild/linux-ppc64@0.21.5': + resolution: {integrity: sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [linux] + '@esbuild/linux-ppc64@0.27.7': resolution: {integrity: sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==} engines: {node: '>=18'} cpu: [ppc64] os: [linux] + '@esbuild/linux-riscv64@0.21.5': + resolution: {integrity: sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==} + engines: {node: '>=12'} + cpu: [riscv64] + os: [linux] + '@esbuild/linux-riscv64@0.27.7': resolution: {integrity: sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==} engines: {node: '>=18'} cpu: [riscv64] os: [linux] + '@esbuild/linux-s390x@0.21.5': + resolution: {integrity: sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==} + engines: {node: '>=12'} + cpu: [s390x] + os: [linux] + '@esbuild/linux-s390x@0.27.7': resolution: {integrity: sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==} engines: {node: '>=18'} cpu: [s390x] os: [linux] + '@esbuild/linux-x64@0.21.5': + resolution: {integrity: sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [linux] + '@esbuild/linux-x64@0.27.7': resolution: {integrity: sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==} engines: {node: '>=18'} @@ -473,6 +636,12 @@ packages: cpu: [arm64] os: [netbsd] + '@esbuild/netbsd-x64@0.21.5': + resolution: {integrity: sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==} + engines: {node: '>=12'} + cpu: [x64] + os: [netbsd] + '@esbuild/netbsd-x64@0.27.7': resolution: {integrity: sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==} engines: {node: '>=18'} @@ -485,6 +654,12 @@ packages: cpu: [arm64] os: [openbsd] + '@esbuild/openbsd-x64@0.21.5': + resolution: {integrity: sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==} + engines: {node: '>=12'} + cpu: [x64] + os: [openbsd] + '@esbuild/openbsd-x64@0.27.7': resolution: {integrity: sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==} engines: {node: '>=18'} @@ -497,24 +672,48 @@ packages: cpu: [arm64] os: [openharmony] + '@esbuild/sunos-x64@0.21.5': + resolution: {integrity: sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==} + engines: {node: '>=12'} + cpu: [x64] + os: [sunos] + '@esbuild/sunos-x64@0.27.7': resolution: {integrity: sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==} engines: {node: '>=18'} cpu: [x64] os: [sunos] + '@esbuild/win32-arm64@0.21.5': + resolution: {integrity: sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [win32] + '@esbuild/win32-arm64@0.27.7': resolution: {integrity: sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==} engines: {node: '>=18'} cpu: [arm64] os: [win32] + '@esbuild/win32-ia32@0.21.5': + resolution: {integrity: sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==} + engines: {node: '>=12'} + cpu: [ia32] + os: [win32] + '@esbuild/win32-ia32@0.27.7': resolution: {integrity: sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==} engines: {node: '>=18'} cpu: [ia32] os: [win32] + '@esbuild/win32-x64@0.21.5': + resolution: {integrity: sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==} + engines: {node: '>=12'} + cpu: [x64] + os: [win32] + '@esbuild/win32-x64@0.27.7': resolution: {integrity: sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==} engines: {node: '>=18'} @@ -559,6 +758,15 @@ packages: resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@exodus/bytes@1.15.1': + resolution: {integrity: sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + peerDependencies: + '@noble/hashes': ^1.8.0 || ^2.0.0 + peerDependenciesMeta: + '@noble/hashes': + optional: true + '@floating-ui/core@1.7.3': resolution: {integrity: sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==} @@ -1102,6 +1310,35 @@ packages: peerDependencies: vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 + '@vitest/expect@2.1.9': + resolution: {integrity: sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==} + + '@vitest/mocker@2.1.9': + resolution: {integrity: sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==} + peerDependencies: + msw: ^2.4.9 + vite: ^5.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@2.1.9': + resolution: {integrity: sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==} + + '@vitest/runner@2.1.9': + resolution: {integrity: sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==} + + '@vitest/snapshot@2.1.9': + resolution: {integrity: sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==} + + '@vitest/spy@2.1.9': + resolution: {integrity: sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==} + + '@vitest/utils@2.1.9': + resolution: {integrity: sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==} + accepts@2.0.0: resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} engines: {node: '>= 0.6'} @@ -1161,6 +1398,10 @@ packages: argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + ast-types@0.16.1: resolution: {integrity: sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg==} engines: {node: '>=4'} @@ -1185,6 +1426,9 @@ packages: resolution: {integrity: sha512-B0xUquLkiGLgHhpPBqvl7GWegWBUNuujQ6kXd/r1U38ElPT6Ok8KZ8e+FpUGEc2ZoRQUzq/aUnaKFc/svWUGSg==} hasBin: true + bidi-js@1.0.3: + resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==} + body-parser@2.2.2: resolution: {integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==} engines: {node: '>=18'} @@ -1212,6 +1456,10 @@ packages: resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} engines: {node: '>= 0.8'} + cac@6.7.14: + resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} + engines: {node: '>=8'} + call-bind-apply-helpers@1.0.2: resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} engines: {node: '>= 0.4'} @@ -1227,6 +1475,10 @@ packages: caniuse-lite@1.0.30001764: resolution: {integrity: sha512-9JGuzl2M+vPL+pz70gtMF9sHdMFbY9FJaQBi186cHKH3pSzDvzoUJUPV6fqiKIMyXbud9ZLg4F3Yza1vJ1+93g==} + chai@5.3.3: + resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} + engines: {node: '>=18'} + chalk@4.1.2: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} @@ -1235,6 +1487,10 @@ packages: resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==} engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} + check-error@2.1.3: + resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} + engines: {node: '>= 16'} + class-variance-authority@0.7.1: resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} @@ -1330,6 +1586,10 @@ packages: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} + css-tree@3.2.1: + resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} + cssesc@3.0.0: resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} engines: {node: '>=4'} @@ -1386,6 +1646,10 @@ packages: resolution: {integrity: sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==} engines: {node: '>= 12'} + data-urls@7.0.0: + resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + date-fns@4.1.0: resolution: {integrity: sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==} @@ -1404,6 +1668,9 @@ packages: decimal.js-light@2.5.1: resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==} + decimal.js@10.6.0: + resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + dedent@1.7.1: resolution: {integrity: sha512-9JmrhGZpOlEgOLdQgSm0zxFaYoQon408V1v49aqTWuXENVlnCuY9JBZcXZiCsZQWDjTm5Qf/nIvAy77mXDAjEg==} peerDependencies: @@ -1412,6 +1679,10 @@ packages: babel-plugin-macros: optional: true + deep-eql@5.0.2: + resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} + engines: {node: '>=6'} + deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} @@ -1479,6 +1750,10 @@ packages: resolution: {integrity: sha512-LgQMM4WXU3QI+SYgEc2liRgznaD5ojbmY3sb8LxyguVkIg5FxdpTkvk72te2R38/TGKxH634oLxXRGY6d7AP+Q==} engines: {node: '>=10.13.0'} + entities@8.0.0: + resolution: {integrity: sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==} + engines: {node: '>=20.19.0'} + env-paths@2.2.1: resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==} engines: {node: '>=6'} @@ -1498,6 +1773,9 @@ packages: resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} engines: {node: '>= 0.4'} + es-module-lexer@1.7.0: + resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + es-object-atoms@1.1.1: resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} engines: {node: '>= 0.4'} @@ -1509,6 +1787,11 @@ packages: es-toolkit@1.44.0: resolution: {integrity: sha512-6penXeZalaV88MM3cGkFZZfOoLGWshWWfdy0tWw/RlVVyhvMaWSBTOvXNeiW3e5FwdS5ePW0LGEu17zT139ktg==} + esbuild@0.21.5: + resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==} + engines: {node: '>=12'} + hasBin: true + esbuild@0.27.7: resolution: {integrity: sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==} engines: {node: '>=18'} @@ -1579,6 +1862,9 @@ packages: resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} engines: {node: '>=4.0'} + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + esutils@2.0.3: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} @@ -1606,6 +1892,10 @@ packages: resolution: {integrity: sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA==} engines: {node: ^18.19.0 || >=20.5.0} + expect-type@1.3.0: + resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} + engines: {node: '>=12.0.0'} + express-rate-limit@7.5.1: resolution: {integrity: sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw==} engines: {node: '>= 16'} @@ -1814,6 +2104,10 @@ packages: resolution: {integrity: sha512-U7tt8JsyrxSRKspfhtLET79pU8K+tInj5QZXs1jSugO1Vq5dFj3kmZsRldo29mTBfcjDRVRXrEZ6LS63Cog9ZA==} engines: {node: '>=16.9.0'} + html-encoding-sniffer@6.0.0: + resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + html-parse-stringify@3.0.1: resolution: {integrity: sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==} @@ -1935,6 +2229,9 @@ packages: resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} engines: {node: '>=12'} + is-potential-custom-element-name@1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + is-promise@4.0.0: resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} @@ -1983,6 +2280,15 @@ packages: resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} hasBin: true + jsdom@29.1.1: + resolution: {integrity: sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24.0.0} + peerDependencies: + canvas: ^3.0.0 + peerDependenciesMeta: + canvas: + optional: true + jsesc@3.1.0: resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} engines: {node: '>=6'} @@ -2126,6 +2432,13 @@ packages: resolution: {integrity: sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==} engines: {node: '>=18'} + loupe@3.2.1: + resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} + + lru-cache@11.5.1: + resolution: {integrity: sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==} + engines: {node: 20 || >=22} + lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} @@ -2141,6 +2454,9 @@ packages: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} + mdn-data@2.27.1: + resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==} + media-typer@1.1.0: resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==} engines: {node: '>= 0.8'} @@ -2316,6 +2632,9 @@ packages: resolution: {integrity: sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==} engines: {node: '>=18'} + parse5@8.0.1: + resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==} + parseurl@1.3.3: resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} engines: {node: '>= 0.8'} @@ -2341,6 +2660,13 @@ packages: path-to-regexp@8.3.0: resolution: {integrity: sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==} + pathe@1.1.2: + resolution: {integrity: sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==} + + pathval@2.0.1: + resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} + engines: {node: '>= 14.16'} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -2575,6 +2901,10 @@ packages: safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + saxes@6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} + scheduler@0.27.0: resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} @@ -2629,6 +2959,9 @@ packages: resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} engines: {node: '>= 0.4'} + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + signal-exit@3.0.7: resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} @@ -2651,10 +2984,16 @@ packages: resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} engines: {node: '>=0.10.0'} + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + statuses@2.0.2: resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} engines: {node: '>= 0.8'} + std-env@3.10.0: + resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + stdin-discarder@0.2.2: resolution: {integrity: sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ==} engines: {node: '>=18'} @@ -2710,6 +3049,9 @@ packages: resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} engines: {node: '>=8'} + symbol-tree@3.2.4: + resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + tabbable@6.4.0: resolution: {integrity: sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==} @@ -2730,6 +3072,12 @@ packages: tiny-invariant@1.3.3: resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@0.3.2: + resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} + tinyexec@1.0.2: resolution: {integrity: sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==} engines: {node: '>=18'} @@ -2738,6 +3086,18 @@ packages: resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} engines: {node: '>=12.0.0'} + tinypool@1.1.1: + resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==} + engines: {node: ^18.0.0 || >=20.0.0} + + tinyrainbow@1.2.0: + resolution: {integrity: sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==} + engines: {node: '>=14.0.0'} + + tinyspy@3.0.2: + resolution: {integrity: sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==} + engines: {node: '>=14.0.0'} + tldts-core@7.0.19: resolution: {integrity: sha512-lJX2dEWx0SGH4O6p+7FPwYmJ/bu1JbcGJ8RLaG9b7liIgZ85itUVEPbMtWRVrde/0fnDPEPHW10ZsKW3kVsE9A==} @@ -2757,6 +3117,14 @@ packages: resolution: {integrity: sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==} engines: {node: '>=16'} + tough-cookie@6.0.1: + resolution: {integrity: sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==} + engines: {node: '>=16'} + + tr46@6.0.0: + resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==} + engines: {node: '>=20'} + ts-api-utils@2.4.0: resolution: {integrity: sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==} engines: {node: '>=18.12'} @@ -2803,6 +3171,10 @@ packages: undici-types@7.16.0: resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} + undici@7.27.1: + resolution: {integrity: sha512-UDdpiex+mzigiyrXrGbiUaF4HzTNhKbh2vRNFaTMzcqmLIPrZxaCtwo/1TMSuWoM1Xz3WiTo9KdgI3kRqYzJGg==} + engines: {node: '>=20.18.1'} + unicorn-magic@0.3.0: resolution: {integrity: sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==} engines: {node: '>=18'} @@ -2846,6 +3218,42 @@ packages: victory-vendor@37.3.6: resolution: {integrity: sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==} + vite-node@2.1.9: + resolution: {integrity: sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + + vite@5.4.21: + resolution: {integrity: sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@types/node': ^18.0.0 || >=20.0.0 + less: '*' + lightningcss: ^1.21.0 + sass: '*' + sass-embedded: '*' + stylus: '*' + sugarss: '*' + terser: ^5.4.0 + peerDependenciesMeta: + '@types/node': + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + vite@7.3.2: resolution: {integrity: sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==} engines: {node: ^20.19.0 || >=22.12.0} @@ -2886,14 +3294,55 @@ packages: yaml: optional: true + vitest@2.1.9: + resolution: {integrity: sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@types/node': ^18.0.0 || >=20.0.0 + '@vitest/browser': 2.1.9 + '@vitest/ui': 2.1.9 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@types/node': + optional: true + '@vitest/browser': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + void-elements@3.1.0: resolution: {integrity: sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==} engines: {node: '>=0.10.0'} + w3c-xmlserializer@5.0.0: + resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} + engines: {node: '>=18'} + web-streams-polyfill@3.3.3: resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==} engines: {node: '>= 8'} + webidl-conversions@8.0.1: + resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==} + engines: {node: '>=20'} + + whatwg-mimetype@5.0.0: + resolution: {integrity: sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==} + engines: {node: '>=20'} + + whatwg-url@16.0.1: + resolution: {integrity: sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + which@2.0.2: resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} engines: {node: '>= 8'} @@ -2904,6 +3353,11 @@ packages: engines: {node: ^16.13.0 || >=18.0.0} hasBin: true + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + word-wrap@1.2.5: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} @@ -2927,6 +3381,13 @@ packages: resolution: {integrity: sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg==} engines: {node: '>=20'} + xml-name-validator@5.0.0: + resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} + engines: {node: '>=18'} + + xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + y18n@5.0.8: resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} engines: {node: '>=10'} @@ -3005,6 +3466,26 @@ snapshots: package-manager-detector: 1.6.0 tinyexec: 1.0.2 + '@asamuzakjp/css-color@5.1.11': + dependencies: + '@asamuzakjp/generational-cache': 1.0.1 + '@csstools/css-calc': 3.2.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-color-parser': 4.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@asamuzakjp/dom-selector@7.1.1': + dependencies: + '@asamuzakjp/generational-cache': 1.0.1 + '@asamuzakjp/nwsapi': 2.3.9 + bidi-js: 1.0.3 + css-tree: 3.2.1 + is-potential-custom-element-name: 1.0.1 + + '@asamuzakjp/generational-cache@1.0.1': {} + + '@asamuzakjp/nwsapi@2.3.9': {} + '@babel/code-frame@7.28.6': dependencies: '@babel/helper-validator-identifier': 7.28.5 @@ -3228,6 +3709,34 @@ snapshots: optionalDependencies: '@types/react': 19.2.8 + '@bramus/specificity@2.4.2': + dependencies: + css-tree: 3.2.1 + + '@csstools/color-helpers@6.0.2': {} + + '@csstools/css-calc@3.2.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-color-parser@4.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/color-helpers': 6.0.2 + '@csstools/css-calc': 3.2.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.1.5(css-tree@3.2.1)': + optionalDependencies: + css-tree: 3.2.1 + + '@csstools/css-tokenizer@4.0.0': {} + '@date-fns/tz@1.5.0': {} '@dnd-kit/accessibility@3.1.1(react@19.2.3)': @@ -3271,81 +3780,150 @@ snapshots: dependencies: '@noble/ciphers': 1.3.0 + '@esbuild/aix-ppc64@0.21.5': + optional: true + '@esbuild/aix-ppc64@0.27.7': optional: true + '@esbuild/android-arm64@0.21.5': + optional: true + '@esbuild/android-arm64@0.27.7': optional: true + '@esbuild/android-arm@0.21.5': + optional: true + '@esbuild/android-arm@0.27.7': optional: true + '@esbuild/android-x64@0.21.5': + optional: true + '@esbuild/android-x64@0.27.7': optional: true + '@esbuild/darwin-arm64@0.21.5': + optional: true + '@esbuild/darwin-arm64@0.27.7': optional: true + '@esbuild/darwin-x64@0.21.5': + optional: true + '@esbuild/darwin-x64@0.27.7': optional: true + '@esbuild/freebsd-arm64@0.21.5': + optional: true + '@esbuild/freebsd-arm64@0.27.7': optional: true + '@esbuild/freebsd-x64@0.21.5': + optional: true + '@esbuild/freebsd-x64@0.27.7': optional: true + '@esbuild/linux-arm64@0.21.5': + optional: true + '@esbuild/linux-arm64@0.27.7': optional: true + '@esbuild/linux-arm@0.21.5': + optional: true + '@esbuild/linux-arm@0.27.7': optional: true + '@esbuild/linux-ia32@0.21.5': + optional: true + '@esbuild/linux-ia32@0.27.7': optional: true + '@esbuild/linux-loong64@0.21.5': + optional: true + '@esbuild/linux-loong64@0.27.7': optional: true + '@esbuild/linux-mips64el@0.21.5': + optional: true + '@esbuild/linux-mips64el@0.27.7': optional: true + '@esbuild/linux-ppc64@0.21.5': + optional: true + '@esbuild/linux-ppc64@0.27.7': optional: true + '@esbuild/linux-riscv64@0.21.5': + optional: true + '@esbuild/linux-riscv64@0.27.7': optional: true + '@esbuild/linux-s390x@0.21.5': + optional: true + '@esbuild/linux-s390x@0.27.7': optional: true + '@esbuild/linux-x64@0.21.5': + optional: true + '@esbuild/linux-x64@0.27.7': optional: true '@esbuild/netbsd-arm64@0.27.7': optional: true + '@esbuild/netbsd-x64@0.21.5': + optional: true + '@esbuild/netbsd-x64@0.27.7': optional: true '@esbuild/openbsd-arm64@0.27.7': optional: true + '@esbuild/openbsd-x64@0.21.5': + optional: true + '@esbuild/openbsd-x64@0.27.7': optional: true '@esbuild/openharmony-arm64@0.27.7': optional: true + '@esbuild/sunos-x64@0.21.5': + optional: true + '@esbuild/sunos-x64@0.27.7': optional: true + '@esbuild/win32-arm64@0.21.5': + optional: true + '@esbuild/win32-arm64@0.27.7': optional: true + '@esbuild/win32-ia32@0.21.5': + optional: true + '@esbuild/win32-ia32@0.27.7': optional: true + '@esbuild/win32-x64@0.21.5': + optional: true + '@esbuild/win32-x64@0.27.7': optional: true @@ -3395,6 +3973,10 @@ snapshots: '@eslint/core': 0.17.0 levn: 0.4.1 + '@exodus/bytes@1.15.1(@noble/hashes@1.8.0)': + optionalDependencies: + '@noble/hashes': 1.8.0 + '@floating-ui/core@1.7.3': dependencies: '@floating-ui/utils': 0.2.10 @@ -3906,6 +4488,47 @@ snapshots: transitivePeerDependencies: - supports-color + '@vitest/expect@2.1.9': + dependencies: + '@vitest/spy': 2.1.9 + '@vitest/utils': 2.1.9 + chai: 5.3.3 + tinyrainbow: 1.2.0 + + '@vitest/mocker@2.1.9(msw@2.12.7(@types/node@24.10.8)(typescript@5.9.3))(vite@5.4.21(@types/node@24.10.8)(lightningcss@1.30.2))': + dependencies: + '@vitest/spy': 2.1.9 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + msw: 2.12.7(@types/node@24.10.8)(typescript@5.9.3) + vite: 5.4.21(@types/node@24.10.8)(lightningcss@1.30.2) + + '@vitest/pretty-format@2.1.9': + dependencies: + tinyrainbow: 1.2.0 + + '@vitest/runner@2.1.9': + dependencies: + '@vitest/utils': 2.1.9 + pathe: 1.1.2 + + '@vitest/snapshot@2.1.9': + dependencies: + '@vitest/pretty-format': 2.1.9 + magic-string: 0.30.21 + pathe: 1.1.2 + + '@vitest/spy@2.1.9': + dependencies: + tinyspy: 3.0.2 + + '@vitest/utils@2.1.9': + dependencies: + '@vitest/pretty-format': 2.1.9 + loupe: 3.2.1 + tinyrainbow: 1.2.0 + accepts@2.0.0: dependencies: mime-types: 3.0.2 @@ -3955,6 +4578,8 @@ snapshots: argparse@2.0.1: {} + assertion-error@2.0.1: {} + ast-types@0.16.1: dependencies: tslib: 2.8.1 @@ -3982,6 +4607,10 @@ snapshots: baseline-browser-mapping@2.9.14: {} + bidi-js@1.0.3: + dependencies: + require-from-string: 2.0.2 + body-parser@2.2.2: dependencies: bytes: 3.1.2 @@ -4023,6 +4652,8 @@ snapshots: bytes@3.1.2: {} + cac@6.7.14: {} + call-bind-apply-helpers@1.0.2: dependencies: es-errors: 1.3.0 @@ -4037,6 +4668,14 @@ snapshots: caniuse-lite@1.0.30001764: {} + chai@5.3.3: + dependencies: + assertion-error: 2.0.1 + check-error: 2.1.3 + deep-eql: 5.0.2 + loupe: 3.2.1 + pathval: 2.0.1 + chalk@4.1.2: dependencies: ansi-styles: 4.3.0 @@ -4044,6 +4683,8 @@ snapshots: chalk@5.6.2: {} + check-error@2.1.3: {} + class-variance-authority@0.7.1: dependencies: clsx: 2.1.1 @@ -4121,6 +4762,11 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 + css-tree@3.2.1: + dependencies: + mdn-data: 2.27.1 + source-map-js: 1.2.1 + cssesc@3.0.0: {} csstype@3.2.3: {} @@ -4165,6 +4811,13 @@ snapshots: data-uri-to-buffer@4.0.1: {} + data-urls@7.0.0(@noble/hashes@1.8.0): + dependencies: + whatwg-mimetype: 5.0.0 + whatwg-url: 16.0.1(@noble/hashes@1.8.0) + transitivePeerDependencies: + - '@noble/hashes' + date-fns@4.1.0: {} dayjs@1.11.19: {} @@ -4175,8 +4828,12 @@ snapshots: decimal.js-light@2.5.1: {} + decimal.js@10.6.0: {} + dedent@1.7.1: {} + deep-eql@5.0.2: {} + deep-is@0.1.4: {} deepmerge@4.3.1: {} @@ -4228,6 +4885,8 @@ snapshots: graceful-fs: 4.2.11 tapable: 2.3.0 + entities@8.0.0: {} + env-paths@2.2.1: {} environment@1.1.0: {} @@ -4240,6 +4899,8 @@ snapshots: es-errors@1.3.0: {} + es-module-lexer@1.7.0: {} + es-object-atoms@1.1.1: dependencies: es-errors: 1.3.0 @@ -4253,6 +4914,32 @@ snapshots: es-toolkit@1.44.0: {} + esbuild@0.21.5: + 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 + esbuild@0.27.7: optionalDependencies: '@esbuild/aix-ppc64': 0.27.7 @@ -4371,6 +5058,10 @@ snapshots: estraverse@5.3.0: {} + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.8 + esutils@2.0.3: {} etag@1.8.1: {} @@ -4410,6 +5101,8 @@ snapshots: strip-final-newline: 4.0.0 yoctocolors: 2.1.2 + expect-type@1.3.0: {} + express-rate-limit@7.5.1(express@5.2.1): dependencies: express: 5.2.1 @@ -4622,6 +5315,12 @@ snapshots: hono@4.11.4: {} + html-encoding-sniffer@6.0.0(@noble/hashes@1.8.0): + dependencies: + '@exodus/bytes': 1.15.1(@noble/hashes@1.8.0) + transitivePeerDependencies: + - '@noble/hashes' + html-parse-stringify@3.0.1: dependencies: void-elements: 3.1.0 @@ -4710,6 +5409,8 @@ snapshots: is-plain-obj@4.1.0: {} + is-potential-custom-element-name@1.0.1: {} + is-promise@4.0.0: {} is-regexp@3.1.0: {} @@ -4740,6 +5441,32 @@ snapshots: dependencies: argparse: 2.0.1 + jsdom@29.1.1(@noble/hashes@1.8.0): + dependencies: + '@asamuzakjp/css-color': 5.1.11 + '@asamuzakjp/dom-selector': 7.1.1 + '@bramus/specificity': 2.4.2 + '@csstools/css-syntax-patches-for-csstree': 1.1.5(css-tree@3.2.1) + '@exodus/bytes': 1.15.1(@noble/hashes@1.8.0) + css-tree: 3.2.1 + data-urls: 7.0.0(@noble/hashes@1.8.0) + decimal.js: 10.6.0 + html-encoding-sniffer: 6.0.0(@noble/hashes@1.8.0) + is-potential-custom-element-name: 1.0.1 + lru-cache: 11.5.1 + parse5: 8.0.1 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 6.0.1 + undici: 7.27.1 + w3c-xmlserializer: 5.0.0 + webidl-conversions: 8.0.1 + whatwg-mimetype: 5.0.0 + whatwg-url: 16.0.1(@noble/hashes@1.8.0) + xml-name-validator: 5.0.0 + transitivePeerDependencies: + - '@noble/hashes' + jsesc@3.1.0: {} json-buffer@3.0.1: {} @@ -4864,6 +5591,10 @@ snapshots: strip-ansi: 7.1.2 wrap-ansi: 9.0.2 + loupe@3.2.1: {} + + lru-cache@11.5.1: {} + lru-cache@5.1.1: dependencies: yallist: 3.1.1 @@ -4878,6 +5609,8 @@ snapshots: math-intrinsics@1.1.0: {} + mdn-data@2.27.1: {} + media-typer@1.1.0: {} merge-descriptors@2.0.0: {} @@ -5054,6 +5787,10 @@ snapshots: parse-ms@4.0.0: {} + parse5@8.0.1: + dependencies: + entities: 8.0.0 + parseurl@1.3.3: {} path-browserify@1.0.1: {} @@ -5068,6 +5805,10 @@ snapshots: path-to-regexp@8.3.0: {} + pathe@1.1.2: {} + + pathval@2.0.1: {} + picocolors@1.1.1: {} picomatch@2.3.2: {} @@ -5298,6 +6039,10 @@ snapshots: safer-buffer@2.1.2: {} + saxes@6.0.0: + dependencies: + xmlchars: 2.2.0 + scheduler@0.27.0: {} semver@6.3.1: {} @@ -5411,6 +6156,8 @@ snapshots: side-channel-map: 1.0.1 side-channel-weakmap: 1.0.2 + siginfo@2.0.0: {} + signal-exit@3.0.7: {} signal-exit@4.1.0: {} @@ -5426,8 +6173,12 @@ snapshots: source-map@0.6.1: {} + stackback@0.0.2: {} + statuses@2.0.2: {} + std-env@3.10.0: {} + stdin-discarder@0.2.2: {} strict-event-emitter@0.5.1: {} @@ -5477,6 +6228,8 @@ snapshots: dependencies: has-flag: 4.0.0 + symbol-tree@3.2.4: {} + tabbable@6.4.0: {} tagged-tag@1.0.0: {} @@ -5489,6 +6242,10 @@ snapshots: tiny-invariant@1.3.3: {} + tinybench@2.9.0: {} + + tinyexec@0.3.2: {} + tinyexec@1.0.2: {} tinyglobby@0.2.15: @@ -5496,6 +6253,12 @@ snapshots: fdir: 6.5.0(picomatch@4.0.4) picomatch: 4.0.4 + tinypool@1.1.1: {} + + tinyrainbow@1.2.0: {} + + tinyspy@3.0.2: {} + tldts-core@7.0.19: {} tldts@7.0.19: @@ -5512,6 +6275,14 @@ snapshots: dependencies: tldts: 7.0.19 + tough-cookie@6.0.1: + dependencies: + tldts: 7.0.19 + + tr46@6.0.0: + dependencies: + punycode: 2.3.1 + ts-api-utils@2.4.0(typescript@5.9.3): dependencies: typescript: 5.9.3 @@ -5560,6 +6331,8 @@ snapshots: undici-types@7.16.0: {} + undici@7.27.1: {} + unicorn-magic@0.3.0: {} universalify@2.0.1: {} @@ -5605,6 +6378,34 @@ snapshots: d3-time: 3.1.0 d3-timer: 3.0.1 + vite-node@2.1.9(@types/node@24.10.8)(lightningcss@1.30.2): + dependencies: + cac: 6.7.14 + debug: 4.4.3 + es-module-lexer: 1.7.0 + pathe: 1.1.2 + vite: 5.4.21(@types/node@24.10.8)(lightningcss@1.30.2) + transitivePeerDependencies: + - '@types/node' + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + + vite@5.4.21(@types/node@24.10.8)(lightningcss@1.30.2): + dependencies: + esbuild: 0.21.5 + postcss: 8.5.10 + rollup: 4.60.1 + optionalDependencies: + '@types/node': 24.10.8 + fsevents: 2.3.3 + lightningcss: 1.30.2 + vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.2): dependencies: esbuild: 0.27.7 @@ -5620,10 +6421,62 @@ snapshots: lightningcss: 1.30.2 yaml: 2.8.2 + vitest@2.1.9(@types/node@24.10.8)(jsdom@29.1.1(@noble/hashes@1.8.0))(lightningcss@1.30.2)(msw@2.12.7(@types/node@24.10.8)(typescript@5.9.3)): + dependencies: + '@vitest/expect': 2.1.9 + '@vitest/mocker': 2.1.9(msw@2.12.7(@types/node@24.10.8)(typescript@5.9.3))(vite@5.4.21(@types/node@24.10.8)(lightningcss@1.30.2)) + '@vitest/pretty-format': 2.1.9 + '@vitest/runner': 2.1.9 + '@vitest/snapshot': 2.1.9 + '@vitest/spy': 2.1.9 + '@vitest/utils': 2.1.9 + chai: 5.3.3 + debug: 4.4.3 + expect-type: 1.3.0 + magic-string: 0.30.21 + pathe: 1.1.2 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinypool: 1.1.1 + tinyrainbow: 1.2.0 + vite: 5.4.21(@types/node@24.10.8)(lightningcss@1.30.2) + vite-node: 2.1.9(@types/node@24.10.8)(lightningcss@1.30.2) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 24.10.8 + jsdom: 29.1.1(@noble/hashes@1.8.0) + transitivePeerDependencies: + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + void-elements@3.1.0: {} + w3c-xmlserializer@5.0.0: + dependencies: + xml-name-validator: 5.0.0 + web-streams-polyfill@3.3.3: {} + webidl-conversions@8.0.1: {} + + whatwg-mimetype@5.0.0: {} + + whatwg-url@16.0.1(@noble/hashes@1.8.0): + dependencies: + '@exodus/bytes': 1.15.1(@noble/hashes@1.8.0) + tr46: 6.0.0 + webidl-conversions: 8.0.1 + transitivePeerDependencies: + - '@noble/hashes' + which@2.0.2: dependencies: isexe: 2.0.0 @@ -5632,6 +6485,11 @@ snapshots: dependencies: isexe: 3.1.1 + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + word-wrap@1.2.5: {} wrap-ansi@6.2.0: @@ -5659,6 +6517,10 @@ snapshots: is-wsl: 3.1.0 powershell-utils: 0.1.0 + xml-name-validator@5.0.0: {} + + xmlchars@2.2.0: {} + y18n@5.0.8: {} yallist@3.1.1: {} diff --git a/web/src/components/backend-address-control.tsx b/web/src/components/backend-address-control.tsx new file mode 100644 index 00000000..f898381d --- /dev/null +++ b/web/src/components/backend-address-control.tsx @@ -0,0 +1,116 @@ +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { ChevronDownIcon, ServerIcon } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { getBackendUrl, setBackendUrl, BackendStorageError } from '@/lib/backend-config'; + +interface BackendAddressControlProps { + /** When true, render expanded by default (e.g. on a settings page). */ + defaultOpen?: boolean; + /** When true, render without the collapsible toggle (always expanded). */ + alwaysOpen?: boolean; +} + +/** + * Lets the user point this UI at a backend on a different origin. The override + * is stored in localStorage; saving reloads the page so the transport layer + * re-initializes against the new address. + */ +export function BackendAddressControl({ + defaultOpen = false, + alwaysOpen = false, +}: BackendAddressControlProps) { + const { t } = useTranslation(); + const [open, setOpen] = useState(defaultOpen || alwaysOpen); + const [value, setValue] = useState(() => getBackendUrl()); + const [error, setError] = useState(''); + + const current = getBackendUrl(); + + const apply = (next: string) => { + try { + setBackendUrl(next); + } catch (err) { + setError( + err instanceof BackendStorageError + ? t('backendAddress.storageError') + : t('backendAddress.invalid'), + ); + return; + } + // Reload so the transport singleton is rebuilt with the new config. + window.location.reload(); + }; + + const handleSave = () => apply(value); + const handleReset = () => { + setValue(''); + apply(''); + }; + + const body = ( +
+

{t('backendAddress.description')}

+
+ + { + setValue(e.target.value); + setError(''); + }} + /> + {error ? ( +

{error}

+ ) : ( +

+ {t('backendAddress.current', { + value: current || t('backendAddress.sameOrigin'), + })} +

+ )} +

{t('backendAddress.corsHint')}

+
+
+ + {current && ( + + )} +
+
+ ); + + if (alwaysOpen) { + return body; + } + + return ( +
+ + {open && body} +
+ ); +} diff --git a/web/src/lib/backend-config.test.ts b/web/src/lib/backend-config.test.ts new file mode 100644 index 00000000..72646fe6 --- /dev/null +++ b/web/src/lib/backend-config.test.ts @@ -0,0 +1,126 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { + BackendStorageError, + buildTransportConfig, + getBackendUrl, + setBackendUrl, +} from './backend-config'; + +const BACKEND_KEY = 'maxx_backend_url'; +const AUTH_KEY = 'maxx-admin-token'; + +beforeEach(() => { + localStorage.clear(); +}); + +describe('getBackendUrl', () => { + it('returns empty string when nothing is configured (same-origin default)', () => { + expect(getBackendUrl()).toBe(''); + }); + + it('returns the stored override without a trailing slash', () => { + localStorage.setItem(BACKEND_KEY, 'https://api.example.com'); + expect(getBackendUrl()).toBe('https://api.example.com'); + }); +}); + +describe('setBackendUrl normalization', () => { + it('stores a plain origin as-is', () => { + expect(setBackendUrl('https://api.example.com')).toBe('https://api.example.com'); + expect(localStorage.getItem(BACKEND_KEY)).toBe('https://api.example.com'); + }); + + it('strips trailing slashes', () => { + expect(setBackendUrl('https://api.example.com///')).toBe('https://api.example.com'); + }); + + it('drops query and hash so appending /api stays valid', () => { + expect(setBackendUrl('https://api.example.com?x=1#frag')).toBe('https://api.example.com'); + }); + + it('preserves a reverse-proxy sub-path', () => { + expect(setBackendUrl('https://example.com/maxx/')).toBe('https://example.com/maxx'); + }); + + it('rejects non-http(s) URLs', () => { + expect(() => setBackendUrl('ftp://api.example.com')).toThrow(); + expect(() => setBackendUrl('not a url')).toThrow(); + }); + + it('throws a distinct BackendStorageError when storage is unavailable', () => { + const spy = vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { + throw new Error('storage denied'); + }); + try { + expect(() => setBackendUrl('https://api.example.com')).toThrow(BackendStorageError); + // A genuinely invalid URL still throws the plain validation error, not storage. + expect(() => setBackendUrl('not a url')).not.toThrow(BackendStorageError); + } finally { + spy.mockRestore(); + } + }); + + it('clearing reverts to same-origin default', () => { + setBackendUrl('https://api.example.com'); + expect(setBackendUrl(' ')).toBe(''); + expect(localStorage.getItem(BACKEND_KEY)).toBeNull(); + }); +}); + +describe('setBackendUrl auth-token handling', () => { + it('clears the stored admin token when the backend changes', () => { + localStorage.setItem(AUTH_KEY, 'jwt-for-backend-a'); + setBackendUrl('https://api.example.com'); + expect(localStorage.getItem(AUTH_KEY)).toBeNull(); + }); + + it('keeps the token when the effective backend is unchanged', () => { + setBackendUrl('https://api.example.com'); + localStorage.setItem(AUTH_KEY, 'jwt'); + // Re-saving the same (normalized) URL must not log the user out. + setBackendUrl('https://api.example.com/'); + expect(localStorage.getItem(AUTH_KEY)).toBe('jwt'); + }); + + it('clears the token when reverting to same-origin', () => { + setBackendUrl('https://api.example.com'); + localStorage.setItem(AUTH_KEY, 'jwt'); + setBackendUrl(''); + expect(localStorage.getItem(AUTH_KEY)).toBeNull(); + }); +}); + +describe('buildTransportConfig', () => { + it('returns undefined for the same-origin default', () => { + expect(buildTransportConfig()).toBeUndefined(); + }); + + it('derives base/admin/ws URLs from an https backend', () => { + setBackendUrl('https://api.example.com'); + expect(buildTransportConfig()).toEqual({ + baseURL: 'https://api.example.com/api', + adminBaseURL: 'https://api.example.com/api/admin', + wsURL: 'wss://api.example.com/ws', + }); + }); + + it('derives a ws:// URL from an http backend', () => { + setBackendUrl('http://localhost:9880'); + expect(buildTransportConfig()?.wsURL).toBe('ws://localhost:9880/ws'); + }); + + it('normalizes the build-time VITE_BACKEND_URL through the same contract', () => { + // No runtime override; the build-time fallback carries query/hash + trailing slash. + vi.stubEnv('VITE_BACKEND_URL', 'https://api.example.com/?x=1#frag'); + try { + expect(getBackendUrl()).toBe('https://api.example.com'); + expect(buildTransportConfig()).toEqual({ + baseURL: 'https://api.example.com/api', + adminBaseURL: 'https://api.example.com/api/admin', + wsURL: 'wss://api.example.com/ws', + }); + } finally { + vi.unstubAllEnvs(); + } + }); +}); diff --git a/web/src/lib/backend-config.ts b/web/src/lib/backend-config.ts new file mode 100644 index 00000000..28e7d8f0 --- /dev/null +++ b/web/src/lib/backend-config.ts @@ -0,0 +1,154 @@ +/** + * Backend address configuration. + * + * By default the web UI talks to the same origin that served it (baseURL + * `/api`, WebSocket on `/ws`). This module lets a separately-hosted frontend + * (e.g. a static build on a CDN, or a dev server) point at an arbitrary backend + * by storing an override in localStorage. A build-time `VITE_BACKEND_URL` is + * used as the fallback when no runtime override is present. + * + * The backend must allow the frontend's origin via MAXX_CORS_ALLOW_ORIGINS for + * cross-origin requests to succeed. + */ + +import type { TransportConfig } from './transport/interface'; + +const STORAGE_KEY = 'maxx_backend_url'; + +// Must match AUTH_TOKEN_KEY in lib/auth-context.ts. Duplicated here (rather than +// imported) to avoid an import cycle: auth-context → transport → backend-config. +const AUTH_TOKEN_KEY = 'maxx-admin-token'; + +/** + * Thrown when persisting the backend URL fails because storage is unavailable + * (e.g. private mode, locked-down environments) — as opposed to the URL being + * invalid. Lets the UI surface a distinct, accurate error. + */ +export class BackendStorageError extends Error { + constructor(cause?: unknown) { + super('Failed to persist backend URL: storage unavailable'); + this.name = 'BackendStorageError'; + if (cause !== undefined) { + (this as { cause?: unknown }).cause = cause; + } + } +} + +/** Build-time fallback (empty string when unset). Read lazily so it can be tested. */ +function buildTimeBackendUrl(): string { + return (import.meta.env.VITE_BACKEND_URL as string | undefined)?.trim() ?? ''; +} + +/** + * Normalizes a backend URL to `origin + pathname` (query and hash dropped, + * trailing slashes stripped) so that later appending "/api" or "/ws" never + * yields a malformed URL. A sub-path is preserved to support a backend behind a + * reverse-proxy prefix (e.g. https://example.com/maxx). Empty input → "". + * + * @throws Error if the (non-empty) input is not a valid absolute http(s) URL. + */ +function normalizeBackendUrl(raw: string): string { + const trimmed = raw.trim(); + if (!trimmed) { + return ''; + } + const parsed = new URL(trimmed); // throws on an unparseable URL + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + throw new Error('Backend URL must use http or https'); + } + return (parsed.origin + parsed.pathname).replace(/\/+$/, ''); +} + +/** Like normalizeBackendUrl but returns "" instead of throwing on bad input. */ +function normalizeBackendUrlSafe(raw: string): string { + try { + return normalizeBackendUrl(raw); + } catch { + return ''; + } +} + +/** + * Returns the configured backend base (origin, plus any reverse-proxy sub-path), + * or an empty string when the UI should use its own origin (same-origin default). + * The runtime override and the build-time VITE_BACKEND_URL fallback share the + * same normalization, so both honor the identical URL contract. + */ +export function getBackendUrl(): string { + let stored = ''; + try { + stored = localStorage.getItem(STORAGE_KEY)?.trim() ?? ''; + } catch { + // localStorage may be unavailable (private mode / SSR); fall through. + } + return normalizeBackendUrlSafe(stored || buildTimeBackendUrl()); +} + +/** + * Persists a backend URL override. Pass an empty/whitespace string to clear it + * and revert to the build-time / same-origin default. Returns the normalized + * effective backend after the change. + * + * The input is normalized to `origin + pathname` (query and hash are dropped, + * trailing slashes stripped) so that later appending "/api" never yields a + * malformed URL. A sub-path is preserved to support a backend hosted behind a + * reverse-proxy prefix (e.g. https://example.com/maxx). + * + * When the effective backend actually changes, the stored admin token is + * cleared so a session minted by one backend is never replayed against another. + * + * @throws Error if the provided value is not a valid absolute http(s) URL. + */ +export function setBackendUrl(raw: string): string { + const previous = getBackendUrl(); + + // Validate/normalize before touching storage so a bad URL never half-applies. + // Shared with getBackendUrl() so runtime and build-time values are identical. + const normalized = normalizeBackendUrl(raw); // throws on an invalid URL; "" for empty + + // Storage failures (private mode, locked-down env) are a distinct error from + // an invalid URL, so the UI can report them accurately rather than as "invalid". + try { + if (!normalized) { + localStorage.removeItem(STORAGE_KEY); + } else { + localStorage.setItem(STORAGE_KEY, normalized); + } + } catch (err) { + throw new BackendStorageError(err); + } + + const current = getBackendUrl(); + if (current !== previous) { + // Don't carry one backend's credentials over to another origin. + try { + localStorage.removeItem(AUTH_TOKEN_KEY); + } catch { + // ignore storage errors + } + } + return current; +} + +/** + * Builds the TransportConfig (baseURL / adminBaseURL / wsURL) for the configured + * backend. Returns `undefined` when no override is set, so HttpTransport applies + * its same-origin defaults. + */ +export function buildTransportConfig(): TransportConfig | undefined { + const backend = getBackendUrl(); + if (!backend) { + return undefined; + } + + const baseURL = `${backend}/api`; + // Derive ws(s):// from the backend origin. + const wsOrigin = backend.replace(/^http/, 'ws'); + const wsURL = `${wsOrigin}/ws`; + + return { + baseURL, + adminBaseURL: `${baseURL}/admin`, + wsURL, + }; +} diff --git a/web/src/lib/transport/context.tsx b/web/src/lib/transport/context.tsx index fc5a18cd..cdeebd6c 100644 --- a/web/src/lib/transport/context.tsx +++ b/web/src/lib/transport/context.tsx @@ -8,6 +8,7 @@ import { createContext, useContext, useState, useEffect, type ReactNode } from 'react'; import type { Transport, TransportType } from './interface'; import { initializeTransport, getTransport, isTransportReady, getTransportType } from './factory'; +import { buildTransportConfig } from '../backend-config'; /** * Transport Context 的值类型 @@ -101,7 +102,7 @@ export function TransportProvider({ console.log('[TransportProvider] Initializing transport...'); - initializeTransport() + initializeTransport(buildTransportConfig()) .then((transport) => { if (!cancelled) { const type = getTransportType()!; diff --git a/web/src/locales/en.json b/web/src/locales/en.json index 0cb28b1e..82fe425e 100644 --- a/web/src/locales/en.json +++ b/web/src/locales/en.json @@ -1503,5 +1503,19 @@ "requestModel": "Request Model", "mappedModel": "Mapped Model", "emptyHint": "No model mappings configured. Add mappings to transform request models before sending to upstream." + }, + "backendAddress": { + "title": "Backend address", + "description": "Connect this UI to a backend on a different origin. Leave empty to use the default backend (the page's own origin, or the build-time default if one was configured).", + "advanced": "Connection settings", + "label": "Backend URL", + "placeholder": "https://api.example.com", + "current": "Current: {{value}}", + "sameOrigin": "same origin (default)", + "save": "Save & reconnect", + "reset": "Reset to default", + "invalid": "Enter a valid http(s) URL", + "corsHint": "The backend must allow this origin via MAXX_CORS_ALLOW_ORIGINS.", + "storageError": "Could not save: browser storage is unavailable (e.g. private mode)." } } diff --git a/web/src/locales/zh.json b/web/src/locales/zh.json index 8cb7a7f2..c34fcde2 100644 --- a/web/src/locales/zh.json +++ b/web/src/locales/zh.json @@ -1501,5 +1501,19 @@ "requestModel": "请求模型", "mappedModel": "映射模型", "emptyHint": "暂无模型映射。添加映射以在发送到上游前转换请求模型。" + }, + "backendAddress": { + "title": "后端地址", + "description": "将此界面连接到其他来源的后端。留空则使用默认后端(页面自身来源,或构建时配置的默认值)。", + "advanced": "连接设置", + "label": "后端 URL", + "placeholder": "https://api.example.com", + "current": "当前:{{value}}", + "sameOrigin": "同源(默认)", + "save": "保存并重新连接", + "reset": "恢复默认", + "invalid": "请输入有效的 http(s) URL", + "corsHint": "后端需通过 MAXX_CORS_ALLOW_ORIGINS 允许此来源。", + "storageError": "无法保存:浏览器存储不可用(如隐私模式)。" } } diff --git a/web/src/pages/login.tsx b/web/src/pages/login.tsx index f0a74e56..1e6b6d49 100644 --- a/web/src/pages/login.tsx +++ b/web/src/pages/login.tsx @@ -5,6 +5,7 @@ import { ChevronDownIcon, FingerprintIcon, } from 'lucide-react'; +import { BackendAddressControl } from '@/components/backend-address-control'; import { PasswordRulesPopover } from '@/components/auth/password-rules-popover'; import { FieldError } from '@/components/field-error'; import { PasswordInput } from '@/components/password-input'; @@ -654,6 +655,10 @@ export function LoginPage({ onSuccess }: LoginPageProps) { )} + +
+ +
diff --git a/web/src/pages/settings/index.tsx b/web/src/pages/settings/index.tsx index 25e7ad20..1771f0d1 100644 --- a/web/src/pages/settings/index.tsx +++ b/web/src/pages/settings/index.tsx @@ -41,6 +41,7 @@ import { } from '@/components/ui'; import { Textarea } from '@/components/ui/textarea'; import { PageHeader } from '@/components/layout/page-header'; +import { BackendAddressControl } from '@/components/backend-address-control'; import { useSettings, useUpdateSetting, useDeleteSetting } from '@/hooks/queries'; import { useAuth } from '@/lib/auth-context'; import { useTransport } from '@/lib/transport/context'; @@ -280,6 +281,7 @@ export function SettingsPage() {
+ {isAdmin && ( <> @@ -440,6 +442,24 @@ function GeneralSection() { ); } +function BackendAddressSection() { + const { t } = useTranslation(); + + return ( + + + + + {t('backendAddress.title')} + + + + + + + ); +} + // 常用时区列表 const COMMON_TIMEZONES = [ 'UTC', diff --git a/web/tsconfig.app.json b/web/tsconfig.app.json index c92c99d9..ba2bffab 100644 --- a/web/tsconfig.app.json +++ b/web/tsconfig.app.json @@ -28,5 +28,5 @@ } }, "include": ["src"], - "exclude": ["src/lib/transport/wails-transport.ts", "src/wailsjs"] + "exclude": ["src/lib/transport/wails-transport.ts", "src/wailsjs", "src/**/*.test.ts"] } diff --git a/web/vitest.config.ts b/web/vitest.config.ts new file mode 100644 index 00000000..fa7e2775 --- /dev/null +++ b/web/vitest.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + environment: 'jsdom', + globals: true, + include: ['src/**/*.test.ts'], + }, +});