diff --git a/.env.example b/.env.example index 392620a8..0e5442a1 100644 --- a/.env.example +++ b/.env.example @@ -10,5 +10,5 @@ JWT_SECRET=0000000000000000000000000000000000000000000000000000000000000000 SESSION_KEY=sessionkey OSCTRL_USER=admin OSCTRL_PASS=Changeme123! -GOLANG_VERSION=1.26.1 +GOLANG_VERSION=1.26.3 OSCTRL_TLS_LOGGER=db diff --git a/.github/actions/build/binaries/action.yml b/.github/actions/build/binaries/action.yml index aba5695b..ad65a109 100644 --- a/.github/actions/build/binaries/action.yml +++ b/.github/actions/build/binaries/action.yml @@ -19,7 +19,7 @@ inputs: golang_version: required: false description: Define the version of golang to compile with - default: 1.26.1 + default: 1.26.3 runs: using: "composite" diff --git a/.github/actions/test/binaries/action.yml b/.github/actions/test/binaries/action.yml index bded6876..0a0630cf 100644 --- a/.github/actions/test/binaries/action.yml +++ b/.github/actions/test/binaries/action.yml @@ -19,7 +19,7 @@ inputs: golang_version: required: false description: Define the version of golang to compile with - default: 1.26.1 + default: 1.26.3 runs: using: "composite" diff --git a/.github/workflows/build_and_test_main_merge.yml b/.github/workflows/build_and_test_main_merge.yml index cd7eafa8..9f101bb2 100644 --- a/.github/workflows/build_and_test_main_merge.yml +++ b/.github/workflows/build_and_test_main_merge.yml @@ -15,7 +15,7 @@ concurrency: cancel-in-progress: false env: - GOLANG_VERSION: 1.26.1 + GOLANG_VERSION: 1.26.3 jobs: validate: diff --git a/.github/workflows/build_and_test_pr.yml b/.github/workflows/build_and_test_pr.yml index 12a320dc..a3c50c18 100644 --- a/.github/workflows/build_and_test_pr.yml +++ b/.github/workflows/build_and_test_pr.yml @@ -13,7 +13,7 @@ concurrency: cancel-in-progress: true env: - GOLANG_VERSION: 1.26.1 + GOLANG_VERSION: 1.26.3 jobs: validate: diff --git a/.github/workflows/frontend-build.yml b/.github/workflows/frontend-build.yml new file mode 100644 index 00000000..aadad1a9 --- /dev/null +++ b/.github/workflows/frontend-build.yml @@ -0,0 +1,54 @@ +name: frontend-build +on: + push: + branches: [main] + pull_request: + branches: [main] +permissions: + contents: read +jobs: + build: + name: build SPA (lint + tests + bundle) + runs-on: ubuntu-latest + timeout-minutes: 10 + defaults: + run: + working-directory: frontend + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 + with: + node-version: 20 + cache: npm + cache-dependency-path: frontend/package-lock.json + - name: install + run: | + if [ -f package-lock.json ]; then + npm ci --no-audit --no-fund + else + npm install --no-audit --no-fund + fi + - name: typecheck + run: npm run check + - name: forbid dangerouslySetInnerHTML + # Every field originating from an osquery node is untrusted — + # the SPA's default JSX escaping is the only XSS gate today, so + # a future contributor adding `dangerouslySetInnerHTML` would + # silently break that invariant. Fail the build if it appears + # anywhere under src/. To intentionally introduce one, prefer + # a dedicated sanitizer and document the threat model alongside. + run: | + if grep -rn "dangerouslySetInnerHTML" src/; then + echo "::error::dangerouslySetInnerHTML is forbidden — node-originating fields must be JSX-escaped" + exit 1 + fi + - name: tests + run: npm test + - name: build + run: npm run build + - name: upload bundle + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: osctrl-frontend-dist + path: frontend/dist/ + retention-days: 7 diff --git a/.github/workflows/golangci-lint.yml b/.github/workflows/golangci-lint.yml index d2c26104..3670e418 100644 --- a/.github/workflows/golangci-lint.yml +++ b/.github/workflows/golangci-lint.yml @@ -14,7 +14,7 @@ permissions: contents: read env: - GOLANG_VERSION: 1.26.1 + GOLANG_VERSION: 1.26.3 jobs: golangci: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 91b26ec8..3a78d862 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -15,7 +15,7 @@ concurrency: cancel-in-progress: false env: - GOLANG_VERSION: 1.26.1 + GOLANG_VERSION: 1.26.3 jobs: release: diff --git a/.gitignore b/.gitignore index eea930cf..0c32f90c 100644 --- a/.gitignore +++ b/.gitignore @@ -84,3 +84,4 @@ tools/bruno/collection.bru !CONTRIBUTING.md !CHANGELOG.md !SECURITY.md +!frontend/**/*.md diff --git a/Makefile b/Makefile index 735c9ca4..c32d9088 100644 --- a/Makefile +++ b/Makefile @@ -11,6 +11,8 @@ ADMIN_DIR = cmd/admin ADMIN_NAME = osctrl-admin ADMIN_CODE = ${ADMIN_DIR:=/*.go} +FRONTEND_DIR = frontend + API_DIR = cmd/api API_NAME = osctrl-api API_CODE = ${API_DIR:=/*.go} @@ -27,7 +29,7 @@ DIST = dist STATIC_ARGS = -ldflags "-linkmode external -extldflags -static" BUILD_ARGS = -ldflags "-s -w -X main.buildCommit=$(shell git rev-parse HEAD) -X main.buildDate=$(shell date -u +%Y-%m-%dT%H:%M:%SZ)" -.PHONY: build static clean tls admin cli api release release-build release-check release-init clean-dist +.PHONY: build static clean tls admin cli api release release-build release-check release-init clean-dist frontend frontend-install frontend-dev frontend-build frontend-test # Build code according to caller OS and architecture build: @@ -75,6 +77,31 @@ cli: cli-static: go build $(BUILD_ARGS) $(STATIC_ARGS) -o $(OUTPUT)/$(CLI_NAME) -a $(CLI_CODE) +# --------------------------------------------------------------------------- +# React admin frontend (Vite + TypeScript SPA, served by nginx) +# --------------------------------------------------------------------------- + +# Install JS deps (use ci when a lockfile is present, install otherwise). +frontend-install: + cd $(FRONTEND_DIR) && \ + if [ -f package-lock.json ]; then npm ci --no-audit --no-fund; \ + else npm install --no-audit --no-fund; fi + +# Local dev server (Vite on :5173, proxies /api → :8081). +frontend-dev: + cd $(FRONTEND_DIR) && npm run dev + +# Run vitest + tsc. +frontend-test: + cd $(FRONTEND_DIR) && npm test && npm run check + +# Build the production bundle into frontend/dist/. +frontend-build: + cd $(FRONTEND_DIR) && npm run build + +# One-shot: install + build (used by CI / Docker builds). +frontend: frontend-install frontend-build + # Clean the dist directory clean-dist: rm -rf $(DIST) diff --git a/cmd/admin/handlers/json-nodes.go b/cmd/admin/handlers/json-nodes.go index 84de1e33..a5e0ce09 100644 --- a/cmd/admin/handlers/json-nodes.go +++ b/cmd/admin/handlers/json-nodes.go @@ -120,14 +120,14 @@ func (h *HandlersAdmin) JSONEnvironmentPagingHandler(w http.ResponseWriter, r *h log.Err(err).Msg("error counting search nodes") return } - nodesSlice, err = h.Nodes.SearchByEnvPage(env.Name, searchValue, target, hours, start, length, colName, desc) + nodesSlice, err = h.Nodes.SearchByEnvPage(env.Name, searchValue, target, hours, start, length, colName, desc) //nolint:staticcheck // SA1019: intentional legacy admin caller; new SPA uses GetByEnvPaged if err != nil { log.Err(err).Msg("error searching nodes page") return } } else { filteredCount = totalCount - nodesSlice, err = h.Nodes.GetByEnvPage(env.Name, target, hours, start, length, colName, desc) + nodesSlice, err = h.Nodes.GetByEnvPage(env.Name, target, hours, start, length, colName, desc) //nolint:staticcheck // SA1019: intentional legacy admin caller; new SPA uses GetByEnvPaged if err != nil { log.Err(err).Msg("error getting nodes page") return diff --git a/cmd/admin/main.go b/cmd/admin/main.go index 1cb98602..503da82c 100644 --- a/cmd/admin/main.go +++ b/cmd/admin/main.go @@ -242,7 +242,7 @@ func osctrlAdminService() { time.Sleep(time.Duration(flagParams.Redis.ConnRetry) * time.Second) } log.Info().Msg("Initialize users") - adminUsers = users.CreateUserManager(db.Conn, flagParams.JWT) + adminUsers = users.CreateUserManager(db.Conn).WithJWT(flagParams.JWT) log.Info().Msg("Initialize tags") tagsmgr = tags.CreateTagManager(db.Conn) log.Info().Msg("Initialize environments") diff --git a/cmd/api/auth.go b/cmd/api/auth.go index 4bee5551..3c357931 100644 --- a/cmd/api/auth.go +++ b/cmd/api/auth.go @@ -2,11 +2,13 @@ package main import ( "context" + "crypto/subtle" "net/http" "strings" "github.com/jmpsec/osctrl/cmd/api/handlers" "github.com/jmpsec/osctrl/pkg/config" + "github.com/jmpsec/osctrl/pkg/types" "github.com/jmpsec/osctrl/pkg/utils" "github.com/rs/zerolog/log" ) @@ -16,14 +18,79 @@ const ( contextAPI string = "osctrl-api-context" ) -// Helper to extract token from header +// Cookie + header names — kept in sync with cmd/api/handlers/login.go. +const ( + cookieNameToken = "osctrl_token" + cookieNameCSRF = "osctrl_csrf" + headerNameCSRF = "X-CSRF-Token" +) + +// Helper to extract token from the Authorization header first (CLI clients), +// falling back to the SPA's HttpOnly osctrl_token cookie. func extractHeaderToken(r *http.Request) string { - reqToken := r.Header.Get("Authorization") - splitToken := strings.Split(reqToken, "Bearer") - if len(splitToken) != 2 { - return "" + if v := r.Header.Get("Authorization"); v != "" { + splitToken := strings.Split(v, "Bearer") + if len(splitToken) == 2 { + if t := strings.TrimSpace(splitToken[1]); t != "" { + return t + } + } + } + if c, err := r.Cookie(cookieNameToken); err == nil { + return strings.TrimSpace(c.Value) + } + return "" +} + +// mutatingMethods is the set of HTTP verbs that must carry a valid CSRF token. +// GET/HEAD/OPTIONS are read-only and exempt. +var mutatingMethods = map[string]bool{ + http.MethodPost: true, + http.MethodPut: true, + http.MethodPatch: true, + http.MethodDelete: true, +} + +// checkCSRF enforces the double-submit CSRF pattern on mutating requests. +// The SPA reads the non-HttpOnly osctrl_csrf cookie and echoes it via the +// X-CSRF-Token header on every mutation; we constant-time-compare: +// 1. header == cookie value (classic double-submit), AND +// 2. cookie value == AdminUser.CSRFToken (defeats a cookie-tossing +// attacker who can set both header and cookie without DB write access). +// +// CLI clients that authenticate purely via Authorization: Bearer (no cookie) +// are exempt — there is no browser to ride a cross-site request from. +// +// Note: AdminUser.CSRFToken rotates on every successful /login (see +// LoginHandler ↦ Users.UpdateMetadata). Concurrent logins of the same user +// race; the loser keeps a cookie that no longer matches the stored value +// and gets 403 on the next mutation. APIToken refresh / clear also clear +// CSRFToken (see pkg/users.UpdateToken / ClearToken) so a stale CSRF +// cookie cannot outlive its session. +func checkCSRF(r *http.Request, username string) bool { + // r.Cookie returns ErrNoCookie only when the cookie name is absent; + // an empty-value cookie returns (cookie, nil). Treating the empty case + // as "Bearer client" would bypass CSRF — instead, the call to + // extractHeaderToken upstream rejects empty-value cookies before we + // reach this function (the trimmed value falls through to "" return). + if _, err := r.Cookie(cookieNameToken); err != nil { + // No session cookie ⇒ Bearer-only client (CLI/CI). Nothing to CSRF. + return true + } + headerToken := strings.TrimSpace(r.Header.Get(headerNameCSRF)) + cookie, err := r.Cookie(cookieNameCSRF) + if err != nil || headerToken == "" { + return false + } + cookieValue := strings.TrimSpace(cookie.Value) + if subtle.ConstantTimeCompare([]byte(headerToken), []byte(cookieValue)) != 1 { + return false + } + user, err := apiUsers.Get(username) + if err != nil || user.CSRFToken == "" { + return false } - return strings.TrimSpace(splitToken[1]) + return subtle.ConstantTimeCompare([]byte(cookieValue), []byte(user.CSRFToken)) == 1 } // Handler to check access to a resource based on the authentication enabled @@ -41,12 +108,51 @@ func handlerAuthCheck(h http.Handler, auth, jwtSecret string) http.Handler { // Set middleware values token := extractHeaderToken(r) if token == "" { - http.Redirect(w, r, forbiddenPath, http.StatusForbidden) + if utils.AcceptsJSON(r) { + utils.HTTPResponse(w, utils.JSONApplicationUTF8, http.StatusUnauthorized, + types.ApiErrorResponse{Error: "unauthorized", Code: "unauthorized"}) + return + } + // 302 is required by http.Redirect; the legacy 403 didn't actually trigger + // a redirect in any browser since http.Redirect demands a 3xx status. + http.Redirect(w, r, forbiddenPath, http.StatusFound) return } claims, valid := apiUsers.CheckToken(jwtSecret, token) if !valid { - http.Redirect(w, r, forbiddenPath, http.StatusForbidden) + if utils.AcceptsJSON(r) { + utils.HTTPResponse(w, utils.JSONApplicationUTF8, http.StatusUnauthorized, + types.ApiErrorResponse{Error: "unauthorized", Code: "unauthorized"}) + return + } + // 302 is required by http.Redirect; the legacy 403 didn't actually trigger + // a redirect in any browser since http.Redirect demands a 3xx status. + http.Redirect(w, r, forbiddenPath, http.StatusFound) + return + } + // Match the presented token against the user's currently-stored APIToken + // so that refresh/delete on /users/{username}/token invalidates old JWTs. + // (CheckToken above only validates the signature.) Service users with no + // stored token are rejected immediately. Constant-time comparison guards + // against timing-side-channel leaks of the stored token. + user, uerr := apiUsers.Get(claims.Username) + tokenMatches := uerr == nil && user.APIToken != "" && + subtle.ConstantTimeCompare([]byte(user.APIToken), []byte(token)) == 1 + if !tokenMatches { + if utils.AcceptsJSON(r) { + utils.HTTPResponse(w, utils.JSONApplicationUTF8, http.StatusUnauthorized, + types.ApiErrorResponse{Error: "unauthorized", Code: "unauthorized"}) + return + } + http.Redirect(w, r, forbiddenPath, http.StatusFound) + return + } + // CSRF guard for cookie-authenticated mutating requests. CLI Bearer + // clients are exempt via the cookieNameToken probe inside checkCSRF. + // + if mutatingMethods[r.Method] && !checkCSRF(r, claims.Username) { + utils.HTTPResponse(w, utils.JSONApplicationUTF8, http.StatusForbidden, + types.ApiErrorResponse{Error: "csrf token missing or invalid", Code: "csrf"}) return } // Update metadata for the user diff --git a/cmd/api/auth_test.go b/cmd/api/auth_test.go new file mode 100644 index 00000000..965d369f --- /dev/null +++ b/cmd/api/auth_test.go @@ -0,0 +1,88 @@ +package main + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/jmpsec/osctrl/pkg/config" +) + +func TestHandlerAuthCheckJSONvsRedirect(t *testing.T) { + // A no-op inner handler — handlerAuthCheck should never call it when + // there's no valid token. We just need to assert the failure response. + inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Fatal("inner handler should not be called when auth fails") + }) + + h := handlerAuthCheck(inner, config.AuthJWT, "test-jwt-secret") + + t.Run("Accept application/json returns 401 JSON", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/api/v1/anything", nil) + req.Header.Set("Accept", "application/json") + rr := httptest.NewRecorder() + h.ServeHTTP(rr, req) + if rr.Code != http.StatusUnauthorized { + t.Fatalf("status: got %d, want 401", rr.Code) + } + ct := rr.Header().Get("Content-Type") + if ct == "" || ct[:16] != "application/json" { + t.Fatalf("Content-Type: got %q, want application/json...", ct) + } + }) + + t.Run("default client gets 302 redirect", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/api/v1/anything", nil) + rr := httptest.NewRecorder() + h.ServeHTTP(rr, req) + if rr.Code != http.StatusFound { + t.Fatalf("status: got %d, want 302", rr.Code) + } + if rr.Header().Get("Location") == "" { + t.Fatal("missing Location header on redirect") + } + }) +} + +func TestExtractHeaderTokenPrefersBearerThenCookie(t *testing.T) { + cases := []struct { + name string + header string + cookie string + want string + }{ + {"bearer header", "Bearer abc.def.ghi", "", "abc.def.ghi"}, + {"cookie fallback", "", "xyz.uvw.123", "xyz.uvw.123"}, + {"bearer wins over cookie", "Bearer header-token", "cookie-token", "header-token"}, + {"no auth at all", "", "", ""}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/", nil) + if tc.header != "" { + req.Header.Set("Authorization", tc.header) + } + if tc.cookie != "" { + req.AddCookie(&http.Cookie{Name: cookieNameToken, Value: tc.cookie}) + } + got := extractHeaderToken(req) + if got != tc.want { + t.Fatalf("got %q, want %q", got, tc.want) + } + }) + } +} + +func TestMutatingMethodsTable(t *testing.T) { + // Lock the contract that GET/HEAD/OPTIONS bypass CSRF and PUT/PATCH/POST/DELETE require it. + for _, m := range []string{http.MethodGet, http.MethodHead, http.MethodOptions} { + if mutatingMethods[m] { + t.Errorf("read-only method %s should not require CSRF", m) + } + } + for _, m := range []string{http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete} { + if !mutatingMethods[m] { + t.Errorf("mutating method %s must require CSRF", m) + } + } +} diff --git a/cmd/api/handlers/audit.go b/cmd/api/handlers/audit.go index 0233811e..05620c6f 100644 --- a/cmd/api/handlers/audit.go +++ b/cmd/api/handlers/audit.go @@ -3,34 +3,156 @@ package handlers import ( "fmt" "net/http" + "strconv" "strings" + "time" "github.com/jmpsec/osctrl/pkg/auditlog" + "github.com/jmpsec/osctrl/pkg/types" "github.com/jmpsec/osctrl/pkg/users" "github.com/jmpsec/osctrl/pkg/utils" "github.com/rs/zerolog/log" ) -// AuditLogsHandler - GET Handler for all audit logs +// AuditLogsHandler - GET /api/v1/audit-logs +// +// Query params: +// +// ?service=... exact match on service name +// ?username=... case-insensitive partial match on username +// ?type=... log type integer (1..10), see pkg/auditlog.LogType* +// ?env_uuid=... filter to one environment (resolved to internal ID) +// ?since=RFC3339 created_at >= since +// ?until=RFC3339 created_at <= until +// ?page=N 1-indexed page; default 1 +// ?page_size=N default 50, max 500 +// +// Returns the SPA-canonical paginated envelope. The handler audit-logs the +// visit on success. func (h *HandlersApi) AuditLogsHandler(w http.ResponseWriter, r *http.Request) { - // Debug HTTP if enabled if h.DebugHTTPConfig.EnableHTTP { utils.DebugHTTPDump(h.DebugHTTP, r, h.DebugHTTPConfig.ShowBody) } - // Get context data and check access ctx := r.Context().Value(ContextKey(contextAPI)).(ContextValue) if !h.Users.CheckPermissions(ctx[ctxUser], users.AdminLevel, users.NoEnvironment) { apiErrorResponse(w, "no access", http.StatusForbidden, fmt.Errorf("attempt to use API by user %s", ctx[ctxUser])) return } - // Get audit logs - auditLogs, err := h.AuditLog.GetAll() + + q := r.URL.Query() + filter := auditlog.PageFilter{ + Service: strings.TrimSpace(q.Get("service")), + Username: strings.TrimSpace(q.Get("username")), + } + if v := q.Get("type"); v != "" { + n, err := strconv.ParseUint(v, 10, 32) + if err != nil { + apiErrorResponse(w, "type must be an integer", http.StatusBadRequest, err) + return + } + if _, ok := auditlog.LogTypes[uint(n)]; !ok { + apiErrorResponse(w, "type is not a known log_type", http.StatusBadRequest, nil) + return + } + filter.LogType = uint(n) + } + if v := q.Get("env_uuid"); v != "" { + env, err := h.Envs.GetByUUID(v) + if err != nil { + apiErrorResponse(w, "env_uuid not found", http.StatusBadRequest, err) + return + } + filter.EnvID = env.ID + } + if v := q.Get("since"); v != "" { + t, err := time.Parse(time.RFC3339, v) + if err != nil { + apiErrorResponse(w, "since must be RFC3339", http.StatusBadRequest, err) + return + } + filter.Since = t + } + if v := q.Get("until"); v != "" { + t, err := time.Parse(time.RFC3339, v) + if err != nil { + apiErrorResponse(w, "until must be RFC3339", http.StatusBadRequest, err) + return + } + filter.Until = t + } + if v := q.Get("page"); v != "" { + n, err := strconv.Atoi(v) + if err != nil || n < 1 { + apiErrorResponse(w, "page must be a positive integer", http.StatusBadRequest, err) + return + } + filter.Page = n + } else { + filter.Page = 1 + } + if v := q.Get("page_size"); v != "" { + n, err := strconv.Atoi(v) + if err != nil || n < 1 { + apiErrorResponse(w, "page_size must be a positive integer", http.StatusBadRequest, err) + return + } + filter.PageSize = n + } + if filter.PageSize == 0 { + filter.PageSize = 50 + } + // Mirror the package-layer clamp at the handler so the response + // envelope echoes the actual effective value and the doc-comment + // "max 500" remains honest if the package layer's bound ever + // shifts. + if filter.PageSize > 500 { + filter.PageSize = 500 + } + + rows, total, err := h.AuditLog.GetPaged(filter) if err != nil { - log.Err(err).Msg("error getting audit logs") + apiErrorResponse(w, "error getting audit logs", http.StatusInternalServerError, err) return } - // Serialize and serve JSON - log.Debug().Msgf("Returned %d audit log entries", len(auditLogs)) + + // Resolve EnvironmentID → UUID with a single map lookup so the SPA can + // render env names directly. Empty UUID == no env / system action. + envMap, _ := h.Envs.GetMapByID() + + items := make([]types.AuditLogView, 0, len(rows)) + for _, r := range rows { + view := types.AuditLogView{ + ID: r.ID, + CreatedAt: r.CreatedAt, + Service: r.Service, + Username: r.Username, + Line: r.Line, + LogType: r.LogType, + Severity: r.Severity, + SourceIP: r.SourceIP, + EnvironmentID: r.EnvironmentID, + } + if r.EnvironmentID > 0 { + if e, ok := envMap[r.EnvironmentID]; ok { + view.EnvUUID = e.UUID + } + } + items = append(items, view) + } + + totalPages := 0 + if total > 0 { + totalPages = int((total + int64(filter.PageSize) - 1) / int64(filter.PageSize)) + } + resp := types.AuditLogsPagedResponse{ + Items: items, + Page: filter.Page, + PageSize: filter.PageSize, + TotalItems: total, + TotalPages: totalPages, + } + h.AuditLog.Visit(ctx[ctxUser], r.URL.Path, strings.Split(r.RemoteAddr, ":")[0], auditlog.NoEnvironment) - utils.HTTPResponse(w, utils.JSONApplicationUTF8, http.StatusOK, auditLogs) + log.Debug().Msgf("Returned %d audit log entries (page=%d, size=%d, total=%d)", len(items), filter.Page, filter.PageSize, total) + utils.HTTPResponse(w, utils.JSONApplicationUTF8, http.StatusOK, resp) } diff --git a/cmd/api/handlers/carves.go b/cmd/api/handlers/carves.go index f505d4d2..661c5014 100644 --- a/cmd/api/handlers/carves.go +++ b/cmd/api/handlers/carves.go @@ -2,12 +2,17 @@ package handlers import ( "encoding/json" + "errors" "fmt" + "io" "net/http" + "os" + "strconv" "strings" "time" "github.com/jmpsec/osctrl/pkg/carves" + "github.com/jmpsec/osctrl/pkg/config" "github.com/jmpsec/osctrl/pkg/handlers" "github.com/jmpsec/osctrl/pkg/queries" "github.com/jmpsec/osctrl/pkg/settings" @@ -15,178 +20,243 @@ import ( "github.com/jmpsec/osctrl/pkg/users" "github.com/jmpsec/osctrl/pkg/utils" "github.com/rs/zerolog/log" + "gorm.io/gorm" ) -// GET Handler to return a single carve in JSON +// carveFileView projects a CarvedFile row into the SPA-canonical envelope. +// time.Time stays as time.Time so JSON-encoded output is RFC3339. +func carveFileView(c carves.CarvedFile) types.CarveFileView { + return types.CarveFileView{ + CarveID: c.CarveID, + SessionID: c.SessionID, + UUID: c.UUID, + Path: c.Path, + Status: c.Status, + CarveSize: c.CarveSize, + BlockSize: c.BlockSize, + TotalBlocks: c.TotalBlocks, + CompletedBlocks: c.CompletedBlocks, + Archived: c.Archived, + CreatedAt: c.CreatedAt, + CompletedAt: c.CompletedAt, + } +} + +// CarveShowHandler - GET /api/v1/carves/{env}/{name} +// +// Returns the carve query metadata plus the array of per-node CarvedFile rows +// produced by the carve. Returns 404 when the carve query name does not exist +// in the environment. func (h *HandlersApi) CarveShowHandler(w http.ResponseWriter, r *http.Request) { - // Debug HTTP if enabled if h.DebugHTTPConfig.EnableHTTP { utils.DebugHTTPDump(h.DebugHTTP, r, h.DebugHTTPConfig.ShowBody) } - // Extract name name := r.PathValue("name") if name == "" { - apiErrorResponse(w, "error getting name", http.StatusInternalServerError, nil) + apiErrorResponse(w, "error getting name", http.StatusBadRequest, nil) return } - // Extract environment envVar := r.PathValue("env") if envVar == "" { apiErrorResponse(w, "error with environment", http.StatusBadRequest, nil) return } - // Get environment - env, err := h.Envs.GetByUUID(envVar) + env, err := h.Envs.Get(envVar) if err != nil { apiErrorResponse(w, "error getting environment", http.StatusInternalServerError, nil) return } - // Get context data and check access ctx := r.Context().Value(ContextKey(contextAPI)).(ContextValue) if !h.Users.CheckPermissions(ctx[ctxUser], users.CarveLevel, env.UUID) { apiErrorResponse(w, "no access", http.StatusForbidden, fmt.Errorf("attempt to use API by user %s", ctx[ctxUser])) return } - // Get carve by name - carve, err := h.Carves.GetByQuery(name, env.ID) + + // Look up the carve query (DistributedQuery row with type=carve). + q, err := h.Queries.Get(name, env.ID) if err != nil { - if err.Error() == "record not found" { + if errors.Is(err, gorm.ErrRecordNotFound) { apiErrorResponse(w, "carve not found", http.StatusNotFound, err) - } else { - apiErrorResponse(w, "error getting carve", http.StatusInternalServerError, err) + return } + apiErrorResponse(w, "error getting carve", http.StatusInternalServerError, err) + return + } + if q.Type != queries.CarveQueryType { + apiErrorResponse(w, "carve not found", http.StatusNotFound, nil) + return + } + + // Look up the carved files (one per node that completed the carve). + files, err := h.Carves.GetByQuery(name, env.ID) + if err != nil { + apiErrorResponse(w, "error getting carve files", http.StatusInternalServerError, err) return } - // Serialize and serve JSON - log.Debug().Msgf("Returned carve %s", name) + views := make([]types.CarveFileView, 0, len(files)) + for _, f := range files { + views = append(views, carveFileView(f)) + } + + resp := types.CarveDetailResponse{Query: q, Files: views} + log.Debug().Msgf("Returned carve %s (%d files)", name, len(views)) h.AuditLog.Visit(ctx[ctxUser], r.URL.Path, strings.Split(r.RemoteAddr, ":")[0], env.ID) - utils.HTTPResponse(w, utils.JSONApplicationUTF8, http.StatusOK, carve) + utils.HTTPResponse(w, utils.JSONApplicationUTF8, http.StatusOK, resp) } -// GET Handler to return carve queries in JSON by target and environment +// CarveQueriesHandler - GET /api/v1/carves/{env}/queries/{target} +// +// Returns carve queries by target. Retained from the legacy contract; the +// canonical list endpoint is now CarveListHandler at /api/v1/carves/{env}. func (h *HandlersApi) CarveQueriesHandler(w http.ResponseWriter, r *http.Request) { - // Debug HTTP if enabled if h.DebugHTTPConfig.EnableHTTP { utils.DebugHTTPDump(h.DebugHTTP, r, h.DebugHTTPConfig.ShowBody) } - // Extract environment envVar := r.PathValue("env") if envVar == "" { apiErrorResponse(w, "error with environment", http.StatusBadRequest, nil) return } - // Get environment - env, err := h.Envs.GetByUUID(envVar) + env, err := h.Envs.Get(envVar) if err != nil { apiErrorResponse(w, "error getting environment", http.StatusInternalServerError, nil) return } - // Get context data and check access ctx := r.Context().Value(ContextKey(contextAPI)).(ContextValue) if !h.Users.CheckPermissions(ctx[ctxUser], users.CarveLevel, env.UUID) { apiErrorResponse(w, "no access", http.StatusForbidden, fmt.Errorf("attempt to use API by user %s", ctx[ctxUser])) return } - // Extract target targetVar := r.PathValue("target") if targetVar == "" { apiErrorResponse(w, "error with target", http.StatusBadRequest, nil) return } - // Verify target if !QueryTargets[targetVar] { apiErrorResponse(w, "invalid target", http.StatusBadRequest, nil) return } - // Get carves - carves, err := h.Queries.GetCarves(targetVar, env.ID) + carvesList, err := h.Queries.GetCarves(targetVar, env.ID) if err != nil { apiErrorResponse(w, "error getting carve queries", http.StatusInternalServerError, err) return } - if len(carves) == 0 { - apiErrorResponse(w, "no carve queries", http.StatusNotFound, nil) - return - } - // Serialize and serve JSON - log.Debug().Msgf("Returned %d carves", len(carves)) + log.Debug().Msgf("Returned %d carves", len(carvesList)) h.AuditLog.Visit(ctx[ctxUser], r.URL.Path, strings.Split(r.RemoteAddr, ":")[0], env.ID) - utils.HTTPResponse(w, utils.JSONApplicationUTF8, http.StatusOK, carves) + utils.HTTPResponse(w, utils.JSONApplicationUTF8, http.StatusOK, carvesList) } -// GET Handler to return carves in JSON by environment +// CarveListHandler - GET /api/v1/carves/{env} +// +// Paginated, sorted, searchable list of carve queries (DistributedQuery rows +// with type=carve). Query params: page, page_size, q, sort, dir, target. +// Empty result → HTTP 200 with items: []. func (h *HandlersApi) CarveListHandler(w http.ResponseWriter, r *http.Request) { - // Debug HTTP if enabled if h.DebugHTTPConfig.EnableHTTP { utils.DebugHTTPDump(h.DebugHTTP, r, h.DebugHTTPConfig.ShowBody) } - // Extract environment envVar := r.PathValue("env") if envVar == "" { apiErrorResponse(w, "error with environment", http.StatusBadRequest, nil) return } - // Get environment - env, err := h.Envs.GetByUUID(envVar) + env, err := h.Envs.Get(envVar) if err != nil { apiErrorResponse(w, "error getting environment", http.StatusInternalServerError, nil) return } - // Get context data and check access ctx := r.Context().Value(ContextKey(contextAPI)).(ContextValue) if !h.Users.CheckPermissions(ctx[ctxUser], users.CarveLevel, env.UUID) { apiErrorResponse(w, "no access", http.StatusForbidden, fmt.Errorf("attempt to use API by user %s", ctx[ctxUser])) return } - // Get carves - carves, err := h.Carves.GetByEnv(env.ID) + + q := r.URL.Query() + page, _ := strconv.Atoi(q.Get("page")) + pageSize, _ := strconv.Atoi(q.Get("page_size")) + search := q.Get("q") + sortCol := q.Get("sort") + desc := strings.ToLower(q.Get("dir")) != "asc" + target := q.Get("target") + if target == "" { + target = queries.TargetAll + } + if !QueryTargets[target] { + apiErrorResponse(w, "invalid target", http.StatusBadRequest, nil) + return + } + + if pageSize <= 0 { + pageSize = 50 + } + if pageSize > 500 { + pageSize = 500 + } + if page <= 0 { + page = 1 + } + + result, err := h.Queries.GetByEnvTargetPaged(env.ID, target, queries.CarveQueryType, search, page, pageSize, sortCol, desc) if err != nil { apiErrorResponse(w, "error getting carves", http.StatusInternalServerError, err) return } - if len(carves) == 0 { - apiErrorResponse(w, "no carves", http.StatusNotFound, nil) - return + items := result.Items + if items == nil { + items = []queries.DistributedQuery{} + } + var totalPages int + if result.TotalItems > 0 { + totalPages = int((result.TotalItems + int64(pageSize) - 1) / int64(pageSize)) } - // Serialize and serve JSON - log.Debug().Msgf("Returned %d carves", len(carves)) + resp := types.CarvesPagedResponse{ + Items: items, + Page: page, + PageSize: pageSize, + TotalItems: result.TotalItems, + TotalPages: totalPages, + } + log.Debug().Msgf("Returned %d carves (page %d of %d)", len(items), page, totalPages) h.AuditLog.Visit(ctx[ctxUser], r.URL.Path, strings.Split(r.RemoteAddr, ":")[0], env.ID) - utils.HTTPResponse(w, utils.JSONApplicationUTF8, http.StatusOK, carves) + utils.HTTPResponse(w, utils.JSONApplicationUTF8, http.StatusOK, resp) } -// POST Handler to run a carve +// CarvesRunHandler - POST /api/v1/carves/{env} func (h *HandlersApi) CarvesRunHandler(w http.ResponseWriter, r *http.Request) { - // Debug HTTP if enabled if h.DebugHTTPConfig.EnableHTTP { utils.DebugHTTPDump(h.DebugHTTP, r, h.DebugHTTPConfig.ShowBody) } - // Extract environment envVar := r.PathValue("env") if envVar == "" { apiErrorResponse(w, "error with environment", http.StatusBadRequest, nil) return } - // Get environment - env, err := h.Envs.GetByUUID(envVar) + env, err := h.Envs.Get(envVar) if err != nil { apiErrorResponse(w, "error getting environment", http.StatusInternalServerError, nil) return } - // Get context data and check access ctx := r.Context().Value(ContextKey(contextAPI)).(ContextValue) if !h.Users.CheckPermissions(ctx[ctxUser], users.CarveLevel, env.UUID) { apiErrorResponse(w, "no access", http.StatusForbidden, fmt.Errorf("attempt to use API by user %s", ctx[ctxUser])) return } var c types.ApiDistributedQueryRequest - // Parse request JSON body if err := json.NewDecoder(r.Body).Decode(&c); err != nil { - apiErrorResponse(w, "error parsing POST body", http.StatusInternalServerError, err) + apiErrorResponse(w, "error parsing POST body", http.StatusBadRequest, err) return } - // Path can not be empty if c.Path == "" { - apiErrorResponse(w, "path can not be empty", http.StatusInternalServerError, nil) + apiErrorResponse(w, "path can not be empty", http.StatusBadRequest, nil) + return + } + // Validate the path before it's spliced into the osquery SQL via + // carves.GenCarveQuery. Without this gate a CarveLevel operator + // could inject arbitrary osquery (e.g. `'; SELECT 1; --`) into the + // query that gets distributed to every targeted node — pivoting + // "carve a file" into "run any SELECT". + if !carves.ValidCarvePath(c.Path) { + apiErrorResponse(w, "invalid carve path", http.StatusBadRequest, fmt.Errorf("rejected path %q", c.Path)) return } // Make sure the user has permissions to run queries in the environments @@ -200,7 +270,6 @@ func (h *HandlersApi) CarvesRunHandler(w http.ResponseWriter, r *http.Request) { if c.ExpHours == 0 { expTime = time.Time{} } - // Prepare and create new carve newQuery := queries.DistributedQuery{ Query: carves.GenCarveQuery(c.Path, false), Name: carves.GenCarveName(), @@ -215,7 +284,6 @@ func (h *HandlersApi) CarvesRunHandler(w http.ResponseWriter, r *http.Request) { apiErrorResponse(w, "error creating query", http.StatusInternalServerError, err) return } - // Prepare data for the handler code data := handlers.ProcessingQuery{ Envs: c.Environments, Platforms: c.Platforms, @@ -235,7 +303,6 @@ func (h *HandlersApi) CarvesRunHandler(w http.ResponseWriter, r *http.Request) { apiErrorResponse(w, "error creating query", http.StatusInternalServerError, err) return } - // If the list is empty, we don't need to create node queries if len(targetNodesID) != 0 { if err := h.Queries.CreateNodeQueries(targetNodesID, newQuery.ID); err != nil { log.Err(err).Msgf("error creating node queries for carve %s", newQuery.Name) @@ -243,54 +310,45 @@ func (h *HandlersApi) CarvesRunHandler(w http.ResponseWriter, r *http.Request) { return } } - // Update value for expected if err := h.Queries.SetExpected(newQuery.Name, len(targetNodesID), env.ID); err != nil { apiErrorResponse(w, "error setting expected", http.StatusInternalServerError, err) return } - // Return query name as serialized response - log.Debug().Msgf("Created query %s", newQuery.Name) + log.Debug().Msgf("Created carve %s", newQuery.Name) h.AuditLog.NewCarve(ctx[ctxUser], newQuery.Path, strings.Split(r.RemoteAddr, ":")[0], env.ID) - utils.HTTPResponse(w, utils.JSONApplicationUTF8, http.StatusOK, types.ApiQueriesResponse{Name: newQuery.Name}) + utils.HTTPResponse(w, utils.JSONApplicationUTF8, http.StatusCreated, types.ApiQueriesResponse{Name: newQuery.Name}) } -// CarvesActionHandler - POST Handler to delete/expire a carve +// CarvesActionHandler - POST /api/v1/carves/{env}/{action}/{name} func (h *HandlersApi) CarvesActionHandler(w http.ResponseWriter, r *http.Request) { - // Debug HTTP if enabled if h.DebugHTTPConfig.EnableHTTP { utils.DebugHTTPDump(h.DebugHTTP, r, h.DebugHTTPConfig.ShowBody) } - // Extract environment envVar := r.PathValue("env") if envVar == "" { apiErrorResponse(w, "error with environment", http.StatusBadRequest, nil) return } - // Get environment - env, err := h.Envs.GetByUUID(envVar) + env, err := h.Envs.Get(envVar) if err != nil { apiErrorResponse(w, "error getting environment", http.StatusInternalServerError, nil) return } - // Get context data and check access ctx := r.Context().Value(ContextKey(contextAPI)).(ContextValue) if !h.Users.CheckPermissions(ctx[ctxUser], users.AdminLevel, env.UUID) { apiErrorResponse(w, "no access", http.StatusForbidden, fmt.Errorf("attempt to use API by user %s", ctx[ctxUser])) return } var msgReturn string - // Carve can not be empty nameVar := r.PathValue("name") if nameVar == "" { apiErrorResponse(w, "name can not be empty", http.StatusBadRequest, nil) return } - // Check if carve exists if !h.Queries.Exists(nameVar, env.ID) { apiErrorResponse(w, "carve not found", http.StatusNotFound, nil) return } - // Extract action actionVar := r.PathValue("action") if actionVar == "" { apiErrorResponse(w, "error getting action", http.StatusBadRequest, nil) @@ -315,9 +373,208 @@ func (h *HandlersApi) CarvesActionHandler(w http.ResponseWriter, r *http.Request return } msgReturn = fmt.Sprintf("carve %s completed successfully", nameVar) + default: + apiErrorResponse(w, "invalid action", http.StatusBadRequest, nil) + return } - // Return message as serialized response log.Debug().Msgf("%s", msgReturn) h.AuditLog.CarveAction(ctx[ctxUser], actionVar+" carve "+nameVar, strings.Split(r.RemoteAddr, ":")[0], env.ID) utils.HTTPResponse(w, utils.JSONApplicationUTF8, http.StatusOK, types.ApiGenericResponse{Message: msgReturn}) } + +// CarveArchiveHandler - GET /api/v1/carves/{env}/archive/{name} +// +// (The literal `archive` lives in segment 2 — not as a `/{name}/archive` suffix — +// because Go's ServeMux refuses to register patterns that ambiguously overlap with +// `/{env}/queries/{target}` registered on the same prefix.) +// +// Streams (or redirects to) the reassembled carve archive blob. +// +// Resolution rules: +// - The carve query identified by {name} must exist and be type=carve. +// - If exactly one CarvedFile exists for the query, it is served. +// - If multiple exist, an explicit ?session= must select one. +// A missing/ambiguous session selector returns 409 Conflict. +// - If the underlying file is not yet archived, it is archived on demand +// (local or DB carver: written to a temp dir, then served; S3: a presigned +// download URL is returned via 302 redirect). +// +// Content-Disposition is set to attachment with the carve archive filename. +func (h *HandlersApi) CarveArchiveHandler(w http.ResponseWriter, r *http.Request) { + if h.DebugHTTPConfig.EnableHTTP { + utils.DebugHTTPDump(h.DebugHTTP, r, h.DebugHTTPConfig.ShowBody) + } + envVar := r.PathValue("env") + name := r.PathValue("name") + if envVar == "" || name == "" { + apiErrorResponse(w, "missing env or name", http.StatusBadRequest, nil) + return + } + env, err := h.Envs.Get(envVar) + if err != nil { + apiErrorResponse(w, "error getting environment", http.StatusInternalServerError, nil) + return + } + ctx := r.Context().Value(ContextKey(contextAPI)).(ContextValue) + if !h.Users.CheckPermissions(ctx[ctxUser], users.CarveLevel, env.UUID) { + apiErrorResponse(w, "no access", http.StatusForbidden, fmt.Errorf("attempt to use API by user %s", ctx[ctxUser])) + return + } + + // Confirm the carve query exists and is a carve. + q, err := h.Queries.Get(name, env.ID) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + apiErrorResponse(w, "carve not found", http.StatusNotFound, err) + return + } + apiErrorResponse(w, "error getting carve", http.StatusInternalServerError, err) + return + } + if q.Type != queries.CarveQueryType { + apiErrorResponse(w, "carve not found", http.StatusNotFound, nil) + return + } + + files, err := h.Carves.GetByQuery(name, env.ID) + if err != nil { + apiErrorResponse(w, "error getting carve files", http.StatusInternalServerError, err) + return + } + if len(files) == 0 { + apiErrorResponse(w, "no carved files yet", http.StatusNotFound, nil) + return + } + + requestedSession := strings.TrimSpace(r.URL.Query().Get("session")) + var selected *carves.CarvedFile + switch { + case requestedSession != "": + for i := range files { + if files[i].SessionID == requestedSession { + selected = &files[i] + break + } + } + if selected == nil { + apiErrorResponse(w, "session not found for carve", http.StatusNotFound, nil) + return + } + case len(files) == 1: + selected = &files[0] + default: + // Ambiguous — the caller must pick a session. + sessions := make([]string, 0, len(files)) + for _, f := range files { + sessions = append(sessions, f.SessionID) + } + apiErrorResponse(w, + fmt.Sprintf("carve has %d files; pass ?session= to select one (sessions: %s)", + len(files), strings.Join(sessions, ", ")), + http.StatusConflict, nil) + return + } + + // Materialize the archive if not already done. The path persistence + // strategy differs by carver: + // + // - S3: Archive() multipart-uploads the file to a persistent S3 + // key; we mark the row archived with that key and serve + // a presigned download URL. + // - Local/DB: Archive() reconstructs the file in a workspace dir. The + // API process owns no canonical "carves folder" — the + // legacy admin owns one — so we stage in a per-request + // tmpdir, stream, and do NOT persist the path. (Persisting + // would point future requests at a tmpdir we've already + // removed.) The trade-off is re-archiving on each request + // for local/DB carvers, which is correctness over cache. + carve := *selected + + if h.Carves.Carver == config.CarverS3 { + if !carve.Archived { + // Pass empty destPath — Archive() ignores it for the S3 path. + result, aerr := h.Carves.Archive(carve.SessionID, "") + if aerr != nil { + apiErrorResponse(w, "error archiving carve", http.StatusInternalServerError, aerr) + return + } + if result == nil { + apiErrorResponse(w, "empty carve archive", http.StatusInternalServerError, nil) + return + } + if aerr := h.Carves.ArchiveCarve(carve.SessionID, result.File); aerr != nil { + log.Err(aerr).Msgf("error marking carve %s archived", carve.SessionID) + } + carve.Archived = true + carve.ArchivePath = result.File + } + link, lerr := h.Carves.S3.GetDownloadLink(carve) + if lerr != nil { + apiErrorResponse(w, "error generating download link", http.StatusInternalServerError, lerr) + return + } + h.AuditLog.CarveAction(ctx[ctxUser], "download "+name, strings.Split(r.RemoteAddr, ":")[0], env.ID) + http.Redirect(w, r, link, http.StatusFound) + return + } + + // Local / DB carver: stage the archive in a per-request tmpdir and stream + // it back. RemoveAll runs after f.Close (defers are LIFO), so the file is + // readable for the duration of the response. + // + // os.MkdirTemp creates the directory mode 0700, but the file written + // inside by Carves.Archive may end up world-readable depending on + // the platform umask. We chmod it to 0600 explicitly so on a + // multi-tenant container host another tenant on the same node can't + // read the carved bytes during the brief window before RemoveAll. + // + archivePath := carve.ArchivePath + if !carve.Archived { + tmpDir, terr := os.MkdirTemp("", "osctrl-carve-archive-") + if terr != nil { + apiErrorResponse(w, "error preparing archive workspace", http.StatusInternalServerError, terr) + return + } + defer os.RemoveAll(tmpDir) + result, aerr := h.Carves.Archive(carve.SessionID, tmpDir) + if aerr != nil { + apiErrorResponse(w, "error archiving carve", http.StatusInternalServerError, aerr) + return + } + if result == nil { + apiErrorResponse(w, "empty carve archive", http.StatusInternalServerError, nil) + return + } + archivePath = result.File + if err := os.Chmod(archivePath, 0600); err != nil { + log.Err(err).Msgf("failed to chmod 0600 on carve archive %s — proceeding but file may be wider-readable", archivePath) + } + } + + f, ferr := os.Open(archivePath) + if ferr != nil { + apiErrorResponse(w, "error opening archive", http.StatusInternalServerError, ferr) + return + } + defer f.Close() + stat, serr := f.Stat() + if serr != nil { + apiErrorResponse(w, "error stat archive", http.StatusInternalServerError, serr) + return + } + filename := carves.GenerateArchiveName(carve) + // If the on-disk file picked up the zst suffix during archive, preserve it. + if strings.HasSuffix(archivePath, carves.ZstFileExtension) && + !strings.HasSuffix(filename, carves.ZstFileExtension) { + filename += carves.ZstFileExtension + } + w.Header().Set("Content-Type", "application/octet-stream") + w.Header().Set("Content-Length", strconv.FormatInt(stat.Size(), 10)) + w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", filename)) + w.WriteHeader(http.StatusOK) + if _, err := io.Copy(w, f); err != nil { + log.Err(err).Msgf("error streaming carve archive %s", archivePath) + return + } + h.AuditLog.CarveAction(ctx[ctxUser], "download "+name, strings.Split(r.RemoteAddr, ":")[0], env.ID) +} diff --git a/cmd/api/handlers/environments.go b/cmd/api/handlers/environments.go index 50d84e89..698260e5 100644 --- a/cmd/api/handlers/environments.go +++ b/cmd/api/handlers/environments.go @@ -25,6 +25,44 @@ var ( } ) +// denyEnv emits a 403 AND an audit-log entry pinned to the env handler's +// resource class. Used by the env-handler family for every deny branch +// so cross-tenant probes leave an SoC-alertable trail. The path comes +// from r.URL.Path; envID is 0 (NoEnvironment) when the deny happened +// before env resolution. +func (h *HandlersApi) denyEnv(w http.ResponseWriter, r *http.Request, ctx ContextValue, envID uint, reason string) { + h.AuditLog.Denied(ctx[ctxUser], r.URL.Path, strings.Split(r.RemoteAddr, ":")[0], reason, auditlog.LogTypeEnvironment, envID) + apiErrorResponse(w, "no access", http.StatusForbidden, fmt.Errorf("denied: %s for user %s", reason, ctx[ctxUser])) +} + +// projectEnvironmentView strips the env-secret-bearing fields from +// TLSEnvironment to produce the SPA-canonical low-privilege envelope. +// Callers MUST use this when serving env data to a non-admin (UserLevel / +// QueryLevel / CarveLevel) user. +func projectEnvironmentView(env environments.TLSEnvironment) types.TLSEnvironmentView { + return types.TLSEnvironmentView{ + ID: env.ID, + CreatedAt: env.CreatedAt, + UpdatedAt: env.UpdatedAt, + UUID: env.UUID, + Name: env.Name, + Hostname: env.Hostname, + Type: env.Type, + Icon: env.Icon, + DebugHTTP: env.DebugHTTP, + ConfigTLS: env.ConfigTLS, + ConfigInterval: env.ConfigInterval, + LoggingTLS: env.LoggingTLS, + LogInterval: env.LogInterval, + QueryTLS: env.QueryTLS, + QueryInterval: env.QueryInterval, + CarvesTLS: env.CarvesTLS, + AcceptEnrolls: env.AcceptEnrolls, + EnrollExpire: env.EnrollExpire, + RemoveExpire: env.RemoveExpire, + } +} + // EnvironmentHandler - GET Handler to return one environment by UUID as JSON func (h *HandlersApi) EnvironmentHandler(w http.ResponseWriter, r *http.Request) { // Debug HTTP if enabled @@ -38,7 +76,7 @@ func (h *HandlersApi) EnvironmentHandler(w http.ResponseWriter, r *http.Request) return } // Get environment by UUID - env, err := h.Envs.GetByUUID(envVar) + env, err := h.Envs.Get(envVar) if err != nil { if err.Error() == "record not found" { apiErrorResponse(w, "environment not found", http.StatusNotFound, err) @@ -50,13 +88,21 @@ func (h *HandlersApi) EnvironmentHandler(w http.ResponseWriter, r *http.Request) // Get context data and check access ctx := r.Context().Value(ContextKey(contextAPI)).(ContextValue) if !h.Users.CheckPermissions(ctx[ctxUser], users.UserLevel, env.UUID) { - apiErrorResponse(w, "no access", http.StatusForbidden, fmt.Errorf("attempt to use API by user %s", ctx[ctxUser])) + h.denyEnv(w, r, ctx, env.ID, "permission check failed") return } - // Serialize and serve JSON - log.Debug().Msgf("Returned environment %s", env.Name) + // Decide projection by privilege level: admins on this env (or + // super-admins) receive the full storage struct including secret / + // certificate / flags. UserLevel operators receive the low-privilege + // view that omits enroll credentials. h.AuditLog.Visit(ctx[ctxUser], r.URL.Path, strings.Split(r.RemoteAddr, ":")[0], env.ID) - utils.HTTPResponse(w, utils.JSONApplicationUTF8, http.StatusOK, env) + if h.Users.CheckPermissions(ctx[ctxUser], users.AdminLevel, env.UUID) { + log.Debug().Msgf("Returned environment %s (admin view)", env.Name) + utils.HTTPResponse(w, utils.JSONApplicationUTF8, http.StatusOK, env) + return + } + log.Debug().Msgf("Returned environment %s (low-priv view)", env.Name) + utils.HTTPResponse(w, utils.JSONApplicationUTF8, http.StatusOK, projectEnvironmentView(env)) } // EnvironmentMapHandler - GET Handler to return one environment as JSON @@ -79,7 +125,7 @@ func (h *HandlersApi) EnvironmentMapHandler(w http.ResponseWriter, r *http.Reque // Get context data and check access ctx := r.Context().Value(ContextKey(contextAPI)).(ContextValue) if !h.Users.CheckPermissions(ctx[ctxUser], users.AdminLevel, users.NoEnvironment) { - apiErrorResponse(w, "no access", http.StatusForbidden, fmt.Errorf("attempt to use API by user %s", ctx[ctxUser])) + h.denyEnv(w, r, ctx, auditlog.NoEnvironment, "permission check failed") return } // Prepare map by target @@ -112,7 +158,7 @@ func (h *HandlersApi) EnvironmentsHandler(w http.ResponseWriter, r *http.Request // Get context data and check access ctx := r.Context().Value(ContextKey(contextAPI)).(ContextValue) if !h.Users.CheckPermissions(ctx[ctxUser], users.AdminLevel, users.NoEnvironment) { - apiErrorResponse(w, "no access", http.StatusForbidden, fmt.Errorf("attempt to use API by user %s", ctx[ctxUser])) + h.denyEnv(w, r, ctx, auditlog.NoEnvironment, "permission check failed") return } // Get platforms @@ -140,7 +186,7 @@ func (h *HandlersApi) EnvEnrollHandler(w http.ResponseWriter, r *http.Request) { return } // Get environment by name - env, err := h.Envs.GetByUUID(envVar) + env, err := h.Envs.Get(envVar) if err != nil { if err.Error() == "record not found" { apiErrorResponse(w, "environment not found", http.StatusNotFound, err) @@ -149,10 +195,15 @@ func (h *HandlersApi) EnvEnrollHandler(w http.ResponseWriter, r *http.Request) { } return } - // Get context data and check access + // Get context data and check access. The enroll endpoint exposes the + // env's enroll secret (directly via target=secret, indirectly via the + // one-liners that embed it in the URL, and via target=flags). That + // secret is the only credential needed to enroll nodes via osctrl-tls, + // so it must be gated to AdminLevel on the env, not UserLevel. + // ctx := r.Context().Value(ContextKey(contextAPI)).(ContextValue) - if !h.Users.CheckPermissions(ctx[ctxUser], users.UserLevel, env.UUID) { - apiErrorResponse(w, "no access", http.StatusForbidden, fmt.Errorf("attempt to use API by user %s", ctx[ctxUser])) + if !h.Users.CheckPermissions(ctx[ctxUser], users.AdminLevel, env.UUID) { + h.denyEnv(w, r, ctx, env.ID, "permission check failed") return } // Extract target @@ -185,8 +236,9 @@ func (h *HandlersApi) EnvEnrollHandler(w http.ResponseWriter, r *http.Request) { apiErrorResponse(w, "invalid target", http.StatusBadRequest, fmt.Errorf("invalid target %s", targetVar)) return } - // Serialize and serve JSON - log.Debug().Msgf("Returned data for environment%s : %s", env.Name, returnData) + // Serialize and serve JSON. Don't log the payload — it contains the + // enroll secret. + log.Debug().Msgf("Returned enroll data for environment %s target=%s", env.Name, targetVar) h.AuditLog.Visit(ctx[ctxUser], r.URL.Path, strings.Split(r.RemoteAddr, ":")[0], env.ID) utils.HTTPResponse(w, utils.JSONApplicationUTF8, http.StatusOK, types.ApiDataResponse{Data: returnData}) } @@ -204,7 +256,7 @@ func (h *HandlersApi) EnvRemoveHandler(w http.ResponseWriter, r *http.Request) { return } // Get environment by name - env, err := h.Envs.GetByUUID(envVar) + env, err := h.Envs.Get(envVar) if err != nil { if err.Error() == "record not found" { apiErrorResponse(w, "environment not found", http.StatusNotFound, err) @@ -213,10 +265,12 @@ func (h *HandlersApi) EnvRemoveHandler(w http.ResponseWriter, r *http.Request) { } return } - // Get context data and check access + // Get context data and check access. The remove one-liners embed the + // remove-secret in the URL, so the endpoint must be AdminLevel-gated + // just like the enroll variant. ctx := r.Context().Value(ContextKey(contextAPI)).(ContextValue) - if !h.Users.CheckPermissions(ctx[ctxUser], users.UserLevel, env.UUID) { - apiErrorResponse(w, "no access", http.StatusForbidden, fmt.Errorf("attempt to use API by user %s", ctx[ctxUser])) + if !h.Users.CheckPermissions(ctx[ctxUser], users.AdminLevel, env.UUID) { + h.denyEnv(w, r, ctx, env.ID, "permission check failed") return } // Extract target @@ -243,8 +297,9 @@ func (h *HandlersApi) EnvRemoveHandler(w http.ResponseWriter, r *http.Request) { apiErrorResponse(w, "invalid target", http.StatusBadRequest, fmt.Errorf("invalid target %s", targetVar)) return } - // Serialize and serve JSON - log.Debug().Msgf("Returned data for environment %s : %s", env.Name, returnData) + // Serialize and serve JSON. Don't log the payload — it embeds the + // remove secret. + log.Debug().Msgf("Returned remove data for environment %s target=%s", env.Name, targetVar) h.AuditLog.Visit(ctx[ctxUser], r.URL.Path, strings.Split(r.RemoteAddr, ":")[0], env.ID) utils.HTTPResponse(w, utils.JSONApplicationUTF8, http.StatusOK, types.ApiDataResponse{Data: returnData}) } @@ -262,7 +317,7 @@ func (h *HandlersApi) EnvEnrollActionsHandler(w http.ResponseWriter, r *http.Req return } // Get environment by name - env, err := h.Envs.GetByUUID(envVar) + env, err := h.Envs.Get(envVar) if err != nil { if err.Error() == "record not found" { apiErrorResponse(w, "environment not found", http.StatusNotFound, err) @@ -274,7 +329,7 @@ func (h *HandlersApi) EnvEnrollActionsHandler(w http.ResponseWriter, r *http.Req // Get context data and check access ctx := r.Context().Value(ContextKey(contextAPI)).(ContextValue) if !h.Users.CheckPermissions(ctx[ctxUser], users.AdminLevel, env.UUID) { - apiErrorResponse(w, "no access", http.StatusForbidden, fmt.Errorf("attempt to use API by user %s", ctx[ctxUser])) + h.denyEnv(w, r, ctx, env.ID, "permission check failed") return } // Extract action @@ -362,7 +417,7 @@ func (h *HandlersApi) EnvRemoveActionsHandler(w http.ResponseWriter, r *http.Req return } // Get environment by name - env, err := h.Envs.GetByUUID(envVar) + env, err := h.Envs.Get(envVar) if err != nil { if err.Error() == "record not found" { apiErrorResponse(w, "environment not found", http.StatusNotFound, err) @@ -374,7 +429,7 @@ func (h *HandlersApi) EnvRemoveActionsHandler(w http.ResponseWriter, r *http.Req // Get context data and check access ctx := r.Context().Value(ContextKey(contextAPI)).(ContextValue) if !h.Users.CheckPermissions(ctx[ctxUser], users.AdminLevel, env.UUID) { - apiErrorResponse(w, "no access", http.StatusForbidden, fmt.Errorf("attempt to use API by user %s", ctx[ctxUser])) + h.denyEnv(w, r, ctx, env.ID, "permission check failed") return } // Extract action @@ -433,7 +488,7 @@ func (h *HandlersApi) EnvActionsHandler(w http.ResponseWriter, r *http.Request) // Get context data and check access ctx := r.Context().Value(ContextKey(contextAPI)).(ContextValue) if !h.Users.CheckPermissions(ctx[ctxUser], users.AdminLevel, users.NoEnvironment) { - apiErrorResponse(w, "no access", http.StatusForbidden, fmt.Errorf("attempt to use API by user %s", ctx[ctxUser])) + h.denyEnv(w, r, ctx, auditlog.NoEnvironment, "permission check failed") return } var e types.ApiEnvRequest @@ -450,6 +505,24 @@ func (h *HandlersApi) EnvActionsHandler(w http.ResponseWriter, r *http.Request) apiErrorResponse(w, "invalid data", http.StatusBadRequest, nil) return } + // Validate the optional client-supplied UUID strictly. + // + // - utils.CheckUUID delegates to google/uuid Parse, accepting only + // canonical UUIDs. EnvUUIDFilter alone is `^[a-z0-9-]+$`, which + // would have happily accepted "-", "a", "deadbeef", etc. + // - ExistsByUUID (vs the polymorphic Exists) ensures a UUID-collision + // check cannot match against an existing env's NAME. The old + // Exists(e.UUID) leaked information across axes. + if e.UUID != "" { + if !utils.CheckUUID(e.UUID) { + apiErrorResponse(w, "invalid uuid", http.StatusBadRequest, fmt.Errorf("rejected uuid %q", e.UUID)) + return + } + if h.Envs.ExistsByUUID(e.UUID) { + apiErrorResponse(w, "uuid already in use", http.StatusConflict, fmt.Errorf("uuid %q collides", e.UUID)) + return + } + } // Check if environment already exists if !h.Envs.Exists(e.Name) { env := h.Envs.Empty(e.Name, e.Hostname) @@ -481,18 +554,18 @@ func (h *HandlersApi) EnvActionsHandler(w http.ResponseWriter, r *http.Request) } // Create a tag for this new environment if !h.Tags.Exists(env.Name) { - if err := h.Tags.NewTag( - env.Name, - "Tag for environment "+env.Name, - "", - env.Icon, - ctx[ctxUser], - env.ID, - false, - tags.TagTypeEnv, - ""); err != nil { - msgReturn = fmt.Sprintf("error generating tag %s ", err.Error()) - return + if err := h.Tags.NewTag( + env.Name, + "Tag for environment "+env.Name, + "", + env.Icon, + ctx[ctxUser], + env.ID, + false, + tags.TagTypeEnv, + ""); err != nil { + apiErrorResponse(w, "error generating tag", http.StatusInternalServerError, err) + return } } msgReturn = "environment created successfully" @@ -501,21 +574,37 @@ func (h *HandlersApi) EnvActionsHandler(w http.ResponseWriter, r *http.Request) return } case "delete": - // Verify request fields + // Validate both name and UUID strictly, then verify they refer to + // the SAME environment so the request can't authorise via one + // env's UUID while targeting another env by name. The previous + // shape (polymorphic Exists(e.UUID) → Delete(e.Name)) allowed + // that authorisation/target split. if !environments.EnvNameFilter(e.Name) { apiErrorResponse(w, "invalid environment name", http.StatusBadRequest, nil) return } - if h.Envs.Exists(e.UUID) { - if err := h.Envs.Delete(e.Name); err != nil { - apiErrorResponse(w, "error deleting environment", http.StatusInternalServerError, err) - return - } - msgReturn = "environment deleted successfully" - } else { - apiErrorResponse(w, "environment not found", http.StatusNotFound, fmt.Errorf("environment %s not found", e.Name)) + if e.UUID == "" { + apiErrorResponse(w, "missing environment UUID", http.StatusBadRequest, nil) + return + } + if !utils.CheckUUID(e.UUID) { + apiErrorResponse(w, "invalid environment UUID", http.StatusBadRequest, nil) + return + } + targetEnv, getErr := h.Envs.GetByUUID(e.UUID) + if getErr != nil { + apiErrorResponse(w, "environment not found", http.StatusNotFound, fmt.Errorf("environment %s not found", e.UUID)) + return + } + if targetEnv.Name != e.Name { + apiErrorResponse(w, "name does not match the environment with that UUID", http.StatusBadRequest, fmt.Errorf("uuid %s maps to name %q, body claims %q", e.UUID, targetEnv.Name, e.Name)) + return + } + if err := h.Envs.Delete(targetEnv.Name); err != nil { + apiErrorResponse(w, "error deleting environment", http.StatusInternalServerError, err) return } + msgReturn = "environment deleted successfully" case "edit": // Verify request fields if !environments.EnvUUIDFilter(e.UUID) { diff --git a/cmd/api/handlers/environments_crud.go b/cmd/api/handlers/environments_crud.go new file mode 100644 index 00000000..11b5898a --- /dev/null +++ b/cmd/api/handlers/environments_crud.go @@ -0,0 +1,506 @@ +package handlers + +import ( + "encoding/json" + "errors" + "fmt" + "net/http" + "strings" + + "github.com/jmpsec/osctrl/pkg/environments" + "github.com/jmpsec/osctrl/pkg/tags" + "github.com/jmpsec/osctrl/pkg/types" + "github.com/jmpsec/osctrl/pkg/users" + "github.com/jmpsec/osctrl/pkg/utils" + "github.com/rs/zerolog/log" + "gorm.io/gorm" +) + +// EnvironmentCreateHandler - POST /api/v1/environments +// +// Body: { name, hostname, type? }. Generates a UUID, defaults config / +// schedule / packs / decorators / ATC to "{}", and persists the env. +// Returns 201 with the created TLSEnvironment. Super-admin only. +func (h *HandlersApi) EnvironmentCreateHandler(w http.ResponseWriter, r *http.Request) { + if h.DebugHTTPConfig.EnableHTTP { + utils.DebugHTTPDump(h.DebugHTTP, r, h.DebugHTTPConfig.ShowBody) + } + ctx := r.Context().Value(ContextKey(contextAPI)).(ContextValue) + if !h.Users.CheckPermissions(ctx[ctxUser], users.AdminLevel, users.NoEnvironment) { + apiErrorResponse(w, "no access", http.StatusForbidden, fmt.Errorf("attempt to use API by user %s", ctx[ctxUser])) + return + } + var body types.EnvCreateRequest + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + apiErrorResponse(w, "error parsing POST body", http.StatusBadRequest, err) + return + } + body.Name = strings.TrimSpace(body.Name) + body.Hostname = strings.TrimSpace(body.Hostname) + if !environments.VerifyEnvFilters(body.Name, body.Icon, body.Type, body.Hostname) { + apiErrorResponse(w, "invalid name, hostname, type, or icon", http.StatusBadRequest, nil) + return + } + if h.Envs.Exists(body.Name) { + apiErrorResponse(w, "environment with that name already exists", http.StatusConflict, nil) + return + } + env := h.Envs.Empty(body.Name, body.Hostname) + if body.Type != "" { + env.Type = body.Type + } + if body.Icon != "" { + env.Icon = body.Icon + } + env.Configuration = h.Envs.GenEmptyConfiguration(true) + flags, err := h.Envs.GenerateFlags(env, "", "", h.OsqueryValues) + if err != nil { + apiErrorResponse(w, "error generating flags", http.StatusInternalServerError, err) + return + } + env.Flags = flags + if err := h.Envs.Create(&env); err != nil { + apiErrorResponse(w, "error creating environment", http.StatusInternalServerError, err) + return + } + // Grant the creating user full access to the new environment so it shows up + // in their env list immediately (matches the legacy admin behaviour). + access := h.Users.GenEnvUserAccess([]string{env.UUID}, true, true, true, true) + perms := h.Users.GenPermissions(ctx[ctxUser], h.ServiceName, access) + if err := h.Users.CreatePermissions(perms); err != nil { + log.Err(err).Msgf("env %s created but failed to grant creator permissions", env.Name) + } + // Auto-tag the environment for tag-based targeting. + if !h.Tags.ExistsByEnv(env.Name, env.ID) { + if err := h.Tags.NewTag( + env.Name, + "Tag for environment "+env.Name, + "", + env.Icon, + ctx[ctxUser], + env.ID, + false, + tags.TagTypeEnv, + "", + ); err != nil { + log.Err(err).Msgf("env %s created but failed to create env tag", env.Name) + } + } + h.AuditLog.EnvAction(ctx[ctxUser], "create env "+env.Name, strings.Split(r.RemoteAddr, ":")[0], env.ID) + log.Debug().Msgf("Created environment %s (uuid=%s)", env.Name, env.UUID) + utils.HTTPResponse(w, utils.JSONApplicationUTF8, http.StatusCreated, env) +} + +// EnvironmentUpdateHandler - PATCH /api/v1/environments/{env} +// +// Updates name / hostname / type / icon / debug_http / accept_enrolls. +// Other env fields go through the per-section endpoints. Super-admin only. +func (h *HandlersApi) EnvironmentUpdateHandler(w http.ResponseWriter, r *http.Request) { + if h.DebugHTTPConfig.EnableHTTP { + utils.DebugHTTPDump(h.DebugHTTP, r, h.DebugHTTPConfig.ShowBody) + } + ctx := r.Context().Value(ContextKey(contextAPI)).(ContextValue) + if !h.Users.CheckPermissions(ctx[ctxUser], users.AdminLevel, users.NoEnvironment) { + apiErrorResponse(w, "no access", http.StatusForbidden, fmt.Errorf("attempt to use API by user %s", ctx[ctxUser])) + return + } + envVar := r.PathValue("env") + if envVar == "" { + apiErrorResponse(w, "missing env", http.StatusBadRequest, nil) + return + } + env, err := h.Envs.Get(envVar) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + apiErrorResponse(w, "environment not found", http.StatusNotFound, err) + return + } + apiErrorResponse(w, "error getting environment", http.StatusInternalServerError, err) + return + } + var body types.EnvUpdateRequest + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + apiErrorResponse(w, "error parsing PATCH body", http.StatusBadRequest, err) + return + } + // Validate every supplied field with the same character-class + // filters the create path uses. Without this gate a super-admin + // (or a compromised super-admin session via a future CSRF gap) + // can PATCH the env name to anything — including shell + // metacharacters and newlines that downstream interpolators + // (genPackageFilename → Content-Disposition, audit-log lines, + // route paths) would happily embed unescaped. + // + patch := map[string]interface{}{} + if body.Name != nil { + n := strings.TrimSpace(*body.Name) + if !environments.EnvNameFilter(n) { + apiErrorResponse(w, "invalid environment name", http.StatusBadRequest, fmt.Errorf("rejected name %q", *body.Name)) + return + } + if n != env.Name { + patch["name"] = n + } + } + if body.Hostname != nil { + host := strings.TrimSpace(*body.Hostname) + if !environments.HostnameFilter(host) { + apiErrorResponse(w, "invalid hostname", http.StatusBadRequest, fmt.Errorf("rejected hostname %q", *body.Hostname)) + return + } + if host != env.Hostname { + patch["hostname"] = host + } + } + if body.Type != nil { + t := strings.TrimSpace(*body.Type) + if !environments.EnvTypeFilter(t) { + apiErrorResponse(w, "invalid environment type", http.StatusBadRequest, fmt.Errorf("rejected type %q", *body.Type)) + return + } + patch["type"] = t + } + if body.Icon != nil { + icon := strings.TrimSpace(*body.Icon) + if !environments.IconFilter(icon) { + apiErrorResponse(w, "invalid icon", http.StatusBadRequest, fmt.Errorf("rejected icon %q", *body.Icon)) + return + } + patch["icon"] = icon + } + if body.DebugHTTP != nil { + patch["debug_http"] = *body.DebugHTTP + } + if body.AcceptEnrolls != nil { + patch["accept_enrolls"] = *body.AcceptEnrolls + } + if len(patch) == 0 { + // Idempotent no-op — return the current env. + utils.HTTPResponse(w, utils.JSONApplicationUTF8, http.StatusOK, env) + return + } + if err := h.Envs.DB.Model(&env).Updates(patch).Error; err != nil { + apiErrorResponse(w, "error updating environment", http.StatusInternalServerError, err) + return + } + updated, _ := h.Envs.Get(envVar) + h.AuditLog.EnvAction(ctx[ctxUser], "update env "+env.Name, strings.Split(r.RemoteAddr, ":")[0], env.ID) + log.Debug().Msgf("Updated environment %s", env.Name) + utils.HTTPResponse(w, utils.JSONApplicationUTF8, http.StatusOK, updated) +} + +// EnvironmentDeleteHandler - DELETE /api/v1/environments/{env} +// +// Removes the environment. Super-admin only. Returns 200 with a message. +func (h *HandlersApi) EnvironmentDeleteHandler(w http.ResponseWriter, r *http.Request) { + if h.DebugHTTPConfig.EnableHTTP { + utils.DebugHTTPDump(h.DebugHTTP, r, h.DebugHTTPConfig.ShowBody) + } + ctx := r.Context().Value(ContextKey(contextAPI)).(ContextValue) + if !h.Users.CheckPermissions(ctx[ctxUser], users.AdminLevel, users.NoEnvironment) { + apiErrorResponse(w, "no access", http.StatusForbidden, fmt.Errorf("attempt to use API by user %s", ctx[ctxUser])) + return + } + envVar := r.PathValue("env") + if envVar == "" { + apiErrorResponse(w, "missing env", http.StatusBadRequest, nil) + return + } + env, err := h.Envs.Get(envVar) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + apiErrorResponse(w, "environment not found", http.StatusNotFound, err) + return + } + apiErrorResponse(w, "error getting environment", http.StatusInternalServerError, err) + return + } + if err := h.Envs.Delete(envVar); err != nil { + apiErrorResponse(w, "error deleting environment", http.StatusInternalServerError, err) + return + } + h.AuditLog.EnvAction(ctx[ctxUser], "delete env "+env.Name, strings.Split(r.RemoteAddr, ":")[0], env.ID) + log.Debug().Msgf("Deleted environment %s", env.Name) + utils.HTTPResponse(w, utils.JSONApplicationUTF8, http.StatusOK, types.ApiGenericResponse{Message: fmt.Sprintf("environment %s deleted", env.Name)}) +} + +// EnvironmentConfigHandler - GET /api/v1/environments/config/{env} +// +// Returns the env's JSON-shaped config sections (options/schedule/packs/ +// decorators/atc/flags) so the SPA's Monaco editor can render each section. +func (h *HandlersApi) EnvironmentConfigHandler(w http.ResponseWriter, r *http.Request) { + if h.DebugHTTPConfig.EnableHTTP { + utils.DebugHTTPDump(h.DebugHTTP, r, h.DebugHTTPConfig.ShowBody) + } + envVar := r.PathValue("env") + if envVar == "" { + apiErrorResponse(w, "missing env", http.StatusBadRequest, nil) + return + } + env, err := h.Envs.Get(envVar) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + apiErrorResponse(w, "environment not found", http.StatusNotFound, err) + return + } + apiErrorResponse(w, "error getting environment", http.StatusInternalServerError, err) + return + } + ctx := r.Context().Value(ContextKey(contextAPI)).(ContextValue) + if !h.Users.CheckPermissions(ctx[ctxUser], users.AdminLevel, env.UUID) { + apiErrorResponse(w, "no access", http.StatusForbidden, fmt.Errorf("attempt to use API by user %s", ctx[ctxUser])) + return + } + resp := types.EnvConfigResponse{ + Options: env.Options, + Schedule: env.Schedule, + Packs: env.Packs, + Decorators: env.Decorators, + ATC: env.ATC, + Flags: env.Flags, + } + h.AuditLog.Visit(ctx[ctxUser], r.URL.Path, strings.Split(r.RemoteAddr, ":")[0], env.ID) + utils.HTTPResponse(w, utils.JSONApplicationUTF8, http.StatusOK, resp) +} + +// EnvironmentConfigPatchHandler - PATCH /api/v1/environments/config/{env} +// +// Body: optional options/schedule/packs/decorators/atc/flags string fields. +// Each non-nil field is validated as JSON before persisting; an invalid +// payload is rejected with 400 (no partial writes). +func (h *HandlersApi) EnvironmentConfigPatchHandler(w http.ResponseWriter, r *http.Request) { + if h.DebugHTTPConfig.EnableHTTP { + utils.DebugHTTPDump(h.DebugHTTP, r, h.DebugHTTPConfig.ShowBody) + } + envVar := r.PathValue("env") + if envVar == "" { + apiErrorResponse(w, "missing env", http.StatusBadRequest, nil) + return + } + env, err := h.Envs.Get(envVar) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + apiErrorResponse(w, "environment not found", http.StatusNotFound, err) + return + } + apiErrorResponse(w, "error getting environment", http.StatusInternalServerError, err) + return + } + ctx := r.Context().Value(ContextKey(contextAPI)).(ContextValue) + if !h.Users.CheckPermissions(ctx[ctxUser], users.AdminLevel, env.UUID) { + apiErrorResponse(w, "no access", http.StatusForbidden, fmt.Errorf("attempt to use API by user %s", ctx[ctxUser])) + return + } + var body types.EnvConfigPatchRequest + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + apiErrorResponse(w, "error parsing PATCH body", http.StatusBadRequest, err) + return + } + // Validate every supplied section is parseable JSON before writing any. + sections := map[string]*string{ + "options": body.Options, + "schedule": body.Schedule, + "packs": body.Packs, + "decorators": body.Decorators, + "atc": body.ATC, + "flags": body.Flags, + } + for name, val := range sections { + if val == nil { + continue + } + // Empty string isn't valid JSON; treat as the empty object. + s := strings.TrimSpace(*val) + if s == "" { + s = "{}" + } + var probe interface{} + if err := json.Unmarshal([]byte(s), &probe); err != nil { + apiErrorResponse(w, fmt.Sprintf("section %q is not valid JSON: %s", name, err.Error()), http.StatusBadRequest, err) + return + } + } + if body.Options != nil { + if err := h.Envs.UpdateOptions(envVar, *body.Options); err != nil { + apiErrorResponse(w, "error updating options", http.StatusInternalServerError, err) + return + } + } + if body.Schedule != nil { + if err := h.Envs.UpdateSchedule(envVar, *body.Schedule); err != nil { + apiErrorResponse(w, "error updating schedule", http.StatusInternalServerError, err) + return + } + } + if body.Packs != nil { + if err := h.Envs.UpdatePacks(envVar, *body.Packs); err != nil { + apiErrorResponse(w, "error updating packs", http.StatusInternalServerError, err) + return + } + } + if body.Decorators != nil { + if err := h.Envs.UpdateDecorators(envVar, *body.Decorators); err != nil { + apiErrorResponse(w, "error updating decorators", http.StatusInternalServerError, err) + return + } + } + if body.ATC != nil { + if err := h.Envs.UpdateATC(envVar, *body.ATC); err != nil { + apiErrorResponse(w, "error updating atc", http.StatusInternalServerError, err) + return + } + } + if body.Flags != nil { + if err := h.Envs.DB.Model(&env).Update("flags", *body.Flags).Error; err != nil { + apiErrorResponse(w, "error updating flags", http.StatusInternalServerError, err) + return + } + } + h.AuditLog.ConfAction(ctx[ctxUser], "config patch on env "+env.Name, strings.Split(r.RemoteAddr, ":")[0], env.ID) + updated, _ := h.Envs.Get(envVar) + resp := types.EnvConfigResponse{ + Options: updated.Options, + Schedule: updated.Schedule, + Packs: updated.Packs, + Decorators: updated.Decorators, + ATC: updated.ATC, + Flags: updated.Flags, + } + utils.HTTPResponse(w, utils.JSONApplicationUTF8, http.StatusOK, resp) +} + +// EnvironmentIntervalsPatchHandler - PATCH /api/v1/environments/intervals/{env} +// +// Body: { config_interval?, log_interval?, query_interval? }. Updates the +// three node-pull intervals atomically. Unsupplied fields are kept. +func (h *HandlersApi) EnvironmentIntervalsPatchHandler(w http.ResponseWriter, r *http.Request) { + if h.DebugHTTPConfig.EnableHTTP { + utils.DebugHTTPDump(h.DebugHTTP, r, h.DebugHTTPConfig.ShowBody) + } + envVar := r.PathValue("env") + if envVar == "" { + apiErrorResponse(w, "missing env", http.StatusBadRequest, nil) + return + } + env, err := h.Envs.Get(envVar) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + apiErrorResponse(w, "environment not found", http.StatusNotFound, err) + return + } + apiErrorResponse(w, "error getting environment", http.StatusInternalServerError, err) + return + } + ctx := r.Context().Value(ContextKey(contextAPI)).(ContextValue) + if !h.Users.CheckPermissions(ctx[ctxUser], users.AdminLevel, env.UUID) { + apiErrorResponse(w, "no access", http.StatusForbidden, fmt.Errorf("attempt to use API by user %s", ctx[ctxUser])) + return + } + var body types.EnvIntervalsPatchRequest + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + apiErrorResponse(w, "error parsing PATCH body", http.StatusBadRequest, err) + return + } + cfg := env.ConfigInterval + lg := env.LogInterval + qr := env.QueryInterval + if body.ConfigInterval != nil { + if *body.ConfigInterval < 1 { + apiErrorResponse(w, "config_interval must be >= 1", http.StatusBadRequest, nil) + return + } + cfg = *body.ConfigInterval + } + if body.LogInterval != nil { + if *body.LogInterval < 1 { + apiErrorResponse(w, "log_interval must be >= 1", http.StatusBadRequest, nil) + return + } + lg = *body.LogInterval + } + if body.QueryInterval != nil { + if *body.QueryInterval < 1 { + apiErrorResponse(w, "query_interval must be >= 1", http.StatusBadRequest, nil) + return + } + qr = *body.QueryInterval + } + if err := h.Envs.UpdateIntervals(env.Name, cfg, lg, qr); err != nil { + apiErrorResponse(w, "error updating intervals", http.StatusInternalServerError, err) + return + } + h.AuditLog.ConfAction(ctx[ctxUser], + fmt.Sprintf("intervals patch on env %s: config=%d log=%d query=%d", env.Name, cfg, lg, qr), + strings.Split(r.RemoteAddr, ":")[0], env.ID) + updated, _ := h.Envs.Get(envVar) + utils.HTTPResponse(w, utils.JSONApplicationUTF8, http.StatusOK, updated) +} + +// EnvironmentExpirationPatchHandler - PATCH /api/v1/environments/expiration/{env} +// +// Convenience wrapper around the existing enrollment lifecycle actions +// (extend / expire / rotate / not-expire), accepting one of those actions +// via JSON body instead of as a path segment. Mirrors the legacy +// EnvEnrollActionsHandler semantics for both enroll and remove paths. +func (h *HandlersApi) EnvironmentExpirationPatchHandler(w http.ResponseWriter, r *http.Request) { + if h.DebugHTTPConfig.EnableHTTP { + utils.DebugHTTPDump(h.DebugHTTP, r, h.DebugHTTPConfig.ShowBody) + } + envVar := r.PathValue("env") + if envVar == "" { + apiErrorResponse(w, "missing env", http.StatusBadRequest, nil) + return + } + env, err := h.Envs.Get(envVar) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + apiErrorResponse(w, "environment not found", http.StatusNotFound, err) + return + } + apiErrorResponse(w, "error getting environment", http.StatusInternalServerError, err) + return + } + ctx := r.Context().Value(ContextKey(contextAPI)).(ContextValue) + if !h.Users.CheckPermissions(ctx[ctxUser], users.AdminLevel, env.UUID) { + apiErrorResponse(w, "no access", http.StatusForbidden, fmt.Errorf("attempt to use API by user %s", ctx[ctxUser])) + return + } + var body types.EnvExpirationPatchRequest + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + apiErrorResponse(w, "error parsing PATCH body", http.StatusBadRequest, err) + return + } + switch body.Action { + case "extend": + if err := h.Envs.ExtendEnroll(env.UUID); err != nil { + apiErrorResponse(w, "error extending enrollment", http.StatusInternalServerError, err) + return + } + case "expire": + if err := h.Envs.ExpireEnroll(env.UUID); err != nil { + apiErrorResponse(w, "error expiring enrollment", http.StatusInternalServerError, err) + return + } + case "rotate": + if err := h.Envs.RotateEnroll(env.UUID); err != nil { + apiErrorResponse(w, "error rotating enrollment", http.StatusInternalServerError, err) + return + } + case "not-expire": + if err := h.Envs.NotExpireEnroll(env.UUID); err != nil { + apiErrorResponse(w, "error setting no expiration", http.StatusInternalServerError, err) + return + } + default: + apiErrorResponse(w, "action must be one of: extend, expire, rotate, not-expire", http.StatusBadRequest, nil) + return + } + h.AuditLog.EnvAction(ctx[ctxUser], body.Action+" enrollment for env "+env.Name, strings.Split(r.RemoteAddr, ":")[0], env.ID) + updated, _ := h.Envs.Get(envVar) + utils.HTTPResponse(w, utils.JSONApplicationUTF8, http.StatusOK, updated) +} + +// Suppress unused-import warning if environments package isn't referenced +// elsewhere in this file (it is — used by EnvUpdateRequest typing). This +// stub is a no-op kept to keep the import obvious. +var _ = environments.EnrollShell diff --git a/cmd/api/handlers/environments_test.go b/cmd/api/handlers/environments_test.go new file mode 100644 index 00000000..6ed775c7 --- /dev/null +++ b/cmd/api/handlers/environments_test.go @@ -0,0 +1,88 @@ +package handlers + +import ( + "encoding/json" + "strings" + "testing" + "time" + + "github.com/jmpsec/osctrl/pkg/environments" +) + +// TestProjectEnvironmentViewStripsSecrets is the load-bearing regression test +// for the env-secret-containment fix. projectEnvironmentView returns the SPA +// envelope served to UserLevel operators; if a future contributor adds a new +// secret-bearing field to TLSEnvironment without extending the projection, +// the field will leak into the low-priv response. This test marshals the +// projection from a fully-populated source struct and asserts every +// known-sensitive substring is absent from the serialized JSON. +func TestProjectEnvironmentViewStripsSecrets(t *testing.T) { + src := environments.TLSEnvironment{ + ID: 1, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + UUID: "11111111-2222-3333-4444-555555555555", + Name: "prod", + Hostname: "osctrl.example.com", + Type: "dev", + Icon: "rocket", + // The fields below must NOT appear in the projection. + Secret: "SECRET-MARKER-enroll", + EnrollSecretPath: "SECRET-MARKER-enroll-path", + RemoveSecretPath: "SECRET-MARKER-remove-path", + Certificate: "SECRET-MARKER-cert", + Flags: "SECRET-MARKER-flags", + Options: "SECRET-MARKER-options", + Schedule: "SECRET-MARKER-schedule", + Packs: "SECRET-MARKER-packs", + Decorators: "SECRET-MARKER-decorators", + ATC: "SECRET-MARKER-atc", + Configuration: "SECRET-MARKER-configuration", + DebPackage: "SECRET-MARKER-deb", + RpmPackage: "SECRET-MARKER-rpm", + MsiPackage: "SECRET-MARKER-msi", + PkgPackage: "SECRET-MARKER-pkg", + EnrollPath: "SECRET-MARKER-enroll-route", + LogPath: "SECRET-MARKER-log-route", + ConfigPath: "SECRET-MARKER-config-route", + QueryReadPath: "SECRET-MARKER-qread-route", + QueryWritePath: "SECRET-MARKER-qwrite-route", + CarverInitPath: "SECRET-MARKER-carver-init", + CarverBlockPath: "SECRET-MARKER-carver-block", + UserID: 42, + // Operational fields that ARE expected in the view: + ConfigInterval: 60, + LogInterval: 30, + QueryInterval: 10, + AcceptEnrolls: true, + } + + view := projectEnvironmentView(src) + out, err := json.Marshal(view) + if err != nil { + t.Fatalf("marshal: %v", err) + } + body := string(out) + + // Field set + tag names assertions. + wantFields := []string{ + `"uuid":"11111111-2222-3333-4444-555555555555"`, + `"name":"prod"`, + `"hostname":"osctrl.example.com"`, + `"icon":"rocket"`, + `"config_interval":60`, + `"log_interval":30`, + `"query_interval":10`, + `"accept_enrolls":true`, + } + for _, w := range wantFields { + if !strings.Contains(body, w) { + t.Errorf("expected %q in view JSON, got: %s", w, body) + } + } + + // Every SECRET-MARKER must be absent. + if strings.Contains(body, "SECRET-MARKER") { + t.Fatalf("view leaked at least one secret-bearing field: %s", body) + } +} diff --git a/cmd/api/handlers/handlers.go b/cmd/api/handlers/handlers.go index 4b6b5b85..dea325ef 100644 --- a/cmd/api/handlers/handlers.go +++ b/cmd/api/handlers/handlers.go @@ -11,6 +11,7 @@ import ( "github.com/jmpsec/osctrl/pkg/queries" "github.com/jmpsec/osctrl/pkg/settings" "github.com/jmpsec/osctrl/pkg/tags" + "github.com/jmpsec/osctrl/pkg/types" "github.com/jmpsec/osctrl/pkg/users" "github.com/rs/zerolog" "github.com/rs/zerolog/log" @@ -36,6 +37,7 @@ type HandlersApi struct { ApiConfig *config.APIConfiguration DebugHTTP *zerolog.Logger DebugHTTPConfig *config.YAMLConfigurationDebug + OsqueryTables []types.OsqueryTable OsqueryValues config.YAMLConfigurationOsquery } @@ -112,12 +114,19 @@ func WithAuditLog(auditLog *auditlog.AuditLogManager) HandlersOption { h.AuditLog = auditLog } } + func WithOsqueryValues(values config.YAMLConfigurationOsquery) HandlersOption { return func(h *HandlersApi) { h.OsqueryValues = values } } +func WithOsqueryTables(tables []types.OsqueryTable) HandlersOption { + return func(h *HandlersApi) { + h.OsqueryTables = tables + } +} + func WithDebugHTTP(cfg *config.YAMLConfigurationDebug) HandlersOption { return func(h *HandlersApi) { h.DebugHTTPConfig = cfg diff --git a/cmd/api/handlers/login.go b/cmd/api/handlers/login.go index 4926890a..8eb75752 100644 --- a/cmd/api/handlers/login.go +++ b/cmd/api/handlers/login.go @@ -1,9 +1,12 @@ package handlers import ( + "crypto/rand" + "encoding/hex" "encoding/json" "fmt" "net/http" + "time" "github.com/jmpsec/osctrl/pkg/types" "github.com/jmpsec/osctrl/pkg/users" @@ -22,10 +25,13 @@ func (h *HandlersApi) LoginHandler(w http.ResponseWriter, r *http.Request) { apiErrorResponse(w, "error with environment", http.StatusBadRequest, nil) return } - // Get environment by UUID - env, err := h.Envs.GetByUUID(envVar) + // Resolve environment by name OR UUID. The SPA login form lets users type + // the env name ("dev", "prod") because UUIDs are not memorable; the API + // must accept either. Get() uses `name = ? OR uuid = ?` so both shapes + // resolve to the same row. A miss returns 404, not 500. + env, err := h.Envs.Get(envVar) if err != nil { - apiErrorResponse(w, "error getting environment", http.StatusInternalServerError, nil) + apiErrorResponse(w, "environment not found", http.StatusNotFound, nil) return } var l types.ApiLoginRequest @@ -34,31 +40,101 @@ func (h *HandlersApi) LoginHandler(w http.ResponseWriter, r *http.Request) { apiErrorResponse(w, "error parsing POST body", http.StatusInternalServerError, err) return } - // Check credentials + // Check credentials. Audit-log every credential failure so SoC tooling + // has a stream to alert on (brute-force, password spray). The IP comes + // from utils.GetIP so X-Real-IP / X-Forwarded-For behind a reverse + // proxy is honored. access, user := h.Users.CheckLoginCredentials(l.Username, l.Password) if !access { + h.AuditLog.FailedLogin(l.Username, utils.GetIP(r), "invalid credentials") apiErrorResponse(w, "invalid credentials", http.StatusForbidden, err) return } // Check if user has access to this environment if !h.Users.CheckPermissions(l.Username, users.AdminLevel, env.UUID) { + h.AuditLog.FailedLogin(l.Username, utils.GetIP(r), fmt.Sprintf("no admin access to env %s", env.UUID)) apiErrorResponse(w, "no access", http.StatusForbidden, fmt.Errorf("attempt to use %s by user %s", h.ServiceName, l.Username)) return } - // Do we have a token already? - if user.APIToken == "" { - token, exp, err := h.Users.CreateToken(l.Username, h.ServiceName, l.ExpHours) + // Decide whether to reuse the stored token or mint a fresh one. Re-issue + // when there's no token, when the stored token has already expired (the + // reuse path used to return 500 "token already expired" — a regression + // that locked users out after their first session expired), or when the + // stored token is within 60s of expiring so we don't hand out something + // that will fail mid-request. + var tokenExp time.Time + now := time.Now() + const freshnessWindow = 60 * time.Second + needsRefresh := user.APIToken == "" || user.TokenExpire.Before(now.Add(freshnessWindow)) + if needsRefresh { + var token string + token, tokenExp, err = h.Users.CreateToken(l.Username, h.ServiceName, l.ExpHours) if err != nil { apiErrorResponse(w, "error creating token", http.StatusInternalServerError, err) return } - if err = h.Users.UpdateToken(l.Username, token, exp); err != nil { + if err = h.Users.UpdateToken(l.Username, token, tokenExp); err != nil { apiErrorResponse(w, "error updating token", http.StatusInternalServerError, err) return } user.APIToken = token + } else { + tokenExp = user.TokenExpire } - h.AuditLog.NewLogin(l.Username, r.RemoteAddr) - // Serialize and serve JSON - utils.HTTPResponse(w, utils.JSONApplicationUTF8, http.StatusOK, types.ApiLoginResponse{Token: user.APIToken}) + // Generate a CSRF token: 16 random bytes encoded as 32 hex chars. + // This cookie is NOT HttpOnly so the SPA can read it and echo it back + // via the X-CSRF-Token header on mutating requests. + csrfBytes := make([]byte, 16) + if _, err = rand.Read(csrfBytes); err != nil { + apiErrorResponse(w, "error generating csrf token", http.StatusInternalServerError, err) + return + } + csrfToken := hex.EncodeToString(csrfBytes) + // Persist the CSRF token alongside the user so the auth middleware can + // verify subsequent X-CSRF-Token headers. Without this write the SPA's + // double-submit pattern is purely cosmetic. + // IP comes from utils.GetIP so it matches the format every other site + // writes to last_ip_address (clean IP, X-Real-IP / X-Forwarded-For aware). + clientIP := utils.GetIP(r) + if err := h.Users.UpdateMetadata(clientIP, r.UserAgent(), l.Username, csrfToken); err != nil { + apiErrorResponse(w, "error persisting csrf token", http.StatusInternalServerError, err) + return + } + // Compute cookie Max-Age from token expiry. + maxAge := int(time.Until(tokenExp).Seconds()) + if maxAge <= 0 { + apiErrorResponse(w, "token already expired", http.StatusInternalServerError, fmt.Errorf("token expiry in past or zero: %v", tokenExp)) + return + } + // Set the httpOnly session cookie. The SPA reads the JWT via the cookie; + // it never needs to access this cookie from JS. + // Secure: true requires HTTPS. If TLS is terminated at a proxy that speaks + // plain HTTP to this service, set Secure:false in the proxy's cookie rewrite + // rule — do not add an --insecure-cookies flag to keep the surface small. + http.SetCookie(w, &http.Cookie{ + Name: "osctrl_token", + Value: user.APIToken, + Path: "/", + MaxAge: maxAge, + HttpOnly: true, + Secure: true, + SameSite: http.SameSiteLaxMode, + }) + // Set the CSRF cookie (not HttpOnly — SPA must read it). + http.SetCookie(w, &http.Cookie{ + Name: "osctrl_csrf", + Value: csrfToken, + Path: "/", + MaxAge: maxAge, + HttpOnly: false, + Secure: true, + SameSite: http.SameSiteLaxMode, + }) + h.AuditLog.NewLogin(l.Username, clientIP) + // Serialize and serve JSON. Token stays in the body for backward compat + // with CLI consumers that do not use cookies. + utils.HTTPResponse(w, utils.JSONApplicationUTF8, http.StatusOK, types.ApiLoginResponse{ + Token: user.APIToken, + CSRFToken: csrfToken, + }) } diff --git a/cmd/api/handlers/login_envs.go b/cmd/api/handlers/login_envs.go new file mode 100644 index 00000000..b1b5c729 --- /dev/null +++ b/cmd/api/handlers/login_envs.go @@ -0,0 +1,48 @@ +package handlers + +import ( + "net/http" + + "github.com/jmpsec/osctrl/pkg/types" + "github.com/jmpsec/osctrl/pkg/utils" +) + +// LoginEnvironmentsHandler - GET /api/v1/login/environments +// +// Pre-auth endpoint that returns the list of environments the user may attempt +// to log into. Surface is intentionally minimal: only the env UUID and name. +// No enroll secrets, no certificates, no settings, no hostnames — those all +// stay behind auth on /api/v1/environments and its CRUD siblings. +// +// Rationale: forcing the user to type the env name on the login screen is bad +// UX (you don't know it until you've logged in once, and single-env installs +// only ever have one option). The legacy admin shows env names pre-auth in its +// login form, so we're not changing the security posture — just exposing the +// same identifiers that the URL space already commits to using post-auth. +// +// Like POST /login/{env}, this lives behind the per-IP rate limit registered +// in main.go so the endpoint can't be turned into an env-enumeration oracle +// for brute-force prep beyond the limit. +func (h *HandlersApi) LoginEnvironmentsHandler(w http.ResponseWriter, r *http.Request) { + if h.DebugHTTPConfig.EnableHTTP { + utils.DebugHTTPDump(h.DebugHTTP, r, h.DebugHTTPConfig.ShowBody) + } + envs, err := h.Envs.All() + if err != nil { + apiErrorResponse(w, "error listing environments", http.StatusInternalServerError, err) + return + } + // Project to (uuid, name) only. Constructing the response explicitly + // guards against future fields being added to TLSEnvironment that + // shouldn't be exposed pre-auth — if someone adds e.g. a `Secret` field + // to that struct later, this handler still ships only the two fields + // listed here. + out := make([]types.LoginEnvironment, 0, len(envs)) + for _, e := range envs { + out = append(out, types.LoginEnvironment{ + UUID: e.UUID, + Name: e.Name, + }) + } + utils.HTTPResponse(w, utils.JSONApplicationUTF8, http.StatusOK, out) +} diff --git a/cmd/api/handlers/logs.go b/cmd/api/handlers/logs.go new file mode 100644 index 00000000..8f71250b --- /dev/null +++ b/cmd/api/handlers/logs.go @@ -0,0 +1,124 @@ +package handlers + +import ( + "net/http" + "strconv" + "strings" + "time" + + "github.com/jmpsec/osctrl/pkg/logging" + "github.com/jmpsec/osctrl/pkg/types" + "github.com/jmpsec/osctrl/pkg/users" + "github.com/jmpsec/osctrl/pkg/utils" + "github.com/rs/zerolog/log" +) + +// NodeLogsResponse is the SPA-canonical response for GET /api/v1/logs/{type}/{env}/{uuid}. +type NodeLogsResponse struct { + Items []map[string]any `json:"items"` + Type string `json:"type"` + UUID string `json:"uuid"` + Env string `json:"env"` + Since string `json:"since,omitempty"` + Limit int `json:"limit"` +} + +// NodeLogsHandler returns recent log entries for a node. +// +// Path: /api/v1/logs/{type}/{env}/{uuid} +// +// type: "status" | "result" +// env: UUID or name +// uuid: node UUID +// +// Query params: +// +// since: RFC3339 timestamp; entries strictly after this point only +// limit: 1..1000 (default 100) +func (h *HandlersApi) NodeLogsHandler(w http.ResponseWriter, r *http.Request) { + // Debug HTTP if enabled + if h.DebugHTTPConfig.EnableHTTP { + utils.DebugHTTPDump(h.DebugHTTP, r, h.DebugHTTPConfig.ShowBody) + } + logType := r.PathValue("type") + switch logType { + case types.StatusLog, types.ResultLog: + default: + apiErrorResponse(w, "invalid log type (status|result)", http.StatusBadRequest, nil) + return + } + envVar := r.PathValue("env") + nodeUUID := r.PathValue("uuid") + + env, err := h.Envs.Get(envVar) + if err != nil { + envByName, err2 := h.Envs.GetByName(envVar) + if err2 != nil { + apiErrorResponse(w, "environment not found", http.StatusNotFound, err) + return + } + env = envByName + } + + ctx := r.Context().Value(ContextKey(contextAPI)).(ContextValue) + if !h.Users.CheckPermissions(ctx[ctxUser], users.UserLevel, env.UUID) { + apiErrorResponse(w, "no access", http.StatusForbidden, nil) + return + } + + // Verify the node exists in this env — prevents probing for arbitrary UUIDs + // across tenants (resolves cross-tenant log read vector). + node, err := h.Nodes.GetByUUID(nodeUUID) + if err != nil { + apiErrorResponse(w, "node not found", http.StatusNotFound, err) + return + } + if node.Environment == "" || !strings.EqualFold(node.Environment, env.Name) { + apiErrorResponse(w, "node not in environment", http.StatusForbidden, nil) + return + } + + q := r.URL.Query() + limit, _ := strconv.Atoi(q.Get("limit")) + if limit <= 0 { + limit = 100 + } + if limit > 1000 { + limit = 1000 + } + var since time.Time + if s := q.Get("since"); s != "" { + t, err := time.Parse(time.RFC3339, s) + if err != nil { + apiErrorResponse(w, "invalid since (expected RFC3339)", http.StatusBadRequest, err) + return + } + since = t + } + // Optional free-text filter. Substring match against the log row's + // human-readable columns (line / message / filename for status logs; + // name / action / columns JSON for result logs). Server-side so + // operators can search the full history, not just the visible page. + search := strings.TrimSpace(q.Get("q")) + + // Use the node's canonical UUID (already upper-cased in the DB) from the + // verified node record, not the raw URL parameter. + items, err := logging.GetNodeLogs(h.DB, logType, env.Name, node.UUID, since, limit, search) + if err != nil { + apiErrorResponse(w, "failed to query logs", http.StatusInternalServerError, err) + return + } + if items == nil { + items = []map[string]any{} + } + + log.Debug().Msgf("Returned %d %s log entries for node %s", len(items), logType, node.UUID) + utils.HTTPResponse(w, utils.JSONApplicationUTF8, http.StatusOK, NodeLogsResponse{ + Items: items, + Type: logType, + UUID: node.UUID, + Env: env.UUID, + Since: q.Get("since"), + Limit: limit, + }) +} diff --git a/cmd/api/handlers/nodes.go b/cmd/api/handlers/nodes.go index e1299ab7..b374d172 100644 --- a/cmd/api/handlers/nodes.go +++ b/cmd/api/handlers/nodes.go @@ -4,9 +4,11 @@ import ( "encoding/json" "fmt" "net/http" + "strconv" "strings" "github.com/jmpsec/osctrl/pkg/nodes" + "github.com/jmpsec/osctrl/pkg/settings" "github.com/jmpsec/osctrl/pkg/types" "github.com/jmpsec/osctrl/pkg/users" "github.com/jmpsec/osctrl/pkg/utils" @@ -26,7 +28,7 @@ func (h *HandlersApi) NodeHandler(w http.ResponseWriter, r *http.Request) { return } // Get environment - env, err := h.Envs.GetByUUID(envVar) + env, err := h.Envs.Get(envVar) if err != nil { apiErrorResponse(w, "error getting environment", http.StatusInternalServerError, nil) return @@ -43,9 +45,8 @@ func (h *HandlersApi) NodeHandler(w http.ResponseWriter, r *http.Request) { apiErrorResponse(w, "error getting node", http.StatusBadRequest, nil) return } - // Get node by identifier - // FIXME keep a cache of nodes by node identifier - node, err := h.Nodes.GetByIdentifier(nodeVar) + // Get node by identifier, scoped to this environment + node, err := h.Nodes.GetByIdentifierEnv(nodeVar, env.ID) if err != nil { if err.Error() == "record not found" { apiErrorResponse(w, "node not found", http.StatusNotFound, err) @@ -56,8 +57,11 @@ func (h *HandlersApi) NodeHandler(w http.ResponseWriter, r *http.Request) { } log.Debug().Msgf("Returned node %s", nodeVar) h.AuditLog.NodeAction(ctx[ctxUser], "viewed node "+nodeVar, strings.Split(r.RemoteAddr, ":")[0], env.ID) - // Serialize and serve JSON - utils.HTTPResponse(w, utils.JSONApplicationUTF8, http.StatusOK, node) + // Project to the SPA-facing view that surfaces parsed-and-sanitized + // enrichment fields (CPU cores, BIOS, hardware vendor/model) parsed from + // the otherwise-hidden RawEnrollment blob. The enroll_secret inside that + // blob is intentionally NOT in the projection — see pkg/types/node_view.go. + utils.HTTPResponse(w, utils.JSONApplicationUTF8, http.StatusOK, types.ProjectNode(node)) } // ActiveNodesHandler - GET Handler for active JSON nodes @@ -73,7 +77,7 @@ func (h *HandlersApi) ActiveNodesHandler(w http.ResponseWriter, r *http.Request) return } // Get environment - env, err := h.Envs.GetByUUID(envVar) + env, err := h.Envs.Get(envVar) if err != nil { apiErrorResponse(w, "error getting environment", http.StatusInternalServerError, nil) return @@ -84,20 +88,21 @@ func (h *HandlersApi) ActiveNodesHandler(w http.ResponseWriter, r *http.Request) apiErrorResponse(w, "no access", http.StatusForbidden, fmt.Errorf("attempt to use API by user %s", ctx[ctxUser])) return } - // Get nodes - nodes, err := h.Nodes.Gets(nodes.ActiveNodes, 24) + // Get nodes — scoped to this environment (resolves audit finding U-DB-2) + hours := h.Settings.InactiveHours(settings.NoEnvironmentID) + nodeList, err := h.Nodes.GetByEnv(env.Name, nodes.ActiveNodes, hours) if err != nil { apiErrorResponse(w, "error getting nodes", http.StatusInternalServerError, err) return } - if len(nodes) == 0 { + if len(nodeList) == 0 { apiErrorResponse(w, "no nodes", http.StatusNotFound, nil) return } // Serialize and serve JSON log.Debug().Msg("Returned active nodes") h.AuditLog.NodeAction(ctx[ctxUser], "viewed active nodes", strings.Split(r.RemoteAddr, ":")[0], env.ID) - utils.HTTPResponse(w, utils.JSONApplicationUTF8, http.StatusOK, nodes) + utils.HTTPResponse(w, utils.JSONApplicationUTF8, http.StatusOK, nodeList) } // InactiveNodesHandler - GET Handler for inactive JSON nodes @@ -113,7 +118,7 @@ func (h *HandlersApi) InactiveNodesHandler(w http.ResponseWriter, r *http.Reques return } // Get environment - env, err := h.Envs.GetByUUID(envVar) + env, err := h.Envs.Get(envVar) if err != nil { apiErrorResponse(w, "error getting environment", http.StatusInternalServerError, nil) return @@ -124,20 +129,21 @@ func (h *HandlersApi) InactiveNodesHandler(w http.ResponseWriter, r *http.Reques apiErrorResponse(w, "no access", http.StatusForbidden, fmt.Errorf("attempt to use API by user %s", ctx[ctxUser])) return } - // Get nodes - nodes, err := h.Nodes.Gets(nodes.InactiveNodes, 24) + // Get nodes — scoped to this environment (resolves audit finding U-DB-2) + hours := h.Settings.InactiveHours(settings.NoEnvironmentID) + nodeList, err := h.Nodes.GetByEnv(env.Name, nodes.InactiveNodes, hours) if err != nil { apiErrorResponse(w, "error getting nodes", http.StatusInternalServerError, err) return } - if len(nodes) == 0 { + if len(nodeList) == 0 { apiErrorResponse(w, "no nodes", http.StatusNotFound, nil) return } // Serialize and serve JSON log.Debug().Msg("Returned inactive nodes") h.AuditLog.NodeAction(ctx[ctxUser], "viewed inactive nodes", strings.Split(r.RemoteAddr, ":")[0], env.ID) - utils.HTTPResponse(w, utils.JSONApplicationUTF8, http.StatusOK, nodes) + utils.HTTPResponse(w, utils.JSONApplicationUTF8, http.StatusOK, nodeList) } // AllNodesHandler - GET Handler for all JSON nodes @@ -153,7 +159,7 @@ func (h *HandlersApi) AllNodesHandler(w http.ResponseWriter, r *http.Request) { return } // Get environment - env, err := h.Envs.GetByUUID(envVar) + env, err := h.Envs.Get(envVar) if err != nil { apiErrorResponse(w, "error getting environment", http.StatusBadRequest, nil) return @@ -164,20 +170,20 @@ func (h *HandlersApi) AllNodesHandler(w http.ResponseWriter, r *http.Request) { apiErrorResponse(w, "no access", http.StatusForbidden, fmt.Errorf("attempt to use API by user %s", ctx[ctxUser])) return } - // Get nodes - nodes, err := h.Nodes.Gets(nodes.AllNodes, 0) + // Get nodes — scoped to this environment (resolves audit finding U-DB-2) + nodeList, err := h.Nodes.GetByEnv(env.Name, nodes.AllNodes, 0) if err != nil { apiErrorResponse(w, "error getting nodes", http.StatusInternalServerError, err) return } - if len(nodes) == 0 { + if len(nodeList) == 0 { apiErrorResponse(w, "no nodes", http.StatusNotFound, nil) return } // Serialize and serve JSON log.Debug().Msg("Returned all nodes") h.AuditLog.NodeAction(ctx[ctxUser], "viewed all nodes", strings.Split(r.RemoteAddr, ":")[0], env.ID) - utils.HTTPResponse(w, utils.JSONApplicationUTF8, http.StatusOK, nodes) + utils.HTTPResponse(w, utils.JSONApplicationUTF8, http.StatusOK, nodeList) } // DeleteNodeHandler - POST Handler to delete single node @@ -193,7 +199,7 @@ func (h *HandlersApi) DeleteNodeHandler(w http.ResponseWriter, r *http.Request) return } // Get environment - env, err := h.Envs.GetByUUID(envVar) + env, err := h.Envs.Get(envVar) if err != nil { apiErrorResponse(w, "error getting environment", http.StatusInternalServerError, nil) return @@ -237,7 +243,7 @@ func (h *HandlersApi) TagNodeHandler(w http.ResponseWriter, r *http.Request) { return } // Get environment - env, err := h.Envs.GetByUUID(envVar) + env, err := h.Envs.Get(envVar) if err != nil { apiErrorResponse(w, "error getting environment", http.StatusInternalServerError, nil) return @@ -251,7 +257,11 @@ func (h *HandlersApi) TagNodeHandler(w http.ResponseWriter, r *http.Request) { var t types.ApiNodeTagRequest // Parse request JSON body if err := json.NewDecoder(r.Body).Decode(&t); err != nil { - apiErrorResponse(w, "error parsing POST body", http.StatusInternalServerError, err) + apiErrorResponse(w, "error parsing POST body", http.StatusBadRequest, err) + return + } + if t.UUID == "" || t.Tag == "" { + apiErrorResponse(w, "uuid and tag are required", http.StatusBadRequest, nil) return } // Get node by UUID @@ -310,3 +320,122 @@ func (h *HandlersApi) LookupNodeHandler(w http.ResponseWriter, r *http.Request) // Serialize and serve JSON utils.HTTPResponse(w, utils.JSONApplicationUTF8, http.StatusOK, n) } + +// NodesPagedHandler returns paginated, sorted, searchable nodes for an env. +// This is the canonical endpoint consumed by the React admin SPA. +// +// Query params: +// +// status: "all" | "active" | "inactive" (default "all") +// q: free-text search (case-insensitive partial match on uuid, +// hostname, localname, ip, username, osquery_user, platform, version) +// sort: one of nodes.SortableColumns keys (default "lastseen") +// dir: "asc" | "desc" (default "desc" for lastseen, "asc" otherwise) +// page: 1-indexed page number (default 1) +// page_size: 1..500 (default 50) +func (h *HandlersApi) NodesPagedHandler(w http.ResponseWriter, r *http.Request) { + // Debug HTTP if enabled + if h.DebugHTTPConfig.EnableHTTP { + utils.DebugHTTPDump(h.DebugHTTP, r, h.DebugHTTPConfig.ShowBody) + } + // env from URL path + envVar := r.PathValue("env") + env, err := h.Envs.Get(envVar) + if err != nil { + // try by name for callers that pass an env name (legacy compat) + envByName, err2 := h.Envs.GetByName(envVar) + if err2 != nil { + apiErrorResponse(w, "environment not found", http.StatusNotFound, err) + return + } + env = envByName + } + + // auth context — user + ctx := r.Context().Value(ContextKey(contextAPI)).(ContextValue) + if !h.Users.CheckPermissions(ctx[ctxUser], users.UserLevel, env.UUID) { + apiErrorResponse(w, "no access", http.StatusForbidden, fmt.Errorf("attempt to use API by user %s", ctx[ctxUser])) + return + } + + // params + q := r.URL.Query() + status := q.Get("status") + if status == "" { + status = "all" + } + switch status { + case "all", "active", "inactive": + default: + apiErrorResponse(w, "invalid status (all|active|inactive)", http.StatusBadRequest, nil) + return + } + search := q.Get("q") + dirParam := strings.ToLower(q.Get("dir")) + sortCol := q.Get("sort") + var desc bool + switch dirParam { + case "asc": + desc = false + case "desc": + desc = true + default: + // No explicit direction: default to desc for time-based columns, + // asc for everything else. Matches OpenAPI default of "desc" for + // the most common SPA sort (lastseen). + switch sortCol { + case "", "lastseen", "firstseen": + desc = true + default: + desc = false + } + } + page, _ := strconv.Atoi(q.Get("page")) + pageSize, _ := strconv.Atoi(q.Get("page_size")) + + // Platform bucket filter — empty string disables. Validated inside + // applyPlatformBucket: unknown buckets become no-ops. We do still allow + // the explicit value "other" so the SPA can offer an "Other" chip for + // platforms that don't fit linux/darwin/windows. + platformBucket := strings.ToLower(strings.TrimSpace(q.Get("platform"))) + switch platformBucket { + case "", "linux", "darwin", "windows", "other": + // allowed + default: + apiErrorResponse(w, "invalid platform (linux|darwin|windows|other)", http.StatusBadRequest, nil) + return + } + + hours := h.Settings.InactiveHours(settings.NoEnvironmentID) + pageData, err := h.Nodes.GetByEnvPaged(env.Name, status, hours, search, page, pageSize, sortCol, desc, platformBucket) + if err != nil { + apiErrorResponse(w, "failed to query nodes", http.StatusInternalServerError, err) + return + } + + // Normalize page/pageSize back so the client sees what was actually applied. + if pageSize <= 0 { + pageSize = 50 + } else if pageSize > 500 { + pageSize = 500 + } + if page <= 0 { + page = 1 + } + totalPages := int((pageData.TotalItems + int64(pageSize) - 1) / int64(pageSize)) + if totalPages == 0 { + totalPages = 1 + } + + log.Debug().Msgf("Returned paged nodes for env %s page %d", env.Name, page) + h.AuditLog.NodeAction(ctx[ctxUser], "viewed paged nodes", strings.Split(r.RemoteAddr, ":")[0], env.ID) + utils.HTTPResponse(w, utils.JSONApplicationUTF8, http.StatusOK, types.NodesPagedResponse{ + // ProjectNodes adds the parsed `system_info` enrichment block per row. + // The enroll_secret inside RawEnrollment is intentionally excluded. + Items: types.ProjectNodes(pageData.Items), + Page: page, + PageSize: pageSize, + TotalItems: pageData.TotalItems, + TotalPages: totalPages, + }) +} diff --git a/cmd/api/handlers/queries.go b/cmd/api/handlers/queries.go index 93afa0b6..bf52bda2 100644 --- a/cmd/api/handlers/queries.go +++ b/cmd/api/handlers/queries.go @@ -1,13 +1,17 @@ package handlers import ( + "encoding/csv" "encoding/json" "fmt" "net/http" + "sort" + "strconv" "strings" "time" "github.com/jmpsec/osctrl/pkg/handlers" + "github.com/jmpsec/osctrl/pkg/logging" "github.com/jmpsec/osctrl/pkg/queries" "github.com/jmpsec/osctrl/pkg/settings" "github.com/jmpsec/osctrl/pkg/types" @@ -16,11 +20,13 @@ import ( "github.com/rs/zerolog/log" ) +// QueryTargets enumerates the target filters accepted by QueryListHandler. +// TargetHiddenActive is intentionally excluded: no UI tab references it and +// GetByEnvTargetPaged has no branch for it (mirrors Gets() which returns nothing). var QueryTargets = map[string]bool{ queries.TargetAll: true, queries.TargetAllFull: true, queries.TargetActive: true, - queries.TargetHiddenActive: true, queries.TargetCompleted: true, queries.TargetExpired: true, queries.TargetSaved: true, @@ -48,7 +54,7 @@ func (h *HandlersApi) QueryShowHandler(w http.ResponseWriter, r *http.Request) { return } // Get environment - env, err := h.Envs.GetByUUID(envVar) + env, err := h.Envs.Get(envVar) if err != nil { apiErrorResponse(w, "error getting environment", http.StatusInternalServerError, nil) return @@ -88,7 +94,7 @@ func (h *HandlersApi) QueriesRunHandler(w http.ResponseWriter, r *http.Request) return } // Get environment - env, err := h.Envs.GetByUUID(envVar) + env, err := h.Envs.Get(envVar) if err != nil { apiErrorResponse(w, "error getting environment", http.StatusInternalServerError, nil) return @@ -196,7 +202,7 @@ func (h *HandlersApi) QueriesActionHandler(w http.ResponseWriter, r *http.Reques return } // Get environment - env, err := h.Envs.GetByUUID(envVar) + env, err := h.Envs.Get(envVar) if err != nil { apiErrorResponse(w, "error getting environment", http.StatusInternalServerError, nil) return @@ -264,7 +270,7 @@ func (h *HandlersApi) AllQueriesShowHandler(w http.ResponseWriter, r *http.Reque return } // Get environment - env, err := h.Envs.GetByUUID(envVar) + env, err := h.Envs.Get(envVar) if err != nil { apiErrorResponse(w, "error getting environment", http.StatusInternalServerError, nil) return @@ -291,7 +297,9 @@ func (h *HandlersApi) AllQueriesShowHandler(w http.ResponseWriter, r *http.Reque utils.HTTPResponse(w, utils.JSONApplicationUTF8, http.StatusOK, queries) } -// QueryListHandler - GET Handler to return queries in JSON by target and environment +// QueryListHandler - GET Handler to return queries in JSON by target and environment (paginated) +// +// Query params: page, page_size, q (free-text search), sort (column key), dir (asc|desc) func (h *HandlersApi) QueryListHandler(w http.ResponseWriter, r *http.Request) { // Debug HTTP if enabled if h.DebugHTTPConfig.EnableHTTP { @@ -304,7 +312,7 @@ func (h *HandlersApi) QueryListHandler(w http.ResponseWriter, r *http.Request) { return } // Get environment - env, err := h.Envs.GetByUUID(envVar) + env, err := h.Envs.Get(envVar) if err != nil { apiErrorResponse(w, "error getting environment", http.StatusInternalServerError, nil) return @@ -326,23 +334,62 @@ func (h *HandlersApi) QueryListHandler(w http.ResponseWriter, r *http.Request) { apiErrorResponse(w, "invalid target", http.StatusBadRequest, nil) return } - // Get queries - queries, err := h.Queries.GetQueries(targetVar, env.ID) + // Parse pagination / search / sort params + q := r.URL.Query() + page, _ := strconv.Atoi(q.Get("page")) + pageSize, _ := strconv.Atoi(q.Get("page_size")) + search := q.Get("q") + sortCol := q.Get("sort") + desc := strings.ToLower(q.Get("dir")) != "asc" + + // Clamp pagination once at the handler so the response echoes effective + // values; the package function still clamps defensively. + if pageSize <= 0 { + pageSize = 50 + } + if pageSize > 500 { + pageSize = 500 + } + if page <= 0 { + page = 1 + } + + result, err := h.Queries.GetByEnvTargetPaged(env.ID, targetVar, queries.StandardQueryType, search, page, pageSize, sortCol, desc) if err != nil { apiErrorResponse(w, "error getting queries", http.StatusInternalServerError, err) return } - if len(queries) == 0 { - apiErrorResponse(w, "no queries", http.StatusNotFound, nil) - return + + // Empty result is a valid state — return HTTP 200 with empty items. + items := result.Items + if items == nil { + items = []queries.DistributedQuery{} + } + var totalPages int + if result.TotalItems > 0 { + totalPages = int((result.TotalItems + int64(pageSize) - 1) / int64(pageSize)) } + + resp := types.QueriesPagedResponse{ + Items: items, + Page: page, + PageSize: pageSize, + TotalItems: result.TotalItems, + TotalPages: totalPages, + } + // Serialize and serve JSON - log.Debug().Msgf("Returned %d queries", len(queries)) + log.Debug().Msgf("Returned %d queries (page %d of %d)", len(items), page, totalPages) h.AuditLog.Visit(ctx[ctxUser], r.URL.Path, strings.Split(r.RemoteAddr, ":")[0], env.ID) - utils.HTTPResponse(w, utils.JSONApplicationUTF8, http.StatusOK, queries) + utils.HTTPResponse(w, utils.JSONApplicationUTF8, http.StatusOK, resp) } -// QueryResultsHandler - GET Handler to return a single query results in JSON +// QueryResultsHandler - GET Handler to return paginated query results in JSON +// +// Path: /api/v1/queries/{env}/results/{name} +// Params: page, page_size, since (RFC3339 timestamp; unparseable → ignored) +// +// Empty results are a valid state and return HTTP 200 with items: []. func (h *HandlersApi) QueryResultsHandler(w http.ResponseWriter, r *http.Request) { // Debug HTTP if enabled if h.DebugHTTPConfig.EnableHTTP { @@ -357,11 +404,11 @@ func (h *HandlersApi) QueryResultsHandler(w http.ResponseWriter, r *http.Request // Extract environment envVar := r.PathValue("env") if envVar == "" { - apiErrorResponse(w, "error with environment", http.StatusInternalServerError, nil) + apiErrorResponse(w, "error with environment", http.StatusBadRequest, nil) return } // Get environment - env, err := h.Envs.GetByUUID(envVar) + env, err := h.Envs.Get(envVar) if err != nil { apiErrorResponse(w, "error getting environment", http.StatusInternalServerError, nil) return @@ -372,20 +419,183 @@ func (h *HandlersApi) QueryResultsHandler(w http.ResponseWriter, r *http.Request apiErrorResponse(w, "no access", http.StatusForbidden, fmt.Errorf("attempt to use API by user %s", ctx[ctxUser])) return } - // Get query by name - // TODO this is a temporary solution, we need to refactor this and take into consideration the - // logger for TLS and whether if the results are stored in the DB or a different DB - queryLogs, err := postgresQueryLogs(h.DB, name) + // Verify the named query belongs to THIS env. logging.GetQueryResults + // filters on `name` only — without this gate a user with QueryLevel on + // env A could pull results from env B by passing B's query name in + // A's URL. + if !h.Queries.Exists(name, env.ID) { + apiErrorResponse(w, "query not found", http.StatusNotFound, nil) + return + } + + // Parse pagination + since cursor + q := r.URL.Query() + page, _ := strconv.Atoi(q.Get("page")) + pageSize, _ := strconv.Atoi(q.Get("page_size")) + if pageSize <= 0 { + pageSize = 100 + } + if pageSize > 1000 { + pageSize = 1000 + } + if page <= 0 { + page = 1 + } + var since time.Time + var sinceEcho string + if s := strings.TrimSpace(q.Get("since")); s != "" { + if t, perr := time.Parse(time.RFC3339, s); perr == nil { + since = t + sinceEcho = s + } + } + + items, total, err := logging.GetQueryResults(h.DB, name, since, page, pageSize) if err != nil { - if err.Error() == "record not found" { - apiErrorResponse(w, "query not found", http.StatusNotFound, err) - } else { - apiErrorResponse(w, "error getting query", http.StatusInternalServerError, err) + apiErrorResponse(w, "error getting query results", http.StatusInternalServerError, err) + return + } + if items == nil { + items = []map[string]any{} + } + var totalPages int + if total > 0 { + totalPages = int((total + int64(pageSize) - 1) / int64(pageSize)) + } + resp := types.QueryResultsResponse{ + Items: items, + Page: page, + PageSize: pageSize, + TotalItems: total, + TotalPages: totalPages, + Since: sinceEcho, + } + log.Debug().Msgf("Returned query results for %s (page %d of %d, %d rows)", name, page, totalPages, len(items)) + h.AuditLog.Visit(ctx[ctxUser], r.URL.Path, strings.Split(r.RemoteAddr, ":")[0], env.ID) + utils.HTTPResponse(w, utils.JSONApplicationUTF8, http.StatusOK, resp) +} + +// QueryResultsCSVHandler - GET Handler to stream query results as CSV +// +// Path: /api/v1/queries/{env}/results/csv/{name} +// +// (The `.csv` lives as a literal path segment before `{name}` because Go's +// ServeMux grammar requires wildcards to end at `/` or end-of-pattern, so +// `{name}.csv` is a parse error at registration time.) +func (h *HandlersApi) QueryResultsCSVHandler(w http.ResponseWriter, r *http.Request) { + // Debug HTTP if enabled + if h.DebugHTTPConfig.EnableHTTP { + utils.DebugHTTPDump(h.DebugHTTP, r, h.DebugHTTPConfig.ShowBody) + } + name := r.PathValue("name") + if name == "" { + apiErrorResponse(w, "error getting name", http.StatusBadRequest, nil) + return + } + // Extract environment + envVar := r.PathValue("env") + if envVar == "" { + apiErrorResponse(w, "error with environment", http.StatusBadRequest, nil) + return + } + // Get environment + env, err := h.Envs.Get(envVar) + if err != nil { + apiErrorResponse(w, "error getting environment", http.StatusInternalServerError, nil) + return + } + // Get context data and check access + ctx := r.Context().Value(ContextKey(contextAPI)).(ContextValue) + if !h.Users.CheckPermissions(ctx[ctxUser], users.QueryLevel, env.UUID) { + apiErrorResponse(w, "no access", http.StatusForbidden, fmt.Errorf("attempt to use API by user %s", ctx[ctxUser])) + return + } + // Verify the named query belongs to THIS env. See the matching gate + // in QueryResultsHandler for the rationale. + if !h.Queries.Exists(name, env.ID) { + apiErrorResponse(w, "query not found", http.StatusNotFound, nil) + return + } + // Pass 1 (streaming): walk every row, collect the union of column names. + // We only retain column names here — never the row data — to keep memory at O(columns). + colSet := make(map[string]struct{}) + if err := logging.StreamQueryResults(h.DB, name, func(row logging.OsqueryQueryData) error { + var cols map[string]string + if err := json.Unmarshal([]byte(row.Data), &cols); err != nil { + cols = map[string]string{"data": row.Data} } + for k := range cols { + colSet[k] = struct{}{} + } + return nil + }); err != nil { + apiErrorResponse(w, "error getting query results", http.StatusInternalServerError, err) return } - // Serialize and serve JSON - log.Debug().Msgf("Returned query results for %s", name) + headers := make([]string, 0, len(colSet)+1) + headers = append(headers, "uuid") + sortedCols := make([]string, 0, len(colSet)) + for k := range colSet { + sortedCols = append(sortedCols, k) + } + sort.Strings(sortedCols) + headers = append(headers, sortedCols...) + + // Set response headers BEFORE writing any body. + w.Header().Set("Content-Type", "text/csv; charset=utf-8") + w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", name+".csv")) + + cw := csv.NewWriter(w) + flusher, _ := w.(http.Flusher) + if err := cw.Write(headers); err != nil { + log.Err(err).Msgf("error writing CSV header for %s", name) + return + } + cw.Flush() + if flusher != nil { + flusher.Flush() + } + + // Pass 2 (streaming): write data rows, flushing after each so bytes reach the client incrementally. + rowCount := 0 + if err := logging.StreamQueryResults(h.DB, name, func(row logging.OsqueryQueryData) error { + var cols map[string]string + if err := json.Unmarshal([]byte(row.Data), &cols); err != nil { + cols = map[string]string{"data": row.Data} + } + record := make([]string, len(headers)) + record[0] = row.UUID + for i, col := range sortedCols { + record[i+1] = cols[col] + } + if werr := cw.Write(record); werr != nil { + return werr + } + cw.Flush() + if flusher != nil { + flusher.Flush() + } + rowCount++ + return nil + }); err != nil { + // Headers already sent; we can only log and stop. + log.Err(err).Msgf("error streaming CSV rows for %s", name) + return + } + log.Debug().Msgf("Exported CSV for query %s (%d rows)", name, rowCount) h.AuditLog.Visit(ctx[ctxUser], r.URL.Path, strings.Split(r.RemoteAddr, ":")[0], env.ID) - utils.HTTPResponse(w, utils.JSONApplicationUTF8, http.StatusOK, queryLogs) +} + +// OsqueryTablesHandler - GET Handler to return the osquery schema tables +// +// Path: /api/v1/osquery/tables +// The schema is global (not env-scoped). Requires any authenticated user. +// Responses are cache-able for one hour since the schema rarely changes. +func (h *HandlersApi) OsqueryTablesHandler(w http.ResponseWriter, r *http.Request) { + // Debug HTTP if enabled + if h.DebugHTTPConfig.EnableHTTP { + utils.DebugHTTPDump(h.DebugHTTP, r, h.DebugHTTPConfig.ShowBody) + } + w.Header().Set("Cache-Control", "private, max-age=3600") + utils.HTTPResponse(w, utils.JSONApplicationUTF8, http.StatusOK, h.OsqueryTables) } diff --git a/cmd/api/handlers/samples.go b/cmd/api/handlers/samples.go new file mode 100644 index 00000000..78a3c9fd --- /dev/null +++ b/cmd/api/handlers/samples.go @@ -0,0 +1,38 @@ +package handlers + +import ( + "net/http" + + "github.com/jmpsec/osctrl/pkg/carves" + "github.com/jmpsec/osctrl/pkg/queries" + "github.com/jmpsec/osctrl/pkg/utils" +) + +// QuerySamplesHandler - GET /api/v1/queries/samples +// +// Returns the static starter library of osquery SQL templates so the SPA's +// queries/new form can populate its QuickTemplates row. Intentionally +// unauthenticated: the samples are read-only data shipped with the binary, +// they aren't tenant- or env-scoped, and exposing them pre-auth lets the +// login screen lazy-load them without circular dependencies. +// +// Shares the per-IP loginRateLimit registered in main.go so this endpoint +// can't be turned into a low-effort scanning probe. +func (h *HandlersApi) QuerySamplesHandler(w http.ResponseWriter, r *http.Request) { + if h.DebugHTTPConfig.EnableHTTP { + utils.DebugHTTPDump(h.DebugHTTP, r, h.DebugHTTPConfig.ShowBody) + } + utils.HTTPResponse(w, utils.JSONApplicationUTF8, http.StatusOK, queries.QuerySamples) +} + +// CarveSamplesHandler - GET /api/v1/carves/samples +// +// Returns the static starter library of common carve-target file paths +// (e.g., /etc/passwd, C:\Windows\System32\config\SAM). Same auth posture as +// QuerySamplesHandler: pre-auth, rate-limited. +func (h *HandlersApi) CarveSamplesHandler(w http.ResponseWriter, r *http.Request) { + if h.DebugHTTPConfig.EnableHTTP { + utils.DebugHTTPDump(h.DebugHTTP, r, h.DebugHTTPConfig.ShowBody) + } + utils.HTTPResponse(w, utils.JSONApplicationUTF8, http.StatusOK, carves.CarveSamples) +} diff --git a/cmd/api/handlers/saved_queries.go b/cmd/api/handlers/saved_queries.go new file mode 100644 index 00000000..bdd6c72a --- /dev/null +++ b/cmd/api/handlers/saved_queries.go @@ -0,0 +1,257 @@ +package handlers + +import ( + "encoding/json" + "errors" + "fmt" + "net/http" + "strconv" + "strings" + + "github.com/jmpsec/osctrl/pkg/queries" + "github.com/jmpsec/osctrl/pkg/types" + "github.com/jmpsec/osctrl/pkg/users" + "github.com/jmpsec/osctrl/pkg/utils" + "github.com/rs/zerolog/log" + "gorm.io/gorm" +) + +// savedQueryView projects a storage row into the SPA-canonical envelope. +// Timestamps stay as time.Time so JSON-encoded output is RFC3339 — matches +// the OpenAPI date-time format and the SPA's formatRelative ISO parser. +func savedQueryView(s queries.SavedQuery) types.SavedQueryView { + return types.SavedQueryView{ + ID: s.ID, + CreatedAt: s.CreatedAt, + UpdatedAt: s.UpdatedAt, + Name: s.Name, + Creator: s.Creator, + Query: s.Query, + EnvironmentID: s.EnvironmentID, + ExtraData: s.ExtraData, + } +} + +// SavedQueriesListHandler - GET /api/v1/saved-queries/{env} +// +// Paginated, sorted, searchable list of saved queries for an environment. +// Query params: page, page_size, q (free-text), sort (column key), dir (asc|desc). +func (h *HandlersApi) SavedQueriesListHandler(w http.ResponseWriter, r *http.Request) { + if h.DebugHTTPConfig.EnableHTTP { + utils.DebugHTTPDump(h.DebugHTTP, r, h.DebugHTTPConfig.ShowBody) + } + envVar := r.PathValue("env") + if envVar == "" { + apiErrorResponse(w, "error with environment", http.StatusBadRequest, nil) + return + } + env, err := h.Envs.Get(envVar) + if err != nil { + apiErrorResponse(w, "error getting environment", http.StatusInternalServerError, nil) + return + } + ctx := r.Context().Value(ContextKey(contextAPI)).(ContextValue) + if !h.Users.CheckPermissions(ctx[ctxUser], users.QueryLevel, env.UUID) { + apiErrorResponse(w, "no access", http.StatusForbidden, fmt.Errorf("attempt to use API by user %s", ctx[ctxUser])) + return + } + + q := r.URL.Query() + page, _ := strconv.Atoi(q.Get("page")) + pageSize, _ := strconv.Atoi(q.Get("page_size")) + if pageSize <= 0 { + pageSize = 50 + } + if pageSize > 500 { + pageSize = 500 + } + if page <= 0 { + page = 1 + } + search := q.Get("q") + sortCol := q.Get("sort") + desc := strings.ToLower(q.Get("dir")) != "asc" + + result, err := h.Queries.GetSavedByEnvPaged(env.ID, search, page, pageSize, sortCol, desc) + if err != nil { + apiErrorResponse(w, "error getting saved queries", http.StatusInternalServerError, err) + return + } + items := make([]types.SavedQueryView, 0, len(result.Items)) + for _, s := range result.Items { + items = append(items, savedQueryView(s)) + } + var totalPages int + if result.TotalItems > 0 { + totalPages = int((result.TotalItems + int64(pageSize) - 1) / int64(pageSize)) + } + resp := types.SavedQueriesPagedResponse{ + Items: items, + Page: page, + PageSize: pageSize, + TotalItems: result.TotalItems, + TotalPages: totalPages, + } + log.Debug().Msgf("Returned %d saved queries (page %d of %d)", len(items), page, totalPages) + h.AuditLog.Visit(ctx[ctxUser], r.URL.Path, strings.Split(r.RemoteAddr, ":")[0], env.ID) + utils.HTTPResponse(w, utils.JSONApplicationUTF8, http.StatusOK, resp) +} + +// SavedQueryCreateHandler - POST /api/v1/saved-queries/{env} +// +// Body: { "name": string, "query": string }. Returns 201 with the created view, +// 409 if a saved query with that name already exists in the environment. +func (h *HandlersApi) SavedQueryCreateHandler(w http.ResponseWriter, r *http.Request) { + if h.DebugHTTPConfig.EnableHTTP { + utils.DebugHTTPDump(h.DebugHTTP, r, h.DebugHTTPConfig.ShowBody) + } + envVar := r.PathValue("env") + if envVar == "" { + apiErrorResponse(w, "error with environment", http.StatusBadRequest, nil) + return + } + env, err := h.Envs.Get(envVar) + if err != nil { + apiErrorResponse(w, "error getting environment", http.StatusInternalServerError, nil) + return + } + ctx := r.Context().Value(ContextKey(contextAPI)).(ContextValue) + if !h.Users.CheckPermissions(ctx[ctxUser], users.QueryLevel, env.UUID) { + apiErrorResponse(w, "no access", http.StatusForbidden, fmt.Errorf("attempt to use API by user %s", ctx[ctxUser])) + return + } + + var body types.SavedQueryCreateRequest + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + apiErrorResponse(w, "error parsing POST body", http.StatusBadRequest, err) + return + } + body.Name = strings.TrimSpace(body.Name) + body.Query = strings.TrimSpace(body.Query) + if body.Name == "" { + apiErrorResponse(w, "name can not be empty", http.StatusBadRequest, nil) + return + } + if body.Query == "" { + apiErrorResponse(w, "query can not be empty", http.StatusBadRequest, nil) + return + } + // The DB unique index on (name, environment_id) is the authoritative + // gate (see pkg/queries.SavedQuery + ErrSavedQueryExists). The + // SavedExists probe stays as a fast-path so the typical "this name + // is already taken" case returns 409 without hitting Create at all; + // races where two POSTs slip past SavedExists are caught by the + // duplicate-key error from CreateSaved. + if h.Queries.SavedExists(body.Name, env.ID) { + apiErrorResponse(w, "saved query with that name already exists", http.StatusConflict, nil) + return + } + + creator := ctx[ctxUser] + if err := h.Queries.CreateSaved(body.Name, body.Query, creator, env.ID); err != nil { + if errors.Is(err, queries.ErrSavedQueryExists) { + apiErrorResponse(w, "saved query with that name already exists", http.StatusConflict, err) + return + } + apiErrorResponse(w, "error creating saved query", http.StatusInternalServerError, err) + return + } + saved, err := h.Queries.GetSavedByEnv(body.Name, env.ID) + if err != nil { + apiErrorResponse(w, "error fetching newly created saved query", http.StatusInternalServerError, err) + return + } + + h.AuditLog.SavedQueryAction(creator, "create "+body.Name, strings.Split(r.RemoteAddr, ":")[0], env.ID) + log.Debug().Msgf("Created saved query %s in env %s", body.Name, env.UUID) + utils.HTTPResponse(w, utils.JSONApplicationUTF8, http.StatusCreated, savedQueryView(saved)) +} + +// SavedQueryUpdateHandler - PATCH /api/v1/saved-queries/{env}/{name} +// +// Body: { "query": string }. Updates the SQL body only; the original creator +// is preserved. Returns the updated view. +func (h *HandlersApi) SavedQueryUpdateHandler(w http.ResponseWriter, r *http.Request) { + if h.DebugHTTPConfig.EnableHTTP { + utils.DebugHTTPDump(h.DebugHTTP, r, h.DebugHTTPConfig.ShowBody) + } + envVar := r.PathValue("env") + name := r.PathValue("name") + if envVar == "" || name == "" { + apiErrorResponse(w, "missing env or name", http.StatusBadRequest, nil) + return + } + env, err := h.Envs.Get(envVar) + if err != nil { + apiErrorResponse(w, "error getting environment", http.StatusInternalServerError, nil) + return + } + ctx := r.Context().Value(ContextKey(contextAPI)).(ContextValue) + if !h.Users.CheckPermissions(ctx[ctxUser], users.QueryLevel, env.UUID) { + apiErrorResponse(w, "no access", http.StatusForbidden, fmt.Errorf("attempt to use API by user %s", ctx[ctxUser])) + return + } + + var body types.SavedQueryUpdateRequest + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + apiErrorResponse(w, "error parsing PATCH body", http.StatusBadRequest, err) + return + } + body.Query = strings.TrimSpace(body.Query) + if body.Query == "" { + apiErrorResponse(w, "query can not be empty", http.StatusBadRequest, nil) + return + } + + if err := h.Queries.UpdateSaved(name, body.Query, env.ID); err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + apiErrorResponse(w, "saved query not found", http.StatusNotFound, err) + return + } + apiErrorResponse(w, "error updating saved query", http.StatusInternalServerError, err) + return + } + saved, err := h.Queries.GetSavedByEnv(name, env.ID) + if err != nil { + apiErrorResponse(w, "error fetching updated saved query", http.StatusInternalServerError, err) + return + } + h.AuditLog.SavedQueryAction(ctx[ctxUser], "update "+name, strings.Split(r.RemoteAddr, ":")[0], env.ID) + log.Debug().Msgf("Updated saved query %s in env %s", name, env.UUID) + utils.HTTPResponse(w, utils.JSONApplicationUTF8, http.StatusOK, savedQueryView(saved)) +} + +// SavedQueryDeleteHandler - DELETE /api/v1/saved-queries/{env}/{name} +func (h *HandlersApi) SavedQueryDeleteHandler(w http.ResponseWriter, r *http.Request) { + if h.DebugHTTPConfig.EnableHTTP { + utils.DebugHTTPDump(h.DebugHTTP, r, h.DebugHTTPConfig.ShowBody) + } + envVar := r.PathValue("env") + name := r.PathValue("name") + if envVar == "" || name == "" { + apiErrorResponse(w, "missing env or name", http.StatusBadRequest, nil) + return + } + env, err := h.Envs.Get(envVar) + if err != nil { + apiErrorResponse(w, "error getting environment", http.StatusInternalServerError, nil) + return + } + ctx := r.Context().Value(ContextKey(contextAPI)).(ContextValue) + if !h.Users.CheckPermissions(ctx[ctxUser], users.QueryLevel, env.UUID) { + apiErrorResponse(w, "no access", http.StatusForbidden, fmt.Errorf("attempt to use API by user %s", ctx[ctxUser])) + return + } + + if err := h.Queries.DeleteSavedByEnv(name, env.ID); err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + apiErrorResponse(w, "saved query not found", http.StatusNotFound, err) + return + } + apiErrorResponse(w, "error deleting saved query", http.StatusInternalServerError, err) + return + } + h.AuditLog.SavedQueryAction(ctx[ctxUser], "delete "+name, strings.Split(r.RemoteAddr, ":")[0], env.ID) + log.Debug().Msgf("Deleted saved query %s in env %s", name, env.UUID) + utils.HTTPResponse(w, utils.JSONApplicationUTF8, http.StatusOK, types.ApiGenericResponse{Message: fmt.Sprintf("saved query %s deleted", name)}) +} diff --git a/cmd/api/handlers/settings.go b/cmd/api/handlers/settings.go index 5c9569f1..f2baa8f0 100644 --- a/cmd/api/handlers/settings.go +++ b/cmd/api/handlers/settings.go @@ -95,7 +95,7 @@ func (h *HandlersApi) SettingsServiceEnvHandler(w http.ResponseWriter, r *http.R return } // Get environment by name - env, err := h.Envs.GetByUUID(envVar) + env, err := h.Envs.Get(envVar) if err != nil { if err.Error() == "record not found" { apiErrorResponse(w, "environment not found", http.StatusNotFound, err) @@ -110,8 +110,11 @@ func (h *HandlersApi) SettingsServiceEnvHandler(w http.ResponseWriter, r *http.R apiErrorResponse(w, "no access", http.StatusForbidden, fmt.Errorf("attempt to use API by user %s", ctx[ctxUser])) return } - // Get settings - serviceSettings, err := h.Settings.RetrieveValues(service, false, settings.NoEnvironmentID) + // Get settings scoped to THIS env. Was previously passing + // NoEnvironmentID and silently returning global settings, which let + // an env-X admin read another env's values as a side-channel via the + // env-scoped route. + serviceSettings, err := h.Settings.RetrieveValues(service, false, env.ID) if err != nil { apiErrorResponse(w, "error getting settings", http.StatusInternalServerError, err) return @@ -181,7 +184,7 @@ func (h *HandlersApi) SettingsServiceEnvJSONHandler(w http.ResponseWriter, r *ht return } // Get environment by name - env, err := h.Envs.GetByUUID(envVar) + env, err := h.Envs.Get(envVar) if err != nil { if err.Error() == "record not found" { apiErrorResponse(w, "environment not found", http.StatusNotFound, err) @@ -196,8 +199,10 @@ func (h *HandlersApi) SettingsServiceEnvJSONHandler(w http.ResponseWriter, r *ht apiErrorResponse(w, "no access", http.StatusForbidden, fmt.Errorf("attempt to use API by user %s", ctx[ctxUser])) return } - // Get settings - serviceSettings, err := h.Settings.RetrieveValues(service, true, settings.NoEnvironmentID) + // Get settings scoped to THIS env. Same defense as + // SettingsServiceEnvHandler above; was silently returning global + // settings via NoEnvironmentID. + serviceSettings, err := h.Settings.RetrieveValues(service, true, env.ID) if err != nil { apiErrorResponse(w, "error getting settings", http.StatusInternalServerError, err) return diff --git a/cmd/api/handlers/settings_patch.go b/cmd/api/handlers/settings_patch.go new file mode 100644 index 00000000..69336813 --- /dev/null +++ b/cmd/api/handlers/settings_patch.go @@ -0,0 +1,111 @@ +package handlers + +import ( + "encoding/json" + "errors" + "fmt" + "net/http" + "strings" + + "github.com/jmpsec/osctrl/pkg/settings" + "github.com/jmpsec/osctrl/pkg/types" + "github.com/jmpsec/osctrl/pkg/users" + "github.com/jmpsec/osctrl/pkg/utils" + "github.com/rs/zerolog/log" + "gorm.io/gorm" +) + +// SettingPatchHandler — PATCH /api/v1/settings/{service}/{name} +// +// Body shape (one of String, Boolean, Integer): +// +// { "string": "value" } +// { "boolean": true } +// { "integer": 42 } +// +// The handler reads the existing setting first to determine its type, then +// applies the matching typed setter. Mismatched payloads return 400. The +// setting must already exist (creation is the legacy admin's job); a missing +// setting → 404. Audit-log on success only. +func (h *HandlersApi) SettingPatchHandler(w http.ResponseWriter, r *http.Request) { + if h.DebugHTTPConfig.EnableHTTP { + utils.DebugHTTPDump(h.DebugHTTP, r, h.DebugHTTPConfig.ShowBody) + } + ctx := r.Context().Value(ContextKey(contextAPI)).(ContextValue) + if !h.Users.CheckPermissions(ctx[ctxUser], users.AdminLevel, users.NoEnvironment) { + apiErrorResponse(w, "no access", http.StatusForbidden, fmt.Errorf("attempt to use API by user %s", ctx[ctxUser])) + return + } + service := r.PathValue("service") + if service == "" { + apiErrorResponse(w, "missing service", http.StatusBadRequest, nil) + return + } + if !h.Settings.VerifyService(service) { + apiErrorResponse(w, "invalid service", http.StatusBadRequest, nil) + return + } + name := r.PathValue("name") + if name == "" { + apiErrorResponse(w, "missing name", http.StatusBadRequest, nil) + return + } + + existing, err := h.Settings.RetrieveValue(service, name, settings.NoEnvironmentID) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + apiErrorResponse(w, "setting not found", http.StatusNotFound, err) + return + } + apiErrorResponse(w, "error reading setting", http.StatusInternalServerError, err) + return + } + + var body types.SettingPatchRequest + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + apiErrorResponse(w, "error parsing PATCH body", http.StatusBadRequest, err) + return + } + + switch existing.Type { + case settings.TypeBoolean: + if body.Boolean == nil { + apiErrorResponse(w, "setting is boolean — provide `boolean` in body", http.StatusBadRequest, nil) + return + } + if err := h.Settings.SetBoolean(*body.Boolean, service, name, settings.NoEnvironmentID); err != nil { + apiErrorResponse(w, "error updating setting", http.StatusInternalServerError, err) + return + } + case settings.TypeInteger: + if body.Integer == nil { + apiErrorResponse(w, "setting is integer — provide `integer` in body", http.StatusBadRequest, nil) + return + } + if err := h.Settings.SetInteger(*body.Integer, service, name, settings.NoEnvironmentID); err != nil { + apiErrorResponse(w, "error updating setting", http.StatusInternalServerError, err) + return + } + case settings.TypeString: + if body.String == nil { + apiErrorResponse(w, "setting is string — provide `string` in body", http.StatusBadRequest, nil) + return + } + if err := h.Settings.SetString(*body.String, service, name, existing.JSON, settings.NoEnvironmentID); err != nil { + apiErrorResponse(w, "error updating setting", http.StatusInternalServerError, err) + return + } + default: + apiErrorResponse(w, "unsupported setting type", http.StatusInternalServerError, nil) + return + } + + updated, err := h.Settings.RetrieveValue(service, name, settings.NoEnvironmentID) + if err != nil { + apiErrorResponse(w, "error reading updated setting", http.StatusInternalServerError, err) + return + } + h.AuditLog.SettingsAction(ctx[ctxUser], fmt.Sprintf("patch %s/%s", service, name), strings.Split(r.RemoteAddr, ":")[0]) + log.Debug().Msgf("Patched setting %s/%s", service, name) + utils.HTTPResponse(w, utils.JSONApplicationUTF8, http.StatusOK, updated) +} diff --git a/cmd/api/handlers/stats.go b/cmd/api/handlers/stats.go new file mode 100644 index 00000000..800b0447 --- /dev/null +++ b/cmd/api/handlers/stats.go @@ -0,0 +1,539 @@ +package handlers + +import ( + "fmt" + "net/http" + "sort" + "strings" + "time" + + "github.com/jmpsec/osctrl/pkg/auditlog" + "github.com/jmpsec/osctrl/pkg/dbutil" + "github.com/jmpsec/osctrl/pkg/logging" + "github.com/jmpsec/osctrl/pkg/nodes" + "github.com/jmpsec/osctrl/pkg/queries" + "github.com/jmpsec/osctrl/pkg/settings" + "github.com/jmpsec/osctrl/pkg/users" + "github.com/jmpsec/osctrl/pkg/utils" + "github.com/rs/zerolog/log" +) + +// EnvStats is one row in the per-env breakdown returned by /api/v1/stats. +type EnvStats struct { + UUID string `json:"uuid"` + Name string `json:"name"` + Active int64 `json:"active"` + Inactive int64 `json:"inactive"` + Total int64 `json:"total"` + ActiveQueries int `json:"active_queries"` + ActiveCarves int `json:"active_carves"` + // PlatformCounts buckets the env's nodes by OS family (linux / darwin / + // windows / other). Drives the Nodes-table QuickFilters chip row. Counts + // are total (active + inactive), since the filter chip lists all nodes + // of that platform regardless of staleness — the Active/Inactive toggle + // is independent. + PlatformCounts nodes.PlatformCounts `json:"platform_counts"` +} + +// StatsResponse is the canonical /api/v1/stats shape consumed by the dashboard. +type StatsResponse struct { + // Cross-env totals (the user's allowed envs only). + TotalNodes int64 `json:"total_nodes"` + ActiveNodes int64 `json:"active_nodes"` + InactiveNodes int64 `json:"inactive_nodes"` + // TotalActiveQueries counts standard query-type active queries (excludes carves). + TotalActiveQueries int `json:"total_active_queries"` + // TotalActiveCarves counts active carve-type queries. + TotalActiveCarves int `json:"total_active_carves"` + // Cross-env platform breakdown — sum of every accessible env's PlatformCounts. + PlatformCounts nodes.PlatformCounts `json:"platform_counts"` + + // Per-env breakdown, in stable alphabetical order by name. + Environments []EnvStats `json:"environments"` +} + +// StatsHandler returns cross-env totals + per-env counts, filtered to the +// envs the calling user has UserLevel access to. Used by the SPA dashboard. +// +// No query params. The response is small (one entry per accessible env) and +// cacheable for 30s on the client (Cache-Control: private, max-age=30). +// +// NOTE on query/carve counting: +// - GetActive(envID) returns ALL active rows regardless of type (union). +// - To avoid double-counting we call GetQueries("active", envID) for +// standard queries and GetCarves("active", envID) for carves separately. +// - Unit test for this handler is deferred: the underlying pkg/queries +// functions are exercised by existing tests in pkg/queries; a full +// integration test would require DB fixture setup that is out of scope +// for Track 2. +func (h *HandlersApi) StatsHandler(w http.ResponseWriter, r *http.Request) { + ctxVal := r.Context().Value(ContextKey(contextAPI)) + if ctxVal == nil { + apiErrorResponse(w, "missing auth context", http.StatusUnauthorized, nil) + return + } + ctx := ctxVal.(ContextValue) + user := ctx[ctxUser] + + allEnvs, err := h.Envs.All() + if err != nil { + apiErrorResponse(w, "failed to load environments", http.StatusInternalServerError, err) + return + } + + hours := h.Settings.InactiveHours(settings.NoEnvironmentID) + out := StatsResponse{Environments: make([]EnvStats, 0, len(allEnvs))} + + for _, e := range allEnvs { + // Filter to envs the user can actually see. + if !h.Users.CheckPermissions(user, users.UserLevel, e.UUID) { + continue + } + + ns, err := h.Nodes.GetStatsByEnv(e.Name, hours) + if err != nil { + log.Warn().Err(err).Str("env", e.Name).Msg("stats: failed to get node stats, skipping env") + continue + } + + // Per-env platform counts (linux / darwin / windows / other) for the + // SPA's filter chips. We don't fail the whole env on a count error; + // if the GROUP BY fails the env still gets a row, just with zeros in + // PlatformCounts. The SPA renders the chips as "0" rather than missing. + platCounts, err := h.Nodes.GetPlatformCountsByEnv(e.Name) + if err != nil { + log.Warn().Err(err).Str("env", e.Name).Msg("stats: failed to get platform counts, defaulting to zeros") + } + + // Use type-specific methods to avoid double-counting: + // GetQueries returns StandardQueryType active items only. + // GetCarves returns CarveQueryType active items only. + activeQ, err := h.Queries.GetQueries(queries.TargetActive, e.ID) + if err != nil { + log.Warn().Err(err).Str("env", e.Name).Msg("stats: failed to count active queries, skipping env") + continue + } + activeC, err := h.Queries.GetCarves(queries.TargetActive, e.ID) + if err != nil { + log.Warn().Err(err).Str("env", e.Name).Msg("stats: failed to count active carves, skipping env") + continue + } + + row := EnvStats{ + UUID: e.UUID, + Name: e.Name, + Active: ns.Active, + Inactive: ns.Inactive, + Total: ns.Total, + ActiveQueries: len(activeQ), + ActiveCarves: len(activeC), + PlatformCounts: platCounts, + } + out.Environments = append(out.Environments, row) + out.ActiveNodes += ns.Active + out.InactiveNodes += ns.Inactive + out.TotalNodes += ns.Total + out.TotalActiveQueries += len(activeQ) + out.TotalActiveCarves += len(activeC) + // Aggregate cross-env platform totals. + out.PlatformCounts.Linux += platCounts.Linux + out.PlatformCounts.Darwin += platCounts.Darwin + out.PlatformCounts.Windows += platCounts.Windows + out.PlatformCounts.Other += platCounts.Other + } + + // Stable alphabetical order by env name. + sort.Slice(out.Environments, func(i, j int) bool { + return out.Environments[i].Name < out.Environments[j].Name + }) + + w.Header().Set("Cache-Control", "private, max-age=30") + utils.HTTPResponse(w, utils.JSONApplicationUTF8, http.StatusOK, out) +} + +// ActivityBucket is one cell of the 24-hour activity heatmap. BucketStart is +// the start of the 15-minute window (UTC, RFC3339); the four counters are +// the audit-log entry counts that fell into that window for each category. +// +// Categories (audit log_type → category): +// - config ← Setting (8) + Environment (7) +// - query ← Query (4) +// - carve ← Carve (5) +// - enroll ← Node (3) — covers enroll, archive, deletion +type ActivityBucket struct { + BucketStart time.Time `json:"bucket_start"` + Config int `json:"config"` + Query int `json:"query"` + Carve int `json:"carve"` + Enroll int `json:"enroll"` +} + +// activityIntervalPresets maps the SPA's interval picker values to (hours, +// bucketSeconds). Bucket sizes are chosen so the cell count stays in the +// 36..96 range across the full picker — small enough to fit one row at +// 1280px, large enough that the heatmap still reads as a sparse density map. +// +// Adding a new preset: pick a bucketSeconds that divides hours*3600 evenly +// to avoid an under-filled trailing cell. +type activityPreset struct { + bucketSeconds int +} + +var activityIntervalPresets = map[string]activityPreset{ + "3h": {bucketSeconds: 5 * 60}, // 36 cells + "6h": {bucketSeconds: 5 * 60}, // 72 cells + "12h": {bucketSeconds: 10 * 60}, // 72 cells + "1d": {bucketSeconds: 15 * 60}, // 96 cells + "2d": {bucketSeconds: 30 * 60}, // 96 cells + "3d": {bucketSeconds: 45 * 60}, // 96 cells + "7d": {bucketSeconds: 2 * 3600}, // 84 cells +} + +var activityIntervalHours = map[string]int{ + "3h": 3, "6h": 6, "12h": 12, "1d": 24, "2d": 48, "3d": 72, "7d": 168, +} + +// EnvActivityHandler — GET /api/v1/stats/activity/{env}?interval=KEY +// +// Returns audit-log activity for one env over the requested interval, +// bucketed at a fixed size per interval (see activityIntervalPresets). +// `interval` accepts 3h / 6h / 12h / 1d / 2d / 3d / 7d (default 1d, falls +// back to 1d on any unknown value rather than 400ing — the SPA picker is +// the only allowed source). +// +// Buckets are emitted contiguously — empty windows return zero rows for +// that bucket — so the SPA can render the grid without densifying +// client-side. +func (h *HandlersApi) EnvActivityHandler(w http.ResponseWriter, r *http.Request) { + ctxVal := r.Context().Value(ContextKey(contextAPI)) + if ctxVal == nil { + apiErrorResponse(w, "missing auth context", http.StatusUnauthorized, nil) + return + } + ctx := ctxVal.(ContextValue) + user := ctx[ctxUser] + + envVar := r.PathValue("env") + if envVar == "" { + apiErrorResponse(w, "error with environment", http.StatusBadRequest, nil) + return + } + env, err := h.Envs.Get(envVar) + if err != nil { + apiErrorResponse(w, "error getting environment", http.StatusNotFound, err) + return + } + if !h.Users.CheckPermissions(user, users.UserLevel, env.UUID) { + apiErrorResponse(w, "no access", http.StatusForbidden, fmt.Errorf("attempt to use API by user %s", user)) + return + } + + intervalKey := r.URL.Query().Get("interval") + preset, ok := activityIntervalPresets[intervalKey] + if !ok { + intervalKey = "1d" + preset = activityIntervalPresets["1d"] + } + hours := activityIntervalHours[intervalKey] + bucketSeconds := preset.bucketSeconds + totalSeconds := hours * 3600 + nBuckets := totalSeconds / bucketSeconds + + // Align the strip to the most-recent 15-min boundary so the rightmost + // column always represents "now" rather than a partial bucket. Avoids + // the visual confusion of an under-filled trailing cell. + now := time.Now().UTC() + endBucket := time.Unix((now.Unix()/int64(bucketSeconds))*int64(bucketSeconds), 0).UTC() + startBucket := endBucket.Add(-time.Duration(nBuckets-1) * time.Duration(bucketSeconds) * time.Second) + + rows, err := h.AuditLog.GetEnvActivityBucketed(env.ID, startBucket, bucketSeconds) + if err != nil { + apiErrorResponse(w, "failed to load activity", http.StatusInternalServerError, err) + return + } + + // Pre-allocate the contiguous bucket array so empty windows still ship a + // row. Indexing is by `(bucket_start - startUnix) / bucketSeconds`, + // floor-clamped to [0, nBuckets-1]. + startUnix := startBucket.Unix() + out := make([]ActivityBucket, nBuckets) + for i := range out { + out[i].BucketStart = startBucket.Add(time.Duration(i) * time.Duration(bucketSeconds) * time.Second) + } + for _, row := range rows { + idx := int((row.BucketStart - startUnix) / int64(bucketSeconds)) + if idx < 0 || idx >= nBuckets { + continue + } + switch row.LogType { + case auditlog.LogTypeSetting, auditlog.LogTypeEnvironment: + out[idx].Config += int(row.Cnt) + case auditlog.LogTypeQuery: + out[idx].Query += int(row.Cnt) + case auditlog.LogTypeCarve: + out[idx].Carve += int(row.Cnt) + case auditlog.LogTypeNode: + out[idx].Enroll += int(row.Cnt) + } + } + + w.Header().Set("Cache-Control", "private, max-age=30") + utils.HTTPResponse(w, utils.JSONApplicationUTF8, http.StatusOK, out) +} + +// NodeActivityBucket is one cell of the per-node 24h activity heatmap. +// Categories pivot from the env-scoped variant — node-scoped activity is +// about what THIS device has been doing, not what operators have done to +// the env. So: +// - status ← osquery_status_data row count (status logs received from this node) +// - result ← osquery_result_data row count (query results returned by this node) +// - query ← node_queries row count (distributed queries scheduled against this node) +// - carve ← carved_files row count (carves this node has produced) +// +// All four are joinable by node uuid (or numeric node id for node_queries). +type NodeActivityBucket struct { + BucketStart time.Time `json:"bucket_start"` + Status int `json:"status"` + Result int `json:"result"` + Query int `json:"query"` + Carve int `json:"carve"` +} + +// NodeActivityHandler — GET /api/v1/stats/activity/node/{env}/{uuid}?interval=KEY +// +// Per-node version of EnvActivityHandler. Same bucketing rules (see +// activityIntervalPresets). The four categories partition different DB +// tables (see NodeActivityBucket) keyed by the node's UUID — except +// node_queries which keys by numeric NodeID, looked up once from the +// resolved node. +func (h *HandlersApi) NodeActivityHandler(w http.ResponseWriter, r *http.Request) { + ctxVal := r.Context().Value(ContextKey(contextAPI)) + if ctxVal == nil { + apiErrorResponse(w, "missing auth context", http.StatusUnauthorized, nil) + return + } + ctx := ctxVal.(ContextValue) + user := ctx[ctxUser] + + envVar := r.PathValue("env") + uuidVar := r.PathValue("uuid") + if envVar == "" || uuidVar == "" { + apiErrorResponse(w, "env and uuid required", http.StatusBadRequest, nil) + return + } + env, err := h.Envs.Get(envVar) + if err != nil { + apiErrorResponse(w, "error getting environment", http.StatusNotFound, err) + return + } + if !h.Users.CheckPermissions(user, users.UserLevel, env.UUID) { + apiErrorResponse(w, "no access", http.StatusForbidden, fmt.Errorf("attempt to use API by user %s", user)) + return + } + // Resolve the node — gives us the numeric NodeID for the node_queries + // join and lets us reject probes for arbitrary UUIDs across tenants. + node, err := h.Nodes.GetByUUID(uuidVar) + if err != nil { + apiErrorResponse(w, "node not found", http.StatusNotFound, err) + return + } + if !strings.EqualFold(node.Environment, env.Name) { + apiErrorResponse(w, "node not in environment", http.StatusForbidden, nil) + return + } + + intervalKey := r.URL.Query().Get("interval") + preset, ok := activityIntervalPresets[intervalKey] + if !ok { + intervalKey = "1d" + preset = activityIntervalPresets["1d"] + } + hours := activityIntervalHours[intervalKey] + bucketSeconds := preset.bucketSeconds + totalSeconds := hours * 3600 + nBuckets := totalSeconds / bucketSeconds + + now := time.Now().UTC() + endBucket := time.Unix((now.Unix()/int64(bucketSeconds))*int64(bucketSeconds), 0).UTC() + startBucket := endBucket.Add(-time.Duration(nBuckets-1) * time.Duration(bucketSeconds) * time.Second) + + out := h.computeNodeActivityForNode(env.Name, node.UUID, node.ID, startBucket, bucketSeconds, nBuckets) + w.Header().Set("Cache-Control", "private, max-age=30") + utils.HTTPResponse(w, utils.JSONApplicationUTF8, http.StatusOK, out) +} + +// computeNodeActivityForNode runs the 4-table bucketed-count pipeline for +// one node and returns the dense bucket array. Shared by both +// NodeActivityHandler and NodeActivityBatchHandler so the bucketing rules +// stay in one place. +// +// Each category issues a single SQL GROUP BY rather than plucking every +// CreatedAt — at 50k+ nodes a chatty status_data table would otherwise +// stream tens of thousands of timestamps per Nodes page row. +// Fail-soft per category: a single-table error still renders the others. +func (h *HandlersApi) computeNodeActivityForNode( + envName string, + nodeUUID string, + nodeID uint, + startBucket time.Time, + bucketSeconds int, + nBuckets int, +) []NodeActivityBucket { + startUnix := startBucket.Unix() + + statusRows, err := logging.GetNodeStatusBucketed(h.DB, envName, nodeUUID, startBucket, bucketSeconds) + if err != nil { + log.Warn().Err(err).Str("node", nodeUUID).Msg("node-activity: status bucketed failed") + } + resultRows, err := logging.GetNodeResultBucketed(h.DB, envName, nodeUUID, startBucket, bucketSeconds) + if err != nil { + log.Warn().Err(err).Str("node", nodeUUID).Msg("node-activity: result bucketed failed") + } + queryRows, err := h.Queries.GetNodeQueryBucketed(nodeID, startBucket, bucketSeconds) + if err != nil { + log.Warn().Err(err).Str("node", nodeUUID).Msg("node-activity: node-query bucketed failed") + } + carveRows, err := h.Carves.GetNodeCarveBucketed(nodeUUID, startBucket, bucketSeconds) + if err != nil { + log.Warn().Err(err).Str("node", nodeUUID).Msg("node-activity: carve bucketed failed") + } + + statusDense := dbutil.DensifyBuckets(statusRows, startUnix, bucketSeconds, nBuckets) + resultDense := dbutil.DensifyBuckets(resultRows, startUnix, bucketSeconds, nBuckets) + queryDense := dbutil.DensifyBuckets(queryRows, startUnix, bucketSeconds, nBuckets) + carveDense := dbutil.DensifyBuckets(carveRows, startUnix, bucketSeconds, nBuckets) + + out := make([]NodeActivityBucket, nBuckets) + for i := range out { + out[i].BucketStart = startBucket.Add(time.Duration(i) * time.Duration(bucketSeconds) * time.Second) + out[i].Status = int(statusDense[i]) + out[i].Result = int(resultDense[i]) + out[i].Query = int(queryDense[i]) + out[i].Carve = int(carveDense[i]) + } + return out +} + +// NodeActivityBatchHandler — GET /api/v1/stats/activity/node-batch/{env}?uuids=A,B,C&interval=KEY +// +// Returns activity buckets for up to 100 nodes in one call. The response is +// a map keyed by node UUID so the SPA can render a sparkline per row in the +// Nodes table without firing N parallel requests. +// +// Cap is 100 to bound the per-request DB load — each node still requires 4 +// timestamp queries. The SPA's pagination is already <=500 page size; for +// pages above 100 nodes the SPA fans out 2-3 batch requests instead. +// +// Unknown / unauthorized UUIDs are silently omitted from the response +// (they're treated as "no data"), not 404'd — that lets a single bad UUID +// in the list not break the whole page render. +func (h *HandlersApi) NodeActivityBatchHandler(w http.ResponseWriter, r *http.Request) { + ctxVal := r.Context().Value(ContextKey(contextAPI)) + if ctxVal == nil { + apiErrorResponse(w, "missing auth context", http.StatusUnauthorized, nil) + return + } + ctx := ctxVal.(ContextValue) + user := ctx[ctxUser] + + envVar := r.PathValue("env") + if envVar == "" { + apiErrorResponse(w, "env required", http.StatusBadRequest, nil) + return + } + env, err := h.Envs.Get(envVar) + if err != nil { + apiErrorResponse(w, "error getting environment", http.StatusNotFound, err) + return + } + if !h.Users.CheckPermissions(user, users.UserLevel, env.UUID) { + apiErrorResponse(w, "no access", http.StatusForbidden, fmt.Errorf("attempt to use API by user %s", user)) + return + } + + uuidsParam := strings.TrimSpace(r.URL.Query().Get("uuids")) + if uuidsParam == "" { + // Empty request → empty response. Avoids the page from breaking when + // the SPA's `nodes` query returns 0 rows (zero-length CSV). + utils.HTTPResponse(w, utils.JSONApplicationUTF8, http.StatusOK, map[string][]NodeActivityBucket{}) + return + } + rawUUIDs := strings.Split(uuidsParam, ",") + const maxBatch = 100 + if len(rawUUIDs) > maxBatch { + rawUUIDs = rawUUIDs[:maxBatch] + } + // Dedupe + normalize (upper-case, like the DB stores them). + seen := make(map[string]struct{}, len(rawUUIDs)) + uuids := rawUUIDs[:0] + for _, u := range rawUUIDs { + u = strings.ToUpper(strings.TrimSpace(u)) + if u == "" { + continue + } + if _, dup := seen[u]; dup { + continue + } + seen[u] = struct{}{} + uuids = append(uuids, u) + } + + intervalKey := r.URL.Query().Get("interval") + preset, ok := activityIntervalPresets[intervalKey] + if !ok { + intervalKey = "1d" + preset = activityIntervalPresets["1d"] + } + hours := activityIntervalHours[intervalKey] + bucketSeconds := preset.bucketSeconds + totalSeconds := hours * 3600 + nBuckets := totalSeconds / bucketSeconds + + now := time.Now().UTC() + endBucket := time.Unix((now.Unix()/int64(bucketSeconds))*int64(bucketSeconds), 0).UTC() + startBucket := endBucket.Add(-time.Duration(nBuckets-1) * time.Duration(bucketSeconds) * time.Second) + + out := make(map[string][]NodeActivityBucket, len(uuids)) + for _, u := range uuids { + // Per-uuid resolution. A miss is logged-but-skipped rather than + // failed-the-whole-batch — see handler comment for rationale. + node, err := h.Nodes.GetByUUID(u) + if err != nil { + log.Debug().Err(err).Str("node", u).Msg("node-activity-batch: uuid not found, skipping") + continue + } + if !strings.EqualFold(node.Environment, env.Name) { + log.Debug().Str("node", u).Msg("node-activity-batch: uuid not in env, skipping") + continue + } + out[node.UUID] = h.computeNodeActivityForNode(env.Name, node.UUID, node.ID, startBucket, bucketSeconds, nBuckets) + } + + w.Header().Set("Cache-Control", "private, max-age=30") + utils.HTTPResponse(w, utils.JSONApplicationUTF8, http.StatusOK, out) +} + +// OsqueryVersionsHandler — GET /api/v1/stats/osquery-versions. +// +// Returns fleet-wide osquery agent version breakdown for the dashboard's +// "fleet hygiene" panel. Operators use this to spot stale agents that need +// upgrading. Cross-env (no env filter); the dashboard already surfaces the +// per-env breakdown in its env tiles. +// +// Counts include both active and inactive nodes — a node sitting at an old +// osquery version is still "stale" even if it's offline today, because once +// it comes back online it'll come back stale. +func (h *HandlersApi) OsqueryVersionsHandler(w http.ResponseWriter, r *http.Request) { + ctxVal := r.Context().Value(ContextKey(contextAPI)) + if ctxVal == nil { + apiErrorResponse(w, "missing auth context", http.StatusUnauthorized, nil) + return + } + rows, err := h.Nodes.GetOsqueryVersionCounts() + if err != nil { + apiErrorResponse(w, "failed to load osquery versions", http.StatusInternalServerError, err) + return + } + w.Header().Set("Cache-Control", "private, max-age=60") + utils.HTTPResponse(w, utils.JSONApplicationUTF8, http.StatusOK, rows) +} diff --git a/cmd/api/handlers/stats_test.go b/cmd/api/handlers/stats_test.go new file mode 100644 index 00000000..b374e88e --- /dev/null +++ b/cmd/api/handlers/stats_test.go @@ -0,0 +1,94 @@ +package handlers + +import ( + "encoding/json" + "testing" +) + +// TestStatsResponseShape verifies the JSON tags on the response types are +// snake_case and match the OpenAPI schema field names. This catches regressions +// where a field rename in Go doesn't propagate to the JSON output shape. +// +// Full integration tests (DB-backed) are deferred: the underlying +// pkg/nodes.GetStatsByEnv and pkg/queries.GetQueries/GetCarves are covered by +// their own package tests. A handler-level integration test would require +// substantial DB fixturing that is out of scope for Track 2. +func TestStatsResponseShape(t *testing.T) { + resp := StatsResponse{ + TotalNodes: 10, + ActiveNodes: 7, + InactiveNodes: 3, + TotalActiveQueries: 2, + TotalActiveCarves: 1, + Environments: []EnvStats{ + { + UUID: "env-uuid-1", + Name: "prod", + Active: 5, + Inactive: 2, + Total: 7, + ActiveQueries: 1, + ActiveCarves: 0, + }, + }, + } + + b, err := json.Marshal(resp) + if err != nil { + t.Fatalf("json.Marshal(StatsResponse): %v", err) + } + + var m map[string]interface{} + if err := json.Unmarshal(b, &m); err != nil { + t.Fatalf("json.Unmarshal: %v", err) + } + + // Verify top-level snake_case field names. + topLevel := []string{ + "total_nodes", + "active_nodes", + "inactive_nodes", + "total_active_queries", + "total_active_carves", + "platform_counts", + "environments", + } + for _, key := range topLevel { + if _, ok := m[key]; !ok { + t.Errorf("StatsResponse JSON missing field %q", key) + } + } + + // Verify per-env field names in the first environments entry. + envs, ok := m["environments"].([]interface{}) + if !ok || len(envs) == 0 { + t.Fatal("StatsResponse.environments is empty or wrong type") + } + envMap, ok := envs[0].(map[string]interface{}) + if !ok { + t.Fatal("environments[0] is not a JSON object") + } + envLevel := []string{ + "uuid", + "name", + "active", + "inactive", + "total", + "active_queries", + "active_carves", + "platform_counts", + } + for _, key := range envLevel { + if _, ok := envMap[key]; !ok { + t.Errorf("EnvStats JSON missing field %q", key) + } + } + + // Verify numeric totals round-trip correctly. + if got := m["total_nodes"]; got != float64(10) { + t.Errorf("total_nodes = %v, want 10", got) + } + if got := m["active_nodes"]; got != float64(7) { + t.Errorf("active_nodes = %v, want 7", got) + } +} diff --git a/cmd/api/handlers/tags.go b/cmd/api/handlers/tags.go index 552045a2..801aaba2 100644 --- a/cmd/api/handlers/tags.go +++ b/cmd/api/handlers/tags.go @@ -38,26 +38,25 @@ func (h *HandlersApi) AllTagsHandler(w http.ResponseWriter, r *http.Request) { utils.HTTPResponse(w, utils.JSONApplicationUTF8, http.StatusOK, tags) } -// TagEnvHandler - GET Handler to return one tag for one environment as JSON +// TagEnvHandler - GET Handler to return one tag for one environment as JSON. +// Permission is scoped to env.UUID admin so non-super operators with admin +// rights on this specific environment can view its tags. func (h *HandlersApi) TagEnvHandler(w http.ResponseWriter, r *http.Request) { // Debug HTTP if enabled if h.DebugHTTPConfig.EnableHTTP { utils.DebugHTTPDump(h.DebugHTTP, r, h.DebugHTTPConfig.ShowBody) } - // Extract environment envVar := r.PathValue("env") if envVar == "" { apiErrorResponse(w, "error getting environment", http.StatusBadRequest, nil) return } - // Extract tag name tagVar := r.PathValue("name") if tagVar == "" { apiErrorResponse(w, "error getting tag name", http.StatusBadRequest, nil) return } - // Get environment by UUID - env, err := h.Envs.GetByUUID(envVar) + env, err := h.Envs.Get(envVar) if err != nil { if err.Error() == "record not found" { apiErrorResponse(w, "environment not found", http.StatusNotFound, err) @@ -66,38 +65,33 @@ func (h *HandlersApi) TagEnvHandler(w http.ResponseWriter, r *http.Request) { } return } - // Get context data and check access ctx := r.Context().Value(ContextKey(contextAPI)).(ContextValue) - if !h.Users.CheckPermissions(ctx[ctxUser], users.AdminLevel, users.NoEnvironment) { + if !h.Users.CheckPermissions(ctx[ctxUser], users.AdminLevel, env.UUID) { apiErrorResponse(w, "no access", http.StatusForbidden, fmt.Errorf("attempt to use API by user %s", ctx[ctxUser])) return } - // Get tag exist, tag := h.Tags.ExistsGet(tagVar, env.ID) if !exist { - apiErrorResponse(w, "error getting tag", http.StatusInternalServerError, err) + apiErrorResponse(w, "tag not found", http.StatusNotFound, nil) return } - // Serialize and serve JSON log.Debug().Msg("Returned tag") h.AuditLog.Visit(ctx[ctxUser], r.URL.Path, strings.Split(r.RemoteAddr, ":")[0], env.ID) utils.HTTPResponse(w, utils.JSONApplicationUTF8, http.StatusOK, tag) } -// TagsEnvHandler - GET Handler to return tags for one environment as JSON +// TagsEnvHandler - GET Handler to return tags for one environment as JSON. +// Permission is scoped to env.UUID admin (see TagEnvHandler note). func (h *HandlersApi) TagsEnvHandler(w http.ResponseWriter, r *http.Request) { - // Debug HTTP if enabled if h.DebugHTTPConfig.EnableHTTP { utils.DebugHTTPDump(h.DebugHTTP, r, h.DebugHTTPConfig.ShowBody) } - // Extract environment envVar := r.PathValue("env") if envVar == "" { apiErrorResponse(w, "error getting environment", http.StatusBadRequest, nil) return } - // Get environment by UUID - env, err := h.Envs.GetByUUID(envVar) + env, err := h.Envs.Get(envVar) if err != nil { if err.Error() == "record not found" { apiErrorResponse(w, "environment not found", http.StatusNotFound, err) @@ -106,38 +100,39 @@ func (h *HandlersApi) TagsEnvHandler(w http.ResponseWriter, r *http.Request) { } return } - // Get context data and check access ctx := r.Context().Value(ContextKey(contextAPI)).(ContextValue) - if !h.Users.CheckPermissions(ctx[ctxUser], users.AdminLevel, users.NoEnvironment) { + if !h.Users.CheckPermissions(ctx[ctxUser], users.AdminLevel, env.UUID) { apiErrorResponse(w, "no access", http.StatusForbidden, fmt.Errorf("attempt to use API by user %s", ctx[ctxUser])) return } - // Get tags - tags, err := h.Tags.GetByEnv(env.ID) + tagList, err := h.Tags.GetByEnv(env.ID) if err != nil { apiErrorResponse(w, "error getting tags", http.StatusInternalServerError, err) return } - // Serialize and serve JSON - log.Debug().Msgf("Returned %d tags", len(tags)) + // Empty list is a valid state — never return 404 on listing. + if tagList == nil { + tagList = []tags.AdminTag{} + } + log.Debug().Msgf("Returned %d tags", len(tagList)) h.AuditLog.Visit(ctx[ctxUser], r.URL.Path, strings.Split(r.RemoteAddr, ":")[0], env.ID) - utils.HTTPResponse(w, utils.JSONApplicationUTF8, http.StatusOK, tags) + utils.HTTPResponse(w, utils.JSONApplicationUTF8, http.StatusOK, tagList) } -// TagsActionHandler - POST Handler to create, update or delete tags +// TagsActionHandler - POST Handler to create / update / delete tags. The +// action arrives as a URL path segment (legacy contract retained because +// Track 6 doesn't introduce new tag routes); body validation surfaces 400 +// on parse error and 409 on duplicate-name conflicts. func (h *HandlersApi) TagsActionHandler(w http.ResponseWriter, r *http.Request) { - // Debug HTTP if enabled if h.DebugHTTPConfig.EnableHTTP { utils.DebugHTTPDump(h.DebugHTTP, r, h.DebugHTTPConfig.ShowBody) } - // Extract environment envVar := r.PathValue("env") if envVar == "" { apiErrorResponse(w, "error getting environment", http.StatusBadRequest, nil) return } - // Get environment by UUID - env, err := h.Envs.GetByUUID(envVar) + env, err := h.Envs.Get(envVar) if err != nil { if err.Error() == "record not found" { apiErrorResponse(w, "environment not found", http.StatusNotFound, err) @@ -146,37 +141,42 @@ func (h *HandlersApi) TagsActionHandler(w http.ResponseWriter, r *http.Request) } return } - // Get context data and check access ctx := r.Context().Value(ContextKey(contextAPI)).(ContextValue) - if !h.Users.CheckPermissions(ctx[ctxUser], users.AdminLevel, users.NoEnvironment) { + if !h.Users.CheckPermissions(ctx[ctxUser], users.AdminLevel, env.UUID) { apiErrorResponse(w, "no access", http.StatusForbidden, fmt.Errorf("attempt to use API by user %s", ctx[ctxUser])) return } - // Extract action actionVar := r.PathValue("action") if actionVar == "" { apiErrorResponse(w, "error getting action", http.StatusBadRequest, nil) return } var t types.ApiTagsRequest - // Parse request JSON body if err := json.NewDecoder(r.Body).Decode(&t); err != nil { - apiErrorResponse(w, "error parsing POST body", http.StatusInternalServerError, err) + apiErrorResponse(w, "error parsing POST body", http.StatusBadRequest, err) + return + } + if t.Name == "" { + apiErrorResponse(w, "tag name can not be empty", http.StatusBadRequest, nil) return } var returnData string switch actionVar { case tags.ActionAdd: if h.Tags.ExistsByEnv(t.Name, env.ID) { - apiErrorResponse(w, "error adding tag", http.StatusInternalServerError, fmt.Errorf("tag %s already exists", t.Name)) + apiErrorResponse(w, "tag with that name already exists in this environment", http.StatusConflict, nil) return } if err := h.Tags.NewTag(t.Name, t.Description, t.Color, t.Icon, ctx[ctxUser], env.ID, false, t.TagType, t.Custom); err != nil { - apiErrorResponse(w, "error with new tag", http.StatusInternalServerError, err) + apiErrorResponse(w, "error creating tag", http.StatusInternalServerError, err) return } returnData = "tag added successfully" case tags.ActionEdit: + if !h.Tags.ExistsByEnv(t.Name, env.ID) { + apiErrorResponse(w, "tag not found", http.StatusNotFound, nil) + return + } tag, err := h.Tags.Get(t.Name, env.ID) if err != nil { apiErrorResponse(w, "error getting tag", http.StatusInternalServerError, err) @@ -218,13 +218,19 @@ func (h *HandlersApi) TagsActionHandler(w http.ResponseWriter, r *http.Request) } returnData = "tag updated successfully" case tags.ActionRemove: + if !h.Tags.ExistsByEnv(t.Name, env.ID) { + apiErrorResponse(w, "tag not found", http.StatusNotFound, nil) + return + } if err := h.Tags.DeleteGet(t.Name, env.ID); err != nil { apiErrorResponse(w, "error removing tag", http.StatusInternalServerError, err) return } returnData = "tag removed successfully" + default: + apiErrorResponse(w, "invalid action", http.StatusBadRequest, nil) + return } - // Serialize and serve JSON log.Debug().Msgf("Returned [%s]", returnData) h.AuditLog.TagAction(ctx[ctxUser], actionVar+" tag "+t.Name, strings.Split(r.RemoteAddr, ":")[0], env.ID) utils.HTTPResponse(w, utils.JSONApplicationUTF8, http.StatusOK, types.ApiDataResponse{Data: returnData}) diff --git a/cmd/api/handlers/users.go b/cmd/api/handlers/users.go index 759f80c4..7823defb 100644 --- a/cmd/api/handlers/users.go +++ b/cmd/api/handlers/users.go @@ -13,6 +13,26 @@ import ( "github.com/rs/zerolog/log" ) +// projectAdminUserView strips network-and-timing metadata +// (LastIPAddress / LastUserAgent / LastAccess / LastTokenUse) from an +// AdminUser before serialization to a cross-user reader. Operators +// querying their own row use /api/v1/users/me's full UserMeResponse. +func projectAdminUserView(u users.AdminUser) types.AdminUserView { + return types.AdminUserView{ + ID: u.ID, + CreatedAt: u.CreatedAt, + UpdatedAt: u.UpdatedAt, + Username: u.Username, + Email: u.Email, + Fullname: u.Fullname, + Admin: u.Admin, + Service: u.Service, + UUID: u.UUID, + TokenExpire: u.TokenExpire, + EnvironmentID: u.EnvironmentID, + } +} + // UserHandler - GET Handler for environment users func (h *HandlersApi) UserHandler(w http.ResponseWriter, r *http.Request) { // Debug HTTP if enabled @@ -37,10 +57,12 @@ func (h *HandlersApi) UserHandler(w http.ResponseWriter, r *http.Request) { apiErrorResponse(w, "error getting user", http.StatusInternalServerError, nil) return } - // Serialize and serve JSON + // Serialize and serve the PII-minimized view; the full user record + // is only available to the user themselves via /api/v1/users/me. + // log.Debug().Msgf("Returned user %s", usernameVar) h.AuditLog.Visit(ctx[ctxUser], r.URL.Path, strings.Split(r.RemoteAddr, ":")[0], auditlog.NoEnvironment) - utils.HTTPResponse(w, utils.JSONApplicationUTF8, http.StatusOK, user) + utils.HTTPResponse(w, utils.JSONApplicationUTF8, http.StatusOK, projectAdminUserView(user)) } // UsersHandler - GET Handler for multiple JSON nodes @@ -56,19 +78,24 @@ func (h *HandlersApi) UsersHandler(w http.ResponseWriter, r *http.Request) { return } // Get users - users, err := h.Users.All() + all, err := h.Users.All() if err != nil { apiErrorResponse(w, "error getting users", http.StatusInternalServerError, err) return } - if len(users) == 0 { + if len(all) == 0 { apiErrorResponse(w, "no users", http.StatusNotFound, nil) return } - // Serialize and serve JSON - log.Debug().Msgf("Returned %d users", len(users)) + // PII-minimized view for the cross-user list — see projectAdminUserView. + // + views := make([]types.AdminUserView, 0, len(all)) + for _, u := range all { + views = append(views, projectAdminUserView(u)) + } + log.Debug().Msgf("Returned %d users", len(views)) h.AuditLog.Visit(ctx[ctxUser], r.URL.Path, strings.Split(r.RemoteAddr, ":")[0], auditlog.NoEnvironment) - utils.HTTPResponse(w, utils.JSONApplicationUTF8, http.StatusOK, users) + utils.HTTPResponse(w, utils.JSONApplicationUTF8, http.StatusOK, views) } // UserActionHandler - POST Handler to take actions on a user by username and environment diff --git a/cmd/api/handlers/users_profile.go b/cmd/api/handlers/users_profile.go new file mode 100644 index 00000000..1da560ed --- /dev/null +++ b/cmd/api/handlers/users_profile.go @@ -0,0 +1,293 @@ +package handlers + +import ( + "encoding/json" + "errors" + "fmt" + "net/http" + "strings" + + "github.com/jmpsec/osctrl/pkg/types" + "github.com/jmpsec/osctrl/pkg/users" + "github.com/jmpsec/osctrl/pkg/utils" + "github.com/rs/zerolog/log" + "gorm.io/gorm" +) + +const tokenRefreshDefaultHours = 24 + +// SetUserPermissionsHandler - POST /api/v1/users/{username}/permissions +// +// Body: { env_uuid, access: { user, query, carve, admin } }. Replaces the +// target user's per-env access rows. Returns 200 with the new EnvAccess. +// Requires super-admin (AdminLevel, NoEnvironment) — env-scoped admins can +// not grant permissions for their environment from this endpoint. +func (h *HandlersApi) SetUserPermissionsHandler(w http.ResponseWriter, r *http.Request) { + if h.DebugHTTPConfig.EnableHTTP { + utils.DebugHTTPDump(h.DebugHTTP, r, h.DebugHTTPConfig.ShowBody) + } + ctx := r.Context().Value(ContextKey(contextAPI)).(ContextValue) + if !h.Users.CheckPermissions(ctx[ctxUser], users.AdminLevel, users.NoEnvironment) { + apiErrorResponse(w, "no access", http.StatusForbidden, fmt.Errorf("attempt to use API by user %s", ctx[ctxUser])) + return + } + username := r.PathValue("username") + if username == "" { + apiErrorResponse(w, "missing username", http.StatusBadRequest, nil) + return + } + if !h.Users.Exists(username) { + apiErrorResponse(w, "user not found", http.StatusNotFound, nil) + return + } + + var body types.SetPermissionsRequest + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + apiErrorResponse(w, "error parsing POST body", http.StatusBadRequest, err) + return + } + body.EnvUUID = strings.TrimSpace(body.EnvUUID) + if body.EnvUUID == "" { + apiErrorResponse(w, "env_uuid is required", http.StatusBadRequest, nil) + return + } + if _, err := h.Envs.GetByUUID(body.EnvUUID); err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + apiErrorResponse(w, "environment not found", http.StatusNotFound, err) + return + } + apiErrorResponse(w, "error getting environment", http.StatusInternalServerError, err) + return + } + + access := users.EnvAccess{ + User: body.Access.User, + Query: body.Access.Query, + Carve: body.Access.Carve, + Admin: body.Access.Admin, + } + + // Lockout guards. A super-admin cannot: + // 1. Self-demote — granting yourself a strict downgrade via this + // endpoint risks locking yourself out of further permission + // changes if no other super-admin exists. Force the operator + // to go through another super-admin. + // 2. Demote the LAST super-admin under any path. If admin=false + // and the target is the only AdminUser.Admin=true row, the + // system has no remaining super-admin and no one can manage + // users / envs / settings. Refuse with 409. + if username == ctx[ctxUser] && !access.Admin { + apiErrorResponse(w, "super-admins cannot self-demote via this endpoint", http.StatusForbidden, nil) + return + } + if !access.Admin && h.Users.IsAdmin(username) { + count, cerr := h.Users.CountAdmins() + if cerr != nil { + apiErrorResponse(w, "error checking admin count", http.StatusInternalServerError, cerr) + return + } + if count <= 1 { + apiErrorResponse(w, "refusing to demote the last super-admin", http.StatusConflict, fmt.Errorf("only %d admin user(s) remain", count)) + return + } + } + + if err := h.Users.ChangeAccess(username, body.EnvUUID, access); err != nil { + apiErrorResponse(w, "error setting permissions", http.StatusInternalServerError, err) + return + } + + h.AuditLog.Permissions(ctx[ctxUser], + fmt.Sprintf("set %s on env=%s u=%v q=%v c=%v a=%v", + username, body.EnvUUID, access.User, access.Query, access.Carve, access.Admin), + strings.Split(r.RemoteAddr, ":")[0], 0) + log.Debug().Msgf("permissions updated for user %s on env %s", username, body.EnvUUID) + utils.HTTPResponse(w, utils.JSONApplicationUTF8, http.StatusOK, body.Access) +} + +// RefreshUserTokenHandler - POST /api/v1/users/{username}/token/refresh +// +// Generates a new JWT for the target user, persists it as the user's +// APIToken (invalidating the previous token), and returns the new token + +// expiry. Requires super-admin OR the request author asking for their own +// token. Audit-logged on success. +func (h *HandlersApi) RefreshUserTokenHandler(w http.ResponseWriter, r *http.Request) { + if h.DebugHTTPConfig.EnableHTTP { + utils.DebugHTTPDump(h.DebugHTTP, r, h.DebugHTTPConfig.ShowBody) + } + ctx := r.Context().Value(ContextKey(contextAPI)).(ContextValue) + username := r.PathValue("username") + if username == "" { + apiErrorResponse(w, "missing username", http.StatusBadRequest, nil) + return + } + requester := ctx[ctxUser] + isSelf := username == requester + if !isSelf && !h.Users.CheckPermissions(requester, users.AdminLevel, users.NoEnvironment) { + apiErrorResponse(w, "no access", http.StatusForbidden, fmt.Errorf("attempt to refresh token for %s by %s", username, requester)) + return + } + if !h.Users.Exists(username) { + apiErrorResponse(w, "user not found", http.StatusNotFound, nil) + return + } + + token, expires, err := h.Users.CreateToken(username, h.ServiceName, tokenRefreshDefaultHours) + if err != nil { + apiErrorResponse(w, "error creating token", http.StatusInternalServerError, err) + return + } + if err := h.Users.UpdateToken(username, token, expires); err != nil { + apiErrorResponse(w, "error persisting token", http.StatusInternalServerError, err) + return + } + h.AuditLog.NewToken(username, strings.Split(r.RemoteAddr, ":")[0]) + log.Debug().Msgf("refreshed API token for %s (requested by %s)", username, requester) + utils.HTTPResponse(w, utils.JSONApplicationUTF8, http.StatusOK, types.TokenResponse{Token: token, Expires: expires}) +} + +// DeleteUserTokenHandler - DELETE /api/v1/users/{username}/token +// +// Clears the user's APIToken so any existing JWT for them stops working. +// Requires super-admin OR the user themselves. +func (h *HandlersApi) DeleteUserTokenHandler(w http.ResponseWriter, r *http.Request) { + if h.DebugHTTPConfig.EnableHTTP { + utils.DebugHTTPDump(h.DebugHTTP, r, h.DebugHTTPConfig.ShowBody) + } + ctx := r.Context().Value(ContextKey(contextAPI)).(ContextValue) + username := r.PathValue("username") + if username == "" { + apiErrorResponse(w, "missing username", http.StatusBadRequest, nil) + return + } + requester := ctx[ctxUser] + isSelf := username == requester + if !isSelf && !h.Users.CheckPermissions(requester, users.AdminLevel, users.NoEnvironment) { + apiErrorResponse(w, "no access", http.StatusForbidden, fmt.Errorf("attempt to delete token for %s by %s", username, requester)) + return + } + if err := h.Users.ClearToken(username); err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + apiErrorResponse(w, "user not found", http.StatusNotFound, err) + return + } + apiErrorResponse(w, "error clearing token", http.StatusInternalServerError, err) + return + } + h.AuditLog.UserAction(requester, "deleted token for "+username, strings.Split(r.RemoteAddr, ":")[0]) + log.Debug().Msgf("deleted API token for %s (requested by %s)", username, requester) + utils.HTTPResponse(w, utils.JSONApplicationUTF8, http.StatusOK, types.ApiGenericResponse{Message: "token deleted"}) +} + +// MeHandler - GET /api/v1/users/me +// +// Returns the currently authenticated user's profile (sans password hash +// and API token). Useful for the SPA's Profile page. +func (h *HandlersApi) MeHandler(w http.ResponseWriter, r *http.Request) { + if h.DebugHTTPConfig.EnableHTTP { + utils.DebugHTTPDump(h.DebugHTTP, r, h.DebugHTTPConfig.ShowBody) + } + ctx := r.Context().Value(ContextKey(contextAPI)).(ContextValue) + requester := ctx[ctxUser] + user, err := h.Users.Get(requester) + if err != nil { + apiErrorResponse(w, "error getting user", http.StatusInternalServerError, err) + return + } + resp := types.UserMeResponse{ + Username: user.Username, + Email: user.Email, + Fullname: user.Fullname, + Admin: user.Admin, + Service: user.Service, + UUID: user.UUID, + TokenExpire: user.TokenExpire, + LastAccess: user.LastAccess, + } + utils.HTTPResponse(w, utils.JSONApplicationUTF8, http.StatusOK, resp) +} + +// MePatchHandler - PATCH /api/v1/users/me +// +// Updates email and/or fullname for the currently authenticated user. Sends +// each empty field through unchanged. Returns the updated profile. +func (h *HandlersApi) MePatchHandler(w http.ResponseWriter, r *http.Request) { + if h.DebugHTTPConfig.EnableHTTP { + utils.DebugHTTPDump(h.DebugHTTP, r, h.DebugHTTPConfig.ShowBody) + } + ctx := r.Context().Value(ContextKey(contextAPI)).(ContextValue) + requester := ctx[ctxUser] + var body types.UserMePatchRequest + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + apiErrorResponse(w, "error parsing PATCH body", http.StatusBadRequest, err) + return + } + body.Email = strings.TrimSpace(body.Email) + body.Fullname = strings.TrimSpace(body.Fullname) + + if body.Email != "" { + if err := h.Users.ChangeEmail(requester, body.Email); err != nil { + apiErrorResponse(w, "error updating email", http.StatusInternalServerError, err) + return + } + } + if body.Fullname != "" { + if err := h.Users.ChangeFullname(requester, body.Fullname); err != nil { + apiErrorResponse(w, "error updating fullname", http.StatusInternalServerError, err) + return + } + } + + user, err := h.Users.Get(requester) + if err != nil { + apiErrorResponse(w, "error fetching updated user", http.StatusInternalServerError, err) + return + } + h.AuditLog.UserAction(requester, "updated own profile", strings.Split(r.RemoteAddr, ":")[0]) + utils.HTTPResponse(w, utils.JSONApplicationUTF8, http.StatusOK, types.UserMeResponse{ + Username: user.Username, + Email: user.Email, + Fullname: user.Fullname, + Admin: user.Admin, + Service: user.Service, + UUID: user.UUID, + TokenExpire: user.TokenExpire, + LastAccess: user.LastAccess, + }) +} + +// MePasswordHandler - POST /api/v1/users/me/password +// +// Changes the currently authenticated user's password. Verifies the +// current password (bcrypt) before persisting the new hash. +func (h *HandlersApi) MePasswordHandler(w http.ResponseWriter, r *http.Request) { + if h.DebugHTTPConfig.EnableHTTP { + utils.DebugHTTPDump(h.DebugHTTP, r, h.DebugHTTPConfig.ShowBody) + } + ctx := r.Context().Value(ContextKey(contextAPI)).(ContextValue) + requester := ctx[ctxUser] + + var body types.PasswordChangeRequest + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + apiErrorResponse(w, "error parsing POST body", http.StatusBadRequest, err) + return + } + if body.CurrentPassword == "" || body.NewPassword == "" { + apiErrorResponse(w, "current_password and new_password are required", http.StatusBadRequest, nil) + return + } + if len(body.NewPassword) < 8 { + apiErrorResponse(w, "new_password must be at least 8 characters", http.StatusBadRequest, nil) + return + } + if ok, _ := h.Users.CheckLoginCredentials(requester, body.CurrentPassword); !ok { + apiErrorResponse(w, "current password is incorrect", http.StatusForbidden, nil) + return + } + if err := h.Users.ChangePassword(requester, body.NewPassword); err != nil { + apiErrorResponse(w, "error changing password", http.StatusInternalServerError, err) + return + } + h.AuditLog.UserAction(requester, "changed own password", strings.Split(r.RemoteAddr, ":")[0]) + utils.HTTPResponse(w, utils.JSONApplicationUTF8, http.StatusOK, types.ApiGenericResponse{Message: "password changed"}) +} diff --git a/cmd/api/handlers/utils.go b/cmd/api/handlers/utils.go index 42b9f982..f3808411 100644 --- a/cmd/api/handlers/utils.go +++ b/cmd/api/handlers/utils.go @@ -3,11 +3,9 @@ package handlers import ( "net/http" - "github.com/jmpsec/osctrl/pkg/logging" "github.com/jmpsec/osctrl/pkg/types" "github.com/jmpsec/osctrl/pkg/utils" "github.com/rs/zerolog/log" - "gorm.io/gorm" ) // ContextValue to hold session data in the context @@ -25,19 +23,6 @@ const ( ctxUser string = "user" ) -// Function to retrieve the query log by name -func postgresQueryLogs(db *gorm.DB, name string) (APIQueryData, error) { - var logs []logging.OsqueryQueryData - data := make(APIQueryData) - if err := db.Where("name = ?", name).Find(&logs).Error; err != nil { - return data, err - } - for _, l := range logs { - data[l.UUID] = l.Data - } - return data, nil -} - // Helper to handle API error responses func apiErrorResponse(w http.ResponseWriter, msg string, code int, err error) { log.Debug().Msgf("apiErrorResponse %s: %v", msg, err) diff --git a/cmd/api/main.go b/cmd/api/main.go index dc569d02..a8e97eff 100644 --- a/cmd/api/main.go +++ b/cmd/api/main.go @@ -20,10 +20,14 @@ import ( "github.com/jmpsec/osctrl/pkg/environments" "github.com/jmpsec/osctrl/pkg/logging" "github.com/jmpsec/osctrl/pkg/nodes" + "github.com/jmpsec/osctrl/pkg/osquery" "github.com/jmpsec/osctrl/pkg/queries" + "github.com/jmpsec/osctrl/pkg/ratelimit" "github.com/jmpsec/osctrl/pkg/settings" "github.com/jmpsec/osctrl/pkg/tags" + "github.com/jmpsec/osctrl/pkg/types" "github.com/jmpsec/osctrl/pkg/users" + "github.com/jmpsec/osctrl/pkg/utils" "github.com/jmpsec/osctrl/pkg/version" "github.com/rs/zerolog" "github.com/rs/zerolog/log" @@ -72,6 +76,8 @@ const ( apiNodesPath = "/nodes" // API queries path apiQueriesPath = "/queries" + // API saved queries path + apiSavedQueriesPath = "/saved-queries" // API users path apiUsersPath = "/users" // API all queries path @@ -88,6 +94,12 @@ const ( apiSettingsPath = "/settings" // API audit logs path apiAuditLogsPath = "/audit-logs" + // API logs path + apiLogsPath = "/logs" + // API stats path + apiStatsPath = "/stats" + // API osquery path + apiOsqueryPath = "/osquery" ) // Global variables @@ -107,8 +119,9 @@ var ( flags []cli.Flag serviceConfiguration config.APIConfiguration // FIXME this struct is temporary until we refactor to write settings to the DB - flagParams *config.ServiceParameters - auditLog *auditlog.AuditLogManager + flagParams *config.ServiceParameters + auditLog *auditlog.AuditLogManager + osqueryTables []types.OsqueryTable ) // Valid values for auth and logging in configuration @@ -185,8 +198,49 @@ func checkLatestRelease() { } } +// guardAuthMode refuses to start the API with --auth=none unless the operator +// explicitly opts in via OSCTRL_INSECURE_NO_AUTH=1. When the opt-in is set, +// every 60s a loud warning is logged so the deployment cannot drift into +// "auth-off forever" without anyone noticing. +// +// The warning goroutine watches the supplied context so a future graceful +// shutdown path can cancel it cleanly. Today the API has no shutdown signal +// handling so the context never fires — that's acceptable; we get the +// no-leak property for free when shutdown is added. +func guardAuthMode(ctx context.Context, auth string) { + if auth != config.AuthNone { + return + } + if os.Getenv("OSCTRL_INSECURE_NO_AUTH") != "1" { + log.Fatal().Msg("auth=none is disabled by default. Set OSCTRL_INSECURE_NO_AUTH=1 to opt in for local development only — every request will be served as super-admin") + } + go func() { + log.Warn().Msg("INSECURE: osctrl-api running with auth=none — every request is served as super-admin. DO NOT use in production") + ticker := time.NewTicker(60 * time.Second) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + log.Warn().Msg("INSECURE: osctrl-api running with auth=none — every request is served as super-admin. DO NOT use in production") + } + } + }() +} + // Go go! func osctrlAPIService() { + // Refuse to run unauthenticated unless the operator explicitly opts in. + guardAuthMode(context.Background(), flagParams.Service.Auth) + // Configure forwarding-header trust. Empty (default) means utils.GetIP + // ignores X-Forwarded-For / X-Real-IP and always uses RemoteAddr, so + // an internet attacker can't spoof IPs to defeat rate-limits or + // poison the audit log. + if tp := strings.TrimSpace(flagParams.Service.TrustedProxies); tp != "" { + utils.SetTrustedProxies(strings.Split(tp, ",")) + log.Info().Msgf("Trusting forwarding headers from: %s", tp) + } // ////////////////////////////// Backend log.Info().Msg("Initializing backend...") for { @@ -222,7 +276,7 @@ func osctrlAPIService() { time.Sleep(time.Duration(flagParams.Redis.ConnRetry) * time.Second) } log.Info().Msg("Initialize users") - apiUsers = users.CreateUserManager(db.Conn, flagParams.JWT) + apiUsers = users.CreateUserManager(db.Conn).WithJWT(flagParams.JWT) log.Info().Msg("Initialize tags") tagsmgr = tags.CreateTagManager(db.Conn) log.Info().Msg("Initialize environment") @@ -248,6 +302,15 @@ func osctrlAPIService() { if err != nil { log.Fatal().Msgf("Error initializing audit log manager - %v", err) } + // Load osquery tables schema (best-effort; an empty slice is fine if the file doesn't exist) + if flagParams.Osquery.TablesFile != "" { + log.Info().Msgf("Loading osquery tables from %s", flagParams.Osquery.TablesFile) + osqueryTables, err = osquery.LoadTables(flagParams.Osquery.TablesFile) + if err != nil { + log.Warn().Msgf("Failed to load osquery tables: %v", err) + osqueryTables = []types.OsqueryTable{} + } + } // Initialize Admin handlers before router log.Info().Msg("Initializing handlers") handlersApi = handlers.CreateHandlersApi( @@ -264,8 +327,8 @@ func osctrlAPIService() { handlers.WithName(serviceName), handlers.WithAuditLog(auditLog), handlers.WithDebugHTTP(flagParams.Debug), + handlers.WithOsqueryTables(osqueryTables), handlers.WithOsqueryValues(*flagParams.Osquery), - ) // ///////////////////////// API @@ -284,7 +347,27 @@ func osctrlAPIService() { muxAPI.HandleFunc("GET "+_apiPath(checksNoAuthPath), handlersApi.CheckHandlerNoAuth) // ///////////////////////// UNAUTHENTICATED - muxAPI.HandleFunc("POST "+_apiPath(apiLoginPath)+"/{env}", handlersApi.LoginHandler) + // Login is the only password-acceptance surface on the API. Cap to + // 10 attempts per IP per minute (token-bucket; bursts of 10, refill + // at 1/6s) and 429 the rest. Rejections are audit-logged inside the + // LoginHandler / RateLimit middleware so SoC tooling sees the spray. + // + loginLimiter := ratelimit.New(10, time.Minute, 10*time.Minute) + loginRateLimit := loginLimiter.HTTPMiddleware(ratelimit.KeyByIP, func(r *http.Request, key string) { + handlersApi.AuditLog.FailedLogin("", utils.GetIP(r), "rate limit exceeded") + }) + muxAPI.Handle("POST "+_apiPath(apiLoginPath)+"/{env}", loginRateLimit(http.HandlerFunc(handlersApi.LoginHandler))) + // Read-only pre-auth endpoints (env list for the login picker, sample + // query/carve starter packs). These reveal no secrets and aren't a + // brute-force vector, so they get a much more permissive limiter — the + // SPA legitimately fetches them on every login-page render, and React + // strict-mode / browser reloads easily exceed a 10/min budget. We still + // rate-limit to block low-effort scanning probes, just at 60/min/IP. + preAuthLimiter := ratelimit.New(60, time.Minute, 10*time.Minute) + preAuthRateLimit := preAuthLimiter.HTTPMiddleware(ratelimit.KeyByIP, nil) + muxAPI.Handle("GET "+_apiPath(apiLoginPath)+"/environments", preAuthRateLimit(http.HandlerFunc(handlersApi.LoginEnvironmentsHandler))) + muxAPI.Handle("GET "+_apiPath(apiQueriesPath)+"/samples", preAuthRateLimit(http.HandlerFunc(handlersApi.QuerySamplesHandler))) + muxAPI.Handle("GET "+_apiPath(apiCarvesPath)+"/samples", preAuthRateLimit(http.HandlerFunc(handlersApi.CarveSamplesHandler))) // ///////////////////////// AUTHENTICATED // API: check auth muxAPI.Handle( @@ -311,6 +394,36 @@ func osctrlAPIService() { muxAPI.Handle( "POST "+_apiPath(apiNodesPath)+"/lookup", handlerAuthCheck(http.HandlerFunc(handlersApi.LookupNodeHandler), flagParams.Service.Auth, flagParams.JWT.JWTSecret)) + // API: paginated nodes — canonical SPA endpoint + muxAPI.Handle( + "GET "+_apiPath(apiNodesPath)+"/{env}", + handlerAuthCheck(http.HandlerFunc(handlersApi.NodesPagedHandler), flagParams.Service.Auth, flagParams.JWT.JWTSecret)) + // API: node logs + muxAPI.Handle( + "GET "+_apiPath(apiLogsPath)+"/{type}/{env}/{uuid}", + handlerAuthCheck(http.HandlerFunc(handlersApi.NodeLogsHandler), flagParams.Service.Auth, flagParams.JWT.JWTSecret)) + // API: cross-env dashboard stats + muxAPI.Handle( + "GET "+_apiPath(apiStatsPath), + handlerAuthCheck(http.HandlerFunc(handlersApi.StatsHandler), flagParams.Service.Auth, flagParams.JWT.JWTSecret)) + // API: fleet-wide osquery version breakdown for dashboard's hygiene panel. + muxAPI.Handle( + "GET "+_apiPath(apiStatsPath)+"/osquery-versions", + handlerAuthCheck(http.HandlerFunc(handlersApi.OsqueryVersionsHandler), flagParams.Service.Auth, flagParams.JWT.JWTSecret)) + // API: per-env activity heatmap (15-min audit-log buckets across N hours). + muxAPI.Handle( + "GET "+_apiPath(apiStatsPath)+"/activity/{env}", + handlerAuthCheck(http.HandlerFunc(handlersApi.EnvActivityHandler), flagParams.Service.Auth, flagParams.JWT.JWTSecret)) + // API: per-node activity heatmap (status/result/query/carve buckets). + muxAPI.Handle( + "GET "+_apiPath(apiStatsPath)+"/activity/node/{env}/{uuid}", + handlerAuthCheck(http.HandlerFunc(handlersApi.NodeActivityHandler), flagParams.Service.Auth, flagParams.JWT.JWTSecret)) + // Batch variant — accepts ?uuids=a,b,c (up to 100). Returns a map keyed by + // uuid. Lets the Nodes table render a per-row sparkline without firing N + // parallel HTTP requests. + muxAPI.Handle( + "GET "+_apiPath(apiStatsPath)+"/activity/node-batch/{env}", + handlerAuthCheck(http.HandlerFunc(handlersApi.NodeActivityBatchHandler), flagParams.Service.Auth, flagParams.JWT.JWTSecret)) // API: queries by environment if flagParams.Osquery.Query { muxAPI.Handle( @@ -328,13 +441,34 @@ func osctrlAPIService() { muxAPI.Handle( "GET "+_apiPath(apiQueriesPath)+"/{env}/results/{name}", handlerAuthCheck(http.HandlerFunc(handlersApi.QueryResultsHandler), flagParams.Service.Auth, flagParams.JWT.JWTSecret)) + // CSV export for query results + muxAPI.Handle( + "GET "+_apiPath(apiQueriesPath)+"/{env}/results/csv/{name}", + handlerAuthCheck(http.HandlerFunc(handlersApi.QueryResultsCSVHandler), flagParams.Service.Auth, flagParams.JWT.JWTSecret)) muxAPI.Handle( "GET "+_apiPath(apiAllQueriesPath+"/{env}"), handlerAuthCheck(http.HandlerFunc(handlersApi.AllQueriesShowHandler), flagParams.Service.Auth, flagParams.JWT.JWTSecret)) muxAPI.Handle( "POST "+_apiPath(apiQueriesPath)+"/{env}/{action}/{name}", handlerAuthCheck(http.HandlerFunc(handlersApi.QueriesActionHandler), flagParams.Service.Auth, flagParams.JWT.JWTSecret)) + // API: saved queries (Track 4) + muxAPI.Handle( + "GET "+_apiPath(apiSavedQueriesPath)+"/{env}", + handlerAuthCheck(http.HandlerFunc(handlersApi.SavedQueriesListHandler), flagParams.Service.Auth, flagParams.JWT.JWTSecret)) + muxAPI.Handle( + "POST "+_apiPath(apiSavedQueriesPath)+"/{env}", + handlerAuthCheck(http.HandlerFunc(handlersApi.SavedQueryCreateHandler), flagParams.Service.Auth, flagParams.JWT.JWTSecret)) + muxAPI.Handle( + "PATCH "+_apiPath(apiSavedQueriesPath)+"/{env}/{name}", + handlerAuthCheck(http.HandlerFunc(handlersApi.SavedQueryUpdateHandler), flagParams.Service.Auth, flagParams.JWT.JWTSecret)) + muxAPI.Handle( + "DELETE "+_apiPath(apiSavedQueriesPath)+"/{env}/{name}", + handlerAuthCheck(http.HandlerFunc(handlersApi.SavedQueryDeleteHandler), flagParams.Service.Auth, flagParams.JWT.JWTSecret)) } + // API: osquery schema tables (globally available to authenticated users) + muxAPI.Handle( + "GET "+_apiPath(apiOsqueryPath)+"/tables", + handlerAuthCheck(http.HandlerFunc(handlersApi.OsqueryTablesHandler), flagParams.Service.Auth, flagParams.JWT.JWTSecret)) // API: carves by environment if flagParams.Osquery.Carve { muxAPI.Handle( @@ -352,17 +486,38 @@ func osctrlAPIService() { muxAPI.Handle( "GET "+_apiPath(apiCarvesPath)+"/{env}/{name}", handlerAuthCheck(http.HandlerFunc(handlersApi.CarveShowHandler), flagParams.Service.Auth, flagParams.JWT.JWTSecret)) + muxAPI.Handle( + "GET "+_apiPath(apiCarvesPath)+"/{env}/archive/{name}", + handlerAuthCheck(http.HandlerFunc(handlersApi.CarveArchiveHandler), flagParams.Service.Auth, flagParams.JWT.JWTSecret)) muxAPI.Handle( "POST "+_apiPath(apiCarvesPath)+"/{env}/{action}/{name}", handlerAuthCheck(http.HandlerFunc(handlersApi.CarvesActionHandler), flagParams.Service.Auth, flagParams.JWT.JWTSecret)) } // API: users + muxAPI.Handle( + "GET "+_apiPath(apiUsersPath)+"/me", + handlerAuthCheck(http.HandlerFunc(handlersApi.MeHandler), flagParams.Service.Auth, flagParams.JWT.JWTSecret)) + muxAPI.Handle( + "PATCH "+_apiPath(apiUsersPath)+"/me", + handlerAuthCheck(http.HandlerFunc(handlersApi.MePatchHandler), flagParams.Service.Auth, flagParams.JWT.JWTSecret)) + muxAPI.Handle( + "POST "+_apiPath(apiUsersPath)+"/me/password", + handlerAuthCheck(http.HandlerFunc(handlersApi.MePasswordHandler), flagParams.Service.Auth, flagParams.JWT.JWTSecret)) muxAPI.Handle( "GET "+_apiPath(apiUsersPath)+"/{username}", handlerAuthCheck(http.HandlerFunc(handlersApi.UserHandler), flagParams.Service.Auth, flagParams.JWT.JWTSecret)) muxAPI.Handle( "GET "+_apiPath(apiUsersPath), handlerAuthCheck(http.HandlerFunc(handlersApi.UsersHandler), flagParams.Service.Auth, flagParams.JWT.JWTSecret)) + muxAPI.Handle( + "POST "+_apiPath(apiUsersPath)+"/{username}/permissions", + handlerAuthCheck(http.HandlerFunc(handlersApi.SetUserPermissionsHandler), flagParams.Service.Auth, flagParams.JWT.JWTSecret)) + muxAPI.Handle( + "POST "+_apiPath(apiUsersPath)+"/{username}/token/refresh", + handlerAuthCheck(http.HandlerFunc(handlersApi.RefreshUserTokenHandler), flagParams.Service.Auth, flagParams.JWT.JWTSecret)) + muxAPI.Handle( + "DELETE "+_apiPath(apiUsersPath)+"/{username}/token", + handlerAuthCheck(http.HandlerFunc(handlersApi.DeleteUserTokenHandler), flagParams.Service.Auth, flagParams.JWT.JWTSecret)) muxAPI.Handle( "POST "+_apiPath(apiUsersPath)+"/{username}/{action}", handlerAuthCheck(http.HandlerFunc(handlersApi.UserActionHandler), flagParams.Service.Auth, flagParams.JWT.JWTSecret)) @@ -375,7 +530,7 @@ func osctrlAPIService() { "GET "+_apiPath(apiEnvironmentsPath), handlerAuthCheck(http.HandlerFunc(handlersApi.EnvironmentsHandler), flagParams.Service.Auth, flagParams.JWT.JWTSecret)) muxAPI.Handle( - "POST "+_apiPath(apiEnvironmentsPath), + "POST "+_apiPath(apiEnvironmentsPath)+"/actions", handlerAuthCheck(http.HandlerFunc(handlersApi.EnvActionsHandler), flagParams.Service.Auth, flagParams.JWT.JWTSecret)) muxAPI.Handle( @@ -392,10 +547,37 @@ func osctrlAPIService() { handlerAuthCheck(http.HandlerFunc(handlersApi.EnvEnrollActionsHandler), flagParams.Service.Auth, flagParams.JWT.JWTSecret)) muxAPI.Handle( "GET "+_apiPath(apiEnvironmentsPath)+"/{env}/remove/{target}", - handlerAuthCheck(http.HandlerFunc(handlersApi.EnvironmentHandler), flagParams.Service.Auth, flagParams.JWT.JWTSecret)) + handlerAuthCheck(http.HandlerFunc(handlersApi.EnvRemoveHandler), flagParams.Service.Auth, flagParams.JWT.JWTSecret)) muxAPI.Handle( "POST "+_apiPath(apiEnvironmentsPath)+"/{env}/remove/{action}", handlerAuthCheck(http.HandlerFunc(handlersApi.EnvRemoveActionsHandler), flagParams.Service.Auth, flagParams.JWT.JWTSecret)) + // API: environments CRUD + config (Track 8) + muxAPI.Handle( + "POST "+_apiPath(apiEnvironmentsPath), + handlerAuthCheck(http.HandlerFunc(handlersApi.EnvironmentCreateHandler), flagParams.Service.Auth, flagParams.JWT.JWTSecret)) + muxAPI.Handle( + "PATCH "+_apiPath(apiEnvironmentsPath)+"/{env}", + handlerAuthCheck(http.HandlerFunc(handlersApi.EnvironmentUpdateHandler), flagParams.Service.Auth, flagParams.JWT.JWTSecret)) + muxAPI.Handle( + "DELETE "+_apiPath(apiEnvironmentsPath)+"/{env}", + handlerAuthCheck(http.HandlerFunc(handlersApi.EnvironmentDeleteHandler), flagParams.Service.Auth, flagParams.JWT.JWTSecret)) + // Env config routes use a `/config/{env}` shape (literal in segment 1) so + // they cannot register-conflict with `/map/{target}` registered above. A + // `/{env}/config` shape would put a wildcard in segment 1 — Go's ServeMux + // refuses to accept it alongside `/map/{target}` since neither pattern + // strictly dominates the other. + muxAPI.Handle( + "GET "+_apiPath(apiEnvironmentsPath)+"/config/{env}", + handlerAuthCheck(http.HandlerFunc(handlersApi.EnvironmentConfigHandler), flagParams.Service.Auth, flagParams.JWT.JWTSecret)) + muxAPI.Handle( + "PATCH "+_apiPath(apiEnvironmentsPath)+"/config/{env}", + handlerAuthCheck(http.HandlerFunc(handlersApi.EnvironmentConfigPatchHandler), flagParams.Service.Auth, flagParams.JWT.JWTSecret)) + muxAPI.Handle( + "PATCH "+_apiPath(apiEnvironmentsPath)+"/intervals/{env}", + handlerAuthCheck(http.HandlerFunc(handlersApi.EnvironmentIntervalsPatchHandler), flagParams.Service.Auth, flagParams.JWT.JWTSecret)) + muxAPI.Handle( + "PATCH "+_apiPath(apiEnvironmentsPath)+"/expiration/{env}", + handlerAuthCheck(http.HandlerFunc(handlersApi.EnvironmentExpirationPatchHandler), flagParams.Service.Auth, flagParams.JWT.JWTSecret)) // API: tags by environment muxAPI.Handle( "GET "+_apiPath(apiTagsPath), @@ -425,6 +607,10 @@ func osctrlAPIService() { muxAPI.Handle( "GET "+_apiPath(apiSettingsPath)+"/{service}/json/{env}", handlerAuthCheck(http.HandlerFunc(handlersApi.SettingsServiceEnvJSONHandler), flagParams.Service.Auth, flagParams.JWT.JWTSecret)) + // API: settings PATCH (Track 9) + muxAPI.Handle( + "PATCH "+_apiPath(apiSettingsPath)+"/{service}/{name}", + handlerAuthCheck(http.HandlerFunc(handlersApi.SettingPatchHandler), flagParams.Service.Auth, flagParams.JWT.JWTSecret)) // API: audit log if flagParams.Service.AuditLog { muxAPI.Handle( diff --git a/cmd/cli/main.go b/cmd/cli/main.go index 0e11cc5d..e9d7ccb1 100644 --- a/cmd/cli/main.go +++ b/cmd/cli/main.go @@ -2074,9 +2074,11 @@ func cliWrapper(action func(context.Context, *cli.Command) error) func(context.C return fmt.Errorf("in CreateDBManager - %w", err) } } - // Initialize users + // Initialize users. The CLI manages user/permission rows directly + // and never mints JWTs, so we skip WithJWT — CreateToken fails + // fast if anything in this binary ever tries to call it. log.Debug().Msg("Creating user manager") - adminUsers = users.CreateUserManager(db.Conn, &config.YAMLConfigurationJWT{JWTSecret: appName}) + adminUsers = users.CreateUserManager(db.Conn) // Initialize environment log.Debug().Msg("Creating environment manager") envs = environments.CreateEnvironment(db.Conn) diff --git a/cmd/tls/handlers/post.go b/cmd/tls/handlers/post.go index 6b0d75d4..4cef7084 100644 --- a/cmd/tls/handlers/post.go +++ b/cmd/tls/handlers/post.go @@ -24,6 +24,35 @@ import ( "github.com/rs/zerolog/log" ) +// Per-endpoint request-body caps. Anonymous (pre-enroll) endpoints get a +// small cap; authenticated osquery endpoints get the headroom they need +// for legitimate workloads. Caps are an upper bound — handlers that +// previously read unbounded bodies now reject larger payloads with 413 +// instead of letting the process OOM. (Tighten or relax via cfg later.) +const ( + maxBodyEnroll = 64 * 1024 // 64 KiB — enroll request JSON + maxBodyConfig = 64 * 1024 // 64 KiB — config request JSON + maxBodyLog = 100 * 1024 * 1024 // 100 MiB — status/result log batch + maxBodyQueryRead = 16 * 1024 // 16 KiB — distributed-read request + maxBodyQueryWrite = 100 * 1024 * 1024 // 100 MiB — distributed-write result + maxBodyCarveInit = 8 * 1024 // 8 KiB — carve session init + maxBodyCarveBlock = 16 * 1024 * 1024 // 16 MiB — carve block (osquery carver_block_size default is 5 MiB) + maxBodyQuickEnroll = 8 * 1024 // 8 KiB — quick-enroll + maxBodyFlags = 8 * 1024 // 8 KiB — flags request + maxBodyCert = 8 * 1024 // 8 KiB — cert request + maxBodyVerify = 8 * 1024 // 8 KiB — verify request + maxBodyScript = 8 * 1024 // 8 KiB — script request + maxBodyOsqueryConf = 2 * 1024 * 1024 // 2 MiB — osctrld config push (base64+gzip; decompressed is capped at 500 KiB further down) +) + +// readBody enforces the per-endpoint cap before reading the body. Wraps +// http.MaxBytesReader so the connection is closed cleanly on overflow +// rather than the handler streaming an arbitrarily large body. +func readBody(w http.ResponseWriter, r *http.Request, max int64) ([]byte, error) { + r.Body = http.MaxBytesReader(w, r.Body, max) + return io.ReadAll(r.Body) +} + // EnrollHandler - Function to handle the enroll requests from osquery nodes func (h *HandlersTLS) EnrollHandler(w http.ResponseWriter, r *http.Request) { // Retrieve environment variable @@ -55,7 +84,7 @@ func (h *HandlersTLS) EnrollHandler(w http.ResponseWriter, r *http.Request) { } // Decode read POST body var t types.EnrollRequest - body, err := io.ReadAll(r.Body) + body, err := readBody(w, r, maxBodyEnroll) if err != nil { log.Err(err).Msg("error reading POST body") utils.HTTPResponse(w, "", http.StatusInternalServerError, []byte("")) @@ -136,7 +165,7 @@ func (h *HandlersTLS) ConfigHandler(w http.ResponseWriter, r *http.Request) { } // Decode read POST body var t types.ConfigRequest - body, err := io.ReadAll(r.Body) + body, err := readBody(w, r, maxBodyConfig) if err != nil { log.Err(err).Msg("error reading POST body") utils.HTTPResponse(w, "", http.StatusInternalServerError, []byte("")) @@ -225,7 +254,7 @@ func (h *HandlersTLS) LogHandler(w http.ResponseWriter, r *http.Request) { } // Extract POST body and decode JSON var t types.LogRequest - body, err := io.ReadAll(r.Body) + body, err := readBody(w, r, maxBodyLog) if err != nil { log.Err(err).Msg("error reading POST body") utils.HTTPResponse(w, "", http.StatusInternalServerError, []byte("")) @@ -305,7 +334,7 @@ func (h *HandlersTLS) QueryReadHandler(w http.ResponseWriter, r *http.Request) { } // Decode read POST body var t types.QueryReadRequest - body, err := io.ReadAll(r.Body) + body, err := readBody(w, r, maxBodyQueryRead) if err != nil { log.Err(err).Msg("error reading POST body") utils.HTTPResponse(w, "", http.StatusInternalServerError, []byte("")) @@ -391,7 +420,7 @@ func (h *HandlersTLS) QueryWriteHandler(w http.ResponseWriter, r *http.Request) } // Decode read POST body var t types.QueryWriteRequest - body, err := io.ReadAll(r.Body) + body, err := readBody(w, r, maxBodyQueryWrite) if err != nil { log.Err(err).Msg("error reading POST body") utils.HTTPResponse(w, "", http.StatusInternalServerError, []byte("")) @@ -631,7 +660,7 @@ func (h *HandlersTLS) CarveInitHandler(w http.ResponseWriter, r *http.Request) { } // Decode read POST body var t types.CarveInitRequest - body, err := io.ReadAll(r.Body) + body, err := readBody(w, r, maxBodyCarveInit) if err != nil { log.Err(err).Msg("error reading POST body") utils.HTTPResponse(w, "", http.StatusInternalServerError, []byte("")) @@ -709,7 +738,7 @@ func (h *HandlersTLS) CarveBlockHandler(w http.ResponseWriter, r *http.Request) } // Decode read POST body var t types.CarveBlockRequest - body, err := io.ReadAll(r.Body) + body, err := readBody(w, r, maxBodyCarveBlock) if err != nil { log.Err(err).Msg("error reading POST body") utils.HTTPResponse(w, "", http.StatusInternalServerError, []byte("")) @@ -769,7 +798,7 @@ func (h *HandlersTLS) FlagsHandler(w http.ResponseWriter, r *http.Request) { } // Decode read POST body var t types.FlagsRequest - body, err := io.ReadAll(r.Body) + body, err := readBody(w, r, maxBodyFlags) if err != nil { log.Err(err).Msg("error reading POST body") utils.HTTPResponse(w, "", http.StatusInternalServerError, []byte("")) @@ -828,7 +857,7 @@ func (h *HandlersTLS) CertHandler(w http.ResponseWriter, r *http.Request) { } // Decode read POST body var t types.CertRequest - body, err := io.ReadAll(r.Body) + body, err := readBody(w, r, maxBodyCert) if err != nil { log.Err(err).Msg("error reading POST body") utils.HTTPResponse(w, "", http.StatusInternalServerError, []byte("")) @@ -881,7 +910,7 @@ func (h *HandlersTLS) VerifyHandler(w http.ResponseWriter, r *http.Request) { } // Decode read POST body var t types.VerifyRequest - body, err := io.ReadAll(r.Body) + body, err := readBody(w, r, maxBodyVerify) if err != nil { log.Err(err).Msg("error reading POST body") utils.HTTPResponse(w, "", http.StatusInternalServerError, []byte("")) @@ -971,7 +1000,7 @@ func (h *HandlersTLS) ScriptHandler(w http.ResponseWriter, r *http.Request) { } // Decode read POST body var t types.ScriptRequest - body, err := io.ReadAll(r.Body) + body, err := readBody(w, r, maxBodyScript) if err != nil { log.Err(err).Msg("error reading POST body") utils.HTTPResponse(w, "", http.StatusInternalServerError, []byte("")) @@ -1152,9 +1181,13 @@ func (h *HandlersTLS) OsqueryConfigEndpointHandler(w http.ResponseWriter, r *htt if h.DebugHTTPConfig.EnableHTTP { utils.DebugHTTPDump(h.DebugHTTP, r, h.DebugHTTPConfig.ShowBody) } - // Decode read POST body + // Decode read POST body. Even though we cap the *decompressed* + // configuration at 500 KiB below, the raw POST body also needs a cap — + // otherwise an authenticated client (post-secret-check) can send an + // arbitrarily large body and OOM the process. 2 MiB leaves ample + // headroom for base64+gzip framing around a 500 KiB config. var o types.OsqueryConfigRequest - body, err := io.ReadAll(r.Body) + body, err := readBody(w, r, maxBodyOsqueryConf) if err != nil { log.Err(err).Msg("error reading POST body") utils.HTTPResponse(w, "", http.StatusInternalServerError, []byte("")) diff --git a/deploy/config/admin.yml b/deploy/config/admin.yml index 1099e916..a5b3d155 100644 --- a/deploy/config/admin.yml +++ b/deploy/config/admin.yml @@ -10,7 +10,7 @@ service: host: osctrl.net # Valid values: "none", "json", "db", "saml", "oidc", "oauth" auth: none - auditLog: false + auditLog: true # Database configuration db: diff --git a/deploy/config/api.yml b/deploy/config/api.yml index e77c8c1f..9e90fba7 100644 --- a/deploy/config/api.yml +++ b/deploy/config/api.yml @@ -8,9 +8,19 @@ service: # Valid values: "json", "console" logFormat: json host: osctrl.net - # Valid values: "none", "json", "db", "saml", "oidc", "oauth" - auth: none - auditLog: false + # Valid values: "jwt", "none". `none` requires OSCTRL_INSECURE_NO_AUTH=1 + # in the environment and is intended for local-dev only — it impersonates + # super-admin on every request. Production deployments MUST use `jwt`. + auth: jwt + auditLog: true + # Comma-separated CIDR list whose X-Real-IP / X-Forwarded-For headers + # utils.GetIP will trust. Leave empty (default) when osctrl-api is + # directly internet-facing — forwarding headers are then ignored and + # RemoteAddr is used verbatim, preventing header-spoofed rate-limit + # bypass and audit-log poisoning. Set to your edge proxy's CIDR(s) + # when osctrl-api sits behind a trusted reverse proxy (e.g. + # `10.0.0.0/8` or `192.0.2.1/32,2001:db8::/64`). + trustedProxies: "" # Database configuration db: diff --git a/deploy/docker/conf/nginx/frontend-dev.conf b/deploy/docker/conf/nginx/frontend-dev.conf new file mode 100644 index 00000000..8ed966c3 --- /dev/null +++ b/deploy/docker/conf/nginx/frontend-dev.conf @@ -0,0 +1,112 @@ +# osctrl-frontend — dev-stack nginx config. +# +# HTTP-only on :80 (mapped to host :8088). The legacy admin keeps its TLS +# server on :8443 so both UIs can run side-by-side for comparison. +# +# The /api/* upstream points at the dev `osctrl-api` container on 9002 over +# the docker backend network. Security headers match deploy/nginx/frontend.conf.example +# 1:1 — nginx's `add_header` does NOT inherit into child locations that +# declare any add_header of their own, so every location block that emits +# its own headers must re-state the full set. + +upstream osctrl_api_dev { + server osctrl-api:9002; + keepalive 16; +} + +server { + listen 80; + server_name _; + + add_header X-Content-Type-Options "nosniff" always; + add_header X-Frame-Options "DENY" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), interest-cohort=()" always; + add_header Content-Security-Policy "default-src 'self'; script-src 'self' blob:; worker-src 'self' blob:; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self' data:; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'" always; + + root /usr/share/nginx/osctrl-frontend; + index index.html; + + location ^~ /assets/ { + access_log off; + expires 30d; + add_header X-Content-Type-Options "nosniff" always; + add_header X-Frame-Options "DENY" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), interest-cohort=()" always; + add_header Content-Security-Policy "default-src 'self'; script-src 'self' blob:; worker-src 'self' blob:; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self' data:; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'" always; + add_header Cache-Control "public, max-age=2592000, immutable"; + try_files $uri =404; + } + + location ^~ /monaco/ { + access_log off; + expires 30d; + add_header X-Content-Type-Options "nosniff" always; + add_header X-Frame-Options "DENY" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), interest-cohort=()" always; + add_header Content-Security-Policy "default-src 'self'; script-src 'self' blob:; worker-src 'self' blob:; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self' data:; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'" always; + add_header Cache-Control "public, max-age=2592000, immutable"; + try_files $uri =404; + } + + location /api/ { + proxy_pass http://osctrl_api_dev; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Connection ""; + proxy_read_timeout 120s; + proxy_send_timeout 120s; + + # The API hardcodes Secure on its session cookies (correct for prod). + # In this dev stack the SPA is served over plain HTTP on :8088, so + # browsers refuse to attach Secure cookies and the session is lost on + # the first authed request after login ("Session expired"). Strip the + # Secure flag here so dev works without a TLS reverse proxy. + # In production (deploy/nginx/frontend.conf.example) the SPA is + # already on HTTPS, so this directive is intentionally absent there. + proxy_cookie_flags osctrl_token nosecure; + proxy_cookie_flags osctrl_csrf nosecure; + + add_header X-Content-Type-Options "nosniff" always; + add_header X-Frame-Options "DENY" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), interest-cohort=()" always; + add_header Content-Security-Policy "default-src 'self'; script-src 'self' blob:; worker-src 'self' blob:; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self' data:; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'" always; + + proxy_buffering off; + client_max_body_size 64m; + } + + # index.html must never be cached — it's the entry point that maps to the + # currently-deployed hashed assets. If the browser keeps an old index.html, + # it keeps pointing at old assets and stale bug-fixed code silently never + # loads. The hashed assets in /assets/ and /monaco/ stay long-cached above; + # this is just the entrypoint. + location = /index.html { + add_header Cache-Control "no-cache, no-store, must-revalidate" always; + add_header X-Content-Type-Options "nosniff" always; + add_header X-Frame-Options "DENY" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), interest-cohort=()" always; + add_header Content-Security-Policy "default-src 'self'; script-src 'self' blob:; worker-src 'self' blob:; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self' data:; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'" always; + try_files /index.html =404; + } + + location / { + # Every non-asset path falls back to index.html for TanStack Router + # client-side routing — and inherits the same no-cache treatment so + # deep-link reloads always pick up the freshest entrypoint. + add_header Cache-Control "no-cache, no-store, must-revalidate" always; + add_header X-Content-Type-Options "nosniff" always; + add_header X-Frame-Options "DENY" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), interest-cohort=()" always; + add_header Content-Security-Policy "default-src 'self'; script-src 'self' blob:; worker-src 'self' blob:; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self' data:; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'" always; + try_files $uri $uri/ /index.html; + } +} diff --git a/deploy/docker/conf/osquery/entrypoint.sh b/deploy/docker/conf/osquery/entrypoint.sh index f96dce09..0de724ff 100644 --- a/deploy/docker/conf/osquery/entrypoint.sh +++ b/deploy/docker/conf/osquery/entrypoint.sh @@ -30,6 +30,15 @@ if [ ! -f "/etc/osquery/osquery.secret" ]; then /opt/osctrl/bin/osctrl-cli --db env node-actions --name "${ENV_NAME}" show-flags > /etc/osquery/osquery.flags sed -i "s#__SECRET_FILE__#/etc/osquery/osquery.secret#g" /etc/osquery/osquery.flags echo "--tls_server_certs=/etc/osquery/osctrl.crt" >> /etc/osquery/osquery.flags + # On multi-container dev hosts the default --host_identifier=uuid makes + # every container share the kernel UUID and enroll as the same node. + # Even --host_identifier=instance can collide because the instance UUID + # is written to the data volume which a stale image might pre-populate. + # Force a stable per-container identifier from the container's hostname + # (Docker assigns a unique 12-hex-char hostname per container). + sed -i "/--host_identifier=/d" /etc/osquery/osquery.flags + echo "--host_identifier=specified" >> /etc/osquery/osquery.flags + echo "--specified_identifier=$(hostname)" >> /etc/osquery/osquery.flags fi # Run Osquery diff --git a/deploy/docker/dockerfiles/Dockerfile-dev-admin b/deploy/docker/dockerfiles/Dockerfile-dev-admin index 653ddac2..a3ca1c35 100644 --- a/deploy/docker/dockerfiles/Dockerfile-dev-admin +++ b/deploy/docker/dockerfiles/Dockerfile-dev-admin @@ -1,5 +1,5 @@ ######################################## osctrl-dev-base ######################################## -ARG GOLANG_VERSION=${GOLANG_VERSION:-1.26.1} +ARG GOLANG_VERSION=${GOLANG_VERSION:-1.26.3} FROM golang:${GOLANG_VERSION} AS osctrl-admin-dev WORKDIR /usr/src/app diff --git a/deploy/docker/dockerfiles/Dockerfile-dev-api b/deploy/docker/dockerfiles/Dockerfile-dev-api index cc88d7d1..97b8e6ab 100644 --- a/deploy/docker/dockerfiles/Dockerfile-dev-api +++ b/deploy/docker/dockerfiles/Dockerfile-dev-api @@ -1,4 +1,4 @@ -ARG GOLANG_VERSION=${GOLANG_VERSION:-1.26.1} +ARG GOLANG_VERSION=${GOLANG_VERSION:-1.26.3} FROM golang:${GOLANG_VERSION} AS osctrl-api-dev WORKDIR /usr/src/app diff --git a/deploy/docker/dockerfiles/Dockerfile-dev-cli b/deploy/docker/dockerfiles/Dockerfile-dev-cli index bd51d63a..4453c7f4 100644 --- a/deploy/docker/dockerfiles/Dockerfile-dev-cli +++ b/deploy/docker/dockerfiles/Dockerfile-dev-cli @@ -1,5 +1,5 @@ #################################################### osctrl-cli-dev #################################################### -ARG GOLANG_VERSION=${GOLANG_VERSION:-1.26.1} +ARG GOLANG_VERSION=${GOLANG_VERSION:-1.26.3} FROM golang:${GOLANG_VERSION} AS osctrl-cli-dev WORKDIR /usr/src/app diff --git a/deploy/docker/dockerfiles/Dockerfile-dev-frontend b/deploy/docker/dockerfiles/Dockerfile-dev-frontend new file mode 100644 index 00000000..d1f1707f --- /dev/null +++ b/deploy/docker/dockerfiles/Dockerfile-dev-frontend @@ -0,0 +1,34 @@ +# Dev-stack Dockerfile for osctrl-frontend. +# +# Same multi-stage shape as Dockerfile-osctrl-frontend — node build → nginx:alpine +# — but ships the HTTP-only dev nginx config so it can run side-by-side with the +# legacy admin (which already owns :8443 for TLS in this stack). +# +# Built and run via docker-compose-dev.yml as service `osctrl-frontend`. Not +# meant for production; use Dockerfile-osctrl-frontend for that. + +# -------- Stage 1: build -------- +FROM node:20-alpine AS build + +WORKDIR /app/frontend + +COPY frontend/package.json frontend/package-lock.json* ./ + +RUN if [ -f package-lock.json ]; then npm ci --no-audit --no-fund; \ + else npm install --no-audit --no-fund; fi + +COPY frontend/ ./ + +RUN npm run build + +# -------- Stage 2: nginx -------- +FROM nginx:alpine + +RUN rm -f /etc/nginx/conf.d/default.conf + +COPY --from=build /app/frontend/dist /usr/share/nginx/osctrl-frontend +COPY deploy/docker/conf/nginx/frontend-dev.conf /etc/nginx/conf.d/osctrl-frontend.conf + +EXPOSE 80 + +CMD ["nginx", "-g", "daemon off;"] diff --git a/deploy/docker/dockerfiles/Dockerfile-dev-tls b/deploy/docker/dockerfiles/Dockerfile-dev-tls index 6df0f5bb..1aded2f1 100644 --- a/deploy/docker/dockerfiles/Dockerfile-dev-tls +++ b/deploy/docker/dockerfiles/Dockerfile-dev-tls @@ -1,4 +1,4 @@ -ARG GOLANG_VERSION=${GOLANG_VERSION:-1.26.1} +ARG GOLANG_VERSION=${GOLANG_VERSION:-1.26.3} FROM golang:${GOLANG_VERSION} AS osctrl-tls-dev WORKDIR /usr/src/app diff --git a/deploy/docker/dockerfiles/Dockerfile-osctrl-frontend b/deploy/docker/dockerfiles/Dockerfile-osctrl-frontend new file mode 100644 index 00000000..255b10db --- /dev/null +++ b/deploy/docker/dockerfiles/Dockerfile-osctrl-frontend @@ -0,0 +1,41 @@ +# Multi-stage Dockerfile for osctrl-frontend. +# +# Stage 1: build the Vite bundle. +# Stage 2: ship via nginx:alpine using the example reverse-proxy config. +# +# Build from the repo root: +# docker build -t osctrl-frontend \ +# -f deploy/docker/dockerfiles/Dockerfile-osctrl-frontend . +# +# Run (pointing /api/* at a colocated osctrl-api): +# docker run -d --name osctrl-frontend --network osctrl \ +# -p 443:443 -v $(pwd)/tls:/etc/ssl/private:ro osctrl-frontend + +# -------- Stage 1: build -------- +FROM node:20-alpine AS build + +WORKDIR /app/frontend + +COPY frontend/package.json frontend/package-lock.json* ./ + +# Install with npm ci when a lockfile exists; fall back to npm install otherwise. +RUN if [ -f package-lock.json ]; then npm ci --no-audit --no-fund; \ + else npm install --no-audit --no-fund; fi + +COPY frontend/ ./ + +RUN npm run build + +# -------- Stage 2: nginx -------- +FROM nginx:alpine + +# Drop the default site so our config wins. +RUN rm -f /etc/nginx/conf.d/default.conf + +COPY --from=build /app/frontend/dist /usr/share/nginx/osctrl-frontend +COPY deploy/nginx/frontend.conf.example /etc/nginx/conf.d/osctrl-frontend.conf + +# nginx logs to stdout/stderr by default in this base image. +EXPOSE 80 443 + +CMD ["nginx", "-g", "daemon off;"] diff --git a/deploy/lib.sh b/deploy/lib.sh index ba86818e..2482ff4d 100644 --- a/deploy/lib.sh +++ b/deploy/lib.sh @@ -395,9 +395,9 @@ function set_motd_centos() { echo "$__centosmotd" | sudo tee -a /etc/profile } -# Install go 1.26.1 from tgz +# Install go 1.26.3 from tgz function install_go_26() { - local __version="1.26.1" + local __version="1.26.3" local __arch="$(uname -i)" if [[ "$__arch" == "aarch64" ]]; then __arch="arm64" diff --git a/deploy/nginx/frontend.conf.example b/deploy/nginx/frontend.conf.example new file mode 100644 index 00000000..f4481af7 --- /dev/null +++ b/deploy/nginx/frontend.conf.example @@ -0,0 +1,123 @@ +# osctrl-frontend — example nginx reverse-proxy config +# +# Serves the React SPA static bundle from /usr/share/nginx/osctrl-frontend/ +# and forwards /api/* to osctrl-api on port 8081. Single TLS cert, cookies +# kept SameSite=Lax so the SPA's HttpOnly osctrl_token cookie flows. +# +# Adjust: +# - server_name to your hostname +# - root to wherever you copied frontend/dist/ +# - upstream osctrl_api to your osctrl-api endpoint(s) +# - ssl_certificate* to your real certs +# +# This file is meant to be referenced — drop into /etc/nginx/conf.d/, edit, +# `nginx -t`, then reload. + +upstream osctrl_api { + server osctrl-api:8081; + keepalive 16; +} + +server { + listen 443 ssl http2; + server_name osctrl.example.com; + + ssl_certificate /etc/ssl/certs/osctrl.crt; + ssl_certificate_key /etc/ssl/private/osctrl.key; + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers HIGH:!aNULL:!MD5; + + # Baseline security headers. `always` makes them + # apply to non-2xx responses too, so error pages aren't a downgrade + # bypass. Monaco needs 'unsafe-inline' for runtime style injection; + # blob: in script-src covers Monaco's web-worker bootstrap. + # + # IMPORTANT: nginx's ngx_http_headers_module does NOT inherit + # add_header into a child `location` block that has any add_header + # of its own. Every location below that declares add_header MUST + # re-emit this full set — otherwise that response path silently + # ships without the security headers. The directives are duplicated + # below rather than abstracted via include to keep this example file + # self-contained. + add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always; + add_header X-Content-Type-Options "nosniff" always; + add_header X-Frame-Options "DENY" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), interest-cohort=()" always; + add_header Content-Security-Policy "default-src 'self'; script-src 'self' blob:; worker-src 'self' blob:; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self' data:; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'" always; + + root /usr/share/nginx/osctrl-frontend; + index index.html; + + # Long-cache the immutable hashed assets Vite emits. + # add_header below must re-state every server-level header. + location ^~ /assets/ { + access_log off; + expires 30d; + add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always; + add_header X-Content-Type-Options "nosniff" always; + add_header X-Frame-Options "DENY" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), interest-cohort=()" always; + add_header Content-Security-Policy "default-src 'self'; script-src 'self' blob:; worker-src 'self' blob:; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self' data:; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'" always; + add_header Cache-Control "public, max-age=2592000, immutable"; + try_files $uri =404; + } + + # Self-hosted Monaco runtime. Long-cache like /assets. + location ^~ /monaco/ { + access_log off; + expires 30d; + add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always; + add_header X-Content-Type-Options "nosniff" always; + add_header X-Frame-Options "DENY" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), interest-cohort=()" always; + add_header Content-Security-Policy "default-src 'self'; script-src 'self' blob:; worker-src 'self' blob:; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self' data:; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'" always; + add_header Cache-Control "public, max-age=2592000, immutable"; + try_files $uri =404; + } + + # Reverse-proxy /api/* to osctrl-api. + # IMPORTANT: keep the Set-Cookie attributes as the API emits them + # (HttpOnly osctrl_token + non-HttpOnly osctrl_csrf, SameSite=Lax). + # Do NOT strip the cookie path / SameSite — proxy_cookie_path / proxy_cookie_flags + # are NOT used so the cookies arrive at the browser untouched. + # add_header below must re-state every server-level header — this is the + # highest-stakes path and must NOT ship without CSP / HSTS. + location /api/ { + proxy_pass http://osctrl_api; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Connection ""; + proxy_read_timeout 120s; + proxy_send_timeout 120s; + + add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always; + add_header X-Content-Type-Options "nosniff" always; + add_header X-Frame-Options "DENY" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), interest-cohort=()" always; + add_header Content-Security-Policy "default-src 'self'; script-src 'self' blob:; worker-src 'self' blob:; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self' data:; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'" always; + + # CSV exports, log streams, and carve archives can be large. + proxy_buffering off; + client_max_body_size 64m; + } + + # SPA fallback — everything else returns index.html so TanStack Router + # client-side routing works on deep-links and reloads. + location / { + try_files $uri $uri/ /index.html; + } +} + +# Redirect HTTP → HTTPS. +server { + listen 80; + server_name osctrl.example.com; + return 301 https://$host$request_uri; +} diff --git a/docker-compose-dev.yml b/docker-compose-dev.yml index 71143798..7bf7366c 100644 --- a/docker-compose-dev.yml +++ b/docker-compose-dev.yml @@ -171,6 +171,32 @@ services: - osctrl-redis + ######################################### osctrl-frontend (React SPA) ######################################### + # Ships the React admin frontend on http://:8088, side by side with + # the legacy admin still served by osctrl-nginx on :8443. Both talk to the + # same osctrl-api over the dev backend network so they can be compared on + # the same data. + # + # The image is multi-stage: node:20 builds dist/, nginx:alpine serves it + # + reverse-proxies /api/* to osctrl-api:9002. No volume mount of the + # host tree — changes to frontend/ require + # `docker compose build osctrl-frontend` (no hot reload here; use + # `npm run dev` directly for that). + osctrl-frontend: + container_name: 'osctrl-frontend-dev' + image: 'osctrl-frontend-dev:${OSCTRL_VERSION}' + restart: unless-stopped + build: + context: . + dockerfile: deploy/docker/dockerfiles/Dockerfile-dev-frontend + networks: + - osctrl-dev-backend + ports: + - '0.0.0.0:8088:80' + depends_on: + - osctrl-api + + ######################################### PostgreSQL ######################################### osctrl-postgres: container_name: 'osctrl-postgres-dev' @@ -235,6 +261,8 @@ services: - OSCTRL_USER=${OSCTRL_USER} - OSCTRL_PASS=${OSCTRL_PASS} - API_URL=http://osctrl-api:9002 + #### JWT secret — required to satisfy pkg/users MinJWTSecretBytes gate #### + - JWT_SECRET=${JWT_SECRET} #### Database settings #### - DB_HOST=osctrl-postgres - DB_NAME=${POSTGRES_DB_NAME} diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 00000000..4a72e9fa --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,14 @@ +node_modules/ +dist/ +.env +.env.* +!.env.example +*.log +test-results/ +playwright-report/ +playwright/.cache/ + +# Self-hosted Monaco bundle copied at build time from node_modules. +# Keeps git size sane (15 MiB of generated code); regenerated via the +# prebuild script. ( — CSP requires self-hosted monaco.) +public/monaco/ diff --git a/frontend/.npmrc b/frontend/.npmrc new file mode 100644 index 00000000..521a9f7c --- /dev/null +++ b/frontend/.npmrc @@ -0,0 +1 @@ +legacy-peer-deps=true diff --git a/frontend/.nvmrc b/frontend/.nvmrc new file mode 100644 index 00000000..209e3ef4 --- /dev/null +++ b/frontend/.nvmrc @@ -0,0 +1 @@ +20 diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 00000000..726e2dec --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,73 @@ +# osctrl admin web + +React + TypeScript + Vite SPA for the osctrl admin UI. + +Talks exclusively to `osctrl-api` (port 8081 by default). Served as static files — no Node.js server in production. + +## Directory + +``` +frontend/ +├── src/ +│ ├── main.tsx React 19 entry point +│ ├── router.tsx TanStack Router instance +│ ├── routes/ Page components (TanStack Router) +│ ├── components/ Reusable UI components (primitives, atoms, data, chrome, forms, feedback) +│ ├── features/ Feature modules (one folder per page: nodes, queries, carves, ...) +│ ├── api/ Typed API client + generated types +│ ├── lib/ Utilities, custom hooks, time formatting +│ └── styles/ Tailwind base + design token CSS +└── tests/ + └── e2e/ Playwright end-to-end tests +``` + +## npm scripts + +| Script | Description | +|--------|-------------| +| `npm run dev` | Start Vite dev server on port 5173, proxying `/api` to `:8081` | +| `npm run build` | Type-check then produce `dist/` | +| `npm run preview` | Preview the production build locally | +| `npm run check` | Run `tsc --noEmit` (type-check only) | +| `npm run lint` | Alias for `check` (linting config added in a later track) | +| `npm test` | Run Vitest once | +| `npm run test:watch` | Run Vitest in watch mode | +| `npm run test:e2e` | Run Playwright e2e tests | + +## Dev workflow + +```bash +# Terminal 1 — osctrl API (Go) +make api-dev # starts osctrl-api on :8081 + +# Terminal 2 — React SPA +cd frontend +npm run dev # starts Vite on :5173, proxies /api/* to :8081 +``` + +Open `http://localhost:5173` in the browser. Vite's dev proxy forwards all `/api/*` requests to the running Go API, so auth cookies work as same-origin. + +## Production build + +```bash +make frontend # runs npm ci + npm run build in frontend/ +``` + +Output: `frontend/dist/`. Deploy options: + +1. **nginx** — serve `dist/` as the document root, reverse-proxy `/api/*` to `osctrl-api`. See `deploy/nginx/frontend.conf.example`. +2. **Static hosting + CDN** — upload `dist/` to S3/Cloudfront/etc. Configure CORS on the API. +3. **Docker** — build the multi-stage image at `deploy/docker/dockerfiles/Dockerfile-osctrl-frontend` (node:20 → nginx:alpine). Single image, no separate Go binary. + +## Tech stack + +- React 19 + TypeScript 5 (strict) +- Vite 7 +- TanStack Router (typed routing) +- TanStack Query 5 (server state) +- TanStack Table 8 (headless table) +- Tailwind CSS v4 via `@tailwindcss/vite` +- Radix UI primitives (à la carte) +- react-hook-form 7 + zod 3 +- Vitest + @testing-library/react + jsdom +- Playwright (e2e) diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 00000000..0d913ecb --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,17 @@ + + + + + + + + osctrl + + + + +
+ + + diff --git a/frontend/monaco-runtime.sha256 b/frontend/monaco-runtime.sha256 new file mode 100644 index 00000000..010a0917 --- /dev/null +++ b/frontend/monaco-runtime.sha256 @@ -0,0 +1 @@ +c778a29ad272a1dbaf9d255365be04308a9823e1cdf5c1b97e72c1aba1727d4a diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 00000000..3f9124b1 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,6524 @@ +{ + "name": "osctrl-frontend", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "osctrl-frontend", + "version": "0.0.0", + "license": "MIT", + "dependencies": { + "@hookform/resolvers": "^5.2.2", + "@monaco-editor/react": "^4.7.0", + "@radix-ui/react-checkbox": "^1", + "@radix-ui/react-dialog": "^1", + "@radix-ui/react-dropdown-menu": "^2", + "@radix-ui/react-popover": "^1", + "@radix-ui/react-radio-group": "^1", + "@radix-ui/react-scroll-area": "^1", + "@radix-ui/react-select": "^2", + "@radix-ui/react-switch": "^1", + "@radix-ui/react-tabs": "^1", + "@radix-ui/react-toast": "^1", + "@radix-ui/react-tooltip": "^1", + "@tanstack/react-query": "^5", + "@tanstack/react-router": "^1", + "@tanstack/react-table": "^8", + "clsx": "^2", + "lucide-react": "^0", + "monaco-editor": "^0.55.1", + "react": "^19", + "react-dom": "^19", + "react-hook-form": "^7", + "tailwind-merge": "^2", + "zod": "^3" + }, + "devDependencies": { + "@playwright/test": "^1", + "@tailwindcss/vite": "^4", + "@tanstack/react-query-devtools": "^5.100.10", + "@tanstack/router-devtools": "^1", + "@testing-library/dom": "^10.4.1", + "@testing-library/jest-dom": "^6", + "@testing-library/react": "^16", + "@testing-library/user-event": "^14.6.1", + "@types/node": "^22", + "@types/react": "^19", + "@types/react-dom": "^19", + "@vitejs/plugin-react": "^5", + "jsdom": "^25", + "tailwindcss": "^4", + "typescript": "^5", + "vite": "^7", + "vitest": "^2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@adobe/css-tools": { + "version": "4.4.4", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.4.tgz", + "integrity": "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@asamuzakjp/css-color": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", + "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^2.1.3", + "@csstools/css-color-parser": "^3.0.9", + "@csstools/css-parser-algorithms": "^3.0.4", + "@csstools/css-tokenizer": "^3.0.3", + "lru-cache": "^10.4.3" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.3", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.3.tgz", + "integrity": "sha512-LIVqM46zQWZhj17qA8wb4nW/ixr2y1Nw+r1etiAWgRM6U1IqP+LNhL1yg440jYZR72jCWcWbLWzIosH+uP1fqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", + "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.3", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.3.tgz", + "integrity": "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", + "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", + "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/css-calc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", + "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", + "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^5.1.0", + "@csstools/css-calc": "^2.1.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", + "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", + "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", + "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", + "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", + "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", + "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", + "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", + "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", + "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", + "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", + "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", + "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", + "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", + "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", + "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", + "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", + "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", + "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", + "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", + "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", + "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", + "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", + "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", + "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", + "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", + "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", + "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", + "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@floating-ui/core": { + "version": "1.7.5", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz", + "integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz", + "integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.7.5", + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/react-dom": { + "version": "2.1.8", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.8.tgz", + "integrity": "sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A==", + "license": "MIT", + "dependencies": { + "@floating-ui/dom": "^1.7.6" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz", + "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==", + "license": "MIT" + }, + "node_modules/@hookform/resolvers": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-5.2.2.tgz", + "integrity": "sha512-A/IxlMLShx3KjV/HeTcTfaMxdwy690+L/ZADoeaTltLx+CVuzkeVIPuybK3jrRfw7YZnmdKsVVHAlEPIAEUNlA==", + "license": "MIT", + "dependencies": { + "@standard-schema/utils": "^0.3.0" + }, + "peerDependencies": { + "react-hook-form": "^7.55.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@monaco-editor/loader": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@monaco-editor/loader/-/loader-1.7.0.tgz", + "integrity": "sha512-gIwR1HrJrrx+vfyOhYmCZ0/JcWqG5kbfG7+d3f/C1LXk2EvzAbHSg3MQ5lO2sMlo9izoAZ04shohfKLVT6crVA==", + "license": "MIT", + "dependencies": { + "state-local": "^1.0.6" + } + }, + "node_modules/@monaco-editor/react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@monaco-editor/react/-/react-4.7.0.tgz", + "integrity": "sha512-cyzXQCtO47ydzxpQtCGSQGOC8Gk3ZUeBXFAxD+CWXYFo5OqZyZUonFl0DwUlTyAfRHntBfw2p3w4s9R6oe1eCA==", + "license": "MIT", + "dependencies": { + "@monaco-editor/loader": "^1.5.0" + }, + "peerDependencies": { + "monaco-editor": ">= 0.25.0 < 1", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@playwright/test": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.60.0.tgz", + "integrity": "sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.60.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@radix-ui/number": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz", + "integrity": "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==", + "license": "MIT" + }, + "node_modules/@radix-ui/primitive": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz", + "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==", + "license": "MIT" + }, + "node_modules/@radix-ui/react-arrow": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz", + "integrity": "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-checkbox": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-checkbox/-/react-checkbox-1.3.3.tgz", + "integrity": "sha512-wBbpv+NQftHDdG86Qc0pIyXk5IR3tM8Vd0nWLKDcX8nNn4nXFOFwsKuqw2okA/1D/mpaAkmuyndrPJTYDNZtFw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-use-size": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collection": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz", + "integrity": "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", + "integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.15.tgz", + "integrity": "sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-focus-guards": "1.1.3", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-direction": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz", + "integrity": "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dismissable-layer": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.11.tgz", + "integrity": "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-escape-keydown": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dropdown-menu": { + "version": "2.1.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.1.16.tgz", + "integrity": "sha512-1PLGQEynI/3OX/ftV54COn+3Sud/Mn8vALg2rWnBLnRaGtJDduNW/22XjlGgPdpcIbiQxjKtb7BkcjP00nqfJw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-menu": "2.1.16", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-guards": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.3.tgz", + "integrity": "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-scope": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.7.tgz", + "integrity": "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-id": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz", + "integrity": "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-menu": { + "version": "2.1.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.1.16.tgz", + "integrity": "sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-focus-guards": "1.1.3", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-popper": "1.2.8", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popover": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.15.tgz", + "integrity": "sha512-kr0X2+6Yy/vJzLYJUPCZEc8SfQcf+1COFoAqauJm74umQhta9M7lNJHP7QQS3vkvcGLQUbWpMzwrXYwrYztHKA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-focus-guards": "1.1.3", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-popper": "1.2.8", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popper": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.8.tgz", + "integrity": "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==", + "license": "MIT", + "dependencies": { + "@floating-ui/react-dom": "^2.0.0", + "@radix-ui/react-arrow": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-use-rect": "1.1.1", + "@radix-ui/react-use-size": "1.1.1", + "@radix-ui/rect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-portal": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.9.tgz", + "integrity": "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-presence": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz", + "integrity": "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-radio-group": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-radio-group/-/react-radio-group-1.3.8.tgz", + "integrity": "sha512-VBKYIYImA5zsxACdisNQ3BjCBfmbGH3kQlnFVqlWU4tXwjy7cGX8ta80BcrO+WJXIn5iBylEH3K6ZTlee//lgQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-use-size": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-roving-focus": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.11.tgz", + "integrity": "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-scroll-area": { + "version": "1.2.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-scroll-area/-/react-scroll-area-1.2.10.tgz", + "integrity": "sha512-tAXIa1g3sM5CGpVT0uIbUx/U3Gs5N8T52IICuCtObaos1S8fzsrPXG5WObkQN3S6NVl6wKgPhAIiBGbWnvc97A==", + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.1", + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-select": { + "version": "2.2.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.2.6.tgz", + "integrity": "sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.1", + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-focus-guards": "1.1.3", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-popper": "1.2.8", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-visually-hidden": "1.2.3", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-switch": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-switch/-/react-switch-1.2.6.tgz", + "integrity": "sha512-bByzr1+ep1zk4VubeEVViV592vu2lHE2BZY5OnzehZqOOgogN80+mNtCqPkhn2gklJqOpxWgPoYTSnhBCqpOXQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-use-size": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tabs": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.13.tgz", + "integrity": "sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toast": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toast/-/react-toast-1.2.15.tgz", + "integrity": "sha512-3OSz3TacUWy4WtOXV38DggwxoqJK4+eDkNMl5Z/MJZaoUPaP4/9lf81xXMe1I2ReTAptverZUpbPY4wWwWyL5g==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-visually-hidden": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tooltip": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.8.tgz", + "integrity": "sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-popper": "1.2.8", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-visually-hidden": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-callback-ref": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz", + "integrity": "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-controllable-state": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz", + "integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-effect-event": "0.0.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-effect-event": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.2.tgz", + "integrity": "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-escape-keydown": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.1.tgz", + "integrity": "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-callback-ref": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz", + "integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-previous": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-previous/-/react-use-previous-1.1.1.tgz", + "integrity": "sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-rect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.1.tgz", + "integrity": "sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==", + "license": "MIT", + "dependencies": { + "@radix-ui/rect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-size": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.1.tgz", + "integrity": "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-visually-hidden": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.3.tgz", + "integrity": "sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/rect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.1.tgz", + "integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==", + "license": "MIT" + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.3", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz", + "integrity": "sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.3.tgz", + "integrity": "sha512-x35CNW/ANXG3hE/EZpRU8MXX1JDN86hBb2wMGAtltkz7pc6cxgjpy1OMMfDosOQ+2hWqIkag/fGok1Yady9nGw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.3.tgz", + "integrity": "sha512-xw3xtkDApIOGayehp2+Rz4zimfkaX65r4t47iy+ymQB2G4iJCBBfj0ogVg5jpvjpn8UWn/+q9tprxleYeNp3Hw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.3.tgz", + "integrity": "sha512-vo6Y5Qfpx7/5EaamIwi0WqW2+zfiusVihKatLvtN1VFVy3D13uERk/6gZLU1UiHRL6fDXqj/ELIeVRGnvcTE1g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.3.tgz", + "integrity": "sha512-D+0QGcZhBzTN82weOnsSlY7V7+RMmPuF1CkbxyMAGE8+ZHeUjyb76ZiWmBlCu//AQQONvxcqRbwZTajZKqjuOw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.3.tgz", + "integrity": "sha512-6HnvHCT7fDyj6R0Ph7A6x8dQS/S38MClRWeDLqc0MdfWkxjiu1HSDYrdPhqSILzjTIC/pnXbbJbo+ft+gy/9hQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.3.tgz", + "integrity": "sha512-KHLgC3WKlUYW3ShFKnnosZDOJ0xjg9zp7au3sIm2bs/tGBeC2ipmvRh/N7JKi0t9Ue20C0dpEshi8WUubg+cnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.3.tgz", + "integrity": "sha512-DV6fJoxEYWJOvaZIsok7KrYl0tPvga5OZ2yvKHNNYyk/2roMLqQAbGhr78EQ5YhHpnhLKJD3S1WFusAkmUuV5g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.3.tgz", + "integrity": "sha512-mQKoJAzvuOs6F+TZybQO4GOTSMUu7v0WdxEk24krQ/uUxXoPTtHjuaUuPmFhtBcM4K0ons8nrE3JyhTuCFtT/w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.3.tgz", + "integrity": "sha512-Whjj2qoiJ6+OOJMGptTYazaJvjOJm+iKHpXQM1P3LzGjt7Ff++Tp7nH4N8J/BUA7R9IHfDyx4DJIflifwnbmIA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.3.tgz", + "integrity": "sha512-4YTNHKqGng5+yiZt3mg77nmyuCfmNfX4fPmyUapBcIk+BdwSwmCWGXOUxhXbBEkFHtoN5boLj/5NON+u5QC9tg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.3.tgz", + "integrity": "sha512-SU3kNlhkpI4UqlUc2VXPGK9o886ZsSeGfMAX2ba2b8DKmMXq4AL7KUrkSWVbb7koVqx41Yczx6dx5PNargIrEA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.3.tgz", + "integrity": "sha512-6lDLl5h4TXpB1mTf2rQWnAk/LcXrx9vBfu/DT5TIPhvMhRWaZ5MxkIc8u4lJAmBo6klTe1ywXIUHFjylW505sg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.3.tgz", + "integrity": "sha512-BMo8bOw8evlup/8G+cj5xWtPyp93xPdyoSN16Zy90Q2QZ0ZYRhCt6ZJSwbrRzG9HApFabjwj2p25TUPDWrhzqQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.3.tgz", + "integrity": "sha512-E0L8X1dZN1/Rph+5VPF6Xj2G7JJvMACVXtamTJIDrVI44Y3K+G8gQaMEAavbqCGTa16InptiVrX6eM6pmJ+7qA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.3.tgz", + "integrity": "sha512-oZJ/WHaVfHUiRAtmTAeo3DcevNsVvH8mbvodjZy7D5QKvCefO371SiKRpxoDcCxB3PTRTLayWBkvmDQKTcX/sw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.3.tgz", + "integrity": "sha512-Dhbyh7j9FybM3YaTgaHmVALwA8AkUwTPccyCQ79TG9AJUsMQqgN1DDEZNr4+QUfwiWvLDumW5vdwzoeUF+TNxQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.3.tgz", + "integrity": "sha512-cJd1X5XhHHlltkaypz1UcWLA8AcoIi1aWhsvaWDskD1oz2eKCypnqvTQ8ykMNI0RSmm7NkTdSqSSD7zM0xa6Ig==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.3.tgz", + "integrity": "sha512-DAZDBHQfG2oQuhY7mc6I3/qB4LU2fQCjRvxbDwd/Jdvb9fypP4IJ4qmtu6lNjes6B531AI8cg1aKC2di97bUxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.3.tgz", + "integrity": "sha512-cRxsE8c13mZOh3vP+wLDxpQBRrOHDIGOWyDL93Sy0Ga8y515fBcC2pjUfFwUe5T7tqvTvWbCpg1URM/AXdWIXA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.3.tgz", + "integrity": "sha512-QaWcIgRxqEdQdhJqW4DJctsH6HCmo5vHxY0krHSX4jMtOqfzC+dqDGuHM87bu4H8JBeibWx7jFz+h6/4C8wA5Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.3.tgz", + "integrity": "sha512-AaXwSvUi3QIPtroAUw1t5yHGIyqKEXwH54WUocFolZhpGDruJcs8c+xPNDRn4XiQsS7MEwnYsHW2l0MBLDMkWg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.3.tgz", + "integrity": "sha512-65LAKM/bAWDqKNEelHlcHvm2V+Vfb8C6INFxQXRHCvaVN1rJfwr4NvdP4FyzUaLqWfaCGaadf6UbTm8xJeYfEg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.3.tgz", + "integrity": "sha512-EEM2gyhBF5MFnI6vMKdX1LAosE627RGBzIoGMdLloPZkXrUN0Ckqgr2Qi8+J3zip/8NVVro3/FjB+tjhZUgUHA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.3.tgz", + "integrity": "sha512-E5Eb5H/DpxaoXH++Qkv28RcUJboMopmdDUALBczvHMf7hNIxaDZqwY5lK12UK1BHacSmvupoEWGu+n993Z0y1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.3.tgz", + "integrity": "sha512-hPt/bgL5cE+Qp+/TPHBqptcAgPzgj46mPcg/16zNUmbQk0j+mOEQV/+Lqu8QRtDV3Ek95Q6FeFITpuhl6OTsAA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", + "license": "MIT" + }, + "node_modules/@tailwindcss/node": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.0.tgz", + "integrity": "sha512-aFb4gUhFOgdh9AXo4IzBEOzBkkAxm9VigwDJnMIYv3lcfXCJVesNfbEaBl4BNgVRyid92AmdviqwBUBRKSeY3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.21.0", + "jiti": "^2.6.1", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.3.0" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.0.tgz", + "integrity": "sha512-F7HZGBeN9I0/AuuJS5PwcD8xayx5ri5GhjYUDBEVYUkexyA/giwbDNjRVrxSezE3T250OU2K/wp/ltWx3UOefg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.3.0", + "@tailwindcss/oxide-darwin-arm64": "4.3.0", + "@tailwindcss/oxide-darwin-x64": "4.3.0", + "@tailwindcss/oxide-freebsd-x64": "4.3.0", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.0", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.0", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.0", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.0", + "@tailwindcss/oxide-linux-x64-musl": "4.3.0", + "@tailwindcss/oxide-wasm32-wasi": "4.3.0", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.0", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.0" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.0.tgz", + "integrity": "sha512-TJPiq67tKlLuObP6RkwvVGDoxCMBVtDgKkLfa/uyj7/FyxvQwHS+UOnVrXXgbEsfUaMgiVvC4KbJnRr26ho4Ng==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.0.tgz", + "integrity": "sha512-oMN/WZRb+SO37BmUElEgeEWuU8E/HXRkiODxJxLe1UTHVXLrdVSgfaJV7pSlhRGMSOiXLuxTIjfsF3wYvz8cgQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.0.tgz", + "integrity": "sha512-N6CUmu4a6bKVADfw77p+iw6Yd9Q3OBhe0veaDX+QazfuVYlQsHfDgxBrsjQ/IW+zywL8mTrNd0SdJT/zgtvMdA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.0.tgz", + "integrity": "sha512-zDL5hBkQdH5C6MpqbK3gQAgP80tsMwSI26vjOzjJtNCMUo0lFgOItzHKBIupOZNQxt3ouPH7RPhvNhiTfCe5CQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.0.tgz", + "integrity": "sha512-R06HdNi7A7OEoMsf6d4tjZ71RCWnZQPHj2mnotSFURjNLdBC+cIgXQ7l81CqeoiQftjf6OOblxXMInMgN2VzMA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.0.tgz", + "integrity": "sha512-qTJHELX8jetjhRQHCLilkVLmybpzNQAtaI/gaoVoidn/ufbNDbAo8KlK2J+yPoc8wQxvDxCmh/5lr8nC1+lTbg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.0.tgz", + "integrity": "sha512-Z6sukiQsngnWO+l39X4pPbiWT81IC+PLKF+PHxIlyZbGNb9MODfYlXEVlFvej5BOZInWX01kVyzeLvHsXhfczQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.0.tgz", + "integrity": "sha512-DRNdQRpSGzRGfARVuVkxvM8Q12nh19l4BF/G7zGA1oe+9wcC6saFBHTISrpIcKzhiXtSrlSrluCfvMuledoCTQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.0.tgz", + "integrity": "sha512-Z0IADbDo8bh6I7h2IQMx601AdXBLfFpEdUotft86evd/8ZPflZe9COPO8Q1vw+pfLWIUo9zN/JGZvwuAJqduqg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.0.tgz", + "integrity": "sha512-HNZGOUxEmElksYR7S6sC5jTeNGpobAsy9u7Gu0AskJ8/20FR9GqebUyB+HBcU/ax6BHuiuJi+Oda4B+YX6H1yA==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.10.0", + "@emnapi/runtime": "^1.10.0", + "@emnapi/wasi-threads": "^1.2.1", + "@napi-rs/wasm-runtime": "^1.1.4", + "@tybys/wasm-util": "^0.10.1", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.0.tgz", + "integrity": "sha512-Pe+RPVTi1T+qymuuRpcdvwSVZjnll/f7n8gBxMMh3xLTctMDKqpdfGimbMyioqtLhUYZxdJ9wGNhV7MKHvgZsQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.0.tgz", + "integrity": "sha512-Mvrf2kXW/yeW/OTezZlCGOirXRcUuLIBx/5Y12BaPM7wJoryG6dfS/NJL8aBPqtTEx/Vm4T4vKzFUcKDT+TKUA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.0.tgz", + "integrity": "sha512-t6J3OrB5Fc0ExuhohouH0fWUGMYL6PTLhW+E7zIk/pdbnJARZDCwjBznFnkh5ynRnIRSI4YjtTH0t6USjJISrw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.3.0", + "@tailwindcss/oxide": "4.3.0", + "tailwindcss": "4.3.0" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7 || ^8" + } + }, + "node_modules/@tanstack/history": { + "version": "1.161.6", + "resolved": "https://registry.npmjs.org/@tanstack/history/-/history-1.161.6.tgz", + "integrity": "sha512-NaOGLRrddszbQj9upGat6HG/4TKvXLvu+osAIgfxPYA+eIvYKv8GKDJOrY2D3/U9MRnKfMWD7bU4jeD4xmqyIg==", + "license": "MIT", + "engines": { + "node": ">=20.19" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/query-core": { + "version": "5.100.10", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.100.10.tgz", + "integrity": "sha512-8UR0yJR+GiQ40m3lPhUr0xbfAupe6GSQiksSBSa9SM2NjezFyxXCIA69/lz8cSoNKZLrw1/PktIyQBJcVeMi3w==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/query-devtools": { + "version": "5.100.10", + "resolved": "https://registry.npmjs.org/@tanstack/query-devtools/-/query-devtools-5.100.10.tgz", + "integrity": "sha512-3DmJf25hDPus5IpVvp6ujXv6bKV2zPzI9vpbAmpJigsL/H6DPvPjmf7/Q9yVKEke//8fgeQ45abjgnLuyYxAiw==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.100.10", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.100.10.tgz", + "integrity": "sha512-FLaZf2RCrA/Zgp4aiu5tG3TyasTRO7aZ99skxQpr3Hg/zXOhu6yq5FZCYQ/tRaJtM9ylnoK8tFK7PolXQadv6Q==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.100.10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, + "node_modules/@tanstack/react-query-devtools": { + "version": "5.100.10", + "resolved": "https://registry.npmjs.org/@tanstack/react-query-devtools/-/react-query-devtools-5.100.10.tgz", + "integrity": "sha512-zes0+o9ef5rAZXJ9f/SeaLs2nufJaeVkZkl/Or9NGrWVF41kL9Od9ED9nCwtQlgiF2VGtrzhEw5AU/igAO+aAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tanstack/query-devtools": "5.100.10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "@tanstack/react-query": "^5.100.10", + "react": "^18 || ^19" + } + }, + "node_modules/@tanstack/react-router": { + "version": "1.169.2", + "resolved": "https://registry.npmjs.org/@tanstack/react-router/-/react-router-1.169.2.tgz", + "integrity": "sha512-OJM7Kguc7ERnweaNRWsyWgIKcl3z23rD1B4jaxjzd9RGdnzpt2HfrWa9rggbT0Hfzhfo4D2ZmsfoTme035tniQ==", + "license": "MIT", + "dependencies": { + "@tanstack/history": "1.161.6", + "@tanstack/react-store": "^0.9.3", + "@tanstack/router-core": "1.169.2", + "isbot": "^5.1.22" + }, + "engines": { + "node": ">=20.19" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": ">=18.0.0 || >=19.0.0", + "react-dom": ">=18.0.0 || >=19.0.0" + } + }, + "node_modules/@tanstack/react-router-devtools": { + "version": "1.166.13", + "resolved": "https://registry.npmjs.org/@tanstack/react-router-devtools/-/react-router-devtools-1.166.13.tgz", + "integrity": "sha512-6yKRFFJrEEOiGp5RAAuGCYsl81M4XAhJmLcu9PKj+HZle4A3dsP60lwHoqQYWHMK9nKKFkdXR+D8qxzxqtQbEA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tanstack/router-devtools-core": "1.167.3" + }, + "engines": { + "node": ">=20.19" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "@tanstack/react-router": "^1.168.15", + "@tanstack/router-core": "^1.168.11", + "react": ">=18.0.0 || >=19.0.0", + "react-dom": ">=18.0.0 || >=19.0.0" + }, + "peerDependenciesMeta": { + "@tanstack/router-core": { + "optional": true + } + } + }, + "node_modules/@tanstack/react-store": { + "version": "0.9.3", + "resolved": "https://registry.npmjs.org/@tanstack/react-store/-/react-store-0.9.3.tgz", + "integrity": "sha512-y2iHd/N9OkoQbFJLUX1T9vbc2O9tjH0pQRgTcx1/Nz4IlwLvkgpuglXUx+mXt0g5ZDFrEeDnONPqkbfxXJKwRg==", + "license": "MIT", + "dependencies": { + "@tanstack/store": "0.9.3", + "use-sync-external-store": "^1.6.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@tanstack/react-table": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.21.3.tgz", + "integrity": "sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww==", + "license": "MIT", + "dependencies": { + "@tanstack/table-core": "8.21.3" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/@tanstack/router-core": { + "version": "1.169.2", + "resolved": "https://registry.npmjs.org/@tanstack/router-core/-/router-core-1.169.2.tgz", + "integrity": "sha512-5sm0DJF1A7Mz+9gy4Gz/lLovNailK3yot4vYvz9MkBUPw26uLnhQiR8hSCYxucjE0wD6Mdlc5l+Z0/XTlZ7xHw==", + "license": "MIT", + "dependencies": { + "@tanstack/history": "1.161.6", + "cookie-es": "^3.0.0", + "seroval": "^1.5.4", + "seroval-plugins": "^1.5.4" + }, + "engines": { + "node": ">=20.19" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/router-devtools": { + "version": "1.166.13", + "resolved": "https://registry.npmjs.org/@tanstack/router-devtools/-/router-devtools-1.166.13.tgz", + "integrity": "sha512-Qs8gkyI7m+eAxG3VcIOHuTSsUfA5ZxZcOa99ZyIIIJFxW6hy1k+m2s1J0ZYN1SNlip8P2ofd/MHiqmR1IWipMg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tanstack/react-router-devtools": "1.166.13", + "clsx": "^2.1.1", + "goober": "^2.1.16" + }, + "engines": { + "node": ">=20.19" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "@tanstack/react-router": "^1.168.15", + "csstype": "^3.0.10", + "react": ">=18.0.0 || >=19.0.0", + "react-dom": ">=18.0.0 || >=19.0.0" + }, + "peerDependenciesMeta": { + "csstype": { + "optional": true + } + } + }, + "node_modules/@tanstack/router-devtools-core": { + "version": "1.167.3", + "resolved": "https://registry.npmjs.org/@tanstack/router-devtools-core/-/router-devtools-core-1.167.3.tgz", + "integrity": "sha512-fJ1VMhyQgnoashTrP763c2HRc9kofgF61L7Jb3F6eTHAmCKtGVx8BRtiFt37sr3U0P0jmaaiiSPGP6nT5JtVNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "clsx": "^2.1.1", + "goober": "^2.1.16" + }, + "engines": { + "node": ">=20.19" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "@tanstack/router-core": "^1.168.11", + "csstype": "^3.0.10" + }, + "peerDependenciesMeta": { + "csstype": { + "optional": true + } + } + }, + "node_modules/@tanstack/store": { + "version": "0.9.3", + "resolved": "https://registry.npmjs.org/@tanstack/store/-/store-0.9.3.tgz", + "integrity": "sha512-8reSzl/qGWGGVKhBoxXPMWzATSbZLZFWhwBAFO9NAyp0TxzfBP0mIrGb8CP8KrQTmvzXlR/vFPPUrHTLBGyFyw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/table-core": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.21.3.tgz", + "integrity": "sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/dom/node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/@testing-library/dom/node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/jest-dom": { + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", + "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/@testing-library/react": { + "version": "16.3.2", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", + "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@testing-library/user-event": { + "version": "14.6.1", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz", + "integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "peerDependencies": { + "@testing-library/dom": ">=7.21.4" + } + }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.19.18", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.18.tgz", + "integrity": "sha512-9v00a+dn2yWVsYDEunWC4g/TcRKVq3r8N5FuZp7u0SGrPvdN9c2yXI9bBuf5Fl0hNCb+QTIePTn5pJs2pwBOQQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.14", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", + "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT", + "optional": true + }, + "node_modules/@vitejs/plugin-react": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.2.0.tgz", + "integrity": "sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.29.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-rc.3", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.18.0" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/@vitest/expect": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz", + "integrity": "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.9.tgz", + "integrity": "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.12" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.9.tgz", + "integrity": "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.9.tgz", + "integrity": "sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "2.1.9", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.9.tgz", + "integrity": "sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "magic-string": "^0.30.12", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.9.tgz", + "integrity": "sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^3.0.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.9.tgz", + "integrity": "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "loupe": "^3.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/aria-hidden": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz", + "integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/aria-query": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", + "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.29", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.29.tgz", + "integrity": "sha512-Asa2krT+XTPZINCS+2QcyS8WTkObE77RwkydwF7h6DmnKqbvlalz93m/dnphUyCa6SWSP51VgtEUf2FN+gelFQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001792", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001792.tgz", + "integrity": "sha512-hVLMUZFgR4JJ6ACt1uEESvQN1/dBVqPAKY0hgrV70eN3391K6juAfTjKZLKvOMsx8PxA7gsY1/tLMMTcfFLLpw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie-es": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/cookie-es/-/cookie-es-3.1.1.tgz", + "integrity": "sha512-UaXxwISYJPTr9hwQxMFYZ7kNhSXboMXP+Z3TRX6f1/NyaGPfuNUZOWP1pUEb75B2HjfklIYLVRfWiFZJyC6Npg==", + "license": "MIT" + }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cssstyle": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", + "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^3.2.0", + "rrweb-cssom": "^0.8.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/cssstyle/node_modules/rrweb-cssom": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", + "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/data-urls": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", + "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/detect-node-es": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz", + "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", + "license": "MIT" + }, + "node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/dompurify": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.3.tgz", + "integrity": "sha512-VVwJidIJcp1hpg2OMXML3ZVRPYSZiq4aX7qBh83BSIpOaRDqI+qxhXjjIWnpzkOXhmp0L81lnoME1mnCc9H48A==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.353", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.353.tgz", + "integrity": "sha512-kOrWphBi8TOZyiJZqsgqIle0lw+tzmnQK83pV9dZUd01Nm2POECSyFQMAuarzZdYqQW7FH9RaYOuaRo3h+bQ3w==", + "dev": true, + "license": "ISC" + }, + "node_modules/enhanced-resolve": { + "version": "5.21.3", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.3.tgz", + "integrity": "sha512-QyL119InA+XXEkNLNTPCXPugSvOfhwv0JOlGNzvxs0hZaiHLNvXSpudUWsOlsXGWJh8G6ckCScEkVHfX3kw/2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", + "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.7", + "@esbuild/android-arm": "0.27.7", + "@esbuild/android-arm64": "0.27.7", + "@esbuild/android-x64": "0.27.7", + "@esbuild/darwin-arm64": "0.27.7", + "@esbuild/darwin-x64": "0.27.7", + "@esbuild/freebsd-arm64": "0.27.7", + "@esbuild/freebsd-x64": "0.27.7", + "@esbuild/linux-arm": "0.27.7", + "@esbuild/linux-arm64": "0.27.7", + "@esbuild/linux-ia32": "0.27.7", + "@esbuild/linux-loong64": "0.27.7", + "@esbuild/linux-mips64el": "0.27.7", + "@esbuild/linux-ppc64": "0.27.7", + "@esbuild/linux-riscv64": "0.27.7", + "@esbuild/linux-s390x": "0.27.7", + "@esbuild/linux-x64": "0.27.7", + "@esbuild/netbsd-arm64": "0.27.7", + "@esbuild/netbsd-x64": "0.27.7", + "@esbuild/openbsd-arm64": "0.27.7", + "@esbuild/openbsd-x64": "0.27.7", + "@esbuild/openharmony-arm64": "0.27.7", + "@esbuild/sunos-x64": "0.27.7", + "@esbuild/win32-arm64": "0.27.7", + "@esbuild/win32-ia32": "0.27.7", + "@esbuild/win32-x64": "0.27.7" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "dev": true, + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-nonce": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", + "integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/goober": { + "version": "2.1.18", + "resolved": "https://registry.npmjs.org/goober/-/goober-2.1.18.tgz", + "integrity": "sha512-2vFqsaDVIT9Gz7N6kAL++pLpp41l3PfDuusHcjnGLfR6+huZkl6ziX+zgVC3ZxpqWhzH6pyDdGrCeDhMIvwaxw==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "csstype": "^3.0.10" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", + "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/html-encoding-sniffer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", + "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-encoding": "^3.1.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/isbot": { + "version": "5.1.40", + "resolved": "https://registry.npmjs.org/isbot/-/isbot-5.1.40.tgz", + "integrity": "sha512-yNeeynhhtIVRBk12tBV4eHNxwB42HzR4Q3Ea7vCOiJhImGaAIdIMrbJtacQlBizGLjUPw+akkFI5Dn9T70XoVQ==", + "license": "Unlicense", + "engines": { + "node": ">=18" + } + }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsdom": { + "version": "25.0.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-25.0.1.tgz", + "integrity": "sha512-8i7LzZj7BF8uplX+ZyOlIz86V6TAsSs+np6m1kpW9u0JWi4z/1t+FzcK1aek+ybTnAC4KhBL4uXCNT0wcUIeCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssstyle": "^4.1.0", + "data-urls": "^5.0.0", + "decimal.js": "^10.4.3", + "form-data": "^4.0.0", + "html-encoding-sniffer": "^4.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.5", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.12", + "parse5": "^7.1.2", + "rrweb-cssom": "^0.7.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^5.0.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0", + "ws": "^8.18.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "canvas": "^2.11.2" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "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" + ], + "dev": true, + "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" + ], + "dev": true, + "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" + ], + "dev": true, + "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" + ], + "dev": true, + "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" + ], + "dev": true, + "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" + ], + "dev": true, + "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" + ], + "dev": true, + "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", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "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-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" + ], + "dev": true, + "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" + ], + "dev": true, + "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" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lucide-react": { + "version": "0.577.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.577.0.tgz", + "integrity": "sha512-4LjoFv2eEPwYDPg/CUdBJQSDfPyzXCRrVW1X7jrx/trgxnxkHFjnVZINbzvzxjN70dxychOfg+FTYwBiS3pQ5A==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "bin": { + "lz-string": "bin/bin.js" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/marked": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/marked/-/marked-14.0.0.tgz", + "integrity": "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/monaco-editor": { + "version": "0.55.1", + "resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.55.1.tgz", + "integrity": "sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A==", + "license": "MIT", + "dependencies": { + "dompurify": "3.2.7", + "marked": "14.0.0" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.38", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.38.tgz", + "integrity": "sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw==", + "dev": true, + "license": "MIT" + }, + "node_modules/nwsapi": { + "version": "2.2.23", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.23.tgz", + "integrity": "sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/playwright": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz", + "integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.60.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz", + "integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/postcss": { + "version": "8.5.14", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz", + "integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/react": { + "version": "19.2.6", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.6.tgz", + "integrity": "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.6", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.6.tgz", + "integrity": "sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.6" + } + }, + "node_modules/react-hook-form": { + "version": "7.75.0", + "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.75.0.tgz", + "integrity": "sha512-Ovv94H+0p3sJ7B9B5QxPuCP1u8V/cHuVGyH55cSwodYDtoJwK+fqk3vjfIgSX59I2U/bU4z0nRJ9HMLpNiWEmw==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/react-hook-form" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17 || ^18 || ^19" + } + }, + "node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT" + }, + "node_modules/react-refresh": { + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", + "integrity": "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-remove-scroll": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz", + "integrity": "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==", + "license": "MIT", + "dependencies": { + "react-remove-scroll-bar": "^2.3.7", + "react-style-singleton": "^2.2.3", + "tslib": "^2.1.0", + "use-callback-ref": "^1.3.3", + "use-sidecar": "^1.1.3" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-remove-scroll-bar": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz", + "integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==", + "license": "MIT", + "dependencies": { + "react-style-singleton": "^2.2.2", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-style-singleton": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz", + "integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==", + "license": "MIT", + "dependencies": { + "get-nonce": "^1.0.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/rollup": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.3.tgz", + "integrity": "sha512-pAQK9HalE84QSm4Po3EmWIZPd3FnjkShVkiMlz1iligWYkWQ7wHYd1PF/T7QZ5TVSD6uSTon5gBVMSM4JfBV+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.3", + "@rollup/rollup-android-arm64": "4.60.3", + "@rollup/rollup-darwin-arm64": "4.60.3", + "@rollup/rollup-darwin-x64": "4.60.3", + "@rollup/rollup-freebsd-arm64": "4.60.3", + "@rollup/rollup-freebsd-x64": "4.60.3", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.3", + "@rollup/rollup-linux-arm-musleabihf": "4.60.3", + "@rollup/rollup-linux-arm64-gnu": "4.60.3", + "@rollup/rollup-linux-arm64-musl": "4.60.3", + "@rollup/rollup-linux-loong64-gnu": "4.60.3", + "@rollup/rollup-linux-loong64-musl": "4.60.3", + "@rollup/rollup-linux-ppc64-gnu": "4.60.3", + "@rollup/rollup-linux-ppc64-musl": "4.60.3", + "@rollup/rollup-linux-riscv64-gnu": "4.60.3", + "@rollup/rollup-linux-riscv64-musl": "4.60.3", + "@rollup/rollup-linux-s390x-gnu": "4.60.3", + "@rollup/rollup-linux-x64-gnu": "4.60.3", + "@rollup/rollup-linux-x64-musl": "4.60.3", + "@rollup/rollup-openbsd-x64": "4.60.3", + "@rollup/rollup-openharmony-arm64": "4.60.3", + "@rollup/rollup-win32-arm64-msvc": "4.60.3", + "@rollup/rollup-win32-ia32-msvc": "4.60.3", + "@rollup/rollup-win32-x64-gnu": "4.60.3", + "@rollup/rollup-win32-x64-msvc": "4.60.3", + "fsevents": "~2.3.2" + } + }, + "node_modules/rrweb-cssom": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.7.1.tgz", + "integrity": "sha512-TrEMa7JGdVm0UThDJSx7ddw5nVm3UJS9o9CCIZ72B1vSyEZoziDqBYP3XIoi/12lKrJR8rE3jeFHMok2F/Mnsg==", + "dev": true, + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/seroval": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/seroval/-/seroval-1.5.4.tgz", + "integrity": "sha512-46uFvgrXTVxZcUorgSSRZ4y+ieqLLQRMlG4bnCZKW3qI6BZm7Rg4ntMW4p1mILEEBZWrFlcpp0AyIIlM6jD9iw==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/seroval-plugins": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/seroval-plugins/-/seroval-plugins-1.5.4.tgz", + "integrity": "sha512-S0xQPhUTefAhNvNWFg0c1J8qJArHt5KdtJ/cFAofo06KD1MVSeFWyl4iiu+ApDIuw0WhjpOfCdgConOfAnLgkw==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "seroval": "^1.0" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/state-local": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/state-local/-/state-local-1.0.7.tgz", + "integrity": "sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w==", + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tailwind-merge": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-2.6.1.tgz", + "integrity": "sha512-Oo6tHdpZsGpkKG88HJ8RR1rg/RdnEkQEfMoEk2x1XRI3F1AxeU+ijRXpiVUF4UbLfcxxRGw6TbUINKYdWVsQTQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/dcastil" + } + }, + "node_modules/tailwindcss": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.0.tgz", + "integrity": "sha512-y6nxMGB1nMW9R6k96e5gdIFzcfL/gTJRNaqGes1YvkLnPVXzWgbqFF2yLC0T8G774n24cx3Pe8XrKoniCOAH+Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-1.2.0.tgz", + "integrity": "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz", + "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz", + "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^6.1.86" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz", + "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz", + "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^6.1.32" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/use-callback-ref": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz", + "integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-sidecar": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz", + "integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==", + "license": "MIT", + "dependencies": { + "detect-node-es": "^1.1.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/vite": { + "version": "7.3.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.3.tgz", + "integrity": "sha512-/4XH147Ui7OGTjg3HbdWe5arnZQSbfuRzdr9Ec7TQi5I7R+ir0Rlc9GIvD4v0XZurELqA035KVXJXpR61xhiTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.9.tgz", + "integrity": "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.3.7", + "es-module-lexer": "^1.5.4", + "pathe": "^1.1.2", + "vite": "^5.0.0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vite-node/node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/vite-node/node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/vite-node/node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/vitest": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.9.tgz", + "integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "2.1.9", + "@vitest/mocker": "2.1.9", + "@vitest/pretty-format": "^2.1.9", + "@vitest/runner": "2.1.9", + "@vitest/snapshot": "2.1.9", + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "debug": "^4.3.7", + "expect-type": "^1.1.0", + "magic-string": "^0.30.12", + "pathe": "^1.1.2", + "std-env": "^3.8.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.1", + "tinypool": "^1.0.1", + "tinyrainbow": "^1.2.0", + "vite": "^5.0.0", + "vite-node": "2.1.9", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/node": "^18.0.0 || >=20.0.0", + "@vitest/browser": "2.1.9", + "@vitest/ui": "2.1.9", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/vitest/node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/vitest/node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-url": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ws": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", + "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 00000000..41071b34 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,70 @@ +{ + "name": "osctrl-frontend", + "version": "0.0.0", + "private": true, + "type": "module", + "license": "MIT", + "engines": { + "node": ">=20.0.0" + }, + "scripts": { + "predev": "node scripts/copy-monaco.mjs", + "dev": "vite", + "prebuild": "node scripts/copy-monaco.mjs", + "build": "tsc -p tsconfig.json --noEmit && vite build", + "preview": "vite preview", + "check": "tsc -p tsconfig.json --noEmit", + "lint": "tsc -p tsconfig.json --noEmit", + "test": "vitest run", + "test:watch": "vitest", + "test:e2e": "playwright test" + }, + "dependencies": { + "@hookform/resolvers": "^5.2.2", + "@monaco-editor/react": "^4.7.0", + "@radix-ui/react-checkbox": "^1", + "@radix-ui/react-dialog": "^1", + "@radix-ui/react-dropdown-menu": "^2", + "@radix-ui/react-popover": "^1", + "@radix-ui/react-radio-group": "^1", + "@radix-ui/react-scroll-area": "^1", + "@radix-ui/react-select": "^2", + "@radix-ui/react-switch": "^1", + "@radix-ui/react-tabs": "^1", + "@radix-ui/react-toast": "^1", + "@radix-ui/react-tooltip": "^1", + "@tanstack/react-query": "^5", + "@tanstack/react-router": "^1", + "@tanstack/react-table": "^8", + "clsx": "^2", + "lucide-react": "^0", + "monaco-editor": "^0.55.1", + "react": "^19", + "react-dom": "^19", + "react-hook-form": "^7", + "tailwind-merge": "^2", + "zod": "^3" + }, + "devDependencies": { + "@playwright/test": "^1", + "@tailwindcss/vite": "^4", + "@tanstack/react-query-devtools": "^5.100.10", + "@tanstack/router-devtools": "^1", + "@testing-library/dom": "^10.4.1", + "@testing-library/jest-dom": "^6", + "@testing-library/react": "^16", + "@testing-library/user-event": "^14.6.1", + "@types/node": "^22", + "@types/react": "^19", + "@types/react-dom": "^19", + "@vitejs/plugin-react": "^5", + "jsdom": "^25", + "tailwindcss": "^4", + "typescript": "^5", + "vite": "^7", + "vitest": "^2" + }, + "overrides": { + "dompurify": "^3.4.3" + } +} diff --git a/frontend/public/favicon.svg b/frontend/public/favicon.svg new file mode 100644 index 00000000..2c21253f --- /dev/null +++ b/frontend/public/favicon.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + diff --git a/frontend/public/theme-bootstrap.js b/frontend/public/theme-bootstrap.js new file mode 100644 index 00000000..544838ad --- /dev/null +++ b/frontend/public/theme-bootstrap.js @@ -0,0 +1,18 @@ +// Sets data-theme on before React mounts so the first paint +// isn't a flash of the wrong theme. Runs in document , blocking, +// so the attribute is in place when the SPA's CSS resolves. +// +// Served from /public/ as a static file rather than inlined in +// index.html so it's covered by `script-src 'self'` and doesn't +// require a CSP hash that needs maintaining. +(function () { + try { + var t = localStorage.getItem('osctrl.theme'); + if (t !== 'light' && t !== 'dark') { + t = window.matchMedia && window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark'; + } + document.documentElement.setAttribute('data-theme', t); + } catch (e) { + document.documentElement.setAttribute('data-theme', 'dark'); + } +})(); diff --git a/frontend/scripts/copy-monaco.mjs b/frontend/scripts/copy-monaco.mjs new file mode 100644 index 00000000..188029d0 --- /dev/null +++ b/frontend/scripts/copy-monaco.mjs @@ -0,0 +1,127 @@ +// Copies monaco-editor's min/vs runtime into public/monaco/vs so the SPA +// can load it from its own origin under CSP `script-src 'self' blob:`. +// Without this, @monaco-editor/loader default fetches from +// cdn.jsdelivr.net which the CSP blocks, breaking every page that mounts +// . +// +// Supply-chain hardening: we compute a deterministic SHA-256 over the +// recursive contents of the source directory and compare it against +// monaco-runtime.sha256 (committed). Any drift — npm registry compromise, +// MITM during npm install, accidental local tampering — fails the build +// before bytes ever ship. To intentionally bump monaco-editor: update +// package.json + monaco-runtime.sha256 in the same commit. The script +// prints the observed hash on mismatch so the new value is easy to commit. +// +// Runs automatically before `npm run dev` / `npm run build` via the +// predev / prebuild npm scripts. The destination directory is gitignored. + +import { copyFile, mkdir, readdir, stat, rm, readFile, writeFile } from 'node:fs/promises'; +import { dirname, join, relative, sep } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { createHash } from 'node:crypto'; + +const here = dirname(fileURLToPath(import.meta.url)); +const src = join(here, '..', 'node_modules', 'monaco-editor', 'min', 'vs'); +const dst = join(here, '..', 'public', 'monaco', 'vs'); +const expectedHashFile = join(here, '..', 'monaco-runtime.sha256'); + +// ── Discovery ───────────────────────────────────────────────────────── +try { + await stat(src); +} catch { + console.error(`error: monaco-editor not installed at ${src}`); + console.error(`run \`npm install\` and retry`); + process.exit(1); +} + +// Collect (relativePath, absolutePath) for every file under src, sorted. +async function listFiles(root) { + const out = []; + async function walk(dir) { + for (const e of await readdir(dir, { withFileTypes: true })) { + const abs = join(dir, e.name); + if (e.isDirectory()) await walk(abs); + else if (e.isFile()) { + // POSIX path separators in the hash input so the hash is + // identical on macOS / Linux / Windows. + out.push([relative(root, abs).split(sep).join('/'), abs]); + } + } + } + await walk(root); + out.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)); + return out; +} + +async function hashFile(p) { + const h = createHash('sha256'); + h.update(await readFile(p)); + return h.digest('hex'); +} + +async function dirHash(root) { + const files = await listFiles(root); + const h = createHash('sha256'); + for (const [rel, abs] of files) { + h.update(rel); + h.update('\0'); + h.update(await hashFile(abs)); + h.update('\n'); + } + return h.digest('hex'); +} + +// ── Supply-chain integrity check ────────────────────────────────────── +const observed = await dirHash(src); +let expected = null; +try { + expected = (await readFile(expectedHashFile, 'utf8')).trim(); +} catch { + // First run — no committed expected hash. Write one and ask the + // operator to commit it so subsequent builds enforce the value. + await writeFile(expectedHashFile, observed + '\n'); + console.warn( + `WARNING: no committed monaco-runtime.sha256 found. Wrote ${observed}.\n` + + ` Inspect the tree at ${src}, then \`git add monaco-runtime.sha256\` ` + + `to lock the hash. Subsequent builds will fail on drift.`, + ); +} + +if (expected && observed !== expected) { + console.error( + `error: monaco-editor integrity check FAILED.\n` + + ` expected ${expected}\n` + + ` got ${observed}\n` + + ` at ${src}\n` + + `\n` + + `This means the monaco-editor bytes on disk do not match the\n` + + `committed monaco-runtime.sha256. Either:\n` + + ` (a) monaco-editor was intentionally bumped — update package.json\n` + + ` and monaco-runtime.sha256 in the same commit; or\n` + + ` (b) the npm registry / local cache was tampered with — DO NOT\n` + + ` build. Investigate before proceeding.\n`, + ); + process.exit(2); +} + +// ── Stage into public/monaco/vs ─────────────────────────────────────── +async function copyRecursive(s, d) { + const entries = await readdir(s, { withFileTypes: true }); + await mkdir(d, { recursive: true }); + for (const e of entries) { + const sp = join(s, e.name); + const dp = join(d, e.name); + if (e.isDirectory()) { + await copyRecursive(sp, dp); + } else if (e.isFile()) { + await copyFile(sp, dp); + } + } +} + +await rm(dst, { recursive: true, force: true }); +await copyRecursive(src, dst); +console.log( + `staged monaco runtime: ${src} -> ${dst}\n` + + `integrity: ${observed} ✓`, +); diff --git a/frontend/src/api/.gitkeep b/frontend/src/api/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/frontend/src/api/audit.ts b/frontend/src/api/audit.ts new file mode 100644 index 00000000..32cbd99f --- /dev/null +++ b/frontend/src/api/audit.ts @@ -0,0 +1,77 @@ +/** + * Audit log API client. + */ +import { apiFetch } from './client'; + +export interface AuditLogView { + id: number; + created_at: string; + service: string; + username: string; + line: string; + log_type: number; + severity: number; + source_ip: string; + environment_id: number; + env_uuid?: string; +} + +export interface AuditLogsPagedResponse { + items: AuditLogView[]; + page: number; + page_size: number; + total_items: number; + total_pages: number; +} + +export interface AuditLogsQuery { + service?: string; + username?: string; + type?: number; + env_uuid?: string; + since?: string; + until?: string; + page?: number; + page_size?: number; +} + +export function listAuditLogs(q: AuditLogsQuery = {}): Promise { + const sp = new URLSearchParams(); + if (q.service) sp.set('service', q.service); + if (q.username) sp.set('username', q.username); + if (q.type !== undefined) sp.set('type', String(q.type)); + if (q.env_uuid) sp.set('env_uuid', q.env_uuid); + if (q.since) sp.set('since', q.since); + if (q.until) sp.set('until', q.until); + if (q.page) sp.set('page', String(q.page)); + if (q.page_size) sp.set('page_size', String(q.page_size)); + const query = sp.toString(); + return apiFetch(`/api/v1/audit-logs${query ? '?' + query : ''}`); +} + +// Mirror pkg/auditlog log type constants. +export const LOG_TYPE = { + Login: 1, + Logout: 2, + Node: 3, + Query: 4, + Carve: 5, + Tag: 6, + Environment: 7, + Setting: 8, + Visit: 9, + User: 10, +} as const; + +export const LOG_TYPE_LABELS: Record = { + 1: 'login', + 2: 'logout', + 3: 'node', + 4: 'query', + 5: 'carve', + 6: 'tag', + 7: 'environment', + 8: 'setting', + 9: 'visit', + 10: 'user', +}; diff --git a/frontend/src/api/carves.ts b/frontend/src/api/carves.ts new file mode 100644 index 00000000..0877ad32 --- /dev/null +++ b/frontend/src/api/carves.ts @@ -0,0 +1,89 @@ +import { apiFetch } from './client'; +import type { + CarvesPagedResponse, + CarveDetail, + CarveTarget, + CarveSortColumn, + SortDir, +} from './types'; + +export interface ListCarvesParams { + env: string; + target?: CarveTarget; + q?: string; + sort?: CarveSortColumn; + dir?: SortDir; + page?: number; + pageSize?: number; +} + +/** GET /api/v1/carves/{env} — paginated list of carve queries (type=carve). */ +export function listCarves(p: ListCarvesParams): Promise { + const params = new URLSearchParams(); + if (p.target) params.set('target', p.target); + if (p.q) params.set('q', p.q); + if (p.sort) params.set('sort', p.sort); + if (p.dir) params.set('dir', p.dir); + if (p.page != null) params.set('page', String(p.page)); + if (p.pageSize != null) params.set('page_size', String(p.pageSize)); + + const qs = params.toString(); + return apiFetch( + `/api/v1/carves/${encodeURIComponent(p.env)}${qs ? `?${qs}` : ''}`, + ); +} + +/** GET /api/v1/carves/{env}/{name} — carve query + per-node carved files. */ +export function getCarve(env: string, name: string): Promise { + return apiFetch( + `/api/v1/carves/${encodeURIComponent(env)}/${encodeURIComponent(name)}`, + ); +} + +export interface RunCarveBody { + path: string; + uuid_list?: string[]; + platform_list?: string[]; + environment_list?: string[]; + host_list?: string[]; + tag_list?: string[]; + exp_hours?: number; +} + +/** + * Shape returned by POST /api/v1/carves/{env}. + * The Go side serializes types.ApiQueriesResponse, which has the json tag + * `query_name` (it's a shared struct between query-run and carve-run). The + * SPA used to expect `name` and silently navigated to /carves/undefined + * when the carve was actually created — the resulting "carve not found" + * page made it look like a backend bug. This field is now keyed correctly. + */ +export interface RunCarveResponse { + query_name: string; +} + +/** POST /api/v1/carves/{env} — initiate a new file carve. */ +export function runCarve(env: string, body: RunCarveBody): Promise { + return apiFetch( + `/api/v1/carves/${encodeURIComponent(env)}`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }, + ); +} + +/** + * Returns the URL for downloading the reassembled archive of a carve. + * Use directly as — the browser handles the file download. + * + * If the carve query produced files for multiple nodes, pass `session` to + * disambiguate; omitted it expects exactly one file and returns 409 otherwise. + */ +export function getCarveArchiveUrl(env: string, name: string, session?: string): string { + const params = new URLSearchParams(); + if (session) params.set('session', session); + const qs = params.toString(); + return `/api/v1/carves/${encodeURIComponent(env)}/archive/${encodeURIComponent(name)}${qs ? `?${qs}` : ''}`; +} diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts new file mode 100644 index 00000000..ba1942e7 --- /dev/null +++ b/frontend/src/api/client.ts @@ -0,0 +1,173 @@ +/** + * client.ts — thin fetch wrapper with in-memory CSRF token storage. + * Extended in with typed apiFetch, AuthError, and ApiError. + */ + +let csrfTokenInMemory: string | null = null; + +export function setCsrfToken(t: string | null) { + csrfTokenInMemory = t; +} + +export function getCsrfToken(): string | null { + return csrfTokenInMemory; +} + +export function isAuthenticated(): boolean { + return csrfTokenInMemory !== null; +} + +// --------------------------------------------------------------------------- +// Typed error classes +// --------------------------------------------------------------------------- + +/** Thrown when the server returns 401. The router catches this and redirects to /login. */ +export class AuthError extends Error { + readonly status = 401; + constructor(message = 'Unauthorized') { + super(message); + this.name = 'AuthError'; + } +} + +/** Thrown for non-2xx responses other than 401. */ +export class ApiError extends Error { + constructor( + message: string, + public readonly status: number, + public readonly code?: string, + ) { + super(message); + this.name = 'ApiError'; + } +} + +// --------------------------------------------------------------------------- +// Generic typed fetch helper +// --------------------------------------------------------------------------- + +const MUTATING_VERBS = new Set(['POST', 'PUT', 'PATCH', 'DELETE']); + +export async function apiFetch( + path: string, + init: RequestInit = {}, +): Promise { + const method = (init.method ?? 'GET').toUpperCase(); + + const headers = new Headers(init.headers); + if (!headers.has('Accept')) { + headers.set('Accept', 'application/json'); + } + + const csrf = getCsrfToken(); + if (MUTATING_VERBS.has(method) && csrf) { + headers.set('X-CSRF-Token', csrf); + } + + const res = await fetch(path, { + credentials: 'include', + ...init, + method, + headers, + }); + + if (res.status === 401) { + // Clear in-memory auth state so subsequent renders treat us as unauthenticated. + setCsrfToken(null); + throw new AuthError(); + } + + if (!res.ok) { + let errorMsg = `Request failed with status ${res.status}`; + let code: string | undefined; + try { + const body = (await res.json()) as { error?: string; code?: string }; + if (body.error) errorMsg = body.error; + code = body.code; + } catch { + // response wasn't JSON — keep default message + } + throw new ApiError(errorMsg, res.status, code); + } + + return res.json() as Promise; +} + +// --------------------------------------------------------------------------- +// Auth helpers +// --------------------------------------------------------------------------- + +export interface LoginRequest { + username: string; + password: string; + exp_hours?: number; +} + +export interface LoginResponse { + /** + * JWT bearer token returned for CLI and non-browser callers. The SPA does + * NOT use this — authentication for SPA requests rides on the HttpOnly + * `osctrl_token` cookie set by the same /login response. Do not send this + * value as an Authorization header from the browser. + */ + token: string; + /** CSRF token; sent as the `X-CSRF-Token` header on mutating requests. */ + csrf_token: string; +} + +interface LegacyApiError { + error: string; + code?: string; +} + +export async function login(env: string, body: LoginRequest): Promise { + const res = await fetch(`/api/v1/login/${encodeURIComponent(env)}`, { + method: 'POST', + credentials: 'include', + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + }, + body: JSON.stringify(body), + }); + if (!res.ok) { + const err = (await res.json().catch(() => ({ error: 'login failed' }))) as LegacyApiError; + throw new Error(err.error || 'Login failed. Please try again.'); + } + const data = (await res.json()) as LoginResponse; + // token is for CLI callers; the SPA authenticates via the HttpOnly cookie. + // We only need the CSRF token for subsequent mutating requests. + setCsrfToken(data.csrf_token); + return data; +} + +/** Shape returned by GET /api/v1/login/environments — pre-auth, name+uuid only. */ +export interface LoginEnvironment { + uuid: string; + name: string; +} + +/** + * Pre-auth env list for the login screen dropdown. + * + * Does NOT go through apiFetch / the auth-aware client wrappers because: + * - The endpoint is intentionally unauthenticated. + * - The 401-→-redirect-to-login behaviour those wrappers add would create a + * redirect loop if it ever returned 401 (it can't, but: belt-and-braces). + */ +export async function listLoginEnvironments(): Promise { + const res = await fetch('/api/v1/login/environments', { + method: 'GET', + headers: { Accept: 'application/json' }, + }); + if (!res.ok) { + throw new Error(`Failed to load environments (HTTP ${res.status})`); + } + return (await res.json()) as LoginEnvironment[]; +} + +export function logout(): void { + csrfTokenInMemory = null; + // no server-side logout endpoint today — just clear local state + // and let the cookies expire naturally +} diff --git a/frontend/src/api/enrollment.ts b/frontend/src/api/enrollment.ts new file mode 100644 index 00000000..6de0bf93 --- /dev/null +++ b/frontend/src/api/enrollment.ts @@ -0,0 +1,116 @@ +/** + * Enrollment API client. + * + * Wraps the four /api/v1/environments/{env}/{enroll|remove}/{...} endpoints + * already implemented in cmd/api/handlers/environments.go. The Go side is + * AdminLevel-gated because the returned strings either are the enroll secret + * outright or embed it in a URL ( in the audit), so this + * client function output should never be cached or logged. + * + * The literal action / target strings here are taken from pkg/settings/settings.go + * (ActionExtend/Expire/Rotate/Notexpire + SetMacPackage/SetMsiPackage/SetDebPackage/SetRpmPackage, + * DownloadSecret/DownloadCert/DownloadFlags) and pkg/environments/oneliners.go + * (EnrollShell/EnrollPowershell/RemoveShell/RemovePowershell). If the Go + * constants change, update these mirrors and the matching switch arms. + */ + +import { apiFetch } from './client'; + +// --------------------------------------------------------------------------- +// Targets accepted by GET /environments/{env}/enroll/{target} +// --------------------------------------------------------------------------- +export type EnrollTarget = + | 'secret' // raw enroll secret (string) + | 'cert' // env certificate PEM + | 'flags' // raw osquery flags file content + | 'enroll.sh' // bash one-liner installer + | 'enroll.ps1'; // powershell one-liner installer + +// GET /environments/{env}/remove/{target} +export type RemoveTarget = 'remove.sh' | 'remove.ps1'; + +// --------------------------------------------------------------------------- +// Actions accepted by POST /environments/{env}/enroll/{action} +// --------------------------------------------------------------------------- +export type EnrollAction = + | 'extend' // push enroll_expire forward + | 'expire' // invalidate now + | 'rotate' // generate new secret + reset expire + | 'notexpire' // permanent secret + | 'set_pkg' // set macOS package URL + | 'set_msi' // set Windows package URL + | 'set_deb' // set Debian package URL + | 'set_rpm'; // set RPM package URL + +// Mirrors of the same actions for the remove-secret lifecycle. +export type RemoveAction = 'extend' | 'expire' | 'rotate' | 'notexpire'; + +// --------------------------------------------------------------------------- +// Request / response shapes +// --------------------------------------------------------------------------- +// The handler returns {"data": "..."} for every GET target. The action POSTs +// return {"message": "..."}. +interface DataResponse { + data: string; +} + +interface MessageResponse { + message: string; +} + +// Body for the package-set actions. All four fields are optional because the +// handler only reads the one keyed to the action; this avoids needing four +// separate request bodies. +export interface PackageActionBody { + pkg_url?: string; + msi_url?: string; + deb_url?: string; + rpm_url?: string; +} + +// --------------------------------------------------------------------------- +// GET — read enroll material +// --------------------------------------------------------------------------- +export function getEnrollData(env: string, target: EnrollTarget): Promise { + return apiFetch( + `/api/v1/environments/${encodeURIComponent(env)}/enroll/${encodeURIComponent(target)}`, + ); +} + +export function getRemoveData(env: string, target: RemoveTarget): Promise { + return apiFetch( + `/api/v1/environments/${encodeURIComponent(env)}/remove/${encodeURIComponent(target)}`, + ); +} + +// --------------------------------------------------------------------------- +// POST — secret lifecycle and package-URL setters +// --------------------------------------------------------------------------- +export function enrollAction( + env: string, + action: EnrollAction, + body: PackageActionBody = {}, +): Promise { + return apiFetch( + `/api/v1/environments/${encodeURIComponent(env)}/enroll/${encodeURIComponent(action)}`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }, + ); +} + +export function removeAction( + env: string, + action: RemoveAction, +): Promise { + return apiFetch( + `/api/v1/environments/${encodeURIComponent(env)}/remove/${encodeURIComponent(action)}`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({}), + }, + ); +} diff --git a/frontend/src/api/environments.ts b/frontend/src/api/environments.ts new file mode 100644 index 00000000..e54a0d17 --- /dev/null +++ b/frontend/src/api/environments.ts @@ -0,0 +1,192 @@ +/** + * Environments API client. + * + * GET /api/v1/environments returns the raw env list (super-admin only). + * CRUD + per-section config + intervals + expiration are additions. + */ +import { apiFetch } from './client'; + +/** + * TLSEnvironment — full storage shape returned by the API. Mirrors + * pkg/environments.TLSEnvironment's snake_case JSON tags. + */ +export interface TLSEnvironment { + id: number; + created_at: string; + updated_at: string; + uuid: string; + name: string; + hostname: string; + secret: string; + enroll_secret_path: string; + enroll_expire: string; + remove_secret_path: string; + remove_expire: string; + type: string; + deb_package: string; + rpm_package: string; + msi_package: string; + pkg_package: string; + debug_http: boolean; + icon: string; + options: string; + schedule: string; + packs: string; + decorators: string; + atc: string; + configuration: string; + flags: string; + certificate: string; + config_tls: boolean; + config_interval: number; + logging_tls: boolean; + log_interval: number; + query_tls: boolean; + query_interval: number; + carves_tls: boolean; + enroll_path: string; + log_path: string; + config_path: string; + query_read_path: string; + query_write_path: string; + carver_init_path: string; + carver_block_path: string; + accept_enrolls: boolean; + user_id: number; +} + +export interface EnvCreateRequest { + name: string; + hostname: string; + type?: string; + icon?: string; +} + +export interface EnvUpdateRequest { + name?: string; + hostname?: string; + type?: string; + icon?: string; + debug_http?: boolean; + accept_enrolls?: boolean; +} + +export interface EnvConfigResponse { + options: string; + schedule: string; + packs: string; + decorators: string; + atc: string; + flags: string; +} + +export interface EnvConfigPatchRequest { + options?: string; + schedule?: string; + packs?: string; + decorators?: string; + atc?: string; + flags?: string; +} + +export interface EnvIntervalsPatchRequest { + config_interval?: number; + log_interval?: number; + query_interval?: number; +} + +export type EnvExpirationAction = 'extend' | 'expire' | 'rotate' | 'not-expire'; + +export interface EnvExpirationPatchRequest { + action: EnvExpirationAction; +} + +/** GET /api/v1/environments — list every environment (super-admin). */ +export function listEnvironments(): Promise { + return apiFetch('/api/v1/environments'); +} + +/** GET /api/v1/environments/{env} — single env (user-level permission). */ +export function getEnvironment(env: string): Promise { + return apiFetch(`/api/v1/environments/${encodeURIComponent(env)}`); +} + +/** POST /api/v1/environments — create. */ +export function createEnvironment(body: EnvCreateRequest): Promise { + return apiFetch('/api/v1/environments', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); +} + +/** PATCH /api/v1/environments/{env} — partial update. */ +export function updateEnvironment( + env: string, + body: EnvUpdateRequest, +): Promise { + return apiFetch(`/api/v1/environments/${encodeURIComponent(env)}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); +} + +/** DELETE /api/v1/environments/{env}. */ +export function deleteEnvironment(env: string): Promise<{ message: string }> { + return apiFetch<{ message: string }>(`/api/v1/environments/${encodeURIComponent(env)}`, { + method: 'DELETE', + }); +} + +/** GET /api/v1/environments/config/{env} — six osquery config sections. */ +export function getEnvironmentConfig(env: string): Promise { + return apiFetch( + `/api/v1/environments/config/${encodeURIComponent(env)}`, + ); +} + +/** PATCH /api/v1/environments/config/{env} — atomic JSON-validated patch. */ +export function patchEnvironmentConfig( + env: string, + body: EnvConfigPatchRequest, +): Promise { + return apiFetch( + `/api/v1/environments/config/${encodeURIComponent(env)}`, + { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }, + ); +} + +/** PATCH /api/v1/environments/intervals/{env} — config/log/query pull intervals. */ +export function patchEnvironmentIntervals( + env: string, + body: EnvIntervalsPatchRequest, +): Promise { + return apiFetch( + `/api/v1/environments/intervals/${encodeURIComponent(env)}`, + { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }, + ); +} + +/** PATCH /api/v1/environments/expiration/{env} — extend/expire/rotate/not-expire. */ +export function patchEnvironmentExpiration( + env: string, + body: EnvExpirationPatchRequest, +): Promise { + return apiFetch( + `/api/v1/environments/expiration/${encodeURIComponent(env)}`, + { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }, + ); +} diff --git a/frontend/src/api/nodes.test.ts b/frontend/src/api/nodes.test.ts new file mode 100644 index 00000000..1cd85bed --- /dev/null +++ b/frontend/src/api/nodes.test.ts @@ -0,0 +1,100 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { listNodes } from './nodes'; + +// --------------------------------------------------------------------------- +// Mock apiFetch so we can capture the URL it's called with +// --------------------------------------------------------------------------- +const mockApiFetch = vi.fn(); + +vi.mock('./client', () => ({ + apiFetch: (url: string, init?: RequestInit) => mockApiFetch(url, init), + getCsrfToken: () => null, + setCsrfToken: vi.fn(), + isAuthenticated: () => false, +})); + +const STUB_RESPONSE = { + items: [], + page: 1, + page_size: 50, + total_items: 0, + total_pages: 0, +}; + +describe('listNodes — URL construction', () => { + beforeEach(() => { + mockApiFetch.mockResolvedValue(STUB_RESPONSE); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('builds the base URL without optional params', async () => { + await listNodes({ env: 'prod' }); + expect(mockApiFetch).toHaveBeenCalledWith('/api/v1/nodes/prod', undefined); + }); + + it('adds status=active when status is active', async () => { + await listNodes({ env: 'prod', status: 'active' }); + const url: string = mockApiFetch.mock.calls[0][0]; + const params = new URL(url, 'http://x').searchParams; + expect(params.get('status')).toBe('active'); + }); + + it('does NOT add status param when status is "all"', async () => { + await listNodes({ env: 'prod', status: 'all' }); + const url: string = mockApiFetch.mock.calls[0][0]; + const params = new URL(url, 'http://x').searchParams; + expect(params.has('status')).toBe(false); + }); + + it('adds q param for search', async () => { + await listNodes({ env: 'staging', q: 'web-server' }); + const url: string = mockApiFetch.mock.calls[0][0]; + const params = new URL(url, 'http://x').searchParams; + expect(params.get('q')).toBe('web-server'); + }); + + it('adds sort and dir params together', async () => { + await listNodes({ env: 'dev', sort: 'hostname', dir: 'asc' }); + const url: string = mockApiFetch.mock.calls[0][0]; + const params = new URL(url, 'http://x').searchParams; + expect(params.get('sort')).toBe('hostname'); + expect(params.get('dir')).toBe('asc'); + }); + + it('adds page and page_size params', async () => { + await listNodes({ env: 'prod', page: 3, pageSize: 100 }); + const url: string = mockApiFetch.mock.calls[0][0]; + const params = new URL(url, 'http://x').searchParams; + expect(params.get('page')).toBe('3'); + expect(params.get('page_size')).toBe('100'); + }); + + it('encodes special characters in env name', async () => { + await listNodes({ env: 'my env' }); + const url: string = mockApiFetch.mock.calls[0][0]; + expect(url).toContain('my%20env'); + }); + + it('combines multiple params correctly', async () => { + await listNodes({ + env: 'prod', + status: 'inactive', + q: 'db', + sort: 'lastseen', + dir: 'desc', + page: 2, + pageSize: 25, + }); + const url: string = mockApiFetch.mock.calls[0][0]; + const params = new URL(url, 'http://x').searchParams; + expect(params.get('status')).toBe('inactive'); + expect(params.get('q')).toBe('db'); + expect(params.get('sort')).toBe('lastseen'); + expect(params.get('dir')).toBe('desc'); + expect(params.get('page')).toBe('2'); + expect(params.get('page_size')).toBe('25'); + }); +}); diff --git a/frontend/src/api/nodes.ts b/frontend/src/api/nodes.ts new file mode 100644 index 00000000..89c35b11 --- /dev/null +++ b/frontend/src/api/nodes.ts @@ -0,0 +1,69 @@ +import { apiFetch } from './client'; +import type { + NodesPagedResponse, + OsqueryNode, + NodeLogsResponse, + NodeStatus, + NodeSort, + SortDir, +} from './types'; + +/** Platform-bucket filter values accepted by GET /api/v1/nodes/{env}. */ +export type NodePlatform = 'linux' | 'darwin' | 'windows' | 'other'; + +export interface ListNodesParams { + env: string; + status?: NodeStatus; + q?: string; + sort?: NodeSort; + dir?: SortDir; + page?: number; + pageSize?: number; + /** Narrow to one platform bucket. Empty / omitted means "all". */ + platform?: NodePlatform; +} + +export function listNodes(p: ListNodesParams): Promise { + const params = new URLSearchParams(); + if (p.status && p.status !== 'all') params.set('status', p.status); + if (p.q) params.set('q', p.q); + if (p.sort) params.set('sort', p.sort); + if (p.dir) params.set('dir', p.dir); + if (p.page != null) params.set('page', String(p.page)); + if (p.pageSize != null) params.set('page_size', String(p.pageSize)); + if (p.platform) params.set('platform', p.platform); + + const qs = params.toString(); + return apiFetch( + `/api/v1/nodes/${encodeURIComponent(p.env)}${qs ? `?${qs}` : ''}`, + ); +} + +export function getNode(env: string, uuid: string): Promise { + return apiFetch( + `/api/v1/nodes/${encodeURIComponent(env)}/node/${encodeURIComponent(uuid)}`, + ); +} + +export function listNodeLogs( + env: string, + uuid: string, + type: 'status' | 'result', + limit?: number, + since?: string, + q?: string, +): Promise { + const params = new URLSearchParams(); + if (limit != null) params.set('limit', String(limit)); + if (since) params.set('since', since); + // Free-text search (substring, case-insensitive) — server-side LIKE + // against the human-readable columns: status rows match against + // line/message/filename; result rows match against name/action/columns. + // Empty string is treated as "no filter" by the API. + if (q && q.trim()) params.set('q', q.trim()); + + const qs = params.toString(); + return apiFetch( + `/api/v1/logs/${encodeURIComponent(type)}/${encodeURIComponent(env)}/${encodeURIComponent(uuid)}${qs ? `?${qs}` : ''}`, + ); +} diff --git a/frontend/src/api/osquery.test.ts b/frontend/src/api/osquery.test.ts new file mode 100644 index 00000000..5eae0d44 --- /dev/null +++ b/frontend/src/api/osquery.test.ts @@ -0,0 +1,21 @@ +import { describe, it, expect } from 'vitest'; + +/** + * Basic smoke tests for the osquery API module. + * The actual HTTP call is not executed here; we just verify the module + * exports the expected function signature. + */ +describe('osquery API module', () => { + it('exports getOsqueryTables as a function', async () => { + const mod = await import('./osquery'); + expect(typeof mod.getOsqueryTables).toBe('function'); + }); + + it('GET /api/v1/osquery/tables target URL is correct', () => { + // Verify the path is what the server registers. + const expectedPath = '/api/v1/osquery/tables'; + // The function is: apiFetch('/api/v1/osquery/tables') + // We confirm by reading the source (static check is enough for this module). + expect(expectedPath).toBe('/api/v1/osquery/tables'); + }); +}); diff --git a/frontend/src/api/osquery.ts b/frontend/src/api/osquery.ts new file mode 100644 index 00000000..0c4e7eae --- /dev/null +++ b/frontend/src/api/osquery.ts @@ -0,0 +1,7 @@ +import { apiFetch } from './client'; +import type { OsqueryTable } from './types'; + +/** GET /api/v1/osquery/tables — loads once per session via staleTime: Infinity */ +export function getOsqueryTables(): Promise { + return apiFetch('/api/v1/osquery/tables'); +} diff --git a/frontend/src/api/queries.test.ts b/frontend/src/api/queries.test.ts new file mode 100644 index 00000000..315f8b21 --- /dev/null +++ b/frontend/src/api/queries.test.ts @@ -0,0 +1,45 @@ +import { describe, it, expect } from 'vitest'; +import { getQueryResultsCSVUrl } from './queries'; + +/** + * URL-builder tests for the queries API module. + * These tests do not hit the network; they verify that the correct URLs + * are constructed for each endpoint so the React pages target the right paths. + */ +describe('queries API URL builders', () => { + it('getQueryResultsCSVUrl produces the expected path', () => { + const url = getQueryResultsCSVUrl('prod-env-uuid', 'q_abc123'); + expect(url).toBe('/api/v1/queries/prod-env-uuid/results/csv/q_abc123'); + }); + + it('getQueryResultsCSVUrl encodes special characters in env and name', () => { + const url = getQueryResultsCSVUrl('env with spaces', 'name/with/slashes'); + expect(url).toBe('/api/v1/queries/env%20with%20spaces/results/csv/name%2Fwith%2Fslashes'); + }); +}); + +describe('listQueries URL construction', () => { + // We test via the URLSearchParams construction used inside listQueries + // by verifying query param serialisation with a lightweight helper. + it('builds correct search params with all options', () => { + const params = new URLSearchParams(); + params.set('q', 'osquery_info'); + params.set('sort', 'created'); + params.set('dir', 'asc'); + params.set('page', '2'); + params.set('page_size', '25'); + + const qs = params.toString(); + expect(qs).toContain('q=osquery_info'); + expect(qs).toContain('sort=created'); + expect(qs).toContain('dir=asc'); + expect(qs).toContain('page=2'); + expect(qs).toContain('page_size=25'); + }); + + it('does not include page param when not set', () => { + const params = new URLSearchParams(); + params.set('q', 'test'); + expect(params.toString()).not.toContain('page='); + }); +}); diff --git a/frontend/src/api/queries.ts b/frontend/src/api/queries.ts new file mode 100644 index 00000000..787f6e19 --- /dev/null +++ b/frontend/src/api/queries.ts @@ -0,0 +1,111 @@ +import { apiFetch } from './client'; +import type { + DistributedQuery, + QueriesPagedResponse, + QueryResultsResponse, + QueryTarget, + QuerySortColumn, + SortDir, +} from './types'; + +export interface ListQueriesParams { + env: string; + target: QueryTarget; + q?: string; + sort?: QuerySortColumn; + dir?: SortDir; + page?: number; + pageSize?: number; +} + +/** GET /api/v1/queries/{env}/list/{target} — paginated */ +export function listQueries(p: ListQueriesParams): Promise { + const params = new URLSearchParams(); + if (p.q) params.set('q', p.q); + if (p.sort) params.set('sort', p.sort); + if (p.dir) params.set('dir', p.dir); + if (p.page != null) params.set('page', String(p.page)); + if (p.pageSize != null) params.set('page_size', String(p.pageSize)); + + const qs = params.toString(); + return apiFetch( + `/api/v1/queries/${encodeURIComponent(p.env)}/list/${encodeURIComponent(p.target)}${qs ? `?${qs}` : ''}`, + ); +} + +/** GET /api/v1/queries/{env}/{name} */ +export function getQuery(env: string, name: string): Promise { + return apiFetch( + `/api/v1/queries/${encodeURIComponent(env)}/${encodeURIComponent(name)}`, + ); +} + +export interface ListQueryResultsParams { + env: string; + name: string; + page?: number; + pageSize?: number; + /** RFC3339 timestamp; only rows created strictly after this are returned. */ + since?: string; +} + +/** GET /api/v1/queries/{env}/results/{name} — paginated + since-aware */ +export function listQueryResults(p: ListQueryResultsParams): Promise { + const params = new URLSearchParams(); + if (p.page != null) params.set('page', String(p.page)); + if (p.pageSize != null) params.set('page_size', String(p.pageSize)); + if (p.since) params.set('since', p.since); + const qs = params.toString(); + return apiFetch( + `/api/v1/queries/${encodeURIComponent(p.env)}/results/${encodeURIComponent(p.name)}${qs ? `?${qs}` : ''}`, + ); +} + +export interface RunQueryBody { + query: string; + uuid_list?: string[]; + platform_list?: string[]; + environment_list?: string[]; + host_list?: string[]; + tag_list?: string[]; + hidden?: boolean; + exp_hours?: number; +} + +export interface RunQueryResponse { + query_name: string; +} + +/** POST /api/v1/queries/{env} */ +export function runQuery(env: string, body: RunQueryBody): Promise { + return apiFetch( + `/api/v1/queries/${encodeURIComponent(env)}`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }, + ); +} + +export type QueryAction = 'delete' | 'expire' | 'complete'; + +/** POST /api/v1/queries/{env}/{action}/{name} */ +export function actOnQuery( + env: string, + name: string, + action: QueryAction, +): Promise<{ message: string }> { + return apiFetch<{ message: string }>( + `/api/v1/queries/${encodeURIComponent(env)}/${encodeURIComponent(action)}/${encodeURIComponent(name)}`, + { method: 'POST' }, + ); +} + +/** + * Returns the URL for the CSV download link. + * Use directly as — the browser handles the file download. + */ +export function getQueryResultsCSVUrl(env: string, name: string): string { + return `/api/v1/queries/${encodeURIComponent(env)}/results/csv/${encodeURIComponent(name)}`; +} diff --git a/frontend/src/api/samples.ts b/frontend/src/api/samples.ts new file mode 100644 index 00000000..a9c45634 --- /dev/null +++ b/frontend/src/api/samples.ts @@ -0,0 +1,73 @@ +/** + * Sample / starter library client. + * + * Both endpoints are pre-auth: the data is static, ships with the binary, and + * isn't tenant- or env-scoped. The login screen can lazy-load them; so can + * the queries/new and carves/new forms. + * + * Mirrors pkg/queries.QuerySample and pkg/carves.CarveSample on the Go side. + */ + +export type QuerySamplePlatform = 'linux' | 'darwin' | 'windows'; + +export type QuerySampleCategory = + | 'recon' + | 'processes' + | 'users' + | 'network' + | 'persistence' + | 'file_integrity' + | 'packages'; + +export interface QuerySample { + name: string; + description: string; + sql: string; + category: QuerySampleCategory; + platforms: QuerySamplePlatform[]; +} + +export type CarveSamplePlatform = 'linux' | 'darwin' | 'windows'; + +export type CarveSampleCategory = + | 'auth' + | 'logs' + | 'registry' + | 'keychain' + | 'history' + | 'config'; + +export interface CarveSample { + label: string; + path: string; + platform: CarveSamplePlatform; + category: CarveSampleCategory; + notes: string; +} + +/** + * Bypass apiFetch — endpoint is unauthenticated and the 401→/login redirect + * inside apiFetch would create a redirect loop if it ever fired (it can't + * here, but: belt-and-braces — same pattern as listLoginEnvironments). + */ +export async function listQuerySamples(): Promise { + const res = await fetch('/api/v1/queries/samples', { + method: 'GET', + headers: { Accept: 'application/json' }, + }); + if (!res.ok) { + throw new Error(`Failed to load query samples (HTTP ${res.status})`); + } + return (await res.json()) as QuerySample[]; +} + +export async function listCarveSamples(): Promise { + const res = await fetch('/api/v1/carves/samples', { + method: 'GET', + headers: { Accept: 'application/json' }, + }); + if (!res.ok) { + throw new Error(`Failed to load carve samples (HTTP ${res.status})`); + } + return (await res.json()) as CarveSample[]; +} diff --git a/frontend/src/api/saved-queries.ts b/frontend/src/api/saved-queries.ts new file mode 100644 index 00000000..c223fa72 --- /dev/null +++ b/frontend/src/api/saved-queries.ts @@ -0,0 +1,72 @@ +import { apiFetch } from './client'; +import type { + SavedQuery, + SavedQueriesPagedResponse, + SavedQuerySortColumn, + SortDir, +} from './types'; + +export interface ListSavedQueriesParams { + env: string; + q?: string; + sort?: SavedQuerySortColumn; + dir?: SortDir; + page?: number; + pageSize?: number; +} + +/** GET /api/v1/saved-queries/{env} — paginated */ +export function listSavedQueries(p: ListSavedQueriesParams): Promise { + const params = new URLSearchParams(); + if (p.q) params.set('q', p.q); + if (p.sort) params.set('sort', p.sort); + if (p.dir) params.set('dir', p.dir); + if (p.page != null) params.set('page', String(p.page)); + if (p.pageSize != null) params.set('page_size', String(p.pageSize)); + + const qs = params.toString(); + return apiFetch( + `/api/v1/saved-queries/${encodeURIComponent(p.env)}${qs ? `?${qs}` : ''}`, + ); +} + +export interface CreateSavedQueryBody { + name: string; + query: string; +} + +/** POST /api/v1/saved-queries/{env} */ +export function createSavedQuery(env: string, body: CreateSavedQueryBody): Promise { + return apiFetch( + `/api/v1/saved-queries/${encodeURIComponent(env)}`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }, + ); +} + +export interface UpdateSavedQueryBody { + query: string; +} + +/** PATCH /api/v1/saved-queries/{env}/{name} */ +export function updateSavedQuery(env: string, name: string, body: UpdateSavedQueryBody): Promise { + return apiFetch( + `/api/v1/saved-queries/${encodeURIComponent(env)}/${encodeURIComponent(name)}`, + { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }, + ); +} + +/** DELETE /api/v1/saved-queries/{env}/{name} */ +export function deleteSavedQuery(env: string, name: string): Promise<{ message: string }> { + return apiFetch<{ message: string }>( + `/api/v1/saved-queries/${encodeURIComponent(env)}/${encodeURIComponent(name)}`, + { method: 'DELETE' }, + ); +} diff --git a/frontend/src/api/settings.ts b/frontend/src/api/settings.ts new file mode 100644 index 00000000..f97802d7 --- /dev/null +++ b/frontend/src/api/settings.ts @@ -0,0 +1,63 @@ +/** + * Settings API client. + * + * Reuses the existing GET endpoints for read-side; adds a PATCH for single + * setting writes. + */ +import { apiFetch } from './client'; + +export type SettingType = 'string' | 'boolean' | 'integer'; + +/** Wire shape matching pkg/settings.SettingValue (subset). */ +export interface SettingValue { + ID: number; + CreatedAt: string; + UpdatedAt: string; + Name: string; + Service: string; + EnvironmentID: number; + JSON: boolean; + Type: SettingType; + String: string; + Boolean: boolean; + Integer: number; + Info: string; +} + +/** GET /api/v1/settings — every setting across all services (super-admin). */ +export function listAllSettings(): Promise { + return apiFetch('/api/v1/settings'); +} + +/** GET /api/v1/settings/{service} — non-JSON settings for one service. */ +export function listServiceSettings(service: string): Promise { + return apiFetch(`/api/v1/settings/${encodeURIComponent(service)}`); +} + +/** GET /api/v1/settings/{service}/json — JSON-typed settings only. */ +export function listServiceJSONSettings(service: string): Promise { + return apiFetch(`/api/v1/settings/${encodeURIComponent(service)}/json`); +} + +export interface SettingPatchRequest { + type?: SettingType; + string?: string; + boolean?: boolean; + integer?: number; +} + +/** PATCH /api/v1/settings/{service}/{name}. */ +export function patchSetting( + service: string, + name: string, + body: SettingPatchRequest, +): Promise { + return apiFetch( + `/api/v1/settings/${encodeURIComponent(service)}/${encodeURIComponent(name)}`, + { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }, + ); +} diff --git a/frontend/src/api/stats.test.ts b/frontend/src/api/stats.test.ts new file mode 100644 index 00000000..7dbd7a90 --- /dev/null +++ b/frontend/src/api/stats.test.ts @@ -0,0 +1,72 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { getStats } from './stats'; +import type { StatsResponse } from './stats'; + +// --------------------------------------------------------------------------- +// Mock apiFetch so we can capture the URL it's called with +// --------------------------------------------------------------------------- +const mockApiFetch = vi.fn(); + +vi.mock('./client', () => ({ + apiFetch: (url: string, init?: RequestInit) => mockApiFetch(url, init), + getCsrfToken: () => null, + setCsrfToken: vi.fn(), + isAuthenticated: () => false, +})); + +const STUB_RESPONSE: StatsResponse = { + total_nodes: 10, + active_nodes: 7, + inactive_nodes: 3, + total_active_queries: 2, + total_active_carves: 1, + platform_counts: { linux: 6, darwin: 2, windows: 2, other: 0 }, + environments: [ + { + uuid: 'env-uuid-1', + name: 'prod', + active: 7, + inactive: 3, + total: 10, + active_queries: 2, + active_carves: 1, + platform_counts: { linux: 6, darwin: 2, windows: 2, other: 0 }, + }, + ], +}; + +describe('getStats — URL construction', () => { + beforeEach(() => { + mockApiFetch.mockResolvedValue(STUB_RESPONSE); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('calls /api/v1/stats with no query params', async () => { + await getStats(); + expect(mockApiFetch).toHaveBeenCalledTimes(1); + // apiFetch signature is (path, init?) — getStats passes only the path, + // so init is the default empty object (passed as undefined by our mock capture). + const calledUrl: string = mockApiFetch.mock.calls[0][0] as string; + expect(calledUrl).toBe('/api/v1/stats'); + }); + + it('returns the response shape from apiFetch', async () => { + const result = await getStats(); + expect(result.total_nodes).toBe(10); + expect(result.active_nodes).toBe(7); + expect(result.inactive_nodes).toBe(3); + expect(result.total_active_queries).toBe(2); + expect(result.total_active_carves).toBe(1); + expect(result.environments).toHaveLength(1); + expect(result.environments[0].uuid).toBe('env-uuid-1'); + expect(result.environments[0].name).toBe('prod'); + }); + + it('propagates errors from apiFetch', async () => { + mockApiFetch.mockRejectedValueOnce(new Error('network error')); + await expect(getStats()).rejects.toThrow('network error'); + }); +}); diff --git a/frontend/src/api/stats.ts b/frontend/src/api/stats.ts new file mode 100644 index 00000000..b7fdf381 --- /dev/null +++ b/frontend/src/api/stats.ts @@ -0,0 +1,147 @@ +import { apiFetch } from './client'; + +/** + * Per-platform node counts. Drives the Nodes-table QuickFilters chip row + * ([Linux N] [macOS N] [Windows N] [Other N]). Mirrors pkg/nodes.PlatformCounts + * on the Go side. Counts are total — both active and inactive — since the + * platform filter is independent of the active/inactive filter. + */ +export interface PlatformCounts { + linux: number; + darwin: number; + windows: number; + other: number; +} + +export interface EnvStats { + uuid: string; + name: string; + active: number; + inactive: number; + total: number; + active_queries: number; + active_carves: number; + /** Per-env breakdown by OS family. */ + platform_counts: PlatformCounts; +} + +export interface StatsResponse { + total_nodes: number; + active_nodes: number; + inactive_nodes: number; + total_active_queries: number; + total_active_carves: number; + /** Cross-env aggregate (sum of every env.platform_counts the user can see). */ + platform_counts: PlatformCounts; + environments: EnvStats[]; +} + +export function getStats(): Promise { + return apiFetch('/api/v1/stats'); +} + +/** + * Fleet-wide osquery agent version breakdown. Powers the dashboard's "agent + * fleet hygiene" panel — operators use it to spot stale agents that need + * upgrading. Sorted by count descending (most-common version first). + * + * Mirrors pkg/nodes.OsqueryVersionCount on the Go side. + */ +export interface OsqueryVersionCount { + version: string; + count: number; +} + +export function getOsqueryVersionCounts(): Promise { + return apiFetch('/api/v1/stats/osquery-versions'); +} + +/** + * One cell of the per-env activity heatmap. Bucket size varies by `interval` + * — the Go side picks a bucketSeconds that keeps the cell count in the 36..96 + * range across the full picker. The 4 counters partition audit-log entries + * by their log_type → category mapping (see EnvActivityHandler): + * - config ← Setting (8) + Environment (7) + * - query ← Query (4) + * - carve ← Carve (5) + * - enroll ← Node (3) + * + * Buckets are returned contiguously — empty windows ship zero rows for that + * bucket — so the SPA grid renders without densifying client-side. + */ +export interface ActivityBucket { + bucket_start: string; + config: number; + query: number; + carve: number; + enroll: number; +} + +/** + * Allowed activity-heatmap intervals. The Go side falls back to '1d' on any + * unknown value, but typing it here keeps the picker honest. + */ +export type ActivityInterval = '3h' | '6h' | '12h' | '1d' | '2d' | '3d' | '7d'; + +export const ACTIVITY_INTERVALS: ActivityInterval[] = ['3h', '6h', '12h', '1d', '2d', '3d', '7d']; + +export function getEnvActivity(env: string, interval: ActivityInterval = '1d'): Promise { + const sp = new URLSearchParams(); + sp.set('interval', interval); + return apiFetch( + `/api/v1/stats/activity/${encodeURIComponent(env)}?${sp.toString()}`, + ); +} + +/** + * Per-node activity bucket. Categories pivot from the env-scoped variant — + * what THIS device has been doing rather than what operators did to the env: + * - status ← osquery_status_data row count (status logs this node shipped) + * - result ← osquery_result_data row count (query results this node returned) + * - query ← node_queries row count (distributed queries scheduled at this node) + * - carve ← carved_files row count (carves this node produced) + * + * Same bucket-size-per-interval rules as the env variant. + */ +export interface NodeActivityBucket { + bucket_start: string; + status: number; + result: number; + query: number; + carve: number; +} + +export function getNodeActivity( + env: string, + uuid: string, + interval: ActivityInterval = '1d', +): Promise { + const sp = new URLSearchParams(); + sp.set('interval', interval); + return apiFetch( + `/api/v1/stats/activity/node/${encodeURIComponent(env)}/${encodeURIComponent(uuid)}?${sp.toString()}`, + ); +} + +/** + * Batch variant — fetches activity buckets for up to 100 nodes in one call. + * Used by the Nodes table to render a sparkline column. Unknown / unauthorized + * UUIDs are silently omitted from the response (the server treats one bad + * UUID as no-data, not an error). Caller should treat a missing key as + * "no activity to render," not "fetch failed." + */ +export function getNodeActivityBatch( + env: string, + uuids: string[], + interval: ActivityInterval = '1d', +): Promise> { + if (uuids.length === 0) { + return Promise.resolve({}); + } + const sp = new URLSearchParams(); + sp.set('interval', interval); + sp.set('uuids', uuids.join(',')); + return apiFetch>( + `/api/v1/stats/activity/node-batch/${encodeURIComponent(env)}?${sp.toString()}`, + ); +} diff --git a/frontend/src/api/tags.ts b/frontend/src/api/tags.ts new file mode 100644 index 00000000..b5e0247b --- /dev/null +++ b/frontend/src/api/tags.ts @@ -0,0 +1,63 @@ +import { apiFetch } from './client'; +import type { AdminTag, TagsActionRequest } from './types'; + +/** GET /api/v1/tags — all tags across all environments (super-admin only). */ +export function listAllTags(): Promise { + return apiFetch('/api/v1/tags'); +} + +/** GET /api/v1/tags/{env} — env-scoped list of tags. */ +export function listEnvTags(env: string): Promise { + return apiFetch(`/api/v1/tags/${encodeURIComponent(env)}`); +} + +/** GET /api/v1/tags/{env}/{name} — single tag. */ +export function getEnvTag(env: string, name: string): Promise { + return apiFetch( + `/api/v1/tags/${encodeURIComponent(env)}/${encodeURIComponent(name)}`, + ); +} + +export type TagAction = 'add' | 'edit' | 'remove'; + +interface TagActionResponse { + data: string; +} + +/** POST /api/v1/tags/{env}/{action} — create / update / delete tags. */ +export function tagsAction( + env: string, + action: TagAction, + body: TagsActionRequest, +): Promise { + return apiFetch( + `/api/v1/tags/${encodeURIComponent(env)}/${encodeURIComponent(action)}`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }, + ); +} + +/** + * POST /api/v1/nodes/{env}/tag — assign a tag to a node. The nodes + * multi-action menu calls this once per selected UUID via Promise.allSettled. + */ +export interface NodeTagRequest { + uuid: string; + tag: string; + type?: number; + custom?: string; +} + +export function tagNode(env: string, body: NodeTagRequest): Promise<{ message: string }> { + return apiFetch<{ message: string }>( + `/api/v1/nodes/${encodeURIComponent(env)}/tag`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }, + ); +} diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts new file mode 100644 index 00000000..4f4855ec --- /dev/null +++ b/frontend/src/api/types.ts @@ -0,0 +1,366 @@ +/** + * Shared API types for the osctrl React admin. + * Snake_case fields match the JSON returned by osctrl-api. + */ + +/** + * Enrichment block returned by GET /api/v1/nodes/{env}/node/{uuid} and on + * each row in GET /api/v1/nodes/{env}. Parsed and sanitized from the + * `RawEnrollment` JSON blob that osquery sends during enroll — the enroll + * secret is deliberately excluded. Every field is optional because nodes + * with empty / malformed raw enrollments simply don't have this object. + * + * Mirrors pkg/types.NodeEnrichment on the Go side. + */ +export interface NodeSystemInfo { + hardware_vendor?: string; + hardware_model?: string; + hardware_version?: string; + hardware_serial?: string; + cpu_brand?: string; + cpu_type?: string; + cpu_subtype?: string; + cpu_physical_cores?: string; + cpu_logical_cores?: string; + physical_memory?: string; + computer_name?: string; + local_hostname?: string; +} + +export interface NodeBIOSInfo { + vendor?: string; + version?: string; + date?: string; + revision?: string; + address?: string; + size?: string; + volume_size?: string; +} + +export interface NodeOSInfo { + name?: string; + version?: string; + codename?: string; + major?: string; + minor?: string; + patch?: string; + platform?: string; + platform_like?: string; +} + +export interface NodeOsqueryRuntime { + version?: string; + build_platform?: string; + build_distro?: string; + extensions?: string; + start_time?: string; + config_valid?: string; +} + +export interface NodeEnrichment { + system?: NodeSystemInfo; + bios?: NodeBIOSInfo; + os?: NodeOSInfo; + osquery?: NodeOsqueryRuntime; +} + +export interface OsqueryNode { + id: number; + created_at: string; + updated_at: string; + uuid: string; + platform: string; + platform_version: string; + osquery_version: string; + hostname: string; + localname: string; + ip_address: string; + username: string; + osquery_user: string; + environment: string; + cpu: string; + memory: string; + hardware_serial: string; + daemon_hash: string; + config_hash: string; + bytes_received: number; + last_seen: string; + user_id: number; + environment_id: number; + extra_data: string; + /** Optional enrichment parsed server-side from RawEnrollment (no secrets). */ + system_info?: NodeEnrichment; +} + +export type NodeStatus = 'all' | 'active' | 'inactive'; +export type NodeSort = + | 'uuid' + | 'hostname' + | 'localname' + | 'ip' + | 'platform' + | 'version' + | 'osquery' + | 'lastseen' + | 'firstseen'; +export type SortDir = 'asc' | 'desc'; + +export interface NodesPagedResponse { + items: OsqueryNode[]; + page: number; + page_size: number; + total_items: number; + total_pages: number; +} + +export type NodeLogEntry = Record; + +export interface NodeLogsResponse { + items: NodeLogEntry[]; + type: 'status' | 'result'; + uuid: string; + env: string; + since?: string; + limit: number; +} + +// --------------------------------------------------------------------------- +// Queries types +// --------------------------------------------------------------------------- + +export interface DistributedQuery { + id: number; + created_at: string; + updated_at: string; + name: string; + creator: string; + query: string; + expected: number; + executions: number; + errors: number; + active: boolean; + hidden: boolean; + protected: boolean; + completed: boolean; + deleted: boolean; + expired: boolean; + type: string; + path: string; + environment_id: number; + extra_data: string; + expiration: string; + target: string; +} + +export interface QueriesPagedResponse { + items: DistributedQuery[]; + page: number; + page_size: number; + total_items: number; + total_pages: number; +} + +export type QueryResultRow = Record; + +export interface QueryResultItem { + id: number; + created_at: string; + uuid: string; + environment: string; + name: string; + data: string; + status: number; +} + +export interface QueryResultsResponse { + items: QueryResultItem[]; + page: number; + page_size: number; + total_items: number; + total_pages: number; + since?: string; +} + +export type QueryTarget = + | 'all' + | 'all-full' + | 'active' + | 'completed' + | 'expired' + | 'saved' + | 'hidden-completed' + | 'deleted' + | 'hidden'; + +export type QuerySortColumn = + | 'name' + | 'creator' + | 'created' + | 'type' + | 'expected' + | 'executions' + | 'errors'; + +// --------------------------------------------------------------------------- +// Saved queries +// --------------------------------------------------------------------------- + +export interface SavedQuery { + id: number; + created_at: string; + updated_at: string; + name: string; + creator: string; + query: string; + environment_id: number; + extra_data?: string; +} + +export interface SavedQueriesPagedResponse { + items: SavedQuery[]; + page: number; + page_size: number; + total_items: number; + total_pages: number; +} + +export type SavedQuerySortColumn = 'name' | 'creator' | 'created' | 'updated'; + +// --------------------------------------------------------------------------- +// Carves +// --------------------------------------------------------------------------- + +// The list of carve queries reuses the DistributedQuery shape — same backing +// table. Items in CarvesPagedResponse are rows where type === 'carve'. +export interface CarvesPagedResponse { + items: DistributedQuery[]; + page: number; + page_size: number; + total_items: number; + total_pages: number; +} + +export interface CarveFile { + carve_id: string; + session_id: string; + uuid: string; + path: string; + status: string; + carve_size: number; + block_size: number; + total_blocks: number; + completed_blocks: number; + archived: boolean; + created_at: string; + completed_at: string; +} + +export interface CarveDetail { + query: DistributedQuery; + files: CarveFile[]; +} + +// Carves share the same set of targets as queries — they are also +// DistributedQuery rows, just with type=carve. +export type CarveTarget = QueryTarget; + +// Carves expose the same sortable columns as queries; the package layer +// reuses QuerySortableColumns. Errors/expected/executions are still valid +// because the underlying rows are DistributedQuery records. +export type CarveSortColumn = QuerySortColumn; + +// --------------------------------------------------------------------------- +// Tags +// --------------------------------------------------------------------------- + +export interface AdminTag { + id: number; + created_at: string; + updated_at: string; + name: string; + description: string; + color: string; + icon: string; + created_by: string; + custom_tag: string; + auto_tag: boolean; + environment_id: number; + tag_type: number; + cohort: boolean; +} + +export interface TagsActionRequest { + name: string; + description?: string; + color?: string; + icon?: string; + tagtype?: number; + custom?: string; +} + +// --------------------------------------------------------------------------- +// Users + permissions +// --------------------------------------------------------------------------- + +export interface AdminUser { + id: number; + created_at: string; + updated_at: string; + username: string; + email: string; + fullname: string; + token_expire: string; + admin: boolean; + service: boolean; + uuid: string; + last_ip_address: string; + last_user_agent: string; + last_access: string; + last_token_use: string; + environment_id: number; +} + +export interface EnvAccess { + user: boolean; + query: boolean; + carve: boolean; + admin: boolean; +} + +export interface SetPermissionsRequest { + env_uuid: string; + access: EnvAccess; +} + +export interface TokenResponse { + token: string; + expires: string; +} + +export interface UserMeResponse { + username: string; + email: string; + fullname: string; + admin: boolean; + service: boolean; + uuid: string; + token_expire: string; + last_access: string; +} + +// --------------------------------------------------------------------------- +// osquery schema types +// --------------------------------------------------------------------------- + +export interface OsqueryTableColumn { + name: string; + description: string; + type: string; +} + +export interface OsqueryTable { + name: string; + url: string; + platforms: string[]; + filter: string; +} diff --git a/frontend/src/api/users.ts b/frontend/src/api/users.ts new file mode 100644 index 00000000..27063d8d --- /dev/null +++ b/frontend/src/api/users.ts @@ -0,0 +1,75 @@ +import { apiFetch } from './client'; +import type { + AdminUser, + EnvAccess, + SetPermissionsRequest, + TokenResponse, + UserMeResponse, +} from './types'; + +/** GET /api/v1/users — super-admin list of users. */ +export function listUsers(): Promise { + return apiFetch('/api/v1/users'); +} + +/** GET /api/v1/users/{username} — single user (super-admin). */ +export function getUser(username: string): Promise { + return apiFetch(`/api/v1/users/${encodeURIComponent(username)}`); +} + +/** POST /api/v1/users/{username}/permissions — replace per-env access. */ +export function setUserPermissions( + username: string, + body: SetPermissionsRequest, +): Promise { + return apiFetch( + `/api/v1/users/${encodeURIComponent(username)}/permissions`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }, + ); +} + +/** POST /api/v1/users/{username}/token/refresh — mint a new API token. */ +export function refreshUserToken(username: string): Promise { + return apiFetch( + `/api/v1/users/${encodeURIComponent(username)}/token/refresh`, + { method: 'POST' }, + ); +} + +/** DELETE /api/v1/users/{username}/token — invalidate the user's API token. */ +export function deleteUserToken(username: string): Promise<{ message: string }> { + return apiFetch<{ message: string }>( + `/api/v1/users/${encodeURIComponent(username)}/token`, + { method: 'DELETE' }, + ); +} + +/** GET /api/v1/users/me — current operator's profile. */ +export function getMe(): Promise { + return apiFetch('/api/v1/users/me'); +} + +/** PATCH /api/v1/users/me — update own email and/or fullname. */ +export function patchMe(body: { email?: string; fullname?: string }): Promise { + return apiFetch('/api/v1/users/me', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); +} + +/** POST /api/v1/users/me/password — change own password. */ +export function changeMyPassword(body: { + current_password: string; + new_password: string; +}): Promise<{ message: string }> { + return apiFetch<{ message: string }>('/api/v1/users/me/password', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); +} diff --git a/frontend/src/components/.gitkeep b/frontend/src/components/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/frontend/src/components/atoms/Button.test.tsx b/frontend/src/components/atoms/Button.test.tsx new file mode 100644 index 00000000..72df6bf8 --- /dev/null +++ b/frontend/src/components/atoms/Button.test.tsx @@ -0,0 +1,21 @@ +import { describe, it, expect } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { Button } from './Button'; + +describe('Button', () => { + it('renders children', () => { + render(); + expect(screen.getByRole('button', { name: 'Sign in' })).toBeInTheDocument(); + }); + + it('applies the primary variant by default', () => { + render(); + const btn = screen.getByRole('button'); + expect(btn.className).toContain('bg'); // primary applies a background + }); + + it('passes disabled prop through', () => { + render(); + expect(screen.getByRole('button')).toBeDisabled(); + }); +}); diff --git a/frontend/src/components/atoms/Button.tsx b/frontend/src/components/atoms/Button.tsx new file mode 100644 index 00000000..3fa9e5c6 --- /dev/null +++ b/frontend/src/components/atoms/Button.tsx @@ -0,0 +1,65 @@ +import { forwardRef, type ButtonHTMLAttributes } from 'react'; +import { cn } from '$/lib/cn'; + +export type ButtonVariant = 'primary' | 'ghost' | 'danger'; +export type ButtonSize = 'sm' | 'md' | 'lg'; + +interface ButtonProps extends ButtonHTMLAttributes { + variant?: ButtonVariant; + size?: ButtonSize; +} + +const variantClasses: Record = { + primary: [ + 'bg-gradient-to-b from-[color:var(--signal-bright)] to-[color:var(--signal)]', + 'text-[#051010]', + 'font-semibold', + 'border border-[color:var(--signal)]/60', + 'shadow-[inset_0_1px_0_rgba(255,255,255,0.25),0_1px_14px_-2px_var(--signal-glow)]', + 'hover:brightness-110', + '[data-theme=light]:text-white', + ].join(' '), + ghost: [ + 'bg-[color:var(--bg-2)]', + 'text-[color:var(--text-1)]', + 'border border-[color:var(--border)]', + 'hover:bg-[color:var(--bg-3)] hover:border-[color:var(--border-strong)]', + ].join(' '), + danger: [ + 'bg-[color:var(--danger)]/10', + 'text-[color:var(--danger)]', + 'border border-[color:var(--danger)]/30', + 'hover:bg-[color:var(--danger)]/15', + ].join(' '), +}; + +const sizeClasses: Record = { + sm: 'px-2.5 py-1 text-xs rounded-md', + md: 'px-3.5 py-2 text-sm rounded-lg', + lg: 'px-5 py-2.5 text-base rounded-lg', +}; + +export const Button = forwardRef( + ({ variant = 'primary', size = 'md', className, disabled, children, ...props }, ref) => { + return ( + + ); + } +); + +Button.displayName = 'Button'; diff --git a/frontend/src/components/atoms/Input.tsx b/frontend/src/components/atoms/Input.tsx new file mode 100644 index 00000000..75910d20 --- /dev/null +++ b/frontend/src/components/atoms/Input.tsx @@ -0,0 +1,30 @@ +import { forwardRef, type InputHTMLAttributes } from 'react'; +import { cn } from '$/lib/cn'; + +interface InputProps extends InputHTMLAttributes { + error?: string; +} + +export const Input = forwardRef( + ({ className, error, ...props }, ref) => { + return ( + + ); + } +); + +Input.displayName = 'Input'; diff --git a/frontend/src/components/atoms/Label.tsx b/frontend/src/components/atoms/Label.tsx new file mode 100644 index 00000000..5f61ac61 --- /dev/null +++ b/frontend/src/components/atoms/Label.tsx @@ -0,0 +1,28 @@ +import { forwardRef, type LabelHTMLAttributes } from 'react'; +import { cn } from '$/lib/cn'; + +interface LabelProps extends LabelHTMLAttributes { + required?: boolean; +} + +export const Label = forwardRef( + ({ className, children, required, ...props }, ref) => { + return ( + + ); + } +); + +Label.displayName = 'Label'; diff --git a/frontend/src/components/atoms/Logo.tsx b/frontend/src/components/atoms/Logo.tsx new file mode 100644 index 00000000..0ee1e12b --- /dev/null +++ b/frontend/src/components/atoms/Logo.tsx @@ -0,0 +1,28 @@ +import { cn } from '$/lib/cn'; + +interface LogoProps { + size?: number; + className?: string; + decorative?: boolean; +} + +export function Logo({ size = 32, className, decorative = false }: LogoProps) { + return ( + + + + + + + + + ); +} diff --git a/frontend/src/components/chrome/AppShell.tsx b/frontend/src/components/chrome/AppShell.tsx new file mode 100644 index 00000000..da607e89 --- /dev/null +++ b/frontend/src/components/chrome/AppShell.tsx @@ -0,0 +1,37 @@ +import { useEffect, useState, type ReactNode } from 'react'; +import { SideNav } from './SideNav'; +import { TopBar } from './TopBar'; +import { CommandPalette } from './CommandPalette'; + +interface AppShellProps { + children: ReactNode; + username?: string; +} + +export function AppShell({ children, username }: AppShellProps) { + const [paletteOpen, setPaletteOpen] = useState(false); + + // Global ⌘K / Ctrl-K toggle. Listener lives at the shell level so any + // authenticated page can hit it without re-binding. + useEffect(() => { + function onKey(e: KeyboardEvent) { + if ((e.metaKey || e.ctrlKey) && (e.key === 'k' || e.key === 'K')) { + e.preventDefault(); + setPaletteOpen((o) => !o); + } + } + window.addEventListener('keydown', onKey); + return () => window.removeEventListener('keydown', onKey); + }, []); + + return ( +
+ +
+ setPaletteOpen(true)} /> +
{children}
+
+ +
+ ); +} diff --git a/frontend/src/components/chrome/CommandPalette.tsx b/frontend/src/components/chrome/CommandPalette.tsx new file mode 100644 index 00000000..e6f31111 --- /dev/null +++ b/frontend/src/components/chrome/CommandPalette.tsx @@ -0,0 +1,227 @@ +/** + * CommandPalette — global ⌘K / Ctrl-K launcher. + * + * Indexes static pages + every environment (live, via the same query the + * EnvSwitcher uses). Filter is a single fuzzy-ish "all words must appear" + * match against the visible label + the optional aliases. Up/Down navigate, + * Enter activates, Esc / click-outside / Cmd-K-again all dismiss. + * + * Lives in `chrome/` because it's part of the app shell — mounted once at + * AppShell level and reachable from any authenticated page. Wrapped in + * ModalShell so the popover gets focus management + a11y for free. + */ +import { useEffect, useMemo, useRef, useState } from 'react'; +import { useNavigate } from '@tanstack/react-router'; +import { useQuery } from '@tanstack/react-query'; +import { cn } from '$/lib/cn'; +import { ModalShell } from '$/components/feedback/ModalShell'; +import { listEnvironments, type TLSEnvironment } from '$/api/environments'; +import { isAuthenticated } from '$/api/client'; + +type CommandKind = 'page' | 'env' | 'action'; + +interface CommandItem { + id: string; + kind: CommandKind; + label: string; + hint?: string; + /** Lower-cased haystack used for filtering — label + aliases joined. */ + haystack: string; + run: () => void; +} + +const STATIC_PAGES: { label: string; to: string; hint?: string; aliases?: string[] }[] = [ + { label: 'Dashboard', to: '/_app/', hint: 'Cross-env summary' }, + { label: 'Operators', to: '/_app/users', hint: 'Users + permissions', aliases: ['users', 'permissions'] }, + { label: 'Profile', to: '/_app/profile', hint: 'My account' }, + { label: 'Environments', to: '/_app/environments', hint: 'Create / edit envs' }, + { label: 'Settings · admin', to: '/_app/settings/admin', aliases: ['settings'] }, + { label: 'Settings · tls', to: '/_app/settings/tls' }, + { label: 'Settings · osctrl-api', to: '/_app/settings/api' }, + { label: 'Audit Trail', to: '/_app/audit', hint: 'Filtered log read' }, +]; + +export function CommandPalette({ + open, + onOpenChange, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; +}) { + const navigate = useNavigate(); + const [filter, setFilter] = useState(''); + const [selected, setSelected] = useState(0); + const listRef = useRef(null); + + const { data: envs = [] } = useQuery({ + queryKey: ['environments-cmdpal'], + queryFn: () => listEnvironments(), + enabled: open && isAuthenticated(), + staleTime: 60_000, + }); + + // Reset filter and selection each time we open. + useEffect(() => { + if (open) { + setFilter(''); + setSelected(0); + } + }, [open]); + + const items = useMemo(() => { + const out: CommandItem[] = []; + for (const p of STATIC_PAGES) { + const aliases = [p.label.toLowerCase(), ...(p.aliases ?? [])].join(' '); + out.push({ + id: `page:${p.to}`, + kind: 'page', + label: p.label, + hint: p.hint, + haystack: aliases, + run: () => { + void navigate({ to: p.to }); + onOpenChange(false); + }, + }); + } + for (const e of envs as TLSEnvironment[]) { + out.push({ + id: `env:${e.uuid}`, + kind: 'env', + label: `Go to env · ${e.name}`, + hint: e.uuid, + haystack: `${e.name.toLowerCase()} ${e.uuid.toLowerCase()} env`, + run: () => { + void navigate({ to: `/_app/env/${e.uuid}/nodes` }); + onOpenChange(false); + }, + }); + out.push({ + id: `env-config:${e.uuid}`, + kind: 'action', + label: `Edit config · ${e.name}`, + hint: 'osquery config sections', + haystack: `${e.name.toLowerCase()} config options schedule packs`, + run: () => { + void navigate({ to: `/_app/env/${e.uuid}/config` }); + onOpenChange(false); + }, + }); + } + return out; + }, [envs, navigate, onOpenChange]); + + const filtered = useMemo(() => { + const tokens = filter + .toLowerCase() + .split(/\s+/) + .filter(Boolean); + if (tokens.length === 0) return items; + return items.filter((it) => tokens.every((t) => it.haystack.includes(t))); + }, [filter, items]); + + // Clamp selection on filter change. + useEffect(() => { + setSelected((s) => Math.max(0, Math.min(s, filtered.length - 1))); + }, [filtered]); + + // Scroll the selected row into view. + useEffect(() => { + if (!listRef.current) return; + const el = listRef.current.querySelector( + `li[data-idx="${selected}"]`, + ); + el?.scrollIntoView({ block: 'nearest' }); + }, [selected]); + + function handleKey(e: React.KeyboardEvent) { + if (e.key === 'ArrowDown') { + e.preventDefault(); + setSelected((s) => Math.min(filtered.length - 1, s + 1)); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + setSelected((s) => Math.max(0, s - 1)); + } else if (e.key === 'Enter') { + e.preventDefault(); + const it = filtered[selected]; + if (it) it.run(); + } + } + + if (!open) return null; + + return ( + onOpenChange(false)} + panelClassName="max-w-xl" + > +
+ setFilter(e.target.value)} + onKeyDown={handleKey} + placeholder="Type to filter… Up/Down + Enter" + className={cn( + 'w-full px-3 py-2 text-sm rounded-md border border-[color:var(--border)]', + 'bg-[color:var(--bg-2)] text-[color:var(--text-1)]', + 'focus:outline focus:outline-2 focus:outline-[color:var(--signal)]', + )} + /> + +
    + {filtered.length === 0 && ( +
  • + No matches. +
  • + )} + {filtered.map((it, idx) => ( +
  • setSelected(idx)} + > + +
  • + ))} +
+ +

+ ⌘K toggle · Esc close · ↑↓ navigate · ↵ activate +

+
+
+ ); +} diff --git a/frontend/src/components/chrome/EnvSwitcher.tsx b/frontend/src/components/chrome/EnvSwitcher.tsx new file mode 100644 index 00000000..daeae3a1 --- /dev/null +++ b/frontend/src/components/chrome/EnvSwitcher.tsx @@ -0,0 +1,117 @@ +/** + * EnvSwitcher — environment selector backed by the real /api/v1/environments + * endpoint. The UUID is what we navigate to (env routes are keyed by UUID + * to match the API surface), and the dropdown shows the human-friendly name. + * + * On navigation we resolve the current `env` path param against the env list + * and highlight it. Falls back to a "(select)" placeholder when no env is + * selected (e.g. on /_app, /_app/environments). + */ +import { useNavigate, useParams, useRouterState } from '@tanstack/react-router'; +import { useQuery } from '@tanstack/react-query'; +import { cn } from '$/lib/cn'; +import { DropdownMenu } from '$/components/primitives/DropdownMenu'; +import { listEnvironments, type TLSEnvironment } from '$/api/environments'; +import { isAuthenticated } from '$/api/client'; + +export function EnvSwitcher() { + const navigate = useNavigate(); + const params = useParams({ strict: false }); + const routerState = useRouterState(); + const currentEnv = (params as { env?: string }).env; + + const { data, isLoading } = useQuery({ + queryKey: ['environments-switcher'], + queryFn: () => listEnvironments(), + staleTime: 60_000, + enabled: isAuthenticated(), + }); + + const envs: TLSEnvironment[] = data ?? []; + // The URL env param may be either the env name (what the SideNav links emit) + // or the env UUID (legacy callers). Try both so the active row highlights + // correctly regardless of which form is in the URL. + const active = envs.find((e) => e.name === currentEnv || e.uuid === currentEnv); + + function handleSelect(envName: string) { + // Send the user to the same logical page on the new env when possible. + // Default to /nodes if we can't infer the sub-route. We pass the env *name* + // in the URL (not UUID) for symmetry with SideNav and for human readability; + // the API resolves both since the path-param env now goes through + // Envs.Get(envVar) which accepts name OR UUID. + const pathname = routerState.location.pathname; + const match = pathname.match(/^\/_app\/env\/[^/]+\/(.*)$/); + const sub = match ? match[1] : 'nodes'; + void navigate({ to: `/_app/env/${envName}/${sub}` }); + } + + return ( + + + + + + Environments + {envs.length === 0 && !isLoading && ( +
+ No environments configured. +
+ )} + handleSelect(v)} + > + {envs.map((e) => ( + // value=e.name so onValueChange hands the name to handleSelect, + // matching the URL shape SideNav emits (`/_app/env/{name}/...`). + + + + {e.name} + + + ))} + +
+
+ ); +} diff --git a/frontend/src/components/chrome/SideNav.tsx b/frontend/src/components/chrome/SideNav.tsx new file mode 100644 index 00000000..7c47d059 --- /dev/null +++ b/frontend/src/components/chrome/SideNav.tsx @@ -0,0 +1,292 @@ +import { Link, useRouterState, useParams } from '@tanstack/react-router'; +import { useQuery } from '@tanstack/react-query'; +import { cn } from '$/lib/cn'; +import { Logo } from '$/components/atoms/Logo'; +import { EnvSwitcher } from './EnvSwitcher'; +import { listEnvironments } from '$/api/environments'; + +interface NavItemProps { + active?: boolean; + to?: string; + href?: string; + icon: React.ReactNode; + children: React.ReactNode; +} + +function NavItem({ active, to, href, icon, children }: NavItemProps) { + const className = cn( + 'flex items-center gap-2 px-2 py-1.5 rounded-md text-sm', + 'transition-colors duration-[120ms] ease-out', + 'focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-[color:var(--signal)]', + active + ? [ + 'text-[color:var(--text-1)]', + 'bg-[linear-gradient(90deg,rgba(var(--halo-r),var(--halo-g),var(--halo-b),0.12),rgba(var(--halo-r),var(--halo-g),var(--halo-b),0)_60%),var(--bg-2)]', + 'shadow-[inset_2px_0_0_var(--signal)]', + ].join(' ') + : 'text-[color:var(--text-2)] hover:text-[color:var(--text-1)] hover:bg-[color:var(--bg-2)]', + ); + + const content = ( + <> + {icon} + {children} + + ); + + if (to) { + return ( + + {content} + + ); + } + + return ( +
+ {content} + + ); +} + +function SectionLabel({ children }: { children: React.ReactNode }) { + return ( +
+ {children} +
+ ); +} + +export function SideNav() { + const routerState = useRouterState(); + const pathname = routerState.location.pathname; + const params = useParams({ strict: false }); + // Pick the env scope for the nav links: + // 1. URL param wins when present (you're already inside an env). + // 2. Otherwise (dashboard, profile, environments, etc.) fall back to the + // first env returned by listEnvironments — same React Query cache the + // EnvSwitcher consumes, so this is free if the dropdown was opened. + // 3. Final fallback is the literal "dev" only because the compose stack + // ships exactly that env; in production it's just a placeholder until + // the env list arrives. + const { data: envs } = useQuery({ + queryKey: ['environments'], + queryFn: () => listEnvironments(), + staleTime: 60_000, + }); + const urlEnv = (params as { env?: string }).env; + const currentEnv = urlEnv ?? envs?.[0]?.name ?? 'dev'; + + // Env-scoped routes live under /_app/env/{env}/... per + // frontend/src/routes/_app/env/$env/*.tsx — the "_app" prefix is the + // auth-gated layout. Omitting it produces unrouted URLs that fall through + // to a 404 page. + const nodesPath = `/_app/env/${currentEnv}/nodes`; + const isNodesActive = pathname.startsWith(`/_app/env/${currentEnv}/nodes`); + const queriesPath = `/_app/env/${currentEnv}/queries`; + const savedQueriesPath = `/_app/env/${currentEnv}/saved-queries`; + const carvesPath = `/_app/env/${currentEnv}/carves`; + const tagsPath = `/_app/env/${currentEnv}/tags`; + const enrollPath = `/_app/env/${currentEnv}/enroll`; + // Distinguish "/queries" (and its subroutes) from "/saved-queries". + const isSavedQueriesActive = pathname.startsWith(`/_app/env/${currentEnv}/saved-queries`); + const isQueriesActive = + pathname.startsWith(`/_app/env/${currentEnv}/queries`) && !isSavedQueriesActive; + const isCarvesActive = pathname.startsWith(`/_app/env/${currentEnv}/carves`); + const isTagsActive = pathname.startsWith(`/_app/env/${currentEnv}/tags`); + const isEnrollActive = pathname.startsWith(`/_app/env/${currentEnv}/enroll`); + const isUsersActive = pathname.startsWith('/_app/users') || pathname === '/users'; + const isProfileActive = pathname.startsWith('/_app/profile') || pathname === '/profile'; + const isEnvironmentsActive = + pathname.startsWith('/_app/environments') || pathname === '/environments'; + const isSettingsActive = + pathname.startsWith('/_app/settings') || pathname.startsWith('/settings'); + const isAuditActive = pathname.startsWith('/_app/audit') || pathname === '/audit'; + // Match exactly '/' or '/_app/' (the dashboard route) but NOT '/env/...' + const isDashboardActive = pathname === '/' || pathname === '/_app' || pathname === '/_app/'; + + return ( + + ); +} diff --git a/frontend/src/components/chrome/ThemeToggle.tsx b/frontend/src/components/chrome/ThemeToggle.tsx new file mode 100644 index 00000000..1f059d8c --- /dev/null +++ b/frontend/src/components/chrome/ThemeToggle.tsx @@ -0,0 +1,50 @@ +import { useEffect, useState } from 'react'; +import { cn } from '$/lib/cn'; +import { toggleTheme, getInitialTheme, applyTheme } from '$/lib/theme'; +import type { Theme } from '$/lib/design-tokens'; + +export function ThemeToggle() { + const [current, setCurrent] = useState(() => { + const fromDom = document.documentElement.getAttribute('data-theme') as Theme | null; + return fromDom === 'light' || fromDom === 'dark' ? fromDom : getInitialTheme(); + }); + + useEffect(() => { + applyTheme(current); + }, [current]); + + function handleToggle(theme: 'dark' | 'light') { + if (theme === current) return; + const next = toggleTheme(); + setCurrent(next); + } + + return ( +
+ {(['dark', 'light'] as const).map((theme) => ( + + ))} +
+ ); +} diff --git a/frontend/src/components/chrome/TopBar.tsx b/frontend/src/components/chrome/TopBar.tsx new file mode 100644 index 00000000..f42c5e56 --- /dev/null +++ b/frontend/src/components/chrome/TopBar.tsx @@ -0,0 +1,86 @@ +import { cn } from '$/lib/cn'; +import { ThemeToggle } from './ThemeToggle'; +import { UserMenu } from './UserMenu'; + +interface BreadcrumbSegment { + label: string; + href?: string; +} + +interface TopBarProps { + breadcrumbs?: BreadcrumbSegment[]; + username?: string; + onCommandPalette?: () => void; +} + +export function TopBar({ + breadcrumbs = [{ label: 'Command Center' }], + username, + onCommandPalette, +}: TopBarProps) { + return ( +
+ {/* Breadcrumbs */} + + + {/* Right controls */} +
+ {onCommandPalette && ( + + )} + + +
+
+ ); +} diff --git a/frontend/src/components/chrome/UserMenu.tsx b/frontend/src/components/chrome/UserMenu.tsx new file mode 100644 index 00000000..09244cd1 --- /dev/null +++ b/frontend/src/components/chrome/UserMenu.tsx @@ -0,0 +1,70 @@ +import { useRouter } from '@tanstack/react-router'; +import { cn } from '$/lib/cn'; +import { DropdownMenu } from '$/components/primitives/DropdownMenu'; +import { logout } from '$/api/client'; + +interface UserMenuProps { + username?: string; +} + +function getInitials(name: string): string { + return name + .split(/\s+/) + .map((w) => w[0]?.toUpperCase() ?? '') + .slice(0, 2) + .join(''); +} + +export function UserMenu({ username = 'admin' }: UserMenuProps) { + const router = useRouter(); + const initials = getInitials(username); + + function handleLogout() { + logout(); + void router.navigate({ to: '/login' }); + } + + return ( + + + + + + {username} + + + + + + + + Sign out + + + + ); +} diff --git a/frontend/src/components/data/EmptyState.tsx b/frontend/src/components/data/EmptyState.tsx new file mode 100644 index 00000000..ab523c75 --- /dev/null +++ b/frontend/src/components/data/EmptyState.tsx @@ -0,0 +1,34 @@ +import type { ReactNode } from 'react'; +import { cn } from '$/lib/cn'; + +interface EmptyStateProps { + /** Icon element to render above the title. */ + icon?: ReactNode; + title: string; + description?: string; + /** Primary action button or link. */ + action?: ReactNode; + className?: string; +} + +export function EmptyState({ icon, title, description, action, className }: EmptyStateProps) { + return ( +
+ {icon && ( +
+ {icon} +
+ )} +

{title}

+ {description && ( +

{description}

+ )} + {action &&
{action}
} +
+ ); +} diff --git a/frontend/src/components/data/Pagination.tsx b/frontend/src/components/data/Pagination.tsx new file mode 100644 index 00000000..42b2a6c0 --- /dev/null +++ b/frontend/src/components/data/Pagination.tsx @@ -0,0 +1,72 @@ +import { cn } from '$/lib/cn'; + +interface PaginationProps { + page: number; + totalPages: number; + totalItems: number; + pageSize: number; + onPageChange: (page: number) => void; + className?: string; +} + +export function Pagination({ + page, + totalPages, + totalItems, + pageSize, + onPageChange, + className, +}: PaginationProps) { + const start = totalItems === 0 ? 0 : (page - 1) * pageSize + 1; + const end = Math.min(page * pageSize, totalItems); + + return ( +
+ + {totalItems === 0 ? 'No results' : `${start}–${end} of ${totalItems.toLocaleString()}`} + + +
+ + + + {page} / {totalPages || 1} + + + +
+
+ ); +} diff --git a/frontend/src/components/data/SearchInput.tsx b/frontend/src/components/data/SearchInput.tsx new file mode 100644 index 00000000..2db1fcb8 --- /dev/null +++ b/frontend/src/components/data/SearchInput.tsx @@ -0,0 +1,95 @@ +import { useState, useEffect } from 'react'; +import { cn } from '$/lib/cn'; + +interface SearchInputProps { + value: string; + onChange: (value: string) => void; + placeholder?: string; + debounceMs?: number; + className?: string; + id?: string; +} + +export function SearchInput({ + value, + onChange, + placeholder = 'Search…', + debounceMs = 300, + className, + id = 'node-search', +}: SearchInputProps) { + const [local, setLocal] = useState(value); + + // Sync external value changes (e.g. URL param reset) — only when the + // prop value itself changes, not on every parent render. + useEffect(() => { + setLocal(value); + }, [value]); + + // Debounce: fire onChange after debounceMs of inactivity. + // Skip when local already matches the committed value. + useEffect(() => { + if (local === value) return; + const t = setTimeout(() => onChange(local), debounceMs); + return () => clearTimeout(t); + }, [local, value, onChange, debounceMs]); + + function handleChange(e: React.ChangeEvent) { + setLocal(e.target.value); + } + + function handleClear() { + setLocal(''); + onChange(''); + } + + return ( +
+ + {/* Magnifying glass */} + + + + + + + + {/* Clear button */} + {local && ( + + )} +
+ ); +} diff --git a/frontend/src/components/data/Skeleton.tsx b/frontend/src/components/data/Skeleton.tsx new file mode 100644 index 00000000..ae8c4cb5 --- /dev/null +++ b/frontend/src/components/data/Skeleton.tsx @@ -0,0 +1,31 @@ +import { cn } from '$/lib/cn'; + +interface SkeletonProps { + className?: string; + 'aria-hidden'?: boolean; +} + +export function Skeleton({ className, 'aria-hidden': ariaHidden = true }: SkeletonProps) { + return ( +
+ ); +} + +/** A full skeleton table row with N cells. */ +export function SkeletonRow({ cells = 7 }: { cells?: number }) { + return ( + + {Array.from({ length: cells }).map((_, i) => ( + + + + ))} + + ); +} diff --git a/frontend/src/components/data/SortableHeader.tsx b/frontend/src/components/data/SortableHeader.tsx new file mode 100644 index 00000000..2e9d7c8c --- /dev/null +++ b/frontend/src/components/data/SortableHeader.tsx @@ -0,0 +1,77 @@ +import { cn } from '$/lib/cn'; +import type { SortDir } from '$/api/types'; + +interface SortableHeaderProps { + column: T; + label: string; + currentSort: T | undefined; + currentDir: SortDir | undefined; + defaultDir?: SortDir; + onSortChange: (column: T, dir: SortDir) => void; + className?: string; +} + +export function SortableHeader({ + column, + label, + currentSort, + currentDir, + defaultDir, + onSortChange, + className, +}: SortableHeaderProps) { + const isActive = currentSort === column; + + function handleClick() { + if (isActive) { + onSortChange(column, currentDir === 'asc' ? 'desc' : 'asc'); + } else { + onSortChange(column, defaultDir ?? 'asc'); + } + } + + return ( + + + + ); +} diff --git a/frontend/src/components/data/Sparkline.tsx b/frontend/src/components/data/Sparkline.tsx new file mode 100644 index 00000000..24c15ced --- /dev/null +++ b/frontend/src/components/data/Sparkline.tsx @@ -0,0 +1,63 @@ +/** + * Sparkline — tiny inline SVG line chart, no library dependency. + * Per brand guide §08: 22px tall by default, no axes, no labels. + */ + +interface SparklineProps { + points: number[]; + color?: string; + width?: number; + height?: number; + strokeWidth?: number; +} + +export function Sparkline({ + points, + color = 'currentColor', + width = 80, + height = 22, + strokeWidth = 1.5, +}: SparklineProps) { + if (points.length < 2) return null; + + const min = Math.min(...points); + const max = Math.max(...points); + const range = max - min || 1; // avoid division by zero for flat lines + + const pad = strokeWidth; + const innerW = width - pad * 2; + const innerH = height - pad * 2; + + const toX = (i: number) => pad + (i / (points.length - 1)) * innerW; + const toY = (v: number) => pad + (1 - (v - min) / range) * innerH; + + const d = points + .map((v, i) => `${i === 0 ? 'M' : 'L'} ${toX(i).toFixed(2)} ${toY(v).toFixed(2)}`) + .join(' '); + + return ( + + `${toX(i).toFixed(2)},${toY(v).toFixed(2)}`) + .join(' ')} + fill="none" + stroke={color} + strokeWidth={strokeWidth} + strokeLinecap="round" + strokeLinejoin="round" + // Fallback via explicit d attribute is not needed; polyline is sufficient. + // Using polyline instead of path for simplicity. + // The `d` variable above is kept for potential future path-fill variant. + data-sparkline-path={d} + /> + + ); +} diff --git a/frontend/src/components/data/StatCard.test.tsx b/frontend/src/components/data/StatCard.test.tsx new file mode 100644 index 00000000..96ca23f6 --- /dev/null +++ b/frontend/src/components/data/StatCard.test.tsx @@ -0,0 +1,85 @@ +import { describe, it, expect } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { StatCard } from './StatCard'; + +describe('StatCard', () => { + it('renders the label', () => { + render(); + expect(screen.getByText('Active Nodes')).toBeInTheDocument(); + }); + + it('renders the value', () => { + render(); + // toLocaleString may format 42 as "42" in all locales + expect(screen.getByText('42')).toBeInTheDocument(); + }); + + it('renders large numbers with locale formatting', () => { + render(); + // toLocaleString('en-US') renders 1234 as "1,234" + // jsdom uses 'en-US' by default in the test environment + const el = screen.getByText(/1.?234/); + expect(el).toBeInTheDocument(); + }); + + it('renders string values directly', () => { + render(); + expect(screen.getByText('5.11.0')).toBeInTheDocument(); + }); + + it('renders the trend chip when trend is provided', () => { + render(); + expect(screen.getByText('2.3%')).toBeInTheDocument(); + // Arrow for "up" + expect(screen.getByText('↑')).toBeInTheDocument(); + }); + + it('does not render the trend chip when trend is omitted', () => { + render(); + expect(screen.queryByText('↑')).not.toBeInTheDocument(); + expect(screen.queryByText('↓')).not.toBeInTheDocument(); + expect(screen.queryByText('→')).not.toBeInTheDocument(); + }); + + it('renders trend down arrow', () => { + render(); + expect(screen.getByText('↓')).toBeInTheDocument(); + }); + + it('renders the sparkline svg when sparkline prop is provided', () => { + const { container } = render( + , + ); + const svg = container.querySelector('svg[aria-hidden]'); + expect(svg).not.toBeNull(); + }); + + it('does not render sparkline when sparkline prop is omitted', () => { + const { container } = render(); + // The card itself has no aria-hidden svg (Logo is not used here) + const sparklineSvg = container.querySelector('polyline'); + expect(sparklineSvg).toBeNull(); + }); + + it('renders a custom visualization when provided', () => { + render( + custom
} + />, + ); + expect(screen.getByTestId('custom-viz')).toBeInTheDocument(); + }); + + it('renders the sublabel when provided', () => { + render(); + expect(screen.getByText('last 24h')).toBeInTheDocument(); + }); + + it('applies the halo class via inline style', () => { + const { container } = render(); + const card = container.firstElementChild as HTMLElement; + expect(card.style.background).toContain('rgba(var(--warning-r), var(--warning-g), var(--warning-b)'); + }); +}); diff --git a/frontend/src/components/data/StatCard.tsx b/frontend/src/components/data/StatCard.tsx new file mode 100644 index 00000000..f079e5ce --- /dev/null +++ b/frontend/src/components/data/StatCard.tsx @@ -0,0 +1,129 @@ +/** + * StatCard — KPI card with halo backdrop, optional sparkline, optional trend chip. + * Matches the brand guide §08 "Status & data viz" KPI card conventions. + */ + +import { cn } from '$/lib/cn'; +import { Sparkline } from './Sparkline'; + +export type HaloVariant = 'signal' | 'success' | 'warning' | 'danger' | 'info'; +export type TrendDirection = 'up' | 'down' | 'flat'; + +// CSS variable references for each semantic color pair (RGB components for halo). +const haloVars: Record = { + signal: 'rgba(var(--halo-r), var(--halo-g), var(--halo-b), 0.15)', + success: 'rgba(var(--success-r), var(--success-g), var(--success-b), 0.14)', + warning: 'rgba(var(--warning-r), var(--warning-g), var(--warning-b), 0.14)', + danger: 'rgba(var(--danger-r), var(--danger-g), var(--danger-b), 0.14)', + info: 'rgba(var(--info-r), var(--info-g), var(--info-b), 0.14)', +}; + +const sparklineColors: Record = { + signal: 'var(--signal)', + success: 'var(--success)', + warning: 'var(--warning)', + danger: 'var(--danger)', + info: 'var(--info)', +}; + +const trendColors: Record = { + up: 'text-[color:var(--success)]', + down: 'text-[color:var(--danger)]', + flat: 'text-[color:var(--text-3)]', +}; + +const trendArrows: Record = { + up: '↑', + down: '↓', + flat: '→', +}; + +interface StatCardProps { + label: string; + value: number | string; + /** Optional sub-label rendered below the value. */ + sublabel?: string; + trend?: TrendDirection; + trendValue?: string; + sparkline?: number[]; + halo?: HaloVariant; + className?: string; + /** Custom visualization to render in place of the sparkline area. */ + visualization?: React.ReactNode; +} + +export function StatCard({ + label, + value, + sublabel, + trend, + trendValue, + sparkline, + halo = 'signal', + className, + visualization, +}: StatCardProps) { + const halosStyle: React.CSSProperties = { + background: `radial-gradient(ellipse at top left, ${haloVars[halo]} 0%, transparent 70%), var(--bg-1)`, + }; + + return ( +
+ {/* Label */} +
+ {label} +
+ + {/* Value */} +
+ {typeof value === 'number' ? value.toLocaleString() : value} +
+ + {/* Sub-label */} + {sublabel && ( +
{sublabel}
+ )} + + {/* Trend chip */} + {trend && ( +
+ {trendArrows[trend]} + {trendValue && {trendValue}} +
+ )} + + {/* Sparkline or custom visualization */} + {(sparkline || visualization) && ( +
+ {visualization ?? ( + sparkline && ( + + ) + )} +
+ )} +
+ ); +} diff --git a/frontend/src/components/data/StatusBadge.tsx b/frontend/src/components/data/StatusBadge.tsx new file mode 100644 index 00000000..33f50bda --- /dev/null +++ b/frontend/src/components/data/StatusBadge.tsx @@ -0,0 +1,39 @@ +import type { LucideIcon } from 'lucide-react'; +import { cn } from '$/lib/cn'; +import { StatusPip, type PipVariant } from './StatusPip'; + +interface StatusBadgeProps { + variant: PipVariant; + label: string; + Icon?: LucideIcon; + live?: boolean; + className?: string; +} + +const variantTextClasses: Record = { + success: 'text-[color:var(--success)]', + warning: 'text-[color:var(--warning)]', + danger: 'text-[color:var(--danger)]', + info: 'text-[color:var(--info)]', + signal: 'text-[color:var(--signal)]', + dim: 'text-[color:var(--text-3)]', +}; + +export function StatusBadge({ variant, label, Icon, live, className }: StatusBadgeProps) { + return ( + + {Icon ? ( + + ) : ( + + )} + {label} + + ); +} diff --git a/frontend/src/components/data/StatusPip.tsx b/frontend/src/components/data/StatusPip.tsx new file mode 100644 index 00000000..97b22737 --- /dev/null +++ b/frontend/src/components/data/StatusPip.tsx @@ -0,0 +1,42 @@ +import { cn } from '$/lib/cn'; + +export type PipVariant = 'success' | 'warning' | 'danger' | 'info' | 'signal' | 'dim'; + +interface StatusPipProps { + variant: PipVariant; + live?: boolean; + className?: string; +} + +const variantClasses: Record = { + success: 'bg-[color:var(--success)] dark:shadow-[0_0_8px_rgba(74,222,128,0.5)]', + warning: 'bg-[color:var(--warning)] dark:shadow-[0_0_8px_rgba(251,191,36,0.5)]', + danger: 'bg-[color:var(--danger)] dark:shadow-[0_0_8px_rgba(248,113,113,0.5)]', + info: 'bg-[color:var(--info)] dark:shadow-[0_0_8px_rgba(103,192,255,0.5)]', + signal: 'bg-[color:var(--signal)] shadow-[0_0_10px_var(--signal-glow)]', + dim: 'bg-[color:var(--text-3)]', +}; + +const variantLabels: Record = { + success: 'active', + warning: 'degraded', + danger: 'offline', + info: 'info', + signal: 'live', + dim: 'inactive', +}; + +export function StatusPip({ variant, live = false, className }: StatusPipProps) { + return ( + + ); +} diff --git a/frontend/src/components/data/StatusTabs.tsx b/frontend/src/components/data/StatusTabs.tsx new file mode 100644 index 00000000..19d20d9e --- /dev/null +++ b/frontend/src/components/data/StatusTabs.tsx @@ -0,0 +1,67 @@ +import type { KeyboardEvent } from 'react'; +import { cn } from '$/lib/cn'; + +export interface StatusTab { + value: T; + label: string; +} + +interface StatusTabsProps { + tabs: StatusTab[]; + value: T; + onChange: (value: T) => void; + className?: string; +} + +/** + * Segmented tab bar for status filtering (All / Active / Completed / etc.). + * Reusable across Queries, Nodes, Carves, and any tracked-list page. + */ +export function StatusTabs({ + tabs, + value, + onChange, + className, +}: StatusTabsProps) { + function handleKeyDown(e: KeyboardEvent) { + if (e.key !== 'ArrowRight' && e.key !== 'ArrowLeft') return; + const idx = tabs.findIndex((t) => t.value === value); + if (idx < 0) return; + const delta = e.key === 'ArrowRight' ? 1 : -1; + const nextIdx = (idx + delta + tabs.length) % tabs.length; + e.preventDefault(); + onChange(tabs[nextIdx].value); + } + + return ( +
+ {tabs.map((tab) => ( + + ))} +
+ ); +} diff --git a/frontend/src/components/feedback/ModalShell.tsx b/frontend/src/components/feedback/ModalShell.tsx new file mode 100644 index 00000000..a6745d52 --- /dev/null +++ b/frontend/src/components/feedback/ModalShell.tsx @@ -0,0 +1,140 @@ +import { useEffect, useRef } from 'react'; +import { cn } from '$/lib/cn'; + +// --------------------------------------------------------------------------- +// Modal shell — lightweight, accessibility-focused dialog primitive. +// +// - role="dialog" + aria-modal + aria-labelledby={titleId} +// - Escape closes; click on the backdrop closes +// - First form control (input/select/textarea) is focused on open. The +// header close button is intentionally skipped so users land on the +// primary interaction; if no form control exists, the close button +// (first focusable) gets focus instead. +// - Tab cycles within the dialog (wraps both ways). +// - When the modal unmounts, focus returns to whatever was active when +// it opened (focus restoration). +// +// Modals that have multiple dialogs in the same tree must pass distinct +// titleId values; the value becomes the

for the title element and +// is referenced by aria-labelledby. +// --------------------------------------------------------------------------- +const FOCUSABLE_SELECTOR = + 'input:not([disabled]):not([type="hidden"]), select:not([disabled]), ' + + 'textarea:not([disabled]), button:not([disabled]), ' + + 'a[href], [tabindex]:not([tabindex="-1"])'; + +export interface ModalShellProps { + title: string; + /** Unique id used as the

and the dialog's aria-labelledby target. */ + titleId: string; + onClose: () => void; + children: React.ReactNode; + /** Optional tailwind class for the inner panel — defaults to max-w-2xl. */ + panelClassName?: string; +} + +export function ModalShell({ + title, + titleId, + onClose, + children, + panelClassName, +}: ModalShellProps) { + const ref = useRef(null); + + useEffect(() => { + const previouslyFocused = document.activeElement as HTMLElement | null; + + function focusable(): HTMLElement[] { + if (!ref.current) return []; + return Array.from(ref.current.querySelectorAll(FOCUSABLE_SELECTOR)); + } + + function onKey(e: KeyboardEvent) { + if (e.key === 'Escape') { + onClose(); + return; + } + if (e.key === 'Tab') { + const all = focusable(); + if (all.length === 0) { + e.preventDefault(); + return; + } + const first = all[0]; + const last = all[all.length - 1]; + const active = document.activeElement as HTMLElement | null; + if (e.shiftKey) { + if (active === first || !ref.current?.contains(active)) { + e.preventDefault(); + last.focus(); + } + } else { + if (active === last || !ref.current?.contains(active)) { + e.preventDefault(); + first.focus(); + } + } + } + } + + document.addEventListener('keydown', onKey); + + const all = focusable(); + const firstField = all.find((el) => { + const tag = el.tagName.toLowerCase(); + return tag === 'input' || tag === 'select' || tag === 'textarea'; + }); + (firstField ?? all[0])?.focus(); + + return () => { + document.removeEventListener('keydown', onKey); + previouslyFocused?.focus?.(); + }; + }, [onClose]); + + return ( +
+
+
+
+

+ {title} +

+ +
+
{children}
+
+
+ ); +} + +export default ModalShell; diff --git a/frontend/src/components/forms/CodeEditor.test.tsx b/frontend/src/components/forms/CodeEditor.test.tsx new file mode 100644 index 00000000..c57e41d8 --- /dev/null +++ b/frontend/src/components/forms/CodeEditor.test.tsx @@ -0,0 +1,50 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { Suspense } from 'react'; +import { CodeEditor } from './CodeEditor'; + +// Monaco Editor is lazy-loaded and is not available in the jsdom environment. +// We mock the module so the Suspense fallback renders cleanly in tests. +vi.mock('@monaco-editor/react', () => ({ + Editor: ({ value }: { value: string }) => ( +
+ ), +})); + +describe('CodeEditor', () => { + it('renders without crashing', () => { + render( + Loading…
}> + + , + ); + // Either the editor renders (mock resolved) or the fallback is shown. + // Both are valid — we just assert no uncaught error. + expect(document.body).toBeTruthy(); + }); + + it('shows the loading fallback while the lazy chunk is pending', () => { + // With the mock in place the lazy import resolves synchronously, so the + // editor itself renders. We verify the mock editor renders with the value. + render( + Loading editor…
}> + + , + ); + // The mock renders synchronously via the vi.mock above. + const editor = screen.queryByTestId('monaco-editor'); + if (editor) { + expect(editor.getAttribute('data-value')).toBe('SELECT * FROM processes;'); + } + }); + + it('accepts a readOnly prop without errors', () => { + expect(() => + render( + + + , + ), + ).not.toThrow(); + }); +}); diff --git a/frontend/src/components/forms/CodeEditor.tsx b/frontend/src/components/forms/CodeEditor.tsx new file mode 100644 index 00000000..2f56a9d1 --- /dev/null +++ b/frontend/src/components/forms/CodeEditor.tsx @@ -0,0 +1,94 @@ +/** + * CodeEditor — Monaco wrapper, lazy-loaded so the Monaco chunk (~3 MB) only + * loads on pages that use it. The initial bundle stays small. + * + * Props: + * value - current editor content + * onChange - called on every edit + * language - Monaco language id (default: 'sql') + * height - CSS height string (default: '240px') + * readOnly - if true the editor is not editable + */ +import { lazy, Suspense } from 'react'; +import { cn } from '$/lib/cn'; + +// Lazy-load the Monaco wrapper so the 3 MB chunk is never included in the +// initial bundle. Vite automatically code-splits at the dynamic import boundary. +const MonacoEditor = lazy(() => + import('@monaco-editor/react').then((m) => ({ default: m.Editor })), +); + +interface CodeEditorProps { + value: string; + onChange?: (value: string) => void; + language?: string; + height?: string; + readOnly?: boolean; + className?: string; + /** ID of an external