From 07381e19066a9bbdbdc43a3234c8631ddc7c6746 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADctor=20Cuevas?= Date: Wed, 4 Mar 2026 23:08:40 +0100 Subject: [PATCH 01/36] feat: scaffold Go backend with config, SQLite, and SPA serving - Entry point with YAML config, --config flag, and env var overrides - SQLite store with migration framework (instances, users, audit_log, sessions) - HTTP server with security headers (CSP, X-Frame-Options, etc.) - SPA handler with embed.FS for single-binary distribution - Makefile targets: build-admin, run-admin, build-admin-dev --- .gitignore | 11 ++ Makefile | 63 ++++++- admin/cmd/chaperone-admin/main.go | 117 +++++++++++++ admin/config/config.go | 83 +++++++++ admin/config/config_test.go | 126 ++++++++++++++ admin/config/loader.go | 155 +++++++++++++++++ admin/config/loader_test.go | 279 ++++++++++++++++++++++++++++++ admin/config/validate.go | 61 +++++++ admin/config/validate_test.go | 233 +++++++++++++++++++++++++ admin/embed.go | 28 +++ admin/embed_dev.go | 25 +++ admin/go.mod | 21 +++ admin/go.sum | 57 ++++++ admin/server.go | 117 +++++++++++++ admin/server_test.go | 156 +++++++++++++++++ admin/store/migrations.go | 121 +++++++++++++ admin/store/store.go | 64 +++++++ admin/store/store_test.go | 137 +++++++++++++++ 18 files changed, 1849 insertions(+), 5 deletions(-) create mode 100644 admin/cmd/chaperone-admin/main.go create mode 100644 admin/config/config.go create mode 100644 admin/config/config_test.go create mode 100644 admin/config/loader.go create mode 100644 admin/config/loader_test.go create mode 100644 admin/config/validate.go create mode 100644 admin/config/validate_test.go create mode 100644 admin/embed.go create mode 100644 admin/embed_dev.go create mode 100644 admin/go.mod create mode 100644 admin/go.sum create mode 100644 admin/server.go create mode 100644 admin/server_test.go create mode 100644 admin/store/migrations.go create mode 100644 admin/store/store.go create mode 100644 admin/store/store_test.go diff --git a/.gitignore b/.gitignore index 5c93ecc..f0ba93c 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,8 @@ /dist/ /chaperone /chaperone-onboard +/chaperone-admin +admin/chaperone-admin # Test binary, built with `go test -c` *.test @@ -65,3 +67,12 @@ test/load/results/ # PID files for background processes .target-server.pid + +# SQLite database artifacts +*.db +*.db-shm +*.db-wal + +# Admin portal frontend +admin/ui/node_modules/ +admin/ui/dist/ diff --git a/Makefile b/Makefile index ddfbb31..8b2c57d 100644 --- a/Makefile +++ b/Makefile @@ -87,6 +87,46 @@ clean: ## Remove build artifacts @rm -rf $(BUILD_DIR) @rm -f coverage.out coverage.html +# ============================================================================ +# Admin Portal +# ============================================================================ + +ADMIN_BINARY_NAME := chaperone-admin +ADMIN_MODULE_DIR := admin +ADMIN_CMD_PATH := ./cmd/chaperone-admin +ADMIN_UI_DIR := admin/ui + +ADMIN_LDFLAGS := -ldflags "-s -w \ + -X main.Version=$(VERSION) \ + -X main.GitCommit=$(GIT_COMMIT) \ + -X main.BuildDate=$(BUILD_DATE)" + +ADMIN_LDFLAGS_DEV := -ldflags "\ + -X main.Version=$(VERSION)-dev \ + -X main.GitCommit=$(GIT_COMMIT) \ + -X main.BuildDate=$(BUILD_DATE)" + +.PHONY: build-admin +build-admin: build-admin-ui ## Build the admin portal binary (production) + @echo "Building $(ADMIN_BINARY_NAME)..." + @mkdir -p $(BUILD_DIR) + cd $(ADMIN_MODULE_DIR) && CGO_ENABLED=0 go build $(ADMIN_LDFLAGS) -o ../$(BUILD_DIR)/$(ADMIN_BINARY_NAME) $(ADMIN_CMD_PATH) + +.PHONY: build-admin-dev +build-admin-dev: ## Build admin portal for development (no UI build needed) + @echo "Building $(ADMIN_BINARY_NAME) (development)..." + @mkdir -p $(BUILD_DIR) + cd $(ADMIN_MODULE_DIR) && go build -tags dev $(ADMIN_LDFLAGS_DEV) -o ../$(BUILD_DIR)/$(ADMIN_BINARY_NAME) $(ADMIN_CMD_PATH) + +.PHONY: build-admin-ui +build-admin-ui: ## Build the admin portal SPA + @echo "Building admin UI..." + cd $(ADMIN_UI_DIR) && pnpm install && pnpm build + +.PHONY: run-admin +run-admin: build-admin-dev ## Build and run admin portal + @$(BUILD_DIR)/$(ADMIN_BINARY_NAME) + # ============================================================================ # Development Certificates # ============================================================================ @@ -104,24 +144,28 @@ test: ## Run tests (all modules) go test -v ./... cd sdk && go test -v ./... cd plugins/contrib && go test -v ./... + cd admin && go test -v ./... .PHONY: test-race test-race: ## Run tests with race detector go test -race -v ./... cd sdk && go test -race -v ./... cd plugins/contrib && go test -race -v ./... + cd admin && go test -race -v ./... .PHONY: test-cover test-cover: ## Run tests with coverage go test -coverprofile=coverage.out ./... cd sdk && go test -coverprofile=coverage-sdk.out ./... cd plugins/contrib && go test -coverprofile=coverage-contrib.out ./... + cd admin && go test -coverprofile=coverage-admin.out ./... go tool cover -html=coverage.out -o coverage.html @echo "Coverage report: coverage.html" .PHONY: test-short test-short: ## Run short tests only go test -short -v ./... + cd admin && go test -short -v ./... .PHONY: test-integration test-integration: ## Run integration tests @@ -254,7 +298,8 @@ lint: ## Run linters (all modules) @if [ -x "$(GOLANGCI_LINT)" ]; then \ $(GOLANGCI_LINT) run && \ (cd sdk && $(GOLANGCI_LINT) run) && \ - (cd plugins/contrib && $(GOLANGCI_LINT) run); \ + (cd plugins/contrib && $(GOLANGCI_LINT) run) && \ + (cd admin && $(GOLANGCI_LINT) run); \ else \ echo "golangci-lint not installed. Run: make tools"; \ exit 1; \ @@ -265,6 +310,7 @@ lint-fix: ## Run linters and fix issues $(GOLANGCI_LINT) run --fix cd sdk && $(GOLANGCI_LINT) run --fix cd plugins/contrib && $(GOLANGCI_LINT) run --fix + cd admin && $(GOLANGCI_LINT) run --fix .PHONY: fmt fmt: ## Format code (all modules) @@ -274,12 +320,15 @@ fmt: ## Format code (all modules) cd sdk && gofmt -s -w . cd plugins/contrib && go fmt ./... cd plugins/contrib && gofmt -s -w . + cd admin && go fmt ./... + cd admin && gofmt -s -w . .PHONY: vet -vet: ## Run go vet +vet: ## Run go vet (all modules) go vet ./... cd sdk && go vet ./... cd plugins/contrib && go vet ./... + cd admin && go vet ./... .PHONY: tidy tidy: ## Tidy and verify go.mod (all modules) @@ -287,12 +336,14 @@ tidy: ## Tidy and verify go.mod (all modules) go mod verify cd sdk && go mod tidy cd plugins/contrib && go mod tidy && go mod verify + cd admin && go mod tidy .PHONY: gosec gosec: ## Run gosec security scanner (all modules) @if [ -x "$(GOSEC)" ]; then \ $(GOSEC) -exclude=G706 -exclude-dir=sdk -exclude-dir=plugins ./... && \ - (cd sdk && $(GOSEC) ./...); \ + (cd sdk && $(GOSEC) ./...) && \ + (cd admin && $(GOSEC) ./...); \ else \ echo "gosec not installed. Run: make tools"; \ exit 1; \ @@ -302,7 +353,8 @@ gosec: ## Run gosec security scanner (all modules) govulncheck: ## Run govulncheck vulnerability scanner (all modules) @if [ -x "$(GOVULNCHECK)" ]; then \ $(GOVULNCHECK) ./... && \ - (cd sdk && $(GOVULNCHECK) ./...); \ + (cd sdk && $(GOVULNCHECK) ./...) && \ + (cd admin && $(GOVULNCHECK) ./...); \ else \ echo "govulncheck not installed. Run: make tools"; \ exit 1; \ @@ -324,7 +376,8 @@ ADDLICENSE_FLAGS := -f .copyright-header.tmpl \ -ignore 'bin/**' \ -ignore 'certs/**' \ -ignore '.ai/**' \ - -ignore '.claude/**' + -ignore '.claude/**' \ + -ignore 'admin/ui/**' .PHONY: license-check license-check: ## Check that all source files have copyright headers diff --git a/admin/cmd/chaperone-admin/main.go b/admin/cmd/chaperone-admin/main.go new file mode 100644 index 0000000..3875377 --- /dev/null +++ b/admin/cmd/chaperone-admin/main.go @@ -0,0 +1,117 @@ +// Copyright 2026 CloudBlue LLC +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "context" + "flag" + "errors" + "fmt" + "log/slog" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "github.com/cloudblue/chaperone/admin" + "github.com/cloudblue/chaperone/admin/config" + "github.com/cloudblue/chaperone/admin/store" +) + +var ( + Version = "dev" + GitCommit = "unknown" + BuildDate = "unknown" +) + +func main() { + if err := run(); err != nil { + fmt.Fprintf(os.Stderr, "error: %v\n", err) + os.Exit(1) + } +} + +func run() error { + configPath := flag.String("config", "", "Path to config file (default: chaperone-admin.yaml)") + showVersion := flag.Bool("version", false, "Print version and exit") + flag.Parse() + + if *showVersion { + fmt.Printf("chaperone-admin %s (commit: %s, built: %s)\n", Version, GitCommit, BuildDate) + return nil + } + + cfg, err := config.Load(*configPath) + if err != nil { + return fmt.Errorf("loading configuration: %w", err) + } + + configureLogging(cfg) + + slog.Info("starting chaperone-admin", + "version", Version, + "commit", GitCommit, + "built", BuildDate, + ) + + st, err := store.Open(cfg.Database.Path) + if err != nil { + return fmt.Errorf("opening database: %w", err) + } + defer st.Close() + + srv, err := admin.NewServer(cfg, st) + if err != nil { + return fmt.Errorf("creating server: %w", err) + } + + ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer stop() + + errCh := make(chan error, 1) + go func() { + slog.Info("listening", "addr", cfg.Server.Addr) + errCh <- srv.ListenAndServe() + }() + + select { + case err := <-errCh: + if errors.Is(err, http.ErrServerClosed) { + return nil + } + return fmt.Errorf("HTTP server error: %w", err) + case <-ctx.Done(): + slog.Info("shutting down") + shutdownCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + if err := srv.Shutdown(shutdownCtx); err != nil { + return fmt.Errorf("shutting down server: %w", err) + } + return nil + } +} + +func configureLogging(cfg *config.Config) { + var level slog.Level + switch cfg.Log.Level { + case "debug": + level = slog.LevelDebug + case "warn": + level = slog.LevelWarn + case "error": + level = slog.LevelError + default: + level = slog.LevelInfo + } + + opts := &slog.HandlerOptions{Level: level} + var handler slog.Handler + if cfg.Log.Format == "text" { + handler = slog.NewTextHandler(os.Stdout, opts) + } else { + handler = slog.NewJSONHandler(os.Stdout, opts) + } + slog.SetDefault(slog.New(handler)) +} diff --git a/admin/config/config.go b/admin/config/config.go new file mode 100644 index 0000000..2d9d0db --- /dev/null +++ b/admin/config/config.go @@ -0,0 +1,83 @@ +// Copyright 2026 CloudBlue LLC +// SPDX-License-Identifier: Apache-2.0 + +package config + +import ( + "fmt" + "time" + + "gopkg.in/yaml.v3" +) + +// EnvPrefix is the environment variable prefix for admin portal configuration. +const EnvPrefix = "CHAPERONE_ADMIN" + +// Config holds the admin portal configuration. +type Config struct { + Server ServerConfig `yaml:"server"` + Database DatabaseConfig `yaml:"database"` + Scraper ScraperConfig `yaml:"scraper"` + Session SessionConfig `yaml:"session"` + Audit AuditConfig `yaml:"audit"` + Log LogConfig `yaml:"log"` +} + +// ServerConfig configures the HTTP server. +type ServerConfig struct { + Addr string `yaml:"addr"` +} + +// DatabaseConfig configures the SQLite database. +type DatabaseConfig struct { + Path string `yaml:"path"` +} + +// ScraperConfig configures the proxy metrics scraper. +type ScraperConfig struct { + Interval Duration `yaml:"interval"` + Timeout Duration `yaml:"timeout"` +} + +// SessionConfig configures session management. +type SessionConfig struct { + MaxAge Duration `yaml:"max_age"` + IdleTimeout Duration `yaml:"idle_timeout"` +} + +// AuditConfig configures the audit log. +type AuditConfig struct { + RetentionDays int `yaml:"retention_days"` +} + +// LogConfig configures structured logging. +type LogConfig struct { + Level string `yaml:"level"` + Format string `yaml:"format"` +} + +// Duration is a time.Duration that unmarshals from YAML duration strings +// like "10s", "5m", "24h". +type Duration time.Duration + +// Unwrap returns the underlying time.Duration. +func (d Duration) Unwrap() time.Duration { + return time.Duration(d) +} + +func (d Duration) String() string { + return time.Duration(d).String() +} + +func (d *Duration) UnmarshalYAML(node *yaml.Node) error { + dur, err := time.ParseDuration(node.Value) + if err != nil { + return fmt.Errorf("invalid duration %q: %w", node.Value, err) + } + *d = Duration(dur) + return nil +} + +func (d Duration) MarshalYAML() (interface{}, error) { + return time.Duration(d).String(), nil +} diff --git a/admin/config/config_test.go b/admin/config/config_test.go new file mode 100644 index 0000000..065a143 --- /dev/null +++ b/admin/config/config_test.go @@ -0,0 +1,126 @@ +// Copyright 2026 CloudBlue LLC +// SPDX-License-Identifier: Apache-2.0 + +package config + +import ( + "testing" + "time" + + "gopkg.in/yaml.v3" +) + +func TestDuration_UnmarshalYAML_ValidDuration_ParsesCorrectly(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input string + want time.Duration + }{ + {"seconds", `"10s"`, 10 * time.Second}, + {"minutes", `"5m"`, 5 * time.Minute}, + {"hours", `"24h"`, 24 * time.Hour}, + {"milliseconds", `"500ms"`, 500 * time.Millisecond}, + {"compound", `"1h30m"`, 90 * time.Minute}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + // Arrange + var d Duration + + // Act + err := yaml.Unmarshal([]byte(tt.input), &d) + + // Assert + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if d.Unwrap() != tt.want { + t.Errorf("duration = %v, want %v", d.Unwrap(), tt.want) + } + }) + } +} + +func TestDuration_UnmarshalYAML_InvalidDuration_ReturnsError(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input string + }{ + {"no unit", `"10"`}, + {"invalid unit", `"10x"`}, + {"empty", `""`}, + {"text", `"forever"`}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + // Arrange + var d Duration + + // Act + err := yaml.Unmarshal([]byte(tt.input), &d) + + // Assert + if err == nil { + t.Error("expected error, got nil") + } + }) + } +} + +func TestDuration_Unwrap_ReturnsDuration(t *testing.T) { + t.Parallel() + + // Arrange + d := Duration(42 * time.Second) + + // Act + got := d.Unwrap() + + // Assert + if got != 42*time.Second { + t.Errorf("Unwrap() = %v, want %v", got, 42*time.Second) + } +} + +func TestDuration_String_FormatsCorrectly(t *testing.T) { + t.Parallel() + + // Arrange + d := Duration(90 * time.Second) + + // Act + got := d.String() + + // Assert + if got != "1m30s" { + t.Errorf("String() = %q, want %q", got, "1m30s") + } +} + +func TestDuration_MarshalYAML_FormatsCorrectly(t *testing.T) { + t.Parallel() + + // Arrange + d := Duration(10 * time.Second) + + // Act + got, err := d.MarshalYAML() + + // Assert + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != "10s" { + t.Errorf("MarshalYAML() = %q, want %q", got, "10s") + } +} diff --git a/admin/config/loader.go b/admin/config/loader.go new file mode 100644 index 0000000..400fd37 --- /dev/null +++ b/admin/config/loader.go @@ -0,0 +1,155 @@ +// Copyright 2026 CloudBlue LLC +// SPDX-License-Identifier: Apache-2.0 + +package config + +import ( + "errors" + "fmt" + "os" + "strconv" + "strings" + "time" + + "gopkg.in/yaml.v3" +) + +// Load reads configuration from the given path, applies defaults and +// environment variable overrides, and validates the result. +func Load(path string) (*Config, error) { + path = resolveConfigPath(path) + + cfg := &Config{} + if err := loadYAML(path, cfg); err != nil { + if !errors.Is(err, os.ErrNotExist) { + return nil, fmt.Errorf("reading config %s: %w", path, err) + } + // Config file not found — proceed with defaults + env overrides. + } + + applyDefaults(cfg) + + if err := applyEnvOverrides(cfg); err != nil { + return nil, fmt.Errorf("applying env overrides: %w", err) + } + + if err := cfg.Validate(); err != nil { + return nil, fmt.Errorf("validating config: %w", err) + } + + return cfg, nil +} + +func resolveConfigPath(path string) string { + if path != "" { + return path + } + if v := os.Getenv(EnvPrefix + "_CONFIG"); v != "" { + return v + } + return "chaperone-admin.yaml" +} + +func loadYAML(path string, cfg *Config) error { + data, err := os.ReadFile(path) + if err != nil { + return err + } + if err := yaml.Unmarshal(data, cfg); err != nil { + return fmt.Errorf("parsing YAML: %w", err) + } + return nil +} + +func applyDefaults(cfg *Config) { + if cfg.Server.Addr == "" { + cfg.Server.Addr = "127.0.0.1:8080" + } + if cfg.Database.Path == "" { + cfg.Database.Path = "./chaperone-admin.db" + } + if cfg.Scraper.Interval == 0 { + cfg.Scraper.Interval = Duration(10 * time.Second) + } + if cfg.Scraper.Timeout == 0 { + cfg.Scraper.Timeout = Duration(5 * time.Second) + } + if cfg.Session.MaxAge == 0 { + cfg.Session.MaxAge = Duration(24 * time.Hour) + } + if cfg.Session.IdleTimeout == 0 { + cfg.Session.IdleTimeout = Duration(2 * time.Hour) + } + if cfg.Audit.RetentionDays == 0 { + cfg.Audit.RetentionDays = 90 + } + if cfg.Log.Level == "" { + cfg.Log.Level = "info" + } + if cfg.Log.Format == "" { + cfg.Log.Format = "json" + } +} + +func applyEnvOverrides(cfg *Config) error { + var errs []error + + if v := getEnv("SERVER_ADDR"); v != "" { + cfg.Server.Addr = v + } + if v := getEnv("DATABASE_PATH"); v != "" { + cfg.Database.Path = v + } + if v := getEnv("SCRAPER_INTERVAL"); v != "" { + d, err := time.ParseDuration(v) + if err != nil { + errs = append(errs, fmt.Errorf("SCRAPER_INTERVAL: %w", err)) + } else { + cfg.Scraper.Interval = Duration(d) + } + } + if v := getEnv("SCRAPER_TIMEOUT"); v != "" { + d, err := time.ParseDuration(v) + if err != nil { + errs = append(errs, fmt.Errorf("SCRAPER_TIMEOUT: %w", err)) + } else { + cfg.Scraper.Timeout = Duration(d) + } + } + if v := getEnv("SESSION_MAX_AGE"); v != "" { + d, err := time.ParseDuration(v) + if err != nil { + errs = append(errs, fmt.Errorf("SESSION_MAX_AGE: %w", err)) + } else { + cfg.Session.MaxAge = Duration(d) + } + } + if v := getEnv("SESSION_IDLE_TIMEOUT"); v != "" { + d, err := time.ParseDuration(v) + if err != nil { + errs = append(errs, fmt.Errorf("SESSION_IDLE_TIMEOUT: %w", err)) + } else { + cfg.Session.IdleTimeout = Duration(d) + } + } + if v := getEnv("AUDIT_RETENTION_DAYS"); v != "" { + n, err := strconv.Atoi(v) + if err != nil { + errs = append(errs, fmt.Errorf("AUDIT_RETENTION_DAYS: %w", err)) + } else { + cfg.Audit.RetentionDays = n + } + } + if v := getEnv("LOG_LEVEL"); v != "" { + cfg.Log.Level = strings.ToLower(v) + } + if v := getEnv("LOG_FORMAT"); v != "" { + cfg.Log.Format = strings.ToLower(v) + } + + return errors.Join(errs...) +} + +func getEnv(key string) string { + return os.Getenv(EnvPrefix + "_" + key) +} diff --git a/admin/config/loader_test.go b/admin/config/loader_test.go new file mode 100644 index 0000000..1a8dca6 --- /dev/null +++ b/admin/config/loader_test.go @@ -0,0 +1,279 @@ +// Copyright 2026 CloudBlue LLC +// SPDX-License-Identifier: Apache-2.0 + +package config + +import ( + "os" + "path/filepath" + "testing" + "time" +) + +func writeTestConfig(t *testing.T, content string) string { + t.Helper() + dir := t.TempDir() + path := filepath.Join(dir, "config.yaml") + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatalf("failed to write test config: %v", err) + } + return path +} + +func TestLoad_NoFile_AppliesDefaults(t *testing.T) { + t.Parallel() + + // Arrange — point to a non-existent file + path := filepath.Join(t.TempDir(), "nonexistent.yaml") + + // Act + cfg, err := Load(path) + + // Assert + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if cfg.Server.Addr != "127.0.0.1:8080" { + t.Errorf("Server.Addr = %q, want %q", cfg.Server.Addr, "127.0.0.1:8080") + } + if cfg.Database.Path != "./chaperone-admin.db" { + t.Errorf("Database.Path = %q, want %q", cfg.Database.Path, "./chaperone-admin.db") + } + if cfg.Scraper.Interval.Unwrap() != 10*time.Second { + t.Errorf("Scraper.Interval = %v, want %v", cfg.Scraper.Interval.Unwrap(), 10*time.Second) + } + if cfg.Scraper.Timeout.Unwrap() != 5*time.Second { + t.Errorf("Scraper.Timeout = %v, want %v", cfg.Scraper.Timeout.Unwrap(), 5*time.Second) + } + if cfg.Session.MaxAge.Unwrap() != 24*time.Hour { + t.Errorf("Session.MaxAge = %v, want %v", cfg.Session.MaxAge.Unwrap(), 24*time.Hour) + } + if cfg.Session.IdleTimeout.Unwrap() != 2*time.Hour { + t.Errorf("Session.IdleTimeout = %v, want %v", cfg.Session.IdleTimeout.Unwrap(), 2*time.Hour) + } + if cfg.Audit.RetentionDays != 90 { + t.Errorf("Audit.RetentionDays = %d, want %d", cfg.Audit.RetentionDays, 90) + } + if cfg.Log.Level != "info" { + t.Errorf("Log.Level = %q, want %q", cfg.Log.Level, "info") + } + if cfg.Log.Format != "json" { + t.Errorf("Log.Format = %q, want %q", cfg.Log.Format, "json") + } +} + +func TestLoad_ValidYAML_ParsesAllFields(t *testing.T) { + t.Parallel() + + // Arrange + path := writeTestConfig(t, ` +server: + addr: "0.0.0.0:9090" +database: + path: "/var/lib/admin.db" +scraper: + interval: "30s" + timeout: "10s" +session: + max_age: "12h" + idle_timeout: "1h" +audit: + retention_days: 30 +log: + level: "debug" + format: "text" +`) + + // Act + cfg, err := Load(path) + + // Assert + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if cfg.Server.Addr != "0.0.0.0:9090" { + t.Errorf("Server.Addr = %q, want %q", cfg.Server.Addr, "0.0.0.0:9090") + } + if cfg.Database.Path != "/var/lib/admin.db" { + t.Errorf("Database.Path = %q, want %q", cfg.Database.Path, "/var/lib/admin.db") + } + if cfg.Scraper.Interval.Unwrap() != 30*time.Second { + t.Errorf("Scraper.Interval = %v, want %v", cfg.Scraper.Interval.Unwrap(), 30*time.Second) + } + if cfg.Scraper.Timeout.Unwrap() != 10*time.Second { + t.Errorf("Scraper.Timeout = %v, want %v", cfg.Scraper.Timeout.Unwrap(), 10*time.Second) + } + if cfg.Session.MaxAge.Unwrap() != 12*time.Hour { + t.Errorf("Session.MaxAge = %v, want %v", cfg.Session.MaxAge.Unwrap(), 12*time.Hour) + } + if cfg.Session.IdleTimeout.Unwrap() != 1*time.Hour { + t.Errorf("Session.IdleTimeout = %v, want %v", cfg.Session.IdleTimeout.Unwrap(), 1*time.Hour) + } + if cfg.Audit.RetentionDays != 30 { + t.Errorf("Audit.RetentionDays = %d, want %d", cfg.Audit.RetentionDays, 30) + } + if cfg.Log.Level != "debug" { + t.Errorf("Log.Level = %q, want %q", cfg.Log.Level, "debug") + } + if cfg.Log.Format != "text" { + t.Errorf("Log.Format = %q, want %q", cfg.Log.Format, "text") + } +} + +func TestLoad_EnvOverrides_AllFields(t *testing.T) { + // Not parallel — modifies environment via t.Setenv. + + // Arrange + path := filepath.Join(t.TempDir(), "nonexistent.yaml") + t.Setenv("CHAPERONE_ADMIN_SERVER_ADDR", "0.0.0.0:3000") + t.Setenv("CHAPERONE_ADMIN_DATABASE_PATH", "/tmp/test.db") + t.Setenv("CHAPERONE_ADMIN_SCRAPER_INTERVAL", "20s") + t.Setenv("CHAPERONE_ADMIN_SCRAPER_TIMEOUT", "8s") + t.Setenv("CHAPERONE_ADMIN_SESSION_MAX_AGE", "48h") + t.Setenv("CHAPERONE_ADMIN_SESSION_IDLE_TIMEOUT", "4h") + t.Setenv("CHAPERONE_ADMIN_AUDIT_RETENTION_DAYS", "60") + t.Setenv("CHAPERONE_ADMIN_LOG_LEVEL", "WARN") + t.Setenv("CHAPERONE_ADMIN_LOG_FORMAT", "TEXT") + + // Act + cfg, err := Load(path) + + // Assert + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if cfg.Server.Addr != "0.0.0.0:3000" { + t.Errorf("Server.Addr = %q, want %q", cfg.Server.Addr, "0.0.0.0:3000") + } + if cfg.Database.Path != "/tmp/test.db" { + t.Errorf("Database.Path = %q, want %q", cfg.Database.Path, "/tmp/test.db") + } + if cfg.Scraper.Interval.Unwrap() != 20*time.Second { + t.Errorf("Scraper.Interval = %v, want %v", cfg.Scraper.Interval.Unwrap(), 20*time.Second) + } + if cfg.Scraper.Timeout.Unwrap() != 8*time.Second { + t.Errorf("Scraper.Timeout = %v, want %v", cfg.Scraper.Timeout.Unwrap(), 8*time.Second) + } + if cfg.Session.MaxAge.Unwrap() != 48*time.Hour { + t.Errorf("Session.MaxAge = %v, want %v", cfg.Session.MaxAge.Unwrap(), 48*time.Hour) + } + if cfg.Session.IdleTimeout.Unwrap() != 4*time.Hour { + t.Errorf("Session.IdleTimeout = %v, want %v", cfg.Session.IdleTimeout.Unwrap(), 4*time.Hour) + } + if cfg.Audit.RetentionDays != 60 { + t.Errorf("Audit.RetentionDays = %d, want %d", cfg.Audit.RetentionDays, 60) + } + if cfg.Log.Level != "warn" { + t.Errorf("Log.Level = %q, want %q", cfg.Log.Level, "warn") + } + if cfg.Log.Format != "text" { + t.Errorf("Log.Format = %q, want %q", cfg.Log.Format, "text") + } +} + +func TestLoad_InvalidYAML_ReturnsError(t *testing.T) { + t.Parallel() + + // Arrange + path := writeTestConfig(t, `{{{invalid yaml`) + + // Act + _, err := Load(path) + + // Assert + if err == nil { + t.Error("expected error, got nil") + } +} + +func TestLoad_EnvOverride_InvalidDuration_ReturnsError(t *testing.T) { + // Not parallel — modifies environment. + + // Arrange + path := filepath.Join(t.TempDir(), "nonexistent.yaml") + t.Setenv("CHAPERONE_ADMIN_SCRAPER_INTERVAL", "not-a-duration") + + // Act + _, err := Load(path) + + // Assert + if err == nil { + t.Error("expected error, got nil") + } +} + +func TestApplyDefaults_ZeroConfig_SetsAllDefaults(t *testing.T) { + t.Parallel() + + // Arrange + cfg := &Config{} + + // Act + applyDefaults(cfg) + + // Assert + if cfg.Server.Addr != "127.0.0.1:8080" { + t.Errorf("Server.Addr = %q, want %q", cfg.Server.Addr, "127.0.0.1:8080") + } + if cfg.Database.Path != "./chaperone-admin.db" { + t.Errorf("Database.Path = %q, want %q", cfg.Database.Path, "./chaperone-admin.db") + } + if cfg.Scraper.Interval.Unwrap() != 10*time.Second { + t.Errorf("Scraper.Interval = %v, want 10s", cfg.Scraper.Interval.Unwrap()) + } + if cfg.Scraper.Timeout.Unwrap() != 5*time.Second { + t.Errorf("Scraper.Timeout = %v, want 5s", cfg.Scraper.Timeout.Unwrap()) + } + if cfg.Session.MaxAge.Unwrap() != 24*time.Hour { + t.Errorf("Session.MaxAge = %v, want 24h", cfg.Session.MaxAge.Unwrap()) + } + if cfg.Session.IdleTimeout.Unwrap() != 2*time.Hour { + t.Errorf("Session.IdleTimeout = %v, want 2h", cfg.Session.IdleTimeout.Unwrap()) + } + if cfg.Audit.RetentionDays != 90 { + t.Errorf("Audit.RetentionDays = %d, want 90", cfg.Audit.RetentionDays) + } + if cfg.Log.Level != "info" { + t.Errorf("Log.Level = %q, want %q", cfg.Log.Level, "info") + } + if cfg.Log.Format != "json" { + t.Errorf("Log.Format = %q, want %q", cfg.Log.Format, "json") + } +} + +func TestResolveConfigPath_ExplicitPath_ReturnsSame(t *testing.T) { + t.Parallel() + + // Act + got := resolveConfigPath("/custom/config.yaml") + + // Assert + if got != "/custom/config.yaml" { + t.Errorf("resolveConfigPath() = %q, want %q", got, "/custom/config.yaml") + } +} + +func TestResolveConfigPath_EnvVar_ReturnsEnvValue(t *testing.T) { + // Not parallel — modifies environment. + t.Setenv("CHAPERONE_ADMIN_CONFIG", "/env/config.yaml") + + // Act + got := resolveConfigPath("") + + // Assert + if got != "/env/config.yaml" { + t.Errorf("resolveConfigPath() = %q, want %q", got, "/env/config.yaml") + } +} + +func TestResolveConfigPath_Default_ReturnsDefault(t *testing.T) { + t.Parallel() + + // Act + got := resolveConfigPath("") + + // Assert + if got != "chaperone-admin.yaml" { + t.Errorf("resolveConfigPath() = %q, want %q", got, "chaperone-admin.yaml") + } +} diff --git a/admin/config/validate.go b/admin/config/validate.go new file mode 100644 index 0000000..d78f4e2 --- /dev/null +++ b/admin/config/validate.go @@ -0,0 +1,61 @@ +// Copyright 2026 CloudBlue LLC +// SPDX-License-Identifier: Apache-2.0 + +package config + +import ( + "errors" + "fmt" + "net" + "time" +) + +// Validate checks the configuration for required fields and valid values. +func (c *Config) Validate() error { + var errs []error + + if c.Server.Addr == "" { + errs = append(errs, errors.New("server.addr is required")) + } else if _, _, err := net.SplitHostPort(c.Server.Addr); err != nil { + errs = append(errs, fmt.Errorf("server.addr: %w", err)) + } + + if c.Database.Path == "" { + errs = append(errs, errors.New("database.path is required")) + } + + if c.Scraper.Interval.Unwrap() < 1*time.Second { + errs = append(errs, errors.New("scraper.interval must be at least 1s")) + } + if c.Scraper.Timeout.Unwrap() < 1*time.Second { + errs = append(errs, errors.New("scraper.timeout must be at least 1s")) + } + if c.Scraper.Timeout.Unwrap() >= c.Scraper.Interval.Unwrap() { + errs = append(errs, errors.New("scraper.timeout must be less than scraper.interval")) + } + + if c.Session.MaxAge.Unwrap() < 1*time.Minute { + errs = append(errs, errors.New("session.max_age must be at least 1m")) + } + if c.Session.IdleTimeout.Unwrap() < 1*time.Minute { + errs = append(errs, errors.New("session.idle_timeout must be at least 1m")) + } + + if c.Audit.RetentionDays < 0 { + errs = append(errs, errors.New("audit.retention_days must be non-negative (0 = keep forever)")) + } + + switch c.Log.Level { + case "debug", "info", "warn", "error": + default: + errs = append(errs, fmt.Errorf("log.level: unknown level %q (valid: debug, info, warn, error)", c.Log.Level)) + } + + switch c.Log.Format { + case "json", "text": + default: + errs = append(errs, fmt.Errorf("log.format: unknown format %q (valid: json, text)", c.Log.Format)) + } + + return errors.Join(errs...) +} diff --git a/admin/config/validate_test.go b/admin/config/validate_test.go new file mode 100644 index 0000000..9be91d0 --- /dev/null +++ b/admin/config/validate_test.go @@ -0,0 +1,233 @@ +// Copyright 2026 CloudBlue LLC +// SPDX-License-Identifier: Apache-2.0 + +package config + +import ( + "strings" + "testing" + "time" +) + +// validConfig returns a Config with all fields set to valid values. +// Tests mutate a single field to test specific validation rules. +func validConfig() *Config { + return &Config{ + Server: ServerConfig{Addr: "127.0.0.1:8080"}, + Database: DatabaseConfig{Path: "./test.db"}, + Scraper: ScraperConfig{ + Interval: Duration(10 * time.Second), + Timeout: Duration(5 * time.Second), + }, + Session: SessionConfig{ + MaxAge: Duration(24 * time.Hour), + IdleTimeout: Duration(2 * time.Hour), + }, + Audit: AuditConfig{RetentionDays: 90}, + Log: LogConfig{Level: "info", Format: "json"}, + } +} + +func TestValidate_ValidConfig_NoError(t *testing.T) { + t.Parallel() + + // Arrange + cfg := validConfig() + + // Act + err := cfg.Validate() + + // Assert + if err != nil { + t.Errorf("unexpected error: %v", err) + } +} + +func TestValidate_InvalidAddr_ReturnsError(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + addr string + }{ + {"empty addr", ""}, + {"missing port", "127.0.0.1"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + // Arrange + cfg := validConfig() + cfg.Server.Addr = tt.addr + + // Act + err := cfg.Validate() + + // Assert + if err == nil { + t.Error("expected error, got nil") + } + }) + } +} + +func TestValidate_EmptyDatabasePath_ReturnsError(t *testing.T) { + t.Parallel() + + // Arrange + cfg := validConfig() + cfg.Database.Path = "" + + // Act + err := cfg.Validate() + + // Assert + if err == nil { + t.Error("expected error, got nil") + } +} + +func TestValidate_TimeoutGteInterval_ReturnsError(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + interval time.Duration + timeout time.Duration + }{ + {"timeout equals interval", 10 * time.Second, 10 * time.Second}, + {"timeout exceeds interval", 10 * time.Second, 15 * time.Second}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + // Arrange + cfg := validConfig() + cfg.Scraper.Interval = Duration(tt.interval) + cfg.Scraper.Timeout = Duration(tt.timeout) + + // Act + err := cfg.Validate() + + // Assert + if err == nil { + t.Error("expected error, got nil") + } + if !strings.Contains(err.Error(), "timeout must be less than") { + t.Errorf("error = %q, want to contain %q", err.Error(), "timeout must be less than") + } + }) + } +} + +func TestValidate_NegativeRetention_ReturnsError(t *testing.T) { + t.Parallel() + + // Arrange + cfg := validConfig() + cfg.Audit.RetentionDays = -1 + + // Act + err := cfg.Validate() + + // Assert + if err == nil { + t.Error("expected error, got nil") + } + if !strings.Contains(err.Error(), "retention_days") { + t.Errorf("error = %q, want to contain %q", err.Error(), "retention_days") + } +} + +func TestValidate_ZeroRetention_NoError(t *testing.T) { + t.Parallel() + + // Arrange — 0 means "keep forever" + cfg := validConfig() + cfg.Audit.RetentionDays = 0 + + // Act + err := cfg.Validate() + + // Assert + if err != nil { + t.Errorf("unexpected error: %v", err) + } +} + +func TestValidate_UnknownLogLevel_ReturnsError(t *testing.T) { + t.Parallel() + + // Arrange + cfg := validConfig() + cfg.Log.Level = "trace" + + // Act + err := cfg.Validate() + + // Assert + if err == nil { + t.Error("expected error, got nil") + } + if !strings.Contains(err.Error(), "unknown level") { + t.Errorf("error = %q, want to contain %q", err.Error(), "unknown level") + } +} + +func TestValidate_UnknownLogFormat_ReturnsError(t *testing.T) { + t.Parallel() + + // Arrange + cfg := validConfig() + cfg.Log.Format = "xml" + + // Act + err := cfg.Validate() + + // Assert + if err == nil { + t.Error("expected error, got nil") + } + if !strings.Contains(err.Error(), "unknown format") { + t.Errorf("error = %q, want to contain %q", err.Error(), "unknown format") + } +} + +func TestValidate_MultipleErrors_ReturnsAllErrors(t *testing.T) { + t.Parallel() + + // Arrange — multiple invalid fields + cfg := &Config{ + Server: ServerConfig{Addr: ""}, + Database: DatabaseConfig{Path: ""}, + Scraper: ScraperConfig{ + Interval: Duration(10 * time.Second), + Timeout: Duration(10 * time.Second), + }, + Session: SessionConfig{ + MaxAge: Duration(24 * time.Hour), + IdleTimeout: Duration(2 * time.Hour), + }, + Audit: AuditConfig{RetentionDays: -1}, + Log: LogConfig{Level: "bad", Format: "bad"}, + } + + // Act + err := cfg.Validate() + + // Assert + if err == nil { + t.Fatal("expected error, got nil") + } + msg := err.Error() + checks := []string{"server.addr", "database.path", "timeout must be less than", "retention_days", "unknown level", "unknown format"} + for _, check := range checks { + if !strings.Contains(msg, check) { + t.Errorf("error = %q, want to contain %q", msg, check) + } + } +} diff --git a/admin/embed.go b/admin/embed.go new file mode 100644 index 0000000..1c29866 --- /dev/null +++ b/admin/embed.go @@ -0,0 +1,28 @@ +// Copyright 2026 CloudBlue LLC +// SPDX-License-Identifier: Apache-2.0 + +//go:build !dev + +package admin + +import ( + "embed" + "io/fs" +) + +// uiRawAssets holds the compiled Vue SPA build output. +// +// The ui/dist directory must exist at compile time. Build with: +// +// cd admin/ui && pnpm install && pnpm build +// +// Or use: make build-admin +// +// For development without building the UI, use: go build -tags dev +// +//go:embed all:ui/dist +var uiRawAssets embed.FS + +func loadUIAssets() (fs.FS, error) { + return fs.Sub(uiRawAssets, "ui/dist") +} diff --git a/admin/embed_dev.go b/admin/embed_dev.go new file mode 100644 index 0000000..fd9ffc8 --- /dev/null +++ b/admin/embed_dev.go @@ -0,0 +1,25 @@ +// Copyright 2026 CloudBlue LLC +// SPDX-License-Identifier: Apache-2.0 + +//go:build dev + +package admin + +import ( + "fmt" + "io/fs" + "os" +) + +const devUIDir = "admin/ui/dist" + +// loadUIAssets serves the Vue SPA from the filesystem during development. +// This assumes the working directory is the repository root (e.g., via +// make run-admin). For hot reload, run "pnpm dev" separately and use +// the Vite dev server proxy instead. +func loadUIAssets() (fs.FS, error) { + if _, err := os.Stat(devUIDir); err != nil { + return nil, fmt.Errorf("UI dist directory not found at %s: run 'make build-admin-ui' first: %w", devUIDir, err) + } + return os.DirFS(devUIDir), nil +} diff --git a/admin/go.mod b/admin/go.mod new file mode 100644 index 0000000..e4d6f62 --- /dev/null +++ b/admin/go.mod @@ -0,0 +1,21 @@ +module github.com/cloudblue/chaperone/admin + +go 1.25.7 + +require ( + gopkg.in/yaml.v3 v3.0.1 + modernc.org/sqlite v1.46.1 +) + +require ( + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/ncruces/go-strftime v1.0.0 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect + golang.org/x/sys v0.37.0 // indirect + modernc.org/libc v1.67.6 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect +) diff --git a/admin/go.sum b/admin/go.sum new file mode 100644 index 0000000..b2791d1 --- /dev/null +++ b/admin/go.sum @@ -0,0 +1,57 @@ +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY= +golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70= +golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA= +golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w= +golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= +golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= +golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ= +golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis= +modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= +modernc.org/ccgo/v4 v4.30.1 h1:4r4U1J6Fhj98NKfSjnPUN7Ze2c6MnAdL0hWw6+LrJpc= +modernc.org/ccgo/v4 v4.30.1/go.mod h1:bIOeI1JL54Utlxn+LwrFyjCx2n2RDiYEaJVSrgdrRfM= +modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA= +modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc= +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/gc/v3 v3.1.1 h1:k8T3gkXWY9sEiytKhcgyiZ2L0DTyCQ/nvX+LoCljoRE= +modernc.org/gc/v3 v3.1.1/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= +modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= +modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= +modernc.org/libc v1.67.6 h1:eVOQvpModVLKOdT+LvBPjdQqfrZq+pC39BygcT+E7OI= +modernc.org/libc v1.67.6/go.mod h1:JAhxUVlolfYDErnwiqaLvUqc8nfb2r6S6slAgZOnaiE= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= +modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= +modernc.org/sqlite v1.46.1 h1:eFJ2ShBLIEnUWlLy12raN0Z1plqmFX9Qe3rjQTKt6sU= +modernc.org/sqlite v1.46.1/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/admin/server.go b/admin/server.go new file mode 100644 index 0000000..b4776a6 --- /dev/null +++ b/admin/server.go @@ -0,0 +1,117 @@ +// Copyright 2026 CloudBlue LLC +// SPDX-License-Identifier: Apache-2.0 + +package admin + +import ( + "context" + "fmt" + "io/fs" + "log/slog" + "net/http" + "path" + "strings" + "time" + + "github.com/cloudblue/chaperone/admin/config" + "github.com/cloudblue/chaperone/admin/store" +) + +// Server is the admin portal HTTP server. +type Server struct { + httpServer *http.Server + config *config.Config + store *store.Store +} + +// NewServer creates a new admin portal server. +func NewServer(cfg *config.Config, st *store.Store) (*Server, error) { + mux := http.NewServeMux() + + s := &Server{ + httpServer: &http.Server{ + Addr: cfg.Server.Addr, + Handler: securityHeaders(mux), + ReadHeaderTimeout: 5 * time.Second, + ReadTimeout: 15 * time.Second, + WriteTimeout: 30 * time.Second, + IdleTimeout: 60 * time.Second, + }, + config: cfg, + store: st, + } + + if err := s.routes(mux); err != nil { + return nil, fmt.Errorf("setting up routes: %w", err) + } + return s, nil +} + +// ListenAndServe starts the HTTP server. +func (s *Server) ListenAndServe() error { + return s.httpServer.ListenAndServe() +} + +// Shutdown gracefully shuts down the server. +func (s *Server) Shutdown(ctx context.Context) error { + return s.httpServer.Shutdown(ctx) +} + +func (s *Server) routes(mux *http.ServeMux) error { + // API health check for the portal itself. + mux.HandleFunc("GET /api/health", s.handleHealth) + + // SPA serving — all non-API routes serve the Vue app. + assets, err := loadUIAssets() + if err != nil { + return fmt.Errorf("loading UI assets: %w", err) + } + mux.Handle("/", spaHandler(assets)) + return nil +} + +func (s *Server) handleHealth(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + if _, err := w.Write([]byte(`{"status":"ok"}`)); err != nil { + slog.Error("writing health response", "error", err) + } +} + +// securityHeaders adds standard security headers to all responses. +func securityHeaders(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Security-Policy", "default-src 'self'; style-src 'self' 'unsafe-inline'") + w.Header().Set("X-Content-Type-Options", "nosniff") + w.Header().Set("X-Frame-Options", "DENY") + w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin") + w.Header().Set("Permissions-Policy", "camera=(), microphone=(), geolocation=()") + next.ServeHTTP(w, r) + }) +} + +// spaHandler serves static files from the embedded filesystem, +// falling back to index.html for client-side routing. +func spaHandler(assets fs.FS) http.Handler { + fileServer := http.FileServer(http.FS(assets)) + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/" { + fileServer.ServeHTTP(w, r) + return + } + + // API routes that didn't match a registered handler should 404, + // not fall through to the SPA. + if strings.HasPrefix(r.URL.Path, "/api/") { + http.NotFound(w, r) + return + } + + // Clean and strip leading slash for fs.Stat. + name := path.Clean(r.URL.Path[1:]) + if _, err := fs.Stat(assets, name); err != nil { + // File not found — serve index.html for client-side routing. + r.URL.Path = "/" + } + fileServer.ServeHTTP(w, r) + }) +} diff --git a/admin/server_test.go b/admin/server_test.go new file mode 100644 index 0000000..5593b81 --- /dev/null +++ b/admin/server_test.go @@ -0,0 +1,156 @@ +// Copyright 2026 CloudBlue LLC +// SPDX-License-Identifier: Apache-2.0 + +package admin + +import ( + "net/http" + "net/http/httptest" + "testing" + "testing/fstest" +) + +func TestHandleHealth_ReturnsOK_WithJSON(t *testing.T) { + t.Parallel() + + // Arrange + s := &Server{} + req := httptest.NewRequest(http.MethodGet, "/api/health", nil) + rec := httptest.NewRecorder() + + // Act + s.handleHealth(rec, req) + + // Assert + if rec.Code != http.StatusOK { + t.Errorf("status = %d, want %d", rec.Code, http.StatusOK) + } + if ct := rec.Header().Get("Content-Type"); ct != "application/json" { + t.Errorf("Content-Type = %q, want %q", ct, "application/json") + } + if body := rec.Body.String(); body != `{"status":"ok"}` { + t.Errorf("body = %q, want %q", body, `{"status":"ok"}`) + } +} + +func TestSPAHandler_Root_ServesIndexHTML(t *testing.T) { + t.Parallel() + + // Arrange + assets := fstest.MapFS{ + "index.html": &fstest.MapFile{Data: []byte("app")}, + } + handler := spaHandler(assets) + req := httptest.NewRequest(http.MethodGet, "/", nil) + rec := httptest.NewRecorder() + + // Act + handler.ServeHTTP(rec, req) + + // Assert + if rec.Code != http.StatusOK { + t.Errorf("status = %d, want %d", rec.Code, http.StatusOK) + } + if body := rec.Body.String(); body != "app" { + t.Errorf("body = %q, want %q", body, "app") + } +} + +func TestSPAHandler_UnknownRoute_FallsBackToIndex(t *testing.T) { + t.Parallel() + + // Arrange + assets := fstest.MapFS{ + "index.html": &fstest.MapFile{Data: []byte("spa")}, + } + handler := spaHandler(assets) + req := httptest.NewRequest(http.MethodGet, "/dashboard/some-page", nil) + rec := httptest.NewRecorder() + + // Act + handler.ServeHTTP(rec, req) + + // Assert + if rec.Code != http.StatusOK { + t.Errorf("status = %d, want %d", rec.Code, http.StatusOK) + } + if body := rec.Body.String(); body != "spa" { + t.Errorf("body = %q, want %q", body, "spa") + } +} + +func TestSecurityHeaders_SetOnAllResponses(t *testing.T) { + t.Parallel() + + // Arrange + inner := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + }) + handler := securityHeaders(inner) + req := httptest.NewRequest(http.MethodGet, "/", nil) + rec := httptest.NewRecorder() + + // Act + handler.ServeHTTP(rec, req) + + // Assert + tests := []struct { + header string + want string + }{ + {"Content-Security-Policy", "default-src 'self'; style-src 'self' 'unsafe-inline'"}, + {"X-Content-Type-Options", "nosniff"}, + {"X-Frame-Options", "DENY"}, + {"Referrer-Policy", "strict-origin-when-cross-origin"}, + {"Permissions-Policy", "camera=(), microphone=(), geolocation=()"}, + } + for _, tt := range tests { + if got := rec.Header().Get(tt.header); got != tt.want { + t.Errorf("%s = %q, want %q", tt.header, got, tt.want) + } + } +} + +func TestSPAHandler_UnmatchedAPIRoute_Returns404(t *testing.T) { + t.Parallel() + + // Arrange + assets := fstest.MapFS{ + "index.html": &fstest.MapFile{Data: []byte("spa")}, + } + handler := spaHandler(assets) + req := httptest.NewRequest(http.MethodGet, "/api/nonexistent", nil) + rec := httptest.NewRecorder() + + // Act + handler.ServeHTTP(rec, req) + + // Assert + if rec.Code != http.StatusNotFound { + t.Errorf("status = %d, want %d", rec.Code, http.StatusNotFound) + } +} + +func TestSPAHandler_ExistingFile_ServesFile(t *testing.T) { + t.Parallel() + + // Arrange + assets := fstest.MapFS{ + "index.html": &fstest.MapFile{Data: []byte("app")}, + "assets/style.css": &fstest.MapFile{Data: []byte("body{}")}, + } + handler := spaHandler(assets) + req := httptest.NewRequest(http.MethodGet, "/assets/style.css", nil) + rec := httptest.NewRecorder() + + // Act + handler.ServeHTTP(rec, req) + + // Assert + if rec.Code != http.StatusOK { + t.Errorf("status = %d, want %d", rec.Code, http.StatusOK) + } + if body := rec.Body.String(); body != "body{}" { + t.Errorf("body = %q, want %q", body, "body{}") + } +} diff --git a/admin/store/migrations.go b/admin/store/migrations.go new file mode 100644 index 0000000..f0e159d --- /dev/null +++ b/admin/store/migrations.go @@ -0,0 +1,121 @@ +// Copyright 2026 CloudBlue LLC +// SPDX-License-Identifier: Apache-2.0 + +package store + +import ( + "fmt" + "log/slog" +) + +type migration struct { + Version int + Description string + SQL string +} + +var migrations = []migration{ + { + Version: 1, + Description: "initial schema", + SQL: ` +CREATE TABLE instances ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + address TEXT NOT NULL UNIQUE, + status TEXT NOT NULL DEFAULT 'unknown', + version TEXT NOT NULL DEFAULT '', + last_seen_at TIMESTAMP, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE sessions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + token TEXT NOT NULL UNIQUE, + expires_at TIMESTAMP NOT NULL, + last_active_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE audit_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id), + action TEXT NOT NULL, + instance_id INTEGER REFERENCES instances(id) ON DELETE SET NULL, + detail TEXT, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_audit_log_created_at ON audit_log(created_at); +CREATE INDEX idx_audit_log_user_id ON audit_log(user_id); +CREATE INDEX idx_audit_log_action ON audit_log(action); +CREATE INDEX idx_sessions_token ON sessions(token); +CREATE INDEX idx_sessions_user_id ON sessions(user_id); +CREATE INDEX idx_sessions_expires_at ON sessions(expires_at); +`, + }, +} + +func (s *Store) migrate() error { + _, err := s.db.Exec(` + CREATE TABLE IF NOT EXISTS schema_migrations ( + version INTEGER PRIMARY KEY, + applied_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP + ) + `) + if err != nil { + return fmt.Errorf("creating schema_migrations table: %w", err) + } + + var current int + err = s.db.QueryRow("SELECT COALESCE(MAX(version), 0) FROM schema_migrations").Scan(¤t) + if err != nil { + return fmt.Errorf("reading current schema version: %w", err) + } + + for _, m := range migrations { + if m.Version <= current { + continue + } + + slog.Info("applying migration", + "version", m.Version, + "description", m.Description, + ) + + tx, err := s.db.Begin() + if err != nil { + return fmt.Errorf("beginning transaction for migration %d: %w", m.Version, err) + } + + if _, err := tx.Exec(m.SQL); err != nil { + if rbErr := tx.Rollback(); rbErr != nil { + slog.Error("rolling back migration", "version", m.Version, "error", rbErr) + } + return fmt.Errorf("applying migration %d (%s): %w", m.Version, m.Description, err) + } + + if _, err := tx.Exec("INSERT INTO schema_migrations (version) VALUES (?)", m.Version); err != nil { + if rbErr := tx.Rollback(); rbErr != nil { + slog.Error("rolling back migration", "version", m.Version, "error", rbErr) + } + return fmt.Errorf("recording migration %d: %w", m.Version, err) + } + + if err := tx.Commit(); err != nil { + return fmt.Errorf("committing migration %d: %w", m.Version, err) + } + } + + return nil +} diff --git a/admin/store/store.go b/admin/store/store.go new file mode 100644 index 0000000..f00e867 --- /dev/null +++ b/admin/store/store.go @@ -0,0 +1,64 @@ +// Copyright 2026 CloudBlue LLC +// SPDX-License-Identifier: Apache-2.0 + +package store + +import ( + "database/sql" + "fmt" + + _ "modernc.org/sqlite" +) + +// Store wraps a SQLite database connection for the admin portal. +type Store struct { + db *sql.DB +} + +// DB returns the underlying database connection for use by API handlers. +func (s *Store) DB() *sql.DB { + return s.db +} + +// Open creates a new Store with the given SQLite database path. +// It configures WAL mode, busy timeout, and foreign keys, then runs +// any pending schema migrations. +func Open(dbPath string) (*Store, error) { + db, err := sql.Open("sqlite", dbPath) + if err != nil { + return nil, fmt.Errorf("opening database: %w", err) + } + + pragmas := []string{ + "PRAGMA journal_mode=WAL", + "PRAGMA busy_timeout=5000", + "PRAGMA foreign_keys=ON", + } + for _, p := range pragmas { + if _, err := db.Exec(p); err != nil { + db.Close() + return nil, fmt.Errorf("setting pragma %q: %w", p, err) + } + } + + if err := db.Ping(); err != nil { + db.Close() + return nil, fmt.Errorf("pinging database: %w", err) + } + + s := &Store{db: db} + if err := s.migrate(); err != nil { + db.Close() + return nil, fmt.Errorf("running migrations: %w", err) + } + + return s, nil +} + +// Close closes the database connection. +func (s *Store) Close() error { + if err := s.db.Close(); err != nil { + return fmt.Errorf("closing database: %w", err) + } + return nil +} diff --git a/admin/store/store_test.go b/admin/store/store_test.go new file mode 100644 index 0000000..6e6abab --- /dev/null +++ b/admin/store/store_test.go @@ -0,0 +1,137 @@ +// Copyright 2026 CloudBlue LLC +// SPDX-License-Identifier: Apache-2.0 + +package store + +import ( + "path/filepath" + "sort" + "testing" +) + +func openTestStore(t *testing.T) *Store { + t.Helper() + dbPath := filepath.Join(t.TempDir(), "test.db") + st, err := Open(dbPath) + if err != nil { + t.Fatalf("Open(%q) failed: %v", dbPath, err) + } + t.Cleanup(func() { st.Close() }) + return st +} + +func TestOpen_CreatesAllTables(t *testing.T) { + t.Parallel() + + // Arrange & Act + st := openTestStore(t) + + // Assert — query sqlite_master for expected tables + rows, err := st.DB().Query( + `SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name`, + ) + if err != nil { + t.Fatalf("querying sqlite_master: %v", err) + } + defer rows.Close() + + var tables []string + for rows.Next() { + var name string + if err := rows.Scan(&name); err != nil { + t.Fatalf("scanning table name: %v", err) + } + tables = append(tables, name) + } + if err := rows.Err(); err != nil { + t.Fatalf("iterating rows: %v", err) + } + + expected := []string{"audit_log", "instances", "schema_migrations", "sessions", "users"} + sort.Strings(tables) + + if len(tables) != len(expected) { + t.Fatalf("tables = %v, want %v", tables, expected) + } + for i, name := range tables { + if name != expected[i] { + t.Errorf("table[%d] = %q, want %q", i, name, expected[i]) + } + } +} + +func TestOpen_MigrationIdempotent(t *testing.T) { + t.Parallel() + + // Arrange — open twice on same DB to verify re-run is safe + dbPath := filepath.Join(t.TempDir(), "test.db") + + st1, err := Open(dbPath) + if err != nil { + t.Fatalf("first Open failed: %v", err) + } + st1.Close() + + // Act — open again (should re-run migrate without error) + st2, err := Open(dbPath) + if err != nil { + t.Fatalf("second Open failed: %v", err) + } + defer st2.Close() + + // Assert — schema_migrations still has exactly one entry + var count int + if err := st2.DB().QueryRow("SELECT COUNT(*) FROM schema_migrations").Scan(&count); err != nil { + t.Fatalf("counting migrations: %v", err) + } + if count != 1 { + t.Errorf("migration count = %d, want 1", count) + } +} + +func TestOpen_WALMode_Enabled(t *testing.T) { + t.Parallel() + + // Arrange & Act + st := openTestStore(t) + + // Assert + var mode string + if err := st.DB().QueryRow("PRAGMA journal_mode").Scan(&mode); err != nil { + t.Fatalf("querying journal_mode: %v", err) + } + if mode != "wal" { + t.Errorf("journal_mode = %q, want %q", mode, "wal") + } +} + +func TestOpen_SchemaMigrations_TracksVersion(t *testing.T) { + t.Parallel() + + // Arrange & Act + st := openTestStore(t) + + // Assert + var version int + if err := st.DB().QueryRow("SELECT MAX(version) FROM schema_migrations").Scan(&version); err != nil { + t.Fatalf("querying schema version: %v", err) + } + if version != 1 { + t.Errorf("schema version = %d, want 1", version) + } +} + +func TestOpen_InvalidPath_ReturnsError(t *testing.T) { + t.Parallel() + + // Arrange — directory that doesn't exist + dbPath := "/nonexistent/dir/test.db" + + // Act + _, err := Open(dbPath) + + // Assert + if err == nil { + t.Error("expected error, got nil") + } +} From beffabcc241b370501f95bc2a634221659ac0771 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADctor=20Cuevas?= Date: Wed, 4 Mar 2026 23:08:40 +0100 Subject: [PATCH 02/36] feat: initialize Vue 3 SPA with design system and app shell - Vue 3 + Vite + Pinia + Vue Router with Vite proxy to Go backend - Design system: CSS custom properties, BaseButton, BaseCard, BaseInput, StatusIndicator, BaseEmptyState - App shell with sidebar nav, Fleet Dashboard and Audit Log routes - WCAG AA contrast, keyboard focus styles, aria attributes - Vitest + jsdom configured for testing --- admin/ui/.prettierrc | 7 + admin/ui/eslint.config.js | 14 + admin/ui/index.html | 12 + admin/ui/package.json | 34 + admin/ui/pnpm-lock.yaml | 2487 +++++++++++++++++++ admin/ui/src/App.vue | 10 + admin/ui/src/assets/global.css | 40 + admin/ui/src/assets/variables.css | 86 + admin/ui/src/components/BaseButton.vue | 90 + admin/ui/src/components/BaseCard.vue | 32 + admin/ui/src/components/BaseEmptyState.vue | 55 + admin/ui/src/components/BaseInput.vue | 117 + admin/ui/src/components/StatusIndicator.vue | 84 + admin/ui/src/layouts/AppLayout.vue | 167 ++ admin/ui/src/main.js | 11 + admin/ui/src/router/index.js | 28 + admin/ui/src/views/AuditLogView.vue | 66 + admin/ui/src/views/DashboardView.vue | 64 + admin/ui/vite.config.js | 22 + 19 files changed, 3426 insertions(+) create mode 100644 admin/ui/.prettierrc create mode 100644 admin/ui/eslint.config.js create mode 100644 admin/ui/index.html create mode 100644 admin/ui/package.json create mode 100644 admin/ui/pnpm-lock.yaml create mode 100644 admin/ui/src/App.vue create mode 100644 admin/ui/src/assets/global.css create mode 100644 admin/ui/src/assets/variables.css create mode 100644 admin/ui/src/components/BaseButton.vue create mode 100644 admin/ui/src/components/BaseCard.vue create mode 100644 admin/ui/src/components/BaseEmptyState.vue create mode 100644 admin/ui/src/components/BaseInput.vue create mode 100644 admin/ui/src/components/StatusIndicator.vue create mode 100644 admin/ui/src/layouts/AppLayout.vue create mode 100644 admin/ui/src/main.js create mode 100644 admin/ui/src/router/index.js create mode 100644 admin/ui/src/views/AuditLogView.vue create mode 100644 admin/ui/src/views/DashboardView.vue create mode 100644 admin/ui/vite.config.js diff --git a/admin/ui/.prettierrc b/admin/ui/.prettierrc new file mode 100644 index 0000000..aa5c2fb --- /dev/null +++ b/admin/ui/.prettierrc @@ -0,0 +1,7 @@ +{ + "semi": true, + "singleQuote": false, + "useTabs": true, + "tabWidth": 2, + "trailingComma": "all" +} diff --git a/admin/ui/eslint.config.js b/admin/ui/eslint.config.js new file mode 100644 index 0000000..269aa21 --- /dev/null +++ b/admin/ui/eslint.config.js @@ -0,0 +1,14 @@ +import pluginVue from "eslint-plugin-vue"; +import configPrettier from "eslint-config-prettier"; + +export default [ + { ignores: ["dist/**"] }, + ...pluginVue.configs["flat/recommended"], + configPrettier, + { + rules: { + "semi": ["error", "always"], + "vue/multi-word-component-names": "off", + }, + }, +]; diff --git a/admin/ui/index.html b/admin/ui/index.html new file mode 100644 index 0000000..ae4a233 --- /dev/null +++ b/admin/ui/index.html @@ -0,0 +1,12 @@ + + + + + + Chaperone Admin + + +
+ + + diff --git a/admin/ui/package.json b/admin/ui/package.json new file mode 100644 index 0000000..ce27df9 --- /dev/null +++ b/admin/ui/package.json @@ -0,0 +1,34 @@ +{ + "name": "chaperone-admin-ui", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview", + "lint": "eslint .", + "lint:fix": "eslint . --fix", + "format": "prettier --write \"src/**/*.{js,vue,css}\"", + "test": "vitest run", + "test:watch": "vitest" + }, + "dependencies": { + "vue": "^3.5.29", + "vue-router": "^5.0.3", + "pinia": "^3.0.4" + }, + "pnpm": { + "onlyBuiltDependencies": ["esbuild"] + }, + "devDependencies": { + "@vitejs/plugin-vue": "^6.0.4", + "eslint": "^10.0.2", + "eslint-config-prettier": "^10.1.8", + "eslint-plugin-vue": "^10.8.0", + "prettier": "^3.8.1", + "jsdom": "^26.1.0", + "vite": "^7.3.1", + "vitest": "^3.2.1" + } +} diff --git a/admin/ui/pnpm-lock.yaml b/admin/ui/pnpm-lock.yaml new file mode 100644 index 0000000..8404993 --- /dev/null +++ b/admin/ui/pnpm-lock.yaml @@ -0,0 +1,2487 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + pinia: + specifier: ^3.0.4 + version: 3.0.4(vue@3.5.29) + vue: + specifier: ^3.5.29 + version: 3.5.29 + vue-router: + specifier: ^5.0.3 + version: 5.0.3(@vue/compiler-sfc@3.5.29)(pinia@3.0.4(vue@3.5.29))(vue@3.5.29) + devDependencies: + '@vitejs/plugin-vue': + specifier: ^6.0.4 + version: 6.0.4(vite@7.3.1(yaml@2.8.2))(vue@3.5.29) + eslint: + specifier: ^10.0.2 + version: 10.0.2 + eslint-config-prettier: + specifier: ^10.1.8 + version: 10.1.8(eslint@10.0.2) + eslint-plugin-vue: + specifier: ^10.8.0 + version: 10.8.0(eslint@10.0.2)(vue-eslint-parser@10.4.0(eslint@10.0.2)) + jsdom: + specifier: ^26.1.0 + version: 26.1.0 + prettier: + specifier: ^3.8.1 + version: 3.8.1 + vite: + specifier: ^7.3.1 + version: 7.3.1(yaml@2.8.2) + vitest: + specifier: ^3.2.1 + version: 3.2.4(jsdom@26.1.0)(yaml@2.8.2) + +packages: + + '@asamuzakjp/css-color@3.2.0': + resolution: {integrity: sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==} + + '@babel/generator@7.29.1': + resolution: {integrity: sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-string-parser@7.27.1': + resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.28.5': + resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.0': + resolution: {integrity: sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/types@7.29.0': + resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} + engines: {node: '>=6.9.0'} + + '@csstools/color-helpers@5.1.0': + resolution: {integrity: sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==} + engines: {node: '>=18'} + + '@csstools/css-calc@2.1.4': + resolution: {integrity: sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-parser-algorithms': ^3.0.5 + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/css-color-parser@3.1.0': + resolution: {integrity: sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-parser-algorithms': ^3.0.5 + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/css-parser-algorithms@3.0.5': + resolution: {integrity: sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/css-tokenizer@3.0.4': + resolution: {integrity: sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==} + engines: {node: '>=18'} + + '@esbuild/aix-ppc64@0.27.3': + resolution: {integrity: sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.27.3': + resolution: {integrity: sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.27.3': + resolution: {integrity: sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.27.3': + resolution: {integrity: sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.27.3': + resolution: {integrity: sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.27.3': + resolution: {integrity: sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.27.3': + resolution: {integrity: sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.27.3': + resolution: {integrity: sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.27.3': + resolution: {integrity: sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.27.3': + resolution: {integrity: sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.27.3': + resolution: {integrity: sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.27.3': + resolution: {integrity: sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.27.3': + resolution: {integrity: sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.27.3': + resolution: {integrity: sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.27.3': + resolution: {integrity: sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.27.3': + resolution: {integrity: sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.27.3': + resolution: {integrity: sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.27.3': + resolution: {integrity: sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.27.3': + resolution: {integrity: sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.27.3': + resolution: {integrity: sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.27.3': + resolution: {integrity: sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.27.3': + resolution: {integrity: sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.27.3': + resolution: {integrity: sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.27.3': + resolution: {integrity: sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.27.3': + resolution: {integrity: sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.27.3': + resolution: {integrity: sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@eslint-community/eslint-utils@4.9.1': + resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + + '@eslint-community/regexpp@4.12.2': + resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + + '@eslint/config-array@0.23.2': + resolution: {integrity: sha512-YF+fE6LV4v5MGWRGj7G404/OZzGNepVF8fxk7jqmqo3lrza7a0uUcDnROGRBG1WFC1omYUS/Wp1f42i0M+3Q3A==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/config-helpers@0.5.2': + resolution: {integrity: sha512-a5MxrdDXEvqnIq+LisyCX6tQMPF/dSJpCfBgBauY+pNZ28yCtSsTvyTYrMhaI+LK26bVyCJfJkT0u8KIj2i1dQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/core@1.1.0': + resolution: {integrity: sha512-/nr9K9wkr3P1EzFTdFdMoLuo1PmIxjmwvPozwoSodjNBdefGujXQUF93u1DDZpEaTuDvMsIQddsd35BwtrW9Xw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/object-schema@3.0.2': + resolution: {integrity: sha512-HOy56KJt48Bx8KmJ+XGQNSUMT/6dZee/M54XyUyuvTvPXJmsERRvBchsUVx1UMe1WwIH49XLAczNC7V2INsuUw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/plugin-kit@0.6.0': + resolution: {integrity: sha512-bIZEUzOI1jkhviX2cp5vNyXQc6olzb2ohewQubuYlMXZ2Q/XjBO0x0XhGPvc9fjSIiUN0vw+0hq53BJ4eQSJKQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@humanfs/core@0.19.1': + resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==} + engines: {node: '>=18.18.0'} + + '@humanfs/node@0.16.7': + resolution: {integrity: sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==} + engines: {node: '>=18.18.0'} + + '@humanwhocodes/module-importer@1.0.1': + resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} + engines: {node: '>=12.22'} + + '@humanwhocodes/retry@0.4.3': + resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} + engines: {node: '>=18.18'} + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@rolldown/pluginutils@1.0.0-rc.2': + resolution: {integrity: sha512-izyXV/v+cHiRfozX62W9htOAvwMo4/bXKDrQ+vom1L1qRuexPock/7VZDAhnpHCLNejd3NJ6hiab+tO0D44Rgw==} + + '@rollup/rollup-android-arm-eabi@4.59.0': + resolution: {integrity: sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.59.0': + resolution: {integrity: sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.59.0': + resolution: {integrity: sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.59.0': + resolution: {integrity: sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.59.0': + resolution: {integrity: sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.59.0': + resolution: {integrity: sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.59.0': + resolution: {integrity: sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm-musleabihf@4.59.0': + resolution: {integrity: sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==} + cpu: [arm] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-arm64-gnu@4.59.0': + resolution: {integrity: sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm64-musl@4.59.0': + resolution: {integrity: sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-loong64-gnu@4.59.0': + resolution: {integrity: sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==} + cpu: [loong64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-loong64-musl@4.59.0': + resolution: {integrity: sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==} + cpu: [loong64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-ppc64-gnu@4.59.0': + resolution: {integrity: sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-ppc64-musl@4.59.0': + resolution: {integrity: sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==} + cpu: [ppc64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-riscv64-gnu@4.59.0': + resolution: {integrity: sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-riscv64-musl@4.59.0': + resolution: {integrity: sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-s390x-gnu@4.59.0': + resolution: {integrity: sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-gnu@4.59.0': + resolution: {integrity: sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-musl@4.59.0': + resolution: {integrity: sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rollup/rollup-openbsd-x64@4.59.0': + resolution: {integrity: sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.59.0': + resolution: {integrity: sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.59.0': + resolution: {integrity: sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.59.0': + resolution: {integrity: sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.59.0': + resolution: {integrity: sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.59.0': + resolution: {integrity: sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==} + cpu: [x64] + os: [win32] + + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/esrecurse@4.3.1': + resolution: {integrity: sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==} + + '@types/estree@1.0.8': + resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + + '@types/json-schema@7.0.15': + resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + + '@vitejs/plugin-vue@6.0.4': + resolution: {integrity: sha512-uM5iXipgYIn13UUQCZNdWkYk+sysBeA97d5mHsAoAt1u/wpN3+zxOmsVJWosuzX+IMGRzeYUNytztrYznboIkQ==} + engines: {node: ^20.19.0 || >=22.12.0} + peerDependencies: + vite: ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 + vue: ^3.2.25 + + '@vitest/expect@3.2.4': + resolution: {integrity: sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==} + + '@vitest/mocker@3.2.4': + resolution: {integrity: sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==} + peerDependencies: + msw: ^2.4.9 + vite: ^5.0.0 || ^6.0.0 || ^7.0.0-0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@3.2.4': + resolution: {integrity: sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==} + + '@vitest/runner@3.2.4': + resolution: {integrity: sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==} + + '@vitest/snapshot@3.2.4': + resolution: {integrity: sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==} + + '@vitest/spy@3.2.4': + resolution: {integrity: sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==} + + '@vitest/utils@3.2.4': + resolution: {integrity: sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==} + + '@vue-macros/common@3.1.2': + resolution: {integrity: sha512-h9t4ArDdniO9ekYHAD95t9AZcAbb19lEGK+26iAjUODOIJKmObDNBSe4+6ELQAA3vtYiFPPBtHh7+cQCKi3Dng==} + engines: {node: '>=20.19.0'} + peerDependencies: + vue: ^2.7.0 || ^3.2.25 + peerDependenciesMeta: + vue: + optional: true + + '@vue/compiler-core@3.5.29': + resolution: {integrity: sha512-cuzPhD8fwRHk8IGfmYaR4eEe4cAyJEL66Ove/WZL7yWNL134nqLddSLwNRIsFlnnW1kK+p8Ck3viFnC0chXCXw==} + + '@vue/compiler-dom@3.5.29': + resolution: {integrity: sha512-n0G5o7R3uBVmVxjTIYcz7ovr8sy7QObFG8OQJ3xGCDNhbG60biP/P5KnyY8NLd81OuT1WJflG7N4KWYHaeeaIg==} + + '@vue/compiler-sfc@3.5.29': + resolution: {integrity: sha512-oJZhN5XJs35Gzr50E82jg2cYdZQ78wEwvRO6Y63TvLVTc+6xICzJHP1UIecdSPPYIbkautNBanDiWYa64QSFIA==} + + '@vue/compiler-ssr@3.5.29': + resolution: {integrity: sha512-Y/ARJZE6fpjzL5GH/phJmsFwx3g6t2KmHKHx5q+MLl2kencADKIrhH5MLF6HHpRMmlRAYBRSvv347Mepf1zVNw==} + + '@vue/devtools-api@7.7.9': + resolution: {integrity: sha512-kIE8wvwlcZ6TJTbNeU2HQNtaxLx3a84aotTITUuL/4bzfPxzajGBOoqjMhwZJ8L9qFYDU/lAYMEEm11dnZOD6g==} + + '@vue/devtools-api@8.0.7': + resolution: {integrity: sha512-tc1TXAxclsn55JblLkFVcIRG7MeSJC4fWsPjfM7qu/IcmPUYnQ5Q8vzWwBpyDY24ZjmZTUCCwjRSNbx58IhlAA==} + + '@vue/devtools-kit@7.7.9': + resolution: {integrity: sha512-PyQ6odHSgiDVd4hnTP+aDk2X4gl2HmLDfiyEnn3/oV+ckFDuswRs4IbBT7vacMuGdwY/XemxBoh302ctbsptuA==} + + '@vue/devtools-kit@8.0.7': + resolution: {integrity: sha512-H6esJGHGl5q0E9iV3m2EoBQHJ+V83WMW83A0/+Fn95eZ2iIvdsq4+UCS6yT/Fdd4cGZSchx/MdWDreM3WqMsDw==} + + '@vue/devtools-shared@7.7.9': + resolution: {integrity: sha512-iWAb0v2WYf0QWmxCGy0seZNDPdO3Sp5+u78ORnyeonS6MT4PC7VPrryX2BpMJrwlDeaZ6BD4vP4XKjK0SZqaeA==} + + '@vue/devtools-shared@8.0.7': + resolution: {integrity: sha512-CgAb9oJH5NUmbQRdYDj/1zMiaICYSLtm+B1kxcP72LBrifGAjUmt8bx52dDH1gWRPlQgxGPqpAMKavzVirAEhA==} + + '@vue/reactivity@3.5.29': + resolution: {integrity: sha512-zcrANcrRdcLtmGZETBxWqIkoQei8HaFpZWx/GHKxx79JZsiZ8j1du0VUJtu4eJjgFvU/iKL5lRXFXksVmI+5DA==} + + '@vue/runtime-core@3.5.29': + resolution: {integrity: sha512-8DpW2QfdwIWOLqtsNcds4s+QgwSaHSJY/SUe04LptianUQ/0xi6KVsu/pYVh+HO3NTVvVJjIPL2t6GdeKbS4Lg==} + + '@vue/runtime-dom@3.5.29': + resolution: {integrity: sha512-AHvvJEtcY9tw/uk+s/YRLSlxxQnqnAkjqvK25ZiM4CllCZWzElRAoQnCM42m9AHRLNJ6oe2kC5DCgD4AUdlvXg==} + + '@vue/server-renderer@3.5.29': + resolution: {integrity: sha512-G/1k6WK5MusLlbxSE2YTcqAAezS+VuwHhOvLx2KnQU7G2zCH6KIb+5Wyt6UjMq7a3qPzNEjJXs1hvAxDclQH+g==} + peerDependencies: + vue: 3.5.29 + + '@vue/shared@3.5.29': + resolution: {integrity: sha512-w7SR0A5zyRByL9XUkCfdLs7t9XOHUyJ67qPGQjOou3p6GvBeBW+AVjUUmlxtZ4PIYaRvE+1LmK44O4uajlZwcg==} + + acorn-jsx@5.3.2: + resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + + acorn@8.16.0: + resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==} + engines: {node: '>=0.4.0'} + hasBin: true + + agent-base@7.1.4: + resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} + engines: {node: '>= 14'} + + ajv@6.14.0: + resolution: {integrity: sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + ast-kit@2.2.0: + resolution: {integrity: sha512-m1Q/RaVOnTp9JxPX+F+Zn7IcLYMzM8kZofDImfsKZd8MbR+ikdOzTeztStWqfrqIxZnYWryyI9ePm3NGjnZgGw==} + engines: {node: '>=20.19.0'} + + ast-walker-scope@0.8.3: + resolution: {integrity: sha512-cbdCP0PGOBq0ASG+sjnKIoYkWMKhhz+F/h9pRexUdX2Hd38+WOlBkRKlqkGOSm0YQpcFMQBJeK4WspUAkwsEdg==} + engines: {node: '>=20.19.0'} + + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + + birpc@2.9.0: + resolution: {integrity: sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==} + + boolbase@1.0.0: + resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} + + brace-expansion@5.0.4: + resolution: {integrity: sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==} + engines: {node: 18 || 20 || >=22} + + cac@6.7.14: + resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} + engines: {node: '>=8'} + + chai@5.3.3: + resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} + engines: {node: '>=18'} + + check-error@2.1.3: + resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} + engines: {node: '>= 16'} + + chokidar@5.0.0: + resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==} + engines: {node: '>= 20.19.0'} + + confbox@0.1.8: + resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==} + + confbox@0.2.4: + resolution: {integrity: sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==} + + copy-anything@4.0.5: + resolution: {integrity: sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA==} + engines: {node: '>=18'} + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + cssesc@3.0.0: + resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} + engines: {node: '>=4'} + hasBin: true + + cssstyle@4.6.0: + resolution: {integrity: sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==} + engines: {node: '>=18'} + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + data-urls@5.0.0: + resolution: {integrity: sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==} + engines: {node: '>=18'} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + decimal.js@10.6.0: + resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + + deep-eql@5.0.2: + resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} + engines: {node: '>=6'} + + deep-is@0.1.4: + resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + + entities@6.0.1: + resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} + engines: {node: '>=0.12'} + + entities@7.0.1: + resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} + engines: {node: '>=0.12'} + + es-module-lexer@1.7.0: + resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + + esbuild@0.27.3: + resolution: {integrity: sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==} + engines: {node: '>=18'} + hasBin: true + + escape-string-regexp@4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} + + eslint-config-prettier@10.1.8: + resolution: {integrity: sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==} + hasBin: true + peerDependencies: + eslint: '>=7.0.0' + + eslint-plugin-vue@10.8.0: + resolution: {integrity: sha512-f1J/tcbnrpgC8suPN5AtdJ5MQjuXbSU9pGRSSYAuF3SHoiYCOdEX6O22pLaRyLHXvDcOe+O5ENgc1owQ587agA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + '@stylistic/eslint-plugin': ^2.0.0 || ^3.0.0 || ^4.0.0 || ^5.0.0 + '@typescript-eslint/parser': ^7.0.0 || ^8.0.0 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + vue-eslint-parser: ^10.0.0 + peerDependenciesMeta: + '@stylistic/eslint-plugin': + optional: true + '@typescript-eslint/parser': + optional: true + + eslint-scope@9.1.1: + resolution: {integrity: sha512-GaUN0sWim5qc8KVErfPBWmc31LEsOkrUJbvJZV+xuL3u2phMUK4HIvXlWAakfC8W4nzlK+chPEAkYOYb5ZScIw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + eslint-visitor-keys@3.4.3: + resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + eslint-visitor-keys@5.0.1: + resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + eslint@10.0.2: + resolution: {integrity: sha512-uYixubwmqJZH+KLVYIVKY1JQt7tysXhtj21WSvjcSmU5SVNzMus1bgLe+pAt816yQ8opKfheVVoPLqvVMGejYw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + hasBin: true + peerDependencies: + jiti: '*' + peerDependenciesMeta: + jiti: + optional: true + + espree@11.1.1: + resolution: {integrity: sha512-AVHPqQoZYc+RUM4/3Ly5udlZY/U4LS8pIG05jEjWM2lQMU/oaZ7qshzAl2YP1tfNmXfftH3ohurfwNAug+MnsQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + esquery@1.7.0: + resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==} + engines: {node: '>=0.10'} + + esrecurse@4.3.0: + resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} + engines: {node: '>=4.0'} + + estraverse@5.3.0: + resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} + engines: {node: '>=4.0'} + + estree-walker@2.0.2: + resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + esutils@2.0.3: + resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} + engines: {node: '>=0.10.0'} + + expect-type@1.3.0: + resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} + engines: {node: '>=12.0.0'} + + exsolve@1.0.8: + resolution: {integrity: sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-json-stable-stringify@2.1.0: + resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + + fast-levenshtein@2.0.6: + resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + file-entry-cache@8.0.0: + resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} + engines: {node: '>=16.0.0'} + + find-up@5.0.0: + resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} + engines: {node: '>=10'} + + flat-cache@4.0.1: + resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} + engines: {node: '>=16'} + + flatted@3.3.4: + resolution: {integrity: sha512-3+mMldrTAPdta5kjX2G2J7iX4zxtnwpdA8Tr2ZSjkyPSanvbZAcy6flmtnXbEybHrDcU9641lxrMfFuUxVz9vA==} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + glob-parent@6.0.2: + resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} + engines: {node: '>=10.13.0'} + + hookable@5.5.3: + resolution: {integrity: sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==} + + html-encoding-sniffer@4.0.0: + resolution: {integrity: sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==} + engines: {node: '>=18'} + + http-proxy-agent@7.0.2: + resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} + engines: {node: '>= 14'} + + https-proxy-agent@7.0.6: + resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} + engines: {node: '>= 14'} + + iconv-lite@0.6.3: + resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} + engines: {node: '>=0.10.0'} + + ignore@5.3.2: + resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} + engines: {node: '>= 4'} + + imurmurhash@0.1.4: + resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} + engines: {node: '>=0.8.19'} + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + is-potential-custom-element-name@1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + + is-what@5.5.0: + resolution: {integrity: sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw==} + engines: {node: '>=18'} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + js-tokens@9.0.1: + resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} + + jsdom@26.1.0: + resolution: {integrity: sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==} + engines: {node: '>=18'} + peerDependencies: + canvas: ^3.0.0 + peerDependenciesMeta: + canvas: + optional: true + + jsesc@3.1.0: + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} + engines: {node: '>=6'} + hasBin: true + + json-buffer@3.0.1: + resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} + + json-schema-traverse@0.4.1: + resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + + json-stable-stringify-without-jsonify@1.0.1: + resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + + json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + + keyv@4.5.4: + resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + + levn@0.4.1: + resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} + engines: {node: '>= 0.8.0'} + + local-pkg@1.1.2: + resolution: {integrity: sha512-arhlxbFRmoQHl33a0Zkle/YWlmNwoyt6QNZEIJcqNbdrsix5Lvc4HyyI3EnwxTYlZYc32EbYrQ8SzEZ7dqgg9A==} + engines: {node: '>=14'} + + locate-path@6.0.0: + resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} + engines: {node: '>=10'} + + loupe@3.2.1: + resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} + + lru-cache@10.4.3: + resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + + magic-string-ast@1.0.3: + resolution: {integrity: sha512-CvkkH1i81zl7mmb94DsRiFeG9V2fR2JeuK8yDgS8oiZSFa++wWLEgZ5ufEOyLHbvSbD1gTRKv9NdX69Rnvr9JA==} + engines: {node: '>=20.19.0'} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + minimatch@10.2.4: + resolution: {integrity: sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==} + engines: {node: 18 || 20 || >=22} + + mitt@3.0.1: + resolution: {integrity: sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==} + + mlly@1.8.0: + resolution: {integrity: sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g==} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + muggle-string@0.4.1: + resolution: {integrity: sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==} + + nanoid@3.3.11: + resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + natural-compare@1.4.0: + resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + + nth-check@2.1.1: + resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==} + + nwsapi@2.2.23: + resolution: {integrity: sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ==} + + optionator@0.9.4: + resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} + engines: {node: '>= 0.8.0'} + + p-limit@3.1.0: + resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} + engines: {node: '>=10'} + + p-locate@5.0.0: + resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} + engines: {node: '>=10'} + + parse5@7.3.0: + resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} + + path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + pathval@2.0.1: + resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} + engines: {node: '>= 14.16'} + + perfect-debounce@1.0.0: + resolution: {integrity: sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==} + + perfect-debounce@2.1.0: + resolution: {integrity: sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.3: + resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} + engines: {node: '>=12'} + + pinia@3.0.4: + resolution: {integrity: sha512-l7pqLUFTI/+ESXn6k3nu30ZIzW5E2WZF/LaHJEpoq6ElcLD+wduZoB2kBN19du6K/4FDpPMazY2wJr+IndBtQw==} + peerDependencies: + typescript: '>=4.5.0' + vue: ^3.5.11 + peerDependenciesMeta: + typescript: + optional: true + + pkg-types@1.3.1: + resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} + + pkg-types@2.3.0: + resolution: {integrity: sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==} + + postcss-selector-parser@7.1.1: + resolution: {integrity: sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==} + engines: {node: '>=4'} + + postcss@8.5.8: + resolution: {integrity: sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==} + engines: {node: ^10 || ^12 || >=14} + + prelude-ls@1.2.1: + resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} + engines: {node: '>= 0.8.0'} + + prettier@3.8.1: + resolution: {integrity: sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==} + engines: {node: '>=14'} + hasBin: true + + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + + quansync@0.2.11: + resolution: {integrity: sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==} + + readdirp@5.0.0: + resolution: {integrity: sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==} + engines: {node: '>= 20.19.0'} + + rfdc@1.4.1: + resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} + + rollup@4.59.0: + resolution: {integrity: sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + rrweb-cssom@0.8.0: + resolution: {integrity: sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==} + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + saxes@6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} + + scule@1.3.0: + resolution: {integrity: sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==} + + semver@7.7.4: + resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} + engines: {node: '>=10'} + hasBin: true + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + speakingurl@14.0.1: + resolution: {integrity: sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==} + engines: {node: '>=0.10.0'} + + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@3.10.0: + resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + + strip-literal@3.1.0: + resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==} + + superjson@2.2.6: + resolution: {integrity: sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA==} + engines: {node: '>=16'} + + symbol-tree@3.2.4: + resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@0.3.2: + resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} + + tinyglobby@0.2.15: + resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} + engines: {node: '>=12.0.0'} + + tinypool@1.1.1: + resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==} + engines: {node: ^18.0.0 || >=20.0.0} + + tinyrainbow@2.0.0: + resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==} + engines: {node: '>=14.0.0'} + + tinyspy@4.0.4: + resolution: {integrity: sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==} + engines: {node: '>=14.0.0'} + + tldts-core@6.1.86: + resolution: {integrity: sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==} + + tldts@6.1.86: + resolution: {integrity: sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==} + hasBin: true + + tough-cookie@5.1.2: + resolution: {integrity: sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==} + engines: {node: '>=16'} + + tr46@5.1.1: + resolution: {integrity: sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==} + engines: {node: '>=18'} + + type-check@0.4.0: + resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} + engines: {node: '>= 0.8.0'} + + ufo@1.6.3: + resolution: {integrity: sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==} + + unplugin-utils@0.3.1: + resolution: {integrity: sha512-5lWVjgi6vuHhJ526bI4nlCOmkCIF3nnfXkCMDeMJrtdvxTs6ZFCM8oNufGTsDbKv/tJ/xj8RpvXjRuPBZJuJog==} + engines: {node: '>=20.19.0'} + + unplugin@3.0.0: + resolution: {integrity: sha512-0Mqk3AT2TZCXWKdcoaufeXNukv2mTrEZExeXlHIOZXdqYoHHr4n51pymnwV8x2BOVxwXbK2HLlI7usrqMpycdg==} + engines: {node: ^20.19.0 || >=22.12.0} + + uri-js@4.4.1: + resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + + vite-node@3.2.4: + resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + + vite@7.3.1: + resolution: {integrity: sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + 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 + + vitest@3.2.4: + resolution: {integrity: sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@types/debug': ^4.1.12 + '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 + '@vitest/browser': 3.2.4 + '@vitest/ui': 3.2.4 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@types/debug': + optional: true + '@types/node': + optional: true + '@vitest/browser': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + vue-eslint-parser@10.4.0: + resolution: {integrity: sha512-Vxi9pJdbN3ZnVGLODVtZ7y4Y2kzAAE2Cm0CZ3ZDRvydVYxZ6VrnBhLikBsRS+dpwj4Jv4UCv21PTEwF5rQ9WXg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + + vue-router@5.0.3: + resolution: {integrity: sha512-nG1c7aAFac7NYj8Hluo68WyWfc41xkEjaR0ViLHCa3oDvTQ/nIuLJlXJX1NUPw/DXzx/8+OKMng045HHQKQKWw==} + peerDependencies: + '@pinia/colada': '>=0.21.2' + '@vue/compiler-sfc': ^3.5.17 + pinia: ^3.0.4 + vue: ^3.5.0 + peerDependenciesMeta: + '@pinia/colada': + optional: true + '@vue/compiler-sfc': + optional: true + pinia: + optional: true + + vue@3.5.29: + resolution: {integrity: sha512-BZqN4Ze6mDQVNAni0IHeMJ5mwr8VAJ3MQC9FmprRhcBYENw+wOAAjRj8jfmN6FLl0j96OXbR+CjWhmAmM+QGnA==} + peerDependencies: + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + + w3c-xmlserializer@5.0.0: + resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} + engines: {node: '>=18'} + + webidl-conversions@7.0.0: + resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==} + engines: {node: '>=12'} + + webpack-virtual-modules@0.6.2: + resolution: {integrity: sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==} + + whatwg-encoding@3.1.1: + resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==} + engines: {node: '>=18'} + deprecated: Use @exodus/bytes instead for a more spec-conformant and faster implementation + + whatwg-mimetype@4.0.0: + resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==} + engines: {node: '>=18'} + + whatwg-url@14.2.0: + resolution: {integrity: sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==} + engines: {node: '>=18'} + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + + word-wrap@1.2.5: + resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} + engines: {node: '>=0.10.0'} + + ws@8.19.0: + resolution: {integrity: sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==} + 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 + + xml-name-validator@4.0.0: + resolution: {integrity: sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==} + engines: {node: '>=12'} + + xml-name-validator@5.0.0: + resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} + engines: {node: '>=18'} + + xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + + yaml@2.8.2: + resolution: {integrity: sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==} + engines: {node: '>= 14.6'} + hasBin: true + + yocto-queue@0.1.0: + resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} + engines: {node: '>=10'} + +snapshots: + + '@asamuzakjp/css-color@3.2.0': + dependencies: + '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-color-parser': 3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + lru-cache: 10.4.3 + + '@babel/generator@7.29.1': + dependencies: + '@babel/parser': 7.29.0 + '@babel/types': 7.29.0 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + + '@babel/helper-string-parser@7.27.1': {} + + '@babel/helper-validator-identifier@7.28.5': {} + + '@babel/parser@7.29.0': + dependencies: + '@babel/types': 7.29.0 + + '@babel/types@7.29.0': + dependencies: + '@babel/helper-string-parser': 7.27.1 + '@babel/helper-validator-identifier': 7.28.5 + + '@csstools/color-helpers@5.1.0': {} + + '@csstools/css-calc@2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/css-color-parser@3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/color-helpers': 5.1.0 + '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/css-tokenizer@3.0.4': {} + + '@esbuild/aix-ppc64@0.27.3': + optional: true + + '@esbuild/android-arm64@0.27.3': + optional: true + + '@esbuild/android-arm@0.27.3': + optional: true + + '@esbuild/android-x64@0.27.3': + optional: true + + '@esbuild/darwin-arm64@0.27.3': + optional: true + + '@esbuild/darwin-x64@0.27.3': + optional: true + + '@esbuild/freebsd-arm64@0.27.3': + optional: true + + '@esbuild/freebsd-x64@0.27.3': + optional: true + + '@esbuild/linux-arm64@0.27.3': + optional: true + + '@esbuild/linux-arm@0.27.3': + optional: true + + '@esbuild/linux-ia32@0.27.3': + optional: true + + '@esbuild/linux-loong64@0.27.3': + optional: true + + '@esbuild/linux-mips64el@0.27.3': + optional: true + + '@esbuild/linux-ppc64@0.27.3': + optional: true + + '@esbuild/linux-riscv64@0.27.3': + optional: true + + '@esbuild/linux-s390x@0.27.3': + optional: true + + '@esbuild/linux-x64@0.27.3': + optional: true + + '@esbuild/netbsd-arm64@0.27.3': + optional: true + + '@esbuild/netbsd-x64@0.27.3': + optional: true + + '@esbuild/openbsd-arm64@0.27.3': + optional: true + + '@esbuild/openbsd-x64@0.27.3': + optional: true + + '@esbuild/openharmony-arm64@0.27.3': + optional: true + + '@esbuild/sunos-x64@0.27.3': + optional: true + + '@esbuild/win32-arm64@0.27.3': + optional: true + + '@esbuild/win32-ia32@0.27.3': + optional: true + + '@esbuild/win32-x64@0.27.3': + optional: true + + '@eslint-community/eslint-utils@4.9.1(eslint@10.0.2)': + dependencies: + eslint: 10.0.2 + eslint-visitor-keys: 3.4.3 + + '@eslint-community/regexpp@4.12.2': {} + + '@eslint/config-array@0.23.2': + dependencies: + '@eslint/object-schema': 3.0.2 + debug: 4.4.3 + minimatch: 10.2.4 + transitivePeerDependencies: + - supports-color + + '@eslint/config-helpers@0.5.2': + dependencies: + '@eslint/core': 1.1.0 + + '@eslint/core@1.1.0': + dependencies: + '@types/json-schema': 7.0.15 + + '@eslint/object-schema@3.0.2': {} + + '@eslint/plugin-kit@0.6.0': + dependencies: + '@eslint/core': 1.1.0 + levn: 0.4.1 + + '@humanfs/core@0.19.1': {} + + '@humanfs/node@0.16.7': + dependencies: + '@humanfs/core': 0.19.1 + '@humanwhocodes/retry': 0.4.3 + + '@humanwhocodes/module-importer@1.0.1': {} + + '@humanwhocodes/retry@0.4.3': {} + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@rolldown/pluginutils@1.0.0-rc.2': {} + + '@rollup/rollup-android-arm-eabi@4.59.0': + optional: true + + '@rollup/rollup-android-arm64@4.59.0': + optional: true + + '@rollup/rollup-darwin-arm64@4.59.0': + optional: true + + '@rollup/rollup-darwin-x64@4.59.0': + optional: true + + '@rollup/rollup-freebsd-arm64@4.59.0': + optional: true + + '@rollup/rollup-freebsd-x64@4.59.0': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.59.0': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.59.0': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.59.0': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.59.0': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.59.0': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.59.0': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.59.0': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.59.0': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.59.0': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.59.0': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.59.0': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.59.0': + optional: true + + '@rollup/rollup-linux-x64-musl@4.59.0': + optional: true + + '@rollup/rollup-openbsd-x64@4.59.0': + optional: true + + '@rollup/rollup-openharmony-arm64@4.59.0': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.59.0': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.59.0': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.59.0': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.59.0': + optional: true + + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/deep-eql@4.0.2': {} + + '@types/esrecurse@4.3.1': {} + + '@types/estree@1.0.8': {} + + '@types/json-schema@7.0.15': {} + + '@vitejs/plugin-vue@6.0.4(vite@7.3.1(yaml@2.8.2))(vue@3.5.29)': + dependencies: + '@rolldown/pluginutils': 1.0.0-rc.2 + vite: 7.3.1(yaml@2.8.2) + vue: 3.5.29 + + '@vitest/expect@3.2.4': + dependencies: + '@types/chai': 5.2.3 + '@vitest/spy': 3.2.4 + '@vitest/utils': 3.2.4 + chai: 5.3.3 + tinyrainbow: 2.0.0 + + '@vitest/mocker@3.2.4(vite@7.3.1(yaml@2.8.2))': + dependencies: + '@vitest/spy': 3.2.4 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 7.3.1(yaml@2.8.2) + + '@vitest/pretty-format@3.2.4': + dependencies: + tinyrainbow: 2.0.0 + + '@vitest/runner@3.2.4': + dependencies: + '@vitest/utils': 3.2.4 + pathe: 2.0.3 + strip-literal: 3.1.0 + + '@vitest/snapshot@3.2.4': + dependencies: + '@vitest/pretty-format': 3.2.4 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@3.2.4': + dependencies: + tinyspy: 4.0.4 + + '@vitest/utils@3.2.4': + dependencies: + '@vitest/pretty-format': 3.2.4 + loupe: 3.2.1 + tinyrainbow: 2.0.0 + + '@vue-macros/common@3.1.2(vue@3.5.29)': + dependencies: + '@vue/compiler-sfc': 3.5.29 + ast-kit: 2.2.0 + local-pkg: 1.1.2 + magic-string-ast: 1.0.3 + unplugin-utils: 0.3.1 + optionalDependencies: + vue: 3.5.29 + + '@vue/compiler-core@3.5.29': + dependencies: + '@babel/parser': 7.29.0 + '@vue/shared': 3.5.29 + entities: 7.0.1 + estree-walker: 2.0.2 + source-map-js: 1.2.1 + + '@vue/compiler-dom@3.5.29': + dependencies: + '@vue/compiler-core': 3.5.29 + '@vue/shared': 3.5.29 + + '@vue/compiler-sfc@3.5.29': + dependencies: + '@babel/parser': 7.29.0 + '@vue/compiler-core': 3.5.29 + '@vue/compiler-dom': 3.5.29 + '@vue/compiler-ssr': 3.5.29 + '@vue/shared': 3.5.29 + estree-walker: 2.0.2 + magic-string: 0.30.21 + postcss: 8.5.8 + source-map-js: 1.2.1 + + '@vue/compiler-ssr@3.5.29': + dependencies: + '@vue/compiler-dom': 3.5.29 + '@vue/shared': 3.5.29 + + '@vue/devtools-api@7.7.9': + dependencies: + '@vue/devtools-kit': 7.7.9 + + '@vue/devtools-api@8.0.7': + dependencies: + '@vue/devtools-kit': 8.0.7 + + '@vue/devtools-kit@7.7.9': + dependencies: + '@vue/devtools-shared': 7.7.9 + birpc: 2.9.0 + hookable: 5.5.3 + mitt: 3.0.1 + perfect-debounce: 1.0.0 + speakingurl: 14.0.1 + superjson: 2.2.6 + + '@vue/devtools-kit@8.0.7': + dependencies: + '@vue/devtools-shared': 8.0.7 + birpc: 2.9.0 + hookable: 5.5.3 + perfect-debounce: 2.1.0 + + '@vue/devtools-shared@7.7.9': + dependencies: + rfdc: 1.4.1 + + '@vue/devtools-shared@8.0.7': {} + + '@vue/reactivity@3.5.29': + dependencies: + '@vue/shared': 3.5.29 + + '@vue/runtime-core@3.5.29': + dependencies: + '@vue/reactivity': 3.5.29 + '@vue/shared': 3.5.29 + + '@vue/runtime-dom@3.5.29': + dependencies: + '@vue/reactivity': 3.5.29 + '@vue/runtime-core': 3.5.29 + '@vue/shared': 3.5.29 + csstype: 3.2.3 + + '@vue/server-renderer@3.5.29(vue@3.5.29)': + dependencies: + '@vue/compiler-ssr': 3.5.29 + '@vue/shared': 3.5.29 + vue: 3.5.29 + + '@vue/shared@3.5.29': {} + + acorn-jsx@5.3.2(acorn@8.16.0): + dependencies: + acorn: 8.16.0 + + acorn@8.16.0: {} + + agent-base@7.1.4: {} + + ajv@6.14.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-json-stable-stringify: 2.1.0 + json-schema-traverse: 0.4.1 + uri-js: 4.4.1 + + assertion-error@2.0.1: {} + + ast-kit@2.2.0: + dependencies: + '@babel/parser': 7.29.0 + pathe: 2.0.3 + + ast-walker-scope@0.8.3: + dependencies: + '@babel/parser': 7.29.0 + ast-kit: 2.2.0 + + balanced-match@4.0.4: {} + + birpc@2.9.0: {} + + boolbase@1.0.0: {} + + brace-expansion@5.0.4: + dependencies: + balanced-match: 4.0.4 + + cac@6.7.14: {} + + chai@5.3.3: + dependencies: + assertion-error: 2.0.1 + check-error: 2.1.3 + deep-eql: 5.0.2 + loupe: 3.2.1 + pathval: 2.0.1 + + check-error@2.1.3: {} + + chokidar@5.0.0: + dependencies: + readdirp: 5.0.0 + + confbox@0.1.8: {} + + confbox@0.2.4: {} + + copy-anything@4.0.5: + dependencies: + is-what: 5.5.0 + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + cssesc@3.0.0: {} + + cssstyle@4.6.0: + dependencies: + '@asamuzakjp/css-color': 3.2.0 + rrweb-cssom: 0.8.0 + + csstype@3.2.3: {} + + data-urls@5.0.0: + dependencies: + whatwg-mimetype: 4.0.0 + whatwg-url: 14.2.0 + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + decimal.js@10.6.0: {} + + deep-eql@5.0.2: {} + + deep-is@0.1.4: {} + + entities@6.0.1: {} + + entities@7.0.1: {} + + es-module-lexer@1.7.0: {} + + esbuild@0.27.3: + optionalDependencies: + '@esbuild/aix-ppc64': 0.27.3 + '@esbuild/android-arm': 0.27.3 + '@esbuild/android-arm64': 0.27.3 + '@esbuild/android-x64': 0.27.3 + '@esbuild/darwin-arm64': 0.27.3 + '@esbuild/darwin-x64': 0.27.3 + '@esbuild/freebsd-arm64': 0.27.3 + '@esbuild/freebsd-x64': 0.27.3 + '@esbuild/linux-arm': 0.27.3 + '@esbuild/linux-arm64': 0.27.3 + '@esbuild/linux-ia32': 0.27.3 + '@esbuild/linux-loong64': 0.27.3 + '@esbuild/linux-mips64el': 0.27.3 + '@esbuild/linux-ppc64': 0.27.3 + '@esbuild/linux-riscv64': 0.27.3 + '@esbuild/linux-s390x': 0.27.3 + '@esbuild/linux-x64': 0.27.3 + '@esbuild/netbsd-arm64': 0.27.3 + '@esbuild/netbsd-x64': 0.27.3 + '@esbuild/openbsd-arm64': 0.27.3 + '@esbuild/openbsd-x64': 0.27.3 + '@esbuild/openharmony-arm64': 0.27.3 + '@esbuild/sunos-x64': 0.27.3 + '@esbuild/win32-arm64': 0.27.3 + '@esbuild/win32-ia32': 0.27.3 + '@esbuild/win32-x64': 0.27.3 + + escape-string-regexp@4.0.0: {} + + eslint-config-prettier@10.1.8(eslint@10.0.2): + dependencies: + eslint: 10.0.2 + + eslint-plugin-vue@10.8.0(eslint@10.0.2)(vue-eslint-parser@10.4.0(eslint@10.0.2)): + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@10.0.2) + eslint: 10.0.2 + natural-compare: 1.4.0 + nth-check: 2.1.1 + postcss-selector-parser: 7.1.1 + semver: 7.7.4 + vue-eslint-parser: 10.4.0(eslint@10.0.2) + xml-name-validator: 4.0.0 + + eslint-scope@9.1.1: + dependencies: + '@types/esrecurse': 4.3.1 + '@types/estree': 1.0.8 + esrecurse: 4.3.0 + estraverse: 5.3.0 + + eslint-visitor-keys@3.4.3: {} + + eslint-visitor-keys@5.0.1: {} + + eslint@10.0.2: + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@10.0.2) + '@eslint-community/regexpp': 4.12.2 + '@eslint/config-array': 0.23.2 + '@eslint/config-helpers': 0.5.2 + '@eslint/core': 1.1.0 + '@eslint/plugin-kit': 0.6.0 + '@humanfs/node': 0.16.7 + '@humanwhocodes/module-importer': 1.0.1 + '@humanwhocodes/retry': 0.4.3 + '@types/estree': 1.0.8 + ajv: 6.14.0 + cross-spawn: 7.0.6 + debug: 4.4.3 + escape-string-regexp: 4.0.0 + eslint-scope: 9.1.1 + eslint-visitor-keys: 5.0.1 + espree: 11.1.1 + esquery: 1.7.0 + esutils: 2.0.3 + fast-deep-equal: 3.1.3 + file-entry-cache: 8.0.0 + find-up: 5.0.0 + glob-parent: 6.0.2 + ignore: 5.3.2 + imurmurhash: 0.1.4 + is-glob: 4.0.3 + json-stable-stringify-without-jsonify: 1.0.1 + minimatch: 10.2.4 + natural-compare: 1.4.0 + optionator: 0.9.4 + transitivePeerDependencies: + - supports-color + + espree@11.1.1: + dependencies: + acorn: 8.16.0 + acorn-jsx: 5.3.2(acorn@8.16.0) + eslint-visitor-keys: 5.0.1 + + esquery@1.7.0: + dependencies: + estraverse: 5.3.0 + + esrecurse@4.3.0: + dependencies: + estraverse: 5.3.0 + + estraverse@5.3.0: {} + + estree-walker@2.0.2: {} + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.8 + + esutils@2.0.3: {} + + expect-type@1.3.0: {} + + exsolve@1.0.8: {} + + fast-deep-equal@3.1.3: {} + + fast-json-stable-stringify@2.1.0: {} + + fast-levenshtein@2.0.6: {} + + fdir@6.5.0(picomatch@4.0.3): + optionalDependencies: + picomatch: 4.0.3 + + file-entry-cache@8.0.0: + dependencies: + flat-cache: 4.0.1 + + find-up@5.0.0: + dependencies: + locate-path: 6.0.0 + path-exists: 4.0.0 + + flat-cache@4.0.1: + dependencies: + flatted: 3.3.4 + keyv: 4.5.4 + + flatted@3.3.4: {} + + fsevents@2.3.3: + optional: true + + glob-parent@6.0.2: + dependencies: + is-glob: 4.0.3 + + hookable@5.5.3: {} + + html-encoding-sniffer@4.0.0: + dependencies: + whatwg-encoding: 3.1.1 + + http-proxy-agent@7.0.2: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + https-proxy-agent@7.0.6: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + iconv-lite@0.6.3: + dependencies: + safer-buffer: 2.1.2 + + ignore@5.3.2: {} + + imurmurhash@0.1.4: {} + + is-extglob@2.1.1: {} + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + is-potential-custom-element-name@1.0.1: {} + + is-what@5.5.0: {} + + isexe@2.0.0: {} + + js-tokens@9.0.1: {} + + jsdom@26.1.0: + dependencies: + cssstyle: 4.6.0 + data-urls: 5.0.0 + decimal.js: 10.6.0 + html-encoding-sniffer: 4.0.0 + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.6 + is-potential-custom-element-name: 1.0.1 + nwsapi: 2.2.23 + parse5: 7.3.0 + rrweb-cssom: 0.8.0 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 5.1.2 + w3c-xmlserializer: 5.0.0 + webidl-conversions: 7.0.0 + whatwg-encoding: 3.1.1 + whatwg-mimetype: 4.0.0 + whatwg-url: 14.2.0 + ws: 8.19.0 + xml-name-validator: 5.0.0 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + jsesc@3.1.0: {} + + json-buffer@3.0.1: {} + + json-schema-traverse@0.4.1: {} + + json-stable-stringify-without-jsonify@1.0.1: {} + + json5@2.2.3: {} + + keyv@4.5.4: + dependencies: + json-buffer: 3.0.1 + + levn@0.4.1: + dependencies: + prelude-ls: 1.2.1 + type-check: 0.4.0 + + local-pkg@1.1.2: + dependencies: + mlly: 1.8.0 + pkg-types: 2.3.0 + quansync: 0.2.11 + + locate-path@6.0.0: + dependencies: + p-locate: 5.0.0 + + loupe@3.2.1: {} + + lru-cache@10.4.3: {} + + magic-string-ast@1.0.3: + dependencies: + magic-string: 0.30.21 + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + minimatch@10.2.4: + dependencies: + brace-expansion: 5.0.4 + + mitt@3.0.1: {} + + mlly@1.8.0: + dependencies: + acorn: 8.16.0 + pathe: 2.0.3 + pkg-types: 1.3.1 + ufo: 1.6.3 + + ms@2.1.3: {} + + muggle-string@0.4.1: {} + + nanoid@3.3.11: {} + + natural-compare@1.4.0: {} + + nth-check@2.1.1: + dependencies: + boolbase: 1.0.0 + + nwsapi@2.2.23: {} + + optionator@0.9.4: + dependencies: + deep-is: 0.1.4 + fast-levenshtein: 2.0.6 + levn: 0.4.1 + prelude-ls: 1.2.1 + type-check: 0.4.0 + word-wrap: 1.2.5 + + p-limit@3.1.0: + dependencies: + yocto-queue: 0.1.0 + + p-locate@5.0.0: + dependencies: + p-limit: 3.1.0 + + parse5@7.3.0: + dependencies: + entities: 6.0.1 + + path-exists@4.0.0: {} + + path-key@3.1.1: {} + + pathe@2.0.3: {} + + pathval@2.0.1: {} + + perfect-debounce@1.0.0: {} + + perfect-debounce@2.1.0: {} + + picocolors@1.1.1: {} + + picomatch@4.0.3: {} + + pinia@3.0.4(vue@3.5.29): + dependencies: + '@vue/devtools-api': 7.7.9 + vue: 3.5.29 + + pkg-types@1.3.1: + dependencies: + confbox: 0.1.8 + mlly: 1.8.0 + pathe: 2.0.3 + + pkg-types@2.3.0: + dependencies: + confbox: 0.2.4 + exsolve: 1.0.8 + pathe: 2.0.3 + + postcss-selector-parser@7.1.1: + dependencies: + cssesc: 3.0.0 + util-deprecate: 1.0.2 + + postcss@8.5.8: + dependencies: + nanoid: 3.3.11 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + prelude-ls@1.2.1: {} + + prettier@3.8.1: {} + + punycode@2.3.1: {} + + quansync@0.2.11: {} + + readdirp@5.0.0: {} + + rfdc@1.4.1: {} + + rollup@4.59.0: + dependencies: + '@types/estree': 1.0.8 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.59.0 + '@rollup/rollup-android-arm64': 4.59.0 + '@rollup/rollup-darwin-arm64': 4.59.0 + '@rollup/rollup-darwin-x64': 4.59.0 + '@rollup/rollup-freebsd-arm64': 4.59.0 + '@rollup/rollup-freebsd-x64': 4.59.0 + '@rollup/rollup-linux-arm-gnueabihf': 4.59.0 + '@rollup/rollup-linux-arm-musleabihf': 4.59.0 + '@rollup/rollup-linux-arm64-gnu': 4.59.0 + '@rollup/rollup-linux-arm64-musl': 4.59.0 + '@rollup/rollup-linux-loong64-gnu': 4.59.0 + '@rollup/rollup-linux-loong64-musl': 4.59.0 + '@rollup/rollup-linux-ppc64-gnu': 4.59.0 + '@rollup/rollup-linux-ppc64-musl': 4.59.0 + '@rollup/rollup-linux-riscv64-gnu': 4.59.0 + '@rollup/rollup-linux-riscv64-musl': 4.59.0 + '@rollup/rollup-linux-s390x-gnu': 4.59.0 + '@rollup/rollup-linux-x64-gnu': 4.59.0 + '@rollup/rollup-linux-x64-musl': 4.59.0 + '@rollup/rollup-openbsd-x64': 4.59.0 + '@rollup/rollup-openharmony-arm64': 4.59.0 + '@rollup/rollup-win32-arm64-msvc': 4.59.0 + '@rollup/rollup-win32-ia32-msvc': 4.59.0 + '@rollup/rollup-win32-x64-gnu': 4.59.0 + '@rollup/rollup-win32-x64-msvc': 4.59.0 + fsevents: 2.3.3 + + rrweb-cssom@0.8.0: {} + + safer-buffer@2.1.2: {} + + saxes@6.0.0: + dependencies: + xmlchars: 2.2.0 + + scule@1.3.0: {} + + semver@7.7.4: {} + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + siginfo@2.0.0: {} + + source-map-js@1.2.1: {} + + speakingurl@14.0.1: {} + + stackback@0.0.2: {} + + std-env@3.10.0: {} + + strip-literal@3.1.0: + dependencies: + js-tokens: 9.0.1 + + superjson@2.2.6: + dependencies: + copy-anything: 4.0.5 + + symbol-tree@3.2.4: {} + + tinybench@2.9.0: {} + + tinyexec@0.3.2: {} + + tinyglobby@0.2.15: + dependencies: + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + + tinypool@1.1.1: {} + + tinyrainbow@2.0.0: {} + + tinyspy@4.0.4: {} + + tldts-core@6.1.86: {} + + tldts@6.1.86: + dependencies: + tldts-core: 6.1.86 + + tough-cookie@5.1.2: + dependencies: + tldts: 6.1.86 + + tr46@5.1.1: + dependencies: + punycode: 2.3.1 + + type-check@0.4.0: + dependencies: + prelude-ls: 1.2.1 + + ufo@1.6.3: {} + + unplugin-utils@0.3.1: + dependencies: + pathe: 2.0.3 + picomatch: 4.0.3 + + unplugin@3.0.0: + dependencies: + '@jridgewell/remapping': 2.3.5 + picomatch: 4.0.3 + webpack-virtual-modules: 0.6.2 + + uri-js@4.4.1: + dependencies: + punycode: 2.3.1 + + util-deprecate@1.0.2: {} + + vite-node@3.2.4(yaml@2.8.2): + dependencies: + cac: 6.7.14 + debug: 4.4.3 + es-module-lexer: 1.7.0 + pathe: 2.0.3 + vite: 7.3.1(yaml@2.8.2) + transitivePeerDependencies: + - '@types/node' + - jiti + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + + vite@7.3.1(yaml@2.8.2): + dependencies: + esbuild: 0.27.3 + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + postcss: 8.5.8 + rollup: 4.59.0 + tinyglobby: 0.2.15 + optionalDependencies: + fsevents: 2.3.3 + yaml: 2.8.2 + + vitest@3.2.4(jsdom@26.1.0)(yaml@2.8.2): + dependencies: + '@types/chai': 5.2.3 + '@vitest/expect': 3.2.4 + '@vitest/mocker': 3.2.4(vite@7.3.1(yaml@2.8.2)) + '@vitest/pretty-format': 3.2.4 + '@vitest/runner': 3.2.4 + '@vitest/snapshot': 3.2.4 + '@vitest/spy': 3.2.4 + '@vitest/utils': 3.2.4 + chai: 5.3.3 + debug: 4.4.3 + expect-type: 1.3.0 + magic-string: 0.30.21 + pathe: 2.0.3 + picomatch: 4.0.3 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinyglobby: 0.2.15 + tinypool: 1.1.1 + tinyrainbow: 2.0.0 + vite: 7.3.1(yaml@2.8.2) + vite-node: 3.2.4(yaml@2.8.2) + why-is-node-running: 2.3.0 + optionalDependencies: + jsdom: 26.1.0 + transitivePeerDependencies: + - jiti + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + + vue-eslint-parser@10.4.0(eslint@10.0.2): + dependencies: + debug: 4.4.3 + eslint: 10.0.2 + eslint-scope: 9.1.1 + eslint-visitor-keys: 5.0.1 + espree: 11.1.1 + esquery: 1.7.0 + semver: 7.7.4 + transitivePeerDependencies: + - supports-color + + vue-router@5.0.3(@vue/compiler-sfc@3.5.29)(pinia@3.0.4(vue@3.5.29))(vue@3.5.29): + dependencies: + '@babel/generator': 7.29.1 + '@vue-macros/common': 3.1.2(vue@3.5.29) + '@vue/devtools-api': 8.0.7 + ast-walker-scope: 0.8.3 + chokidar: 5.0.0 + json5: 2.2.3 + local-pkg: 1.1.2 + magic-string: 0.30.21 + mlly: 1.8.0 + muggle-string: 0.4.1 + pathe: 2.0.3 + picomatch: 4.0.3 + scule: 1.3.0 + tinyglobby: 0.2.15 + unplugin: 3.0.0 + unplugin-utils: 0.3.1 + vue: 3.5.29 + yaml: 2.8.2 + optionalDependencies: + '@vue/compiler-sfc': 3.5.29 + pinia: 3.0.4(vue@3.5.29) + + vue@3.5.29: + dependencies: + '@vue/compiler-dom': 3.5.29 + '@vue/compiler-sfc': 3.5.29 + '@vue/runtime-dom': 3.5.29 + '@vue/server-renderer': 3.5.29(vue@3.5.29) + '@vue/shared': 3.5.29 + + w3c-xmlserializer@5.0.0: + dependencies: + xml-name-validator: 5.0.0 + + webidl-conversions@7.0.0: {} + + webpack-virtual-modules@0.6.2: {} + + whatwg-encoding@3.1.1: + dependencies: + iconv-lite: 0.6.3 + + whatwg-mimetype@4.0.0: {} + + whatwg-url@14.2.0: + dependencies: + tr46: 5.1.1 + webidl-conversions: 7.0.0 + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + + word-wrap@1.2.5: {} + + ws@8.19.0: {} + + xml-name-validator@4.0.0: {} + + xml-name-validator@5.0.0: {} + + xmlchars@2.2.0: {} + + yaml@2.8.2: {} + + yocto-queue@0.1.0: {} diff --git a/admin/ui/src/App.vue b/admin/ui/src/App.vue new file mode 100644 index 0000000..7a7f92a --- /dev/null +++ b/admin/ui/src/App.vue @@ -0,0 +1,10 @@ + + + diff --git a/admin/ui/src/assets/global.css b/admin/ui/src/assets/global.css new file mode 100644 index 0000000..9567398 --- /dev/null +++ b/admin/ui/src/assets/global.css @@ -0,0 +1,40 @@ +*, +*::before, +*::after { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +html { + font-size: 16px; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +body { + font-family: var(--font-family); + font-size: var(--font-size-sm); + line-height: var(--line-height-normal); + color: var(--color-text-primary); + background-color: var(--color-bg-primary); +} + +a { + color: var(--color-accent); + text-decoration: none; +} + +a:hover { + text-decoration: underline; +} + +button { + cursor: pointer; + font-family: inherit; +} + +:focus-visible { + outline: 2px solid var(--color-accent); + outline-offset: 2px; +} diff --git a/admin/ui/src/assets/variables.css b/admin/ui/src/assets/variables.css new file mode 100644 index 0000000..6d9b4fe --- /dev/null +++ b/admin/ui/src/assets/variables.css @@ -0,0 +1,86 @@ +:root { + /* Colors — Background */ + --color-bg-primary: #f8f9fc; + --color-bg-surface: #ffffff; + --color-bg-sidebar: #0f1729; + --color-bg-sidebar-hover: #1a2340; + --color-bg-sidebar-active: rgba(59, 130, 246, 0.15); + + /* Colors — Accent */ + --color-accent: #3b82f6; + --color-accent-light: #dbeafe; + --color-accent-hover: #2563eb; + + /* Colors — Text */ + --color-text-primary: #111827; + --color-text-secondary: #6b7280; + --color-text-tertiary: #9ca3af; + --color-text-sidebar: #94a3b8; + --color-text-sidebar-active: #ffffff; + --color-text-inverse: #ffffff; + + /* Colors — Status */ + --color-success: #059669; + --color-success-bg: #ecfdf5; + --color-success-border: #a7f3d0; + --color-warning: #f59e0b; + --color-warning-bg: #fffbeb; + --color-warning-border: #fde68a; + --color-error: #dc2626; + --color-error-bg: #fef2f2; + --color-error-hover: #b91c1c; + --color-error-border: #fecaca; + + /* Colors — Border */ + --color-border: #e5e7eb; + --color-border-light: #f3f4f6; + + /* Typography */ + --font-family: + -apple-system, BlinkMacSystemFont, "Segoe UI", "Inter", Roboto, sans-serif; + --font-family-mono: "SF Mono", "Fira Code", "Cascadia Code", monospace; + + --font-size-xs: 0.6875rem; /* 11px */ + --font-size-sm: 0.8125rem; /* 13px */ + --font-size-base: 0.875rem; /* 14px */ + --font-size-md: 0.9375rem; /* 15px */ + --font-size-lg: 1.125rem; /* 18px */ + --font-size-xl: 1.375rem; /* 22px */ + --font-size-2xl: 1.75rem; /* 28px */ + + --font-weight-normal: 400; + --font-weight-medium: 500; + --font-weight-semibold: 600; + --font-weight-bold: 700; + + --line-height-tight: 1.4; + --line-height-normal: 1.5; + --line-height-relaxed: 1.6; + + /* Spacing */ + --space-1: 0.25rem; /* 4px */ + --space-2: 0.5rem; /* 8px */ + --space-3: 0.75rem; /* 12px */ + --space-4: 1rem; /* 16px */ + --space-5: 1.25rem; /* 20px */ + --space-6: 1.5rem; /* 24px */ + --space-8: 2rem; /* 32px */ + + /* Border Radius */ + --radius-sm: 4px; + --radius-md: 6px; + --radius-lg: 8px; + --radius-xl: 12px; + --radius-2xl: 16px; + + /* Shadows */ + --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05); + --shadow-md: + 0 4px 6px -1px rgba(0, 0, 0, 0.07), 0 2px 4px -1px rgba(0, 0, 0, 0.04); + --shadow-lg: + 0 10px 15px -3px rgba(0, 0, 0, 0.08), 0 4px 6px -2px rgba(0, 0, 0, 0.04); + --shadow-modal: 0 25px 50px -12px rgba(0, 0, 0, 0.25); + + /* Layout */ + --sidebar-width: 240px; +} diff --git a/admin/ui/src/components/BaseButton.vue b/admin/ui/src/components/BaseButton.vue new file mode 100644 index 0000000..086adf4 --- /dev/null +++ b/admin/ui/src/components/BaseButton.vue @@ -0,0 +1,90 @@ + + + + + diff --git a/admin/ui/src/components/BaseCard.vue b/admin/ui/src/components/BaseCard.vue new file mode 100644 index 0000000..131890b --- /dev/null +++ b/admin/ui/src/components/BaseCard.vue @@ -0,0 +1,32 @@ + + + + + diff --git a/admin/ui/src/components/BaseEmptyState.vue b/admin/ui/src/components/BaseEmptyState.vue new file mode 100644 index 0000000..2a92ff5 --- /dev/null +++ b/admin/ui/src/components/BaseEmptyState.vue @@ -0,0 +1,55 @@ + + + + + diff --git a/admin/ui/src/components/BaseInput.vue b/admin/ui/src/components/BaseInput.vue new file mode 100644 index 0000000..7e4a915 --- /dev/null +++ b/admin/ui/src/components/BaseInput.vue @@ -0,0 +1,117 @@ + + + + + diff --git a/admin/ui/src/components/StatusIndicator.vue b/admin/ui/src/components/StatusIndicator.vue new file mode 100644 index 0000000..07c8af9 --- /dev/null +++ b/admin/ui/src/components/StatusIndicator.vue @@ -0,0 +1,84 @@ + + + + + diff --git a/admin/ui/src/layouts/AppLayout.vue b/admin/ui/src/layouts/AppLayout.vue new file mode 100644 index 0000000..7ba53ae --- /dev/null +++ b/admin/ui/src/layouts/AppLayout.vue @@ -0,0 +1,167 @@ + + + + + diff --git a/admin/ui/src/main.js b/admin/ui/src/main.js new file mode 100644 index 0000000..e9b5ead --- /dev/null +++ b/admin/ui/src/main.js @@ -0,0 +1,11 @@ +import { createApp } from "vue"; +import { createPinia } from "pinia"; +import App from "./App.vue"; +import router from "./router"; +import "./assets/variables.css"; +import "./assets/global.css"; + +const app = createApp(App); +app.use(createPinia()); +app.use(router); +app.mount("#app"); diff --git a/admin/ui/src/router/index.js b/admin/ui/src/router/index.js new file mode 100644 index 0000000..0804c53 --- /dev/null +++ b/admin/ui/src/router/index.js @@ -0,0 +1,28 @@ +import { createRouter, createWebHistory } from "vue-router"; +import DashboardView from "../views/DashboardView.vue"; +import AuditLogView from "../views/AuditLogView.vue"; + +const routes = [ + { + path: "/", + name: "dashboard", + component: DashboardView, + }, + { + path: "/audit-log", + name: "audit-log", + component: AuditLogView, + }, + { + path: "/:pathMatch(.*)*", + name: "not-found", + redirect: "/", + }, +]; + +const router = createRouter({ + history: createWebHistory(), + routes, +}); + +export default router; diff --git a/admin/ui/src/views/AuditLogView.vue b/admin/ui/src/views/AuditLogView.vue new file mode 100644 index 0000000..b392f56 --- /dev/null +++ b/admin/ui/src/views/AuditLogView.vue @@ -0,0 +1,66 @@ + + + + + diff --git a/admin/ui/src/views/DashboardView.vue b/admin/ui/src/views/DashboardView.vue new file mode 100644 index 0000000..cbd2293 --- /dev/null +++ b/admin/ui/src/views/DashboardView.vue @@ -0,0 +1,64 @@ + + + + + diff --git a/admin/ui/vite.config.js b/admin/ui/vite.config.js new file mode 100644 index 0000000..e20f4e8 --- /dev/null +++ b/admin/ui/vite.config.js @@ -0,0 +1,22 @@ +import { defineConfig } from "vite"; +import vue from "@vitejs/plugin-vue"; + +export default defineConfig({ + plugins: [vue()], + test: { + environment: "jsdom", + }, + server: { + port: 5173, + proxy: { + "/api": { + target: "http://127.0.0.1:8080", + changeOrigin: true, + }, + }, + }, + build: { + outDir: "dist", + emptyOutDir: true, + }, +}); From 8ae12320530cb4597309445b5e40c2d715e74e5c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADctor=20Cuevas?= Date: Wed, 4 Mar 2026 23:08:40 +0100 Subject: [PATCH 03/36] chore: add admin portal CI workflow - Lint (Go + ESLint), test (Go + Vitest), build (Go + Vite) - Path-scoped triggers on admin/** changes only --- .github/workflows/ci-admin.yml | 142 +++++++++++++++++++++++++++++++++ 1 file changed, 142 insertions(+) create mode 100644 .github/workflows/ci-admin.yml diff --git a/.github/workflows/ci-admin.yml b/.github/workflows/ci-admin.yml new file mode 100644 index 0000000..4382a1b --- /dev/null +++ b/.github/workflows/ci-admin.yml @@ -0,0 +1,142 @@ +# Copyright 2026 CloudBlue LLC +# SPDX-License-Identifier: Apache-2.0 + +name: CI (Admin Portal) + +on: + push: + branches: + - master + - 'release/**' + paths: + - 'admin/**' + - '.github/workflows/ci-admin.yml' + pull_request: + branches: + - master + - 'release/**' + paths: + - 'admin/**' + - '.github/workflows/ci-admin.yml' + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +env: + GOLANGCI_LINT_VERSION: 'v2.8.0' + +jobs: + lint-go: + name: Lint (Go) + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Set up Go + uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0 + with: + go-version-file: admin/go.mod + cache-dependency-path: admin/go.sum + + - name: Run golangci-lint (admin module) + uses: golangci/golangci-lint-action@1e7e51e771db61008b38414a730f564565cf7c20 # v9.2.0 + with: + version: ${{ env.GOLANGCI_LINT_VERSION }} + working-directory: admin + + lint-ui: + name: Lint (UI) + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Set up Node.js + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: 24 + + - name: Install pnpm + run: corepack enable && corepack prepare pnpm@latest --activate + + - name: Install dependencies + working-directory: admin/ui + run: pnpm install --frozen-lockfile + + - name: Lint + working-directory: admin/ui + run: pnpm lint + + test-go: + name: Test (Go) + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Set up Go + uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0 + with: + go-version-file: admin/go.mod + cache-dependency-path: admin/go.sum + + - name: Run tests + run: cd admin && go test -race ./... + + test-ui: + name: Test (UI) + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Set up Node.js + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: 24 + + - name: Install pnpm + run: corepack enable && corepack prepare pnpm@latest --activate + + - name: Install dependencies + working-directory: admin/ui + run: pnpm install --frozen-lockfile + + - name: Run tests + working-directory: admin/ui + run: pnpm test --passWithNoTests + + build: + name: Build + runs-on: ubuntu-latest + needs: [lint-go, lint-ui, test-go, test-ui] + steps: + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Set up Go + uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0 + with: + go-version-file: admin/go.mod + cache-dependency-path: admin/go.sum + + - name: Set up Node.js + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: 24 + + - name: Install pnpm + run: corepack enable && corepack prepare pnpm@latest --activate + + - name: Build admin portal + run: make build-admin + + - name: Verify binary exists + run: | + test -f bin/chaperone-admin + ./bin/chaperone-admin --help || true From 3c4d696bf54518072fe1395e70aea6b0cb14d21d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADctor=20Cuevas?= Date: Thu, 5 Mar 2026 00:03:02 +0100 Subject: [PATCH 04/36] ci: stabilize admin workflow test and tooling paths --- .github/workflows/ci-admin.yml | 18 +++++++++++------- Makefile | 8 ++++---- 2 files changed, 15 insertions(+), 11 deletions(-) diff --git a/.github/workflows/ci-admin.yml b/.github/workflows/ci-admin.yml index 4382a1b..70c4ed5 100644 --- a/.github/workflows/ci-admin.yml +++ b/.github/workflows/ci-admin.yml @@ -10,13 +10,12 @@ on: - 'release/**' paths: - 'admin/**' + - 'Makefile' - '.github/workflows/ci-admin.yml' pull_request: - branches: - - master - - 'release/**' paths: - 'admin/**' + - 'Makefile' - '.github/workflows/ci-admin.yml' concurrency: @@ -28,6 +27,7 @@ permissions: env: GOLANGCI_LINT_VERSION: 'v2.8.0' + PNPM_VERSION: '10.28.2' jobs: lint-go: @@ -44,10 +44,13 @@ jobs: cache-dependency-path: admin/go.sum - name: Run golangci-lint (admin module) + # The admin module embeds ui/dist in !dev builds; use dev tags for + # backend lint/test so checks do not depend on prebuilt UI artifacts. uses: golangci/golangci-lint-action@1e7e51e771db61008b38414a730f564565cf7c20 # v9.2.0 with: version: ${{ env.GOLANGCI_LINT_VERSION }} working-directory: admin + args: --build-tags=dev lint-ui: name: Lint (UI) @@ -62,7 +65,7 @@ jobs: node-version: 24 - name: Install pnpm - run: corepack enable && corepack prepare pnpm@latest --activate + run: corepack enable && corepack prepare pnpm@${{ env.PNPM_VERSION }} --activate - name: Install dependencies working-directory: admin/ui @@ -86,7 +89,8 @@ jobs: cache-dependency-path: admin/go.sum - name: Run tests - run: cd admin && go test -race ./... + # Keep Go test path independent from ui/dist embed requirements. + run: cd admin && go test -race -tags dev ./... test-ui: name: Test (UI) @@ -101,7 +105,7 @@ jobs: node-version: 24 - name: Install pnpm - run: corepack enable && corepack prepare pnpm@latest --activate + run: corepack enable && corepack prepare pnpm@${{ env.PNPM_VERSION }} --activate - name: Install dependencies working-directory: admin/ui @@ -131,7 +135,7 @@ jobs: node-version: 24 - name: Install pnpm - run: corepack enable && corepack prepare pnpm@latest --activate + run: corepack enable && corepack prepare pnpm@${{ env.PNPM_VERSION }} --activate - name: Build admin portal run: make build-admin diff --git a/Makefile b/Makefile index 8b2c57d..8f0dbe3 100644 --- a/Makefile +++ b/Makefile @@ -144,28 +144,28 @@ test: ## Run tests (all modules) go test -v ./... cd sdk && go test -v ./... cd plugins/contrib && go test -v ./... - cd admin && go test -v ./... + cd admin && go test -tags dev -v ./... .PHONY: test-race test-race: ## Run tests with race detector go test -race -v ./... cd sdk && go test -race -v ./... cd plugins/contrib && go test -race -v ./... - cd admin && go test -race -v ./... + cd admin && go test -race -tags dev -v ./... .PHONY: test-cover test-cover: ## Run tests with coverage go test -coverprofile=coverage.out ./... cd sdk && go test -coverprofile=coverage-sdk.out ./... cd plugins/contrib && go test -coverprofile=coverage-contrib.out ./... - cd admin && go test -coverprofile=coverage-admin.out ./... + cd admin && go test -tags dev -coverprofile=coverage-admin.out ./... go tool cover -html=coverage.out -o coverage.html @echo "Coverage report: coverage.html" .PHONY: test-short test-short: ## Run short tests only go test -short -v ./... - cd admin && go test -short -v ./... + cd admin && go test -short -tags dev -v ./... .PHONY: test-integration test-integration: ## Run integration tests From 8e73a6a22e7dce31e15ecceb2d4808106843ec27 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADctor=20Cuevas?= Date: Thu, 5 Mar 2026 00:24:02 +0100 Subject: [PATCH 05/36] fix admin: use pointer for RetentionDays to distinguish zero from unset MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RetentionDays as int made it impossible to set retention_days: 0 (keep forever) in YAML — the zero value was indistinguishable from omission and silently became 90. Change to *int so nil means unset (gets default 90) while &0 means explicitly keep forever. Add regression test. --- admin/config/config.go | 2 +- admin/config/loader.go | 8 ++++-- admin/config/loader_test.go | 53 +++++++++++++++++++++++++++++------ admin/config/validate.go | 2 +- admin/config/validate_test.go | 10 +++---- 5 files changed, 57 insertions(+), 18 deletions(-) diff --git a/admin/config/config.go b/admin/config/config.go index 2d9d0db..316988c 100644 --- a/admin/config/config.go +++ b/admin/config/config.go @@ -47,7 +47,7 @@ type SessionConfig struct { // AuditConfig configures the audit log. type AuditConfig struct { - RetentionDays int `yaml:"retention_days"` + RetentionDays *int `yaml:"retention_days"` } // LogConfig configures structured logging. diff --git a/admin/config/loader.go b/admin/config/loader.go index 400fd37..68b817b 100644 --- a/admin/config/loader.go +++ b/admin/config/loader.go @@ -80,8 +80,8 @@ func applyDefaults(cfg *Config) { if cfg.Session.IdleTimeout == 0 { cfg.Session.IdleTimeout = Duration(2 * time.Hour) } - if cfg.Audit.RetentionDays == 0 { - cfg.Audit.RetentionDays = 90 + if cfg.Audit.RetentionDays == nil { + cfg.Audit.RetentionDays = intPtr(90) } if cfg.Log.Level == "" { cfg.Log.Level = "info" @@ -137,7 +137,7 @@ func applyEnvOverrides(cfg *Config) error { if err != nil { errs = append(errs, fmt.Errorf("AUDIT_RETENTION_DAYS: %w", err)) } else { - cfg.Audit.RetentionDays = n + cfg.Audit.RetentionDays = &n } } if v := getEnv("LOG_LEVEL"); v != "" { @@ -153,3 +153,5 @@ func applyEnvOverrides(cfg *Config) error { func getEnv(key string) string { return os.Getenv(EnvPrefix + "_" + key) } + +func intPtr(v int) *int { return &v } diff --git a/admin/config/loader_test.go b/admin/config/loader_test.go index 1a8dca6..ac513e7 100644 --- a/admin/config/loader_test.go +++ b/admin/config/loader_test.go @@ -51,8 +51,8 @@ func TestLoad_NoFile_AppliesDefaults(t *testing.T) { if cfg.Session.IdleTimeout.Unwrap() != 2*time.Hour { t.Errorf("Session.IdleTimeout = %v, want %v", cfg.Session.IdleTimeout.Unwrap(), 2*time.Hour) } - if cfg.Audit.RetentionDays != 90 { - t.Errorf("Audit.RetentionDays = %d, want %d", cfg.Audit.RetentionDays, 90) + if *cfg.Audit.RetentionDays != 90 { + t.Errorf("Audit.RetentionDays = %d, want %d", *cfg.Audit.RetentionDays, 90) } if cfg.Log.Level != "info" { t.Errorf("Log.Level = %q, want %q", cfg.Log.Level, "info") @@ -109,8 +109,8 @@ log: if cfg.Session.IdleTimeout.Unwrap() != 1*time.Hour { t.Errorf("Session.IdleTimeout = %v, want %v", cfg.Session.IdleTimeout.Unwrap(), 1*time.Hour) } - if cfg.Audit.RetentionDays != 30 { - t.Errorf("Audit.RetentionDays = %d, want %d", cfg.Audit.RetentionDays, 30) + if *cfg.Audit.RetentionDays != 30 { + t.Errorf("Audit.RetentionDays = %d, want %d", *cfg.Audit.RetentionDays, 30) } if cfg.Log.Level != "debug" { t.Errorf("Log.Level = %q, want %q", cfg.Log.Level, "debug") @@ -120,6 +120,43 @@ log: } } +func TestLoad_ZeroRetention_PreservedAsKeepForever(t *testing.T) { + t.Parallel() + + // Arrange — explicit retention_days: 0 means "keep forever" + path := writeTestConfig(t, ` +server: + addr: "127.0.0.1:8080" +database: + path: "./test.db" +scraper: + interval: "10s" + timeout: "5s" +session: + max_age: "24h" + idle_timeout: "2h" +audit: + retention_days: 0 +log: + level: "info" + format: "json" +`) + + // Act + cfg, err := Load(path) + + // Assert + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if cfg.Audit.RetentionDays == nil { + t.Fatal("Audit.RetentionDays is nil, want 0") + } + if *cfg.Audit.RetentionDays != 0 { + t.Errorf("Audit.RetentionDays = %d, want 0 (keep forever)", *cfg.Audit.RetentionDays) + } +} + func TestLoad_EnvOverrides_AllFields(t *testing.T) { // Not parallel — modifies environment via t.Setenv. @@ -160,8 +197,8 @@ func TestLoad_EnvOverrides_AllFields(t *testing.T) { if cfg.Session.IdleTimeout.Unwrap() != 4*time.Hour { t.Errorf("Session.IdleTimeout = %v, want %v", cfg.Session.IdleTimeout.Unwrap(), 4*time.Hour) } - if cfg.Audit.RetentionDays != 60 { - t.Errorf("Audit.RetentionDays = %d, want %d", cfg.Audit.RetentionDays, 60) + if *cfg.Audit.RetentionDays != 60 { + t.Errorf("Audit.RetentionDays = %d, want %d", *cfg.Audit.RetentionDays, 60) } if cfg.Log.Level != "warn" { t.Errorf("Log.Level = %q, want %q", cfg.Log.Level, "warn") @@ -230,8 +267,8 @@ func TestApplyDefaults_ZeroConfig_SetsAllDefaults(t *testing.T) { if cfg.Session.IdleTimeout.Unwrap() != 2*time.Hour { t.Errorf("Session.IdleTimeout = %v, want 2h", cfg.Session.IdleTimeout.Unwrap()) } - if cfg.Audit.RetentionDays != 90 { - t.Errorf("Audit.RetentionDays = %d, want 90", cfg.Audit.RetentionDays) + if *cfg.Audit.RetentionDays != 90 { + t.Errorf("Audit.RetentionDays = %d, want 90", *cfg.Audit.RetentionDays) } if cfg.Log.Level != "info" { t.Errorf("Log.Level = %q, want %q", cfg.Log.Level, "info") diff --git a/admin/config/validate.go b/admin/config/validate.go index d78f4e2..ec73df9 100644 --- a/admin/config/validate.go +++ b/admin/config/validate.go @@ -41,7 +41,7 @@ func (c *Config) Validate() error { errs = append(errs, errors.New("session.idle_timeout must be at least 1m")) } - if c.Audit.RetentionDays < 0 { + if *c.Audit.RetentionDays < 0 { errs = append(errs, errors.New("audit.retention_days must be non-negative (0 = keep forever)")) } diff --git a/admin/config/validate_test.go b/admin/config/validate_test.go index 9be91d0..88ef9eb 100644 --- a/admin/config/validate_test.go +++ b/admin/config/validate_test.go @@ -13,7 +13,7 @@ import ( // Tests mutate a single field to test specific validation rules. func validConfig() *Config { return &Config{ - Server: ServerConfig{Addr: "127.0.0.1:8080"}, + Server: ServerConfig{Addr: "127.0.0.1:8080"}, Database: DatabaseConfig{Path: "./test.db"}, Scraper: ScraperConfig{ Interval: Duration(10 * time.Second), @@ -23,7 +23,7 @@ func validConfig() *Config { MaxAge: Duration(24 * time.Hour), IdleTimeout: Duration(2 * time.Hour), }, - Audit: AuditConfig{RetentionDays: 90}, + Audit: AuditConfig{RetentionDays: intPtr(90)}, Log: LogConfig{Level: "info", Format: "json"}, } } @@ -129,7 +129,7 @@ func TestValidate_NegativeRetention_ReturnsError(t *testing.T) { // Arrange cfg := validConfig() - cfg.Audit.RetentionDays = -1 + cfg.Audit.RetentionDays = intPtr(-1) // Act err := cfg.Validate() @@ -148,7 +148,7 @@ func TestValidate_ZeroRetention_NoError(t *testing.T) { // Arrange — 0 means "keep forever" cfg := validConfig() - cfg.Audit.RetentionDays = 0 + cfg.Audit.RetentionDays = intPtr(0) // Act err := cfg.Validate() @@ -212,7 +212,7 @@ func TestValidate_MultipleErrors_ReturnsAllErrors(t *testing.T) { MaxAge: Duration(24 * time.Hour), IdleTimeout: Duration(2 * time.Hour), }, - Audit: AuditConfig{RetentionDays: -1}, + Audit: AuditConfig{RetentionDays: intPtr(-1)}, Log: LogConfig{Level: "bad", Format: "bad"}, } From ecbdda7435700f16720aded6e4a41583d5ffd90f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADctor=20Cuevas?= Date: Thu, 5 Mar 2026 00:43:49 +0100 Subject: [PATCH 06/36] fix(admin): resolve golangci-lint failures in CI - Fix gofmt import ordering in main.go (errors before flag) - Add context.Context to store.Open and migrate for noctx compliance - Use ExecContext/QueryRowContext/BeginTx throughout store package - Extract parseDuration helper to reduce applyEnvOverrides complexity - Split migrate into ensureMigrationsTable/currentSchemaVersion/applyMigration - Add justifying comment to blank sqlite driver import - Extract default config constants to eliminate goconst violations --- admin/cmd/chaperone-admin/main.go | 4 +- admin/config/config.go | 9 ++++ admin/config/loader.go | 59 +++++++++-------------- admin/config/validate.go | 4 +- admin/config/validate_test.go | 4 +- admin/store/migrations.go | 80 ++++++++++++++++++++----------- admin/store/store.go | 11 +++-- admin/store/store_test.go | 17 +++---- 8 files changed, 105 insertions(+), 83 deletions(-) diff --git a/admin/cmd/chaperone-admin/main.go b/admin/cmd/chaperone-admin/main.go index 3875377..75dc23d 100644 --- a/admin/cmd/chaperone-admin/main.go +++ b/admin/cmd/chaperone-admin/main.go @@ -5,8 +5,8 @@ package main import ( "context" - "flag" "errors" + "flag" "fmt" "log/slog" "net/http" @@ -56,7 +56,7 @@ func run() error { "built", BuildDate, ) - st, err := store.Open(cfg.Database.Path) + st, err := store.Open(context.Background(), cfg.Database.Path) if err != nil { return fmt.Errorf("opening database: %w", err) } diff --git a/admin/config/config.go b/admin/config/config.go index 316988c..0f6371e 100644 --- a/admin/config/config.go +++ b/admin/config/config.go @@ -13,6 +13,15 @@ import ( // EnvPrefix is the environment variable prefix for admin portal configuration. const EnvPrefix = "CHAPERONE_ADMIN" +// Default configuration values. +const ( + DefaultAddr = "127.0.0.1:8080" + DefaultDBPath = "./chaperone-admin.db" + DefaultLogLevel = "info" + DefaultLogFormat = "json" + LogFormatText = "text" +) + // Config holds the admin portal configuration. type Config struct { Server ServerConfig `yaml:"server"` diff --git a/admin/config/loader.go b/admin/config/loader.go index 68b817b..b204f2c 100644 --- a/admin/config/loader.go +++ b/admin/config/loader.go @@ -63,10 +63,10 @@ func loadYAML(path string, cfg *Config) error { func applyDefaults(cfg *Config) { if cfg.Server.Addr == "" { - cfg.Server.Addr = "127.0.0.1:8080" + cfg.Server.Addr = DefaultAddr } if cfg.Database.Path == "" { - cfg.Database.Path = "./chaperone-admin.db" + cfg.Database.Path = DefaultDBPath } if cfg.Scraper.Interval == 0 { cfg.Scraper.Interval = Duration(10 * time.Second) @@ -84,10 +84,10 @@ func applyDefaults(cfg *Config) { cfg.Audit.RetentionDays = intPtr(90) } if cfg.Log.Level == "" { - cfg.Log.Level = "info" + cfg.Log.Level = DefaultLogLevel } if cfg.Log.Format == "" { - cfg.Log.Format = "json" + cfg.Log.Format = DefaultLogFormat } } @@ -100,38 +100,12 @@ func applyEnvOverrides(cfg *Config) error { if v := getEnv("DATABASE_PATH"); v != "" { cfg.Database.Path = v } - if v := getEnv("SCRAPER_INTERVAL"); v != "" { - d, err := time.ParseDuration(v) - if err != nil { - errs = append(errs, fmt.Errorf("SCRAPER_INTERVAL: %w", err)) - } else { - cfg.Scraper.Interval = Duration(d) - } - } - if v := getEnv("SCRAPER_TIMEOUT"); v != "" { - d, err := time.ParseDuration(v) - if err != nil { - errs = append(errs, fmt.Errorf("SCRAPER_TIMEOUT: %w", err)) - } else { - cfg.Scraper.Timeout = Duration(d) - } - } - if v := getEnv("SESSION_MAX_AGE"); v != "" { - d, err := time.ParseDuration(v) - if err != nil { - errs = append(errs, fmt.Errorf("SESSION_MAX_AGE: %w", err)) - } else { - cfg.Session.MaxAge = Duration(d) - } - } - if v := getEnv("SESSION_IDLE_TIMEOUT"); v != "" { - d, err := time.ParseDuration(v) - if err != nil { - errs = append(errs, fmt.Errorf("SESSION_IDLE_TIMEOUT: %w", err)) - } else { - cfg.Session.IdleTimeout = Duration(d) - } - } + + parseDuration(&cfg.Scraper.Interval, "SCRAPER_INTERVAL", &errs) + parseDuration(&cfg.Scraper.Timeout, "SCRAPER_TIMEOUT", &errs) + parseDuration(&cfg.Session.MaxAge, "SESSION_MAX_AGE", &errs) + parseDuration(&cfg.Session.IdleTimeout, "SESSION_IDLE_TIMEOUT", &errs) + if v := getEnv("AUDIT_RETENTION_DAYS"); v != "" { n, err := strconv.Atoi(v) if err != nil { @@ -150,6 +124,19 @@ func applyEnvOverrides(cfg *Config) error { return errors.Join(errs...) } +func parseDuration(dst *Duration, envKey string, errs *[]error) { + v := getEnv(envKey) + if v == "" { + return + } + d, err := time.ParseDuration(v) + if err != nil { + *errs = append(*errs, fmt.Errorf("%s: %w", envKey, err)) + return + } + *dst = Duration(d) +} + func getEnv(key string) string { return os.Getenv(EnvPrefix + "_" + key) } diff --git a/admin/config/validate.go b/admin/config/validate.go index ec73df9..6eadfea 100644 --- a/admin/config/validate.go +++ b/admin/config/validate.go @@ -46,13 +46,13 @@ func (c *Config) Validate() error { } switch c.Log.Level { - case "debug", "info", "warn", "error": + case "debug", DefaultLogLevel, "warn", "error": default: errs = append(errs, fmt.Errorf("log.level: unknown level %q (valid: debug, info, warn, error)", c.Log.Level)) } switch c.Log.Format { - case "json", "text": + case DefaultLogFormat, LogFormatText: default: errs = append(errs, fmt.Errorf("log.format: unknown format %q (valid: json, text)", c.Log.Format)) } diff --git a/admin/config/validate_test.go b/admin/config/validate_test.go index 88ef9eb..85ead1b 100644 --- a/admin/config/validate_test.go +++ b/admin/config/validate_test.go @@ -13,7 +13,7 @@ import ( // Tests mutate a single field to test specific validation rules. func validConfig() *Config { return &Config{ - Server: ServerConfig{Addr: "127.0.0.1:8080"}, + Server: ServerConfig{Addr: DefaultAddr}, Database: DatabaseConfig{Path: "./test.db"}, Scraper: ScraperConfig{ Interval: Duration(10 * time.Second), @@ -24,7 +24,7 @@ func validConfig() *Config { IdleTimeout: Duration(2 * time.Hour), }, Audit: AuditConfig{RetentionDays: intPtr(90)}, - Log: LogConfig{Level: "info", Format: "json"}, + Log: LogConfig{Level: DefaultLogLevel, Format: DefaultLogFormat}, } } diff --git a/admin/store/migrations.go b/admin/store/migrations.go index f0e159d..5d8fc7e 100644 --- a/admin/store/migrations.go +++ b/admin/store/migrations.go @@ -4,6 +4,7 @@ package store import ( + "context" "fmt" "log/slog" ) @@ -66,21 +67,14 @@ CREATE INDEX idx_sessions_expires_at ON sessions(expires_at); }, } -func (s *Store) migrate() error { - _, err := s.db.Exec(` - CREATE TABLE IF NOT EXISTS schema_migrations ( - version INTEGER PRIMARY KEY, - applied_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP - ) - `) - if err != nil { - return fmt.Errorf("creating schema_migrations table: %w", err) +func (s *Store) migrate(ctx context.Context) error { + if err := s.ensureMigrationsTable(ctx); err != nil { + return err } - var current int - err = s.db.QueryRow("SELECT COALESCE(MAX(version), 0) FROM schema_migrations").Scan(¤t) + current, err := s.currentSchemaVersion(ctx) if err != nil { - return fmt.Errorf("reading current schema version: %w", err) + return err } for _, m := range migrations { @@ -93,28 +87,58 @@ func (s *Store) migrate() error { "description", m.Description, ) - tx, err := s.db.Begin() - if err != nil { - return fmt.Errorf("beginning transaction for migration %d: %w", m.Version, err) + if err := s.applyMigration(ctx, m); err != nil { + return err } + } - if _, err := tx.Exec(m.SQL); err != nil { - if rbErr := tx.Rollback(); rbErr != nil { - slog.Error("rolling back migration", "version", m.Version, "error", rbErr) - } - return fmt.Errorf("applying migration %d (%s): %w", m.Version, m.Description, err) - } + return nil +} + +func (s *Store) ensureMigrationsTable(ctx context.Context) error { + _, err := s.db.ExecContext(ctx, ` + CREATE TABLE IF NOT EXISTS schema_migrations ( + version INTEGER PRIMARY KEY, + applied_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP + ) + `) + if err != nil { + return fmt.Errorf("creating schema_migrations table: %w", err) + } + return nil +} - if _, err := tx.Exec("INSERT INTO schema_migrations (version) VALUES (?)", m.Version); err != nil { - if rbErr := tx.Rollback(); rbErr != nil { - slog.Error("rolling back migration", "version", m.Version, "error", rbErr) - } - return fmt.Errorf("recording migration %d: %w", m.Version, err) +func (s *Store) currentSchemaVersion(ctx context.Context) (int, error) { + var current int + err := s.db.QueryRowContext(ctx, "SELECT COALESCE(MAX(version), 0) FROM schema_migrations").Scan(¤t) + if err != nil { + return 0, fmt.Errorf("reading current schema version: %w", err) + } + return current, nil +} + +func (s *Store) applyMigration(ctx context.Context, m migration) error { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return fmt.Errorf("beginning transaction for migration %d: %w", m.Version, err) + } + + if _, err := tx.ExecContext(ctx, m.SQL); err != nil { + if rbErr := tx.Rollback(); rbErr != nil { + slog.Error("rolling back migration", "version", m.Version, "error", rbErr) } + return fmt.Errorf("applying migration %d (%s): %w", m.Version, m.Description, err) + } - if err := tx.Commit(); err != nil { - return fmt.Errorf("committing migration %d: %w", m.Version, err) + if _, err := tx.ExecContext(ctx, "INSERT INTO schema_migrations (version) VALUES (?)", m.Version); err != nil { + if rbErr := tx.Rollback(); rbErr != nil { + slog.Error("rolling back migration", "version", m.Version, "error", rbErr) } + return fmt.Errorf("recording migration %d: %w", m.Version, err) + } + + if err := tx.Commit(); err != nil { + return fmt.Errorf("committing migration %d: %w", m.Version, err) } return nil diff --git a/admin/store/store.go b/admin/store/store.go index f00e867..5117c28 100644 --- a/admin/store/store.go +++ b/admin/store/store.go @@ -4,10 +4,11 @@ package store import ( + "context" "database/sql" "fmt" - _ "modernc.org/sqlite" + _ "modernc.org/sqlite" // register sqlite driver ) // Store wraps a SQLite database connection for the admin portal. @@ -23,7 +24,7 @@ func (s *Store) DB() *sql.DB { // Open creates a new Store with the given SQLite database path. // It configures WAL mode, busy timeout, and foreign keys, then runs // any pending schema migrations. -func Open(dbPath string) (*Store, error) { +func Open(ctx context.Context, dbPath string) (*Store, error) { db, err := sql.Open("sqlite", dbPath) if err != nil { return nil, fmt.Errorf("opening database: %w", err) @@ -35,19 +36,19 @@ func Open(dbPath string) (*Store, error) { "PRAGMA foreign_keys=ON", } for _, p := range pragmas { - if _, err := db.Exec(p); err != nil { + if _, err := db.ExecContext(ctx, p); err != nil { db.Close() return nil, fmt.Errorf("setting pragma %q: %w", p, err) } } - if err := db.Ping(); err != nil { + if err := db.PingContext(ctx); err != nil { db.Close() return nil, fmt.Errorf("pinging database: %w", err) } s := &Store{db: db} - if err := s.migrate(); err != nil { + if err := s.migrate(ctx); err != nil { db.Close() return nil, fmt.Errorf("running migrations: %w", err) } diff --git a/admin/store/store_test.go b/admin/store/store_test.go index 6e6abab..31a1e07 100644 --- a/admin/store/store_test.go +++ b/admin/store/store_test.go @@ -4,6 +4,7 @@ package store import ( + "context" "path/filepath" "sort" "testing" @@ -12,7 +13,7 @@ import ( func openTestStore(t *testing.T) *Store { t.Helper() dbPath := filepath.Join(t.TempDir(), "test.db") - st, err := Open(dbPath) + st, err := Open(context.Background(), dbPath) if err != nil { t.Fatalf("Open(%q) failed: %v", dbPath, err) } @@ -27,7 +28,7 @@ func TestOpen_CreatesAllTables(t *testing.T) { st := openTestStore(t) // Assert — query sqlite_master for expected tables - rows, err := st.DB().Query( + rows, err := st.DB().QueryContext(context.Background(), `SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name`, ) if err != nil { @@ -66,14 +67,14 @@ func TestOpen_MigrationIdempotent(t *testing.T) { // Arrange — open twice on same DB to verify re-run is safe dbPath := filepath.Join(t.TempDir(), "test.db") - st1, err := Open(dbPath) + st1, err := Open(context.Background(), dbPath) if err != nil { t.Fatalf("first Open failed: %v", err) } st1.Close() // Act — open again (should re-run migrate without error) - st2, err := Open(dbPath) + st2, err := Open(context.Background(), dbPath) if err != nil { t.Fatalf("second Open failed: %v", err) } @@ -81,7 +82,7 @@ func TestOpen_MigrationIdempotent(t *testing.T) { // Assert — schema_migrations still has exactly one entry var count int - if err := st2.DB().QueryRow("SELECT COUNT(*) FROM schema_migrations").Scan(&count); err != nil { + if err := st2.DB().QueryRowContext(context.Background(), "SELECT COUNT(*) FROM schema_migrations").Scan(&count); err != nil { t.Fatalf("counting migrations: %v", err) } if count != 1 { @@ -97,7 +98,7 @@ func TestOpen_WALMode_Enabled(t *testing.T) { // Assert var mode string - if err := st.DB().QueryRow("PRAGMA journal_mode").Scan(&mode); err != nil { + if err := st.DB().QueryRowContext(context.Background(), "PRAGMA journal_mode").Scan(&mode); err != nil { t.Fatalf("querying journal_mode: %v", err) } if mode != "wal" { @@ -113,7 +114,7 @@ func TestOpen_SchemaMigrations_TracksVersion(t *testing.T) { // Assert var version int - if err := st.DB().QueryRow("SELECT MAX(version) FROM schema_migrations").Scan(&version); err != nil { + if err := st.DB().QueryRowContext(context.Background(), "SELECT MAX(version) FROM schema_migrations").Scan(&version); err != nil { t.Fatalf("querying schema version: %v", err) } if version != 1 { @@ -128,7 +129,7 @@ func TestOpen_InvalidPath_ReturnsError(t *testing.T) { dbPath := "/nonexistent/dir/test.db" // Act - _, err := Open(dbPath) + _, err := Open(context.Background(), dbPath) // Assert if err == nil { From e928fdf1c8c426ce790820018a777e5bbc2db03e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADctor=20Cuevas?= Date: Thu, 5 Mar 2026 00:47:37 +0100 Subject: [PATCH 07/36] fix: address Copilot review comments - CI: use --version instead of --help || true for binary verification - BaseInput: add aria-invalid and aria-describedby for screen readers --- .github/workflows/ci-admin.yml | 2 +- admin/ui/src/components/BaseInput.vue | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci-admin.yml b/.github/workflows/ci-admin.yml index 70c4ed5..d0c6374 100644 --- a/.github/workflows/ci-admin.yml +++ b/.github/workflows/ci-admin.yml @@ -143,4 +143,4 @@ jobs: - name: Verify binary exists run: | test -f bin/chaperone-admin - ./bin/chaperone-admin --help || true + ./bin/chaperone-admin --version diff --git a/admin/ui/src/components/BaseInput.vue b/admin/ui/src/components/BaseInput.vue index 7e4a915..611d3db 100644 --- a/admin/ui/src/components/BaseInput.vue +++ b/admin/ui/src/components/BaseInput.vue @@ -10,10 +10,12 @@ :value="modelValue" :placeholder="placeholder" :disabled="disabled" + :aria-invalid="error ? 'true' : undefined" + :aria-describedby="error ? errorId : undefined" v-bind="$attrs" @input="$emit('update:modelValue', $event.target.value)" /> -

{{ error }}

+

{{ error }}

@@ -58,6 +60,7 @@ const props = defineProps({ defineEmits(["update:modelValue"]); const inputId = computed(() => props.id ?? generatedId); +const errorId = computed(() => `${inputId.value}-error`); diff --git a/admin/ui/src/components/InstanceCard.vue b/admin/ui/src/components/InstanceCard.vue new file mode 100644 index 0000000..808c3b7 --- /dev/null +++ b/admin/ui/src/components/InstanceCard.vue @@ -0,0 +1,130 @@ + + + + + diff --git a/admin/ui/src/components/InstanceTable.vue b/admin/ui/src/components/InstanceTable.vue new file mode 100644 index 0000000..f2faa62 --- /dev/null +++ b/admin/ui/src/components/InstanceTable.vue @@ -0,0 +1,113 @@ + + + + + diff --git a/admin/ui/src/components/StatusIndicator.vue b/admin/ui/src/components/StatusIndicator.vue index 07c8af9..026f4eb 100644 --- a/admin/ui/src/components/StatusIndicator.vue +++ b/admin/ui/src/components/StatusIndicator.vue @@ -16,7 +16,7 @@ const props = defineProps({ status: { type: String, default: "unknown", - validator: (v) => ["healthy", "unreachable", "unknown"].includes(v), + validator: (v) => ["healthy", "unreachable", "unknown", "stale"].includes(v), }, label: { type: String, @@ -28,6 +28,7 @@ const statusLabels = { healthy: "Healthy", unreachable: "Unreachable", unknown: "Unknown", + stale: "Stale", }; const computedAriaLabel = computed(() => { @@ -81,4 +82,13 @@ const computedAriaLabel = computed(() => { .unknown .label { color: var(--color-text-tertiary); } + +.stale .dot { + background-color: var(--color-warning); + box-shadow: 0 0 0 3px var(--color-warning-bg); +} + +.stale .label { + color: var(--color-warning); +} diff --git a/admin/ui/src/stores/instances.js b/admin/ui/src/stores/instances.js new file mode 100644 index 0000000..b363d0c --- /dev/null +++ b/admin/ui/src/stores/instances.js @@ -0,0 +1,73 @@ +import { defineStore } from "pinia"; +import { ref } from "vue"; + +export const useInstanceStore = defineStore("instances", () => { + const instances = ref([]); + const loading = ref(false); + + async function fetchInstances() { + loading.value = true; + try { + const res = await fetch("/api/instances"); + if (!res.ok) throw new Error("Failed to fetch instances"); + instances.value = await res.json(); + } finally { + loading.value = false; + } + } + + async function createInstance(name, address) { + const res = await fetch("/api/instances", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name, address }), + }); + if (!res.ok) { + const data = await res.json(); + throw new Error(data.error?.message || "Failed to create instance"); + } + const inst = await res.json(); + await fetchInstances(); + return inst; + } + + async function updateInstance(id, name, address) { + const res = await fetch(`/api/instances/${id}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name, address }), + }); + if (!res.ok) { + const data = await res.json(); + throw new Error(data.error?.message || "Failed to update instance"); + } + const inst = await res.json(); + await fetchInstances(); + return inst; + } + + async function deleteInstance(id) { + const res = await fetch(`/api/instances/${id}`, { method: "DELETE" }); + if (!res.ok) throw new Error("Failed to delete instance"); + await fetchInstances(); + } + + async function testConnection(address) { + const res = await fetch("/api/instances/test", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ address }), + }); + return await res.json(); + } + + return { + instances, + loading, + fetchInstances, + createInstance, + updateInstance, + deleteInstance, + testConnection, + }; +}); From bfbe258646083da172777b7227978c2933f6ff27 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADctor=20Cuevas?= Date: Mon, 9 Mar 2026 11:00:00 +0100 Subject: [PATCH 11/36] feat(admin): add welcome screen and confirm dialog Replace minimal empty state with a guided welcome screen (portal description + 3-step onboarding flow). Replace window.confirm() with a ConfirmDialog component for instance removal. Add ghost button variant for the Remove action. --- admin/ui/src/components/BaseButton.vue | 13 +- admin/ui/src/components/ConfirmDialog.vue | 93 ++++++ admin/ui/src/views/DashboardView.vue | 368 ++++++++++++++++++++-- 3 files changed, 449 insertions(+), 25 deletions(-) create mode 100644 admin/ui/src/components/ConfirmDialog.vue diff --git a/admin/ui/src/components/BaseButton.vue b/admin/ui/src/components/BaseButton.vue index 086adf4..14c64c6 100644 --- a/admin/ui/src/components/BaseButton.vue +++ b/admin/ui/src/components/BaseButton.vue @@ -13,7 +13,7 @@ defineProps({ variant: { type: String, default: "primary", - validator: (v) => ["primary", "secondary", "danger"].includes(v), + validator: (v) => ["primary", "secondary", "danger", "ghost"].includes(v), }, size: { type: String, @@ -87,4 +87,15 @@ defineProps({ .danger:hover:not(:disabled) { background-color: var(--color-error-hover); } + +.ghost { + background-color: transparent; + color: var(--color-text-tertiary); + border-color: transparent; +} + +.ghost:hover:not(:disabled) { + background-color: var(--color-error-bg); + color: var(--color-error); +} diff --git a/admin/ui/src/components/ConfirmDialog.vue b/admin/ui/src/components/ConfirmDialog.vue new file mode 100644 index 0000000..747d287 --- /dev/null +++ b/admin/ui/src/components/ConfirmDialog.vue @@ -0,0 +1,93 @@ + + + + + diff --git a/admin/ui/src/views/DashboardView.vue b/admin/ui/src/views/DashboardView.vue index cbd2293..4d29a46 100644 --- a/admin/ui/src/views/DashboardView.vue +++ b/admin/ui/src/views/DashboardView.vue @@ -2,39 +2,214 @@

Fleet Dashboard

+
+
+ + +
+ + Add Instance + +
+
+ + +
+ + {{ staleInstances.length === 1 ? '1 instance has' : `${staleInstances.length} instances have` }} + stale data — last seen over 2 minutes ago
+
- - - - + +
+ +

Welcome to Chaperone Admin

+

+ This portal gives you operational visibility into your Chaperone proxy fleet — + health status, live metrics, per-vendor traffic breakdown, and more. All from a single dashboard. +

+
+
+ 1 +
+ Register a proxy instance + Enter the admin address (host:port) of a running Chaperone proxy +
+
+
+ 2 +
+ Test the connection + Verify the portal can reach the proxy's admin port before saving +
+
+
+ 3 +
+ Monitor your fleet + Health, version, request rates, and latency updated every 10 seconds +
+
+
+ + Add Your First Instance + +
+ + +
+ +
+ + + +
+ + + + + +
From 115f630a76468cf53b38a6e880b28df6d9e36db5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADctor=20Cuevas?= Date: Tue, 10 Mar 2026 14:00:00 +0100 Subject: [PATCH 12/36] fix(admin): address review findings for instance registry Validate addresses as strict host:port (rejects :9090, host:abc, host:0). Cap JSON request bodies at 1 MB. Fix poller failures map leak for deleted instances. Make Probe() accept *http.Client to avoid per-call allocation. Extract shared formatTime, isInstanceStale, STALE_THRESHOLD_MS to utils/instance.js with tests. Fix stale-status inconsistency between table and card views. --- .gitignore | 1 + admin/api/instance.go | 40 +++++++++-- admin/api/instance_test.go | 54 +++++++++++++++ admin/poller/poller.go | 34 +++++++--- admin/poller/poller_test.go | 42 +++++++++++- admin/store/instance.go | 11 +-- admin/ui/src/components/InstanceCard.vue | 18 +---- admin/ui/src/components/InstanceTable.vue | 13 +--- admin/ui/src/utils/instance.js | 16 +++++ admin/ui/src/utils/instance.test.js | 82 +++++++++++++++++++++++ admin/ui/src/views/DashboardView.vue | 10 +-- 11 files changed, 260 insertions(+), 61 deletions(-) create mode 100644 admin/ui/src/utils/instance.js create mode 100644 admin/ui/src/utils/instance.test.js diff --git a/.gitignore b/.gitignore index 20cb65a..a4b5c95 100644 --- a/.gitignore +++ b/.gitignore @@ -76,6 +76,7 @@ test/load/results/ # Admin portal frontend admin/ui/node_modules/ admin/ui/dist/ +admin/ui/.vite/ # Playwright MCP output .playwright-mcp/ diff --git a/admin/api/instance.go b/admin/api/instance.go index 40cd80c..3d60dae 100644 --- a/admin/api/instance.go +++ b/admin/api/instance.go @@ -8,6 +8,7 @@ import ( "errors" "fmt" "log/slog" + "net" "net/http" "strconv" "strings" @@ -19,13 +20,16 @@ import ( // InstanceHandler handles instance CRUD and test-connection endpoints. type InstanceHandler struct { - store *store.Store - probeTimeout time.Duration + store *store.Store + client *http.Client } // NewInstanceHandler creates a handler with the given store and probe timeout. func NewInstanceHandler(st *store.Store, probeTimeout time.Duration) *InstanceHandler { - return &InstanceHandler{store: st, probeTimeout: probeTimeout} + return &InstanceHandler{ + store: st, + client: &http.Client{Timeout: probeTimeout}, + } } // Register mounts instance routes on the given mux. @@ -162,8 +166,12 @@ func (h *InstanceHandler) testConnection(w http.ResponseWriter, r *http.Request) respondError(w, http.StatusBadRequest, "VALIDATION_ERROR", "address is required") return } + if err := validHostPort(addr); err != nil { + respondError(w, http.StatusBadRequest, "VALIDATION_ERROR", err.Error()) + return + } - result := poller.Probe(r.Context(), addr, h.probeTimeout) + result := poller.Probe(r.Context(), h.client, addr) respondJSON(w, http.StatusOK, result) } @@ -178,8 +186,9 @@ func parseID(w http.ResponseWriter, r *http.Request) (int64, bool) { return id, true } -// decodeJSON reads and decodes a JSON request body. +// decodeJSON reads and decodes a JSON request body (max 1 MB). func decodeJSON(w http.ResponseWriter, r *http.Request, dst any) bool { + r.Body = http.MaxBytesReader(w, r.Body, 1<<20) if err := json.NewDecoder(r.Body).Decode(dst); err != nil { respondError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Invalid JSON request body") return false @@ -199,5 +208,26 @@ func validateInstanceRequest(w http.ResponseWriter, req *instanceRequest) bool { respondError(w, http.StatusBadRequest, "VALIDATION_ERROR", "address is required") return false } + if err := validHostPort(req.Address); err != nil { + respondError(w, http.StatusBadRequest, "VALIDATION_ERROR", err.Error()) + return false + } return true } + +var errInvalidHostPort = errors.New("address must be a valid host:port (e.g. 192.168.1.10:9090)") + +func validHostPort(addr string) error { + host, portStr, err := net.SplitHostPort(addr) + if err != nil { + return errInvalidHostPort + } + if host == "" { + return errInvalidHostPort + } + port, err := strconv.ParseUint(portStr, 10, 16) + if err != nil || port == 0 { + return errInvalidHostPort + } + return nil +} diff --git a/admin/api/instance_test.go b/admin/api/instance_test.go index 2fd17b3..ebd0444 100644 --- a/admin/api/instance_test.go +++ b/admin/api/instance_test.go @@ -346,6 +346,60 @@ func TestTestConnection_EmptyAddress_Returns400(t *testing.T) { } } +func TestCreateInstance_InvalidAddress_Returns400(t *testing.T) { + t.Parallel() + _, mux := newTestHandler(t) + + tests := []struct { + name string + address string + }{ + {"no port", "not-a-host-port"}, + {"empty host", ":9090"}, + {"non-numeric port", "example.com:abc"}, + {"port zero", "example.com:0"}, + {"port too large", "example.com:70000"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + body := `{"name":"proxy-1","address":"` + tt.address + `"}` + req := httptest.NewRequest(http.MethodPost, "/api/instances", strings.NewReader(body)) + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Errorf("address %q: status = %d, want %d", tt.address, rec.Code, http.StatusBadRequest) + } + }) + } +} + +func TestTestConnection_InvalidAddress_Returns400(t *testing.T) { + t.Parallel() + _, mux := newTestHandler(t) + + tests := []struct { + name string + address string + }{ + {"no port", "no-port-here"}, + {"empty host", ":9090"}, + {"non-numeric port", "example.com:abc"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + body := `{"address":"` + tt.address + `"}` + req := httptest.NewRequest(http.MethodPost, "/api/instances/test", strings.NewReader(body)) + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Errorf("address %q: status = %d, want %d", tt.address, rec.Code, http.StatusBadRequest) + } + }) + } +} + func TestCreateInstance_WhitespaceTrimmed(t *testing.T) { t.Parallel() _, mux := newTestHandler(t) diff --git a/admin/poller/poller.go b/admin/poller/poller.go index 9d5a259..2b95b2f 100644 --- a/admin/poller/poller.go +++ b/admin/poller/poller.go @@ -32,9 +32,7 @@ type ProbeResult struct { } // Probe performs a one-off health and version check against a proxy admin port. -func Probe(ctx context.Context, address string, timeout time.Duration) ProbeResult { - client := &http.Client{Timeout: timeout} - +func Probe(ctx context.Context, client *http.Client, address string) ProbeResult { health, err := fetchHealth(ctx, client, address) if err != nil { return ProbeResult{OK: false, Error: friendlyError(err)} @@ -97,14 +95,16 @@ func (p *Poller) pollAll(ctx context.Context) { slog.Error("poller: listing instances", "error", err) return } + // Prune failure counts for instances no longer in the registry. + p.pruneFailures(instances) + if len(instances) == 0 { return } type result struct { - id int64 - probe ProbeResult - version string + id int64 + probe ProbeResult } results := make(chan result, len(instances)) @@ -118,8 +118,8 @@ func (p *Poller) pollAll(ctx context.Context) { jitter := time.Duration(rand.Int64N(int64(2*maxJitter))) - maxJitter sleep(ctx, jitter) - pr := Probe(ctx, inst.Address, p.timeout) - results <- result{id: inst.ID, probe: pr, version: pr.Version} + pr := Probe(ctx, p.client, inst.Address) + results <- result{id: inst.ID, probe: pr} }(inst) } @@ -133,6 +133,24 @@ func (p *Poller) pollAll(ctx context.Context) { } } +func (p *Poller) pruneFailures(active []store.Instance) { + p.mu.Lock() + defer p.mu.Unlock() + + for id := range p.failures { + found := false + for _, inst := range active { + if inst.ID == id { + found = true + break + } + } + if !found { + delete(p.failures, id) + } + } +} + func (p *Poller) applyResult(ctx context.Context, id int64, pr ProbeResult) { p.mu.Lock() defer p.mu.Unlock() diff --git a/admin/poller/poller_test.go b/admin/poller/poller_test.go index 55e634c..b70f2b1 100644 --- a/admin/poller/poller_test.go +++ b/admin/poller/poller_test.go @@ -48,7 +48,7 @@ func TestProbe_HealthyProxy_ReturnsOK(t *testing.T) { proxy := fakeProxy(t) addr := strings.TrimPrefix(proxy.URL, "http://") - result := Probe(context.Background(), addr, 2*time.Second) + result := Probe(context.Background(), &http.Client{Timeout: 2 * time.Second}, addr) if !result.OK { t.Fatalf("expected OK=true, got error: %s", result.Error) @@ -64,7 +64,7 @@ func TestProbe_HealthyProxy_ReturnsOK(t *testing.T) { func TestProbe_UnreachableAddress_ReturnsError(t *testing.T) { t.Parallel() - result := Probe(context.Background(), "127.0.0.1:1", 1*time.Second) + result := Probe(context.Background(), &http.Client{Timeout: 1 * time.Second}, "127.0.0.1:1") if result.OK { t.Error("expected OK=false for unreachable address") @@ -83,7 +83,7 @@ func TestProbe_HealthEndpointError_ReturnsError(t *testing.T) { defer srv.Close() addr := strings.TrimPrefix(srv.URL, "http://") - result := Probe(context.Background(), addr, 2*time.Second) + result := Probe(context.Background(), &http.Client{Timeout: 2 * time.Second}, addr) if result.OK { t.Error("expected OK=false for error status") @@ -205,6 +205,42 @@ func TestPoller_RecoveryAfterUnreachable_SetsHealthy(t *testing.T) { } } +func TestPoller_DeletedInstance_PrunesFailures(t *testing.T) { + t.Parallel() + st := openTestStore(t) + + ctx := context.Background() + inst, err := st.CreateInstance(ctx, "test-proxy", "127.0.0.1:1") + if err != nil { + t.Fatalf("CreateInstance() error = %v", err) + } + + p := New(st, 1*time.Hour, 500*time.Millisecond) + + // Accumulate failures. + p.pollAll(ctx) + + p.mu.Lock() + count := p.failures[inst.ID] + p.mu.Unlock() + if count != 1 { + t.Fatalf("failures[%d] = %d, want 1", inst.ID, count) + } + + // Delete the instance and poll again. + if err := st.DeleteInstance(ctx, inst.ID); err != nil { + t.Fatalf("DeleteInstance() error = %v", err) + } + p.pollAll(ctx) + + p.mu.Lock() + _, exists := p.failures[inst.ID] + p.mu.Unlock() + if exists { + t.Errorf("failures[%d] still present after instance deletion", inst.ID) + } +} + func TestPoller_RunStopsOnContextCancel(t *testing.T) { t.Parallel() st := openTestStore(t) diff --git a/admin/store/instance.go b/admin/store/instance.go index 782b4de..2d4345a 100644 --- a/admin/store/instance.go +++ b/admin/store/instance.go @@ -57,7 +57,7 @@ func (s *Store) GetInstance(ctx context.Context, id int64) (*Instance, error) { `SELECT id, name, address, status, version, last_seen_at, created_at, updated_at FROM instances WHERE id = ?`, id) - inst, err := scanInstanceRow(row) + inst, err := scanInstance(row) if errors.Is(err, sql.ErrNoRows) { return nil, ErrInstanceNotFound } @@ -164,15 +164,6 @@ func scanInstance(s scanner) (Instance, error) { return inst, nil } -func scanInstanceRow(row *sql.Row) (Instance, error) { - var inst Instance - err := row.Scan( - &inst.ID, &inst.Name, &inst.Address, &inst.Status, - &inst.Version, &inst.LastSeenAt, &inst.CreatedAt, &inst.UpdatedAt, - ) - return inst, err -} - func isUniqueConstraintError(err error) bool { return err != nil && strings.Contains(err.Error(), "UNIQUE constraint failed") } diff --git a/admin/ui/src/components/InstanceCard.vue b/admin/ui/src/components/InstanceCard.vue index 808c3b7..b0d5745 100644 --- a/admin/ui/src/components/InstanceCard.vue +++ b/admin/ui/src/components/InstanceCard.vue @@ -33,6 +33,7 @@ import { computed } from "vue"; import BaseCard from "./BaseCard.vue"; import BaseButton from "./BaseButton.vue"; import StatusIndicator from "./StatusIndicator.vue"; +import { isInstanceStale, formatTime } from "../utils/instance.js"; const props = defineProps({ instance: { type: Object, required: true }, @@ -40,28 +41,13 @@ const props = defineProps({ defineEmits(["edit", "delete"]); -const STALE_THRESHOLD_MS = 2 * 60 * 1000; - -const isStale = computed(() => { - if (props.instance.status !== "healthy" || !props.instance.last_seen_at) return false; - return Date.now() - new Date(props.instance.last_seen_at).getTime() > STALE_THRESHOLD_MS; -}); +const isStale = computed(() => isInstanceStale(props.instance)); const statusLabel = computed(() => { if (isStale.value) return "Stale"; const labels = { healthy: "Healthy", unreachable: "Unreachable", unknown: "Unknown" }; return labels[props.instance.status] || "Unknown"; }); - -function formatTime(ts) { - if (!ts) return ""; - const d = new Date(ts); - const secs = Math.floor((Date.now() - d.getTime()) / 1000); - if (secs < 60) return "just now"; - if (secs < 3600) return `${Math.floor(secs / 60)}m ago`; - if (secs < 86400) return `${Math.floor(secs / 3600)}h ago`; - return d.toLocaleDateString(); -} diff --git a/admin/ui/src/components/InstanceCard.vue b/admin/ui/src/components/InstanceCard.vue index edc440d..f74b593 100644 --- a/admin/ui/src/components/InstanceCard.vue +++ b/admin/ui/src/components/InstanceCard.vue @@ -1,5 +1,5 @@ diff --git a/admin/ui/src/router/index.js b/admin/ui/src/router/index.js index 267c2b7..3ee19de 100644 --- a/admin/ui/src/router/index.js +++ b/admin/ui/src/router/index.js @@ -1,9 +1,18 @@ import { createRouter, createWebHistory } from 'vue-router'; +import { useAuthStore } from '../stores/auth.js'; import DashboardView from '../views/DashboardView.vue'; import AuditLogView from '../views/AuditLogView.vue'; import InstanceDetailView from '../views/InstanceDetailView.vue'; +import LoginView from '../views/LoginView.vue'; +import SettingsView from '../views/SettingsView.vue'; const routes = [ + { + path: '/login', + name: 'login', + component: LoginView, + meta: { public: true }, + }, { path: '/', name: 'dashboard', @@ -19,6 +28,11 @@ const routes = [ name: 'audit-log', component: AuditLogView, }, + { + path: '/settings', + name: 'settings', + component: SettingsView, + }, { path: '/:pathMatch(.*)*', name: 'not-found', @@ -31,4 +45,18 @@ const router = createRouter({ routes, }); +router.beforeEach(async (to) => { + const auth = useAuthStore(); + + if (!auth.ready) await auth.checkSession(); + + if (!to.meta.public && !auth.isAuthenticated) { + return { name: 'login', query: { redirect: to.fullPath } }; + } + + if (to.name === 'login' && auth.isAuthenticated) { + return { name: 'dashboard' }; + } +}); + export default router; diff --git a/admin/ui/src/stores/auth.js b/admin/ui/src/stores/auth.js new file mode 100644 index 0000000..4596b91 --- /dev/null +++ b/admin/ui/src/stores/auth.js @@ -0,0 +1,47 @@ +import { ref, computed } from 'vue'; +import { defineStore } from 'pinia'; +import * as api from '../utils/api.js'; + +export const useAuthStore = defineStore('auth', () => { + const user = ref(null); + const ready = ref(false); + const isAuthenticated = computed(() => user.value !== null); + + async function checkSession() { + try { + const data = await api.get('/api/me'); + user.value = data.user; + } catch { + user.value = null; + } finally { + ready.value = true; + } + } + + async function login(username, password) { + const data = await api.post('/api/login', { username, password }); + user.value = data.user; + } + + async function logout() { + await api.post('/api/logout'); + user.value = null; + } + + async function changePassword(currentPassword, newPassword) { + await api.put('/api/user/password', { + current_password: currentPassword, + new_password: newPassword, + }); + } + + return { + user, + ready, + isAuthenticated, + checkSession, + login, + logout, + changePassword, + }; +}); diff --git a/admin/ui/src/stores/auth.test.js b/admin/ui/src/stores/auth.test.js new file mode 100644 index 0000000..c412716 --- /dev/null +++ b/admin/ui/src/stores/auth.test.js @@ -0,0 +1,89 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { setActivePinia, createPinia } from 'pinia'; +import { useAuthStore } from './auth.js'; + +vi.mock('../utils/api.js', () => ({ + get: vi.fn(), + post: vi.fn(), + put: vi.fn(), +})); + +import * as api from '../utils/api.js'; + +describe('useAuthStore', () => { + let store; + + beforeEach(() => { + setActivePinia(createPinia()); + store = useAuthStore(); + vi.restoreAllMocks(); + }); + + describe('checkSession', () => { + it('sets user on valid session', async () => { + api.get.mockResolvedValue({ user: { id: 1, username: 'admin' } }); + await store.checkSession(); + expect(store.user).toEqual({ id: 1, username: 'admin' }); + expect(store.ready).toBe(true); + expect(store.isAuthenticated).toBe(true); + }); + + it('clears user on invalid session', async () => { + api.get.mockRejectedValue(new Error('401')); + await store.checkSession(); + expect(store.user).toBeNull(); + expect(store.ready).toBe(true); + expect(store.isAuthenticated).toBe(false); + }); + }); + + describe('login', () => { + it('sets user on success', async () => { + api.post.mockResolvedValue({ user: { id: 1, username: 'admin' } }); + await store.login('admin', 'password123456'); + expect(api.post).toHaveBeenCalledWith('/api/login', { + username: 'admin', + password: 'password123456', + }); + expect(store.user).toEqual({ id: 1, username: 'admin' }); + }); + + it('propagates error on failure', async () => { + const err = new Error('Invalid'); + err.status = 401; + api.post.mockRejectedValue(err); + await expect(store.login('admin', 'wrong')).rejects.toThrow('Invalid'); + expect(store.user).toBeNull(); + }); + }); + + describe('logout', () => { + it('clears user on success', async () => { + store.user = { id: 1, username: 'admin' }; + api.post.mockResolvedValue(null); + await store.logout(); + expect(api.post).toHaveBeenCalledWith('/api/logout'); + expect(store.user).toBeNull(); + }); + }); + + describe('changePassword', () => { + it('sends correct payload', async () => { + api.put.mockResolvedValue(null); + await store.changePassword('old-password1', 'new-password1'); + expect(api.put).toHaveBeenCalledWith('/api/user/password', { + current_password: 'old-password1', + new_password: 'new-password1', + }); + }); + + it('propagates error on failure', async () => { + const err = new Error('Current password is incorrect'); + err.status = 401; + api.put.mockRejectedValue(err); + await expect( + store.changePassword('wrong', 'new-password1'), + ).rejects.toThrow('Current password is incorrect'); + }); + }); +}); diff --git a/admin/ui/src/utils/api.js b/admin/ui/src/utils/api.js index 1582b1b..d7456b3 100644 --- a/admin/ui/src/utils/api.js +++ b/admin/ui/src/utils/api.js @@ -7,16 +7,36 @@ class ApiError extends Error { } } +export function getCsrfToken() { + const match = document.cookie.match(/(?:^|;\s*)csrf_token=([^;]*)/); + return match ? decodeURIComponent(match[1]) : ''; +} + +const writeMethods = new Set(['POST', 'PUT', 'DELETE', 'PATCH']); + async function request(path, options = {}) { - const res = await fetch(path, { - ...options, - headers: { - 'Content-Type': 'application/json', - ...options.headers, - }, - }); + const headers = { + 'Content-Type': 'application/json', + ...options.headers, + }; + + if (writeMethods.has(options.method)) { + const token = getCsrfToken(); + if (token) headers['X-CSRF-Token'] = token; + } + + const res = await fetch(path, { ...options, headers }); if (!res.ok) { + if (res.status === 401 && path !== '/api/login') { + const { useAuthStore } = await import('../stores/auth.js'); + const auth = useAuthStore(); + if (auth.ready) { + auth.user = null; + window.location.href = '/login'; + } + } + let message = `Request failed (${res.status})`; let code; try { diff --git a/admin/ui/src/utils/api.test.js b/admin/ui/src/utils/api.test.js index fa7c1e2..d2d9c48 100644 --- a/admin/ui/src/utils/api.test.js +++ b/admin/ui/src/utils/api.test.js @@ -1,9 +1,13 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { get, post, put, del, ApiError } from './api.js'; +import { get, post, put, del, getCsrfToken, ApiError } from './api.js'; describe('api client', () => { beforeEach(() => { vi.restoreAllMocks(); + Object.defineProperty(document, 'cookie', { + writable: true, + value: '', + }); }); function mockFetch(status, body, { json = true } = {}) { @@ -18,6 +22,23 @@ describe('api client', () => { return res; } + describe('getCsrfToken', () => { + it('returns empty string when no cookie', () => { + document.cookie = ''; + expect(getCsrfToken()).toBe(''); + }); + + it('extracts csrf_token from cookies', () => { + document.cookie = 'session=abc123; csrf_token=my-token-value'; + expect(getCsrfToken()).toBe('my-token-value'); + }); + + it('decodes URL-encoded token', () => { + document.cookie = 'csrf_token=token%20with%20spaces'; + expect(getCsrfToken()).toBe('token with spaces'); + }); + }); + describe('get', () => { it('returns parsed JSON on success', async () => { mockFetch(200, [{ id: 1 }]); @@ -28,6 +49,14 @@ describe('api client', () => { }); }); + it('does not send CSRF token on GET', async () => { + document.cookie = 'csrf_token=my-token'; + mockFetch(200, {}); + await get('/api/me'); + const [, opts] = globalThis.fetch.mock.calls[0]; + expect(opts.headers['X-CSRF-Token']).toBeUndefined(); + }); + it('throws ApiError with server message on failure', async () => { mockFetch(404, { error: { @@ -49,6 +78,41 @@ describe('api client', () => { expect(err.message).toBe('Request failed (500)'); expect(err.status).toBe(500); }); + + it('redirects to login on 401 when session is established', async () => { + mockFetch(401, { + error: { code: 'UNAUTHORIZED', message: 'No valid session' }, + }); + const mockStore = { user: { id: 1 }, ready: true }; + vi.doMock('../stores/auth.js', () => ({ + useAuthStore: () => mockStore, + })); + delete window.location; + window.location = { href: '/' }; + const err = await get('/api/me').catch((e) => e); + expect(err).toBeInstanceOf(ApiError); + expect(err.status).toBe(401); + expect(mockStore.user).toBeNull(); + expect(window.location.href).toBe('/login'); + vi.doUnmock('../stores/auth.js'); + }); + + it('does not redirect on 401 during initial session check', async () => { + mockFetch(401, { + error: { code: 'UNAUTHORIZED', message: 'No valid session' }, + }); + const mockStore = { user: null, ready: false }; + vi.doMock('../stores/auth.js', () => ({ + useAuthStore: () => mockStore, + })); + delete window.location; + window.location = { href: '/' }; + const err = await get('/api/me').catch((e) => e); + expect(err).toBeInstanceOf(ApiError); + expect(err.status).toBe(401); + expect(window.location.href).toBe('/'); + vi.doUnmock('../stores/auth.js'); + }); }); describe('post', () => { @@ -69,6 +133,14 @@ describe('api client', () => { }); }); + it('includes CSRF token on POST', async () => { + document.cookie = 'csrf_token=my-csrf-token'; + mockFetch(200, {}); + await post('/api/logout'); + const [, opts] = globalThis.fetch.mock.calls[0]; + expect(opts.headers['X-CSRF-Token']).toBe('my-csrf-token'); + }); + it('throws ApiError with server message on conflict', async () => { mockFetch(409, { error: { @@ -95,6 +167,17 @@ describe('api client', () => { const [, opts] = globalThis.fetch.mock.calls[0]; expect(opts.method).toBe('PUT'); }); + + it('includes CSRF token on PUT', async () => { + document.cookie = 'csrf_token=put-token'; + mockFetch(204, null); + await put('/api/user/password', { + current_password: 'a', + new_password: 'b', + }); + const [, opts] = globalThis.fetch.mock.calls[0]; + expect(opts.headers['X-CSRF-Token']).toBe('put-token'); + }); }); describe('del', () => { @@ -110,7 +193,8 @@ describe('api client', () => { expect(res.json).not.toHaveBeenCalled(); }); - it('sends DELETE method', async () => { + it('sends DELETE method with CSRF token', async () => { + document.cookie = 'csrf_token=del-token'; const res = { ok: true, status: 204, json: vi.fn() }; vi.spyOn(globalThis, 'fetch').mockResolvedValue(res); await del('/api/instances/1'); @@ -118,6 +202,7 @@ describe('api client', () => { const [url, opts] = globalThis.fetch.mock.calls[0]; expect(url).toBe('/api/instances/1'); expect(opts.method).toBe('DELETE'); + expect(opts.headers['X-CSRF-Token']).toBe('del-token'); }); }); }); diff --git a/admin/ui/src/utils/validation.js b/admin/ui/src/utils/validation.js index d2402e1..e6cd435 100644 --- a/admin/ui/src/utils/validation.js +++ b/admin/ui/src/utils/validation.js @@ -4,3 +4,28 @@ export function validateInstanceForm(name, address) { address: address.trim() ? '' : 'Address is required', }; } + +const MIN_PASSWORD_LENGTH = 12; +const MAX_PASSWORD_LENGTH = 72; + +export function validatePasswordChange( + currentPassword, + newPassword, + confirmPassword, +) { + const errors = {}; + if (!currentPassword) errors.currentPassword = 'Current password is required'; + if (!newPassword) { + errors.newPassword = 'New password is required'; + } else if (newPassword.length < MIN_PASSWORD_LENGTH) { + errors.newPassword = `Password must be at least ${MIN_PASSWORD_LENGTH} characters`; + } else if (newPassword.length > MAX_PASSWORD_LENGTH) { + errors.newPassword = `Password must be at most ${MAX_PASSWORD_LENGTH} characters`; + } + if (!confirmPassword) { + errors.confirmPassword = 'Please confirm your new password'; + } else if (newPassword && confirmPassword !== newPassword) { + errors.confirmPassword = 'Passwords do not match'; + } + return errors; +} diff --git a/admin/ui/src/utils/validation.test.js b/admin/ui/src/utils/validation.test.js index 4bfa41f..d58ed42 100644 --- a/admin/ui/src/utils/validation.test.js +++ b/admin/ui/src/utils/validation.test.js @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { validateInstanceForm } from './validation.js'; +import { validateInstanceForm, validatePasswordChange } from './validation.js'; describe('validateInstanceForm', () => { it('returns no errors for valid inputs', () => { @@ -38,3 +38,56 @@ describe('validateInstanceForm', () => { expect(errors.address).toBe(''); }); }); + +describe('validatePasswordChange', () => { + it('requires all fields', () => { + const errors = validatePasswordChange('', '', ''); + expect(errors.currentPassword).toBe('Current password is required'); + expect(errors.newPassword).toBe('New password is required'); + expect(errors.confirmPassword).toBe('Please confirm your new password'); + }); + + it('rejects passwords shorter than 12 characters', () => { + const errors = validatePasswordChange('currentpass1', 'short', 'short'); + expect(errors.newPassword).toBe('Password must be at least 12 characters'); + }); + + it('rejects passwords longer than 72 characters', () => { + const long = 'a'.repeat(73); + const errors = validatePasswordChange('currentpass1', long, long); + expect(errors.newPassword).toBe('Password must be at most 72 characters'); + }); + + it('rejects mismatched passwords', () => { + const errors = validatePasswordChange( + 'currentpass1', + 'validpassword1', + 'differentpass1', + ); + expect(errors.confirmPassword).toBe('Passwords do not match'); + }); + + it('returns empty object for valid input', () => { + const errors = validatePasswordChange( + 'currentpass1', + 'newpassword12', + 'newpassword12', + ); + expect(Object.keys(errors)).toHaveLength(0); + }); + + it('accepts exactly 12 character password', () => { + const errors = validatePasswordChange( + 'currentpass1', + 'exactly12chr', + 'exactly12chr', + ); + expect(Object.keys(errors)).toHaveLength(0); + }); + + it('accepts exactly 72 character password', () => { + const pw = 'a'.repeat(72); + const errors = validatePasswordChange('currentpass1', pw, pw); + expect(Object.keys(errors)).toHaveLength(0); + }); +}); diff --git a/admin/ui/src/views/LoginView.vue b/admin/ui/src/views/LoginView.vue new file mode 100644 index 0000000..55f7fc9 --- /dev/null +++ b/admin/ui/src/views/LoginView.vue @@ -0,0 +1,126 @@ + + + + + diff --git a/admin/ui/src/views/SettingsView.vue b/admin/ui/src/views/SettingsView.vue new file mode 100644 index 0000000..101dd61 --- /dev/null +++ b/admin/ui/src/views/SettingsView.vue @@ -0,0 +1,168 @@ + + + + + diff --git a/go.work b/go.work index 4fb25d9..f052787 100644 --- a/go.work +++ b/go.work @@ -4,4 +4,5 @@ use ( . ./plugins/contrib ./sdk + ./admin ) From 63a973e8a28377cc119cb1365d80148135b679dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADctor=20Cuevas?= Date: Thu, 16 Apr 2026 11:20:32 +0200 Subject: [PATCH 23/36] feat(admin): Fix PR suggestions - Avoid swallowing DeleteSession errors - Discarded CreateUser errors in tests - Rename Token to TokenHash struct field - Added comment about clientIP assumptions - Fix rate limiter unbounded memory by adding a Sweep method and adding a 5min goroutine in main.go --- admin/api/auth.go | 3 ++ admin/api/auth_test.go | 23 ++++++++----- admin/auth/auth.go | 13 ++++++-- admin/auth/ratelimit.go | 21 ++++++++++++ admin/auth/ratelimit_test.go | 54 +++++++++++++++++++++++++++++++ admin/cmd/chaperone-admin/main.go | 15 +++++++++ admin/server.go | 21 ++++++++---- admin/store/user.go | 4 +-- admin/store/user_test.go | 5 ++- 9 files changed, 137 insertions(+), 22 deletions(-) diff --git a/admin/api/auth.go b/admin/api/auth.go index b661261..fd2ef06 100644 --- a/admin/api/auth.go +++ b/admin/api/auth.go @@ -215,6 +215,9 @@ func (h *AuthHandler) clearCookies(w http.ResponseWriter) { }) } +// clientIP extracts the client IP from the request's TCP peer address. +// The admin portal is deployed direct-to-network within Distributor infrastructure; +// X-Forwarded-For is not trusted and must be ignored for rate-limiting. func clientIP(r *http.Request) string { host, _, err := net.SplitHostPort(r.RemoteAddr) if err != nil { diff --git a/admin/api/auth_test.go b/admin/api/auth_test.go index c294260..8a833ad 100644 --- a/admin/api/auth_test.go +++ b/admin/api/auth_test.go @@ -27,12 +27,19 @@ func newTestAuthMux(t *testing.T) (*http.ServeMux, *auth.Service) { return mux, svc } +func createTestUser(t *testing.T, svc *auth.Service) { + t.Helper() + if err := svc.CreateUser(context.Background(), "admin", testPassword); err != nil { + t.Fatalf("CreateUser() error = %v", err) + } +} + // --- Login --- func TestLogin_Success_Returns200WithCookies(t *testing.T) { t.Parallel() mux, svc := newTestAuthMux(t) - svc.CreateUser(context.Background(), "admin", testPassword) + createTestUser(t, svc) body := `{"username":"admin","password":"` + testPassword + `"}` req := httptest.NewRequest(http.MethodPost, "/api/login", strings.NewReader(body)) @@ -83,7 +90,7 @@ func TestLogin_Success_Returns200WithCookies(t *testing.T) { func TestLogin_WrongPassword_Returns401(t *testing.T) { t.Parallel() mux, svc := newTestAuthMux(t) - svc.CreateUser(context.Background(), "admin", testPassword) + createTestUser(t, svc) body := `{"username":"admin","password":"wrongpassword1"}` req := httptest.NewRequest(http.MethodPost, "/api/login", strings.NewReader(body)) @@ -112,7 +119,7 @@ func TestLogin_MissingFields_Returns400(t *testing.T) { func TestLogin_RateLimited_Returns429(t *testing.T) { t.Parallel() mux, svc := newTestAuthMux(t) - svc.CreateUser(context.Background(), "admin", testPassword) + createTestUser(t, svc) for range 5 { body := `{"username":"admin","password":"badpassword00"}` @@ -141,7 +148,7 @@ func TestLogin_RateLimited_Returns429(t *testing.T) { func TestLogout_Returns204_ClearsCookies(t *testing.T) { t.Parallel() mux, svc := newTestAuthMux(t) - svc.CreateUser(context.Background(), "admin", testPassword) + createTestUser(t, svc) result, _ := svc.Login(context.Background(), "127.0.0.1", "admin", testPassword) @@ -169,7 +176,7 @@ func TestLogout_Returns204_ClearsCookies(t *testing.T) { func TestChangePassword_Success_Returns204(t *testing.T) { t.Parallel() mux, svc := newTestAuthMux(t) - svc.CreateUser(context.Background(), "admin", testPassword) + createTestUser(t, svc) result, _ := svc.Login(context.Background(), "127.0.0.1", "admin", testPassword) body := `{"current_password":"` + testPassword + `","new_password":"newpassword1234"}` @@ -190,7 +197,7 @@ func TestChangePassword_Success_Returns204(t *testing.T) { func TestChangePassword_WrongCurrent_Returns401(t *testing.T) { t.Parallel() mux, svc := newTestAuthMux(t) - svc.CreateUser(context.Background(), "admin", testPassword) + createTestUser(t, svc) result, _ := svc.Login(context.Background(), "127.0.0.1", "admin", testPassword) body := `{"current_password":"wrongcurrent1","new_password":"newpassword1234"}` @@ -211,7 +218,7 @@ func TestChangePassword_WrongCurrent_Returns401(t *testing.T) { func TestChangePassword_TooShort_Returns400(t *testing.T) { t.Parallel() mux, svc := newTestAuthMux(t) - svc.CreateUser(context.Background(), "admin", testPassword) + createTestUser(t, svc) result, _ := svc.Login(context.Background(), "127.0.0.1", "admin", testPassword) body := `{"current_password":"` + testPassword + `","new_password":"short"}` @@ -248,7 +255,7 @@ func TestChangePassword_NoUser_Returns401(t *testing.T) { func TestMe_Authenticated_Returns200(t *testing.T) { t.Parallel() mux, svc := newTestAuthMux(t) - svc.CreateUser(context.Background(), "admin", testPassword) + createTestUser(t, svc) result, _ := svc.Login(context.Background(), "127.0.0.1", "admin", testPassword) req := httptest.NewRequest(http.MethodGet, "/api/me", nil) diff --git a/admin/auth/auth.go b/admin/auth/auth.go index 0150961..fde53ba 100644 --- a/admin/auth/auth.go +++ b/admin/auth/auth.go @@ -83,6 +83,11 @@ func NewService(st *store.Store, maxAge, idleTimeout time.Duration) *Service { } } +// SweepRateLimiter removes expired entries from the rate limiter. +func (s *Service) SweepRateLimiter() { + s.limiter.Sweep() +} + // Authenticate validates the session cookie on an HTTP request. // It checks absolute TTL, idle timeout, and touches the session. func (s *Service) Authenticate(r *http.Request) (*User, error) { @@ -102,11 +107,15 @@ func (s *Service) Authenticate(r *http.Request) (*User, error) { now := time.Now() if now.After(sess.ExpiresAt) { - _ = s.store.DeleteSession(r.Context(), rawToken) + if delErr := s.store.DeleteSession(r.Context(), rawToken); delErr != nil { + slog.Error("deleting expired session", "error", delErr) + } return nil, ErrSessionExpired } if now.Sub(sess.LastActiveAt) > s.idleTimeout { - _ = s.store.DeleteSession(r.Context(), rawToken) + if delErr := s.store.DeleteSession(r.Context(), rawToken); delErr != nil { + slog.Error("deleting idle session", "error", delErr) + } return nil, ErrSessionExpired } diff --git a/admin/auth/ratelimit.go b/admin/auth/ratelimit.go index 0a5ff5c..3da339f 100644 --- a/admin/auth/ratelimit.go +++ b/admin/auth/ratelimit.go @@ -72,3 +72,24 @@ func (rl *RateLimiter) prune(ip string) { delete(rl.attempts, ip) } } + +// Sweep removes all expired entries across all IPs. +// Call periodically from a background goroutine to prevent unbounded growth +// from IPs that record failures but never return. +func (rl *RateLimiter) Sweep() { + rl.mu.Lock() + defer rl.mu.Unlock() + + cutoff := rl.now().Add(-rl.window) + for ip, attempts := range rl.attempts { + i := 0 + for i < len(attempts) && attempts[i].Before(cutoff) { + i++ + } + if i == len(attempts) { + delete(rl.attempts, ip) + } else if i > 0 { + rl.attempts[ip] = attempts[i:] + } + } +} diff --git a/admin/auth/ratelimit_test.go b/admin/auth/ratelimit_test.go index 082de51..b552063 100644 --- a/admin/auth/ratelimit_test.go +++ b/admin/auth/ratelimit_test.go @@ -103,3 +103,57 @@ func TestRateLimiter_PartialPrune_KeepsRecentAttempts(t *testing.T) { t.Error("should be blocked (2 recent attempts)") } } + +func TestRateLimiter_Sweep_RemovesExpiredEntries(t *testing.T) { + t.Parallel() + rl := NewRateLimiter(2, time.Minute) + + now := time.Now() + rl.now = func() time.Time { return now } + + rl.Record("1.1.1.1") + rl.Record("2.2.2.2") + rl.Record("3.3.3.3") + + // Advance past the window. + rl.now = func() time.Time { return now.Add(61 * time.Second) } + + rl.Sweep() + + rl.mu.Lock() + remaining := len(rl.attempts) + rl.mu.Unlock() + + if remaining != 0 { + t.Errorf("expected 0 entries after sweep, got %d", remaining) + } +} + +func TestRateLimiter_Sweep_KeepsRecentEntries(t *testing.T) { + t.Parallel() + rl := NewRateLimiter(2, time.Minute) + + now := time.Now() + rl.now = func() time.Time { return now } + rl.Record("1.1.1.1") // old + + rl.now = func() time.Time { return now.Add(50 * time.Second) } + rl.Record("2.2.2.2") // recent + + // At t=61s, 1.1.1.1 is expired but 2.2.2.2 is still within window. + rl.now = func() time.Time { return now.Add(61 * time.Second) } + + rl.Sweep() + + rl.mu.Lock() + remaining := len(rl.attempts) + rl.mu.Unlock() + + if remaining != 1 { + t.Errorf("expected 1 entry after sweep, got %d", remaining) + } + + if !rl.Allow("1.1.1.1") { + t.Error("1.1.1.1 should be allowed after sweep removed its expired entry") + } +} diff --git a/admin/cmd/chaperone-admin/main.go b/admin/cmd/chaperone-admin/main.go index 00c69f5..4a9950f 100644 --- a/admin/cmd/chaperone-admin/main.go +++ b/admin/cmd/chaperone-admin/main.go @@ -107,6 +107,7 @@ func runServer(args []string) error { p := poller.New(st, collector, cfg.Scraper.Interval.Unwrap(), cfg.Scraper.Timeout.Unwrap()) go p.Run(bgCtx) go cleanupExpiredSessions(bgCtx, st) + go sweepRateLimiter(bgCtx, srv) return serve(cfg.Server.Addr, srv) } @@ -237,6 +238,20 @@ func cleanupExpiredSessions(ctx context.Context, st *store.Store) { } } +func sweepRateLimiter(ctx context.Context, srv *admin.Server) { + ticker := time.NewTicker(5 * time.Minute) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + srv.SweepRateLimiter() + } + } +} + func serve(addr string, srv *admin.Server) error { ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) defer stop() diff --git a/admin/server.go b/admin/server.go index 9c0c962..e61ada8 100644 --- a/admin/server.go +++ b/admin/server.go @@ -22,10 +22,11 @@ import ( // Server is the admin portal HTTP server. type Server struct { - httpServer *http.Server - config *config.Config - store *store.Store - collector *metrics.Collector + httpServer *http.Server + config *config.Config + store *store.Store + collector *metrics.Collector + authService *auth.Service } // NewServer creates a new admin portal server. @@ -46,9 +47,10 @@ func NewServer(cfg *config.Config, st *store.Store, collector *metrics.Collector WriteTimeout: 30 * time.Second, IdleTimeout: 60 * time.Second, }, - config: cfg, - store: st, - collector: collector, + config: cfg, + store: st, + collector: collector, + authService: authService, } if err := s.routes(mux, authService, secureCookies); err != nil { @@ -67,6 +69,11 @@ func (s *Server) Shutdown(ctx context.Context) error { return s.httpServer.Shutdown(ctx) } +// SweepRateLimiter removes expired entries from the login rate limiter. +func (s *Server) SweepRateLimiter() { + s.authService.SweepRateLimiter() +} + func (s *Server) routes(mux *http.ServeMux, authService *auth.Service, secureCookies bool) error { // API health check for the portal itself. mux.HandleFunc("GET /api/health", s.handleHealth) diff --git a/admin/store/user.go b/admin/store/user.go index 9f0da48..91db384 100644 --- a/admin/store/user.go +++ b/admin/store/user.go @@ -33,7 +33,7 @@ type User struct { type Session struct { ID int64 UserID int64 - Token string + TokenHash string ExpiresAt time.Time LastActiveAt time.Time CreatedAt time.Time @@ -124,7 +124,7 @@ func (s *Store) GetSessionByToken(ctx context.Context, token string) (*Session, err := s.db.QueryRowContext(ctx, `SELECT id, user_id, token, expires_at, last_active_at, created_at FROM sessions WHERE token = ?`, hashToken(token)). - Scan(&sess.ID, &sess.UserID, &sess.Token, &sess.ExpiresAt, &sess.LastActiveAt, &sess.CreatedAt) + Scan(&sess.ID, &sess.UserID, &sess.TokenHash, &sess.ExpiresAt, &sess.LastActiveAt, &sess.CreatedAt) if errors.Is(err, sql.ErrNoRows) { return nil, ErrSessionNotFound } diff --git a/admin/store/user_test.go b/admin/store/user_test.go index fb47c94..ec96121 100644 --- a/admin/store/user_test.go +++ b/admin/store/user_test.go @@ -158,11 +158,10 @@ func TestCreateSession_And_GetByToken(t *testing.T) { if sess.UserID != user.ID { t.Errorf("UserID = %d, want %d", sess.UserID, user.ID) } - // Token is stored as a SHA-256 hash, so it won't match the raw value. - if sess.Token == "" { + if sess.TokenHash == "" { t.Error("expected non-empty token hash") } - if sess.Token == "tok-abc-123" { + if sess.TokenHash == "tok-abc-123" { t.Error("token should be stored as a hash, not raw") } } From b9b609678b3f72c9c22e97dd3da8a135a6259181 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADctor=20Cuevas?= Date: Thu, 9 Apr 2026 19:54:00 +0200 Subject: [PATCH 24/36] feat(admin): add audit log backend with FTS5 search and retention cleanup Add audit logging to the admin portal: - Store layer: InsertAuditEntry, ListAuditEntries (paginated, filtered by user/action/instance/date range, FTS5 full-text search on detail), DeleteAuditEntriesBefore for retention cleanup - Migration v2: FTS5 virtual table with insert/delete/update triggers for automatic index sync - REST API: GET /api/audit with query params (user, action, instance_id, from, to, q, page, per_page) capped at 100 per page - Write-on-action: instance create/update/delete, login, logout, and password change all produce audit entries with contextual detail - Retention cleanup: background goroutine runs immediately on startup then daily, configurable via audit.retention_days (default 90, 0 to keep forever) --- admin/api/audit.go | 117 +++++++++ admin/api/audit_actions.go | 14 ++ admin/api/audit_test.go | 241 ++++++++++++++++++ admin/api/auth.go | 25 +- admin/api/auth_test.go | 2 +- admin/api/instance.go | 26 ++ admin/cmd/chaperone-admin/main.go | 48 +++- admin/server.go | 6 +- admin/store/audit.go | 162 ++++++++++++ admin/store/audit_test.go | 392 ++++++++++++++++++++++++++++++ admin/store/migrations.go | 24 ++ admin/store/store_test.go | 10 +- 12 files changed, 1053 insertions(+), 14 deletions(-) create mode 100644 admin/api/audit.go create mode 100644 admin/api/audit_actions.go create mode 100644 admin/api/audit_test.go create mode 100644 admin/store/audit.go create mode 100644 admin/store/audit_test.go diff --git a/admin/api/audit.go b/admin/api/audit.go new file mode 100644 index 0000000..6ae4612 --- /dev/null +++ b/admin/api/audit.go @@ -0,0 +1,117 @@ +// Copyright 2026 CloudBlue LLC +// SPDX-License-Identifier: Apache-2.0 + +package api + +import ( + "fmt" + "net/http" + "net/url" + "strconv" + "strings" + "time" + + "github.com/cloudblue/chaperone/admin/store" +) + +// AuditHandler serves the audit log REST endpoint. +type AuditHandler struct { + store *store.Store +} + +// NewAuditHandler creates a handler for the audit log endpoint. +func NewAuditHandler(st *store.Store) *AuditHandler { + return &AuditHandler{store: st} +} + +// Register mounts audit routes on the given mux. +func (h *AuditHandler) Register(mux *http.ServeMux) { + mux.HandleFunc("GET /api/audit", h.list) +} + +func (h *AuditHandler) list(w http.ResponseWriter, r *http.Request) { + filter, err := parseAuditFilter(r.URL.Query()) + if err != nil { + respondError(w, http.StatusBadRequest, "VALIDATION_ERROR", err.Error()) + return + } + + page, err := h.store.ListAuditEntries(r.Context(), filter) + if err != nil { + respondError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to list audit entries") + return + } + + respondJSON(w, http.StatusOK, page) +} + +func parseAuditFilter(q url.Values) (store.AuditFilter, error) { + filter := store.AuditFilter{ + Action: strings.TrimSpace(q.Get("action")), + Query: strings.TrimSpace(q.Get("q")), + Page: 1, + PerPage: 20, + } + + if err := parseIDParam(q, "user", &filter.UserID); err != nil { + return filter, err + } + if err := parseIDParam(q, "instance_id", &filter.InstanceID); err != nil { + return filter, err + } + if err := parseTimeParam(q, "from", &filter.From); err != nil { + return filter, err + } + if err := parseTimeParam(q, "to", &filter.To); err != nil { + return filter, err + } + if err := parsePageParams(q, &filter.Page, &filter.PerPage); err != nil { + return filter, err + } + + return filter, nil +} + +func parseIDParam(q url.Values, key string, dst **int64) error { + v := q.Get(key) + if v == "" { + return nil + } + id, err := strconv.ParseInt(v, 10, 64) + if err != nil { + return fmt.Errorf("invalid %s: %q", key, v) + } + *dst = &id + return nil +} + +func parseTimeParam(q url.Values, key string, dst **time.Time) error { + v := q.Get(key) + if v == "" { + return nil + } + t, err := time.Parse(time.RFC3339, v) + if err != nil { + return fmt.Errorf("invalid %s: %q (expected RFC 3339)", key, v) + } + *dst = &t + return nil +} + +func parsePageParams(q url.Values, page, perPage *int) error { + if v := q.Get("page"); v != "" { + p, err := strconv.Atoi(v) + if err != nil || p < 1 { + return fmt.Errorf("invalid page: %q", v) + } + *page = p + } + if v := q.Get("per_page"); v != "" { + pp, err := strconv.Atoi(v) + if err != nil || pp < 1 || pp > 100 { + return fmt.Errorf("invalid per_page: %q (must be 1-100)", v) + } + *perPage = pp + } + return nil +} diff --git a/admin/api/audit_actions.go b/admin/api/audit_actions.go new file mode 100644 index 0000000..e82b59d --- /dev/null +++ b/admin/api/audit_actions.go @@ -0,0 +1,14 @@ +// Copyright 2026 CloudBlue LLC +// SPDX-License-Identifier: Apache-2.0 + +package api + +// Audit action constants logged for each portal operation. +const ( + AuditActionInstanceCreate = "instance.create" + AuditActionInstanceUpdate = "instance.update" + AuditActionInstanceDelete = "instance.delete" + AuditActionUserLogin = "user.login" + AuditActionUserLogout = "user.logout" + AuditActionPasswordChange = "user.password_change" +) diff --git a/admin/api/audit_test.go b/admin/api/audit_test.go new file mode 100644 index 0000000..010562c --- /dev/null +++ b/admin/api/audit_test.go @@ -0,0 +1,241 @@ +// Copyright 2026 CloudBlue LLC +// SPDX-License-Identifier: Apache-2.0 + +package api + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strconv" + "testing" + + "github.com/cloudblue/chaperone/admin/store" +) + +func newAuditTestMux(t *testing.T) (*http.ServeMux, *store.Store) { + t.Helper() + st := openTestStore(t) + h := NewAuditHandler(st) + mux := http.NewServeMux() + h.Register(mux) + return mux, st +} + +func seedAuditData(t *testing.T, st *store.Store) int64 { + t.Helper() + ctx := context.Background() + user, err := st.CreateUser(ctx, "admin", "$2a$10$abcdefghijklmnopqrstuuABCDEFGHIJKLMNOPQRSTUVWXYZ01234") + if err != nil { + t.Fatalf("CreateUser() error = %v", err) + } + inst, err := st.CreateInstance(ctx, "proxy-1", "10.0.0.1:9090") + if err != nil { + t.Fatalf("CreateInstance() error = %v", err) + } + + entries := []struct { + action string + instanceID *int64 + detail string + }{ + {"instance.create", &inst.ID, "Created instance proxy-1 at 10.0.0.1:9090"}, + {"instance.update", &inst.ID, "Updated instance proxy-1"}, + {"user.login", nil, "User admin logged in"}, + } + for _, e := range entries { + if err := st.InsertAuditEntry(ctx, user.ID, e.action, e.instanceID, e.detail); err != nil { + t.Fatalf("InsertAuditEntry() error = %v", err) + } + } + return user.ID +} + +func TestAuditList_Empty_ReturnsEmptyPage(t *testing.T) { + t.Parallel() + mux, _ := newAuditTestMux(t) + + req := httptest.NewRequest(http.MethodGet, "/api/audit", nil) + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d; body = %s", rec.Code, http.StatusOK, rec.Body.String()) + } + + var page store.AuditPage + if err := json.NewDecoder(rec.Body).Decode(&page); err != nil { + t.Fatalf("decoding response: %v", err) + } + if page.Total != 0 { + t.Errorf("Total = %d, want 0", page.Total) + } + if len(page.Items) != 0 { + t.Errorf("len(Items) = %d, want 0", len(page.Items)) + } +} + +func TestAuditList_ReturnsEntries(t *testing.T) { + t.Parallel() + mux, st := newAuditTestMux(t) + seedAuditData(t, st) + + req := httptest.NewRequest(http.MethodGet, "/api/audit", nil) + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK) + } + + var page store.AuditPage + if err := json.NewDecoder(rec.Body).Decode(&page); err != nil { + t.Fatalf("decoding: %v", err) + } + if page.Total != 3 { + t.Errorf("Total = %d, want 3", page.Total) + } + if len(page.Items) != 3 { + t.Errorf("len(Items) = %d, want 3", len(page.Items)) + } +} + +func TestAuditList_FilterByAction(t *testing.T) { + t.Parallel() + mux, st := newAuditTestMux(t) + seedAuditData(t, st) + + req := httptest.NewRequest(http.MethodGet, "/api/audit?action=user.login", nil) + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d", rec.Code) + } + + var page store.AuditPage + if err := json.NewDecoder(rec.Body).Decode(&page); err != nil { + t.Fatalf("decoding: %v", err) + } + if page.Total != 1 { + t.Errorf("Total = %d, want 1", page.Total) + } +} + +func TestAuditList_FilterByUser(t *testing.T) { + t.Parallel() + mux, st := newAuditTestMux(t) + userID := seedAuditData(t, st) + + req := httptest.NewRequest(http.MethodGet, "/api/audit?user="+itoa(userID), nil) + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + + var page store.AuditPage + if err := json.NewDecoder(rec.Body).Decode(&page); err != nil { + t.Fatalf("decoding: %v", err) + } + if page.Total != 3 { + t.Errorf("Total = %d, want 3", page.Total) + } +} + +func TestAuditList_FullTextSearch(t *testing.T) { + t.Parallel() + mux, st := newAuditTestMux(t) + seedAuditData(t, st) + + req := httptest.NewRequest(http.MethodGet, "/api/audit?q=proxy-1", nil) + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + + var page store.AuditPage + if err := json.NewDecoder(rec.Body).Decode(&page); err != nil { + t.Fatalf("decoding: %v", err) + } + // "proxy-1" appears in instance.create and instance.update details. + if page.Total != 2 { + t.Errorf("Total = %d, want 2", page.Total) + } +} + +func TestAuditList_Pagination(t *testing.T) { + t.Parallel() + mux, st := newAuditTestMux(t) + seedAuditData(t, st) + + req := httptest.NewRequest(http.MethodGet, "/api/audit?page=1&per_page=2", nil) + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + + var page store.AuditPage + if err := json.NewDecoder(rec.Body).Decode(&page); err != nil { + t.Fatalf("decoding: %v", err) + } + if page.Total != 3 { + t.Errorf("Total = %d, want 3", page.Total) + } + if len(page.Items) != 2 { + t.Errorf("len(Items) = %d, want 2", len(page.Items)) + } + if page.Page != 1 { + t.Errorf("Page = %d, want 1", page.Page) + } +} + +func TestAuditList_InvalidPage_Returns400(t *testing.T) { + t.Parallel() + mux, _ := newAuditTestMux(t) + + req := httptest.NewRequest(http.MethodGet, "/api/audit?page=abc", nil) + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Errorf("status = %d, want %d", rec.Code, http.StatusBadRequest) + } +} + +func TestAuditList_InvalidPerPage_Returns400(t *testing.T) { + t.Parallel() + mux, _ := newAuditTestMux(t) + + req := httptest.NewRequest(http.MethodGet, "/api/audit?per_page=999", nil) + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Errorf("status = %d, want %d", rec.Code, http.StatusBadRequest) + } +} + +func TestAuditList_InvalidUserID_Returns400(t *testing.T) { + t.Parallel() + mux, _ := newAuditTestMux(t) + + req := httptest.NewRequest(http.MethodGet, "/api/audit?user=notanumber", nil) + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Errorf("status = %d, want %d", rec.Code, http.StatusBadRequest) + } +} + +func TestAuditList_InvalidFromDate_Returns400(t *testing.T) { + t.Parallel() + mux, _ := newAuditTestMux(t) + + req := httptest.NewRequest(http.MethodGet, "/api/audit?from=not-a-date", nil) + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Errorf("status = %d, want %d", rec.Code, http.StatusBadRequest) + } +} + +func itoa(n int64) string { + return strconv.FormatInt(n, 10) +} diff --git a/admin/api/auth.go b/admin/api/auth.go index fd2ef06..2ef0271 100644 --- a/admin/api/auth.go +++ b/admin/api/auth.go @@ -4,6 +4,7 @@ package api import ( + "context" "errors" "fmt" "log/slog" @@ -12,19 +13,22 @@ import ( "time" "github.com/cloudblue/chaperone/admin/auth" + "github.com/cloudblue/chaperone/admin/store" ) // AuthHandler handles login, logout, and password change endpoints. type AuthHandler struct { auth *auth.Service + store *store.Store secureCookies bool sessionMaxAge time.Duration } // NewAuthHandler creates a handler for auth endpoints. -func NewAuthHandler(authService *auth.Service, secureCookies bool, sessionMaxAge time.Duration) *AuthHandler { +func NewAuthHandler(authService *auth.Service, st *store.Store, secureCookies bool, sessionMaxAge time.Duration) *AuthHandler { return &AuthHandler{ auth: authService, + store: st, secureCookies: secureCookies, sessionMaxAge: sessionMaxAge, } @@ -83,6 +87,9 @@ func (h *AuthHandler) login(w http.ResponseWriter, r *http.Request) { h.setSessionCookie(w, result.SessionToken) h.setCSRFCookie(w) + h.auditLog(r.Context(), result.User.ID, AuditActionUserLogin, + fmt.Sprintf("User %q logged in from %s", result.User.Username, ip)) + respondJSON(w, http.StatusOK, loginResponse{ User: loginUser{ ID: result.User.ID, @@ -103,12 +110,20 @@ func (h *AuthHandler) me(w http.ResponseWriter, r *http.Request) { } func (h *AuthHandler) logout(w http.ResponseWriter, r *http.Request) { + user := auth.ContextUser(r.Context()) + cookie, err := r.Cookie(auth.SessionCookieName) if err == nil { if logoutErr := h.auth.Logout(r.Context(), cookie.Value); logoutErr != nil { slog.Error("logout session deletion", "error", logoutErr) } } + + if user != nil { + h.auditLog(r.Context(), user.ID, AuditActionUserLogout, + fmt.Sprintf("User %q logged out", user.Username)) + } + h.clearCookies(w) w.WriteHeader(http.StatusNoContent) } @@ -162,6 +177,8 @@ func (h *AuthHandler) changePassword(w http.ResponseWriter, r *http.Request) { return } + h.auditLog(r.Context(), user.ID, AuditActionPasswordChange, + fmt.Sprintf("User %q changed password", user.Username)) w.WriteHeader(http.StatusNoContent) } @@ -215,6 +232,12 @@ func (h *AuthHandler) clearCookies(w http.ResponseWriter) { }) } +func (h *AuthHandler) auditLog(ctx context.Context, userID int64, action, detail string) { + if err := h.store.InsertAuditEntry(ctx, userID, action, nil, detail); err != nil { + slog.Error("writing audit entry", "action", action, "error", err) + } +} + // clientIP extracts the client IP from the request's TCP peer address. // The admin portal is deployed direct-to-network within Distributor infrastructure; // X-Forwarded-For is not trusted and must be ignored for rate-limiting. diff --git a/admin/api/auth_test.go b/admin/api/auth_test.go index 8a833ad..190bcb7 100644 --- a/admin/api/auth_test.go +++ b/admin/api/auth_test.go @@ -21,7 +21,7 @@ func newTestAuthMux(t *testing.T) (*http.ServeMux, *auth.Service) { t.Helper() st := openTestStore(t) svc := auth.NewService(st, 24*time.Hour, 2*time.Hour) - h := NewAuthHandler(svc, false, 24*time.Hour) + h := NewAuthHandler(svc, st, false, 24*time.Hour) mux := http.NewServeMux() h.Register(mux) return mux, svc diff --git a/admin/api/instance.go b/admin/api/instance.go index 3d60dae..de00b9b 100644 --- a/admin/api/instance.go +++ b/admin/api/instance.go @@ -14,6 +14,7 @@ import ( "strings" "time" + "github.com/cloudblue/chaperone/admin/auth" "github.com/cloudblue/chaperone/admin/poller" "github.com/cloudblue/chaperone/admin/store" ) @@ -99,6 +100,9 @@ func (h *InstanceHandler) create(w http.ResponseWriter, r *http.Request) { respondError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to create instance") return } + + h.audit(r, AuditActionInstanceCreate, &inst.ID, + fmt.Sprintf("Created instance %q at %s", inst.Name, inst.Address)) respondJSON(w, http.StatusCreated, inst) } @@ -131,6 +135,9 @@ func (h *InstanceHandler) update(w http.ResponseWriter, r *http.Request) { respondError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to update instance") return } + + h.audit(r, AuditActionInstanceUpdate, &inst.ID, + fmt.Sprintf("Updated instance %q (address: %s)", inst.Name, inst.Address)) respondJSON(w, http.StatusOK, inst) } @@ -140,6 +147,9 @@ func (h *InstanceHandler) delete(w http.ResponseWriter, r *http.Request) { return } + // Fetch instance name before deletion for the audit detail. + inst, getErr := h.store.GetInstance(r.Context(), id) + err := h.store.DeleteInstance(r.Context(), id) if errors.Is(err, store.ErrInstanceNotFound) { respondError(w, http.StatusNotFound, "INSTANCE_NOT_FOUND", fmt.Sprintf("No instance with ID %d", id)) @@ -150,6 +160,12 @@ func (h *InstanceHandler) delete(w http.ResponseWriter, r *http.Request) { respondError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to delete instance") return } + + detail := fmt.Sprintf("Deleted instance ID %d", id) + if getErr == nil { + detail = fmt.Sprintf("Deleted instance %q (%s)", inst.Name, inst.Address) + } + h.audit(r, AuditActionInstanceDelete, nil, detail) w.WriteHeader(http.StatusNoContent) } @@ -217,6 +233,16 @@ func validateInstanceRequest(w http.ResponseWriter, req *instanceRequest) bool { var errInvalidHostPort = errors.New("address must be a valid host:port (e.g. 192.168.1.10:9090)") +func (h *InstanceHandler) audit(r *http.Request, action string, instanceID *int64, detail string) { + user := auth.ContextUser(r.Context()) + if user == nil { + return + } + if err := h.store.InsertAuditEntry(r.Context(), user.ID, action, instanceID, detail); err != nil { + slog.Error("writing audit entry", "action", action, "error", err) + } +} + func validHostPort(addr string) error { host, portStr, err := net.SplitHostPort(addr) if err != nil { diff --git a/admin/cmd/chaperone-admin/main.go b/admin/cmd/chaperone-admin/main.go index 4a9950f..40d2691 100644 --- a/admin/cmd/chaperone-admin/main.go +++ b/admin/cmd/chaperone-admin/main.go @@ -100,18 +100,28 @@ func runServer(args []string) error { return fmt.Errorf("creating server: %w", err) } - // Start background goroutines. bgCtx, bgCancel := context.WithCancel(context.Background()) defer bgCancel() - - p := poller.New(st, collector, cfg.Scraper.Interval.Unwrap(), cfg.Scraper.Timeout.Unwrap()) - go p.Run(bgCtx) - go cleanupExpiredSessions(bgCtx, st) - go sweepRateLimiter(bgCtx, srv) + startBackground(bgCtx, cfg, st, collector, srv) return serve(cfg.Server.Addr, srv) } +func startBackground(ctx context.Context, cfg *config.Config, st *store.Store, collector *metrics.Collector, srv *admin.Server) { + p := poller.New(st, collector, cfg.Scraper.Interval.Unwrap(), cfg.Scraper.Timeout.Unwrap()) + go p.Run(ctx) + go cleanupExpiredSessions(ctx, st) + go sweepRateLimiter(ctx, srv) + + if cfg.Audit.RetentionDays == nil || *cfg.Audit.RetentionDays > 0 { + retentionDays := 90 + if cfg.Audit.RetentionDays != nil { + retentionDays = *cfg.Audit.RetentionDays + } + go cleanupOldAuditEntries(ctx, st, retentionDays) + } +} + func runCreateUser(args []string) error { fs := flag.NewFlagSet("create-user", flag.ExitOnError) configPath := fs.String("config", "", "Path to config file") @@ -252,6 +262,32 @@ func sweepRateLimiter(ctx context.Context, srv *admin.Server) { } } +func cleanupOldAuditEntries(ctx context.Context, st *store.Store, retentionDays int) { + ticker := time.NewTicker(24 * time.Hour) + defer ticker.Stop() + + runCleanup := func() { + cutoff := time.Now().AddDate(0, 0, -retentionDays) + n, err := st.DeleteAuditEntriesBefore(ctx, cutoff) + if err != nil { + slog.Error("cleaning up old audit entries", "error", err) + } else if n > 0 { + slog.Info("cleaned up old audit entries", "count", n, "retention_days", retentionDays) + } + } + + runCleanup() + + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + runCleanup() + } + } +} + func serve(addr string, srv *admin.Server) error { ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) defer stop() diff --git a/admin/server.go b/admin/server.go index e61ada8..e6f6bb7 100644 --- a/admin/server.go +++ b/admin/server.go @@ -79,7 +79,7 @@ func (s *Server) routes(mux *http.ServeMux, authService *auth.Service, secureCoo mux.HandleFunc("GET /api/health", s.handleHealth) // Auth endpoints (login, logout, password change). - authHandler := api.NewAuthHandler(authService, secureCookies, s.config.Session.MaxAge.Unwrap()) + authHandler := api.NewAuthHandler(authService, s.store, secureCookies, s.config.Session.MaxAge.Unwrap()) authHandler.Register(mux) // Instance CRUD + test connection. @@ -90,6 +90,10 @@ func (s *Server) routes(mux *http.ServeMux, authService *auth.Service, secureCoo metricsAPI := api.NewMetricsHandler(s.store, s.collector) metricsAPI.Register(mux) + // Audit log API. + audit := api.NewAuditHandler(s.store) + audit.Register(mux) + // SPA serving — all non-API routes serve the Vue app. assets, err := loadUIAssets() if err != nil { diff --git a/admin/store/audit.go b/admin/store/audit.go new file mode 100644 index 0000000..b7ff251 --- /dev/null +++ b/admin/store/audit.go @@ -0,0 +1,162 @@ +// Copyright 2026 CloudBlue LLC +// SPDX-License-Identifier: Apache-2.0 + +package store + +import ( + "context" + "fmt" + "strings" + "time" +) + +// AuditEntry represents a single audit log record. +type AuditEntry struct { + ID int64 `json:"id"` + UserID int64 `json:"user_id"` + Username string `json:"user"` + Action string `json:"action"` + InstanceID *int64 `json:"instance_id,omitempty"` + Detail string `json:"detail"` + CreatedAt time.Time `json:"created_at"` +} + +// AuditFilter specifies query parameters for listing audit entries. +type AuditFilter struct { + UserID *int64 + Action string + InstanceID *int64 + From *time.Time + To *time.Time + Query string // full-text search on detail + Page int + PerPage int +} + +// AuditPage is a paginated response of audit entries. +type AuditPage struct { + Items []AuditEntry `json:"items"` + Total int `json:"total"` + Page int `json:"page"` +} + +// InsertAuditEntry records an action in the audit log. +func (s *Store) InsertAuditEntry(ctx context.Context, userID int64, action string, instanceID *int64, detail string) error { + _, err := s.db.ExecContext(ctx, + `INSERT INTO audit_log (user_id, action, instance_id, detail) VALUES (?, ?, ?, ?)`, + userID, action, instanceID, detail) + if err != nil { + return fmt.Errorf("inserting audit entry: %w", err) + } + return nil +} + +// ListAuditEntries returns a paginated, filtered list of audit entries. +func (s *Store) ListAuditEntries(ctx context.Context, filter AuditFilter) (*AuditPage, error) { + if filter.Page < 1 { + filter.Page = 1 + } + if filter.PerPage < 1 { + filter.PerPage = 20 + } + + conditions, args := buildAuditConditions(filter) + joins := "JOIN users u ON a.user_id = u.id" + if filter.Query != "" { + joins += " JOIN audit_log_fts f ON a.id = f.rowid" + } + + where := "1=1" + if len(conditions) > 0 { + where = strings.Join(conditions, " AND ") + } + + // Count total matching entries. + // Dynamic SQL is safe: joins and where are built from fixed strings, not user input. + countQuery := fmt.Sprintf("SELECT COUNT(*) FROM audit_log a %s WHERE %s", joins, where) //nolint:gosec // G201 -- see above + var total int + if err := s.db.QueryRowContext(ctx, countQuery, args...).Scan(&total); err != nil { + return nil, fmt.Errorf("counting audit entries: %w", err) + } + + // Fetch the page. + offset := (filter.Page - 1) * filter.PerPage + dataQuery := fmt.Sprintf( //nolint:gosec // G201 -- joins/where built from fixed strings + `SELECT a.id, a.user_id, u.username, a.action, a.instance_id, a.detail, a.created_at + FROM audit_log a %s + WHERE %s + ORDER BY a.created_at DESC + LIMIT ? OFFSET ?`, joins, where) + dataArgs := append(args, filter.PerPage, offset) //nolint:gocritic // append to copy is intentional + + rows, err := s.db.QueryContext(ctx, dataQuery, dataArgs...) + if err != nil { + return nil, fmt.Errorf("listing audit entries: %w", err) + } + defer rows.Close() + + items := make([]AuditEntry, 0) + for rows.Next() { + var e AuditEntry + if err := rows.Scan(&e.ID, &e.UserID, &e.Username, &e.Action, &e.InstanceID, &e.Detail, &e.CreatedAt); err != nil { + return nil, fmt.Errorf("scanning audit entry: %w", err) + } + items = append(items, e) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterating audit entries: %w", err) + } + + return &AuditPage{Items: items, Total: total, Page: filter.Page}, nil +} + +// DeleteAuditEntriesBefore removes audit entries older than the given time. +// Returns the number of deleted rows. +func (s *Store) DeleteAuditEntriesBefore(ctx context.Context, before time.Time) (int64, error) { + result, err := s.db.ExecContext(ctx, + `DELETE FROM audit_log WHERE created_at < ?`, before) + if err != nil { + return 0, fmt.Errorf("deleting old audit entries: %w", err) + } + n, err := result.RowsAffected() + if err != nil { + return 0, fmt.Errorf("checking rows affected: %w", err) + } + return n, nil +} + +func buildAuditConditions(filter AuditFilter) (conditions []string, args []any) { + if filter.UserID != nil { + conditions = append(conditions, "a.user_id = ?") + args = append(args, *filter.UserID) + } + if filter.Action != "" { + conditions = append(conditions, "a.action = ?") + args = append(args, filter.Action) + } + if filter.InstanceID != nil { + conditions = append(conditions, "a.instance_id = ?") + args = append(args, *filter.InstanceID) + } + if filter.From != nil { + conditions = append(conditions, "a.created_at >= ?") + args = append(args, *filter.From) + } + if filter.To != nil { + conditions = append(conditions, "a.created_at <= ?") + args = append(args, *filter.To) + } + if filter.Query != "" { + conditions = append(conditions, "audit_log_fts MATCH ?") + args = append(args, ftsQuote(filter.Query)) + } + + return conditions, args +} + +// ftsQuote wraps a user query in double quotes so FTS5 treats it as a +// literal phrase. Internal double quotes are escaped per FTS5 rules. +func ftsQuote(q string) string { + escaped := strings.ReplaceAll(q, `"`, `""`) + return `"` + escaped + `"` +} diff --git a/admin/store/audit_test.go b/admin/store/audit_test.go new file mode 100644 index 0000000..ed6e485 --- /dev/null +++ b/admin/store/audit_test.go @@ -0,0 +1,392 @@ +// Copyright 2026 CloudBlue LLC +// SPDX-License-Identifier: Apache-2.0 + +package store + +import ( + "context" + "testing" + "time" +) + +func createTestUser(t *testing.T, st *Store) int64 { + t.Helper() + user, err := st.CreateUser(context.Background(), "testuser", "$2a$10$abcdefghijklmnopqrstuuABCDEFGHIJKLMNOPQRSTUVWXYZ01234") + if err != nil { + t.Fatalf("CreateUser() error = %v", err) + } + return user.ID +} + +func createTestInstance(t *testing.T, st *Store) int64 { + t.Helper() + inst, err := st.CreateInstance(context.Background(), "test-proxy", "10.0.0.1:9090") + if err != nil { + t.Fatalf("CreateInstance() error = %v", err) + } + return inst.ID +} + +func TestInsertAuditEntry_Success(t *testing.T) { + t.Parallel() + st := openTestStore(t) + ctx := context.Background() + userID := createTestUser(t, st) + instID := createTestInstance(t, st) + + err := st.InsertAuditEntry(ctx, userID, "instance.create", &instID, "Created instance test-proxy at 10.0.0.1:9090") + if err != nil { + t.Fatalf("InsertAuditEntry() error = %v", err) + } +} + +func TestInsertAuditEntry_NilInstanceID(t *testing.T) { + t.Parallel() + st := openTestStore(t) + ctx := context.Background() + userID := createTestUser(t, st) + + err := st.InsertAuditEntry(ctx, userID, "user.login", nil, "User logged in") + if err != nil { + t.Fatalf("InsertAuditEntry() error = %v", err) + } +} + +func TestListAuditEntries_Empty_ReturnsEmptyPage(t *testing.T) { + t.Parallel() + st := openTestStore(t) + ctx := context.Background() + + page, err := st.ListAuditEntries(ctx, AuditFilter{Page: 1, PerPage: 20}) + if err != nil { + t.Fatalf("ListAuditEntries() error = %v", err) + } + if page.Total != 0 { + t.Errorf("Total = %d, want 0", page.Total) + } + if len(page.Items) != 0 { + t.Errorf("len(Items) = %d, want 0", len(page.Items)) + } + if page.Page != 1 { + t.Errorf("Page = %d, want 1", page.Page) + } +} + +func TestListAuditEntries_ReturnsEntriesOrderedByCreatedAtDesc(t *testing.T) { + t.Parallel() + st := openTestStore(t) + ctx := context.Background() + userID := createTestUser(t, st) + + actions := []string{"user.login", "instance.create", "instance.delete"} + for _, action := range actions { + if err := st.InsertAuditEntry(ctx, userID, action, nil, "detail for "+action); err != nil { + t.Fatalf("InsertAuditEntry(%s) error = %v", action, err) + } + } + + page, err := st.ListAuditEntries(ctx, AuditFilter{Page: 1, PerPage: 20}) + if err != nil { + t.Fatalf("ListAuditEntries() error = %v", err) + } + if page.Total != 3 { + t.Fatalf("Total = %d, want 3", page.Total) + } + + // Most recent first. + if page.Items[0].Action != "instance.delete" { + t.Errorf("Items[0].Action = %q, want %q", page.Items[0].Action, "instance.delete") + } + if page.Items[2].Action != "user.login" { + t.Errorf("Items[2].Action = %q, want %q", page.Items[2].Action, "user.login") + } +} + +func TestListAuditEntries_JoinsUsername(t *testing.T) { + t.Parallel() + st := openTestStore(t) + ctx := context.Background() + userID := createTestUser(t, st) + + if err := st.InsertAuditEntry(ctx, userID, "user.login", nil, "Login"); err != nil { + t.Fatalf("InsertAuditEntry() error = %v", err) + } + + page, err := st.ListAuditEntries(ctx, AuditFilter{Page: 1, PerPage: 20}) + if err != nil { + t.Fatalf("ListAuditEntries() error = %v", err) + } + if page.Items[0].Username != "testuser" { + t.Errorf("Username = %q, want %q", page.Items[0].Username, "testuser") + } +} + +func TestListAuditEntries_FilterByAction(t *testing.T) { + t.Parallel() + st := openTestStore(t) + ctx := context.Background() + userID := createTestUser(t, st) + + for _, action := range []string{"user.login", "instance.create", "user.login"} { + if err := st.InsertAuditEntry(ctx, userID, action, nil, "detail"); err != nil { + t.Fatalf("InsertAuditEntry() error = %v", err) + } + } + + page, err := st.ListAuditEntries(ctx, AuditFilter{Action: "user.login", Page: 1, PerPage: 20}) + if err != nil { + t.Fatalf("ListAuditEntries() error = %v", err) + } + if page.Total != 2 { + t.Errorf("Total = %d, want 2", page.Total) + } + for _, item := range page.Items { + if item.Action != "user.login" { + t.Errorf("Action = %q, want %q", item.Action, "user.login") + } + } +} + +func TestListAuditEntries_FilterByUserID(t *testing.T) { + t.Parallel() + st := openTestStore(t) + ctx := context.Background() + userID := createTestUser(t, st) + + user2, createErr := st.CreateUser(ctx, "other", "$2a$10$abcdefghijklmnopqrstuuABCDEFGHIJKLMNOPQRSTUVWXYZ01234") + if createErr != nil { + t.Fatalf("CreateUser() error = %v", createErr) + } + + if insertErr := st.InsertAuditEntry(ctx, userID, "user.login", nil, "u1"); insertErr != nil { + t.Fatal(insertErr) + } + if insertErr := st.InsertAuditEntry(ctx, user2.ID, "user.login", nil, "u2"); insertErr != nil { + t.Fatal(insertErr) + } + + page, err := st.ListAuditEntries(ctx, AuditFilter{UserID: &userID, Page: 1, PerPage: 20}) + if err != nil { + t.Fatalf("ListAuditEntries() error = %v", err) + } + if page.Total != 1 { + t.Errorf("Total = %d, want 1", page.Total) + } +} + +func TestListAuditEntries_FilterByInstanceID(t *testing.T) { + t.Parallel() + st := openTestStore(t) + ctx := context.Background() + userID := createTestUser(t, st) + instID := createTestInstance(t, st) + + if err := st.InsertAuditEntry(ctx, userID, "instance.create", &instID, "with instance"); err != nil { + t.Fatal(err) + } + if err := st.InsertAuditEntry(ctx, userID, "user.login", nil, "without instance"); err != nil { + t.Fatal(err) + } + + page, err := st.ListAuditEntries(ctx, AuditFilter{InstanceID: &instID, Page: 1, PerPage: 20}) + if err != nil { + t.Fatalf("ListAuditEntries() error = %v", err) + } + if page.Total != 1 { + t.Errorf("Total = %d, want 1", page.Total) + } +} + +func TestListAuditEntries_FilterByDateRange(t *testing.T) { + t.Parallel() + st := openTestStore(t) + ctx := context.Background() + userID := createTestUser(t, st) + + // Insert entries with explicit timestamps via raw SQL. + for _, ts := range []string{"2026-01-01 00:00:00", "2026-02-01 00:00:00", "2026-03-01 00:00:00"} { + _, err := st.db.ExecContext(ctx, + `INSERT INTO audit_log (user_id, action, detail, created_at) VALUES (?, 'user.login', ?, ?)`, + userID, "entry at "+ts, ts) + if err != nil { + t.Fatalf("inserting audit entry: %v", err) + } + } + + from := time.Date(2026, 1, 15, 0, 0, 0, 0, time.UTC) + to := time.Date(2026, 2, 15, 0, 0, 0, 0, time.UTC) + + page, err := st.ListAuditEntries(ctx, AuditFilter{From: &from, To: &to, Page: 1, PerPage: 20}) + if err != nil { + t.Fatalf("ListAuditEntries() error = %v", err) + } + if page.Total != 1 { + t.Errorf("Total = %d, want 1", page.Total) + } +} + +func TestListAuditEntries_FullTextSearch(t *testing.T) { + t.Parallel() + st := openTestStore(t) + ctx := context.Background() + userID := createTestUser(t, st) + + entries := []struct{ action, detail string }{ + {"instance.create", "Created instance production-proxy at 10.0.0.1:9090"}, + {"instance.create", "Created instance staging-proxy at 10.0.0.2:9090"}, + {"user.login", "User logged in from 192.168.1.1"}, + } + for _, e := range entries { + if err := st.InsertAuditEntry(ctx, userID, e.action, nil, e.detail); err != nil { + t.Fatalf("InsertAuditEntry() error = %v", err) + } + } + + page, err := st.ListAuditEntries(ctx, AuditFilter{Query: "production", Page: 1, PerPage: 20}) + if err != nil { + t.Fatalf("ListAuditEntries() error = %v", err) + } + if page.Total != 1 { + t.Errorf("Total = %d, want 1", page.Total) + } + if page.Total > 0 && page.Items[0].Detail != "Created instance production-proxy at 10.0.0.1:9090" { + t.Errorf("Detail = %q, unexpected", page.Items[0].Detail) + } +} + +func TestListAuditEntries_Pagination(t *testing.T) { + t.Parallel() + st := openTestStore(t) + ctx := context.Background() + userID := createTestUser(t, st) + + for i := 0; i < 5; i++ { + if err := st.InsertAuditEntry(ctx, userID, "user.login", nil, "entry"); err != nil { + t.Fatal(err) + } + } + + page1, err := st.ListAuditEntries(ctx, AuditFilter{Page: 1, PerPage: 2}) + if err != nil { + t.Fatalf("page 1: %v", err) + } + if len(page1.Items) != 2 { + t.Errorf("page 1 len = %d, want 2", len(page1.Items)) + } + if page1.Total != 5 { + t.Errorf("page 1 Total = %d, want 5", page1.Total) + } + + page3, err := st.ListAuditEntries(ctx, AuditFilter{Page: 3, PerPage: 2}) + if err != nil { + t.Fatalf("page 3: %v", err) + } + if len(page3.Items) != 1 { + t.Errorf("page 3 len = %d, want 1", len(page3.Items)) + } +} + +func TestListAuditEntries_CombinedFilters(t *testing.T) { + t.Parallel() + st := openTestStore(t) + ctx := context.Background() + userID := createTestUser(t, st) + instID := createTestInstance(t, st) + + if err := st.InsertAuditEntry(ctx, userID, "instance.create", &instID, "Created production-proxy"); err != nil { + t.Fatal(err) + } + if err := st.InsertAuditEntry(ctx, userID, "instance.delete", &instID, "Deleted production-proxy"); err != nil { + t.Fatal(err) + } + if err := st.InsertAuditEntry(ctx, userID, "user.login", nil, "Login"); err != nil { + t.Fatal(err) + } + + page, err := st.ListAuditEntries(ctx, AuditFilter{ + Action: "instance.create", + InstanceID: &instID, + Query: "production", + Page: 1, + PerPage: 20, + }) + if err != nil { + t.Fatalf("ListAuditEntries() error = %v", err) + } + if page.Total != 1 { + t.Errorf("Total = %d, want 1", page.Total) + } +} + +func TestDeleteAuditEntriesBefore_DeletesOldEntries(t *testing.T) { + t.Parallel() + st := openTestStore(t) + ctx := context.Background() + userID := createTestUser(t, st) + + // Insert old and new entries via raw SQL for controlled timestamps. + _, insertErr := st.db.ExecContext(ctx, + `INSERT INTO audit_log (user_id, action, detail, created_at) VALUES (?, 'user.login', 'old', '2025-01-01 00:00:00')`, + userID) + if insertErr != nil { + t.Fatal(insertErr) + } + if recentErr := st.InsertAuditEntry(ctx, userID, "user.login", nil, "recent"); recentErr != nil { + t.Fatal(recentErr) + } + + cutoff := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + deleted, err := st.DeleteAuditEntriesBefore(ctx, cutoff) + if err != nil { + t.Fatalf("DeleteAuditEntriesBefore() error = %v", err) + } + if deleted != 1 { + t.Errorf("deleted = %d, want 1", deleted) + } + + page, err := st.ListAuditEntries(ctx, AuditFilter{Page: 1, PerPage: 20}) + if err != nil { + t.Fatal(err) + } + if page.Total != 1 { + t.Errorf("Total = %d, want 1", page.Total) + } +} + +func TestFtsQuote_EscapesSpecialCharacters(t *testing.T) { + t.Parallel() + tests := []struct { + input string + want string + }{ + {"simple", `"simple"`}, + {"proxy-1", `"proxy-1"`}, + {"", `""`}, + {`has "quotes"`, `"has ""quotes"""`}, + {`double "" already`, `"double """" already"`}, + {"AND OR NOT", `"AND OR NOT"`}, + {"prefix*", `"prefix*"`}, + {"NEAR/2", `"NEAR/2"`}, + {`back\slash`, `"back\slash"`}, + } + for _, tt := range tests { + got := ftsQuote(tt.input) + if got != tt.want { + t.Errorf("ftsQuote(%q) = %q, want %q", tt.input, got, tt.want) + } + } +} + +func TestDeleteAuditEntriesBefore_NothingToDelete(t *testing.T) { + t.Parallel() + st := openTestStore(t) + + cutoff := time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC) + deleted, err := st.DeleteAuditEntriesBefore(context.Background(), cutoff) + if err != nil { + t.Fatalf("DeleteAuditEntriesBefore() error = %v", err) + } + if deleted != 0 { + t.Errorf("deleted = %d, want 0", deleted) + } +} diff --git a/admin/store/migrations.go b/admin/store/migrations.go index 5d8fc7e..2c2caf6 100644 --- a/admin/store/migrations.go +++ b/admin/store/migrations.go @@ -63,6 +63,30 @@ CREATE INDEX idx_audit_log_action ON audit_log(action); CREATE INDEX idx_sessions_token ON sessions(token); CREATE INDEX idx_sessions_user_id ON sessions(user_id); CREATE INDEX idx_sessions_expires_at ON sessions(expires_at); +`, + }, + { + Version: 2, + Description: "add FTS5 index for audit log full-text search", + SQL: ` +CREATE VIRTUAL TABLE audit_log_fts USING fts5( + detail, + content='audit_log', + content_rowid='id' +); + +CREATE TRIGGER audit_log_ai AFTER INSERT ON audit_log BEGIN + INSERT INTO audit_log_fts(rowid, detail) VALUES (new.id, new.detail); +END; + +CREATE TRIGGER audit_log_ad AFTER DELETE ON audit_log BEGIN + INSERT INTO audit_log_fts(audit_log_fts, rowid, detail) VALUES('delete', old.id, old.detail); +END; + +CREATE TRIGGER audit_log_au AFTER UPDATE ON audit_log BEGIN + INSERT INTO audit_log_fts(audit_log_fts, rowid, detail) VALUES('delete', old.id, old.detail); + INSERT INTO audit_log_fts(rowid, detail) VALUES (new.id, new.detail); +END; `, }, } diff --git a/admin/store/store_test.go b/admin/store/store_test.go index 31a1e07..2b2bc9e 100644 --- a/admin/store/store_test.go +++ b/admin/store/store_test.go @@ -48,7 +48,7 @@ func TestOpen_CreatesAllTables(t *testing.T) { t.Fatalf("iterating rows: %v", err) } - expected := []string{"audit_log", "instances", "schema_migrations", "sessions", "users"} + expected := []string{"audit_log", "audit_log_fts", "audit_log_fts_config", "audit_log_fts_data", "audit_log_fts_docsize", "audit_log_fts_idx", "instances", "schema_migrations", "sessions", "users"} sort.Strings(tables) if len(tables) != len(expected) { @@ -85,8 +85,8 @@ func TestOpen_MigrationIdempotent(t *testing.T) { if err := st2.DB().QueryRowContext(context.Background(), "SELECT COUNT(*) FROM schema_migrations").Scan(&count); err != nil { t.Fatalf("counting migrations: %v", err) } - if count != 1 { - t.Errorf("migration count = %d, want 1", count) + if count != len(migrations) { + t.Errorf("migration count = %d, want %d", count, len(migrations)) } } @@ -117,8 +117,8 @@ func TestOpen_SchemaMigrations_TracksVersion(t *testing.T) { if err := st.DB().QueryRowContext(context.Background(), "SELECT MAX(version) FROM schema_migrations").Scan(&version); err != nil { t.Fatalf("querying schema version: %v", err) } - if version != 1 { - t.Errorf("schema version = %d, want 1", version) + if version != len(migrations) { + t.Errorf("schema version = %d, want %d", version, len(migrations)) } } From ab1bcc9042b2d2f4015b1527c5499a64c7c8a9a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADctor=20Cuevas?= Date: Fri, 10 Apr 2026 15:28:00 +0200 Subject: [PATCH 25/36] feat(admin): add audit log UI with filters, pagination, and search Filterable, paginated audit log table with debounced FTS5 search, action type dropdown, and date range pickers. Extracts logic into pure functions (utils/audit.js) and composable (useAuditLog.js). - Date filters use local-timezone boundaries, not UTC - Stale response guard discards out-of-order fetches - 25 utility + 11 composable tests (165 total passing) --- admin/api/audit_actions.go | 1 + admin/ui/src/assets/variables.css | 2 + admin/ui/src/composables/useAuditLog.js | 81 ++++ admin/ui/src/composables/useAuditLog.test.js | 179 +++++++ admin/ui/src/utils/audit.js | 104 ++++ admin/ui/src/utils/audit.test.js | 198 ++++++++ admin/ui/src/views/AuditLogView.vue | 486 ++++++++++++++++++- 7 files changed, 1050 insertions(+), 1 deletion(-) create mode 100644 admin/ui/src/composables/useAuditLog.js create mode 100644 admin/ui/src/composables/useAuditLog.test.js create mode 100644 admin/ui/src/utils/audit.js create mode 100644 admin/ui/src/utils/audit.test.js diff --git a/admin/api/audit_actions.go b/admin/api/audit_actions.go index e82b59d..0275ecd 100644 --- a/admin/api/audit_actions.go +++ b/admin/api/audit_actions.go @@ -4,6 +4,7 @@ package api // Audit action constants logged for each portal operation. +// Keep in sync with the frontend labels in admin/ui/src/utils/audit.js. const ( AuditActionInstanceCreate = "instance.create" AuditActionInstanceUpdate = "instance.update" diff --git a/admin/ui/src/assets/variables.css b/admin/ui/src/assets/variables.css index 9b55bf6..2d4f22f 100644 --- a/admin/ui/src/assets/variables.css +++ b/admin/ui/src/assets/variables.css @@ -30,6 +30,8 @@ --color-error-bg: #fef2f2; --color-error-hover: #b91c1c; --color-error-border: #fecaca; + --color-purple: #7c3aed; + --color-purple-light: #f3e8ff; /* Colors — Border */ --color-border: #e5e7eb; diff --git a/admin/ui/src/composables/useAuditLog.js b/admin/ui/src/composables/useAuditLog.js new file mode 100644 index 0000000..f4c2234 --- /dev/null +++ b/admin/ui/src/composables/useAuditLog.js @@ -0,0 +1,81 @@ +import { ref, computed, watch } from 'vue'; +import { buildAuditQueryString, totalPages } from '../utils/audit.js'; + +export function useAuditLog(api) { + const items = ref([]); + const total = ref(0); + const loading = ref(false); + const error = ref(null); + + const filters = ref({ + q: '', + action: '', + from: '', + to: '', + page: 1, + perPage: 20, + }); + + let fetchId = 0; + + async function fetch() { + const id = ++fetchId; + loading.value = true; + error.value = null; + try { + const qs = buildAuditQueryString(filters.value); + const data = await api.get(`/api/audit${qs}`); + if (id !== fetchId) return; + items.value = data.items; + total.value = data.total; + } catch (err) { + if (id !== fetchId) return; + error.value = err.message || 'Failed to load audit log'; + items.value = []; + total.value = 0; + } finally { + if (id === fetchId) loading.value = false; + } + } + + function setFilter(key, value) { + filters.value = { ...filters.value, [key]: value, page: 1 }; + } + + function setPage(page) { + const max = totalPages(total.value, filters.value.perPage); + filters.value = { + ...filters.value, + page: Math.max(1, Math.min(page, max)), + }; + } + + function nextPage() { + setPage(filters.value.page + 1); + } + + function prevPage() { + setPage(filters.value.page - 1); + } + + const pageCount = computed(() => + totalPages(total.value, filters.value.perPage), + ); + + // Refetch when filters change. + watch(filters, () => fetch(), { deep: true }); + + return { + items, + total, + loading, + error, + filters, + fetch, + setFilter, + setPage, + nextPage, + prevPage, + pageCount, + }; +} diff --git a/admin/ui/src/composables/useAuditLog.test.js b/admin/ui/src/composables/useAuditLog.test.js new file mode 100644 index 0000000..c673eda --- /dev/null +++ b/admin/ui/src/composables/useAuditLog.test.js @@ -0,0 +1,179 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { nextTick } from 'vue'; +import { withSetup } from '../utils/test-utils.js'; +import { useAuditLog } from './useAuditLog.js'; + +function makeApi(response) { + return { + get: vi + .fn() + .mockResolvedValue(response ?? { items: [], total: 0, page: 1 }), + }; +} + +function makeMockData(count = 3) { + return { + items: Array.from({ length: count }, (_, i) => ({ + id: i + 1, + user_id: 1, + user: 'admin', + action: 'instance.create', + instance_id: i + 10, + detail: `Created instance ${i + 1}`, + created_at: '2026-03-09T12:00:00Z', + })), + total: count, + page: 1, + }; +} + +describe('useAuditLog', () => { + let api; + + beforeEach(() => { + api = makeApi(makeMockData()); + }); + + it('initializes with empty state', () => { + const { result } = withSetup(() => useAuditLog(api)); + expect(result.items.value).toEqual([]); + expect(result.total.value).toBe(0); + expect(result.loading.value).toBe(false); + expect(result.error.value).toBeNull(); + }); + + it('fetches audit entries from API', async () => { + const { result } = withSetup(() => useAuditLog(api)); + await result.fetch(); + expect(api.get).toHaveBeenCalledWith('/api/audit'); + expect(result.items.value).toHaveLength(3); + expect(result.total.value).toBe(3); + }); + + it('sets loading state during fetch', async () => { + let resolvePromise; + api.get = vi.fn( + () => + new Promise((resolve) => { + resolvePromise = resolve; + }), + ); + const { result } = withSetup(() => useAuditLog(api)); + + const fetchPromise = result.fetch(); + expect(result.loading.value).toBe(true); + + resolvePromise({ items: [], total: 0, page: 1 }); + await fetchPromise; + expect(result.loading.value).toBe(false); + }); + + it('sets error on API failure', async () => { + api.get = vi.fn().mockRejectedValue(new Error('Network error')); + const { result } = withSetup(() => useAuditLog(api)); + + await result.fetch(); + expect(result.error.value).toBe('Network error'); + expect(result.items.value).toEqual([]); + }); + + it('builds query string from filters', async () => { + const { result } = withSetup(() => useAuditLog(api)); + result.setFilter('action', 'user.login'); + await nextTick(); + // Wait for the watcher-triggered fetch + await vi.waitFor(() => { + expect(api.get).toHaveBeenCalledWith( + expect.stringContaining('action=user.login'), + ); + }); + }); + + it('resets page to 1 when setting a filter', () => { + const { result } = withSetup(() => useAuditLog(api)); + result.filters.value.page = 3; + result.setFilter('q', 'test'); + expect(result.filters.value.page).toBe(1); + }); + + it('navigates pages with nextPage/prevPage', async () => { + api = makeApi({ items: [], total: 60, page: 1 }); + const { result } = withSetup(() => useAuditLog(api)); + await result.fetch(); + + result.nextPage(); + expect(result.filters.value.page).toBe(2); + + result.nextPage(); + expect(result.filters.value.page).toBe(3); + + result.prevPage(); + expect(result.filters.value.page).toBe(2); + }); + + it('clamps page within valid range', async () => { + api = makeApi({ items: [], total: 40, page: 1 }); + const { result } = withSetup(() => useAuditLog(api)); + await result.fetch(); + + result.prevPage(); + expect(result.filters.value.page).toBe(1); + + result.setPage(999); + expect(result.filters.value.page).toBe(2); // total 40, perPage 20 = 2 pages + }); + + it('computes pageCount correctly', async () => { + api = makeApi({ items: [], total: 45, page: 1 }); + const { result } = withSetup(() => useAuditLog(api)); + await result.fetch(); + expect(result.pageCount.value).toBe(3); + }); + + it('discards stale responses from overlapping fetches', async () => { + let resolveFirst; + let resolveSecond; + api.get = vi + .fn() + .mockImplementationOnce( + () => + new Promise((r) => { + resolveFirst = r; + }), + ) + .mockImplementationOnce( + () => + new Promise((r) => { + resolveSecond = r; + }), + ); + const { result } = withSetup(() => useAuditLog(api)); + + const first = result.fetch(); + const second = result.fetch(); + + // Resolve second (newer) first + resolveSecond({ items: [{ id: 2 }], total: 1, page: 1 }); + await second; + + // Resolve first (stale) after + resolveFirst({ items: [{ id: 1 }], total: 1, page: 1 }); + await first; + + // Should keep the second (newer) result + expect(result.items.value).toEqual([{ id: 2 }]); + }); + + it('refetches automatically when filters change', async () => { + const { result } = withSetup(() => useAuditLog(api)); + // Initial fetch + await result.fetch(); + api.get.mockClear(); + + result.setFilter('q', 'proxy'); + await nextTick(); + await vi.waitFor(() => { + expect(api.get).toHaveBeenCalled(); + }); + }); +}); diff --git a/admin/ui/src/utils/audit.js b/admin/ui/src/utils/audit.js new file mode 100644 index 0000000..026d9d9 --- /dev/null +++ b/admin/ui/src/utils/audit.js @@ -0,0 +1,104 @@ +// Action identifiers must match the backend constants in admin/api/audit_actions.go. +const ACTION_LABELS = { + 'instance.create': 'Instance created', + 'instance.update': 'Instance updated', + 'instance.delete': 'Instance deleted', + 'user.login': 'User logged in', + 'user.logout': 'User logged out', + 'user.password_change': 'Password changed', +}; + +const ACTION_OPTIONS = [ + { value: '', label: 'All actions' }, + { value: 'instance.create', label: 'Instance created' }, + { value: 'instance.update', label: 'Instance updated' }, + { value: 'instance.delete', label: 'Instance deleted' }, + { value: 'user.login', label: 'User logged in' }, + { value: 'user.logout', label: 'User logged out' }, + { value: 'user.password_change', label: 'Password changed' }, +]; + +export function getActionLabel(action) { + return ACTION_LABELS[action] || action; +} + +export function getActionOptions() { + return ACTION_OPTIONS; +} + +export function formatAuditTimestamp(isoString) { + if (!isoString) return ''; + const d = new Date(isoString); + if (isNaN(d.getTime())) return ''; + + const now = new Date(); + const diffMs = now - d; + const diffSecs = Math.floor(diffMs / 1000); + + if (diffSecs < 60) return 'just now'; + if (diffSecs < 3600) return `${Math.floor(diffSecs / 60)}m ago`; + + const isToday = + d.getDate() === now.getDate() && + d.getMonth() === now.getMonth() && + d.getFullYear() === now.getFullYear(); + + const time = d.toLocaleTimeString(undefined, { + hour: '2-digit', + minute: '2-digit', + }); + + if (isToday) return `Today ${time}`; + + const yesterday = new Date(now); + yesterday.setDate(yesterday.getDate() - 1); + const isYesterday = + d.getDate() === yesterday.getDate() && + d.getMonth() === yesterday.getMonth() && + d.getFullYear() === yesterday.getFullYear(); + + if (isYesterday) return `Yesterday ${time}`; + + return d.toLocaleDateString(undefined, { + month: 'short', + day: 'numeric', + year: d.getFullYear() !== now.getFullYear() ? 'numeric' : undefined, + hour: '2-digit', + minute: '2-digit', + }); +} + +// Converts a YYYY-MM-DD date string to an RFC 3339 UTC timestamp +// representing the start of that day in the user's local timezone. +export function startOfLocalDay(dateStr) { + if (!dateStr) return ''; + return new Date(dateStr + 'T00:00:00').toISOString(); +} + +// Converts a YYYY-MM-DD date string to an RFC 3339 UTC timestamp +// representing the end of that day in the user's local timezone. +export function endOfLocalDay(dateStr) { + if (!dateStr) return ''; + return new Date(dateStr + 'T23:59:59').toISOString(); +} + +export function buildAuditQueryString(filters) { + const params = new URLSearchParams(); + + if (filters.q) params.set('q', filters.q); + if (filters.action) params.set('action', filters.action); + if (filters.from) params.set('from', startOfLocalDay(filters.from)); + if (filters.to) params.set('to', endOfLocalDay(filters.to)); + if (filters.page && filters.page > 1) + params.set('page', String(filters.page)); + if (filters.perPage && filters.perPage !== 20) + params.set('per_page', String(filters.perPage)); + + const qs = params.toString(); + return qs ? `?${qs}` : ''; +} + +export function totalPages(total, perPage) { + if (total <= 0 || perPage <= 0) return 1; + return Math.ceil(total / perPage); +} diff --git a/admin/ui/src/utils/audit.test.js b/admin/ui/src/utils/audit.test.js new file mode 100644 index 0000000..69e666e --- /dev/null +++ b/admin/ui/src/utils/audit.test.js @@ -0,0 +1,198 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { + getActionLabel, + getActionOptions, + formatAuditTimestamp, + buildAuditQueryString, + totalPages, + startOfLocalDay, + endOfLocalDay, +} from './audit.js'; + +describe('getActionLabel', () => { + it('returns human-readable label for known actions', () => { + expect(getActionLabel('instance.create')).toBe('Instance created'); + expect(getActionLabel('user.login')).toBe('User logged in'); + expect(getActionLabel('user.password_change')).toBe('Password changed'); + }); + + it('returns raw action string for unknown actions', () => { + expect(getActionLabel('some.unknown')).toBe('some.unknown'); + }); +}); + +describe('getActionOptions', () => { + it('returns array with "All actions" as first option', () => { + const options = getActionOptions(); + expect(options[0]).toEqual({ value: '', label: 'All actions' }); + expect(options.length).toBeGreaterThan(1); + }); + + it('includes all known action types', () => { + const options = getActionOptions(); + const values = options.map((o) => o.value); + expect(values).toContain('instance.create'); + expect(values).toContain('instance.delete'); + expect(values).toContain('user.login'); + expect(values).toContain('user.logout'); + }); +}); + +describe('formatAuditTimestamp', () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-03-09T12:00:00Z')); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('returns empty string for falsy input', () => { + expect(formatAuditTimestamp(null)).toBe(''); + expect(formatAuditTimestamp('')).toBe(''); + expect(formatAuditTimestamp(undefined)).toBe(''); + }); + + it('returns empty string for invalid date', () => { + expect(formatAuditTimestamp('not-a-date')).toBe(''); + }); + + it('returns "just now" for timestamps less than 60s ago', () => { + expect(formatAuditTimestamp('2026-03-09T11:59:30Z')).toBe('just now'); + }); + + it('returns minutes ago for timestamps less than 1h ago', () => { + expect(formatAuditTimestamp('2026-03-09T11:45:00Z')).toBe('15m ago'); + }); + + it('returns "Today" with time for older timestamps today', () => { + const result = formatAuditTimestamp('2026-03-09T08:30:00Z'); + expect(result).toMatch(/^Today /); + }); + + it('returns "Yesterday" with time for timestamps from yesterday', () => { + const result = formatAuditTimestamp('2026-03-08T14:00:00Z'); + expect(result).toMatch(/^Yesterday /); + }); + + it('returns formatted date for older timestamps', () => { + const result = formatAuditTimestamp('2026-03-01T10:00:00Z'); + expect(result).toBeTruthy(); + expect(result).not.toMatch(/^Today/); + expect(result).not.toMatch(/^Yesterday/); + }); +}); + +describe('buildAuditQueryString', () => { + it('returns empty string when no filters are set', () => { + expect(buildAuditQueryString({})).toBe(''); + }); + + it('includes search query', () => { + expect(buildAuditQueryString({ q: 'test' })).toBe('?q=test'); + }); + + it('includes action filter', () => { + expect(buildAuditQueryString({ action: 'user.login' })).toBe( + '?action=user.login', + ); + }); + + it('converts date-only from/to into RFC 3339 UTC via local timezone', () => { + const qs = buildAuditQueryString({ + from: '2026-03-01', + to: '2026-03-09', + }); + expect(qs).toContain('from='); + expect(qs).toContain('to='); + // The serialized values should be valid ISO timestamps, not date-only + const params = new URLSearchParams(qs.slice(1)); + expect(params.get('from')).toMatch( + /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z$/, + ); + expect(params.get('to')).toMatch( + /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z$/, + ); + // The UTC timestamps must represent local midnight and local end-of-day + expect(new Date(params.get('from')).getTime()).toBe( + new Date('2026-03-01T00:00:00').getTime(), + ); + expect(new Date(params.get('to')).getTime()).toBe( + new Date('2026-03-09T23:59:59').getTime(), + ); + }); + + it('includes page only when > 1', () => { + expect(buildAuditQueryString({ page: 1 })).toBe(''); + expect(buildAuditQueryString({ page: 3 })).toBe('?page=3'); + }); + + it('includes per_page only when not default', () => { + expect(buildAuditQueryString({ perPage: 20 })).toBe(''); + expect(buildAuditQueryString({ perPage: 50 })).toBe('?per_page=50'); + }); + + it('combines multiple filters', () => { + const qs = buildAuditQueryString({ + q: 'proxy', + action: 'instance.create', + page: 2, + }); + expect(qs).toContain('q=proxy'); + expect(qs).toContain('action=instance.create'); + expect(qs).toContain('page=2'); + }); +}); + +describe('totalPages', () => { + it('returns 1 for zero or negative total', () => { + expect(totalPages(0, 20)).toBe(1); + expect(totalPages(-5, 20)).toBe(1); + }); + + it('returns 1 for zero or negative perPage', () => { + expect(totalPages(100, 0)).toBe(1); + expect(totalPages(100, -1)).toBe(1); + }); + + it('computes correct page count', () => { + expect(totalPages(20, 20)).toBe(1); + expect(totalPages(21, 20)).toBe(2); + expect(totalPages(100, 20)).toBe(5); + expect(totalPages(1, 20)).toBe(1); + }); +}); + +describe('startOfLocalDay', () => { + it('returns empty string for falsy input', () => { + expect(startOfLocalDay('')).toBe(''); + expect(startOfLocalDay(null)).toBe(''); + expect(startOfLocalDay(undefined)).toBe(''); + }); + + it('returns an ISO string based on local midnight', () => { + const result = startOfLocalDay('2026-03-09'); + expect(result).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z$/); + // Parse back and verify it represents local midnight + const d = new Date(result); + const local = new Date('2026-03-09T00:00:00'); + expect(d.getTime()).toBe(local.getTime()); + }); +}); + +describe('endOfLocalDay', () => { + it('returns empty string for falsy input', () => { + expect(endOfLocalDay('')).toBe(''); + expect(endOfLocalDay(null)).toBe(''); + expect(endOfLocalDay(undefined)).toBe(''); + }); + + it('returns an ISO string based on local end of day', () => { + const result = endOfLocalDay('2026-03-09'); + expect(result).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z$/); + const d = new Date(result); + const local = new Date('2026-03-09T23:59:59'); + expect(d.getTime()).toBe(local.getTime()); + }); +}); diff --git a/admin/ui/src/views/AuditLogView.vue b/admin/ui/src/views/AuditLogView.vue index 923247e..ffa3b86 100644 --- a/admin/ui/src/views/AuditLogView.vue +++ b/admin/ui/src/views/AuditLogView.vue @@ -3,8 +3,82 @@

Audit Log

+ + +
+
+ + +
+ + + +
+
- + +
+ Failed to load audit log: {{ audit.error.value }} +
+ + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + +
TimeUserActionDetail
+ + {{ entry.user }} + + {{ getActionLabel(entry.action) }} + + + {{ entry.detail }} +
+
+ + +
+ + {{ paginationLabel }} + +
+ + + {{ audit.filters.value.page }} of {{ audit.pageCount.value }} + + +
+
+
From 6de6080e65b5727bcadc45e26ea1d7541c533e83 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADctor=20Cuevas?= Date: Fri, 17 Apr 2026 12:50:44 +0200 Subject: [PATCH 26/36] feat(admin): improve UI accessibility, responsiveness, and polish --- admin/ui/src/components/AddInstanceModal.vue | 30 +++- admin/ui/src/components/BaseCard.vue | 6 +- admin/ui/src/components/ConfirmDialog.vue | 7 +- admin/ui/src/components/InstanceCard.vue | 40 ++--- admin/ui/src/components/InstanceTable.vue | 29 +++- admin/ui/src/components/LoadingSpinner.vue | 75 ++++++++ admin/ui/src/components/OverviewTab.vue | 10 +- admin/ui/src/components/StatusIndicator.vue | 13 +- admin/ui/src/components/TrafficTab.vue | 58 ++++--- admin/ui/src/components/VendorTable.vue | 8 +- admin/ui/src/composables/useFocusTrap.js | 39 +++++ admin/ui/src/composables/useFocusTrap.test.js | 102 +++++++++++ admin/ui/src/layouts/AppLayout.vue | 162 +++++++++++++++++- admin/ui/src/stores/instances.js | 7 +- admin/ui/src/stores/instances.test.js | 17 +- admin/ui/src/utils/instance.js | 17 +- admin/ui/src/utils/instance.test.js | 108 +----------- admin/ui/src/views/AuditLogView.vue | 87 ++++++++-- admin/ui/src/views/DashboardView.vue | 113 +++++++++--- admin/ui/src/views/InstanceDetailView.vue | 36 +++- admin/ui/vite.config.js | 5 + 21 files changed, 720 insertions(+), 249 deletions(-) create mode 100644 admin/ui/src/components/LoadingSpinner.vue create mode 100644 admin/ui/src/composables/useFocusTrap.js create mode 100644 admin/ui/src/composables/useFocusTrap.test.js diff --git a/admin/ui/src/components/AddInstanceModal.vue b/admin/ui/src/components/AddInstanceModal.vue index ffbe252..8e5963c 100644 --- a/admin/ui/src/components/AddInstanceModal.vue +++ b/admin/ui/src/components/AddInstanceModal.vue @@ -1,6 +1,7 @@