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'],
+ },
+});