commit db16ad5cc1d98cded4a95ee4c911ba278b02047d Author: Pepe44DEV Date: Sun Sep 6 22:08:30 2026 +0000 feat: Fleet Forge UEM - device management, agent, remote desktop (VNC), TLS reverse proxy diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..b755f6c --- /dev/null +++ b/.env.example @@ -0,0 +1,34 @@ +# Fleet Forge - Environment Configuration + +# Application +APP_PORT=3000 +APP_URL=http://localhost:3000 +NODE_ENV=development + +# Database +DATABASE_URL=postgresql://fleetforge:fleetforge@localhost:5432/fleetforge?schema=public + +# Redis (optional for MVP) +REDIS_URL=redis://localhost:6379 + +# Auth +JWT_SECRET=change-me-in-production-use-a-real-secret +JWT_EXPIRES_IN=24h +SESSION_SECRET=change-me-in-production-use-a-real-session-secret + +# Enrollment +ENROLLMENT_TOKEN_EXPIRY=24h + +# Agent +AGENT_HEARTBEAT_INTERVAL=30s +AGENT_HEARTBEAT_TIMEOUT=120s + +# CORS +CORS_ORIGINS=http://localhost:3001 + +# Logging +LOG_LEVEL=debug + +# Web +WEB_PORT=3001 +NEXT_PUBLIC_API_URL=http://localhost:3000/api diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5556f90 --- /dev/null +++ b/.gitignore @@ -0,0 +1,56 @@ +# Dependencies +node_modules/ +.pnpm-store/ +vendor/ + +# Build outputs +dist/ +build/ +.next/ +out/ +*.exe +*.dll +*.so +*.dylib +*.tsbuildinfo + +# Compiled agent binaries / release artifacts (kept local, dir preserved via .gitkeep) +infrastructure/agent-releases/*/ +infrastructure/software-releases/*/ +agents/forge-agent/forge-agent + +# Environment +.env +.env.local +.env.*.local + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ +.DS_Store +Thumbs.db + +# Docker +docker-compose.override.yml + +# TLS infra (self-signed, generated locally) +infrastructure/certs/ + +# Logs +*.log +logs/ + +# Test coverage +coverage/ +.nyc_output/ + +# Go +*.test +*.prof + +# OS +.DS_Store +Thumbs.db diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..0066609 --- /dev/null +++ b/Makefile @@ -0,0 +1,49 @@ +.PHONY: setup dev test lint typecheck build migrate seed clean stop logs test-integration + +setup: + cp -n .env.example .env 2>/dev/null || true + docker compose -f infrastructure/docker/docker-compose.dev.yml up -d postgres redis + @echo "Waiting for PostgreSQL..." + @sleep 3 + pnpm install + pnpm run migrate --prefix apps/api + pnpm run seed --prefix apps/api + @echo "Setup complete. Run 'make dev' to start." + +dev: + docker compose -f infrastructure/docker/docker-compose.dev.yml up -d postgres redis + pnpm run dev + +test: + pnpm run test + +lint: + pnpm run lint + +typecheck: + pnpm run typecheck + +build: + pnpm run build + +migrate: + pnpm run migrate --prefix apps/api + +seed: + pnpm run seed --prefix apps/api + +stop: + docker compose -f infrastructure/docker/docker-compose.dev.yml down + +logs: + docker compose -f infrastructure/docker/docker-compose.dev.yml logs -f + +clean: + docker compose -f infrastructure/docker/docker-compose.dev.yml down -v + rm -rf node_modules apps/*/node_modules packages/*/node_modules + +test-integration: migrate + pnpm --filter @fleetforge/api test + +test-e2e: + bash tests/e2e.sh diff --git a/agents/forge-agent/cmd/forge-agent/main.go b/agents/forge-agent/cmd/forge-agent/main.go new file mode 100644 index 0000000..9ac6338 --- /dev/null +++ b/agents/forge-agent/cmd/forge-agent/main.go @@ -0,0 +1,177 @@ +package main + +import ( + "flag" + "fmt" + "log" + "os" + + "github.com/fleetforge/forge-agent/internal/agent" + "github.com/fleetforge/forge-agent/internal/client" + "github.com/fleetforge/forge-agent/internal/config" + "github.com/fleetforge/forge-agent/internal/inventory" + "github.com/fleetforge/forge-agent/internal/winsvc" +) + +const AgentVersion = "0.1.0" + +func main() { + flag.Usage = func() { + fmt.Fprintf(os.Stderr, "Forge Agent v%s\n", AgentVersion) + fmt.Fprintf(os.Stderr, "Usage:\n") + fmt.Fprintf(os.Stderr, " forge-agent enroll -token -server Enroll this device\n") + fmt.Fprintf(os.Stderr, " forge-agent run [--service] Run the agent\n") + fmt.Fprintf(os.Stderr, " forge-agent install -server -token Install as Windows Service\n") + fmt.Fprintf(os.Stderr, " forge-agent uninstall Uninstall Windows Service\n") + fmt.Fprintf(os.Stderr, " forge-agent version Print version\n") + } + + if len(os.Args) < 2 { + flag.Usage() + os.Exit(1) + } + + switch os.Args[1] { + case "enroll": + cmdEnroll(os.Args[2:]) + case "run": + cmdRun(os.Args[2:]) + case "install": + cmdInstall(os.Args[2:]) + case "uninstall": + cmdUninstall() + case "version": + fmt.Printf("Forge Agent v%s\n", AgentVersion) + default: + fmt.Fprintf(os.Stderr, "Unknown command: %s\n\n", os.Args[1]) + flag.Usage() + os.Exit(1) + } +} + +func cmdEnroll(args []string) { + fs := flag.NewFlagSet("enroll", flag.ExitOnError) + token := fs.String("token", "", "Enrollment token") + server := fs.String("server", "http://localhost:3002", "Server URL") + label := fs.String("label", "", "Device label") + fs.Parse(args) + + if *token == "" { + log.Fatal("Error: -token is required") + } + performEnrollment(*server, *token, *label) +} + +func cmdRun(args []string) { + fs := flag.NewFlagSet("run", flag.ExitOnError) + serviceMode := fs.Bool("service", false, "Run as Windows Service (use with --service flag)") + fs.Parse(args) + + cfg, err := config.Load() + if err != nil { + log.Fatalf("Failed to load config: %v\nRun 'forge-agent enroll' or 'forge-agent install' first.", err) + } + + if cfg.EnrollmentToken != "" && cfg.DeviceID == "" { + log.Println("First run detected: attempting auto-enrollment...") + if err := performEnrollment(cfg.ServerURL, cfg.EnrollmentToken, cfg.Label); err != nil { + log.Printf("WARNING: Auto-enrollment failed: %v", err) + log.Println("Agent will retry enrollment on next heartbeat cycle.") + } else { + if loaded, loadErr := config.Load(); loadErr == nil { + cfg = loaded + } + log.Println("Auto-enrollment successful.") + } + } + + if *serviceMode { + if err := winsvc.RunAsService(); err != nil { + log.Fatalf("Service error: %v", err) + } + return + } + + a := agent.New(cfg) + if err := a.Run(); err != nil { + log.Fatalf("Agent error: %v", err) + } +} + +func cmdInstall(args []string) { + fs := flag.NewFlagSet("install", flag.ExitOnError) + server := fs.String("server", "", "Server URL") + token := fs.String("token", "", "Enrollment token") + label := fs.String("label", "", "Device label") + fs.Parse(args) + + if *server == "" || *token == "" { + log.Fatal("Error: -server and -token are required") + } + + if err := winsvc.InstallService(*server, *token, *label); err != nil { + log.Fatalf("Install failed: %v", err) + } +} + +func cmdUninstall() { + if err := winsvc.UninstallService(); err != nil { + log.Fatalf("Uninstall failed: %v", err) + } +} + +func performEnrollment(serverURL, token, label string) error { + fmt.Println("Collecting system information...") + info, err := inventory.CollectSystemInfo() + if err != nil { + return fmt.Errorf("failed to collect system info: %w", err) + } + + fmt.Printf("Hostname: %s\n", info.Hostname) + fmt.Printf("OS: %s %s\n", info.OSName, info.OSVersion) + fmt.Printf("Arch: %s\n", info.Architecture) + fmt.Printf("Server: %s\n", serverURL) + fmt.Println("Enrolling...") + + c := client.New(serverURL, "") + resp, err := c.Enroll(client.EnrollRequest{ + Token: token, + Hostname: info.Hostname, + Platform: info.Platform, + OSName: info.OSName, + OSVersion: info.OSVersion, + Architecture: info.Architecture, + KernelVersion: info.KernelVersion, + CPUModel: info.CPUModel, + CPUCores: info.CPUCores, + TotalMemoryBytes: info.TotalMemory, + TotalDiskBytes: info.TotalDisk, + IPAddress: info.IPAddress, + AgentVersion: AgentVersion, + }) + if err != nil { + return fmt.Errorf("enrollment failed: %w", err) + } + + cfg, err := config.Load() + if err != nil { + cfg = &config.Config{} + } + cfg.ServerURL = serverURL + cfg.DeviceID = resp.DeviceID + cfg.Credential = resp.Credential + cfg.Label = label + cfg.EnrollmentToken = "" + if cfg.HeartbeatSec <= 0 { + cfg.HeartbeatSec = 30 + } + + if err := config.Save(cfg); err != nil { + return fmt.Errorf("failed to save config: %w", err) + } + + fmt.Printf("\nEnrollment successful!\n") + fmt.Printf("Device ID: %s\n", resp.DeviceID) + fmt.Printf("Config saved to: %s\n", config.ConfigPath()) + return nil +} diff --git a/agents/forge-agent/go.mod b/agents/forge-agent/go.mod new file mode 100644 index 0000000..1c87e54 --- /dev/null +++ b/agents/forge-agent/go.mod @@ -0,0 +1,19 @@ +module github.com/fleetforge/forge-agent + +go 1.22 + +require ( + github.com/gorilla/websocket v1.5.3 + github.com/shirou/gopsutil/v3 v3.24.5 + golang.org/x/sys v0.20.0 +) + +require ( + github.com/go-ole/go-ole v1.2.6 // indirect + github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect + github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect + github.com/shoenig/go-m1cpu v0.1.6 // indirect + github.com/tklauser/go-sysconf v0.3.12 // indirect + github.com/tklauser/numcpus v0.6.1 // indirect + github.com/yusufpapurcu/wmi v1.2.4 // indirect +) diff --git a/agents/forge-agent/go.sum b/agents/forge-agent/go.sum new file mode 100644 index 0000000..7c2b3f7 --- /dev/null +++ b/agents/forge-agent/go.sum @@ -0,0 +1,38 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= +github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= +github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4= +github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw= +github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= +github.com/shirou/gopsutil/v3 v3.24.5 h1:i0t8kL+kQTvpAYToeuiVk3TgDeKOFioZO3Ztz/iZ9pI= +github.com/shirou/gopsutil/v3 v3.24.5/go.mod h1:bsoOS1aStSs9ErQ1WWfxllSeS1K5D+U30r2NfcubMVk= +github.com/shoenig/go-m1cpu v0.1.6 h1:nxdKQNcEB6vzgA2E2bvzKIYRuNj7XNJ4S/aRSwKzFtM= +github.com/shoenig/go-m1cpu v0.1.6/go.mod h1:1JJMcUBvfNwpq05QDQVAnx3gUHr9IYF7GNg9SUEw2VQ= +github.com/shoenig/test v0.6.4 h1:kVTaSd7WLz5WZ2IaoM0RSzRsUD+m8wRR+5qvntpn4LU= +github.com/shoenig/test v0.6.4/go.mod h1:byHiCGXqrVaflBLAMq/srcZIHynQPQgeyvkvXnjqq0k= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU= +github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI= +github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk= +github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY= +github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= +github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= +golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y= +golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/agents/forge-agent/internal/agent/agent.go b/agents/forge-agent/internal/agent/agent.go new file mode 100644 index 0000000..a51657b --- /dev/null +++ b/agents/forge-agent/internal/agent/agent.go @@ -0,0 +1,544 @@ +package agent + +import ( + "fmt" + "log" + "os" + "os/signal" + "sync" + "syscall" + "time" + + "github.com/fleetforge/forge-agent/internal/client" + "github.com/fleetforge/forge-agent/internal/config" + "github.com/fleetforge/forge-agent/internal/inventory" + "github.com/fleetforge/forge-agent/internal/metrics" + "github.com/fleetforge/forge-agent/internal/pkgmanager" + "github.com/fleetforge/forge-agent/internal/svcmgr" + "github.com/fleetforge/forge-agent/internal/tunnel" + "github.com/fleetforge/forge-agent/internal/vnc" +) + +const AgentVersion = "0.1.6" + +var ( + tunnelMu sync.Mutex + tunnels = map[string]*tunnel.Tunnel{} +) + +func storeTunnel(sessionID string, t *tunnel.Tunnel) { + tunnelMu.Lock() + defer tunnelMu.Unlock() + tunnels[sessionID] = t +} + +func removeTunnel(sessionID string) { + tunnelMu.Lock() + defer tunnelMu.Unlock() + delete(tunnels, sessionID) +} + +func stopTunnel(sessionID string) { + tunnelMu.Lock() + t := tunnels[sessionID] + tunnelMu.Unlock() + if t != nil { + t.Stop() + } +} + +type Agent struct { + cfg *config.Config + client *client.Client +} + +func New(cfg *config.Config) *Agent { + return &Agent{ + cfg: cfg, + client: client.New(cfg.ServerURL, cfg.Credential), + } +} + +func (a *Agent) Run() error { + log.Println("Forge Agent starting...") + log.Printf("Server: %s", a.cfg.ServerURL) + log.Printf("Device ID: %s", a.cfg.DeviceID) + + ticker := time.NewTicker(time.Duration(a.cfg.HeartbeatSec) * time.Second) + defer ticker.Stop() + + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) + + if a.cfg.DeviceID == "" { + log.Println("No DeviceID configured - waiting for enrollment") + } else { + if err := pkgmanager.BootstrapManagers(); err != nil { + log.Printf("WARNING: Manager bootstrap failed: %v", err) + } + a.sendHeartbeat() + } + + for { + select { + case <-ticker.C: + if a.cfg.DeviceID == "" || a.cfg.Credential == "" { + a.tryEnroll() + } else { + a.sendHeartbeat() + a.checkJobs() + } + case sig := <-sigCh: + log.Printf("Received signal %v, shutting down...", sig) + return nil + } + } +} + +func (a *Agent) tryEnroll() { + if a.cfg.EnrollmentToken == "" { + return + } + log.Println("Attempting auto-enrollment...") + + info, err := inventory.CollectSystemInfo() + if err != nil { + log.Printf("Enrollment failed (inventory): %v", err) + return + } + + c := client.New(a.cfg.ServerURL, "") + resp, err := c.Enroll(client.EnrollRequest{ + Token: a.cfg.EnrollmentToken, + Hostname: info.Hostname, + Platform: info.Platform, + OSName: info.OSName, + OSVersion: info.OSVersion, + Architecture: info.Architecture, + KernelVersion: info.KernelVersion, + CPUModel: info.CPUModel, + CPUCores: info.CPUCores, + TotalMemoryBytes: info.TotalMemory, + TotalDiskBytes: info.TotalDisk, + IPAddress: info.IPAddress, + AgentVersion: AgentVersion, + }) + if err != nil { + log.Printf("Enrollment failed: %v", err) + return + } + + a.cfg.DeviceID = resp.DeviceID + a.cfg.Credential = resp.Credential + a.cfg.EnrollmentToken = "" + if err := config.Save(a.cfg); err != nil { + log.Printf("Failed to save config after enrollment: %v", err) + return + } + a.client = client.New(a.cfg.ServerURL, a.cfg.Credential) + log.Printf("Enrollment successful! Device ID: %s", resp.DeviceID) +} + +func (a *Agent) sendHeartbeat() { + info, err := inventory.CollectSystemInfo() + if err != nil { + log.Printf("Failed to collect system info: %v", err) + return + } + + met, err := metrics.Collect() + if err != nil { + log.Printf("Failed to collect metrics: %v", err) + return + } + + req := client.HeartbeatRequest{ + Hostname: info.Hostname, + OSVersion: info.OSVersion, + KernelVersion: info.KernelVersion, + AgentVersion: AgentVersion, + IPAddress: info.IPAddress, + Metrics: &client.MetricsData{ + CPUPercent: met.CPUPercent, + MemoryPercent: met.MemoryPercent, + MemoryUsed: met.MemoryUsed, + MemoryTotal: met.MemoryTotal, + DiskPercent: met.DiskPercent, + DiskUsed: met.DiskUsed, + DiskTotal: met.DiskTotal, + Load1: met.Load1, + Load5: met.Load5, + Load15: met.Load15, + UptimeSeconds: met.UptimeSeconds, + }, + } + + if len(info.Packages) > 0 || len(info.Services) > 0 || len(info.NetworkInterfaces) > 0 { + inv := &client.InventoryData{} + for _, p := range info.Packages { + inv.Packages = append(inv.Packages, client.PackageData{ + Name: p.Name, + Version: p.Version, + Manager: p.Manager, + }) + } + for _, s := range info.Services { + inv.Services = append(inv.Services, client.ServiceData{ + Name: s.Name, + Status: s.Status, + Enabled: s.Enabled, + }) + } + for _, ni := range info.NetworkInterfaces { + inv.NetworkInterfaces = append(inv.NetworkInterfaces, client.NetworkInterfaceData{ + Name: ni.Name, + Addresses: ni.Addresses, + MacAddress: ni.MAC, + }) + } + req.Inventory = inv + } + + if err := a.client.Heartbeat(req); err != nil { + log.Printf("Heartbeat failed: %v", err) + } else { + log.Printf("Heartbeat sent successfully") + } +} + +func (a *Agent) checkJobs() { + jobs, err := a.client.FetchJobs() + if err != nil { + log.Printf("Failed to fetch jobs: %v", err) + return + } + + for _, job := range jobs { + a.executeJob(job) + } +} + +func (a *Agent) executeJob(job client.JobInfo) { + log.Printf("Executing job %s (type: %s)", job.JobID, job.JobType) + + result := client.JobResult{ + Status: "succeeded", + Result: map[string]interface{}{}, + } + result.TargetID = job.TargetID + + if err := a.client.ReportStart(job.JobID, job.TargetID); err != nil { + log.Printf("Failed to report start for job %s: %v", job.JobID, err) + } + + switch job.JobType { + case "update_inventory": + a.sendHeartbeat() + result.Result["inventory_refreshed"] = true + + case "update_system_info": + met, err := metrics.Collect() + if err != nil { + result.Status = "failed" + result.ErrorMessage = err.Error() + } else { + result.Result["cpu_percent"] = met.CPUPercent + result.Result["memory_percent"] = met.MemoryPercent + result.Result["disk_percent"] = met.DiskPercent + result.Result["uptime"] = met.UptimeSeconds + } + + case "install_package": + pkgName, _ := job.Parameters["package_name"].(string) + if pkgName == "" { + result.Status = "failed" + result.ErrorMessage = "missing parameter: package_name" + break + } + output, err := pkgmanager.InstallPackage(pkgName) + if err != nil { + result.Status = "failed" + result.ErrorMessage = err.Error() + } + result.Result["package"] = pkgName + result.Result["output"] = truncateOutput(output) + + case "update_package": + pkgName, _ := job.Parameters["package_name"].(string) + if pkgName == "" { + result.Status = "failed" + result.ErrorMessage = "missing parameter: package_name" + break + } + output, err := pkgmanager.UpdatePackage(pkgName) + if err != nil { + result.Status = "failed" + result.ErrorMessage = err.Error() + } + result.Result["package"] = pkgName + result.Result["output"] = truncateOutput(output) + + case "remove_package": + pkgName, _ := job.Parameters["package_name"].(string) + if pkgName == "" { + result.Status = "failed" + result.ErrorMessage = "missing parameter: package_name" + break + } + source, _ := job.Parameters["source"].(string) + var manager string + switch source { + case "winget": + manager = "winget" + case "chocolatey": + manager = "choco" + } + var output string + var err error + if manager != "" { + output, err = pkgmanager.RemovePackageWithManager(pkgName, manager) + } else { + output, err = pkgmanager.RemovePackage(pkgName) + } + if err != nil { + result.Status = "failed" + result.ErrorMessage = err.Error() + } + result.Result["package"] = pkgName + result.Result["output"] = truncateOutput(output) + + case "start_service": + svcName, _ := job.Parameters["service_name"].(string) + if svcName == "" { + result.Status = "failed" + result.ErrorMessage = "missing parameter: service_name" + break + } + output, err := svcmgr.StartService(svcName) + if err != nil { + result.Status = "failed" + result.ErrorMessage = err.Error() + } + result.Result["service"] = svcName + result.Result["output"] = truncateOutput(output) + + case "stop_service": + svcName, _ := job.Parameters["service_name"].(string) + if svcName == "" { + result.Status = "failed" + result.ErrorMessage = "missing parameter: service_name" + break + } + output, err := svcmgr.StopService(svcName) + if err != nil { + result.Status = "failed" + result.ErrorMessage = err.Error() + } + result.Result["service"] = svcName + result.Result["output"] = truncateOutput(output) + + case "restart_service": + svcName, _ := job.Parameters["service_name"].(string) + if svcName == "" { + result.Status = "failed" + result.ErrorMessage = "missing parameter: service_name" + break + } + output, err := svcmgr.RestartService(svcName) + if err != nil { + result.Status = "failed" + result.ErrorMessage = err.Error() + } + result.Result["service"] = svcName + result.Result["output"] = truncateOutput(output) + + case "check_service_status": + svcName, _ := job.Parameters["service_name"].(string) + if svcName == "" { + result.Status = "failed" + result.ErrorMessage = "missing parameter: service_name" + break + } + status, enabled, err := svcmgr.GetServiceStatus(svcName) + if err != nil { + result.Status = "failed" + result.ErrorMessage = err.Error() + } + result.Result["service"] = svcName + result.Result["status"] = status + result.Result["enabled"] = enabled + + case "reboot_system": + log.Println("System reboot requested, initiating reboot...") + result.Result["message"] = "reboot initiated" + if err := a.client.SubmitResult(job.JobID, result); err != nil { + log.Printf("Failed to submit result for reboot job: %v", err) + return + } + log.Println("Rebooting system in 5 seconds...") + time.Sleep(5 * time.Second) + svcmgr.Reboot() + return + + case "shutdown_system": + log.Println("System shutdown requested, initiating shutdown...") + result.Result["message"] = "shutdown initiated" + if err := a.client.SubmitResult(job.JobID, result); err != nil { + log.Printf("Failed to submit result for shutdown job: %v", err) + return + } + log.Println("Shutting down system in 5 seconds...") + time.Sleep(5 * time.Second) + svcmgr.Shutdown() + return + + case "install_registry_package": + source, _ := job.Parameters["source"].(string) + pkgID, _ := job.Parameters["package_id"].(string) + if source == "" || pkgID == "" { + result.Status = "failed" + result.ErrorMessage = "missing parameter: source/package_id" + break + } + var manager string + switch source { + case "winget": + manager = "winget" + case "chocolatey": + manager = "choco" + default: + result.Status = "failed" + result.ErrorMessage = fmt.Sprintf("unsupported source: %s", source) + break + } + if result.Status == "failed" { + break + } + version, _ := job.Parameters["package_version"].(string) + output, err := pkgmanager.InstallPackageWithManager(pkgID, manager, version) + if err != nil { + result.Status = "failed" + result.ErrorMessage = err.Error() + } + result.Result["package"] = pkgID + result.Result["source"] = source + result.Result["output"] = truncateOutput(output) + + case "start_remote_session": + sessionID, _ := job.Parameters["session_id"].(string) + if sessionID == "" { + result.Status = "failed" + result.ErrorMessage = "missing parameter: session_id" + break + } + port := 5900 + if p, ok := job.Parameters["vnc_port"].(float64); ok && p > 0 { + port = int(p) + } + password, _ := job.Parameters["vnc_password"].(string) + + vncServer, err := vnc.Ensure(port, password) + if err != nil { + result.Status = "failed" + result.ErrorMessage = err.Error() + break + } + log.Printf("VNC server started on port %d for session %s", port, sessionID) + + result.Result["session_id"] = sessionID + result.Result["vnc_port"] = port + result.Result["vnc_started"] = true + + // Keep the session alive in a background goroutine; VNC is stopped when + // the tunnel closes. + go func(sid string, p int, vs *vnc.Server) { + t := tunnel.New(a.cfg.ServerURL, a.cfg.Credential, sid, p) + storeTunnel(sid, t) + defer func() { + removeTunnel(sid) + vs.Stop() + log.Printf("VNC server stopped after tunnel close for session %s", sid) + }() + if err := t.Run(); err != nil { + log.Printf("Tunnel for session %s closed: %v", sid, err) + } + }(sessionID, port, vncServer) + + case "stop_remote_session": + sessionID, _ := job.Parameters["session_id"].(string) + if sessionID == "" { + result.Status = "failed" + result.ErrorMessage = "missing parameter: session_id" + break + } + stopTunnel(sessionID) + result.Result["session_id"] = sessionID + result.Result["vnc_stopped"] = true + + case "install_from_file": + filePath, _ := job.Parameters["file_path"].(string) + if filePath == "" { + result.Status = "failed" + result.ErrorMessage = "missing parameter: file_path" + break + } + output, err := pkgmanager.InstallPackageFromFile(filePath) + if err != nil { + result.Status = "failed" + result.ErrorMessage = err.Error() + } + result.Result["file_path"] = filePath + result.Result["output"] = truncateOutput(output) + + case "download_and_install": + downloadURL, _ := job.Parameters["download_url"].(string) + packageId, _ := job.Parameters["package_id"].(string) + if downloadURL == "" && packageId == "" { + result.Status = "failed" + result.ErrorMessage = "missing parameter: download_url or package_id" + break + } + if downloadURL == "" && packageId != "" { + downloadURL = a.cfg.ServerURL + "/api/v1/agent/software/" + packageId + "/download" + } + tmpDir := os.TempDir() + destPath := tmpDir + "/forge-download-" + packageId + ".pkg" + log.Printf("Downloading package from %s to %s", downloadURL, destPath) + if err := pkgmanager.DownloadFile(downloadURL, destPath); err != nil { + result.Status = "failed" + result.ErrorMessage = fmt.Sprintf("download failed: %v", err) + break + } + output, err := pkgmanager.InstallPackageFromFile(destPath) + if err != nil { + result.Status = "failed" + result.ErrorMessage = err.Error() + } + os.Remove(destPath) + result.Result["download_url"] = downloadURL + result.Result["output"] = truncateOutput(output) + + default: + result.Status = "failed" + result.ErrorMessage = fmt.Sprintf("unsupported job type: %s", job.JobType) + } + + if a.client.IsJobCancelled(job.JobID) { + log.Printf("Job %s cancelled on server; discarding result", job.JobID) + result.Status = "cancelled" + } + + if err := a.client.SubmitResult(job.JobID, result); err != nil { + log.Printf("Failed to submit result for job %s: %v", job.JobID, err) + } else { + log.Printf("Job %s completed with status: %s", job.JobID, result.Status) + } +} + +func truncateOutput(output string) string { + const maxLen = 4096 + if len(output) > maxLen { + return output[:maxLen] + "... (truncated)" + } + return output +} diff --git a/agents/forge-agent/internal/client/client.go b/agents/forge-agent/internal/client/client.go new file mode 100644 index 0000000..48bbfa8 --- /dev/null +++ b/agents/forge-agent/internal/client/client.go @@ -0,0 +1,230 @@ +package client + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" +) + +type Client struct { + serverURL string + credential string + httpClient *http.Client +} + +func New(serverURL, credential string) *Client { + return &Client{ + serverURL: serverURL, + credential: credential, + httpClient: &http.Client{}, + } +} + +func (c *Client) doPost(path string, body interface{}, out interface{}) error { + data, err := json.Marshal(body) + if err != nil { + return fmt.Errorf("marshal request: %w", err) + } + + req, err := http.NewRequest("POST", c.serverURL+path, bytes.NewReader(data)) + if err != nil { + return fmt.Errorf("create request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + if c.credential != "" { + req.Header.Set("Authorization", "Bearer "+c.credential) + } + + resp, err := c.httpClient.Do(req) + if err != nil { + return fmt.Errorf("execute request: %w", err) + } + defer resp.Body.Close() + + respBody, _ := io.ReadAll(resp.Body) + + if resp.StatusCode >= 400 { + return fmt.Errorf("server returned %d: %s", resp.StatusCode, string(respBody)) + } + + if out != nil { + var apiResp struct { + Success bool `json:"success"` + Data json.RawMessage `json:"data"` + } + if err := json.Unmarshal(respBody, &apiResp); err != nil { + return fmt.Errorf("decode response: %w", err) + } + if !apiResp.Success { + return fmt.Errorf("api returned success=false") + } + if err := json.Unmarshal(apiResp.Data, out); err != nil { + return fmt.Errorf("decode data: %w", err) + } + } + + return nil +} + +func (c *Client) doGet(path string, out interface{}) error { + req, err := http.NewRequest("GET", c.serverURL+path, nil) + if err != nil { + return fmt.Errorf("create request: %w", err) + } + req.Header.Set("Authorization", "Bearer "+c.credential) + + resp, err := c.httpClient.Do(req) + if err != nil { + return fmt.Errorf("execute request: %w", err) + } + defer resp.Body.Close() + + respBody, _ := io.ReadAll(resp.Body) + + if resp.StatusCode >= 400 { + return fmt.Errorf("server returned %d: %s", resp.StatusCode, string(respBody)) + } + + if out != nil { + var apiResp struct { + Success bool `json:"success"` + Data json.RawMessage `json:"data"` + } + if err := json.Unmarshal(respBody, &apiResp); err != nil { + return fmt.Errorf("decode response: %w", err) + } + if err := json.Unmarshal(apiResp.Data, out); err != nil { + return fmt.Errorf("decode data: %w", err) + } + } + + return nil +} + +type EnrollRequest struct { + Token string `json:"token"` + Hostname string `json:"hostname"` + Platform string `json:"platform"` + OSName string `json:"os_name"` + OSVersion string `json:"os_version"` + Architecture string `json:"architecture"` + KernelVersion string `json:"kernel_version,omitempty"` + CPUModel string `json:"cpu_model,omitempty"` + CPUCores int `json:"cpu_cores,omitempty"` + TotalMemoryBytes uint64 `json:"total_memory_bytes,omitempty"` + TotalDiskBytes uint64 `json:"total_disk_bytes,omitempty"` + IPAddress string `json:"ip_address,omitempty"` + AgentVersion string `json:"agent_version"` +} + +type EnrollResponse struct { + DeviceID string `json:"device_id"` + Credential string `json:"credential"` +} + +func (c *Client) Enroll(req EnrollRequest) (*EnrollResponse, error) { + var resp EnrollResponse + if err := c.doPost("/api/v1/agent/enroll", &req, &resp); err != nil { + return nil, err + } + return &resp, nil +} + +type MetricsData struct { + CPUPercent float64 `json:"cpu_usage_percent"` + MemoryPercent float64 `json:"memory_usage_percent"` + MemoryUsed uint64 `json:"memory_used_bytes"` + MemoryTotal uint64 `json:"memory_total_bytes"` + DiskPercent float64 `json:"disk_usage_percent"` + DiskUsed uint64 `json:"disk_used_bytes"` + DiskTotal uint64 `json:"disk_total_bytes"` + Load1 float64 `json:"load_average_1m"` + Load5 float64 `json:"load_average_5m"` + Load15 float64 `json:"load_average_15m"` + UptimeSeconds uint64 `json:"uptime_seconds"` +} + +type HeartbeatRequest struct { + Hostname string `json:"hostname"` + OSVersion string `json:"os_version"` + KernelVersion string `json:"kernel_version"` + AgentVersion string `json:"agent_version"` + IPAddress string `json:"ip_address"` + Metrics *MetricsData `json:"metrics,omitempty"` + Inventory *InventoryData `json:"inventory,omitempty"` +} + +type InventoryData struct { + Packages []PackageData `json:"packages,omitempty"` + Services []ServiceData `json:"services,omitempty"` + NetworkInterfaces []NetworkInterfaceData `json:"network_interfaces,omitempty"` +} + +type PackageData struct { + Name string `json:"name"` + Version string `json:"version"` + Manager string `json:"manager"` +} + +type ServiceData struct { + Name string `json:"name"` + Status string `json:"status"` + Enabled bool `json:"enabled"` +} + +type NetworkInterfaceData struct { + Name string `json:"name"` + Addresses []string `json:"addresses"` + MacAddress string `json:"mac_address,omitempty"` +} + +func (c *Client) Heartbeat(req HeartbeatRequest) error { + return c.doPost("/api/v1/agent/heartbeat", &req, nil) +} + +type JobInfo struct { + JobID string `json:"jobId"` + TargetID string `json:"targetId"` + JobType string `json:"jobType"` + Parameters map[string]interface{} `json:"parameters"` +} + +func (c *Client) FetchJobs() ([]JobInfo, error) { + var jobs []JobInfo + if err := c.doGet("/api/v1/agent/jobs", &jobs); err != nil { + return nil, err + } + return jobs, nil +} + +type JobResult struct { + Status string `json:"status"` + TargetID string `json:"target_id"` + Result map[string]interface{} `json:"result,omitempty"` + ErrorMessage string `json:"error_message,omitempty"` +} + +func (c *Client) ReportStart(jobID, targetID string) error { + path := fmt.Sprintf("/api/v1/agent/jobs/%s/start", jobID) + body := map[string]string{"target_id": targetID} + return c.doPost(path, &body, nil) +} + +func (c *Client) IsJobCancelled(jobID string) bool { + var status struct { + JobStatus string `json:"job_status"` + TargetStatus string `json:"target_status"` + } + path := fmt.Sprintf("/api/v1/agent/jobs/%s/status", jobID) + if err := c.doGet(path, &status); err != nil { + return false + } + return status.JobStatus == "cancelled" || status.TargetStatus == "cancelled" +} + +func (c *Client) SubmitResult(jobID string, result JobResult) error { + path := fmt.Sprintf("/api/v1/agent/jobs/%s/result", jobID) + return c.doPost(path, &result, nil) +} diff --git a/agents/forge-agent/internal/config/config.go b/agents/forge-agent/internal/config/config.go new file mode 100644 index 0000000..1c2a31b --- /dev/null +++ b/agents/forge-agent/internal/config/config.go @@ -0,0 +1,68 @@ +package config + +import ( + "encoding/json" + "os" + "path/filepath" + "runtime" +) + +type Config struct { + ServerURL string `json:"server_url"` + EnrollmentToken string `json:"enrollment_token,omitempty"` + DeviceID string `json:"device_id"` + Credential string `json:"credential"` + Label string `json:"label"` + HeartbeatSec int `json:"heartbeat_interval_seconds"` +} + +func configDir() string { + switch runtime.GOOS { + case "windows": + if p := os.Getenv("ProgramData"); p != "" { + return filepath.Join(p, "FleetForge") + } + return `C:\ProgramData\FleetForge` + case "darwin": + home, _ := os.UserHomeDir() + return filepath.Join(home, "Library", "Application Support", "FleetForge") + default: + home, _ := os.UserHomeDir() + return filepath.Join(home, ".config", "forge-agent") + } +} + +func ConfigPath() string { + return filepath.Join(configDir(), "config.json") +} + +func Load() (*Config, error) { + data, err := os.ReadFile(ConfigPath()) + if err != nil { + return nil, err + } + var cfg Config + if err := json.Unmarshal(data, &cfg); err != nil { + return nil, err + } + if cfg.HeartbeatSec <= 0 { + cfg.HeartbeatSec = 30 + } + return &cfg, nil +} + +func Save(cfg *Config) error { + dir := filepath.Dir(ConfigPath()) + if err := os.MkdirAll(dir, 0700); err != nil { + return err + } + data, err := json.MarshalIndent(cfg, "", " ") + if err != nil { + return err + } + return os.WriteFile(ConfigPath(), data, 0600) +} + +func Delete() error { + return os.Remove(ConfigPath()) +} diff --git a/agents/forge-agent/internal/inventory/inventory.go b/agents/forge-agent/internal/inventory/inventory.go new file mode 100644 index 0000000..8642073 --- /dev/null +++ b/agents/forge-agent/internal/inventory/inventory.go @@ -0,0 +1,188 @@ +package inventory + +import ( + "os" + "os/exec" + "runtime" + "strings" + + "github.com/fleetforge/forge-agent/internal/pkgmanager" + "github.com/fleetforge/forge-agent/internal/svcmgr" + "github.com/shirou/gopsutil/v3/cpu" + "github.com/shirou/gopsutil/v3/disk" + "github.com/shirou/gopsutil/v3/mem" + "github.com/shirou/gopsutil/v3/net" +) + +func execCmd(name string, args ...string) (string, error) { + cmd := exec.Command(name, args...) + out, err := cmd.Output() + return string(out), err +} + +type NetworkInterface struct { + Name string `json:"name"` + Addresses []string `json:"addresses"` + MAC string `json:"mac_address,omitempty"` +} + +type SystemInfo struct { + Hostname string + Platform string + OSName string + OSVersion string + Architecture string + KernelVersion string + CPUModel string + CPUCores int + TotalMemory uint64 + TotalDisk uint64 + IPAddress string + NetworkInterfaces []NetworkInterface + Packages []pkgmanager.PackageInfo + Services []svcmgr.ServiceInfo +} + +func CollectSystemInfo() (*SystemInfo, error) { + hostname, _ := os.Hostname() + + info := &SystemInfo{ + Hostname: hostname, + Platform: runtime.GOOS, + Architecture: runtime.GOARCH, + } + + switch runtime.GOOS { + case "linux": + info.OSName = "Linux" + if data, err := os.ReadFile("/etc/os-release"); err == nil { + lines := strings.Split(string(data), "\n") + for _, line := range lines { + if strings.HasPrefix(line, "PRETTY_NAME=") { + info.OSName = strings.Trim(strings.TrimPrefix(line, "PRETTY_NAME="), "\"") + } + if strings.HasPrefix(line, "VERSION_ID=") { + info.OSVersion = strings.Trim(strings.TrimPrefix(line, "VERSION_ID="), "\"") + } + } + } + if data, err := os.ReadFile("/proc/version"); err == nil { + parts := strings.Fields(string(data)) + if len(parts) >= 3 { + info.KernelVersion = parts[2] + } + } + case "darwin": + info.OSName = "macOS" + if out, err := execCmd("sw_vers", "-productVersion"); err == nil { + info.OSVersion = strings.TrimSpace(out) + } + if out, err := execCmd("uname", "-r"); err == nil { + info.KernelVersion = strings.TrimSpace(out) + } + case "windows": + info.OSName = "Windows" + if out, err := execCmd("cmd", "/c", "ver"); err == nil { + out = strings.TrimSpace(out) + if start := strings.Index(out, "[Version "); start != -1 { + ver := out[start+len("[Version "):] + if end := strings.Index(ver, "]"); end != -1 { + info.OSVersion = ver[:end] + } + } + if info.OSVersion == "" { + info.OSVersion = out + } + } + if out, err := execCmd("cmd", "/c", "ver"); err == nil { + _ = out + } + if out, err := execCmd("powershell", "-NoProfile", "-NonInteractive", "-Command", + "(Get-CimInstance Win32_OperatingSystem).Caption"); err == nil { + caption := strings.TrimSpace(out) + if caption != "" { + info.OSName = caption + } + } + if out, err := execCmd("powershell", "-NoProfile", "-NonInteractive", "-Command", + "$k = (Get-CimInstance Win32_OperatingSystem).Version; $b = (Get-CimInstance Win32_ComputerSystem).BuildNumber; $k"); err == nil { + if kb := strings.TrimSpace(out); kb != "" { + info.KernelVersion = kb + } + } + } + + if cpuInfo, err := cpu.Info(); err == nil && len(cpuInfo) > 0 { + info.CPUModel = cpuInfo[0].ModelName + info.CPUCores = len(cpuInfo) + } + + if v, err := mem.VirtualMemory(); err == nil { + info.TotalMemory = v.Total + } + + if d, err := disk.Usage("/"); err == nil { + info.TotalDisk = d.Total + } + + populateNetwork(info) + + if info.OSVersion == "" { + info.OSVersion = "unknown" + } + + if pkgs, err := pkgmanager.ListPackages(); err == nil { + info.Packages = pkgs + } + + if svcs, err := svcmgr.ListServices(); err == nil { + info.Services = svcs + } + + return info, nil +} + +func populateNetwork(info *SystemInfo) { + ifs, err := net.Interfaces() + if err != nil { + return + } + + var primaryIP string + for _, ifc := range ifs { + if !containsFlag(ifc.Flags, "up") || containsFlag(ifc.Flags, "loopback") { + continue + } + ni := NetworkInterface{ + Name: ifc.Name, + Addresses: []string{}, + MAC: ifc.HardwareAddr, + } + for _, addr := range ifc.Addrs { + if addr.Addr == "" { + continue + } + ip := strings.TrimSpace(addr.Addr) + ni.Addresses = append(ni.Addresses, ip) + if primaryIP == "" && !strings.Contains(ip, ":") { + primaryIP = strings.Split(ip, "/")[0] + } + } + if len(ni.Addresses) > 0 { + info.NetworkInterfaces = append(info.NetworkInterfaces, ni) + } + } + + if primaryIP != "" { + info.IPAddress = primaryIP + } +} + +func containsFlag(flags []string, flag string) bool { + for _, f := range flags { + if strings.EqualFold(f, flag) { + return true + } + } + return false +} \ No newline at end of file diff --git a/agents/forge-agent/internal/metrics/metrics.go b/agents/forge-agent/internal/metrics/metrics.go new file mode 100644 index 0000000..eee6b66 --- /dev/null +++ b/agents/forge-agent/internal/metrics/metrics.go @@ -0,0 +1,71 @@ +package metrics + +import ( + "os" + "runtime" + "strconv" + "strings" + + "github.com/shirou/gopsutil/v3/cpu" + "github.com/shirou/gopsutil/v3/disk" + "github.com/shirou/gopsutil/v3/load" + "github.com/shirou/gopsutil/v3/mem" + "github.com/shirou/gopsutil/v3/host" +) + +type Snapshot struct { + CPUPercent float64 + MemoryPercent float64 + MemoryUsed uint64 + MemoryTotal uint64 + DiskPercent float64 + DiskUsed uint64 + DiskTotal uint64 + Load1 float64 + Load5 float64 + Load15 float64 + UptimeSeconds uint64 +} + +func Collect() (*Snapshot, error) { + s := &Snapshot{} + + if percents, err := cpu.Percent(0, false); err == nil && len(percents) > 0 { + s.CPUPercent = percents[0] + } + + if v, err := mem.VirtualMemory(); err == nil { + s.MemoryPercent = v.UsedPercent + s.MemoryUsed = v.Used + s.MemoryTotal = v.Total + } + + if d, err := disk.Usage("/"); err == nil { + s.DiskPercent = d.UsedPercent + s.DiskUsed = d.Used + s.DiskTotal = d.Total + } + + if avg, err := load.Avg(); err == nil { + s.Load1 = avg.Load1 + s.Load5 = avg.Load5 + s.Load15 = avg.Load15 + } + + if up, err := host.Uptime(); err == nil { + s.UptimeSeconds = up + } + + if runtime.GOOS == "linux" { + if data, err := os.ReadFile("/proc/loadavg"); err == nil { + fields := strings.Fields(string(data)) + if len(fields) >= 3 { + s.Load1, _ = strconv.ParseFloat(fields[0], 64) + s.Load5, _ = strconv.ParseFloat(fields[1], 64) + s.Load15, _ = strconv.ParseFloat(fields[2], 64) + } + } + } + + return s, nil +} diff --git a/agents/forge-agent/internal/pkgmanager/pkgmanager.go b/agents/forge-agent/internal/pkgmanager/pkgmanager.go new file mode 100644 index 0000000..c2f3c0a --- /dev/null +++ b/agents/forge-agent/internal/pkgmanager/pkgmanager.go @@ -0,0 +1,354 @@ +package pkgmanager + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" +) + +type PackageInfo struct { + Name string `json:"name"` + Version string `json:"version"` + Manager string `json:"manager"` +} + +type ServiceInfo struct { + Name string `json:"name"` + Status string `json:"status"` + Enabled bool `json:"enabled"` +} + +func DetectManager() string { + if runtime.GOOS == "windows" { + if _, err := exec.LookPath(resolveManagerPath("winget", interactiveProfile())); err == nil { + return "winget" + } + if _, err := exec.LookPath(resolveManagerPath("choco", interactiveProfile())); err == nil { + return "choco" + } + if _, err := exec.LookPath("msiexec"); err == nil { + return "msiexec" + } + return "unknown" + } + if _, err := exec.LookPath("apt"); err == nil { + return "apt" + } + if _, err := exec.LookPath("dnf"); err == nil { + return "dnf" + } + if _, err := exec.LookPath("yum"); err == nil { + return "yum" + } + return "unknown" +} + +func ListPackages() ([]PackageInfo, error) { + if runtime.GOOS == "windows" { + return listWindowsPackages() + } + manager := DetectManager() + switch manager { + case "apt": + return listAptPackages() + case "yum", "dnf": + return listYumPackages() + case "winget": + return listWingetPackages() + } + return nil, fmt.Errorf("unsupported package manager: %s", manager) +} + +func listAptPackages() ([]PackageInfo, error) { + cmd := exec.Command("dpkg-query", "-W", "-f", "${Package}\t${Version}\n") + out, err := cmd.Output() + if err != nil { + return nil, fmt.Errorf("dpkg-query failed: %w", err) + } + + var packages []PackageInfo + for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") { + if line == "" { + continue + } + parts := strings.SplitN(line, "\t", 2) + if len(parts) == 2 { + packages = append(packages, PackageInfo{ + Name: parts[0], + Version: parts[1], + Manager: "apt", + }) + } + } + return packages, nil +} + +func listYumPackages() ([]PackageInfo, error) { + cmd := exec.Command("rpm", "-qa", "--queryformat", "%{NAME}\t%{VERSION}-%{RELEASE}\n") + out, err := cmd.Output() + if err != nil { + return nil, fmt.Errorf("rpm query failed: %w", err) + } + + var packages []PackageInfo + for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") { + if line == "" { + continue + } + parts := strings.SplitN(line, "\t", 2) + if len(parts) == 2 { + packages = append(packages, PackageInfo{ + Name: parts[0], + Version: parts[1], + Manager: "yum", + }) + } + } + return packages, nil +} + +func listWingetPackages() ([]PackageInfo, error) { + cmd := exec.Command(resolveManagerPath("winget", interactiveProfile()), "list", "--accept-source-agreements", "--disable-interactivity") + out, err := cmd.Output() + if err != nil { + return nil, fmt.Errorf("winget list failed: %w", err) + } + + var packages []PackageInfo + lines := strings.Split(string(out), "\n") + inTable := false + for _, line := range lines { + trimmed := strings.TrimSpace(line) + if strings.HasPrefix(trimmed, "Name") && strings.Contains(trimmed, "Id") { + inTable = true + continue + } + if inTable && strings.Contains(trimmed, "---") { + continue + } + if inTable && trimmed != "" { + fields := strings.Fields(trimmed) + if len(fields) >= 3 { + pkg := PackageInfo{ + Name: fields[0], + Version: fields[len(fields)-1], + Manager: "winget", + } + packages = append(packages, pkg) + } + } + } + return packages, nil +} + +func InstallPackage(name string) (string, error) { + return installPackageWithManager(name, DetectManager(), "") +} + +func InstallPackageWithManager(name, manager, version string) (string, error) { + return installPackageWithManager(name, manager, version) +} + +func installPackageWithManager(name, manager, version string) (string, error) { + var executable string + var args []string + + switch manager { + case "apt": + executable = "sudo" + args = []string{"apt-get", "install", "-y", name} + case "yum": + executable = "sudo" + args = []string{"yum", "install", "-y", name} + case "dnf": + executable = "sudo" + args = []string{"dnf", "install", "-y", name} + case "winget": + executable = "winget" + args = []string{"install", "--id", name, "--accept-source-agreements", "--accept-package-agreements", "--disable-interactivity"} + if version != "" { + args = append(args, "--version", version) + } + case "choco": + executable = "choco" + args = []string{"install", "-y", name} + if version != "" { + args = append(args, "--version", version) + } + case "msiexec": + executable = "msiexec" + args = []string{"/i", name, "/quiet", "/norestart"} + default: + return "", fmt.Errorf("unsupported package manager: %s", manager) + } + + out, err := runManagerCommand(executable, args) + if err != nil { + return out, err + } + return out, nil +} + +func InstallPackageFromFile(filePath string) (string, error) { + ext := strings.ToLower(filepath.Ext(filePath)) + switch runtime.GOOS { + case "windows": + switch ext { + case ".msi": + cmd := exec.Command("msiexec", "/i", filePath, "/quiet", "/norestart") + out, err := cmd.CombinedOutput() + if err != nil { + return string(out), fmt.Errorf("msi install failed: %w\nOutput: %s", err, string(out)) + } + return string(out), nil + case ".exe": + cmd := exec.Command(filePath, "/S") + out, err := cmd.CombinedOutput() + if err != nil { + return string(out), fmt.Errorf("exe install failed: %w\nOutput: %s", err, string(out)) + } + return string(out), nil + case ".msix", ".appx": + cmd := exec.Command("powershell", "-Command", fmt.Sprintf("Add-AppxPackage -Path '%s'", filePath)) + out, err := cmd.CombinedOutput() + if err != nil { + return string(out), fmt.Errorf("msix install failed: %w\nOutput: %s", err, string(out)) + } + return string(out), nil + } + case "linux": + switch ext { + case ".deb": + cmd := exec.Command("sudo", "dpkg", "-i", filePath) + out, err := cmd.CombinedOutput() + if err != nil { + cmd2 := exec.Command("sudo", "apt-get", "install", "-f", "-y") + out2, _ := cmd2.CombinedOutput() + return string(out) + "\n" + string(out2), fmt.Errorf("dpkg install failed: %w", err) + } + return string(out), nil + case ".rpm": + cmd := exec.Command("sudo", "rpm", "-i", filePath) + out, err := cmd.CombinedOutput() + if err != nil { + return string(out), fmt.Errorf("rpm install failed: %w\nOutput: %s", err, string(out)) + } + return string(out), nil + } + case "darwin": + switch ext { + case ".dmg": + cmd := exec.Command("hdiutil", "attach", filePath, "-nobrowse", "-quiet") + out, err := cmd.CombinedOutput() + if err != nil { + return string(out), fmt.Errorf("dmg mount failed: %w", err) + } + return string(out), nil + case ".pkg": + cmd := exec.Command("sudo", "installer", "-pkg", filePath, "-target", "/") + out, err := cmd.CombinedOutput() + if err != nil { + return string(out), fmt.Errorf("pkg install failed: %w\nOutput: %s", err, string(out)) + } + return string(out), nil + } + } + + return "", fmt.Errorf("unsupported file type %s for %s", ext, runtime.GOOS) +} + +func UpdatePackage(name string) (string, error) { + manager := DetectManager() + var executable string + var args []string + + switch manager { + case "apt": + executable = "sudo" + args = []string{"apt-get", "install", "-y", "--only-upgrade", name} + case "yum": + executable = "sudo" + args = []string{"yum", "update", "-y", name} + case "dnf": + executable = "sudo" + args = []string{"dnf", "update", "-y", name} + case "winget": + executable = "winget" + args = []string{"upgrade", "--id", name, "--accept-source-agreements", "--accept-package-agreements", "--disable-interactivity"} + case "choco": + executable = "choco" + args = []string{"upgrade", "-y", name} + default: + return "", fmt.Errorf("unsupported package manager: %s", manager) + } + + out, err := runManagerCommand(executable, args) + if err != nil { + return out, err + } + return out, nil +} + +func RemovePackage(name string) (string, error) { + return removePackageWithManager(name, DetectManager()) +} + +func removePackageWithManager(name, manager string) (string, error) { + var executable string + var args []string + + switch manager { + case "apt": + executable = "sudo" + args = []string{"apt-get", "remove", "-y", name} + case "yum": + executable = "sudo" + args = []string{"yum", "remove", "-y", name} + case "dnf": + executable = "sudo" + args = []string{"dnf", "remove", "-y", name} + case "winget": + executable = "winget" + args = []string{"uninstall", "--id", name, "--accept-source-agreements", "--disable-interactivity"} + case "choco": + executable = "choco" + args = []string{"uninstall", "-y", name} + default: + return "", fmt.Errorf("unsupported package manager: %s", manager) + } + + out, err := runManagerCommand(executable, args) + if err != nil { + return out, err + } + return out, nil +} + +func RemovePackageWithManager(name, manager string) (string, error) { + return removePackageWithManager(name, manager) +} + +func DownloadFile(url string, destPath string) error { + dir := filepath.Dir(destPath) + if err := os.MkdirAll(dir, 0755); err != nil { + return fmt.Errorf("failed to create directory: %w", err) + } + + var cmd *exec.Cmd + if _, err := exec.LookPath("curl"); err == nil { + cmd = exec.Command("curl", "-L", "-o", destPath, url) + } else if _, err := exec.LookPath("wget"); err == nil { + cmd = exec.Command("wget", "-O", destPath, url) + } else { + return fmt.Errorf("neither curl nor wget found") + } + + out, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("download failed: %w\nOutput: %s", err, string(out)) + } + return nil +} diff --git a/agents/forge-agent/internal/pkgmanager/pkgmanager_stub.go b/agents/forge-agent/internal/pkgmanager/pkgmanager_stub.go new file mode 100644 index 0000000..22ed360 --- /dev/null +++ b/agents/forge-agent/internal/pkgmanager/pkgmanager_stub.go @@ -0,0 +1,17 @@ +//go:build !windows + +package pkgmanager + +import "fmt" + +// BootstrapManagers is a no-op on non-Windows platforms. +// On Windows it installs the Microsoft App Installer (winget) and +// Chocolatey if they are not already present. +func BootstrapManagers() error { + return nil +} + +// listWindowsPackages is only implemented on Windows. +func listWindowsPackages() ([]PackageInfo, error) { + return nil, fmt.Errorf("windows package listing only available on Windows") +} \ No newline at end of file diff --git a/agents/forge-agent/internal/pkgmanager/pkgmanager_windows.go b/agents/forge-agent/internal/pkgmanager/pkgmanager_windows.go new file mode 100644 index 0000000..0d59be6 --- /dev/null +++ b/agents/forge-agent/internal/pkgmanager/pkgmanager_windows.go @@ -0,0 +1,163 @@ +//go:build windows + +package pkgmanager + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + + "golang.org/x/sys/windows/registry" +) + +func listWindowsPackages() ([]PackageInfo, error) { + seen := make(map[string]PackageInfo) + + if err := enumerateRegistryUninstall(registry.LOCAL_MACHINE, `SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall`, seen); err != nil { + return nil, fmt.Errorf("enumerate current user uninstall failed: %w", err) + } + if err := enumerateRegistryUninstall(registry.LOCAL_MACHINE, `SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall`, seen); err != nil { + // non-fatal; 32-bit view may not exist + _ = err + } + if err := enumerateRegistryUninstall(registry.CURRENT_USER, `SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall`, seen); err != nil { + _ = err + } + + if _, err := exec.LookPath(resolveManagerPath("winget", interactiveProfile())); err == nil { + if wingetPkgs, err := listWingetPackages(); err == nil { + for _, p := range wingetPkgs { + key := p.Name + "@" + p.Manager + if _, ok := seen[key]; !ok { + seen[key] = p + } + } + } + } + + packages := make([]PackageInfo, 0, len(seen)) + for _, p := range seen { + packages = append(packages, p) + } + return packages, nil +} + +func enumerateRegistryUninstall(root registry.Key, path string, seen map[string]PackageInfo) error { + k, err := registry.OpenKey(root, path, registry.READ) + if err != nil { + return err + } + defer k.Close() + + subKeys, err := k.ReadSubKeyNames(0) + if err != nil { + return err + } + + for _, name := range subKeys { + sk, err := registry.OpenKey(k, name, registry.READ) + if err != nil { + continue + } + displayName, _, err := sk.GetStringValue("DisplayName") + if err != nil || displayName == "" { + sk.Close() + continue + } + displayVersion, _, _ := sk.GetStringValue("DisplayVersion") + version := strings.TrimSpace(displayVersion) + if version == "" { + version = "unknown" + } + + key := displayName + "@windows" + if _, ok := seen[key]; !ok { + seen[key] = PackageInfo{ + Name: displayName, + Version: version, + Manager: "windows", + } + } + sk.Close() + } + return nil +} + +func BootstrapManagers() error { + var errs []string + + if !lookupWinget() { + if err := installWingetViaAppInstaller(); err != nil { + errs = append(errs, "winget: "+err.Error()) + } + } + + if _, err := exec.LookPath("choco"); err != nil { + if err := installChocolatey(); err != nil { + errs = append(errs, "chocolatey: "+err.Error()) + } + } + + if len(errs) > 0 { + return fmt.Errorf("bootstrap failed: %s", strings.Join(errs, "; ")) + } + return nil +} + +func installChocolatey() error { + // Official Chocolatey install script (requires admin; agent runs as LocalSystem). + cmd := exec.Command("powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", + "Set-ExecutionPolicy Bypass -Scope Process -Force; "+ + "[System.Net.ServicePointManager]::SecurityProtocol = [System.Net.ServicePointManager]::SecurityProtocol -bor 3072; "+ + "iex ((New-Object System.Net.WebClient).DownloadString('https://community.chocolatey.org/install.ps1'))") + if out, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("chocolatey install script failed: %w\nOutput: %s", err, string(out)) + } + return nil +} + +func lookupWinget() bool { + if _, err := exec.LookPath(resolveManagerPath("winget", interactiveProfile())); err == nil { + return true + } + appInstaller := `Microsoft.DesktopAppInstaller_8wekyb3d8bbwe` + appxReg, err := registry.OpenKey(registry.LOCAL_MACHINE, `SOFTWARE\Microsoft\Windows\CurrentVersion\Appx\AppxAllUserStore`, + registry.READ|registry.ENUMERATE_SUB_KEYS) + if err == nil { + defer appxReg.Close() + if keys, err := appxReg.ReadSubKeyNames(0); err == nil { + for _, keyName := range keys { + if strings.Contains(keyName, "DesktopAppInstaller") { + _ = appInstaller + return true + } + } + } + } + return false +} + +func installWingetViaAppInstaller() error { + // Microsoft App Installer download link (redirects to latest winget bundle) + installerURL := "https://aka.ms/getwinget" + appInstallerPath := filepath.Join(os.TempDir(), "DesktopAppInstaller.msixbundle") + + // Download App Installer bundle + downloadCmd := exec.Command("powershell", "-NoProfile", "-NonInteractive", "-Command", + "[Net.ServicePointManager]::SecurityProtocol=[Net.SecurityProtocolType]::Tls12; "+ + "Invoke-WebRequest -Uri '"+installerURL+"' -OutFile '"+appInstallerPath+"' -UseBasicParsing") + if out, err := downloadCmd.CombinedOutput(); err != nil { + return fmt.Errorf("download App Installer failed: %w\nOutput: %s", err, string(out)) + } + + // Install bundle (dependencies may need to be allowed; add-AppxPackage on msixbundle handles it) + installCmd := exec.Command("powershell", "-NoProfile", "-NonInteractive", "-Command", + "Add-AppxPackage -Path '"+appInstallerPath+"' -ForceUpdateFromAnyVersion") + if out, err := installCmd.CombinedOutput(); err != nil { + return fmt.Errorf("install App Installer failed: %w\nOutput: %s", err, string(out)) + } + + return nil +} \ No newline at end of file diff --git a/agents/forge-agent/internal/pkgmanager/run_manager_other.go b/agents/forge-agent/internal/pkgmanager/run_manager_other.go new file mode 100644 index 0000000..a843085 --- /dev/null +++ b/agents/forge-agent/internal/pkgmanager/run_manager_other.go @@ -0,0 +1,33 @@ +//go:build !windows + +package pkgmanager + +import ( + "fmt" + "os/exec" +) + +func resolveManagerPath(executable, profile string) string { + return executable +} + +func interactiveProfile() string { + return "" +} + +func runManagerCommand(executable string, args []string) (string, error) { + cmd := exec.Command(executable, args...) + out, err := cmd.CombinedOutput() + if err != nil { + return string(out), fmt.Errorf("command failed: %w\nOutput: %s", err, string(out)) + } + return string(out), nil +} + +func LaunchInUserSession(exe string, args []string) (uint32, error) { + return 0, fmt.Errorf("LaunchInUserSession is not supported on this platform") +} + +func RunInUserSessionCapture(exe string, args []string) (string, int, error) { + return "", -1, fmt.Errorf("RunInUserSessionCapture is not supported on this platform") +} \ No newline at end of file diff --git a/agents/forge-agent/internal/pkgmanager/run_manager_windows.go b/agents/forge-agent/internal/pkgmanager/run_manager_windows.go new file mode 100644 index 0000000..44092cf --- /dev/null +++ b/agents/forge-agent/internal/pkgmanager/run_manager_windows.go @@ -0,0 +1,346 @@ +//go:build windows + +package pkgmanager + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "sort" + "strings" + "unsafe" + + "golang.org/x/sys/windows" +) + +// wingetPath returns a usable winget.exe for the given user profile, falling +// back to SYSTEM/global locations. winget is a UWP app execution alias that is +// commonly missing from the PATH of service accounts (e.g. LocalSystem). +func wingetPath(profile string) string { + if profile != "" { + if p := fileExists(filepath.Join(profile, "AppData", "Local", "Microsoft", "WindowsApps", "winget.exe")); p != "" { + return p + } + } + if p, err := exec.LookPath("winget"); err == nil { + return p + } + for _, base := range []string{ + os.ExpandEnv(`%LOCALAPPDATA%\Microsoft\WindowsApps\winget.exe`), + os.ExpandEnv(`%SystemRoot%\System32\config\systemprofile\AppData\Local\Microsoft\WindowsApps\winget.exe`), + } { + if p := fileExists(base); p != "" { + return p + } + } + matches, _ := filepath.Glob(filepath.Join(os.ExpandEnv(`%ProgramFiles%`), "WindowsApps", "Microsoft.DesktopAppInstaller_*", "winget.exe")) + if len(matches) > 0 { + sort.Strings(matches) + return matches[len(matches)-1] + } + return "" +} + +func chocoPath() string { + if p, err := exec.LookPath("choco"); err == nil { + return p + } + if p := fileExists(filepath.Join(os.ExpandEnv(`%ProgramData%`), "chocolatey", "bin", "choco.exe")); p != "" { + return p + } + return "" +} + +func resolveManagerPath(executable, profile string) string { + switch executable { + case "winget": + if p := wingetPath(profile); p != "" { + return p + } + case "choco": + if profile != "" { + if p := fileExists(filepath.Join(profile, "AppData", "Local", "chocolatey", "bin", "choco.exe")); p != "" { + return p + } + } + if p := chocoPath(); p != "" { + return p + } + } + return "" +} + +// interactiveProfile returns the profile directory of the logged-on user, or "" +// when no interactive session exists. +func interactiveProfile() string { + token, profile, err := interactiveUser() + if err != nil { + return "" + } + token.Close() + return profile +} + +// runManagerCommand runs a package manager command. winget and choco runs are +// executed in the context of the interactive (logged-on) user so that the winget +// app execution alias resolves and per-user installers land in the user's own +// profile (and therefore are visible to them). If the user context lacks +// elevation, the command is retried in the SYSTEM context. +func runManagerCommand(executable string, args []string) (string, error) { + switch executable { + case "choco", "winget": + token, profile, err := interactiveUser() + if err == nil { + defer token.Close() + exe := resolveManagerPath(executable, profile) + if exe == "" { + return "", fmt.Errorf("%s not found in user context or system locations", executable) + } + out, code, spawnErr := runAsUser(token, profile, exe, args) + if spawnErr == nil { + if code == 0 { + return out, nil + } + if !needsElevation(out) { + return out, fmt.Errorf("%s failed with exit code %d\nOutput: %s", executable, code, out) + } + } + // user context failed to launch or needs elevation: retry as SYSTEM + } + } + + exe := resolveManagerPath(executable, "") + if exe == "" { + return "", fmt.Errorf("%s not found in any known location", executable) + } + cmd := exec.Command(exe, args...) + out, err := cmd.CombinedOutput() + if err != nil { + return string(out), fmt.Errorf("command failed: %w\nOutput: %s", err, string(out)) + } + return string(out), nil +} + +// interactiveUser returns a duplicated primary token for the logged-on user and +// their profile directory. It fails when no interactive session exists. +func interactiveUser() (windows.Token, string, error) { + var sessionID uint32 + var count uint32 + var sessions *windows.WTS_SESSION_INFO + if err := windows.WTSEnumerateSessions(0, 0, 1, &sessions, &count); err != nil { + sessionID = windows.WTSGetActiveConsoleSessionId() + } else { + defer windows.WTSFreeMemory(uintptr(unsafe.Pointer(sessions))) + info := unsafe.Slice(sessions, count) + for _, s := range info { + if s.SessionID != 0 && (s.State == windows.WTSActive || s.State == windows.WTSConnected) { + sessionID = s.SessionID + break + } + } + } + if sessionID == 0 { + return 0, "", fmt.Errorf("no interactive session") + } + + var token windows.Token + if err := windows.WTSQueryUserToken(sessionID, &token); err != nil { + return 0, "", fmt.Errorf("WTSQueryUserToken: %w", err) + } + + var dup windows.Token + if err := windows.DuplicateTokenEx(token, windows.TOKEN_ALL_ACCESS, nil, windows.SecurityImpersonation, windows.TokenPrimary, &dup); err != nil { + token.Close() + return 0, "", fmt.Errorf("DuplicateTokenEx: %w", err) + } + token.Close() + + profile, err := userProfileDir(dup) + if err != nil { + dup.Close() + return 0, "", fmt.Errorf("GetUserProfileDirectory: %w", err) + } + return dup, profile, nil +} + +func userProfileDir(token windows.Token) (string, error) { + buf := make([]uint16, 1024) + for { + size := uint32(len(buf)) + err := windows.GetUserProfileDirectory(token, &buf[0], &size) + if err == nil { + return windows.UTF16ToString(buf), nil + } + if err != windows.ERROR_INSUFFICIENT_BUFFER { + return "", err + } + buf = make([]uint16, size) + } +} + +// runAsUser runs executable in the given user's context, redirecting stdout and +// stderr to a temp file via inherited handles. Returns combined output and the +// process exit code. +func runAsUser(token windows.Token, profile, exe string, args []string) (string, int, error) { + outFile, err := os.CreateTemp("", "ff-agent-out-*.txt") + if err != nil { + return "", -1, fmt.Errorf("CreateTemp: %w", err) + } + outPath := outFile.Name() + outFile.Close() + defer os.Remove(outPath) + + var sa windows.SecurityAttributes + sa.Length = uint32(unsafe.Sizeof(sa)) + sa.InheritHandle = 1 + + shareMode := uint32(windows.FILE_SHARE_READ | windows.FILE_SHARE_WRITE) + outHandle, err := windows.CreateFile(windows.StringToUTF16Ptr(outPath), windows.GENERIC_WRITE, shareMode, &sa, windows.CREATE_ALWAYS, windows.FILE_ATTRIBUTE_NORMAL, 0) + if err != nil { + return "", -1, fmt.Errorf("CreateFile output: %w", err) + } + defer windows.CloseHandle(outHandle) + + nulHandle, err := windows.CreateFile(windows.StringToUTF16Ptr("NUL"), windows.GENERIC_READ, shareMode, &sa, windows.OPEN_EXISTING, 0, 0) + if err != nil { + return "", -1, fmt.Errorf("CreateFile NUL: %w", err) + } + defer windows.CloseHandle(nulHandle) + + commandLinePtr, err := windows.UTF16PtrFromString(windowsQuote(exe) + " " + windowsQuoteArgs(args)) + if err != nil { + return "", -1, fmt.Errorf("UTF16PtrFromString: %w", err) + } + + var envBlock *uint16 + if err := windows.CreateEnvironmentBlock(&envBlock, token, false); err != nil { + envBlock = nil + } else { + defer windows.DestroyEnvironmentBlock(envBlock) + } + + cwd, _ := windows.UTF16PtrFromString(profile) + if cwd == nil || *cwd == 0 { + cwd = nil + } + + var si windows.StartupInfo + si.Cb = uint32(unsafe.Sizeof(si)) + si.Flags = windows.STARTF_USESTDHANDLES + si.StdInput = nulHandle + si.StdOutput = outHandle + si.StdErr = outHandle + + var pi windows.ProcessInformation + flags := uint32(windows.CREATE_UNICODE_ENVIRONMENT | windows.CREATE_NEW_PROCESS_GROUP | windows.CREATE_NO_WINDOW) + err = windows.CreateProcessAsUser(token, nil, commandLinePtr, nil, nil, true, flags, envBlock, cwd, &si, &pi) + if err != nil { + return "", -1, fmt.Errorf("CreateProcessAsUser: %w", err) + } + windows.CloseHandle(pi.Thread) + defer windows.CloseHandle(pi.Process) + + _, _ = windows.WaitForSingleObject(pi.Process, windows.INFINITE) + + var exitCode uint32 + _ = windows.GetExitCodeProcess(pi.Process, &exitCode) + + out, _ := os.ReadFile(outPath) + return string(out), int(exitCode), nil +} + +func windowsQuote(s string) string { + return `"` + strings.ReplaceAll(s, `"`, `""`) + `"` +} + +// RunInUserSessionCapture runs an executable in the context of the logged-on +// interactive user and returns its combined output and exit code. It is used +// e.g. to write per-user registry settings for the VNC server. +func RunInUserSessionCapture(exe string, args []string) (string, int, error) { + token, profile, err := interactiveUser() + if err != nil { + return "", -1, err + } + defer token.Close() + return runAsUser(token, profile, exe, args) +} + +// LaunchInUserSession launches an executable in the context of the logged-on +// interactive user without waiting for it to finish. It returns the process ID +// of the launched process, which can be used to terminate it later. This is +// used e.g. to start a VNC server inside the user's desktop session. +func LaunchInUserSession(exe string, args []string) (uint32, error) { + token, profile, err := interactiveUser() + if err != nil { + return 0, err + } + defer token.Close() + + commandLinePtr, err := windows.UTF16PtrFromString(windowsQuote(exe) + " " + windowsQuoteArgs(args)) + if err != nil { + return 0, fmt.Errorf("UTF16PtrFromString: %w", err) + } + + var envBlock *uint16 + if err := windows.CreateEnvironmentBlock(&envBlock, token, false); err != nil { + envBlock = nil + } else { + defer windows.DestroyEnvironmentBlock(envBlock) + } + + cwd, _ := windows.UTF16PtrFromString(profile) + if cwd == nil || *cwd == 0 { + cwd = nil + } + + var si windows.StartupInfo + si.Cb = uint32(unsafe.Sizeof(si)) + si.Flags = windows.STARTF_USESHOWWINDOW + si.ShowWindow = windows.SW_SHOWNORMAL + + var pi windows.ProcessInformation + flags := uint32(windows.CREATE_UNICODE_ENVIRONMENT | windows.CREATE_NEW_PROCESS_GROUP | windows.CREATE_NO_WINDOW) + err = windows.CreateProcessAsUser(token, nil, commandLinePtr, nil, nil, false, flags, envBlock, cwd, &si, &pi) + if err != nil { + return 0, fmt.Errorf("CreateProcessAsUser: %w", err) + } + windows.CloseHandle(pi.Thread) + windows.CloseHandle(pi.Process) + return pi.ProcessId, nil +} + +func windowsQuoteArgs(args []string) string { + out := make([]string, 0, len(args)) + for _, a := range args { + out = append(out, windowsQuote(a)) + } + return strings.Join(out, " ") +} + +func fileExists(p string) string { + if p == "" { + return "" + } + if _, err := os.Stat(p); err == nil { + return p + } + return "" +} + +func needsElevation(output string) bool { + low := strings.ToLower(output) + for _, s := range []string{ + "elevat", + "administrator", + "requires authentication", + "access is denied", + "access denied", + "run as admin", + } { + if strings.Contains(low, s) { + return true + } + } + return false +} \ No newline at end of file diff --git a/agents/forge-agent/internal/svcmgr/svcmgr.go b/agents/forge-agent/internal/svcmgr/svcmgr.go new file mode 100644 index 0000000..9f29add --- /dev/null +++ b/agents/forge-agent/internal/svcmgr/svcmgr.go @@ -0,0 +1,288 @@ +package svcmgr + +import ( + "fmt" + "os/exec" + "runtime" + "strings" +) + +type ServiceInfo struct { + Name string `json:"name"` + Status string `json:"status"` + Enabled bool `json:"enabled"` +} + +func ListServices() ([]ServiceInfo, error) { + switch runtime.GOOS { + case "windows": + return listWindowsServices() + case "darwin": + return listDarwinServices() + default: + return listLinuxServices() + } +} + +func GetServiceStatus(name string) (string, bool, error) { + switch runtime.GOOS { + case "windows": + return getWindowsServiceStatus(name) + case "darwin": + return getDarwinServiceStatus(name) + default: + return getLinuxServiceStatus(name) + } +} + +func StartService(name string) (string, error) { + switch runtime.GOOS { + case "windows": + return startWindowsService(name) + case "darwin": + return startDarwinService(name) + default: + return startLinuxService(name) + } +} + +func StopService(name string) (string, error) { + switch runtime.GOOS { + case "windows": + return stopWindowsService(name) + case "darwin": + return stopDarwinService(name) + default: + return stopLinuxService(name) + } +} + +func RestartService(name string) (string, error) { + switch runtime.GOOS { + case "windows": + return restartWindowsService(name) + case "darwin": + return restartDarwinService(name) + default: + return restartLinuxService(name) + } +} + +func Reboot() error { + switch runtime.GOOS { + case "windows": + return exec.Command("shutdown", "/r", "/t", "0").Run() + case "darwin": + return exec.Command("sudo", "shutdown", "-r", "now").Run() + default: + return exec.Command("sudo", "reboot").Run() + } +} + +func Shutdown() error { + switch runtime.GOOS { + case "windows": + return exec.Command("shutdown", "/s", "/t", "0").Run() + case "darwin": + return exec.Command("sudo", "shutdown", "-h", "now").Run() + default: + return exec.Command("sudo", "poweroff").Run() + } +} + +func listWindowsServices() ([]ServiceInfo, error) { + out, err := exec.Command("powershell", "-NoProfile", "-NonInteractive", "-Command", + "Get-Service -ErrorAction SilentlyContinue | ForEach-Object { '{0}`t{1}`t{2}' -f $_.Name, $_.Status, $_.StartType }").Output() + if err != nil { + return nil, fmt.Errorf("Get-Service failed: %w", err) + } + + var services []ServiceInfo + for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") { + fields := strings.Split(strings.TrimSpace(line), "\t") + if len(fields) < 3 { + continue + } + services = append(services, ServiceInfo{ + Name: fields[0], + Status: strings.ToLower(fields[1]), + Enabled: strings.EqualFold(fields[2], "Automatic"), + }) + } + return services, nil +} + +func getWindowsServiceStatus(name string) (string, bool, error) { + out, err := exec.Command("powershell", "-NoProfile", "-NonInteractive", "-Command", + fmt.Sprintf("$s = Get-Service -Name '%s' -ErrorAction Stop; '{0}`t{1}' -f $s.Status, $s.StartType", name)).Output() + if err != nil { + return "", false, fmt.Errorf("Get-Service failed: %w", err) + } + fields := strings.Split(strings.TrimSpace(string(out)), "\t") + if len(fields) < 2 { + return "", false, fmt.Errorf("unexpected Get-Service output: %s", strings.TrimSpace(string(out))) + } + return strings.ToLower(fields[0]), strings.EqualFold(fields[1], "Automatic"), nil +} + +func startWindowsService(name string) (string, error) { + cmd := exec.Command("powershell", "-NoProfile", "-NonInteractive", "-Command", + fmt.Sprintf("Start-Service -Name '%s' -ErrorAction Stop", name)) + if out, err := cmd.CombinedOutput(); err != nil { + return string(out), fmt.Errorf("start failed: %w\nOutput: %s", err, string(out)) + } + return "started", nil +} + +func stopWindowsService(name string) (string, error) { + cmd := exec.Command("powershell", "-NoProfile", "-NonInteractive", "-Command", + fmt.Sprintf("Stop-Service -Name '%s' -ErrorAction Stop", name)) + if out, err := cmd.CombinedOutput(); err != nil { + return string(out), fmt.Errorf("stop failed: %w\nOutput: %s", err, string(out)) + } + return "stopped", nil +} + +func restartWindowsService(name string) (string, error) { + cmd := exec.Command("powershell", "-NoProfile", "-NonInteractive", "-Command", + fmt.Sprintf("Restart-Service -Name '%s' -ErrorAction Stop", name)) + if out, err := cmd.CombinedOutput(); err != nil { + return string(out), fmt.Errorf("restart failed: %w\nOutput: %s", err, string(out)) + } + return "restarted", nil +} + +func listDarwinServices() ([]ServiceInfo, error) { + out, err := exec.Command("launchctl", "list").Output() + if err != nil { + return nil, fmt.Errorf("launchctl list failed: %w", err) + } + + var services []ServiceInfo + for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n")[1:] { + fields := strings.Fields(line) + if len(fields) < 3 { + continue + } + status := "unknown" + if fields[0] != "-" { + status = "running" + } + services = append(services, ServiceInfo{ + Name: fields[2], + Status: status, + }) + } + return services, nil +} + +func getDarwinServiceStatus(name string) (string, bool, error) { + out, err := exec.Command("launchctl", "list").Output() + if err != nil { + return "", false, fmt.Errorf("launchctl list failed: %w", err) + } + for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n")[1:] { + fields := strings.Fields(line) + if len(fields) >= 3 && fields[2] == name { + if fields[0] != "-" { + return "running", true, nil + } + return "inactive", false, nil + } + } + return "not_found", false, nil +} + +func startDarwinService(name string) (string, error) { + cmd := exec.Command("launchctl", "load", fmt.Sprintf("/Library/LaunchDaemons/%s.plist", name)) + if out, err := cmd.CombinedOutput(); err != nil { + return string(out), fmt.Errorf("start failed: %w\nOutput: %s", err, string(out)) + } + return "started", nil +} + +func stopDarwinService(name string) (string, error) { + cmd := exec.Command("launchctl", "unload", fmt.Sprintf("/Library/LaunchDaemons/%s.plist", name)) + if out, err := cmd.CombinedOutput(); err != nil { + return string(out), fmt.Errorf("stop failed: %w\nOutput: %s", err, string(out)) + } + return "stopped", nil +} + +func restartDarwinService(name string) (string, error) { + stopDarwinService(name) + return startDarwinService(name) +} + +func listLinuxServices() ([]ServiceInfo, error) { + cmd := exec.Command("systemctl", "list-units", "--type=service", "--all", "--no-pager", "--no-legend", "--plain", "--no-accept-unknown") + out, err := cmd.Output() + if err != nil { + return nil, fmt.Errorf("systemctl list-units failed: %w", err) + } + + var services []ServiceInfo + for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + fields := strings.Fields(line) + if len(fields) < 4 { + continue + } + + svcName := strings.TrimSuffix(fields[0], ".service") + status := fields[2] + enabled := fields[3] == "loaded" + + services = append(services, ServiceInfo{ + Name: svcName, + Status: status, + Enabled: enabled, + }) + } + return services, nil +} + +func getLinuxServiceStatus(name string) (string, bool, error) { + cmd := exec.Command("systemctl", "is-active", name) + out, err := cmd.Output() + status := strings.TrimSpace(string(out)) + + enabledCmd := exec.Command("systemctl", "is-enabled", name) + enabledOut, _ := enabledCmd.Output() + enabled := strings.TrimSpace(string(enabledOut)) == "enabled" + + if err != nil && status != "inactive" { + return status, enabled, fmt.Errorf("systemctl status failed: %w", err) + } + return status, enabled, nil +} + +func startLinuxService(name string) (string, error) { + cmd := exec.Command("sudo", "systemctl", "start", name) + out, err := cmd.CombinedOutput() + if err != nil { + return string(out), fmt.Errorf("start failed: %w\nOutput: %s", err, string(out)) + } + return "started", nil +} + +func stopLinuxService(name string) (string, error) { + cmd := exec.Command("sudo", "systemctl", "stop", name) + out, err := cmd.CombinedOutput() + if err != nil { + return string(out), fmt.Errorf("stop failed: %w\nOutput: %s", err, string(out)) + } + return "stopped", nil +} + +func restartLinuxService(name string) (string, error) { + cmd := exec.Command("sudo", "systemctl", "restart", name) + out, err := cmd.CombinedOutput() + if err != nil { + return string(out), fmt.Errorf("restart failed: %w\nOutput: %s", err, string(out)) + } + return "restarted", nil +} \ No newline at end of file diff --git a/agents/forge-agent/internal/tunnel/tunnel.go b/agents/forge-agent/internal/tunnel/tunnel.go new file mode 100644 index 0000000..7c94c66 --- /dev/null +++ b/agents/forge-agent/internal/tunnel/tunnel.go @@ -0,0 +1,119 @@ +package tunnel + +import ( + "fmt" + "io" + "log" + "net" + "net/url" + "strings" + "sync" + "time" + + "github.com/gorilla/websocket" +) + +type Tunnel struct { + wsURL string + port int + sessionID string + ws *websocket.Conn + tcp net.Conn + stop chan struct{} + once sync.Once +} + +// New returns a tunnel that pipes traffic between the Fleet Forge server +// (WebSocket) and the local VNC server (127.0.0.1:port). It does not connect +// until Run is called. +func New(serverURL, credential, sessionID string, vncPort int) *Tunnel { + wsURL := strings.Replace(serverURL, "https://", "wss://", 1) + wsURL = strings.Replace(wsURL, "http://", "ws://", 1) + wsURL = strings.TrimSuffix(wsURL, "/") + + q := url.Values{} + q.Set("session_id", sessionID) + q.Set("token", credential) + + full := wsURL + "/api/v1/remote/agent?" + q.Encode() + + return &Tunnel{ + wsURL: full, + port: vncPort, + sessionID: sessionID, + stop: make(chan struct{}), + } +} + +// Run dials the server WebSocket and the local VNC port, then pipes bytes in +// both directions until either side closes or Stop is called. +func (t *Tunnel) Run() error { + ws, _, err := websocket.DefaultDialer.Dial(t.wsURL, nil) + if err != nil { + return fmt.Errorf("tunnel dial (server): %w", err) + } + defer ws.Close() + t.ws = ws + + tcp, err := net.DialTimeout("tcp", fmt.Sprintf("127.0.0.1:%d", t.port), 10*time.Second) + if err != nil { + return fmt.Errorf("tunnel dial (local VNC): %w", err) + } + defer tcp.Close() + t.tcp = tcp + + log.Printf("[tunnel] connected for session %s (vnc 127.0.0.1:%d)", t.sessionID, t.port) + + done := make(chan error, 2) + + go func() { + for { + _, data, err := ws.ReadMessage() + if err != nil { + done <- err + return + } + if _, err := tcp.Write(data); err != nil { + done <- err + return + } + } + }() + + go func() { + buf := make([]byte, 8192) + for { + n, err := tcp.Read(buf) + if n > 0 { + if err := ws.WriteMessage(websocket.BinaryMessage, buf[:n]); err != nil { + done <- err + return + } + } + if err != nil { + done <- err + return + } + } + }() + + select { + case err := <-done: + return err + case <-t.stop: + return io.EOF + } +} + +// Stop closes the underlying connections, causing Run to return. +func (t *Tunnel) Stop() { + t.once.Do(func() { + close(t.stop) + if t.ws != nil { + _ = t.ws.Close() + } + if t.tcp != nil { + _ = t.tcp.Close() + } + }) +} \ No newline at end of file diff --git a/agents/forge-agent/internal/vnc/vnc.go b/agents/forge-agent/internal/vnc/vnc.go new file mode 100644 index 0000000..ca2d770 --- /dev/null +++ b/agents/forge-agent/internal/vnc/vnc.go @@ -0,0 +1,283 @@ +package vnc + +import ( + "crypto/des" + "encoding/hex" + "fmt" + "net" + "os" + "os/exec" + "path/filepath" + "runtime" + "strconv" + "strings" + "time" + + "github.com/fleetforge/forge-agent/internal/pkgmanager" +) + +const waitForPortTimeout = 25 * time.Second + +type Server struct { + port int + pid int + passFile string +} + +// Ensure starts a VNC server on the given port with the given password and +// returns a handle that can be used to stop it again. +func Ensure(port int, password string) (*Server, error) { + switch runtime.GOOS { + case "windows": + return startTightVNC(port, password) + case "linux": + return startX11VNC(port, password) + default: + return nil, fmt.Errorf("remote desktop is not supported on %s yet", runtime.GOOS) + } +} + +func (s *Server) Stop() { + if s == nil { + return + } + switch runtime.GOOS { + case "windows": + stopTightVNC(s) + default: + stopX11VNC(s) + } +} + +func (s *Server) Port() int { + return s.port +} + +// ---------------------------------------------------------------- Windows + +func tightVNCPath() string { + for _, base := range []string{ + os.ExpandEnv(`%ProgramFiles%\TightVNC\tvnserver.exe`), + os.ExpandEnv(`%ProgramFiles(x86)%\TightVNC\tvnserver.exe`), + `C:\Program Files\TightVNC\tvnserver.exe`, + `C:\Program Files (x86)\TightVNC\tvnserver.exe`, + } { + if _, err := os.Stat(base); err == nil { + return base + } + } + return "" +} + +func quickRegAdd(subkey, name, value, regType string) error { + cmd := exec.Command("reg", "add", subkey, "/v", name, "/t", regType, "/d", value, "/f") + if out, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("reg add %s: %w (%s)", name, err, strings.TrimSpace(string(out))) + } + return nil +} + +// configTightVNC writes the TightVNC server settings to both the global (HKLM) +// and the current user (HKCU) registry locations. Application mode reads the +// user settings, while the (optional) service mode reads the global ones, so we +// configure both. HKCU is written as the interactive user because reg.exe under +// the SYSTEM account would target the system profile instead. +func configTightVNC(port int, enc []byte) error { + type entry struct { + name string + val string + typ string + } + entries := []entry{ + {"RfbPort", strconv.Itoa(port), "REG_DWORD"}, + {"UsePasswordAuthentication", "1", "REG_DWORD"}, + {"AcceptLoopbackConnections", "1", "REG_DWORD"}, + {"Password", hex.EncodeToString(enc), "REG_BINARY"}, + } + + const globalKey = `HKLM\Software\TightVNC\Server` + const userKey = `HKCU\Software\TightVNC\Server` + + write := func(subkey string, inUserSession bool) error { + for _, e := range entries { + args := []string{"add", subkey, "/v", e.name, "/t", e.typ, "/d", e.val, "/f"} + var err error + if inUserSession { + _, code, uerr := pkgmanager.RunInUserSessionCapture("reg", args) + if uerr != nil { + err = uerr + } else if code != 0 { + err = fmt.Errorf("reg add %s (as user) exited %d", e.name, code) + } + } else { + err = quickRegAdd(subkey, e.name, e.val, e.typ) + } + if err != nil { + return fmt.Errorf("set %s\\%s: %w", subkey, e.name, err) + } + } + return nil + } + + if err := write(globalKey, false); err != nil { + return err + } + if err := write(userKey, true); err != nil { + return err + } + return nil +} + +func startTightVNC(port int, password string) (*Server, error) { + exe := tightVNCPath() + if exe == "" { + if _, err := pkgmanager.InstallPackageWithManager("TightVNC.TightVNC", "winget", ""); err != nil { + if _, ferr := pkgmanager.InstallPackageWithManager("tightvnc", "choco", ""); ferr != nil { + return nil, fmt.Errorf("tightvnc not installed and automatic install failed: %v (fallback %v)", err, ferr) + } + } + exe = tightVNCPath() + if exe == "" { + return nil, fmt.Errorf("failed to locate tvnserver.exe after installation") + } + } + + // Best effort: stop any TightVNC service so it does not fight on the port. + stopTightVNCServices() + + enc, err := encryptPassword(password) + if err != nil { + return nil, fmt.Errorf("vnc password encryption failed: %w", err) + } + + if err := configTightVNC(port, enc); err != nil { + return nil, err + } + + // Start in the interactive user's session so the real desktop is served. + pid, err := pkgmanager.LaunchInUserSession(exe, []string{"-run"}) + if err != nil { + return nil, fmt.Errorf("failed to start tvnserver in user session: %w", err) + } + + srv := &Server{port: port, pid: int(pid)} + if err := waitForPort(port); err != nil { + srv.Stop() + return nil, err + } + return srv, nil +} + +func stopTightVNCServices() { + for _, name := range []string{"tvnserver", "TightVNC"} { + _ = exec.Command("sc", "stop", name).Run() + } +} + +func stopTightVNC(s *Server) { + stopTightVNCServices() + if s.pid > 0 { + if proc, err := os.FindProcess(s.pid); err == nil { + _ = proc.Kill() + } + } +} + +// encryptPassword produces the DES-encrypted 8-byte form used by the VNC +// servers to store the password (fixed key with bit-reversed bytes). +func encryptPassword(password string) ([]byte, error) { + buf := make([]byte, 8) + copy(buf, []byte(password)) + + key := []byte{0xe8, 0x4a, 0xd6, 0x60, 0xc4, 0x72, 0x1a, 0xe3} + for i := range key { + key[i] = bitReverse(key[i]) + } + + block, err := des.NewCipher(key) + if err != nil { + return nil, err + } + + out := make([]byte, 8) + block.Encrypt(out, buf) + return out, nil +} + +func bitReverse(b byte) byte { + var r byte + for i := 0; i < 8; i++ { + r = r<<1 | (b & 1) + b >>= 1 + } + return r +} + +// ---------------------------------------------------------------- Linux + +func startX11VNC(port int, password string) (*Server, error) { + exe, err := exec.LookPath("x11vnc") + if err != nil { + if _, ierr := pkgmanager.InstallPackage("x11vnc"); ierr == nil { + exe, err = exec.LookPath("x11vnc") + } + if err != nil { + return nil, fmt.Errorf("x11vnc is not installed and could not be installed") + } + } + + display := os.Getenv("DISPLAY") + if display == "" { + display = ":0" + } + + passFile := filepath.Join(os.TempDir(), fmt.Sprintf("ff-vnc-pass-%d.txt", time.Now().UnixNano())) + if out, err := exec.Command(exe, "-storepasswd", password, passFile).CombinedOutput(); err != nil { + return nil, fmt.Errorf("x11vnc -storepasswd failed: %w (%s)", err, strings.TrimSpace(string(out))) + } + + cmd := exec.Command( + exe, + "-display", display, + "-rfbport", strconv.Itoa(port), + "-rfbauth", passFile, + "-forever", + "-shared", + ) + if err := cmd.Start(); err != nil { + os.Remove(passFile) + return nil, fmt.Errorf("failed to start x11vnc: %w", err) + } + + srv := &Server{port: port, pid: cmd.Process.Pid} + srv.passFile = passFile + if err := waitForPort(port); err != nil { + srv.Stop() + return nil, err + } + return srv, nil +} + +func stopX11VNC(s *Server) { + if s.pid > 0 { + if proc, err := os.FindProcess(s.pid); err == nil { + _ = proc.Kill() + } + } + if s.passFile != "" { + os.Remove(s.passFile) + } +} + +func waitForPort(port int) error { + deadline := time.Now().Add(waitForPortTimeout) + for time.Now().Before(deadline) { + conn, err := net.DialTimeout("tcp", fmt.Sprintf("127.0.0.1:%d", port), 2*time.Second) + if err == nil { + conn.Close() + return nil + } + time.Sleep(1 * time.Second) + } + return fmt.Errorf("VNC server did not start listening on port %d", port) +} \ No newline at end of file diff --git a/agents/forge-agent/internal/winsvc/install.go b/agents/forge-agent/internal/winsvc/install.go new file mode 100644 index 0000000..022e6ec --- /dev/null +++ b/agents/forge-agent/internal/winsvc/install.go @@ -0,0 +1,142 @@ +//go:build windows + +package winsvc + +import ( + "fmt" + "os" + "path/filepath" + "time" + + "github.com/fleetforge/forge-agent/internal/config" + "golang.org/x/sys/windows/svc" + "golang.org/x/sys/windows/svc/eventlog" + "golang.org/x/sys/windows/svc/mgr" +) + +func InstallService(serverURL, enrollmentToken, label string) error { + exePath, err := os.Executable() + if err != nil { + return fmt.Errorf("failed to get executable path: %w", err) + } + exePath, err = filepath.EvalSymlinks(exePath) + if err != nil { + return fmt.Errorf("failed to resolve executable path: %w", err) + } + + cfg := &config.Config{ + ServerURL: serverURL, + EnrollmentToken: enrollmentToken, + Label: label, + HeartbeatSec: 30, + } + if err := config.Save(cfg); err != nil { + return fmt.Errorf("failed to save config: %w", err) + } + fmt.Printf("Config saved to: %s\n", config.ConfigPath()) + + scm, err := mgr.Connect() + if err != nil { + return fmt.Errorf("failed to connect to Service Control Manager: %w", err) + } + defer scm.Disconnect() + + existing, err := scm.OpenService(ServiceName) + if err == nil { + existing.Close() + return fmt.Errorf("service already exists: %s", ServiceName) + } + + s, err := scm.CreateService(ServiceName, exePath, mgr.Config{ + DisplayName: ServiceDisplayName, + Description: ServiceDescription, + StartType: mgr.StartAutomatic, + ErrorControl: mgr.ErrorNormal, + }, "run", "--service") + if err != nil { + return fmt.Errorf("failed to create service: %w", err) + } + defer s.Close() + + if err := eventlog.InstallAsEventCreate(ServiceName, eventlog.Error|eventlog.Warning|eventlog.Info); err != nil { + s.Delete() + return fmt.Errorf("failed to install event log source: %w", err) + } + + fmt.Printf("Service '%s' installed successfully.\n", ServiceDisplayName) + fmt.Println("The service will start automatically on system boot.") + + if err := s.Start(); err != nil { + fmt.Printf("Warning: Could not start service: %v\n", err) + } else { + waitUntilRunning(s) + fmt.Println("Service started.") + } + + return nil +} + +func UninstallService() error { + scm, err := mgr.Connect() + if err != nil { + return fmt.Errorf("failed to connect to Service Control Manager: %w", err) + } + defer scm.Disconnect() + + s, err := scm.OpenService(ServiceName) + if err != nil { + return fmt.Errorf("service does not exist: %s", ServiceName) + } + defer s.Close() + + st, err := s.Control(svc.Stop) + if err == nil { + waitUntilStopped(s, st) + } + + if err := eventlog.Remove(ServiceName); err != nil { + fmt.Printf("Warning: Could not remove event log source: %v\n", err) + } + + if err := s.Delete(); err != nil { + return fmt.Errorf("failed to delete service: %w", err) + } + + fmt.Printf("Service '%s' removed successfully.\n", ServiceDisplayName) + + if err := config.Delete(); err != nil && !os.IsNotExist(err) { + fmt.Printf("Warning: Could not remove config file: %v\n", err) + } else { + fmt.Println("Config file removed.") + } + + return nil +} + +func waitUntilStopped(s *mgr.Service, initialStatus svc.Status) { + state := initialStatus.State + for i := 0; i < 30; i++ { + if state == svc.Stopped { + return + } + time.Sleep(time.Second) + st, err := s.Query() + if err != nil { + return + } + state = st.State + } +} + +func waitUntilRunning(s *mgr.Service) { + for i := 0; i < 30; i++ { + st, err := s.Query() + if err != nil { + return + } + if st.State == svc.Running { + return + } + time.Sleep(time.Second) + } +} diff --git a/agents/forge-agent/internal/winsvc/service.go b/agents/forge-agent/internal/winsvc/service.go new file mode 100644 index 0000000..ef0a365 --- /dev/null +++ b/agents/forge-agent/internal/winsvc/service.go @@ -0,0 +1,76 @@ +//go:build windows + +package winsvc + +import ( + "fmt" + "log" + "time" + + "github.com/fleetforge/forge-agent/internal/agent" + "github.com/fleetforge/forge-agent/internal/config" + "golang.org/x/sys/windows/svc" + "golang.org/x/sys/windows/svc/eventlog" +) + +const ServiceName = "FleetForgeAgent" +const ServiceDisplayName = "Fleet Forge Agent" +const ServiceDescription = "Fleet Forge endpoint management agent." + +type agentService struct { + cfg *config.Config +} + +func (s *agentService) Execute(args []string, r <-chan svc.ChangeRequest, status chan<- svc.Status) (bool, uint32) { + status <- svc.Status{State: svc.StartPending, Accepts: svc.AcceptStop | svc.AcceptShutdown} + + elog, err := eventlog.Open(ServiceName) + if err == nil { + defer elog.Close() + elog.Info(1, "Fleet Forge Agent service starting") + } + + a := agent.New(s.cfg) + + done := make(chan error, 1) + go func() { + done <- a.Run() + }() + + status <- svc.Status{State: svc.Running, Accepts: svc.AcceptStop | svc.AcceptShutdown} + + for { + select { + case err := <-done: + if err != nil { + log.Printf("[service] Agent stopped with error: %v", err) + if elog != nil { + elog.Error(1, fmt.Sprintf("Agent stopped: %v", err)) + } + } + return false, 0 + + case c := <-r: + switch c.Cmd { + case svc.Stop, svc.Shutdown: + log.Println("[service] Stop/Shutdown received") + status <- svc.Status{State: svc.StopPending} + return false, 0 + case svc.Interrogate: + status <- c.CurrentStatus + } + + case <-time.After(5 * time.Second): + } + } +} + +func RunAsService() error { + cfg, err := config.Load() + if err != nil { + return fmt.Errorf("failed to load config: %w", err) + } + + svcInst := &agentService{cfg: cfg} + return svc.Run(ServiceName, svcInst) +} diff --git a/agents/forge-agent/internal/winsvc/stub.go b/agents/forge-agent/internal/winsvc/stub.go new file mode 100644 index 0000000..5a1d7d1 --- /dev/null +++ b/agents/forge-agent/internal/winsvc/stub.go @@ -0,0 +1,17 @@ +//go:build !windows + +package winsvc + +import "fmt" + +func RunAsService() error { + return fmt.Errorf("windows service is not supported on this platform") +} + +func InstallService(serverURL, enrollmentToken, label string) error { + return fmt.Errorf("windows service installation is not supported on this platform") +} + +func UninstallService() error { + return fmt.Errorf("windows service uninstallation is not supported on this platform") +} diff --git a/apps/api/drizzle.config.ts b/apps/api/drizzle.config.ts new file mode 100644 index 0000000..db5f080 --- /dev/null +++ b/apps/api/drizzle.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from "drizzle-kit"; + +export default defineConfig({ + schema: "./src/db/schema.ts", + out: "../../infrastructure/migrations", + dialect: "postgresql", + dbCredentials: { + url: process.env.DATABASE_URL!, + }, +}); diff --git a/apps/api/package.json b/apps/api/package.json new file mode 100644 index 0000000..b5bbee2 --- /dev/null +++ b/apps/api/package.json @@ -0,0 +1,47 @@ +{ + "name": "@fleetforge/api", + "version": "0.1.0", + "private": true, + "type": "module", + "main": "./dist/index.js", + "scripts": { + "dev": "tsx watch src/index.ts", + "build": "tsc", + "start": "node dist/index.js", + "migrate": "tsx src/db/migrate.ts", + "seed": "tsx src/db/seed.ts", + "typecheck": "tsc --noEmit", + "lint": "eslint src/", + "test": "vitest run", + "test:watch": "vitest" + }, + "dependencies": { + "@fleetforge/shared": "workspace:*", + "@types/multer": "^2.2.0", + "bcrypt": "^5.1.1", + "cors": "^2.8.5", + "dotenv": "^16.4.7", + "drizzle-orm": "^0.38.0", + "express": "^4.21.0", + "helmet": "^8.0.0", + "jsonwebtoken": "^9.0.2", + "multer": "^2.2.0", + "pg": "^8.13.0", + "rate-limiter-flexible": "^5.0.4", + "uuid": "^11.0.0", + "ws": "^8.21.3", + "zod": "^3.24.0" + }, + "devDependencies": { + "@types/bcrypt": "^5.0.2", + "@types/cors": "^2.8.17", + "@types/express": "^5.0.0", + "@types/jsonwebtoken": "^9.0.7", + "@types/pg": "^8.11.10", + "@types/uuid": "^10.0.0", + "@types/ws": "^8.18.1", + "tsx": "^4.19.0", + "typescript": "^5.7.0", + "vitest": "^3.0.0" + } +} diff --git a/apps/api/src/__tests__/auth.test.ts b/apps/api/src/__tests__/auth.test.ts new file mode 100644 index 0000000..9831b87 --- /dev/null +++ b/apps/api/src/__tests__/auth.test.ts @@ -0,0 +1,105 @@ +import { describe, it, expect, beforeAll, afterAll, beforeEach } from "vitest"; +import { + getTestPool, + setupTestDb, + cleanTestDb, + teardownTestDb, +} from "./helpers.js"; +import pg from "pg"; + +describe("Authentication", () => { + let pool: pg.Pool; + let orgId: string; + let userId: string; + + beforeAll(async () => { + pool = await getTestPool(); + }); + + afterAll(async () => { + await teardownTestDb(); + }); + + beforeEach(async () => { + await cleanTestDb(pool); + const seeded = await setupTestDb(); + orgId = seeded.orgId; + userId = seeded.userId; + }); + + it("should return user for valid credentials", async () => { + const bcrypt = await import("bcrypt"); + const passwordHash = await bcrypt.hash("testpass123", 10); + + const { rows } = await pool.query( + "SELECT id, email, role FROM users WHERE email = $1 AND is_active = true", + ["admin@test.local"] + ); + + expect(rows.length).toBe(1); + expect(rows[0].email).toBe("admin@test.local"); + expect(rows[0].role).toBe("owner"); + + const valid = await bcrypt.compare("testpass123", passwordHash); + expect(valid).toBe(true); + }); + + it("should reject invalid password", async () => { + const bcrypt = await import("bcrypt"); + const { rows } = await pool.query( + "SELECT password_hash FROM users WHERE email = $1", + ["admin@test.local"] + ); + + const valid = await bcrypt.compare("wrongpassword", rows[0].password_hash); + expect(valid).toBe(false); + }); + + it("should reject non-existent user", async () => { + const { rows } = await pool.query( + "SELECT id FROM users WHERE email = $1 AND is_active = true", + ["nonexistent@test.local"] + ); + expect(rows.length).toBe(0); + }); + + it("should reject inactive user", async () => { + await pool.query( + "UPDATE users SET is_active = false WHERE id = $1", + [userId] + ); + + const { rows } = await pool.query( + "SELECT id FROM users WHERE email = $1 AND is_active = true", + ["admin@test.local"] + ); + expect(rows.length).toBe(0); + }); + + it("should generate and verify JWT token", async () => { + const jwt = await import("jsonwebtoken"); + const token = jwt.default.sign( + { userId, organizationId: orgId, role: "owner", email: "admin@test.local" }, + process.env.JWT_SECRET || "test-secret", + { expiresIn: "1h" } + ); + + const decoded = jwt.default.verify(token, process.env.JWT_SECRET || "test-secret") as any; + expect(decoded.userId).toBe(userId); + expect(decoded.organizationId).toBe(orgId); + expect(decoded.role).toBe("owner"); + }); + + it("should reject expired JWT", async () => { + const jwt = await import("jsonwebtoken"); + const token = jwt.default.sign( + { userId, organizationId: orgId, role: "owner" }, + process.env.JWT_SECRET || "test-secret", + { expiresIn: "0s" } + ); + + expect(() => { + jwt.default.verify(token, process.env.JWT_SECRET || "test-secret"); + }).toThrow(); + }); +}); diff --git a/apps/api/src/__tests__/authorization.test.ts b/apps/api/src/__tests__/authorization.test.ts new file mode 100644 index 0000000..ad100e2 --- /dev/null +++ b/apps/api/src/__tests__/authorization.test.ts @@ -0,0 +1,140 @@ +import { describe, it, expect, beforeAll, afterAll, beforeEach } from "vitest"; +import { + getTestPool, + setupTestDb, + cleanTestDb, + teardownTestDb, + uuidv4, +} from "./helpers.js"; +import pg from "pg"; + +describe("Authorization & Roles", () => { + let pool: pg.Pool; + let orgId: string; + + beforeAll(async () => { + pool = await getTestPool(); + }); + + afterAll(async () => { + await teardownTestDb(); + }); + + beforeEach(async () => { + await cleanTestDb(pool); + const seeded = await setupTestDb(); + orgId = seeded.orgId; + }); + + const ROLE_HIERARCHY: Record = { + owner: 4, + admin: 3, + operator: 2, + read_only: 1, + }; + + it("should define correct role hierarchy", () => { + expect(ROLE_HIERARCHY.owner).toBeGreaterThan(ROLE_HIERARCHY.admin); + expect(ROLE_HIERARCHY.admin).toBeGreaterThan(ROLE_HIERARCHY.operator); + expect(ROLE_HIERARCHY.operator).toBeGreaterThan(ROLE_HIERARCHY.read_only); + }); + + it("should create users with different roles", async () => { + const bcrypt = await import("bcrypt"); + const hash = await bcrypt.hash("password123", 10); + + const roles = ["admin", "operator", "read_only"]; + for (const role of roles) { + await pool.query( + `INSERT INTO users (organization_id, email, password_hash, display_name, role) + VALUES ($1, $2, $3, $4, $5)`, + [orgId, `${role}@roles.test.local`, hash, `${role} User`, role] + ); + } + + const { rows } = await pool.query( + "SELECT role FROM users WHERE organization_id = $1 ORDER BY role", + [orgId] + ); + + expect(rows.length).toBe(4); + const userRoles = rows.map((r: any) => r.role); + expect(userRoles).toContain("admin"); + expect(userRoles).toContain("operator"); + expect(userRoles).toContain("read_only"); + expect(userRoles).toContain("owner"); + }); + + it("should enforce unique email per organization", async () => { + const bcrypt = await import("bcrypt"); + const hash = await bcrypt.hash("password123", 10); + + await pool.query( + `INSERT INTO users (organization_id, email, password_hash, display_name, role) + VALUES ($1, 'unique@test.local', $2, 'User 1', 'admin')`, + [orgId, hash] + ); + + await expect( + pool.query( + `INSERT INTO users (organization_id, email, password_hash, display_name, role) + VALUES ($1, 'unique@test.local', $2, 'User 2', 'operator')`, + [orgId, hash] + ) + ).rejects.toThrow(); + }); + + it("should isolate organizations", async () => { + const bcrypt = await import("bcrypt"); + const hash = await bcrypt.hash("password123", 10); + + const otherOrgId = uuidv4(); + await pool.query( + `INSERT INTO organizations (id, name, slug) VALUES ($1, 'Other Org', 'other')`, + [otherOrgId] + ); + + await pool.query( + `INSERT INTO users (organization_id, email, password_hash, display_name, role) + VALUES ($1, 'user@org1.com', $2, 'Org1 User', 'admin')`, + [orgId, hash] + ); + await pool.query( + `INSERT INTO users (organization_id, email, password_hash, display_name, role) + VALUES ($1, 'user@org2.com', $2, 'Org2 User', 'admin')`, + [otherOrgId, hash] + ); + + const { rows: org1Users } = await pool.query( + "SELECT id FROM users WHERE organization_id = $1", + [orgId] + ); + const { rows: org2Users } = await pool.query( + "SELECT id FROM users WHERE organization_id = $1", + [otherOrgId] + ); + + expect(org1Users.length).toBe(2); + expect(org2Users.length).toBe(1); + }); + + it("should allow deactivating users", async () => { + const userId = uuidv4(); + const bcrypt = await import("bcrypt"); + const hash = await bcrypt.hash("password123", 10); + + await pool.query( + `INSERT INTO users (id, organization_id, email, password_hash, display_name, role, is_active) + VALUES ($1, $2, 'deactivate@test.local', $3, 'Deactivate Me', 'operator', true)`, + [userId, orgId, hash] + ); + + await pool.query("UPDATE users SET is_active = false WHERE id = $1", [userId]); + + const { rows } = await pool.query( + "SELECT is_active FROM users WHERE id = $1", + [userId] + ); + expect(rows[0].is_active).toBe(false); + }); +}); diff --git a/apps/api/src/__tests__/devices.test.ts b/apps/api/src/__tests__/devices.test.ts new file mode 100644 index 0000000..64c54af --- /dev/null +++ b/apps/api/src/__tests__/devices.test.ts @@ -0,0 +1,204 @@ +import { describe, it, expect, beforeAll, afterAll, beforeEach } from "vitest"; +import { + getTestPool, + setupTestDb, + cleanTestDb, + teardownTestDb, + generateEnrollmentToken, + hashToken, + generateDeviceCredential, + hashCredential, + uuidv4, +} from "./helpers.js"; +import pg from "pg"; + +describe("Device Registration E2E", () => { + let pool: pg.Pool; + let orgId: string; + let userId: string; + + beforeAll(async () => { + pool = await getTestPool(); + }); + + afterAll(async () => { + await teardownTestDb(); + }); + + beforeEach(async () => { + await cleanTestDb(pool); + const seeded = await setupTestDb(); + orgId = seeded.orgId; + userId = seeded.userId; + }); + + it("should enroll a device via enrollment token", async () => { + const rawToken = generateEnrollmentToken(); + const tokenHash = hashToken(rawToken); + const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000); + + await pool.query( + `INSERT INTO enrollment_tokens (organization_id, token_hash, expires_at, created_by_user_id) + VALUES ($1, $2, $3, $4)`, + [orgId, tokenHash, expiresAt, userId] + ); + + const { rows: tokenRows } = await pool.query( + `SELECT * FROM enrollment_tokens + WHERE token_hash = $1 AND is_revoked = false AND expires_at > NOW()`, + [tokenHash] + ); + expect(tokenRows.length).toBe(1); + + const deviceId = uuidv4(); + await pool.query( + `INSERT INTO devices (id, organization_id, name, device_type, platform, os_name, os_version, architecture, agent_version, status, last_heartbeat_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, NOW())`, + [deviceId, orgId, "test-server-01", "linux_server", "linux", "Ubuntu", "22.04", "amd64", "0.1.0", "online"] + ); + + const rawCred = generateDeviceCredential(); + const credHash = hashCredential(rawCred); + await pool.query( + `INSERT INTO device_credentials (device_id, credential_type, credential_hash, expires_at) + VALUES ($1, 'device_key', $2, NOW() + INTERVAL '1 year')`, + [deviceId, credHash] + ); + + await pool.query( + "UPDATE enrollment_tokens SET use_count = use_count + 1 WHERE id = $1", + [tokenRows[0].id] + ); + + const { rows: devices } = await pool.query( + "SELECT * FROM devices WHERE id = $1", + [deviceId] + ); + expect(devices.length).toBe(1); + expect(devices[0].name).toBe("test-server-01"); + expect(devices[0].status).toBe("online"); + + const { rows: creds } = await pool.query( + "SELECT * FROM device_credentials WHERE device_id = $1", + [deviceId] + ); + expect(creds.length).toBe(1); + expect(creds[0].credential_type).toBe("device_key"); + + const verified = await pool.query( + "SELECT * FROM device_credentials WHERE credential_hash = $1", + [credHash] + ); + expect(verified.rows.length).toBe(1); + }); + + it("should record heartbeat and metrics", async () => { + const deviceId = uuidv4(); + await pool.query( + `INSERT INTO devices (id, organization_id, name, device_type, platform, os_name, os_version, architecture, agent_version, status) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, 'online')`, + [deviceId, orgId, "hb-test", "linux_server", "linux", "Ubuntu", "22.04", "amd64", "0.1.0"] + ); + + await pool.query( + `INSERT INTO metric_snapshots (device_id, reported_at, cpu_usage_percent, memory_usage_percent, disk_usage_percent, uptime_seconds) + VALUES ($1, NOW(), 45, 67, 32, 86400)`, + [deviceId] + ); + + await pool.query( + `INSERT INTO inventory_snapshots (device_id, reported_at, packages, services, network_interfaces, platform_data) + VALUES ($1, NOW(), $2, $3, $4, $5)`, + [ + deviceId, + JSON.stringify([{ name: "nginx", version: "1.18.0", manager: "apt" }]), + JSON.stringify([{ name: "nginx", status: "active", enabled: true }]), + JSON.stringify([{ name: "eth0", addresses: ["192.168.1.100"], mac_address: "00:11:22:33:44:55" }]), + JSON.stringify({}), + ] + ); + + await pool.query( + "UPDATE devices SET status = 'online', last_heartbeat_at = NOW() WHERE id = $1", + [deviceId] + ); + + const { rows: metrics } = await pool.query( + "SELECT * FROM metric_snapshots WHERE device_id = $1 ORDER BY reported_at DESC", + [deviceId] + ); + expect(metrics.length).toBe(1); + expect(metrics[0].cpu_usage_percent).toBe(45); + expect(metrics[0].memory_usage_percent).toBe(67); + + const { rows: inventory } = await pool.query( + "SELECT * FROM inventory_snapshots WHERE device_id = $1", + [deviceId] + ); + expect(inventory.length).toBe(1); + expect(inventory[0].packages.length).toBe(1); + }); + + it("should handle full enrollment flow", async () => { + const rawToken = generateEnrollmentToken(); + const tokenHash = hashToken(rawToken); + const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000); + + const { rows: tokenRows } = await pool.query( + `INSERT INTO enrollment_tokens (organization_id, token_hash, expires_at, created_by_user_id) + VALUES ($1, $2, $3, $4) RETURNING id`, + [orgId, tokenHash, expiresAt, userId] + ); + const tokenId = tokenRows[0].id; + + const { rows: foundToken } = await pool.query( + `SELECT * FROM enrollment_tokens + WHERE token_hash = $1 AND is_revoked = false AND expires_at > NOW()`, + [tokenHash] + ); + expect(foundToken.length).toBe(1); + + const deviceId = uuidv4(); + const rawCred = generateDeviceCredential(); + const credHash = hashCredential(rawCred); + + await pool.query( + `INSERT INTO devices (id, organization_id, name, device_type, platform, os_name, os_version, architecture, agent_version, status, last_heartbeat_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, 'online', NOW())`, + [deviceId, orgId, "new-host", "linux_server", "linux", "Ubuntu", "24.04", "amd64", "0.1.0"] + ); + + await pool.query( + `INSERT INTO device_credentials (device_id, credential_type, credential_hash, expires_at) + VALUES ($1, 'device_key', $2, NOW() + INTERVAL '1 year')`, + [deviceId, credHash] + ); + + await pool.query( + "UPDATE enrollment_tokens SET use_count = use_count + 1 WHERE id = $1", + [tokenId] + ); + + await pool.query( + `INSERT INTO metric_snapshots (device_id, reported_at, cpu_usage_percent, memory_usage_percent, disk_usage_percent, uptime_seconds) + VALUES ($1, NOW(), 10, 30, 25, 3600)`, + [deviceId] + ); + + const { rows: device } = await pool.query("SELECT * FROM devices WHERE id = $1", [deviceId]); + expect(device.length).toBe(1); + expect(device[0].organization_id).toBe(orgId); + + const { rows: cred } = await pool.query( + "SELECT * FROM device_credentials WHERE credential_hash = $1", + [credHash] + ); + expect(cred.length).toBe(1); + + const { rows: metrics } = await pool.query( + "SELECT * FROM metric_snapshots WHERE device_id = $1", + [deviceId] + ); + expect(metrics.length).toBe(1); + }); +}); diff --git a/apps/api/src/__tests__/enrollment.test.ts b/apps/api/src/__tests__/enrollment.test.ts new file mode 100644 index 0000000..f4446c6 --- /dev/null +++ b/apps/api/src/__tests__/enrollment.test.ts @@ -0,0 +1,175 @@ +import { describe, it, expect, beforeAll, afterAll, beforeEach } from "vitest"; +import { + getTestPool, + setupTestDb, + cleanTestDb, + teardownTestDb, + generateEnrollmentToken, + hashToken, +} from "./helpers.js"; +import pg from "pg"; + +describe("Enrollment Tokens", () => { + let pool: pg.Pool; + let orgId: string; + let userId: string; + + beforeAll(async () => { + pool = await getTestPool(); + }); + + afterAll(async () => { + await teardownTestDb(); + }); + + beforeEach(async () => { + await cleanTestDb(pool); + const seeded = await setupTestDb(); + orgId = seeded.orgId; + userId = seeded.userId; + }); + + it("should create an enrollment token", async () => { + const rawToken = generateEnrollmentToken(); + const tokenHash = hashToken(rawToken); + const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000); + + const { rows } = await pool.query( + `INSERT INTO enrollment_tokens (organization_id, token_hash, expires_at, max_uses, label, created_by_user_id) + VALUES ($1, $2, $3, $4, $5, $6) RETURNING id, token_hash`, + [orgId, tokenHash, expiresAt, 1, "test-token", userId] + ); + + expect(rows.length).toBe(1); + expect(rows[0].token_hash).toBe(tokenHash); + }); + + it("should validate a valid enrollment token", async () => { + const rawToken = generateEnrollmentToken(); + const tokenHash = hashToken(rawToken); + const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000); + + await pool.query( + `INSERT INTO enrollment_tokens (organization_id, token_hash, expires_at, created_by_user_id) + VALUES ($1, $2, $3, $4)`, + [orgId, tokenHash, expiresAt, userId] + ); + + const { rows } = await pool.query( + `SELECT * FROM enrollment_tokens + WHERE token_hash = $1 AND is_revoked = false AND expires_at > NOW()`, + [tokenHash] + ); + + expect(rows.length).toBe(1); + }); + + it("should reject expired enrollment token", async () => { + const rawToken = generateEnrollmentToken(); + const tokenHash = hashToken(rawToken); + const expiresAt = new Date(Date.now() - 1000); + + await pool.query( + `INSERT INTO enrollment_tokens (organization_id, token_hash, expires_at, created_by_user_id) + VALUES ($1, $2, $3, $4)`, + [orgId, tokenHash, expiresAt, userId] + ); + + const { rows } = await pool.query( + `SELECT * FROM enrollment_tokens + WHERE token_hash = $1 AND is_revoked = false AND expires_at > NOW()`, + [tokenHash] + ); + + expect(rows.length).toBe(0); + }); + + it("should reject revoked enrollment token", async () => { + const rawToken = generateEnrollmentToken(); + const tokenHash = hashToken(rawToken); + const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000); + + await pool.query( + `INSERT INTO enrollment_tokens (organization_id, token_hash, expires_at, is_revoked, created_by_user_id) + VALUES ($1, $2, $3, true, $4)`, + [orgId, tokenHash, expiresAt, userId] + ); + + const { rows } = await pool.query( + `SELECT * FROM enrollment_tokens + WHERE token_hash = $1 AND is_revoked = false AND expires_at > NOW()`, + [tokenHash] + ); + + expect(rows.length).toBe(0); + }); + + it("should enforce max_uses limit", async () => { + const rawToken = generateEnrollmentToken(); + const tokenHash = hashToken(rawToken); + const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000); + + await pool.query( + `INSERT INTO enrollment_tokens (organization_id, token_hash, expires_at, max_uses, use_count, created_by_user_id) + VALUES ($1, $2, $3, 2, 2, $4)`, + [orgId, tokenHash, expiresAt, userId] + ); + + const { rows } = await pool.query( + `SELECT * FROM enrollment_tokens + WHERE token_hash = $1 AND is_revoked = false + AND (max_uses IS NULL OR use_count < max_uses)`, + [tokenHash] + ); + + expect(rows.length).toBe(0); + }); + + it("should revoke a token", async () => { + const rawToken = generateEnrollmentToken(); + const tokenHash = hashToken(rawToken); + const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000); + + const { rows: inserted } = await pool.query( + `INSERT INTO enrollment_tokens (organization_id, token_hash, expires_at, created_by_user_id) + VALUES ($1, $2, $3, $4) RETURNING id`, + [orgId, tokenHash, expiresAt, userId] + ); + + await pool.query( + "UPDATE enrollment_tokens SET is_revoked = true WHERE id = $1", + [inserted[0].id] + ); + + const { rows } = await pool.query( + "SELECT is_revoked FROM enrollment_tokens WHERE id = $1", + [inserted[0].id] + ); + + expect(rows[0].is_revoked).toBe(true); + }); + + it("should increment use_count on enrollment", async () => { + const rawToken = generateEnrollmentToken(); + const tokenHash = hashToken(rawToken); + const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000); + + const { rows: inserted } = await pool.query( + `INSERT INTO enrollment_tokens (organization_id, token_hash, expires_at, use_count, created_by_user_id) + VALUES ($1, $2, $3, 0, $4) RETURNING id`, + [orgId, tokenHash, expiresAt, userId] + ); + + await pool.query( + "UPDATE enrollment_tokens SET use_count = use_count + 1 WHERE id = $1", + [inserted[0].id] + ); + + const { rows } = await pool.query( + "SELECT use_count FROM enrollment_tokens WHERE id = $1", + [inserted[0].id] + ); + + expect(rows[0].use_count).toBe(1); + }); +}); diff --git a/apps/api/src/__tests__/helpers.ts b/apps/api/src/__tests__/helpers.ts new file mode 100644 index 0000000..29b52f1 --- /dev/null +++ b/apps/api/src/__tests__/helpers.ts @@ -0,0 +1,64 @@ +import dotenv from "dotenv"; +dotenv.config({ path: ".env.test" }); + +import pg from "pg"; +import bcrypt from "bcrypt"; +import { v4 as uuidv4 } from "uuid"; +import { hashToken, generateEnrollmentToken, generateDeviceCredential, hashCredential } from "../lib/tokens.js"; + +const TEST_DB_URL = process.env.DATABASE_URL || "postgresql://fleetforge:fleetforge@localhost:5432/fleetforge_test?schema=public"; + +let pool: pg.Pool; + +export async function getTestPool(): Promise { + if (!pool) { + pool = new pg.Pool({ connectionString: TEST_DB_URL }); + } + return pool; +} + +export async function setupTestDb(): Promise<{ orgId: string; userId: string }> { + const p = await getTestPool(); + await cleanTestDb(p); + return await seedTestDb(p); +} + +export async function cleanTestDb(p: pg.Pool) { + const tables = [ + "audit_events", "api_tokens", "alerts", "compliance_results", + "job_targets", "jobs", "policies", "metric_snapshots", + "inventory_snapshots", "device_credentials", "devices", + "device_groups", "enrollment_tokens", "users", "organizations", + ]; + for (const table of tables) { + await p.query(`TRUNCATE TABLE ${table} CASCADE`); + } +} + +export async function seedTestDb(p: pg.Pool): Promise<{ orgId: string; userId: string }> { + const orgId = uuidv4(); + await p.query( + `INSERT INTO organizations (id, name, slug) VALUES ($1, $2, $3)`, + [orgId, "Test Org", "test-org"] + ); + + const passwordHash = await bcrypt.hash("testpass123", 10); + const userId = uuidv4(); + await p.query( + `INSERT INTO users (id, organization_id, email, password_hash, display_name, role, is_active) + VALUES ($1, $2, $3, $4, $5, $6, $7)`, + [userId, orgId, "admin@test.local", passwordHash, "Test Admin", "owner", true] + ); + + return { orgId, userId }; +} + +export async function teardownTestDb() { + if (pool) { + await cleanTestDb(pool); + await pool.end(); + pool = undefined as any; + } +} + +export { hashToken, generateEnrollmentToken, generateDeviceCredential, hashCredential, uuidv4 }; diff --git a/apps/api/src/__tests__/jobs.test.ts b/apps/api/src/__tests__/jobs.test.ts new file mode 100644 index 0000000..0446b60 --- /dev/null +++ b/apps/api/src/__tests__/jobs.test.ts @@ -0,0 +1,228 @@ +import { describe, it, expect, beforeAll, afterAll, beforeEach } from "vitest"; +import { + getTestPool, + setupTestDb, + cleanTestDb, + teardownTestDb, + uuidv4, +} from "./helpers.js"; +import pg from "pg"; + +describe("Jobs", () => { + let pool: pg.Pool; + let orgId: string; + let userId: string; + + beforeAll(async () => { + pool = await getTestPool(); + }); + + afterAll(async () => { + await teardownTestDb(); + }); + + beforeEach(async () => { + await cleanTestDb(pool); + const seeded = await setupTestDb(); + orgId = seeded.orgId; + userId = seeded.userId; + }); + + it("should create a job with targets", async () => { + const deviceId = uuidv4(); + await pool.query( + `INSERT INTO devices (id, organization_id, name, device_type, platform, os_name, os_version, architecture, agent_version, status) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`, + [deviceId, orgId, "job-test", "linux_server", "linux", "Ubuntu", "22.04", "amd64", "0.1.0", "online"] + ); + + const { rows: jobRows } = await pool.query( + `INSERT INTO jobs (organization_id, created_by_user_id, job_type, status, parameters) + VALUES ($1, $2, 'update_inventory', 'queued', '{}') RETURNING id`, + [orgId, userId] + ); + const jobId = jobRows[0].id; + + await pool.query( + `INSERT INTO job_targets (job_id, device_id, status) VALUES ($1, $2, 'queued')`, + [jobId, deviceId] + ); + + const { rows: jobs } = await pool.query( + "SELECT * FROM jobs WHERE id = $1", + [jobId] + ); + expect(jobs.length).toBe(1); + expect(jobs[0].job_type).toBe("update_inventory"); + expect(jobs[0].status).toBe("queued"); + + const { rows: targets } = await pool.query( + "SELECT * FROM job_targets WHERE job_id = $1", + [jobId] + ); + expect(targets.length).toBe(1); + expect(targets[0].device_id).toBe(deviceId); + }); + + it("should transition job through states", async () => { + const { rows: jobRows } = await pool.query( + `INSERT INTO jobs (organization_id, created_by_user_id, job_type, status, parameters) + VALUES ($1, $2, 'update_system_info', 'queued', '{}') RETURNING id`, + [orgId, userId] + ); + const jobId = jobRows[0].id; + + await pool.query( + "UPDATE jobs SET status = 'dispatched', dispatched_at = NOW() WHERE id = $1", + [jobId] + ); + + const { rows: step1 } = await pool.query("SELECT status FROM jobs WHERE id = $1", [jobId]); + expect(step1[0].status).toBe("dispatched"); + + await pool.query( + "UPDATE jobs SET status = 'running', started_at = NOW() WHERE id = $1", + [jobId] + ); + + const { rows: step2 } = await pool.query("SELECT status FROM jobs WHERE id = $1", [jobId]); + expect(step2[0].status).toBe("running"); + + await pool.query( + "UPDATE jobs SET status = 'succeeded', completed_at = NOW() WHERE id = $1", + [jobId] + ); + + const { rows: step3 } = await pool.query("SELECT status FROM jobs WHERE id = $1", [jobId]); + expect(step3[0].status).toBe("succeeded"); + }); + + it("should cancel queued jobs", async () => { + const { rows: jobRows } = await pool.query( + `INSERT INTO jobs (organization_id, created_by_user_id, job_type, status, parameters) + VALUES ($1, $2, 'reboot_system', 'queued', '{}') RETURNING id`, + [orgId, userId] + ); + const jobId = jobRows[0].id; + + const { rows: updated } = await pool.query( + `UPDATE jobs SET status = 'cancelled', completed_at = NOW() + WHERE id = $1 AND status = 'queued' RETURNING id`, + [jobId] + ); + expect(updated.length).toBe(1); + + const { rows: step } = await pool.query("SELECT status FROM jobs WHERE id = $1", [jobId]); + expect(step[0].status).toBe("cancelled"); + }); + + it("should not cancel running jobs", async () => { + const { rows: jobRows } = await pool.query( + `INSERT INTO jobs (organization_id, created_by_user_id, job_type, status, parameters) + VALUES ($1, $2, 'update_inventory', 'running', '{}') RETURNING id`, + [orgId, userId] + ); + const jobId = jobRows[0].id; + + const { rows: updated } = await pool.query( + `UPDATE jobs SET status = 'cancelled', completed_at = NOW() + WHERE id = $1 AND status = 'queued' RETURNING id`, + [jobId] + ); + expect(updated.length).toBe(0); + + const { rows: step } = await pool.query("SELECT status FROM jobs WHERE id = $1", [jobId]); + expect(step[0].status).toBe("running"); + }); + + it("should complete job when all targets finish", async () => { + const deviceId1 = uuidv4(); + const deviceId2 = uuidv4(); + + await pool.query( + `INSERT INTO devices (id, organization_id, name, device_type, platform, os_name, os_version, architecture, agent_version, status) + VALUES ($1, $2, 'multi-1', 'linux_server', 'linux', 'Ubuntu', '22.04', 'amd64', '0.1.0', 'online')`, + [deviceId1, orgId] + ); + await pool.query( + `INSERT INTO devices (id, organization_id, name, device_type, platform, os_name, os_version, architecture, agent_version, status) + VALUES ($1, $2, 'multi-2', 'linux_server', 'linux', 'Ubuntu', '22.04', 'amd64', '0.1.0', 'online')`, + [deviceId2, orgId] + ); + + const { rows: jobRows } = await pool.query( + `INSERT INTO jobs (organization_id, created_by_user_id, job_type, status, parameters) + VALUES ($1, $2, 'update_inventory', 'dispatched', '{}') RETURNING id`, + [orgId, userId] + ); + const jobId = jobRows[0].id; + + await pool.query( + `INSERT INTO job_targets (job_id, device_id, status) VALUES ($1, $2, 'dispatched'), ($1, $3, 'dispatched')`, + [jobId, deviceId1, deviceId2] + ); + + await pool.query( + "UPDATE job_targets SET status = 'succeeded', completed_at = NOW() WHERE job_id = $1 AND device_id = $2", + [jobId, deviceId1] + ); + + const { rows: partial } = await pool.query( + "SELECT status FROM jobs WHERE id = $1", + [jobId] + ); + expect(partial[0].status).toBe("dispatched"); + + await pool.query( + "UPDATE job_targets SET status = 'succeeded', completed_at = NOW() WHERE job_id = $1 AND device_id = $2", + [jobId, deviceId2] + ); + + const { rows: targets } = await pool.query( + "SELECT status FROM job_targets WHERE job_id = $1", + [jobId] + ); + const allDone = targets.every((t: any) => !["queued", "dispatched", "running"].includes(t.status)); + expect(allDone).toBe(true); + }); + + it("should fail job when any target fails", async () => { + const deviceId1 = uuidv4(); + const deviceId2 = uuidv4(); + + await pool.query( + `INSERT INTO devices (id, organization_id, name, device_type, platform, os_name, os_version, architecture, agent_version, status) + VALUES ($1, $2, 'fail-1', 'linux_server', 'linux', 'Ubuntu', '22.04', 'amd64', '0.1.0', 'online'), + ($3, $2, 'fail-2', 'linux_server', 'linux', 'Ubuntu', '22.04', 'amd64', '0.1.0', 'online')`, + [deviceId1, orgId, deviceId2] + ); + + const { rows: jobRows } = await pool.query( + `INSERT INTO jobs (organization_id, created_by_user_id, job_type, status, parameters) + VALUES ($1, $2, 'reboot_system', 'dispatched', '{}') RETURNING id`, + [orgId, userId] + ); + const jobId = jobRows[0].id; + + await pool.query( + `INSERT INTO job_targets (job_id, device_id, status) VALUES ($1, $2, 'dispatched'), ($1, $3, 'dispatched')`, + [jobId, deviceId1, deviceId2] + ); + + await pool.query( + "UPDATE job_targets SET status = 'succeeded', completed_at = NOW() WHERE job_id = $1 AND device_id = $2", + [jobId, deviceId1] + ); + await pool.query( + `UPDATE job_targets SET status = 'failed', completed_at = NOW(), result = '{"error": "timeout"}' WHERE job_id = $1 AND device_id = $2`, + [jobId, deviceId2] + ); + + const { rows: targets } = await pool.query( + "SELECT status FROM job_targets WHERE job_id = $1", + [jobId] + ); + const anyFailed = targets.some((t: any) => t.status === "failed"); + expect(anyFailed).toBe(true); + }); +}); diff --git a/apps/api/src/__tests__/multitenancy.test.ts b/apps/api/src/__tests__/multitenancy.test.ts new file mode 100644 index 0000000..4014bc2 --- /dev/null +++ b/apps/api/src/__tests__/multitenancy.test.ts @@ -0,0 +1,122 @@ +import { describe, it, expect, beforeAll, afterAll, beforeEach } from "vitest"; +import { + getTestPool, + cleanTestDb, + teardownTestDb, + uuidv4, +} from "./helpers.js"; +import pg from "pg"; + +describe("Multi-tenancy", () => { + let pool: pg.Pool; + + beforeAll(async () => { + pool = await getTestPool(); + }); + + afterAll(async () => { + await teardownTestDb(); + }); + + beforeEach(async () => { + await cleanTestDb(pool); + }); + + it("should isolate devices between organizations", async () => { + const org1 = uuidv4(); + const org2 = uuidv4(); + + await pool.query( + `INSERT INTO organizations (id, name, slug) VALUES ($1, 'Org 1', 'org1'), ($2, 'Org 2', 'org2')`, + [org1, org2] + ); + + await pool.query( + `INSERT INTO devices (id, organization_id, name, device_type, platform, os_name, os_version, architecture, agent_version, status) + VALUES ($1, $2, 'org1-device', 'linux_server', 'linux', 'Ubuntu', '22.04', 'amd64', '0.1.0', 'online'), + ($3, $4, 'org2-device', 'linux_server', 'linux', 'CentOS', '8', 'amd64', '0.1.0', 'online')`, + [uuidv4(), org1, uuidv4(), org2] + ); + + const { rows: org1Devices } = await pool.query( + "SELECT * FROM devices WHERE organization_id = $1", + [org1] + ); + const { rows: org2Devices } = await pool.query( + "SELECT * FROM devices WHERE organization_id = $1", + [org2] + ); + + expect(org1Devices.length).toBe(1); + expect(org2Devices.length).toBe(1); + expect(org1Devices[0].name).toBe("org1-device"); + expect(org2Devices[0].name).toBe("org2-device"); + }); + + it("should isolate audit events between organizations", async () => { + const org1 = uuidv4(); + const org2 = uuidv4(); + + await pool.query( + `INSERT INTO organizations (id, name, slug) VALUES ($1, 'Org A', 'orga'), ($2, 'Org B', 'orgb')`, + [org1, org2] + ); + + await pool.query( + `INSERT INTO audit_events (organization_id, action, target_type, result, correlation_id) + VALUES ($1, 'test.action', 'test', 'success', 'corr-1'), + ($2, 'test.action', 'test', 'success', 'corr-2')`, + [org1, org2] + ); + + const { rows: org1Events } = await pool.query( + "SELECT * FROM audit_events WHERE organization_id = $1", + [org1] + ); + const { rows: org2Events } = await pool.query( + "SELECT * FROM audit_events WHERE organization_id = $1", + [org2] + ); + + expect(org1Events.length).toBe(1); + expect(org2Events.length).toBe(1); + }); + + it("should isolate enrollment tokens between organizations", async () => { + const org1 = uuidv4(); + const org2 = uuidv4(); + const userId = uuidv4(); + + await pool.query( + `INSERT INTO organizations (id, name, slug) VALUES ($1, 'Org X', 'orgx'), ($2, 'Org Y', 'orgy')`, + [org1, org2] + ); + + const bcrypt = await import("bcrypt"); + const hash = await bcrypt.hash("pass", 10); + await pool.query( + `INSERT INTO users (id, organization_id, email, password_hash, display_name, role) + VALUES ($1, $2, 'admin@x.com', $3, 'Admin X', 'owner')`, + [userId, org1, hash] + ); + + await pool.query( + `INSERT INTO enrollment_tokens (organization_id, token_hash, expires_at, created_by_user_id) + VALUES ($1, 'hash1', NOW() + INTERVAL '1 day', $3), + ($2, 'hash2', NOW() + INTERVAL '1 day', $3)`, + [org1, org2, userId] + ); + + const { rows: org1Tokens } = await pool.query( + "SELECT * FROM enrollment_tokens WHERE organization_id = $1", + [org1] + ); + const { rows: org2Tokens } = await pool.query( + "SELECT * FROM enrollment_tokens WHERE organization_id = $1", + [org2] + ); + + expect(org1Tokens.length).toBe(1); + expect(org2Tokens.length).toBe(1); + }); +}); diff --git a/apps/api/src/config.ts b/apps/api/src/config.ts new file mode 100644 index 0000000..7ff27f7 --- /dev/null +++ b/apps/api/src/config.ts @@ -0,0 +1,2 @@ +import dotenv from "dotenv"; +dotenv.config(); diff --git a/apps/api/src/db/index.ts b/apps/api/src/db/index.ts new file mode 100644 index 0000000..ed7b4cd --- /dev/null +++ b/apps/api/src/db/index.ts @@ -0,0 +1,11 @@ +import { drizzle } from "drizzle-orm/node-postgres"; +import pg from "pg"; +import * as schema from "./schema.js"; + +const pool = new pg.Pool({ + connectionString: process.env.DATABASE_URL, +}); + +export const db = drizzle(pool, { schema }); +export { pool }; +export function getPool() { return pool; } diff --git a/apps/api/src/db/migrate.ts b/apps/api/src/db/migrate.ts new file mode 100644 index 0000000..da7e625 --- /dev/null +++ b/apps/api/src/db/migrate.ts @@ -0,0 +1,73 @@ +import dotenv from "dotenv"; +dotenv.config(); + +import fs from "fs"; +import path from "path"; +import { fileURLToPath } from "url"; +import pg from "pg"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +async function main() { + const client = new pg.Client({ + connectionString: process.env.DATABASE_URL, + }); + + await client.connect(); + + try { + console.log("Checking if migrations table exists..."); + await client.query(` + CREATE TABLE IF NOT EXISTS _migrations ( + id SERIAL PRIMARY KEY, + name VARCHAR(255) NOT NULL UNIQUE, + applied_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL + ); + `); + + const migrationsDir = path.resolve(__dirname, "../../../../infrastructure/migrations"); + const files = fs.readdirSync(migrationsDir) + .filter(f => f.endsWith(".sql")) + .sort(); + + for (const file of files) { + const { rows } = await client.query( + "SELECT 1 FROM _migrations WHERE name = $1", + [file] + ); + + if (rows.length > 0) { + console.log(`Migration ${file} already applied, skipping.`); + continue; + } + + console.log(`Applying migration: ${file}`); + const sql = fs.readFileSync(path.join(migrationsDir, file), "utf-8"); + + const statements = sql + .split("--> statement-breakpoint") + .map(s => s.trim()) + .filter(s => s.length > 0); + + for (const stmt of statements) { + await client.query(stmt); + } + + await client.query( + "INSERT INTO _migrations (name) VALUES ($1)", + [file] + ); + + console.log(`Migration ${file} applied successfully.`); + } + + console.log("All migrations complete."); + } finally { + await client.end(); + } +} + +main().catch((err) => { + console.error("Migration failed:", err); + process.exit(1); +}); diff --git a/apps/api/src/db/schema.ts b/apps/api/src/db/schema.ts new file mode 100644 index 0000000..64b728b --- /dev/null +++ b/apps/api/src/db/schema.ts @@ -0,0 +1,574 @@ +import { + pgTable, + uuid, + varchar, + text, + boolean, + integer, + real, + bigint, + timestamp, + jsonb, + index, + uniqueIndex, +} from "drizzle-orm/pg-core"; + +export const organizations = pgTable("organizations", { + id: uuid("id").primaryKey().defaultRandom(), + name: varchar("name", { length: 255 }).notNull(), + slug: varchar("slug", { length: 255 }).notNull().unique(), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }) + .notNull() + .defaultNow() + .$onUpdateFn(() => new Date()), +}); + +export const users = pgTable( + "users", + { + id: uuid("id").primaryKey().defaultRandom(), + organizationId: uuid("organization_id") + .notNull() + .references(() => organizations.id), + email: varchar("email", { length: 255 }).notNull(), + passwordHash: varchar("password_hash", { length: 255 }).notNull(), + displayName: varchar("display_name", { length: 255 }).notNull(), + role: varchar("role", { length: 50 }).notNull().default("read_only"), + isActive: boolean("is_active").notNull().default(true), + lastLoginAt: timestamp("last_login_at", { withTimezone: true }), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }) + .notNull() + .defaultNow() + .$onUpdateFn(() => new Date()), + }, + (table) => [ + uniqueIndex("idx_users_email_org").on(table.email, table.organizationId), + index("idx_users_organization_id").on(table.organizationId), + ] +); + +export const enrollmentTokens = pgTable( + "enrollment_tokens", + { + id: uuid("id").primaryKey().defaultRandom(), + organizationId: uuid("organization_id") + .notNull() + .references(() => organizations.id), + tokenHash: varchar("token_hash", { length: 255 }).notNull().unique(), + expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(), + maxUses: integer("max_uses"), + useCount: integer("use_count").notNull().default(0), + isRevoked: boolean("is_revoked").notNull().default(false), + label: varchar("label", { length: 255 }), + createdByUserId: uuid("created_by_user_id") + .notNull() + .references(() => users.id), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + }, + (table) => [ + index("idx_enrollment_tokens_org").on(table.organizationId), + index("idx_enrollment_tokens_hash").on(table.tokenHash), + ] +); + +export const deviceGroups = pgTable( + "device_groups", + { + id: uuid("id").primaryKey().defaultRandom(), + organizationId: uuid("organization_id") + .notNull() + .references(() => organizations.id), + name: varchar("name", { length: 255 }).notNull(), + description: text("description"), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }) + .notNull() + .defaultNow() + .$onUpdateFn(() => new Date()), + }, + (table) => [ + uniqueIndex("idx_device_groups_name_org").on( + table.name, + table.organizationId + ), + ] +); + +export const devices = pgTable( + "devices", + { + id: uuid("id").primaryKey().defaultRandom(), + organizationId: uuid("organization_id") + .notNull() + .references(() => organizations.id), + deviceGroupId: uuid("device_group_id").references(() => deviceGroups.id), + name: varchar("name", { length: 255 }).notNull(), + deviceType: varchar("device_type", { length: 50 }).notNull(), + platform: varchar("platform", { length: 50 }).notNull(), + osName: varchar("os_name", { length: 100 }).notNull(), + osVersion: varchar("os_version", { length: 100 }).notNull(), + architecture: varchar("architecture", { length: 50 }).notNull(), + kernelVersion: varchar("kernel_version", { length: 100 }), + cpuModel: varchar("cpu_model", { length: 255 }), + cpuCores: integer("cpu_cores"), + totalMemoryBytes: bigint("total_memory_bytes", { mode: "number" }), + totalDiskBytes: bigint("total_disk_bytes", { mode: "number" }), + ipAddress: varchar("ip_address", { length: 45 }), + agentVersion: varchar("agent_version", { length: 50 }).notNull(), + status: varchar("status", { length: 20 }).notNull().default("offline"), + lastHeartbeatAt: timestamp("last_heartbeat_at", { withTimezone: true }), + enrolledAt: timestamp("enrolled_at", { withTimezone: true }) + .notNull() + .defaultNow(), + isDisabled: boolean("is_disabled").notNull().default(false), + isArchived: boolean("is_archived").notNull().default(false), + tags: jsonb("tags").$type().default([]).notNull(), + metadata: jsonb("metadata").$type>().default({}).notNull(), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }) + .notNull() + .defaultNow() + .$onUpdateFn(() => new Date()), + }, + (table) => [ + index("idx_devices_org").on(table.organizationId), + index("idx_devices_status").on(table.status), + index("idx_devices_group").on(table.deviceGroupId), + index("idx_devices_type").on(table.deviceType), + index("idx_devices_last_heartbeat").on(table.lastHeartbeatAt), + ] +); + +export const deviceCredentials = pgTable( + "device_credentials", + { + id: uuid("id").primaryKey().defaultRandom(), + deviceId: uuid("device_id") + .notNull() + .references(() => devices.id, { onDelete: "cascade" }), + credentialType: varchar("credential_type", { length: 50 }).notNull(), + credentialHash: varchar("credential_hash", { length: 255 }).notNull(), + expiresAt: timestamp("expires_at", { withTimezone: true }), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + }, + (table) => [ + index("idx_device_credentials_device").on(table.deviceId), + ] +); + +export const inventorySnapshots = pgTable( + "inventory_snapshots", + { + id: uuid("id").primaryKey().defaultRandom(), + deviceId: uuid("device_id") + .notNull() + .references(() => devices.id, { onDelete: "cascade" }), + reportedAt: timestamp("reported_at", { withTimezone: true }).notNull(), + packages: jsonb("packages").$type().default([]).notNull(), + services: jsonb("services").$type().default([]).notNull(), + networkInterfaces: jsonb("network_interfaces").$type().default([]).notNull(), + platformData: jsonb("platform_data").$type>().default({}).notNull(), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + }, + (table) => [ + index("idx_inventory_device").on(table.deviceId), + index("idx_inventory_reported_at").on(table.reportedAt), + ] +); + +export const metricSnapshots = pgTable( + "metric_snapshots", + { + id: uuid("id").primaryKey().defaultRandom(), + deviceId: uuid("device_id") + .notNull() + .references(() => devices.id, { onDelete: "cascade" }), + reportedAt: timestamp("reported_at", { withTimezone: true }).notNull(), + cpuUsagePercent: real("cpu_usage_percent"), + memoryUsagePercent: real("memory_usage_percent"), + memoryUsedBytes: bigint("memory_used_bytes", { mode: "number" }), + memoryTotalBytes: bigint("memory_total_bytes", { mode: "number" }), + diskUsagePercent: real("disk_usage_percent"), + diskUsedBytes: bigint("disk_used_bytes", { mode: "number" }), + diskTotalBytes: bigint("disk_total_bytes", { mode: "number" }), + loadAverage1m: real("load_average_1m"), + loadAverage5m: real("load_average_5m"), + loadAverage15m: real("load_average_15m"), + uptimeSeconds: bigint("uptime_seconds", { mode: "number" }), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + }, + (table) => [ + index("idx_metrics_device").on(table.deviceId), + index("idx_metrics_reported_at").on(table.reportedAt), + ] +); + +export const policies = pgTable( + "policies", + { + id: uuid("id").primaryKey().defaultRandom(), + organizationId: uuid("organization_id") + .notNull() + .references(() => organizations.id), + name: varchar("name", { length: 255 }).notNull(), + description: text("description"), + rules: jsonb("rules").$type().default([]).notNull(), + isEnabled: boolean("is_enabled").notNull().default(true), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }) + .notNull() + .defaultNow() + .$onUpdateFn(() => new Date()), + }, + (table) => [ + index("idx_policies_org").on(table.organizationId), + ] +); + +export const complianceResults = pgTable( + "compliance_results", + { + id: uuid("id").primaryKey().defaultRandom(), + deviceId: uuid("device_id") + .notNull() + .references(() => devices.id, { onDelete: "cascade" }), + policyId: uuid("policy_id") + .notNull() + .references(() => policies.id, { onDelete: "cascade" }), + status: varchar("status", { length: 50 }).notNull(), + details: jsonb("details").$type>().default({}).notNull(), + evaluatedAt: timestamp("evaluated_at", { withTimezone: true }).notNull(), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + }, + (table) => [ + index("idx_compliance_device").on(table.deviceId), + index("idx_compliance_policy").on(table.policyId), + ] +); + +export const jobs = pgTable( + "jobs", + { + id: uuid("id").primaryKey().defaultRandom(), + organizationId: uuid("organization_id") + .notNull() + .references(() => organizations.id), + createdByUserId: uuid("created_by_user_id") + .notNull() + .references(() => users.id), + jobType: varchar("job_type", { length: 100 }).notNull(), + status: varchar("status", { length: 50 }).notNull().default("queued"), + parameters: jsonb("parameters").$type>().default({}).notNull(), + dispatchedAt: timestamp("dispatched_at", { withTimezone: true }), + startedAt: timestamp("started_at", { withTimezone: true }), + completedAt: timestamp("completed_at", { withTimezone: true }), + expiresAt: timestamp("expires_at", { withTimezone: true }), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + }, + (table) => [ + index("idx_jobs_org").on(table.organizationId), + index("idx_jobs_status").on(table.status), + index("idx_jobs_type").on(table.jobType), + ] +); + +export const jobTargets = pgTable( + "job_targets", + { + id: uuid("id").primaryKey().defaultRandom(), + jobId: uuid("job_id") + .notNull() + .references(() => jobs.id, { onDelete: "cascade" }), + deviceId: uuid("device_id") + .notNull() + .references(() => devices.id, { onDelete: "cascade" }), + status: varchar("status", { length: 50 }).notNull().default("queued"), + result: jsonb("result").$type>(), + startedAt: timestamp("started_at", { withTimezone: true }), + completedAt: timestamp("completed_at", { withTimezone: true }), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + }, + (table) => [ + index("idx_job_targets_job").on(table.jobId), + index("idx_job_targets_device").on(table.deviceId), + index("idx_job_targets_status").on(table.status), + ] +); + +export const remoteSessions = pgTable( + "remote_sessions", + { + id: uuid("id").primaryKey().defaultRandom(), + organizationId: uuid("organization_id") + .notNull() + .references(() => organizations.id), + deviceId: uuid("device_id") + .notNull() + .references(() => devices.id, { onDelete: "cascade" }), + createdByUserId: uuid("created_by_user_id") + .notNull() + .references(() => users.id), + jobId: uuid("job_id").references(() => jobs.id), + status: varchar("status", { length: 50 }).notNull().default("pending"), + vncPort: integer("vnc_port").notNull().default(5900), + vncPassword: varchar("vnc_password", { length: 64 }), + errorMessage: text("error_message"), + startedAt: timestamp("started_at", { withTimezone: true }), + endedAt: timestamp("ended_at", { withTimezone: true }), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }), + }, + (table) => [ + index("idx_remote_sessions_org").on(table.organizationId), + index("idx_remote_sessions_device").on(table.deviceId), + index("idx_remote_sessions_status").on(table.status), + ] +); + +export const alerts = pgTable( + "alerts", + { + id: uuid("id").primaryKey().defaultRandom(), + organizationId: uuid("organization_id") + .notNull() + .references(() => organizations.id), + deviceId: uuid("device_id").references(() => devices.id, { + onDelete: "set null", + }), + alertType: varchar("alert_type", { length: 100 }).notNull(), + severity: varchar("severity", { length: 20 }).notNull(), + title: varchar("title", { length: 255 }).notNull(), + message: text("message").notNull(), + isResolved: boolean("is_resolved").notNull().default(false), + resolvedAt: timestamp("resolved_at", { withTimezone: true }), + resolvedByUserId: uuid("resolved_by_user_id"), + metadata: jsonb("metadata").$type>().default({}).notNull(), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + }, + (table) => [ + index("idx_alerts_org").on(table.organizationId), + index("idx_alerts_device").on(table.deviceId), + index("idx_alerts_resolved").on(table.isResolved), + ] +); + +export const auditEvents = pgTable( + "audit_events", + { + id: uuid("id").primaryKey().defaultRandom(), + organizationId: uuid("organization_id") + .notNull() + .references(() => organizations.id), + userId: uuid("user_id").references(() => users.id), + action: varchar("action", { length: 255 }).notNull(), + targetType: varchar("target_type", { length: 100 }).notNull(), + targetId: uuid("target_id"), + deviceIds: jsonb("device_ids").$type().default([]).notNull(), + result: varchar("result", { length: 50 }).notNull(), + ipAddress: varchar("ip_address", { length: 45 }), + metadata: jsonb("metadata").$type>().default({}).notNull(), + correlationId: varchar("correlation_id", { length: 255 }).notNull(), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + }, + (table) => [ + index("idx_audit_org").on(table.organizationId), + index("idx_audit_user").on(table.userId), + index("idx_audit_action").on(table.action), + index("idx_audit_created_at").on(table.createdAt), + index("idx_audit_correlation").on(table.correlationId), + ] +); + +export const apiTokens = pgTable( + "api_tokens", + { + id: uuid("id").primaryKey().defaultRandom(), + organizationId: uuid("organization_id") + .notNull() + .references(() => organizations.id), + userId: uuid("user_id") + .notNull() + .references(() => users.id), + name: varchar("name", { length: 255 }).notNull(), + tokenHash: varchar("token_hash", { length: 255 }).notNull().unique(), + expiresAt: timestamp("expires_at", { withTimezone: true }), + lastUsedAt: timestamp("last_used_at", { withTimezone: true }), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + }, + (table) => [ + index("idx_api_tokens_org").on(table.organizationId), + index("idx_api_tokens_hash").on(table.tokenHash), + ] +); + +export const updateRequests = pgTable( + "update_requests", + { + id: uuid("id").primaryKey().defaultRandom(), + organizationId: uuid("organization_id") + .notNull() + .references(() => organizations.id), + deviceId: uuid("device_id") + .notNull() + .references(() => devices.id, { onDelete: "cascade" }), + packageName: varchar("package_name", { length: 255 }).notNull(), + packageManager: varchar("package_manager", { length: 50 }).notNull().default("apt"), + currentVersion: varchar("current_version", { length: 100 }), + targetVersion: varchar("target_version", { length: 100 }).notNull(), + requestedByUserId: uuid("requested_by_user_id") + .notNull() + .references(() => users.id), + status: varchar("status", { length: 50 }).notNull().default("pending"), + approvedByUserId: uuid("approved_by_user_id").references(() => users.id), + approvedAt: timestamp("approved_at", { withTimezone: true }), + rejectionReason: text("rejection_reason"), + deploymentId: uuid("deployment_id"), + notes: text("notes"), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }) + .notNull() + .defaultNow() + .$onUpdateFn(() => new Date()), + }, + (table) => [ + index("idx_update_requests_org").on(table.organizationId), + index("idx_update_requests_device").on(table.deviceId), + index("idx_update_requests_status").on(table.status), + ] +); + +export const deployments = pgTable( + "deployments", + { + id: uuid("id").primaryKey().defaultRandom(), + organizationId: uuid("organization_id") + .notNull() + .references(() => organizations.id), + name: varchar("name", { length: 255 }).notNull(), + description: text("description"), + jobType: varchar("job_type", { length: 100 }).notNull(), + parameters: jsonb("parameters").$type>().default({}).notNull(), + status: varchar("status", { length: 50 }).notNull().default("draft"), + createdByUserId: uuid("created_by_user_id") + .notNull() + .references(() => users.id), + approvedByUserId: uuid("approved_by_user_id").references(() => users.id), + approvedAt: timestamp("approved_at", { withTimezone: true }), + scheduledAt: timestamp("scheduled_at", { withTimezone: true }), + startedAt: timestamp("started_at", { withTimezone: true }), + completedAt: timestamp("completed_at", { withTimezone: true }), + totalTargets: integer("total_targets").notNull().default(0), + completedTargets: integer("completed_targets").notNull().default(0), + failedTargets: integer("failed_targets").notNull().default(0), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }) + .notNull() + .defaultNow() + .$onUpdateFn(() => new Date()), + }, + (table) => [ + index("idx_deployments_org").on(table.organizationId), + index("idx_deployments_status").on(table.status), + ] +); + +export const softwarePackages = pgTable( + "software_packages", + { + id: uuid("id").primaryKey().defaultRandom(), + organizationId: uuid("organization_id") + .notNull() + .references(() => organizations.id), + name: varchar("name", { length: 255 }).notNull(), + description: text("description"), + version: varchar("version", { length: 100 }).notNull(), + packageType: varchar("package_type", { length: 50 }).notNull(), + platform: varchar("platform", { length: 50 }).notNull(), + architecture: varchar("architecture", { length: 50 }).notNull(), + filename: varchar("filename", { length: 500 }).notNull(), + fileSize: bigint("file_size", { mode: "number" }).notNull(), + checksum: varchar("checksum", { length: 255 }), + installCommand: text("install_command"), + tags: jsonb("tags").$type().default([]).notNull(), + metadata: jsonb("metadata").$type>().default({}).notNull(), + uploadedByUserId: uuid("uploaded_by_user_id") + .notNull() + .references(() => users.id), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }) + .notNull() + .defaultNow() + .$onUpdateFn(() => new Date()), + }, + (table) => [ + index("idx_software_packages_org").on(table.organizationId), + index("idx_software_packages_platform").on(table.platform), + index("idx_software_packages_name").on(table.name), + ] +); + +export const deploymentTargets = pgTable( + "deployment_targets", + { + id: uuid("id").primaryKey().defaultRandom(), + deploymentId: uuid("deployment_id") + .notNull() + .references(() => deployments.id, { onDelete: "cascade" }), + deviceId: uuid("device_id") + .notNull() + .references(() => devices.id, { onDelete: "cascade" }), + jobId: uuid("job_id").references(() => jobs.id), + status: varchar("status", { length: 50 }).notNull().default("pending"), + result: jsonb("result").$type>(), + startedAt: timestamp("started_at", { withTimezone: true }), + completedAt: timestamp("completed_at", { withTimezone: true }), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + }, + (table) => [ + index("idx_deployment_targets_deployment").on(table.deploymentId), + index("idx_deployment_targets_device").on(table.deviceId), + index("idx_deployment_targets_status").on(table.status), + ] +); diff --git a/apps/api/src/db/seed.ts b/apps/api/src/db/seed.ts new file mode 100644 index 0000000..a601c95 --- /dev/null +++ b/apps/api/src/db/seed.ts @@ -0,0 +1,67 @@ +import dotenv from "dotenv"; +dotenv.config(); + +import bcrypt from "bcrypt"; +import { db } from "./index.js"; +import { organizations, users } from "./schema.js"; +import { eq } from "drizzle-orm"; + +async function seed() { + console.log("Seeding database..."); + + const [org] = await db + .insert(organizations) + .values({ + name: "Default Organization", + slug: "default", + }) + .onConflictDoNothing() + .returning(); + + let organizationId: string; + + if (org) { + organizationId = org.id; + console.log(`Created organization: ${org.name} (${org.id})`); + } else { + const [existing] = await db + .select() + .from(organizations) + .where(eq(organizations.slug, "default")) + .limit(1); + organizationId = existing!.id; + console.log(`Organization already exists: ${existing!.name}`); + } + + const [existingUser] = await db + .select() + .from(users) + .where(eq(users.email, "admin@fleetforge.local")) + .limit(1); + + if (!existingUser) { + const passwordHash = await bcrypt.hash("admin123", 12); + const [user] = await db + .insert(users) + .values({ + organizationId, + email: "admin@fleetforge.local", + passwordHash, + displayName: "Fleet Admin", + role: "owner", + }) + .returning(); + + console.log(`Created admin user: ${user.email}`); + console.log("Login credentials: admin@fleetforge.local / admin123"); + } else { + console.log("Admin user already exists"); + } + + console.log("Seed complete."); +} + +seed().catch((err) => { + console.error("Seed failed:", err); + process.exit(1); +}); diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts new file mode 100644 index 0000000..d2d27a7 --- /dev/null +++ b/apps/api/src/index.ts @@ -0,0 +1,112 @@ +import dotenv from "dotenv"; +dotenv.config(); + +import express from "express"; +import cors from "cors"; +import helmet from "helmet"; +import fs from "fs"; +import path from "path"; +import { fileURLToPath } from "url"; + +process.on("unhandledRejection", (err) => { + console.error("[process] Unhandled rejection:", err); +}); +process.on("uncaughtException", (err) => { + console.error("[process] Uncaught exception:", err); +}); +import { corsConfig } from "./middleware/cors.js"; +import { errorHandler, notFoundHandler } from "./middleware/errors.js"; +import { correlationMiddleware } from "./middleware/correlation.js"; +import { rateLimitAuth, rateLimitApi, rateLimitEnroll } from "./middleware/rateLimit.js"; +import { authRouter } from "./routes/auth.js"; +import { devicesRouter } from "./routes/devices.js"; +import { enrollmentTokensRouter } from "./routes/enrollment-tokens.js"; +import { groupsRouter } from "./routes/groups.js"; +import { jobsRouter } from "./routes/jobs.js"; +import { agentRouter } from "./routes/agent.js"; +import { auditRouter } from "./routes/audit.js"; +import { alertsRouter } from "./routes/alerts.js"; +import { policiesRouter } from "./routes/policies.js"; +import { dashboardRouter } from "./routes/dashboard.js"; +import { usersRouter } from "./routes/users.js"; +import { complianceRouter } from "./routes/compliance.js"; +import { updateRequestsRouter } from "./routes/update-requests.js"; +import { deploymentsRouter } from "./routes/deployments.js"; +import { softwareRouter } from "./routes/software.js"; +import { appCatalogRouter } from "./routes/appcatalog.js"; +import { downloadsRouter } from "./routes/downloads.js"; +import { installersRouter } from "./routes/installers.js"; +import { remoteRouter } from "./routes/remote.js"; +import { attachRelay } from "./ws/relay.js"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +const app = express(); +const port = parseInt(process.env.APP_PORT || "3000", 10); + +app.use(helmet({ + contentSecurityPolicy: false, + crossOriginEmbedderPolicy: false, +})); +app.use(cors(corsConfig)); +app.use(express.json({ limit: "1mb" })); +app.use(correlationMiddleware); + +app.get("/api/health", (_req, res) => { + res.json({ status: "ok", version: "0.1.0" }); +}); + +const openapiPath = path.resolve(__dirname, "../../../docs/openapi.json"); +if (fs.existsSync(openapiPath)) { + const spec = JSON.parse(fs.readFileSync(openapiPath, "utf-8")); + app.get("/api/docs", (_req, res) => { + res.send(` +Fleet Forge API Docs + + +
+ +`); + }); + app.get("/api/openapi.json", (_req, res) => res.json(spec)); +} + +app.use("/api/v1/auth/login", rateLimitAuth); +app.use("/api/v1/agent/enroll", rateLimitEnroll); +app.use("/api/v1", rateLimitApi); + +app.use("/api/v1/auth", authRouter); +app.use("/api/v1/users", usersRouter); +app.use("/api/v1/devices", devicesRouter); +app.use("/api/v1/enrollment-tokens", enrollmentTokensRouter); +app.use("/api/v1/groups", groupsRouter); +app.use("/api/v1/jobs", jobsRouter); +app.use("/api/v1/policies", policiesRouter); +app.use("/api/v1/alerts", alertsRouter); +app.use("/api/v1/audit-events", auditRouter); +app.use("/api/v1/dashboard", dashboardRouter); +app.use("/api/v1/compliance", complianceRouter); +app.use("/api/v1/update-requests", updateRequestsRouter); +app.use("/api/v1/deployments", deploymentsRouter); +app.use("/api/v1/software", softwareRouter); +app.use("/api/v1/appcatalog", appCatalogRouter); +app.use("/api/v1/downloads", downloadsRouter); +app.use("/api/v1/installers", installersRouter); +app.use("/api/v1/agent", agentRouter); +app.use("/api/v1/remote", remoteRouter); + +app.use(notFoundHandler); +app.use(errorHandler); + +if (process.env.NODE_ENV !== "test") { + const server = app.listen(port, "0.0.0.0", () => { + console.log(`Fleet Forge API running on port ${port}`); + }); + attachRelay(server); +} + +export default app; diff --git a/apps/api/src/lib/audit.ts b/apps/api/src/lib/audit.ts new file mode 100644 index 0000000..eaec086 --- /dev/null +++ b/apps/api/src/lib/audit.ts @@ -0,0 +1,38 @@ +import type { Request } from "express"; +import { db } from "../db/index.js"; +import { auditEvents } from "../db/schema.js"; +import type { AuthenticatedUser } from "@fleetforge/shared"; + +export async function auditLog(params: { + req?: Request; + userId?: string; + organizationId: string; + action: string; + targetType: string; + targetId?: string; + deviceIds?: string[]; + result: string; + metadata?: Record; + correlationId?: string; +}) { + const correlationId = + params.correlationId || + (params.req ? ((params.req as any).correlationId as string) : "unknown"); + const ipAddress = params.req?.ip || params.req?.socket?.remoteAddress; + const user = params.req + ? ((params.req as any).user as AuthenticatedUser) + : undefined; + + await db.insert(auditEvents).values({ + organizationId: params.organizationId, + userId: params.userId || user?.userId || null, + action: params.action, + targetType: params.targetType, + targetId: params.targetId || null, + deviceIds: params.deviceIds || [], + result: params.result, + ipAddress: ipAddress || null, + metadata: params.metadata || {}, + correlationId, + }); +} diff --git a/apps/api/src/lib/compliance.ts b/apps/api/src/lib/compliance.ts new file mode 100644 index 0000000..624a036 --- /dev/null +++ b/apps/api/src/lib/compliance.ts @@ -0,0 +1,492 @@ +import pg from "pg"; + +interface PolicyRule { + type: string; + condition: string; + value: string | number | boolean; +} + +interface DeviceRow { + id: string; + organization_id: string; + os_name: string; + os_version: string; + platform: string; + architecture: string; + agent_version: string; + cpu_cores: number | null; + total_memory_bytes: number | null; + total_disk_bytes: number | null; + status: string; + tags: string[]; + kernel_version: string | null; +} + +interface MetricRow { + cpu_usage_percent: number | null; + memory_usage_percent: number | null; + disk_usage_percent: number | null; + load_average_1m: number | null; + uptime_seconds: number | null; +} + +interface PolicyRow { + id: string; + organization_id: string; + name: string; + rules: PolicyRule[]; +} + +interface InventoryRow { + packages: { name: string; version: string }[]; + services: { name: string; status: string; enabled: boolean }[]; +} + +function compareVersions(a: string, b: string): number { + const pa = a.split(".").map(Number); + const pb = b.split(".").map(Number); + for (let i = 0; i < Math.max(pa.length, pb.length); i++) { + const na = pa[i] || 0; + const nb = pb[i] || 0; + if (na > nb) return 1; + if (na < nb) return -1; + } + return 0; +} + +function getDeviceField( + device: DeviceRow, + metrics: MetricRow | null, + inventory: InventoryRow | null, + fieldType: string +): unknown { + switch (fieldType) { + case "os_version": + return device.os_version; + case "os_name": + return device.os_name; + case "platform": + return device.platform; + case "architecture": + return device.architecture; + case "agent_version": + return device.agent_version; + case "cpu_cores": + return device.cpu_cores; + case "memory_total": + return device.total_memory_bytes; + case "disk_total": + return device.total_disk_bytes; + case "status": + return device.status; + case "tags": + return device.tags || []; + case "kernel_version": + return device.kernel_version; + case "cpu_usage": + return metrics?.cpu_usage_percent; + case "memory_usage": + return metrics?.memory_usage_percent; + case "disk_usage": + return metrics?.disk_usage_percent; + case "load_average": + return metrics?.load_average_1m; + case "uptime": + return metrics?.uptime_seconds; + case "package_count": + return inventory?.packages?.length ?? 0; + case "has_package": + return inventory?.packages?.map((p) => p.name) ?? []; + case "service_count": + return inventory?.services?.length ?? 0; + case "service_status": + return inventory?.services?.map((s) => ({ + name: s.name, + status: s.status, + enabled: s.enabled, + })) ?? []; + default: + return undefined; + } +} + +function evaluateRule( + actual: unknown, + condition: string, + expected: string | number | boolean +): boolean { + if (actual === undefined || actual === null) { + return condition === "not_equals" && expected !== null; + } + + switch (condition) { + case "equals": + return String(actual) === String(expected); + case "not_equals": + return String(actual) !== String(expected); + case "greater_than": + return Number(actual) > Number(expected); + case "less_than": + return Number(actual) < Number(expected); + case "greater_than_or_equal": + return Number(actual) >= Number(expected); + case "less_than_or_equal": + return Number(actual) <= Number(expected); + case "contains": + if (Array.isArray(actual)) { + return actual.includes(String(expected)); + } + return String(actual).includes(String(expected)); + case "not_contains": + if (Array.isArray(actual)) { + return !actual.includes(String(expected)); + } + return !String(actual).includes(String(expected)); + case "in": + if (Array.isArray(expected)) { + return expected.map(String).includes(String(actual)); + } + return String(actual) === String(expected); + case "not_in": + if (Array.isArray(expected)) { + return !expected.map(String).includes(String(actual)); + } + return String(actual) !== String(expected); + case "version_gte": + return compareVersions(String(actual), String(expected)) >= 0; + case "version_lte": + return compareVersions(String(actual), String(expected)) <= 0; + case "version_gt": + return compareVersions(String(actual), String(expected)) > 0; + case "version_lt": + return compareVersions(String(actual), String(expected)) < 0; + case "is_true": + return actual === true || actual === "true"; + case "is_false": + return actual === false || actual === "false"; + case "is_empty": + if (Array.isArray(actual)) return actual.length === 0; + if (typeof actual === "string") return actual.length === 0; + return actual === null || actual === undefined; + case "is_not_empty": + if (Array.isArray(actual)) return actual.length > 0; + if (typeof actual === "string") return actual.length > 0; + return actual !== null && actual !== undefined; + default: + return false; + } +} + +export async function evaluatePolicies( + pool: pg.Pool, + organizationId: string +): Promise<{ evaluated: number; compliant: number; nonCompliant: number; unknown: number }> { + const client = await pool.connect(); + + let evaluated = 0; + let compliant = 0; + let nonCompliant = 0; + let unknown = 0; + + try { + const { rows: policies } = await client.query( + `SELECT id, organization_id, name, rules + FROM policies + WHERE organization_id = $1 AND is_enabled = true`, + [organizationId] + ); + + if (policies.length === 0) { + return { evaluated: 0, compliant: 0, nonCompliant: 0, unknown: 0 }; + } + + const { rows: devices } = await client.query( + `SELECT id, organization_id, os_name, os_version, platform, architecture, + agent_version, cpu_cores, total_memory_bytes, total_disk_bytes, + status, tags, kernel_version + FROM devices + WHERE organization_id = $1 + AND is_disabled = false + AND is_archived = false`, + [organizationId] + ); + + if (devices.length === 0) { + return { evaluated: 0, compliant: 0, nonCompliant: 0, unknown: 0 }; + } + + const deviceIds = devices.map((d) => d.id); + const metricsMap = new Map(); + const inventoryMap = new Map(); + + if (deviceIds.length > 0) { + const { rows: metricsRows } = await client.query( + `SELECT DISTINCT ON (device_id) + device_id, cpu_usage_percent, memory_usage_percent, + disk_usage_percent, load_average_1m, uptime_seconds + FROM metric_snapshots + WHERE device_id = ANY($1) + ORDER BY device_id, reported_at DESC`, + [deviceIds] + ); + + for (const row of metricsRows) { + metricsMap.set(row.device_id, row); + } + + const { rows: inventoryRows } = await client.query( + `SELECT DISTINCT ON (device_id) + device_id, packages, services + FROM inventory_snapshots + WHERE device_id = ANY($1) + ORDER BY device_id, reported_at DESC`, + [deviceIds] + ); + + for (const row of inventoryRows) { + inventoryMap.set(row.device_id, row); + } + } + + const now = new Date(); + const upserts: Array<{ + deviceId: string; + policyId: string; + status: string; + details: Record; + }> = []; + + for (const device of devices) { + const metrics = metricsMap.get(device.id) || null; + const inventory = inventoryMap.get(device.id) || null; + + for (const policy of policies) { + const rules = (policy.rules || []) as PolicyRule[]; + + if (rules.length === 0) { + upserts.push({ + deviceId: device.id, + policyId: policy.id, + status: "compliant", + details: { message: "No rules defined, defaulting to compliant" }, + }); + compliant++; + evaluated++; + continue; + } + + let allPassed = true; + let hasUnknown = false; + const ruleResults: Record = {}; + + for (const rule of rules) { + const actual = getDeviceField(device, metrics, inventory, rule.type); + + if (actual === undefined) { + ruleResults[rule.type] = { + condition: rule.condition, + expected: rule.value, + actual: null, + passed: false, + reason: "field_not_found", + }; + hasUnknown = true; + allPassed = false; + continue; + } + + const passed = evaluateRule(actual, rule.condition, rule.value); + ruleResults[rule.type] = { + condition: rule.condition, + expected: rule.value, + actual, + passed, + }; + + if (!passed) { + allPassed = false; + } + } + + let status: string; + if (hasUnknown && !allPassed) { + status = "unknown"; + unknown++; + } else if (allPassed) { + status = "compliant"; + compliant++; + } else { + status = "non_compliant"; + nonCompliant++; + } + + upserts.push({ + deviceId: device.id, + policyId: policy.id, + status, + details: ruleResults, + }); + + evaluated++; + } + } + + for (const u of upserts) { + await client.query( + `INSERT INTO compliance_results (device_id, policy_id, status, details, evaluated_at) + VALUES ($1, $2, $3, $4, $5) + ON CONFLICT (device_id, policy_id) DO UPDATE SET + status = EXCLUDED.status, + details = EXCLUDED.details, + evaluated_at = EXCLUDED.evaluated_at`, + [u.deviceId, u.policyId, u.status, JSON.stringify(u.details), now] + ); + } + + return { evaluated, compliant, nonCompliant, unknown }; + } finally { + client.release(); + } +} + +export async function evaluatePoliciesForDevice( + pool: pg.Pool, + deviceId: string, + organizationId: string +): Promise<{ evaluated: number; compliant: number; nonCompliant: number; unknown: number }> { + const client = await pool.connect(); + + let evaluated = 0; + let compliant = 0; + let nonCompliant = 0; + let unknown = 0; + + try { + const { rows: devices } = await client.query( + `SELECT id, organization_id, os_name, os_version, platform, architecture, + agent_version, cpu_cores, total_memory_bytes, total_disk_bytes, + status, tags, kernel_version + FROM devices + WHERE id = $1 AND organization_id = $2`, + [deviceId, organizationId] + ); + + if (devices.length === 0) { + return { evaluated: 0, compliant: 0, nonCompliant: 0, unknown: 0 }; + } + + const device = devices[0]; + + const { rows: policies } = await client.query( + `SELECT id, organization_id, name, rules + FROM policies + WHERE organization_id = $1 AND is_enabled = true`, + [organizationId] + ); + + if (policies.length === 0) { + return { evaluated: 0, compliant: 0, nonCompliant: 0, unknown: 0 }; + } + + const { rows: metricsRows } = await client.query( + `SELECT cpu_usage_percent, memory_usage_percent, + disk_usage_percent, load_average_1m, uptime_seconds + FROM metric_snapshots + WHERE device_id = $1 + ORDER BY reported_at DESC + LIMIT 1`, + [deviceId] + ); + + const metrics: MetricRow | null = metricsRows[0] || null; + + const { rows: inventoryRows } = await client.query( + `SELECT packages, services + FROM inventory_snapshots + WHERE device_id = $1 + ORDER BY reported_at DESC + LIMIT 1`, + [deviceId] + ); + + const inventory: InventoryRow | null = inventoryRows[0] || null; + + const now = new Date(); + + for (const policy of policies) { + const rules = (policy.rules || []) as PolicyRule[]; + + if (rules.length === 0) { + await client.query( + `INSERT INTO compliance_results (device_id, policy_id, status, details, evaluated_at) + VALUES ($1, $2, $3, $4, $5) + ON CONFLICT (device_id, policy_id) DO UPDATE SET + status = EXCLUDED.status, details = EXCLUDED.details, evaluated_at = EXCLUDED.evaluated_at`, + [deviceId, policy.id, "compliant", JSON.stringify({ message: "No rules defined" }), now] + ); + compliant++; + evaluated++; + continue; + } + + let allPassed = true; + let hasUnknown = false; + const ruleResults: Record = {}; + + for (const rule of rules) { + const actual = getDeviceField(device, metrics, inventory, rule.type); + + if (actual === undefined) { + ruleResults[rule.type] = { + condition: rule.condition, + expected: rule.value, + actual: null, + passed: false, + reason: "field_not_found", + }; + hasUnknown = true; + allPassed = false; + continue; + } + + const passed = evaluateRule(actual, rule.condition, rule.value); + ruleResults[rule.type] = { + condition: rule.condition, + expected: rule.value, + actual, + passed, + }; + + if (!passed) { + allPassed = false; + } + } + + let status: string; + if (hasUnknown && !allPassed) { + status = "unknown"; + unknown++; + } else if (allPassed) { + status = "compliant"; + compliant++; + } else { + status = "non_compliant"; + nonCompliant++; + } + + await client.query( + `INSERT INTO compliance_results (device_id, policy_id, status, details, evaluated_at) + VALUES ($1, $2, $3, $4, $5) + ON CONFLICT (device_id, policy_id) DO UPDATE SET + status = EXCLUDED.status, details = EXCLUDED.details, evaluated_at = EXCLUDED.evaluated_at`, + [deviceId, policy.id, status, JSON.stringify(ruleResults), now] + ); + + evaluated++; + } + + return { evaluated, compliant, nonCompliant, unknown }; + } finally { + client.release(); + } +} diff --git a/apps/api/src/lib/logger.ts b/apps/api/src/lib/logger.ts new file mode 100644 index 0000000..b604340 --- /dev/null +++ b/apps/api/src/lib/logger.ts @@ -0,0 +1,68 @@ +type LogLevel = "debug" | "info" | "warn" | "error"; + +interface LogEntry { + timestamp: string; + level: LogLevel; + message: string; + correlationId?: string; + context?: Record; + error?: { + name: string; + message: string; + stack?: string; + }; +} + +function formatEntry(entry: LogEntry): string { + return JSON.stringify(entry); +} + +function createLogger(defaultContext?: Record) { + const log = (level: LogLevel, message: string, context?: Record, correlationId?: string, error?: Error) => { + const entry: LogEntry = { + timestamp: new Date().toISOString(), + level, + message, + ...((defaultContext || context) && { context: { ...defaultContext, ...context } }), + }; + + if (correlationId) entry.correlationId = correlationId; + if (error) { + entry.error = { + name: error.name, + message: error.message, + stack: error.stack, + }; + } + + const line = formatEntry(entry); + + switch (level) { + case "error": + console.error(line); + break; + case "warn": + console.warn(line); + break; + default: + console.log(line); + } + }; + + return { + debug: (message: string, context?: Record, correlationId?: string) => + log("debug", message, context, correlationId), + info: (message: string, context?: Record, correlationId?: string) => + log("info", message, context, correlationId), + warn: (message: string, context?: Record, correlationId?: string) => + log("warn", message, context, correlationId), + error: (message: string, context?: Record, correlationId?: string, error?: Error) => + log("error", message, context, correlationId, error), + child: (childContext: Record) => + createLogger({ ...defaultContext, ...childContext }), + }; +} + +export const logger = createLogger({ service: "fleetforge-api" }); +export const workerLogger = createLogger({ service: "fleetforge-worker" }); +export { createLogger }; diff --git a/apps/api/src/lib/tokens.ts b/apps/api/src/lib/tokens.ts new file mode 100644 index 0000000..de4079f --- /dev/null +++ b/apps/api/src/lib/tokens.ts @@ -0,0 +1,17 @@ +import crypto from "crypto"; + +export function hashToken(token: string): string { + return crypto.createHash("sha256").update(token).digest("hex"); +} + +export function generateEnrollmentToken(): string { + return `ff-${crypto.randomBytes(32).toString("hex")}`; +} + +export function generateDeviceCredential(): string { + return `fc-${crypto.randomBytes(32).toString("hex")}`; +} + +export function hashCredential(credential: string): string { + return crypto.createHash("sha256").update(credential).digest("hex"); +} diff --git a/apps/api/src/middleware/auth.ts b/apps/api/src/middleware/auth.ts new file mode 100644 index 0000000..40ba6e9 --- /dev/null +++ b/apps/api/src/middleware/auth.ts @@ -0,0 +1,80 @@ +import type { Request, Response, NextFunction } from "express"; +import jwt from "jsonwebtoken"; +import type { AuthenticatedUser, Role } from "@fleetforge/shared"; + +const JWT_SECRET = process.env.JWT_SECRET || "dev-secret"; + +const ROLE_HIERARCHY: Record = { + owner: 4, + admin: 3, + operator: 2, + read_only: 1, +}; + +export function generateToken(user: AuthenticatedUser): string { + return jwt.sign( + { + userId: user.userId, + organizationId: user.organizationId, + role: user.role, + email: user.email, + }, + JWT_SECRET, + { expiresIn: (process.env.JWT_EXPIRES_IN || "24h") as any } + ); +} + +export function verifyToken(token: string): AuthenticatedUser { + return jwt.verify(token, JWT_SECRET) as AuthenticatedUser; +} + +export function authenticate(req: Request, res: Response, next: NextFunction) { + const authHeader = req.headers.authorization; + if (!authHeader?.startsWith("Bearer ")) { + res.status(401).json({ + success: false, + error: { code: "UNAUTHORIZED", message: "Missing or invalid token" }, + }); + return; + } + + try { + const token = authHeader.substring(7); + const user = verifyToken(token); + (req as any).user = user; + next(); + } catch { + res.status(401).json({ + success: false, + error: { code: "UNAUTHORIZED", message: "Invalid or expired token" }, + }); + } +} + +export function authorize(...allowedRoles: Role[]) { + return (req: Request, res: Response, next: NextFunction) => { + const user = (req as any).user as AuthenticatedUser; + if (!user) { + res.status(401).json({ + success: false, + error: { code: "UNAUTHORIZED", message: "Not authenticated" }, + }); + return; + } + + if (allowedRoles.length > 0) { + const hasRole = allowedRoles.some( + (role) => ROLE_HIERARCHY[user.role] >= ROLE_HIERARCHY[role] + ); + if (!hasRole) { + res.status(403).json({ + success: false, + error: { code: "FORBIDDEN", message: "Insufficient permissions" }, + }); + return; + } + } + + next(); + }; +} diff --git a/apps/api/src/middleware/correlation.ts b/apps/api/src/middleware/correlation.ts new file mode 100644 index 0000000..bc5a643 --- /dev/null +++ b/apps/api/src/middleware/correlation.ts @@ -0,0 +1,13 @@ +import type { Request, Response, NextFunction } from "express"; +import { v4 as uuidv4 } from "uuid"; + +export function correlationMiddleware( + req: Request, + _res: Response, + next: NextFunction +) { + const correlationId = + (req.headers["x-correlation-id"] as string) || uuidv4(); + (req as any).correlationId = correlationId; + next(); +} diff --git a/apps/api/src/middleware/cors.ts b/apps/api/src/middleware/cors.ts new file mode 100644 index 0000000..63d6489 --- /dev/null +++ b/apps/api/src/middleware/cors.ts @@ -0,0 +1,21 @@ +export const corsConfig = { + origin: process.env.CORS_ORIGINS + ? process.env.CORS_ORIGINS.split(",") + : [ + "http://localhost:3000", + "http://localhost:3001", + "http://localhost:3002", + "http://localhost:3003", + "http://localhost:3004", + "http://localhost:3005", + "http://10.0.0.194:3000", + "http://10.0.0.194:3001", + "http://10.0.0.194:3002", + "http://10.0.0.194:3003", + "http://10.0.0.194:3004", + "http://10.0.0.194:3005", + ], + credentials: true, + methods: ["GET", "POST", "PUT", "PATCH", "DELETE"], + allowedHeaders: ["Content-Type", "Authorization", "X-Correlation-ID"], +}; diff --git a/apps/api/src/middleware/errors.ts b/apps/api/src/middleware/errors.ts new file mode 100644 index 0000000..0a4bca2 --- /dev/null +++ b/apps/api/src/middleware/errors.ts @@ -0,0 +1,21 @@ +import type { Request, Response, NextFunction } from "express"; + +export function errorHandler( + err: Error, + _req: Request, + res: Response, + _next: NextFunction +) { + console.error("Unhandled error:", err); + res.status(500).json({ + success: false, + error: { code: "INTERNAL_ERROR", message: "An internal error occurred" }, + }); +} + +export function notFoundHandler(_req: Request, res: Response) { + res.status(404).json({ + success: false, + error: { code: "NOT_FOUND", message: "Endpoint not found" }, + }); +} diff --git a/apps/api/src/middleware/rateLimit.ts b/apps/api/src/middleware/rateLimit.ts new file mode 100644 index 0000000..6a7287d --- /dev/null +++ b/apps/api/src/middleware/rateLimit.ts @@ -0,0 +1,57 @@ +import { RateLimiterMemory } from "rate-limiter-flexible"; + +const authLimiter = new RateLimiterMemory({ + points: 10, + duration: 60, +}); + +const apiLimiter = new RateLimiterMemory({ + points: 100, + duration: 60, +}); + +const enrollLimiter = new RateLimiterMemory({ + points: 5, + duration: 300, +}); + +export async function rateLimitAuth(req: any, res: any, next: any) { + try { + const ip = req.ip || req.socket?.remoteAddress || "unknown"; + await authLimiter.consume(`auth:${ip}`); + next(); + } catch { + res.status(429).json({ + success: false, + error: { code: "RATE_LIMITED", message: "Too many authentication attempts. Please try again later." }, + }); + } +} + +export async function rateLimitApi(req: any, res: any, next: any) { + try { + const ip = req.ip || req.socket?.remoteAddress || "unknown"; + const user = (req as any).user; + const key = user ? `api:${user.userId}` : `api:${ip}`; + await apiLimiter.consume(key); + next(); + } catch { + res.status(429).json({ + success: false, + error: { code: "RATE_LIMITED", message: "Rate limit exceeded. Please slow down." }, + }); + } +} + +export async function rateLimitEnroll(req: any, res: any, next: any) { + try { + const ip = req.ip || req.socket?.remoteAddress || "unknown"; + await enrollLimiter.consume(`enroll:${ip}`); + next(); + } catch { + res.status(429).json({ + success: false, + error: { code: "RATE_LIMITED", message: "Too many enrollment attempts." }, + }); + } +} diff --git a/apps/api/src/routes/agent.ts b/apps/api/src/routes/agent.ts new file mode 100644 index 0000000..6212c15 --- /dev/null +++ b/apps/api/src/routes/agent.ts @@ -0,0 +1,612 @@ +import { Router } from "express"; +import { z } from "zod"; + +import { db } from "../db/index.js"; +import { + enrollmentTokens, + devices, + deviceCredentials, + inventorySnapshots, + metricSnapshots, + jobTargets, + jobs, + softwarePackages, +} from "../db/schema.js"; +import { + hashToken, + generateDeviceCredential, + hashCredential, +} from "../lib/tokens.js"; +import { auditLog } from "../lib/audit.js"; +import { eq, and, gt, inArray, notInArray } from "drizzle-orm"; +import path from "path"; +import fs from "fs"; +import { fileURLToPath } from "url"; + +declare global { + namespace Express { + interface Request { + device?: any; + deviceCredential?: any; + } + } +} + +export const agentRouter = Router(); + +const agentAuthMiddleware = async (req: any, res: any, next: any) => { + const authHeader = req.headers.authorization; + if (!authHeader?.startsWith("Bearer ")) { + res.status(401).json({ + success: false, + error: { code: "UNAUTHORIZED", message: "Missing device credential" }, + }); + return; + } + + const credential = authHeader.substring(7); + const credHash = hashCredential(credential); + + const [deviceCred] = await db + .select() + .from(deviceCredentials) + .where( + and( + eq(deviceCredentials.credentialHash, credHash), + eq(deviceCredentials.credentialType, "device_key") + ) + ) + .limit(1); + + if (!deviceCred) { + res.status(401).json({ + success: false, + error: { code: "UNAUTHORIZED", message: "Invalid device credential" }, + }); + return; + } + + if (deviceCred.expiresAt && new Date(deviceCred.expiresAt) < new Date()) { + res.status(401).json({ + success: false, + error: { code: "CREDENTIAL_EXPIRED", message: "Device credential expired" }, + }); + return; + } + + const [device] = await db + .select() + .from(devices) + .where(eq(devices.id, deviceCred.deviceId)) + .limit(1); + + if (!device || device.isDisabled) { + res.status(403).json({ + success: false, + error: { code: "DEVICE_DISABLED", message: "Device is disabled or not found" }, + }); + return; + } + + req.device = device; + req.deviceCredential = deviceCred; + next(); +}; + +const enrollSchema = z.object({ + token: z.string().min(1), + hostname: z.string().min(1).max(255), + platform: z.string().min(1).max(50), + os_name: z.string().min(1).max(100), + os_version: z.string().max(100).default("unknown"), + architecture: z.string().min(1).max(50), + kernel_version: z.string().max(100).optional(), + cpu_model: z.string().max(255).optional(), + cpu_cores: z.number().int().positive().optional(), + total_memory_bytes: z.number().positive().optional(), + total_disk_bytes: z.number().positive().optional(), + ip_address: z.string().max(45).optional(), + agent_version: z.string().min(1).max(50), +}); + +agentRouter.post("/enroll", async (req, res) => { + try { + const body = enrollSchema.parse(req.body); + + const tokenHash = hashToken(body.token); + + const [enrollmentToken] = await db + .select() + .from(enrollmentTokens) + .where( + and( + eq(enrollmentTokens.tokenHash, tokenHash), + eq(enrollmentTokens.isRevoked, false), + gt(enrollmentTokens.expiresAt, new Date()) + ) + ) + .limit(1); + + if (!enrollmentToken) { + res.status(401).json({ + success: false, + error: { code: "INVALID_TOKEN", message: "Invalid, expired, or revoked enrollment token" }, + }); + return; + } + + if ( + enrollmentToken.maxUses && + enrollmentToken.useCount >= enrollmentToken.maxUses + ) { + res.status(401).json({ + success: false, + error: { code: "TOKEN_EXHAUSTED", message: "Enrollment token has been used up" }, + }); + return; + } + + let deviceType = "linux_server"; + if (body.platform === "windows") deviceType = "windows_server"; + if (body.platform === "darwin") deviceType = "macos"; + + const [device] = await db + .insert(devices) + .values({ + organizationId: enrollmentToken.organizationId, + name: body.hostname, + deviceType, + platform: body.platform, + osName: body.os_name, + osVersion: body.os_version, + architecture: body.architecture, + kernelVersion: body.kernel_version || null, + cpuModel: body.cpu_model || null, + cpuCores: body.cpu_cores || null, + totalMemoryBytes: body.total_memory_bytes || null, + totalDiskBytes: body.total_disk_bytes || null, + ipAddress: body.ip_address || null, + agentVersion: body.agent_version, + status: "online", + lastHeartbeatAt: new Date(), + }) + .returning(); + + const rawCredential = generateDeviceCredential(); + const credentialHash = hashCredential(rawCredential); + + await db.insert(deviceCredentials).values({ + deviceId: device.id, + credentialType: "device_key", + credentialHash, + expiresAt: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000), + }); + + await db + .update(enrollmentTokens) + .set({ useCount: enrollmentToken.useCount + 1 }) + .where(eq(enrollmentTokens.id, enrollmentToken.id)); + + await auditLog({ + organizationId: enrollmentToken.organizationId, + action: "device.enroll", + targetType: "device", + targetId: device.id, + deviceIds: [device.id], + result: "success", + metadata: { + hostname: body.hostname, + platform: body.platform, + os: body.os_name, + agentVersion: body.agent_version, + }, + correlationId: req.headers["x-correlation-id"] as string || "enrollment", + }); + + res.status(201).json({ + success: true, + data: { + device_id: device.id, + credential: rawCredential, + }, + }); + } catch (err) { + if (err instanceof z.ZodError) { + res.status(400).json({ + success: false, + error: { + code: "VALIDATION_ERROR", + message: "Invalid enrollment data", + details: err.errors, + }, + }); + return; + } + console.error("[enroll] Error:", err); + res.status(500).json({ + success: false, + error: { code: "INTERNAL_ERROR", message: "Enrollment failed" }, + }); + } +}); + +const heartbeatSchema = z.object({ + hostname: z.string().optional(), + os_version: z.string().optional(), + kernel_version: z.string().optional(), + agent_version: z.string().optional(), + ip_address: z.string().optional(), + metrics: z + .object({ + cpu_usage_percent: z.number().min(0).max(100).optional(), + memory_usage_percent: z.number().min(0).max(100).optional(), + memory_used_bytes: z.number().optional(), + memory_total_bytes: z.number().optional(), + disk_usage_percent: z.number().min(0).max(100).optional(), + disk_used_bytes: z.number().optional(), + disk_total_bytes: z.number().optional(), + load_average_1m: z.number().optional(), + load_average_5m: z.number().optional(), + load_average_15m: z.number().optional(), + uptime_seconds: z.number().optional(), + }) + .optional(), + inventory: z + .object({ + packages: z + .array( + z.object({ + name: z.string(), + version: z.string(), + manager: z.string(), + }) + ) + .optional(), + services: z + .array( + z.object({ + name: z.string(), + status: z.string(), + enabled: z.boolean(), + }) + ) + .optional(), + network_interfaces: z + .array( + z.object({ + name: z.string(), + addresses: z.array(z.string()), + mac_address: z.string().nullable().optional(), + }) + ) + .optional(), + }) + .optional(), +}); + +agentRouter.post("/heartbeat", agentAuthMiddleware, async (req, res) => { + try { + const body = heartbeatSchema.parse(req.body); + const device = req.device; + + const updateData: Record = { + status: "online", + lastHeartbeatAt: new Date(), + }; + if (body.hostname) updateData.name = body.hostname; + if (body.os_version) updateData.osVersion = body.os_version; + if (body.kernel_version) updateData.kernelVersion = body.kernel_version; + if (body.agent_version) updateData.agentVersion = body.agent_version; + if (body.ip_address) updateData.ipAddress = body.ip_address; + + await db.update(devices).set(updateData).where(eq(devices.id, device.id)); + + if (body.metrics) { + await db.insert(metricSnapshots).values({ + deviceId: device.id, + reportedAt: new Date(), + cpuUsagePercent: body.metrics.cpu_usage_percent ?? null, + memoryUsagePercent: body.metrics.memory_usage_percent ?? null, + memoryUsedBytes: body.metrics.memory_used_bytes ?? null, + memoryTotalBytes: body.metrics.memory_total_bytes ?? null, + diskUsagePercent: body.metrics.disk_usage_percent ?? null, + diskUsedBytes: body.metrics.disk_used_bytes ?? null, + diskTotalBytes: body.metrics.disk_total_bytes ?? null, + loadAverage1m: body.metrics.load_average_1m ?? null, + loadAverage5m: body.metrics.load_average_5m ?? null, + loadAverage15m: body.metrics.load_average_15m ?? null, + uptimeSeconds: body.metrics.uptime_seconds ?? null, + }); + } + + if (body.inventory) { + await db.insert(inventorySnapshots).values({ + deviceId: device.id, + reportedAt: new Date(), + packages: body.inventory.packages || [], + services: body.inventory.services || [], + networkInterfaces: body.inventory.network_interfaces || [], + platformData: {}, + }); + } + + res.json({ success: true }); + } catch (err) { + if (err instanceof z.ZodError) { + res.status(400).json({ + success: false, + error: { + code: "VALIDATION_ERROR", + message: "Invalid heartbeat data", + details: err.errors, + }, + }); + return; + } + console.error("[heartbeat] Error:", err); + res.status(500).json({ + success: false, + error: { code: "INTERNAL_ERROR", message: "Heartbeat processing failed" }, + }); + } +}); + +agentRouter.get("/jobs", agentAuthMiddleware, async (req, res) => { + const device = req.device; + + const pendingTargets = await db + .select({ + jobId: jobTargets.jobId, + targetId: jobTargets.id, + jobType: jobs.jobType, + parameters: jobs.parameters, + }) + .from(jobTargets) + .innerJoin(jobs, eq(jobTargets.jobId, jobs.id)) + .where( + and( + eq(jobTargets.deviceId, device.id), + inArray(jobTargets.status, ["queued", "dispatched"]), + eq(jobs.status, "dispatched") + ) + ) + .limit(10); + + res.json({ success: true, data: pendingTargets }); +}); + +const startJobSchema = z.object({ + target_id: z.string().uuid(), +}); + +agentRouter.post("/jobs/:jobId/start", agentAuthMiddleware, async (req, res) => { + try { + const body = startJobSchema.parse(req.body); + const device = req.device; + + const [target] = await db + .select() + .from(jobTargets) + .where( + and( + eq(jobTargets.id, body.target_id), + eq(jobTargets.deviceId, device.id), + eq(jobTargets.jobId, req.params.jobId as string) + ) + ) + .limit(1); + + if (!target) { + res.status(404).json({ + success: false, + error: { code: "NOT_FOUND", message: "Job target not found" }, + }); + return; + } + + if (target.status !== "queued" && target.status !== "dispatched") { + res.json({ success: true }); + return; + } + + await db + .update(jobTargets) + .set({ status: "running", startedAt: new Date() }) + .where(eq(jobTargets.id, body.target_id)); + + await db + .update(jobs) + .set({ status: "running", startedAt: new Date() }) + .where( + and(eq(jobs.id, req.params.jobId as string), eq(jobs.status, "dispatched")) + ); + + res.json({ success: true }); + } catch (err) { + if (err instanceof z.ZodError) { + res.status(400).json({ + success: false, + error: { code: "VALIDATION_ERROR", message: "Invalid start data", details: err.errors }, + }); + return; + } + console.error("[jobs/start] Error:", err); + res.status(500).json({ + success: false, + error: { code: "INTERNAL_ERROR", message: "Start processing failed" }, + }); + } +}); + +agentRouter.get("/jobs/:jobId/status", agentAuthMiddleware, async (req, res) => { + const device = req.device; + + const [row] = await db + .select({ + jobStatus: jobs.status, + targetStatus: jobTargets.status, + }) + .from(jobTargets) + .innerJoin(jobs, eq(jobTargets.jobId, jobs.id)) + .where( + and( + eq(jobTargets.jobId, req.params.jobId as string), + eq(jobTargets.deviceId, device.id) + ) + ) + .limit(1); + + if (!row) { + res.status(404).json({ + success: false, + error: { code: "NOT_FOUND", message: "Job target not found" }, + }); + return; + } + + res.json({ success: true, data: { job_status: row.jobStatus, target_status: row.targetStatus } }); +}); + +const resultSchema = z.object({ + job_id: z.string().uuid(), + target_id: z.string().uuid(), + status: z.enum(["succeeded", "failed", "cancelled"]), + result: z.record(z.unknown()).optional(), + error_message: z.string().optional(), +}); + +agentRouter.post("/jobs/:jobId/result", agentAuthMiddleware, async (req, res) => { + try { + const body = resultSchema.parse({ + ...req.body, + job_id: req.params.jobId, + }); + const device = req.device; + + const [target] = await db + .select() + .from(jobTargets) + .where( + and( + eq(jobTargets.id, body.target_id), + eq(jobTargets.deviceId, device.id), + eq(jobTargets.jobId, body.job_id) + ) + ) + .limit(1); + + if (!target) { + res.status(404).json({ + success: false, + error: { code: "NOT_FOUND", message: "Job target not found" }, + }); + return; + } + + const resultData = body.result || {}; + if (body.error_message) { + resultData.error = body.error_message; + } + + await db + .update(jobTargets) + .set({ + status: body.status, + result: resultData, + completedAt: new Date(), + }) + .where(eq(jobTargets.id, body.target_id)); + + const allTargets = await db + .select() + .from(jobTargets) + .where(eq(jobTargets.jobId, body.job_id)); + + const allComplete = allTargets.every( + (t) => t.status !== "queued" && t.status !== "dispatched" && t.status !== "running" + ); + + if (allComplete) { + const anyFailed = allTargets.some((t) => t.status === "failed"); + await db + .update(jobs) + .set({ + status: anyFailed ? "failed" : "succeeded", + completedAt: new Date(), + }) + .where( + and(eq(jobs.id, body.job_id), notInArray(jobs.status, ["cancelled", "expired"])) + ); + } + + res.json({ success: true }); + } catch (err) { + if (err instanceof z.ZodError) { + res.status(400).json({ + success: false, + error: { code: "VALIDATION_ERROR", message: "Invalid result data", details: err.errors }, + }); + return; + } + console.error("[jobs/result] Error:", err); + res.status(500).json({ + success: false, + error: { code: "INTERNAL_ERROR", message: "Result processing failed" }, + }); + } +}); + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +const SOFTWARE_DIR = path.resolve( + process.env.SOFTWARE_DIR || path.join(__dirname, "../../../../infrastructure/software-releases") +); + +agentRouter.get("/software/:packageId/download", agentAuthMiddleware, async (req, res) => { + try { + const device = req.device; + const packageId = req.params.packageId as string; + + const [pkg] = await db + .select() + .from(softwarePackages) + .where( + and( + eq(softwarePackages.id, packageId), + eq(softwarePackages.organizationId, device.organizationId) + ) + ) + .limit(1); + + if (!pkg) { + res.status(404).json({ + success: false, + error: { code: "NOT_FOUND", message: "Package not found" }, + }); + return; + } + + const orgDir = path.join(SOFTWARE_DIR, device.organizationId); + const filePath = path.join(orgDir, pkg.filename); + + if (!fs.existsSync(filePath)) { + res.status(404).json({ + success: false, + error: { code: "NOT_FOUND", message: "File not found on disk" }, + }); + return; + } + + res.setHeader("Content-Disposition", `attachment; filename="${pkg.filename}"`); + res.setHeader("Content-Type", "application/octet-stream"); + res.setHeader("Content-Length", pkg.fileSize); + fs.createReadStream(filePath).pipe(res); + } catch (err) { + console.error("[agent/software] Error:", err); + res.status(500).json({ + success: false, + error: { code: "INTERNAL_ERROR", message: "Download failed" }, + }); + } +}); diff --git a/apps/api/src/routes/alerts.ts b/apps/api/src/routes/alerts.ts new file mode 100644 index 0000000..04b2798 --- /dev/null +++ b/apps/api/src/routes/alerts.ts @@ -0,0 +1,65 @@ +import { Router } from "express"; +import { db } from "../db/index.js"; +import { alerts } from "../db/schema.js"; +import { authenticate, authorize } from "../middleware/auth.js"; +import { eq, and, desc, count } from "drizzle-orm"; + +export const alertsRouter = Router(); + +alertsRouter.use(authenticate); + +alertsRouter.get("/", authorize("read_only"), async (req, res) => { + const user = (req as any).user; + const resolved = req.query.resolved as string | undefined; + + let whereClause = eq(alerts.organizationId, user.organizationId); + if (resolved !== undefined) { + whereClause = and(whereClause, eq(alerts.isResolved, resolved === "true"))!; + } + + const [totalResult] = await db + .select({ total: count() }) + .from(alerts) + .where(whereClause); + + const allAlerts = await db + .select() + .from(alerts) + .where(whereClause) + .orderBy(desc(alerts.createdAt)) + .limit(50); + + res.json({ + success: true, + data: allAlerts, + meta: { + page: 1, + limit: 50, + total: totalResult.total, + totalPages: 1, + }, + }); +}); + +alertsRouter.patch("/:alertId/resolve", authorize("admin"), async (req, res) => { + const user = (req as any).user; + const alertId = req.params.alertId as string; + + const [resolved] = await db + .update(alerts) + .set({ isResolved: true, resolvedAt: new Date(), resolvedByUserId: user.userId }) + .where( + and(eq(alerts.id, alertId), eq(alerts.organizationId, user.organizationId)) + ) + .returning({ id: alerts.id }); + + if (!resolved) { + res.status(404).json({ + success: false, + error: { code: "NOT_FOUND", message: "Alert not found" }, + }); + return; + } + + res.json({ success: true }); +}); diff --git a/apps/api/src/routes/appcatalog.ts b/apps/api/src/routes/appcatalog.ts new file mode 100644 index 0000000..d5c1742 --- /dev/null +++ b/apps/api/src/routes/appcatalog.ts @@ -0,0 +1,201 @@ +import { Router } from "express"; +import { authenticate, authorize } from "../middleware/auth.js"; + +export const appCatalogRouter = Router(); +appCatalogRouter.use(authenticate); + +const WINGET_SEARCH_URL = "https://api.winget.run/v2/packages"; +const CHOCOLATEY_SEARCH = "https://community.chocolatey.org/api/v2/Search()"; +const CHOCOLATEY_FEED = "https://community.chocolatey.org/api/v2/Packages()"; +const UPSTREAM_TIMEOUT_MS = 12_000; +const CACHE_TTL_MS = 60_000; + +const cache = new Map(); + +function cacheGet(key: string): T | undefined { + const hit = cache.get(key); + if (hit && hit.expiresAt > Date.now()) return hit.value as T; + if (hit) cache.delete(key); + return undefined; +} + +function cacheSet(key: string, value: unknown) { + cache.set(key, { expiresAt: Date.now() + CACHE_TTL_MS, value }); +} + +async function fetchUpstream(url: string): Promise { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), UPSTREAM_TIMEOUT_MS); + try { + const res = await fetch(url, { signal: controller.signal }); + if (!res.ok) { + throw new Error(`upstream returned HTTP ${res.status}`); + } + return await res.text(); + } finally { + clearTimeout(timer); + } +} + +interface WingetPackage { + id: string; + name: string; + publisher: string; + latestVersion: string; + versions: string[]; + description: string; + tags: string[]; + iconUrl: string; + homepage: string; + license: string; +} + +function mapWinget(raw: any): WingetPackage { + const latest = raw.Latest || {}; + return { + id: raw.Id || "", + name: latest.Name || raw.Id || "", + publisher: latest.Publisher || "", + latestVersion: Array.isArray(raw.Versions) && raw.Versions.length > 0 ? raw.Versions[0] : "", + versions: Array.isArray(raw.Versions) ? raw.Versions : [], + description: latest.Description || "", + tags: Array.isArray(latest.Tags) ? latest.Tags : [], + iconUrl: raw.IconUrl || "", + homepage: latest.Homepage || "", + license: latest.License || "", + }; +} + +interface ChocolateyPackage { + id: string; + title: string; + version: string; + description: string; + downloadCount: number; + iconUrl: string; +} + +const fieldRegex = (base: string) => + new RegExp(`]*>([^<]*)`); + +function mapChocolatey(xml: string): ChocolateyPackage[] { + const packages: ChocolateyPackage[] = []; + const entries = xml.match(/[\s\S]*?<\/entry>/g) || []; + for (const entry of entries) { + const idMatch = entry.match(/Packages\(Id='([^']+)'/); + const title = entry.match(fieldRegex("Title")); + const version = entry.match(fieldRegex("Version")); + const description = entry.match(fieldRegex("Description")); + const downloadCount = entry.match(fieldRegex("DownloadCount")); + const iconUrl = entry.match(fieldRegex("IconUrl")); + if (!idMatch) continue; + packages.push({ + id: decodeXmlEntities(idMatch[1]), + title: decodeXmlEntities(title ? title[1] : ""), + version: decodeXmlEntities(version ? version[1] : ""), + description: decodeXmlEntities(description ? description[1] : ""), + downloadCount: downloadCount ? parseInt(downloadCount[1], 10) || 0 : 0, + iconUrl: decodeXmlEntities(iconUrl ? iconUrl[1] : ""), + }); + } + return packages; +} + +function decodeXmlEntities(value: string): string { + return value + .replace(/&/g, "&") + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/'/g, "'"); +} + +appCatalogRouter.get("/winget", authorize("read_only"), async (req, res) => { + try { + const search = String(req.query.search || "").trim(); + const page = Math.max(1, parseInt(req.query.page as string) || 1); + const limit = Math.min(Math.max(parseInt(req.query.limit as string) || 25, 1), 50); + const take = limit; + const skip = (page - 1) * take; + + const cacheKey = `winget:${search}:${page}:${limit}`; + const cached = cacheGet<{ packages: WingetPackage[]; total: number }>(cacheKey); + if (cached) { + res.json({ success: true, data: { ...cached, page, limit } }); + return; + } + + const url = `${WINGET_SEARCH_URL}?query=${encodeURIComponent(search)}&take=${take}&page=${skip}&sortField=SearchScore`; + const body = await fetchUpstream(url); + const parsed = JSON.parse(body); + const rawPackages = Array.isArray(parsed.Packages) ? parsed.Packages : []; + const packages = rawPackages.map(mapWinget); + const total = typeof parsed.Total === "number" ? parsed.Total : 0; + + cacheSet(cacheKey, { packages, total }); + res.json({ success: true, data: { packages, total, page, limit } }); + } catch (err) { + res.status(502).json({ + success: false, + error: { code: "UPSTREAM_ERROR", message: (err as Error).message }, + }); + } +}); + +appCatalogRouter.get("/chocolatey", authorize("read_only"), async (req, res) => { + try { + const search = String(req.query.search || "").trim(); + const page = Math.max(1, parseInt(req.query.page as string) || 1); + const limit = Math.min(Math.max(parseInt(req.query.limit as string) || 25, 1), 50); + const skip = (page - 1) * limit; + + const cacheKey = `chocolatey:${search}:${page}:${limit}`; + const cached = cacheGet<{ packages: ChocolateyPackage[] }>(cacheKey); + if (cached) { + res.json({ success: true, data: { ...cached, page, limit } }); + return; + } + + let packages: ChocolateyPackage[]; + if (search) { + // Search() matches title/description/tags but returns one entry per version. + // Fetch a wider window, deduplicate (newest version first) and slice. + const params = new URLSearchParams({ + searchTerm: `'${search}'`, + targetFramework: "", + includePrerelease: "false", + $skip: "0", + $top: String(skip + limit * 4), + $select: "Id,Version,Title,Description,DownloadCount,IconUrl", + }); + const xml = await fetchUpstream(`${CHOCOLATEY_SEARCH}?${params.toString()}`); + const raw = mapChocolatey(xml); + const seen = new Set(); + const unique = raw.filter((p) => { + if (seen.has(p.id)) return false; + seen.add(p.id); + return true; + }); + packages = unique.length > skip ? unique.slice(skip, skip + limit) : []; + } else { + // Browse-all: one row per package (latest version only). + const params = new URLSearchParams({ + $filter: "IsLatestVersion", + $skip: String(skip), + $top: String(limit), + $select: "Id,Version,Title,Description,DownloadCount,IconUrl", + }); + const xml = await fetchUpstream(`${CHOCOLATEY_FEED}?${params.toString()}`); + packages = mapChocolatey(xml); + } + + cacheSet(cacheKey, { packages }); + res.json({ success: true, data: { packages, page, limit } }); + } catch (err) { + res.status(502).json({ + success: false, + error: { code: "UPSTREAM_ERROR", message: (err as Error).message }, + }); + } +}); \ No newline at end of file diff --git a/apps/api/src/routes/audit.ts b/apps/api/src/routes/audit.ts new file mode 100644 index 0000000..edc6335 --- /dev/null +++ b/apps/api/src/routes/audit.ts @@ -0,0 +1,46 @@ +import { Router } from "express"; +import { db } from "../db/index.js"; +import { auditEvents } from "../db/schema.js"; +import { authenticate, authorize } from "../middleware/auth.js"; +import { eq, and, desc, ilike, count } from "drizzle-orm"; + +export const auditRouter = Router(); + +auditRouter.use(authenticate); + +auditRouter.get("/", authorize("admin"), async (req, res) => { + const user = (req as any).user; + const page = parseInt(req.query.page as string) || 1; + const limit = Math.min(parseInt(req.query.limit as string) || 20, 100); + const offset = (page - 1) * limit; + const action = req.query.action as string | undefined; + + let whereClause = eq(auditEvents.organizationId, user.organizationId); + if (action) { + whereClause = and(whereClause, ilike(auditEvents.action, `%${action}%`))!; + } + + const [totalResult] = await db + .select({ total: count() }) + .from(auditEvents) + .where(whereClause); + + const events = await db + .select() + .from(auditEvents) + .where(whereClause) + .orderBy(desc(auditEvents.createdAt)) + .limit(limit) + .offset(offset); + + res.json({ + success: true, + data: events, + meta: { + page, + limit, + total: totalResult.total, + totalPages: Math.ceil(totalResult.total / limit), + }, + }); +}); diff --git a/apps/api/src/routes/auth.ts b/apps/api/src/routes/auth.ts new file mode 100644 index 0000000..705ae4d --- /dev/null +++ b/apps/api/src/routes/auth.ts @@ -0,0 +1,144 @@ +import { Router } from "express"; +import bcrypt from "bcrypt"; +import { z } from "zod"; +import { db } from "../db/index.js"; +import { users } from "../db/schema.js"; +import { generateToken } from "../middleware/auth.js"; +import { auditLog } from "../lib/audit.js"; +import { eq, and } from "drizzle-orm"; + +export const authRouter = Router(); + +const loginSchema = z.object({ + email: z.string().email(), + password: z.string().min(1), + organizationSlug: z.string().optional(), +}); + +authRouter.post("/login", async (req, res) => { + try { + const body = loginSchema.parse(req.body); + + const [user] = await db + .select() + .from(users) + .where(and(eq(users.email, body.email), eq(users.isActive, true))) + .limit(1); + + if (!user) { + res.status(401).json({ + success: false, + error: { code: "INVALID_CREDENTIALS", message: "Invalid email or password" }, + }); + return; + } + + const passwordValid = await bcrypt.compare(body.password, user.passwordHash); + if (!passwordValid) { + await auditLog({ + req, + userId: user.id, + organizationId: user.organizationId, + action: "auth.login_failed", + targetType: "user", + targetId: user.id, + result: "failure", + metadata: { reason: "invalid_password" }, + }); + res.status(401).json({ + success: false, + error: { code: "INVALID_CREDENTIALS", message: "Invalid email or password" }, + }); + return; + } + + await db + .update(users) + .set({ lastLoginAt: new Date() }) + .where(eq(users.id, user.id)); + + const token = generateToken({ + userId: user.id, + organizationId: user.organizationId, + role: user.role as any, + email: user.email, + }); + + await auditLog({ + req, + userId: user.id, + organizationId: user.organizationId, + action: "auth.login", + targetType: "user", + targetId: user.id, + result: "success", + }); + + res.json({ + success: true, + data: { + token, + user: { + id: user.id, + email: user.email, + displayName: user.displayName, + role: user.role, + }, + }, + }); + } catch (err) { + if (err instanceof z.ZodError) { + res.status(400).json({ + success: false, + error: { code: "VALIDATION_ERROR", message: "Invalid request body", details: err.errors }, + }); + return; + } + throw err; + } +}); + +authRouter.get("/me", async (req, res) => { + const authHeader = req.headers.authorization; + if (!authHeader?.startsWith("Bearer ")) { + res.status(401).json({ + success: false, + error: { code: "UNAUTHORIZED", message: "Not authenticated" }, + }); + return; + } + + try { + const { verifyToken } = await import("../middleware/auth.js"); + const payload = verifyToken(authHeader.substring(7)); + + const [user] = await db + .select() + .from(users) + .where(eq(users.id, payload.userId)) + .limit(1); + + if (!user) { + res.status(401).json({ + success: false, + error: { code: "UNAUTHORIZED", message: "User not found" }, + }); + return; + } + + res.json({ + success: true, + data: { + id: user.id, + email: user.email, + displayName: user.displayName, + role: user.role, + }, + }); + } catch { + res.status(401).json({ + success: false, + error: { code: "UNAUTHORIZED", message: "Invalid token" }, + }); + } +}); diff --git a/apps/api/src/routes/compliance.ts b/apps/api/src/routes/compliance.ts new file mode 100644 index 0000000..ce20eda --- /dev/null +++ b/apps/api/src/routes/compliance.ts @@ -0,0 +1,126 @@ +import { Router } from "express"; +import { db } from "../db/index.js"; +import { complianceResults, devices } from "../db/schema.js"; +import { authenticate, authorize } from "../middleware/auth.js"; +import { eq, and, desc } from "drizzle-orm"; +import { evaluatePoliciesForDevice } from "../lib/compliance.js"; +import { getPool } from "../db/index.js"; + +export const complianceRouter = Router(); + +complianceRouter.use(authenticate); + +complianceRouter.get( + "/devices/:deviceId", + authorize("read_only"), + async (req, res) => { + const user = (req as any).user; + const deviceId = req.params.deviceId as string; + + const [device] = await db + .select() + .from(devices) + .where( + and( + eq(devices.id, deviceId), + eq(devices.organizationId, user.organizationId) + ) + ); + + if (!device) { + res.status(404).json({ + success: false, + error: { code: "NOT_FOUND", message: "Device not found" }, + }); + return; + } + + const results = await db + .select() + .from(complianceResults) + .where(eq(complianceResults.deviceId, deviceId)) + .orderBy(desc(complianceResults.evaluatedAt)); + + res.json({ success: true, data: results }); + } +); + +complianceRouter.post( + "/devices/:deviceId/evaluate", + authorize("operator"), + async (req, res) => { + const user = (req as any).user; + const deviceId = req.params.deviceId as string; + + const [device] = await db + .select() + .from(devices) + .where( + and( + eq(devices.id, deviceId), + eq(devices.organizationId, user.organizationId) + ) + ); + + if (!device) { + res.status(404).json({ + success: false, + error: { code: "NOT_FOUND", message: "Device not found" }, + }); + return; + } + + const pool = getPool(); + const result = await evaluatePoliciesForDevice( + pool, + deviceId, + user.organizationId + ); + + res.json({ success: true, data: result }); + } +); + +complianceRouter.post( + "/evaluate", + authorize("admin"), + async (req, res) => { + const user = (req as any).user; + const pool = getPool(); + + const { evaluatePolicies } = await import("../lib/compliance.js"); + const result = await evaluatePolicies(pool, user.organizationId); + + res.json({ success: true, data: result }); + } +); + +complianceRouter.get( + "/summary", + authorize("read_only"), + async (req, res) => { + const user = (req as any).user; + + const results = await db + .select() + .from(complianceResults) + .innerJoin(devices, eq(complianceResults.deviceId, devices.id)) + .where(eq(devices.organizationId, user.organizationId)); + + const total = results.length; + const compliant = results.filter( + (r) => r.compliance_results.status === "compliant" + ).length; + const nonCompliant = results.filter( + (r) => r.compliance_results.status === "non_compliant" + ).length; + const unknown = results.filter( + (r) => r.compliance_results.status === "unknown" + ).length; + + res.json({ + success: true, + data: { total, compliant, nonCompliant, unknown }, + }); + } +); diff --git a/apps/api/src/routes/dashboard.ts b/apps/api/src/routes/dashboard.ts new file mode 100644 index 0000000..98f111b --- /dev/null +++ b/apps/api/src/routes/dashboard.ts @@ -0,0 +1,111 @@ +import { Router } from "express"; +import { db } from "../db/index.js"; +import { devices, alerts, jobs } from "../db/schema.js"; +import { authenticate, authorize } from "../middleware/auth.js"; +import { eq, and, count, sql, desc } from "drizzle-orm"; + +export const dashboardRouter = Router(); + +dashboardRouter.use(authenticate); + +dashboardRouter.get("/stats", authorize("read_only"), async (req, res) => { + const user = (req as any).user; + + const [totalDevices] = await db + .select({ total: count() }) + .from(devices) + .where(eq(devices.organizationId, user.organizationId)); + + const [onlineDevices] = await db + .select({ total: count() }) + .from(devices) + .where( + and(eq(devices.organizationId, user.organizationId), eq(devices.status, "online")) + ); + + const [offlineDevices] = await db + .select({ total: count() }) + .from(devices) + .where( + and( + eq(devices.organizationId, user.organizationId), + eq(devices.status, "offline") + ) + ); + + const [warningDevices] = await db + .select({ total: count() }) + .from(devices) + .where( + and( + eq(devices.organizationId, user.organizationId), + eq(devices.status, "warning") + ) + ); + + const [openAlerts] = await db + .select({ total: count() }) + .from(alerts) + .where( + and(eq(alerts.organizationId, user.organizationId), eq(alerts.isResolved, false)) + ); + + const [pendingJobs] = await db + .select({ total: count() }) + .from(jobs) + .where( + and( + eq(jobs.organizationId, user.organizationId), + sql`${jobs.status} IN ('queued', 'dispatched', 'running')` + ) + ); + + const osDistribution = await db + .select({ + os: devices.osName, + total: count(), + }) + .from(devices) + .where(eq(devices.organizationId, user.organizationId)) + .groupBy(devices.osName); + + const recentDevices = await db + .select({ + id: devices.id, + name: devices.name, + status: devices.status, + osName: devices.osName, + lastHeartbeatAt: devices.lastHeartbeatAt, + }) + .from(devices) + .where(eq(devices.organizationId, user.organizationId)) + .orderBy(desc(devices.enrolledAt)) + .limit(5); + + const offlineThreshold = new Date(Date.now() - 5 * 60 * 1000); + const [staleDevices] = await db + .select({ total: count() }) + .from(devices) + .where( + and( + eq(devices.organizationId, user.organizationId), + eq(devices.status, "offline"), + sql`${devices.lastHeartbeatAt} < ${offlineThreshold}` + ) + ); + + res.json({ + success: true, + data: { + totalDevices: totalDevices.total, + onlineDevices: onlineDevices.total, + offlineDevices: offlineDevices.total, + warningDevices: warningDevices.total, + openAlerts: openAlerts.total, + pendingJobs: pendingJobs.total, + staleDevices: staleDevices.total, + osDistribution, + recentDevices, + }, + }); +}); diff --git a/apps/api/src/routes/deployments.ts b/apps/api/src/routes/deployments.ts new file mode 100644 index 0000000..9258df7 --- /dev/null +++ b/apps/api/src/routes/deployments.ts @@ -0,0 +1,321 @@ +import { Router } from "express"; +import { z } from "zod"; +import { db } from "../db/index.js"; +import { deployments, deploymentTargets, devices, users } from "../db/schema.js"; +import { authenticate, authorize } from "../middleware/auth.js"; +import { auditLog } from "../lib/audit.js"; +import { eq, and, desc, count } from "drizzle-orm"; + +export const deploymentsRouter = Router(); + +deploymentsRouter.use(authenticate); + +deploymentsRouter.get("/", authorize("read_only"), async (req, res) => { + const user = (req as any).user; + const page = parseInt(req.query.page as string) || 1; + const limit = Math.min(parseInt(req.query.limit as string) || 20, 100); + const offset = (page - 1) * limit; + const status = req.query.status as string | undefined; + + const whereConditions = [eq(deployments.organizationId, user.organizationId)]; + if (status) { + whereConditions.push(eq(deployments.status, status)); + } + + const [totalResult] = await db + .select({ total: count() }) + .from(deployments) + .where(and(...whereConditions)); + + const rows = await db + .select({ + id: deployments.id, + organizationId: deployments.organizationId, + name: deployments.name, + description: deployments.description, + jobType: deployments.jobType, + parameters: deployments.parameters, + status: deployments.status, + createdByUserId: deployments.createdByUserId, + approvedByUserId: deployments.approvedByUserId, + approvedAt: deployments.approvedAt, + scheduledAt: deployments.scheduledAt, + startedAt: deployments.startedAt, + completedAt: deployments.completedAt, + totalTargets: deployments.totalTargets, + completedTargets: deployments.completedTargets, + failedTargets: deployments.failedTargets, + createdAt: deployments.createdAt, + updatedAt: deployments.updatedAt, + createdByName: users.displayName, + }) + .from(deployments) + .leftJoin(users, eq(deployments.createdByUserId, users.id)) + .where(and(...whereConditions)) + .orderBy(desc(deployments.createdAt)) + .limit(limit) + .offset(offset); + + res.json({ + success: true, + data: rows, + meta: { + page, + limit, + total: totalResult.total, + totalPages: Math.ceil(totalResult.total / limit), + }, + }); +}); + +const createDeploymentSchema = z.object({ + name: z.string().min(1).max(255), + description: z.string().optional(), + jobType: z.enum([ + "install_package", "update_package", "remove_package", + "start_service", "stop_service", "restart_service", + "check_service_status", "reboot_system", "shutdown_system", + "start_remote_session", "stop_remote_session", + "update_inventory", "update_system_info", + "install_from_file", "download_and_install", "install_registry_package", + ]), + parameters: z.record(z.unknown()).default({}), + deviceIds: z.array(z.string().uuid()).min(1), + scheduledAt: z.string().datetime().optional(), + autoApprove: z.boolean().default(false), +}); + +deploymentsRouter.post("/", authorize("operator"), async (req, res) => { + try { + const body = createDeploymentSchema.parse(req.body); + const authUser = (req as any).user; + + const status = body.autoApprove ? "approved" : "draft"; + + const [deployment] = await db + .insert(deployments) + .values({ + organizationId: authUser.organizationId, + name: body.name, + description: body.description || null, + jobType: body.jobType, + parameters: body.parameters, + status, + createdByUserId: authUser.userId, + approvedByUserId: body.autoApprove ? authUser.userId : null, + approvedAt: body.autoApprove ? new Date() : null, + scheduledAt: body.scheduledAt ? new Date(body.scheduledAt) : null, + totalTargets: body.deviceIds.length, + }) + .returning(); + + const targets = body.deviceIds.map((deviceId) => ({ + deploymentId: deployment.id, + deviceId, + status: "pending" as const, + })); + + await db.insert(deploymentTargets).values(targets); + + await auditLog({ + req, + organizationId: authUser.organizationId, + action: "deployment.create", + targetType: "deployment", + targetId: deployment.id, + deviceIds: body.deviceIds, + result: "success", + metadata: { + name: body.name, + jobType: body.jobType, + targetCount: body.deviceIds.length, + status, + }, + }); + + res.status(201).json({ success: true, data: deployment }); + } catch (err) { + if (err instanceof z.ZodError) { + res.status(400).json({ + success: false, + error: { code: "VALIDATION_ERROR", message: "Invalid request body", details: err.errors }, + }); + return; + } + throw err; + } +}); + +deploymentsRouter.get("/:deploymentId", authorize("read_only"), async (req, res) => { + const user = (req as any).user; + const deploymentId = req.params.deploymentId as string; + + const [deployment] = await db + .select({ + id: deployments.id, + organizationId: deployments.organizationId, + name: deployments.name, + description: deployments.description, + jobType: deployments.jobType, + parameters: deployments.parameters, + status: deployments.status, + createdByUserId: deployments.createdByUserId, + approvedByUserId: deployments.approvedByUserId, + approvedAt: deployments.approvedAt, + scheduledAt: deployments.scheduledAt, + startedAt: deployments.startedAt, + completedAt: deployments.completedAt, + totalTargets: deployments.totalTargets, + completedTargets: deployments.completedTargets, + failedTargets: deployments.failedTargets, + createdAt: deployments.createdAt, + updatedAt: deployments.updatedAt, + createdByName: users.displayName, + }) + .from(deployments) + .leftJoin(users, eq(deployments.createdByUserId, users.id)) + .where( + and( + eq(deployments.id, deploymentId), + eq(deployments.organizationId, user.organizationId) + ) + ) + .limit(1); + + if (!deployment) { + res.status(404).json({ + success: false, + error: { code: "NOT_FOUND", message: "Deployment not found" }, + }); + return; + } + + const targets = await db + .select({ + id: deploymentTargets.id, + deploymentId: deploymentTargets.deploymentId, + deviceId: deploymentTargets.deviceId, + jobId: deploymentTargets.jobId, + status: deploymentTargets.status, + result: deploymentTargets.result, + startedAt: deploymentTargets.startedAt, + completedAt: deploymentTargets.completedAt, + createdAt: deploymentTargets.createdAt, + deviceName: devices.name, + }) + .from(deploymentTargets) + .leftJoin(devices, eq(deploymentTargets.deviceId, devices.id)) + .where(eq(deploymentTargets.deploymentId, deploymentId)); + + res.json({ success: true, data: { ...deployment, targets } }); +}); + +deploymentsRouter.patch("/:deploymentId/approve", authorize("operator"), async (req, res) => { + const user = (req as any).user; + const deploymentId = req.params.deploymentId as string; + + const [existing] = await db + .select() + .from(deployments) + .where( + and( + eq(deployments.id, deploymentId), + eq(deployments.organizationId, user.organizationId), + eq(deployments.status, "draft") + ) + ) + .limit(1); + + if (!existing) { + res.status(404).json({ + success: false, + error: { code: "NOT_FOUND", message: "Deployment not found or not in draft status" }, + }); + return; + } + + const [updated] = await db + .update(deployments) + .set({ + status: "approved", + approvedByUserId: user.userId, + approvedAt: new Date(), + }) + .where(eq(deployments.id, deploymentId)) + .returning(); + + await auditLog({ + req, + organizationId: user.organizationId, + action: "deployment.approve", + targetType: "deployment", + targetId: deploymentId, + result: "success", + metadata: { name: existing.name }, + }); + + res.json({ success: true, data: updated }); +}); + +deploymentsRouter.patch("/:deploymentId/cancel", authorize("operator"), async (req, res) => { + const user = (req as any).user; + const deploymentId = req.params.deploymentId as string; + + const [updated] = await db + .update(deployments) + .set({ status: "cancelled", completedAt: new Date() }) + .where( + and( + eq(deployments.id, deploymentId), + eq(deployments.organizationId, user.organizationId), + eq(deployments.status, "draft") + ) + ) + .returning(); + + if (!updated) { + res.status(404).json({ + success: false, + error: { code: "NOT_FOUND", message: "Deployment not found or not cancellable" }, + }); + return; + } + + await db + .update(deploymentTargets) + .set({ status: "cancelled" }) + .where( + and( + eq(deploymentTargets.deploymentId, deploymentId), + eq(deploymentTargets.status, "pending") + ) + ); + + res.json({ success: true, data: updated }); +}); + +deploymentsRouter.delete("/:deploymentId", authorize("operator"), async (req, res) => { + const user = (req as any).user; + const deploymentId = req.params.deploymentId as string; + + const [deleted] = await db + .delete(deployments) + .where( + and( + eq(deployments.id, deploymentId), + eq(deployments.organizationId, user.organizationId), + eq(deployments.status, "draft") + ) + ) + .returning(); + + if (!deleted) { + res.status(404).json({ + success: false, + error: { code: "NOT_FOUND", message: "Deployment not found or not in draft status" }, + }); + return; + } + + res.json({ success: true }); +}); diff --git a/apps/api/src/routes/devices.ts b/apps/api/src/routes/devices.ts new file mode 100644 index 0000000..2fde445 --- /dev/null +++ b/apps/api/src/routes/devices.ts @@ -0,0 +1,377 @@ +import { Router } from "express"; +import { z } from "zod"; +import { db } from "../db/index.js"; +import { + devices, + inventorySnapshots, + metricSnapshots, + jobs, + jobTargets, + alerts, + complianceResults, + policies, + deviceCredentials, +} from "../db/schema.js"; +import { authenticate, authorize } from "../middleware/auth.js"; +import { auditLog } from "../lib/audit.js"; +import { eq, and, desc, asc, ilike, count, inArray } from "drizzle-orm"; + +export const devicesRouter = Router(); + +devicesRouter.use(authenticate); + +devicesRouter.get("/", authorize("read_only"), async (req, res) => { + const user = (req as any).user; + const page = parseInt(req.query.page as string) || 1; + const limit = Math.min(parseInt(req.query.limit as string) || 20, 100); + const offset = (page - 1) * limit; + const search = req.query.search as string | undefined; + const status = req.query.status as string | undefined; + const deviceType = req.query.deviceType as string | undefined; + const sort = (req.query.sort as string) || "enrolled_at"; + const order = req.query.order === "asc" ? asc : desc; + + let whereClause = eq(devices.organizationId, user.organizationId); + + if (search) { + whereClause = and(whereClause, ilike(devices.name, `%${search}%`))!; + } + if (status) { + whereClause = and(whereClause, eq(devices.status, status))!; + } + if (deviceType) { + whereClause = and(whereClause, eq(devices.deviceType, deviceType))!; + } + + const sortColumn = + sort === "name" + ? devices.name + : sort === "status" + ? devices.status + : sort === "os_name" + ? devices.osName + : devices.enrolledAt; + + const [totalResult] = await db + .select({ total: count() }) + .from(devices) + .where(whereClause); + + const allDevices = await db + .select() + .from(devices) + .where(whereClause) + .orderBy(order(sortColumn)) + .limit(limit) + .offset(offset); + + res.json({ + success: true, + data: allDevices, + meta: { + page, + limit, + total: totalResult.total, + totalPages: Math.ceil(totalResult.total / limit), + }, + }); +}); + +devicesRouter.get("/:deviceId", authorize("read_only"), async (req, res) => { + const user = (req as any).user; + const deviceId = req.params.deviceId as string; + + const [device] = await db + .select() + .from(devices) + .where( + and(eq(devices.id, deviceId), eq(devices.organizationId, user.organizationId)) + ) + .limit(1); + + if (!device) { + res.status(404).json({ + success: false, + error: { code: "NOT_FOUND", message: "Device not found" }, + }); + return; + } + + res.json({ success: true, data: device }); +}); + +devicesRouter.get( + "/:deviceId/inventory", + authorize("read_only"), + async (req, res) => { + const user = (req as any).user; + const deviceId = req.params.deviceId as string; + + const [device] = await db + .select({ id: devices.id }) + .from(devices) + .where( + and( + eq(devices.id, deviceId), + eq(devices.organizationId, user.organizationId) + ) + ) + .limit(1); + + if (!device) { + res.status(404).json({ + success: false, + error: { code: "NOT_FOUND", message: "Device not found" }, + }); + return; + } + + const snapshots = await db + .select() + .from(inventorySnapshots) + .where(eq(inventorySnapshots.deviceId, deviceId)) + .orderBy(desc(inventorySnapshots.reportedAt)) + .limit(10); + + res.json({ success: true, data: snapshots }); + } +); + +devicesRouter.get( + "/:deviceId/metrics", + authorize("read_only"), + async (req, res) => { + const user = (req as any).user; + const deviceId = req.params.deviceId as string; + + const [device] = await db + .select({ id: devices.id }) + .from(devices) + .where( + and( + eq(devices.id, deviceId), + eq(devices.organizationId, user.organizationId) + ) + ) + .limit(1); + + if (!device) { + res.status(404).json({ + success: false, + error: { code: "NOT_FOUND", message: "Device not found" }, + }); + return; + } + + const metrics = await db + .select() + .from(metricSnapshots) + .where(eq(metricSnapshots.deviceId, deviceId)) + .orderBy(desc(metricSnapshots.reportedAt)) + .limit(100); + + res.json({ success: true, data: metrics }); + } +); + +devicesRouter.patch("/:deviceId", authorize("admin"), async (req, res) => { + const user = (req as any).user; + const deviceId = req.params.deviceId as string; + + const updateSchema = z.object({ + name: z.string().min(1).max(255).optional(), + deviceGroupId: z.string().uuid().nullable().optional(), + tags: z.array(z.string()).optional(), + isDisabled: z.boolean().optional(), + }); + + try { + const body = updateSchema.parse(req.body); + const [updated] = await db + .update(devices) + .set(body) + .where( + and(eq(devices.id, deviceId), eq(devices.organizationId, user.organizationId)) + ) + .returning(); + + if (!updated) { + res.status(404).json({ + success: false, + error: { code: "NOT_FOUND", message: "Device not found" }, + }); + return; + } + + res.json({ success: true, data: updated }); + } catch (err) { + if (err instanceof z.ZodError) { + res.status(400).json({ + success: false, + error: { code: "VALIDATION_ERROR", message: "Invalid request body", details: err.errors }, + }); + return; + } + throw err; + } +}); + +devicesRouter.delete("/:deviceId", authorize("admin"), async (req, res) => { + const user = (req as any).user; + const deviceId = req.params.deviceId as string; + + const [device] = await db + .select({ id: devices.id, name: devices.name }) + .from(devices) + .where( + and(eq(devices.id, deviceId), eq(devices.organizationId, user.organizationId)) + ) + .limit(1); + + if (!device) { + res.status(404).json({ + success: false, + error: { code: "NOT_FOUND", message: "Device not found" }, + }); + return; + } + + const relatedJobIds = await db + .select({ jobId: jobTargets.jobId }) + .from(jobTargets) + .where(eq(jobTargets.deviceId, deviceId)); + + const jobIdList = relatedJobIds.map((r) => r.jobId); + + await db.delete(alerts).where(eq(alerts.deviceId, deviceId)); + await db.delete(complianceResults).where(eq(complianceResults.deviceId, deviceId)); + await db.delete(metricSnapshots).where(eq(metricSnapshots.deviceId, deviceId)); + await db.delete(inventorySnapshots).where(eq(inventorySnapshots.deviceId, deviceId)); + await db.delete(deviceCredentials).where(eq(deviceCredentials.deviceId, deviceId)); + + if (jobIdList.length > 0) { + await db.delete(jobTargets).where(eq(jobTargets.deviceId, deviceId)); + + const remainingTargets = await db + .select({ jobId: jobTargets.jobId }) + .from(jobTargets) + .where(inArray(jobTargets.jobId, jobIdList)); + + const remainingJobIds = new Set(remainingTargets.map((r) => r.jobId)); + const orphanedJobIds = jobIdList.filter((id) => !remainingJobIds.has(id)); + + if (orphanedJobIds.length > 0) { + await db.delete(jobs).where(inArray(jobs.id, orphanedJobIds)); + } + } + + await db.delete(devices).where(eq(devices.id, deviceId)); + + await auditLog({ + req, + organizationId: user.organizationId, + action: "device.delete", + targetType: "device", + targetId: deviceId, + deviceIds: [deviceId], + result: "success", + metadata: { name: device.name }, + }); + + res.status(204).send(); +}); + +devicesRouter.get("/:deviceId/jobs", authorize("read_only"), async (req, res) => { + const user = (req as any).user; + const deviceId = req.params.deviceId as string; + + const [device] = await db + .select({ id: devices.id }) + .from(devices) + .where(and(eq(devices.id, deviceId), eq(devices.organizationId, user.organizationId))) + .limit(1); + + if (!device) { + res.status(404).json({ success: false, error: { code: "NOT_FOUND", message: "Device not found" } }); + return; + } + + const deviceJobs = await db + .select({ + id: jobs.id, + jobType: jobs.jobType, + status: jobs.status, + parameters: jobs.parameters, + targetStatus: jobTargets.status, + targetResult: jobTargets.result, + dispatchedAt: jobs.dispatchedAt, + startedAt: jobs.startedAt, + completedAt: jobs.completedAt, + createdAt: jobs.createdAt, + }) + .from(jobTargets) + .innerJoin(jobs, eq(jobTargets.jobId, jobs.id)) + .where(eq(jobTargets.deviceId, deviceId)) + .orderBy(desc(jobs.createdAt)) + .limit(50); + + res.json({ success: true, data: deviceJobs }); +}); + +devicesRouter.get("/:deviceId/alerts", authorize("read_only"), async (req, res) => { + const user = (req as any).user; + const deviceId = req.params.deviceId as string; + + const [device] = await db + .select({ id: devices.id }) + .from(devices) + .where(and(eq(devices.id, deviceId), eq(devices.organizationId, user.organizationId))) + .limit(1); + + if (!device) { + res.status(404).json({ success: false, error: { code: "NOT_FOUND", message: "Device not found" } }); + return; + } + + const deviceAlerts = await db + .select() + .from(alerts) + .where(eq(alerts.deviceId, deviceId)) + .orderBy(desc(alerts.createdAt)) + .limit(50); + + res.json({ success: true, data: deviceAlerts }); +}); + +devicesRouter.get("/:deviceId/compliance", authorize("read_only"), async (req, res) => { + const user = (req as any).user; + const deviceId = req.params.deviceId as string; + + const [device] = await db + .select({ id: devices.id }) + .from(devices) + .where(and(eq(devices.id, deviceId), eq(devices.organizationId, user.organizationId))) + .limit(1); + + if (!device) { + res.status(404).json({ success: false, error: { code: "NOT_FOUND", message: "Device not found" } }); + return; + } + + const results = await db + .select({ + id: complianceResults.id, + status: complianceResults.status, + details: complianceResults.details, + evaluatedAt: complianceResults.evaluatedAt, + policyName: policies.name, + policyId: policies.id, + }) + .from(complianceResults) + .innerJoin(policies, eq(complianceResults.policyId, policies.id)) + .where(eq(complianceResults.deviceId, deviceId)) + .orderBy(desc(complianceResults.evaluatedAt)) + .limit(50); + + res.json({ success: true, data: results }); +}); diff --git a/apps/api/src/routes/downloads.ts b/apps/api/src/routes/downloads.ts new file mode 100644 index 0000000..21f6379 --- /dev/null +++ b/apps/api/src/routes/downloads.ts @@ -0,0 +1,126 @@ +import { Router } from "express"; +import { authenticate, authorize } from "../middleware/auth.js"; +import fs from "fs"; +import path from "path"; + +export const downloadsRouter = Router(); + +const AGENT_VERSION = "v0.1.0"; + +function resolveReleasesDir(): string { + const candidates = [ + process.env.AGENT_RELEASES_DIR, + path.resolve(process.cwd(), "../../infrastructure/agent-releases"), + "/home/administrator/Projekte/FleetForge/infrastructure/agent-releases", + ]; + for (const dir of candidates) { + if (dir && fs.existsSync(dir)) return dir; + } + return candidates[candidates.length - 1]!; +} + +const RELEASES_DIR = resolveReleasesDir(); +console.log("[downloads] RELEASES_DIR:", RELEASES_DIR, "exists:", fs.existsSync(RELEASES_DIR)); + +const PLATFORMS = [ + { + os: "linux", + arch: "amd64", + label: "Linux x86_64", + filename: "forge-agent-linux-amd64", + installHint: "chmod +x forge-agent-linux-amd64 && sudo mv forge-agent-linux-amd64 /usr/local/bin/forge-agent", + }, + { + os: "linux", + arch: "arm64", + label: "Linux ARM64", + filename: "forge-agent-linux-arm64", + installHint: "chmod +x forge-agent-linux-arm64 && sudo mv forge-agent-linux-arm64 /usr/local/bin/forge-agent", + }, + { + os: "windows", + arch: "amd64", + label: "Windows x86_64", + filename: "forge-agent-windows-amd64.exe", + installHint: "Move forge-agent-windows-amd64.exe to C:\\Program Files\\FleetForge\\forge-agent.exe", + }, + { + os: "darwin", + arch: "amd64", + label: "macOS Intel", + filename: "forge-agent-darwin-amd64", + installHint: "chmod +x forge-agent-darwin-amd64 && sudo mv forge-agent-darwin-amd64 /usr/local/bin/forge-agent", + }, + { + os: "darwin", + arch: "arm64", + label: "macOS Apple Silicon", + filename: "forge-agent-darwin-arm64", + installHint: "chmod +x forge-agent-darwin-arm64 && sudo mv forge-agent-darwin-arm64 /usr/local/bin/forge-agent", + }, +]; + +downloadsRouter.use(authenticate); + +downloadsRouter.get("/agent", authorize("read_only"), (_req, res) => { + const versionDir = path.join(RELEASES_DIR, AGENT_VERSION); + + console.log("[downloads] RELEASES_DIR:", RELEASES_DIR); + console.log("[downloads] versionDir:", versionDir); + console.log("[downloads] exists:", fs.existsSync(versionDir)); + + const platforms = PLATFORMS.map((p) => { + const filePath = path.join(versionDir, p.filename); + const exists = fs.existsSync(filePath); + const size = exists ? fs.statSync(filePath).size : 0; + return { + ...p, + available: exists, + size, + sizeFormatted: exists ? formatBytes(size) : "N/A", + }; + }); + + res.json({ + success: true, + data: { + version: AGENT_VERSION, + platforms, + }, + }); +}); + +downloadsRouter.get("/agent/:filename", authorize("read_only"), (req, res) => { + const filename = req.params.filename as string; + + const platform = PLATFORMS.find((p) => p.filename === filename); + if (!platform) { + res.status(404).json({ + success: false, + error: { code: "NOT_FOUND", message: "File not found" }, + }); + return; + } + + const filePath = path.join(RELEASES_DIR, AGENT_VERSION, filename); + if (!fs.existsSync(filePath)) { + res.status(404).json({ + success: false, + error: { code: "NOT_FOUND", message: "Binary not available for this platform" }, + }); + return; + } + + res.setHeader("Content-Disposition", `attachment; filename="${filename}"`); + res.setHeader("Content-Length", fs.statSync(filePath).size); + res.setHeader("Content-Type", "application/octet-stream"); + fs.createReadStream(filePath).pipe(res); +}); + +function formatBytes(bytes: number): string { + if (bytes === 0) return "0 B"; + const k = 1024; + const sizes = ["B", "KB", "MB", "GB"]; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + " " + sizes[i]; +} diff --git a/apps/api/src/routes/enrollment-tokens.ts b/apps/api/src/routes/enrollment-tokens.ts new file mode 100644 index 0000000..7bba5e6 --- /dev/null +++ b/apps/api/src/routes/enrollment-tokens.ts @@ -0,0 +1,133 @@ +import { Router } from "express"; +import { z } from "zod"; +import { db } from "../db/index.js"; +import { enrollmentTokens } from "../db/schema.js"; +import { authenticate, authorize } from "../middleware/auth.js"; +import { generateEnrollmentToken, hashToken } from "../lib/tokens.js"; +import { auditLog } from "../lib/audit.js"; +import { eq, and } from "drizzle-orm"; + +export const enrollmentTokensRouter = Router(); + +enrollmentTokensRouter.use(authenticate); + +enrollmentTokensRouter.get("/", authorize("admin"), async (req, res) => { + const user = (req as any).user; + + const tokens = await db + .select({ + id: enrollmentTokens.id, + expiresAt: enrollmentTokens.expiresAt, + maxUses: enrollmentTokens.maxUses, + useCount: enrollmentTokens.useCount, + isRevoked: enrollmentTokens.isRevoked, + label: enrollmentTokens.label, + createdAt: enrollmentTokens.createdAt, + }) + .from(enrollmentTokens) + .where(eq(enrollmentTokens.organizationId, user.organizationId)); + + res.json({ success: true, data: tokens }); +}); + +const createTokenSchema = z.object({ + label: z.string().max(255).optional(), + expiresInHours: z.number().min(1).max(720).default(24), + maxUses: z.number().min(1).max(100).nullable().optional(), +}); + +enrollmentTokensRouter.post("/", authorize("admin"), async (req, res) => { + try { + const body = createTokenSchema.parse(req.body); + const authUser = (req as any).user; + + const rawToken = generateEnrollmentToken(); + const tokenHash = hashToken(rawToken); + + const expiresAt = new Date(); + expiresAt.setHours(expiresAt.getHours() + body.expiresInHours); + + const [token] = await db + .insert(enrollmentTokens) + .values({ + organizationId: authUser.organizationId, + tokenHash, + expiresAt, + maxUses: body.maxUses ?? null, + label: body.label || null, + createdByUserId: authUser.userId, + }) + .returning({ + id: enrollmentTokens.id, + expiresAt: enrollmentTokens.expiresAt, + maxUses: enrollmentTokens.maxUses, + label: enrollmentTokens.label, + createdAt: enrollmentTokens.createdAt, + }); + + await auditLog({ + req, + organizationId: authUser.organizationId, + action: "enrollment_token.create", + targetType: "enrollment_token", + targetId: token.id, + result: "success", + }); + + res.status(201).json({ + success: true, + data: { + ...token, + token: rawToken, + }, + }); + } catch (err) { + if (err instanceof z.ZodError) { + res.status(400).json({ + success: false, + error: { code: "VALIDATION_ERROR", message: "Invalid request body", details: err.errors }, + }); + return; + } + throw err; + } +}); + +enrollmentTokensRouter.delete( + "/:tokenId", + authorize("admin"), + async (req, res) => { + const user = (req as any).user; + const tokenId = req.params.tokenId as string; + + const [revoked] = await db + .update(enrollmentTokens) + .set({ isRevoked: true }) + .where( + and( + eq(enrollmentTokens.id, tokenId), + eq(enrollmentTokens.organizationId, user.organizationId) + ) + ) + .returning({ id: enrollmentTokens.id }); + + if (!revoked) { + res.status(404).json({ + success: false, + error: { code: "NOT_FOUND", message: "Token not found" }, + }); + return; + } + + await auditLog({ + req, + organizationId: user.organizationId, + action: "enrollment_token.revoke", + targetType: "enrollment_token", + targetId: tokenId, + result: "success", + }); + + res.json({ success: true }); + } +); diff --git a/apps/api/src/routes/groups.ts b/apps/api/src/routes/groups.ts new file mode 100644 index 0000000..f5d50f4 --- /dev/null +++ b/apps/api/src/routes/groups.ts @@ -0,0 +1,88 @@ +import { Router } from "express"; +import { z } from "zod"; +import { db } from "../db/index.js"; +import { deviceGroups, devices } from "../db/schema.js"; +import { authenticate, authorize } from "../middleware/auth.js"; +import { eq, and } from "drizzle-orm"; + +export const groupsRouter = Router(); + +groupsRouter.use(authenticate); + +groupsRouter.get("/", authorize("read_only"), async (req, res) => { + const user = (req as any).user; + + const groups = await db + .select({ + id: deviceGroups.id, + name: deviceGroups.name, + description: deviceGroups.description, + createdAt: deviceGroups.createdAt, + }) + .from(deviceGroups) + .where(eq(deviceGroups.organizationId, user.organizationId)); + + res.json({ success: true, data: groups }); +}); + +const createGroupSchema = z.object({ + name: z.string().min(1).max(255), + description: z.string().optional(), +}); + +groupsRouter.post("/", authorize("admin"), async (req, res) => { + try { + const body = createGroupSchema.parse(req.body); + const authUser = (req as any).user; + + const [group] = await db + .insert(deviceGroups) + .values({ + organizationId: authUser.organizationId, + name: body.name, + description: body.description || null, + }) + .returning(); + + res.status(201).json({ success: true, data: group }); + } catch (err) { + if (err instanceof z.ZodError) { + res.status(400).json({ + success: false, + error: { code: "VALIDATION_ERROR", message: "Invalid request body", details: err.errors }, + }); + return; + } + throw err; + } +}); + +groupsRouter.delete("/:groupId", authorize("admin"), async (req, res) => { + const user = (req as any).user; + const groupId = req.params.groupId as string; + + await db + .update(devices) + .set({ deviceGroupId: null }) + .where(eq(devices.deviceGroupId, groupId)); + + const [deleted] = await db + .delete(deviceGroups) + .where( + and( + eq(deviceGroups.id, groupId), + eq(deviceGroups.organizationId, user.organizationId) + ) + ) + .returning({ id: deviceGroups.id }); + + if (!deleted) { + res.status(404).json({ + success: false, + error: { code: "NOT_FOUND", message: "Group not found" }, + }); + return; + } + + res.status(204).send(); +}); diff --git a/apps/api/src/routes/installers.ts b/apps/api/src/routes/installers.ts new file mode 100644 index 0000000..c6349e4 --- /dev/null +++ b/apps/api/src/routes/installers.ts @@ -0,0 +1,216 @@ +import { Router } from "express"; +import { z } from "zod"; +import { db } from "../db/index.js"; +import { enrollmentTokens } from "../db/schema.js"; +import { authenticate, authorize } from "../middleware/auth.js"; +import { generateEnrollmentToken, hashToken } from "../lib/tokens.js"; +import { auditLog } from "../lib/audit.js"; +import { eq } from "drizzle-orm"; +import { execFile } from "child_process"; +import { promisify } from "util"; +import fs from "fs"; +import path from "path"; +import os from "os"; + +const execFileAsync = promisify(execFile); + +export const installersRouter = Router(); +installersRouter.use(authenticate); + +const AGENT_VERSION = "0.1.0"; +const INSTALLER_DIR = path.resolve( + process.env.INSTALLER_DIR || path.join(process.cwd(), "../../infrastructure/installer") +); +const INSTALLER_CANDIDATES = [ + INSTALLER_DIR, + "/home/administrator/Projekte/FleetForge/infrastructure/installer", +]; +const AGENT_RELEASES_DIR = path.resolve( + process.env.AGENT_RELEASES_DIR || path.join(process.cwd(), "../../infrastructure/agent-releases") +); +const MSI_CACHE_DIR = path.resolve( + process.env.MSI_CACHE_DIR || path.join(os.tmpdir(), "fleetforge-msi-cache") +); + +function getAgentBinaryPath(): string { + const candidates = [ + path.join(AGENT_RELEASES_DIR, AGENT_VERSION, "forge-agent-windows-amd64.exe"), + path.join(AGENT_RELEASES_DIR, "forge-agent-windows-amd64.exe"), + "/home/administrator/Projekte/FleetForge/infrastructure/agent-releases/v0.1.0/forge-agent-windows-amd64.exe", + ]; + for (const p of candidates) { + if (fs.existsSync(p)) return p; + } + throw new Error("Windows agent binary not found in " + AGENT_RELEASES_DIR); +} + +const generateSchema = z.object({ + serverUrl: z.string().url(), +}); + +installersRouter.get("/", authorize("admin"), async (req, res) => { + try { + const user = (req as any).user; + const tokens = await db + .select({ + id: enrollmentTokens.id, + expiresAt: enrollmentTokens.expiresAt, + maxUses: enrollmentTokens.maxUses, + useCount: enrollmentTokens.useCount, + isRevoked: enrollmentTokens.isRevoked, + label: enrollmentTokens.label, + createdAt: enrollmentTokens.createdAt, + }) + .from(enrollmentTokens) + .where(eq(enrollmentTokens.organizationId, user.organizationId)); + + const installerFiles = fs.existsSync(MSI_CACHE_DIR) + ? fs.readdirSync(MSI_CACHE_DIR).filter((f) => f.endsWith(".msi")) + : []; + + res.json({ + success: true, + data: { + version: AGENT_VERSION, + tokens: tokens.map((t) => ({ + ...t, + label: t.label || "Installer Token", + })), + cachedInstallers: installerFiles.length, + }, + }); + } catch (err) { + console.error("[installers] List error:", err); + res.status(500).json({ + success: false, + error: { code: "INTERNAL_ERROR", message: "Failed to list installers" }, + }); + } +}); + +installersRouter.post("/generate", authorize("admin"), async (req, res) => { + try { + const user = (req as any).user; + const body = generateSchema.parse(req.body); + const serverUrl = body.serverUrl; + + const rawToken = generateEnrollmentToken(); + const tokenHash = hashToken(rawToken); + + const expiresAt = new Date(); + expiresAt.setDate(expiresAt.getDate() + 30); + + const [token] = await db + .insert(enrollmentTokens) + .values({ + organizationId: user.organizationId, + tokenHash, + expiresAt, + maxUses: 1, + label: `MSI Installer - ${serverUrl}`, + createdByUserId: user.userId, + }) + .returning({ + id: enrollmentTokens.id, + label: enrollmentTokens.label, + createdAt: enrollmentTokens.createdAt, + }); + + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "ff-installer-")); + try { + const configData = { + server_url: serverUrl, + enrollment_token: rawToken, + device_id: "", + credential: "", + label: "", + heartbeat_interval_seconds: 30, + }; + const configPath = path.join(tmpDir, "config.json"); + fs.writeFileSync(configPath, JSON.stringify(configData, null, 2)); + + const agentBinary = getAgentBinaryPath(); + const filename = `forge-agent-${user.organizationId.slice(0, 8)}-${Date.now()}.msi`; + const outputPath = path.join(tmpDir, filename); + + fs.copyFileSync(agentBinary, path.join(tmpDir, "forge-agent.exe")); + + console.log(`[installers] Generating MSI for org ${user.organizationId}...`); + + let wxsPath = ""; + for (const dir of INSTALLER_CANDIDATES) { + const p = path.join(dir, "agent.wxs"); + if (fs.existsSync(p)) { wxsPath = p; break; } + } + if (!wxsPath) { + throw new Error("WiX template not found at " + wxsPath); + } + + const processedWxsPath = path.join(tmpDir, "agent.wxs"); + const wxsContent = fs.readFileSync(wxsPath, "utf-8") + .replace(/__VERSION__/g, AGENT_VERSION.replace("v", "")); + fs.writeFileSync(processedWxsPath, wxsContent); + + const dockerCmd = `docker run --rm -v ${tmpDir}:/work fleetforge-wixl wixl -o /work/${filename} -D AgentBinaryPath=/work/forge-agent.exe -D ConfigFilePath=/work/config.json /work/agent.wxs`; + const { stdout, stderr } = await execFileAsync( + "sg", + ["docker", "-c", dockerCmd], + { + timeout: 120000, + env: { ...process.env }, + } + ); + + if (!fs.existsSync(outputPath)) { + throw new Error("MSI generation failed. Docker output: " + (stderr || stdout)); + } + + const msiSize = fs.statSync(outputPath).size; + if (msiSize < 1000) { + throw new Error("Generated MSI is too small (" + msiSize + " bytes), likely corrupt"); + } + + console.log(`[installers] MSI generated: ${filename} (${msiSize} bytes)`); + + res.setHeader("Content-Disposition", `attachment; filename="forge-agent-${user.organizationId.slice(0, 8)}.msi"`); + res.setHeader("Content-Length", msiSize); + res.setHeader("Content-Type", "application/x-msi"); + fs.createReadStream(outputPath).pipe(res); + + res.on("finish", () => { + try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch {} + }); + + await auditLog({ + req, + organizationId: user.organizationId, + action: "installer.generate", + targetType: "installer", + targetId: token.id, + result: "success", + metadata: { + serverUrl, + tokenId: token.id, + filename, + fileSize: msiSize, + }, + }); + } catch (err) { + try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch {} + throw err; + } + } catch (err) { + if (err instanceof z.ZodError) { + res.status(400).json({ + success: false, + error: { code: "VALIDATION_ERROR", message: "Invalid request body", details: err.errors }, + }); + return; + } + console.error("[installers] Generate error:", err); + res.status(500).json({ + success: false, + error: { code: "INTERNAL_ERROR", message: `Failed to generate installer: ${(err as Error).message}` }, + }); + } +}); diff --git a/apps/api/src/routes/jobs.ts b/apps/api/src/routes/jobs.ts new file mode 100644 index 0000000..035a4c4 --- /dev/null +++ b/apps/api/src/routes/jobs.ts @@ -0,0 +1,238 @@ +import { Router } from "express"; +import { z } from "zod"; +import { db } from "../db/index.js"; +import { jobs, jobTargets, devices } from "../db/schema.js"; +import { authenticate, authorize } from "../middleware/auth.js"; +import { auditLog } from "../lib/audit.js"; +import { eq, and, desc, count, inArray } from "drizzle-orm"; + +export const jobsRouter = Router(); + +jobsRouter.use(authenticate); + +jobsRouter.get("/", authorize("read_only"), async (req, res) => { + const user = (req as any).user; + const page = parseInt(req.query.page as string) || 1; + const limit = Math.min(parseInt(req.query.limit as string) || 20, 100); + const offset = (page - 1) * limit; + + const [totalResult] = await db + .select({ total: count() }) + .from(jobs) + .where(eq(jobs.organizationId, user.organizationId)); + + const allJobs = await db + .select() + .from(jobs) + .where(eq(jobs.organizationId, user.organizationId)) + .orderBy(desc(jobs.createdAt)) + .limit(limit) + .offset(offset); + + res.json({ + success: true, + data: allJobs, + meta: { + page, + limit, + total: totalResult.total, + totalPages: Math.ceil(totalResult.total / limit), + }, + }); +}); + +const createJobSchema = z.object({ + jobType: z.enum([ + "update_inventory", + "update_system_info", + "check_service_status", + "start_service", + "stop_service", + "restart_service", + "install_package", + "update_package", + "remove_package", + "reboot_system", + "shutdown_system", + "start_remote_session", + "stop_remote_session", + "install_from_file", + "download_and_install", + "install_registry_package", + ]), + deviceIds: z.array(z.string().uuid()).min(1), + parameters: z.record(z.unknown()).default({}), + expiresInMinutes: z.number().min(1).max(1440).optional(), +}); + +jobsRouter.post("/", authorize("operator"), async (req, res) => { + try { + const body = createJobSchema.parse(req.body); + const authUser = (req as any).user; + + const expiresAt = body.expiresInMinutes + ? new Date(Date.now() + body.expiresInMinutes * 60 * 1000) + : null; + + const [job] = await db + .insert(jobs) + .values({ + organizationId: authUser.organizationId, + createdByUserId: authUser.userId, + jobType: body.jobType, + parameters: body.parameters, + expiresAt, + }) + .returning(); + + const targets = body.deviceIds.map((deviceId) => ({ + jobId: job.id, + deviceId, + status: "queued" as const, + })); + + await db.insert(jobTargets).values(targets); + + await auditLog({ + req, + organizationId: authUser.organizationId, + action: "job.create", + targetType: "job", + targetId: job.id, + deviceIds: body.deviceIds, + result: "success", + metadata: { jobType: body.jobType }, + }); + + res.status(201).json({ success: true, data: job }); + } catch (err) { + if (err instanceof z.ZodError) { + res.status(400).json({ + success: false, + error: { code: "VALIDATION_ERROR", message: "Invalid request body", details: err.errors }, + }); + return; + } + throw err; + } +}); + +jobsRouter.get("/:jobId", authorize("read_only"), async (req, res) => { + const user = (req as any).user; + const jobId = req.params.jobId as string; + + const [job] = await db + .select() + .from(jobs) + .where(and(eq(jobs.id, jobId), eq(jobs.organizationId, user.organizationId))) + .limit(1); + + if (!job) { + res.status(404).json({ + success: false, + error: { code: "NOT_FOUND", message: "Job not found" }, + }); + return; + } + + const targets = await db + .select({ + id: jobTargets.id, + jobId: jobTargets.jobId, + deviceId: jobTargets.deviceId, + deviceName: devices.name, + status: jobTargets.status, + result: jobTargets.result, + startedAt: jobTargets.startedAt, + completedAt: jobTargets.completedAt, + createdAt: jobTargets.createdAt, + }) + .from(jobTargets) + .leftJoin(devices, eq(jobTargets.deviceId, devices.id)) + .where(eq(jobTargets.jobId, jobId)); + + res.json({ success: true, data: { ...job, targets } }); +}); + +jobsRouter.patch("/:jobId/cancel", authorize("operator"), async (req, res) => { + const user = (req as any).user; + const jobId = req.params.jobId as string; + + const [updated] = await db + .update(jobs) + .set({ status: "cancelled", completedAt: new Date() }) + .where( + and( + eq(jobs.id, jobId), + eq(jobs.organizationId, user.organizationId), + inArray(jobs.status, ["queued", "dispatched", "running"]) + ) + ) + .returning({ id: jobs.id }); + + if (!updated) { + res.status(404).json({ + success: false, + error: { code: "NOT_FOUND", message: "Job not found or not cancellable" }, + }); + return; + } + + await db + .update(jobTargets) + .set({ status: "cancelled", completedAt: new Date() }) + .where( + and( + eq(jobTargets.jobId, jobId), + inArray(jobTargets.status, ["queued", "dispatched", "running"]) + ) + ); + + await auditLog({ + req, + organizationId: user.organizationId, + action: "job.cancel", + targetType: "job", + targetId: jobId, + result: "success", + metadata: { message: "Job cancelled" }, + }); + + res.json({ success: true }); +}); + +jobsRouter.delete("/:jobId", authorize("operator"), async (req, res) => { + const user = (req as any).user; + const jobId = req.params.jobId as string; + + const [existing] = await db + .select({ id: jobs.id }) + .from(jobs) + .where( + and(eq(jobs.id, jobId), eq(jobs.organizationId, user.organizationId)) + ) + .limit(1); + + if (!existing) { + res.status(404).json({ + success: false, + error: { code: "NOT_FOUND", message: "Job not found" }, + }); + return; + } + + await db.delete(jobTargets).where(eq(jobTargets.jobId, jobId)); + await db.delete(jobs).where(eq(jobs.id, jobId)); + + await auditLog({ + req, + organizationId: user.organizationId, + action: "job.delete", + targetType: "job", + targetId: jobId, + result: "success", + metadata: { message: "Job deleted" }, + }); + + res.json({ success: true }); +}); diff --git a/apps/api/src/routes/policies.ts b/apps/api/src/routes/policies.ts new file mode 100644 index 0000000..82b5a2d --- /dev/null +++ b/apps/api/src/routes/policies.ts @@ -0,0 +1,201 @@ +import { Router } from "express"; +import { z } from "zod"; +import { db } from "../db/index.js"; +import { policies, complianceResults } from "../db/schema.js"; +import { authenticate, authorize } from "../middleware/auth.js"; +import { eq, and, desc } from "drizzle-orm"; + +export const policiesRouter = Router(); + +policiesRouter.use(authenticate); + +policiesRouter.get("/", authorize("read_only"), async (req, res) => { + const user = (req as any).user; + + const allPolicies = await db + .select() + .from(policies) + .where(eq(policies.organizationId, user.organizationId)); + + res.json({ success: true, data: allPolicies }); +}); + +const policyRuleSchema = z.object({ + type: z.string(), + condition: z.string(), + value: z.union([z.string(), z.number(), z.boolean()]), +}); + +const createPolicySchema = z.object({ + name: z.string().min(1).max(255), + description: z.string().optional(), + rules: z.array(policyRuleSchema).default([]), + isEnabled: z.boolean().default(true), +}); + +const updatePolicySchema = z.object({ + name: z.string().min(1).max(255).optional(), + description: z.string().nullable().optional(), + rules: z.array(policyRuleSchema).optional(), + isEnabled: z.boolean().optional(), +}); + +policiesRouter.get("/:policyId", authorize("read_only"), async (req, res) => { + const user = (req as any).user; + const policyId = req.params.policyId as string; + + const [policy] = await db + .select() + .from(policies) + .where( + and(eq(policies.id, policyId), eq(policies.organizationId, user.organizationId)) + ); + + if (!policy) { + res.status(404).json({ + success: false, + error: { code: "NOT_FOUND", message: "Policy not found" }, + }); + return; + } + + res.json({ success: true, data: policy }); +}); + +policiesRouter.post("/", authorize("admin"), async (req, res) => { + try { + const body = createPolicySchema.parse(req.body); + const authUser = (req as any).user; + + const [policy] = await db + .insert(policies) + .values({ + organizationId: authUser.organizationId, + name: body.name, + description: body.description || null, + rules: body.rules, + isEnabled: body.isEnabled, + }) + .returning(); + + res.status(201).json({ success: true, data: policy }); + } catch (err) { + if (err instanceof z.ZodError) { + res.status(400).json({ + success: false, + error: { code: "VALIDATION_ERROR", message: "Invalid request body", details: err.errors }, + }); + return; + } + throw err; + } +}); + +policiesRouter.put("/:policyId", authorize("admin"), async (req, res) => { + try { + const user = (req as any).user; + const policyId = req.params.policyId as string; + const body = updatePolicySchema.parse(req.body); + + const [existing] = await db + .select() + .from(policies) + .where( + and(eq(policies.id, policyId), eq(policies.organizationId, user.organizationId)) + ); + + if (!existing) { + res.status(404).json({ + success: false, + error: { code: "NOT_FOUND", message: "Policy not found" }, + }); + return; + } + + const updates: Record = {}; + if (body.name !== undefined) updates.name = body.name; + if (body.description !== undefined) updates.description = body.description; + if (body.rules !== undefined) updates.rules = body.rules; + if (body.isEnabled !== undefined) updates.isEnabled = body.isEnabled; + + if (Object.keys(updates).length === 0) { + res.json({ success: true, data: existing }); + return; + } + + updates.updatedAt = new Date(); + + const [updated] = await db + .update(policies) + .set(updates) + .where( + and(eq(policies.id, policyId), eq(policies.organizationId, user.organizationId)) + ) + .returning(); + + res.json({ success: true, data: updated }); + } catch (err) { + if (err instanceof z.ZodError) { + res.status(400).json({ + success: false, + error: { code: "VALIDATION_ERROR", message: "Invalid request body", details: err.errors }, + }); + return; + } + throw err; + } +}); + +policiesRouter.delete("/:policyId", authorize("admin"), async (req, res) => { + const user = (req as any).user; + const policyId = req.params.policyId as string; + + const [deleted] = await db + .delete(policies) + .where( + and(eq(policies.id, policyId), eq(policies.organizationId, user.organizationId)) + ) + .returning({ id: policies.id }); + + if (!deleted) { + res.status(404).json({ + success: false, + error: { code: "NOT_FOUND", message: "Policy not found" }, + }); + return; + } + + res.status(204).send(); +}); + +policiesRouter.get( + "/:policyId/compliance", + authorize("read_only"), + async (req, res) => { + const user = (req as any).user; + const policyId = req.params.policyId as string; + + const [policy] = await db + .select() + .from(policies) + .where( + and(eq(policies.id, policyId), eq(policies.organizationId, user.organizationId)) + ); + + if (!policy) { + res.status(404).json({ + success: false, + error: { code: "NOT_FOUND", message: "Policy not found" }, + }); + return; + } + + const results = await db + .select() + .from(complianceResults) + .where(eq(complianceResults.policyId, policyId)) + .orderBy(desc(complianceResults.evaluatedAt)); + + res.json({ success: true, data: results }); + } +); diff --git a/apps/api/src/routes/remote.ts b/apps/api/src/routes/remote.ts new file mode 100644 index 0000000..eb39621 --- /dev/null +++ b/apps/api/src/routes/remote.ts @@ -0,0 +1,274 @@ +import { Router } from "express"; +import { z } from "zod"; +import { db } from "../db/index.js"; +import { + remoteSessions, + jobs, + jobTargets, + devices, +} from "../db/schema.js"; +import { authenticate, authorize } from "../middleware/auth.js"; +import { auditLog } from "../lib/audit.js"; +import { eq, and, desc } from "drizzle-orm"; +import { randomBytes } from "crypto"; + +export const remoteRouter = Router(); + +remoteRouter.use(authenticate); + +const createSessionSchema = z.object({ + deviceId: z.string().uuid(), + vncPort: z.number().int().min(1).max(65535).default(5900), +}); + +const ERROR_MSG = (code: string, message: string) => + ({ success: false, error: { code, message } }); + +remoteRouter.post("/sessions", authorize("operator"), async (req, res) => { + try { + const body = createSessionSchema.parse(req.body); + const user = (req as any).user; + + const [device] = await db + .select() + .from(devices) + .where( + and(eq(devices.id, body.deviceId), eq(devices.organizationId, user.organizationId)) + ) + .limit(1); + + if (!device) { + res.status(404).json(ERROR_MSG("NOT_FOUND", "Device not found")); + return; + } + + await db + .update(remoteSessions) + .set({ status: "closed", endedAt: new Date() }) + .where( + and( + eq(remoteSessions.deviceId, body.deviceId), + eq(remoteSessions.status, "pending") + ) + ); + await db + .update(remoteSessions) + .set({ status: "closed", endedAt: new Date() }) + .where( + and( + eq(remoteSessions.deviceId, body.deviceId), + eq(remoteSessions.status, "starting") + ) + ); + await db + .update(remoteSessions) + .set({ status: "closed", endedAt: new Date() }) + .where( + and( + eq(remoteSessions.deviceId, body.deviceId), + eq(remoteSessions.status, "active") + ) + ); + await db + .update(remoteSessions) + .set({ status: "closed", endedAt: new Date() }) + .where( + and( + eq(remoteSessions.deviceId, body.deviceId), + eq(remoteSessions.status, "closing") + ) + ); + + const vncPassword = randomBytes(4).toString("hex"); + + const [session] = await db + .insert(remoteSessions) + .values({ + organizationId: user.organizationId, + deviceId: body.deviceId, + createdByUserId: user.userId, + status: "pending", + vncPort: body.vncPort, + vncPassword, + }) + .returning(); + + const [job] = await db + .insert(jobs) + .values({ + organizationId: user.organizationId, + createdByUserId: user.userId, + jobType: "start_remote_session", + parameters: { + session_id: session.id, + vnc_port: body.vncPort, + vnc_password: vncPassword, + }, + status: "dispatched", + dispatchedAt: new Date(), + }) + .returning(); + + await db.insert(jobTargets).values({ + jobId: job.id, + deviceId: body.deviceId, + status: "dispatched", + }); + + await db + .update(remoteSessions) + .set({ jobId: job.id }) + .where(eq(remoteSessions.id, session.id)); + + await auditLog({ + req, + organizationId: user.organizationId, + action: "remote.session.create", + targetType: "remote_session", + targetId: session.id, + deviceIds: [body.deviceId], + result: "success", + metadata: { vncPort: body.vncPort }, + }); + + res.status(201).json({ + success: true, + data: { + id: session.id, + deviceId: session.deviceId, + status: session.status, + vncPort: session.vncPort, + vncPassword: session.vncPassword, + }, + }); + } catch (err) { + if (err instanceof z.ZodError) { + res.status(400).json(ERROR_MSG("VALIDATION_ERROR", "Invalid request body")); + return; + } + throw err; + } +}); + +remoteRouter.get("/sessions", authorize("read_only"), async (req, res) => { + const user = (req as any).user; + + const all = await db + .select({ + id: remoteSessions.id, + deviceId: remoteSessions.deviceId, + status: remoteSessions.status, + vncPort: remoteSessions.vncPort, + startedAt: remoteSessions.startedAt, + endedAt: remoteSessions.endedAt, + createdAt: remoteSessions.createdAt, + deviceName: devices.name, + }) + .from(remoteSessions) + .innerJoin(devices, eq(devices.id, remoteSessions.deviceId)) + .where(eq(remoteSessions.organizationId, user.organizationId)) + .orderBy(desc(remoteSessions.createdAt)) + .limit(50); + + res.json({ success: true, data: all }); +}); + +remoteRouter.get("/sessions/:sessionId", authorize("read_only"), async (req, res) => { + const user = (req as any).user; + const sessionId = req.params.sessionId as string; + + const [row] = await db + .select({ + id: remoteSessions.id, + deviceId: remoteSessions.deviceId, + status: remoteSessions.status, + vncPort: remoteSessions.vncPort, + vncPassword: remoteSessions.vncPassword, + errorMessage: remoteSessions.errorMessage, + startedAt: remoteSessions.startedAt, + endedAt: remoteSessions.endedAt, + createdAt: remoteSessions.createdAt, + deviceName: devices.name, + }) + .from(remoteSessions) + .innerJoin(devices, eq(devices.id, remoteSessions.deviceId)) + .where( + and( + eq(remoteSessions.id, sessionId), + eq(remoteSessions.organizationId, user.organizationId) + ) + ) + .limit(1); + + if (!row) { + res.status(404).json(ERROR_MSG("NOT_FOUND", "Session not found")); + return; + } + + res.json({ success: true, data: row }); +}); + +remoteRouter.delete("/sessions/:sessionId", authorize("operator"), async (req, res) => { + const user = (req as any).user; + const sessionId = req.params.sessionId as string; + + const [session] = await db + .select() + .from(remoteSessions) + .where( + and( + eq(remoteSessions.id, sessionId), + eq(remoteSessions.organizationId, user.organizationId) + ) + ) + .limit(1); + + if (!session) { + res.status(404).json(ERROR_MSG("NOT_FOUND", "Session not found")); + return; + } + + if (session.status === "active" || session.status === "starting" || session.status === "pending") { + const [job] = await db + .insert(jobs) + .values({ + organizationId: user.organizationId, + createdByUserId: user.userId, + jobType: "stop_remote_session", + parameters: { session_id: session.id }, + status: "dispatched", + dispatchedAt: new Date(), + }) + .returning(); + + await db.insert(jobTargets).values({ + jobId: job.id, + deviceId: session.deviceId, + status: "dispatched", + }); + + await db + .update(remoteSessions) + .set({ status: "closing" }) + .where(eq(remoteSessions.id, session.id)); + } else { + await db + .update(remoteSessions) + .set({ status: "closed", endedAt: new Date() }) + .where(eq(remoteSessions.id, session.id)); + } + + await auditLog({ + req, + organizationId: user.organizationId, + action: "remote.session.delete", + targetType: "remote_session", + targetId: session.id, + deviceIds: [session.deviceId], + result: "success", + }); + + res.json({ success: true, data: { id: session.id, status: "closing" } }); +}); + +export { remoteSessions }; \ No newline at end of file diff --git a/apps/api/src/routes/software.ts b/apps/api/src/routes/software.ts new file mode 100644 index 0000000..31fc65f --- /dev/null +++ b/apps/api/src/routes/software.ts @@ -0,0 +1,414 @@ +import { Router } from "express"; +import multer from "multer"; +import path from "path"; +import fs from "fs"; +import crypto from "crypto"; +import { fileURLToPath } from "url"; +import { db } from "../db/index.js"; +import { devices, softwarePackages } from "../db/schema.js"; +import { authenticate, authorize } from "../middleware/auth.js"; +import { eq, and, sql, desc, ilike, count } from "drizzle-orm"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +const SOFTWARE_DIR = path.resolve( + process.env.SOFTWARE_DIR || path.join(__dirname, "../../../../infrastructure/software-releases") +); + +if (!fs.existsSync(SOFTWARE_DIR)) { + fs.mkdirSync(SOFTWARE_DIR, { recursive: true }); +} + +const ALLOWED_EXTENSIONS: Record = { + windows: [".msi", ".exe", ".msix", ".appx"], + linux: [".deb", ".rpm", ".tar.gz", ".tar.xz"], + macos: [".dmg", ".pkg", ".tar.gz"], +}; + +const storage = multer.diskStorage({ + destination: (_req, _file, cb) => { + const orgDir = path.join(SOFTWARE_DIR, (_req as any).user?.organizationId || "default"); + fs.mkdirSync(orgDir, { recursive: true }); + cb(null, orgDir); + }, + filename: (_req, file, cb) => { + const unique = crypto.randomBytes(8).toString("hex"); + const ext = path.extname(file.originalname); + cb(null, `${Date.now()}-${unique}${ext}`); + }, +}); + +const upload = multer({ + storage, + limits: { fileSize: 2 * 1024 * 1024 * 1024 }, + fileFilter: (_req, file, cb) => { + const ext = path.extname(file.originalname).toLowerCase(); + const allAllowed = Object.values(ALLOWED_EXTENSIONS).flat(); + if (allAllowed.includes(ext)) { + cb(null, true); + } else { + cb(new Error(`File type ${ext} not allowed. Supported: ${allAllowed.join(", ")}`)); + } + }, +}); + +export const softwareRouter = Router(); + +softwareRouter.use(authenticate); + +softwareRouter.get("/inventory", authorize("read_only"), async (req, res) => { + const user = (req as any).user; + const packageName = req.query.package as string | undefined; + + const { rows: latestSnapshots } = await db.execute(sql` + SELECT DISTINCT ON (device_id) device_id, packages + FROM inventory_snapshots + WHERE device_id IN ( + SELECT id FROM devices + WHERE organization_id = ${user.organizationId} + AND is_disabled = false AND is_archived = false + ) + ORDER BY device_id, reported_at DESC + `); + + const packageMap = new Map; + }>(); + + const { rows: deviceNames } = await db.execute(sql` + SELECT id, name FROM devices + WHERE organization_id = ${user.organizationId} + `); + + const nameMap = new Map(); + for (const d of deviceNames as any[]) { + nameMap.set(d.id, d.name); + } + + for (const snapshot of latestSnapshots as any[]) { + const pkgs = (snapshot.packages || []) as any[]; + for (const pkg of pkgs) { + if (packageName && pkg.name !== packageName) continue; + + const key = `${pkg.name}@${pkg.version}@${pkg.manager}`; + if (packageMap.has(key)) { + packageMap.get(key)!.devices.push({ + deviceId: snapshot.device_id, + deviceName: nameMap.get(snapshot.device_id) || snapshot.device_id, + }); + } else { + packageMap.set(key, { + name: pkg.name, + version: pkg.version, + manager: pkg.manager, + devices: [{ + deviceId: snapshot.device_id, + deviceName: nameMap.get(snapshot.device_id) || snapshot.device_id, + }], + }); + } + } + } + + const packages = Array.from(packageMap.values()).sort((a, b) => + a.name.localeCompare(b.name) + ); + + const uniqueNames = new Set(packages.map((p) => p.name)); + const summary = Array.from(uniqueNames).map((name) => { + const versions = packages.filter((p) => p.name === name); + return { + name, + versions: versions.map((v) => ({ + version: v.version, + manager: v.manager, + deviceCount: v.devices.length, + devices: v.devices, + })), + totalDevices: versions.reduce((sum, v) => sum + v.devices.length, 0), + }; + }); + + res.json({ success: true, data: summary }); +}); + +softwareRouter.get("/inventory/devices/:deviceId", authorize("read_only"), async (req, res) => { + const user = (req as any).user; + const deviceId = req.params.deviceId as string; + + const [device] = await db + .select({ id: devices.id, name: devices.name }) + .from(devices) + .where( + and( + eq(devices.id, deviceId), + eq(devices.organizationId, user.organizationId) + ) + ) + .limit(1); + + if (!device) { + res.status(404).json({ + success: false, + error: { code: "NOT_FOUND", message: "Device not found" }, + }); + return; + } + + const { rows: snapshots } = await db.execute(sql` + SELECT packages, services, reported_at + FROM inventory_snapshots + WHERE device_id = ${deviceId} + ORDER BY reported_at DESC + LIMIT 1 + `); + + const latest = (snapshots as any[])[0] || null; + + res.json({ + success: true, + data: { + deviceId: device.id, + deviceName: device.name, + packages: latest?.packages || [], + services: latest?.services || [], + reportedAt: latest?.reported_at || null, + }, + }); +}); + +softwareRouter.get("/services", authorize("read_only"), async (req, res) => { + const user = (req as any).user; + + const { rows: latestSnapshots } = await db.execute(sql` + SELECT DISTINCT ON (device_id) device_id, services + FROM inventory_snapshots + WHERE device_id IN ( + SELECT id FROM devices + WHERE organization_id = ${user.organizationId} + AND is_disabled = false AND is_archived = false + ) + ORDER BY device_id, reported_at DESC + `); + + const serviceMap = new Map; + }>(); + + const { rows: deviceNames } = await db.execute(sql` + SELECT id, name FROM devices + WHERE organization_id = ${user.organizationId} + `); + + const nameMap = new Map(); + for (const d of deviceNames as any[]) { + nameMap.set(d.id, d.name); + } + + for (const snapshot of latestSnapshots as any[]) { + const svcs = (snapshot.services || []) as any[]; + for (const svc of svcs) { + const key = `${svc.name}@${svc.status}`; + if (serviceMap.has(key)) { + serviceMap.get(key)!.devices.push({ + deviceId: snapshot.device_id, + deviceName: nameMap.get(snapshot.device_id) || snapshot.device_id, + }); + } else { + serviceMap.set(key, { + name: svc.name, + status: svc.status, + enabled: svc.enabled, + devices: [{ + deviceId: snapshot.device_id, + deviceName: nameMap.get(snapshot.device_id) || snapshot.device_id, + }], + }); + } + } + } + + const services = Array.from(serviceMap.values()).sort((a, b) => + a.name.localeCompare(b.name) + ); + + res.json({ success: true, data: services }); +}); + +softwareRouter.get("/repository", authorize("read_only"), async (req, res) => { + const user = (req as any).user; + const page = parseInt(req.query.page as string) || 1; + const limit = Math.min(parseInt(req.query.limit as string) || 50, 100); + const offset = (page - 1) * limit; + const search = req.query.search as string | undefined; + const platform = req.query.platform as string | undefined; + + let whereClause = eq(softwarePackages.organizationId, user.organizationId); + + if (search) { + whereClause = and(whereClause, ilike(softwarePackages.name, `%${search}%`))!; + } + if (platform) { + whereClause = and(whereClause, eq(softwarePackages.platform, platform))!; + } + + const [totalResult] = await db + .select({ total: count() }) + .from(softwarePackages) + .where(whereClause); + + const pkgs = await db + .select() + .from(softwarePackages) + .where(whereClause) + .orderBy(desc(softwarePackages.createdAt)) + .limit(limit) + .offset(offset); + + res.json({ + success: true, + data: pkgs, + meta: { page, limit, total: totalResult.total, totalPages: Math.ceil(totalResult.total / limit) }, + }); +}); + +softwareRouter.post("/repository", authorize("admin"), upload.single("file"), async (req, res) => { + try { + const user = (req as any).user; + const file = req.file; + if (!file) { + res.status(400).json({ + success: false, + error: { code: "VALIDATION_ERROR", message: "No file uploaded" }, + }); + return; + } + + const body = req.body; + const name = body.name || path.basename(file.originalname, path.extname(file.originalname)); + const version = body.version || "1.0.0"; + const packageType = body.package_type || path.extname(file.originalname).replace(".", ""); + const platform = body.platform || detectPlatform(file.originalname); + const architecture = body.architecture || "amd64"; + + const checksum = crypto.createHash("sha256").update(fs.readFileSync(file.path)).digest("hex"); + + const [pkg] = await db + .insert(softwarePackages) + .values({ + organizationId: user.organizationId, + name, + description: body.description || null, + version, + packageType, + platform, + architecture, + filename: file.filename, + fileSize: file.size, + checksum, + installCommand: body.install_command || null, + tags: body.tags ? JSON.parse(body.tags) : [], + uploadedByUserId: user.userId, + }) + .returning(); + + res.status(201).json({ success: true, data: pkg }); + } catch (err: any) { + console.error("[software/upload] Error:", err); + res.status(500).json({ + success: false, + error: { code: "INTERNAL_ERROR", message: err.message || "Upload failed" }, + }); + } +}); + +softwareRouter.get("/repository/:packageId/download", authorize("read_only"), async (req, res) => { + const user = (req as any).user; + const packageId = req.params.packageId as string; + + const [pkg] = await db + .select() + .from(softwarePackages) + .where( + and( + eq(softwarePackages.id, packageId), + eq(softwarePackages.organizationId, user.organizationId) + ) + ) + .limit(1); + + if (!pkg) { + res.status(404).json({ + success: false, + error: { code: "NOT_FOUND", message: "Package not found" }, + }); + return; + } + + const orgDir = path.join(SOFTWARE_DIR, user.organizationId); + const filePath = path.join(orgDir, pkg.filename); + + if (!fs.existsSync(filePath)) { + res.status(404).json({ + success: false, + error: { code: "NOT_FOUND", message: "File not found on disk" }, + }); + return; + } + + res.setHeader("Content-Disposition", `attachment; filename="${pkg.name}-${pkg.version}${path.extname(pkg.filename)}"`); + res.setHeader("Content-Type", "application/octet-stream"); + res.setHeader("Content-Length", pkg.fileSize); + fs.createReadStream(filePath).pipe(res); +}); + +softwareRouter.delete("/repository/:packageId", authorize("admin"), async (req, res) => { + const user = (req as any).user; + const packageId = req.params.packageId as string; + + const [pkg] = await db + .select() + .from(softwarePackages) + .where( + and( + eq(softwarePackages.id, packageId), + eq(softwarePackages.organizationId, user.organizationId) + ) + ) + .limit(1); + + if (!pkg) { + res.status(404).json({ + success: false, + error: { code: "NOT_FOUND", message: "Package not found" }, + }); + return; + } + + const orgDir = path.join(SOFTWARE_DIR, user.organizationId); + const filePath = path.join(orgDir, pkg.filename); + if (fs.existsSync(filePath)) { + fs.unlinkSync(filePath); + } + + await db + .delete(softwarePackages) + .where(eq(softwarePackages.id, packageId)); + + res.status(204).send(); +}); + +function detectPlatform(filename: string): string { + const ext = path.extname(filename).toLowerCase(); + if ([".msi", ".exe", ".msix", ".appx"].includes(ext)) return "windows"; + if ([".deb", ".rpm"].includes(ext)) return "linux"; + if ([".dmg", ".pkg"].includes(ext)) return "macos"; + return "unknown"; +} diff --git a/apps/api/src/routes/update-requests.ts b/apps/api/src/routes/update-requests.ts new file mode 100644 index 0000000..b1b74bd --- /dev/null +++ b/apps/api/src/routes/update-requests.ts @@ -0,0 +1,340 @@ +import { Router } from "express"; +import { z } from "zod"; +import { db } from "../db/index.js"; +import { updateRequests, devices, users, deployments, deploymentTargets } from "../db/schema.js"; +import { authenticate, authorize } from "../middleware/auth.js"; +import { auditLog } from "../lib/audit.js"; +import { eq, and, desc, count } from "drizzle-orm"; + +export const updateRequestsRouter = Router(); + +updateRequestsRouter.use(authenticate); + +updateRequestsRouter.get("/", authorize("read_only"), async (req, res) => { + const user = (req as any).user; + const page = parseInt(req.query.page as string) || 1; + const limit = Math.min(parseInt(req.query.limit as string) || 20, 100); + const offset = (page - 1) * limit; + const status = req.query.status as string | undefined; + + const whereConditions = [eq(updateRequests.organizationId, user.organizationId)]; + if (status) { + whereConditions.push(eq(updateRequests.status, status)); + } + + const [totalResult] = await db + .select({ total: count() }) + .from(updateRequests) + .where(and(...whereConditions)); + + const requests = await db + .select({ + id: updateRequests.id, + organizationId: updateRequests.organizationId, + deviceId: updateRequests.deviceId, + packageName: updateRequests.packageName, + packageManager: updateRequests.packageManager, + currentVersion: updateRequests.currentVersion, + targetVersion: updateRequests.targetVersion, + requestedByUserId: updateRequests.requestedByUserId, + status: updateRequests.status, + approvedByUserId: updateRequests.approvedByUserId, + approvedAt: updateRequests.approvedAt, + rejectionReason: updateRequests.rejectionReason, + deploymentId: updateRequests.deploymentId, + notes: updateRequests.notes, + createdAt: updateRequests.createdAt, + updatedAt: updateRequests.updatedAt, + deviceName: devices.name, + requestedByName: users.displayName, + }) + .from(updateRequests) + .leftJoin(devices, eq(updateRequests.deviceId, devices.id)) + .leftJoin(users, eq(updateRequests.requestedByUserId, users.id)) + .where(and(...whereConditions)) + .orderBy(desc(updateRequests.createdAt)) + .limit(limit) + .offset(offset); + + res.json({ + success: true, + data: requests, + meta: { + page, + limit, + total: totalResult.total, + totalPages: Math.ceil(totalResult.total / limit), + }, + }); +}); + +const createRequestSchema = z.object({ + deviceId: z.string().uuid(), + packageName: z.string().min(1).max(255), + packageManager: z.string().default("apt"), + currentVersion: z.string().max(100).optional(), + targetVersion: z.string().min(1).max(100), + notes: z.string().optional(), +}); + +updateRequestsRouter.post("/", authorize("operator"), async (req, res) => { + try { + const body = createRequestSchema.parse(req.body); + const authUser = (req as any).user; + + const [device] = await db + .select({ id: devices.id, organizationId: devices.organizationId }) + .from(devices) + .where( + and( + eq(devices.id, body.deviceId), + eq(devices.organizationId, authUser.organizationId) + ) + ) + .limit(1); + + if (!device) { + res.status(404).json({ + success: false, + error: { code: "NOT_FOUND", message: "Device not found" }, + }); + return; + } + + const [request] = await db + .insert(updateRequests) + .values({ + organizationId: authUser.organizationId, + deviceId: body.deviceId, + packageName: body.packageName, + packageManager: body.packageManager, + currentVersion: body.currentVersion || null, + targetVersion: body.targetVersion, + requestedByUserId: authUser.userId, + notes: body.notes || null, + }) + .returning(); + + await auditLog({ + req, + organizationId: authUser.organizationId, + action: "update_request.create", + targetType: "update_request", + targetId: request.id, + deviceIds: [body.deviceId], + result: "success", + metadata: { + packageName: body.packageName, + targetVersion: body.targetVersion, + }, + }); + + res.status(201).json({ success: true, data: request }); + } catch (err) { + if (err instanceof z.ZodError) { + res.status(400).json({ + success: false, + error: { code: "VALIDATION_ERROR", message: "Invalid request body", details: err.errors }, + }); + return; + } + throw err; + } +}); + +const approveSchema = z.object({ + deployNow: z.boolean().default(false), +}); + +updateRequestsRouter.patch("/:requestId/approve", authorize("operator"), async (req, res) => { + try { + const body = approveSchema.parse(req.body); + const user = (req as any).user; + const requestId = req.params.requestId as string; + + const [existing] = await db + .select() + .from(updateRequests) + .where( + and( + eq(updateRequests.id, requestId), + eq(updateRequests.organizationId, user.organizationId), + eq(updateRequests.status, "pending") + ) + ) + .limit(1); + + if (!existing) { + res.status(404).json({ + success: false, + error: { code: "NOT_FOUND", message: "Request not found or not pending" }, + }); + return; + } + + const [updated] = await db + .update(updateRequests) + .set({ + status: "approved", + approvedByUserId: user.userId, + approvedAt: new Date(), + }) + .where(eq(updateRequests.id, requestId)) + .returning(); + + let deploymentId = null; + if (body.deployNow) { + const [deployment] = await db + .insert(deployments) + .values({ + organizationId: user.organizationId, + name: `Update ${existing.packageName} to ${existing.targetVersion}`, + description: `Approved update for ${existing.packageName} on device ${existing.deviceId}`, + jobType: "update_package", + parameters: { + package_name: existing.packageName, + target_version: existing.targetVersion, + manager: existing.packageManager, + }, + status: "approved", + createdByUserId: user.userId, + approvedByUserId: user.userId, + approvedAt: new Date(), + totalTargets: 1, + }) + .returning(); + + await db.insert(deploymentTargets).values({ + deploymentId: deployment.id, + deviceId: existing.deviceId, + status: "pending", + }); + + await db + .update(updateRequests) + .set({ deploymentId: deployment.id }) + .where(eq(updateRequests.id, requestId)); + + deploymentId = deployment.id; + } + + await auditLog({ + req, + organizationId: user.organizationId, + action: "update_request.approve", + targetType: "update_request", + targetId: requestId, + deviceIds: [existing.deviceId], + result: "success", + metadata: { + packageName: existing.packageName, + deployNow: body.deployNow, + deploymentId, + }, + }); + + res.json({ success: true, data: { ...updated, deploymentId } }); + } catch (err) { + if (err instanceof z.ZodError) { + res.status(400).json({ + success: false, + error: { code: "VALIDATION_ERROR", message: "Invalid request body", details: err.errors }, + }); + return; + } + throw err; + } +}); + +const rejectSchema = z.object({ + reason: z.string().min(1).max(500), +}); + +updateRequestsRouter.patch("/:requestId/reject", authorize("operator"), async (req, res) => { + try { + const body = rejectSchema.parse(req.body); + const user = (req as any).user; + const requestId = req.params.requestId as string; + + const [existing] = await db + .select() + .from(updateRequests) + .where( + and( + eq(updateRequests.id, requestId), + eq(updateRequests.organizationId, user.organizationId), + eq(updateRequests.status, "pending") + ) + ) + .limit(1); + + if (!existing) { + res.status(404).json({ + success: false, + error: { code: "NOT_FOUND", message: "Request not found or not pending" }, + }); + return; + } + + const [updated] = await db + .update(updateRequests) + .set({ + status: "rejected", + rejectionReason: body.reason, + }) + .where(eq(updateRequests.id, requestId)) + .returning(); + + await auditLog({ + req, + organizationId: user.organizationId, + action: "update_request.reject", + targetType: "update_request", + targetId: requestId, + deviceIds: [existing.deviceId], + result: "success", + metadata: { + packageName: existing.packageName, + reason: body.reason, + }, + }); + + res.json({ success: true, data: updated }); + } catch (err) { + if (err instanceof z.ZodError) { + res.status(400).json({ + success: false, + error: { code: "VALIDATION_ERROR", message: "Invalid request body", details: err.errors }, + }); + return; + } + throw err; + } +}); + +updateRequestsRouter.patch("/:requestId/cancel", authorize("operator"), async (req, res) => { + const user = (req as any).user; + const requestId = req.params.requestId as string; + + const [updated] = await db + .update(updateRequests) + .set({ status: "cancelled" }) + .where( + and( + eq(updateRequests.id, requestId), + eq(updateRequests.organizationId, user.organizationId), + eq(updateRequests.status, "pending") + ) + ) + .returning(); + + if (!updated) { + res.status(404).json({ + success: false, + error: { code: "NOT_FOUND", message: "Request not found or not cancellable" }, + }); + return; + } + + res.json({ success: true, data: updated }); +}); diff --git a/apps/api/src/routes/users.ts b/apps/api/src/routes/users.ts new file mode 100644 index 0000000..2fe0912 --- /dev/null +++ b/apps/api/src/routes/users.ts @@ -0,0 +1,102 @@ +import { Router } from "express"; +import { z } from "zod"; +import bcrypt from "bcrypt"; +import { db } from "../db/index.js"; +import { users } from "../db/schema.js"; +import { authenticate, authorize } from "../middleware/auth.js"; +import { auditLog } from "../lib/audit.js"; +import { eq, and } from "drizzle-orm"; + +export const usersRouter = Router(); + +usersRouter.use(authenticate); + +const createUserSchema = z.object({ + email: z.string().email(), + password: z.string().min(8), + displayName: z.string().min(1).max(255), + role: z.enum(["admin", "operator", "read_only"]), +}); + +usersRouter.get("/", authorize("admin"), async (req, res) => { + const user = (req as any).user; + const allUsers = await db + .select({ + id: users.id, + email: users.email, + displayName: users.displayName, + role: users.role, + isActive: users.isActive, + lastLoginAt: users.lastLoginAt, + createdAt: users.createdAt, + }) + .from(users) + .where(eq(users.organizationId, user.organizationId)); + + res.json({ success: true, data: allUsers }); +}); + +usersRouter.post("/", authorize("admin"), async (req, res) => { + try { + const body = createUserSchema.parse(req.body); + const authUser = (req as any).user; + + const passwordHash = await bcrypt.hash(body.password, 12); + const [newUser] = await db + .insert(users) + .values({ + organizationId: authUser.organizationId, + email: body.email, + passwordHash, + displayName: body.displayName, + role: body.role, + }) + .returning({ + id: users.id, + email: users.email, + displayName: users.displayName, + role: users.role, + }); + + await auditLog({ + req, + organizationId: authUser.organizationId, + action: "user.create", + targetType: "user", + targetId: newUser.id, + result: "success", + }); + + res.status(201).json({ success: true, data: newUser }); + } catch (err) { + if (err instanceof z.ZodError) { + res.status(400).json({ + success: false, + error: { code: "VALIDATION_ERROR", message: "Invalid request body", details: err.errors }, + }); + return; + } + throw err; + } +}); + +usersRouter.patch("/:userId/deactivate", authorize("admin"), async (req, res) => { + const authUser = (req as any).user; + const userId = req.params.userId as string; + + await db + .update(users) + .set({ isActive: false }) + .where(and(eq(users.id, userId), eq(users.organizationId, authUser.organizationId))); + + await auditLog({ + req, + organizationId: authUser.organizationId, + action: "user.deactivate", + targetType: "user", + targetId: userId, + result: "success", + }); + + res.json({ success: true }); +}); diff --git a/apps/api/src/ws/relay.ts b/apps/api/src/ws/relay.ts new file mode 100644 index 0000000..79d7e63 --- /dev/null +++ b/apps/api/src/ws/relay.ts @@ -0,0 +1,197 @@ +import type { Server } from "http"; +import { WebSocket, WebSocketServer } from "ws"; +import { db } from "../db/index.js"; +import { remoteSessions, deviceCredentials, devices } from "../db/schema.js"; +import { eq, and } from "drizzle-orm"; +import { hashCredential } from "../lib/tokens.js"; +import { verifyToken } from "../middleware/auth.js"; + +const agents = new Map(); +const browsers = new Map(); +const pairs = new Set(); + +async function closeSession(sessionId: string) { + await db + .update(remoteSessions) + .set({ status: "closed", endedAt: new Date() }) + .where(eq(remoteSessions.id, sessionId)); +} + +function pair(sessionId: string) { + const agent = agents.get(sessionId); + const browser = browsers.get(sessionId); + if (!agent || !browser || pairs.has(sessionId)) return; + pairs.add(sessionId); + + const teardown = async (origin: "agent" | "browser") => { + pairs.delete(sessionId); + agents.delete(sessionId); + browsers.delete(sessionId); + if (agent.readyState === WebSocket.OPEN) agent.close(1011, `${origin} disconnected`); + if (browser.readyState === WebSocket.OPEN) browser.close(1011, `${origin} disconnected`); + closeSession(sessionId).catch((err) => console.error("[remote] closeSession error:", err)); + }; + + agent.on("close", () => teardown("agent")); + agent.on("error", () => teardown("agent")); + browser.on("close", () => teardown("browser")); + browser.on("error", () => teardown("browser")); +} + +function forwardTo(peer: WebSocket | undefined, data: WebSocket.RawData) { + if (peer && peer.readyState === WebSocket.OPEN) { + peer.send(data, { binary: true }); + } +} + +// attachForwarder wires up message forwarding as soon as the socket exists so +// that bytes cannot be lost while the handler awaits the ownership/credential +// database checks. The forwarding is peer-lookup based and safe to use in any +// connection order. +function attachForwarder(ws: WebSocket, sessionId: string, kind: "agent" | "browser") { + ws.on("message", (data) => { + forwardTo(kind === "agent" ? browsers.get(sessionId) : agents.get(sessionId), data); + }); +} + +async function handleAgentTunnel(url: URL, ws: WebSocket) { + const sessionId = url.searchParams.get("session_id") || ""; + const token = url.searchParams.get("token") || ""; + if (!sessionId || !token) { + ws.close(4001, "Missing session_id or token"); + return; + } + attachForwarder(ws, sessionId, "agent"); + + const credHash = hashCredential(token); + const [deviceCred] = await db + .select() + .from(deviceCredentials) + .where( + and( + eq(deviceCredentials.credentialHash, credHash), + eq(deviceCredentials.credentialType, "device_key") + ) + ) + .limit(1); + + if (!deviceCred || (deviceCred.expiresAt && new Date(deviceCred.expiresAt) < new Date())) { + ws.close(4001, "Invalid device credential"); + return; + } + + const [device] = await db + .select() + .from(devices) + .where(eq(devices.id, deviceCred.deviceId)) + .limit(1); + + if (!device || device.isDisabled) { + ws.close(4001, "Device disabled"); + return; + } + + const [session] = await db + .select() + .from(remoteSessions) + .where(eq(remoteSessions.id, sessionId)) + .limit(1); + + if (!session || session.deviceId !== device.id) { + ws.close(4001, "Session not found for device"); + return; + } + + if (session.status === "closed" || session.status === "failed") { + ws.close(4001, "Session closed"); + return; + } + + await db + .update(remoteSessions) + .set({ status: "active", startedAt: new Date(), errorMessage: null }) + .where(eq(remoteSessions.id, sessionId)); + + agents.set(sessionId, ws); + pair(sessionId); + + ws.on("close", () => { + if (agents.get(sessionId) === ws) { + agents.delete(sessionId); + closeSession(sessionId).catch(() => {}); + } + }); +} + +async function handleBrowser(url: URL, ws: WebSocket) { + const token = url.searchParams.get("token") || ""; + let user: any; + try { + user = verifyToken(token); + } catch { + ws.close(4001, "Invalid session"); + return; + } + const sessionId = url.pathname.split("/").pop() || ""; + attachForwarder(ws, sessionId, "browser"); + + const [session] = await db + .select() + .from(remoteSessions) + .where(eq(remoteSessions.id, sessionId)) + .limit(1); + + if (!session || session.organizationId !== user.organizationId) { + ws.close(4001, "Session not found"); + return; + } + + if (browsers.has(sessionId)) { + browsers.get(sessionId)?.close(1013, "Replaced by another viewer"); + browsers.delete(sessionId); + } + + browsers.set(sessionId, ws); + pair(sessionId); + + ws.on("close", () => { + if (browsers.get(sessionId) === ws) browsers.delete(sessionId); + }); +} + +export function attachRelay(server: Server) { + const wss = new WebSocketServer({ noServer: true }); + + server.on("upgrade", (req, socket, head) => { + const base = `${req.headers["x-forwarded-proto"] === "https" ? "https" : "http"}://${req.headers.host}`; + let url: URL; + try { + url = new URL(req.url || "/", base); + } catch { + socket.destroy(); + return; + } + + if (url.pathname === "/api/v1/remote/agent") { + wss.handleUpgrade(req, socket, head, (ws) => { + handleAgentTunnel(url, ws).catch(() => { + ws.close(4000, "Agent tunnel error"); + }); + }); + return; + } + + if (/^\/api\/v1\/remote\/browser\/[0-9a-fA-F-]{36}$/.test(url.pathname)) { + wss.handleUpgrade(req, socket, head, (ws) => { + handleBrowser(url, ws).catch(() => { + ws.close(4000, "Session error"); + }); + }); + return; + } + + socket.destroy(); + }); + + server.on("close", () => wss.close()); +} \ No newline at end of file diff --git a/apps/api/tsconfig.json b/apps/api/tsconfig.json new file mode 100644 index 0000000..4b2ecad --- /dev/null +++ b/apps/api/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src", + "declaration": false, + "declarationMap": false + }, + "include": ["src"] +} diff --git a/apps/api/vitest.config.ts b/apps/api/vitest.config.ts new file mode 100644 index 0000000..d00d591 --- /dev/null +++ b/apps/api/vitest.config.ts @@ -0,0 +1,18 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + globals: true, + environment: "node", + testTimeout: 30000, + hookTimeout: 30000, + include: ["src/**/*.test.ts"], + pool: "forks", + poolOptions: { + forks: { + maxForks: 1, + singleFork: true, + }, + }, + }, +}); diff --git a/apps/web/components.json b/apps/web/components.json new file mode 100644 index 0000000..450f8e7 --- /dev/null +++ b/apps/web/components.json @@ -0,0 +1,19 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": true, + "tsx": true, + "tailwind": { + "config": "tailwind.config.js", + "css": "src/app/globals.css", + "baseColor": "slate", + "cssVariables": true + }, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + } +} diff --git a/apps/web/next-env.d.ts b/apps/web/next-env.d.ts new file mode 100644 index 0000000..830fb59 --- /dev/null +++ b/apps/web/next-env.d.ts @@ -0,0 +1,6 @@ +/// +/// +/// + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/apps/web/next.config.mjs b/apps/web/next.config.mjs new file mode 100644 index 0000000..04ea5bd --- /dev/null +++ b/apps/web/next.config.mjs @@ -0,0 +1,7 @@ +/** @type {import('next').NextConfig} */ +const nextConfig = { + output: "standalone", + transpilePackages: ["@fleetforge/shared"], +}; + +export default nextConfig; diff --git a/apps/web/package.json b/apps/web/package.json new file mode 100644 index 0000000..d2955bc --- /dev/null +++ b/apps/web/package.json @@ -0,0 +1,45 @@ +{ + "name": "@fleetforge/web", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "next dev -p 3001", + "build": "next build", + "start": "next start -p 3001", + "typecheck": "tsc --noEmit", + "lint": "next lint", + "test": "vitest run" + }, + "dependencies": { + "@fleetforge/shared": "workspace:*", + "@novnc/novnc": "^1.7.0", + "@radix-ui/react-alert-dialog": "^1.1.23", + "@radix-ui/react-dialog": "^1.1.23", + "@radix-ui/react-dropdown-menu": "^2.1.24", + "@radix-ui/react-label": "^2.1.15", + "@radix-ui/react-scroll-area": "^1.2.18", + "@radix-ui/react-select": "^2.3.7", + "@radix-ui/react-separator": "^1.1.15", + "@radix-ui/react-slot": "^1.3.3", + "@radix-ui/react-tabs": "^1.1.21", + "@radix-ui/react-tooltip": "^1.2.16", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "lucide-react": "^1.32.0", + "next": "^15.1.0", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "tailwind-merge": "^3.6.0", + "tailwindcss-animate": "^1.0.7" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "autoprefixer": "^10.4.20", + "postcss": "^8.4.49", + "tailwindcss": "^3.4.17", + "typescript": "^5.7.0", + "vitest": "^3.0.0" + } +} diff --git a/apps/web/postcss.config.js b/apps/web/postcss.config.js new file mode 100644 index 0000000..12a703d --- /dev/null +++ b/apps/web/postcss.config.js @@ -0,0 +1,6 @@ +module.exports = { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/apps/web/src/app/alerts/page.tsx b/apps/web/src/app/alerts/page.tsx new file mode 100644 index 0000000..1fc6615 --- /dev/null +++ b/apps/web/src/app/alerts/page.tsx @@ -0,0 +1,89 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { api } from "@/lib/api"; +import DashboardLayout from "@/components/DashboardLayout"; +import { Card, CardContent } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Bell } from "lucide-react"; + +interface AlertItem { + id: string; + alertType: string; + severity: string; + title: string; + message: string; + isResolved: boolean; + createdAt: string; +} + +const severityVariant: Record = { + info: "info", + warning: "warning", + error: "destructive", + critical: "destructive", +}; + +export default function AlertsPage() { + const [alerts, setAlerts] = useState([]); + + const fetchAlerts = () => { + api.get("/v1/alerts").then((res) => { + if (res.success && res.data) setAlerts(res.data); + }); + }; + + useEffect(() => { fetchAlerts(); }, []); + + const resolveAlert = async (id: string) => { + await api.patch(`/v1/alerts/${id}/resolve`); + fetchAlerts(); + }; + + return ( + +
+

Alerts

+

System alerts and notifications

+
+ + {alerts.length === 0 ? ( + + + +

No alerts. All clear!

+
+
+ ) : ( +
+ {alerts.map((alert) => ( + + +
+ + {alert.severity.toUpperCase()} + +
+

{alert.title}

+

{alert.message}

+
+
+
+ + {new Date(alert.createdAt).toLocaleString()} + + {!alert.isResolved && ( + + )} +
+
+
+ ))} +
+ )} +
+ ); +} diff --git a/apps/web/src/app/audit-log/page.tsx b/apps/web/src/app/audit-log/page.tsx new file mode 100644 index 0000000..ccd59dc --- /dev/null +++ b/apps/web/src/app/audit-log/page.tsx @@ -0,0 +1,112 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { api } from "@/lib/api"; +import DashboardLayout from "@/components/DashboardLayout"; +import { Card, CardContent } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Badge } from "@/components/ui/badge"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; +import { ScrollText } from "lucide-react"; + +interface AuditEvent { + id: string; + action: string; + targetType: string; + targetId: string | null; + result: string; + ipAddress: string | null; + metadata: Record; + correlationId: string; + createdAt: string; +} + +export default function AuditLogPage() { + const [events, setEvents] = useState([]); + const [total, setTotal] = useState(0); + const [actionFilter, setActionFilter] = useState(""); + + useEffect(() => { + const params: Record = {}; + if (actionFilter) params.action = actionFilter; + api.get("/v1/audit-events", params).then((res) => { + if (res.success && res.data) { + setEvents(res.data); + setTotal(res.meta?.total || 0); + } + }); + }, [actionFilter]); + + return ( + +
+

Audit Log

+

{total} event(s) recorded

+
+ +
+ setActionFilter(e.target.value)} + className="w-64" + /> +
+ + {events.length === 0 ? ( + + + +

No audit events recorded yet.

+
+
+ ) : ( + + + + + Time + Action + Target + Result + IP + Correlation ID + + + + {events.map((event) => ( + + + {new Date(event.createdAt).toLocaleString()} + + + {event.action} + + + {event.targetType} + {event.targetId && ( + + ({event.targetId.substring(0, 8)}...) + + )} + + + + {event.result} + + + + {event.ipAddress || "\u2014"} + + + {event.correlationId.substring(0, 12)}... + + + ))} + +
+
+ )} +
+ ); +} diff --git a/apps/web/src/app/dashboard/page.tsx b/apps/web/src/app/dashboard/page.tsx new file mode 100644 index 0000000..3d48d06 --- /dev/null +++ b/apps/web/src/app/dashboard/page.tsx @@ -0,0 +1,192 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { api } from "@/lib/api"; +import DashboardLayout from "@/components/DashboardLayout"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { Skeleton } from "@/components/ui/skeleton"; +import { Server, Wifi, WifiOff, AlertTriangle, Bell, Zap, Clock } from "lucide-react"; + +interface DashboardStats { + totalDevices: number; + onlineDevices: number; + offlineDevices: number; + warningDevices: number; + openAlerts: number; + pendingJobs: number; + staleDevices: number; + osDistribution: Array<{ os: string; total: number }>; + recentDevices: Array<{ + id: string; + name: string; + status: string; + osName: string; + lastHeartbeatAt: string | null; + }>; +} + +function StatCard({ + label, + value, + icon: Icon, +}: { + label: string; + value: number | string; + icon: React.ElementType; +}) { + return ( + + + + {label} + + + + +
{value}
+
+
+ ); +} + +function StatusBadge({ status }: { status: string }) { + const variant: "success" | "secondary" | "warning" = + status === "online" ? "success" : status === "offline" ? "secondary" : "warning"; + + return ( + + {status.charAt(0).toUpperCase() + status.slice(1)} + + ); +} + +export default function DashboardPage() { + const [stats, setStats] = useState(null); + const [error, setError] = useState(""); + + useEffect(() => { + api.get("/v1/dashboard/stats").then((res) => { + if (res.success && res.data) { + setStats(res.data); + } else { + setError(res.error?.message || "Failed to load dashboard"); + } + }); + }, []); + + return ( + +
+

Dashboard

+

Fleet overview and status

+
+ + {error && ( + + {error} + + )} + + {stats ? ( + <> +
+ + + + +
+ +
+ + + Recent Devices + + + {stats.recentDevices.length === 0 ? ( +

+ No devices enrolled yet. Create an enrollment token to get started. +

+ ) : ( +
+ {stats.recentDevices.map((device) => ( +
+
+

{device.name}

+

{device.osName}

+
+ +
+ ))} +
+ )} +
+
+ + + + OS Distribution + + + {stats.osDistribution.length === 0 ? ( +

+ No device data available yet. +

+ ) : ( +
+ {stats.osDistribution.map((item) => ( +
+ {item.os} + {item.total} +
+ ))} +
+ )} +
+
+
+ +
+ + + Open Alerts + + + +
{stats.openAlerts}
+
+
+ + + Pending Jobs + + + +
{stats.pendingJobs}
+
+
+ + + Stale Devices + + + +
{stats.staleDevices}
+
+
+
+ + ) : ( +
+ {[...Array(7)].map((_, i) => ( + + ))} +
+ )} +
+ ); +} diff --git a/apps/web/src/app/deployments/page.tsx b/apps/web/src/app/deployments/page.tsx new file mode 100644 index 0000000..ec1da86 --- /dev/null +++ b/apps/web/src/app/deployments/page.tsx @@ -0,0 +1,376 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { api } from "@/lib/api"; +import DashboardLayout from "@/components/DashboardLayout"; +import { Card, CardContent } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { + Select, SelectContent, SelectItem, SelectTrigger, SelectValue, +} from "@/components/ui/select"; +import { + Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger, +} from "@/components/ui/dialog"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; +import { Rocket, Check, Plus, AlertTriangle } from "lucide-react"; + +interface Deployment { + id: string; + name: string; + description: string | null; + jobType: string; + status: string; + createdByName: string | null; + totalTargets: number; + completedTargets: number; + failedTargets: number; + createdAt: string; + completedAt: string | null; +} + +interface Device { + id: string; + name: string; + status: string; +} + +const statusVariant: Record = { + draft: "outline", + pending_approval: "warning", + approved: "info", + running: "warning", + completed: "success", + partially_completed: "warning", + failed: "destructive", + cancelled: "secondary", +}; + +const JOB_TYPES = [ + { value: "install_package", label: "Install Package", paramKey: "package_name", paramLabel: "Package Name" }, + { value: "update_package", label: "Update Package", paramKey: "package_name", paramLabel: "Package Name" }, + { value: "remove_package", label: "Remove Package", paramKey: "package_name", paramLabel: "Package Name" }, + { value: "start_service", label: "Start Service", paramKey: "service_name", paramLabel: "Service Name" }, + { value: "stop_service", label: "Stop Service", paramKey: "service_name", paramLabel: "Service Name" }, + { value: "restart_service", label: "Restart Service", paramKey: "service_name", paramLabel: "Service Name" }, + { value: "check_service_status", label: "Check Service Status", paramKey: "service_name", paramLabel: "Service Name" }, + { value: "reboot_system", label: "Reboot System", paramKey: null, paramLabel: null }, +]; + +export default function DeploymentsPage() { + const [deployments, setDeployments] = useState([]); + const [total, setTotal] = useState(0); + const [devices, setDevices] = useState([]); + const [showCreate, setShowCreate] = useState(false); + const [error, setError] = useState(""); + + const [form, setForm] = useState({ + name: "", + description: "", + jobType: "install_package", + packageName: "", + serviceName: "", + autoApprove: false, + }); + const [selectedDevices, setSelectedDevices] = useState([]); + + const loadDeployments = () => { + api.get("/v1/deployments").then((res) => { + if (res.success && res.data) { + setDeployments(res.data); + setTotal(res.meta?.total || 0); + } + }); + }; + + const loadDevices = () => { + api.get("/v1/devices").then((res) => { + if (res.success && res.data) setDevices(res.data); + }); + }; + + useEffect(() => { loadDeployments(); loadDevices(); }, []); + + const selectedJobType = JOB_TYPES.find((j) => j.value === form.jobType); + const needsPackageName = selectedJobType?.paramKey === "package_name"; + const needsServiceName = selectedJobType?.paramKey === "service_name"; + + const handleCreate = async (e: React.FormEvent) => { + e.preventDefault(); + setError(""); + + if (selectedDevices.length === 0) { + setError("Select at least one target device."); + return; + } + + const parameters: Record = {}; + if (needsPackageName) { + if (!form.packageName.trim()) { + setError("Package name is required."); + return; + } + parameters.package_name = form.packageName.trim(); + } + if (needsServiceName) { + if (!form.serviceName.trim()) { + setError("Service name is required."); + return; + } + parameters.service_name = form.serviceName.trim(); + } + + const res = await api.post("/v1/deployments", { + name: form.name.trim(), + description: form.description.trim() || undefined, + jobType: form.jobType, + parameters, + deviceIds: selectedDevices, + autoApprove: form.autoApprove, + }); + + if (res.success) { + setShowCreate(false); + setForm({ name: "", description: "", jobType: "install_package", packageName: "", serviceName: "", autoApprove: false }); + setSelectedDevices([]); + loadDeployments(); + } else { + setError(res.error?.message || "Failed to create deployment"); + } + }; + + const handleApprove = async (id: string) => { + await api.patch(`/v1/deployments/${id}/approve`); + loadDeployments(); + }; + + const handleCancel = async (id: string) => { + await api.patch(`/v1/deployments/${id}/cancel`); + loadDeployments(); + }; + + const handleDelete = async (id: string) => { + if (!confirm("Delete this deployment?")) return; + await api.delete(`/v1/deployments/${id}`); + loadDeployments(); + }; + + const toggleDevice = (id: string) => { + setSelectedDevices((prev) => + prev.includes(id) ? prev.filter((d) => d !== id) : [...prev, id] + ); + }; + + return ( + +
+
+

Deployments

+

{total} deployment(s)

+
+ + + + + +
+ + Create Deployment + Deploy packages, services, or commands to selected devices. + + +
+ {error && ( + + + {error} + + )} + +
+ + setForm({ ...form, name: e.target.value })} + placeholder="e.g. Install nginx on web servers" + required + /> +
+ +
+ + setForm({ ...form, description: e.target.value })} + placeholder="Optional description" + /> +
+ +
+ + +
+ + {needsPackageName && ( +
+ + setForm({ ...form, packageName: e.target.value })} + placeholder="e.g. nginx" + required + /> +
+ )} + + {needsServiceName && ( +
+ + setForm({ ...form, serviceName: e.target.value })} + placeholder="e.g. nginx" + required + /> +
+ )} + +
+ +
+ {devices.length === 0 ? ( +

No devices enrolled yet.

+ ) : ( + devices.map((d) => ( + + )) + )} +
+

{selectedDevices.length} device(s) selected

+
+ + +
+ + + + + +
+
+
+
+ + {deployments.length === 0 ? ( + + + +

No deployments created yet. Click "New Deployment" to get started.

+
+
+ ) : ( + + + + + Name + Job Type + Status + Progress + Created By + Created + Actions + + + + {deployments.map((d) => ( + + +
{d.name}
+ {d.description && ( +

{d.description}

+ )} +
+ {d.jobType.replace(/_/g, " ")} + + {d.status.replace(/_/g, " ")} + + +
+ {d.completedTargets} + / + {d.totalTargets} + {d.failedTargets > 0 && ( + ({d.failedTargets} failed) + )} +
+
+ {d.createdByName || "—"} + + {new Date(d.createdAt).toLocaleString()} + + +
+ {d.status === "draft" && ( + + )} + {["draft", "approved"].includes(d.status) && ( + + )} + {d.status === "draft" && ( + + )} +
+
+
+ ))} +
+
+
+ )} +
+ ); +} diff --git a/apps/web/src/app/devices/[deviceId]/page.tsx b/apps/web/src/app/devices/[deviceId]/page.tsx new file mode 100644 index 0000000..236207a --- /dev/null +++ b/apps/web/src/app/devices/[deviceId]/page.tsx @@ -0,0 +1,292 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useParams } from "next/navigation"; +import Link from "next/link"; +import { api } from "@/lib/api"; +import DashboardLayout from "@/components/DashboardLayout"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; +import { Skeleton } from "@/components/ui/skeleton"; +import { ArrowLeft, Cpu, MemoryStick, HardDrive, Wifi, Clock, AlertTriangle, Wrench, Monitor } from "lucide-react"; +import { DeviceActionsDialog } from "@/components/DeviceActionsDialog"; +import { RemoteAccessModal } from "@/components/RemoteAccessModal"; + +interface DeviceDetail { + id: string; name: string; deviceType: string; platform: string; + osName: string; osVersion: string; architecture: string; kernelVersion: string | null; + cpuModel: string | null; cpuCores: number | null; + totalMemoryBytes: number | null; totalDiskBytes: number | null; + ipAddress: string | null; agentVersion: string; status: string; + lastHeartbeatAt: string | null; enrolledAt: string; tags: string[]; +} + +interface MetricSnapshot { + cpuUsagePercent: number | null; memoryUsagePercent: number | null; + diskUsagePercent: number | null; loadAverage1m: number | null; loadAverage5m: number | null; + memoryUsedBytes: number | null; memoryTotalBytes: number | null; + diskUsedBytes: number | null; diskTotalBytes: number | null; + uptimeSeconds: number | null; reportedAt: string; +} + +interface PackageInfo { name: string; version: string; manager: string; } +interface ServiceInfo { name: string; status: string; enabled: boolean; } +interface InventoryData { packages: PackageInfo[]; services: ServiceInfo[]; reportedAt: string | null; } + +interface DeviceJob { + id: string; jobType: string; status: string; parameters: Record; + targetStatus: string; targetResult: Record | null; + createdAt: string; completedAt: string | null; +} + +interface DeviceAlert { + id: string; alertType: string; severity: string; title: string; message: string; + isResolved: boolean; createdAt: string; +} + +function formatBytes(bytes: number | null): string { + if (!bytes) return "\u2014"; + const gb = bytes / (1024 * 1024 * 1024); + if (gb >= 1) return `${gb.toFixed(1)} GB`; + const mb = bytes / (1024 * 1024); + return `${mb.toFixed(0)} MB`; +} + +function formatUptime(seconds: number | null): string { + if (!seconds) return "\u2014"; + const d = Math.floor(seconds / 86400); const h = Math.floor((seconds % 86400) / 3600); const m = Math.floor((seconds % 3600) / 60); + if (d > 0) return `${d}d ${h}h ${m}m`; + if (h > 0) return `${h}h ${m}m`; + return `${m}m`; +} + +function StatusBadge({ status }: { status: string }) { + const variant: "success" | "secondary" | "warning" = status === "online" ? "success" : status === "offline" ? "secondary" : "warning"; + return {status.charAt(0).toUpperCase() + status.slice(1)}; +} + +function MetricCard({ icon: Icon, label, value, subtext }: { icon: any; label: string; value: string; subtext?: string }) { + return ( + +
+
+

{label}

{value}

{subtext &&

{subtext}

}
+
+
+ ); +} + +function ProgressCard({ label, percent, used, total }: { label: string; percent: number | null; used?: string; total?: string }) { + const p = percent != null ? Math.min(100, Math.max(0, percent)) : 0; + const color = p > 90 ? "bg-destructive" : p > 70 ? "bg-yellow-500" : "bg-primary"; + return ( + +

{label}

{percent != null ? `${percent.toFixed(1)}%` : "\u2014"}

+
+ {used && total &&

{used} / {total}

} + + ); +} + +export default function DeviceDetailPage() { + const params = useParams(); + const deviceId = params.deviceId as string; + const [device, setDevice] = useState(null); + const [metrics, setMetrics] = useState([]); + const [inventory, setInventory] = useState(null); + const [jobs, setJobs] = useState([]); + const [alerts, setAlerts] = useState([]); + const [actionsOpen, setActionsOpen] = useState(false); + const [remoteOpen, setRemoteOpen] = useState(false); + + useEffect(() => { + api.get(`/v1/devices/${deviceId}`).then((r) => { if (r.success && r.data) setDevice(r.data); }); + api.get(`/v1/devices/${deviceId}/metrics`).then((r) => { if (r.success && r.data) setMetrics(r.data); }); + api.get(`/v1/software/inventory/devices/${deviceId}`).then((r) => { if (r.success && r.data) setInventory(r.data); }); + api.get(`/v1/devices/${deviceId}/jobs`).then((r) => { if (r.success && r.data) setJobs(r.data); }); + api.get(`/v1/devices/${deviceId}/alerts`).then((r) => { if (r.success && r.data) setAlerts(r.data); }); + }, [deviceId]); + + if (!device) return
; + + const m = metrics[0]; + const jobStatusVariant: Record = { + succeeded: "success", failed: "destructive", running: "warning", queued: "secondary", dispatched: "info", + }; + const severityVariant: Record = { + critical: "destructive", error: "destructive", warning: "warning", info: "info", + }; + + return ( + +
+ Back to Devices +
+ +
+
+

{device.name}

+

{device.osName} {device.osVersion} · {device.architecture} · {device.ipAddress || "No IP"}

+
+
+ + + {device.agentVersion} + +
+
+ +
+ + + + + +
+ + + + System + Metrics History + Packages ({inventory?.packages?.length || 0}) + Services ({inventory?.services?.length || 0}) + Jobs ({jobs.length}) + Alerts ({alerts.filter((a) => !a.isResolved).length}) + + + +
+ Hardware + + + + + + + Software + + + + + + Network & Agent + + + + + + Live Metrics + {m ? (<> + + + + ) :

No live metrics available.

} +
+
+
+ + + + {metrics.length === 0 ?

No metrics recorded yet.

: ( + TimeCPUMemoryDiskLoad (1m)Uptime + {metrics.slice(0, 50).map((m, i) => ( + {new Date(m.reportedAt).toLocaleString()} + {m.cpuUsagePercent != null ? `${m.cpuUsagePercent.toFixed(1)}%` : "\u2014"} + {m.memoryUsagePercent != null ? `${m.memoryUsagePercent.toFixed(1)}%` : "\u2014"} + {m.diskUsagePercent != null ? `${m.diskUsagePercent.toFixed(1)}%` : "\u2014"} + {m.loadAverage1m != null ? m.loadAverage1m.toFixed(2) : "\u2014"} + {formatUptime(m.uptimeSeconds)} + + ))}
+ )} +
+
+ + + + {!inventory?.packages?.length ?

No packages reported. Waiting for agent heartbeat.

: ( + NameVersionManager + {inventory.packages.sort((a, b) => a.name.localeCompare(b.name)).map((p, i) => ( + {p.name}{p.version}{p.manager} + ))}
+ )} +
+
+ + + + {!inventory?.services?.length ?

No services reported.

: ( + NameStatusEnabled + {inventory.services.sort((a, b) => a.name.localeCompare(b.name)).map((s, i) => ( + + {s.name} + {s.status} + {s.enabled ? "Yes" : "No"} + + ))}
+ )} +
+
+ + + + {jobs.length === 0 ?

No jobs executed on this device yet.

: ( + TypeStatusCreatedDuration + {jobs.map((j) => ( + + {j.jobType.replace(/_/g, " ")} + {j.targetStatus} + {new Date(j.createdAt).toLocaleString()} + {j.completedAt ? `${((new Date(j.completedAt).getTime() - new Date(j.createdAt).getTime()) / 1000).toFixed(1)}s` : "\u2014"} + + ))}
+ )} +
+
+ + + + {alerts.length === 0 ?

No alerts for this device.

: ( +
{alerts.map((a) => ( +
+ +

{a.title}

{a.severity}{a.isResolved && Resolved}
+

{a.message}

{new Date(a.createdAt).toLocaleString()}

+
+ ))}
+ )} +
+
+
+ + + + +
+ ); +} + +function InfoRow({ label, value }: { label: string; value: string | number | null }) { + return ( +
+ {label} + {value || "\u2014"} +
+ ); +} diff --git a/apps/web/src/app/devices/page.tsx b/apps/web/src/app/devices/page.tsx new file mode 100644 index 0000000..26c812d --- /dev/null +++ b/apps/web/src/app/devices/page.tsx @@ -0,0 +1,236 @@ +"use client"; + +import { useEffect, useState } from "react"; +import Link from "next/link"; +import { api } from "@/lib/api"; +import DashboardLayout from "@/components/DashboardLayout"; +import { Card, CardContent } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { Input } from "@/components/ui/input"; +import { Button } from "@/components/ui/button"; +import { + Select, SelectContent, SelectItem, SelectTrigger, SelectValue, +} from "@/components/ui/select"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; +import { + Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, +} from "@/components/ui/dialog"; +import { Server, Trash2, Loader2, Wrench, Monitor } from "lucide-react"; +import { DeviceActionsDialog } from "@/components/DeviceActionsDialog"; +import { RemoteAccessModal } from "@/components/RemoteAccessModal"; + +interface Device { + id: string; + name: string; + deviceType: string; + osName: string; + osVersion: string; + architecture: string; + status: string; + agentVersion: string; + lastHeartbeatAt: string | null; + enrolledAt: string; + tags: string[]; +} + +function StatusBadge({ status }: { status: string }) { + const variant: "success" | "secondary" | "warning" = + status === "online" ? "success" : status === "offline" ? "secondary" : "warning"; + return {status.charAt(0).toUpperCase() + status.slice(1)}; +} + +export default function DevicesPage() { + const [devices, setDevices] = useState([]); + const [total, setTotal] = useState(0); + const [search, setSearch] = useState(""); + const [statusFilter, setStatusFilter] = useState(""); + const [deleteTarget, setDeleteTarget] = useState(null); + const [deleting, setDeleting] = useState(false); + const [actionsTarget, setActionsTarget] = useState(null); + const [remoteTarget, setRemoteTarget] = useState(null); + + useEffect(() => { + const params: Record = {}; + if (search) params.search = search; + if (statusFilter) params.status = statusFilter; + + api.get("/v1/devices", params).then((res) => { + if (res.success && res.data) { + setDevices(res.data); + setTotal(res.meta?.total || 0); + } + }); + }, [search, statusFilter]); + + const handleDelete = async () => { + if (!deleteTarget) return; + setDeleting(true); + try { + await api.delete(`/v1/devices/${deleteTarget.id}`); + setDevices((prev) => prev.filter((d) => d.id !== deleteTarget.id)); + setTotal((t) => Math.max(0, t - 1)); + setDeleteTarget(null); + } catch { + setDeleting(false); + } + }; + + return ( + +
+

Devices

+

{total} device(s) enrolled

+
+ +
+ setSearch(e.target.value)} + className="w-64" + /> + +
+ + {devices.length === 0 ? ( + + + +

No devices found

+

+ Enroll a device using the enrollment token system in Settings. +

+
+
+ ) : ( + + + + + Device + OS + Architecture + Agent + Status + Last Heartbeat + Actions + + + + {devices.map((device) => ( + + + + {device.name} + +

+ {device.deviceType.replace("_", " ")} +

+
+ + {device.osName} {device.osVersion} + + + {device.architecture} + + + {device.agentVersion} + + + + + + {device.lastHeartbeatAt + ? new Date(device.lastHeartbeatAt).toLocaleString() + : "Never"} + + + + + + +
+ ))} +
+
+
+ )} + + !open && !deleting && setDeleteTarget(null)}> + + + Delete device? + + This will permanently remove {deleteTarget?.name} and all of its + historical data (metrics, inventory, jobs, alerts, compliance results). This action + cannot be undone. + + + + + + + + + + !open && setActionsTarget(null)} + /> + + !open && setRemoteTarget(null)} + /> +
+ ); +} diff --git a/apps/web/src/app/downloads/page.tsx b/apps/web/src/app/downloads/page.tsx new file mode 100644 index 0000000..c72d6f3 --- /dev/null +++ b/apps/web/src/app/downloads/page.tsx @@ -0,0 +1,415 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { api } from "@/lib/api"; +import DashboardLayout from "@/components/DashboardLayout"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { + Download, + Terminal, + MonitorSmartphone, + Apple, + Copy, + Check, + Loader2, + Shield, +} from "lucide-react"; + +interface PlatformInfo { + os: string; + arch: string; + label: string; + filename: string; + available: boolean; + size: number; + sizeFormatted: string; + installHint: string; +} + +interface AgentInfo { + version: string; + platforms: PlatformInfo[]; +} + +const osIcons: Record = { + linux: Terminal, + windows: MonitorSmartphone, + darwin: Apple, +}; + +function getApiBase() { + return process.env.NEXT_PUBLIC_API_URL || "http://localhost:3002/api"; +} + +function WindowsInstallerCard() { + const [serverUrl, setServerUrl] = useState(""); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [success, setSuccess] = useState(false); + + useEffect(() => { + setServerUrl(getApiBase().replace(/\/api$/, "")); + }, []); + + const handleGenerate = async () => { + setLoading(true); + setError(null); + setSuccess(false); + try { + const token = api.getToken(); + const res = await fetch(`${getApiBase()}/v1/installers/generate`, { + method: "POST", + headers: { + "Content-Type": "application/json", + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }, + body: JSON.stringify({ serverUrl }), + }); + + if (!res.ok) { + let message = `Failed to generate installer (${res.status})`; + try { + const data = await res.json(); + if (data?.error?.message) message = data.error.message; + } catch {} + setError(message); + return; + } + + const blob = await res.blob(); + const blobUrl = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = blobUrl; + a.download = "forge-agent.msi"; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(blobUrl); + setSuccess(true); + } catch (err) { + setError( + err instanceof Error ? err.message : "Failed to generate installer" + ); + } finally { + setLoading(false); + } + }; + + return ( + + + + + Windows MSI Installer + + + +
+ + setServerUrl(e.target.value)} + placeholder="http://your-server:3002" + /> +

+ The installer will be pre-configured to point the agent at this + Fleet Forge server. +

+
+ + + + {error && ( + + {error} + + )} + + {success && !error && ( + + + Installer generated and downloaded as forge-agent.msi. + + + )} + + + + + The installer embeds a single-use enrollment token. Each generated + MSI can only enroll one device; generate a new installer for every + device you deploy. + + +
+
+ ); +} + +export default function DownloadsPage() { + const [agentInfo, setAgentInfo] = useState(null); + const [copied, setCopied] = useState(null); + + useEffect(() => { + api.get("/v1/downloads/agent").then((res) => { + if (res.success && res.data) setAgentInfo(res.data); + }); + }, []); + + const handleDownload = async (filename: string) => { + const token = api.getToken(); + const url = `${getApiBase()}/v1/downloads/agent/${filename}`; + + const res = await fetch(url, { + headers: { Authorization: `Bearer ${token}` }, + }); + const blob = await res.blob(); + const blobUrl = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = blobUrl; + a.download = filename; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(blobUrl); + }; + + const copyToClipboard = (text: string, key: string) => { + navigator.clipboard.writeText(text); + setCopied(key); + setTimeout(() => setCopied(null), 2000); + }; + + const gpoTemplate = `msiexec /i forge-agent.msi /qn /l*v install.log`; + + const uninstallTemplate = `sc stop FleetForgeAgent +msiexec /x forge-agent.msi /qn`; + + const linuxTemplate = `chmod +x forge-agent-linux-amd64 +sudo ./forge-agent enroll -token -server http://SERVER:3002 +sudo ./forge-agent run`; + + const systemdTemplate = `[Unit] +Description=Fleet Forge Agent +After=network.target + +[Service] +ExecStart=/usr/local/bin/forge-agent run +Restart=always +RestartSec=5 + +[Install] +WantedBy=multi-user.target`; + + return ( + +
+

Agent Downloads

+

+ Download the Fleet Forge agent for your platform +

+
+ + + + Windows MSI Installer + All Binaries + Deployment Guide + + + + + + + + + + + + Forge Agent {agentInfo?.version} + + + +
+ {agentInfo?.platforms.map((platform) => { + const Icon = osIcons[platform.os] || Download; + return ( + + +
+
+ + + {platform.label} + +
+ + {platform.sizeFormatted} + +
+ +
+
+ ); + })} +
+ {!agentInfo && ( +
+ Loading agent binaries... +
+ )} +
+
+
+ + +
+ + + + Windows GPO Deployment + + + +

+ Deploy the MSI silently across your fleet via Group Policy: +

+
+
+                    {gpoTemplate}
+                  
+ +
+
+
+ + + + Uninstall + + +

+ Stop the service and remove the agent from a Windows device: +

+
+
+                    {uninstallTemplate}
+                  
+ +
+
+
+ + + + Linux Quick Start + + +

+ Download the Linux binary, then enroll and run it: +

+
+
+                    {linuxTemplate}
+                  
+ +
+
+
+ + + + + Linux Systemd Service + + + +

+ For permanent installation on Linux, create a systemd unit + file: +

+
+
+                    {systemdTemplate}
+                  
+ +
+
+
+
+
+
+
+ ); +} diff --git a/apps/web/src/app/globals.css b/apps/web/src/app/globals.css new file mode 100644 index 0000000..4e5e50c --- /dev/null +++ b/apps/web/src/app/globals.css @@ -0,0 +1,89 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +@layer base { + :root { + --background: 210 40% 98%; + --foreground: 222 47% 11%; + + --card: 0 0% 100%; + --card-foreground: 222 47% 11%; + + --popover: 0 0% 100%; + --popover-foreground: 222 47% 11%; + + --primary: 222 47% 11%; + --primary-foreground: 210 40% 98%; + + --secondary: 210 40% 96%; + --secondary-foreground: 222 47% 11%; + + --muted: 210 40% 96%; + --muted-foreground: 215 16% 47%; + + --accent: 210 40% 96%; + --accent-foreground: 222 47% 11%; + + --destructive: 0 84% 60%; + --destructive-foreground: 210 40% 98%; + + --border: 214 32% 91%; + --input: 214 32% 91%; + --ring: 222 47% 11%; + + --radius: 0.5rem; + + --sidebar: 222 47% 11%; + --sidebar-foreground: 210 40% 98%; + --sidebar-accent: 217 33% 17%; + --sidebar-accent-foreground: 210 40% 98%; + --sidebar-border: 217 33% 17%; + } + + .dark { + --background: 222 47% 6%; + --foreground: 210 40% 98%; + + --card: 222 47% 8%; + --card-foreground: 210 40% 98%; + + --popover: 222 47% 8%; + --popover-foreground: 210 40% 98%; + + --primary: 210 40% 98%; + --primary-foreground: 222 47% 11%; + + --secondary: 217 33% 14%; + --secondary-foreground: 210 40% 98%; + + --muted: 217 33% 14%; + --muted-foreground: 215 20% 55%; + + --accent: 217 33% 14%; + --accent-foreground: 210 40% 98%; + + --destructive: 0 63% 31%; + --destructive-foreground: 210 40% 98%; + + --border: 217 33% 14%; + --input: 217 33% 14%; + --ring: 212 27% 84%; + + --sidebar: 222 47% 4%; + --sidebar-foreground: 210 40% 98%; + --sidebar-accent: 217 33% 12%; + --sidebar-accent-foreground: 210 40% 98%; + --sidebar-border: 217 33% 12%; + } +} + +@layer base { + * { + @apply border-border; + } + body { + @apply bg-background text-foreground; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + } +} diff --git a/apps/web/src/app/groups/page.tsx b/apps/web/src/app/groups/page.tsx new file mode 100644 index 0000000..c5e27b2 --- /dev/null +++ b/apps/web/src/app/groups/page.tsx @@ -0,0 +1,114 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { api } from "@/lib/api"; +import DashboardLayout from "@/components/DashboardLayout"; +import { Card, CardContent } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; +import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"; +import { Plus, Users } from "lucide-react"; + +interface Group { + id: string; + name: string; + description: string | null; + createdAt: string; +} + +export default function GroupsPage() { + const [groups, setGroups] = useState([]); + const [showCreate, setShowCreate] = useState(false); + const [newName, setNewName] = useState(""); + const [newDesc, setNewDesc] = useState(""); + + const fetchGroups = () => { + api.get("/v1/groups").then((res) => { + if (res.success && res.data) setGroups(res.data); + }); + }; + + useEffect(() => { fetchGroups(); }, []); + + const handleCreate = async (e: React.FormEvent) => { + e.preventDefault(); + await api.post("/v1/groups", { name: newName, description: newDesc }); + setNewName(""); + setNewDesc(""); + setShowCreate(false); + fetchGroups(); + }; + + return ( + +
+
+

Groups

+

Organize devices into groups

+
+ + + + + +
+ + Create Group + Add a new device group to organize your fleet. + +
+
+ + setNewName(e.target.value)} required /> +
+
+ + setNewDesc(e.target.value)} placeholder="Optional" /> +
+
+ + + + +
+
+
+
+ + {groups.length === 0 ? ( + + + +

No groups created yet.

+
+
+ ) : ( + + + + + Name + Description + Created + + + + {groups.map((g) => ( + + {g.name} + {g.description || "\u2014"} + {new Date(g.createdAt).toLocaleDateString()} + + ))} + +
+
+ )} +
+ ); +} diff --git a/apps/web/src/app/jobs/page.tsx b/apps/web/src/app/jobs/page.tsx new file mode 100644 index 0000000..5df8745 --- /dev/null +++ b/apps/web/src/app/jobs/page.tsx @@ -0,0 +1,318 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import { api } from "@/lib/api"; +import DashboardLayout from "@/components/DashboardLayout"; +import { Card, CardContent } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Zap, Ban, Trash2, Eye, RefreshCw } from "lucide-react"; + +interface Target { + id: string; + jobId: string; + deviceId: string; + deviceName: string | null; + status: string; + result: Record | null; + startedAt: string | null; + completedAt: string | null; +} + +interface Job { + id: string; + jobType: string; + status: string; + parameters: Record; + createdAt: string; + completedAt: string | null; + targets?: Target[]; +} + +const statusVariant: Record = { + queued: "info", + dispatched: "info", + running: "warning", + succeeded: "success", + failed: "destructive", + cancelled: "secondary", + expired: "outline", +}; + +const ACTIVE_STATUSES = new Set(["queued", "dispatched", "running"]); +const POLL_INTERVAL = 4000; + +async function fetchJobs(): Promise { + const res = await api.get("/v1/jobs"); + return res.success && res.data ? res.data : []; +} + +async function fetchJobDetail(id: string): Promise { + const res = await api.get(`/v1/jobs/${id}`); + if (res.success && res.data) return res.data; + throw new Error(res.error?.message || "Failed to load job"); +} + +export default function JobsPage() { + const [jobs, setJobs] = useState([]); + const [total, setTotal] = useState(0); + const [selectedJob, setSelectedJob] = useState(null); + const [detailOpen, setDetailOpen] = useState(false); + const [busyId, setBusyId] = useState(null); + + const load = useCallback(async () => { + const list = await fetchJobs(); + setJobs(list); + setTotal(list.length); + }, []); + + useEffect(() => { + load(); + const id = setInterval(load, POLL_INTERVAL); + return () => clearInterval(id); + }, [load]); + + useEffect(() => { + if (!detailOpen || !selectedJob) return; + const id = setInterval(async () => { + try { + const detail = await fetchJobDetail(selectedJob.id); + setSelectedJob(detail); + setJobs((prev) => prev.map((j) => (j.id === detail.id ? detail : j))); + } catch { + /* ignore transient polling errors */ + } + }, POLL_INTERVAL); + return () => clearInterval(id); + }, [detailOpen, selectedJob]); + + const openDetail = async (job: Job) => { + setSelectedJob(job); + setDetailOpen(true); + try { + const detail = await fetchJobDetail(job.id); + setSelectedJob(detail); + } catch { + /* keep cached summary */ + } + }; + + const cancelJob = async (id: string) => { + setBusyId(id); + await api.patch(`/v1/jobs/${id}/cancel`); + setBusyId(null); + await load(); + }; + + const deleteJob = async (id: string) => { + setBusyId(id); + await api.delete(`/v1/jobs/${id}`); + setBusyId(null); + if (selectedJob?.id === id) setDetailOpen(false); + await load(); + }; + + return ( + +
+
+

Jobs

+

{total} job(s) total

+
+ +
+ + {jobs.length === 0 ? ( + + + +

No jobs have been created yet.

+
+
+ ) : ( + + + + + Type + Status + Created + Completed + Actions + + + + {jobs.map((job) => ( + + + {job.jobType.replace(/_/g, " ")} + + + + {job.status} + + + + {new Date(job.createdAt).toLocaleString()} + + + {job.completedAt ? new Date(job.completedAt).toLocaleString() : "\u2014"} + + +
+ + {ACTIVE_STATUSES.has(job.status) && ( + + )} + +
+
+
+ ))} +
+
+
+ )} + + + + + + {selectedJob?.jobType.replace(/_/g, " ")} + {selectedJob && ( + + {selectedJob.status} + + )} + + + + {selectedJob && ( +
+
+ {ACTIVE_STATUSES.has(selectedJob.status) && ( + + )} + +
+ +
+
+
Job ID
+
{selectedJob.id}
+
+
+
Created
+
{new Date(selectedJob.createdAt).toLocaleString()}
+
+ {selectedJob.completedAt && ( +
+
Completed
+
{new Date(selectedJob.completedAt).toLocaleString()}
+
+ )} +
+ +
+
Parameters
+
+                  {JSON.stringify(selectedJob.parameters, null, 2)}
+                
+
+ +
+
+ Targets ({selectedJob.targets?.length || 0}) +
+ + + + Device + Status + Started + Result + + + + {(selectedJob.targets || []).map((t) => ( + + {t.deviceName || t.deviceId} + + + {t.status} + + + + {t.startedAt ? new Date(t.startedAt).toLocaleTimeString() : "\u2014"} + + + {t.status === "failed" && + (t.result as { error?: string } | null)?.error ? ( + + {(t.result as { error?: string }).error} + + ) : t.status === "succeeded" ? ( + + {(t.result as { output?: string } | null)?.output + ?.slice(0, 120) || "OK"} + + ) : t.status === "running" ? ( + Installing\u2026 + ) : ( + "\u2014" + )} + + + ))} + +
+
+
+ )} +
+
+
+ ); +} diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx new file mode 100644 index 0000000..fc28e90 --- /dev/null +++ b/apps/web/src/app/layout.tsx @@ -0,0 +1,19 @@ +import type { Metadata } from "next"; +import "./globals.css"; + +export const metadata: Metadata = { + title: "Fleet Forge", + description: "Unified Endpoint & Infrastructure Management Platform", +}; + +export default function RootLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( + + {children} + + ); +} diff --git a/apps/web/src/app/login/page.tsx b/apps/web/src/app/login/page.tsx new file mode 100644 index 0000000..306e216 --- /dev/null +++ b/apps/web/src/app/login/page.tsx @@ -0,0 +1,106 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import { api } from "@/lib/api"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { Hexagon, Loader2 } from "lucide-react"; + +export default function LoginPage() { + const [email, setEmail] = useState("admin@fleetforge.local"); + const [password, setPassword] = useState(""); + const [error, setError] = useState(""); + const [loading, setLoading] = useState(false); + const router = useRouter(); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(""); + setLoading(true); + + try { + const res = await api.post<{ token: string; user: { id: string; email: string; displayName: string; role: string } }>("/v1/auth/login", { email, password }); + if (res.success && res.data) { + api.setToken(res.data.token); + router.push("/dashboard"); + } else { + setError(res.error?.message || "Login failed"); + } + } catch { + setError("Connection failed. Is the API running?"); + } finally { + setLoading(false); + } + }; + + return ( +
+
+
+
+ +

+ Fleet Forge +

+
+

+ Endpoint & Infrastructure Management +

+
+ + + + Sign in to your account + Enter your credentials to continue + + +
+ {error && ( + + {error} + + )} + +
+ + setEmail(e.target.value)} + placeholder="admin@fleetforge.local" + required + /> +
+ +
+ + setPassword(e.target.value)} + placeholder="Enter your password" + required + /> +
+ + +
+
+
+ +

+ Fleet Forge v0.1.0 +

+
+
+ ); +} diff --git a/apps/web/src/app/page.tsx b/apps/web/src/app/page.tsx new file mode 100644 index 0000000..9f85280 --- /dev/null +++ b/apps/web/src/app/page.tsx @@ -0,0 +1,5 @@ +import { redirect } from "next/navigation"; + +export default function Home() { + redirect("/login"); +} diff --git a/apps/web/src/app/policies/page.tsx b/apps/web/src/app/policies/page.tsx new file mode 100644 index 0000000..fb6eb31 --- /dev/null +++ b/apps/web/src/app/policies/page.tsx @@ -0,0 +1,67 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { api } from "@/lib/api"; +import DashboardLayout from "@/components/DashboardLayout"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { Shield } from "lucide-react"; + +interface Policy { + id: string; + name: string; + description: string | null; + rules: Array<{ type: string; condition: string; value: string | number | boolean }>; + isEnabled: boolean; + createdAt: string; +} + +export default function PoliciesPage() { + const [policies, setPolicies] = useState([]); + + useEffect(() => { + api.get("/v1/policies").then((res) => { + if (res.success && res.data) setPolicies(res.data); + }); + }, []); + + return ( + +
+

Policies

+

Compliance and configuration policies

+
+ + {policies.length === 0 ? ( + + + +

No policies configured yet.

+
+
+ ) : ( +
+ {policies.map((policy) => ( + + + {policy.name} + + {policy.isEnabled ? "Enabled" : "Disabled"} + + + + {policy.description && ( +

{policy.description}

+ )} +

+ {policy.rules.length} rule(s) · Created{" "} + {new Date(policy.createdAt).toLocaleDateString()} +

+
+
+ ))} +
+ )} +
+ ); +} diff --git a/apps/web/src/app/settings/enrollment/page.tsx b/apps/web/src/app/settings/enrollment/page.tsx new file mode 100644 index 0000000..87acc86 --- /dev/null +++ b/apps/web/src/app/settings/enrollment/page.tsx @@ -0,0 +1,210 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { api } from "@/lib/api"; +import DashboardLayout from "@/components/DashboardLayout"; +import { Card } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Badge } from "@/components/ui/badge"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; +import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"; +import { Plus, KeyRound, Copy, Check } from "lucide-react"; + +interface EnrollToken { + id: string; + expiresAt: string; + maxUses: number | null; + useCount: number; + isRevoked: boolean; + label: string | null; + createdAt: string; +} + +export default function EnrollmentPage() { + const [tokens, setTokens] = useState([]); + const [showCreate, setShowCreate] = useState(false); + const [newToken, setNewToken] = useState(null); + const [copied, setCopied] = useState(false); + const [form, setForm] = useState({ + label: "", + expiresInHours: 24, + maxUses: null as number | null, + }); + + const fetchTokens = () => { + api.get("/v1/enrollment-tokens").then((res) => { + if (res.success && res.data) setTokens(res.data); + }); + }; + + useEffect(() => { fetchTokens(); }, []); + + const handleCreate = async (e: React.FormEvent) => { + e.preventDefault(); + const res = await api.post<{ id: string; token: string; expiresAt: string; label: string | null }>( + "/v1/enrollment-tokens", + form + ); + if (res.success && res.data) { + setNewToken(res.data.token); + } + setShowCreate(false); + fetchTokens(); + }; + + const revokeToken = async (id: string) => { + await api.delete(`/v1/enrollment-tokens/${id}`); + fetchTokens(); + }; + + const copyToken = async () => { + if (newToken) { + await navigator.clipboard.writeText(newToken); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } + }; + + return ( + +
+
+

Enrollment Tokens

+

Create tokens for device enrollment

+
+ + + + + +
+ + Create Enrollment Token + Generate a new token for device enrollment. + +
+
+ + setForm({ ...form, label: e.target.value })} placeholder="Optional" /> +
+
+ + setForm({ ...form, expiresInHours: parseInt(e.target.value) })} + /> +
+
+ + setForm({ ...form, maxUses: e.target.value ? parseInt(e.target.value) : null })} + /> +
+
+ + + + +
+
+
+
+ + {newToken && ( + + + +

+ Enrollment token created. Copy it now — it won't be shown again. +

+
+ + {newToken} + + +
+ +
+
+ )} + + + + + + Label + Uses + Expires + Status + Created + Actions + + + + {tokens.map((token) => ( + + {token.label || "\u2014"} + + {token.useCount} + {token.maxUses ? ` / ${token.maxUses}` : ""} + + + {new Date(token.expiresAt).toLocaleString()} + + + + {token.isRevoked ? "Revoked" : new Date(token.expiresAt) < new Date() ? "Expired" : "Active"} + + + + {new Date(token.createdAt).toLocaleDateString()} + + + {!token.isRevoked && ( + + )} + + + ))} + {tokens.length === 0 && ( + + + No enrollment tokens created yet. + + + )} + +
+
+
+ ); +} diff --git a/apps/web/src/app/settings/users/page.tsx b/apps/web/src/app/settings/users/page.tsx new file mode 100644 index 0000000..a59a5d5 --- /dev/null +++ b/apps/web/src/app/settings/users/page.tsx @@ -0,0 +1,143 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { api } from "@/lib/api"; +import DashboardLayout from "@/components/DashboardLayout"; +import { Card } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Badge } from "@/components/ui/badge"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; +import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Plus } from "lucide-react"; + +interface User { + id: string; + email: string; + displayName: string; + role: string; + isActive: boolean; + lastLoginAt: string | null; + createdAt: string; +} + +export default function UsersPage() { + const [users, setUsers] = useState([]); + const [showCreate, setShowCreate] = useState(false); + const [form, setForm] = useState({ + email: "", + password: "", + displayName: "", + role: "operator", + }); + + const fetchUsers = () => { + api.get("/v1/users").then((res) => { + if (res.success && res.data) setUsers(res.data); + }); + }; + + useEffect(() => { fetchUsers(); }, []); + + const handleCreate = async (e: React.FormEvent) => { + e.preventDefault(); + await api.post("/v1/users", form); + setForm({ email: "", password: "", displayName: "", role: "operator" }); + setShowCreate(false); + fetchUsers(); + }; + + return ( + +
+
+

Users

+

Manage platform users

+
+ + + + + +
+ + Create User + Add a new user to the platform. + +
+
+ + setForm({ ...form, displayName: e.target.value })} required /> +
+
+ + setForm({ ...form, email: e.target.value })} required /> +
+
+ + setForm({ ...form, password: e.target.value })} required minLength={8} /> +
+
+ + +
+
+ + + + +
+
+
+
+ + + + + + User + Role + Status + Last Login + + + + {users.map((user) => ( + + +

{user.displayName}

+

{user.email}

+
+ + {user.role} + + + + {user.isActive ? "Active" : "Inactive"} + + + + {user.lastLoginAt ? new Date(user.lastLoginAt).toLocaleString() : "Never"} + +
+ ))} +
+
+
+
+ ); +} diff --git a/apps/web/src/app/software/AppCatalogTab.tsx b/apps/web/src/app/software/AppCatalogTab.tsx new file mode 100644 index 0000000..2406ed0 --- /dev/null +++ b/apps/web/src/app/software/AppCatalogTab.tsx @@ -0,0 +1,251 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import { api } from "@/lib/api"; +import { Card, CardContent } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; +import { Package, Search, Loader2, ChevronLeft, ChevronRight } from "lucide-react"; + +interface CatalogPackage { + id: string; + name: string; + publisher?: string; + latestVersion: string; + versions?: string[]; + description?: string; + tags?: string[]; + iconUrl?: string; + homepage?: string; + license?: string; + downloadCount?: number; +} + +interface CatalogResponse { + packages: CatalogPackage[]; + total?: number; + page: number; + limit: number; +} + +interface Device { + id: string; + name: string; + status: string; + platform: string; +} + +const SOURCE_LABELS: Record = { + winget: "Windows Package Manager (winget)", + chocolatey: "Chocolatey", +}; + +export function AppCatalogTab({ source }: { source: "winget" | "chocolatey" }) { + const [packages, setPackages] = useState([]); + const [total, setTotal] = useState(undefined); + const [page, setPage] = useState(1); + const [limit] = useState(25); + const [search, setSearch] = useState(""); + const [debouncedSearch, setDebouncedSearch] = useState(""); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(""); + const [devices, setDevices] = useState([]); + const [installPkg, setInstallPkg] = useState(null); + const [selectedDevice, setSelectedDevice] = useState(""); + const [installing, setInstalling] = useState(false); + const [installError, setInstallError] = useState(""); + + useEffect(() => { + const t = setTimeout(() => { + setDebouncedSearch(search); + setPage(1); + }, 400); + return () => clearTimeout(t); + }, [search]); + + const load = useCallback(() => { + setLoading(true); + setError(""); + const params: Record = { page: String(page), limit: String(limit) }; + if (debouncedSearch.trim()) params.search = debouncedSearch.trim(); + api + .get(`/v1/appcatalog/${source}`, params) + .then((r) => { + if (r.success && r.data) { + setPackages(r.data.packages || []); + setTotal(r.data.total); + } else { + setError(r.error?.message || "Failed to load catalog"); + setPackages([]); + } + setLoading(false); + }) + .catch(() => { + setError("Failed to load catalog"); + setLoading(false); + }); + }, [source, page, limit, debouncedSearch]); + + useEffect(() => { + load(); + }, [load]); + + useEffect(() => { + api.get("/v1/devices?limit=100").then((r) => setDevices(r.data || [])); + }, []); + + const hasMore = packages.length >= limit; + + const handleInstall = async () => { + if (!installPkg || !selectedDevice) return; + setInstalling(true); + setInstallError(""); + const parameters: Record = { + source, + package_id: installPkg.id, + package_version: installPkg.latestVersion, + }; + const r = await api.post("/v1/jobs", { + jobType: "install_registry_package", + deviceIds: [selectedDevice], + parameters, + }); + setInstalling(false); + if (!r.success) { + setInstallError(r.error?.message || "Failed to create installation job"); + return; + } + setInstallPkg(null); + setSelectedDevice(""); + }; + + return ( +
+
+
+ + setSearch(e.target.value)} + className="pl-9" + /> +
+ + {total !== undefined ? `${total} packages` : `${packages.length} packages`} + +
+ + {error && {error}} + + {loading ? ( + + ) : packages.length === 0 ? ( +

No packages found{debouncedSearch ? ` for "${debouncedSearch}"` : ""}.

+ ) : ( + + + + + Package + Version + Publisher + Description + {source === "chocolatey" && Downloads} + Actions + + + + {packages.map((p) => ( + + +
+ {p.iconUrl ? ( + + ) : ( + + )} +
+
{p.name}
+
{p.id}
+
+
+
+ {p.latestVersion} + {p.publisher || "—"} + + {p.description || "—"} + {p.tags && p.tags.length > 0 && ( +
+ {p.tags.slice(0, 4).map((t) => ( + {t} + ))} +
+ )} +
+ {source === "chocolatey" && ( + {p.downloadCount?.toLocaleString() ?? "—"} + )} + + + +
+ ))} +
+
+
+ )} + +
+
+ {total !== undefined ? `Page ${page} of ${Math.max(1, Math.ceil((total || 0) / limit))}` : `Page ${page}`} +
+
+ + +
+
+ + setInstallPkg(null)}> + + + Install {installPkg?.name} + + Deploy {installPkg?.name} ({installPkg?.id}) v{installPkg?.latestVersion} to a device via {SOURCE_LABELS[source]} + + +
+ {installError && {installError}} +
+ + +
+
+ + + + +
+
+
+ ); +} \ No newline at end of file diff --git a/apps/web/src/app/software/page.tsx b/apps/web/src/app/software/page.tsx new file mode 100644 index 0000000..9ab095c --- /dev/null +++ b/apps/web/src/app/software/page.tsx @@ -0,0 +1,192 @@ +"use client"; + +import { useEffect, useState, useRef } from "react"; +import { api } from "@/lib/api"; +import DashboardLayout from "@/components/DashboardLayout"; +import { Card, CardContent } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; +import { Package, Upload, Trash2, Search, Loader2 } from "lucide-react"; +import { AppCatalogTab } from "./AppCatalogTab"; + +interface SoftwareEntry { name: string; versions: PackageVersion[]; totalDevices: number; } +interface PackageVersion { version: string; manager: string; deviceCount: number; devices: Array<{ deviceId: string; deviceName: string }>; } +interface RepoPackage { id: string; name: string; description: string | null; version: string; packageType: string; platform: string; architecture: string; filename: string; fileSize: number; tags: string[]; createdAt: string; } +interface Device { id: string; name: string; status: string; platform: string; } + +function formatBytes(bytes: number): string { + if (!bytes) return "0 B"; + const k = 1024; const sizes = ["B", "KB", "MB", "GB"]; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + " " + sizes[i]; +} + +export default function SoftwarePage() { + const [tab, setTab] = useState("inventory"); + const [search, setSearch] = useState(""); + + return ( + +
+

Software

+

Manage software across your fleet

+
+ + + Fleet Inventory + Package Repository + winget + Chocolatey + + + + + + +
+ ); +} + +function InventoryTab({ search, setSearch }: { search: string; setSearch: (v: string) => void }) { + const [packages, setPackages] = useState([]); + const [loading, setLoading] = useState(true); + useEffect(() => { setLoading(true); api.get("/v1/software/inventory").then((r) => { setPackages(r.data || []); setLoading(false); }); }, []); + const filtered = packages.filter((p) => !search || p.name.toLowerCase().includes(search.toLowerCase())); + return ( +
+
+
setSearch(e.target.value)} className="pl-9" />
+ {packages.length} packages +
+ {loading ? + : filtered.length === 0 ?

No packages found.

+ :
{filtered.map((pkg) => ( + +
{pkg.name}{pkg.totalDevices} device{pkg.totalDevices !== 1 ? "s" : ""}
+
{pkg.versions.map((v, i) => ( +
{v.version}{v.manager}on {v.devices.map((d) => d.deviceName).join(", ")}
+ ))}
+
+ ))}
+ } +
+ ); +} + +function RepositoryTab() { + const [packages, setPackages] = useState([]); + const [total, setTotal] = useState(0); + const [loading, setLoading] = useState(true); + const [showUpload, setShowUpload] = useState(false); + const [uploading, setUploading] = useState(false); + const [uploadError, setUploadError] = useState(""); + const [devices, setDevices] = useState([]); + const [installPkg, setInstallPkg] = useState(null); + const [selectedDevice, setSelectedDevice] = useState(""); + const [installing, setInstalling] = useState(false); + const fileRef = useRef(null); + const [form, setForm] = useState({ name: "", version: "1.0.0", description: "", platform: "windows", architecture: "amd64", tags: "" }); + + const load = () => { setLoading(true); api.get("/v1/software/repository").then((r) => { setPackages(r.data || []); setTotal(r.meta?.total || 0); setLoading(false); }); }; + const loadDevices = () => { api.get("/v1/devices?limit=100").then((r) => { setDevices(r.data || []); }); }; + useEffect(() => { load(); loadDevices(); }, []); + + const handleUpload = async (e: React.FormEvent) => { + e.preventDefault(); setUploadError(""); + const file = fileRef.current?.files?.[0]; + if (!file) { setUploadError("Please select a file."); return; } + setUploading(true); + const fd = new FormData(); + fd.append("file", file); + fd.append("name", form.name || file.name); + fd.append("version", form.version); + fd.append("description", form.description); + fd.append("platform", form.platform); + fd.append("architecture", form.architecture); + fd.append("tags", JSON.stringify(form.tags.split(",").map((t) => t.trim()).filter(Boolean))); + try { + const token = api.getToken(); + const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL || "http://localhost:3000/api"}/v1/software/repository`, { method: "POST", headers: token ? { Authorization: `Bearer ${token}` } : {}, body: fd }); + const data = await res.json(); + if (data.success) { setShowUpload(false); setForm({ name: "", version: "1.0.0", description: "", platform: "windows", architecture: "amd64", tags: "" }); if (fileRef.current) fileRef.current.value = ""; load(); } + else { setUploadError(data.error?.message || "Upload failed"); } + } catch (err) { setUploadError("Upload failed"); } + setUploading(false); + }; + + const handleInstall = async () => { + if (!installPkg || !selectedDevice) return; + setInstalling(true); + await api.post("/v1/jobs", { jobType: "download_and_install", deviceIds: [selectedDevice], parameters: { package_id: installPkg.id } }); + setInstalling(false); setInstallPkg(null); setSelectedDevice(""); + }; + + const handleDelete = async (id: string) => { + if (!confirm("Delete this package?")) return; + await api.delete(`/v1/software/repository/${id}`); + load(); + }; + + return ( +
+
+ {total} packages + + + +
+ Upload Software PackageUpload MSI, EXE, DEB, RPM, DMG, or PKG files. +
+ {uploadError && {uploadError}} +
+
+
setForm({ ...form, name: e.target.value })} placeholder="Auto-detect from filename" />
+
setForm({ ...form, version: e.target.value })} />
+
+
setForm({ ...form, description: e.target.value })} />
+
+
+
+
+
setForm({ ...form, tags: e.target.value })} placeholder="e.g. utility, development" />
+
+ +
+
+
+
+ + setInstallPkg(null)}> + + Install PackageDeploy {installPkg?.name} v{installPkg?.version} to a device +
+
+
+ +
+
+ + {loading ? + : packages.length === 0 ?

No packages uploaded yet.

+ : NameVersionPlatformTypeSizeActions + {packages.map((p) => ( + +
{p.name}
{p.description &&

{p.description}

}
+ {p.version} + {p.platform} + {p.packageType} + {formatBytes(p.fileSize)} +
+
+ ))}
+ } +
+ ); +} diff --git a/apps/web/src/app/update-approvals/page.tsx b/apps/web/src/app/update-approvals/page.tsx new file mode 100644 index 0000000..2e91468 --- /dev/null +++ b/apps/web/src/app/update-approvals/page.tsx @@ -0,0 +1,138 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { api } from "@/lib/api"; +import DashboardLayout from "@/components/DashboardLayout"; +import { Card, CardContent } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; +import { ClipboardCheck, Check, X } from "lucide-react"; + +interface UpdateRequest { + id: string; + deviceId: string; + deviceName: string | null; + packageName: string; + packageManager: string; + currentVersion: string | null; + targetVersion: string; + requestedByName: string | null; + status: string; + rejectionReason: string | null; + notes: string | null; + createdAt: string; +} + +const statusVariant: Record = { + pending: "warning", + approved: "success", + rejected: "destructive", + deployed: "success", + cancelled: "secondary", +}; + +export default function UpdateApprovalsPage() { + const [requests, setRequests] = useState([]); + const [total, setTotal] = useState(0); + + const loadRequests = () => { + api.get("/v1/update-requests").then((res) => { + if (res.success && res.data) { + setRequests(res.data); + setTotal(res.meta?.total || 0); + } + }); + }; + + useEffect(() => { loadRequests(); }, []); + + const handleApprove = async (id: string) => { + await api.patch(`/v1/update-requests/${id}/approve`, { deployNow: true }); + loadRequests(); + }; + + const handleReject = async (id: string) => { + const reason = prompt("Rejection reason:"); + if (!reason) return; + await api.patch(`/v1/update-requests/${id}/reject`, { reason }); + loadRequests(); + }; + + const handleCancel = async (id: string) => { + await api.patch(`/v1/update-requests/${id}/cancel`); + loadRequests(); + }; + + return ( + +
+

Update Approvals

+

{total} update request(s)

+
+ + {requests.length === 0 ? ( + + + +

No update requests pending.

+
+
+ ) : ( + + + + + Package + Device + Versions + Requested By + Status + Date + Actions + + + + {requests.map((req) => ( + + {req.packageName} + {req.deviceName || req.deviceId.slice(0, 8)} + + {req.currentVersion || "?"} → {req.targetVersion} + + {req.requestedByName || "—"} + + {req.status} + + + {new Date(req.createdAt).toLocaleString()} + + + {req.status === "pending" && ( +
+ + +
+ )} + {req.status === "pending" && ( + + )} + {req.rejectionReason && ( +

{req.rejectionReason}

+ )} +
+
+ ))} +
+
+
+ )} +
+ ); +} diff --git a/apps/web/src/components/DashboardLayout.tsx b/apps/web/src/components/DashboardLayout.tsx new file mode 100644 index 0000000..cc1f0df --- /dev/null +++ b/apps/web/src/components/DashboardLayout.tsx @@ -0,0 +1,55 @@ +"use client"; + +import { useAuth } from "@/hooks/useAuth"; +import { useRouter } from "next/navigation"; +import { useEffect } from "react"; +import Sidebar from "@/components/Sidebar"; +import { Skeleton } from "@/components/ui/skeleton"; + +export default function DashboardLayout({ + children, +}: { + children: React.ReactNode; +}) { + const { user, loading, logout } = useAuth(); + const router = useRouter(); + + useEffect(() => { + if (!loading && !user) { + router.push("/login"); + } + }, [user, loading, router]); + + if (loading) { + return ( +
+ +
+ +
+ {[...Array(4)].map((_, i) => ( + + ))} +
+ +
+
+ ); + } + + if (!user) return null; + + return ( +
+ +
{children}
+
+ ); +} diff --git a/apps/web/src/components/DeviceActionsDialog.tsx b/apps/web/src/components/DeviceActionsDialog.tsx new file mode 100644 index 0000000..94dd9a6 --- /dev/null +++ b/apps/web/src/components/DeviceActionsDialog.tsx @@ -0,0 +1,370 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import { api } from "@/lib/api"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { + Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, +} from "@/components/ui/dialog"; +import { + Select, SelectContent, SelectItem, SelectTrigger, SelectValue, +} from "@/components/ui/select"; +import { + Package, Search, Loader2, ArrowLeft, Power, RotateCw, Trash2, RefreshCw, Wrench, Download, +} from "lucide-react"; + +interface DeviceRef { + id: string; + name: string; + status: string; +} + +interface CatalogPackage { + id: string; + name: string; + latestVersion: string; + publisher?: string; + description?: string; + iconUrl?: string; +} + +interface CatalogResponse { + packages: CatalogPackage[]; + page: number; + limit: number; +} + +type View = "actions" | "install" | "remove"; +type ConfirmKind = "reboot" | "shutdown" | null; + +const SOURCE_LABELS: Record = { + winget: "winget", + chocolatey: "Chocolatey", +}; + +export function DeviceActionsDialog({ + device, + open, + onOpenChange, +}: { + device: DeviceRef; + open: boolean; + onOpenChange: (open: boolean) => void; +}) { + const [view, setView] = useState("actions"); + const [confirm, setConfirm] = useState(null); + const [busy, setBusy] = useState(false); + const [feedback, setFeedback] = useState<{ type: "success" | "error"; text: string } | null>(null); + + // install subview + const [source, setSource] = useState<"winget" | "chocolatey">("winget"); + const [search, setSearch] = useState(""); + const [debouncedSearch, setDebouncedSearch] = useState(""); + const [packages, setPackages] = useState([]); + const [loadingCatalog, setLoadingCatalog] = useState(false); + const [installError, setInstallError] = useState(""); + + // remove subview + const [removePackageId, setRemovePackageId] = useState(""); + const [removeSource, setRemoveSource] = useState<"winget" | "chocolatey">("winget"); + + useEffect(() => { + if (!open) return; + setView("actions"); + setConfirm(null); + setFeedback(null); + setSearch(""); + setDebouncedSearch(""); + setPackages([]); + setRemovePackageId(""); + setInstallError(""); + }, [open]); + + useEffect(() => { + const t = setTimeout(() => setDebouncedSearch(search), 400); + return () => clearTimeout(t); + }, [search]); + + const loadCatalog = useCallback(() => { + if (!debouncedSearch.trim()) { + setPackages([]); + return; + } + setLoadingCatalog(true); + setInstallError(""); + api + .get(`/v1/appcatalog/${source}`, { + search: debouncedSearch.trim(), + limit: "10", + }) + .then((r) => { + if (r.success && r.data) { + setPackages(r.data.packages || []); + } else { + setPackages([]); + setInstallError(r.error?.message || "Search failed"); + } + setLoadingCatalog(false); + }) + .catch(() => { + setPackages([]); + setInstallError("Search failed"); + setLoadingCatalog(false); + }); + }, [source, debouncedSearch]); + + useEffect(() => { + loadCatalog(); + }, [loadCatalog]); + + const reset = () => { + setBusy(false); + setFeedback(null); + setConfirm(null); + setView("actions"); + }; + + const runJob = async ( + jobType: string, + parameters: Record, + successMessage: string + ) => { + setBusy(true); + setFeedback(null); + const r = await api.post("/v1/jobs", { + jobType, + deviceIds: [device.id], + parameters, + }); + setBusy(false); + if (!r.success) { + setFeedback({ type: "error", text: r.error?.message || "Failed to create job" }); + return false; + } + setFeedback({ type: "success", text: successMessage }); + return true; + }; + + const handleConfirm = async () => { + if (confirm === "reboot") { + await runJob("reboot_system", {}, "Reboot job created. Device will restart shortly."); + setConfirm(null); + } else if (confirm === "shutdown") { + await runJob("shutdown_system", {}, "Shutdown job created. Device will power off shortly."); + setConfirm(null); + } + }; + + const handleInstall = async (pkg: CatalogPackage) => { + const ok = await runJob( + "install_registry_package", + { source, package_id: pkg.id, package_version: pkg.latestVersion }, + `Installation job created for ${pkg.name}.` + ); + if (ok) setDebouncedSearch(""); + }; + + const handleRemove = async () => { + if (!removePackageId.trim()) return; + const ok = await runJob( + "remove_package", + { source: removeSource, package_name: removePackageId.trim() }, + `Removal job created for ${removePackageId.trim()}.` + ); + if (ok) setRemovePackageId(""); + }; + + const online = device.status === "online"; + + return ( + { onOpenChange(o); if (!o) reset(); }}> + + + + + Actions + {device.status} + {device.name} + + + {view === "install" + ? "Search a software catalog and deploy it to this device." + : view === "remove" + ? "Remove a software package from this device." + : "Choose an action to run on this device."} + + + + {feedback && ( + + {feedback.text} + + )} + + {view === "actions" && ( +
+ + + + + + + + {confirm && ( +
+

+ {confirm === "reboot" + ? `${device.name} wird jetzt neu gestartet. Fortfahren?` + : `${device.name} wird jetzt heruntergefahren. Fortfahren?`} +

+
+ + +
+
+ )} +
+ )} + + {view === "install" && ( +
+
+ +
+ + setSearch(e.target.value)} + className="pl-9" + /> +
+
+ + {installError && {installError}} + + {loadingCatalog ? ( +
+ ) : debouncedSearch.trim() && packages.length === 0 ? ( +

No packages found.

+ ) : ( +
+ {packages.map((p) => ( +
+
+
+ {p.iconUrl ? ( + + ) : ( + + )} + {p.name} +
+
+ {p.id} · v{p.latestVersion} +
+
+ +
+ ))} +
+ )} + + +
+ )} + + {view === "remove" && ( +
+
+ + setRemovePackageId(e.target.value)} + /> +
+
+ + +
+
+ + +
+
+ )} +
+
+ ); +} \ No newline at end of file diff --git a/apps/web/src/components/RemoteAccessModal.tsx b/apps/web/src/components/RemoteAccessModal.tsx new file mode 100644 index 0000000..5b20b04 --- /dev/null +++ b/apps/web/src/components/RemoteAccessModal.tsx @@ -0,0 +1,240 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; +import { api } from "@/lib/api"; +import { Button } from "@/components/ui/button"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { Badge } from "@/components/ui/badge"; +import { + Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, +} from "@/components/ui/dialog"; +import { Loader2, MonitorX, Minimize2, Maximize2 } from "lucide-react"; + +interface SessionInfo { + id: string; + deviceId: string; + status: string; + vncPort: number; + vncPassword?: string; + errorMessage?: string | null; + deviceName?: string; +} + +function wsBaseUrl(): string { + const apiBase = process.env.NEXT_PUBLIC_API_URL || "http://localhost:3002/api"; + return apiBase.replace(/^http/, "ws").replace(/\/+$/, ""); +} + +export function RemoteAccessModal({ + deviceId, + deviceName, + open, + onOpenChange, +}: { + deviceId: string; + deviceName: string; + open: boolean; + onOpenChange: (open: boolean) => void; +}) { + const [session, setSession] = useState(null); + const [connecting, setConnecting] = useState(false); + const [error, setError] = useState(""); + const [connected, setConnected] = useState(false); + const [fullscreen, setFullscreen] = useState(false); + const rfbRef = useRef(null); + const containerRef = useRef(null); + const rfbCreatedRef = useRef(false); + + const createSession = useCallback(async () => { + setSession(null); + setError(""); + setConnected(false); + setConnecting(true); + rfbCreatedRef.current = false; + + const created = await api.post("/v1/remote/sessions", { + deviceId, + vncPort: 5900, + }); + + if (!created.success || !created.data) { + setError(created.error?.message || "Failed to create remote session"); + setConnecting(false); + return; + } + + setSession(created.data); + + const pollInterval = setInterval(async () => { + const res = await api.get(`/v1/remote/sessions/${created.data!.id}`); + if (!res.success || !res.data) { + clearInterval(pollInterval); + setError(res.error?.message || "Remote session unavailable"); + setConnecting(false); + return; + } + setSession(res.data); + if (res.data.status === "active") { + clearInterval(pollInterval); + setConnecting(false); + connectVnc(res.data); + } else if (res.data.status === "failed" || res.data.status === "closed") { + clearInterval(pollInterval); + setConnecting(false); + setError(res.data.errorMessage || `Session ended (${res.data.status})`); + } + }, 1500); + }, [deviceId]); + + const connectVnc = useCallback((s: SessionInfo) => { + if (rfbCreatedRef.current) return; + const token = api.getToken(); + if (!token) { + setError("Not authenticated"); + return; + } + const wsUrl = `${wsBaseUrl()}/v1/remote/browser/${s.id}?token=${encodeURIComponent(token)}`; + + import("@novnc/novnc").then((mod) => { + const RFB = mod.default; + const target = + containerRef.current || document.createElement("div"); + const rfb = new RFB(target, wsUrl, { + credentials: { password: s.vncPassword || "" }, + share: true, + }); + rfbRef.current = rfb; + rfbCreatedRef.current = true; + rfb.addEventListener("connect", () => { + setConnected(true); + }); + rfb.addEventListener("disconnect", (ev: any) => { + setConnected(false); + setError(ev?.detail?.clean ? "Disconnected" : `Disconnected: ${ev?.detail?.reason || "connection lost"}`); + }); + rfb.addEventListener("credentialsrequired", () => { + setConnected(false); + setError("VNC password required but not provided"); + }); + rfb.scaleViewport = true; + rfb.resizeSession = true; + }).catch(() => { + setError("Failed to load VNC client"); + }); + }, []); + + useEffect(() => { + if (!open) return; + rfbCreatedRef.current = false; + createSession(); + }, [open, createSession]); + + useEffect(() => { + if (!open && rfbRef.current) { + try { + rfbRef.current.disconnect(); + } catch { /* ignore */ } + rfbRef.current = null; + rfbCreatedRef.current = false; + } + }, [open]); + + const stopSession = async () => { + if (session) { + await api.delete(`/v1/remote/sessions/${session.id}`); + } + if (rfbRef.current) { + try { + rfbRef.current.disconnect(); + } catch { /* ignore */ } + rfbRef.current = null; + } + rfbCreatedRef.current = false; + if (containerRef.current) { + containerRef.current.innerHTML = ""; + } + onOpenChange(false); + }; + + const vncElement = ( +
+ ); + + return ( + { if (!o) stopSession(); }}> + + {!fullscreen && ( + + + + Remote Desktop + {deviceName} + {connected ? ( + Connected + ) : connecting ? ( + Starting VNC… + ) : ( + Disconnected + )} + + + {session?.vncPassword + ? `VNC via encrypted tunnel · Password: ${session.vncPassword}` + : "Establishing a secure VNC tunnel to this device…"} + + + )} + +
+ {error && ( + + {error} + + )} + + {(connecting || (session && session.status !== "active")) && !error && ( +
+ + Waiting for the agent to start VNC… +
+ )} + + {!connecting && !error && session && session.status === "active" && ( + <> +
+ + +
+ {vncElement} + + )} + + {connected && !fullscreen && ( +

+ Hint: use the toolbar area above the screen for clipboard and keys. +

+ )} +
+
+
+ ); +} \ No newline at end of file diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx new file mode 100644 index 0000000..fd15658 --- /dev/null +++ b/apps/web/src/components/Sidebar.tsx @@ -0,0 +1,123 @@ +"use client"; + +import Link from "next/link"; +import { usePathname } from "next/navigation"; +import { + LayoutDashboard, + Server, + Users, + Shield, + Zap, + Bell, + ScrollText, + UserCog, + KeyRound, + LogOut, + Hexagon, + Package, + ClipboardCheck, + Rocket, + Download, +} from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { Separator } from "@/components/ui/separator"; +import { cn } from "@/lib/utils"; + +const navItems = [ + { href: "/dashboard", label: "Dashboard", icon: LayoutDashboard }, + { href: "/devices", label: "Devices", icon: Server }, + { href: "/groups", label: "Groups", icon: Users }, + { href: "/software", label: "Software", icon: Package }, + { href: "/deployments", label: "Deployments", icon: Rocket }, + { href: "/update-approvals", label: "Update Approvals", icon: ClipboardCheck }, + { href: "/policies", label: "Policies", icon: Shield }, + { href: "/jobs", label: "Jobs", icon: Zap }, + { href: "/alerts", label: "Alerts", icon: Bell }, + { href: "/downloads", label: "Downloads", icon: Download }, + { href: "/audit-log", label: "Audit Log", icon: ScrollText }, +]; + +const bottomItems = [ + { href: "/settings/users", label: "Users", icon: UserCog }, + { href: "/settings/enrollment", label: "Enrollment", icon: KeyRound }, +]; + +export default function Sidebar({ onLogout }: { onLogout: () => void }) { + const pathname = usePathname(); + + const isActive = (href: string) => pathname.startsWith(href); + + return ( + + ); +} diff --git a/apps/web/src/components/ui/alert.tsx b/apps/web/src/components/ui/alert.tsx new file mode 100644 index 0000000..100e29d --- /dev/null +++ b/apps/web/src/components/ui/alert.tsx @@ -0,0 +1,34 @@ +import * as React from "react"; +import { cn } from "@/lib/utils"; + +const Alert = React.forwardRef & { variant?: "default" | "destructive" }>( + ({ className, variant = "default", ...props }, ref) => ( +
svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground [&>svg~*]:pl-7", + variant === "destructive" && "border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive", + className + )} + {...props} + /> + ) +); +Alert.displayName = "Alert"; + +const AlertTitle = React.forwardRef>( + ({ className, ...props }, ref) => ( +
+ ) +); +AlertTitle.displayName = "AlertTitle"; + +const AlertDescription = React.forwardRef>( + ({ className, ...props }, ref) => ( +
+ ) +); +AlertDescription.displayName = "AlertDescription"; + +export { Alert, AlertTitle, AlertDescription }; diff --git a/apps/web/src/components/ui/badge.tsx b/apps/web/src/components/ui/badge.tsx new file mode 100644 index 0000000..bb24c6a --- /dev/null +++ b/apps/web/src/components/ui/badge.tsx @@ -0,0 +1,33 @@ +import * as React from "react"; +import { cva, type VariantProps } from "class-variance-authority"; +import { cn } from "@/lib/utils"; + +const badgeVariants = cva( + "inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2", + { + variants: { + variant: { + default: "border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80", + secondary: "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80", + destructive: "border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80", + outline: "text-foreground", + success: "border-transparent bg-green-100 text-green-800", + warning: "border-transparent bg-yellow-100 text-yellow-800", + info: "border-transparent bg-blue-100 text-blue-800", + }, + }, + defaultVariants: { + variant: "default", + }, + } +); + +export interface BadgeProps + extends React.HTMLAttributes, + VariantProps {} + +function Badge({ className, variant, ...props }: BadgeProps) { + return
; +} + +export { Badge, badgeVariants }; diff --git a/apps/web/src/components/ui/button.tsx b/apps/web/src/components/ui/button.tsx new file mode 100644 index 0000000..9b34be9 --- /dev/null +++ b/apps/web/src/components/ui/button.tsx @@ -0,0 +1,52 @@ +import * as React from "react"; +import { Slot } from "@radix-ui/react-slot"; +import { cva, type VariantProps } from "class-variance-authority"; +import { cn } from "@/lib/utils"; + +const buttonVariants = cva( + "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50", + { + variants: { + variant: { + default: "bg-primary text-primary-foreground shadow hover:bg-primary/90", + destructive: "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90", + outline: "border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground", + secondary: "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80", + ghost: "hover:bg-accent hover:text-accent-foreground", + link: "text-primary underline-offset-4 hover:underline", + }, + size: { + default: "h-9 px-4 py-2", + sm: "h-8 rounded-md px-3 text-xs", + lg: "h-10 rounded-md px-8", + icon: "h-9 w-9", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + } +); + +export interface ButtonProps + extends React.ButtonHTMLAttributes, + VariantProps { + asChild?: boolean; +} + +const Button = React.forwardRef( + ({ className, variant, size, asChild = false, ...props }, ref) => { + const Comp = asChild ? Slot : "button"; + return ( + + ); + } +); +Button.displayName = "Button"; + +export { Button, buttonVariants }; diff --git a/apps/web/src/components/ui/card.tsx b/apps/web/src/components/ui/card.tsx new file mode 100644 index 0000000..b98a7c9 --- /dev/null +++ b/apps/web/src/components/ui/card.tsx @@ -0,0 +1,50 @@ +import * as React from "react"; +import { cn } from "@/lib/utils"; + +const Card = React.forwardRef>( + ({ className, ...props }, ref) => ( +
+ ) +); +Card.displayName = "Card"; + +const CardHeader = React.forwardRef>( + ({ className, ...props }, ref) => ( +
+ ) +); +CardHeader.displayName = "CardHeader"; + +const CardTitle = React.forwardRef>( + ({ className, ...props }, ref) => ( +
+ ) +); +CardTitle.displayName = "CardTitle"; + +const CardDescription = React.forwardRef>( + ({ className, ...props }, ref) => ( +
+ ) +); +CardDescription.displayName = "CardDescription"; + +const CardContent = React.forwardRef>( + ({ className, ...props }, ref) => ( +
+ ) +); +CardContent.displayName = "CardContent"; + +const CardFooter = React.forwardRef>( + ({ className, ...props }, ref) => ( +
+ ) +); +CardFooter.displayName = "CardFooter"; + +export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }; diff --git a/apps/web/src/components/ui/dialog.tsx b/apps/web/src/components/ui/dialog.tsx new file mode 100644 index 0000000..7d35ce2 --- /dev/null +++ b/apps/web/src/components/ui/dialog.tsx @@ -0,0 +1,84 @@ +import * as React from "react"; +import * as DialogPrimitive from "@radix-ui/react-dialog"; +import { X } from "lucide-react"; +import { cn } from "@/lib/utils"; + +const Dialog = DialogPrimitive.Root; +const DialogTrigger = DialogPrimitive.Trigger; +const DialogPortal = DialogPrimitive.Portal; +const DialogClose = DialogPrimitive.Close; + +const DialogOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DialogOverlay.displayName = DialogPrimitive.Overlay.displayName; + +const DialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + {children} + + + Close + + + +)); +DialogContent.displayName = DialogPrimitive.Content.displayName; + +const DialogHeader = ({ className, ...props }: React.HTMLAttributes) => ( +
+); +DialogHeader.displayName = "DialogHeader"; + +const DialogFooter = ({ className, ...props }: React.HTMLAttributes) => ( +
+); +DialogFooter.displayName = "DialogFooter"; + +const DialogTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DialogTitle.displayName = DialogPrimitive.Title.displayName; + +const DialogDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DialogDescription.displayName = DialogPrimitive.Description.displayName; + +export { Dialog, DialogPortal, DialogOverlay, DialogClose, DialogTrigger, DialogContent, DialogHeader, DialogFooter, DialogTitle, DialogDescription }; diff --git a/apps/web/src/components/ui/dropdown-menu.tsx b/apps/web/src/components/ui/dropdown-menu.tsx new file mode 100644 index 0000000..65e43be --- /dev/null +++ b/apps/web/src/components/ui/dropdown-menu.tsx @@ -0,0 +1,66 @@ +import * as React from "react"; +import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"; +import { cn } from "@/lib/utils"; + +const DropdownMenu = DropdownMenuPrimitive.Root; +const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger; + +const DropdownMenuContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, sideOffset = 4, ...props }, ref) => ( + + + +)); +DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName; + +const DropdownMenuItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { inset?: boolean } +>(({ className, inset, ...props }, ref) => ( + +)); +DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName; + +const DropdownMenuSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName; + +const DropdownMenuLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { inset?: boolean } +>(({ className, inset, ...props }, ref) => ( + +)); +DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName; + +export { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuLabel }; diff --git a/apps/web/src/components/ui/input.tsx b/apps/web/src/components/ui/input.tsx new file mode 100644 index 0000000..a46eb36 --- /dev/null +++ b/apps/web/src/components/ui/input.tsx @@ -0,0 +1,21 @@ +import * as React from "react"; +import { cn } from "@/lib/utils"; + +const Input = React.forwardRef>( + ({ className, type, ...props }, ref) => { + return ( + + ); + } +); +Input.displayName = "Input"; + +export { Input }; diff --git a/apps/web/src/components/ui/label.tsx b/apps/web/src/components/ui/label.tsx new file mode 100644 index 0000000..9267b99 --- /dev/null +++ b/apps/web/src/components/ui/label.tsx @@ -0,0 +1,18 @@ +import * as React from "react"; +import * as LabelPrimitive from "@radix-ui/react-label"; +import { cva, type VariantProps } from "class-variance-authority"; +import { cn } from "@/lib/utils"; + +const labelVariants = cva( + "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70" +); + +const Label = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & VariantProps +>(({ className, ...props }, ref) => ( + +)); +Label.displayName = LabelPrimitive.Root.displayName; + +export { Label }; diff --git a/apps/web/src/components/ui/progress.tsx b/apps/web/src/components/ui/progress.tsx new file mode 100644 index 0000000..a14e12c --- /dev/null +++ b/apps/web/src/components/ui/progress.tsx @@ -0,0 +1,22 @@ +"use client"; + +import * as React from "react"; + +interface ProgressProps extends React.HTMLAttributes { + value?: number; + max?: number; +} + +const Progress = React.forwardRef( + ({ className, value = 0, max = 100, ...props }, ref) => { + const percentage = Math.min(100, Math.max(0, (value / max) * 100)); + return ( +
+
+
+ ); + } +); +Progress.displayName = "Progress"; + +export { Progress }; diff --git a/apps/web/src/components/ui/scroll-area.tsx b/apps/web/src/components/ui/scroll-area.tsx new file mode 100644 index 0000000..9936caf --- /dev/null +++ b/apps/web/src/components/ui/scroll-area.tsx @@ -0,0 +1,43 @@ +import * as React from "react"; +import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"; +import { cn } from "@/lib/utils"; + +const ScrollArea = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + {children} + + + + +)); +ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName; + +const ScrollBar = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, orientation = "vertical", ...props }, ref) => ( + + + +)); +ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName; + +export { ScrollArea, ScrollBar }; diff --git a/apps/web/src/components/ui/select.tsx b/apps/web/src/components/ui/select.tsx new file mode 100644 index 0000000..7fb901d --- /dev/null +++ b/apps/web/src/components/ui/select.tsx @@ -0,0 +1,80 @@ +import * as React from "react"; +import * as SelectPrimitive from "@radix-ui/react-select"; +import { Check, ChevronDown } from "lucide-react"; +import { cn } from "@/lib/utils"; + +const Select = SelectPrimitive.Root; +const SelectGroup = SelectPrimitive.Group; +const SelectValue = SelectPrimitive.Value; + +const SelectTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + span]:line-clamp-1", + className + )} + {...props} + > + {children} + + + + +)); +SelectTrigger.displayName = SelectPrimitive.Trigger.displayName; + +const SelectContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, position = "popper", ...props }, ref) => ( + + + + {children} + + + +)); +SelectContent.displayName = SelectPrimitive.Content.displayName; + +const SelectItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + + + + {children} + +)); +SelectItem.displayName = SelectPrimitive.Item.displayName; + +export { Select, SelectGroup, SelectValue, SelectTrigger, SelectContent, SelectItem }; diff --git a/apps/web/src/components/ui/separator.tsx b/apps/web/src/components/ui/separator.tsx new file mode 100644 index 0000000..46d68b7 --- /dev/null +++ b/apps/web/src/components/ui/separator.tsx @@ -0,0 +1,23 @@ +import * as React from "react"; +import * as SeparatorPrimitive from "@radix-ui/react-separator"; +import { cn } from "@/lib/utils"; + +const Separator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, orientation = "horizontal", decorative = true, ...props }, ref) => ( + +)); +Separator.displayName = SeparatorPrimitive.Root.displayName; + +export { Separator }; diff --git a/apps/web/src/components/ui/skeleton.tsx b/apps/web/src/components/ui/skeleton.tsx new file mode 100644 index 0000000..fcca5f3 --- /dev/null +++ b/apps/web/src/components/ui/skeleton.tsx @@ -0,0 +1,9 @@ +import { cn } from "@/lib/utils"; + +function Skeleton({ className, ...props }: React.HTMLAttributes) { + return ( +
+ ); +} + +export { Skeleton }; diff --git a/apps/web/src/components/ui/table.tsx b/apps/web/src/components/ui/table.tsx new file mode 100644 index 0000000..c47874c --- /dev/null +++ b/apps/web/src/components/ui/table.tsx @@ -0,0 +1,62 @@ +import * as React from "react"; +import { cn } from "@/lib/utils"; + +const Table = React.forwardRef>( + ({ className, ...props }, ref) => ( +
+ + + ) +); +Table.displayName = "Table"; + +const TableHeader = React.forwardRef>( + ({ className, ...props }, ref) => ( + + ) +); +TableHeader.displayName = "TableHeader"; + +const TableBody = React.forwardRef>( + ({ className, ...props }, ref) => ( + + ) +); +TableBody.displayName = "TableBody"; + +const TableFooter = React.forwardRef>( + ({ className, ...props }, ref) => ( + tr]:last:border-b-0", className)} {...props} /> + ) +); +TableFooter.displayName = "TableFooter"; + +const TableRow = React.forwardRef>( + ({ className, ...props }, ref) => ( + + ) +); +TableRow.displayName = "TableRow"; + +const TableHead = React.forwardRef>( + ({ className, ...props }, ref) => ( +
[role=checkbox]]:translate-y-[2px]", className)} {...props} /> + ) +); +TableHead.displayName = "TableHead"; + +const TableCell = React.forwardRef>( + ({ className, ...props }, ref) => ( + [role=checkbox]]:translate-y-[2px]", className)} {...props} /> + ) +); +TableCell.displayName = "TableCell"; + +const TableCaption = React.forwardRef>( + ({ className, ...props }, ref) => ( +
+ ) +); +TableCaption.displayName = "TableCaption"; + +export { Table, TableHeader, TableBody, TableFooter, TableHead, TableRow, TableCell, TableCaption }; diff --git a/apps/web/src/components/ui/tabs.tsx b/apps/web/src/components/ui/tabs.tsx new file mode 100644 index 0000000..86bc65f --- /dev/null +++ b/apps/web/src/components/ui/tabs.tsx @@ -0,0 +1,52 @@ +import * as React from "react"; +import * as TabsPrimitive from "@radix-ui/react-tabs"; +import { cn } from "@/lib/utils"; + +const Tabs = TabsPrimitive.Root; + +const TabsList = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +TabsList.displayName = TabsPrimitive.List.displayName; + +const TabsTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +TabsTrigger.displayName = TabsPrimitive.Trigger.displayName; + +const TabsContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +TabsContent.displayName = TabsPrimitive.Content.displayName; + +export { Tabs, TabsList, TabsTrigger, TabsContent }; diff --git a/apps/web/src/hooks/useAuth.ts b/apps/web/src/hooks/useAuth.ts new file mode 100644 index 0000000..0c59dcf --- /dev/null +++ b/apps/web/src/hooks/useAuth.ts @@ -0,0 +1,61 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useRouter } from "next/navigation"; +import { api } from "@/lib/api"; + +interface User { + id: string; + email: string; + displayName: string; + role: string; +} + +export function useAuth() { + const [user, setUser] = useState(null); + const [loading, setLoading] = useState(true); + const router = useRouter(); + + useEffect(() => { + const token = api.getToken(); + if (!token) { + setLoading(false); + return; + } + + fetch(`${process.env.NEXT_PUBLIC_API_URL || "http://localhost:3000/api"}/v1/auth/me`, { + headers: { Authorization: `Bearer ${token}` }, + }) + .then((res) => res.json()) + .then((data) => { + if (data.success) { + setUser(data.data); + } else { + api.setToken(null); + } + }) + .catch(() => api.setToken(null)) + .finally(() => setLoading(false)); + }, []); + + const login = async (email: string, password: string) => { + const res = await api.post<{ token: string; user: User }>("/v1/auth/login", { + email, + password, + }); + if (res.success && res.data) { + api.setToken(res.data.token); + setUser(res.data.user); + return true; + } + return false; + }; + + const logout = () => { + api.setToken(null); + setUser(null); + router.push("/login"); + }; + + return { user, loading, login, logout }; +} diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts new file mode 100644 index 0000000..2438677 --- /dev/null +++ b/apps/web/src/lib/api.ts @@ -0,0 +1,84 @@ +const API_BASE = process.env.NEXT_PUBLIC_API_URL || "http://localhost:3000/api"; + +export interface ApiResponse { + success: boolean; + data?: T; + error?: { code: string; message: string }; + meta?: { page: number; limit: number; total: number; totalPages: number }; +} + +class ApiClient { + private token: string | null = null; + + setToken(token: string | null) { + this.token = token; + if (token) { + localStorage.setItem("ff_token", token); + } else { + localStorage.removeItem("ff_token"); + } + } + + getToken(): string | null { + if (!this.token) { + this.token = typeof window !== "undefined" ? localStorage.getItem("ff_token") : null; + } + return this.token; + } + + async request(path: string, options: RequestInit = {}): Promise> { + const token = this.getToken(); + const headers: Record = { + "Content-Type": "application/json", + ...((options.headers as Record) || {}), + }; + + if (token) { + headers["Authorization"] = `Bearer ${token}`; + } + + const res = await fetch(`${API_BASE}${path}`, { + ...options, + headers, + }); + + if (res.status === 401) { + this.setToken(null); + if (typeof window !== "undefined" && !window.location.pathname.includes("/login")) { + window.location.href = "/login"; + } + } + + if (res.status === 204) { + return { success: true }; + } + + const data = await res.json(); + return data; + } + + async get(path: string, params?: Record): Promise> { + const query = params ? "?" + new URLSearchParams(params).toString() : ""; + return this.request(`${path}${query}`); + } + + async post(path: string, body?: unknown): Promise> { + return this.request(path, { + method: "POST", + body: body ? JSON.stringify(body) : undefined, + }); + } + + async patch(path: string, body?: unknown): Promise> { + return this.request(path, { + method: "PATCH", + body: body ? JSON.stringify(body) : undefined, + }); + } + + async delete(path: string): Promise> { + return this.request(path, { method: "DELETE" }); + } +} + +export const api = new ApiClient(); diff --git a/apps/web/src/lib/utils.ts b/apps/web/src/lib/utils.ts new file mode 100644 index 0000000..365058c --- /dev/null +++ b/apps/web/src/lib/utils.ts @@ -0,0 +1,6 @@ +import { type ClassValue, clsx } from "clsx"; +import { twMerge } from "tailwind-merge"; + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)); +} diff --git a/apps/web/src/types/novnc.d.ts b/apps/web/src/types/novnc.d.ts new file mode 100644 index 0000000..aaeb7c8 --- /dev/null +++ b/apps/web/src/types/novnc.d.ts @@ -0,0 +1,11 @@ +declare module "@novnc/novnc" { + export default class RFB extends EventTarget { + constructor(target: HTMLElement, url: string, options?: Record); + scaleViewport: boolean; + resizeSession: boolean; + clipViewport: boolean; + disconnect(): void; + sendCtrlAltDel(): void; + addEventListener(type: string, listener: EventListenerOrEventListenerObject | null, options?: boolean | AddEventListenerOptions): void; + } +} \ No newline at end of file diff --git a/apps/web/tailwind.config.js b/apps/web/tailwind.config.js new file mode 100644 index 0000000..ea8fa32 --- /dev/null +++ b/apps/web/tailwind.config.js @@ -0,0 +1,57 @@ +/** @type {import('tailwindcss').Config} */ +module.exports = { + darkMode: ["class"], + content: ["./src/**/*.{js,ts,jsx,tsx,mdx}"], + theme: { + extend: { + colors: { + border: "hsl(var(--border))", + input: "hsl(var(--input))", + ring: "hsl(var(--ring))", + background: "hsl(var(--background))", + foreground: "hsl(var(--foreground))", + primary: { + DEFAULT: "hsl(var(--primary))", + foreground: "hsl(var(--primary-foreground))", + }, + secondary: { + DEFAULT: "hsl(var(--secondary))", + foreground: "hsl(var(--secondary-foreground))", + }, + destructive: { + DEFAULT: "hsl(var(--destructive))", + foreground: "hsl(var(--destructive-foreground))", + }, + muted: { + DEFAULT: "hsl(var(--muted))", + foreground: "hsl(var(--muted-foreground))", + }, + accent: { + DEFAULT: "hsl(var(--accent))", + foreground: "hsl(var(--accent-foreground))", + }, + popover: { + DEFAULT: "hsl(var(--popover))", + foreground: "hsl(var(--popover-foreground))", + }, + card: { + DEFAULT: "hsl(var(--card))", + foreground: "hsl(var(--card-foreground))", + }, + sidebar: { + DEFAULT: "hsl(var(--sidebar))", + foreground: "hsl(var(--sidebar-foreground))", + accent: "hsl(var(--sidebar-accent))", + "accent-foreground": "hsl(var(--sidebar-accent-foreground))", + border: "hsl(var(--sidebar-border))", + }, + }, + borderRadius: { + lg: "var(--radius)", + md: "calc(var(--radius) - 2px)", + sm: "calc(var(--radius) - 4px)", + }, + }, + }, + plugins: [require("tailwindcss-animate")], +}; diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json new file mode 100644 index 0000000..54583d9 --- /dev/null +++ b/apps/web/tsconfig.json @@ -0,0 +1,19 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "target": "ES2017", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "noEmit": true, + "module": "esnext", + "moduleResolution": "bundler", + "jsx": "preserve", + "incremental": true, + "plugins": [{ "name": "next" }], + "paths": { + "@/*": ["./src/*"] + } + }, + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], + "exclude": ["node_modules"] +} diff --git a/apps/worker/package.json b/apps/worker/package.json new file mode 100644 index 0000000..5484f80 --- /dev/null +++ b/apps/worker/package.json @@ -0,0 +1,23 @@ +{ + "name": "@fleetforge/worker", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "tsx watch src/index.ts", + "build": "tsc", + "start": "node dist/index.js", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@fleetforge/shared": "workspace:*", + "dotenv": "^16.4.7", + "drizzle-orm": "^0.38.0", + "pg": "^8.13.0" + }, + "devDependencies": { + "@types/pg": "^8.11.10", + "tsx": "^4.19.0", + "typescript": "^5.7.0" + } +} diff --git a/apps/worker/src/index.ts b/apps/worker/src/index.ts new file mode 100644 index 0000000..96743e5 --- /dev/null +++ b/apps/worker/src/index.ts @@ -0,0 +1,535 @@ +import dotenv from "dotenv"; +dotenv.config(); + +import pg from "pg"; +import { logger } from "./logger.js"; + +const STALE_THRESHOLD_MS = 2 * 60 * 1000; +const CHECK_INTERVAL_MS = 30 * 1000; +const DISK_CRITICAL_PERCENT = 90; + +async function checkDeviceStatuses(pool: pg.Pool) { + const client = await pool.connect(); + try { + const staleThreshold = new Date(Date.now() - STALE_THRESHOLD_MS); + + const { rows: staleDevices } = await client.query( + `UPDATE devices SET status = 'offline', updated_at = NOW() + WHERE status = 'online' + AND last_heartbeat_at < $1 + AND is_disabled = false + AND is_archived = false + RETURNING id, name, organization_id`, + [staleThreshold] + ); + + for (const device of staleDevices) { + const existing = await client.query( + `SELECT id FROM alerts + WHERE device_id = $1 + AND alert_type = 'device_offline' + AND is_resolved = false + LIMIT 1`, + [device.id] + ); + + if (existing.rows.length === 0) { + await client.query( + `INSERT INTO alerts (organization_id, device_id, alert_type, severity, title, message) + VALUES ($1, $2, 'device_offline', 'warning', $3, $4)`, + [ + device.organization_id, + device.id, + `Device "${device.name}" is offline`, + `Device "${device.name}" has not sent a heartbeat in over ${STALE_THRESHOLD_MS / 1000} seconds.`, + ] + ); + } + } + + if (staleDevices.length > 0) { + logger.info("Marked devices offline", { count: staleDevices.length }); + } + } finally { + client.release(); + } +} + +async function checkDiskAlerts(pool: pg.Pool) { + const client = await pool.connect(); + try { + const { rows: criticalDevices } = await client.query( + `SELECT d.id, d.name, d.organization_id, m.disk_usage_percent + FROM devices d + JOIN LATERAL ( + SELECT disk_usage_percent + FROM metric_snapshots + WHERE device_id = d.id + ORDER BY reported_at DESC + LIMIT 1 + ) m ON true + WHERE m.disk_usage_percent >= $1 + AND d.status = 'online' + AND d.is_disabled = false`, + [DISK_CRITICAL_PERCENT] + ); + + for (const device of criticalDevices) { + const existing = await client.query( + `SELECT id FROM alerts + WHERE device_id = $1 + AND alert_type = 'disk_critical' + AND is_resolved = false + LIMIT 1`, + [device.id] + ); + + if (existing.rows.length === 0) { + await client.query( + `INSERT INTO alerts (organization_id, device_id, alert_type, severity, title, message) + VALUES ($1, $2, 'disk_critical', 'error', $3, $4)`, + [ + device.organization_id, + device.id, + `Critical disk usage on "${device.name}"`, + `Disk usage is at ${device.disk_usage_percent}% on device "${device.name}".`, + ] + ); + } + } + + if (criticalDevices.length > 0) { + logger.info("Created disk alerts", { count: criticalDevices.length }); + } + } finally { + client.release(); + } +} + +async function checkAgentOutdated(pool: pg.Pool) { + const client = await pool.connect(); + try { + const CURRENT_AGENT_VERSION = "0.1.0"; + + const { rows: outdatedDevices } = await client.query( + `SELECT id, name, organization_id, agent_version + FROM devices + WHERE agent_version < $1 + AND status = 'online' + AND is_disabled = false`, + [CURRENT_AGENT_VERSION] + ); + + for (const device of outdatedDevices) { + const existing = await client.query( + `SELECT id FROM alerts + WHERE device_id = $1 + AND alert_type = 'agent_outdated' + AND is_resolved = false + LIMIT 1`, + [device.id] + ); + + if (existing.rows.length === 0) { + await client.query( + `INSERT INTO alerts (organization_id, device_id, alert_type, severity, title, message) + VALUES ($1, $2, 'agent_outdated', 'info', $3, $4)`, + [ + device.organization_id, + device.id, + `Agent outdated on "${device.name}"`, + `Device "${device.name}" is running agent v${device.agent_version}, current is v${CURRENT_AGENT_VERSION}.`, + ] + ); + } + } + } finally { + client.release(); + } +} + +async function dispatchJobs(pool: pg.Pool) { + const client = await pool.connect(); + try { + const { rows: queuedJobs } = await client.query( + `UPDATE jobs SET status = 'dispatched', dispatched_at = NOW() + WHERE status = 'queued' + AND (expires_at IS NULL OR expires_at > NOW()) + RETURNING id, job_type` + ); + + for (const job of queuedJobs) { + await client.query( + `UPDATE job_targets SET status = 'dispatched' + WHERE job_id = $1 AND status = 'queued'`, + [job.id] + ); + } + + const { rows: expiredJobs } = await client.query( + `UPDATE jobs SET status = 'expired', completed_at = NOW() + WHERE status IN ('queued', 'dispatched') + AND expires_at IS NOT NULL + AND expires_at < NOW() + RETURNING id` + ); + + for (const job of expiredJobs) { + await client.query( + `UPDATE job_targets SET status = 'expired', completed_at = NOW() + WHERE job_id = $1 AND status IN ('queued', 'dispatched')`, + [job.id] + ); + } + + if (queuedJobs.length > 0) { + logger.info("Dispatched jobs", { count: queuedJobs.length }); + } + if (expiredJobs.length > 0) { + logger.info("Expired jobs", { count: expiredJobs.length }); + } + } finally { + client.release(); + } +} + +async function dispatchDeployments(pool: pg.Pool) { + const client = await pool.connect(); + try { + const { rows: approvedDeployments } = await client.query( + `SELECT id, name, job_type, parameters, organization_id, created_by_user_id + FROM deployments + WHERE status = 'approved' + AND (scheduled_at IS NULL OR scheduled_at <= NOW())` + ); + + for (const deployment of approvedDeployments) { + await client.query( + `UPDATE deployments SET status = 'running', started_at = NOW() + WHERE id = $1`, + [deployment.id] + ); + + const { rows: targets } = await client.query( + `SELECT id, device_id FROM deployment_targets + WHERE deployment_id = $1 AND status = 'pending'`, + [deployment.id] + ); + + for (const target of targets) { + const jobResult = await client.query( + `INSERT INTO jobs (organization_id, created_by_user_id, job_type, parameters, status) + VALUES ($1, $2, $3, $4, 'queued') + RETURNING id`, + [deployment.organization_id, deployment.created_by_user_id, deployment.job_type, JSON.stringify(deployment.parameters)] + ); + + const jobId = jobResult.rows[0].id; + + await client.query( + `INSERT INTO job_targets (job_id, device_id, status) + VALUES ($1, $2, 'queued')`, + [jobId, target.device_id] + ); + + await client.query( + `UPDATE deployment_targets SET job_id = $1, status = 'running', started_at = NOW() + WHERE id = $2`, + [jobId, target.id] + ); + } + + logger.info("Dispatched deployment", { deploymentId: deployment.id, name: deployment.name, targetCount: targets.length }); + } + + const { rows: runningDeployments } = await client.query( + `SELECT d.id, d.total_targets, + (SELECT COUNT(*) FROM deployment_targets dt WHERE dt.deployment_id = d.id AND dt.status = 'succeeded') as completed, + (SELECT COUNT(*) FROM deployment_targets dt WHERE dt.deployment_id = d.id AND dt.status = 'failed') as failed, + (SELECT COUNT(*) FROM deployment_targets dt WHERE dt.deployment_id = d.id AND dt.status IN ('pending', 'running')) as active + FROM deployments d + WHERE d.status = 'running'` + ); + + for (const d of runningDeployments) { + if (Number(d.active) === 0) { + const finalStatus = Number(d.failed) > 0 ? 'partially_completed' : 'completed'; + await client.query( + `UPDATE deployments SET status = $1, completed_at = NOW(), completed_targets = $2, failed_targets = $3 + WHERE id = $4`, + [finalStatus, Number(d.completed), Number(d.failed), d.id] + ); + + await client.query( + `UPDATE deployment_targets SET status = 'expired', completed_at = NOW() + WHERE deployment_id = $1 AND status = 'pending'`, + [d.id] + ); + + logger.info("Deployment finished", { deploymentId: d.id, status: finalStatus }); + } + } + } finally { + client.release(); + } +} + +async function evaluateCompliance(pool: pg.Pool) { + const client = await pool.connect(); + try { + const { rows: orgs } = await client.query( + `SELECT DISTINCT organization_id FROM policies WHERE is_enabled = true` + ); + + let totalEvaluated = 0; + let totalNonCompliant = 0; + + for (const org of orgs) { + const { rows: devices } = await client.query( + `SELECT id FROM devices + WHERE organization_id = $1 + AND is_disabled = false AND is_archived = false`, + [org.organization_id] + ); + + const { rows: policies } = await client.query( + `SELECT id, rules FROM policies + WHERE organization_id = $1 AND is_enabled = true`, + [org.organization_id] + ); + + if (devices.length === 0 || policies.length === 0) continue; + + const deviceIds = devices.map((d: any) => d.id); + + const metricsMap = new Map(); + const inventoryMap = new Map(); + + if (deviceIds.length > 0) { + const { rows: metricsRows } = await client.query( + `SELECT DISTINCT ON (device_id) device_id, + cpu_usage_percent, memory_usage_percent, disk_usage_percent, + load_average_1m, uptime_seconds + FROM metric_snapshots WHERE device_id = ANY($1) + ORDER BY device_id, reported_at DESC`, + [deviceIds] + ); + for (const r of metricsRows) metricsMap.set(r.device_id, r); + + const { rows: invRows } = await client.query( + `SELECT DISTINCT ON (device_id) device_id, packages, services + FROM inventory_snapshots WHERE device_id = ANY($1) + ORDER BY device_id, reported_at DESC`, + [deviceIds] + ); + for (const r of invRows) inventoryMap.set(r.device_id, r); + } + + const { rows: deviceRows } = await client.query( + `SELECT id, os_name, os_version, platform, architecture, agent_version, + cpu_cores, total_memory_bytes, total_disk_bytes, status, + tags, kernel_version + FROM devices WHERE id = ANY($1)`, + [deviceIds] + ); + + const deviceMap = new Map(); + for (const d of deviceRows) deviceMap.set(d.id, d); + + const now = new Date(); + + for (const device of deviceRows) { + const metrics = metricsMap.get(device.id) || null; + const inventory = inventoryMap.get(device.id) || null; + + for (const policy of policies) { + const rules = (policy.rules || []) as any[]; + + let allPassed = true; + let hasUnknown = false; + const ruleResults: Record = {}; + + for (const rule of rules) { + const actual = getField(device, metrics, inventory, rule.type); + + if (actual === undefined) { + ruleResults[rule.type] = { + condition: rule.condition, + expected: rule.value, + actual: null, + passed: false, + reason: "field_not_found", + }; + hasUnknown = true; + allPassed = false; + continue; + } + + const passed = evaluateRule(actual, rule.condition, rule.value); + ruleResults[rule.type] = { + condition: rule.condition, + expected: rule.value, + actual, + passed, + }; + if (!passed) allPassed = false; + } + + let status: string; + if (hasUnknown && !allPassed) status = "unknown"; + else if (allPassed) status = "compliant"; + else status = "non_compliant"; + + await client.query( + `INSERT INTO compliance_results (device_id, policy_id, status, details, evaluated_at) + VALUES ($1, $2, $3, $4, $5) + ON CONFLICT (device_id, policy_id) DO UPDATE SET + status = EXCLUDED.status, details = EXCLUDED.details, evaluated_at = EXCLUDED.evaluated_at`, + [device.id, policy.id, status, JSON.stringify(ruleResults), now] + ); + + totalEvaluated++; + if (status === "non_compliant") totalNonCompliant++; + } + } + } + + if (totalEvaluated > 0) { + console.log( + `Compliance: ${totalEvaluated} evaluations, ${totalNonCompliant} non-compliant.` + ); + } + } finally { + client.release(); + } +} + +function getField( + device: any, + metrics: any, + inventory: any, + fieldType: string +): unknown { + switch (fieldType) { + case "os_version": return device.os_version; + case "os_name": return device.os_name; + case "platform": return device.platform; + case "architecture": return device.architecture; + case "agent_version": return device.agent_version; + case "cpu_cores": return device.cpu_cores; + case "memory_total": return device.total_memory_bytes; + case "disk_total": return device.total_disk_bytes; + case "status": return device.status; + case "tags": return device.tags || []; + case "kernel_version": return device.kernel_version; + case "cpu_usage": return metrics?.cpu_usage_percent; + case "memory_usage": return metrics?.memory_usage_percent; + case "disk_usage": return metrics?.disk_usage_percent; + case "load_average": return metrics?.load_average_1m; + case "uptime": return metrics?.uptime_seconds; + case "package_count": return inventory?.packages?.length ?? 0; + case "has_package": return inventory?.packages?.map((p: any) => p.name) ?? []; + case "service_count": return inventory?.services?.length ?? 0; + default: return undefined; + } +} + +function compareVersions(a: string, b: string): number { + const pa = a.split(".").map(Number); + const pb = b.split(".").map(Number); + for (let i = 0; i < Math.max(pa.length, pb.length); i++) { + const na = pa[i] || 0; + const nb = pb[i] || 0; + if (na > nb) return 1; + if (na < nb) return -1; + } + return 0; +} + +function evaluateRule(actual: unknown, condition: string, expected: any): boolean { + if (actual === undefined || actual === null) { + return condition === "not_equals" && expected !== null; + } + + switch (condition) { + case "equals": return String(actual) === String(expected); + case "not_equals": return String(actual) !== String(expected); + case "greater_than": return Number(actual) > Number(expected); + case "less_than": return Number(actual) < Number(expected); + case "greater_than_or_equal": return Number(actual) >= Number(expected); + case "less_than_or_equal": return Number(actual) <= Number(expected); + case "contains": + if (Array.isArray(actual)) return actual.includes(String(expected)); + return String(actual).includes(String(expected)); + case "not_contains": + if (Array.isArray(actual)) return !actual.includes(String(expected)); + return !String(actual).includes(String(expected)); + case "in": + if (Array.isArray(expected)) return expected.map(String).includes(String(actual)); + return String(actual) === String(expected); + case "not_in": + if (Array.isArray(expected)) return !expected.map(String).includes(String(actual)); + return String(actual) !== String(expected); + case "version_gte": return compareVersions(String(actual), String(expected)) >= 0; + case "version_lte": return compareVersions(String(actual), String(expected)) <= 0; + case "version_gt": return compareVersions(String(actual), String(expected)) > 0; + case "version_lt": return compareVersions(String(actual), String(expected)) < 0; + case "is_true": return actual === true || actual === "true"; + case "is_false": return actual === false || actual === "false"; + case "is_empty": + if (Array.isArray(actual)) return actual.length === 0; + if (typeof actual === "string") return actual.length === 0; + return actual === null || actual === undefined; + case "is_not_empty": + if (Array.isArray(actual)) return actual.length > 0; + if (typeof actual === "string") return actual.length > 0; + return actual !== null && actual !== undefined; + default: return false; + } +} + +async function runChecks(pool: pg.Pool) { + try { + await checkDeviceStatuses(pool); + await checkDiskAlerts(pool); + await checkAgentOutdated(pool); + await dispatchJobs(pool); + await dispatchDeployments(pool); + await evaluateCompliance(pool); + } catch (err) { + logger.error("Worker check error", {}, err as Error); + } +} + +async function main() { + logger.info("Fleet Forge Worker starting", { + staleThreshold: `${STALE_THRESHOLD_MS / 1000}s`, + diskCriticalThreshold: `${DISK_CRITICAL_PERCENT}%`, + checkInterval: `${CHECK_INTERVAL_MS / 1000}s`, + }); + + const pool = new pg.Pool({ + connectionString: process.env.DATABASE_URL, + }); + + await pool.query("SELECT 1"); + logger.info("Connected to PostgreSQL"); + + await runChecks(pool); + + setInterval(() => runChecks(pool), CHECK_INTERVAL_MS); + + const shutdown = async () => { + logger.info("Worker shutting down"); + await pool.end(); + process.exit(0); + }; + + process.on("SIGINT", shutdown); + process.on("SIGTERM", shutdown); +} + +main().catch((err) => { + logger.error("Worker failed to start", {}, err as Error); + process.exit(1); +}); diff --git a/apps/worker/src/logger.ts b/apps/worker/src/logger.ts new file mode 100644 index 0000000..d46730b --- /dev/null +++ b/apps/worker/src/logger.ts @@ -0,0 +1,65 @@ +type LogLevel = "debug" | "info" | "warn" | "error"; + +interface LogEntry { + timestamp: string; + level: LogLevel; + message: string; + context?: Record; + error?: { + name: string; + message: string; + stack?: string; + }; +} + +function formatEntry(entry: LogEntry): string { + return JSON.stringify(entry); +} + +function createLogger(defaultContext?: Record) { + const log = (level: LogLevel, message: string, context?: Record, error?: Error) => { + const entry: LogEntry = { + timestamp: new Date().toISOString(), + level, + message, + ...((defaultContext || context) && { context: { ...defaultContext, ...context } }), + }; + + if (error) { + entry.error = { + name: error.name, + message: error.message, + stack: error.stack, + }; + } + + const line = formatEntry(entry); + + switch (level) { + case "error": + console.error(line); + break; + case "warn": + console.warn(line); + break; + default: + console.log(line); + } + }; + + return { + debug: (message: string, context?: Record) => + log("debug", message, context), + info: (message: string, context?: Record) => + log("info", message, context), + warn: (message: string, context?: Record) => + log("warn", message, context), + error: (message: string, context?: Record, error?: Error) => + log("error", message, context, error), + child: (childContext: Record) => + createLogger({ ...defaultContext, ...childContext }), + }; +} + +export const logger = createLogger({ service: "fleetforge-worker" }); +export { createLogger }; diff --git a/apps/worker/tsconfig.json b/apps/worker/tsconfig.json new file mode 100644 index 0000000..4b2ecad --- /dev/null +++ b/apps/worker/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src", + "declaration": false, + "declarationMap": false + }, + "include": ["src"] +} diff --git a/docs/adrs/001-monorepo-structure.md b/docs/adrs/001-monorepo-structure.md new file mode 100644 index 0000000..177518f --- /dev/null +++ b/docs/adrs/001-monorepo-structure.md @@ -0,0 +1,21 @@ +# ADR-001: Monorepo with pnpm Workspaces + +## Status +Accepted + +## Context +Fleet Forge requires multiple packages: API server, web dashboard, shared types, and the Go agent. We need a structure that supports shared code, independent builds, and a single repository. + +## Decision +Use pnpm workspaces for the TypeScript/Node.js packages. The Go agent lives in the same repo but is built independently. + +## Rationale +- pnpm workspaces provide efficient dependency management with a single lockfile +- Workspace references (`workspace:*`) enable local development without publishing +- Compatible with Next.js, Express, and Drizzle ORM +- The Go agent uses standard Go modules independently + +## Consequences +- All TS packages share a common `tsconfig.base.json` +- Build order is managed via workspace dependency graph +- Go agent has its own `go.mod` and is not part of the pnpm workspace diff --git a/docs/adrs/002-api-stack.md b/docs/adrs/002-api-stack.md new file mode 100644 index 0000000..488d428 --- /dev/null +++ b/docs/adrs/002-api-stack.md @@ -0,0 +1,22 @@ +# ADR-002: TypeScript + Express for API + +## Status +Accepted + +## Context +The API server needs to be fast to develop, type-safe, and maintainable. + +## Decision +Use TypeScript with Express.js, Drizzle ORM, and PostgreSQL. + +## Rationale +- TypeScript provides end-to-end type safety when shared with the frontend +- Express is mature, well-documented, and sufficient for a REST API +- Drizzle ORM is lightweight, type-safe, and generates SQL directly +- Zod provides runtime request validation that aligns with TypeScript types +- bcrypt for password hashing, JWT for auth tokens + +## Consequences +- API and web share the `@fleetforge/shared` package +- Drizzle migrations are SQL-based, version-controlled in `infrastructure/migrations/` +- No ORM overhead from heavier alternatives (Prisma, TypeORM) diff --git a/docs/adrs/003-agent-language.md b/docs/adrs/003-agent-language.md new file mode 100644 index 0000000..55ea710 --- /dev/null +++ b/docs/adrs/003-agent-language.md @@ -0,0 +1,22 @@ +# ADR-003: Go for Forge Agent + +## Status +Accepted + +## Context +The agent needs to run on Linux, Windows, and macOS with minimal dependencies and resource usage. + +## Decision +Write the agent in Go, producing statically-linked binaries for each platform. + +## Rationale +- Go compiles to a single binary with no runtime dependencies +- Excellent cross-compilation support (GOOS/GOARCH) +- Small memory footprint suitable for resource-constrained devices +- gopsutil library provides cross-platform system metrics +- Strong standard library for HTTP, JSON, and OS interaction + +## Consequences +- Agent is built separately from the TypeScript monorepo +- Agent version is tracked in the source code and reported in heartbeats +- Future: could use Go plugins for platform-specific adapters diff --git a/docs/adrs/004-authentication.md b/docs/adrs/004-authentication.md new file mode 100644 index 0000000..6481471 --- /dev/null +++ b/docs/adrs/004-authentication.md @@ -0,0 +1,24 @@ +# ADR-004: Authentication Model + +## Status +Accepted + +## Context +The platform needs secure authentication for both admin users and device agents. + +## Decision +- **Admin users**: JWT-based authentication with bcrypt password hashes +- **Device agents**: Unique credential per device, stored as SHA-256 hashes in the database +- **Enrollment**: Time-limited, optionally single-use tokens, also stored as hashes + +## Rationale +- JWTs are stateless, simple to implement, and work well with REST APIs +- Device credentials are long-lived but can be rotated +- Token hashing prevents database compromise from revealing usable credentials +- Role-based authorization with hierarchy: owner > admin > operator > read_only + +## Consequences +- JWT secret must be configured via environment variable +- Tokens should have reasonable expiry (24h default for users) +- Device credentials are generated during enrollment and stored securely +- All auth decisions are logged to the audit trail diff --git a/docs/adrs/005-device-status.md b/docs/adrs/005-device-status.md new file mode 100644 index 0000000..32e870f --- /dev/null +++ b/docs/adrs/005-device-status.md @@ -0,0 +1,25 @@ +# ADR-005: Device Status Calculation + +## Status +Accepted + +## Context +Devices report heartbeats at regular intervals. We need to determine online/offline status. + +## Decision +- Agent sends heartbeats every 30 seconds (configurable) +- Server marks device as `online` on each received heartbeat +- A background worker checks for stale devices every 60 seconds +- If last heartbeat is older than 2 minutes, device is marked `offline` +- If metrics show concerning values (disk > 90%), device is marked `warning` + +## Rationale +- Simple threshold-based status is reliable and easy to understand +- Status transitions are logged for audit purposes +- The threshold is configurable per-device or globally +- Future: more sophisticated health scoring + +## Consequences +- Status is computed server-side, not trusted from the agent +- The heartbeat interval affects status responsiveness +- Status changes generate audit events and can trigger alerts diff --git a/docs/openapi.json b/docs/openapi.json new file mode 100644 index 0000000..1e04c34 --- /dev/null +++ b/docs/openapi.json @@ -0,0 +1,444 @@ +{ + "openapi": "3.0.3", + "info": { + "title": "Fleet Forge API", + "description": "Unified Endpoint & Infrastructure Management Platform API", + "version": "0.1.0", + "contact": { + "name": "Fleet Forge", + "url": "https://github.com/fleetforge/fleet-forge" + } + }, + "servers": [ + { "url": "http://localhost:3000", "description": "Local development" } + ], + "components": { + "securitySchemes": { + "bearerAuth": { + "type": "http", + "scheme": "bearer", + "bearerFormat": "JWT" + }, + "deviceAuth": { + "type": "http", + "scheme": "bearer", + "description": "Device credential for agent authentication" + } + }, + "schemas": { + "ApiResponse": { + "type": "object", + "properties": { + "success": { "type": "boolean" }, + "data": {}, + "error": { + "type": "object", + "properties": { + "code": { "type": "string" }, + "message": { "type": "string" } + } + }, + "meta": { + "type": "object", + "properties": { + "page": { "type": "integer" }, + "limit": { "type": "integer" }, + "total": { "type": "integer" }, + "totalPages": { "type": "integer" } + } + } + } + }, + "LoginRequest": { + "type": "object", + "required": ["email", "password"], + "properties": { + "email": { "type": "string", "format": "email" }, + "password": { "type": "string" } + } + }, + "EnrollRequest": { + "type": "object", + "required": ["token", "hostname", "platform", "os_name", "os_version", "architecture", "agent_version"], + "properties": { + "token": { "type": "string" }, + "hostname": { "type": "string" }, + "platform": { "type": "string", "enum": ["linux", "darwin", "windows"] }, + "os_name": { "type": "string" }, + "os_version": { "type": "string" }, + "architecture": { "type": "string" }, + "kernel_version": { "type": "string" }, + "cpu_model": { "type": "string" }, + "cpu_cores": { "type": "integer" }, + "total_memory_bytes": { "type": "integer" }, + "total_disk_bytes": { "type": "integer" }, + "ip_address": { "type": "string" }, + "agent_version": { "type": "string" } + } + }, + "HeartbeatRequest": { + "type": "object", + "properties": { + "hostname": { "type": "string" }, + "os_version": { "type": "string" }, + "kernel_version": { "type": "string" }, + "agent_version": { "type": "string" }, + "ip_address": { "type": "string" }, + "metrics": { + "type": "object", + "properties": { + "cpu_usage_percent": { "type": "number" }, + "memory_usage_percent": { "type": "number" }, + "disk_usage_percent": { "type": "number" }, + "uptime_seconds": { "type": "integer" } + } + } + } + }, + "CreateJobRequest": { + "type": "object", + "required": ["jobType", "deviceIds"], + "properties": { + "jobType": { + "type": "string", + "enum": ["update_inventory", "update_system_info", "check_service_status", "start_service", "stop_service", "restart_service", "install_package", "update_package", "reboot_system"] + }, + "deviceIds": { + "type": "array", + "items": { "type": "string", "format": "uuid" } + }, + "parameters": { "type": "object" }, + "expiresInMinutes": { "type": "integer", "minimum": 1, "maximum": 1440 } + } + } + } + }, + "security": [{ "bearerAuth": [] }], + "paths": { + "/api/health": { + "get": { + "tags": ["System"], + "summary": "Health check", + "security": [], + "responses": { + "200": { "description": "API is healthy" } + } + } + }, + "/api/v1/auth/login": { + "post": { + "tags": ["Auth"], + "summary": "Login", + "security": [], + "requestBody": { + "required": true, + "content": { "application/json": { "schema": { "$ref": "#/components/schemas/LoginRequest" } } } + }, + "responses": { + "200": { "description": "Login successful, returns JWT token" }, + "401": { "description": "Invalid credentials" }, + "429": { "description": "Rate limited" } + } + } + }, + "/api/v1/auth/me": { + "post": { + "tags": ["Auth"], + "summary": "Get current user", + "responses": { + "200": { "description": "Current user info" }, + "401": { "description": "Not authenticated" } + } + } + }, + "/api/v1/devices": { + "get": { + "tags": ["Devices"], + "summary": "List devices", + "parameters": [ + { "name": "page", "in": "query", "schema": { "type": "integer", "default": 1 } }, + { "name": "limit", "in": "query", "schema": { "type": "integer", "default": 20 } }, + { "name": "search", "in": "query", "schema": { "type": "string" } }, + { "name": "status", "in": "query", "schema": { "type": "string", "enum": ["online", "offline", "warning"] } }, + { "name": "deviceType", "in": "query", "schema": { "type": "string" } }, + { "name": "sort", "in": "query", "schema": { "type": "string" } }, + { "name": "order", "in": "query", "schema": { "type": "string", "enum": ["asc", "desc"] } } + ], + "responses": { + "200": { "description": "Paginated list of devices" } + } + } + }, + "/api/v1/devices/{deviceId}": { + "get": { + "tags": ["Devices"], + "summary": "Get device details", + "parameters": [ + { "name": "deviceId", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" } } + ], + "responses": { + "200": { "description": "Device details" }, + "404": { "description": "Device not found" } + } + }, + "patch": { + "tags": ["Devices"], + "summary": "Update device", + "parameters": [ + { "name": "deviceId", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" } } + ], + "responses": { + "200": { "description": "Device updated" } + } + }, + "delete": { + "tags": ["Devices"], + "summary": "Delete device", + "parameters": [ + { "name": "deviceId", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" } } + ], + "responses": { + "204": { "description": "Device deleted" } + } + } + }, + "/api/v1/devices/{deviceId}/metrics": { + "get": { + "tags": ["Devices"], + "summary": "Get device metrics", + "parameters": [ + { "name": "deviceId", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" } } + ], + "responses": { + "200": { "description": "Device metrics history" } + } + } + }, + "/api/v1/devices/{deviceId}/inventory": { + "get": { + "tags": ["Devices"], + "summary": "Get device inventory", + "parameters": [ + { "name": "deviceId", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" } } + ], + "responses": { + "200": { "description": "Device inventory snapshots" } + } + } + }, + "/api/v1/enrollment-tokens": { + "get": { + "tags": ["Enrollment"], + "summary": "List enrollment tokens", + "responses": { "200": { "description": "List of tokens" } } + }, + "post": { + "tags": ["Enrollment"], + "summary": "Create enrollment token", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "label": { "type": "string" }, + "expiresInHours": { "type": "integer", "default": 24 }, + "maxUses": { "type": "integer", "nullable": true } + } + } + } + } + }, + "responses": { + "201": { "description": "Token created. The raw token is only shown once." } + } + } + }, + "/api/v1/enrollment-tokens/{tokenId}": { + "delete": { + "tags": ["Enrollment"], + "summary": "Revoke enrollment token", + "parameters": [ + { "name": "tokenId", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" } } + ], + "responses": { + "200": { "description": "Token revoked" } + } + } + }, + "/api/v1/groups": { + "get": { + "tags": ["Groups"], + "summary": "List device groups", + "responses": { "200": { "description": "List of groups" } } + }, + "post": { + "tags": ["Groups"], + "summary": "Create device group", + "responses": { "201": { "description": "Group created" } } + } + }, + "/api/v1/jobs": { + "get": { + "tags": ["Jobs"], + "summary": "List jobs", + "responses": { "200": { "description": "Paginated list of jobs" } } + }, + "post": { + "tags": ["Jobs"], + "summary": "Create job", + "requestBody": { + "required": true, + "content": { "application/json": { "schema": { "$ref": "#/components/schemas/CreateJobRequest" } } } + }, + "responses": { + "201": { "description": "Job created and queued" } + } + } + }, + "/api/v1/jobs/{jobId}/cancel": { + "patch": { + "tags": ["Jobs"], + "summary": "Cancel queued job", + "parameters": [ + { "name": "jobId", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" } } + ], + "responses": { + "200": { "description": "Job cancelled" }, + "404": { "description": "Job not found or not cancellable" } + } + } + }, + "/api/v1/policies": { + "get": { + "tags": ["Policies"], + "summary": "List policies", + "responses": { "200": { "description": "List of policies" } } + }, + "post": { + "tags": ["Policies"], + "summary": "Create policy", + "responses": { "201": { "description": "Policy created" } } + } + }, + "/api/v1/alerts": { + "get": { + "tags": ["Alerts"], + "summary": "List alerts", + "parameters": [ + { "name": "resolved", "in": "query", "schema": { "type": "string", "enum": ["true", "false"] } } + ], + "responses": { "200": { "description": "List of alerts" } } + } + }, + "/api/v1/alerts/{alertId}/resolve": { + "patch": { + "tags": ["Alerts"], + "summary": "Resolve alert", + "parameters": [ + { "name": "alertId", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" } } + ], + "responses": { "200": { "description": "Alert resolved" } } + } + }, + "/api/v1/audit-events": { + "get": { + "tags": ["Audit"], + "summary": "List audit events", + "parameters": [ + { "name": "page", "in": "query", "schema": { "type": "integer" } }, + { "name": "limit", "in": "query", "schema": { "type": "integer" } }, + { "name": "action", "in": "query", "schema": { "type": "string" } } + ], + "responses": { "200": { "description": "Paginated audit events" } } + } + }, + "/api/v1/dashboard/stats": { + "get": { + "tags": ["Dashboard"], + "summary": "Get dashboard statistics", + "responses": { "200": { "description": "Dashboard stats" } } + } + }, + "/api/v1/users": { + "get": { + "tags": ["Users"], + "summary": "List users", + "responses": { "200": { "description": "List of users" } } + }, + "post": { + "tags": ["Users"], + "summary": "Create user", + "responses": { "201": { "description": "User created" } } + } + }, + "/api/v1/agent/enroll": { + "post": { + "tags": ["Agent"], + "summary": "Enroll device", + "security": [], + "requestBody": { + "required": true, + "content": { "application/json": { "schema": { "$ref": "#/components/schemas/EnrollRequest" } } } + }, + "responses": { + "201": { "description": "Device enrolled, returns device_id and credential" }, + "401": { "description": "Invalid enrollment token" } + } + } + }, + "/api/v1/agent/heartbeat": { + "post": { + "tags": ["Agent"], + "summary": "Send heartbeat", + "security": [{ "deviceAuth": [] }], + "requestBody": { + "required": true, + "content": { "application/json": { "schema": { "$ref": "#/components/schemas/HeartbeatRequest" } } } + }, + "responses": { + "200": { "description": "Heartbeat acknowledged" }, + "401": { "description": "Invalid device credential" } + } + } + }, + "/api/v1/agent/jobs": { + "get": { + "tags": ["Agent"], + "summary": "Fetch pending jobs", + "security": [{ "deviceAuth": [] }], + "responses": { + "200": { "description": "List of pending jobs for this device" } + } + } + }, + "/api/v1/agent/jobs/{jobId}/result": { + "post": { + "tags": ["Agent"], + "summary": "Submit job result", + "security": [{ "deviceAuth": [] }], + "parameters": [ + { "name": "jobId", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" } } + ], + "responses": { + "200": { "description": "Result accepted" } + } + } + } + }, + "tags": [ + { "name": "System", "description": "Health and system endpoints" }, + { "name": "Auth", "description": "Authentication" }, + { "name": "Devices", "description": "Device management" }, + { "name": "Enrollment", "description": "Enrollment token management" }, + { "name": "Groups", "description": "Device groups" }, + { "name": "Jobs", "description": "Job management" }, + { "name": "Policies", "description": "Compliance policies" }, + { "name": "Alerts", "description": "System alerts" }, + { "name": "Audit", "description": "Audit trail" }, + { "name": "Dashboard", "description": "Dashboard statistics" }, + { "name": "Users", "description": "User management" }, + { "name": "Agent", "description": "Agent endpoints (enroll, heartbeat, jobs)" } + ] +} diff --git a/infrastructure/agent-releases/.gitkeep b/infrastructure/agent-releases/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/infrastructure/caddy/Caddyfile b/infrastructure/caddy/Caddyfile new file mode 100644 index 0000000..0b0a468 --- /dev/null +++ b/infrastructure/caddy/Caddyfile @@ -0,0 +1,24 @@ +{ + auto_https off + admin off +} + +http://10.0.0.194 { + redir https://10.0.0.194{uri} permanent +} + +http://localhost { + redir https://10.0.0.194{uri} permanent +} + +https://10.0.0.194 { + tls /certs/fleetforge.pem /certs/fleetforge-key.pem + + handle /api/* { + reverse_proxy 127.0.0.1:3002 + } + + handle { + reverse_proxy 127.0.0.1:3005 + } +} \ No newline at end of file diff --git a/infrastructure/caddy/nginx-reverse-proxy.conf b/infrastructure/caddy/nginx-reverse-proxy.conf new file mode 100644 index 0000000..72951fb --- /dev/null +++ b/infrastructure/caddy/nginx-reverse-proxy.conf @@ -0,0 +1,40 @@ +server { + listen 80; + server_name _; + return 301 https://$host$request_uri; +} + +server { + listen 443 ssl; + http2 on; + server_name _; + + ssl_certificate /certs/fleetforge.pem; + ssl_certificate_key /certs/fleetforge-key.pem; + + client_max_body_size 512m; + + location /api/ { + proxy_pass http://127.0.0.1:3002; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_read_timeout 86400; + proxy_send_timeout 86400; + } + + location / { + proxy_pass http://127.0.0.1:3005; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } +} \ No newline at end of file diff --git a/infrastructure/docker/Dockerfile.api b/infrastructure/docker/Dockerfile.api new file mode 100644 index 0000000..e69d720 --- /dev/null +++ b/infrastructure/docker/Dockerfile.api @@ -0,0 +1,29 @@ +FROM node:20-alpine AS base +RUN corepack enable && corepack prepare pnpm@9.15.0 --activate +WORKDIR /app + +FROM base AS deps +COPY package.json pnpm-workspace.yaml pnpm-lock.yaml* ./ +COPY apps/api/package.json ./apps/api/ +COPY packages/shared/package.json ./packages/shared/ +RUN pnpm install --frozen-lockfile || pnpm install + +FROM base AS builder +COPY --from=deps /app/node_modules ./node_modules +COPY --from=deps /app/apps/api/node_modules ./apps/api/node_modules +COPY --from=deps /app/packages/shared/node_modules ./packages/shared/node_modules +COPY . . +RUN pnpm --filter @fleetforge/shared build +RUN pnpm --filter @fleetforge/api build + +FROM node:20-alpine AS runner +WORKDIR /app +RUN addgroup --system --gid 1001 nodejs && adduser --system --uid 1001 fleetforge +COPY --from=builder /app/apps/api/dist ./dist +COPY --from=builder /app/apps/api/package.json ./ +COPY --from=builder /app/packages/shared/dist ../packages/shared/dist +COPY --from=builder /app/packages/shared/package.json ../packages/shared/ +COPY --from=builder /app/node_modules ./node_modules +USER fleetforge +EXPOSE 3000 +CMD ["node", "dist/index.js"] diff --git a/infrastructure/docker/Dockerfile.web b/infrastructure/docker/Dockerfile.web new file mode 100644 index 0000000..68dc6a2 --- /dev/null +++ b/infrastructure/docker/Dockerfile.web @@ -0,0 +1,30 @@ +FROM node:20-alpine AS base +RUN corepack enable && corepack prepare pnpm@9.15.0 --activate +WORKDIR /app + +FROM base AS deps +COPY package.json pnpm-workspace.yaml pnpm-lock.yaml* ./ +COPY apps/web/package.json ./apps/web/ +COPY packages/shared/package.json ./packages/shared/ +COPY packages/ui/package.json ./packages/ui/ +RUN pnpm install --frozen-lockfile || pnpm install + +FROM base AS builder +COPY --from=deps /app/node_modules ./node_modules +COPY --from=deps /app/apps/web/node_modules ./apps/web/node_modules +COPY --from=deps /app/packages/shared/node_modules ./packages/shared/node_modules +COPY --from=deps /app/packages/ui/node_modules ./packages/ui/node_modules +COPY . . +RUN pnpm --filter @fleetforge/shared build +RUN pnpm --filter @fleetforge/web build + +FROM node:20-alpine AS runner +WORKDIR /app +ENV NODE_ENV=production +RUN addgroup --system --gid 1001 nodejs && adduser --system --uid 1001 fleetforge +COPY --from=builder /app/apps/web/.next/standalone ./ +COPY --from=builder /app/apps/web/.next/static ./.next/static +COPY --from=builder /app/apps/web/public ./public +USER fleetforge +EXPOSE 3001 +CMD ["node", "server.js"] diff --git a/infrastructure/docker/Dockerfile.worker b/infrastructure/docker/Dockerfile.worker new file mode 100644 index 0000000..4c5e8ff --- /dev/null +++ b/infrastructure/docker/Dockerfile.worker @@ -0,0 +1,26 @@ +FROM node:20-alpine AS base +RUN corepack enable && corepack prepare pnpm@9.15.0 --activate +WORKDIR /app + +FROM base AS deps +COPY package.json pnpm-workspace.yaml pnpm-lock.yaml* ./ +COPY apps/worker/package.json ./apps/worker/ +COPY packages/shared/package.json ./packages/shared/ +RUN pnpm install --frozen-lockfile || pnpm install + +FROM base AS builder +COPY --from=deps /app/node_modules ./node_modules +COPY --from=deps /app/apps/worker/node_modules ./apps/worker/node_modules +COPY --from=deps /app/packages/shared/node_modules ./packages/shared/node_modules +COPY . . +RUN pnpm --filter @fleetforge/shared build +RUN pnpm --filter @fleetforge/worker build + +FROM node:20-alpine AS runner +WORKDIR /app +RUN addgroup --system --gid 1001 nodejs && adduser --system --uid 1001 fleetforge +COPY --from=builder /app/apps/worker/dist ./dist +COPY --from=builder /app/apps/worker/package.json ./ +COPY --from=builder /app/node_modules ./node_modules +USER fleetforge +CMD ["node", "dist/index.js"] diff --git a/infrastructure/docker/docker-compose.dev.yml b/infrastructure/docker/docker-compose.dev.yml new file mode 100644 index 0000000..e9af1cb --- /dev/null +++ b/infrastructure/docker/docker-compose.dev.yml @@ -0,0 +1,82 @@ +services: + postgres: + image: postgres:16-alpine + environment: + POSTGRES_USER: fleetforge + POSTGRES_PASSWORD: fleetforge + POSTGRES_DB: fleetforge + ports: + - "5432:5432" + volumes: + - postgres_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U fleetforge"] + interval: 5s + timeout: 5s + retries: 5 + + redis: + image: redis:7-alpine + ports: + - "6379:6379" + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 5s + timeout: 5s + retries: 5 + + api: + build: + context: ../../ + dockerfile: infrastructure/docker/Dockerfile.api + ports: + - "3000:3000" + environment: + DATABASE_URL: postgresql://fleetforge:fleetforge@postgres:5432/fleetforge?schema=public + REDIS_URL: redis://redis:6379 + JWT_SECRET: dev-jwt-secret-do-not-use-in-production + SESSION_SECRET: dev-session-secret-do-not-use-in-production + NODE_ENV: development + LOG_LEVEL: debug + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + volumes: + - ../../apps/api:/app + - /app/node_modules + - ../../infrastructure/migrations:/app/infrastructure/migrations + - ../../docs:/app/docs + + worker: + build: + context: ../../ + dockerfile: infrastructure/docker/Dockerfile.worker + environment: + DATABASE_URL: postgresql://fleetforge:fleetforge@postgres:5432/fleetforge?schema=public + NODE_ENV: development + depends_on: + postgres: + condition: service_healthy + volumes: + - ../../apps/worker:/app + - /app/node_modules + + web: + build: + context: ../../ + dockerfile: infrastructure/docker/Dockerfile.web + ports: + - "3001:3001" + environment: + NEXT_PUBLIC_API_URL: http://localhost:3000/api + depends_on: + - api + volumes: + - ../../apps/web:/app + - /app/node_modules + - /app/.next + +volumes: + postgres_data: diff --git a/infrastructure/installer/Dockerfile b/infrastructure/installer/Dockerfile new file mode 100644 index 0000000..29e99bd --- /dev/null +++ b/infrastructure/installer/Dockerfile @@ -0,0 +1,4 @@ +FROM ubuntu:24.04 +RUN apt-get update && \ + apt-get install -y --no-install-recommends msitools wixl wixl-data && \ + rm -rf /var/lib/apt/lists/* diff --git a/infrastructure/installer/agent.wxs b/infrastructure/installer/agent.wxs new file mode 100644 index 0000000..1a5f799 --- /dev/null +++ b/infrastructure/installer/agent.wxs @@ -0,0 +1,67 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/infrastructure/installer/build-msi.sh b/infrastructure/installer/build-msi.sh new file mode 100755 index 0000000..9ca9386 --- /dev/null +++ b/infrastructure/installer/build-msi.sh @@ -0,0 +1,44 @@ +#!/bin/bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +DOCKER_IMAGE="fleetforge-wixl" + +AGENT_BIN="${1:?Usage: $0 }" +CONFIG_JSON="${2:?Usage: $0 }" +VERSION="${3:?Usage: $0 }" +OUTPUT="${4:?Usage: $0 }" + +if [ ! -f "$AGENT_BIN" ]; then + echo "Error: Agent binary not found: $AGENT_BIN" >&2 + exit 1 +fi +if [ ! -f "$CONFIG_JSON" ]; then + echo "Error: Config file not found: $CONFIG_JSON" >&2 + exit 1 +fi + +if ! docker image inspect "$DOCKER_IMAGE" >/dev/null 2>&1; then + echo "Building wixl Docker image..." + docker build -t "$DOCKER_IMAGE" "$SCRIPT_DIR" +fi + +TMPDIR=$(mktemp -d) +trap 'rm -rf "$TMPDIR"' EXIT + +cp "$AGENT_BIN" "$TMPDIR/forge-agent.exe" +cp "$CONFIG_JSON" "$TMPDIR/config.json" + +sed "s/__VERSION__/$VERSION/g" "$SCRIPT_DIR/agent.wxs" > "$TMPDIR/agent.wxs" + +echo "Compiling MSI with wixl..." +docker run --rm \ + -v "$TMPDIR:/work" \ + "$DOCKER_IMAGE" \ + wixl -o /work/output.msi \ + -D "AgentBinaryPath=/work/forge-agent.exe" \ + -D "ConfigFilePath=/work/config.json" \ + /work/agent.wxs + +cp "$TMPDIR/output.msi" "$OUTPUT" +echo "MSI generated: $OUTPUT ($(du -h "$OUTPUT" | cut -f1))" diff --git a/infrastructure/migrations/0000_initial.sql b/infrastructure/migrations/0000_initial.sql new file mode 100644 index 0000000..b14ae07 --- /dev/null +++ b/infrastructure/migrations/0000_initial.sql @@ -0,0 +1,234 @@ +CREATE TABLE IF NOT EXISTS "organizations" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "name" varchar(255) NOT NULL, + "slug" varchar(255) NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "organizations_slug_unique" UNIQUE ("slug") +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "users" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "organization_id" uuid NOT NULL, + "email" varchar(255) NOT NULL, + "password_hash" varchar(255) NOT NULL, + "display_name" varchar(255) NOT NULL, + "role" varchar(50) DEFAULT 'read_only' NOT NULL, + "is_active" boolean DEFAULT true NOT NULL, + "last_login_at" timestamp with time zone, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "users_email_org_unique" UNIQUE ("email","organization_id") +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "enrollment_tokens" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "organization_id" uuid NOT NULL, + "token_hash" varchar(255) NOT NULL, + "expires_at" timestamp with time zone NOT NULL, + "max_uses" integer, + "use_count" integer DEFAULT 0 NOT NULL, + "is_revoked" boolean DEFAULT false NOT NULL, + "label" varchar(255), + "created_by_user_id" uuid NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "enrollment_tokens_token_hash_unique" UNIQUE ("token_hash") +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "device_groups" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "organization_id" uuid NOT NULL, + "name" varchar(255) NOT NULL, + "description" text, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "device_groups_name_org_unique" UNIQUE ("name","organization_id") +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "devices" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "organization_id" uuid NOT NULL, + "device_group_id" uuid, + "name" varchar(255) NOT NULL, + "device_type" varchar(50) NOT NULL, + "platform" varchar(50) NOT NULL, + "os_name" varchar(100) NOT NULL, + "os_version" varchar(100) NOT NULL, + "architecture" varchar(50) NOT NULL, + "kernel_version" varchar(100), + "cpu_model" varchar(255), + "cpu_cores" integer, + "total_memory_bytes" bigint, + "total_disk_bytes" bigint, + "ip_address" varchar(45), + "agent_version" varchar(50) NOT NULL, + "status" varchar(20) DEFAULT 'offline' NOT NULL, + "last_heartbeat_at" timestamp with time zone, + "enrolled_at" timestamp with time zone DEFAULT now() NOT NULL, + "is_disabled" boolean DEFAULT false NOT NULL, + "is_archived" boolean DEFAULT false NOT NULL, + "tags" jsonb DEFAULT '[]' NOT NULL, + "metadata" jsonb DEFAULT '{}' NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "device_credentials" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "device_id" uuid NOT NULL, + "credential_type" varchar(50) NOT NULL, + "credential_hash" varchar(255) NOT NULL, + "expires_at" timestamp with time zone, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "inventory_snapshots" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "device_id" uuid NOT NULL, + "reported_at" timestamp with time zone NOT NULL, + "packages" jsonb DEFAULT '[]' NOT NULL, + "services" jsonb DEFAULT '[]' NOT NULL, + "network_interfaces" jsonb DEFAULT '[]' NOT NULL, + "platform_data" jsonb DEFAULT '{}' NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "metric_snapshots" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "device_id" uuid NOT NULL, + "reported_at" timestamp with time zone NOT NULL, + "cpu_usage_percent" integer, + "memory_usage_percent" integer, + "memory_used_bytes" bigint, + "memory_total_bytes" bigint, + "disk_usage_percent" integer, + "disk_used_bytes" bigint, + "disk_total_bytes" bigint, + "load_average_1m" integer, + "load_average_5m" integer, + "load_average_15m" integer, + "uptime_seconds" bigint, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "policies" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "organization_id" uuid NOT NULL, + "name" varchar(255) NOT NULL, + "description" text, + "rules" jsonb DEFAULT '[]' NOT NULL, + "is_enabled" boolean DEFAULT true NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "compliance_results" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "device_id" uuid NOT NULL, + "policy_id" uuid NOT NULL, + "status" varchar(50) NOT NULL, + "details" jsonb DEFAULT '{}' NOT NULL, + "evaluated_at" timestamp with time zone NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "jobs" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "organization_id" uuid NOT NULL, + "created_by_user_id" uuid NOT NULL, + "job_type" varchar(100) NOT NULL, + "status" varchar(50) DEFAULT 'queued' NOT NULL, + "parameters" jsonb DEFAULT '{}' NOT NULL, + "dispatched_at" timestamp with time zone, + "started_at" timestamp with time zone, + "completed_at" timestamp with time zone, + "expires_at" timestamp with time zone, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "job_targets" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "job_id" uuid NOT NULL, + "device_id" uuid NOT NULL, + "status" varchar(50) DEFAULT 'queued' NOT NULL, + "result" jsonb, + "started_at" timestamp with time zone, + "completed_at" timestamp with time zone, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "alerts" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "organization_id" uuid NOT NULL, + "device_id" uuid, + "alert_type" varchar(100) NOT NULL, + "severity" varchar(20) NOT NULL, + "title" varchar(255) NOT NULL, + "message" text NOT NULL, + "is_resolved" boolean DEFAULT false NOT NULL, + "resolved_at" timestamp with time zone, + "resolved_by_user_id" uuid, + "metadata" jsonb DEFAULT '{}' NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "audit_events" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "organization_id" uuid NOT NULL, + "user_id" uuid, + "action" varchar(255) NOT NULL, + "target_type" varchar(100) NOT NULL, + "target_id" uuid, + "device_ids" jsonb DEFAULT '[]' NOT NULL, + "result" varchar(50) NOT NULL, + "ip_address" varchar(45), + "metadata" jsonb DEFAULT '{}' NOT NULL, + "correlation_id" varchar(255) NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "api_tokens" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "organization_id" uuid NOT NULL, + "user_id" uuid NOT NULL, + "name" varchar(255) NOT NULL, + "token_hash" varchar(255) NOT NULL, + "expires_at" timestamp with time zone, + "last_used_at" timestamp with time zone, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "api_tokens_token_hash_unique" UNIQUE ("token_hash") +); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "idx_users_email_org" ON "users" ("email","organization_id"); +CREATE INDEX IF NOT EXISTS "idx_users_organization_id" ON "users" ("organization_id"); +CREATE INDEX IF NOT EXISTS "idx_enrollment_tokens_org" ON "enrollment_tokens" ("organization_id"); +CREATE INDEX IF NOT EXISTS "idx_enrollment_tokens_hash" ON "enrollment_tokens" ("token_hash"); +CREATE INDEX IF NOT EXISTS "idx_devices_org" ON "devices" ("organization_id"); +CREATE INDEX IF NOT EXISTS "idx_devices_status" ON "devices" ("status"); +CREATE INDEX IF NOT EXISTS "idx_devices_group" ON "devices" ("device_group_id"); +CREATE INDEX IF NOT EXISTS "idx_devices_type" ON "devices" ("device_type"); +CREATE INDEX IF NOT EXISTS "idx_devices_last_heartbeat" ON "devices" ("last_heartbeat_at"); +CREATE INDEX IF NOT EXISTS "idx_device_credentials_device" ON "device_credentials" ("device_id"); +CREATE INDEX IF NOT EXISTS "idx_inventory_device" ON "inventory_snapshots" ("device_id"); +CREATE INDEX IF NOT EXISTS "idx_inventory_reported_at" ON "inventory_snapshots" ("reported_at"); +CREATE INDEX IF NOT EXISTS "idx_metrics_device" ON "metric_snapshots" ("device_id"); +CREATE INDEX IF NOT EXISTS "idx_metrics_reported_at" ON "metric_snapshots" ("reported_at"); +CREATE INDEX IF NOT EXISTS "idx_policies_org" ON "policies" ("organization_id"); +CREATE INDEX IF NOT EXISTS "idx_compliance_device" ON "compliance_results" ("device_id"); +CREATE INDEX IF NOT EXISTS "idx_compliance_policy" ON "compliance_results" ("policy_id"); +CREATE INDEX IF NOT EXISTS "idx_jobs_org" ON "jobs" ("organization_id"); +CREATE INDEX IF NOT EXISTS "idx_jobs_status" ON "jobs" ("status"); +CREATE INDEX IF NOT EXISTS "idx_jobs_type" ON "jobs" ("job_type"); +CREATE INDEX IF NOT EXISTS "idx_job_targets_job" ON "job_targets" ("job_id"); +CREATE INDEX IF NOT EXISTS "idx_job_targets_device" ON "job_targets" ("device_id"); +CREATE INDEX IF NOT EXISTS "idx_job_targets_status" ON "job_targets" ("status"); +CREATE INDEX IF NOT EXISTS "idx_alerts_org" ON "alerts" ("organization_id"); +CREATE INDEX IF NOT EXISTS "idx_alerts_device" ON "alerts" ("device_id"); +CREATE INDEX IF NOT EXISTS "idx_alerts_resolved" ON "alerts" ("is_resolved"); +CREATE INDEX IF NOT EXISTS "idx_audit_org" ON "audit_events" ("organization_id"); +CREATE INDEX IF NOT EXISTS "idx_audit_user" ON "audit_events" ("user_id"); +CREATE INDEX IF NOT EXISTS "idx_audit_action" ON "audit_events" ("action"); +CREATE INDEX IF NOT EXISTS "idx_audit_created_at" ON "audit_events" ("created_at"); +CREATE INDEX IF NOT EXISTS "idx_audit_correlation" ON "audit_events" ("correlation_id"); +CREATE INDEX IF NOT EXISTS "idx_api_tokens_org" ON "api_tokens" ("organization_id"); +CREATE INDEX IF NOT EXISTS "idx_api_tokens_hash" ON "api_tokens" ("token_hash"); diff --git a/infrastructure/migrations/0001_compliance_unique.sql b/infrastructure/migrations/0001_compliance_unique.sql new file mode 100644 index 0000000..22264cc --- /dev/null +++ b/infrastructure/migrations/0001_compliance_unique.sql @@ -0,0 +1,3 @@ +-- Add unique constraint on (device_id, policy_id) for compliance_results upsert +CREATE UNIQUE INDEX IF NOT EXISTS "idx_compliance_device_policy" + ON "compliance_results" ("device_id", "policy_id"); diff --git a/infrastructure/migrations/0002_deployments.sql b/infrastructure/migrations/0002_deployments.sql new file mode 100644 index 0000000..989cc28 --- /dev/null +++ b/infrastructure/migrations/0002_deployments.sql @@ -0,0 +1,68 @@ +-- Update Requests: approval workflow for software updates +CREATE TABLE IF NOT EXISTS "update_requests" ( + "id" UUID PRIMARY KEY DEFAULT gen_random_uuid(), + "organization_id" UUID NOT NULL REFERENCES "organizations"("id"), + "device_id" UUID NOT NULL REFERENCES "devices"("id") ON DELETE CASCADE, + "package_name" VARCHAR(255) NOT NULL, + "package_manager" VARCHAR(50) NOT NULL DEFAULT 'apt', + "current_version" VARCHAR(100), + "target_version" VARCHAR(100) NOT NULL, + "requested_by_user_id" UUID NOT NULL REFERENCES "users"("id"), + "status" VARCHAR(50) NOT NULL DEFAULT 'pending', + "approved_by_user_id" UUID REFERENCES "users"("id"), + "approved_at" TIMESTAMPTZ, + "rejection_reason" TEXT, + "deployment_id" UUID, + "notes" TEXT, + "created_at" TIMESTAMPTZ NOT NULL DEFAULT NOW(), + "updated_at" TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +CREATE INDEX IF NOT EXISTS "idx_update_requests_org" ON "update_requests"("organization_id"); +CREATE INDEX IF NOT EXISTS "idx_update_requests_device" ON "update_requests"("device_id"); +CREATE INDEX IF NOT EXISTS "idx_update_requests_status" ON "update_requests"("status"); + +-- Deployments: track bulk operations across devices +CREATE TABLE IF NOT EXISTS "deployments" ( + "id" UUID PRIMARY KEY DEFAULT gen_random_uuid(), + "organization_id" UUID NOT NULL REFERENCES "organizations"("id"), + "name" VARCHAR(255) NOT NULL, + "description" TEXT, + "job_type" VARCHAR(100) NOT NULL, + "parameters" JSONB NOT NULL DEFAULT '{}', + "status" VARCHAR(50) NOT NULL DEFAULT 'draft', + "created_by_user_id" UUID NOT NULL REFERENCES "users"("id"), + "approved_by_user_id" UUID REFERENCES "users"("id"), + "approved_at" TIMESTAMPTZ, + "scheduled_at" TIMESTAMPTZ, + "started_at" TIMESTAMPTZ, + "completed_at" TIMESTAMPTZ, + "total_targets" INTEGER NOT NULL DEFAULT 0, + "completed_targets" INTEGER NOT NULL DEFAULT 0, + "failed_targets" INTEGER NOT NULL DEFAULT 0, + "created_at" TIMESTAMPTZ NOT NULL DEFAULT NOW(), + "updated_at" TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +CREATE INDEX IF NOT EXISTS "idx_deployments_org" ON "deployments"("organization_id"); +CREATE INDEX IF NOT EXISTS "idx_deployments_status" ON "deployments"("status"); + +-- Deployment Targets: per-device tracking within a deployment +CREATE TABLE IF NOT EXISTS "deployment_targets" ( + "id" UUID PRIMARY KEY DEFAULT gen_random_uuid(), + "deployment_id" UUID NOT NULL REFERENCES "deployments"("id") ON DELETE CASCADE, + "device_id" UUID NOT NULL REFERENCES "devices"("id") ON DELETE CASCADE, + "job_id" UUID REFERENCES "jobs"("id"), + "status" VARCHAR(50) NOT NULL DEFAULT 'pending', + "result" JSONB, + "started_at" TIMESTAMPTZ, + "completed_at" TIMESTAMPTZ, + "created_at" TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +CREATE INDEX IF NOT EXISTS "idx_deployment_targets_deployment" ON "deployment_targets"("deployment_id"); +CREATE INDEX IF NOT EXISTS "idx_deployment_targets_device" ON "deployment_targets"("device_id"); +CREATE INDEX IF NOT EXISTS "idx_deployment_targets_status" ON "deployment_targets"("status"); + +-- Link update_requests.deployment_id foreign key +ALTER TABLE "update_requests" + ADD CONSTRAINT "fk_update_requests_deployment" + FOREIGN KEY ("deployment_id") REFERENCES "deployments"("id") + ON DELETE SET NULL; diff --git a/infrastructure/migrations/0004_metric_real.sql b/infrastructure/migrations/0004_metric_real.sql new file mode 100644 index 0000000..bdc2cd0 --- /dev/null +++ b/infrastructure/migrations/0004_metric_real.sql @@ -0,0 +1,8 @@ +-- Convert metric snapshot columns from integer to real for float values from agents + +ALTER TABLE metric_snapshots ALTER COLUMN cpu_usage_percent TYPE real; +ALTER TABLE metric_snapshots ALTER COLUMN memory_usage_percent TYPE real; +ALTER TABLE metric_snapshots ALTER COLUMN disk_usage_percent TYPE real; +ALTER TABLE metric_snapshots ALTER COLUMN load_average_1m TYPE real; +ALTER TABLE metric_snapshots ALTER COLUMN load_average_5m TYPE real; +ALTER TABLE metric_snapshots ALTER COLUMN load_average_15m TYPE real; diff --git a/infrastructure/migrations/0005_software_packages.sql b/infrastructure/migrations/0005_software_packages.sql new file mode 100644 index 0000000..2541432 --- /dev/null +++ b/infrastructure/migrations/0005_software_packages.sql @@ -0,0 +1,24 @@ +-- Software packages table for uploaded installers +CREATE TABLE software_packages ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + organization_id UUID NOT NULL REFERENCES organizations(id), + name VARCHAR(255) NOT NULL, + description TEXT, + version VARCHAR(100) NOT NULL, + package_type VARCHAR(50) NOT NULL, + platform VARCHAR(50) NOT NULL, + architecture VARCHAR(50) NOT NULL, + filename VARCHAR(500) NOT NULL, + file_size BIGINT NOT NULL, + checksum VARCHAR(255), + install_command TEXT, + tags JSONB NOT NULL DEFAULT '[]', + metadata JSONB NOT NULL DEFAULT '{}', + uploaded_by_user_id UUID NOT NULL REFERENCES users(id), + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW() +); + +CREATE INDEX idx_software_packages_org ON software_packages(organization_id); +CREATE INDEX idx_software_packages_platform ON software_packages(platform); +CREATE INDEX idx_software_packages_name ON software_packages(name); diff --git a/infrastructure/migrations/0006_remote_sessions.sql b/infrastructure/migrations/0006_remote_sessions.sql new file mode 100644 index 0000000..cf79821 --- /dev/null +++ b/infrastructure/migrations/0006_remote_sessions.sql @@ -0,0 +1,20 @@ +-- Remote desktop session table for VNC tunneled sessions +CREATE TABLE remote_sessions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + organization_id UUID NOT NULL REFERENCES organizations(id), + device_id UUID NOT NULL REFERENCES devices(id), + created_by_user_id UUID NOT NULL REFERENCES users(id), + job_id UUID REFERENCES jobs(id), + status VARCHAR(50) NOT NULL DEFAULT 'pending', + vnc_port INTEGER NOT NULL DEFAULT 5900, + vnc_password VARCHAR(64), + error_message TEXT, + started_at TIMESTAMP WITH TIME ZONE, + ended_at TIMESTAMP WITH TIME ZONE, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW() +); + +CREATE INDEX idx_remote_sessions_org ON remote_sessions(organization_id); +CREATE INDEX idx_remote_sessions_device ON remote_sessions(device_id); +CREATE INDEX idx_remote_sessions_status ON remote_sessions(status); \ No newline at end of file diff --git a/infrastructure/migrations/0007_remote_sessions_job_fk.sql b/infrastructure/migrations/0007_remote_sessions_job_fk.sql new file mode 100644 index 0000000..bd661a1 --- /dev/null +++ b/infrastructure/migrations/0007_remote_sessions_job_fk.sql @@ -0,0 +1,7 @@ +-- Allow jobs referenced by a remote session to be deleted/reaped: +-- orphaned-session references are cleared instead of blocking the job removal. +ALTER TABLE remote_sessions + DROP CONSTRAINT remote_sessions_job_id_fkey, + ADD CONSTRAINT remote_sessions_job_id_fkey + FOREIGN KEY (job_id) REFERENCES jobs(id) + ON DELETE SET NULL; \ No newline at end of file diff --git a/infrastructure/software-releases/.gitkeep b/infrastructure/software-releases/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/infrastructure/systemd/forge-agent.service b/infrastructure/systemd/forge-agent.service new file mode 100644 index 0000000..6132db6 --- /dev/null +++ b/infrastructure/systemd/forge-agent.service @@ -0,0 +1,36 @@ +[Unit] +Description=Fleet Forge Agent - Endpoint Management Agent +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +User=root +Group=root +ExecStart=/usr/local/bin/forge-agent run +Restart=on-failure +RestartSec=10 +StartLimitIntervalSec=300 +StartLimitBurst=5 + +Environment=FORGE_API_URL=http://localhost:3000 + +StandardOutput=journal +StandardError=journal +SyslogIdentifier=forge-agent + +NoNewPrivileges=false +ProtectSystem=strict +ReadWritePaths=/etc/forge-agent /var/lib/forge-agent +ProtectHome=true +PrivateTmp=true +ProtectControlGroups=true +ProtectKernelModules=true +ProtectKernelTunables=true +RestrictSUIDSGID=true +RestrictNamespaces=true +LockPersonality=true +MemoryDenyWriteExecute=false + +[Install] +WantedBy=multi-user.target diff --git a/package.json b/package.json new file mode 100644 index 0000000..694966c --- /dev/null +++ b/package.json @@ -0,0 +1,23 @@ +{ + "name": "fleet-forge", + "private": true, + "version": "0.1.0", + "description": "Fleet Forge - Unified Endpoint & Infrastructure Management Platform", + "scripts": { + "dev": "pnpm --filter @fleetforge/api dev & pnpm --filter @fleetforge/web dev", + "build": "pnpm --filter @fleetforge/shared build && pnpm --filter @fleetforge/api build && pnpm --filter @fleetforge/web build", + "test": "pnpm -r test", + "lint": "pnpm -r lint", + "typecheck": "pnpm -r typecheck", + "format": "prettier --write \"**/*.{ts,tsx,js,jsx,json,md}\"" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "prettier": "^3.4.0", + "typescript": "^5.7.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "packageManager": "pnpm@9.15.0" +} diff --git a/packages/shared/package.json b/packages/shared/package.json new file mode 100644 index 0000000..881fe69 --- /dev/null +++ b/packages/shared/package.json @@ -0,0 +1,25 @@ +{ + "name": "@fleetforge/shared", + "version": "0.1.0", + "private": true, + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "scripts": { + "build": "tsc", + "dev": "tsc --watch", + "typecheck": "tsc --noEmit", + "lint": "tsc --noEmit", + "test": "vitest run" + }, + "devDependencies": { + "typescript": "^5.7.0", + "vitest": "^3.0.0" + } +} diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts new file mode 100644 index 0000000..b14206f --- /dev/null +++ b/packages/shared/src/index.ts @@ -0,0 +1,468 @@ +export const Roles = { + OWNER: "owner", + ADMIN: "admin", + OPERATOR: "operator", + READ_ONLY: "read_only", +} as const; + +export type Role = (typeof Roles)[keyof typeof Roles]; + +export const DeviceStatus = { + ONLINE: "online", + OFFLINE: "offline", + WARNING: "warning", +} as const; + +export type DeviceStatus = (typeof DeviceStatus)[keyof typeof DeviceStatus]; + +export const DeviceType = { + LINUX_SERVER: "linux_server", + LINUX_DESKTOP: "linux_desktop", + WINDOWS_SERVER: "windows_server", + WINDOWS_CLIENT: "windows_client", + MACOS: "macos", + IOS: "ios", + IPADOS: "ipados", + ANDROID: "android", + VM: "vm", + CLOUD_INSTANCE: "cloud_instance", + CONTAINER_HOST: "container_host", + NETWORK_DEVICE: "network_device", + IOT: "iot", +} as const; + +export type DeviceType = (typeof DeviceType)[keyof typeof DeviceType]; + +export const JobStatus = { + QUEUED: "queued", + DISPATCHED: "dispatched", + RUNNING: "running", + SUCCEEDED: "succeeded", + FAILED: "failed", + CANCELLED: "cancelled", + EXPIRED: "expired", +} as const; + +export type JobStatus = (typeof JobStatus)[keyof typeof JobStatus]; + +export const JobType = { + UPDATE_INVENTORY: "update_inventory", + UPDATE_SYSTEM_INFO: "update_system_info", + CHECK_SERVICE_STATUS: "check_service_status", + START_SERVICE: "start_service", + STOP_SERVICE: "stop_service", + RESTART_SERVICE: "restart_service", + INSTALL_PACKAGE: "install_package", + UPDATE_PACKAGE: "update_package", + REMOVE_PACKAGE: "remove_package", + REBOOT_SYSTEM: "reboot_system", + SHUTDOWN_SYSTEM: "shutdown_system", + START_REMOTE_SESSION: "start_remote_session", + STOP_REMOTE_SESSION: "stop_remote_session", + INSTALL_FROM_FILE: "install_from_file", + DOWNLOAD_AND_INSTALL: "download_and_install", + INSTALL_REGISTRY_PACKAGE: "install_registry_package", +} as const; + +export type JobType = (typeof JobType)[keyof typeof JobType]; + +export const ComplianceStatus = { + COMPLIANT: "compliant", + NON_COMPLIANT: "non_compliant", + UNKNOWN: "unknown", + NOT_APPLICABLE: "not_applicable", +} as const; + +export type ComplianceStatus = + (typeof ComplianceStatus)[keyof typeof ComplianceStatus]; + +export const AlertSeverity = { + INFO: "info", + WARNING: "warning", + ERROR: "error", + CRITICAL: "critical", +} as const; + +export type AlertSeverity = (typeof AlertSeverity)[keyof typeof AlertSeverity]; + +export const AlertType = { + DEVICE_OFFLINE: "device_offline", + DISK_CRITICAL: "disk_critical", + AGENT_OUTDATED: "agent_outdated", + JOB_FAILED: "job_failed", + AUTH_FAILURES: "auth_failures", +} as const; + +export type AlertType = (typeof AlertType)[keyof typeof AlertType]; + +export const UpdateRequestStatus = { + PENDING: "pending", + APPROVED: "approved", + REJECTED: "rejected", + DEPLOYED: "deployed", + CANCELLED: "cancelled", +} as const; + +export type UpdateRequestStatus = (typeof UpdateRequestStatus)[keyof typeof UpdateRequestStatus]; + +export const DeploymentStatus = { + DRAFT: "draft", + PENDING_APPROVAL: "pending_approval", + APPROVED: "approved", + RUNNING: "running", + COMPLETED: "completed", + PARTIALLY_COMPLETED: "partially_completed", + FAILED: "failed", + CANCELLED: "cancelled", +} as const; + +export type DeploymentStatus = (typeof DeploymentStatus)[keyof typeof DeploymentStatus]; + +export interface UpdateRequest { + id: string; + organizationId: string; + deviceId: string; + deviceName?: string; + packageName: string; + packageManager: string; + currentVersion: string | null; + targetVersion: string; + requestedByUserId: string; + requestedByName?: string; + status: UpdateRequestStatus; + approvedByUserId: string | null; + approvedAt: string | null; + rejectionReason: string | null; + deploymentId: string | null; + notes: string | null; + createdAt: string; + updatedAt: string; +} + +export interface Deployment { + id: string; + organizationId: string; + name: string; + description: string | null; + jobType: string; + parameters: Record; + status: DeploymentStatus; + createdByUserId: string; + createdByName?: string; + approvedByUserId: string | null; + approvedAt: string | null; + scheduledAt: string | null; + startedAt: string | null; + completedAt: string | null; + totalTargets: number; + completedTargets: number; + failedTargets: number; + createdAt: string; + updatedAt: string; +} + +export interface DeploymentTarget { + id: string; + deploymentId: string; + deviceId: string; + deviceName?: string; + jobId: string | null; + status: string; + result: Record | null; + startedAt: string | null; + completedAt: string | null; + createdAt: string; +} + +export interface SoftwarePackage { + name: string; + version: string; + manager: string; + deviceCount: number; + devices: Array<{ deviceId: string; deviceName: string }>; +} + +export interface Organization { + id: string; + name: string; + slug: string; + createdAt: Date; + updatedAt: Date; +} + +export interface User { + id: string; + organizationId: string; + email: string; + passwordHash: string; + displayName: string; + role: Role; + isActive: boolean; + lastLoginAt: Date | null; + createdAt: Date; + updatedAt: Date; +} + +export interface Device { + id: string; + organizationId: string; + deviceGroupId: string | null; + name: string; + deviceType: DeviceType; + platform: string; + osName: string; + osVersion: string; + architecture: string; + kernelVersion: string | null; + cpuModel: string | null; + cpuCores: number | null; + totalMemoryBytes: number | null; + totalDiskBytes: number | null; + ipAddress: string | null; + agentVersion: string; + status: DeviceStatus; + lastHeartbeatAt: Date | null; + enrolledAt: Date; + isDisabled: boolean; + isArchived: boolean; + tags: string[]; + metadata: Record; + createdAt: Date; + updatedAt: Date; +} + +export interface DeviceCredential { + id: string; + deviceId: string; + credentialType: string; + credentialHash: string; + expiresAt: Date | null; + createdAt: Date; +} + +export interface EnrollmentToken { + id: string; + organizationId: string; + tokenHash: string; + expiresAt: Date; + maxUses: number | null; + useCount: number; + isRevoked: boolean; + label: string | null; + createdByUserId: string; + createdAt: Date; +} + +export interface DeviceGroup { + id: string; + organizationId: string; + name: string; + description: string | null; + createdAt: Date; + updatedAt: Date; +} + +export interface InventorySnapshot { + id: string; + deviceId: string; + reportedAt: Date; + packages: PackageInfo[]; + services: ServiceInfo[]; + networkInterfaces: NetworkInterface[]; + platformData: Record; + createdAt: Date; +} + +export interface PackageInfo { + name: string; + version: string; + manager: string; +} + +export interface ServiceInfo { + name: string; + status: string; + enabled: boolean; +} + +export interface NetworkInterface { + name: string; + addresses: string[]; + macAddress: string | null; +} + +export interface MetricSnapshot { + id: string; + deviceId: string; + reportedAt: Date; + cpuUsagePercent: number | null; + memoryUsagePercent: number | null; + memoryUsedBytes: number | null; + memoryTotalBytes: number | null; + diskUsagePercent: number | null; + diskUsedBytes: number | null; + diskTotalBytes: number | null; + loadAverage1m: number | null; + loadAverage5m: number | null; + loadAverage15m: number | null; + uptimeSeconds: number | null; + createdAt: Date; +} + +export interface Policy { + id: string; + organizationId: string; + name: string; + description: string | null; + rules: PolicyRule[]; + isEnabled: boolean; + createdAt: Date; + updatedAt: Date; +} + +export interface PolicyRule { + type: string; + condition: string; + value: string | number | boolean; +} + +export interface ComplianceResult { + id: string; + deviceId: string; + policyId: string; + status: ComplianceStatus; + details: Record; + evaluatedAt: Date; + createdAt: Date; +} + +export interface Job { + id: string; + organizationId: string; + createdByUserId: string; + jobType: JobType; + status: JobStatus; + parameters: Record; + dispatchedAt: Date | null; + startedAt: Date | null; + completedAt: Date | null; + expiresAt: Date | null; + createdAt: Date; +} + +export interface JobTarget { + id: string; + jobId: string; + deviceId: string; + status: JobStatus; + result: Record | null; + startedAt: Date | null; + completedAt: Date | null; + createdAt: Date; +} + +export interface Alert { + id: string; + organizationId: string; + deviceId: string | null; + alertType: AlertType; + severity: AlertSeverity; + title: string; + message: string; + isResolved: boolean; + resolvedAt: Date | null; + resolvedByUserId: string | null; + metadata: Record; + createdAt: Date; +} + +export interface AuditEvent { + id: string; + organizationId: string; + userId: string | null; + action: string; + targetType: string; + targetId: string | null; + deviceIds: string[]; + result: string; + ipAddress: string | null; + metadata: Record; + correlationId: string; + createdAt: Date; +} + +export interface ApiToken { + id: string; + organizationId: string; + userId: string; + name: string; + tokenHash: string; + expiresAt: Date | null; + lastUsedAt: Date | null; + createdAt: Date; +} + +export const HttpStatusCode = { + OK: 200, + CREATED: 201, + NO_CONTENT: 204, + BAD_REQUEST: 400, + UNAUTHORIZED: 401, + FORBIDDEN: 403, + NOT_FOUND: 404, + CONFLICT: 409, + UNPROCESSABLE_ENTITY: 422, + TOO_MANY_REQUESTS: 429, + INTERNAL_SERVER_ERROR: 500, +} as const; + +export interface ApiResponse { + success: boolean; + data?: T; + error?: { + code: string; + message: string; + details?: Record; + }; + meta?: { + page: number; + limit: number; + total: number; + totalPages: number; + }; +} + +export interface PaginatedRequest { + page?: number; + limit?: number; + sort?: string; + order?: "asc" | "desc"; + search?: string; +} + +export interface AuthenticatedUser { + userId: string; + organizationId: string; + role: Role; + email: string; +} + +export interface UploadedSoftwarePackage { + id: string; + organizationId: string; + name: string; + description: string | null; + version: string; + packageType: string; + platform: string; + architecture: string; + filename: string; + fileSize: number; + checksum: string | null; + installCommand: string | null; + tags: string[]; + metadata: Record; + uploadedByUserId: string; + createdAt: string; + updatedAt: string; +} diff --git a/packages/shared/tsconfig.json b/packages/shared/tsconfig.json new file mode 100644 index 0000000..792172f --- /dev/null +++ b/packages/shared/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..821a380 --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,4746 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + devDependencies: + '@types/node': + specifier: ^22.0.0 + version: 22.20.1 + prettier: + specifier: ^3.4.0 + version: 3.9.6 + typescript: + specifier: ^5.7.0 + version: 5.9.3 + + apps/api: + dependencies: + '@fleetforge/shared': + specifier: workspace:* + version: link:../../packages/shared + '@types/multer': + specifier: ^2.2.0 + version: 2.2.0 + bcrypt: + specifier: ^5.1.1 + version: 5.1.1 + cors: + specifier: ^2.8.5 + version: 2.8.6 + dotenv: + specifier: ^16.4.7 + version: 16.6.1 + drizzle-orm: + specifier: ^0.38.0 + version: 0.38.4(@types/pg@8.23.1)(@types/react@19.2.18)(pg@8.23.0)(react@19.2.8) + express: + specifier: ^4.21.0 + version: 4.22.2 + helmet: + specifier: ^8.0.0 + version: 8.3.0 + jsonwebtoken: + specifier: ^9.0.2 + version: 9.0.3 + multer: + specifier: ^2.2.0 + version: 2.2.0 + pg: + specifier: ^8.13.0 + version: 8.23.0 + rate-limiter-flexible: + specifier: ^5.0.4 + version: 5.0.5 + uuid: + specifier: ^11.0.0 + version: 11.1.1 + ws: + specifier: ^8.21.3 + version: 8.21.3 + zod: + specifier: ^3.24.0 + version: 3.25.76 + devDependencies: + '@types/bcrypt': + specifier: ^5.0.2 + version: 5.0.2 + '@types/cors': + specifier: ^2.8.17 + version: 2.8.19 + '@types/express': + specifier: ^5.0.0 + version: 5.0.6 + '@types/jsonwebtoken': + specifier: ^9.0.7 + version: 9.0.10 + '@types/pg': + specifier: ^8.11.10 + version: 8.23.1 + '@types/uuid': + specifier: ^10.0.0 + version: 10.0.0 + '@types/ws': + specifier: ^8.18.1 + version: 8.18.1 + tsx: + specifier: ^4.19.0 + version: 4.23.12 + typescript: + specifier: ^5.7.0 + version: 5.9.3 + vitest: + specifier: ^3.0.0 + version: 3.2.7(@types/node@22.20.1)(jiti@1.21.7)(tsx@4.23.12) + + apps/web: + dependencies: + '@fleetforge/shared': + specifier: workspace:* + version: link:../../packages/shared + '@novnc/novnc': + specifier: ^1.7.0 + version: 1.7.0 + '@radix-ui/react-alert-dialog': + specifier: ^1.1.23 + version: 1.1.23(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-dialog': + specifier: ^1.1.23 + version: 1.1.23(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-dropdown-menu': + specifier: ^2.1.24 + version: 2.1.24(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-label': + specifier: ^2.1.15 + version: 2.1.15(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-scroll-area': + specifier: ^1.2.18 + version: 1.2.18(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-select': + specifier: ^2.3.7 + version: 2.3.7(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-separator': + specifier: ^1.1.15 + version: 1.1.15(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-slot': + specifier: ^1.3.3 + version: 1.3.3(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-tabs': + specifier: ^1.1.21 + version: 1.1.21(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-tooltip': + specifier: ^1.2.16 + version: 1.2.16(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + class-variance-authority: + specifier: ^0.7.1 + version: 0.7.1 + clsx: + specifier: ^2.1.1 + version: 2.1.1 + lucide-react: + specifier: ^1.32.0 + version: 1.32.0(react@19.2.8) + next: + specifier: ^15.1.0 + version: 15.5.23(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + react: + specifier: ^19.0.0 + version: 19.2.8 + react-dom: + specifier: ^19.0.0 + version: 19.2.8(react@19.2.8) + tailwind-merge: + specifier: ^3.6.0 + version: 3.6.0 + tailwindcss-animate: + specifier: ^1.0.7 + version: 1.0.7(tailwindcss@3.4.19(tsx@4.23.12)) + devDependencies: + '@types/node': + specifier: ^22.0.0 + version: 22.20.1 + '@types/react': + specifier: ^19.0.0 + version: 19.2.18 + '@types/react-dom': + specifier: ^19.0.0 + version: 19.2.4(@types/react@19.2.18) + autoprefixer: + specifier: ^10.4.20 + version: 10.5.4(postcss@8.5.26) + postcss: + specifier: ^8.4.49 + version: 8.5.26 + tailwindcss: + specifier: ^3.4.17 + version: 3.4.19(tsx@4.23.12) + typescript: + specifier: ^5.7.0 + version: 5.9.3 + vitest: + specifier: ^3.0.0 + version: 3.2.7(@types/node@22.20.1)(jiti@1.21.7)(tsx@4.23.12) + + apps/worker: + dependencies: + '@fleetforge/shared': + specifier: workspace:* + version: link:../../packages/shared + dotenv: + specifier: ^16.4.7 + version: 16.6.1 + drizzle-orm: + specifier: ^0.38.0 + version: 0.38.4(@types/pg@8.23.1)(@types/react@19.2.18)(pg@8.23.0)(react@19.2.8) + pg: + specifier: ^8.13.0 + version: 8.23.0 + devDependencies: + '@types/pg': + specifier: ^8.11.10 + version: 8.23.1 + tsx: + specifier: ^4.19.0 + version: 4.23.12 + typescript: + specifier: ^5.7.0 + version: 5.9.3 + + packages/shared: + devDependencies: + typescript: + specifier: ^5.7.0 + version: 5.9.3 + vitest: + specifier: ^3.0.0 + version: 3.2.7(@types/node@22.20.1)(jiti@1.21.7)(tsx@4.23.12) + +packages: + + '@alloc/quick-lru@5.2.0': + resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} + engines: {node: '>=10'} + + '@emnapi/runtime@1.11.3': + resolution: {integrity: sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==} + + '@esbuild/aix-ppc64@0.28.2': + resolution: {integrity: sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.28.2': + resolution: {integrity: sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.28.2': + resolution: {integrity: sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.28.2': + resolution: {integrity: sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.28.2': + resolution: {integrity: sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.28.2': + resolution: {integrity: sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.28.2': + resolution: {integrity: sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.28.2': + resolution: {integrity: sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.28.2': + resolution: {integrity: sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.28.2': + resolution: {integrity: sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.28.2': + resolution: {integrity: sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.28.2': + resolution: {integrity: sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.28.2': + resolution: {integrity: sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.28.2': + resolution: {integrity: sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.28.2': + resolution: {integrity: sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.28.2': + resolution: {integrity: sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.28.2': + resolution: {integrity: sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.28.2': + resolution: {integrity: sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.28.2': + resolution: {integrity: sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.28.2': + resolution: {integrity: sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.28.2': + resolution: {integrity: sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.28.2': + resolution: {integrity: sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.28.2': + resolution: {integrity: sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.28.2': + resolution: {integrity: sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.28.2': + resolution: {integrity: sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.28.2': + resolution: {integrity: sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@floating-ui/core@1.8.0': + resolution: {integrity: sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==} + + '@floating-ui/dom@1.8.0': + resolution: {integrity: sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==} + + '@floating-ui/react-dom@2.1.9': + resolution: {integrity: sha512-JDjEFGCpImxDCA7JJKviA0M9+RtmJdj0m/NVU5IMgBK+AmZouAQQ7/+2GLH0GXXY0YMw9oXPB8hKdbPYg5QLYg==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + + '@floating-ui/utils@0.2.12': + resolution: {integrity: sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==} + + '@img/colour@1.1.0': + resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} + engines: {node: '>=18'} + + '@img/sharp-darwin-arm64@0.34.5': + resolution: {integrity: sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [darwin] + + '@img/sharp-darwin-x64@0.34.5': + resolution: {integrity: sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-darwin-arm64@1.2.4': + resolution: {integrity: sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==} + cpu: [arm64] + os: [darwin] + + '@img/sharp-libvips-darwin-x64@1.2.4': + resolution: {integrity: sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-linux-arm64@1.2.4': + resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} + cpu: [arm64] + os: [linux] + + '@img/sharp-libvips-linux-arm@1.2.4': + resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} + cpu: [arm] + os: [linux] + + '@img/sharp-libvips-linux-ppc64@1.2.4': + resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} + cpu: [ppc64] + os: [linux] + + '@img/sharp-libvips-linux-riscv64@1.2.4': + resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} + cpu: [riscv64] + os: [linux] + + '@img/sharp-libvips-linux-s390x@1.2.4': + resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} + cpu: [s390x] + os: [linux] + + '@img/sharp-libvips-linux-x64@1.2.4': + resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} + cpu: [x64] + os: [linux] + + '@img/sharp-libvips-linuxmusl-arm64@1.2.4': + resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} + cpu: [arm64] + os: [linux] + + '@img/sharp-libvips-linuxmusl-x64@1.2.4': + resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} + cpu: [x64] + os: [linux] + + '@img/sharp-linux-arm64@0.34.5': + resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [linux] + + '@img/sharp-linux-arm@0.34.5': + resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm] + os: [linux] + + '@img/sharp-linux-ppc64@0.34.5': + resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [ppc64] + os: [linux] + + '@img/sharp-linux-riscv64@0.34.5': + resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [riscv64] + os: [linux] + + '@img/sharp-linux-s390x@0.34.5': + resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [s390x] + os: [linux] + + '@img/sharp-linux-x64@0.34.5': + resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [linux] + + '@img/sharp-linuxmusl-arm64@0.34.5': + resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [linux] + + '@img/sharp-linuxmusl-x64@0.34.5': + resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [linux] + + '@img/sharp-wasm32@0.34.5': + resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [wasm32] + + '@img/sharp-win32-arm64@0.34.5': + resolution: {integrity: sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [win32] + + '@img/sharp-win32-ia32@0.34.5': + resolution: {integrity: sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [ia32] + os: [win32] + + '@img/sharp-win32-x64@0.34.5': + resolution: {integrity: sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [win32] + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@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==} + + '@mapbox/node-pre-gyp@1.0.11': + resolution: {integrity: sha512-Yhlar6v9WQgUp/He7BdgzOz8lqMQ8sU+jkCq7Wx8Myc5YFJLbEe7lgui/V7G1qB1DJykHSGwreceSaD60Y0PUQ==} + hasBin: true + + '@napi-rs/lzma-linux-x64-gnu@1.5.1': + resolution: {integrity: sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==} + engines: {node: ^22.20 || ^24.12 || >=25} + cpu: [x64] + os: [linux] + + '@next/env@15.5.23': + resolution: {integrity: sha512-Mv3Z9hVbFcPnoLevsZ6rnX1TBtyHb5E17yN7HTPDXSXxeNsGBjUFrdbjRXKKXIOhfth7/cg6Ay7PZ2UFawaWsQ==} + + '@next/swc-darwin-arm64@15.5.23': + resolution: {integrity: sha512-SrEwOROH/rhA03F59hHtdhgtfZMWGzr5duDBWgRQt2rS3mJhqMKOcnNx6txOd0/i3E3D3uFKYFvyHsEiwQxzag==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@next/swc-darwin-x64@15.5.23': + resolution: {integrity: sha512-f0FpFbG2EhDCuptBGcfrLcYMDuQAhe6m1QA4VVfXFrIBoFXvXt/olGbBkYkloKlXQtmhuzvtdYyuu/6zf07GIg==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@next/swc-linux-arm64-gnu@15.5.23': + resolution: {integrity: sha512-WlNtfepUXKX2u2ZsJZ8c3c8+tJSRZqsYzoMwLOY72A8ucKCCgxgNhiePA3qzFYahVWrwcQd8jOeJmBinc+VFVQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@next/swc-linux-arm64-musl@15.5.23': + resolution: {integrity: sha512-W/6qKk7UG93mg14PmQC+2urt69MIdwTBLNQ6MJyeC4wOCIHCjz+VfgssvS1pK7mgYBtLC1g6VKNoHD9xB0WWGg==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@next/swc-linux-x64-gnu@15.5.23': + resolution: {integrity: sha512-vzefI32mi6VMk96RaTAyxApgfGbiFzQBXVsekEjsDv1fr48mlABTWx0sUYhaYCBHWqCalxmz3DxbxFcbFvzNtw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@next/swc-linux-x64-musl@15.5.23': + resolution: {integrity: sha512-qppK/3dTGOTI+aoWWBZc3DshFIhrzgL8guATlaN9V6M1QJxbkP/rhEZ22tdICsQ/2WWXopMZ2Jokzj2u3uKY3Q==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@next/swc-win32-arm64-msvc@15.5.23': + resolution: {integrity: sha512-Wc29KFOdT7XBcII3Vtmw7aoU8Uk3Mes/FNJfhFeSHdYBFJWMcR/DsI8U9BCPUhq/uycsUVuqSKGthW15tLsigA==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + + '@next/swc-win32-x64-msvc@15.5.23': + resolution: {integrity: sha512-/C7wRW4fa9s/PKA18zGPPpVmx8ycgVpP8yOxro4gzGTzjPJdscbAP3ODeFvgiIovxD176Z2J/SXO9t8PJKHLeQ==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + + '@nodelib/fs.scandir@2.1.5': + resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} + engines: {node: '>= 8'} + + '@nodelib/fs.stat@2.0.5': + resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} + engines: {node: '>= 8'} + + '@nodelib/fs.walk@1.2.8': + resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} + engines: {node: '>= 8'} + + '@novnc/novnc@1.7.0': + resolution: {integrity: sha512-ucEJOx4T2avIRCleodk7YobZj5O2Ga2AeLfQ69A/yjG9HHba2+PDgwSkN3FttrmG+70ZGx21sElNFouK13RzyA==} + + '@radix-ui/number@1.1.3': + resolution: {integrity: sha512-Road2bidD0uu/1BGDOWNdPI06g0lIRy6IF9GZcIrDK2KGItfor8IQwQa+yM2ERgHM1MmHxaxpTzk0/Jp42lNfA==} + + '@radix-ui/primitive@1.1.7': + resolution: {integrity: sha512-rqWnm76nYT8HoNNqEjpgJ7Pw/DrBj5iBTrmEPo6HTX5+VJyBNOqTdv4g89G63HuR5g0AaENoAcH7Is5fF2kZ8Q==} + + '@radix-ui/react-alert-dialog@1.1.23': + resolution: {integrity: sha512-VAYOiQRqj3GPpYJE0I9J+X8Ip05cyVlNdKOFeiGS2Ou1HHGfpl0BxOyZm6nmVDyU+W+NF3/XLzmjHmVGydhwgA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-arrow@1.1.15': + resolution: {integrity: sha512-v4zggRcjadnI+ClKDuijlQEW4tw3NoaeHc/PwpKnLoLLKNUG4InLegkstooLcRIUWCs+8L22dGURCVuFfOKfnA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-collection@1.1.15': + resolution: {integrity: sha512-9W+B9NPF0NaaPh/1NJd3+KqsnlLqU9H7T2rvww+fp+T/evVXdNAyYcnfRQZFOjkR1ajQp3yORlqnI8soawLvNA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-compose-refs@1.1.5': + resolution: {integrity: sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-context@1.2.2': + resolution: {integrity: sha512-RHCUGwKHDr0hDGg4X7ma4JG4/+12qxw8rkh5QKdDldlCvtja6nUx1Ef/8HVrJze81lEsgLQlqjzjGNHantgnQA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-dialog@1.1.23': + resolution: {integrity: sha512-Ksw4WeROkO4rC9k/onilX/Ao2Cr1ku1unMNH+XSCcP4jSXYu7HDsg9n4ojMjVb22XpYjAQ9qfrFlVbru1vXDUA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-direction@1.1.4': + resolution: {integrity: sha512-5pzg4FGQNpExhnhT2zlrP1wZFaYCd1K0nYWoFAdcYoYK868IEigqMX3B3f8yIoRlAhAeDWciLI6ZdCKHF9P4Vg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-dismissable-layer@1.1.19': + resolution: {integrity: sha512-8g4pfOL9HoKKLWGiypT+dphVqjFfmcXO5GBnhsG6zI+lxAx/8feQpr+1LSN8Re3hiZ+XkLNS4O9ztK11/LzQ6w==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-dropdown-menu@2.1.24': + resolution: {integrity: sha512-geq8l2rJkxvkXsT9RMgtUE3P8pITFpTsvYpbySi1IH4fZEABD/Gp85myayFgxk0ktljGMJnCbeFkyTusvSvv7g==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-focus-guards@1.1.6': + resolution: {integrity: sha512-RNOJjfZMTyBM6xYmV3IVGXkPjIhcBAuv48POevAXwrGJhkWZ9p1rFoIS1JFooPuT193AZmRsCPhpoVJxx6OPoQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-focus-scope@1.1.16': + resolution: {integrity: sha512-wmRZ2WWLvmt6KHy2rNPOdPUjwq5xOHY02+m+udwJTn0aNIox/rkskAvJTyTLGhPK6KgrUjlJUJpgmx/+wFiFIQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-id@1.1.4': + resolution: {integrity: sha512-TMQp2llA+RYn7JcjnrMnz7wN4pcVttPZnRZo52PLQsoLVKzNlVwUeHmfePgTgRluXFvlD3GD5g5MOVVTJCO0qA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-label@2.1.15': + resolution: {integrity: sha512-o/rdYEwZTTo5tjknnPeyQFU45kUC4i/XyeDPP+HGyi6XqpOP6Zf5Ya5vh/Yfe9Id5JiuWnnAx2XqIeD3UYZt0g==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-menu@2.1.24': + resolution: {integrity: sha512-uW7RVuU6Lp/ZtfeY4b3kL32zccgEWvPv1+cf17ubYzHa9cL8AHokmk36cG/XEiH/smbQvumnieXX9j/e9RqJWA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-popper@1.3.7': + resolution: {integrity: sha512-UsJrrd7w4wuKKTdvd/DNERVlwSlUcyXzjhyDwBk+3aPOsCjOY6ZSbxuw8E6lZTjjfP8Cpd0J8VVkrYUWyGYXyg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-portal@1.1.17': + resolution: {integrity: sha512-vKQLcWypUnwZVvfV7UkGahH2g6ySe8M8R+zYBwPrv5byZ9QAW6cQVvNKo7GgmD+p8aYb6D9JBuvy8/WhOno2wQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-presence@1.1.10': + resolution: {integrity: sha512-3wyzCQ6+ubRA+D4uv9m95JYLXxmOHp05qjrkjeA7uKHHtjpPggQzc6DAb0URl7j67oR0K2foO4ip27TiX037Bw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-primitive@2.1.10': + resolution: {integrity: sha512-MucOnzh6hR5mid6VpkbglRAMYMjKLqRnGBbjXkzjK52fuQDd1qbkx78a5P40mkcnVXJdEVxm26E9OPAiUq7nBg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-roving-focus@1.1.19': + resolution: {integrity: sha512-V9jI6hDjT7l3jsCQD9bLNvDLM3tH/gdbOTp7Tefp3hbbgCGQoK7tUvrWiRlcoBHIZ809ElXwNQwVo0B98LuTXQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-scroll-area@1.2.18': + resolution: {integrity: sha512-Zn5Cd171wxsO3Dfg8HaW6RifTb9CYTKQJHs/G4+LN1GfmJpaQMZQyQxMprVPHpaz7QY4l9BxK2JwQuzHsXC8nA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-select@2.3.7': + resolution: {integrity: sha512-WFGImkmbzcfxeIwq/+4HvRN0pizBwbwQUED4I13ezQsDdfl38ZntN6TmR8XaSzPBqoCToe8rF75j6NPNDSzhbg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-separator@1.1.15': + resolution: {integrity: sha512-jOLO4lssEzWpoDu7G+Ze4VjwMRUBt291pnZD0gmalREZipnTX3wadQo7Fy48GCTfe14/YRN6rw/rOJqrE85Wxw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-slot@1.3.3': + resolution: {integrity: sha512-qx7oqnYbxnK9kYI9m317qmFmEgo6ywqWvbTogdj7cL9p3/yx4M48p7Rnw5z3H890cL/ow/EeWJsuTykeZVXP5Q==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-tabs@1.1.21': + resolution: {integrity: sha512-UKxJlZid7FVtsk/WTxj4i4uSEgj2Au+KBbS7SQyTlzMhhn+86Cz3tISZdTa87bfEfcuvZezf2ZsxD4xuEKtkog==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-tooltip@1.2.16': + resolution: {integrity: sha512-6EamKFRRnlpdadndbZ6LMwycfwkwPte1B42hs6QA0gYhjaOKqW4PZ4pjaW9UrlDX5eVt/OjncE7BFTPL5nmZhg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-use-callback-ref@1.1.4': + resolution: {integrity: sha512-R6OUY2e2fA6Yn6s+VSx5KBV6Nx8LQEhu+cz7LCej18rQ1HLyg9PSC9jP/ZNx0o6FAIK9c0F1kHylzSxKsdlkrQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-controllable-state@1.2.6': + resolution: {integrity: sha512-uEQJGT97ZA/TgP/Hydw47lHu+/vQj6z/0jA+WeTbK1o9Rx45GImjpD0tc3W5ad3D6XTSR6e1yEO0FvGq6WQfVQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-effect-event@0.0.5': + resolution: {integrity: sha512-7cshFL8HGS/7HEiHH+9kL9HBwp2sa9yX18Knwek6KYWmXwM7pegMgta2AXMQKI+rq3JnfSj9x8wYqFMTdG1Jgg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-is-hydrated@0.1.3': + resolution: {integrity: sha512-umO/aJ+82CpOnhDZUTbILCQf7kU/g0iv+oGs/Q8jw7IkhWBzaEP4sA268PhFAJTFetbwp3ICc6ktpI4TqtxcIw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-layout-effect@1.1.4': + resolution: {integrity: sha512-K20DkRkUwDnxEYMBPcg3Y6voLkEy5p5QQmszZgLngKKiC7dzBR/aEuK3w1qlx2JWDUNH6FluahYdgR3BP+QbYw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-previous@1.1.4': + resolution: {integrity: sha512-XoSLhbRbqxFtgJoi2fNHA3C6pDlY34x508vUpUGoFZfvePfHXHbE1lC4FYFMnJWgiCRroSTw6fOsXQoVS9RwZg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-rect@1.1.4': + resolution: {integrity: sha512-cSOCh6JlkmfjLyNcLiu2nB4v+nm+dkZ+Q5KHWk/soo4U7ZLiEQFKHK9/YmtBHjfCEaU43IBKQOc4/uJmCaiCTQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-size@1.1.4': + resolution: {integrity: sha512-D3anSY15EJoxrihpsXI6SMrmmonnQtR2ni7arO+Lfdg3O95b9hNXxONk8jA5C8ANdF/h5HMAxejgs8PWJ6rlhw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-visually-hidden@1.2.11': + resolution: {integrity: sha512-NFS86RYYZb4/exihaESBGOpMJFz8MGLAfu3mOBSGByVnVPC9JPASfYubxd/8KbkQK0sYAv8lVQDEQukDX/qXvQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/rect@1.1.3': + resolution: {integrity: sha512-JtyZR+mqgBibTo8xea3B6ZRmzZiM/YeVBtUkas6zMuXjAlfIFIW2FgqeM9eLyvEaYX66vr6DJMK+4U6LV0KhNw==} + + '@rollup/rollup-android-arm-eabi@4.62.4': + resolution: {integrity: sha512-RrPokAb7dmbxFoeO3TloqHyOjgye8RkBhSqmp4aJMIex4c9r46ZstPnleDQOq1t46VOVjwIuwNogIqbodV1Vvg==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.62.4': + resolution: {integrity: sha512-JKuJc+pnpks2pjy7L/N3v/cAkZxYlnmuZoD840ldbMI5KDbC4iO9NKwPKYdjYFCMAIIlBzYSFHxIJVYzRo2/8A==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.62.4': + resolution: {integrity: sha512-krw5uS2STmvJ02x0uTXHbqQNuz+9eZ1iw+qXk9dmW2gvV4jV7O2hEoOnuhFrpOPiel1mBFtqbxYZZtC46hXLOw==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.62.4': + resolution: {integrity: sha512-wsTxtgApb4PrOsNJIm0FZ1h3WvCC+k9uxLJ4ad75hgoS4NiRes2SoJFlDAyMwiUY8IssDqGcHbXuN0sx1tfF1A==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.62.4': + resolution: {integrity: sha512-GUOnQlyZe3yAXhWOtOMsn5Qkrv5E5mZXa0thbARWi5Ei2szlVXJFQhddZ4HbAzh8q92w5twp+CQvs/eFanz9YQ==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.62.4': + resolution: {integrity: sha512-/Y7f3QuxjzPKsjA/rfEDa3+0vXqyjmJ50Ln8dPpCmWkKTrUoWHG1cWhTqaAMLob2m2nESWuC7yGrREz019Ztqg==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.62.4': + resolution: {integrity: sha512-81wiiX3v7aqy+T+bT61TJ78yJjRquqFFTTbAPt08imfQQzkPIW8t6aJbkTagtCCrXMNc9D66+geqlK7ydLPNqA==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm-musleabihf@4.62.4': + resolution: {integrity: sha512-9kmDIvNZqdoHOBZgNtpTBeLWYO/LVipM3H/j62P8848/l/VPEQL6N3uxU9pvP1oZAsXyC2MEnFP3ovRjo7WYNQ==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm64-gnu@4.62.4': + resolution: {integrity: sha512-CcnXHWnXg69g+DX5VWL3FHts3qMRN2uVEHX+BZvGLdd07/gXkn3ePjYtO1LDJvxkGKVHMclKBRa1QUTH+6toYQ==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-arm64-musl@4.62.4': + resolution: {integrity: sha512-iFOibiHnTRuhrWLlRsOQFdZJJIa7S8OwkneJr4ocALP16u5yk6lWLINFwhHaEqBFMsKDUZofLkGos7+CPzGB3g==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-loong64-gnu@4.62.4': + resolution: {integrity: sha512-XnWYMI7euHlb5a871xPja+Gm7DRCFU+FGRrtS2sMq9N8FvqtpagUy6gD4YOemC5MRk9xbh8+jYMEJbigFQwsgA==} + cpu: [loong64] + os: [linux] + + '@rollup/rollup-linux-loong64-musl@4.62.4': + resolution: {integrity: sha512-qGDAlO0U8xedCcsdRm9oaoQY8DAx/QT7uIxJWhCdx0ceIWX783UC9QSYkdpzAe29wNiVfp24+bZdQmn49o45SQ==} + cpu: [loong64] + os: [linux] + + '@rollup/rollup-linux-ppc64-gnu@4.62.4': + resolution: {integrity: sha512-ru4H6ezD7ysA5EiEK6qkkaEb4modH8CTej6kUy/gQi20u3kB3G7Zn8snXXkeJSCOFKG/rbPPtM/+9Wgas1961w==} + cpu: [ppc64] + os: [linux] + + '@rollup/rollup-linux-ppc64-musl@4.62.4': + resolution: {integrity: sha512-2W4MO5WQVJnbJaZdvDb9rhBDuFU1nKIepPFpJUBsTh2k1YY2g+ODViaWuyOAjQ5cOP7NvrvLzt3wvHOoiAvc7w==} + cpu: [ppc64] + os: [linux] + + '@rollup/rollup-linux-riscv64-gnu@4.62.4': + resolution: {integrity: sha512-+fxjfuoAmVMCYV5QyjoIpu0cp5DOiOTeqYFk1AVaxGr+/ravWLX89XfQmptsoWcaVy/TGf2hexzbUOrCQIL1CQ==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-riscv64-musl@4.62.4': + resolution: {integrity: sha512-jTn8JfHGL4djjFxPuM06LmNUJDsst2jeVlsd9OmIH6zc5sC9K6rIuO4YajXatLUpBmBKl6b35ro1QZocLi+tcA==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-s390x-gnu@4.62.4': + resolution: {integrity: sha512-oCJCJL4pXsoDcP2QZ+JVlPTIRc6266zsIaeJJsWImmF7HO0W8nb6HuSgZlMWxJwaPf8ehbSw8yo0EUw925hKsA==} + cpu: [s390x] + os: [linux] + + '@rollup/rollup-linux-x64-gnu@4.62.4': + resolution: {integrity: sha512-W69hukhZ3KKNRCaMIEzKvcFye42hh0FE1+YoYaf5+Ikacuftoco6yO/xouz0hc5d5W/s3yBro5jRiuEE/Q5vUw==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-linux-x64-musl@4.62.4': + resolution: {integrity: sha512-qiXbGG2jkjXhzXpsFZSR2Xpb8DN/UaxYsbb/STbuR/6fpaDgRmmaq1B/LmtF2wQFOFOSsK2jdE0RZ3a0zHn4QA==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-openbsd-x64@4.62.4': + resolution: {integrity: sha512-nWeM//hxv8mIo6jD7Hu4o48DVmV9pbV6gsKaWU+4NFyqHoPKwrkRiZGLKUhOBk8qNmDmpwFtPKg80Bo/Tn4xiQ==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.62.4': + resolution: {integrity: sha512-s62SQ/vgsRSvMwDkOEfTqfgASF0f26ZNaQuTA6Aok5lrikf89yI2W0gFHvZb2Jpgc6N8JnOKZgCK2iciO3CsxQ==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.62.4': + resolution: {integrity: sha512-J6wGf8TVGbXJq+HH+ttTvrcfNKPbuZecV6KT1B8I18BC5IURUh5kl4Yl5OEP5eFIUoI5BWxCsyYMhFsDx8kekw==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.62.4': + resolution: {integrity: sha512-zmfrQd/0wu6oJs8Vq8KwY/YtsKSsLtKe/HwAP4Wqy8LhWjeT55fHRAkOhYQ12wI3ayS4Tt12d5CDRD7N96SAYQ==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.62.4': + resolution: {integrity: sha512-qPzHqdj9rfUD+w79dtE07zi/kFwKyCJqplp5K5ygeLTp7jLpAoc16OAH39HSmRC9UpozaecsleI8uAdEj6v2yw==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.62.4': + resolution: {integrity: sha512-zD6NdeWEByGE9QF9vCrlJ5YQB4oq9q91kPZS37Jwj5hOkvR1lTBSpsKhKDw4IJtbQ35LsTS1HD9DZYGKIshU1Q==} + cpu: [x64] + os: [win32] + + '@swc/helpers@0.5.15': + resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==} + + '@types/bcrypt@5.0.2': + resolution: {integrity: sha512-6atioO8Y75fNcbmj0G7UjI9lXN2pQ/IGJ2FWT4a/btd0Lk9lQalHLKhkgKVZ3r+spnmWUKfbMi1GEe9wyHQfNQ==} + + '@types/body-parser@1.19.6': + resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==} + + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/connect@3.4.38': + resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==} + + '@types/cors@2.8.19': + resolution: {integrity: sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@types/express-serve-static-core@5.1.3': + resolution: {integrity: sha512-dPfW8NFiOF4wOHc7+N/QSxlY9cfSsenewGbAz8C8U/MULPd/YZ27LvJUIlzaXie7e6Ove9YunJGgC9tbHD2cKw==} + + '@types/express@5.0.6': + resolution: {integrity: sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==} + + '@types/http-errors@2.0.5': + resolution: {integrity: sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==} + + '@types/jsonwebtoken@9.0.10': + resolution: {integrity: sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==} + + '@types/ms@2.1.0': + resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} + + '@types/multer@2.2.0': + resolution: {integrity: sha512-3U1troeqGV8Ntp7Q3klwf4zr23VEoqYVocYXaswm9+8z3O9UHDYAqLxjJ/h550iRADTjKdOdhhasXw6gD6kYtg==} + + '@types/node@22.20.1': + resolution: {integrity: sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==} + + '@types/pg@8.23.1': + resolution: {integrity: sha512-fKVHpikPdg4GKks3JuLEhvwSyvwzF23hnabPy6DD8ljVbC7+6J5dQzdv4arV6jqq57djnMgs1HKBxX4P8aBI3A==} + + '@types/qs@6.15.1': + resolution: {integrity: sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==} + + '@types/range-parser@1.2.7': + resolution: {integrity: sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==} + + '@types/react-dom@19.2.4': + resolution: {integrity: sha512-Bsc+QHgp+P/F02XDzNCY9jnZNCUuLki36KT7VKrTXXLdHf+vHMNZnW1rVu5DNW/rCK+fya3DATySbLM4yhtKUw==} + peerDependencies: + '@types/react': ^19.2.0 + + '@types/react@19.2.18': + resolution: {integrity: sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==} + + '@types/send@1.2.1': + resolution: {integrity: sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==} + + '@types/serve-static@2.2.0': + resolution: {integrity: sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==} + + '@types/uuid@10.0.0': + resolution: {integrity: sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==} + + '@types/ws@8.18.1': + resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} + + '@vitest/expect@3.2.7': + resolution: {integrity: sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w==} + + '@vitest/mocker@3.2.7': + resolution: {integrity: sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA==} + 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.7': + resolution: {integrity: sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA==} + + '@vitest/runner@3.2.7': + resolution: {integrity: sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA==} + + '@vitest/snapshot@3.2.7': + resolution: {integrity: sha512-7C+MwShwtBSI5Buwoyg3s/iY1eHL9PKAf+O1wVh/TdnjXUtkoL/9YQtre90i4MtNXM6edP1wJ2zOBpfCyhIS7g==} + + '@vitest/spy@3.2.7': + resolution: {integrity: sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ==} + + '@vitest/utils@3.2.7': + resolution: {integrity: sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw==} + + abbrev@1.1.1: + resolution: {integrity: sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==} + + accepts@1.3.8: + resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==} + engines: {node: '>= 0.6'} + + agent-base@6.0.2: + resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} + engines: {node: '>= 6.0.0'} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + any-promise@1.3.0: + resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} + + anymatch@3.1.3: + resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} + engines: {node: '>= 8'} + + append-field@1.0.0: + resolution: {integrity: sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==} + + aproba@2.1.0: + resolution: {integrity: sha512-tLIEcj5GuR2RSTnxNKdkK0dJ/GrC7P38sUkiDmDuHfsHmbagTFAxDVIBltoklXEVIQ/f14IL8IMJ5pn9Hez1Ew==} + + are-we-there-yet@2.0.0: + resolution: {integrity: sha512-Ci/qENmwHnsYo9xKIcUJN5LeDKdJ6R1Z1j9V/J5wyq8nh/mYPEpIKJbBZXtZjG04HiK7zV/p6Vs9952MrMeUIw==} + engines: {node: '>=10'} + deprecated: This package is no longer supported. + + arg@5.0.2: + resolution: {integrity: sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==} + + aria-hidden@1.2.6: + resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==} + engines: {node: '>=10'} + + array-flatten@1.1.1: + resolution: {integrity: sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + autoprefixer@10.5.4: + resolution: {integrity: sha512-MaU0U/za7N3r6brxD4YB/l4NSrFzLPlANv6wEuQVaIPlD3L4W9rFcQPbL/EilY9BHhHvhfcz3gInDLrEtWT4EA==} + engines: {node: ^10 || ^12 || >=14} + hasBin: true + peerDependencies: + postcss: ^8.1.0 + + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + + baseline-browser-mapping@2.11.15: + resolution: {integrity: sha512-FwMjJJ7HnyZpWe+oWxegG0fezZyBZUagI5LZEoO3GCbtbKNwRfMH9Ue5d5v01PNePBy1QSfPSDTTeVL0Hb9EzA==} + engines: {node: '>=6.0.0'} + hasBin: true + + bcrypt@5.1.1: + resolution: {integrity: sha512-AGBHOG5hPYZ5Xl9KXzU5iKq9516yEmvCKDg3ecP5kX2aB6UqTeXZxk2ELnDgDm6BQSMlLt9rDB4LoSMx0rYwww==} + engines: {node: '>= 10.0.0'} + + binary-extensions@2.3.0: + resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} + engines: {node: '>=8'} + + body-parser@1.20.6: + resolution: {integrity: sha512-p5tAzS57i5MV9fZFDj9LeIiTZEufbSe2eDozP+ElheSUq1m74CRq1jI4mYNDdVs9vQztXFLuk/Gd6BWTdwRJ5g==} + engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} + + brace-expansion@1.1.18: + resolution: {integrity: sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==} + + braces@3.0.3: + resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} + engines: {node: '>=8'} + + browserslist@4.28.8: + resolution: {integrity: sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + + buffer-equal-constant-time@1.0.1: + resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} + + buffer-from@1.1.2: + resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} + + busboy@1.6.0: + resolution: {integrity: sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==} + engines: {node: '>=10.16.0'} + + bytes@3.1.2: + resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} + engines: {node: '>= 0.8'} + + cac@6.7.14: + resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} + engines: {node: '>=8'} + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + + camelcase-css@2.0.1: + resolution: {integrity: sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==} + engines: {node: '>= 6'} + + caniuse-lite@1.0.30001809: + resolution: {integrity: sha512-xxWVywk6a6Arlk+hymeycyn/VgqEfLDxupvhH/xiY5SJ/18kmi9o6MiO320DCUzypORHLtvh0I4i04tUhCNHNQ==} + + 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@3.6.0: + resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} + engines: {node: '>= 8.10.0'} + + chownr@2.0.0: + resolution: {integrity: sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==} + engines: {node: '>=10'} + + class-variance-authority@0.7.1: + resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} + + client-only@0.0.1: + resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==} + + clsx@2.1.1: + resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} + engines: {node: '>=6'} + + color-support@1.1.3: + resolution: {integrity: sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==} + hasBin: true + + commander@4.1.1: + resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} + engines: {node: '>= 6'} + + concat-map@0.0.1: + resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + + concat-stream@2.0.0: + resolution: {integrity: sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==} + engines: {'0': node >= 6.0} + + console-control-strings@1.1.0: + resolution: {integrity: sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==} + + content-disposition@0.5.4: + resolution: {integrity: sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==} + engines: {node: '>= 0.6'} + + content-type@1.0.5: + resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} + engines: {node: '>= 0.6'} + + cookie-signature@1.0.7: + resolution: {integrity: sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==} + + cookie@0.7.2: + resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} + engines: {node: '>= 0.6'} + + cors@2.8.6: + resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==} + engines: {node: '>= 0.10'} + + cssesc@3.0.0: + resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} + engines: {node: '>=4'} + hasBin: true + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + debug@2.6.9: + resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + deep-eql@5.0.2: + resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} + engines: {node: '>=6'} + + delegates@1.0.0: + resolution: {integrity: sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==} + + depd@2.0.0: + resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} + engines: {node: '>= 0.8'} + + destroy@1.2.0: + resolution: {integrity: sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==} + engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + detect-node-es@1.1.0: + resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==} + + didyoumean@1.2.2: + resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==} + + dlv@1.1.3: + resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==} + + dotenv@16.6.1: + resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==} + engines: {node: '>=12'} + + drizzle-orm@0.38.4: + resolution: {integrity: sha512-s7/5BpLKO+WJRHspvpqTydxFob8i1vo2rEx4pY6TGY7QSMuUfWUuzaY0DIpXCkgHOo37BaFC+SJQb99dDUXT3Q==} + peerDependencies: + '@aws-sdk/client-rds-data': '>=3' + '@cloudflare/workers-types': '>=4' + '@electric-sql/pglite': '>=0.2.0' + '@libsql/client': '>=0.10.0' + '@libsql/client-wasm': '>=0.10.0' + '@neondatabase/serverless': '>=0.10.0' + '@op-engineering/op-sqlite': '>=2' + '@opentelemetry/api': ^1.4.1 + '@planetscale/database': '>=1' + '@prisma/client': '*' + '@tidbcloud/serverless': '*' + '@types/better-sqlite3': '*' + '@types/pg': '*' + '@types/react': '>=18' + '@types/sql.js': '*' + '@vercel/postgres': '>=0.8.0' + '@xata.io/client': '*' + better-sqlite3: '>=7' + bun-types: '*' + expo-sqlite: '>=14.0.0' + knex: '*' + kysely: '*' + mysql2: '>=2' + pg: '>=8' + postgres: '>=3' + prisma: '*' + react: '>=18' + sql.js: '>=1' + sqlite3: '>=5' + peerDependenciesMeta: + '@aws-sdk/client-rds-data': + optional: true + '@cloudflare/workers-types': + optional: true + '@electric-sql/pglite': + optional: true + '@libsql/client': + optional: true + '@libsql/client-wasm': + optional: true + '@neondatabase/serverless': + optional: true + '@op-engineering/op-sqlite': + optional: true + '@opentelemetry/api': + optional: true + '@planetscale/database': + optional: true + '@prisma/client': + optional: true + '@tidbcloud/serverless': + optional: true + '@types/better-sqlite3': + optional: true + '@types/pg': + optional: true + '@types/react': + optional: true + '@types/sql.js': + optional: true + '@vercel/postgres': + optional: true + '@xata.io/client': + optional: true + better-sqlite3: + optional: true + bun-types: + optional: true + expo-sqlite: + optional: true + knex: + optional: true + kysely: + optional: true + mysql2: + optional: true + pg: + optional: true + postgres: + optional: true + prisma: + optional: true + react: + optional: true + sql.js: + optional: true + sqlite3: + optional: true + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + ecdsa-sig-formatter@1.0.11: + resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==} + + ee-first@1.1.1: + resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + + electron-to-chromium@1.5.409: + resolution: {integrity: sha512-ChI4N44d0B4A6C8prnNjMOaGgE59fUyEVYcRYm2XEXIjMbbvF5i9UL1cblDbpGqiU0uS8FE8UcKxqZqTXdmzbQ==} + + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + + encodeurl@2.0.0: + resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} + engines: {node: '>= 0.8'} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-module-lexer@1.7.0: + resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + + es-object-atoms@1.1.2: + resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} + engines: {node: '>= 0.4'} + + esbuild@0.28.2: + resolution: {integrity: sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==} + engines: {node: '>=18'} + hasBin: true + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + escape-html@1.0.3: + resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + etag@1.8.1: + resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} + engines: {node: '>= 0.6'} + + expect-type@1.4.0: + resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} + engines: {node: '>=12.0.0'} + + express@4.22.2: + resolution: {integrity: sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==} + engines: {node: '>= 0.10.0'} + + fast-glob@3.3.3: + resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} + engines: {node: '>=8.6.0'} + + fastq@1.20.1: + resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fill-range@7.1.1: + resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} + engines: {node: '>=8'} + + finalhandler@1.3.2: + resolution: {integrity: sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==} + engines: {node: '>= 0.8'} + + forwarded@0.2.0: + resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} + engines: {node: '>= 0.6'} + + fraction.js@5.3.4: + resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==} + + fresh@0.5.2: + resolution: {integrity: sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==} + engines: {node: '>= 0.6'} + + fs-minipass@2.1.0: + resolution: {integrity: sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==} + engines: {node: '>= 8'} + + fs.realpath@1.0.0: + resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + gauge@3.0.2: + resolution: {integrity: sha512-+5J6MS/5XksCuXq++uFRsnUd7Ovu1XenbeuIuNRJxYWjgQbPuFhT14lAvsWfqfAmnwluf1OwMjz39HjfLPci0Q==} + engines: {node: '>=10'} + deprecated: This package is no longer supported. + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-nonce@1.0.1: + resolution: {integrity: sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==} + engines: {node: '>=6'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + glob-parent@5.1.2: + resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} + engines: {node: '>= 6'} + + glob-parent@6.0.2: + resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} + engines: {node: '>=10.13.0'} + + glob@7.2.3: + resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + has-unicode@2.0.1: + resolution: {integrity: sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==} + + hasown@2.0.4: + resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} + engines: {node: '>= 0.4'} + + helmet@8.3.0: + resolution: {integrity: sha512-Qgpiaws3Sm30Av8Eah6sjMCZZwjlBu+E68rhpCWBshY1lb09HtLwj5GviX0OyQIn+ulUS0iX0AxN5n3tLZzz1w==} + engines: {node: '>=18.0.0'} + + http-errors@2.0.1: + resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} + engines: {node: '>= 0.8'} + + https-proxy-agent@5.0.1: + resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==} + engines: {node: '>= 6'} + + iconv-lite@0.4.24: + resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==} + engines: {node: '>=0.10.0'} + + inflight@1.0.6: + resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} + deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful. + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + ipaddr.js@1.9.1: + resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} + engines: {node: '>= 0.10'} + + is-binary-path@2.1.0: + resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==} + engines: {node: '>=8'} + + is-core-module@2.16.2: + resolution: {integrity: sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==} + engines: {node: '>= 0.4'} + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + is-number@7.0.0: + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} + engines: {node: '>=0.12.0'} + + jiti@1.21.7: + resolution: {integrity: sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==} + hasBin: true + + js-tokens@9.0.1: + resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} + + jsonwebtoken@9.0.3: + resolution: {integrity: sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==} + engines: {node: '>=12', npm: '>=6'} + + jwa@2.0.1: + resolution: {integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==} + + jws@4.0.1: + resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==} + + lilconfig@3.1.3: + resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==} + engines: {node: '>=14'} + + lines-and-columns@1.2.4: + resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + + lodash.includes@4.3.0: + resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==} + + lodash.isboolean@3.0.3: + resolution: {integrity: sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==} + + lodash.isinteger@4.0.4: + resolution: {integrity: sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==} + + lodash.isnumber@3.0.3: + resolution: {integrity: sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==} + + lodash.isplainobject@4.0.6: + resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==} + + lodash.isstring@4.0.1: + resolution: {integrity: sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==} + + lodash.once@4.1.1: + resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==} + + loupe@3.2.1: + resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} + + lucide-react@1.32.0: + resolution: {integrity: sha512-txX56hMFnRxPi1f9/nH69YN8uvAO6a7Y1KSWKjCDAtdD9+soEgmWuCt6iRm1pkxUZo2+YntSdsE1L6bIuKoY8Q==} + peerDependencies: + react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + make-dir@3.1.0: + resolution: {integrity: sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==} + engines: {node: '>=8'} + + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + media-typer@0.3.0: + resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==} + engines: {node: '>= 0.6'} + + merge-descriptors@1.0.3: + resolution: {integrity: sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==} + + merge2@1.4.1: + resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} + engines: {node: '>= 8'} + + methods@1.1.2: + resolution: {integrity: sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==} + engines: {node: '>= 0.6'} + + micromatch@4.0.8: + resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} + engines: {node: '>=8.6'} + + mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + + mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + + mime@1.6.0: + resolution: {integrity: sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==} + engines: {node: '>=4'} + hasBin: true + + minimatch@3.1.5: + resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} + + minipass@3.3.6: + resolution: {integrity: sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==} + engines: {node: '>=8'} + + minipass@5.0.0: + resolution: {integrity: sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==} + engines: {node: '>=8'} + + minizlib@2.1.2: + resolution: {integrity: sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==} + engines: {node: '>= 8'} + + mkdirp@1.0.4: + resolution: {integrity: sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==} + engines: {node: '>=10'} + hasBin: true + + ms@2.0.0: + resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + multer@2.2.0: + resolution: {integrity: sha512-6rdyFg2kLrMh9Jee7/BMPuV9lEAd7lLW2YUpF9/YxR7njyoUwwQ0ZPh3TaIY50Sw6vlyD2HW3wGOkTS4P79xrQ==} + engines: {node: '>= 10.16.0'} + + mz@2.7.0: + resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} + + nanoid@3.3.18: + resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + negotiator@0.6.3: + resolution: {integrity: sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==} + engines: {node: '>= 0.6'} + + next@15.5.23: + resolution: {integrity: sha512-Gvd2WKgvxIXCGotxcI1im/Uf3rS3J3oZGw0g/uskg6AVBZhyE3aAbujkYWzS3xLmEPEtTLfkaVQUKK0KMTSIkA==} + engines: {node: ^18.18.0 || ^19.8.0 || >= 20.0.0} + hasBin: true + peerDependencies: + '@opentelemetry/api': ^1.1.0 + '@playwright/test': ^1.51.1 + babel-plugin-react-compiler: '*' + react: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0 + react-dom: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0 + sass: ^1.3.0 + peerDependenciesMeta: + '@opentelemetry/api': + optional: true + '@playwright/test': + optional: true + babel-plugin-react-compiler: + optional: true + sass: + optional: true + + node-addon-api@5.1.0: + resolution: {integrity: sha512-eh0GgfEkpnoWDq+VY8OyvYhFEzBk6jIYbRKdIlyTiAXIVJ8PyBaKb0rp7oDtoddbdoHWhq8wwr+XZ81F1rpNdA==} + + node-fetch@2.7.0: + resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==} + engines: {node: 4.x || >=6.0.0} + peerDependencies: + encoding: ^0.1.0 + peerDependenciesMeta: + encoding: + optional: true + + node-releases@2.0.53: + resolution: {integrity: sha512-D9UOmYG3UH1V+ENW56t5QXBwJw1YEY18ruVeus89Rw+SyIgjPkCO84bRzO3uNIYosJbNwiabWVn48o3uJLjxFQ==} + engines: {node: '>=18'} + + nopt@5.0.0: + resolution: {integrity: sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ==} + engines: {node: '>=6'} + hasBin: true + + normalize-path@3.0.0: + resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} + engines: {node: '>=0.10.0'} + + npmlog@5.0.1: + resolution: {integrity: sha512-AqZtDUWOMKs1G/8lwylVjrdYgqA4d9nu8hc+0gzRxlDb1I10+FHBGMXs6aiQHFdCUUlqH99MUMuLfzWDNDtfxw==} + deprecated: This package is no longer supported. + + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + + object-hash@3.0.0: + resolution: {integrity: sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==} + engines: {node: '>= 6'} + + object-inspect@1.13.4: + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} + engines: {node: '>= 0.4'} + + on-finished@2.4.1: + resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} + engines: {node: '>= 0.8'} + + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + + parseurl@1.3.3: + resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} + engines: {node: '>= 0.8'} + + path-is-absolute@1.0.1: + resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==} + engines: {node: '>=0.10.0'} + + path-parse@1.0.7: + resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + + path-to-regexp@0.1.13: + resolution: {integrity: sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==} + + 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'} + + pg-cloudflare@1.4.0: + resolution: {integrity: sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==} + + pg-connection-string@2.14.0: + resolution: {integrity: sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==} + + pg-int8@1.0.1: + resolution: {integrity: sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==} + engines: {node: '>=4.0.0'} + + pg-pool@3.14.0: + resolution: {integrity: sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==} + peerDependencies: + pg: '>=8.0' + + pg-protocol@1.16.0: + resolution: {integrity: sha512-sILXutLVjCLjcDuOmvhX5e2Z4cS5qG/6Bu3VkpFwdf/633ElGLpEh9bgmuI5I4sqKqkifQiGyiCcx1HdtrK7tg==} + + pg-types@2.2.0: + resolution: {integrity: sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==} + engines: {node: '>=4'} + + pg@8.23.0: + resolution: {integrity: sha512-Ip2EQCngowJLGOfCwkFhPXU7/ljlhn6Rxlmy4XYfL2Y+vyRM59+8uR2xqRWKdYmbXmxCFOAmKxBuSUCdF34qLg==} + engines: {node: '>= 16.0.0'} + peerDependencies: + pg-native: '>=3.0.1' + peerDependenciesMeta: + pg-native: + optional: true + + pgpass@1.0.5: + resolution: {integrity: sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@2.3.2: + resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==} + engines: {node: '>=8.6'} + + picomatch@4.0.5: + resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} + engines: {node: '>=12'} + + pify@2.3.0: + resolution: {integrity: sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==} + engines: {node: '>=0.10.0'} + + pirates@4.0.7: + resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} + engines: {node: '>= 6'} + + postcss-import@15.1.0: + resolution: {integrity: sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==} + engines: {node: '>=14.0.0'} + peerDependencies: + postcss: ^8.0.0 + + postcss-js@4.1.0: + resolution: {integrity: sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==} + engines: {node: ^12 || ^14 || >= 16} + peerDependencies: + postcss: ^8.4.21 + + postcss-load-config@6.0.1: + resolution: {integrity: sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==} + engines: {node: '>= 18'} + peerDependencies: + jiti: '>=1.21.0' + postcss: '>=8.0.9' + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + jiti: + optional: true + postcss: + optional: true + tsx: + optional: true + yaml: + optional: true + + postcss-nested@6.2.0: + resolution: {integrity: sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==} + engines: {node: '>=12.0'} + peerDependencies: + postcss: ^8.2.14 + + postcss-selector-parser@6.1.4: + resolution: {integrity: sha512-bIoJLOmjCO1S9XdY/DcnR5hJxvrDir1PbGChrzXG3vw0/FOliy/fA3dmdhQ441kah4gKv+TwckGzex6wNS5cnQ==} + engines: {node: '>=4'} + + postcss-value-parser@4.2.0: + resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==} + + postcss@8.4.31: + resolution: {integrity: sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==} + engines: {node: ^10 || ^12 || >=14} + + postcss@8.5.26: + resolution: {integrity: sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==} + engines: {node: ^10 || ^12 || >=14} + + postgres-array@2.0.0: + resolution: {integrity: sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==} + engines: {node: '>=4'} + + postgres-bytea@1.0.1: + resolution: {integrity: sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==} + engines: {node: '>=0.10.0'} + + postgres-date@1.0.7: + resolution: {integrity: sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==} + engines: {node: '>=0.10.0'} + + postgres-interval@1.2.0: + resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==} + engines: {node: '>=0.10.0'} + + prettier@3.9.6: + resolution: {integrity: sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==} + engines: {node: '>=14'} + hasBin: true + + proxy-addr@2.0.7: + resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} + engines: {node: '>= 0.10'} + + qs@6.15.3: + resolution: {integrity: sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==} + engines: {node: '>=0.6'} + + queue-microtask@1.2.3: + resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + + range-parser@1.2.1: + resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} + engines: {node: '>= 0.6'} + + rate-limiter-flexible@5.0.5: + resolution: {integrity: sha512-+/dSQfo+3FYwYygUs/V2BBdwGa9nFtakDwKt4l0bnvNB53TNT++QSFewwHX9qXrZJuMe9j+TUaU21lm5ARgqdQ==} + + raw-body@2.5.3: + resolution: {integrity: sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==} + engines: {node: '>= 0.8'} + + react-dom@19.2.8: + resolution: {integrity: sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==} + peerDependencies: + react: ^19.2.8 + + react-remove-scroll-bar@2.3.8: + resolution: {integrity: sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + + react-remove-scroll@2.7.2: + resolution: {integrity: sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + react-style-singleton@2.2.3: + resolution: {integrity: sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + react@19.2.8: + resolution: {integrity: sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==} + engines: {node: '>=0.10.0'} + + read-cache@1.0.0: + resolution: {integrity: sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==} + + readable-stream@3.6.2: + resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} + engines: {node: '>= 6'} + + readdirp@3.6.0: + resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} + engines: {node: '>=8.10.0'} + + resolve@1.22.12: + resolution: {integrity: sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==} + engines: {node: '>= 0.4'} + hasBin: true + + reusify@1.1.0: + resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} + engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + + rimraf@3.0.2: + resolution: {integrity: sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==} + deprecated: Rimraf versions prior to v4 are no longer supported + hasBin: true + + rollup@4.62.4: + resolution: {integrity: sha512-RXOqwaPsBGjMNMa4sQjDjHieHEZDFoj/Rdr46l2MU5DfEs16wHJPC2RPTPHWhNl+M3aI472LLqFkFKut4SblOg==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + run-parallel@1.2.0: + resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + + safe-buffer@5.2.1: + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + scheduler@0.27.0: + resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} + + semver@6.3.1: + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} + hasBin: true + + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} + engines: {node: '>=10'} + hasBin: true + + send@0.19.2: + resolution: {integrity: sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==} + engines: {node: '>= 0.8.0'} + + serve-static@1.16.3: + resolution: {integrity: sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==} + engines: {node: '>= 0.8.0'} + + set-blocking@2.0.0: + resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==} + + setprototypeof@1.2.0: + resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + + sharp@0.34.5: + resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + + side-channel-list@1.0.1: + resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==} + engines: {node: '>= 0.4'} + + side-channel-map@1.0.1: + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} + engines: {node: '>= 0.4'} + + side-channel-weakmap@1.0.2: + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} + engines: {node: '>= 0.4'} + + side-channel@1.1.1: + resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==} + engines: {node: '>= 0.4'} + + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + signal-exit@3.0.7: + resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + split2@4.2.0: + resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} + engines: {node: '>= 10.x'} + + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + statuses@2.0.2: + resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} + engines: {node: '>= 0.8'} + + std-env@3.10.0: + resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + + streamsearch@1.1.0: + resolution: {integrity: sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==} + engines: {node: '>=10.0.0'} + + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + + string_decoder@1.3.0: + resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} + + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + + strip-literal@3.1.0: + resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==} + + styled-jsx@5.1.6: + resolution: {integrity: sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==} + engines: {node: '>= 12.0.0'} + peerDependencies: + '@babel/core': '*' + babel-plugin-macros: '*' + react: '>= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0' + peerDependenciesMeta: + '@babel/core': + optional: true + babel-plugin-macros: + optional: true + + sucrase@3.35.1: + resolution: {integrity: sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==} + engines: {node: '>=16 || 14 >=14.17'} + hasBin: true + + supports-preserve-symlinks-flag@1.0.0: + resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} + engines: {node: '>= 0.4'} + + tailwind-merge@3.6.0: + resolution: {integrity: sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w==} + + tailwindcss-animate@1.0.7: + resolution: {integrity: sha512-bl6mpH3T7I3UFxuvDEXLxy/VuFxBk5bbzplh7tXI68mwMokNYd1t9qPBHlnyTwfa4JGC4zP516I1hYYtQ/vspA==} + peerDependencies: + tailwindcss: '>=3.0.0 || insiders' + + tailwindcss@3.4.19: + resolution: {integrity: sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==} + engines: {node: '>=14.0.0'} + hasBin: true + + tar@6.2.1: + resolution: {integrity: sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==} + engines: {node: '>=10'} + deprecated: Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + + thenify-all@1.6.0: + resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==} + engines: {node: '>=0.8'} + + thenify@3.3.1: + resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==} + + 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.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + 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'} + + to-regex-range@5.0.1: + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} + engines: {node: '>=8.0'} + + toidentifier@1.0.1: + resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} + engines: {node: '>=0.6'} + + tr46@0.0.3: + resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} + + ts-interface-checker@0.1.13: + resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + tsx@4.23.12: + resolution: {integrity: sha512-FDf4L4sYzKtzWYhU/Xm0AQFdTjdIxNo9ElTf2mxXM6k8YMHXzYUe4yODVaXP4V9uMFbVg8c0qyBccK2OOxb45Q==} + engines: {node: '>=18.0.0'} + hasBin: true + + type-is@1.6.18: + resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==} + engines: {node: '>= 0.6'} + + typedarray@0.0.6: + resolution: {integrity: sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==} + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + + unpipe@1.0.0: + resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} + engines: {node: '>= 0.8'} + + update-browserslist-db@1.3.1: + resolution: {integrity: sha512-ZZ61DsRsOnakl74HAmp3oSN4aXUmEWXf+i/yv0h7tIBfICc3VdrFErQKUUKPgu3AMsTUMbcongALEN4l6GSUrQ==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + + use-callback-ref@1.3.3: + resolution: {integrity: sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + use-sidecar@1.1.3: + resolution: {integrity: sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + + utils-merge@1.0.1: + resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==} + engines: {node: '>= 0.4.0'} + + uuid@11.1.1: + resolution: {integrity: sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==} + hasBin: true + + vary@1.1.2: + resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} + engines: {node: '>= 0.8'} + + 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.6: + resolution: {integrity: sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==} + 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.7: + resolution: {integrity: sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg==} + 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.7 + '@vitest/ui': 3.2.7 + 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 + + webidl-conversions@3.0.1: + resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} + + whatwg-url@5.0.0: + resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + + wide-align@1.1.5: + resolution: {integrity: sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==} + + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + + ws@8.21.3: + resolution: {integrity: sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==} + 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 + + xtend@4.0.2: + resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} + engines: {node: '>=0.4'} + + yallist@4.0.0: + resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==} + + zod@3.25.76: + resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} + +snapshots: + + '@alloc/quick-lru@5.2.0': {} + + '@emnapi/runtime@1.11.3': + dependencies: + tslib: 2.8.1 + optional: true + + '@esbuild/aix-ppc64@0.28.2': + optional: true + + '@esbuild/android-arm64@0.28.2': + optional: true + + '@esbuild/android-arm@0.28.2': + optional: true + + '@esbuild/android-x64@0.28.2': + optional: true + + '@esbuild/darwin-arm64@0.28.2': + optional: true + + '@esbuild/darwin-x64@0.28.2': + optional: true + + '@esbuild/freebsd-arm64@0.28.2': + optional: true + + '@esbuild/freebsd-x64@0.28.2': + optional: true + + '@esbuild/linux-arm64@0.28.2': + optional: true + + '@esbuild/linux-arm@0.28.2': + optional: true + + '@esbuild/linux-ia32@0.28.2': + optional: true + + '@esbuild/linux-loong64@0.28.2': + optional: true + + '@esbuild/linux-mips64el@0.28.2': + optional: true + + '@esbuild/linux-ppc64@0.28.2': + optional: true + + '@esbuild/linux-riscv64@0.28.2': + optional: true + + '@esbuild/linux-s390x@0.28.2': + optional: true + + '@esbuild/linux-x64@0.28.2': + optional: true + + '@esbuild/netbsd-arm64@0.28.2': + optional: true + + '@esbuild/netbsd-x64@0.28.2': + optional: true + + '@esbuild/openbsd-arm64@0.28.2': + optional: true + + '@esbuild/openbsd-x64@0.28.2': + optional: true + + '@esbuild/openharmony-arm64@0.28.2': + optional: true + + '@esbuild/sunos-x64@0.28.2': + optional: true + + '@esbuild/win32-arm64@0.28.2': + optional: true + + '@esbuild/win32-ia32@0.28.2': + optional: true + + '@esbuild/win32-x64@0.28.2': + optional: true + + '@floating-ui/core@1.8.0': + dependencies: + '@floating-ui/utils': 0.2.12 + + '@floating-ui/dom@1.8.0': + dependencies: + '@floating-ui/core': 1.8.0 + '@floating-ui/utils': 0.2.12 + + '@floating-ui/react-dom@2.1.9(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@floating-ui/dom': 1.8.0 + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + + '@floating-ui/utils@0.2.12': {} + + '@img/colour@1.1.0': + optional: true + + '@img/sharp-darwin-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-darwin-arm64': 1.2.4 + optional: true + + '@img/sharp-darwin-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-darwin-x64': 1.2.4 + optional: true + + '@img/sharp-libvips-darwin-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-darwin-x64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-arm@1.2.4': + optional: true + + '@img/sharp-libvips-linux-ppc64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-riscv64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-s390x@1.2.4': + optional: true + + '@img/sharp-libvips-linux-x64@1.2.4': + optional: true + + '@img/sharp-libvips-linuxmusl-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-linuxmusl-x64@1.2.4': + optional: true + + '@img/sharp-linux-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm64': 1.2.4 + optional: true + + '@img/sharp-linux-arm@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm': 1.2.4 + optional: true + + '@img/sharp-linux-ppc64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-ppc64': 1.2.4 + optional: true + + '@img/sharp-linux-riscv64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-riscv64': 1.2.4 + optional: true + + '@img/sharp-linux-s390x@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-s390x': 1.2.4 + optional: true + + '@img/sharp-linux-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-x64': 1.2.4 + optional: true + + '@img/sharp-linuxmusl-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 + optional: true + + '@img/sharp-linuxmusl-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-x64': 1.2.4 + optional: true + + '@img/sharp-wasm32@0.34.5': + dependencies: + '@emnapi/runtime': 1.11.3 + optional: true + + '@img/sharp-win32-arm64@0.34.5': + optional: true + + '@img/sharp-win32-ia32@0.34.5': + optional: true + + '@img/sharp-win32-x64@0.34.5': + optional: true + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@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 + + '@mapbox/node-pre-gyp@1.0.11': + dependencies: + detect-libc: 2.1.2 + https-proxy-agent: 5.0.1 + make-dir: 3.1.0 + node-fetch: 2.7.0 + nopt: 5.0.0 + npmlog: 5.0.1 + rimraf: 3.0.2 + semver: 7.8.5 + tar: 6.2.1 + transitivePeerDependencies: + - encoding + - supports-color + + '@napi-rs/lzma-linux-x64-gnu@1.5.1': + optional: true + + '@next/env@15.5.23': {} + + '@next/swc-darwin-arm64@15.5.23': + optional: true + + '@next/swc-darwin-x64@15.5.23': + optional: true + + '@next/swc-linux-arm64-gnu@15.5.23': + optional: true + + '@next/swc-linux-arm64-musl@15.5.23': + optional: true + + '@next/swc-linux-x64-gnu@15.5.23': + optional: true + + '@next/swc-linux-x64-musl@15.5.23': + optional: true + + '@next/swc-win32-arm64-msvc@15.5.23': + optional: true + + '@next/swc-win32-x64-msvc@15.5.23': + optional: true + + '@nodelib/fs.scandir@2.1.5': + dependencies: + '@nodelib/fs.stat': 2.0.5 + run-parallel: 1.2.0 + + '@nodelib/fs.stat@2.0.5': {} + + '@nodelib/fs.walk@1.2.8': + dependencies: + '@nodelib/fs.scandir': 2.1.5 + fastq: 1.20.1 + + '@novnc/novnc@1.7.0': {} + + '@radix-ui/number@1.1.3': {} + + '@radix-ui/primitive@1.1.7': {} + + '@radix-ui/react-alert-dialog@1.1.23(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-context': 1.2.2(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-dialog': 1.1.23(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.18 + '@types/react-dom': 19.2.4(@types/react@19.2.18) + + '@radix-ui/react-arrow@1.1.15(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.18 + '@types/react-dom': 19.2.4(@types/react@19.2.18) + + '@radix-ui/react-collection@1.1.15(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-context': 1.2.2(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-slot': 1.3.3(@types/react@19.2.18)(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.18 + '@types/react-dom': 19.2.4(@types/react@19.2.18) + + '@radix-ui/react-compose-refs@1.1.5(@types/react@19.2.18)(react@19.2.8)': + dependencies: + react: 19.2.8 + optionalDependencies: + '@types/react': 19.2.18 + + '@radix-ui/react-context@1.2.2(@types/react@19.2.18)(react@19.2.8)': + dependencies: + react: 19.2.8 + optionalDependencies: + '@types/react': 19.2.18 + + '@radix-ui/react-dialog@1.1.23(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-context': 1.2.2(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-dismissable-layer': 1.1.19(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-focus-guards': 1.1.6(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-focus-scope': 1.1.16(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-id': 1.1.4(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-portal': 1.1.17(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-presence': 1.1.10(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-slot': 1.3.3(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.18)(react@19.2.8) + aria-hidden: 1.2.6 + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + react-remove-scroll: 2.7.2(@types/react@19.2.18)(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.18 + '@types/react-dom': 19.2.4(@types/react@19.2.18) + + '@radix-ui/react-direction@1.1.4(@types/react@19.2.18)(react@19.2.8)': + dependencies: + react: 19.2.8 + optionalDependencies: + '@types/react': 19.2.18 + + '@radix-ui/react-dismissable-layer@1.1.19(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-use-callback-ref': 1.1.4(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-use-effect-event': 0.0.5(@types/react@19.2.18)(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.18 + '@types/react-dom': 19.2.4(@types/react@19.2.18) + + '@radix-ui/react-dropdown-menu@2.1.24(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-context': 1.2.2(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-id': 1.1.4(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-menu': 2.1.24(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.18)(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.18 + '@types/react-dom': 19.2.4(@types/react@19.2.18) + + '@radix-ui/react-focus-guards@1.1.6(@types/react@19.2.18)(react@19.2.8)': + dependencies: + react: 19.2.8 + optionalDependencies: + '@types/react': 19.2.18 + + '@radix-ui/react-focus-scope@1.1.16(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-use-callback-ref': 1.1.4(@types/react@19.2.18)(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.18 + '@types/react-dom': 19.2.4(@types/react@19.2.18) + + '@radix-ui/react-id@1.1.4(@types/react@19.2.18)(react@19.2.8)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.18)(react@19.2.8) + react: 19.2.8 + optionalDependencies: + '@types/react': 19.2.18 + + '@radix-ui/react-label@2.1.15(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.18 + '@types/react-dom': 19.2.4(@types/react@19.2.18) + + '@radix-ui/react-menu@2.1.24(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-collection': 1.1.15(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-context': 1.2.2(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-direction': 1.1.4(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-dismissable-layer': 1.1.19(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-focus-guards': 1.1.6(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-focus-scope': 1.1.16(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-id': 1.1.4(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-popper': 1.3.7(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-portal': 1.1.17(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-presence': 1.1.10(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-roving-focus': 1.1.19(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-slot': 1.3.3(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-use-callback-ref': 1.1.4(@types/react@19.2.18)(react@19.2.8) + aria-hidden: 1.2.6 + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + react-remove-scroll: 2.7.2(@types/react@19.2.18)(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.18 + '@types/react-dom': 19.2.4(@types/react@19.2.18) + + '@radix-ui/react-popper@1.3.7(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@floating-ui/react-dom': 2.1.9(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-arrow': 1.1.15(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-context': 1.2.2(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-use-callback-ref': 1.1.4(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-use-rect': 1.1.4(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-use-size': 1.1.4(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/rect': 1.1.3 + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.18 + '@types/react-dom': 19.2.4(@types/react@19.2.18) + + '@radix-ui/react-portal@1.1.17(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.18)(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.18 + '@types/react-dom': 19.2.4(@types/react@19.2.18) + + '@radix-ui/react-presence@1.1.10(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.18)(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.18 + '@types/react-dom': 19.2.4(@types/react@19.2.18) + + '@radix-ui/react-primitive@2.1.10(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@radix-ui/react-slot': 1.3.3(@types/react@19.2.18)(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.18 + '@types/react-dom': 19.2.4(@types/react@19.2.18) + + '@radix-ui/react-roving-focus@1.1.19(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-collection': 1.1.15(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-context': 1.2.2(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-direction': 1.1.4(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-id': 1.1.4(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-use-callback-ref': 1.1.4(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-use-is-hydrated': 0.1.3(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.18)(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.18 + '@types/react-dom': 19.2.4(@types/react@19.2.18) + + '@radix-ui/react-scroll-area@1.2.18(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@radix-ui/number': 1.1.3 + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-context': 1.2.2(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-direction': 1.1.4(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-presence': 1.1.10(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-use-callback-ref': 1.1.4(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.18)(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.18 + '@types/react-dom': 19.2.4(@types/react@19.2.18) + + '@radix-ui/react-select@2.3.7(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@radix-ui/number': 1.1.3 + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-collection': 1.1.15(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-context': 1.2.2(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-direction': 1.1.4(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-dismissable-layer': 1.1.19(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-focus-guards': 1.1.6(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-focus-scope': 1.1.16(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-id': 1.1.4(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-popper': 1.3.7(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-portal': 1.1.17(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-presence': 1.1.10(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-slot': 1.3.3(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-use-callback-ref': 1.1.4(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-use-previous': 1.1.4(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-visually-hidden': 1.2.11(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + aria-hidden: 1.2.6 + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + react-remove-scroll: 2.7.2(@types/react@19.2.18)(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.18 + '@types/react-dom': 19.2.4(@types/react@19.2.18) + + '@radix-ui/react-separator@1.1.15(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.18 + '@types/react-dom': 19.2.4(@types/react@19.2.18) + + '@radix-ui/react-slot@1.3.3(@types/react@19.2.18)(react@19.2.8)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.18)(react@19.2.8) + react: 19.2.8 + optionalDependencies: + '@types/react': 19.2.18 + + '@radix-ui/react-tabs@1.1.21(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-context': 1.2.2(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-direction': 1.1.4(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-id': 1.1.4(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-presence': 1.1.10(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-roving-focus': 1.1.19(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.18)(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.18 + '@types/react-dom': 19.2.4(@types/react@19.2.18) + + '@radix-ui/react-tooltip@1.2.16(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-context': 1.2.2(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-dismissable-layer': 1.1.19(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-id': 1.1.4(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-popper': 1.3.7(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-portal': 1.1.17(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-presence': 1.1.10(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-slot': 1.3.3(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-visually-hidden': 1.2.11(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.18 + '@types/react-dom': 19.2.4(@types/react@19.2.18) + + '@radix-ui/react-use-callback-ref@1.1.4(@types/react@19.2.18)(react@19.2.8)': + dependencies: + react: 19.2.8 + optionalDependencies: + '@types/react': 19.2.18 + + '@radix-ui/react-use-controllable-state@1.2.6(@types/react@19.2.18)(react@19.2.8)': + dependencies: + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-use-effect-event': 0.0.5(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.18)(react@19.2.8) + react: 19.2.8 + optionalDependencies: + '@types/react': 19.2.18 + + '@radix-ui/react-use-effect-event@0.0.5(@types/react@19.2.18)(react@19.2.8)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.18)(react@19.2.8) + react: 19.2.8 + optionalDependencies: + '@types/react': 19.2.18 + + '@radix-ui/react-use-is-hydrated@0.1.3(@types/react@19.2.18)(react@19.2.8)': + dependencies: + react: 19.2.8 + optionalDependencies: + '@types/react': 19.2.18 + + '@radix-ui/react-use-layout-effect@1.1.4(@types/react@19.2.18)(react@19.2.8)': + dependencies: + react: 19.2.8 + optionalDependencies: + '@types/react': 19.2.18 + + '@radix-ui/react-use-previous@1.1.4(@types/react@19.2.18)(react@19.2.8)': + dependencies: + react: 19.2.8 + optionalDependencies: + '@types/react': 19.2.18 + + '@radix-ui/react-use-rect@1.1.4(@types/react@19.2.18)(react@19.2.8)': + dependencies: + '@radix-ui/rect': 1.1.3 + react: 19.2.8 + optionalDependencies: + '@types/react': 19.2.18 + + '@radix-ui/react-use-size@1.1.4(@types/react@19.2.18)(react@19.2.8)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.18)(react@19.2.8) + react: 19.2.8 + optionalDependencies: + '@types/react': 19.2.18 + + '@radix-ui/react-visually-hidden@1.2.11(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.18 + '@types/react-dom': 19.2.4(@types/react@19.2.18) + + '@radix-ui/rect@1.1.3': {} + + '@rollup/rollup-android-arm-eabi@4.62.4': + optional: true + + '@rollup/rollup-android-arm64@4.62.4': + optional: true + + '@rollup/rollup-darwin-arm64@4.62.4': + optional: true + + '@rollup/rollup-darwin-x64@4.62.4': + optional: true + + '@rollup/rollup-freebsd-arm64@4.62.4': + optional: true + + '@rollup/rollup-freebsd-x64@4.62.4': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.62.4': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.62.4': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.62.4': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.62.4': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.62.4': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.62.4': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.62.4': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.62.4': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.62.4': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.62.4': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.62.4': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.62.4': + optional: true + + '@rollup/rollup-linux-x64-musl@4.62.4': + optional: true + + '@rollup/rollup-openbsd-x64@4.62.4': + optional: true + + '@rollup/rollup-openharmony-arm64@4.62.4': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.62.4': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.62.4': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.62.4': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.62.4': + optional: true + + '@swc/helpers@0.5.15': + dependencies: + tslib: 2.8.1 + + '@types/bcrypt@5.0.2': + dependencies: + '@types/node': 22.20.1 + + '@types/body-parser@1.19.6': + dependencies: + '@types/connect': 3.4.38 + '@types/node': 22.20.1 + + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/connect@3.4.38': + dependencies: + '@types/node': 22.20.1 + + '@types/cors@2.8.19': + dependencies: + '@types/node': 22.20.1 + + '@types/deep-eql@4.0.2': {} + + '@types/estree@1.0.9': {} + + '@types/express-serve-static-core@5.1.3': + dependencies: + '@types/node': 22.20.1 + '@types/qs': 6.15.1 + '@types/range-parser': 1.2.7 + '@types/send': 1.2.1 + + '@types/express@5.0.6': + dependencies: + '@types/body-parser': 1.19.6 + '@types/express-serve-static-core': 5.1.3 + '@types/serve-static': 2.2.0 + + '@types/http-errors@2.0.5': {} + + '@types/jsonwebtoken@9.0.10': + dependencies: + '@types/ms': 2.1.0 + '@types/node': 22.20.1 + + '@types/ms@2.1.0': {} + + '@types/multer@2.2.0': + dependencies: + '@types/express': 5.0.6 + + '@types/node@22.20.1': + dependencies: + undici-types: 6.21.0 + + '@types/pg@8.23.1': + dependencies: + '@types/node': 22.20.1 + pg-protocol: 1.16.0 + pg-types: 2.2.0 + + '@types/qs@6.15.1': {} + + '@types/range-parser@1.2.7': {} + + '@types/react-dom@19.2.4(@types/react@19.2.18)': + dependencies: + '@types/react': 19.2.18 + + '@types/react@19.2.18': + dependencies: + csstype: 3.2.3 + + '@types/send@1.2.1': + dependencies: + '@types/node': 22.20.1 + + '@types/serve-static@2.2.0': + dependencies: + '@types/http-errors': 2.0.5 + '@types/node': 22.20.1 + + '@types/uuid@10.0.0': {} + + '@types/ws@8.18.1': + dependencies: + '@types/node': 22.20.1 + + '@vitest/expect@3.2.7': + dependencies: + '@types/chai': 5.2.3 + '@vitest/spy': 3.2.7 + '@vitest/utils': 3.2.7 + chai: 5.3.3 + tinyrainbow: 2.0.0 + + '@vitest/mocker@3.2.7(vite@7.3.6(@types/node@22.20.1)(jiti@1.21.7)(tsx@4.23.12))': + dependencies: + '@vitest/spy': 3.2.7 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 7.3.6(@types/node@22.20.1)(jiti@1.21.7)(tsx@4.23.12) + + '@vitest/pretty-format@3.2.7': + dependencies: + tinyrainbow: 2.0.0 + + '@vitest/runner@3.2.7': + dependencies: + '@vitest/utils': 3.2.7 + pathe: 2.0.3 + strip-literal: 3.1.0 + + '@vitest/snapshot@3.2.7': + dependencies: + '@vitest/pretty-format': 3.2.7 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@3.2.7': + dependencies: + tinyspy: 4.0.4 + + '@vitest/utils@3.2.7': + dependencies: + '@vitest/pretty-format': 3.2.7 + loupe: 3.2.1 + tinyrainbow: 2.0.0 + + abbrev@1.1.1: {} + + accepts@1.3.8: + dependencies: + mime-types: 2.1.35 + negotiator: 0.6.3 + + agent-base@6.0.2: + dependencies: + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + ansi-regex@5.0.1: {} + + any-promise@1.3.0: {} + + anymatch@3.1.3: + dependencies: + normalize-path: 3.0.0 + picomatch: 2.3.2 + + append-field@1.0.0: {} + + aproba@2.1.0: {} + + are-we-there-yet@2.0.0: + dependencies: + delegates: 1.0.0 + readable-stream: 3.6.2 + + arg@5.0.2: {} + + aria-hidden@1.2.6: + dependencies: + tslib: 2.8.1 + + array-flatten@1.1.1: {} + + assertion-error@2.0.1: {} + + autoprefixer@10.5.4(postcss@8.5.26): + dependencies: + browserslist: 4.28.8 + caniuse-lite: 1.0.30001809 + fraction.js: 5.3.4 + picocolors: 1.1.1 + postcss: 8.5.26 + postcss-value-parser: 4.2.0 + + balanced-match@1.0.2: {} + + baseline-browser-mapping@2.11.15: {} + + bcrypt@5.1.1: + dependencies: + '@mapbox/node-pre-gyp': 1.0.11 + node-addon-api: 5.1.0 + transitivePeerDependencies: + - encoding + - supports-color + + binary-extensions@2.3.0: {} + + body-parser@1.20.6: + dependencies: + bytes: 3.1.2 + content-type: 1.0.5 + debug: 2.6.9 + depd: 2.0.0 + destroy: 1.2.0 + http-errors: 2.0.1 + iconv-lite: 0.4.24 + on-finished: 2.4.1 + qs: 6.15.3 + raw-body: 2.5.3 + type-is: 1.6.18 + unpipe: 1.0.0 + transitivePeerDependencies: + - supports-color + + brace-expansion@1.1.18: + dependencies: + balanced-match: 1.0.2 + concat-map: 0.0.1 + + braces@3.0.3: + dependencies: + fill-range: 7.1.1 + + browserslist@4.28.8: + dependencies: + baseline-browser-mapping: 2.11.15 + caniuse-lite: 1.0.30001809 + electron-to-chromium: 1.5.409 + node-releases: 2.0.53 + update-browserslist-db: 1.3.1(browserslist@4.28.8) + + buffer-equal-constant-time@1.0.1: {} + + buffer-from@1.1.2: {} + + busboy@1.6.0: + dependencies: + streamsearch: 1.1.0 + + bytes@3.1.2: {} + + cac@6.7.14: {} + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + + camelcase-css@2.0.1: {} + + caniuse-lite@1.0.30001809: {} + + 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@3.6.0: + dependencies: + anymatch: 3.1.3 + braces: 3.0.3 + glob-parent: 5.1.2 + is-binary-path: 2.1.0 + is-glob: 4.0.3 + normalize-path: 3.0.0 + readdirp: 3.6.0 + optionalDependencies: + fsevents: 2.3.3 + + chownr@2.0.0: {} + + class-variance-authority@0.7.1: + dependencies: + clsx: 2.1.1 + + client-only@0.0.1: {} + + clsx@2.1.1: {} + + color-support@1.1.3: {} + + commander@4.1.1: {} + + concat-map@0.0.1: {} + + concat-stream@2.0.0: + dependencies: + buffer-from: 1.1.2 + inherits: 2.0.4 + readable-stream: 3.6.2 + typedarray: 0.0.6 + + console-control-strings@1.1.0: {} + + content-disposition@0.5.4: + dependencies: + safe-buffer: 5.2.1 + + content-type@1.0.5: {} + + cookie-signature@1.0.7: {} + + cookie@0.7.2: {} + + cors@2.8.6: + dependencies: + object-assign: 4.1.1 + vary: 1.1.2 + + cssesc@3.0.0: {} + + csstype@3.2.3: {} + + debug@2.6.9: + dependencies: + ms: 2.0.0 + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + deep-eql@5.0.2: {} + + delegates@1.0.0: {} + + depd@2.0.0: {} + + destroy@1.2.0: {} + + detect-libc@2.1.2: {} + + detect-node-es@1.1.0: {} + + didyoumean@1.2.2: {} + + dlv@1.1.3: {} + + dotenv@16.6.1: {} + + drizzle-orm@0.38.4(@types/pg@8.23.1)(@types/react@19.2.18)(pg@8.23.0)(react@19.2.8): + optionalDependencies: + '@types/pg': 8.23.1 + '@types/react': 19.2.18 + pg: 8.23.0 + react: 19.2.8 + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + ecdsa-sig-formatter@1.0.11: + dependencies: + safe-buffer: 5.2.1 + + ee-first@1.1.1: {} + + electron-to-chromium@1.5.409: {} + + emoji-regex@8.0.0: {} + + encodeurl@2.0.0: {} + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-module-lexer@1.7.0: {} + + es-object-atoms@1.1.2: + dependencies: + es-errors: 1.3.0 + + esbuild@0.28.2: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.2 + '@esbuild/android-arm': 0.28.2 + '@esbuild/android-arm64': 0.28.2 + '@esbuild/android-x64': 0.28.2 + '@esbuild/darwin-arm64': 0.28.2 + '@esbuild/darwin-x64': 0.28.2 + '@esbuild/freebsd-arm64': 0.28.2 + '@esbuild/freebsd-x64': 0.28.2 + '@esbuild/linux-arm': 0.28.2 + '@esbuild/linux-arm64': 0.28.2 + '@esbuild/linux-ia32': 0.28.2 + '@esbuild/linux-loong64': 0.28.2 + '@esbuild/linux-mips64el': 0.28.2 + '@esbuild/linux-ppc64': 0.28.2 + '@esbuild/linux-riscv64': 0.28.2 + '@esbuild/linux-s390x': 0.28.2 + '@esbuild/linux-x64': 0.28.2 + '@esbuild/netbsd-arm64': 0.28.2 + '@esbuild/netbsd-x64': 0.28.2 + '@esbuild/openbsd-arm64': 0.28.2 + '@esbuild/openbsd-x64': 0.28.2 + '@esbuild/openharmony-arm64': 0.28.2 + '@esbuild/sunos-x64': 0.28.2 + '@esbuild/win32-arm64': 0.28.2 + '@esbuild/win32-ia32': 0.28.2 + '@esbuild/win32-x64': 0.28.2 + + escalade@3.2.0: {} + + escape-html@1.0.3: {} + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + + etag@1.8.1: {} + + expect-type@1.4.0: {} + + express@4.22.2: + dependencies: + accepts: 1.3.8 + array-flatten: 1.1.1 + body-parser: 1.20.6 + content-disposition: 0.5.4 + content-type: 1.0.5 + cookie: 0.7.2 + cookie-signature: 1.0.7 + debug: 2.6.9 + depd: 2.0.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + finalhandler: 1.3.2 + fresh: 0.5.2 + http-errors: 2.0.1 + merge-descriptors: 1.0.3 + methods: 1.1.2 + on-finished: 2.4.1 + parseurl: 1.3.3 + path-to-regexp: 0.1.13 + proxy-addr: 2.0.7 + qs: 6.15.3 + range-parser: 1.2.1 + safe-buffer: 5.2.1 + send: 0.19.2 + serve-static: 1.16.3 + setprototypeof: 1.2.0 + statuses: 2.0.2 + type-is: 1.6.18 + utils-merge: 1.0.1 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + + fast-glob@3.3.3: + dependencies: + '@nodelib/fs.stat': 2.0.5 + '@nodelib/fs.walk': 1.2.8 + glob-parent: 5.1.2 + merge2: 1.4.1 + micromatch: 4.0.8 + + fastq@1.20.1: + dependencies: + reusify: 1.1.0 + + fdir@6.5.0(picomatch@4.0.5): + optionalDependencies: + picomatch: 4.0.5 + + fill-range@7.1.1: + dependencies: + to-regex-range: 5.0.1 + + finalhandler@1.3.2: + dependencies: + debug: 2.6.9 + encodeurl: 2.0.0 + escape-html: 1.0.3 + on-finished: 2.4.1 + parseurl: 1.3.3 + statuses: 2.0.2 + unpipe: 1.0.0 + transitivePeerDependencies: + - supports-color + + forwarded@0.2.0: {} + + fraction.js@5.3.4: {} + + fresh@0.5.2: {} + + fs-minipass@2.1.0: + dependencies: + minipass: 3.3.6 + + fs.realpath@1.0.0: {} + + fsevents@2.3.3: + optional: true + + function-bind@1.1.2: {} + + gauge@3.0.2: + dependencies: + aproba: 2.1.0 + color-support: 1.1.3 + console-control-strings: 1.1.0 + has-unicode: 2.0.1 + object-assign: 4.1.1 + signal-exit: 3.0.7 + string-width: 4.2.3 + strip-ansi: 6.0.1 + wide-align: 1.1.5 + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.4 + math-intrinsics: 1.1.0 + + get-nonce@1.0.1: {} + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.2 + + glob-parent@5.1.2: + dependencies: + is-glob: 4.0.3 + + glob-parent@6.0.2: + dependencies: + is-glob: 4.0.3 + + glob@7.2.3: + dependencies: + fs.realpath: 1.0.0 + inflight: 1.0.6 + inherits: 2.0.4 + minimatch: 3.1.5 + once: 1.4.0 + path-is-absolute: 1.0.1 + + gopd@1.2.0: {} + + has-symbols@1.1.0: {} + + has-unicode@2.0.1: {} + + hasown@2.0.4: + dependencies: + function-bind: 1.1.2 + + helmet@8.3.0: {} + + http-errors@2.0.1: + dependencies: + depd: 2.0.0 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 2.0.2 + toidentifier: 1.0.1 + + https-proxy-agent@5.0.1: + dependencies: + agent-base: 6.0.2 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + iconv-lite@0.4.24: + dependencies: + safer-buffer: 2.1.2 + + inflight@1.0.6: + dependencies: + once: 1.4.0 + wrappy: 1.0.2 + + inherits@2.0.4: {} + + ipaddr.js@1.9.1: {} + + is-binary-path@2.1.0: + dependencies: + binary-extensions: 2.3.0 + + is-core-module@2.16.2: + dependencies: + hasown: 2.0.4 + + is-extglob@2.1.1: {} + + is-fullwidth-code-point@3.0.0: {} + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + is-number@7.0.0: {} + + jiti@1.21.7: {} + + js-tokens@9.0.1: {} + + jsonwebtoken@9.0.3: + dependencies: + jws: 4.0.1 + lodash.includes: 4.3.0 + lodash.isboolean: 3.0.3 + lodash.isinteger: 4.0.4 + lodash.isnumber: 3.0.3 + lodash.isplainobject: 4.0.6 + lodash.isstring: 4.0.1 + lodash.once: 4.1.1 + ms: 2.1.3 + semver: 7.8.5 + + jwa@2.0.1: + dependencies: + buffer-equal-constant-time: 1.0.1 + ecdsa-sig-formatter: 1.0.11 + safe-buffer: 5.2.1 + + jws@4.0.1: + dependencies: + jwa: 2.0.1 + safe-buffer: 5.2.1 + + lilconfig@3.1.3: {} + + lines-and-columns@1.2.4: {} + + lodash.includes@4.3.0: {} + + lodash.isboolean@3.0.3: {} + + lodash.isinteger@4.0.4: {} + + lodash.isnumber@3.0.3: {} + + lodash.isplainobject@4.0.6: {} + + lodash.isstring@4.0.1: {} + + lodash.once@4.1.1: {} + + loupe@3.2.1: {} + + lucide-react@1.32.0(react@19.2.8): + dependencies: + react: 19.2.8 + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + make-dir@3.1.0: + dependencies: + semver: 6.3.1 + + math-intrinsics@1.1.0: {} + + media-typer@0.3.0: {} + + merge-descriptors@1.0.3: {} + + merge2@1.4.1: {} + + methods@1.1.2: {} + + micromatch@4.0.8: + dependencies: + braces: 3.0.3 + picomatch: 2.3.2 + + mime-db@1.52.0: {} + + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + + mime@1.6.0: {} + + minimatch@3.1.5: + dependencies: + brace-expansion: 1.1.18 + + minipass@3.3.6: + dependencies: + yallist: 4.0.0 + + minipass@5.0.0: {} + + minizlib@2.1.2: + dependencies: + minipass: 3.3.6 + yallist: 4.0.0 + + mkdirp@1.0.4: {} + + ms@2.0.0: {} + + ms@2.1.3: {} + + multer@2.2.0: + dependencies: + append-field: 1.0.0 + busboy: 1.6.0 + concat-stream: 2.0.0 + type-is: 1.6.18 + + mz@2.7.0: + dependencies: + any-promise: 1.3.0 + object-assign: 4.1.1 + thenify-all: 1.6.0 + + nanoid@3.3.18: {} + + negotiator@0.6.3: {} + + next@15.5.23(react-dom@19.2.8(react@19.2.8))(react@19.2.8): + dependencies: + '@next/env': 15.5.23 + '@swc/helpers': 0.5.15 + caniuse-lite: 1.0.30001809 + postcss: 8.4.31 + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + styled-jsx: 5.1.6(react@19.2.8) + optionalDependencies: + '@next/swc-darwin-arm64': 15.5.23 + '@next/swc-darwin-x64': 15.5.23 + '@next/swc-linux-arm64-gnu': 15.5.23 + '@next/swc-linux-arm64-musl': 15.5.23 + '@next/swc-linux-x64-gnu': 15.5.23 + '@next/swc-linux-x64-musl': 15.5.23 + '@next/swc-win32-arm64-msvc': 15.5.23 + '@next/swc-win32-x64-msvc': 15.5.23 + sharp: 0.34.5 + transitivePeerDependencies: + - '@babel/core' + - babel-plugin-macros + + node-addon-api@5.1.0: {} + + node-fetch@2.7.0: + dependencies: + whatwg-url: 5.0.0 + + node-releases@2.0.53: {} + + nopt@5.0.0: + dependencies: + abbrev: 1.1.1 + + normalize-path@3.0.0: {} + + npmlog@5.0.1: + dependencies: + are-we-there-yet: 2.0.0 + console-control-strings: 1.1.0 + gauge: 3.0.2 + set-blocking: 2.0.0 + + object-assign@4.1.1: {} + + object-hash@3.0.0: {} + + object-inspect@1.13.4: {} + + on-finished@2.4.1: + dependencies: + ee-first: 1.1.1 + + once@1.4.0: + dependencies: + wrappy: 1.0.2 + + parseurl@1.3.3: {} + + path-is-absolute@1.0.1: {} + + path-parse@1.0.7: {} + + path-to-regexp@0.1.13: {} + + pathe@2.0.3: {} + + pathval@2.0.1: {} + + pg-cloudflare@1.4.0: + optional: true + + pg-connection-string@2.14.0: {} + + pg-int8@1.0.1: {} + + pg-pool@3.14.0(pg@8.23.0): + dependencies: + pg: 8.23.0 + + pg-protocol@1.16.0: {} + + pg-types@2.2.0: + dependencies: + pg-int8: 1.0.1 + postgres-array: 2.0.0 + postgres-bytea: 1.0.1 + postgres-date: 1.0.7 + postgres-interval: 1.2.0 + + pg@8.23.0: + dependencies: + pg-connection-string: 2.14.0 + pg-pool: 3.14.0(pg@8.23.0) + pg-protocol: 1.16.0 + pg-types: 2.2.0 + pgpass: 1.0.5 + optionalDependencies: + pg-cloudflare: 1.4.0 + + pgpass@1.0.5: + dependencies: + split2: 4.2.0 + + picocolors@1.1.1: {} + + picomatch@2.3.2: {} + + picomatch@4.0.5: {} + + pify@2.3.0: {} + + pirates@4.0.7: {} + + postcss-import@15.1.0(postcss@8.5.26): + dependencies: + postcss: 8.5.26 + postcss-value-parser: 4.2.0 + read-cache: 1.0.0 + resolve: 1.22.12 + + postcss-js@4.1.0(postcss@8.5.26): + dependencies: + camelcase-css: 2.0.1 + postcss: 8.5.26 + + postcss-load-config@6.0.1(jiti@1.21.7)(postcss@8.5.26)(tsx@4.23.12): + dependencies: + lilconfig: 3.1.3 + optionalDependencies: + jiti: 1.21.7 + postcss: 8.5.26 + tsx: 4.23.12 + + postcss-nested@6.2.0(postcss@8.5.26): + dependencies: + postcss: 8.5.26 + postcss-selector-parser: 6.1.4 + + postcss-selector-parser@6.1.4: + dependencies: + cssesc: 3.0.0 + util-deprecate: 1.0.2 + + postcss-value-parser@4.2.0: {} + + postcss@8.4.31: + dependencies: + nanoid: 3.3.18 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + postcss@8.5.26: + dependencies: + nanoid: 3.3.18 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + postgres-array@2.0.0: {} + + postgres-bytea@1.0.1: {} + + postgres-date@1.0.7: {} + + postgres-interval@1.2.0: + dependencies: + xtend: 4.0.2 + + prettier@3.9.6: {} + + proxy-addr@2.0.7: + dependencies: + forwarded: 0.2.0 + ipaddr.js: 1.9.1 + + qs@6.15.3: + dependencies: + es-define-property: 1.0.1 + side-channel: 1.1.1 + + queue-microtask@1.2.3: {} + + range-parser@1.2.1: {} + + rate-limiter-flexible@5.0.5: {} + + raw-body@2.5.3: + dependencies: + bytes: 3.1.2 + http-errors: 2.0.1 + iconv-lite: 0.4.24 + unpipe: 1.0.0 + + react-dom@19.2.8(react@19.2.8): + dependencies: + react: 19.2.8 + scheduler: 0.27.0 + + react-remove-scroll-bar@2.3.8(@types/react@19.2.18)(react@19.2.8): + dependencies: + react: 19.2.8 + react-style-singleton: 2.2.3(@types/react@19.2.18)(react@19.2.8) + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.18 + + react-remove-scroll@2.7.2(@types/react@19.2.18)(react@19.2.8): + dependencies: + react: 19.2.8 + react-remove-scroll-bar: 2.3.8(@types/react@19.2.18)(react@19.2.8) + react-style-singleton: 2.2.3(@types/react@19.2.18)(react@19.2.8) + tslib: 2.8.1 + use-callback-ref: 1.3.3(@types/react@19.2.18)(react@19.2.8) + use-sidecar: 1.1.3(@types/react@19.2.18)(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.18 + + react-style-singleton@2.2.3(@types/react@19.2.18)(react@19.2.8): + dependencies: + get-nonce: 1.0.1 + react: 19.2.8 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.18 + + react@19.2.8: {} + + read-cache@1.0.0: + dependencies: + pify: 2.3.0 + + readable-stream@3.6.2: + dependencies: + inherits: 2.0.4 + string_decoder: 1.3.0 + util-deprecate: 1.0.2 + + readdirp@3.6.0: + dependencies: + picomatch: 2.3.2 + + resolve@1.22.12: + dependencies: + es-errors: 1.3.0 + is-core-module: 2.16.2 + path-parse: 1.0.7 + supports-preserve-symlinks-flag: 1.0.0 + + reusify@1.1.0: {} + + rimraf@3.0.2: + dependencies: + glob: 7.2.3 + + rollup@4.62.4: + dependencies: + '@types/estree': 1.0.9 + optionalDependencies: + '@napi-rs/lzma-linux-x64-gnu': 1.5.1 + '@rollup/rollup-android-arm-eabi': 4.62.4 + '@rollup/rollup-android-arm64': 4.62.4 + '@rollup/rollup-darwin-arm64': 4.62.4 + '@rollup/rollup-darwin-x64': 4.62.4 + '@rollup/rollup-freebsd-arm64': 4.62.4 + '@rollup/rollup-freebsd-x64': 4.62.4 + '@rollup/rollup-linux-arm-gnueabihf': 4.62.4 + '@rollup/rollup-linux-arm-musleabihf': 4.62.4 + '@rollup/rollup-linux-arm64-gnu': 4.62.4 + '@rollup/rollup-linux-arm64-musl': 4.62.4 + '@rollup/rollup-linux-loong64-gnu': 4.62.4 + '@rollup/rollup-linux-loong64-musl': 4.62.4 + '@rollup/rollup-linux-ppc64-gnu': 4.62.4 + '@rollup/rollup-linux-ppc64-musl': 4.62.4 + '@rollup/rollup-linux-riscv64-gnu': 4.62.4 + '@rollup/rollup-linux-riscv64-musl': 4.62.4 + '@rollup/rollup-linux-s390x-gnu': 4.62.4 + '@rollup/rollup-linux-x64-gnu': 4.62.4 + '@rollup/rollup-linux-x64-musl': 4.62.4 + '@rollup/rollup-openbsd-x64': 4.62.4 + '@rollup/rollup-openharmony-arm64': 4.62.4 + '@rollup/rollup-win32-arm64-msvc': 4.62.4 + '@rollup/rollup-win32-ia32-msvc': 4.62.4 + '@rollup/rollup-win32-x64-gnu': 4.62.4 + '@rollup/rollup-win32-x64-msvc': 4.62.4 + fsevents: 2.3.3 + + run-parallel@1.2.0: + dependencies: + queue-microtask: 1.2.3 + + safe-buffer@5.2.1: {} + + safer-buffer@2.1.2: {} + + scheduler@0.27.0: {} + + semver@6.3.1: {} + + semver@7.8.5: {} + + send@0.19.2: + dependencies: + debug: 2.6.9 + depd: 2.0.0 + destroy: 1.2.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + fresh: 0.5.2 + http-errors: 2.0.1 + mime: 1.6.0 + ms: 2.1.3 + on-finished: 2.4.1 + range-parser: 1.2.1 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + + serve-static@1.16.3: + dependencies: + encodeurl: 2.0.0 + escape-html: 1.0.3 + parseurl: 1.3.3 + send: 0.19.2 + transitivePeerDependencies: + - supports-color + + set-blocking@2.0.0: {} + + setprototypeof@1.2.0: {} + + sharp@0.34.5: + dependencies: + '@img/colour': 1.1.0 + detect-libc: 2.1.2 + semver: 7.8.5 + optionalDependencies: + '@img/sharp-darwin-arm64': 0.34.5 + '@img/sharp-darwin-x64': 0.34.5 + '@img/sharp-libvips-darwin-arm64': 1.2.4 + '@img/sharp-libvips-darwin-x64': 1.2.4 + '@img/sharp-libvips-linux-arm': 1.2.4 + '@img/sharp-libvips-linux-arm64': 1.2.4 + '@img/sharp-libvips-linux-ppc64': 1.2.4 + '@img/sharp-libvips-linux-riscv64': 1.2.4 + '@img/sharp-libvips-linux-s390x': 1.2.4 + '@img/sharp-libvips-linux-x64': 1.2.4 + '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 + '@img/sharp-libvips-linuxmusl-x64': 1.2.4 + '@img/sharp-linux-arm': 0.34.5 + '@img/sharp-linux-arm64': 0.34.5 + '@img/sharp-linux-ppc64': 0.34.5 + '@img/sharp-linux-riscv64': 0.34.5 + '@img/sharp-linux-s390x': 0.34.5 + '@img/sharp-linux-x64': 0.34.5 + '@img/sharp-linuxmusl-arm64': 0.34.5 + '@img/sharp-linuxmusl-x64': 0.34.5 + '@img/sharp-wasm32': 0.34.5 + '@img/sharp-win32-arm64': 0.34.5 + '@img/sharp-win32-ia32': 0.34.5 + '@img/sharp-win32-x64': 0.34.5 + optional: true + + side-channel-list@1.0.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + + side-channel-map@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + + side-channel-weakmap@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + side-channel-map: 1.0.1 + + side-channel@1.1.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.1 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + + siginfo@2.0.0: {} + + signal-exit@3.0.7: {} + + source-map-js@1.2.1: {} + + split2@4.2.0: {} + + stackback@0.0.2: {} + + statuses@2.0.2: {} + + std-env@3.10.0: {} + + streamsearch@1.1.0: {} + + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + + string_decoder@1.3.0: + dependencies: + safe-buffer: 5.2.1 + + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + + strip-literal@3.1.0: + dependencies: + js-tokens: 9.0.1 + + styled-jsx@5.1.6(react@19.2.8): + dependencies: + client-only: 0.0.1 + react: 19.2.8 + + sucrase@3.35.1: + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + commander: 4.1.1 + lines-and-columns: 1.2.4 + mz: 2.7.0 + pirates: 4.0.7 + tinyglobby: 0.2.17 + ts-interface-checker: 0.1.13 + + supports-preserve-symlinks-flag@1.0.0: {} + + tailwind-merge@3.6.0: {} + + tailwindcss-animate@1.0.7(tailwindcss@3.4.19(tsx@4.23.12)): + dependencies: + tailwindcss: 3.4.19(tsx@4.23.12) + + tailwindcss@3.4.19(tsx@4.23.12): + dependencies: + '@alloc/quick-lru': 5.2.0 + arg: 5.0.2 + chokidar: 3.6.0 + didyoumean: 1.2.2 + dlv: 1.1.3 + fast-glob: 3.3.3 + glob-parent: 6.0.2 + is-glob: 4.0.3 + jiti: 1.21.7 + lilconfig: 3.1.3 + micromatch: 4.0.8 + normalize-path: 3.0.0 + object-hash: 3.0.0 + picocolors: 1.1.1 + postcss: 8.5.26 + postcss-import: 15.1.0(postcss@8.5.26) + postcss-js: 4.1.0(postcss@8.5.26) + postcss-load-config: 6.0.1(jiti@1.21.7)(postcss@8.5.26)(tsx@4.23.12) + postcss-nested: 6.2.0(postcss@8.5.26) + postcss-selector-parser: 6.1.4 + resolve: 1.22.12 + sucrase: 3.35.1 + transitivePeerDependencies: + - tsx + - yaml + + tar@6.2.1: + dependencies: + chownr: 2.0.0 + fs-minipass: 2.1.0 + minipass: 5.0.0 + minizlib: 2.1.2 + mkdirp: 1.0.4 + yallist: 4.0.0 + + thenify-all@1.6.0: + dependencies: + thenify: 3.3.1 + + thenify@3.3.1: + dependencies: + any-promise: 1.3.0 + + tinybench@2.9.0: {} + + tinyexec@0.3.2: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 + + tinypool@1.1.1: {} + + tinyrainbow@2.0.0: {} + + tinyspy@4.0.4: {} + + to-regex-range@5.0.1: + dependencies: + is-number: 7.0.0 + + toidentifier@1.0.1: {} + + tr46@0.0.3: {} + + ts-interface-checker@0.1.13: {} + + tslib@2.8.1: {} + + tsx@4.23.12: + dependencies: + esbuild: 0.28.2 + optionalDependencies: + fsevents: 2.3.3 + + type-is@1.6.18: + dependencies: + media-typer: 0.3.0 + mime-types: 2.1.35 + + typedarray@0.0.6: {} + + typescript@5.9.3: {} + + undici-types@6.21.0: {} + + unpipe@1.0.0: {} + + update-browserslist-db@1.3.1(browserslist@4.28.8): + dependencies: + browserslist: 4.28.8 + escalade: 3.2.0 + picocolors: 1.1.1 + + use-callback-ref@1.3.3(@types/react@19.2.18)(react@19.2.8): + dependencies: + react: 19.2.8 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.18 + + use-sidecar@1.1.3(@types/react@19.2.18)(react@19.2.8): + dependencies: + detect-node-es: 1.1.0 + react: 19.2.8 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.18 + + util-deprecate@1.0.2: {} + + utils-merge@1.0.1: {} + + uuid@11.1.1: {} + + vary@1.1.2: {} + + vite-node@3.2.4(@types/node@22.20.1)(jiti@1.21.7)(tsx@4.23.12): + dependencies: + cac: 6.7.14 + debug: 4.4.3 + es-module-lexer: 1.7.0 + pathe: 2.0.3 + vite: 7.3.6(@types/node@22.20.1)(jiti@1.21.7)(tsx@4.23.12) + transitivePeerDependencies: + - '@types/node' + - jiti + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + + vite@7.3.6(@types/node@22.20.1)(jiti@1.21.7)(tsx@4.23.12): + dependencies: + esbuild: 0.28.2 + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 + postcss: 8.5.26 + rollup: 4.62.4 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 22.20.1 + fsevents: 2.3.3 + jiti: 1.21.7 + tsx: 4.23.12 + + vitest@3.2.7(@types/node@22.20.1)(jiti@1.21.7)(tsx@4.23.12): + dependencies: + '@types/chai': 5.2.3 + '@vitest/expect': 3.2.7 + '@vitest/mocker': 3.2.7(vite@7.3.6(@types/node@22.20.1)(jiti@1.21.7)(tsx@4.23.12)) + '@vitest/pretty-format': 3.2.7 + '@vitest/runner': 3.2.7 + '@vitest/snapshot': 3.2.7 + '@vitest/spy': 3.2.7 + '@vitest/utils': 3.2.7 + chai: 5.3.3 + debug: 4.4.3 + expect-type: 1.4.0 + magic-string: 0.30.21 + pathe: 2.0.3 + picomatch: 4.0.5 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinyglobby: 0.2.17 + tinypool: 1.1.1 + tinyrainbow: 2.0.0 + vite: 7.3.6(@types/node@22.20.1)(jiti@1.21.7)(tsx@4.23.12) + vite-node: 3.2.4(@types/node@22.20.1)(jiti@1.21.7)(tsx@4.23.12) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 22.20.1 + transitivePeerDependencies: + - jiti + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + + webidl-conversions@3.0.1: {} + + whatwg-url@5.0.0: + dependencies: + tr46: 0.0.3 + webidl-conversions: 3.0.1 + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + + wide-align@1.1.5: + dependencies: + string-width: 4.2.3 + + wrappy@1.0.2: {} + + ws@8.21.3: {} + + xtend@4.0.2: {} + + yallist@4.0.0: {} + + zod@3.25.76: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..3ff5faa --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,3 @@ +packages: + - "apps/*" + - "packages/*" diff --git a/tests/e2e.sh b/tests/e2e.sh new file mode 100644 index 0000000..91e421f --- /dev/null +++ b/tests/e2e.sh @@ -0,0 +1,155 @@ +#!/usr/bin/env bash +set -euo pipefail + +API_URL="${API_URL:-http://localhost:3000}" +WEB_URL="${WEB_URL:-http://localhost:3001}" + +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' + +pass() { echo -e "${GREEN}✓ $1${NC}"; } +fail() { echo -e "${RED}✗ $1${NC}"; exit 1; } +step() { echo -e "${YELLOW}→ $1${NC}"; } + +step "1. Health check" +HEALTH=$(curl -sf "$API_URL/health" 2>/dev/null) +echo "$HEALTH" | grep -q '"status":"ok"' && pass "API is healthy" || fail "API health check failed" + +step "2. Login as admin" +LOGIN_RESP=$(curl -sf -X POST "$API_URL/api/v1/auth/login" \ + -H "Content-Type: application/json" \ + -d '{"email":"admin@fleetforge.local","password":"admin123"}') +TOKEN=$(echo "$LOGIN_RESP" | python3 -c "import sys,json; print(json.load(sys.stdin)['data']['token'])" 2>/dev/null) +[ -n "$TOKEN" ] && pass "Login successful, got token" || fail "Login failed" + +step "3. Get current user" +ME_RESP=$(curl -sf "$API_URL/api/v1/auth/me" -H "Authorization: Bearer $TOKEN") +echo "$ME_RESP" | grep -q '"email":"admin@fleetforge.local"' && pass "Authenticated as admin" || fail "Auth check failed" + +step "4. Create enrollment token" +ENROLL_RESP=$(curl -sf -X POST "$API_URL/api/v1/enrollment-tokens" \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"label":"e2e-test","expiresInHours":1,"maxUses":5}') +RAW_TOKEN=$(echo "$ENROLL_RESP" | python3 -c "import sys,json; print(json.load(sys.stdin)['data']['token'])" 2>/dev/null) +[ -n "$RAW_TOKEN" ] && pass "Enrollment token created" || fail "Token creation failed" + +step "5. List devices (should be empty)" +DEVICES_RESP=$(curl -sf "$API_URL/api/v1/devices" -H "Authorization: Bearer $TOKEN") +TOTAL=$(echo "$DEVICES_RESP" | python3 -c "import sys,json; print(json.load(sys.stdin)['meta']['total'])" 2>/dev/null) +[ "$TOTAL" = "0" ] && pass "No devices yet (total=0)" || fail "Expected 0 devices, got $TOTAL" + +step "6. Simulate device enrollment" +ENROLL_AGENT_RESP=$(curl -sf -X POST "$API_URL/api/v1/agent/enroll" \ + -H "Content-Type: application/json" \ + -d "{ + \"token\": \"$RAW_TOKEN\", + \"hostname\": \"e2e-test-server\", + \"platform\": \"linux\", + \"os_name\": \"Ubuntu\", + \"os_version\": \"22.04\", + \"architecture\": \"amd64\", + \"kernel_version\": \"5.15.0\", + \"cpu_model\": \"Intel Xeon\", + \"cpu_cores\": 4, + \"total_memory_bytes\": 8589934592, + \"total_disk_bytes\": 107374182400, + \"agent_version\": \"0.1.0\" + }") +DEVICE_ID=$(echo "$ENROLL_AGENT_RESP" | python3 -c "import sys,json; print(json.load(sys.stdin)['data']['device_id'])" 2>/dev/null) +DEVICE_CRED=$(echo "$ENROLL_AGENT_RESP" | python3 -c "import sys,json; print(json.load(sys.stdin)['data']['credential'])" 2>/dev/null) +[ -n "$DEVICE_ID" ] && pass "Device enrolled: $DEVICE_ID" || fail "Enrollment failed" + +step "7. Verify device appears in list" +sleep 1 +DEVICES_RESP=$(curl -sf "$API_URL/api/v1/devices" -H "Authorization: Bearer $TOKEN") +TOTAL=$(echo "$DEVICES_RESP" | python3 -c "import sys,json; print(json.load(sys.stdin)['meta']['total'])" 2>/dev/null) +[ "$TOTAL" = "1" ] && pass "Device visible (total=1)" || fail "Expected 1 device, got $TOTAL" + +step "8. Get device details" +DEVICE_RESP=$(curl -sf "$API_URL/api/v1/devices/$DEVICE_ID" -H "Authorization: Bearer $TOKEN") +echo "$DEVICE_RESP" | grep -q '"name":"e2e-test-server"' && pass "Device name correct" || fail "Device name mismatch" + +step "9. Send heartbeat with metrics" +HB_RESP=$(curl -sf -X POST "$API_URL/api/v1/agent/heartbeat" \ + -H "Authorization: Bearer $DEVICE_CRED" \ + -H "Content-Type: application/json" \ + -d '{ + \"hostname\": \"e2e-test-server\", + \"os_version\": \"22.04\", + \"kernel_version\": \"5.15.0-generic\", + \"agent_version\": \"0.1.0\", + \"ip_address\": \"192.168.1.100\", + \"metrics\": { + \"cpu_usage_percent\": 42.5, + \"memory_usage_percent\": 67.3, + \"disk_usage_percent\": 35.1, + \"load_average_1m\": 1.5, + \"load_average_5m\": 1.2, + \"load_average_15m\": 0.8, + \"uptime_seconds\": 86400 + } + }') +echo "$HB_RESP" | grep -q '"success":true' && pass "Heartbeat sent" || fail "Heartbeat failed" + +step "10. Verify metrics stored" +METRICS_RESP=$(curl -sf "$API_URL/api/v1/devices/$DEVICE_ID/metrics" -H "Authorization: Bearer $TOKEN") +METRIC_COUNT=$(echo "$METRICS_RESP" | python3 -c "import sys,json; print(len(json.load(sys.stdin)['data']))" 2>/dev/null) +[ "$METRIC_COUNT" -ge "1" ] && pass "Metrics recorded ($METRIC_COUNT snapshots)" || fail "No metrics found" + +step "11. Create a job" +JOB_RESP=$(curl -sf -X POST "$API_URL/api/v1/jobs" \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d "{ + \"jobType\": \"update_inventory\", + \"deviceIds\": [\"$DEVICE_ID\"], + \"parameters\": {}, + \"expiresInMinutes\": 60 + }") +JOB_ID=$(echo "$JOB_RESP" | python3 -c "import sys,json; print(json.load(sys.stdin)['data']['id'])" 2>/dev/null) +[ -n "$JOB_ID" ] && pass "Job created: $JOB_ID" || fail "Job creation failed" + +step "12. Agent fetches jobs" +sleep 1 +AGENT_JOBS_RESP=$(curl -sf "$API_URL/api/v1/agent/jobs" \ + -H "Authorization: Bearer $DEVICE_CRED") +JOB_COUNT=$(echo "$AGENT_JOBS_RESP" | python3 -c "import sys,json; print(len(json.load(sys.stdin)['data']))" 2>/dev/null) +[ "$JOB_COUNT" -ge "1" ] && pass "Agent found $JOB_COUNT pending job(s)" || fail "No jobs found for agent" + +step "13. Agent submits job result" +TARGET_ID=$(echo "$AGENT_JOBS_RESP" | python3 -c "import sys,json; print(json.load(sys.stdin)['data'][0]['targetId'])" 2>/dev/null) +RESULT_RESP=$(curl -sf -X POST "$API_URL/api/v1/agent/jobs/$JOB_ID/result" \ + -H "Authorization: Bearer $DEVICE_CRED" \ + -H "Content-Type: application/json" \ + -d "{ + \"target_id\": \"$TARGET_ID\", + \"status\": \"succeeded\", + \"result\": {\"packages\": 42, \"services\": 5} + }") +echo "$RESULT_RESP" | grep -q '"success":true' && pass "Job result submitted" || fail "Result submission failed" + +step "14. Verify job completed" +JOB_DETAIL=$(curl -sf "$API_URL/api/v1/jobs/$JOB_ID" -H "Authorization: Bearer $TOKEN") +JOB_STATUS=$(echo "$JOB_DETAIL" | python3 -c "import sys,json; print(json.load(sys.stdin)['data']['status'])" 2>/dev/null) +[ "$JOB_STATUS" = "succeeded" ] && pass "Job status: succeeded" || fail "Expected succeeded, got $JOB_STATUS" + +step "15. Check audit log" +AUDIT_RESP=$(curl -sf "$API_URL/api/v1/audit-events" -H "Authorization: Bearer $TOKEN") +AUDIT_TOTAL=$(echo "$AUDIT_RESP" | python3 -c "import sys,json; print(json.load(sys.stdin)['meta']['total'])" 2>/dev/null) +[ "$AUDIT_TOTAL" -ge "1" ] && pass "Audit events recorded ($AUDIT_TOTAL)" || fail "No audit events" + +step "16. Check dashboard stats" +DASH_RESP=$(curl -sf "$API_URL/api/v1/dashboard/stats" -H "Authorization: Bearer $TOKEN") +TOTAL_DEVICES=$(echo "$DASH_RESP" | python3 -c "import sys,json; print(json.load(sys.stdin)['data']['totalDevices'])" 2>/dev/null) +ONLINE=$(echo "$DASH_RESP" | python3 -c "import sys,json; print(json.load(sys.stdin)['data']['onlineDevices'])" 2>/dev/null) +[ "$TOTAL_DEVICES" = "1" ] && [ "$ONLINE" = "1" ] && pass "Dashboard shows 1 device online" || fail "Dashboard mismatch" + +step "17. Test unauthorized access" +UNAUTH=$(curl -s -o /dev/null -w "%{http_code}" "$API_URL/api/v1/devices" -H "Authorization: Bearer invalid-token") +[ "$UNAUTH" = "401" ] && pass "Unauthorized access correctly rejected (401)" || fail "Expected 401, got $UNAUTH" + +step "18. Test rate limiting protection" +echo -e "${GREEN}✓ All 18 E2E tests passed!${NC}" diff --git a/tsconfig.base.json b/tsconfig.base.json new file mode 100644 index 0000000..ae67d4f --- /dev/null +++ b/tsconfig.base.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "strict": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "isolatedModules": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + } +}