diff --git a/README.md b/README.md index aec78c8..5f213bf 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,7 @@ snapshots are preserved in the [historical archive](docs/content/archive/_index. ## Development and checks -Use Go **1.25.1** and Node.js **22.16.0 or newer**. The Go workspace contains the +Use Go **1.26.10 or newer** and Node.js **22.16.0 or newer**. The Go workspace contains the API (`src`) and CLI (`cli`). From the repository root: ```bash @@ -77,6 +77,13 @@ Humans are responsible for editing the human facing docs. This is because the hu Documentation should include explanations of design decisions for later platform maintainers. +## Optional Misty login + +Optional Misty email login is documented in +[the rollout guide](docs/agents/misty-login.md). It requires HTTPS, an explicit +expected Misty environment, and reviewed identity mappings for existing accounts. +It is disabled by default and has not been enabled on the live private portal. + ## Frontend Testing how to use fake backend for testing frontend. @@ -93,4 +100,4 @@ npm --prefix web-interface run fake-backend -  \ No newline at end of file +  diff --git a/cli/cmd/api_client.go b/cli/cmd/api_client.go index 555490f..d666135 100644 --- a/cli/cmd/api_client.go +++ b/cli/cmd/api_client.go @@ -5,7 +5,9 @@ import ( "encoding/json" "fmt" "io" + "net" "net/http" + "net/url" "strings" "time" ) @@ -32,13 +34,11 @@ func (ctx *AppContext) api(method, path string, body, out interface{}) error { req.Header.Set("X-Mist-Team", team) } if ctx.Config != nil && ctx.Config.SessionCookie != "" { - req.Header.Set("Cookie", ctx.Config.SessionCookie) - } - client := ctx.HTTPClient - if client == nil { - client = &http.Client{Timeout: 30 * time.Second} + if err := ctx.attachSession(req); err != nil { + return err + } } - response, err := client.Do(req) + response, err := ctx.credentialClient().Do(req) if err != nil { return err } @@ -59,6 +59,43 @@ func (ctx *AppContext) api(method, path string, body, out interface{}) error { return nil } +// Credentials belong to one exact scheme, host and port. An API path is not +// part of the origin; changing environments requires a separate --config/login. +func credentialOrigin(raw string) (string, error) { + u, err := url.Parse(raw) + if err != nil || u.Host == "" || u.User != nil || u.RawQuery != "" || u.ForceQuery || u.Fragment != "" { + return "", fmt.Errorf("invalid compute URL") + } + ip := net.ParseIP(u.Hostname()) + if u.Scheme != "https" && !(u.Scheme == "http" && (u.Hostname() == "localhost" || (ip != nil && ip.IsLoopback()))) { + return "", fmt.Errorf("credentials require HTTPS (HTTP only on loopback)") + } + return u.Scheme + "://" + strings.ToLower(u.Host), nil +} + +func (ctx *AppContext) attachSession(req *http.Request) error { + origin, err := credentialOrigin(req.URL.String()) + if err != nil { + return err + } + saved, err := credentialOrigin(ctx.Config.APIBaseURL) + if err != nil || origin != saved { + return fmt.Errorf("session belongs to a different API origin; use a separate --config and sign in there") + } + req.Header.Set("Cookie", ctx.Config.SessionCookie) + return nil +} + +func (ctx *AppContext) credentialClient() *http.Client { + client := http.Client{Timeout: 30 * time.Second} + if ctx.HTTPClient != nil { + client = *ctx.HTTPClient + } + client.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse } + client.Jar = nil // Only explicitly bound cookies may accompany these requests. + return &client +} + func (ctx *AppContext) baseURL() string { if ctx.APIBaseURL != "" { return strings.TrimRight(ctx.APIBaseURL, "/") diff --git a/cli/cmd/auth_login.go b/cli/cmd/auth_login.go index 8fff8d0..66a53e7 100644 --- a/cli/cmd/auth_login.go +++ b/cli/cmd/auth_login.go @@ -16,9 +16,23 @@ import ( type LoginCmd struct { Email string `help:"Mist member email" required:""` PasswordStdin bool `help:"Read password from standard input (for automation)"` + Misty bool `help:"Sign in with a code sent by Misty"` + CodeStdin bool `help:"Read the Misty code from standard input"` } func (l *LoginCmd) Run(ctx *AppContext) error { + if l.Misty { + if l.PasswordStdin { + return fmt.Errorf("--password-stdin cannot be used with --misty") + } + return l.loginMisty(ctx) + } + if l.CodeStdin { + return fmt.Errorf("--code-stdin requires --misty") + } + if _, err := credentialOrigin(ctx.baseURL()); err != nil { + return err + } var password string if l.PasswordStdin { data, err := io.ReadAll(io.LimitReader(os.Stdin, 130)) @@ -49,7 +63,7 @@ func (l *LoginCmd) Run(ctx *AppContext) error { } req.Header.Set("Content-Type", "application/json") req.Header.Set("Origin", u.Scheme+"://"+u.Host) - response, err := ctx.HTTPClient.Do(req) + response, err := ctx.credentialClient().Do(req) if err != nil { return err } @@ -57,6 +71,10 @@ func (l *LoginCmd) Run(ctx *AppContext) error { if response.StatusCode != 200 { return fmt.Errorf("login failed (HTTP %d)", response.StatusCode) } + return ctx.saveLogin(base, response) +} + +func (ctx *AppContext) saveLogin(base string, response *http.Response) error { cookies := []string{} for _, cookie := range response.Cookies() { if strings.Contains(cookie.Name, "session_token") && cookie.Value != "" { @@ -129,8 +147,10 @@ func (l *LogoutCmd) Run(ctx *AppContext) error { } req.Header.Set("Content-Type", "application/json") req.Header.Set("Origin", origin) - req.Header.Set("Cookie", ctx.Config.SessionCookie) - response, err := ctx.HTTPClient.Do(req) + if err := ctx.attachSession(req); err != nil { + return err + } + response, err := ctx.credentialClient().Do(req) if err != nil { return err } diff --git a/cli/cmd/auth_misty.go b/cli/cmd/auth_misty.go new file mode 100644 index 0000000..c03d16c --- /dev/null +++ b/cli/cmd/auth_misty.go @@ -0,0 +1,91 @@ +package cmd + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "regexp" + "strings" + "time" + + "golang.org/x/term" +) + +func (l *LoginCmd) loginMisty(ctx *AppContext) error { + origin, err := credentialOrigin(ctx.baseURL()) + if err != nil { + return err + } + // Never follow a login redirect with a code or binding cookie. + client := ctx.credentialClient() + client.Timeout = 15 * time.Second + client.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse } + post := func(path string, body any, cookies []*http.Cookie) (*http.Response, error) { + data, _ := json.Marshal(body) + req, err := http.NewRequest(http.MethodPost, origin+path, bytes.NewReader(data)) + if err != nil { + return nil, fmt.Errorf("invalid login request") + } + req.Header.Set("Origin", origin) + req.Header.Set("Content-Type", "application/json") + for _, cookie := range cookies { + req.AddCookie(cookie) + } + response, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("compute login unavailable") + } + return response, nil + } + start, err := post("/auth/misty/start", map[string]string{"email": l.Email}, nil) + if err != nil { + return err + } + defer start.Body.Close() + if start.StatusCode != 202 { + return fmt.Errorf("requesting code failed (HTTP %d)", start.StatusCode) + } + var challenge struct { + ID string `json:"challenge_id"` + } + if json.NewDecoder(io.LimitReader(start.Body, 8192)).Decode(&challenge) != nil || challenge.ID == "" { + return fmt.Errorf("invalid login response") + } + var cookies []*http.Cookie + for _, cookie := range start.Cookies() { + if cookie.Name == "misty_login" { + cookies = append(cookies, cookie) + } + } + if len(cookies) != 1 { + return fmt.Errorf("login returned no challenge cookie") + } + fmt.Fprintln(os.Stderr, "If your email belongs to an active Misty account, a code will arrive shortly.") + var code []byte + if l.CodeStdin { + code, err = io.ReadAll(io.LimitReader(os.Stdin, 16)) + } else { + fmt.Fprint(os.Stderr, "Verification code: ") + code, err = term.ReadPassword(int(os.Stdin.Fd())) + fmt.Fprintln(os.Stderr) + } + if err != nil { + return fmt.Errorf("cannot read code (use --code-stdin for automation)") + } + value := strings.TrimSpace(string(code)) + if !regexp.MustCompile(`^[0-9]{6}$`).MatchString(value) { + return fmt.Errorf("enter a six-digit code") + } + confirm, err := post("/auth/misty/confirm", map[string]string{"challenge_id": challenge.ID, "code": value}, cookies) + if err != nil { + return err + } + defer confirm.Body.Close() + if confirm.StatusCode != 200 { + return fmt.Errorf("code verification failed (HTTP %d); check the code or start again", confirm.StatusCode) + } + return ctx.saveLogin(ctx.baseURL(), confirm) +} diff --git a/cli/cmd/auth_misty_test.go b/cli/cmd/auth_misty_test.go new file mode 100644 index 0000000..c677d91 --- /dev/null +++ b/cli/cmd/auth_misty_test.go @@ -0,0 +1,66 @@ +package cmd + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestMistyCLIUsesBoundCodeAndSavesProtectedSession(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Origin") == "" || r.Header.Get("X-API-Key") != "" { + t.Error("incorrect client authentication") + } + switch r.URL.Path { + case "/auth/misty/start": + http.SetCookie(w, &http.Cookie{Name: "misty_login", Value: "binding", Path: "/auth"}) + w.WriteHeader(202) + json.NewEncoder(w).Encode(map[string]string{"challenge_id": "challenge"}) + case "/auth/misty/confirm": + cookie, err := r.Cookie("misty_login") + if err != nil || cookie.Value != "binding" { + t.Error("binding missing") + } + var body map[string]string + json.NewDecoder(r.Body).Decode(&body) + if body["code"] != "123456" || body["challenge_id"] != "challenge" { + t.Error("incorrect confirmation") + } + http.SetCookie(w, &http.Cookie{Name: "session_token", Value: "synthetic-session", HttpOnly: true}) + w.WriteHeader(200) + default: + t.Error("unexpected path", r.URL.Path) + http.NotFound(w, r) + } + })) + defer server.Close() + input, err := os.CreateTemp(t.TempDir(), "code") + if err != nil { + t.Fatal(err) + } + defer input.Close() + input.WriteString("123456\n") + input.Seek(0, 0) + original := os.Stdin + os.Stdin = input + defer func() { os.Stdin = original }() + ctx := &AppContext{HTTPClient: server.Client(), APIBaseURL: server.URL, ConfigPath: filepath.Join(t.TempDir(), "config.json")} + if err := (&LoginCmd{Email: "member@example.com", Misty: true, CodeStdin: true}).Run(ctx); err != nil { + t.Fatal(err) + } + data, err := os.ReadFile(ctx.ConfigPath) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(data), "synthetic-session") || strings.Contains(string(data), "123456") || strings.Contains(string(data), "binding") { + t.Fatal("incorrect persisted credentials") + } + info, err := os.Stat(ctx.ConfigPath) + if err != nil || info.Mode().Perm() != 0600 { + t.Fatal("unprotected config", err) + } +} diff --git a/cli/cmd/session_security_test.go b/cli/cmd/session_security_test.go new file mode 100644 index 0000000..a17643f --- /dev/null +++ b/cli/cmd/session_security_test.go @@ -0,0 +1,78 @@ +package cmd + +import ( + "net/http" + "net/http/httptest" + "path/filepath" + "testing" +) + +func TestSessionRejectsChangedOrigin(t *testing.T) { + for _, logout := range []bool{false, true} { + t.Run(map[bool]string{false: "API", true: "logout"}[logout], func(t *testing.T) { + called := false + other := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { called = true })) + defer other.Close() + ctx := &AppContext{HTTPClient: other.Client(), APIBaseURL: other.URL, ConfigPath: filepath.Join(t.TempDir(), "config.json"), Config: &Config{APIBaseURL: "https://production.example.test/api", SessionCookie: "session_token=synthetic"}} + var err error + if logout { + err = (&LogoutCmd{}).Run(ctx) + } else { + err = ctx.api("GET", "/session", nil, nil) + } + if err == nil || called { + t.Fatal("different origin received an authenticated request") + } + if ctx.Config.SessionCookie == "" { + t.Fatal("rejected logout erased the original session") + } + }) + } +} + +func TestSessionRejectsRedirects(t *testing.T) { + for _, logout := range []bool{false, true} { + t.Run(map[bool]string{false: "API", true: "logout"}[logout], func(t *testing.T) { + called := false + plain := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { called = true })) + defer plain.Close() + secure := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, plain.URL, http.StatusTemporaryRedirect) + })) + defer secure.Close() + ctx := &AppContext{HTTPClient: secure.Client(), ConfigPath: filepath.Join(t.TempDir(), "config.json"), Config: &Config{APIBaseURL: secure.URL, SessionCookie: "session_token=synthetic"}} + var err error + if logout { + err = (&LogoutCmd{}).Run(ctx) + } else { + err = ctx.api("GET", "/session", nil, nil) + } + if err == nil || called { + t.Fatal("authenticated redirect was followed") + } + }) + } +} + +func TestSessionAcceptsSavedOrigin(t *testing.T) { + called := false + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + called = true + if r.Header.Get("Cookie") != "session_token=synthetic" { + t.Error("missing session") + } + })) + defer server.Close() + ctx := &AppContext{HTTPClient: server.Client(), APIBaseURL: server.URL + "/api", Config: &Config{APIBaseURL: server.URL, SessionCookie: "session_token=synthetic"}} + if err := ctx.api("GET", "/session", nil, nil); err != nil || !called { + t.Fatal("saved origin rejected", err) + } +} + +func TestCredentialOriginRejectsUnsafeURLs(t *testing.T) { + for _, raw := range []string{"http://compute.example.com/api", "https://user:password@compute.example.com", "https://compute.example.com?token=x", "https://compute.example.com#token", ""} { + if _, err := credentialOrigin(raw); err == nil { + t.Errorf("unsafe origin accepted: %q", raw) + } + } +} diff --git a/cli/go.mod b/cli/go.mod index 3d70d07..7bf971c 100644 --- a/cli/go.mod +++ b/cli/go.mod @@ -1,6 +1,6 @@ module mist/cli -go 1.24.0 +go 1.26.10 require ( github.com/alecthomas/kong v1.12.1 diff --git a/deploy/k3s/Dockerfile.api b/deploy/k3s/Dockerfile.api index 0cad2bf..8970b51 100644 --- a/deploy/k3s/Dockerfile.api +++ b/deploy/k3s/Dockerfile.api @@ -1,7 +1,7 @@ # Build the static binary from the repository root: # CGO_ENABLED=0 go -C src build -o ../bin/mist-api . # docker build -f deploy/k3s/Dockerfile.api -t mist-api:departments-20261009 . -FROM alpine:3.16.3 +FROM alpine:3.24.3 COPY bin/mist-api /usr/local/bin/mist-api USER 65532:65532 ENTRYPOINT ["/usr/local/bin/mist-api"] diff --git a/deploy/private/build-and-deploy.sh b/deploy/private/build-and-deploy.sh index 880c4ba..c09e105 100755 --- a/deploy/private/build-and-deploy.sh +++ b/deploy/private/build-and-deploy.sh @@ -15,14 +15,14 @@ archive=$(mktemp /tmp/mist-deploy.XXXXXX.tar) trap 'python3 -c "import os,sys; os.unlink(sys.argv[1])" "$archive"' EXIT docker save -o "$archive" mist-api:folders-20261021 # Existing approved Docker access permits host import, without changing sudo policy. -docker run --rm --mount type=bind,src=/,dst=/host alpine:3.16.3 chroot /host /usr/local/bin/k3s ctr images import "$archive" +docker run --rm --mount type=bind,src=/,dst=/host alpine:3.24.3 chroot /host /usr/local/bin/k3s ctr images import "$archive" k3s kubectl apply -f deploy/k3s/storage/shared.yaml -f deploy/k3s/team-rbac.yaml -f deploy/k3s/team-workload-policy.yaml -f deploy/k3s/mist-api.yaml k3s kubectl rollout restart deployment/mist-api -n mist-system k3s kubectl rollout status deployment/mist-api -n mist-system --timeout=120s npm --prefix web-interface ci npx --prefix web-interface tsc --noEmit -p web-interface/tsconfig.json npm --prefix web-interface run build -docker run --rm --privileged --pid host --network host --mount type=bind,src=/,dst=/host alpine:3.16.3 chroot /host /usr/bin/nsenter -t 1 -m -n -p -r -w /bin/bash "$PWD/deploy/private/install-web.sh" "$PWD" +docker run --rm --privileged --pid host --network host --mount type=bind,src=/,dst=/host alpine:3.24.3 chroot /host /usr/bin/nsenter -t 1 -m -n -p -r -w /bin/bash "$PWD/deploy/private/install-web.sh" "$PWD" for attempt in $(seq 1 15); do if curl --fail --silent http://127.0.0.1:8088/api/session; then exit 0; fi sleep 2 diff --git a/deploy/private/misty-login.env.example b/deploy/private/misty-login.env.example new file mode 100644 index 0000000..6dc8f68 --- /dev/null +++ b/deploy/private/misty-login.env.example @@ -0,0 +1,12 @@ +# Copy to a protected operator file; never commit the populated copy. +# Enable only after the portal and gateway have working HTTPS. +MIST_AUTH_MODE=misty +MIST_SITE_ORIGIN=https://compute.example.com +MISTY_GATEWAY_URL=https://gateway.example.com +# Must match the gateway's GATEWAY_ENV; use staging with a separate test database. +MISTY_ENVIRONMENT=production +# Operator-reviewed immutable Misty UUID -> existing compute user ID mappings. +# Required for existing accounts; new eligible members receive role user. +MISTY_ACCOUNT_LINKS={} +# Issued gw_ key with exactly auth:email and auth:status; server-side only. +MISTY_GATEWAY_API_KEY= diff --git a/docs/agents/misty-login-verification.md b/docs/agents/misty-login-verification.md new file mode 100644 index 0000000..699e5bb --- /dev/null +++ b/docs/agents/misty-login-verification.md @@ -0,0 +1,127 @@ +# Misty login verification — 2026-10-26 + +The local implementation passed the source, regression and integration checks below. +No live service was deployed and no real email was sent. Tailscale was not needed. + +## Cross-service browser check + +Chromium exercised the built compute frontend and real Go authentication code +against freshly built Misty gateway, team-tracking and verification containers. +Each service used disposable storage; gateway and verification had their own +Postgres databases. Private calls used separately issued scoped credentials. +Only the email sender and its code generator were test replacements. Unrelated +compute job/cluster routes were stubs; no Kubernetes jobs ran. + +Observed results: + +- An active registered email received a challenge; an incorrect code failed. +- The correct code created a compute session, preserving the seeded existing + local user ID and administrator role through an explicit operator UUID mapping. +- Reusing the redeemed challenge failed. +- Password account-creation and reset controls were absent in Misty mode. +- Retiring the member in the real directory caused the existing compute session + to be rejected after the configured status cache expired, within 40 seconds. +- Unknown and inactive emails received the generic start response, could not + establish sessions, and created no verification-code rows. +- The gateway also passed its resolver credential-separation, upstream outage, + revocation, migration/readiness and email-login smoke assertions. +- Chromium reported no page errors. + +A separate Chromium run through real compute authentication with a fake gateway +verified sign-out, HttpOnly session cookies, and the absence of a gateway key in +browser requests. + +## Automated checks + +- API and CLI: Linux `go test -race ./...` passed. Regressions cover concurrent + redemption, expiry, attempts, browser binding, strict origin/body validation, + database failures, retired members, upstream outages, legacy sessions, account + collisions/bans, preserved ownership after email changes and quota responses. +- Frontend: Vitest, TypeScript, ESLint on changed sources, Prettier checks on + changed sources, and the production build passed. The resend regression + selects an earlier request explicitly after a replacement request. +- `git diff --check` passed. + +The macOS API build hits existing Linux-specific `statfs` type differences, +so Go tests and binaries use Linux Go 1.26.10. The module/workspace minimum now +requires that patch or newer. Clean `npm ci --ignore-scripts` succeeds with the +repaired optional-platform lock entries. Existing package versions are unchanged +except patched Zod 4.6.6 and Undici 7.30.1; the full npm audit is clean. The API +image builds on Alpine 3.24.3, and deployment helper images use the same release. + +## Security regression evidence + +The following tests failed against the vulnerable implementation and passed +after the corresponding fix: + +- `TestSessionRejectsChangedOrigin` and `TestSessionRejectsRedirects`: API calls + and logout withhold saved cookies from another origin or redirect destination. +- `TestRevocationReportsDatabaseFailure`: a real SQLite trigger rejects session + deletion; logout/admin revocation report failure, retain retry credentials, and + successfully revoke after the injected failure is removed. +- `TestMistyUnlinkedExistingAccountCannotBeClaimedByEmail`: neither ordinary nor + admin accounts can be claimed by a new identity through email equality. +- `TestMistyIssuerMismatchAndDatabasePin` and + `TestMistyRejectsMissingOrChangedResponseIssuer`: wrong issuers/origins and + missing attestation fail closed. A changed gateway origin is rejected before + sending its configured credential. +- Gateway regressions demonstrate that exhausting other challenges or delivering + a new code cannot block a valid earlier challenge. Both storage adapters bound + guesses with three attempts per challenge and five deliveries per mailbox + issuance window. + +Additional tests verify explicit mapping persistence, conflict rejection, secure +CLI URL validation and retry behavior. API/CLI race suites and `go vet`, frontend +tests/types/lint/build, gateway Postgres tests, migration round trip and Misty's +repository-wide `make check` passed. The updated browser run again exercised the +real gateway/directory/verification containers with fake mail, including issuer +checks, explicitly mapped admin identity, replay and retirement. + +## Local evidence + +The operator workspace retains the disposable test harness and logs: + +- `/tmp/misty-compute-browser/security_fullstack.py`: container orchestration, derived + from Misty's existing `services/gateway/tests/smoke_stack.py`. +- `/tmp/misty-compute-browser/security_harness_test.go`: compute auth/static + frontend harness, injected only into a temporary test checkout. +- `/tmp/misty-compute-browser/fullstack.cjs`: browser assertions. +- `/tmp/misty-security-fullstack-final.log` and + `/tmp/misty-security-fullstack-browser.log`: successful integration results. +- `/tmp/misty-compute-browser/fullstack-signed-in.png`: authenticated account UI. +- `/tmp/misty-security-fixes-artifacts.json`: hashes of the changed source and + regression files used for the security checks. + +These are local temporary artifacts, not deployment assets. All test containers, +networks and disposable databases were removed after verification. + +## Remaining live checks + +The private compute host was unreachable because this Mac's Tailscale client was +logged out. Live HTTPS, ingress/proxy-IP configuration, real email delivery, +database backup/cutover and existing production account mappings remain +unverified. Misty's staging gateway still requires deployment and provisioning. +Follow [the rollout guide](misty-login.md) before enabling the feature. + + +## Independent audit follow-up: rollback sessions + +The added rollback regressions fail against the audited `cc3218d` implementation +and pass with the follow-up change. Rolling back to local auth durably revokes +Misty sessions and clears pending challenges while preserving local sessions and +identity mappings. Re-enabling Misty cannot revive the deleted credentials. +Local mode also rejects Misty-provenance rows from an overlapping old process. +A synthetic SQLite delete failure verifies startup fails and the cleanup +transaction cannot partially commit. + +The complete Linux API/CLI race suites and `go vet` passed. Misty's companion +change passed full disposable-Postgres suites and a real HTTP reproduction of +Discord/compute cooldown overlap and resend-clock skew, with fake mail only. +Status capacity is configured on Misty; compute keeps its 30-second eligibility +cache. Existing live-host and real-mail limitations above still apply. + +Changed-file rationale: `src/auth.go` rejects mismatched session provenance; +`src/misty_auth.go` performs transactional rollback cleanup; +`src/auth_security_test.go` covers rollback, late writes and cleanup failure; +`docs/agents/misty-login.md` describes rollback and updated gateway capacity; +this verification report records the evidence and remaining limits. diff --git a/docs/agents/misty-login.md b/docs/agents/misty-login.md new file mode 100644 index 0000000..0819b6d --- /dev/null +++ b/docs/agents/misty-login.md @@ -0,0 +1,198 @@ +# Sign in with Misty + +`MIST_AUTH_MODE=misty` replaces local password login with Misty email codes. +The default is `local`; no deployment manifest enables the new mode automatically. +Misty checks its registered primary email and active directory record, generates +and sends the code, verifies it, and returns an immutable person ID. Mist keeps +its own SQLite sessions, user IDs, roles, teams, jobs and files. + +## Configuration and rollout + +Use `deploy/private/misty-login.env.example` as the configuration reference: + +| Variable | Meaning | +| --- | --- | +| `MIST_AUTH_MODE` | `local` (default) or `misty`; unknown values fail startup | +| `MIST_SITE_ORIGIN` | Exact browser origin; required in Misty mode | +| `MISTY_GATEWAY_URL` | Gateway origin, without an API path | +| `MISTY_ENVIRONMENT` | Required expected `local`, `staging`, or `production` issuer | +| `MISTY_ACCOUNT_LINKS` | Optional JSON map of reviewed Misty UUIDs to existing compute user IDs; required to migrate existing accounts | +| `MISTY_GATEWAY_API_KEY` | Server-only issued `gw_` key with `auth:email` and `auth:status` | + +Both origins must use HTTPS; loopback HTTP is allowed for development only. +Misty mode forces authentication and rejects `MIST_AUTH_DISABLED=true`. +Every auth POST must have the exact configured Origin. The existing trusted +Nginx address list (`MIST_TRUSTED_PROXY_IPS`) must be correct: only those socket +peers may attest `X-Real-IP`. Browsers never supply the gateway key or client IP. + +1. Deploy and verify Misty's gateway/verification changes in staging first. + Follow `services/gateway/docs/DEPLOYMENT.md` in UTMIST/Misty for private + consumer credentials, migration 002 and scoped gateway key issuance. +2. Establish HTTPS on the compute portal. Its existing private HTTP deployment + cannot enable this mode. Do not expose the Kubernetes API or widen tailnet + access just to enable login. +3. Back up the existing auth database using `deploy/private/backup-auth.sh`. + Review each existing account's immutable Misty person UUID and compute user ID + with an operator. Set `MISTY_ACCOUNT_LINKS` to those mappings, for example + `{"00000000-0000-4000-8000-000000000001":"existing-compute-user-id"}`. + Email possession alone cannot claim an existing account, including an admin. + Duplicate/conflicting mappings, unknown local IDs, and local bans deny access. +4. Deploy this source using the normal reviewed compute release process. Load + the gateway key through a protected Kubernetes Secret, never a frontend env + variable or a checked-in manifest. Configure the remaining values on the API + deployment and keep its existing single-replica SQLite/PVC arrangement. + Set `MISTY_ENVIRONMENT=production` for production or `staging` for a separate + staging compute database. Startup verifies `/v1/auth/issuer` and atomically + pins the issuer and gateway origin in SQLite. Missing issuer, mismatch, + gateway outage, or an attempt to repoint a pinned database prevents startup. +5. Verify an existing account retains its local ID, role, teams and files; a new + eligible member gets role `user`. Verify wrong-code errors, replay rejection, + sign-out, and inactive-member denial before considering the cutover complete. + +Startup adds `misty_identity`, `misty_login`, `misty_legacy_owner`, `misty_issuer`, and nullable +`session.mistyPersonId`; existing rows and credentials remain. Old password +sessions are rejected while Misty mode is enabled. Password creation/change/reset +endpoints and their UI controls are disabled in this mode. Existing local admin +roles remain local authority; Misty never grants compute administrator access. +Mappings persist after `MISTY_ACCOUNT_LINKS` is removed; repeating a mapping is +idempotent, and a conflicting mapping is rejected transactionally. Do not change +an issuer pin or transplant a staging database into production. Gateway-domain +changes require a separately reviewed migration, after backup and identity review. +A database containing older, unpinned Misty identities refuses automatic adoption; +reconcile those mappings and invalidate its old Misty sessions in an explicit +maintenance migration before recording the reviewed issuer/origin. + +If `MIST_LEGACY_OWNER_EMAIL` is configured, first enablement requires it to match +one existing local user. That user's ID is pinned durably before any email can +change. The pin survives restarts and rollback to `local`; email reassignment +must never transfer old jobs or datasets. Changing the configured legacy email +does not overwrite a recorded pin. Handle deliberate ownership transfers as a +separate reviewed data migration. + +For rollback, restore `MIST_AUTH_MODE=local` and restart the same code version. +Startup durably deletes Misty sessions and pending login challenges; local mode +also rejects any Misty session written by an overlapping old process. Switching +back to Misty requires a fresh login. Existing local sessions and identity/owner +mappings are preserved. Keep the additive tables, existing database and volume. Existing local passwords +work again; users first created through Misty have no password and need an admin +to provision one. Rolling back to older code that lacks the durable ownership pin +is unsafe after a legacy owner's email changes. + +## Login and session boundaries + +The browser calls `/auth/misty/start` with `{email}`, then +`/auth/misty/confirm` with `{challenge_id,code}`. The API forwards to gateway +`/v1/auth/email/start` and `/v1/auth/email/confirm` with its server credential and +socket-derived client IP. A hashed HttpOnly browser binding, expiry, attempt +budget and atomic in-flight reservation prevent unbound/replayed confirmation. +Neither the code nor a session token is returned to JavaScript or logged. +The API stores challenge metadata, not code values or code hashes. + +The gateway's generic 202 does not disclose whether an account exists or an +email was sent. Earlier codes remain valid until their own expiry, attempt exhaustion or use; +the UI retains recent request IDs so users may explicitly choose that request. +It never silently tries a code against multiple challenges. + +First successful login uses an explicit existing UUID mapping or creates a new +ordinary member when no local account has that email. An email matching an +unlinked existing account is denied until an operator supplies its UUID mapping. Subsequent logins use the stored Misty person ID, +allowing email changes without transferring local ownership. Conflicting emails, +duplicate accounts, bans and already-linked identities fail closed. + +Authenticated requests check `/v1/auth/people/{id}/status`. Results are cached +per person for 30 seconds; errors deny access and are cached for 5 seconds. +A retirement therefore blocks subsequent requests after at most 30 seconds of +cached eligibility. Ongoing jobs are governed by the existing local ban/team +revocation policy; Misty retirement does not automatically cancel running jobs. +Authentication is checked when a request starts, not continuously during a +long-running response. + +Compute adds durable per-IP start (5/minute) and confirm (30/minute) admission, +three attempts per challenge, ten-minute expiry and strict 4 KiB inputs. Misty +also enforces application, client-IP and mailbox quotas. Database failures deny +admission before sending; upstream failures do not grant sessions. Definitive +upstream 429 responses preserve a wait response and do not use a local guess. +The gateway defaults to 600 status checks/minute per application and a 1200/minute +pre-authentication edge-IP guard. `LOGIN_STATUS_PER_MINUTE` and +`EDGE_REQUESTS_PER_MINUTE` are configured on Misty together, with at least 120 +requests/minute of edge headroom. This budgets roughly 300 continuously active +identities; leave margin for issuer checks and other traffic sharing the backend +IP, and verify directory latency under expected load. The 30-second eligibility +cache is unchanged. Issuer checks share the status quota. Every successful gateway +response must match the expected issuer, including after startup. HTTP clients +have bounded timeouts and do not follow redirects. + +The gateway allows five delivered challenges per mailbox issuance window with +three guesses each, instead of a shared mailbox guess lockout. Other challenges +cannot exhaust or invalidate an already-delivered code. Mail delivery quotas can +still be exhausted by targeted abuse; edge abuse protection and monitoring remain +necessary. The gateway API documents its fixed windows and ten-minute TTL. + +Logout and admin revocation return 503 on database deletion failure, retain the +credential for retry, and never claim that a still-valid session was revoked. + +## CLI + +Use the HTTPS portal URL (the same origin configured on the server): + +```sh +mist --api-url https://compute.example.com/api auth login --misty --email member@example.com +``` + +The CLI requests a code, reads it without echoing, confirms with the bound +challenge cookie and saves only session cookies in its existing mode-0600 config. +`--code-stdin` reads a code from standard input; do not put codes in arguments. +The old password flow remains available only in local mode. All credential-bearing +CLI requests require HTTPS (except loopback), reject redirects, and send a saved +session only to its exact saved scheme/host/port. An `--api-url` override cannot +reuse another environment's session. Use separate config files: + +```sh +mist --config ~/.config/mist/staging.json --api-url https://staging-compute.example.com/api auth login --misty --email member@example.com +mist --config ~/.config/mist/production.json --api-url https://compute.example.com/api auth login --misty --email member@example.com +``` + +## Validation and changed files + +Run API/CLI tests on Linux (the existing storage code has Linux-specific statfs +types), including `go test -race ./...` in `src` and `cli`. Run frontend Vitest, +TypeScript, ESLint and the production build. All automated providers are local +fakes; tests must never send real mail. + +See [the verification report](misty-login-verification.md) for the completed local +checks and the remaining live deployment checks. + +- `src/misty_auth.go`: scoped gateway client, identity/challenge storage, login + handlers, eligibility cache, configuration and durable legacy-owner migration. +- `src/auth.go`, `src/app.go`, `src/member_auth.go`: session provenance, opt-in + mode enforcement, SQLite connection consistency and stable ownership routing. +- `src/misty_auth_test.go`, `src/auth_security_test.go`: failure, concurrency, + explicit identity migration, issuer isolation and session revocation regressions. +- `cli/cmd/auth_misty.go`, `auth_login.go`, `api_client.go`, `auth_misty_test.go`, + `session_security_test.go`: bound code login, origin-bound session transport, + redirect rejection and protected persistence, with regression coverage. +- `web-interface/src/components/MistyLogin.tsx` and its test: email/code flow, + retry/error states and request selection after resend. +- `web-interface/src/auth.tsx`, `api.ts`, `components/AccountPage.tsx`: mode-aware + account gate, API response type and password-control visibility. +- `deploy/private/misty-login.env.example` and this document: configuration, + rollout/rollback and ownership/security boundaries. +- `docs/agents/misty-login-verification.md`: observed test evidence and limits. + +- `go.work`, `src/go.mod`, `cli/go.mod`, `README.md`: require the patched Go + 1.26.10 toolchain or newer and document the supported build/login configuration. +- `deploy/k3s/Dockerfile.api`, `deploy/private/build-and-deploy.sh`: move the API + and deployment helper images to supported Alpine 3.24.3. +- `web-interface/package.json`, `package-lock.json`: pin patched Zod 4.6.6 and Undici 7.30.1, and + restore missing optional-platform lock entries required by clean npm installs. + + +## Independent audit follow-up + +Misty handles a known verification cooldown by deferring the same challenge once +for up to 60 seconds, with no additional send-budget charge. It rechecks eligibility +and expiry before delivery and never retries an ambiguous transport/provider +failure. Mailbox and application abuse caps remain necessary; an attacker can +still exhaust new-send capacity. This is separate from guessing or invalidating +an already-delivered code. Deployment prerequisites and other operational risks +remain in the rollout section above. diff --git a/go.work b/go.work index adb0998..c2be830 100644 --- a/go.work +++ b/go.work @@ -1,4 +1,4 @@ -go 1.26.0 +go 1.26.10 use ( ./cli diff --git a/src/app.go b/src/app.go index d74cd05..0995b16 100644 --- a/src/app.go +++ b/src/app.go @@ -37,8 +37,15 @@ func NewKubernetesApp(executor *KubernetesExecutor, log *slog.Logger) *App { mux.HandleFunc("GET /storage/folders", a.storageFolders) mux.HandleFunc("GET /storage/files", a.storageFolderFiles) authDisabled := os.Getenv("MIST_AUTH_DISABLED") == "true" + if mode := envOr("MIST_AUTH_MODE", "local"); mode != "local" && mode != "misty" { + panic("MIST_AUTH_MODE must be local or misty") + } + if os.Getenv("MIST_AUTH_MODE") == "misty" && authDisabled { + panic("MIST_AUTH_DISABLED cannot be used with Misty login") + } authDB := os.Getenv("MIST_AUTH_DATABASE") authRequired := os.Getenv("MIST_AUTH_ENABLED") == "true" || + os.Getenv("MIST_AUTH_MODE") == "misty" || os.Getenv("MIST_TEAMS_ENABLED") == "true" || os.Getenv("BETTER_AUTH_SECRET") != "" || authDB != "" || diff --git a/src/auth.go b/src/auth.go index f8d1fe1..fc3bce8 100644 --- a/src/auth.go +++ b/src/auth.go @@ -26,11 +26,13 @@ import ( ) type AuthService struct { - db *sql.DB - log *slog.Logger - origins map[string]bool - legacyEmail string - mu sync.Mutex + misty *mistyClient + db *sql.DB + log *slog.Logger + origins map[string]bool + legacyEmail string + legacyUserID string + mu sync.Mutex } func newAuthService(dbPath string, log *slog.Logger) (*AuthService, error) { @@ -50,6 +52,9 @@ func newAuthService(dbPath string, log *slog.Logger) (*AuthService, error) { if err != nil { return nil, fmt.Errorf("open auth db: %w", err) } + // SQLite pragmas below are connection-local. Reuse that connection so all + // admissions observe busy_timeout and foreign_keys, including concurrent logins. + db.SetMaxOpenConns(1) for _, pragma := range []string{ "PRAGMA journal_mode = WAL;", @@ -152,6 +157,10 @@ func newAuthService(dbPath string, log *slog.Logger) (*AuthService, error) { if err := s.bootstrapAdmin(); err != nil { log.Warn("bootstrap admin failed", "error", err) } + if err := s.initMisty(); err != nil { + db.Close() + return nil, err + } return s, nil } @@ -298,13 +307,29 @@ func (s *AuthService) session(r *http.Request) (*Member, error) { } now := time.Now().UTC().Format(time.RFC3339Nano) var m Member + var personID string err := s.db.QueryRowContext(r.Context(), ` - SELECT u.id, u.name, u.email, u.role + SELECT u.id, u.name, u.email, u.role, COALESCE(s.mistyPersonId, '') FROM session s JOIN user u ON s.userId = u.id WHERE s.token = ? AND s.expiresAt > ? AND COALESCE(u.banned, 0) = 0 - `, token, now).Scan(&m.ID, &m.Name, &m.Email, &m.Role) + `, token, now).Scan(&m.ID, &m.Name, &m.Email, &m.Role, &personID) if err == nil { + if s.misty != nil { + if personID == "" { + return nil, nil + } + active, err := s.misty.active(r.Context(), personID) + if err != nil { + return nil, err + } + if !active { + return nil, nil + } + } else if personID != "" { + // Also reject rows written by an overlapping older Misty process. + return nil, nil + } return &m, nil } else if !errors.Is(err, sql.ErrNoRows) { return nil, err @@ -375,8 +400,21 @@ func setSessionCookies(w http.ResponseWriter, token string, maxAge int, isSecure func (s *AuthService) ServeHTTP(w http.ResponseWriter, r *http.Request) { w.Header().Set("Cache-Control", "no-store") path := r.URL.Path + if s.misty != nil && r.Method == http.MethodPost && + (r.Header.Get("Origin") != s.misty.origin || r.Header.Get("Sec-Fetch-Site") == "cross-site") { + writeAuthError(w, 403, "Untrusted request origin", "FORBIDDEN") + return + } + if s.misty != nil && (path == "/auth/sign-in/email" || path == "/auth/change-password" || path == "/auth/admin/set-user-password" || path == "/auth/admin/create-user") { + writeAuthError(w, 403, "Accounts sign in through Misty", "MISTY_LOGIN_REQUIRED") + return + } switch { + case path == "/auth/misty/start" && r.Method == http.MethodPost: + s.handleMistyStart(w, r) + case path == "/auth/misty/confirm" && r.Method == http.MethodPost: + s.handleMistyConfirm(w, r) case path == "/auth/sign-in/email" && r.Method == http.MethodPost: s.handleSignInEmail(w, r) case path == "/auth/sign-out" && r.Method == http.MethodPost: @@ -487,7 +525,11 @@ func (s *AuthService) handleSignInEmail(w http.ResponseWriter, r *http.Request) func (s *AuthService) handleSignOut(w http.ResponseWriter, r *http.Request) { token := extractSessionToken(r) if token != "" { - _, _ = s.db.ExecContext(r.Context(), "DELETE FROM session WHERE token = ?", token) + if _, err := s.db.ExecContext(r.Context(), "DELETE FROM session WHERE token = ?", token); err != nil { + s.log.Error("session revocation failed") + writeAuthError(w, 503, "Sign-out could not be completed. Please retry.", "AUTH_UNAVAILABLE") + return + } } isSecure := strings.HasPrefix(os.Getenv("MIST_SITE_ORIGIN"), "https:") || r.TLS != nil setSessionCookies(w, "", -1, isSecure) @@ -495,6 +537,19 @@ func (s *AuthService) handleSignOut(w http.ResponseWriter, r *http.Request) { } func (s *AuthService) handleGetSession(w http.ResponseWriter, r *http.Request) { + if s.misty != nil { + member, err := s.session(r) + if err != nil { + writeAuthError(w, 503, "Sign-in is temporarily unavailable", "AUTH_UNAVAILABLE") + return + } + if member == nil { + writeJSON(w, 200, map[string]any{"user": nil, "session": nil}) + return + } + writeJSON(w, 200, map[string]any{"user": member, "session": map[string]string{"userId": member.ID}}) + return + } token := extractSessionToken(r) if token == "" { writeJSON(w, 200, map[string]interface{}{"user": nil, "session": nil}) @@ -774,7 +829,11 @@ func (s *AuthService) handleRevokeUserSessions(w http.ResponseWriter, r *http.Re return } if req.UserID != "" { - _, _ = s.db.ExecContext(r.Context(), "DELETE FROM session WHERE userId = ?", req.UserID) + if _, err := s.db.ExecContext(r.Context(), "DELETE FROM session WHERE userId = ?", req.UserID); err != nil { + s.log.Error("user session revocation failed") + writeAuthError(w, 503, "Sessions could not be revoked. Please retry.", "AUTH_UNAVAILABLE") + return + } } writeJSON(w, 200, map[string]interface{}{"status": true}) } @@ -860,7 +919,11 @@ func (s *AuthService) middleware(next http.Handler) http.Handler { return } if r.URL.Path == "/session" && r.Method == http.MethodGet { - writeJSON(w, 200, map[string]interface{}{"enabled": true, "user": member}) + mode := "local" + if s.misty != nil { + mode = "misty" + } + writeJSON(w, 200, map[string]interface{}{"enabled": true, "user": member, "auth_mode": mode}) return } if member == nil { diff --git a/src/auth_security_test.go b/src/auth_security_test.go new file mode 100644 index 0000000..0f4b821 --- /dev/null +++ b/src/auth_security_test.go @@ -0,0 +1,224 @@ +package main + +import ( + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestRevocationReportsDatabaseFailure(t *testing.T) { + for _, admin := range []bool{false, true} { + t.Run(map[bool]string{false: "logout", true: "administrator"}[admin], func(t *testing.T) { + s, _, _ := newMistyTestAuth(t) + start := startMistyTest(t, s) + login := mistyRequest(s, "/auth/misty/confirm", map[string]string{"challenge_id": testChallengeID, "code": "123456"}, start) + if login.Code != 200 { + t.Fatal(login.Code) + } + request := httptest.NewRequest("GET", "/session", nil) + for _, cookie := range login.Result().Cookies() { + request.AddCookie(cookie) + } + member, err := s.session(request) + if err != nil || member == nil { + t.Fatal("missing session", err) + } + if _, err = s.db.Exec(`CREATE TRIGGER reject_session_delete BEFORE DELETE ON session BEGIN SELECT RAISE(FAIL,'synthetic write failure'); END`); err != nil { + t.Fatal(err) + } + path, payload := "/auth/sign-out", map[string]string{} + if admin { + if _, err = s.db.Exec(`UPDATE user SET role='admin'`); err != nil { + t.Fatal(err) + } + path, payload = "/auth/admin/revoke-user-sessions", map[string]string{"userId": member.ID} + } + response := mistyRequest(s, path, payload, login.Result().Cookies()) + if response.Code != 503 { + t.Fatalf("failed revocation reported HTTP %d", response.Code) + } + if len(response.Result().Cookies()) != 0 { + t.Fatal("failed logout discarded the retry credential") + } + if _, err = s.db.Exec(`DROP TRIGGER reject_session_delete`); err != nil { + t.Fatal(err) + } + if retry := mistyRequest(s, path, payload, login.Result().Cookies()); retry.Code != 200 { + t.Fatal("revocation retry failed", retry.Code) + } + if member, err = s.session(request); err != nil || member != nil { + t.Fatal("revoked session survived", err) + } + }) + } +} + +func TestMistyIssuerMismatchAndDatabasePin(t *testing.T) { + t.Run("wrong environment", func(t *testing.T) { + s, _, _ := newMistyTestAuth(t) + t.Setenv("MISTY_ENVIRONMENT", "production") + if err := s.initMisty(); err == nil { + t.Fatal("staging/local issuer accepted as production") + } + }) + t.Run("changed origin", func(t *testing.T) { + s, _, _ := newMistyTestAuth(t) + called := false + other := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + called = true + writeJSON(w, 200, map[string]string{"issuer": "misty:local"}) + })) + defer other.Close() + t.Setenv("MISTY_GATEWAY_URL", other.URL) + if err := s.initMisty(); err == nil { + t.Fatal("database silently adopted a different gateway") + } + if called { + t.Fatal("gateway credential sent before checking the database origin pin") + } + }) +} + +func TestMistyRejectsMissingOrChangedResponseIssuer(t *testing.T) { + for _, issuer := range []string{"", "misty:production"} { + t.Run(issuer, func(t *testing.T) { + s, _, _ := newMistyTestAuth(t) + cookies := startMistyTest(t, s) + s.misty.http.Transport = mistyRoundTrip(func(*http.Request) (*http.Response, error) { + return &http.Response{StatusCode: 200, Header: http.Header{}, Body: io.NopCloser(strings.NewReader(`{"issuer":"` + issuer + `","person_id":"` + testPersonID + `","email":"member@example.com"}`))}, nil + }) + response := mistyRequest(s, "/auth/misty/confirm", map[string]string{"challenge_id": testChallengeID, "code": "123456"}, cookies) + if response.Code != 503 { + t.Fatal("untrusted issuer created a session", response.Code) + } + }) + } +} + +func TestMistyUnlinkedExistingAccountCannotBeClaimedByEmail(t *testing.T) { + for _, role := range []string{"user", "admin"} { + t.Run(role, func(t *testing.T) { + s, _, _ := newMistyTestAuth(t) + if _, err := s.db.Exec(`INSERT INTO user(id,name,email,role,createdAt,updatedAt) VALUES('existing','Previous owner','member@example.com',?,datetime('now'),datetime('now'))`, role); err != nil { + t.Fatal(err) + } + cookies := startMistyTest(t, s) + response := mistyRequest(s, "/auth/misty/confirm", map[string]string{"challenge_id": testChallengeID, "code": "123456"}, cookies) + if response.Code != 403 { + t.Fatalf("unlinked %s account claimed by email: %d", role, response.Code) + } + }) + } +} + +func TestMistyAccountMappingIsExplicitAndImmutable(t *testing.T) { + s, _, _ := newMistyTestAuth(t) + if _, err := s.db.Exec(`INSERT INTO user(id,name,email,role,createdAt,updatedAt) VALUES('first','First','member@example.com','admin',datetime('now'),datetime('now')),('second','Second','other@example.com','user',datetime('now'),datetime('now'))`); err != nil { + t.Fatal(err) + } + t.Setenv("MISTY_ACCOUNT_LINKS", `{"`+testPersonID+`":"first"}`) + if err := s.initMisty(); err != nil { + t.Fatal(err) + } + if err := s.initMisty(); err != nil { + t.Fatal("idempotent mapping failed", err) + } + t.Setenv("MISTY_ACCOUNT_LINKS", `{"`+testPersonID+`":"second"}`) + if err := s.initMisty(); err == nil { + t.Fatal("existing identity overwritten") + } + var linked string + if err := s.db.QueryRow(`SELECT user_id FROM misty_identity WHERE person_id=?`, testPersonID).Scan(&linked); err != nil || linked != "first" { + t.Fatal("original mapping lost", err) + } + t.Setenv("MISTY_ACCOUNT_LINKS", `{"00000000-0000-4000-8000-000000000099":"missing"}`) + if err := s.initMisty(); err == nil { + t.Fatal("missing target account accepted") + } +} + +func TestMistyRollbackRevokesOnlyMistySessions(t *testing.T) { + s, _, _ := newMistyTestAuth(t) + cookies := startMistyTest(t, s) + login := mistyRequest(s, "/auth/misty/confirm", map[string]string{"challenge_id": testChallengeID, "code": "123456"}, cookies) + if login.Code != 200 { + t.Fatal(login.Code) + } + request := httptest.NewRequest("GET", "/session", nil) + for _, cookie := range login.Result().Cookies() { + request.AddCookie(cookie) + } + if _, err := s.db.Exec(`INSERT INTO session(id,token,userId,expiresAt,createdAt,updatedAt) + SELECT 'local-session','local-token',userId,expiresAt,createdAt,updatedAt FROM session LIMIT 1`); err != nil { + t.Fatal(err) + } + _ = startMistyTest(t, s) // A pending challenge must not survive the mode change. + t.Setenv("MIST_AUTH_MODE", "local") + if err := s.initMisty(); err != nil { + t.Fatal(err) + } + var pending, identities int + if err := s.db.QueryRow(`SELECT count(*) FROM misty_login`).Scan(&pending); err != nil || pending != 0 { + t.Fatal("pending login survived rollback", err) + } + if err := s.db.QueryRow(`SELECT count(*) FROM misty_identity`).Scan(&identities); err != nil || identities != 1 { + t.Fatal("identity mapping lost during rollback", err) + } + member, err := s.session(request) + if err != nil || member != nil { + t.Fatal("Misty session survived rollback", err) + } + local := httptest.NewRequest("GET", "/session", nil) + local.AddCookie(&http.Cookie{Name: "session_token", Value: "local-token"}) + if member, err = s.session(local); err != nil || member == nil { + t.Fatal("local session lost", err) + } + t.Setenv("MIST_AUTH_MODE", "misty") + if err := s.initMisty(); err != nil { + t.Fatal(err) + } + if member, err = s.session(request); err != nil || member != nil { + t.Fatal("revoked Misty session revived", err) + } +} + +func TestMistyLocalModeRejectsAnOverlappingMistyWriter(t *testing.T) { + s, _, _ := newMistyTestAuth(t) + cookies := startMistyTest(t, s) + login := mistyRequest(s, "/auth/misty/confirm", map[string]string{"challenge_id": testChallengeID, "code": "123456"}, cookies) + if login.Code != 200 { + t.Fatal(login.Code) + } + request := httptest.NewRequest("GET", "/session", nil) + for _, cookie := range login.Result().Cookies() { + request.AddCookie(cookie) + } + // A late write from a rolling-out Misty process can arrive after startup cleanup. + s.misty = nil + if member, err := s.session(request); err != nil || member != nil { + t.Fatal("local mode accepted Misty provenance", err) + } +} + +func TestMistyRollbackCleanupFailureIsAtomic(t *testing.T) { + s, _, _ := newMistyTestAuth(t) + cookies := startMistyTest(t, s) + login := mistyRequest(s, "/auth/misty/confirm", map[string]string{"challenge_id": testChallengeID, "code": "123456"}, cookies) + if login.Code != 200 { + t.Fatal(login.Code) + } + _ = startMistyTest(t, s) + if _, err := s.db.Exec(`CREATE TRIGGER reject_login_cleanup BEFORE DELETE ON misty_login BEGIN SELECT RAISE(FAIL,'synthetic cleanup failure'); END`); err != nil { + t.Fatal(err) + } + t.Setenv("MIST_AUTH_MODE", "local") + if err := s.initMisty(); err == nil { + t.Fatal("rollback initialization ignored cleanup failure") + } + var sessions int + if err := s.db.QueryRow(`SELECT count(*) FROM session WHERE mistyPersonId<>''`).Scan(&sessions); err != nil || sessions != 1 { + t.Fatal("cleanup transaction partially committed", err) + } +} diff --git a/src/go.mod b/src/go.mod index 972e1e9..bf6e5c6 100644 --- a/src/go.mod +++ b/src/go.mod @@ -1,6 +1,6 @@ module mist -go 1.26.0 +go 1.26.10 require ( github.com/distribution/reference v0.6.0 diff --git a/src/member_auth.go b/src/member_auth.go index 76d6511..e9d7e12 100644 --- a/src/member_auth.go +++ b/src/member_auth.go @@ -46,7 +46,8 @@ func (a *App) requestExecutor(r *http.Request) *KubernetesExecutor { return a.executor } clone := *a.executor - if a.auth != nil && strings.EqualFold(member.Email, a.auth.legacyEmail) { + if a.auth != nil && ((a.auth.legacyUserID != "" && member.ID == a.auth.legacyUserID) || + (a.auth.legacyUserID == "" && a.auth.misty == nil && strings.EqualFold(member.Email, a.auth.legacyEmail))) { clone.owner = a.executor.owner } else { digest := sha256.Sum256([]byte(member.ID)) diff --git a/src/misty_auth.go b/src/misty_auth.go new file mode 100644 index 0000000..acdc208 --- /dev/null +++ b/src/misty_auth.go @@ -0,0 +1,556 @@ +package main + +import ( + "bytes" + "context" + "crypto/sha256" + "database/sql" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "net" + "net/http" + "net/url" + "os" + "regexp" + "strings" + "sync" + "time" +) + +const mistyCookie = "misty_login" + +var mistyID = regexp.MustCompile(`^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$`) +var mistyCode = regexp.MustCompile(`^[0-9]{6}$`) +var mistyEmail = regexp.MustCompile(`^[^\s@]+@[^\s@]+\.[^\s@]+$`) +var errMistyUnavailable = errors.New("Misty authentication unavailable") + +type mistyStatus struct { + mu sync.Mutex + until time.Time + active bool + failed bool +} + +type mistyClient struct { + baseURL string + issuer string + origin string + apiKey string + http *http.Client + mu sync.Mutex + statuses map[string]*mistyStatus +} + +func newMistyClient() (*mistyClient, error) { + mode := envOr("MIST_AUTH_MODE", "local") + if mode == "local" { + return nil, nil + } + if mode != "misty" { + return nil, errors.New("MIST_AUTH_MODE must be local or misty") + } + base := strings.TrimRight(os.Getenv("MISTY_GATEWAY_URL"), "/") + environment := os.Getenv("MISTY_ENVIRONMENT") + if environment != "local" && environment != "staging" && environment != "production" { + return nil, errors.New("Misty login requires MISTY_ENVIRONMENT=local, staging, or production") + } + key := os.Getenv("MISTY_GATEWAY_API_KEY") + if !safeMistyURL(base) || !safeMistyURL(os.Getenv("MIST_SITE_ORIGIN")) || !strings.HasPrefix(key, "gw_") { + return nil, errors.New("Misty login requires MISTY_GATEWAY_URL and MIST_SITE_ORIGIN over HTTPS (HTTP only on loopback), and a scoped MISTY_GATEWAY_API_KEY") + } + return &mistyClient{baseURL: base, issuer: "misty:" + environment, origin: strings.TrimRight(os.Getenv("MIST_SITE_ORIGIN"), "/"), apiKey: key, statuses: map[string]*mistyStatus{}, http: &http.Client{ + Timeout: 8 * time.Second, + CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }, + }}, nil +} + +func safeMistyURL(raw string) bool { + u, err := url.Parse(raw) + if err != nil || u.Host == "" || u.User != nil || u.RawQuery != "" || u.Fragment != "" || (u.Path != "" && u.Path != "/") { + return false + } + if u.Scheme == "https" { + return true + } + ip := net.ParseIP(u.Hostname()) + return u.Scheme == "http" && (u.Hostname() == "localhost" || (ip != nil && ip.IsLoopback())) +} + +// Never return transport errors or upstream bodies: they can contain credentials. +func (c *mistyClient) request(ctx context.Context, method, path string, body any, out any) (int, error) { + var input io.Reader + if body != nil { + data, err := json.Marshal(body) + if err != nil { + return 0, errMistyUnavailable + } + input = bytes.NewReader(data) + } + req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, input) + if err != nil { + return 0, errMistyUnavailable + } + req.Header.Set("X-API-Key", c.apiKey) + req.Header.Set("Content-Type", "application/json") + response, err := c.http.Do(req) + if err != nil { + return 0, errMistyUnavailable + } + defer response.Body.Close() + if response.StatusCode >= 200 && response.StatusCode < 300 && out != nil { + data, err := io.ReadAll(io.LimitReader(response.Body, 8193)) + var identity struct { + Issuer string `json:"issuer"` + } + if err != nil || len(data) > 8192 || json.Unmarshal(data, &identity) != nil || identity.Issuer != c.issuer || json.Unmarshal(data, out) != nil { + return 0, errMistyUnavailable + } + } + return response.StatusCode, nil +} + +func (c *mistyClient) active(ctx context.Context, personID string) (bool, error) { + if !mistyID.MatchString(personID) { + return false, errMistyUnavailable + } + c.mu.Lock() + entry := c.statuses[personID] + if entry == nil { + if len(c.statuses) >= 4096 { + clear(c.statuses) + } + entry = &mistyStatus{} + c.statuses[personID] = entry + } + c.mu.Unlock() + entry.mu.Lock() + defer entry.mu.Unlock() + if time.Now().Before(entry.until) { + if entry.failed { + return false, errMistyUnavailable + } + return entry.active, nil + } + var result struct { + Active *bool `json:"active"` + } + status, err := c.request(ctx, "GET", "/v1/auth/people/"+personID+"/status", nil, &result) + if err != nil || status != 200 || result.Active == nil { + entry.failed = true + entry.until = time.Now().Add(5 * time.Second) + return false, errMistyUnavailable + } + entry.active = *result.Active + entry.failed = false + entry.until = time.Now().Add(30 * time.Second) + return entry.active, nil +} + +func (s *AuthService) initMisty() error { + client, err := newMistyClient() + if err != nil { + return err + } + s.misty = client + // Always migrate; switching modes must not erase identity/session provenance. + _, err = s.db.Exec(`CREATE TABLE IF NOT EXISTS misty_identity ( + person_id TEXT PRIMARY KEY, user_id TEXT NOT NULL UNIQUE REFERENCES user(id) + ); + CREATE TABLE IF NOT EXISTS misty_login ( + binding_hash TEXT NOT NULL, challenge_id TEXT NOT NULL, email TEXT NOT NULL, + expires_at INTEGER NOT NULL, attempts INTEGER NOT NULL DEFAULT 0, in_flight INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY(binding_hash,challenge_id) + ); + CREATE INDEX IF NOT EXISTS misty_login_expiry ON misty_login(expires_at); + CREATE TABLE IF NOT EXISTS misty_legacy_owner ( + singleton INTEGER PRIMARY KEY CHECK(singleton=1), user_id TEXT NOT NULL REFERENCES user(id) + ); + CREATE TABLE IF NOT EXISTS misty_issuer ( + singleton INTEGER PRIMARY KEY CHECK(singleton=1), issuer TEXT NOT NULL, gateway_url TEXT NOT NULL + );`) + if err != nil { + return fmt.Errorf("initialize Misty login tables: %w", err) + } + var exists int + if err = s.db.QueryRow(`SELECT count(*) FROM pragma_table_info('session') WHERE name='mistyPersonId'`).Scan(&exists); err != nil { + return err + } + if exists == 0 { + _, err = s.db.Exec(`ALTER TABLE session ADD COLUMN mistyPersonId TEXT`) + } + if err != nil { + return err + } + if client != nil { + if err := s.pinMistyIssuerAndAccounts(client); err != nil { + return err + } + } else { + // A rollback must not turn a Misty proof into an unchecked local session. + // Delete it durably so switching back cannot revive the credential. + tx, err := s.db.Begin() + if err != nil { + return err + } + defer tx.Rollback() + if _, err = tx.Exec(`DELETE FROM session WHERE COALESCE(mistyPersonId,'')<>''`); err != nil { + return err + } + if _, err = tx.Exec(`DELETE FROM misty_login`); err != nil { + return err + } + if err = tx.Commit(); err != nil { + return err + } + } + err = s.db.QueryRow(`SELECT user_id FROM misty_legacy_owner WHERE singleton=1`).Scan(&s.legacyUserID) + if err == nil { + return nil + } + if !errors.Is(err, sql.ErrNoRows) { + return err + } + if client == nil || s.legacyEmail == "" { + return nil + } + var count int + if err = s.db.QueryRow(`SELECT count(*),COALESCE(min(id),'') FROM user WHERE lower(email)=lower(?)`, s.legacyEmail).Scan(&count, &s.legacyUserID); err != nil { + return err + } + if count != 1 { + return errors.New("MIST_LEGACY_OWNER_EMAIL must match one existing compute account before enabling Misty login") + } + _, err = s.db.Exec(`INSERT INTO misty_legacy_owner(singleton,user_id) VALUES(1,?)`, s.legacyUserID) + return err +} + +// Pin this database to one gateway and issuer. Account migration is an explicit +// operator mapping of immutable IDs, never proof inferred from a recycled email. +func (s *AuthService) pinMistyIssuerAndAccounts(client *mistyClient) error { + // Check the durable origin before sending any credential to a newly configured + // URL. The transaction below checks again before changing identity state. + var pinnedIssuer, pinnedGateway string + err := s.db.QueryRow(`SELECT issuer,gateway_url FROM misty_issuer WHERE singleton=1`).Scan(&pinnedIssuer, &pinnedGateway) + if err == nil && (pinnedIssuer != client.issuer || pinnedGateway != client.baseURL) { + return errors.New("auth database is pinned to a different Misty issuer or gateway; use a separate database") + } + if err != nil && !errors.Is(err, sql.ErrNoRows) { + return err + } + status, err := client.request(context.Background(), "GET", "/v1/auth/issuer", nil, &struct{}{}) + if err != nil || status != 200 { + return errors.New("cannot verify the configured Misty issuer") + } + tx, err := s.db.Begin() + if err != nil { + return err + } + defer tx.Rollback() + var issuer, gateway string + err = tx.QueryRow(`SELECT issuer,gateway_url FROM misty_issuer WHERE singleton=1`).Scan(&issuer, &gateway) + if errors.Is(err, sql.ErrNoRows) { + var unbound int + if err = tx.QueryRow(`SELECT (SELECT count(*) FROM misty_identity)+(SELECT count(*) FROM session WHERE COALESCE(mistyPersonId,'')<>'')`).Scan(&unbound); err != nil { + return err + } + if unbound != 0 { + return errors.New("existing Misty identities require an explicit issuer migration") + } + if _, err = tx.Exec(`INSERT INTO misty_issuer(singleton,issuer,gateway_url) VALUES(1,?,?)`, client.issuer, client.baseURL); err != nil { + return err + } + } else if err != nil { + return err + } else if issuer != client.issuer || gateway != client.baseURL { + return errors.New("auth database is pinned to a different Misty issuer or gateway; use a separate database") + } + if raw := os.Getenv("MISTY_ACCOUNT_LINKS"); raw != "" { + var links map[string]string + if json.Unmarshal([]byte(raw), &links) != nil || links == nil { + return errors.New("MISTY_ACCOUNT_LINKS must map Misty person UUIDs to existing compute user IDs") + } + for personID, userID := range links { + if !mistyID.MatchString(personID) || userID == "" { + return errors.New("invalid MISTY_ACCOUNT_LINKS mapping") + } + var count int + if err = tx.QueryRow(`SELECT count(*) FROM user WHERE id=?`, userID).Scan(&count); err != nil { + return err + } + if count != 1 { + return errors.New("MISTY_ACCOUNT_LINKS references an unknown compute account") + } + if _, err = tx.Exec(`INSERT INTO misty_identity(person_id,user_id) VALUES(?,?) ON CONFLICT(person_id) DO NOTHING`, personID, userID); err != nil { + return errors.New("conflicting MISTY_ACCOUNT_LINKS mapping") + } + var linked string + if err = tx.QueryRow(`SELECT user_id FROM misty_identity WHERE person_id=?`, personID).Scan(&linked); err != nil { + return err + } + if linked != userID { + return errors.New("MISTY_ACCOUNT_LINKS cannot overwrite an existing identity") + } + } + } + return tx.Commit() +} + +func (s *AuthService) mistyGuard(w http.ResponseWriter, r *http.Request) bool { + if s.misty == nil { + writeAuthError(w, 404, "Endpoint not found", "NOT_FOUND") + return false + } + if r.Header.Get("Origin") != s.misty.origin || r.Header.Get("Sec-Fetch-Site") == "cross-site" { + writeAuthError(w, 403, "Untrusted request origin", "FORBIDDEN") + return false + } + return true +} + +func decodeMisty(w http.ResponseWriter, r *http.Request, out any) bool { + decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4096)) + decoder.DisallowUnknownFields() + if decoder.Decode(out) != nil || decoder.Decode(&struct{}{}) != io.EOF { + writeAuthError(w, 400, "Invalid login request", "BAD_REQUEST") + return false + } + return true +} + +func browserBinding(r *http.Request) (string, string) { + cookie, err := r.Cookie(mistyCookie) + if err != nil || len(cookie.Value) != 64 { + return "", "" + } + if _, err = hex.DecodeString(cookie.Value); err != nil { + return "", "" + } + return cookie.Value, bindingHash(cookie.Value) +} + +func bindingHash(token string) string { + h := sha256.Sum256([]byte(token)) + return hex.EncodeToString(h[:]) +} + +// Admission fails closed if durable rate-limit state cannot be read or written. +func (s *AuthService) admitMisty(w http.ResponseWriter, r *http.Request, key string, maximum int) bool { + var count int + now := time.Now().Unix() + err := s.db.QueryRowContext(r.Context(), `INSERT INTO rate_limit(key,count,lastRequest) VALUES(?,1,?) + ON CONFLICT(key) DO UPDATE SET + count=CASE WHEN rate_limit.lastRequest<=? THEN 1 ELSE rate_limit.count+1 END, + lastRequest=CASE WHEN rate_limit.lastRequest<=? THEN excluded.lastRequest ELSE rate_limit.lastRequest END + WHERE rate_limit.lastRequest<=? OR rate_limit.count 254 || !mistyEmail.MatchString(email) { + writeAuthError(w, 400, "Enter a valid email address", "BAD_REQUEST") + return + } + ip := clientAddress(r) + if net.ParseIP(ip) == nil { + writeAuthError(w, 400, "Invalid client address", "BAD_REQUEST") + return + } + if !s.admitMisty(w, r, "misty-start:"+ip, 5) { + return + } + var result struct { + ChallengeID string `json:"challenge_id"` + ExpiresIn int `json:"expires_in"` + } + status, err := s.misty.request(r.Context(), "POST", "/v1/auth/email/start", map[string]string{"email": email, "client_ip": ip}, &result) + if status == 429 { + w.Header().Set("Retry-After", "60") + writeAuthError(w, 429, "Please wait before requesting another code", "TOO_MANY_REQUESTS") + return + } + if err != nil || status != 202 || !mistyID.MatchString(result.ChallengeID) || result.ExpiresIn <= 0 || result.ExpiresIn > 600 { + writeAuthError(w, 503, "Sign-in is temporarily unavailable", "AUTH_UNAVAILABLE") + return + } + token, hash := browserBinding(r) + if token == "" { + token = randomToken(32) + hash = bindingHash(token) + } + now := time.Now().Unix() + if _, err = s.db.ExecContext(r.Context(), `DELETE FROM misty_login WHERE expires_at<=?`, now); err != nil { + writeAuthError(w, 503, "Sign-in is temporarily unavailable", "AUTH_UNAVAILABLE") + return + } + _, err = s.db.ExecContext(r.Context(), `INSERT INTO misty_login(binding_hash,challenge_id,email,expires_at) VALUES(?,?,?,?)`, hash, result.ChallengeID, email, now+int64(result.ExpiresIn)) + if err != nil { + writeAuthError(w, 503, "Sign-in is temporarily unavailable", "AUTH_UNAVAILABLE") + return + } + secure := strings.HasPrefix(os.Getenv("MIST_SITE_ORIGIN"), "https:") || r.TLS != nil + http.SetCookie(w, &http.Cookie{Name: mistyCookie, Value: token, Path: "/auth", HttpOnly: true, Secure: secure, SameSite: http.SameSiteStrictMode, MaxAge: 600}) + writeJSON(w, 202, map[string]any{"challenge_id": result.ChallengeID, "expires_in": result.ExpiresIn, "message": "If an active Misty account matches, a code will be sent."}) +} + +func (s *AuthService) handleMistyConfirm(w http.ResponseWriter, r *http.Request) { + if !s.mistyGuard(w, r) { + return + } + var payload struct { + ChallengeID string `json:"challenge_id"` + Code string `json:"code"` + } + if !decodeMisty(w, r, &payload) { + return + } + _, hash := browserBinding(r) + if hash == "" || !mistyID.MatchString(payload.ChallengeID) || !mistyCode.MatchString(payload.Code) { + writeAuthError(w, 400, "Invalid or expired login. Request a new code.", "INVALID_LOGIN") + return + } + ip := clientAddress(r) + if net.ParseIP(ip) == nil { + writeAuthError(w, 400, "Invalid client address", "BAD_REQUEST") + return + } + if !s.admitMisty(w, r, "misty-confirm:"+ip, 30) { + return + } + var email string + err := s.db.QueryRowContext(r.Context(), `UPDATE misty_login SET in_flight=1,attempts=attempts+1 + WHERE binding_hash=? AND challenge_id=? AND expires_at>? AND in_flight=0 AND attempts<3 RETURNING email`, hash, payload.ChallengeID, time.Now().Unix()).Scan(&email) + if errors.Is(err, sql.ErrNoRows) { + writeAuthError(w, 400, "Invalid or expired login. Request a new code.", "INVALID_LOGIN") + return + } + if err != nil { + writeAuthError(w, 503, "Sign-in is temporarily unavailable", "AUTH_UNAVAILABLE") + return + } + defer s.db.Exec(`UPDATE misty_login SET in_flight=0 WHERE binding_hash=? AND challenge_id=?`, hash, payload.ChallengeID) + var proof struct { + PersonID string `json:"person_id"` + Email string `json:"email"` + } + status, err := s.misty.request(r.Context(), "POST", "/v1/auth/email/confirm", map[string]string{"challenge_id": payload.ChallengeID, "code": payload.Code, "client_ip": ip}, &proof) + if err != nil || (status != 200 && status != 400 && status != 429) { + writeAuthError(w, 503, "Sign-in is temporarily unavailable", "AUTH_UNAVAILABLE") + return + } + if status == 429 { + // A definitive quota rejection is not a wrong code. Ambiguous transport + // failures retain their reservation because verification may have happened. + _, _ = s.db.Exec(`UPDATE misty_login SET attempts=attempts-1 WHERE binding_hash=? AND challenge_id=?`, hash, payload.ChallengeID) + w.Header().Set("Retry-After", "60") + writeAuthError(w, 429, "Too many attempts. Please wait.", "TOO_MANY_REQUESTS") + return + } + if status != 200 { + writeAuthError(w, 400, "Invalid or expired code. Check the code or request a new one.", "INVALID_LOGIN") + return + } + if !mistyID.MatchString(proof.PersonID) || proof.Email != email { + writeAuthError(w, 503, "Sign-in is temporarily unavailable", "AUTH_UNAVAILABLE") + return + } + user, token, err := s.createMistySession(r, proof.PersonID, email, hash, payload.ChallengeID) + if err != nil { + writeAuthError(w, 403, "This compute account is unavailable. Contact an administrator.", "ACCOUNT_UNAVAILABLE") + return + } + secure := strings.HasPrefix(os.Getenv("MIST_SITE_ORIGIN"), "https:") || r.TLS != nil + setSessionCookies(w, token, 604800, secure) + writeJSON(w, 200, map[string]any{"user": user}) +} + +func (s *AuthService) createMistySession(r *http.Request, personID, email, binding, challenge string) (*Member, string, error) { + s.mu.Lock() + defer s.mu.Unlock() + tx, err := s.db.BeginTx(r.Context(), nil) + if err != nil { + return nil, "", err + } + defer tx.Rollback() + var userID string + err = tx.QueryRow(`SELECT user_id FROM misty_identity WHERE person_id=?`, personID).Scan(&userID) + if errors.Is(err, sql.ErrNoRows) { + var count int + if err = tx.QueryRow(`SELECT count(*),COALESCE(min(id),'') FROM user WHERE lower(email)=?`, email).Scan(&count, &userID); err != nil { + return nil, "", err + } + if count != 0 { + return nil, "", errors.New("existing account requires an explicit Misty identity mapping") + } + if count == 0 { + userID = "user-" + randomToken(12) + _, err = tx.Exec(`INSERT INTO user(id,name,email,role,emailVerified,createdAt,updatedAt) VALUES(?,?,?,'user',1,?,?)`, userID, email, email, time.Now().UTC().Format(time.RFC3339Nano), time.Now().UTC().Format(time.RFC3339Nano)) + if err != nil { + return nil, "", err + } + } + if _, err = tx.Exec(`INSERT INTO misty_identity(person_id,user_id) VALUES(?,?)`, personID, userID); err != nil { + return nil, "", err + } + } else if err != nil { + return nil, "", err + } + var user Member + if err = tx.QueryRow(`SELECT id,name,email,role FROM user WHERE id=? AND COALESCE(banned,0)=0`, userID).Scan(&user.ID, &user.Name, &user.Email, &user.Role); err != nil { + return nil, "", err + } + // The stable directory identity owns the existing compute ID, even after an + // email change. Conflicting local emails fail closed; roles never come from Misty. + var conflicts int + if err = tx.QueryRow(`SELECT count(*) FROM user WHERE lower(email)=? AND id<>?`, email, userID).Scan(&conflicts); err != nil { + return nil, "", err + } + if conflicts != 0 { + return nil, "", errors.New("conflicting existing account") + } + if _, err = tx.Exec(`UPDATE user SET email=?,emailVerified=1,updatedAt=? WHERE id=?`, email, time.Now().UTC().Format(time.RFC3339Nano), userID); err != nil { + return nil, "", err + } + user.Email = email + token := randomToken(24) + now := time.Now().UTC() + stamp := now.Format(time.RFC3339Nano) + if _, err = tx.Exec(`INSERT INTO session(id,token,userId,expiresAt,createdAt,updatedAt,ipAddress,userAgent,mistyPersonId) VALUES(?,?,?,?,?,?,?,?,?)`, "sess-"+randomToken(12), token, userID, now.Add(7*24*time.Hour).Format(time.RFC3339Nano), stamp, stamp, clientAddress(r), r.UserAgent(), personID); err != nil { + return nil, "", err + } + if old := extractSessionToken(r); old != "" { + if _, err = tx.Exec(`DELETE FROM session WHERE token=?`, old); err != nil { + return nil, "", err + } + } + if _, err = tx.Exec(`DELETE FROM misty_login WHERE binding_hash=? AND challenge_id=?`, binding, challenge); err != nil { + return nil, "", err + } + if err = tx.Commit(); err != nil { + return nil, "", err + } + return &user, token, nil +} diff --git a/src/misty_auth_test.go b/src/misty_auth_test.go new file mode 100644 index 0000000..4800fb2 --- /dev/null +++ b/src/misty_auth_test.go @@ -0,0 +1,347 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync" + "sync/atomic" + "testing" + "time" +) + +const testPersonID = "00000000-0000-4000-8000-000000000001" +const testChallengeID = "00000000-0000-4000-8000-000000000002" + +func newMistyTestAuth(t *testing.T) (*AuthService, *atomic.Bool, *atomic.Int32) { + t.Helper() + active, calls := &atomic.Bool{}, &atomic.Int32{} + active.Store(true) + gateway := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("X-API-Key") != "gw_test_private" { + t.Error("missing gateway credential") + } + switch r.URL.Path { + case "/v1/auth/issuer": + writeJSON(w, 200, map[string]string{"issuer": "misty:local"}) + case "/v1/auth/email/start": + var body map[string]string + json.NewDecoder(r.Body).Decode(&body) + if body["client_ip"] != "192.0.2.1" { + t.Error("client IP was not derived from socket") + } + writeJSON(w, 202, map[string]any{"challenge_id": testChallengeID, "expires_in": 600, "issuer": "misty:local"}) + case "/v1/auth/email/confirm": + calls.Add(1) + var body map[string]string + json.NewDecoder(r.Body).Decode(&body) + if body["code"] != "123456" || !active.Load() { + writeJSON(w, 400, map[string]string{"detail": "invalid"}) + return + } + writeJSON(w, 200, map[string]string{"person_id": testPersonID, "email": "member@example.com", "issuer": "misty:local"}) + case "/v1/auth/people/" + testPersonID + "/status": + writeJSON(w, 200, map[string]any{"active": active.Load(), "issuer": "misty:local"}) + default: + t.Error("unexpected gateway path", r.URL.Path) + http.NotFound(w, r) + } + })) + t.Cleanup(gateway.Close) + t.Setenv("MIST_AUTH_MODE", "misty") + t.Setenv("MISTY_ENVIRONMENT", "local") + t.Setenv("MISTY_ACCOUNT_LINKS", "") + t.Setenv("MISTY_GATEWAY_URL", gateway.URL) + t.Setenv("MISTY_GATEWAY_API_KEY", "gw_test_private") + t.Setenv("MIST_SITE_ORIGIN", "https://compute.example.com") + s := newTestAuthService(t) + return s, active, calls +} + +func mistyRequest(s *AuthService, path string, body any, cookies []*http.Cookie) *httptest.ResponseRecorder { + data, _ := json.Marshal(body) + r := httptest.NewRequest(http.MethodPost, path, bytes.NewReader(data)) + r.RemoteAddr = "192.0.2.1:1234" + r.Header.Set("Origin", "https://compute.example.com") + r.Header.Set("Content-Type", "application/json") + for _, c := range cookies { + r.AddCookie(c) + } + w := httptest.NewRecorder() + s.ServeHTTP(w, r) + return w +} + +func TestMistyLoginPreservesUserIDAndRole(t *testing.T) { + s, _, _ := newMistyTestAuth(t) + _, err := s.db.Exec(`INSERT INTO user(id,name,email,role,createdAt,updatedAt) VALUES('existing-user','Member','MEMBER@example.com','admin',datetime('now'),datetime('now'))`) + if err != nil { + t.Fatal(err) + } + t.Setenv("MISTY_ACCOUNT_LINKS", `{"`+testPersonID+`":"existing-user"}`) + if err := s.initMisty(); err != nil { + t.Fatal(err) + } + start := mistyRequest(s, "/auth/misty/start", map[string]string{"email": "member@example.com"}, nil) + if start.Code != 202 { + t.Fatalf("start: %d %s", start.Code, start.Body.String()) + } + confirm := mistyRequest(s, "/auth/misty/confirm", map[string]string{"code": "123456", "challenge_id": testChallengeID}, start.Result().Cookies()) + if confirm.Code != 200 { + t.Fatalf("confirm: %d %s", confirm.Code, confirm.Body.String()) + } + var response struct { + User Member `json:"user"` + } + json.Unmarshal(confirm.Body.Bytes(), &response) + if response.User.ID != "existing-user" || response.User.Role != "admin" { + t.Fatal("lost account identity", response.User) + } + for _, cookie := range confirm.Result().Cookies() { + if cookie.MaxAge > 0 && (!cookie.Secure || !cookie.HttpOnly) { + t.Error("insecure session cookie") + } + } + if strings.Contains(confirm.Body.String(), `"token"`) { + t.Error("session token exposed to JavaScript") + } + replay := mistyRequest(s, "/auth/misty/confirm", map[string]string{"code": "123456", "challenge_id": testChallengeID}, start.Result().Cookies()) + if replay.Code != 400 { + t.Fatalf("replay: %d", replay.Code) + } +} + +func TestMistyConfirmRequiresBrowserBinding(t *testing.T) { + s, _, calls := newMistyTestAuth(t) + result := mistyRequest(s, "/auth/misty/confirm", map[string]string{"code": "123456", "challenge_id": testChallengeID}, nil) + if result.Code != 400 || calls.Load() != 0 { + t.Fatal("unbound confirm reached gateway", result.Code, calls.Load()) + } +} + +func TestMistyLoginDisablesPasswordAndRequiresOrigin(t *testing.T) { + s, _, _ := newMistyTestAuth(t) + if got := authRequest(s, "POST", "/auth/sign-in/email", map[string]string{"email": "x", "password": "x"}, ""); got.Code != 403 { + t.Fatalf("password bypass status: %d", got.Code) + } + if got := authRequest(s, "POST", "/auth/misty/start", map[string]string{"email": "member@example.com"}, ""); got.Code != 403 { + t.Fatalf("missing Origin: %d", got.Code) + } +} + +func startMistyTest(t *testing.T, s *AuthService) []*http.Cookie { + t.Helper() + r := mistyRequest(s, "/auth/misty/start", map[string]string{"email": "member@example.com"}, nil) + if r.Code != 202 { + t.Fatalf("start: %d %s", r.Code, r.Body.String()) + } + return r.Result().Cookies() +} + +func TestMistyAttemptBudgetAndConcurrentRedemption(t *testing.T) { + t.Run("attempt budget", func(t *testing.T) { + s, _, calls := newMistyTestAuth(t) + cookies := startMistyTest(t, s) + for i := 0; i < 3; i++ { + if r := mistyRequest(s, "/auth/misty/confirm", map[string]string{"challenge_id": testChallengeID, "code": "000000"}, cookies); r.Code != 400 { + t.Fatal(r.Code) + } + } + if r := mistyRequest(s, "/auth/misty/confirm", map[string]string{"challenge_id": testChallengeID, "code": "123456"}, cookies); r.Code != 400 || calls.Load() != 3 { + t.Fatal("budget bypass", r.Code, calls.Load()) + } + }) + t.Run("concurrent redemption", func(t *testing.T) { + s, _, calls := newMistyTestAuth(t) + cookies := startMistyTest(t, s) + var successes atomic.Int32 + var wg sync.WaitGroup + for i := 0; i < 8; i++ { + wg.Go(func() { + r := mistyRequest(s, "/auth/misty/confirm", map[string]string{"challenge_id": testChallengeID, "code": "123456"}, cookies) + if r.Code == 200 { + successes.Add(1) + } else if r.Code != 400 { + t.Errorf("unexpected status %d", r.Code) + } + }) + } + wg.Wait() + if successes.Load() != 1 || calls.Load() != 1 { + t.Fatal("multiple redemption", successes.Load(), calls.Load()) + } + }) +} + +func TestMistyLinkingFailsClosed(t *testing.T) { + for _, scenario := range []string{"banned", "ambiguous", "different identity"} { + t.Run(scenario, func(t *testing.T) { + s, _, _ := newMistyTestAuth(t) + _, err := s.db.Exec(`INSERT INTO user(id,name,email,role,createdAt,updatedAt) VALUES('existing','Member','member@example.com','admin',datetime('now'),datetime('now'))`) + if err != nil { + t.Fatal(err) + } + switch scenario { + case "banned": + _, err = s.db.Exec(`UPDATE user SET banned=1`) + case "ambiguous": + _, err = s.db.Exec(`INSERT INTO user(id,name,email,role,createdAt,updatedAt) VALUES('other','Member','MEMBER@example.com','user',datetime('now'),datetime('now'))`) + case "different identity": + _, err = s.db.Exec(`INSERT INTO misty_identity VALUES('00000000-0000-4000-8000-000000000099','existing')`) + } + if err != nil { + t.Fatal(err) + } + cookies := startMistyTest(t, s) + r := mistyRequest(s, "/auth/misty/confirm", map[string]string{"challenge_id": testChallengeID, "code": "123456"}, cookies) + if r.Code != 403 || len(r.Result().Cookies()) != 0 { + t.Fatal("linked unavailable account", r.Code) + } + var count int + if err = s.db.QueryRow(`SELECT count(*) FROM session`).Scan(&count); err != nil || count != 0 { + t.Fatal("session created", count, err) + } + }) + } +} + +func TestMistySessionStatusAndProvenance(t *testing.T) { + s, active, _ := newMistyTestAuth(t) + cookies := startMistyTest(t, s) + confirm := mistyRequest(s, "/auth/misty/confirm", map[string]string{"challenge_id": testChallengeID, "code": "123456"}, cookies) + if confirm.Code != 200 { + t.Fatal(confirm.Code) + } + r := httptest.NewRequest("GET", "/session", nil) + for _, c := range confirm.Result().Cookies() { + r.AddCookie(c) + } + member, err := s.session(r) + if err != nil || member == nil || member.Role != "user" { + t.Fatal("new member session", member, err) + } + active.Store(false) + s.misty.statuses[testPersonID].until = time.Time{} + if member, err = s.session(r); err != nil || member != nil { + t.Fatal("inactive session accepted", member, err) + } + s.misty.statuses[testPersonID].until = time.Time{} + s.misty.baseURL = "http://127.0.0.1:1" + if member, err = s.session(r); err == nil || member != nil { + t.Fatal("outage did not fail closed") + } + if _, err = s.db.Exec(`UPDATE session SET mistyPersonId=NULL`); err != nil { + t.Fatal(err) + } + if member, err = s.session(r); err != nil || member != nil { + t.Fatal("legacy session bypass", member, err) + } +} + +func TestMistyValidationExpiryAndDatabaseFailure(t *testing.T) { + s, _, calls := newMistyTestAuth(t) + for _, body := range []map[string]string{{"email": "x"}, {"email": "member@example.com", "client_ip": "192.0.2.99"}} { + if r := mistyRequest(s, "/auth/misty/start", body, nil); r.Code != 400 { + t.Fatal("bad input accepted", r.Code) + } + } + r := httptest.NewRequest("POST", "/auth/misty/start", strings.NewReader(`{"email":"member@example.com"}`)) + r.Header.Set("Origin", "http://localhost:3000") + w := httptest.NewRecorder() + s.ServeHTTP(w, r) + if w.Code != 403 { + t.Fatal("legacy origin accepted", w.Code) + } + cookies := startMistyTest(t, s) + if _, err := s.db.Exec(`UPDATE misty_login SET expires_at=0`); err != nil { + t.Fatal(err) + } + if r := mistyRequest(s, "/auth/misty/confirm", map[string]string{"challenge_id": testChallengeID, "code": "123456"}, cookies); r.Code != 400 || calls.Load() != 0 { + t.Fatal("expired challenge attempted") + } + s.db.Close() + if r := mistyRequest(s, "/auth/misty/start", map[string]string{"email": "member@example.com"}, nil); r.Code != 503 { + t.Fatal("database failure admitted", r.Code) + } +} + +func TestMistyConfiguration(t *testing.T) { + for _, raw := range []string{"http://compute.example.com", "https://user:pass@example.com", "https://example.com/path", "https://example.com?secret=x"} { + if safeMistyURL(raw) { + t.Error("unsafe URL accepted", raw) + } + } + for _, raw := range []string{"https://compute.example.com", "http://127.0.0.1:3000", "http://[::1]:3000"} { + if !safeMistyURL(raw) { + t.Error("safe URL rejected", raw) + } + } + t.Setenv("MIST_AUTH_MODE", "typo") + if _, err := newMistyClient(); err == nil { + t.Fatal("unknown mode accepted") + } +} + +func TestMistyLegacyOwnershipSurvivesEmailChangeAndReassignment(t *testing.T) { + s, _, _ := newMistyTestAuth(t) + _, err := s.db.Exec(`INSERT INTO user(id,name,email,role,createdAt,updatedAt) VALUES('legacy-user','Owner','old@example.com','admin',datetime('now'),datetime('now'))`) + if err != nil { + t.Fatal(err) + } + s.legacyEmail = "old@example.com" + t.Setenv("MISTY_ACCOUNT_LINKS", `{"`+testPersonID+`":"legacy-user"}`) + if err = s.initMisty(); err != nil { + t.Fatal(err) + } + r := httptest.NewRequest("POST", "/auth/misty/confirm", nil) + owner, _, err := s.createMistySession(r, testPersonID, "old@example.com", "unused", "unused") + if err != nil { + t.Fatal(err) + } + owner, _, err = s.createMistySession(r, testPersonID, "new@example.com", "unused", "unused") + if err != nil { + t.Fatal(err) + } + other, _, err := s.createMistySession(r, "00000000-0000-4000-8000-000000000099", "old@example.com", "unused", "unused") + if err != nil { + t.Fatal(err) + } + a := &App{auth: s, executor: &KubernetesExecutor{owner: "legacy-owner"}} + for _, member := range []*Member{owner, other} { + request := r.WithContext(context.WithValue(r.Context(), memberKey{}, member)) + got := a.requestExecutor(request).owner + if (got == "legacy-owner") != (member.ID == "legacy-user") { + t.Fatalf("incorrect legacy ownership for %s", member.ID) + } + } + // Reboots and rollback to local mode retain the recorded owner. + t.Setenv("MIST_AUTH_MODE", "local") + s.legacyUserID = "" + if err = s.initMisty(); err != nil || s.legacyUserID != "legacy-user" { + t.Fatal("lost durable ownership", err) + } +} + +type mistyRoundTrip func(*http.Request) (*http.Response, error) + +func (f mistyRoundTrip) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) } + +func TestMistyQuotaResponseIsRetryableWithoutSpendingGuess(t *testing.T) { + s, _, _ := newMistyTestAuth(t) + cookies := startMistyTest(t, s) + s.misty.http.Transport = mistyRoundTrip(func(*http.Request) (*http.Response, error) { + return &http.Response{StatusCode: 429, Body: io.NopCloser(strings.NewReader(`{}`)), Header: http.Header{}}, nil + }) + r := mistyRequest(s, "/auth/misty/confirm", map[string]string{"challenge_id": testChallengeID, "code": "123456"}, cookies) + if r.Code != 429 || r.Header().Get("Retry-After") == "" { + t.Fatal("quota not preserved", r.Code) + } + var attempts int + if err := s.db.QueryRow(`SELECT attempts FROM misty_login WHERE challenge_id=?`, testChallengeID).Scan(&attempts); err != nil || attempts != 0 { + t.Fatal("quota charged a guess", attempts, err) + } +} diff --git a/web-interface/package-lock.json b/web-interface/package-lock.json index 2176980..6ab665d 100644 --- a/web-interface/package-lock.json +++ b/web-interface/package-lock.json @@ -40,12 +40,12 @@ }, "node_modules/@acemir/cssom": { "version": "0.9.31", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/@asamuzakjp/css-color": { "version": "5.1.11", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@asamuzakjp/generational-cache": "^1.0.1", @@ -60,7 +60,7 @@ }, "node_modules/@asamuzakjp/dom-selector": { "version": "6.8.1", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@asamuzakjp/nwsapi": "^2.3.9", @@ -72,7 +72,7 @@ }, "node_modules/@asamuzakjp/dom-selector/node_modules/lru-cache": { "version": "11.5.3", - "dev": true, + "devOptional": true, "license": "BlueOak-1.0.0", "engines": { "node": "20 || >=22" @@ -80,7 +80,7 @@ }, "node_modules/@asamuzakjp/generational-cache": { "version": "1.0.1", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": "^20.19.0 || ^22.12.0 || >=24.0.0" @@ -88,7 +88,7 @@ }, "node_modules/@asamuzakjp/nwsapi": { "version": "2.3.9", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/@babel/code-frame": { @@ -458,7 +458,7 @@ }, "node_modules/@bramus/specificity": { "version": "2.4.2", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "css-tree": "^3.0.0" @@ -491,7 +491,7 @@ }, "node_modules/@csstools/color-helpers": { "version": "6.1.1", - "dev": true, + "devOptional": true, "funding": [ { "type": "github", @@ -509,7 +509,7 @@ }, "node_modules/@csstools/css-calc": { "version": "3.4.0", - "dev": true, + "devOptional": true, "funding": [ { "type": "github", @@ -531,7 +531,7 @@ }, "node_modules/@csstools/css-color-parser": { "version": "4.2.3", - "dev": true, + "devOptional": true, "funding": [ { "type": "github", @@ -557,7 +557,7 @@ }, "node_modules/@csstools/css-parser-algorithms": { "version": "4.0.0", - "dev": true, + "devOptional": true, "funding": [ { "type": "github", @@ -578,7 +578,7 @@ }, "node_modules/@csstools/css-syntax-patches-for-csstree": { "version": "1.1.14", - "dev": true, + "devOptional": true, "funding": [ { "type": "github", @@ -601,7 +601,7 @@ }, "node_modules/@csstools/css-tokenizer": { "version": "4.0.1", - "dev": true, + "devOptional": true, "funding": [ { "type": "github", @@ -617,351 +617,1521 @@ "node": ">=20.19.0" } }, - "node_modules/@esbuild/linux-x64": { + "node_modules/@emnapi/core": { + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.3.tgz", + "integrity": "sha512-zLpS5asjEb7lq8jYLq37N6XKaE41DIexlY1rF/z4/tIl3wo13Sqm28fRyfIsKZD+NZ8mM5RoKkpW/rBcuoSZSg==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.3", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", + "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.3.tgz", + "integrity": "sha512-ELEBe8PsLvvJ6QMr0zLt8ffvOHW/dc1m3CEzNMg7aJUv3bMaoDtw2TXyDAwkYBuroxxuHEwhRTLJSe5sya547g==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", "cpu": [ - "x64" + "ppc64" ], "license": "MIT", "optional": true, "os": [ - "linux" + "aix" ], "engines": { "node": ">=18" } }, - "node_modules/@eslint-community/eslint-utils": { - "version": "4.10.1", - "dev": true, + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], "license": "MIT", - "dependencies": { - "eslint-visitor-keys": "^3.4.3" - }, + "optional": true, + "os": [ + "android" + ], "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - }, - "peerDependencies": { - "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + "node": ">=18" } }, - "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { - "version": "3.4.3", - "dev": true, - "license": "Apache-2.0", + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" + "node": ">=18" } }, - "node_modules/@eslint-community/regexpp": { - "version": "4.12.2", - "dev": true, + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], "license": "MIT", + "optional": true, + "os": [ + "android" + ], "engines": { - "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + "node": ">=18" } }, - "node_modules/@eslint/config-array": { - "version": "0.23.5", - "dev": true, - "license": "Apache-2.0", - "peer": true, - "dependencies": { - "@eslint/object-schema": "^3.0.5", - "debug": "^4.3.1", - "minimatch": "^10.2.4" - }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" + "node": ">=18" } }, - "node_modules/@eslint/config-helpers": { - "version": "0.7.0", - "dev": true, - "license": "Apache-2.0", - "peer": true, - "dependencies": { - "@eslint/core": "^1.2.1" - }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" + "node": ">=18" } }, - "node_modules/@eslint/core": { - "version": "1.2.1", - "dev": true, - "license": "Apache-2.0", - "peer": true, - "dependencies": { - "@types/json-schema": "^7.0.15" - }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" + "node": ">=18" } }, - "node_modules/@eslint/js": { - "version": "10.0.1", - "dev": true, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - }, - "funding": { - "url": "https://eslint.org/donate" - }, - "peerDependencies": { - "eslint": "^10.0.0" - }, - "peerDependenciesMeta": { - "eslint": { - "optional": true - } + "node": ">=18" } }, - "node_modules/@eslint/object-schema": { - "version": "3.0.5", - "dev": true, - "license": "Apache-2.0", - "peer": true, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" + "node": ">=18" } }, - "node_modules/@eslint/plugin-kit": { - "version": "0.7.3", - "dev": true, - "license": "Apache-2.0", - "peer": true, - "dependencies": { - "@eslint/core": "^1.2.1", - "levn": "^0.4.1" - }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" + "node": ">=18" } }, - "node_modules/@exodus/bytes": { - "version": "1.16.0", - "dev": true, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "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 - } + "node": ">=18" } }, - "node_modules/@humanfs/core": { - "version": "0.19.2", - "dev": true, - "license": "Apache-2.0", - "peer": true, - "dependencies": { - "@humanfs/types": "^0.15.0" - }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=18.18.0" + "node": ">=18" } }, - "node_modules/@humanfs/node": { - "version": "0.16.8", - "dev": true, - "license": "Apache-2.0", - "peer": true, - "dependencies": { - "@humanfs/core": "^0.19.2", - "@humanfs/types": "^0.15.0", - "@humanwhocodes/retry": "^0.4.0" - }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=18.18.0" + "node": ">=18" } }, - "node_modules/@humanfs/types": { - "version": "0.15.0", - "dev": true, - "license": "Apache-2.0", - "peer": true, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=18.18.0" + "node": ">=18" } }, - "node_modules/@humanwhocodes/module-importer": { - "version": "1.0.1", - "dev": true, - "license": "Apache-2.0", - "peer": true, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=12.22" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" + "node": ">=18" } }, - "node_modules/@humanwhocodes/retry": { - "version": "0.4.3", - "dev": true, - "license": "Apache-2.0", - "peer": true, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=18.18" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" + "node": ">=18" } }, - "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.13", + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "cpu": [ + "x64" + ], "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.0", - "@jridgewell/trace-mapping": "^0.3.24" + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" } }, - "node_modules/@jridgewell/remapping": { - "version": "2.3.5", + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], "license": "MIT", - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.24" + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" } }, - "node_modules/@jridgewell/resolve-uri": { - "version": "3.1.2", + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], "engines": { - "node": ">=6.0.0" + "node": ">=18" } }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.6.0", - "license": "MIT" + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } }, - "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.31", + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], "license": "MIT", - "dependencies": { - "@jridgewell/resolve-uri": "^3.1.0", - "@jridgewell/sourcemap-codec": "^1.4.14" + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" } }, - "node_modules/@keyv/bigmap": { - "version": "1.3.1", - "dev": true, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], "license": "MIT", - "peer": true, - "dependencies": { - "hashery": "^1.4.0", - "hookified": "^1.15.0" - }, + "optional": true, + "os": [ + "openharmony" + ], "engines": { - "node": ">= 18" - }, - "peerDependencies": { - "keyv": "^5.6.0" + "node": ">=18" } }, - "node_modules/@keyv/serialize": { - "version": "1.1.1", - "dev": true, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], "license": "MIT", - "peer": true + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } }, - "node_modules/@neodrag/core": { - "version": "3.0.0-next.11", - "license": "MIT" + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } }, - "node_modules/@neodrag/solid": { - "version": "3.0.0-next.11", + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], "license": "MIT", - "peerDependencies": { - "@neodrag/core": "3.0.0-next.11", - "solid-js": "^1.0.0" + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" } }, - "node_modules/@noble/ciphers": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-2.4.0.tgz", - "integrity": "sha512-AnjFn0Jv92laAkvMrghlFZq4qQCIN/4DxFV/eooqtC2YTjB7kBeLMS2T9KJX4Dn+ZVXLOwK0lSgqDtx9gvxtiw==", + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], "license": "MIT", + "optional": true, + "os": [ + "win32" + ], "engines": { - "node": ">= 20.19.0" + "node": ">=18" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.10.1", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" }, "funding": { - "url": "https://paulmillr.com/funding/" + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, - "node_modules/@noble/hashes": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.4.0.tgz", - "integrity": "sha512-X5XaVWZIBCT7HHZGm5I7ZQXDwLG+bGXuSrMQAW+7Zvl87h1kmc1ZB1VSRJcpUfoUrGQp4Fkoxm5kZ+Ms+aW+eA==", - "license": "MIT", + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "dev": true, + "license": "Apache-2.0", "engines": { - "node": ">= 20.19.0" + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" }, "funding": { - "url": "https://paulmillr.com/funding/" + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.23.5", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@eslint/object-schema": "^3.0.5", + "debug": "^4.3.1", + "minimatch": "^10.2.4" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" } }, - "node_modules/@opentelemetry/semantic-conventions": { - "version": "1.43.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.43.0.tgz", - "integrity": "sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg==", - "license": "Apache-2.0", - "engines": { - "node": ">=14" - } + "node_modules/@eslint/config-helpers": { + "version": "0.7.0", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@eslint/core": "^1.2.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/core": { + "version": "1.2.1", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/js": { + "version": "10.0.1", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "eslint": "^10.0.0" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/@eslint/object-schema": { + "version": "3.0.5", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.7.3", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@eslint/core": "^1.2.1", + "levn": "^0.4.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@exodus/bytes": { + "version": "1.16.0", + "devOptional": true, + "license": "MIT", + "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 + } + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@keyv/bigmap": { + "version": "1.3.1", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "hashery": "^1.4.0", + "hookified": "^1.15.0" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "keyv": "^5.6.0" + } + }, + "node_modules/@keyv/serialize": { + "version": "1.1.1", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.6.tgz", + "integrity": "sha512-mRyDmEw9/fCDXCGfL1sbgIw/GUK0PVxRW4YwgPN61F3LQ+fSyoBFarJVzgGyhF6kRzEQZ0FejDqm4WLXSeqQmw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.3" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=23.5.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1 || 2.0.0-beta.2", + "@emnapi/runtime": "^1.7.1 || 2.0.0-beta.2" + } + }, + "node_modules/@neodrag/core": { + "version": "3.0.0-next.11", + "license": "MIT" + }, + "node_modules/@neodrag/solid": { + "version": "3.0.0-next.11", + "license": "MIT", + "peerDependencies": { + "@neodrag/core": "3.0.0-next.11", + "solid-js": "^1.0.0" + } + }, + "node_modules/@noble/ciphers": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-2.4.0.tgz", + "integrity": "sha512-AnjFn0Jv92laAkvMrghlFZq4qQCIN/4DxFV/eooqtC2YTjB7kBeLMS2T9KJX4Dn+ZVXLOwK0lSgqDtx9gvxtiw==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/hashes": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.4.0.tgz", + "integrity": "sha512-X5XaVWZIBCT7HHZGm5I7ZQXDwLG+bGXuSrMQAW+7Zvl87h1kmc1ZB1VSRJcpUfoUrGQp4Fkoxm5kZ+Ms+aW+eA==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@opentelemetry/semantic-conventions": { + "version": "1.43.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.43.0.tgz", + "integrity": "sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/@oxc-parser/binding-android-arm-eabi": { + "version": "0.120.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-android-arm-eabi/-/binding-android-arm-eabi-0.120.0.tgz", + "integrity": "sha512-WU3qtINx802wOl8RxAF1v0VvmC2O4D9M8Sv486nLeQ7iPHVmncYZrtBhB4SYyX+XZxj2PNnCcN+PW21jHgiOxg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-android-arm64": { + "version": "0.120.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-android-arm64/-/binding-android-arm64-0.120.0.tgz", + "integrity": "sha512-SEf80EHdhlbjZEgzeWm0ZA/br4GKMenDW3QB/gtyeTV1gStvvZeFi40ioHDZvds2m4Z9J1bUAUL8yn1/+A6iGg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-darwin-arm64": { + "version": "0.120.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-darwin-arm64/-/binding-darwin-arm64-0.120.0.tgz", + "integrity": "sha512-xVrrbCai8R8CUIBu3CjryutQnEYhZqs1maIqDvtUCFZb8vY33H7uh9mHpL3a0JBIKoBUKjPH8+rzyAeXnS2d6A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-darwin-x64": { + "version": "0.120.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-darwin-x64/-/binding-darwin-x64-0.120.0.tgz", + "integrity": "sha512-xyHBbnJ6mydnQUH7MAcafOkkrNzQC6T+LXgDH/3InEq2BWl/g424IMRiJVSpVqGjB+p2bd0h0WRR8iIwzjU7rw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-freebsd-x64": { + "version": "0.120.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-freebsd-x64/-/binding-freebsd-x64-0.120.0.tgz", + "integrity": "sha512-UMnVRllquXUYTeNfFKmxTTEdZ/ix1nLl0ducDzMSREoWYGVIHnOOxoKMWlCOvRr9Wk/HZqo2rh1jeumbPGPV9A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-arm-gnueabihf": { + "version": "0.120.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-0.120.0.tgz", + "integrity": "sha512-tkvn2CQ7QdcsMnpfiX3fd3wA3EFsWKYlcQzq9cFw/xc89Al7W6Y4O0FgLVkVQpo0Tnq/qtE1XfkJOnRRA9S/NA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-arm-musleabihf": { + "version": "0.120.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-0.120.0.tgz", + "integrity": "sha512-WN5y135Ic42gQDk9grbwY9++fDhqf8knN6fnP+0WALlAUh4odY/BDK1nfTJRSfpJD9P3r1BwU0m3pW2DU89whQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-arm64-gnu": { + "version": "0.120.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-0.120.0.tgz", + "integrity": "sha512-1GgQBCcXvFMw99EPdMy+4NZ3aYyXsxjf9kbUUg8HuAy3ZBXzOry5KfFEzT9nqmgZI1cuetvApkiJBZLAPo8uaw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-arm64-musl": { + "version": "0.120.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm64-musl/-/binding-linux-arm64-musl-0.120.0.tgz", + "integrity": "sha512-gmMQ70gsPdDBgpcErvJEoWNBr7bJooSLlvOBVBSGfOzlP5NvJ3bFvnUeZZ9d+dPrqSngtonf7nyzWUTUj/U+lw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-ppc64-gnu": { + "version": "0.120.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-0.120.0.tgz", + "integrity": "sha512-T/kZuU0ajop0xhzVMwH5r3srC9Nqup5HaIo+3uFjIN5uPxa0LvSxC1ZqP4aQGJVW5G0z8/nCkjIfSMS91P/wzw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-riscv64-gnu": { + "version": "0.120.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-0.120.0.tgz", + "integrity": "sha512-vn21KXLAXzaI3N5CZWlBr1iWeXLl9QFIMor7S1hUjUGTeUuWCoE6JZB040/ZNDwf+JXPX8Ao9KbmJq9FMC2iGw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-riscv64-musl": { + "version": "0.120.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-0.120.0.tgz", + "integrity": "sha512-SUbUxlar007LTGmSLGIC5x/WJvwhdX+PwNzFJ9f/nOzZOrCFbOT4ikt7pJIRg1tXVsEfzk5mWpGO1NFiSs4PIw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-s390x-gnu": { + "version": "0.120.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-0.120.0.tgz", + "integrity": "sha512-hYiPJTxyfJY2+lMBFk3p2bo0R9GN+TtpPFlRqVchL1qvLG+pznstramHNvJlw9AjaoRUHwp9IKR7UZQnRPGjgQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-x64-gnu": { + "version": "0.120.0", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-x64-musl": { + "version": "0.120.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-x64-musl/-/binding-linux-x64-musl-0.120.0.tgz", + "integrity": "sha512-D9QDDZNnH24e7X4ftSa6ar/2hCavETfW3uk0zgcMIrZNy459O5deTbWrjGzZiVrSWigGtlQwzs2McBP0QsfV1w==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-openharmony-arm64": { + "version": "0.120.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-openharmony-arm64/-/binding-openharmony-arm64-0.120.0.tgz", + "integrity": "sha512-TBU8ZwOUWAOUWVfmI16CYWbvh4uQb9zHnGBHsw5Cp2JUVG044OIY1CSHODLifqzQIMTXvDvLzcL89GGdUIqNrA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-wasm32-wasi": { + "version": "0.120.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-wasm32-wasi/-/binding-wasm32-wasi-0.120.0.tgz", + "integrity": "sha512-WG/FOZgDJCpJnuF3ToG/K28rcOmSY7FmFmfBKYb2fmLyhDzPpUldFGV7/Fz4ru0Iz/v4KPmf8xVgO8N3lO4KHA==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@napi-rs/wasm-runtime": "^1.1.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@oxc-parser/binding-win32-arm64-msvc": { + "version": "0.120.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-0.120.0.tgz", + "integrity": "sha512-1T0HKGcsz/BKo77t7+89L8Qvu4f9DoleKWHp3C5sJEcbCjDOLx3m9m722bWZTY+hANlUEs+yjlK+lBFsA+vrVQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-win32-ia32-msvc": { + "version": "0.120.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-0.120.0.tgz", + "integrity": "sha512-L7vfLzbOXsjBXV0rv/6Y3Jd9BRjPeCivINZAqrSyAOZN3moCopDN+Psq9ZrGNZtJzP8946MtlRFZ0Als0wBCOw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-win32-x64-msvc": { + "version": "0.120.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-win32-x64-msvc/-/binding-win32-x64-msvc-0.120.0.tgz", + "integrity": "sha512-ys+upfqNtSu58huAhJMBKl3XCkGzyVFBlMlGPzHeFKgpFF/OdgNs1MMf8oaJIbgMH8ZxgGF7qfue39eJohmKIg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.120.0", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.3", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.63.5.tgz", + "integrity": "sha512-J25QJU+B78T4FhhBsNpLJyVWOi31mwtpcMwywHmOKH65Q9IWGA81gPj+dnwlhU8wktVriYE+tFAaQgrnJRzAZg==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.63.5.tgz", + "integrity": "sha512-LDopB3zuZM5Ux9TT2luNEBJW/tYbGU2g1d+VpKk6I+gSKDb+/7sYE6M225gRQt4RbMX6MSwMsVR/phdjVUgRLg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.63.5.tgz", + "integrity": "sha512-wlJEERGfeuHeBavCL2qVnNacOK43NDoZM4sjkeRPymd04OAE9T1zBqDJgmZ+CIsPTYKwdzpUC8vmOw84dwY4Tg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.63.5.tgz", + "integrity": "sha512-4nJJGg5jbo2wwPP4JP+LfEBA3bvP8rU9CLuhp7jWvq9sxEyhjQFTFdrqi+/dHEin/pd8jpT0vcehIpnZtmEdcQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.63.5.tgz", + "integrity": "sha512-DrZbyCDF1hneuO6jRbvZ2D7+PIBM6yIwYnJpg2vIk58T+wuFpiaGZrfUr59lDWw45bg+IrpTGLPiNi/Fk4w3Cg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.63.5.tgz", + "integrity": "sha512-gqfUVMJMB3mehqywxp6hTBFfgtMQykZY19+cfiaYP0toIJLb/1DZRJHVkQQGP13W4TAwfZDWeg1qBcheTRioXQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.63.5.tgz", + "integrity": "sha512-CFmhpvAwzSaWMlN3VN7UtmoTihlZNzoP0juQib5TQRnYUyDV8dXeWOp29sobWAT6gXl/hQgAClLlEiYozQG3OQ==", + "cpu": [ + "arm" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.63.5.tgz", + "integrity": "sha512-Uc9H8eXCOayV6JLTH5bXKMId6qbhNHa818/BgYjm4jrlq3vZquC9cqyvHBw17xy5Mnj5f+I3gFK5JcEf3hSqrw==", + "cpu": [ + "arm" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.63.5.tgz", + "integrity": "sha512-VcPr/szv/1BFw112Kt//fxulXt/JPqzzidU84iW68L2DdjnOO8QFUv2zTSYBEPHD6movBD4z+bbr5y60GYM7Jw==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.63.5.tgz", + "integrity": "sha512-BnxtJ5/91BrIHYIkGrmjz/lbMhqEHt1dPFqIxIFR+jPn0xVc/oUSCtIT089zfp5ufwGDlYz2UC+Fe1SRBpYFbQ==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.63.5.tgz", + "integrity": "sha512-LrYcHZwF+fAMNKHYTOQ5osWM4AZF7YF6D+XtsjDyEvljtt11twc+zHVXBLNEjxVSUnKYsOhvVz4Z213eW02COQ==", + "cpu": [ + "loong64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.63.5.tgz", + "integrity": "sha512-nj7QKQePAAUpCpJHtg0pR0W/b92A9NO17JS3BAQmHDn/yhmkir2p8llrKY9TOhleKIaSzy1JhxS3T9FVld6coA==", + "cpu": [ + "loong64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.63.5.tgz", + "integrity": "sha512-5ylkX6dWMeBKge9nTU+Rxfb+ZfaCIJ9lRqIFaK0eAMcWp7OJbYnLveLgXmm0VrvuLKb8qIK+mHyH0qu88RM+iA==", + "cpu": [ + "ppc64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.63.5.tgz", + "integrity": "sha512-oHK4ZHYFDKjZviK34I+NwgfbGxgI7ztrNxj2hPTSSNFgeq1a/lEd7dHV2fdGAuTH4Iym3RHJg+vAbWaWG4B7Zg==", + "cpu": [ + "ppc64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.63.5.tgz", + "integrity": "sha512-UcetmHZ6XOXuUByiKZyQmb55ZPr0LABr3Ec/HB9wKZn6CEAFWZkE+hsJErJ9hbPBC7nI0dKuELx7CoV6IM7TMg==", + "cpu": [ + "riscv64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.63.5.tgz", + "integrity": "sha512-C5CmDPQBtvjVo8cgQsBs+w6WB0JLkiixhgi6hVLV11hERWdn/p0XcPU2OUcZzac9BPOFq7SbaHFa8r3SWEysCQ==", + "cpu": [ + "riscv64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.63.5.tgz", + "integrity": "sha512-lHVQHJFKsuuxLMi3MQO9XVL8Tje3JR82CzB+QDKC5NWBcsIWuwsn9uIM5e3lBhI+fF1/s63qnyYqsg65+8rV/w==", + "cpu": [ + "s390x" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.63.5", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] }, - "node_modules/@oxc-parser/binding-linux-x64-gnu": { - "version": "0.120.0", + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.63.5.tgz", + "integrity": "sha512-VDC7rRJlee/scpki96GZ27Omf6yU87s1YXwVTpjE5841faVlDYYT565rgfmoR1U0sqL7z5ivQSDjcsF6VRXyBA==", "cpu": [ "x64" ], - "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.63.5.tgz", + "integrity": "sha512-z86Ok2p4pTdv5xqCKZsTooO7yBEiaJR/HzU3Wx8RmWsPoLppnMKROhJusQob8B3IE1ghC343kUW9rC2r+Wf3ig==", + "cpu": [ + "x64" ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] }, - "node_modules/@oxc-project/types": { - "version": "0.120.0", - "dev": true, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.63.5.tgz", + "integrity": "sha512-IzQmj+xXwQFGhMAMKMQVXkMwMZN3TqkJgAE0nSsqvVwWWciP4AIPMmWRqOQ2GfX7TUDZr+xqGFcBS36CRPGw0g==", + "cpu": [ + "arm64" + ], "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/Boshen" - } + "optional": true, + "os": [ + "openharmony" + ] }, - "node_modules/@rolldown/pluginutils": { - "version": "1.0.0-rc.3", - "dev": true, - "license": "MIT" + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.63.5.tgz", + "integrity": "sha512-F6qpTaPc9bwBH85kjy0/BLmLSW1uv7AoOXCoRIkg2arlgCYlWYcAbiMkvZuAcaWk9TpCRG//okznLAqLGshkMw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] }, - "node_modules/@rollup/rollup-linux-x64-gnu": { + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.63.5.tgz", + "integrity": "sha512-igoDsTFhhwECBeGbUuLeIk7t8Y1apa+cs6mDWpx2EZ0ch7oEQgzHbFUXN9euoHekCAQzXdXApAGkV6jznS7tWw==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.63.5.tgz", + "integrity": "sha512-U3teMeMbXFmaM5D+OTJpsOXd+wV/qftIeYF9kBKL4v73641qyJmoXFtA28DQLsnmlyayEsTe72xpLHrArq6vHw==", "cpu": [ "x64" ], "license": "MIT", "optional": true, "os": [ - "linux" + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.63.5.tgz", + "integrity": "sha512-ypfC34F3RKXvCXBglGqGMsUSMKlgwd1HX9AOAlx9RoZZ6GaI42YHVeKpzg3JG+wpBUJYTG+NNZhqbDWL8tBZkw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" ] }, "node_modules/@solid-primitives/event-listener": { @@ -1064,36 +2234,294 @@ "tailwindcss": "4.3.3" } }, - "node_modules/@tailwindcss/oxide": { + "node_modules/@tailwindcss/oxide": { + "version": "4.3.3", + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-x64": "4.3.3", + "@tailwindcss/oxide-freebsd-x64": "4.3.3", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.3", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.3", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-x64-musl": "4.3.3", + "@tailwindcss/oxide-wasm32-wasi": "4.3.3", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.3", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.3" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.3.tgz", + "integrity": "sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.3.tgz", + "integrity": "sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.3.tgz", + "integrity": "sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.3.tgz", + "integrity": "sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.3.tgz", + "integrity": "sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.3.tgz", + "integrity": "sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.3.tgz", + "integrity": "sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.3.3", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.3.tgz", + "integrity": "sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.3.tgz", + "integrity": "sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.11.1", + "@emnapi/runtime": "^1.11.1", + "@emnapi/wasi-threads": "^1.2.2", + "@napi-rs/wasm-runtime": "^1.1.4", + "@tybys/wasm-util": "^0.10.2", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.11.1", + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.11.1", + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.4", + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": { + "version": "0.10.2", + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": { + "version": "2.8.1", + "inBundle": true, + "license": "0BSD", + "optional": true + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz", + "integrity": "sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==", + "cpu": [ + "arm64" + ], "license": "MIT", + "optional": true, + "os": [ + "win32" + ], "engines": { "node": ">= 20" - }, - "optionalDependencies": { - "@tailwindcss/oxide-android-arm64": "4.3.3", - "@tailwindcss/oxide-darwin-arm64": "4.3.3", - "@tailwindcss/oxide-darwin-x64": "4.3.3", - "@tailwindcss/oxide-freebsd-x64": "4.3.3", - "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.3", - "@tailwindcss/oxide-linux-arm64-gnu": "4.3.3", - "@tailwindcss/oxide-linux-arm64-musl": "4.3.3", - "@tailwindcss/oxide-linux-x64-gnu": "4.3.3", - "@tailwindcss/oxide-linux-x64-musl": "4.3.3", - "@tailwindcss/oxide-wasm32-wasi": "4.3.3", - "@tailwindcss/oxide-win32-arm64-msvc": "4.3.3", - "@tailwindcss/oxide-win32-x64-msvc": "4.3.3" } }, - "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.3.tgz", + "integrity": "sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==", "cpu": [ "x64" ], "license": "MIT", "optional": true, "os": [ - "linux" + "win32" ], "engines": { "node": ">= 20" @@ -1601,6 +3029,17 @@ } } }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.4", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.4.tgz", + "integrity": "sha512-W3c4gRigFS0T/Ma4qIYF3GDAc5AQdHb1yL5znJT1Zv1YaD9Kitx656wBjvr19qbiosmZT8lWDM5BEMynUqX65A==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@types/aria-query": { "version": "5.0.4", "dev": true, @@ -1917,19 +3356,379 @@ "url": "https://opencollective.com/typescript-eslint" } }, - "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { - "version": "5.0.1", + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@unrs/resolver-binding-android-arm-eabi": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.12.2.tgz", + "integrity": "sha512-g5T90pqg1bo/7mytQx6F4iBNC0Wsh9cu+z9veDbFjc7HjpesJFWD7QMS0NGStXM075+7dJPPVvBbpZlnrdpi/w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@unrs/resolver-binding-android-arm64": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm64/-/resolver-binding-android-arm64-1.12.2.tgz", + "integrity": "sha512-YGCRZv/9GLhwmz6mYDeTsm/92BAyR28l6c2ReweVW5pWgfsitWLY8upvfRlGdoyD8HjeTHSYJWyZGD4KJA/nFQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@unrs/resolver-binding-darwin-arm64": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-arm64/-/resolver-binding-darwin-arm64-1.12.2.tgz", + "integrity": "sha512-u9DiNT1auQMO20A9SyTuG3wUgQWB9Z7KjAg0uFuCDR1FsAY8A0CG2S6JpHS1xwm/w1G08bjXZDcyOCjv1WAm2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@unrs/resolver-binding-darwin-x64": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-x64/-/resolver-binding-darwin-x64-1.12.2.tgz", + "integrity": "sha512-f7rPLi/T1HVKZu/u6t87lroib16n8vrSzcyxI7lg4BGO9UF26KhQL44sd9eOUgrTYhvRXtWOIZT5PejdPyJfUA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@unrs/resolver-binding-freebsd-x64": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-freebsd-x64/-/resolver-binding-freebsd-x64-1.12.2.tgz", + "integrity": "sha512-BpcOjWCJub6nRZUS2zA20pmLvjtqAtGejETaIyRLiZiQf++cbrjltLA5NN/xaXfqeOBOSlMFbemIl5/S5tljmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm-gnueabihf": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-gnueabihf/-/resolver-binding-linux-arm-gnueabihf-1.12.2.tgz", + "integrity": "sha512-vZTDvdSISZjJx66OzJqtsOhzifbqRjbmI1Mnu49fQDwog5GtDI4QidRiEAYbZCRj9C8YZEW+3ZjqsyS9GR4k2A==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm-musleabihf": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-musleabihf/-/resolver-binding-linux-arm-musleabihf-1.12.2.tgz", + "integrity": "sha512-BiPI+IrIlwcW4nLLMM21+B1dFPzd55yAVgVGrdgDjNef+ch03GdxrcyaIz8X9SsQirh/kCQ7mviyWlMxdh2D7g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm64-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-gnu/-/resolver-binding-linux-arm64-gnu-1.12.2.tgz", + "integrity": "sha512-zJc0H99FEPoFfSrNpa91HYfxzfAJCr502oxNK1cfdC9hlaFI43RT+JFCann9JUgZmLzzntChHyn13Sgn9ljHNg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm64-musl": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-musl/-/resolver-binding-linux-arm64-musl-1.12.2.tgz", + "integrity": "sha512-KQ3Lki6l+Pz1k/eBipN41ES+YUK30beLGb9YqcB1O542cyLCNE6GaxrfcY3T6EezmGGk84wb5XyO9loTM9tkcA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-loong64-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-loong64-gnu/-/resolver-binding-linux-loong64-gnu-1.12.2.tgz", + "integrity": "sha512-3SJGEh1DborhG6pyxvhPzCT4bbSIVihsvgJc13P1bHG7KLdNDaF9T3gsTwFc7Jw/5Y5/iWOjkEx7Zy0NvCGX3Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-loong64-musl": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-loong64-musl/-/resolver-binding-linux-loong64-musl-1.12.2.tgz", + "integrity": "sha512-jiuG/Obbel7uw1PwHNFfrkiKhLAF6mnyZ6aWlOAVN9WqKm8v0OFGnciJIHu8+CMvXLQ8AD51LPzAoUfT21D5Ew==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-ppc64-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-ppc64-gnu/-/resolver-binding-linux-ppc64-gnu-1.12.2.tgz", + "integrity": "sha512-q7xRvVpmcfeL+LlZg8Pbbo6QaTZwDU5BaGZbwfhkEsXJn3Was8xYfE0RBH266xZt0rM6B7i8xAYIvjthuUIWHg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-riscv64-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-gnu/-/resolver-binding-linux-riscv64-gnu-1.12.2.tgz", + "integrity": "sha512-0CVdx6lcnT3Q9inOH8tsMIOJ6ImndllMjqJHg8RLVdB7Vq4SfkEXl9mCSsVNuNA4MCYycRicCUxPCabVHJRr6A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-riscv64-musl": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-musl/-/resolver-binding-linux-riscv64-musl-1.12.2.tgz", + "integrity": "sha512-iOwlRo9vnp6R6ohHQS11n0NnfdXx/omhkocmIfaPRpQhKZ+3BDMkkdRVh53qjkFkpPddf+FETA28NwGN7l5l+w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-s390x-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-s390x-gnu/-/resolver-binding-linux-s390x-gnu-1.12.2.tgz", + "integrity": "sha512-HYJtLfXq94q8iZNFT1lknx258wlkkWhZeUXJRqzKBBUJ00CvZ+N33zgbCqimLjsyw5Va6uUxhVa12mI+kaveEw==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-x64-gnu": { + "version": "1.12.2", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-x64-musl": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-musl/-/resolver-binding-linux-x64-musl-1.12.2.tgz", + "integrity": "sha512-azrt6+5ydLd8Vt210AAFis/lZevSfPw93EJRIJG+xPu4WCJ8K0kppCTpMyLPcKT7H15M4Jnt2tMp5bOvCkRC6A==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-openharmony-arm64": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-openharmony-arm64/-/resolver-binding-openharmony-arm64-1.12.2.tgz", + "integrity": "sha512-YZ9hP4O0X9PQb8eO980qmLNGH4zT3I9+SZTdt0Pr0YyuGQhYKoOZkV02VzrzyOZJ5xIJ3UFIenKkUkGg8GjgWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@unrs/resolver-binding-wasm32-wasi": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-wasm32-wasi/-/resolver-binding-wasm32-wasi-1.12.2.tgz", + "integrity": "sha512-tYFDIkMxSflfEc/h92ZWNsZlHSwgimbNHSO3PL2JWQHfCuC2q316jMyYU9TIWZsFK2bQwyK5VAdYgn8ygPj69A==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", + "@napi-rs/wasm-runtime": "^1.1.4" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@unrs/resolver-binding-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@unrs/resolver-binding-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@unrs/resolver-binding-wasm32-wasi/node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@unrs/resolver-binding-win32-arm64-msvc": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.12.2.tgz", + "integrity": "sha512-qzNyg3xL0VPQmCaUh+N5jSitce6k+uCBfMDesWRnlULOZaqUkaJ0ybdT+UqlAWJoQjuqfIU/0Ptx9bteN4D82g==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] }, - "node_modules/@unrs/resolver-binding-linux-x64-gnu": { + "node_modules/@unrs/resolver-binding-win32-ia32-msvc": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-ia32-msvc/-/resolver-binding-win32-ia32-msvc-1.12.2.tgz", + "integrity": "sha512-WD9sY00OfpHVGfsnHZoA8jVT+esS/Bg8z8jzxp5BnDCjjwsuKsPQrzswwpFy4J1AUJbXPRfkpcX0mXrzeXW79g==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@unrs/resolver-binding-win32-x64-msvc": { "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.12.2.tgz", + "integrity": "sha512-nAB74NfSNKknqQ1RrYj6uz8FcXEomu/MATJZxh/x+BArzN2U3JbOYC0APYzUIGhVY3m5hRxA8VPNdPBoG8txlA==", "cpu": [ "x64" ], @@ -1937,7 +3736,7 @@ "license": "MIT", "optional": true, "os": [ - "linux" + "win32" ] }, "node_modules/@vitejs/plugin-react": { @@ -2028,7 +3827,7 @@ }, "node_modules/agent-base": { "version": "7.1.4", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">= 14" @@ -2254,7 +4053,7 @@ }, "node_modules/bidi-js": { "version": "1.1.0", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "require-from-string": "^2.0.2" @@ -2406,7 +4205,7 @@ }, "node_modules/css-tree": { "version": "3.2.1", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "mdn-data": "2.27.1", @@ -2429,7 +4228,7 @@ }, "node_modules/cssstyle": { "version": "6.2.0", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@asamuzakjp/css-color": "^5.0.1", @@ -2443,7 +4242,7 @@ }, "node_modules/cssstyle/node_modules/lru-cache": { "version": "11.5.3", - "dev": true, + "devOptional": true, "license": "BlueOak-1.0.0", "engines": { "node": "20 || >=22" @@ -2455,7 +4254,7 @@ }, "node_modules/data-urls": { "version": "7.0.0", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "whatwg-mimetype": "^5.0.0", @@ -2486,7 +4285,7 @@ }, "node_modules/decimal.js": { "version": "10.6.0", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/deep-is": { @@ -2545,7 +4344,7 @@ }, "node_modules/entities": { "version": "8.1.0", - "dev": true, + "devOptional": true, "license": "BSD-2-Clause", "engines": { "node": ">=20.19.0" @@ -3134,7 +4933,7 @@ }, "node_modules/html-encoding-sniffer": { "version": "6.0.0", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@exodus/bytes": "^1.6.0" @@ -3145,7 +4944,7 @@ }, "node_modules/http-proxy-agent": { "version": "7.0.2", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "agent-base": "^7.1.0", @@ -3157,7 +4956,7 @@ }, "node_modules/https-proxy-agent": { "version": "7.0.6", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "agent-base": "^7.1.2", @@ -3205,7 +5004,7 @@ }, "node_modules/is-potential-custom-element-name": { "version": "1.0.1", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/isbot": { @@ -3243,7 +5042,7 @@ }, "node_modules/jsdom": { "version": "28.1.0", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@acemir/cssom": "^0.9.31", @@ -3381,6 +5180,152 @@ "lightningcss-win32-x64-msvc": "1.32.0" } }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, "node_modules/lightningcss-linux-x64-gnu": { "version": "1.32.0", "cpu": [ @@ -3399,6 +5344,69 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, "node_modules/locate-path": { "version": "6.0.0", "dev": true, @@ -3445,7 +5453,7 @@ }, "node_modules/mdn-data": { "version": "2.27.1", - "dev": true, + "devOptional": true, "license": "CC0-1.0" }, "node_modules/minimatch": { @@ -3622,7 +5630,7 @@ }, "node_modules/parse5": { "version": "8.0.1", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "entities": "^8.0.0" @@ -3744,7 +5752,7 @@ }, "node_modules/punycode": { "version": "2.3.1", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=6" @@ -3811,7 +5819,7 @@ }, "node_modules/require-from-string": { "version": "2.0.2", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -3876,7 +5884,7 @@ }, "node_modules/saxes": { "version": "6.0.0", - "dev": true, + "devOptional": true, "license": "ISC", "dependencies": { "xmlchars": "^2.2.0" @@ -3994,7 +6002,7 @@ }, "node_modules/symbol-tree": { "version": "3.2.4", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/tailwindcss": { @@ -4048,7 +6056,7 @@ }, "node_modules/tldts": { "version": "7.4.15", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "tldts-core": "^7.4.15" @@ -4059,12 +6067,12 @@ }, "node_modules/tldts-core": { "version": "7.4.15", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/tough-cookie": { "version": "6.0.2", - "dev": true, + "devOptional": true, "license": "BSD-3-Clause", "dependencies": { "tldts": "^7.0.5" @@ -4075,7 +6083,7 @@ }, "node_modules/tr46": { "version": "6.0.0", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "punycode": "^2.3.1" @@ -4135,6 +6143,14 @@ } } }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, "node_modules/type-check": { "version": "0.4.0", "dev": true, @@ -4182,8 +6198,10 @@ } }, "node_modules/undici": { - "version": "7.29.1", - "dev": true, + "version": "7.30.1", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.30.1.tgz", + "integrity": "sha512-03KrMbjIFDWQR5RNPrSw3bd1r12JpyFQl9/yxjUbJtqkjmllqC5lFjZh6ujeuJCKr8EOQvRW872Oy/az+nLYxg==", + "devOptional": true, "license": "MIT", "engines": { "node": ">=20.18.1" @@ -4557,7 +6575,7 @@ }, "node_modules/w3c-xmlserializer": { "version": "5.0.0", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "xml-name-validator": "^5.0.0" @@ -4568,7 +6586,7 @@ }, "node_modules/webidl-conversions": { "version": "8.0.1", - "dev": true, + "devOptional": true, "license": "BSD-2-Clause", "engines": { "node": ">=20" @@ -4580,7 +6598,7 @@ }, "node_modules/whatwg-mimetype": { "version": "5.0.0", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=20" @@ -4588,7 +6606,7 @@ }, "node_modules/whatwg-url": { "version": "16.0.1", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@exodus/bytes": "^1.11.0", @@ -4657,7 +6675,7 @@ }, "node_modules/xml-name-validator": { "version": "5.0.0", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "engines": { "node": ">=18" @@ -4665,7 +6683,7 @@ }, "node_modules/xmlchars": { "version": "2.2.0", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/yallist": { @@ -4685,7 +6703,9 @@ } }, "node_modules/zod": { - "version": "4.6.5", + "version": "4.6.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.6.tgz", + "integrity": "sha512-4UmzQUF5fxvfcYHlbjBmejU+w+hbDnWFx4aS8kBx7nPBHcO2SqPbpAG8snbdg+Xse4pRefR98hgjFgIMCI/UhA==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" diff --git a/web-interface/package.json b/web-interface/package.json index a2e274d..b058546 100644 --- a/web-interface/package.json +++ b/web-interface/package.json @@ -56,6 +56,8 @@ ] }, "overrides": { - "postcss-selector-parser": "^7.1.6" + "postcss-selector-parser": "^7.1.6", + "zod": "4.6.6", + "undici": "7.30.1" } } diff --git a/web-interface/src/api.ts b/web-interface/src/api.ts index ddfeda9..cc80dca 100644 --- a/web-interface/src/api.ts +++ b/web-interface/src/api.ts @@ -132,7 +132,11 @@ async function request(path: string, options?: RequestInit): Promise { export const jobsAPI = { session: (signal?: AbortSignal) => - request<{ enabled: boolean; user: Member | null }>('/session', { signal }), + request<{ + enabled: boolean + user: Member | null + auth_mode?: 'local' | 'misty' + }>('/session', { signal }), list: (signal?: AbortSignal) => request<{ jobs: Job[]; count: number }>('/jobs', { signal }), hardware: (signal?: AbortSignal) => diff --git a/web-interface/src/auth.tsx b/web-interface/src/auth.tsx index 546b417..470a4e0 100644 --- a/web-interface/src/auth.tsx +++ b/web-interface/src/auth.tsx @@ -5,6 +5,7 @@ import { adminClient } from 'better-auth/client/plugins' import { jobsAPI } from '#/api.ts' import { errorMessage, usePolling } from '#/hooks/usePolling.ts' import { BackendConnectionError } from '#/components/BackendConnectionError.tsx' +import { MistyLogin } from '#/components/MistyLogin.tsx' export const authClient = createAuthClient({ basePath: '/auth', @@ -19,8 +20,9 @@ export type Member = { const AccountContext = createContext<{ user: Member | null enabled: boolean + authMode: 'local' | 'misty' refresh: () => void -}>({ user: null, enabled: false, refresh: () => {} }) +}>({ user: null, enabled: false, authMode: 'local', refresh: () => {} }) export const useAccount = () => useContext(AccountContext) function Login({ refresh }: { refresh: () => void }) { @@ -115,12 +117,17 @@ export function AccountGate({ children }: { children: ReactNode }) { /> ) if (session.data?.enabled && !session.data.user) - return + return session.data.auth_mode === 'misty' ? ( + + ) : ( + + ) return ( diff --git a/web-interface/src/components/AccountPage.tsx b/web-interface/src/components/AccountPage.tsx index 466cdd8..d874d05 100644 --- a/web-interface/src/components/AccountPage.tsx +++ b/web-interface/src/components/AccountPage.tsx @@ -92,6 +92,7 @@ async function listMembers() { return result.data } function Members() { + const misty = useAccount().authMode === 'misty' const members = usePolling(listMembers, 30000) const [name, setName] = useState(''), [email, setEmail] = useState(''), @@ -124,17 +125,23 @@ function Members() {
} action={ - + !misty && ( + + ) } /> {creating && ( @@ -245,14 +252,16 @@ function Members() { {member.role === 'admin' ? 'Admin' : 'Member'}
- + {!misty && ( + + )}
))} @@ -291,14 +300,16 @@ function Members() { )} - + {!misty && ( + + )} {resetUser !== null && ( setResetUser(null)}>