diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..7d28e3d --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,93 @@ +name: ci + +on: + push: + branches: [main] + pull_request: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + openapi: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: "22" + - name: Lint OpenAPI + run: npx --yes @redocly/cli@1.34.6 lint api/openapi.yaml + + agent: + runs-on: ubuntu-latest + defaults: + run: + working-directory: agent + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version: "1.25.7" + cache: true + cache-dependency-path: agent/go.sum + - name: gofmt + run: | + out=$(gofmt -l .) + if [ -n "$out" ]; then echo "$out"; exit 1; fi + - name: vet + run: go vet ./... + - name: test + run: go test ./... + - name: race + run: go test -race ./... + + server: + runs-on: ubuntu-latest + defaults: + run: + working-directory: server + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.14" + - uses: astral-sh/setup-uv@v3 + with: + enable-cache: true + - name: install + run: uv sync --extra dev + - name: lint + run: uv run ruff check . && uv run ruff format --check . + - name: test + run: uv run pytest -q + + web: + runs-on: ubuntu-latest + defaults: + run: + working-directory: web + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: "22" + cache: npm + cache-dependency-path: web/package-lock.json + - run: npm ci --no-audit --no-fund + - run: npm run gen:api + - run: npm run lint + - run: npm run typecheck + - run: npm run build + - name: install playwright browser + run: npx playwright install chromium + - run: npm run smoke + + stack-smoke: + runs-on: ubuntu-latest + needs: [server, agent] + steps: + - uses: actions/checkout@v4 + - name: stack smoke + run: bash deploy/smoke.sh diff --git a/agent/README.md b/agent/README.md index f5b8fa2..fd71dbb 100644 --- a/agent/README.md +++ b/agent/README.md @@ -14,19 +14,36 @@ Do not use `go install` or `go env -w`. Module cache, build cache, and tool bina ## Configuration -See `config.example.toml`. Required sections: top-level `state_dir`, `[server]` (`url`, `token`), at least one `[[checks]]` (`id`, `command`, `interval`, `timeout`). `agent_id` is optional — if empty, the agent generates and persists a UUID under `state_dir/agent_id`. +See `config.example.toml`. Required fields: top-level `state_dir`, at least one `[[checks]]` (`id`, `command`, `interval`, `timeout`), and at least one output enabled: `server.enabled = true` or `metrics.enabled = true`. `agent_id` is optional; if omitted, it defaults to `hostname`. | Key | Default | Notes | |---|---|---| -| `mode` | `hybrid` | `prometheus_only` (only `/metrics`), `push_only` (only heartbeat+events), `hybrid` (both). `prometheus_only` does not require `server.url`/`server.token`. | +| `server.enabled` | `false` | Enables heartbeat/event push. Requires `server.url` and `server.token` when true. | +| `metrics.enabled` | `false` | Enables `/metrics`. | +| `labels` | `{}` | Optional heartbeat labels. Max 31 custom labels because `monlet_agent_version` is generated by the agent. Secret-like keys are rejected. | | `server.heartbeat_interval` | `30s` | ADR-0005 | | `server.batch_interval` | `10s` | events flush cadence | | `metrics.listen` | `127.0.0.1:9465` | low-cardinality only | -| `checks[].notification_owner` | `server` | `server`, `prometheus`, `none` | +| `checks[].command` | required | Shell string executed as `/bin/sh -c`; TOML multi-line strings are supported. | +| `checks[].notifications_enabled` | `true` | Set `false` for checks that must not create server notifications. | + +Example: + +```toml +[[checks]] +id = "disk_root" +command = ''' +set -eu +/usr/local/lib/monlet/check_disk_root.sh +''' +interval = "60s" +timeout = "10s" +``` ## Behaviour - Per-check scheduler with no-overlap: a tick is skipped if the previous run is still in flight (`monlet_agent_check_skipped_total`). +- Check commands run through `/bin/sh -c`; the old argv-array form is intentionally unsupported. - Timeout via `exec.CommandContext` with `Setpgid` so the process group is killed on expiry; timeout → `unknown`. - Exit code mapping: 0=ok, 1=warning, 2=critical, 3+=unknown (ADR-0005). - `output` (stdout+stderr) is truncated by **UTF-8 byte length** to 8 KiB with marker `...[truncated N bytes]`. @@ -34,6 +51,8 @@ See `config.example.toml`. Required sections: top-level `state_dir`, `[server]` - Spool: per-event JSON files in `state_dir/spool/`, FIFO, limits 10 000 events / 50 MiB, drop oldest on overflow (ADR-0005). - Retry backoff for heartbeat and events: 1s → 30s, exp ×2, jitter ±20%. 4xx are dropped (not retried) to avoid hot loop. - Bearer auth header on both endpoints (ADR-0007). +- Heartbeats include configured labels plus reserved `monlet_agent_version=`. +- Heartbeats include explicit feature flags: `push` from `server.enabled`, `metrics` from `metrics.enabled`. ## Prometheus metrics @@ -52,7 +71,7 @@ Exposed on `metrics.listen` at `/metrics`. Labels are static (`check_id`, `statu - `monlet_agent_send_attempts_total{kind}`, `monlet_agent_send_failures_total{kind}` (`kind` ∈ `heartbeat`, `events`) - `monlet_agent_events_accepted_total`, `monlet_agent_events_deduplicated_total` - `monlet_agent_last_heartbeat_timestamp_seconds` -- `monlet_agent_build_info{version,mode}` (constant 1) +- `monlet_agent_build_info{version,push,metrics}` (constant 1) ## systemd diff --git a/agent/cmd/monlet-agent/main.go b/agent/cmd/monlet-agent/main.go index 905ff20..a9d397c 100644 --- a/agent/cmd/monlet-agent/main.go +++ b/agent/cmd/monlet-agent/main.go @@ -17,7 +17,7 @@ import ( "github.com/monlet/agent/internal/spool" ) -var version = "dev" +var version = "0.1.0" func main() { cfgPath := flag.String("config", "config.toml", "path to TOML config") @@ -31,7 +31,7 @@ func main() { os.Exit(1) } - agentID, err := ids.LoadOrCreateAgentID(cfg.StateDir, cfg.AgentID) + agentID, err := ids.ResolveAgentID(cfg.AgentID, cfg.Hostname) if err != nil { log.Error("agent_id", "err", err) os.Exit(1) @@ -47,7 +47,7 @@ func main() { Cfg: cfg, AgentID: agentID, Version: version, - Client: client.New(cfg.Server.URL, cfg.Server.Token, agentID, version), + Client: client.New(cfg.Server.URL, cfg.Server.Token, agentID), Spool: sp, Metrics: metrics.New(), Log: log, diff --git a/agent/config.example.toml b/agent/config.example.toml index a9ab9ff..3e94c05 100644 --- a/agent/config.example.toml +++ b/agent/config.example.toml @@ -1,9 +1,12 @@ -agent_id = "example-host-01" hostname = "example-host-01" -mode = "hybrid" state_dir = "/var/lib/monlet-agent" +[labels] +env = "prod" +role = "api" + [server] +enabled = true url = "http://127.0.0.1:8000" token = "replace-me" heartbeat_interval = "30s" @@ -16,7 +19,8 @@ listen = "127.0.0.1:9465" [[checks]] id = "disk_root" name = "Root disk usage" -command = ["/usr/local/lib/monlet/check_disk_root.sh"] +command = ''' +/usr/local/lib/monlet/check_disk_root.sh +''' interval = "60s" timeout = "10s" -notification_owner = "server" diff --git a/agent/internal/app/app.go b/agent/internal/app/app.go index 41cd180..6237529 100644 --- a/agent/internal/app/app.go +++ b/agent/internal/app/app.go @@ -28,7 +28,7 @@ func (a *App) Run(ctx context.Context) error { events := make(chan scheduler.Event, 256) a.Metrics.ChecksConfigured.Set(float64(len(a.Cfg.Checks))) - a.Metrics.BuildInfo.WithLabelValues(a.Version, a.Cfg.Mode).Set(1) + a.Metrics.BuildInfo.WithLabelValues(a.Version, boolLabel(a.Cfg.PushesToServer()), boolLabel(a.Cfg.ExposesMetrics())).Set(1) for _, c := range a.Cfg.Checks { a.Metrics.CheckInterval.WithLabelValues(c.ID).Set(c.Interval.Duration.Seconds()) } @@ -122,8 +122,11 @@ func (a *App) heartbeatLoop(ctx context.Context) { AgentID: a.AgentID, ObservedAt: time.Now().UTC(), Hostname: a.Cfg.Hostname, - Version: a.Version, - Mode: a.Cfg.Mode, + Features: client.AgentFeatures{ + Push: a.Cfg.PushesToServer(), + Metrics: a.Cfg.ExposesMetrics(), + }, + Labels: a.Cfg.HeartbeatLabels(a.Version), } a.Metrics.SendAttempts.WithLabelValues("heartbeat").Inc() err := a.Client.SendHeartbeat(ctx, hb) @@ -210,3 +213,10 @@ func sleep(ctx context.Context, d time.Duration) bool { return false } } + +func boolLabel(v bool) string { + if v { + return "true" + } + return "false" +} diff --git a/agent/internal/app/app_test.go b/agent/internal/app/app_test.go index bd2ddaf..5081800 100644 --- a/agent/internal/app/app_test.go +++ b/agent/internal/app/app_test.go @@ -30,22 +30,20 @@ func TestPrometheusOnlyDoesNotPush(t *testing.T) { dir := t.TempDir() cfg := &config.Config{ AgentID: "a1", - Mode: "prometheus_only", StateDir: dir, - Server: config.ServerConfig{URL: srv.URL, Token: "t", HeartbeatInterval: config.Duration{Duration: 50 * time.Millisecond}, BatchInterval: config.Duration{Duration: 50 * time.Millisecond}}, - Metrics: config.MetricsConfig{Enabled: false}, + Server: config.ServerConfig{Enabled: boolPtr(false), URL: srv.URL, Token: "t", HeartbeatInterval: config.Duration{Duration: 50 * time.Millisecond}, BatchInterval: config.Duration{Duration: 50 * time.Millisecond}}, + Metrics: config.MetricsConfig{Enabled: true, Listen: "127.0.0.1:0"}, Checks: []config.CheckConfig{{ - ID: "c1", - Command: []string{"sh", "-c", "echo hi"}, - Interval: config.Duration{Duration: 100 * time.Millisecond}, - Timeout: config.Duration{Duration: 50 * time.Millisecond}, - NotificationOwner: "server", + ID: "c1", + Command: "echo hi", + Interval: config.Duration{Duration: 100 * time.Millisecond}, + Timeout: config.Duration{Duration: 50 * time.Millisecond}, }}, } sp, _ := spool.Open(filepath.Join(dir, "spool"), 100, 1<<20) a := &App{ Cfg: cfg, AgentID: "a1", Version: "t", - Client: client.New(srv.URL, "t", "a1", "t"), + Client: client.New(srv.URL, "t", "a1"), Spool: sp, Metrics: metrics.New(), Log: slog.New(slog.NewTextHandler(io.Discard, nil)), } @@ -62,16 +60,22 @@ func TestPrometheusOnlyDoesNotPush(t *testing.T) { func TestEndToEndPushAndReplay(t *testing.T) { var ( - mu sync.Mutex - seenEventIDs = make(map[string]int) - hbCount int32 - fail atomic.Bool + mu sync.Mutex + seenEventIDs = make(map[string]int) + lastHeartbeat client.HeartbeatRequest + hbCount int32 + fail atomic.Bool ) fail.Store(true) srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case "/api/v1/heartbeat": + var req client.HeartbeatRequest + _ = json.NewDecoder(r.Body).Decode(&req) + mu.Lock() + lastHeartbeat = req + mu.Unlock() atomic.AddInt32(&hbCount, 1) w.WriteHeader(http.StatusAccepted) _, _ = io.WriteString(w, `{"accepted":true}`) @@ -97,9 +101,10 @@ func TestEndToEndPushAndReplay(t *testing.T) { cfg := &config.Config{ AgentID: "a1", Hostname: "h", - Mode: "hybrid", StateDir: dir, + Labels: map[string]string{"env": "test"}, Server: config.ServerConfig{ + Enabled: boolPtr(true), URL: srv.URL, Token: "t", HeartbeatInterval: config.Duration{Duration: 100 * time.Millisecond}, @@ -107,11 +112,10 @@ func TestEndToEndPushAndReplay(t *testing.T) { }, Metrics: config.MetricsConfig{Enabled: false}, Checks: []config.CheckConfig{{ - ID: "c1", - Command: []string{"sh", "-c", "echo hi"}, - Interval: config.Duration{Duration: 150 * time.Millisecond}, - Timeout: config.Duration{Duration: 100 * time.Millisecond}, - NotificationOwner: "server", + ID: "c1", + Command: "echo hi", + Interval: config.Duration{Duration: 150 * time.Millisecond}, + Timeout: config.Duration{Duration: 100 * time.Millisecond}, }}, } sp, err := spool.Open(filepath.Join(dir, "spool"), 100, 1<<20) @@ -122,7 +126,7 @@ func TestEndToEndPushAndReplay(t *testing.T) { Cfg: cfg, AgentID: "a1", Version: "test", - Client: client.New(cfg.Server.URL, "t", "a1", "test"), + Client: client.New(cfg.Server.URL, "t", "a1"), Spool: sp, Metrics: metrics.New(), Log: slog.New(slog.NewTextHandler(io.Discard, nil)), @@ -168,4 +172,17 @@ func TestEndToEndPushAndReplay(t *testing.T) { if atomic.LoadInt32(&hbCount) == 0 { t.Fatal("no heartbeats observed") } + if lastHeartbeat.Labels["env"] != "test" { + t.Fatalf("configured heartbeat label missing: %#v", lastHeartbeat.Labels) + } + if lastHeartbeat.Labels[config.AgentVersionLabel] != "test" { + t.Fatalf("version heartbeat label missing: %#v", lastHeartbeat.Labels) + } + if !lastHeartbeat.Features.Push || lastHeartbeat.Features.Metrics { + t.Fatalf("unexpected heartbeat features: %+v", lastHeartbeat.Features) + } +} + +func boolPtr(v bool) *bool { + return &v } diff --git a/agent/internal/client/client.go b/agent/internal/client/client.go index 7990967..4cd8c68 100644 --- a/agent/internal/client/client.go +++ b/agent/internal/client/client.go @@ -21,26 +21,28 @@ type Client struct { baseURL string token string agentID string - version string http *http.Client } -func New(baseURL, token, agentID, version string) *Client { +func New(baseURL, token, agentID string) *Client { return &Client{ baseURL: strings.TrimRight(baseURL, "/"), token: token, agentID: agentID, - version: version, http: &http.Client{Timeout: 15 * time.Second}, } } +type AgentFeatures struct { + Push bool `json:"push"` + Metrics bool `json:"metrics"` +} + type HeartbeatRequest struct { AgentID string `json:"agent_id"` ObservedAt time.Time `json:"observed_at"` Hostname string `json:"hostname"` - Version string `json:"version"` - Mode string `json:"mode,omitempty"` + Features AgentFeatures `json:"features"` Labels map[string]string `json:"labels,omitempty"` } @@ -65,7 +67,11 @@ func (c *Client) SendEvents(ctx context.Context, events []scheduler.Event) (*Eve if len(events) > MaxBatchEvents { return nil, fmt.Errorf("batch exceeds %d events", MaxBatchEvents) } - req := EventBatchRequest{AgentID: c.agentID, Events: events} + wireEvents := make([]scheduler.Event, len(events)) + for i, ev := range events { + wireEvents[i] = ev.WireEvent() + } + req := EventBatchRequest{AgentID: c.agentID, Events: wireEvents} var resp EventBatchResponse if err := c.post(ctx, "/api/v1/events", req, &resp); err != nil { return nil, err diff --git a/agent/internal/client/client_test.go b/agent/internal/client/client_test.go index d3d680b..4a2fd77 100644 --- a/agent/internal/client/client_test.go +++ b/agent/internal/client/client_test.go @@ -24,8 +24,13 @@ func TestSendHeartbeatSuccess(t *testing.T) { _, _ = io.WriteString(w, `{"accepted":true}`) })) defer srv.Close() - c := New(srv.URL, "tok", "a1", "v0") - err := c.SendHeartbeat(context.Background(), HeartbeatRequest{AgentID: "a1", ObservedAt: time.Now().UTC(), Hostname: "h", Version: "v0"}) + c := New(srv.URL, "tok", "a1") + err := c.SendHeartbeat(context.Background(), HeartbeatRequest{ + AgentID: "a1", + ObservedAt: time.Now().UTC(), + Hostname: "h", + Features: AgentFeatures{Push: true, Metrics: false}, + }) if err != nil { t.Fatal(err) } @@ -42,7 +47,7 @@ func TestSendEventsBatch(t *testing.T) { _, _ = io.WriteString(w, `{"accepted":2,"deduplicated":0}`) })) defer srv.Close() - c := New(srv.URL, "tok", "a1", "v0") + c := New(srv.URL, "tok", "a1") resp, err := c.SendEvents(context.Background(), []scheduler.Event{{EventID: "x"}, {EventID: "y"}}) if err != nil { t.Fatal(err) @@ -53,7 +58,7 @@ func TestSendEventsBatch(t *testing.T) { } func TestSendEventsTooBig(t *testing.T) { - c := New("http://x", "t", "a1", "v0") + c := New("http://x", "t", "a1") big := make([]scheduler.Event, MaxBatchEvents+1) _, err := c.SendEvents(context.Background(), big) if err == nil { diff --git a/agent/internal/config/config.go b/agent/internal/config/config.go index c8fce74..d5a404e 100644 --- a/agent/internal/config/config.go +++ b/agent/internal/config/config.go @@ -4,7 +4,9 @@ import ( "fmt" "os" "regexp" + "strings" "time" + "unicode/utf8" "github.com/BurntSushi/toml" ) @@ -13,22 +15,27 @@ var idPattern = regexp.MustCompile(`^[A-Za-z0-9._:-]+$`) const ( maxIDLen = 128 + maxLabelKeyLen = 64 + maxLabelValueLen = 256 + maxLabels = 32 + AgentVersionLabel = "monlet_agent_version" defaultHeartbeat = 30 * time.Second defaultBatch = 10 * time.Second defaultMetricsAddr = "127.0.0.1:9465" ) type Config struct { - AgentID string `toml:"agent_id"` - Hostname string `toml:"hostname"` - Mode string `toml:"mode"` - StateDir string `toml:"state_dir"` - Server ServerConfig `toml:"server"` - Metrics MetricsConfig `toml:"metrics"` - Checks []CheckConfig `toml:"checks"` + AgentID string `toml:"agent_id"` + Hostname string `toml:"hostname"` + StateDir string `toml:"state_dir"` + Labels map[string]string `toml:"labels"` + Server ServerConfig `toml:"server"` + Metrics MetricsConfig `toml:"metrics"` + Checks []CheckConfig `toml:"checks"` } type ServerConfig struct { + Enabled *bool `toml:"enabled"` URL string `toml:"url"` Token string `toml:"token"` HeartbeatInterval Duration `toml:"heartbeat_interval"` @@ -41,13 +48,13 @@ type MetricsConfig struct { } type CheckConfig struct { - ID string `toml:"id"` - Name string `toml:"name"` - Command []string `toml:"command"` - Interval Duration `toml:"interval"` - Timeout Duration `toml:"timeout"` - NotificationOwner string `toml:"notification_owner"` - DedupeKey string `toml:"dedupe_key"` + ID string `toml:"id"` + Name string `toml:"name"` + Command string `toml:"command"` + Interval Duration `toml:"interval"` + Timeout Duration `toml:"timeout"` + NotificationsEnabled *bool `toml:"notifications_enabled"` + DedupeKey string `toml:"dedupe_key"` } type Duration struct{ time.Duration } @@ -63,9 +70,17 @@ func (d *Duration) UnmarshalText(b []byte) error { func Load(path string) (*Config, error) { var c Config - if _, err := toml.DecodeFile(path, &c); err != nil { + md, err := toml.DecodeFile(path, &c) + if err != nil { return nil, fmt.Errorf("read config: %w", err) } + if undecoded := md.Undecoded(); len(undecoded) > 0 { + keys := make([]string, len(undecoded)) + for i, key := range undecoded { + keys[i] = key.String() + } + return nil, fmt.Errorf("unknown config keys: %s", strings.Join(keys, ", ")) + } c.applyDefaults() if err := c.Validate(); err != nil { return nil, err @@ -74,9 +89,6 @@ func Load(path string) (*Config, error) { } func (c *Config) applyDefaults() { - if c.Mode == "" { - c.Mode = "hybrid" - } if c.Hostname == "" { if h, err := os.Hostname(); err == nil { c.Hostname = h @@ -99,21 +111,27 @@ func (c *Config) Validate() error { return err } } - switch c.Mode { - case "prometheus_only", "push_only", "hybrid": - default: - return fmt.Errorf("invalid mode %q", c.Mode) + if c.Hostname == "" { + return fmt.Errorf("hostname is required") } if c.StateDir == "" { return fmt.Errorf("state_dir is required") } + if err := validateLabels(c.Labels); err != nil { + return err + } if c.PushesToServer() { if c.Server.URL == "" { - return fmt.Errorf("server.url is required for mode %q", c.Mode) + return fmt.Errorf("server.url is required when server.enabled = true") } if c.Server.Token == "" { - return fmt.Errorf("server.token is required for mode %q", c.Mode) + return fmt.Errorf("server.token is required when server.enabled = true") } + } else if c.Server.URL != "" || c.Server.Token != "" { + return fmt.Errorf("server.url/token require server.enabled = true") + } + if !c.PushesToServer() && !c.ExposesMetrics() { + return fmt.Errorf("server.enabled or metrics.enabled must be true") } if len(c.Checks) == 0 { return fmt.Errorf("at least one check is required") @@ -128,7 +146,7 @@ func (c *Config) Validate() error { return fmt.Errorf("duplicate check id %q", ch.ID) } seen[ch.ID] = struct{}{} - if len(ch.Command) == 0 { + if strings.TrimSpace(ch.Command) == "" { return fmt.Errorf("check %q: command is required", ch.ID) } if ch.Interval.Duration <= 0 { @@ -140,14 +158,6 @@ func (c *Config) Validate() error { if ch.Timeout.Duration > ch.Interval.Duration { return fmt.Errorf("check %q: timeout must be <= interval", ch.ID) } - switch ch.NotificationOwner { - case "", "server", "prometheus", "none": - default: - return fmt.Errorf("check %q: invalid notification_owner %q", ch.ID, ch.NotificationOwner) - } - if ch.NotificationOwner == "" { - ch.NotificationOwner = "server" - } if len(ch.DedupeKey) > 256 { return fmt.Errorf("check %q: dedupe_key too long", ch.ID) } @@ -155,15 +165,31 @@ func (c *Config) Validate() error { return nil } -// PushesToServer is true when the mode requires heartbeat/events push. +// PushesToServer is true when heartbeat/events push is enabled. func (c *Config) PushesToServer() bool { - return c.Mode == "push_only" || c.Mode == "hybrid" + return c.Server.Enabled != nil && *c.Server.Enabled } // ExposesMetrics is true when the agent should serve /metrics. -// `metrics.enabled` is an independent gate; both must agree. func (c *Config) ExposesMetrics() bool { - return c.Metrics.Enabled && (c.Mode == "prometheus_only" || c.Mode == "hybrid") + return c.Metrics.Enabled +} + +func (c *Config) HeartbeatLabels(version string) map[string]string { + labels := make(map[string]string, len(c.Labels)+1) + for k, v := range c.Labels { + labels[k] = v + } + labels[AgentVersionLabel] = version + return labels +} + +func (c CheckConfig) NotificationsOn() bool { + return c.NotificationsEnabled == nil || *c.NotificationsEnabled +} + +func (c CheckConfig) Argv() []string { + return []string{"/bin/sh", "-c", c.Command} } func ValidateID(field, v string) error { @@ -178,3 +204,40 @@ func ValidateID(field, v string) error { } return nil } + +func validateLabels(labels map[string]string) error { + if len(labels) > maxLabels { + return fmt.Errorf("labels exceed %d entries", maxLabels) + } + if _, hasVersion := labels[AgentVersionLabel]; !hasVersion && len(labels) >= maxLabels { + return fmt.Errorf("labels leave no room for reserved %q label", AgentVersionLabel) + } + for k, v := range labels { + if k == "" { + return fmt.Errorf("label key is empty") + } + if len(k) > maxLabelKeyLen { + return fmt.Errorf("label key %q exceeds %d chars", k, maxLabelKeyLen) + } + if !idPattern.MatchString(k) { + return fmt.Errorf("label key %q has invalid characters", k) + } + if isSensitiveLabelKey(k) { + return fmt.Errorf("label key %q is not allowed", k) + } + if utf8.RuneCountInString(v) > maxLabelValueLen { + return fmt.Errorf("label %q value exceeds %d chars", k, maxLabelValueLen) + } + } + return nil +} + +func isSensitiveLabelKey(k string) bool { + normalized := strings.NewReplacer("-", "_", ".", "_", ":", "_").Replace(strings.ToLower(k)) + for _, word := range []string{"token", "secret", "password", "credential", "authorization", "cookie", "api_key", "apikey"} { + if strings.Contains(normalized, word) { + return true + } + } + return false +} diff --git a/agent/internal/config/config_test.go b/agent/internal/config/config_test.go index 99e7c33..ee75244 100644 --- a/agent/internal/config/config_test.go +++ b/agent/internal/config/config_test.go @@ -1,8 +1,10 @@ package config import ( + "fmt" "os" "path/filepath" + "strings" "testing" ) @@ -20,12 +22,13 @@ agent_id = "host-1" state_dir = "/tmp/x" [server] +enabled = true url = "http://localhost" token = "t" [[checks]] id = "c1" -command = ["true"] +command = "true" interval = "10s" timeout = "5s" ` @@ -35,14 +38,105 @@ func TestLoadMinimal(t *testing.T) { if err != nil { t.Fatal(err) } - if c.Mode != "hybrid" { - t.Errorf("default mode: %q", c.Mode) + if !c.PushesToServer() || c.ExposesMetrics() { + t.Errorf("features: push=%v metrics=%v", c.PushesToServer(), c.ExposesMetrics()) } if c.Server.HeartbeatInterval.Duration == 0 { t.Error("default heartbeat not applied") } - if c.Checks[0].NotificationOwner != "server" { - t.Errorf("default notification_owner: %q", c.Checks[0].NotificationOwner) + if !c.Checks[0].NotificationsOn() { + t.Error("notifications should default on") + } +} + +func TestLoadMultilineCommand(t *testing.T) { + c, err := Load(writeTmp(t, ` +agent_id = "host-1" +state_dir = "/tmp/x" + +[server] +enabled = true +url = "http://localhost" +token = "t" + +[[checks]] +id = "c1" +command = ''' +set -eu +echo ok +''' +interval = "10s" +timeout = "5s" +`)) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(c.Checks[0].Command, "echo ok") { + t.Fatalf("command not loaded: %q", c.Checks[0].Command) + } + if got := c.Checks[0].Argv(); len(got) != 3 || got[0] != "/bin/sh" || got[1] != "-c" { + t.Fatalf("argv: %#v", got) + } +} + +func TestCommandArrayRejected(t *testing.T) { + _, err := Load(writeTmp(t, ` +agent_id = "host-1" +state_dir = "/tmp/x" + +[server] +enabled = true +url = "http://localhost" +token = "t" + +[[checks]] +id = "c1" +command = ["true"] +interval = "10s" +timeout = "5s" +`)) + if err == nil { + t.Fatal("expected command array to be rejected") + } +} + +func TestLoadLabels(t *testing.T) { + c, err := Load(writeTmp(t, ` +agent_id = "host-1" +state_dir = "/tmp/x" + +[labels] +env = "prod" +role = "api" + +[server] +enabled = true +url = "http://localhost" +token = "t" + +[[checks]] +id = "c1" +command = "true" +interval = "10s" +timeout = "5s" +`)) + if err != nil { + t.Fatal(err) + } + labels := c.HeartbeatLabels("1.2.3") + if labels["env"] != "prod" || labels["role"] != "api" { + t.Fatalf("configured labels missing: %#v", labels) + } + if labels[AgentVersionLabel] != "1.2.3" { + t.Fatalf("version label missing: %#v", labels) + } +} + +func TestHeartbeatLabelsVersionLabelWins(t *testing.T) { + c := &Config{Labels: map[string]string{AgentVersionLabel: "manual"}} + labels := c.HeartbeatLabels("real") + if labels[AgentVersionLabel] != "real" { + t.Fatalf("version label must be generated from binary version, got %q", labels[AgentVersionLabel]) } } @@ -52,11 +146,12 @@ func TestValidateBadID(t *testing.T) { agent_id = "bad id!" state_dir = "/tmp/x" [server] +enabled = true url = "http://localhost" token = "t" [[checks]] id = "c1" -command = ["true"] +command = "true" interval = "10s" timeout = "5s" `)) @@ -70,11 +165,12 @@ func TestValidateTimeoutExceedsInterval(t *testing.T) { agent_id = "x" state_dir = "/tmp/x" [server] +enabled = true url = "http://localhost" token = "t" [[checks]] id = "c1" -command = ["true"] +command = "true" interval = "5s" timeout = "10s" `)) @@ -83,13 +179,69 @@ timeout = "10s" } } -func TestPrometheusOnlyAllowsMissingServer(t *testing.T) { - c, err := Load(writeTmp(t, ` -mode = "prometheus_only" +func TestValidateBadLabelKey(t *testing.T) { + _, err := Load(writeTmp(t, ` +agent_id = "x" state_dir = "/tmp/x" +[labels] +"bad key" = "x" +[server] +enabled = true +url = "http://localhost" +token = "t" [[checks]] id = "c1" -command = ["true"] +command = "true" +interval = "10s" +timeout = "5s" +`)) + if err == nil { + t.Fatal("expected bad label key error") + } +} + +func TestValidateSensitiveLabelKey(t *testing.T) { + for _, key := range []string{"token", "api_key", "api-key", "password", "credential", "authorization", "cookie"} { + if err := validateLabels(map[string]string{key: "x"}); err == nil { + t.Fatalf("expected sensitive label key error for %q", key) + } + } +} + +func TestValidateLabelsReservedSlot(t *testing.T) { + labels := make(map[string]string, maxLabels) + for i := 0; i < maxLabels; i++ { + labels[fmt.Sprintf("k%d", i)] = "v" + } + if err := validateLabels(labels); err == nil { + t.Fatal("expected reserved label slot error") + } + delete(labels, "k0") + labels[AgentVersionLabel] = "manual" + if err := validateLabels(labels); err != nil { + t.Fatal(err) + } +} + +func TestValidateLabelValueMaxLength(t *testing.T) { + if err := validateLabels(map[string]string{"note": strings.Repeat("ю", maxLabelValueLen)}); err != nil { + t.Fatal(err) + } + if err := validateLabels(map[string]string{"note": strings.Repeat("x", maxLabelValueLen+1)}); err == nil { + t.Fatal("expected label value length error") + } +} + +func TestPrometheusOnlyAllowsMissingServer(t *testing.T) { + c, err := Load(writeTmp(t, ` +state_dir = "/tmp/x" +[server] +enabled = false +[metrics] +enabled = true +[[checks]] +id = "c1" +command = "true" interval = "10s" timeout = "5s" `)) @@ -97,17 +249,21 @@ timeout = "5s" t.Fatal(err) } if c.PushesToServer() { - t.Fatal("prometheus_only must not push") + t.Fatal("metrics-only config must not push") + } + if !c.ExposesMetrics() { + t.Fatal("metrics-only config must expose metrics") } } func TestPushOnlyRequiresServer(t *testing.T) { _, err := Load(writeTmp(t, ` -mode = "push_only" state_dir = "/tmp/x" +[server] +enabled = true [[checks]] id = "c1" -command = ["true"] +command = "true" interval = "10s" timeout = "5s" `)) @@ -116,21 +272,52 @@ timeout = "5s" } } +func TestUnknownConfigKeyRejected(t *testing.T) { + _, err := Load(writeTmp(t, ` +mode = "hybrid" +state_dir = "/tmp/x" +[server] +enabled = true +url = "http://localhost" +token = "t" +[[checks]] +id = "c1" +command = "true" +interval = "10s" +timeout = "5s" +`)) + if err == nil { + t.Fatal("expected unknown key error") + } +} + +func TestNotificationsEnabledCanDisable(t *testing.T) { + c, err := Load(writeTmp(t, minimal+` +notifications_enabled = false +`)) + if err != nil { + t.Fatal(err) + } + if c.Checks[0].NotificationsOn() { + t.Fatal("notifications_enabled=false was not applied") + } +} + func TestExposesMetricsGate(t *testing.T) { cases := []struct { - mode string - enabled bool + server bool + metrics bool want bool }{ - {"hybrid", true, true}, - {"hybrid", false, false}, - {"prometheus_only", true, true}, - {"push_only", true, false}, + {true, true, true}, + {true, false, false}, + {false, true, true}, + {false, false, false}, } for _, tc := range cases { - c := &Config{Mode: tc.mode, Metrics: MetricsConfig{Enabled: tc.enabled}} + c := &Config{Server: ServerConfig{Enabled: &tc.server}, Metrics: MetricsConfig{Enabled: tc.metrics}} if got := c.ExposesMetrics(); got != tc.want { - t.Errorf("mode=%s enabled=%v want=%v got=%v", tc.mode, tc.enabled, tc.want, got) + t.Errorf("server=%v metrics=%v want=%v got=%v", tc.server, tc.metrics, tc.want, got) } } } @@ -140,16 +327,17 @@ func TestValidateDuplicateCheckID(t *testing.T) { agent_id = "x" state_dir = "/tmp/x" [server] +enabled = true url = "http://localhost" token = "t" [[checks]] id = "c1" -command = ["true"] +command = "true" interval = "10s" timeout = "5s" [[checks]] id = "c1" -command = ["true"] +command = "true" interval = "10s" timeout = "5s" `)) diff --git a/agent/internal/ids/ids.go b/agent/internal/ids/ids.go index 144deb1..bc81f3f 100644 --- a/agent/internal/ids/ids.go +++ b/agent/internal/ids/ids.go @@ -2,38 +2,24 @@ package ids import ( "fmt" - "os" - "path/filepath" "strings" - "github.com/google/uuid" - "github.com/monlet/agent/internal/config" ) -// LoadOrCreateAgentID returns configured ID if non-empty, otherwise loads -// state_dir/agent_id, generating and persisting it on first run. -func LoadOrCreateAgentID(stateDir, configured string) (string, error) { +func ResolveAgentID(configured, hostname string) (string, error) { if configured != "" { - return configured, nil - } - if err := os.MkdirAll(stateDir, 0o755); err != nil { - return "", fmt.Errorf("create state dir: %w", err) - } - path := filepath.Join(stateDir, "agent_id") - b, err := os.ReadFile(path) - if err == nil { - id := strings.TrimSpace(string(b)) + id := strings.TrimSpace(configured) if err := config.ValidateID("agent_id", id); err != nil { - return "", fmt.Errorf("persisted agent_id invalid: %w", err) + return "", err } return id, nil } - if !os.IsNotExist(err) { - return "", err + id := strings.TrimSpace(hostname) + if id == "" { + return "", fmt.Errorf("hostname is empty") } - id := uuid.NewString() - if err := os.WriteFile(path, []byte(id+"\n"), 0o600); err != nil { + if err := config.ValidateID("hostname as agent_id", id); err != nil { return "", err } return id, nil diff --git a/agent/internal/ids/ids_test.go b/agent/internal/ids/ids_test.go index 7290d7a..a56a03a 100644 --- a/agent/internal/ids/ids_test.go +++ b/agent/internal/ids/ids_test.go @@ -1,33 +1,17 @@ package ids -import ( - "os" - "path/filepath" - "testing" -) +import "testing" func TestConfiguredWins(t *testing.T) { - id, err := LoadOrCreateAgentID(t.TempDir(), "fixed") + id, err := ResolveAgentID("fixed", "host") if err != nil || id != "fixed" { t.Fatalf("got %q %v", id, err) } } -func TestGenerateAndReuse(t *testing.T) { - dir := t.TempDir() - id1, err := LoadOrCreateAgentID(dir, "") - if err != nil { - t.Fatal(err) - } - id2, err := LoadOrCreateAgentID(dir, "") - if err != nil { - t.Fatal(err) - } - if id1 != id2 { - t.Fatalf("not stable: %q vs %q", id1, id2) - } - b, _ := os.ReadFile(filepath.Join(dir, "agent_id")) - if len(b) == 0 { - t.Fatal("agent_id not persisted") +func TestHostnameFallback(t *testing.T) { + id, err := ResolveAgentID("", "host-1") + if err != nil || id != "host-1" { + t.Fatalf("got %q %v", id, err) } } diff --git a/agent/internal/metrics/metrics.go b/agent/internal/metrics/metrics.go index 78efd1d..60af019 100644 --- a/agent/internal/metrics/metrics.go +++ b/agent/internal/metrics/metrics.go @@ -117,7 +117,7 @@ func New() *Metrics { m.BuildInfo = prometheus.NewGaugeVec(prometheus.GaugeOpts{ Name: "monlet_agent_build_info", Help: "Constant 1 with build labels.", - }, []string{"version", "mode"}) + }, []string{"version", "push", "metrics"}) reg.MustRegister(m.CheckRuns, m.CheckSkipped, m.CheckDuration, m.CheckStatus, m.CheckExitCode, m.CheckLastRun, m.CheckLastSuccess, m.CheckInterval, m.ChecksConfigured, diff --git a/agent/internal/scheduler/scheduler.go b/agent/internal/scheduler/scheduler.go index 62de024..495fb34 100644 --- a/agent/internal/scheduler/scheduler.go +++ b/agent/internal/scheduler/scheduler.go @@ -12,16 +12,24 @@ import ( // Event is a contract-shaped check result emitted by the scheduler. type Event struct { - EventID string `json:"event_id"` - CheckID string `json:"check_id"` - ObservedAt time.Time `json:"observed_at"` - Status string `json:"status"` - ExitCode int `json:"exit_code"` - DurationMs int64 `json:"duration_ms"` - Output string `json:"output,omitempty"` - OutputTruncated bool `json:"output_truncated,omitempty"` - NotificationOwner string `json:"notification_owner,omitempty"` - IncidentKey string `json:"incident_key,omitempty"` + EventID string `json:"event_id"` + CheckID string `json:"check_id"` + ObservedAt time.Time `json:"observed_at"` + Status string `json:"status"` + ExitCode int `json:"exit_code"` + DurationMs int64 `json:"duration_ms"` + Output string `json:"output,omitempty"` + OutputTruncated bool `json:"output_truncated,omitempty"` + NotificationsEnabled *bool `json:"notifications_enabled,omitempty"` + IncidentKey string `json:"incident_key,omitempty"` +} + +func (e Event) WireEvent() Event { + if e.NotificationsEnabled == nil { + enabled := true + e.NotificationsEnabled = &enabled + } + return e } type Hooks struct { @@ -66,22 +74,22 @@ func runCheck(ctx context.Context, agentID string, c config.CheckConfig, out cha mu.Unlock() }() - res := runner.Run(ctx, c.Command, c.Timeout.Duration) + res := runner.Run(ctx, c.Argv(), c.Timeout.Duration) id, err := eventid.New() if err != nil { return } ev := Event{ - EventID: id, - CheckID: c.ID, - ObservedAt: time.Now().UTC(), - Status: string(res.Status), - ExitCode: res.ExitCode, - DurationMs: res.DurationMs, - Output: res.Output, - OutputTruncated: res.OutputTruncated, - NotificationOwner: c.NotificationOwner, - IncidentKey: incidentKey(agentID, c), + EventID: id, + CheckID: c.ID, + ObservedAt: time.Now().UTC(), + Status: string(res.Status), + ExitCode: res.ExitCode, + DurationMs: res.DurationMs, + Output: res.Output, + OutputTruncated: res.OutputTruncated, + NotificationsEnabled: boolPtr(c.NotificationsOn()), + IncidentKey: incidentKey(agentID, c), } if h.OnResult != nil { h.OnResult(ev) @@ -105,6 +113,10 @@ func runCheck(ctx context.Context, agentID string, c config.CheckConfig, out cha } } +func boolPtr(v bool) *bool { + return &v +} + func incidentKey(agentID string, c config.CheckConfig) string { if c.DedupeKey != "" { return c.DedupeKey diff --git a/agent/internal/scheduler/scheduler_test.go b/agent/internal/scheduler/scheduler_test.go index 9f917b2..7495368 100644 --- a/agent/internal/scheduler/scheduler_test.go +++ b/agent/internal/scheduler/scheduler_test.go @@ -11,11 +11,10 @@ import ( func mkCheck(id, cmd string, interval, timeout time.Duration) config.CheckConfig { return config.CheckConfig{ - ID: id, - Command: []string{"sh", "-c", cmd}, - Interval: config.Duration{Duration: interval}, - Timeout: config.Duration{Duration: timeout}, - NotificationOwner: "server", + ID: id, + Command: cmd, + Interval: config.Duration{Duration: interval}, + Timeout: config.Duration{Duration: timeout}, } } diff --git a/api/openapi.yaml b/api/openapi.yaml index 086a5cf..9fc03ff 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -14,6 +14,7 @@ paths: /api/v1/health: get: summary: Health check + operationId: getHealth security: [] responses: "200": @@ -25,6 +26,7 @@ paths: /api/v1/ready: get: summary: Readiness check + operationId: getReady security: [] responses: "200": @@ -42,6 +44,7 @@ paths: /api/v1/heartbeat: post: summary: Ingest agent heartbeat + operationId: ingestHeartbeat requestBody: required: true content: @@ -66,6 +69,7 @@ paths: /api/v1/events: post: summary: Ingest agent check result events + operationId: ingestEvents requestBody: required: true content: @@ -90,6 +94,7 @@ paths: /api/v1/agents: get: summary: List agents + operationId: listAgents parameters: - $ref: "#/components/parameters/Cursor" - $ref: "#/components/parameters/Limit" @@ -113,6 +118,7 @@ paths: /api/v1/agents/{agent_id}: get: summary: Get agent detail + operationId: getAgent parameters: - $ref: "#/components/parameters/AgentId" responses: @@ -129,7 +135,15 @@ paths: /api/v1/checks: get: summary: List current check states + operationId: listChecks parameters: + - name: agent_id + in: query + required: false + schema: + type: string + maxLength: 128 + pattern: "^[A-Za-z0-9._:-]+$" - $ref: "#/components/parameters/Cursor" - $ref: "#/components/parameters/Limit" responses: @@ -152,6 +166,7 @@ paths: /api/v1/incidents: get: summary: List incidents + operationId: listIncidents parameters: - name: state in: query @@ -181,6 +196,7 @@ paths: /api/v1/events/query: get: summary: Query stored events + operationId: queryEvents parameters: - name: agent_id in: query @@ -218,6 +234,7 @@ paths: /api/v1/notifiers/outbox: get: summary: List notification outbox items + operationId: listOutbox parameters: - $ref: "#/components/parameters/Cursor" - $ref: "#/components/parameters/Limit" @@ -358,7 +375,8 @@ components: description: Number of events whose `event_id` was already known. HeartbeatRequest: type: object - required: [agent_id, observed_at, hostname, version] + additionalProperties: false + required: [agent_id, observed_at, hostname, features] properties: agent_id: type: string @@ -370,12 +388,8 @@ components: hostname: type: string maxLength: 253 - version: - type: string - maxLength: 64 - mode: - type: string - enum: [prometheus_only, push_only, hybrid] + features: + $ref: "#/components/schemas/AgentFeatures" labels: type: object maxProperties: 32 @@ -385,9 +399,21 @@ components: additionalProperties: type: string maxLength: 256 - description: Up to 32 labels. Key max 64, value max 256. + description: Up to 32 labels. Key max 64, value max 256. Monlet agents reserve `monlet_agent_version`. Secret-like keys are rejected. + AgentFeatures: + type: object + additionalProperties: false + required: [push, metrics] + properties: + push: + type: boolean + description: Agent pushes heartbeats/events to the server. + metrics: + type: boolean + description: Agent exposes a Prometheus-compatible metrics endpoint. EventBatchRequest: type: object + additionalProperties: false required: [agent_id, events] properties: agent_id: @@ -402,6 +428,7 @@ components: $ref: "#/components/schemas/CheckResultEvent" CheckResultEvent: type: object + additionalProperties: false description: | Event body used inside `EventBatchRequest.events`. The owning `agent_id` is supplied once at the batch envelope; per-event `agent_id` is intentionally @@ -449,9 +476,10 @@ components: output_truncated: type: boolean default: false - notification_owner: - type: string - enum: [server, prometheus, none] + notifications_enabled: + type: boolean + default: true + description: If false, the server stores state/incidents but does not enqueue notifications for this event. incident_key: type: string maxLength: 256 @@ -471,7 +499,7 @@ components: format: date-time Agent: type: object - required: [agent_id, hostname, status, last_seen_at] + required: [agent_id, hostname, status, last_seen_at, features] properties: agent_id: type: string @@ -483,11 +511,8 @@ components: last_seen_at: type: string format: date-time - version: - type: string - mode: - type: string - enum: [prometheus_only, push_only, hybrid] + features: + $ref: "#/components/schemas/AgentFeatures" labels: type: object additionalProperties: diff --git a/deploy/README.md b/deploy/README.md index fcfeb55..304b0e3 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -1,15 +1,75 @@ # Deploy -Deployment examples live here. +Local stack and example operator configs. -Planned files: +## Quick start -- Docker Compose for local development; -- PostgreSQL service; -- optional Prometheus scrape config; -- optional Alertmanager config; -- optional Grafana dashboard stub. +```sh +cd deploy +MONLET_AUTH_TOKEN=$(openssl rand -hex 16) docker compose up --build +``` -No production-ready deployment files are implemented in Stage 0. +Brings up: PostgreSQL 16, Monlet server (with Alembic upgrade on start), Web UI. -Docker examples must not install Python, Node, or Go project dependencies on the host. Dependency installation belongs inside images/containers or repo-local development directories. +- Server: http://127.0.0.1:8000 (`/api/v1/health`, `/api/v1/ready`, `/metrics`) +- Web UI: http://127.0.0.1:3000 +- PostgreSQL: 127.0.0.1:5432, db `monlet`, user `monlet` + +Add Prometheus + Alertmanager + Grafana: + +```sh +MONLET_AUTH_TOKEN=$(openssl rand -hex 16) docker compose --profile observability up +``` + +- Prometheus: http://127.0.0.1:9090 +- Alertmanager: http://127.0.0.1:9093 +- Grafana: http://127.0.0.1:3001 (admin / `$GF_ADMIN_PASSWORD`, default `admin`) + +## Smoke test + +```sh +bash deploy/smoke.sh +``` + +Boots the stack, hits `/health`, `/ready`, `/metrics`, ingests example payloads from `examples/`, and verifies the API exposes resulting agent/check/incident/outbox state. + +`SMOKE_KEEP=1 bash deploy/smoke.sh` leaves the stack up after the run. + +## Showcase / E2E stand (Stage 6.1) + +Full-fat demo stack with several dockerized agents driving the system through +every witness state: agent alive/stale/dead, check ok/warning/critical/unknown, +incident open/resolved, outbox sent/retry/failed/discarded, +anti-double-alerting with `notifications_enabled=false`, and agent spool +replay against a server that becomes available after the agent starts. + +```sh +bash deploy/e2e-showcase.sh +``` + +Internally: + +```sh +MONLET_AUTH_TOKEN=monlet-dev-token docker compose -f deploy/docker-compose.yml -f deploy/docker-compose.showcase.yml up -d --build +``` + +`SHOWCASE_KEEP=1 bash deploy/e2e-showcase.sh` leaves the stack up so you can +inspect the web UI at http://127.0.0.1:3000. + +Dockerized agents (`deploy/docker/agent.Dockerfile`, `deploy/showcase/`) exist +for demo/e2e only — production deploys the Go binary under systemd. + +## Files + +- `docker-compose.yml` — full local stack (server / postgres / web / optional observability profile). +- `prometheus/prometheus.yml` — example scrape config (Monlet server + agent target). +- `alertmanager/alertmanager.yml` — example route + receiver. +- `grafana/provisioning/` — datasource + dashboard providers. +- `grafana/dashboards/monlet-overview.json` — starter dashboard. +- `smoke.sh` — local stack smoke entry point. +- `docker-compose.showcase.yml` — override that adds demo agents + mock webhook. +- `docker/agent.Dockerfile` — demo/e2e agent image (not for production). +- `showcase/` — demo agent configs, check scripts, mock webhook, seed SQL. +- `e2e-showcase.sh` — full showcase runner (Stage 6.1). + +Docker images install Python/Node project dependencies inside the image; host venvs are never mounted (see `AGENTS.md`). diff --git a/deploy/alertmanager/alertmanager.yml b/deploy/alertmanager/alertmanager.yml new file mode 100644 index 0000000..16579c4 --- /dev/null +++ b/deploy/alertmanager/alertmanager.yml @@ -0,0 +1,15 @@ +# Example Alertmanager config. REPLACE the receiver before using in any real +# environment — the default 'null' receiver silently drops alerts. + +route: + group_by: ["alertname", "agent_id", "check_id"] + group_wait: 10s + group_interval: 30s + repeat_interval: 1h + receiver: "null" + +receivers: + - name: "null" + # Intentionally empty: drops everything routed here. Add your real + # receiver (email_configs, telegram_configs, webhook_configs, ...) and + # repoint `route.receiver` at it. diff --git a/deploy/docker-compose.showcase.yml b/deploy/docker-compose.showcase.yml new file mode 100644 index 0000000..24a0da0 --- /dev/null +++ b/deploy/docker-compose.showcase.yml @@ -0,0 +1,113 @@ +# Showcase / e2e override on top of deploy/docker-compose.yml. +# Usage: +# docker compose -f deploy/docker-compose.yml -f deploy/docker-compose.showcase.yml up -d --build +# Demo agents only — production uses systemd-managed Go binary. + +services: + server: + environment: + MONLET_STALE_AFTER_SEC: "20" + MONLET_DEAD_AFTER_SEC: "40" + MONLET_DETECTOR_TICK_SEC: "5" + MONLET_EVENTS_RETENTION_MAX_ROWS: "5000" + MONLET_OUTBOX_RETENTION_MAX_ROWS: "1000" + MONLET_NOTIFIER_DEBUG_ENABLED: "true" + MONLET_NOTIFIER_WEBHOOK_ENABLED: "true" + MONLET_NOTIFIER_WEBHOOK_URL: "http://mock-webhook:8080/" + + mock-webhook: + image: python:3.14-alpine + command: ["python3", "-u", "/opt/mock_webhook.py"] + volumes: + - ./showcase/mock_webhook.py:/opt/mock_webhook.py:ro + healthcheck: + test: ["CMD-SHELL", "wget -qO- --post-data='{}' http://localhost:8080/ >/dev/null 2>&1 || true"] + interval: 5s + timeout: 3s + retries: 5 + + agent-mixed: + image: monlet-showcase-agent + build: + context: .. + dockerfile: deploy/docker/agent.Dockerfile + environment: + MONLET_CONFIG: /etc/monlet/agent.toml + volumes: + - ./showcase/agents/mixed.toml:/etc/monlet/agent.toml:ro + - ./showcase/checks:/opt/monlet/checks:ro + depends_on: + server: + condition: service_healthy + ports: + - "9465:9465" + + agent-resolve: + image: monlet-showcase-agent + environment: + MONLET_CONFIG: /etc/monlet/agent.toml + volumes: + - ./showcase/agents/resolve.toml:/etc/monlet/agent.toml:ro + - ./showcase/checks:/opt/monlet/checks:ro + - resolve_state:/state + depends_on: + server: + condition: service_healthy + agent-mixed: + condition: service_started + + agent-prometheus-owned: + image: monlet-showcase-agent + environment: + MONLET_CONFIG: /etc/monlet/agent.toml + volumes: + - ./showcase/agents/prometheus_owned.toml:/etc/monlet/agent.toml:ro + - ./showcase/checks:/opt/monlet/checks:ro + depends_on: + server: + condition: service_healthy + agent-mixed: + condition: service_started + + agent-stale: + image: monlet-showcase-agent + environment: + MONLET_CONFIG: /etc/monlet/agent.toml + volumes: + - ./showcase/agents/stale.toml:/etc/monlet/agent.toml:ro + - ./showcase/checks:/opt/monlet/checks:ro + depends_on: + server: + condition: service_healthy + agent-mixed: + condition: service_started + + agent-dead: + image: monlet-showcase-agent + environment: + MONLET_CONFIG: /etc/monlet/agent.toml + volumes: + - ./showcase/agents/dead.toml:/etc/monlet/agent.toml:ro + - ./showcase/checks:/opt/monlet/checks:ro + depends_on: + server: + condition: service_healthy + agent-mixed: + condition: service_started + + # No depends_on:server — starts in parallel so it must spool events until server is ready. + agent-spool-replay: + image: monlet-showcase-agent + environment: + MONLET_CONFIG: /etc/monlet/agent.toml + volumes: + - ./showcase/agents/spool_replay.toml:/etc/monlet/agent.toml:ro + - ./showcase/checks:/opt/monlet/checks:ro + - spool_replay_state:/var/lib/monlet-agent + depends_on: + postgres: + condition: service_healthy + +volumes: + resolve_state: + spool_replay_state: diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml new file mode 100644 index 0000000..fa29797 --- /dev/null +++ b/deploy/docker-compose.yml @@ -0,0 +1,85 @@ +services: + postgres: + image: postgres:16-alpine + environment: + POSTGRES_USER: monlet + POSTGRES_PASSWORD: monlet + POSTGRES_DB: monlet + healthcheck: + test: ["CMD-SHELL", "pg_isready -U monlet -d monlet"] + interval: 5s + timeout: 3s + retries: 20 + volumes: + - monlet_pg:/var/lib/postgresql/data + ports: + - "5432:5432" + + server: + build: + context: ../server + depends_on: + postgres: + condition: service_healthy + environment: + MONLET_AUTH_TOKEN: ${MONLET_AUTH_TOKEN:?MONLET_AUTH_TOKEN is required} + MONLET_DATABASE_URL: postgresql+asyncpg://monlet:monlet@postgres:5432/monlet + MONLET_LOG_LEVEL: ${MONLET_LOG_LEVEL:-INFO} + MONLET_ENABLE_DETECTOR: "true" + MONLET_ENABLE_NOTIFIER_WORKER: "true" + MONLET_NOTIFIER_DEBUG_ENABLED: "true" + command: + - sh + - -c + - "alembic upgrade head && uvicorn monlet_server.main:app --host 0.0.0.0 --port 8000" + ports: + - "8000:8000" + healthcheck: + test: ["CMD-SHELL", "python -c 'import urllib.request,sys;sys.exit(0) if urllib.request.urlopen(\"http://localhost:8000/api/v1/ready\").status==200 else sys.exit(1)'"] + interval: 5s + timeout: 3s + retries: 30 + + web: + build: + context: ../web + depends_on: + server: + condition: service_healthy + environment: + MONLET_API_BASE_URL: http://server:8000 + MONLET_API_TOKEN: ${MONLET_AUTH_TOKEN:?MONLET_AUTH_TOKEN is required} + ports: + - "3000:3000" + + prometheus: + image: prom/prometheus:v2.55.0 + profiles: ["observability"] + volumes: + - ./prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro + ports: + - "9090:9090" + depends_on: + - server + + alertmanager: + image: prom/alertmanager:v0.27.0 + profiles: ["observability"] + volumes: + - ./alertmanager/alertmanager.yml:/etc/alertmanager/alertmanager.yml:ro + ports: + - "9093:9093" + + grafana: + image: grafana/grafana:11.3.0 + profiles: ["observability"] + environment: + GF_SECURITY_ADMIN_PASSWORD: ${GF_ADMIN_PASSWORD:-admin} + volumes: + - ./grafana/provisioning:/etc/grafana/provisioning:ro + - ./grafana/dashboards:/var/lib/grafana/dashboards:ro + ports: + - "3001:3000" + +volumes: + monlet_pg: diff --git a/deploy/docker/agent.Dockerfile b/deploy/docker/agent.Dockerfile new file mode 100644 index 0000000..38dcf40 --- /dev/null +++ b/deploy/docker/agent.Dockerfile @@ -0,0 +1,21 @@ +# Dockerized monlet-agent for the showcase / e2e stand only. +# Production deployment uses a systemd-managed Go binary; do not use this image in prod. +# Build context: repo root (we need ../agent). + +FROM golang:1.25-alpine AS builder +WORKDIR /src +ENV CGO_ENABLED=0 GOTOOLCHAIN=auto +ARG MONLET_AGENT_VERSION=0.1.0 +COPY agent/go.mod agent/go.sum ./ +RUN go mod download +COPY agent/ ./ +RUN go build -trimpath -ldflags="-s -w -X main.version=${MONLET_AGENT_VERSION}" -o /out/monlet-agent ./cmd/monlet-agent + +FROM ubuntu:24.04 +RUN apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates \ + && rm -rf /var/lib/apt/lists/* +COPY --from=builder /out/monlet-agent /usr/local/bin/monlet-agent +RUN mkdir -p /var/lib/monlet-agent /etc/monlet /opt/monlet/checks +ENV MONLET_CONFIG=/etc/monlet/agent.toml +ENTRYPOINT ["/bin/sh","-c","exec /usr/local/bin/monlet-agent -config \"$MONLET_CONFIG\""] diff --git a/deploy/e2e-showcase.sh b/deploy/e2e-showcase.sh new file mode 100755 index 0000000..f360f1e --- /dev/null +++ b/deploy/e2e-showcase.sh @@ -0,0 +1,193 @@ +#!/usr/bin/env bash +# Stage 6.1 — Docker showcase / e2e stand for Monlet. +# Brings up the full stack with demo agents, drives scenarios, asserts all +# witness states (agent alive/stale/dead, check ok/warning/critical/unknown, +# incident open/resolved, outbox sent/retry/failed/discarded, disabled server notifications, +# agent spool replay), and verifies metrics + web pages. +set -euo pipefail + +HERE="$(cd "$(dirname "$0")" && pwd)" +ROOT="$(cd "$HERE/.." && pwd)" +TOKEN="${MONLET_AUTH_TOKEN:-monlet-dev-token}" +BASE="${MONLET_API_BASE_URL:-http://127.0.0.1:8000}" +WEB_BASE="${MONLET_WEB_BASE_URL:-http://127.0.0.1:3000}" +AGENT_METRICS="${MONLET_AGENT_METRICS_URL:-http://127.0.0.1:9465/metrics}" + +COMPOSE=(docker compose + -f "$HERE/docker-compose.yml" + -f "$HERE/docker-compose.showcase.yml") + +log() { printf "\033[1;36m[showcase]\033[0m %s\n" "$*"; } +fail() { printf "\033[1;31m[showcase]\033[0m %s\n" "$*" >&2; exit 1; } + +AUTH=(-H "Authorization: Bearer $TOKEN") + +cleanup() { + if [[ "${SHOWCASE_KEEP:-0}" != "1" ]]; then + log "tearing down stack" + "${COMPOSE[@]}" down -v --remove-orphans >/dev/null 2>&1 || true + else + log "SHOWCASE_KEEP=1 → leaving stack running (web: $WEB_BASE)" + fi +} +trap cleanup EXIT + +wait_url() { + local url="$1" label="$2" tries="${3:-60}" + for i in $(seq 1 "$tries"); do + if curl -sf -o /dev/null "$url"; then return 0; fi + sleep 2 + [[ "$i" == "$tries" ]] && fail "$label not ready: $url" + done +} + +api() { curl -sf "${AUTH[@]}" "$BASE$1"; } + +log "starting full showcase stack" +MONLET_AUTH_TOKEN="$TOKEN" "${COMPOSE[@]}" up -d --build + +log "waiting for server /api/v1/ready" +wait_url "$BASE/api/v1/ready" "server" 90 + +log "waiting for web /" +wait_url "$WEB_BASE/" "web" 90 + +# --- Phase A: let agents run a few cycles --- +log "letting demo agents push events for ~25s" +sleep 25 + +log "asserting agent labels include scenario and monlet_agent_version" +api "/api/v1/agents/agent-mixed" | python3 -c " +import json,sys +d=json.load(sys.stdin) +labels=d.get('labels') or {} +ver=labels.get('monlet_agent_version') +ok=labels.get('scenario')=='mixed-status' and ver and ver!='dev' +print('agent-mixed labels:', labels) +sys.exit(0 if ok else 1)" \ + || fail "agent-mixed labels missing scenario or numeric monlet_agent_version" + +log "asserting agent-mixed has 4 distinct check statuses (ok/warning/critical/unknown)" +api "/api/v1/checks?limit=500" | python3 -c " +import json,sys +d=json.load(sys.stdin) +want={'ok','warning','critical','unknown'} +have={c['status'] for c in d['items'] if c['agent_id']=='agent-mixed'} +missing=want-have +print('agent-mixed statuses:', sorted(have)) +sys.exit(0 if not missing else 1)" \ + || fail "agent-mixed missing one of ok/warning/critical/unknown" + +# --- Phase B: trigger resolve scenario --- +log "triggering resolve: touch /state/resolve.flag in agent-resolve" +"${COMPOSE[@]}" exec -T agent-resolve sh -c 'touch /state/resolve.flag' \ + || fail "could not touch resolve flag" + +log "waiting for resolve event to be processed (~15s)" +sleep 15 + +log "asserting resolved incident exists for agent-resolve/flapping" +api "/api/v1/incidents?state=resolved&limit=500" \ + | python3 -c " +import json,sys +d=json.load(sys.stdin) +ok=any(i['agent_id']=='agent-resolve' and i['check_id']=='flapping' for i in d['items']) +sys.exit(0 if ok else 1)" \ + || fail "no resolved incident for agent-resolve/flapping" + +# --- Phase C: disabled server notifications --- +log "asserting notifications-disabled incident exists but no outbox row for it" +PROM_INC="$(api "/api/v1/incidents?limit=500" | python3 -c " +import json,sys +d=json.load(sys.stdin) +for i in d['items']: + if i['agent_id']=='agent-prometheus-owned' and i['check_id']=='promcheck': + print(i['id']); break")" +[[ -n "$PROM_INC" ]] || fail "no incident for prometheus-owned agent" +api "/api/v1/notifiers/outbox?limit=500" | grep -q "\"incident_id\":\"$PROM_INC\"" \ + && fail "outbox unexpectedly contains row for prometheus-owned incident $PROM_INC" +log "no outbox row for notifications-disabled incident" + +# --- Phase D: stop stale/dead agents to drive lifecycle transitions --- +log "stopping agent-stale and agent-dead to age their heartbeats" +"${COMPOSE[@]}" stop agent-stale agent-dead >/dev/null + +log "waiting ~25s for stale transition" +sleep 25 +api "/api/v1/agents/agent-stale" | grep -q '"status":"stale"' \ + || api "/api/v1/agents/agent-stale" | grep -q '"status":"dead"' \ + || fail "agent-stale did not transition to stale/dead" + +log "waiting another ~25s for dead transition" +sleep 25 +api "/api/v1/agents/agent-stale" | grep -q '"status":"dead"' \ + || fail "agent-stale did not transition to dead" +api "/api/v1/agents/agent-dead" | grep -q '"status":"dead"' \ + || fail "agent-dead did not transition to dead" + +# At least one agent must still be alive (agent-mixed keeps pushing). +api "/api/v1/agents/agent-mixed" | grep -q '"status":"alive"' \ + || fail "agent-mixed not alive" + +# --- Phase E: outbox state coverage (live via webhook + debug) --- +log "waiting for notifier worker cycles (~10s)" +sleep 10 + +OUTBOX="$(api "/api/v1/notifiers/outbox?limit=500")" +echo "$OUTBOX" | python3 -c " +import json,sys +d=json.load(sys.stdin) +states={i['state'] for i in d['items']} +need={'sent','retry','failed'} +missing=need-states +print('outbox states:', sorted(states)) +sys.exit(0 if not missing else 2)" \ + || fail "outbox missing one of sent/retry/failed" + +# --- Phase F: seed discarded row, wait for worker --- +log "seeding 'discarded' outbox row via SQL" +"${COMPOSE[@]}" exec -T postgres \ + psql -U monlet -d monlet -v ON_ERROR_STOP=1 -q \ + < "$HERE/showcase/seed_discarded.sql" >/dev/null \ + || fail "seed_discarded.sql failed" + +log "waiting for worker to mark unknown-notifier row as discarded (~10s)" +sleep 10 +api "/api/v1/notifiers/outbox?state=discarded&limit=10" \ + | grep -q '"state":"discarded"' \ + || fail "no outbox row in state=discarded" + +# --- Phase G: spool replay assertion --- +log "asserting agent-spool-replay events arrived without duplicates" +api "/api/v1/events/query?agent_id=agent-spool-replay&check_id=replay_check&limit=500" \ + | python3 -c " +import json,sys +d=json.load(sys.stdin) +items=d['items'] +ids=[e['event_id'] for e in items] +if len(items) < 2: + print(f'too few events: {len(items)}'); sys.exit(1) +if len(set(ids)) != len(ids): + print('duplicate event_ids'); sys.exit(2) +print(f'spool replay events: {len(items)} unique')" \ + || fail "spool replay assertion failed" + +# --- Phase H: metrics --- +log "server /metrics contains monlet_server_*" +curl -sf "$BASE/metrics" | grep -q '^monlet_server_' \ + || fail "server metrics missing monlet_server_*" + +log "agent-mixed /metrics contains monlet_agent_check_status" +curl -sf "$AGENT_METRICS" | grep -q 'monlet_agent_check_status' \ + || fail "agent metrics missing monlet_agent_check_status" + +# --- Phase I: web UI --- +log "web pages render with expected content" +curl -sf "$WEB_BASE/" | grep -q "Overview" || fail "web / missing Overview" +curl -sf "$WEB_BASE/agents" | grep -q "agent-mixed" || fail "web /agents missing agent-mixed" +curl -sf "$WEB_BASE/checks" | grep -q "critical_check" || fail "web /checks missing critical_check" +curl -sf "$WEB_BASE/incidents" | grep -q "agent-resolve" || fail "web /incidents missing agent-resolve" +curl -sf "$WEB_BASE/outbox" | grep -q -E 'webhook|debug' || fail "web /outbox missing notifier names" +curl -sf -o /dev/null "$WEB_BASE/events" || fail "web /events returned non-200" + +log "ALL SHOWCASE ASSERTIONS PASSED" diff --git a/deploy/grafana/dashboards/monlet-overview.json b/deploy/grafana/dashboards/monlet-overview.json new file mode 100644 index 0000000..a79efa2 --- /dev/null +++ b/deploy/grafana/dashboards/monlet-overview.json @@ -0,0 +1,42 @@ +{ + "title": "Monlet Overview", + "uid": "monlet-overview", + "schemaVersion": 39, + "version": 1, + "refresh": "30s", + "time": { "from": "now-1h", "to": "now" }, + "panels": [ + { + "id": 1, + "type": "stat", + "title": "Agents by status", + "targets": [{ "expr": "monlet_server_agents", "legendFormat": "{{status}}" }], + "gridPos": { "h": 6, "w": 8, "x": 0, "y": 0 } + }, + { + "id": 2, + "type": "stat", + "title": "Open incidents", + "targets": [{ "expr": "monlet_server_open_incidents" }], + "gridPos": { "h": 6, "w": 8, "x": 8, "y": 0 } + }, + { + "id": 3, + "type": "timeseries", + "title": "Events/sec by result", + "targets": [ + { "expr": "rate(monlet_server_events_total[1m])", "legendFormat": "{{result}}" } + ], + "gridPos": { "h": 8, "w": 16, "x": 0, "y": 6 } + }, + { + "id": 4, + "type": "timeseries", + "title": "Notifications/sec by notifier+result", + "targets": [ + { "expr": "rate(monlet_server_notifications_total[1m])", "legendFormat": "{{notifier}} {{result}}" } + ], + "gridPos": { "h": 8, "w": 16, "x": 0, "y": 14 } + } + ] +} diff --git a/deploy/grafana/provisioning/dashboards/monlet.yml b/deploy/grafana/provisioning/dashboards/monlet.yml new file mode 100644 index 0000000..12be2cb --- /dev/null +++ b/deploy/grafana/provisioning/dashboards/monlet.yml @@ -0,0 +1,7 @@ +apiVersion: 1 +providers: + - name: monlet + folder: "" + type: file + options: + path: /var/lib/grafana/dashboards diff --git a/deploy/grafana/provisioning/datasources/prometheus.yml b/deploy/grafana/provisioning/datasources/prometheus.yml new file mode 100644 index 0000000..0eddf26 --- /dev/null +++ b/deploy/grafana/provisioning/datasources/prometheus.yml @@ -0,0 +1,7 @@ +apiVersion: 1 +datasources: + - name: Prometheus + type: prometheus + access: proxy + url: http://prometheus:9090 + isDefault: true diff --git a/deploy/prometheus/README.md b/deploy/prometheus/README.md index 75d1612..b29e014 100644 --- a/deploy/prometheus/README.md +++ b/deploy/prometheus/README.md @@ -6,4 +6,4 @@ Planned examples: - scrape Monlet agent `/metrics`; - scrape Monlet server `/metrics`; -- sample rules that avoid duplicate alerting when `notification_owner = server`. +- sample rules for checks that use `notifications_enabled = false`. diff --git a/deploy/prometheus/prometheus.yml b/deploy/prometheus/prometheus.yml new file mode 100644 index 0000000..46ed9f9 --- /dev/null +++ b/deploy/prometheus/prometheus.yml @@ -0,0 +1,14 @@ +global: + scrape_interval: 15s + evaluation_interval: 15s + +scrape_configs: + - job_name: monlet-server + metrics_path: /metrics + static_configs: + - targets: ["server:8000"] + + - job_name: monlet-agent + metrics_path: /metrics + static_configs: + - targets: ["host.docker.internal:9100"] diff --git a/deploy/showcase/agents/dead.toml b/deploy/showcase/agents/dead.toml new file mode 100644 index 0000000..9aa2a7f --- /dev/null +++ b/deploy/showcase/agents/dead.toml @@ -0,0 +1,24 @@ +hostname = "agent-dead" +state_dir = "/var/lib/monlet-agent" + +[labels] +scenario = "dead" +role = "showcase" + +[server] +enabled = true +url = "http://server:8000" +token = "monlet-dev-token" +heartbeat_interval = "5s" +batch_interval = "3s" + +[metrics] +enabled = false +listen = "127.0.0.1:9465" + +[[checks]] +id = "ok_check" +name = "Dead agent placeholder check" +command = "/opt/monlet/checks/exit_0.sh" +interval = "10s" +timeout = "3s" diff --git a/deploy/showcase/agents/mixed.toml b/deploy/showcase/agents/mixed.toml new file mode 100644 index 0000000..3a12b34 --- /dev/null +++ b/deploy/showcase/agents/mixed.toml @@ -0,0 +1,46 @@ +hostname = "agent-mixed" +state_dir = "/var/lib/monlet-agent" + +[labels] +scenario = "mixed-status" +role = "showcase" + +[server] +enabled = true +url = "http://server:8000" +token = "monlet-dev-token" +heartbeat_interval = "5s" +batch_interval = "3s" + +[metrics] +enabled = true +listen = "0.0.0.0:9465" + +[[checks]] +id = "ok_check" +name = "Showcase ok" +command = "/opt/monlet/checks/exit_0.sh" +interval = "5s" +timeout = "3s" + +[[checks]] +id = "warning_check" +name = "Showcase warning" +command = "/opt/monlet/checks/exit_1.sh" +interval = "5s" +timeout = "3s" + +[[checks]] +id = "critical_check" +name = "Showcase critical (permanent webhook fail)" +command = "/opt/monlet/checks/exit_2.sh" +interval = "5s" +timeout = "3s" +dedupe_key = "agent-mixed:critical:fail" + +[[checks]] +id = "unknown_check" +name = "Showcase unknown" +command = "/opt/monlet/checks/exit_3.sh" +interval = "5s" +timeout = "3s" diff --git a/deploy/showcase/agents/prometheus_owned.toml b/deploy/showcase/agents/prometheus_owned.toml new file mode 100644 index 0000000..c89f61f --- /dev/null +++ b/deploy/showcase/agents/prometheus_owned.toml @@ -0,0 +1,25 @@ +hostname = "agent-prometheus-owned" +state_dir = "/var/lib/monlet-agent" + +[labels] +scenario = "prometheus-owned" +role = "showcase" + +[server] +enabled = true +url = "http://server:8000" +token = "monlet-dev-token" +heartbeat_interval = "5s" +batch_interval = "3s" + +[metrics] +enabled = false +listen = "127.0.0.1:9465" + +[[checks]] +id = "promcheck" +name = "Prometheus-owned critical (no outbox expected)" +command = "/opt/monlet/checks/exit_2.sh" +interval = "5s" +timeout = "3s" +notifications_enabled = false diff --git a/deploy/showcase/agents/resolve.toml b/deploy/showcase/agents/resolve.toml new file mode 100644 index 0000000..7624548 --- /dev/null +++ b/deploy/showcase/agents/resolve.toml @@ -0,0 +1,25 @@ +hostname = "agent-resolve" +state_dir = "/var/lib/monlet-agent" + +[labels] +scenario = "resolve" +role = "showcase" + +[server] +enabled = true +url = "http://server:8000" +token = "monlet-dev-token" +heartbeat_interval = "5s" +batch_interval = "3s" + +[metrics] +enabled = false +listen = "127.0.0.1:9465" + +[[checks]] +id = "flapping" +name = "Flapping check (critical then ok)" +command = "/opt/monlet/checks/flapping.sh" +interval = "5s" +timeout = "3s" +dedupe_key = "agent-resolve:flapping:retry" diff --git a/deploy/showcase/agents/spool_replay.toml b/deploy/showcase/agents/spool_replay.toml new file mode 100644 index 0000000..9bb326a --- /dev/null +++ b/deploy/showcase/agents/spool_replay.toml @@ -0,0 +1,24 @@ +hostname = "agent-spool-replay" +state_dir = "/var/lib/monlet-agent" + +[labels] +scenario = "spool-replay" +role = "showcase" + +[server] +enabled = true +url = "http://server:8000" +token = "monlet-dev-token" +heartbeat_interval = "5s" +batch_interval = "3s" + +[metrics] +enabled = false +listen = "127.0.0.1:9465" + +[[checks]] +id = "replay_check" +name = "Spool replay check" +command = "/opt/monlet/checks/replay_check.sh" +interval = "3s" +timeout = "2s" diff --git a/deploy/showcase/agents/stale.toml b/deploy/showcase/agents/stale.toml new file mode 100644 index 0000000..07f24cc --- /dev/null +++ b/deploy/showcase/agents/stale.toml @@ -0,0 +1,24 @@ +hostname = "agent-stale" +state_dir = "/var/lib/monlet-agent" + +[labels] +scenario = "stale" +role = "showcase" + +[server] +enabled = true +url = "http://server:8000" +token = "monlet-dev-token" +heartbeat_interval = "5s" +batch_interval = "3s" + +[metrics] +enabled = false +listen = "127.0.0.1:9465" + +[[checks]] +id = "ok_check" +name = "Stale agent placeholder check" +command = "/opt/monlet/checks/exit_0.sh" +interval = "10s" +timeout = "3s" diff --git a/deploy/showcase/checks/exit_0.sh b/deploy/showcase/checks/exit_0.sh new file mode 100755 index 0000000..83f64f4 --- /dev/null +++ b/deploy/showcase/checks/exit_0.sh @@ -0,0 +1,3 @@ +#!/bin/sh +echo "ok: $(date -u +%FT%TZ)" +exit 0 diff --git a/deploy/showcase/checks/exit_1.sh b/deploy/showcase/checks/exit_1.sh new file mode 100755 index 0000000..680ab7f --- /dev/null +++ b/deploy/showcase/checks/exit_1.sh @@ -0,0 +1,3 @@ +#!/bin/sh +echo "warning: degraded" +exit 1 diff --git a/deploy/showcase/checks/exit_2.sh b/deploy/showcase/checks/exit_2.sh new file mode 100755 index 0000000..332405e --- /dev/null +++ b/deploy/showcase/checks/exit_2.sh @@ -0,0 +1,3 @@ +#!/bin/sh +echo "critical: probe failed" +exit 2 diff --git a/deploy/showcase/checks/exit_3.sh b/deploy/showcase/checks/exit_3.sh new file mode 100755 index 0000000..fdfa011 --- /dev/null +++ b/deploy/showcase/checks/exit_3.sh @@ -0,0 +1,3 @@ +#!/bin/sh +echo "unknown: probe returned unexpected exit code" +exit 3 diff --git a/deploy/showcase/checks/flapping.sh b/deploy/showcase/checks/flapping.sh new file mode 100755 index 0000000..8e0f64e --- /dev/null +++ b/deploy/showcase/checks/flapping.sh @@ -0,0 +1,8 @@ +#!/bin/sh +# critical until /state/resolve.flag exists, then ok. +if [ -f /state/resolve.flag ]; then + echo "ok: resolve flag present" + exit 0 +fi +echo "critical: waiting for resolve flag" +exit 2 diff --git a/deploy/showcase/checks/replay_check.sh b/deploy/showcase/checks/replay_check.sh new file mode 100755 index 0000000..9d254df --- /dev/null +++ b/deploy/showcase/checks/replay_check.sh @@ -0,0 +1,3 @@ +#!/bin/sh +echo "ok: replay tick" +exit 0 diff --git a/deploy/showcase/mock_webhook.py b/deploy/showcase/mock_webhook.py new file mode 100755 index 0000000..50ce6f1 --- /dev/null +++ b/deploy/showcase/mock_webhook.py @@ -0,0 +1,69 @@ +#!/usr/bin/env python3 +"""Mock webhook for showcase/e2e. + +Decides response status by substring in incident.incident_key from request body: +- contains 'retry' -> 503 (retryable failure) +- contains 'fail' -> 400 (permanent failure) +- otherwise -> 200 (success) +""" + +from __future__ import annotations + +import json +import sys +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + + +def _classify(body: bytes) -> int: + try: + data = json.loads(body or b"{}") + except Exception: + return 200 + incident = data.get("incident") or {} + key = str(incident.get("incident_key") or "") + if "retry" in key: + return 503 + if "fail" in key: + return 400 + return 200 + + +class Handler(BaseHTTPRequestHandler): + def do_POST(self) -> None: # noqa: N802 + length = int(self.headers.get("Content-Length") or 0) + body = self.rfile.read(length) if length else b"" + code = _classify(body) + self.send_response(code) + self.send_header("Content-Type", "text/plain") + self.send_header("Content-Length", "0") + self.end_headers() + try: + data = json.loads(body or b"{}") + inc = (data.get("incident") or {}) + print( + f"webhook {code} event_type={data.get('event_type')!r} " + f"incident_key={inc.get('incident_key')!r}", + flush=True, + ) + except Exception: + print(f"webhook {code} (unparsable body, {length}B)", flush=True) + + def log_message(self, *_args: object) -> None: + return + + +def main() -> int: + addr = ("0.0.0.0", 8080) + srv = ThreadingHTTPServer(addr, Handler) + print(f"mock_webhook listening on {addr[0]}:{addr[1]}", flush=True) + try: + srv.serve_forever() + except KeyboardInterrupt: + pass + finally: + srv.server_close() + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/deploy/showcase/seed_discarded.sql b/deploy/showcase/seed_discarded.sql new file mode 100644 index 0000000..ad1bb4b --- /dev/null +++ b/deploy/showcase/seed_discarded.sql @@ -0,0 +1,16 @@ +-- Seed one outbox row with an unknown notifier name so the worker marks it 'discarded'. +-- Attached to any existing incident; if none exists yet the script is a no-op. +INSERT INTO notification_outbox + (id, incident_id, notifier, event_type, state, attempts, next_attempt_at, payload) +SELECT + gen_random_uuid(), + i.id, + 'showcase-unknown', + 'firing', + 'pending', + 0, + now(), + '{"showcase":"discarded"}'::jsonb +FROM incidents i +ORDER BY i.opened_at DESC +LIMIT 1; diff --git a/deploy/smoke.sh b/deploy/smoke.sh new file mode 100755 index 0000000..eba8279 --- /dev/null +++ b/deploy/smoke.sh @@ -0,0 +1,80 @@ +#!/usr/bin/env bash +# Local stack smoke test for Stage 6. +# Brings the stack up, hits /health /ready /metrics, ingests sample heartbeat + events, +# verifies the API exposes agent / check / incident state. +set -euo pipefail + +HERE="$(cd "$(dirname "$0")" && pwd)" +ROOT="$(cd "$HERE/.." && pwd)" +TOKEN="${MONLET_AUTH_TOKEN:-monlet-dev-token}" +BASE="${MONLET_API_BASE_URL:-http://127.0.0.1:8000}" +COMPOSE=(docker compose -f "$HERE/docker-compose.yml") + +log() { printf "\033[1;36m[smoke]\033[0m %s\n" "$*"; } +fail() { printf "\033[1;31m[smoke]\033[0m %s\n" "$*" >&2; exit 1; } + +cleanup() { + if [[ "${SMOKE_KEEP:-0}" != "1" ]]; then + log "tearing down stack" + "${COMPOSE[@]}" down -v --remove-orphans >/dev/null 2>&1 || true + fi +} +trap cleanup EXIT + +log "starting stack" +MONLET_AUTH_TOKEN="$TOKEN" "${COMPOSE[@]}" up -d --build postgres server web + +log "waiting for /api/v1/ready" +for i in $(seq 1 60); do + if curl -sf "$BASE/api/v1/ready" >/dev/null; then break; fi + sleep 2 + [[ "$i" == "60" ]] && fail "server did not become ready" +done + +log "GET /api/v1/health" +curl -sf "$BASE/api/v1/health" | grep -q '"status":"ok"' || fail "health wrong body" + +log "GET /metrics" +curl -sf "$BASE/metrics" | grep -q '^monlet_server_' || fail "metrics missing" + +AUTH=(-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json") + +log "POST heartbeat (examples/heartbeat.json)" +curl -sf "${AUTH[@]}" -X POST "$BASE/api/v1/heartbeat" --data @"$ROOT/examples/heartbeat.json" \ + | grep -q '"accepted":true' || fail "heartbeat not accepted" + +log "POST events (examples/event-batch.json)" +curl -sf "${AUTH[@]}" -X POST "$BASE/api/v1/events" --data @"$ROOT/examples/event-batch.json" \ + | grep -q '"accepted":' || fail "event batch not accepted" + +log "GET /agents has host-01.prod" +curl -sf "${AUTH[@]}" "$BASE/api/v1/agents" | grep -q '"agent_id":"host-01.prod"' \ + || fail "host-01.prod missing" + +log "GET /checks not empty" +curl -sf "${AUTH[@]}" "$BASE/api/v1/checks" | grep -q '"items":\[{' \ + || fail "checks empty" + +log "GET /incidents not empty (event-batch.json contains a critical event)" +curl -sf "${AUTH[@]}" "$BASE/api/v1/incidents?state=open" | grep -q '"items":\[{' \ + || fail "no open incident" + +log "GET /notifiers/outbox not empty" +curl -sf "${AUTH[@]}" "$BASE/api/v1/notifiers/outbox" | grep -q '"items":\[{' \ + || fail "outbox empty" + +WEB_BASE="${MONLET_WEB_BASE_URL:-http://127.0.0.1:3000}" +log "waiting for web $WEB_BASE" +for i in $(seq 1 60); do + if curl -sf -o /dev/null "$WEB_BASE/"; then break; fi + sleep 2 + [[ "$i" == "60" ]] && fail "web did not become ready" +done + +log "GET / (web overview)" +curl -sf "$WEB_BASE/" | grep -q "Overview" || fail "web overview missing 'Overview'" + +log "GET /agents (web list)" +curl -sf "$WEB_BASE/agents" | grep -q "host-01.prod" || fail "web /agents missing host-01.prod" + +log "ALL SMOKE TESTS PASSED" diff --git a/docs/ops/backup-restore.md b/docs/ops/backup-restore.md new file mode 100644 index 0000000..64826a2 --- /dev/null +++ b/docs/ops/backup-restore.md @@ -0,0 +1,49 @@ +# Backup and restore + +All persistent state lives in PostgreSQL (`agents`, `checks`, `events`, `incidents`, `notification_outbox`). The server is stateless; the agent local spool is best-effort and not part of the canonical state (ADR-0006). + +## What to back up + +- The Monlet PostgreSQL database (logical or physical). +- The server `MONLET_AUTH_TOKEN` and `.env` (kept in your secret store, not in backups of the DB). +- The agent `config.toml` per host (kept in your config management). + +## Logical backup (recommended for small deployments) + +```sh +PGPASSWORD=monlet pg_dump \ + -h $PGHOST -U monlet -d monlet \ + --format=custom --no-owner --no-privileges \ + --file=monlet-$(date -u +%Y%m%dT%H%M%SZ).dump +``` + +Restore into an empty database: + +```sh +createdb -h $PGHOST -U postgres monlet +PGPASSWORD=monlet pg_restore \ + -h $PGHOST -U monlet -d monlet \ + --no-owner --no-privileges \ + monlet-YYYYMMDDTHHMMSSZ.dump +``` + +The schema is owned by Alembic. Restoring a logical dump from the same Monlet version is safe. After restore, run `alembic upgrade head` only if you restored from an older version. + +## Retention + +- `events` grows unbounded in v1. Decide a retention policy that matches your storage budget (typical: 7–30 days). Stage 6+ may add server-side trimming; until then, run a manual cron: + + ```sql + DELETE FROM events WHERE observed_at < now() - interval '14 days'; + ``` + + `checks.last_event_id` is not a FK, so trimming `events` does not break current state. + +- `incidents` and `notification_outbox` are small. Trim outbox `state IN ('sent','failed','discarded')` rows older than 7 days if storage is tight. + +## Disaster recovery + +- Restore the database. +- Restart the server. Agents will resume sending; spooled events on each agent are replayed and de-duplicated by `event_id` (ADR-0006). +- The agent spool is bounded (10 000 events / 50 MiB FIFO, ADR-0005). During a long outage the oldest events get dropped to make room for new ones, so observations from the start of the outage are lost first. Plan recovery time accordingly — for an agent producing ~1 event/s per check, ~10 000 events is roughly 2–3 hours of buffering for a handful of checks. +- If agents were also lost, only events that were not yet persisted to the spool file are lost in addition. diff --git a/docs/ops/deployment-checklist.md b/docs/ops/deployment-checklist.md new file mode 100644 index 0000000..aac7732 --- /dev/null +++ b/docs/ops/deployment-checklist.md @@ -0,0 +1,57 @@ +# First deployment checklist + +A pragmatic list for the first production-ish Monlet stand-up. Adjust per environment. + +## 1. Secrets + +- [ ] Generate `MONLET_AUTH_TOKEN` (`openssl rand -hex 32`) and store it in your secret manager. +- [ ] Generate a PostgreSQL password and store it in your secret manager. +- [ ] Decide which notifier channels you want and gather their credentials (Telegram bot token + chat id, webhook URL, Alertmanager URL). + +## 2. Database + +- [ ] Provision PostgreSQL 16+. +- [ ] Create `monlet` database and role with `CREATE`/`USAGE` on the schema. +- [ ] Set `MONLET_DATABASE_URL=postgresql+asyncpg://...`. +- [ ] Run `alembic upgrade head` from a one-shot container or the server image. +- [ ] Verify `\dt` shows `agents, checks, events, incidents, notification_outbox`. + +## 3. Server + +- [ ] Set env vars per `server/.env.example` (see also `server/README.md`). +- [ ] Start container; verify `GET /api/v1/health` and `/api/v1/ready` return 200. +- [ ] Verify `/metrics` exposes `monlet_server_*`. +- [ ] Confirm an authenticated `GET /api/v1/agents` returns `{ items: [], next_cursor: null }`. +- [ ] Confirm logs show no unredacted token/cookie material. + +## 4. Agent (per host) + +- [ ] Install binary (see `agent/systemd/`). +- [ ] Render `config.toml` from `agent/config.example.toml` with hostname or explicit `agent_id`, and `[server].token`. +- [ ] Start under systemd; verify a heartbeat reaches the server (`GET /api/v1/agents`). +- [ ] Run one configured check and verify a row appears in `/api/v1/checks` and an event in `/api/v1/events/query`. + +## 5. Notifications + +- [ ] Enable at least one notifier in server env (`MONLET_NOTIFIER_*`). +- [ ] Force a `critical` event from one agent and confirm an outbox row reaches `sent` (`GET /api/v1/notifiers/outbox`). +- [ ] Resolve the check and confirm a `resolved` outbox row is emitted. + +## 6. Web UI + +- [ ] Set `MONLET_API_BASE_URL` and `MONLET_API_TOKEN` for the web container. +- [ ] Browse `/`, `/agents`, `/checks`, `/incidents`, `/events`, `/outbox`. +- [ ] Verify the web image does not log the token (`docker logs` greps clean). + +## 7. Observability (optional) + +- [ ] Add Monlet server to your Prometheus scrape config (`deploy/prometheus/prometheus.yml`). +- [ ] Import `deploy/grafana/dashboards/monlet-overview.json` for the starter dashboard. +- [ ] If using Alertmanager as a notifier, point `MONLET_NOTIFIER_ALERTMANAGER_URL` at it. + +## 8. Operational + +- [ ] Schedule database backups (`docs/ops/backup-restore.md`). +- [ ] Document token rotation owner and cadence (`docs/ops/token-rotation.md`). +- [ ] Add `monlet_server_open_incidents` and `monlet_server_agents{status="dead"}` to your dashboards. +- [ ] Decide an `events` retention policy. diff --git a/docs/ops/token-rotation.md b/docs/ops/token-rotation.md new file mode 100644 index 0000000..777ed55 --- /dev/null +++ b/docs/ops/token-rotation.md @@ -0,0 +1,45 @@ +# Token rotation + +Monlet v1 uses a single shared Bearer token (`MONLET_AUTH_TOKEN`) for all agent and UI endpoints (ADR-0007). Per-agent tokens and dual-token acceptance are out of scope for v1. + +## Why rotation is disruptive in v1 + +The server validates exactly one token at a time. Agents that present the old token after the server has switched receive `401`, which the agent treats as a **non-retryable** failure and drops the affected batch from its local spool (`agent/internal/client/client.go`, `agent/internal/app/app.go`). Events flushed during the mismatch window are lost — they are not re-spooled and not re-sent. + +Plan rotations during a low-traffic window and either stop the agents first (so spooled events persist on disk until they restart with the new token) or accept the event-loss window. + +## When to rotate + +- A suspected leak (token committed, log exposure, lost laptop with `config.toml`). +- Operator turnover. +- Routine schedule (e.g., quarterly). + +## Procedure + +1. **Generate a new token**: + + ```sh + openssl rand -hex 32 + ``` + +2. **Stop or pause agents** (recommended). Either: + - `systemctl stop monlet-agent` on each host; spooled events remain on disk and replay after restart, or + - skip this step and accept that any events sent during steps 3–4 are lost on the `401`. + +3. **Restart server** with the new `MONLET_AUTH_TOKEN`. Verify: + + ```sh + curl -sf -H "Authorization: Bearer $NEW" http://server:8000/api/v1/agents | jq . + ``` + +4. **Update agent `config.toml`** (`[server].token`) on each host and start agents. + +5. **Update Web UI** (`MONLET_API_TOKEN`) and restart. + +6. **Revoke the old token**: remove the old value from secret stores, CI variables, and operator notes. + +## Notes + +- The token is the only auth in v1; protect it like a database password. +- The token never appears in metrics labels, incident keys, or notification labels (ADR-0007, redaction policy). +- The server redacts `Authorization`/`Cookie`-style headers before logging (`monlet_server/redaction.py`). diff --git a/examples/event-batch.json b/examples/event-batch.json index 16d4352..1f6fd98 100644 --- a/examples/event-batch.json +++ b/examples/event-batch.json @@ -10,7 +10,6 @@ "duration_ms": 42, "output": "/ 18% used (180G/1.0T)", "output_truncated": false, - "notification_owner": "server", "incident_key": "host-01.prod:disk_root" }, { @@ -22,7 +21,6 @@ "duration_ms": 1503, "output": "could not connect to server: Connection refused", "output_truncated": false, - "notification_owner": "server", "incident_key": "host-01.prod:postgres_up" } ] diff --git a/examples/heartbeat.json b/examples/heartbeat.json index 3f3d0bd..2e580e5 100644 --- a/examples/heartbeat.json +++ b/examples/heartbeat.json @@ -2,9 +2,12 @@ "agent_id": "host-01.prod", "observed_at": "2026-05-26T12:00:00Z", "hostname": "host-01.prod.example.com", - "version": "0.1.0", - "mode": "push_only", + "features": { + "push": true, + "metrics": false + }, "labels": { + "monlet_agent_version": "0.1.0", "env": "prod", "region": "eu-central-1", "role": "api" diff --git a/server/.env.example b/server/.env.example index d847af7..477edab 100644 --- a/server/.env.example +++ b/server/.env.example @@ -1,6 +1,27 @@ -MONLET_ENV=local -MONLET_LOG_LEVEL=info -MONLET_DATABASE_URL=postgresql+psycopg://monlet:monlet@localhost:5432/monlet -MONLET_AGENT_TOKEN_BOOTSTRAP=replace-me -MONLET_MAX_REQUEST_BYTES=1048576 -MONLET_MAX_CHECK_OUTPUT_BYTES=8192 +MONLET_AUTH_TOKEN=replace-me-with-random-token +MONLET_DATABASE_URL=postgresql+asyncpg://monlet:monlet@localhost:5432/monlet +MONLET_LOG_LEVEL=INFO +MONLET_BODY_LIMIT_BYTES=1048576 +MONLET_OUTPUT_MAX_BYTES=8192 +MONLET_STALE_AFTER_SEC=90 +MONLET_DEAD_AFTER_SEC=300 +MONLET_DETECTOR_TICK_SEC=15 +MONLET_ENABLE_DETECTOR=true +MONLET_EVENTS_RETENTION_MAX_ROWS=100000 +MONLET_OUTBOX_RETENTION_MAX_ROWS=50000 + +MONLET_ENABLE_NOTIFIER_WORKER=true +MONLET_NOTIFIER_TICK_SEC=5 +MONLET_NOTIFIER_BATCH_SIZE=20 +MONLET_NOTIFIER_MAX_ATTEMPTS=8 +MONLET_NOTIFIER_LEASE_SEC=300 +MONLET_NOTIFIER_HTTP_TIMEOUT_SEC=10 +MONLET_NOTIFIER_DEBUG_ENABLED=true +MONLET_NOTIFIER_TELEGRAM_ENABLED=false +MONLET_NOTIFIER_TELEGRAM_TOKEN= +MONLET_NOTIFIER_TELEGRAM_CHAT_ID= +MONLET_NOTIFIER_WEBHOOK_ENABLED=false +MONLET_NOTIFIER_WEBHOOK_URL= +MONLET_NOTIFIER_WEBHOOK_TOKEN= +MONLET_NOTIFIER_ALERTMANAGER_ENABLED=false +MONLET_NOTIFIER_ALERTMANAGER_URL= diff --git a/server/Dockerfile b/server/Dockerfile new file mode 100644 index 0000000..c8e4c62 --- /dev/null +++ b/server/Dockerfile @@ -0,0 +1,27 @@ +FROM python:3.14-slim AS builder + +ENV UV_LINK_MODE=copy \ + UV_COMPILE_BYTECODE=1 \ + UV_PROJECT_ENVIRONMENT=/app/.venv + +RUN apt-get update && apt-get install -y --no-install-recommends curl ca-certificates && rm -rf /var/lib/apt/lists/* +COPY --from=ghcr.io/astral-sh/uv:0.5 /uv /uvx /usr/local/bin/ + +WORKDIR /app +COPY pyproject.toml uv.lock ./ +RUN --mount=type=cache,target=/root/.cache/uv \ + uv sync --frozen --no-dev --no-install-project +COPY monlet_server ./monlet_server +COPY alembic.ini ./ +COPY alembic ./alembic +RUN --mount=type=cache,target=/root/.cache/uv uv sync --frozen --no-dev + + +FROM python:3.14-slim +ENV PATH="/app/.venv/bin:$PATH" \ + PYTHONUNBUFFERED=1 +WORKDIR /app +COPY --from=builder /app /app + +EXPOSE 8000 +CMD ["uvicorn", "monlet_server.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/server/README.md b/server/README.md index f90978c..8bf4efb 100644 --- a/server/README.md +++ b/server/README.md @@ -1,28 +1,78 @@ # Monlet Server -Placeholder for the Python/FastAPI server. +FastAPI server: heartbeat/event ingestion, current check state, incident lifecycle, stale/dead detector, notification outbox, Prometheus metrics. -Planned stack: +## Stack -- Python 3.14; -- uv; -- FastAPI; -- Pydantic; -- SQLAlchemy 2.x style; -- Alembic; -- PostgreSQL; -- httpx for outbound notifier calls; -- structured logging; -- Prometheus metrics; -- Docker image. +- Python 3.14+ +- `uv` +- FastAPI, Pydantic v2 +- SQLAlchemy 2.x async + `asyncpg` +- Alembic (sync `psycopg`) +- `structlog`, `prometheus-client` +- PostgreSQL 16+ -No server core logic is implemented in Stage 0. - -## Planned Commands +## Local development ```sh -UV_PROJECT_ENVIRONMENT=.venv UV_CACHE_DIR=../.cache/uv uv sync +cd server +UV_PROJECT_ENVIRONMENT=.venv UV_CACHE_DIR=../.cache/uv uv sync --extra dev +``` + +Run a Postgres instance (Docker or local), set `MONLET_DATABASE_URL`, then: + +```sh +UV_PROJECT_ENVIRONMENT=.venv UV_CACHE_DIR=../.cache/uv uv run alembic upgrade head UV_PROJECT_ENVIRONMENT=.venv UV_CACHE_DIR=../.cache/uv uv run fastapi dev monlet_server/main.py ``` -Do not install Python packages globally on the host. Docker builds install server dependencies inside the image; local development uses `server/.venv` and repo-local `.cache/uv`. +## Environment + +| Var | Default | Purpose | +|---|---|---| +| `MONLET_AUTH_TOKEN` | required | Shared Bearer token (ADR-0007); `changeme` is rejected | +| `MONLET_DATABASE_URL` | `postgresql+asyncpg://monlet:monlet@127.0.0.1:5432/monlet` | Async DSN | +| `MONLET_LOG_LEVEL` | `INFO` | Log level | +| `MONLET_BODY_LIMIT_BYTES` | `1048576` | Request body limit (ADR-0005) | +| `MONLET_OUTPUT_MAX_BYTES` | `8192` | Per-event output limit | +| `MONLET_STALE_AFTER_SEC` | `90` | Stale threshold | +| `MONLET_DEAD_AFTER_SEC` | `300` | Dead threshold | +| `MONLET_DETECTOR_TICK_SEC` | `15` | Detector tick | +| `MONLET_ENABLE_DETECTOR` | `true` | Toggle detector task | +| `MONLET_EVENTS_RETENTION_MAX_ROWS` | `100000` | Maximum retained check-result events; `0` disables pruning | +| `MONLET_OUTBOX_RETENTION_MAX_ROWS` | `50000` | Maximum retained terminal outbox rows; `0` disables pruning | +| `MONLET_ENABLE_NOTIFIER_WORKER` | `true` | Toggle outbox worker | +| `MONLET_NOTIFIER_TICK_SEC` | `5` | Worker poll interval | +| `MONLET_NOTIFIER_BATCH_SIZE` | `20` | Rows claimed per tick | +| `MONLET_NOTIFIER_MAX_ATTEMPTS` | `8` | Terminal-failure threshold (ADR-0005) | +| `MONLET_NOTIFIER_LEASE_SEC` | `300` | Recovery lease for stuck `sending` rows | +| `MONLET_NOTIFIER_HTTP_TIMEOUT_SEC` | `10` | Outbound HTTP timeout | +| `MONLET_NOTIFIER_DEBUG_ENABLED` | `true` | Log-only notifier | +| `MONLET_NOTIFIER_TELEGRAM_ENABLED` | `false` | Telegram notifier on/off | +| `MONLET_NOTIFIER_TELEGRAM_TOKEN` | `` | Telegram bot token | +| `MONLET_NOTIFIER_TELEGRAM_CHAT_ID` | `` | Telegram chat id | +| `MONLET_NOTIFIER_WEBHOOK_ENABLED` | `false` | Generic webhook notifier on/off | +| `MONLET_NOTIFIER_WEBHOOK_URL` | `` | Webhook URL | +| `MONLET_NOTIFIER_WEBHOOK_TOKEN` | `` | Optional Bearer token for webhook | +| `MONLET_NOTIFIER_ALERTMANAGER_ENABLED` | `false` | Alertmanager notifier on/off | +| `MONLET_NOTIFIER_ALERTMANAGER_URL` | `` | Alertmanager base URL (e.g. `http://am:9093`) | + +## Tests + +Tests use `testcontainers-python` to spin up a real PostgreSQL container; Docker must be available. + +```sh +UV_PROJECT_ENVIRONMENT=.venv UV_CACHE_DIR=../.cache/uv uv run pytest -q +``` + +## Docker + +Image installs dependencies inside the container only; host venv is never mounted. + +```sh +docker build -t monlet-server . +``` + +## Endpoints + +All routes under `/api/v1` (per `api/openapi.yaml`). `/metrics` and `/api/v1/health`, `/api/v1/ready` are open; everything else requires `Authorization: Bearer `. diff --git a/server/alembic.ini b/server/alembic.ini new file mode 100644 index 0000000..a97945f --- /dev/null +++ b/server/alembic.ini @@ -0,0 +1,38 @@ +[alembic] +script_location = alembic +prepend_sys_path = . +sqlalchemy.url = + +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/server/alembic/env.py b/server/alembic/env.py new file mode 100644 index 0000000..551d8da --- /dev/null +++ b/server/alembic/env.py @@ -0,0 +1,48 @@ +import os +from logging.config import fileConfig + +from sqlalchemy import engine_from_config, pool + +from alembic import context +from monlet_server.models import Base + +config = context.config +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +target_metadata = Base.metadata + +database_url = os.environ.get( + "MONLET_DATABASE_URL", "postgresql+asyncpg://monlet:monlet@127.0.0.1:5432/monlet" +) +config.set_main_option("sqlalchemy.url", database_url.replace("+asyncpg", "+psycopg")) + + +def run_migrations_offline() -> None: + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + ) + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online() -> None: + connectable = engine_from_config( + config.get_section(config.config_ini_section, {}), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + with connectable.connect() as connection: + context.configure(connection=connection, target_metadata=target_metadata) + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/server/alembic/script.py.mako b/server/alembic/script.py.mako new file mode 100644 index 0000000..17dcba0 --- /dev/null +++ b/server/alembic/script.py.mako @@ -0,0 +1,25 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +revision: str = ${repr(up_revision)} +down_revision: Union[str, None] = ${repr(down_revision)} +branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} +depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} + + +def upgrade() -> None: + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + ${downgrades if downgrades else "pass"} diff --git a/server/alembic/versions/0001_baseline.py b/server/alembic/versions/0001_baseline.py new file mode 100644 index 0000000..d64f658 --- /dev/null +++ b/server/alembic/versions/0001_baseline.py @@ -0,0 +1,123 @@ +"""baseline schema (ADR-0004) + +Revision ID: 0001_baseline +Revises: +Create Date: 2026-05-26 + +""" + +from collections.abc import Sequence + +from alembic import op + +revision: str = "0001_baseline" +down_revision: str | None = None +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.execute( + """ + CREATE TABLE agents ( + agent_id TEXT PRIMARY KEY, + hostname TEXT NOT NULL, + status TEXT NOT NULL CHECK (status IN ('alive','stale','dead')) DEFAULT 'alive', + last_seen_at TIMESTAMPTZ NOT NULL, + features JSONB NOT NULL DEFAULT jsonb_build_object('push', false, 'metrics', false), + labels JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() + ); + """ + ) + op.execute("CREATE INDEX agents_status_idx ON agents (status);") + op.execute("CREATE INDEX agents_last_seen_idx ON agents (last_seen_at);") + + op.execute( + """ + CREATE TABLE checks ( + agent_id TEXT NOT NULL REFERENCES agents(agent_id) ON DELETE CASCADE, + check_id TEXT NOT NULL, + status TEXT NOT NULL CHECK (status IN ('ok','warning','critical','unknown')), + exit_code INTEGER NOT NULL, + last_observed_at TIMESTAMPTZ NOT NULL, + last_event_id UUID NOT NULL, + incident_key TEXT NOT NULL, + PRIMARY KEY (agent_id, check_id) + ); + """ + ) + op.execute("CREATE INDEX checks_status_idx ON checks (status);") + + op.execute( + """ + CREATE TABLE events ( + event_id UUID PRIMARY KEY, + agent_id TEXT NOT NULL, + check_id TEXT NOT NULL, + observed_at TIMESTAMPTZ NOT NULL, + received_at TIMESTAMPTZ NOT NULL DEFAULT now(), + status TEXT NOT NULL CHECK (status IN ('ok','warning','critical','unknown')), + exit_code INTEGER NOT NULL, + duration_ms INTEGER NOT NULL CHECK (duration_ms >= 0), + output TEXT, + output_truncated BOOLEAN NOT NULL DEFAULT FALSE, + notifications_enabled BOOLEAN NOT NULL DEFAULT TRUE, + incident_key TEXT NOT NULL + ); + """ + ) + op.execute( + "CREATE INDEX events_agent_check_observed_idx ON events (agent_id, check_id, observed_at DESC);" + ) + op.execute("CREATE INDEX events_observed_at_idx ON events (observed_at DESC);") + + op.execute( + """ + CREATE TABLE incidents ( + id UUID PRIMARY KEY, + incident_key TEXT NOT NULL, + agent_id TEXT NOT NULL, + check_id TEXT NOT NULL, + state TEXT NOT NULL CHECK (state IN ('open','resolved')), + severity TEXT NOT NULL CHECK (severity IN ('warning','critical','unknown')), + opened_at TIMESTAMPTZ NOT NULL, + resolved_at TIMESTAMPTZ, + summary TEXT, + last_event_id UUID NOT NULL + ); + """ + ) + op.execute( + "CREATE UNIQUE INDEX incidents_open_key_idx ON incidents (incident_key) WHERE state = 'open';" + ) + op.execute("CREATE INDEX incidents_state_opened_idx ON incidents (state, opened_at DESC);") + + op.execute( + """ + CREATE TABLE notification_outbox ( + id UUID PRIMARY KEY, + incident_id UUID NOT NULL REFERENCES incidents(id) ON DELETE CASCADE, + notifier TEXT NOT NULL, + event_type TEXT NOT NULL CHECK (event_type IN ('firing','resolved')), + state TEXT NOT NULL CHECK (state IN ('pending','sending','sent','retry','failed','discarded')), + attempts INTEGER NOT NULL DEFAULT 0, + next_attempt_at TIMESTAMPTZ, + last_error TEXT, + payload JSONB NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() + ); + """ + ) + op.execute( + "CREATE INDEX outbox_pending_idx ON notification_outbox (state, next_attempt_at) WHERE state IN ('pending','retry');" + ) + + +def downgrade() -> None: + op.execute("DROP TABLE IF EXISTS notification_outbox CASCADE;") + op.execute("DROP TABLE IF EXISTS incidents CASCADE;") + op.execute("DROP TABLE IF EXISTS events CASCADE;") + op.execute("DROP TABLE IF EXISTS checks CASCADE;") + op.execute("DROP TABLE IF EXISTS agents CASCADE;") diff --git a/server/monlet_server/__init__.py b/server/monlet_server/__init__.py new file mode 100644 index 0000000..dc26362 --- /dev/null +++ b/server/monlet_server/__init__.py @@ -0,0 +1 @@ +"""Monlet server package.""" diff --git a/server/monlet_server/api/__init__.py b/server/monlet_server/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/server/monlet_server/api/agents.py b/server/monlet_server/api/agents.py new file mode 100644 index 0000000..bd7641a --- /dev/null +++ b/server/monlet_server/api/agents.py @@ -0,0 +1,48 @@ +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from ..cursors import decode, encode +from ..db import get_session +from ..models import Agent as AgentModel +from ..schemas import Agent, AgentsPage + +router = APIRouter() + + +def _to_schema(a: AgentModel) -> Agent: + return Agent( + agent_id=a.agent_id, + hostname=a.hostname, + status=a.status, + last_seen_at=a.last_seen_at, + features=a.features or {"push": False, "metrics": False}, + labels=a.labels or {}, + ) + + +@router.get("/agents", response_model=AgentsPage) +async def list_agents( + cursor: str | None = Query(default=None, max_length=512), + limit: int = Query(default=100, ge=1, le=500), + session: AsyncSession = Depends(get_session), +) -> AgentsPage: + stmt = select(AgentModel).order_by(AgentModel.agent_id) + if cursor is not None: + (last_id,) = decode(cursor, 1) + stmt = stmt.where(AgentModel.agent_id > last_id) + rows = (await session.execute(stmt.limit(limit + 1))).scalars().all() + next_cursor = None + if len(rows) > limit: + rows = rows[:limit] + next_cursor = encode(rows[-1].agent_id) + return AgentsPage(items=[_to_schema(a) for a in rows], next_cursor=next_cursor) + + +@router.get("/agents/{agent_id}", response_model=Agent) +async def get_agent(agent_id: str, session: AsyncSession = Depends(get_session)) -> Agent: + res = await session.execute(select(AgentModel).where(AgentModel.agent_id == agent_id)) + a = res.scalar_one_or_none() + if a is None: + raise HTTPException(status_code=404, detail="agent not found") + return _to_schema(a) diff --git a/server/monlet_server/api/checks.py b/server/monlet_server/api/checks.py new file mode 100644 index 0000000..7e101f0 --- /dev/null +++ b/server/monlet_server/api/checks.py @@ -0,0 +1,47 @@ +from fastapi import APIRouter, Depends, Query +from sqlalchemy import and_, or_, select +from sqlalchemy.ext.asyncio import AsyncSession + +from ..cursors import decode, encode +from ..db import get_session +from ..models import Check +from ..schemas import ID_PATTERN, ChecksPage, CheckState + +router = APIRouter() + + +@router.get("/checks", response_model=ChecksPage) +async def list_checks( + agent_id: str | None = Query(default=None, max_length=128, pattern=ID_PATTERN), + cursor: str | None = Query(default=None, max_length=512), + limit: int = Query(default=100, ge=1, le=500), + session: AsyncSession = Depends(get_session), +) -> ChecksPage: + stmt = select(Check).order_by(Check.agent_id, Check.check_id) + if agent_id is not None: + stmt = stmt.where(Check.agent_id == agent_id) + if cursor is not None: + last_agent, last_check = decode(cursor, 2) + stmt = stmt.where( + or_( + Check.agent_id > last_agent, + and_(Check.agent_id == last_agent, Check.check_id > last_check), + ) + ) + rows = (await session.execute(stmt.limit(limit + 1))).scalars().all() + next_cursor = None + if len(rows) > limit: + rows = rows[:limit] + next_cursor = encode(rows[-1].agent_id, rows[-1].check_id) + items = [ + CheckState( + agent_id=r.agent_id, + check_id=r.check_id, + status=r.status, + last_observed_at=r.last_observed_at, + exit_code=r.exit_code, + incident_key=r.incident_key, + ) + for r in rows + ] + return ChecksPage(items=items, next_cursor=next_cursor) diff --git a/server/monlet_server/api/events.py b/server/monlet_server/api/events.py new file mode 100644 index 0000000..432cd26 --- /dev/null +++ b/server/monlet_server/api/events.py @@ -0,0 +1,102 @@ +import base64 +import binascii +from datetime import datetime + +from fastapi import APIRouter, Depends, HTTPException, Query, status +from sqlalchemy import and_, or_, select +from sqlalchemy.ext.asyncio import AsyncSession + +from ..db import get_session +from ..models import Event +from ..schemas import ( + ID_PATTERN, + EventBatchAcceptedResponse, + EventBatchRequest, + EventsPage, + StoredCheckResultEvent, +) +from ..services.ingestion import ingest_event + +router = APIRouter() + + +def _encode_cursor(received_at: datetime, event_id) -> str: + raw = f"{received_at.isoformat()}|{event_id}".encode() + return base64.urlsafe_b64encode(raw).decode().rstrip("=") + + +def _decode_cursor(cursor: str) -> tuple[datetime, str]: + pad = "=" * (-len(cursor) % 4) + try: + raw = base64.urlsafe_b64decode(cursor + pad).decode() + ts_str, eid = raw.split("|", 1) + return datetime.fromisoformat(ts_str), eid + except (binascii.Error, ValueError) as exc: + raise HTTPException(status_code=400, detail="invalid cursor") from exc + + +@router.post( + "/events", + response_model=EventBatchAcceptedResponse, + status_code=status.HTTP_202_ACCEPTED, +) +async def events( + body: EventBatchRequest, session: AsyncSession = Depends(get_session) +) -> EventBatchAcceptedResponse: + accepted = 0 + deduplicated = 0 + for ev in body.events: + ok = await ingest_event(session, body.agent_id, ev) + if ok: + accepted += 1 + else: + deduplicated += 1 + await session.commit() + return EventBatchAcceptedResponse(accepted=accepted, deduplicated=deduplicated) + + +@router.get("/events/query", response_model=EventsPage) +async def query_events( + agent_id: str | None = Query(default=None, max_length=128, pattern=ID_PATTERN), + check_id: str | None = Query(default=None, max_length=128, pattern=ID_PATTERN), + cursor: str | None = Query(default=None, max_length=512), + limit: int = Query(default=100, ge=1, le=500), + session: AsyncSession = Depends(get_session), +) -> EventsPage: + stmt = select(Event).order_by(Event.received_at.desc(), Event.event_id.desc()) + if agent_id is not None: + stmt = stmt.where(Event.agent_id == agent_id) + if check_id is not None: + stmt = stmt.where(Event.check_id == check_id) + if cursor is not None: + ts, eid = _decode_cursor(cursor) + stmt = stmt.where( + or_( + Event.received_at < ts, + and_(Event.received_at == ts, Event.event_id < eid), + ) + ) + stmt = stmt.limit(limit + 1) + rows = (await session.execute(stmt)).scalars().all() + next_cursor = None + if len(rows) > limit: + rows = rows[:limit] + next_cursor = _encode_cursor(rows[-1].received_at, rows[-1].event_id) + items = [ + StoredCheckResultEvent( + event_id=str(r.event_id), + agent_id=r.agent_id, + check_id=r.check_id, + observed_at=r.observed_at, + received_at=r.received_at, + status=r.status, + exit_code=r.exit_code, + duration_ms=r.duration_ms, + output=r.output, + output_truncated=r.output_truncated, + notifications_enabled=r.notifications_enabled, + incident_key=r.incident_key, + ) + for r in rows + ] + return EventsPage(items=items, next_cursor=next_cursor) diff --git a/server/monlet_server/api/health.py b/server/monlet_server/api/health.py new file mode 100644 index 0000000..2f757ae --- /dev/null +++ b/server/monlet_server/api/health.py @@ -0,0 +1,22 @@ +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy import text +from sqlalchemy.ext.asyncio import AsyncSession + +from ..db import get_session +from ..schemas import HealthResponse + +router = APIRouter() + + +@router.get("/health", response_model=HealthResponse) +async def health() -> HealthResponse: + return HealthResponse() + + +@router.get("/ready", response_model=HealthResponse) +async def ready(session: AsyncSession = Depends(get_session)) -> HealthResponse: + try: + await session.execute(text("SELECT 1")) + except Exception as exc: + raise HTTPException(status_code=503, detail="database not reachable") from exc + return HealthResponse() diff --git a/server/monlet_server/api/heartbeat.py b/server/monlet_server/api/heartbeat.py new file mode 100644 index 0000000..edfef28 --- /dev/null +++ b/server/monlet_server/api/heartbeat.py @@ -0,0 +1,21 @@ +from fastapi import APIRouter, Depends, status +from sqlalchemy.ext.asyncio import AsyncSession + +from ..db import get_session +from ..schemas import AcceptedResponse, HeartbeatRequest +from ..services.ingestion import upsert_agent_heartbeat + +router = APIRouter() + + +@router.post( + "/heartbeat", + response_model=AcceptedResponse, + status_code=status.HTTP_202_ACCEPTED, +) +async def heartbeat( + body: HeartbeatRequest, session: AsyncSession = Depends(get_session) +) -> AcceptedResponse: + await upsert_agent_heartbeat(session, body) + await session.commit() + return AcceptedResponse(accepted=True) diff --git a/server/monlet_server/api/incidents.py b/server/monlet_server/api/incidents.py new file mode 100644 index 0000000..a502a5a --- /dev/null +++ b/server/monlet_server/api/incidents.py @@ -0,0 +1,53 @@ +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy import and_, or_, select +from sqlalchemy.ext.asyncio import AsyncSession + +from ..cursors import decode, decode_datetime, encode +from ..db import get_session +from ..models import Incident as IncidentModel +from ..schemas import Incident, IncidentsPage + +router = APIRouter() + + +def _to_schema(i: IncidentModel) -> Incident: + return Incident( + id=str(i.id), + incident_key=i.incident_key, + state=i.state, + severity=i.severity, + agent_id=i.agent_id, + check_id=i.check_id, + opened_at=i.opened_at, + resolved_at=i.resolved_at, + summary=i.summary, + ) + + +@router.get("/incidents", response_model=IncidentsPage) +async def list_incidents( + state: str | None = Query(default=None), + cursor: str | None = Query(default=None, max_length=512), + limit: int = Query(default=100, ge=1, le=500), + session: AsyncSession = Depends(get_session), +) -> IncidentsPage: + if state is not None and state not in ("open", "resolved"): + raise HTTPException(status_code=400, detail="invalid state") + stmt = select(IncidentModel).order_by(IncidentModel.opened_at.desc(), IncidentModel.id.desc()) + if state is not None: + stmt = stmt.where(IncidentModel.state == state) + if cursor is not None: + ts_str, last_id = decode(cursor, 2) + ts = decode_datetime(ts_str) + stmt = stmt.where( + or_( + IncidentModel.opened_at < ts, + and_(IncidentModel.opened_at == ts, IncidentModel.id < last_id), + ) + ) + rows = (await session.execute(stmt.limit(limit + 1))).scalars().all() + next_cursor = None + if len(rows) > limit: + rows = rows[:limit] + next_cursor = encode(rows[-1].opened_at.isoformat(), rows[-1].id) + return IncidentsPage(items=[_to_schema(r) for r in rows], next_cursor=next_cursor) diff --git a/server/monlet_server/api/outbox.py b/server/monlet_server/api/outbox.py new file mode 100644 index 0000000..d049e37 --- /dev/null +++ b/server/monlet_server/api/outbox.py @@ -0,0 +1,58 @@ +from fastapi import APIRouter, Depends, Query +from sqlalchemy import and_, or_, select +from sqlalchemy.ext.asyncio import AsyncSession + +from ..cursors import decode, decode_datetime, encode +from ..db import get_session +from ..models import NotificationOutbox +from ..schemas import NotificationOutboxItem, OutboxPage + +router = APIRouter() + + +@router.get("/notifiers/outbox", response_model=OutboxPage) +async def list_outbox( + state: str | None = Query(default=None), + cursor: str | None = Query(default=None, max_length=512), + limit: int = Query(default=100, ge=1, le=500), + session: AsyncSession = Depends(get_session), +) -> OutboxPage: + stmt = select(NotificationOutbox).order_by( + NotificationOutbox.created_at.desc(), NotificationOutbox.id.desc() + ) + if state is not None: + stmt = stmt.where(NotificationOutbox.state == state) + if cursor is not None: + ts_str, last_id = decode(cursor, 2) + ts = decode_datetime(ts_str) + stmt = stmt.where( + or_( + NotificationOutbox.created_at < ts, + and_( + NotificationOutbox.created_at == ts, + NotificationOutbox.id < last_id, + ), + ) + ) + rows = (await session.execute(stmt.limit(limit + 1))).scalars().all() + next_cursor = None + if len(rows) > limit: + rows = rows[:limit] + next_cursor = encode(rows[-1].created_at.isoformat(), rows[-1].id) + return OutboxPage( + items=[ + NotificationOutboxItem( + id=str(r.id), + notifier=r.notifier, + state=r.state, + incident_id=str(r.incident_id), + event_type=r.event_type, + attempts=r.attempts, + next_attempt_at=r.next_attempt_at, + last_error=r.last_error, + updated_at=r.updated_at, + ) + for r in rows + ], + next_cursor=next_cursor, + ) diff --git a/server/monlet_server/cursors.py b/server/monlet_server/cursors.py new file mode 100644 index 0000000..af9abb3 --- /dev/null +++ b/server/monlet_server/cursors.py @@ -0,0 +1,29 @@ +import base64 +import binascii +from datetime import datetime + +from fastapi import HTTPException + + +def encode(*parts: object) -> str: + raw = "|".join(str(p) for p in parts).encode() + return base64.urlsafe_b64encode(raw).decode().rstrip("=") + + +def decode(cursor: str, count: int) -> list[str]: + pad = "=" * (-len(cursor) % 4) + try: + raw = base64.urlsafe_b64decode(cursor + pad).decode() + except (binascii.Error, ValueError) as exc: + raise HTTPException(status_code=400, detail="invalid cursor") from exc + parts = raw.split("|", count - 1) + if len(parts) != count: + raise HTTPException(status_code=400, detail="invalid cursor") + return parts + + +def decode_datetime(value: str) -> datetime: + try: + return datetime.fromisoformat(value) + except ValueError as exc: + raise HTTPException(status_code=400, detail="invalid cursor") from exc diff --git a/server/monlet_server/db.py b/server/monlet_server/db.py new file mode 100644 index 0000000..86ec333 --- /dev/null +++ b/server/monlet_server/db.py @@ -0,0 +1,43 @@ +from collections.abc import AsyncIterator + +from sqlalchemy.ext.asyncio import ( + AsyncEngine, + AsyncSession, + async_sessionmaker, + create_async_engine, +) + +from .settings import get_settings + +_engine: AsyncEngine | None = None +_sessionmaker: async_sessionmaker[AsyncSession] | None = None + + +def get_engine() -> AsyncEngine: + global _engine, _sessionmaker + if _engine is None: + s = get_settings() + _engine = create_async_engine(s.database_url, pool_pre_ping=True, future=True) + _sessionmaker = async_sessionmaker(_engine, expire_on_commit=False) + return _engine + + +def get_sessionmaker() -> async_sessionmaker[AsyncSession]: + if _sessionmaker is None: + get_engine() + assert _sessionmaker is not None + return _sessionmaker + + +async def dispose_engine() -> None: + global _engine, _sessionmaker + if _engine is not None: + await _engine.dispose() + _engine = None + _sessionmaker = None + + +async def get_session() -> AsyncIterator[AsyncSession]: + sm = get_sessionmaker() + async with sm() as session: + yield session diff --git a/server/monlet_server/errors.py b/server/monlet_server/errors.py new file mode 100644 index 0000000..4f14618 --- /dev/null +++ b/server/monlet_server/errors.py @@ -0,0 +1,54 @@ +from fastapi import FastAPI, Request +from fastapi.exceptions import RequestValidationError +from fastapi.responses import JSONResponse +from starlette.exceptions import HTTPException as StarletteHTTPException + +from .logging_config import get_logger, request_id_var +from .schemas import ErrorBody, ErrorCode, ErrorResponse + +log = get_logger("monlet.errors") + + +def _request_id(request: Request) -> str: + rid = getattr(request.state, "request_id", None) + if rid is None: + rid = request_id_var.get() or "" + return rid + + +def error_response( + request: Request, status_code: int, code: ErrorCode, message: str +) -> JSONResponse: + body = ErrorResponse( + error=ErrorBody(code=code, message=message, request_id=_request_id(request)) + ) + return JSONResponse(status_code=status_code, content=body.model_dump()) + + +_STATUS_TO_CODE: dict[int, ErrorCode] = { + 400: "validation", + 401: "unauthorized", + 404: "not_found", + 413: "payload_too_large", + 429: "rate_limited", + 500: "internal", + 503: "not_ready", +} + + +def install_error_handlers(app: FastAPI) -> None: + @app.exception_handler(RequestValidationError) + async def _validation(request: Request, exc: RequestValidationError): + message = "request validation failed" + return error_response(request, 400, "validation", message) + + @app.exception_handler(StarletteHTTPException) + async def _http(request: Request, exc: StarletteHTTPException): + code = _STATUS_TO_CODE.get(exc.status_code, "internal") + msg = exc.detail if isinstance(exc.detail, str) else code + return error_response(request, exc.status_code, code, msg) + + @app.exception_handler(Exception) + async def _unhandled(request: Request, exc: Exception): + log.exception("unhandled_exception", error=type(exc).__name__) + return error_response(request, 500, "internal", "internal server error") diff --git a/server/monlet_server/logging_config.py b/server/monlet_server/logging_config.py new file mode 100644 index 0000000..bc87b0e --- /dev/null +++ b/server/monlet_server/logging_config.py @@ -0,0 +1,36 @@ +import logging +from contextvars import ContextVar + +import structlog + +request_id_var: ContextVar[str | None] = ContextVar("request_id", default=None) + + +def _add_request_id(_logger, _name, event_dict): + rid = request_id_var.get() + if rid is not None: + event_dict["request_id"] = rid + return event_dict + + +def configure_logging(level: str = "INFO") -> None: + logging.basicConfig(format="%(message)s", level=getattr(logging, level.upper(), logging.INFO)) + structlog.configure( + processors=[ + structlog.contextvars.merge_contextvars, + _add_request_id, + structlog.processors.add_log_level, + structlog.processors.TimeStamper(fmt="iso", utc=True), + structlog.processors.StackInfoRenderer(), + structlog.processors.format_exc_info, + structlog.processors.JSONRenderer(), + ], + wrapper_class=structlog.make_filtering_bound_logger( + getattr(logging, level.upper(), logging.INFO) + ), + cache_logger_on_first_use=True, + ) + + +def get_logger(name: str = "monlet"): + return structlog.get_logger(name) diff --git a/server/monlet_server/main.py b/server/monlet_server/main.py new file mode 100644 index 0000000..450380d --- /dev/null +++ b/server/monlet_server/main.py @@ -0,0 +1,74 @@ +import asyncio +from contextlib import asynccontextmanager + +from fastapi import FastAPI +from fastapi.responses import Response + +from . import metrics +from .api import agents, checks, events, health, heartbeat, incidents, outbox +from .db import dispose_engine, get_engine, get_sessionmaker +from .errors import install_error_handlers +from .logging_config import configure_logging, get_logger +from .middleware.auth import BearerAuthMiddleware +from .middleware.body_limit import BodyLimitMiddleware +from .middleware.request_id import RequestIdMiddleware +from .services.detector import run_detector +from .services.notifier_worker import run_notifier_worker +from .settings import get_settings + +log = get_logger("monlet.main") + + +@asynccontextmanager +async def lifespan(app: FastAPI): + s = get_settings() + configure_logging(s.log_level) + get_engine() + sm = get_sessionmaker() + stop_event = asyncio.Event() + tasks: list[asyncio.Task] = [] + if s.enable_detector: + tasks.append(asyncio.create_task(run_detector(sm, stop_event), name="detector")) + if s.enable_notifier_worker: + tasks.append( + asyncio.create_task(run_notifier_worker(sm, stop_event), name="notifier_worker") + ) + log.info("server_started", host=s.host, port=s.port) + try: + yield + finally: + stop_event.set() + for t in tasks: + try: + await asyncio.wait_for(t, timeout=5.0) + except TimeoutError: + t.cancel() + await dispose_engine() + + +def create_app() -> FastAPI: + app = FastAPI(title="Monlet Server", version="1.0.0", lifespan=lifespan) + + app.add_middleware(BearerAuthMiddleware) + app.add_middleware(BodyLimitMiddleware) + app.add_middleware(RequestIdMiddleware) + + install_error_handlers(app) + + app.include_router(health.router, prefix="/api/v1") + app.include_router(heartbeat.router, prefix="/api/v1") + app.include_router(events.router, prefix="/api/v1") + app.include_router(agents.router, prefix="/api/v1") + app.include_router(checks.router, prefix="/api/v1") + app.include_router(incidents.router, prefix="/api/v1") + app.include_router(outbox.router, prefix="/api/v1") + + @app.get("/metrics") + async def metrics_endpoint() -> Response: + body, ct = metrics.render() + return Response(content=body, media_type=ct) + + return app + + +app = create_app() diff --git a/server/monlet_server/metrics.py b/server/monlet_server/metrics.py new file mode 100644 index 0000000..009f7c6 --- /dev/null +++ b/server/monlet_server/metrics.py @@ -0,0 +1,54 @@ +from prometheus_client import CollectorRegistry, Counter, Gauge, Histogram, generate_latest +from prometheus_client.exposition import CONTENT_TYPE_LATEST + +REGISTRY = CollectorRegistry() + +heartbeats_total = Counter( + "monlet_server_heartbeats_total", + "Total heartbeats received.", + ["result"], + registry=REGISTRY, +) +events_total = Counter( + "monlet_server_events_total", + "Total events ingested.", + ["result"], + registry=REGISTRY, +) +incidents_opened_total = Counter( + "monlet_server_incidents_opened_total", + "Total incidents opened.", + registry=REGISTRY, +) +incidents_resolved_total = Counter( + "monlet_server_incidents_resolved_total", + "Total incidents resolved.", + registry=REGISTRY, +) +agents_gauge = Gauge( + "monlet_server_agents", + "Agents by status.", + ["status"], + registry=REGISTRY, +) +open_incidents_gauge = Gauge( + "monlet_server_open_incidents", + "Number of open incidents.", + registry=REGISTRY, +) +notifications_total = Counter( + "monlet_server_notifications_total", + "Notification delivery attempts by notifier and result.", + ["notifier", "result"], + registry=REGISTRY, +) +request_duration = Histogram( + "monlet_server_request_duration_seconds", + "Request duration seconds.", + ["path"], + registry=REGISTRY, +) + + +def render() -> tuple[bytes, str]: + return generate_latest(REGISTRY), CONTENT_TYPE_LATEST diff --git a/server/monlet_server/middleware/__init__.py b/server/monlet_server/middleware/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/server/monlet_server/middleware/auth.py b/server/monlet_server/middleware/auth.py new file mode 100644 index 0000000..e2e689f --- /dev/null +++ b/server/monlet_server/middleware/auth.py @@ -0,0 +1,22 @@ +from starlette.middleware.base import BaseHTTPMiddleware +from starlette.requests import Request + +from ..errors import error_response +from ..settings import get_settings + +NO_AUTH_PATHS = {"/api/v1/health", "/api/v1/ready", "/metrics"} + + +class BearerAuthMiddleware(BaseHTTPMiddleware): + async def dispatch(self, request: Request, call_next): + if request.url.path in NO_AUTH_PATHS: + return await call_next(request) + + expected = get_settings().auth_token + header = request.headers.get("authorization") + if not header or not header.lower().startswith("bearer "): + return error_response(request, 401, "unauthorized", "authentication required") + token = header.split(" ", 1)[1].strip() + if token != expected: + return error_response(request, 401, "unauthorized", "authentication required") + return await call_next(request) diff --git a/server/monlet_server/middleware/body_limit.py b/server/monlet_server/middleware/body_limit.py new file mode 100644 index 0000000..cc71139 --- /dev/null +++ b/server/monlet_server/middleware/body_limit.py @@ -0,0 +1,32 @@ +from starlette.middleware.base import BaseHTTPMiddleware +from starlette.requests import Request + +from ..errors import error_response +from ..settings import get_settings + + +class BodyLimitMiddleware(BaseHTTPMiddleware): + async def dispatch(self, request: Request, call_next): + limit = get_settings().body_limit_bytes + cl = request.headers.get("content-length") + if cl is not None: + try: + if int(cl) > limit: + return error_response(request, 413, "payload_too_large", "body too large") + except ValueError: + return error_response(request, 400, "validation", "invalid content-length") + return await call_next(request) + + total = 0 + chunks: list[bytes] = [] + async for chunk in request.stream(): + total += len(chunk) + if total > limit: + return error_response(request, 413, "payload_too_large", "body too large") + chunks.append(chunk) + + async def receive(): + return {"type": "http.request", "body": b"".join(chunks), "more_body": False} + + request._receive = receive # type: ignore[attr-defined] + return await call_next(request) diff --git a/server/monlet_server/middleware/request_id.py b/server/monlet_server/middleware/request_id.py new file mode 100644 index 0000000..c3c7823 --- /dev/null +++ b/server/monlet_server/middleware/request_id.py @@ -0,0 +1,23 @@ +import re +import uuid + +from starlette.middleware.base import BaseHTTPMiddleware +from starlette.requests import Request + +from ..logging_config import request_id_var + +_UUID_RE = re.compile(r"^[0-9a-fA-F-]{8,64}$") + + +class RequestIdMiddleware(BaseHTTPMiddleware): + async def dispatch(self, request: Request, call_next): + incoming = request.headers.get("X-Request-Id") + rid = incoming if incoming and _UUID_RE.match(incoming) else str(uuid.uuid4()) + request.state.request_id = rid + token = request_id_var.set(rid) + try: + response = await call_next(request) + finally: + request_id_var.reset(token) + response.headers["X-Request-Id"] = rid + return response diff --git a/server/monlet_server/models.py b/server/monlet_server/models.py new file mode 100644 index 0000000..764f245 --- /dev/null +++ b/server/monlet_server/models.py @@ -0,0 +1,181 @@ +from datetime import datetime +from uuid import UUID + +from sqlalchemy import ( + JSON, + Boolean, + CheckConstraint, + DateTime, + ForeignKey, + Index, + Integer, + PrimaryKeyConstraint, + String, + Text, + func, + text, +) +from sqlalchemy.dialects.postgresql import JSONB +from sqlalchemy.dialects.postgresql import UUID as PG_UUID +from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column + + +class Base(DeclarativeBase): + pass + + +def _jsonb(): + return JSONB().with_variant(JSON(), "sqlite") + + +def _uuid(): + return PG_UUID(as_uuid=True).with_variant(String(36), "sqlite") + + +class Agent(Base): + __tablename__ = "agents" + + agent_id: Mapped[str] = mapped_column(Text, primary_key=True) + hostname: Mapped[str] = mapped_column(Text, nullable=False) + status: Mapped[str] = mapped_column(Text, nullable=False, default="alive") + last_seen_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + features: Mapped[dict] = mapped_column( + _jsonb(), + nullable=False, + default=lambda: {"push": False, "metrics": False}, + server_default=text("jsonb_build_object('push', false, 'metrics', false)"), + ) + labels: Mapped[dict] = mapped_column( + _jsonb(), nullable=False, default=dict, server_default=text("'{}'::jsonb") + ) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, server_default=func.now() + ) + + __table_args__ = ( + CheckConstraint("status IN ('alive','stale','dead')", name="agents_status_chk"), + Index("agents_status_idx", "status"), + Index("agents_last_seen_idx", "last_seen_at"), + ) + + +class Check(Base): + __tablename__ = "checks" + + agent_id: Mapped[str] = mapped_column( + Text, ForeignKey("agents.agent_id", ondelete="CASCADE"), nullable=False + ) + check_id: Mapped[str] = mapped_column(Text, nullable=False) + status: Mapped[str] = mapped_column(Text, nullable=False) + exit_code: Mapped[int] = mapped_column(Integer, nullable=False) + last_observed_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + last_event_id: Mapped[UUID] = mapped_column(_uuid(), nullable=False) + incident_key: Mapped[str] = mapped_column(Text, nullable=False) + + __table_args__ = ( + PrimaryKeyConstraint("agent_id", "check_id", name="checks_pkey"), + CheckConstraint( + "status IN ('ok','warning','critical','unknown')", name="checks_status_chk" + ), + Index("checks_status_idx", "status"), + ) + + +class Event(Base): + __tablename__ = "events" + + event_id: Mapped[UUID] = mapped_column(_uuid(), primary_key=True) + agent_id: Mapped[str] = mapped_column(Text, nullable=False) + check_id: Mapped[str] = mapped_column(Text, nullable=False) + observed_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + received_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, server_default=func.now() + ) + status: Mapped[str] = mapped_column(Text, nullable=False) + exit_code: Mapped[int] = mapped_column(Integer, nullable=False) + duration_ms: Mapped[int] = mapped_column(Integer, nullable=False) + output: Mapped[str | None] = mapped_column(Text, nullable=True) + output_truncated: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) + notifications_enabled: Mapped[bool] = mapped_column( + Boolean, nullable=False, default=True, server_default=text("TRUE") + ) + incident_key: Mapped[str] = mapped_column(Text, nullable=False) + + __table_args__ = ( + CheckConstraint( + "status IN ('ok','warning','critical','unknown')", name="events_status_chk" + ), + CheckConstraint("duration_ms >= 0", name="events_duration_chk"), + Index( + "events_agent_check_observed_idx", + "agent_id", + "check_id", + text("observed_at DESC"), + ), + Index("events_observed_at_idx", text("observed_at DESC")), + ) + + +class Incident(Base): + __tablename__ = "incidents" + + id: Mapped[UUID] = mapped_column(_uuid(), primary_key=True) + incident_key: Mapped[str] = mapped_column(Text, nullable=False) + agent_id: Mapped[str] = mapped_column(Text, nullable=False) + check_id: Mapped[str] = mapped_column(Text, nullable=False) + state: Mapped[str] = mapped_column(Text, nullable=False) + severity: Mapped[str] = mapped_column(Text, nullable=False) + opened_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + resolved_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + summary: Mapped[str | None] = mapped_column(Text, nullable=True) + last_event_id: Mapped[UUID] = mapped_column(_uuid(), nullable=False) + + __table_args__ = ( + CheckConstraint("state IN ('open','resolved')", name="incidents_state_chk"), + CheckConstraint( + "severity IN ('warning','critical','unknown')", name="incidents_severity_chk" + ), + Index( + "incidents_open_key_idx", + "incident_key", + unique=True, + postgresql_where=text("state = 'open'"), + ), + Index("incidents_state_opened_idx", "state", text("opened_at DESC")), + ) + + +class NotificationOutbox(Base): + __tablename__ = "notification_outbox" + + id: Mapped[UUID] = mapped_column(_uuid(), primary_key=True) + incident_id: Mapped[UUID] = mapped_column( + _uuid(), ForeignKey("incidents.id", ondelete="CASCADE"), nullable=False + ) + notifier: Mapped[str] = mapped_column(Text, nullable=False) + event_type: Mapped[str] = mapped_column(Text, nullable=False) + state: Mapped[str] = mapped_column(Text, nullable=False) + attempts: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + next_attempt_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + last_error: Mapped[str | None] = mapped_column(Text, nullable=True) + payload: Mapped[dict] = mapped_column(_jsonb(), nullable=False) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, server_default=func.now() + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, server_default=func.now() + ) + + __table_args__ = ( + CheckConstraint("event_type IN ('firing','resolved')", name="outbox_event_type_chk"), + CheckConstraint( + "state IN ('pending','sending','sent','retry','failed','discarded')", + name="outbox_state_chk", + ), + Index( + "outbox_pending_idx", + "state", + "next_attempt_at", + postgresql_where=text("state IN ('pending','retry')"), + ), + ) diff --git a/server/monlet_server/redaction.py b/server/monlet_server/redaction.py new file mode 100644 index 0000000..ae6dde6 --- /dev/null +++ b/server/monlet_server/redaction.py @@ -0,0 +1,26 @@ +import re + +_PATTERNS: list[tuple[re.Pattern[str], object]] = [ + ( + re.compile(r"(?i)(authorization|set-cookie|cookie)([\"'=:]+\s*)([^\r\n]+)"), + lambda m: f"{m.group(1)}{m.group(2)}***", + ), + ( + re.compile(r"(?i)(token|secret|password|api[_-]?key|credential)([\"'=:]+\s*)([^\s\"',;]+)"), + lambda m: f"{m.group(1)}{m.group(2)}***", + ), + (re.compile(r"(?i)(bearer)\s+\S+"), r"\1 ***"), +] + + +def redact(value: str | None) -> str | None: + if value is None: + return None + out = value + for pat, repl in _PATTERNS: + out = pat.sub(repl, out) + return out + + +def redact_dict(d: dict) -> dict: + return {k: redact(v) if isinstance(v, str) else v for k, v in d.items()} diff --git a/server/monlet_server/schemas.py b/server/monlet_server/schemas.py new file mode 100644 index 0000000..29cc2b7 --- /dev/null +++ b/server/monlet_server/schemas.py @@ -0,0 +1,201 @@ +import re +from datetime import datetime +from typing import Literal +from uuid import UUID + +from pydantic import BaseModel, ConfigDict, Field, field_validator + +ID_PATTERN = r"^[A-Za-z0-9._:-]+$" +UUIDV7_PATTERN = r"^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$" +ID_RE = re.compile(ID_PATTERN) +SENSITIVE_LABEL_WORDS = ( + "token", + "secret", + "password", + "credential", + "authorization", + "cookie", + "api_key", + "apikey", +) + +Status = Literal["ok", "warning", "critical", "unknown"] +AgentStatus = Literal["alive", "stale", "dead"] +IncidentState = Literal["open", "resolved"] +Severity = Literal["warning", "critical", "unknown"] +OutboxState = Literal["pending", "sending", "sent", "retry", "failed", "discarded"] +OutboxEventType = Literal["firing", "resolved"] +ErrorCode = Literal[ + "unauthorized", + "not_found", + "validation", + "payload_too_large", + "rate_limited", + "internal", + "not_ready", +] + + +class HealthResponse(BaseModel): + status: Literal["ok"] = "ok" + + +class ErrorBody(BaseModel): + code: ErrorCode + message: str + request_id: str + + +class ErrorResponse(BaseModel): + error: ErrorBody + + +class AcceptedResponse(BaseModel): + accepted: bool = True + + +class EventBatchAcceptedResponse(BaseModel): + accepted: int = Field(ge=0) + deduplicated: int = Field(ge=0) + + +def _is_sensitive_label_key(key: str) -> bool: + normalized = key.lower().replace("-", "_").replace(".", "_").replace(":", "_") + return any(word in normalized for word in SENSITIVE_LABEL_WORDS) + + +class AgentFeatures(BaseModel): + model_config = ConfigDict(extra="forbid") + + push: bool + metrics: bool + + +class HeartbeatRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + agent_id: str = Field(max_length=128, pattern=ID_PATTERN) + observed_at: datetime + hostname: str = Field(max_length=253) + features: AgentFeatures + labels: dict[str, str] = Field(default_factory=dict) + + @field_validator("labels") + @classmethod + def _check_labels(cls, v: dict[str, str]) -> dict[str, str]: + if len(v) > 32: + raise ValueError("too many labels (max 32)") + for k, val in v.items(): + if len(k) > 64 or not ID_RE.match(k): + raise ValueError(f"invalid label key: {k!r}") + if _is_sensitive_label_key(k): + raise ValueError(f"label key is not allowed: {k!r}") + if len(val) > 256: + raise ValueError(f"label value too long for key {k!r}") + return v + + +class CheckResultEvent(BaseModel): + model_config = ConfigDict(extra="forbid") + + event_id: str = Field(pattern=UUIDV7_PATTERN, min_length=36, max_length=36) + check_id: str = Field(max_length=128, pattern=ID_PATTERN) + observed_at: datetime + status: Status + exit_code: int + duration_ms: int = Field(ge=0) + output: str | None = Field(default=None, max_length=8192) + output_truncated: bool = False + notifications_enabled: bool = True + incident_key: str | None = Field(default=None, max_length=256) + + @field_validator("output") + @classmethod + def _check_output_bytes(cls, v: str | None) -> str | None: + if v is not None and len(v.encode("utf-8")) > 8192: + raise ValueError("output exceeds 8192 UTF-8 bytes") + return v + + +class EventBatchRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + agent_id: str = Field(max_length=128, pattern=ID_PATTERN) + events: list[CheckResultEvent] = Field(min_length=1, max_length=200) + + +class Agent(BaseModel): + agent_id: str + hostname: str + status: AgentStatus + last_seen_at: datetime + features: AgentFeatures + labels: dict[str, str] = Field(default_factory=dict) + + +class CheckState(BaseModel): + agent_id: str + check_id: str + status: Status + last_observed_at: datetime + exit_code: int + incident_key: str + + +class Incident(BaseModel): + id: str + incident_key: str + state: IncidentState + severity: Severity + agent_id: str + check_id: str + opened_at: datetime + resolved_at: datetime | None = None + summary: str | None = None + + +class StoredCheckResultEvent(CheckResultEvent): + agent_id: str = Field(max_length=128, pattern=ID_PATTERN) + received_at: datetime + + +class NotificationOutboxItem(BaseModel): + id: str + notifier: str + state: OutboxState + incident_id: str + event_type: OutboxEventType + attempts: int = Field(ge=0) + next_attempt_at: datetime | None = None + last_error: str | None = None + updated_at: datetime + + +class PageEnvelope(BaseModel): + next_cursor: str | None = None + + +class AgentsPage(PageEnvelope): + items: list[Agent] + + +class ChecksPage(PageEnvelope): + items: list[CheckState] + + +class IncidentsPage(PageEnvelope): + items: list[Incident] + + +class EventsPage(PageEnvelope): + items: list[StoredCheckResultEvent] + + +class OutboxPage(PageEnvelope): + items: list[NotificationOutboxItem] + + +class UUIDStr(BaseModel): + """Helper to validate generated UUIDs.""" + + value: UUID diff --git a/server/monlet_server/services/__init__.py b/server/monlet_server/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/server/monlet_server/services/detector.py b/server/monlet_server/services/detector.py new file mode 100644 index 0000000..31ede3f --- /dev/null +++ b/server/monlet_server/services/detector.py @@ -0,0 +1,90 @@ +import asyncio +from datetime import UTC, datetime, timedelta + +from sqlalchemy import delete, func, select, update +from sqlalchemy.ext.asyncio import async_sessionmaker + +from .. import metrics +from ..logging_config import get_logger +from ..models import Agent, Event, Incident, NotificationOutbox +from ..settings import get_settings + +log = get_logger("monlet.detector") + +OUTBOX_TERMINAL_STATES = ("sent", "failed", "discarded") + + +async def _prune_history(session) -> None: + s = get_settings() + if s.events_retention_max_rows > 0: + keep_events = ( + select(Event.event_id) + .order_by(Event.received_at.desc(), Event.event_id.desc()) + .limit(s.events_retention_max_rows) + ) + await session.execute(delete(Event).where(Event.event_id.not_in(keep_events))) + + if s.outbox_retention_max_rows > 0: + keep_outbox = ( + select(NotificationOutbox.id) + .where(NotificationOutbox.state.in_(OUTBOX_TERMINAL_STATES)) + .order_by(NotificationOutbox.created_at.desc(), NotificationOutbox.id.desc()) + .limit(s.outbox_retention_max_rows) + ) + await session.execute( + delete(NotificationOutbox) + .where(NotificationOutbox.state.in_(OUTBOX_TERMINAL_STATES)) + .where(NotificationOutbox.id.not_in(keep_outbox)) + ) + + +async def _tick(sm: async_sessionmaker) -> None: + s = get_settings() + now = datetime.now(UTC) + stale_cut = now - timedelta(seconds=s.stale_after_sec) + dead_cut = now - timedelta(seconds=s.dead_after_sec) + async with sm() as session: + await session.execute( + update(Agent) + .where(Agent.last_seen_at <= dead_cut) + .where(Agent.status != "dead") + .values(status="dead") + ) + await session.execute( + update(Agent) + .where(Agent.last_seen_at <= stale_cut) + .where(Agent.last_seen_at > dead_cut) + .where(Agent.status != "stale") + .values(status="stale") + ) + await session.execute( + update(Agent) + .where(Agent.last_seen_at > stale_cut) + .where(Agent.status != "alive") + .values(status="alive") + ) + await _prune_history(session) + await session.commit() + + for status in ("alive", "stale", "dead"): + r = await session.execute( + select(func.count()).select_from(Agent).where(Agent.status == status) + ) + metrics.agents_gauge.labels(status=status).set(r.scalar_one()) + r = await session.execute( + select(func.count()).select_from(Incident).where(Incident.state == "open") + ) + metrics.open_incidents_gauge.set(r.scalar_one()) + + +async def run_detector(sm: async_sessionmaker, stop_event: asyncio.Event) -> None: + tick = get_settings().detector_tick_sec + while not stop_event.is_set(): + try: + await _tick(sm) + except Exception as exc: + log.warning("detector_tick_failed", error=str(exc)) + try: + await asyncio.wait_for(stop_event.wait(), timeout=tick) + except TimeoutError: + continue diff --git a/server/monlet_server/services/ingestion.py b/server/monlet_server/services/ingestion.py new file mode 100644 index 0000000..ee5fe1d --- /dev/null +++ b/server/monlet_server/services/ingestion.py @@ -0,0 +1,237 @@ +from datetime import UTC, datetime +from uuid import UUID, uuid4 + +from fastapi import HTTPException +from sqlalchemy import select, text +from sqlalchemy.dialects.postgresql import insert as pg_insert +from sqlalchemy.ext.asyncio import AsyncSession + +from .. import metrics +from ..logging_config import get_logger +from ..models import Agent, Check, Event, Incident, NotificationOutbox +from ..redaction import redact +from ..schemas import CheckResultEvent, HeartbeatRequest +from ..settings import get_settings + +INCIDENT_KEY_MAX = 256 + +log = get_logger("monlet.ingestion") + +_SEVERITY_RANK = {"unknown": 1, "warning": 2, "critical": 3} + + +def _severity_for_status(status: str) -> str: + if status in ("warning", "critical", "unknown"): + return status + return "unknown" + + +def _incident_key(agent_id: str, ev: CheckResultEvent) -> str: + key = ev.incident_key or f"{agent_id}:{ev.check_id}" + if len(key) > INCIDENT_KEY_MAX: + raise HTTPException( + status_code=400, + detail=f"incident_key exceeds {INCIDENT_KEY_MAX} chars", + ) + return key + + +async def upsert_agent_heartbeat(session: AsyncSession, hb: HeartbeatRequest) -> None: + observed = ( + hb.observed_at.astimezone(UTC) + if hb.observed_at.tzinfo + else hb.observed_at.replace(tzinfo=UTC) + ) + stmt = pg_insert(Agent).values( + agent_id=hb.agent_id, + hostname=hb.hostname, + status="alive", + last_seen_at=observed, + features=hb.features.model_dump(), + labels=hb.labels, + ) + stmt = stmt.on_conflict_do_update( + index_elements=[Agent.agent_id], + set_={ + "hostname": stmt.excluded.hostname, + "status": "alive", + "last_seen_at": stmt.excluded.last_seen_at, + "features": stmt.excluded.features, + "labels": stmt.excluded.labels, + }, + ) + await session.execute(stmt) + metrics.heartbeats_total.labels(result="ok").inc() + + +async def _apply_incident( + session: AsyncSession, + agent_id: str, + ev: CheckResultEvent, + incident_key: str, +) -> tuple[bool, Incident | None]: + """Return (transition, incident). transition True if open->resolved or none->open.""" + observed = ev.observed_at + if ev.status == "ok": + res = await session.execute( + select(Incident).where(Incident.incident_key == incident_key, Incident.state == "open") + ) + inc = res.scalar_one_or_none() + if inc is None: + return False, None + inc.state = "resolved" + inc.resolved_at = observed + inc.last_event_id = UUID(ev.event_id) + metrics.incidents_resolved_total.inc() + return True, inc + + severity = _severity_for_status(ev.status) + new_id = uuid4() + summary = (redact(ev.output) or "")[:200] if ev.output else None + stmt = pg_insert(Incident).values( + id=new_id, + incident_key=incident_key, + agent_id=agent_id, + check_id=ev.check_id, + state="open", + severity=severity, + opened_at=observed, + last_event_id=UUID(ev.event_id), + summary=summary, + ) + stmt = stmt.on_conflict_do_nothing( + index_elements=[Incident.incident_key], + index_where=text("state = 'open'"), + ).returning(Incident.id) + ins = await session.execute(stmt) + inserted_id = ins.scalar_one_or_none() + if inserted_id is not None: + await session.flush() + res = await session.execute(select(Incident).where(Incident.id == inserted_id)) + metrics.incidents_opened_total.inc() + return True, res.scalar_one() + + res = await session.execute( + select(Incident).where(Incident.incident_key == incident_key, Incident.state == "open") + ) + inc = res.scalar_one() + if inc.agent_id != agent_id or inc.check_id != ev.check_id: + raise HTTPException( + status_code=400, + detail="incident_key already bound to another (agent_id, check_id)", + ) + cur = _SEVERITY_RANK.get(inc.severity, 0) + new = _SEVERITY_RANK.get(severity, 0) + if new > cur: + inc.severity = severity + inc.last_event_id = UUID(ev.event_id) + return False, inc + + +async def _enqueue_outbox( + session: AsyncSession, + incident: Incident, + event_type: str, + payload: dict, +) -> None: + notifiers = get_settings().enabled_notifiers + if not notifiers: + return + now = datetime.now(UTC) + for name in notifiers: + session.add( + NotificationOutbox( + id=uuid4(), + incident_id=incident.id, + notifier=name, + event_type=event_type, + state="pending", + attempts=0, + next_attempt_at=now, + payload=payload, + ) + ) + + +async def ingest_event( + session: AsyncSession, + agent_id: str, + ev: CheckResultEvent, +) -> bool: + """Return True if accepted, False if deduplicated.""" + observed = ev.observed_at + output = redact(ev.output) + incident_key = _incident_key(agent_id, ev) + + stmt = pg_insert(Event).values( + event_id=UUID(ev.event_id), + agent_id=agent_id, + check_id=ev.check_id, + observed_at=observed, + status=ev.status, + exit_code=ev.exit_code, + duration_ms=ev.duration_ms, + output=output, + output_truncated=ev.output_truncated, + notifications_enabled=ev.notifications_enabled, + incident_key=incident_key, + ) + stmt = stmt.on_conflict_do_nothing(index_elements=[Event.event_id]).returning(Event.event_id) + result = await session.execute(stmt) + inserted = result.scalar_one_or_none() + if inserted is None: + metrics.events_total.labels(result="deduplicated").inc() + return False + + metrics.events_total.labels(result="accepted").inc() + + chk_res = await session.execute( + select(Check).where(Check.agent_id == agent_id, Check.check_id == ev.check_id) + ) + cur = chk_res.scalar_one_or_none() + is_late = cur is not None and cur.last_observed_at > observed + if is_late: + return True + + if cur is None: + session.add( + Check( + agent_id=agent_id, + check_id=ev.check_id, + status=ev.status, + exit_code=ev.exit_code, + last_observed_at=observed, + last_event_id=UUID(ev.event_id), + incident_key=incident_key, + ) + ) + else: + cur.status = ev.status + cur.exit_code = ev.exit_code + cur.last_observed_at = observed + cur.last_event_id = UUID(ev.event_id) + cur.incident_key = incident_key + + transition, inc = await _apply_incident(session, agent_id, ev, incident_key) + if transition and inc is not None and ev.notifications_enabled: + event_type = "resolved" if ev.status == "ok" else "firing" + await _enqueue_outbox( + session, + inc, + event_type, + { + "incident_id": str(inc.id), + "agent_id": agent_id, + "check_id": ev.check_id, + "status": ev.status, + "severity": inc.severity, + "incident_key": incident_key, + "observed_at": observed.isoformat(), + "opened_at": inc.opened_at.isoformat() if inc.opened_at else None, + "resolved_at": inc.resolved_at.isoformat() if inc.resolved_at else None, + "summary": inc.summary, + "output": output, + "event_type": event_type, + }, + ) + return True diff --git a/server/monlet_server/services/notifier_worker.py b/server/monlet_server/services/notifier_worker.py new file mode 100644 index 0000000..ac3a5f1 --- /dev/null +++ b/server/monlet_server/services/notifier_worker.py @@ -0,0 +1,207 @@ +from __future__ import annotations + +import asyncio +from datetime import UTC, datetime, timedelta + +import httpx +from sqlalchemy import text +from sqlalchemy.ext.asyncio import async_sessionmaker + +from .. import metrics +from ..logging_config import get_logger +from ..redaction import redact +from ..settings import Settings, get_settings +from .notifiers import Notifier, build_registry + +log = get_logger("monlet.notifier_worker") + + +# Per ADR-0005: 5s, 15s, 60s, 5m, 30m, 1h, 2h, 4h. +_BACKOFF_SCHEDULE_SEC = (5, 15, 60, 300, 1800, 3600, 7200, 14400) + + +def _backoff(attempts: int) -> timedelta: + idx = max(0, min(attempts - 1, len(_BACKOFF_SCHEDULE_SEC) - 1)) + return timedelta(seconds=_BACKOFF_SCHEDULE_SEC[idx]) + + +async def _claim_batch(session, limit: int, lease_sec: int) -> list[dict]: + res = await session.execute( + text( + """ + UPDATE notification_outbox + SET state = 'sending', updated_at = now() + WHERE id IN ( + SELECT id FROM notification_outbox + WHERE ( + state IN ('pending','retry') + AND (next_attempt_at IS NULL OR next_attempt_at <= now()) + ) + OR ( + state = 'sending' + AND updated_at < now() - make_interval(secs => :lease) + ) + ORDER BY next_attempt_at NULLS FIRST + LIMIT :lim + FOR UPDATE SKIP LOCKED + ) + RETURNING id, notifier, event_type, attempts, payload + """ + ), + {"lim": limit, "lease": lease_sec}, + ) + rows = res.mappings().all() + return [dict(r) for r in rows] + + +async def _finalize( + session, + row_id, + *, + state: str, + attempts: int, + next_attempt_at: datetime | None, + last_error: str | None, +) -> None: + await session.execute( + text( + """ + UPDATE notification_outbox + SET state = :state, + attempts = :attempts, + next_attempt_at = :next_attempt_at, + last_error = :last_error, + updated_at = now() + WHERE id = :id + """ + ), + { + "state": state, + "attempts": attempts, + "next_attempt_at": next_attempt_at, + "last_error": last_error, + "id": row_id, + }, + ) + + +async def _process_row( + sm: async_sessionmaker, + registry: dict[str, Notifier], + row: dict, + max_attempts: int, +) -> None: + notifier = registry.get(row["notifier"]) + attempts = int(row["attempts"]) + 1 + async with sm() as session: + if notifier is None: + await _finalize( + session, + row["id"], + state="discarded", + attempts=attempts, + next_attempt_at=None, + last_error=f"unknown_notifier:{row['notifier']}", + ) + await session.commit() + metrics.notifications_total.labels(notifier=row["notifier"], result="discarded").inc() + return + + try: + result = await notifier.deliver(row["event_type"], row["payload"]) + except Exception as exc: # defensive: notifier should not raise + result_ok = False + result_perm = False + result_err = f"exception:{type(exc).__name__}" + else: + result_ok = result.ok + result_perm = result.permanent + result_err = redact(result.error) if result.error else None + + if result_ok: + await _finalize( + session, + row["id"], + state="sent", + attempts=attempts, + next_attempt_at=None, + last_error=None, + ) + await session.commit() + metrics.notifications_total.labels(notifier=notifier.name, result="sent").inc() + return + + if result_perm or attempts >= max_attempts: + await _finalize( + session, + row["id"], + state="failed", + attempts=attempts, + next_attempt_at=None, + last_error=result_err, + ) + await session.commit() + metrics.notifications_total.labels(notifier=notifier.name, result="failed").inc() + return + + next_at = datetime.now(UTC) + _backoff(attempts) + await _finalize( + session, + row["id"], + state="retry", + attempts=attempts, + next_attempt_at=next_at, + last_error=result_err, + ) + await session.commit() + metrics.notifications_total.labels(notifier=notifier.name, result="retry").inc() + + +async def tick( + sm: async_sessionmaker, + registry: dict[str, Notifier], + settings: Settings, +) -> int: + async with sm() as session: + rows = await _claim_batch( + session, settings.notifier_batch_size, settings.notifier_lease_sec + ) + await session.commit() + if not rows: + return 0 + await asyncio.gather( + *(_process_row(sm, registry, r, settings.notifier_max_attempts) for r in rows) + ) + return len(rows) + + +async def run_notifier_worker( + sm: async_sessionmaker, + stop_event: asyncio.Event, + client: httpx.AsyncClient | None = None, +) -> None: + settings = get_settings() + own_client = False + if client is None: + client = httpx.AsyncClient(timeout=settings.notifier_http_timeout_sec) + own_client = True + registry = build_registry(settings, client) + if not registry: + log.info("notifier_worker_disabled_no_notifiers") + if own_client: + await client.aclose() + return + log.info("notifier_worker_started", notifiers=list(registry.keys())) + try: + while not stop_event.is_set(): + try: + await tick(sm, registry, settings) + except Exception as exc: + log.warning("notifier_tick_failed", error=str(exc)) + try: + await asyncio.wait_for(stop_event.wait(), timeout=settings.notifier_tick_sec) + except TimeoutError: + continue + finally: + if own_client: + await client.aclose() diff --git a/server/monlet_server/services/notifiers/__init__.py b/server/monlet_server/services/notifiers/__init__.py new file mode 100644 index 0000000..3763d4a --- /dev/null +++ b/server/monlet_server/services/notifiers/__init__.py @@ -0,0 +1,4 @@ +from .base import DeliveryResult, Notifier +from .registry import build_registry + +__all__ = ["DeliveryResult", "Notifier", "build_registry"] diff --git a/server/monlet_server/services/notifiers/alertmanager.py b/server/monlet_server/services/notifiers/alertmanager.py new file mode 100644 index 0000000..eaec2b2 --- /dev/null +++ b/server/monlet_server/services/notifiers/alertmanager.py @@ -0,0 +1,45 @@ +from __future__ import annotations + +import httpx + +from ...redaction import redact +from .base import DeliveryResult +from .http import classify_response + + +class AlertmanagerNotifier: + name = "alertmanager" + + def __init__(self, client: httpx.AsyncClient, url: str) -> None: + self._client = client + self._url = url.rstrip("/") + + async def deliver(self, event_type: str, payload: dict) -> DeliveryResult: + labels = { + "alertname": "monlet_incident", + "agent_id": str(payload.get("agent_id", "")), + "check_id": str(payload.get("check_id", "")), + "incident_key": str(payload.get("incident_key", "")), + } + annotations = { + "severity": str(payload.get("severity", "unknown")), + "status": str(payload.get("status", "")), + "summary": redact(payload.get("summary") or "") or "", + } + if payload.get("output"): + annotations["output"] = (redact(payload["output"]) or "")[:1024] + alert = { + "labels": labels, + "annotations": annotations, + "startsAt": payload.get("opened_at") or payload.get("observed_at"), + } + if event_type == "resolved" and payload.get("resolved_at"): + alert["endsAt"] = payload["resolved_at"] + try: + resp = await self._client.post(f"{self._url}/api/v2/alerts", json=[alert]) + except httpx.HTTPError as exc: + return DeliveryResult(ok=False, permanent=False, error=type(exc).__name__) + ok, perm = classify_response(resp.status_code) + if ok: + return DeliveryResult(ok=True) + return DeliveryResult(ok=False, permanent=perm, error=f"http_{resp.status_code}") diff --git a/server/monlet_server/services/notifiers/base.py b/server/monlet_server/services/notifiers/base.py new file mode 100644 index 0000000..bd6b85d --- /dev/null +++ b/server/monlet_server/services/notifiers/base.py @@ -0,0 +1,17 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Protocol + + +@dataclass(frozen=True) +class DeliveryResult: + ok: bool + permanent: bool = False + error: str | None = None + + +class Notifier(Protocol): + name: str + + async def deliver(self, event_type: str, payload: dict) -> DeliveryResult: ... diff --git a/server/monlet_server/services/notifiers/debug.py b/server/monlet_server/services/notifiers/debug.py new file mode 100644 index 0000000..7308d10 --- /dev/null +++ b/server/monlet_server/services/notifiers/debug.py @@ -0,0 +1,14 @@ +from ...logging_config import get_logger +from ...redaction import redact_dict +from .base import DeliveryResult + + +class DebugNotifier: + name = "debug" + + def __init__(self) -> None: + self._log = get_logger("monlet.notifier.debug") + + async def deliver(self, event_type: str, payload: dict) -> DeliveryResult: + self._log.info("notify", event_type=event_type, payload=redact_dict(payload)) + return DeliveryResult(ok=True) diff --git a/server/monlet_server/services/notifiers/http.py b/server/monlet_server/services/notifiers/http.py new file mode 100644 index 0000000..0d710b8 --- /dev/null +++ b/server/monlet_server/services/notifiers/http.py @@ -0,0 +1,18 @@ +from __future__ import annotations + +import httpx + + +def classify_response(status: int) -> tuple[bool, bool]: + """Return (ok, permanent_failure).""" + if 200 <= status < 300: + return True, False + if 400 <= status < 500 and status not in (408, 425, 429): + return False, True + return False, False + + +def classify_exception(exc: Exception) -> tuple[bool, bool]: + if isinstance(exc, httpx.TimeoutException | httpx.NetworkError): + return False, False + return False, False diff --git a/server/monlet_server/services/notifiers/registry.py b/server/monlet_server/services/notifiers/registry.py new file mode 100644 index 0000000..8030395 --- /dev/null +++ b/server/monlet_server/services/notifiers/registry.py @@ -0,0 +1,31 @@ +from __future__ import annotations + +import httpx + +from ...settings import Settings +from .alertmanager import AlertmanagerNotifier +from .base import Notifier +from .debug import DebugNotifier +from .telegram import TelegramNotifier +from .webhook import WebhookNotifier + + +def build_registry(settings: Settings, client: httpx.AsyncClient) -> dict[str, Notifier]: + reg: dict[str, Notifier] = {} + if settings.notifier_debug_enabled: + reg["debug"] = DebugNotifier() + if ( + settings.notifier_telegram_enabled + and settings.notifier_telegram_token + and settings.notifier_telegram_chat_id + ): + reg["telegram"] = TelegramNotifier( + client, settings.notifier_telegram_token, settings.notifier_telegram_chat_id + ) + if settings.notifier_webhook_enabled and settings.notifier_webhook_url: + reg["webhook"] = WebhookNotifier( + client, settings.notifier_webhook_url, settings.notifier_webhook_token + ) + if settings.notifier_alertmanager_enabled and settings.notifier_alertmanager_url: + reg["alertmanager"] = AlertmanagerNotifier(client, settings.notifier_alertmanager_url) + return reg diff --git a/server/monlet_server/services/notifiers/telegram.py b/server/monlet_server/services/notifiers/telegram.py new file mode 100644 index 0000000..74d4889 --- /dev/null +++ b/server/monlet_server/services/notifiers/telegram.py @@ -0,0 +1,47 @@ +from __future__ import annotations + +import httpx + +from ...redaction import redact +from .base import DeliveryResult +from .http import classify_response + + +def _format_message(event_type: str, payload: dict) -> str: + icon = "[FIRING]" if event_type == "firing" else "[RESOLVED]" + sev = payload.get("severity", "unknown") + agent = payload.get("agent_id", "?") + check = payload.get("check_id", "?") + status = payload.get("status", "?") + incident_key = payload.get("incident_key", "?") + output = payload.get("output") or "" + lines = [ + f"{icon} {sev} {agent}/{check}", + f"status: {status}", + f"incident_key: {incident_key}", + f"observed_at: {payload.get('observed_at', '?')}", + ] + if output: + lines.append(f"output: {output[:300]}") + return redact("\n".join(lines)) or "" + + +class TelegramNotifier: + name = "telegram" + + def __init__(self, client: httpx.AsyncClient, token: str, chat_id: str) -> None: + self._client = client + self._token = token + self._chat_id = chat_id + + async def deliver(self, event_type: str, payload: dict) -> DeliveryResult: + url = f"https://api.telegram.org/bot{self._token}/sendMessage" + body = {"chat_id": self._chat_id, "text": _format_message(event_type, payload)} + try: + resp = await self._client.post(url, json=body) + except httpx.HTTPError as exc: + return DeliveryResult(ok=False, permanent=False, error=type(exc).__name__) + ok, perm = classify_response(resp.status_code) + if ok: + return DeliveryResult(ok=True) + return DeliveryResult(ok=False, permanent=perm, error=f"http_{resp.status_code}") diff --git a/server/monlet_server/services/notifiers/webhook.py b/server/monlet_server/services/notifiers/webhook.py new file mode 100644 index 0000000..600a009 --- /dev/null +++ b/server/monlet_server/services/notifiers/webhook.py @@ -0,0 +1,30 @@ +from __future__ import annotations + +import httpx + +from ...redaction import redact_dict +from .base import DeliveryResult +from .http import classify_response + + +class WebhookNotifier: + name = "webhook" + + def __init__(self, client: httpx.AsyncClient, url: str, token: str = "") -> None: + self._client = client + self._url = url + self._token = token + + async def deliver(self, event_type: str, payload: dict) -> DeliveryResult: + headers = {"Content-Type": "application/json"} + if self._token: + headers["Authorization"] = f"Bearer {self._token}" + body = {"event_type": event_type, "incident": redact_dict(payload)} + try: + resp = await self._client.post(self._url, json=body, headers=headers) + except httpx.HTTPError as exc: + return DeliveryResult(ok=False, permanent=False, error=type(exc).__name__) + ok, perm = classify_response(resp.status_code) + if ok: + return DeliveryResult(ok=True) + return DeliveryResult(ok=False, permanent=perm, error=f"http_{resp.status_code}") diff --git a/server/monlet_server/settings.py b/server/monlet_server/settings.py new file mode 100644 index 0000000..bec3148 --- /dev/null +++ b/server/monlet_server/settings.py @@ -0,0 +1,80 @@ +from pydantic import field_validator +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class Settings(BaseSettings): + model_config = SettingsConfigDict(env_prefix="MONLET_", env_file=".env", extra="ignore") + + auth_token: str + database_url: str = "postgresql+asyncpg://monlet:monlet@127.0.0.1:5432/monlet" + log_level: str = "INFO" + body_limit_bytes: int = 1_048_576 + output_max_bytes: int = 8192 + max_batch_events: int = 200 + stale_after_sec: int = 90 + dead_after_sec: int = 300 + detector_tick_sec: int = 15 + host: str = "0.0.0.0" + port: int = 8000 + enable_detector: bool = True + enable_notifier_worker: bool = True + events_retention_max_rows: int = 100_000 + outbox_retention_max_rows: int = 50_000 + notifier_tick_sec: int = 5 + notifier_batch_size: int = 20 + notifier_max_attempts: int = 8 + notifier_lease_sec: int = 300 + notifier_http_timeout_sec: float = 10.0 + + notifier_debug_enabled: bool = True + notifier_telegram_enabled: bool = False + notifier_telegram_token: str = "" + notifier_telegram_chat_id: str = "" + notifier_webhook_enabled: bool = False + notifier_webhook_url: str = "" + notifier_webhook_token: str = "" + notifier_alertmanager_enabled: bool = False + notifier_alertmanager_url: str = "" + + @field_validator("auth_token") + @classmethod + def _validate_auth_token(cls, value: str) -> str: + if not value or value == "changeme": + raise ValueError("MONLET_AUTH_TOKEN must be set to a non-default value") + return value + + @property + def enabled_notifiers(self) -> list[str]: + out: list[str] = [] + if self.notifier_debug_enabled: + out.append("debug") + if ( + self.notifier_telegram_enabled + and self.notifier_telegram_token + and self.notifier_telegram_chat_id + ): + out.append("telegram") + if self.notifier_webhook_enabled and self.notifier_webhook_url: + out.append("webhook") + if self.notifier_alertmanager_enabled and self.notifier_alertmanager_url: + out.append("alertmanager") + return out + + @property + def sync_database_url(self) -> str: + return self.database_url.replace("+asyncpg", "+psycopg") + + +_settings: Settings | None = None + + +def get_settings() -> Settings: + global _settings + if _settings is None: + _settings = Settings() + return _settings + + +def reset_settings_cache() -> None: + global _settings + _settings = None diff --git a/server/pyproject.toml b/server/pyproject.toml new file mode 100644 index 0000000..a75ec21 --- /dev/null +++ b/server/pyproject.toml @@ -0,0 +1,51 @@ +[project] +name = "monlet-server" +version = "0.1.0" +description = "Monlet central server (ingestion, state, incidents, outbox)" +requires-python = ">=3.14,<3.15" +dependencies = [ + "fastapi>=0.115", + "uvicorn[standard]>=0.32", + "pydantic>=2.9", + "pydantic-settings>=2.6", + "sqlalchemy[asyncio]>=2.0.36", + "asyncpg>=0.30", + "psycopg[binary]>=3.2", + "alembic>=1.13", + "structlog>=24.4", + "prometheus-client>=0.21", + "python-multipart>=0.0.12", + "httpx>=0.27", +] + +[project.optional-dependencies] +dev = [ + "pytest>=8.3", + "pytest-asyncio>=0.24", + "httpx>=0.27", + "testcontainers[postgresql]>=4.8", + "ruff>=0.7", + "freezegun>=1.5", +] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["monlet_server"] + +[tool.uv] +package = true + +[tool.ruff] +line-length = 100 +target-version = "py314" + +[tool.ruff.lint] +select = ["E", "F", "I", "UP", "B", "SIM"] +ignore = ["E501", "B008"] + +[tool.pytest.ini_options] +asyncio_mode = "auto" +testpaths = ["tests"] diff --git a/server/tests/__init__.py b/server/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/server/tests/_helpers.py b/server/tests/_helpers.py new file mode 100644 index 0000000..b23a1f5 --- /dev/null +++ b/server/tests/_helpers.py @@ -0,0 +1,55 @@ +from __future__ import annotations + +import random +import time +import uuid +from datetime import UTC, datetime + + +def uuidv7() -> str: + ts_ms = int(time.time() * 1000) + rand_a = random.getrandbits(12) + rand_b = random.getrandbits(62) + high = (ts_ms & 0xFFFFFFFFFFFF) << 16 | 0x7000 | rand_a + low = (0b10 << 62) | rand_b + return str(uuid.UUID(int=(high << 64) | low)) + + +def now_iso(offset_sec: int = 0) -> str: + return datetime.now(UTC).replace(microsecond=0).isoformat() + + +def make_event( + check_id: str = "disk_root", + status: str = "ok", + exit_code: int = 0, + observed_at: str | None = None, + output: str | None = None, + incident_key: str | None = None, + notifications_enabled: bool = True, +) -> dict: + ev = { + "event_id": uuidv7(), + "check_id": check_id, + "observed_at": observed_at or now_iso(), + "status": status, + "exit_code": exit_code, + "duration_ms": 12, + "output_truncated": False, + "notifications_enabled": notifications_enabled, + } + if output is not None: + ev["output"] = output + if incident_key is not None: + ev["incident_key"] = incident_key + return ev + + +def make_heartbeat(agent_id: str = "agent-1") -> dict: + return { + "agent_id": agent_id, + "observed_at": now_iso(), + "hostname": "host-1", + "features": {"push": True, "metrics": True}, + "labels": {}, + } diff --git a/server/tests/conftest.py b/server/tests/conftest.py new file mode 100644 index 0000000..42174ef --- /dev/null +++ b/server/tests/conftest.py @@ -0,0 +1,105 @@ +from __future__ import annotations + +import asyncio +import os +from collections.abc import AsyncIterator, Iterator + +import pytest +import pytest_asyncio +from alembic.config import Config +from httpx import ASGITransport, AsyncClient +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine +from testcontainers.postgres import PostgresContainer + +from alembic import command + +os.environ.setdefault("MONLET_AUTH_TOKEN", "test-token") +os.environ.setdefault("MONLET_ENABLE_DETECTOR", "false") +os.environ.setdefault("MONLET_ENABLE_NOTIFIER_WORKER", "false") + +from monlet_server import db as db_module # noqa: E402 +from monlet_server.main import create_app # noqa: E402 +from monlet_server.settings import get_settings, reset_settings_cache # noqa: E402 + +HERE = os.path.dirname(__file__) +SERVER_ROOT = os.path.dirname(HERE) + + +@pytest.fixture(scope="session") +def event_loop() -> Iterator[asyncio.AbstractEventLoop]: + loop = asyncio.new_event_loop() + yield loop + loop.close() + + +@pytest.fixture(scope="session") +def pg_container() -> Iterator[PostgresContainer]: + with PostgresContainer("postgres:16-alpine") as pg: + yield pg + + +@pytest.fixture(scope="session") +def database_url(pg_container: PostgresContainer) -> str: + raw = pg_container.get_connection_url() + if raw.startswith("postgresql+psycopg2://"): + async_url = raw.replace("postgresql+psycopg2://", "postgresql+asyncpg://") + elif raw.startswith("postgresql://"): + async_url = raw.replace("postgresql://", "postgresql+asyncpg://", 1) + else: + async_url = raw + return async_url + + +@pytest.fixture(scope="session", autouse=True) +def _apply_migrations(database_url: str) -> Iterator[None]: + os.environ["MONLET_DATABASE_URL"] = database_url + reset_settings_cache() + cfg = Config(os.path.join(SERVER_ROOT, "alembic.ini")) + cfg.set_main_option("script_location", os.path.join(SERVER_ROOT, "alembic")) + cfg.set_main_option("sqlalchemy.url", get_settings().sync_database_url) + command.upgrade(cfg, "head") + yield + + +@pytest_asyncio.fixture +async def engine(database_url: str): + eng = create_async_engine(database_url, future=True) + yield eng + await eng.dispose() + + +@pytest_asyncio.fixture +async def session(engine) -> AsyncIterator[AsyncSession]: + sm = async_sessionmaker(engine, expire_on_commit=False) + async with sm() as s: + yield s + + +@pytest_asyncio.fixture(autouse=True) +async def _truncate_tables(engine) -> AsyncIterator[None]: + yield + from sqlalchemy import text + + async with engine.begin() as conn: + await conn.execute( + text( + "TRUNCATE TABLE notification_outbox, incidents, events, checks, agents RESTART IDENTITY CASCADE" + ) + ) + + +@pytest_asyncio.fixture +async def app_client(database_url: str) -> AsyncIterator[AsyncClient]: + reset_settings_cache() + await db_module.dispose_engine() + app = create_app() + async with app.router.lifespan_context(app): + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + yield client + await db_module.dispose_engine() + + +@pytest.fixture +def auth_headers() -> dict[str, str]: + return {"Authorization": "Bearer test-token"} diff --git a/server/tests/test_auth.py b/server/tests/test_auth.py new file mode 100644 index 0000000..d2aba3f --- /dev/null +++ b/server/tests/test_auth.py @@ -0,0 +1,35 @@ +import pytest + + +@pytest.mark.asyncio +async def test_health_no_auth(app_client): + r = await app_client.get("/api/v1/health") + assert r.status_code == 200 + assert r.json()["status"] == "ok" + + +@pytest.mark.asyncio +async def test_metrics_no_auth(app_client): + r = await app_client.get("/metrics") + assert r.status_code == 200 + + +@pytest.mark.asyncio +async def test_missing_token_401(app_client): + r = await app_client.get("/api/v1/agents") + assert r.status_code == 401 + body = r.json() + assert body["error"]["code"] == "unauthorized" + assert body["error"]["request_id"] + + +@pytest.mark.asyncio +async def test_wrong_token_401(app_client): + r = await app_client.get("/api/v1/agents", headers={"Authorization": "Bearer nope"}) + assert r.status_code == 401 + + +@pytest.mark.asyncio +async def test_valid_token_200(app_client, auth_headers): + r = await app_client.get("/api/v1/agents", headers=auth_headers) + assert r.status_code == 200 diff --git a/server/tests/test_body_limit.py b/server/tests/test_body_limit.py new file mode 100644 index 0000000..ba00f7b --- /dev/null +++ b/server/tests/test_body_limit.py @@ -0,0 +1,30 @@ +import json +import uuid + +import pytest + +from ._helpers import make_event, make_heartbeat + + +@pytest.mark.asyncio +async def test_body_too_large_413(app_client, auth_headers): + big_event = make_event(output="x" * 8000) + payload = {"agent_id": "agent-1", "events": [big_event] * 200} + raw = json.dumps(payload) + " " * (1_048_577 - len(json.dumps(payload))) + rid_in = str(uuid.uuid4()) + r = await app_client.post( + "/api/v1/events", + content=raw, + headers={**auth_headers, "Content-Type": "application/json", "X-Request-Id": rid_in}, + ) + assert r.status_code == 413 + body = r.json() + assert body["error"]["code"] == "payload_too_large" + assert body["error"]["request_id"] == rid_in + assert r.headers.get("X-Request-Id") == rid_in + + +@pytest.mark.asyncio +async def test_small_body_ok(app_client, auth_headers): + r = await app_client.post("/api/v1/heartbeat", json=make_heartbeat(), headers=auth_headers) + assert r.status_code == 202 diff --git a/server/tests/test_constraints.py b/server/tests/test_constraints.py new file mode 100644 index 0000000..c463604 --- /dev/null +++ b/server/tests/test_constraints.py @@ -0,0 +1,70 @@ +from datetime import UTC, datetime +from uuid import uuid4 + +import pytest +from sqlalchemy import text +from sqlalchemy.exc import IntegrityError + +from monlet_server.models import Incident + + +@pytest.mark.asyncio +async def test_event_id_conflict_do_nothing(engine): + eid = str(uuid4()) + async with engine.begin() as conn: + await conn.execute( + text( + "INSERT INTO agents (agent_id, hostname, features, status, last_seen_at) " + "VALUES ('a','h',jsonb_build_object('push', true, 'metrics', false),'alive', now())" + ) + ) + await conn.execute( + text( + "INSERT INTO events (event_id, agent_id, check_id, observed_at, status, exit_code, duration_ms, incident_key)" + " VALUES (:e,'a','c', now(),'ok',0,1,'a:c')" + ), + {"e": eid}, + ) + r = await conn.execute( + text( + "INSERT INTO events (event_id, agent_id, check_id, observed_at, status, exit_code, duration_ms, incident_key)" + " VALUES (:e,'a','c', now(),'ok',0,1,'a:c') ON CONFLICT (event_id) DO NOTHING RETURNING event_id" + ), + {"e": eid}, + ) + assert r.scalar_one_or_none() is None + + +@pytest.mark.asyncio +async def test_partial_unique_open_incident(session): + key = "k1" + now = datetime.now(UTC) + eid = uuid4() + session.add( + Incident( + id=uuid4(), + incident_key=key, + agent_id="a", + check_id="c", + state="open", + severity="warning", + opened_at=now, + last_event_id=eid, + ) + ) + await session.commit() + session.add( + Incident( + id=uuid4(), + incident_key=key, + agent_id="a", + check_id="c", + state="open", + severity="critical", + opened_at=now, + last_event_id=eid, + ) + ) + with pytest.raises(IntegrityError): + await session.commit() + await session.rollback() diff --git a/server/tests/test_detector.py b/server/tests/test_detector.py new file mode 100644 index 0000000..191c7d9 --- /dev/null +++ b/server/tests/test_detector.py @@ -0,0 +1,109 @@ +from datetime import UTC, datetime, timedelta +from uuid import uuid4 + +import pytest +from sqlalchemy import func, select, update +from sqlalchemy.ext.asyncio import async_sessionmaker + +from monlet_server.models import Agent, Event, Incident, NotificationOutbox +from monlet_server.services.detector import _tick +from monlet_server.settings import reset_settings_cache + +from ._helpers import make_event, make_heartbeat + + +@pytest.mark.asyncio +async def test_detector_transitions(app_client, auth_headers, engine, session): + await app_client.post( + "/api/v1/heartbeat", json=make_heartbeat("agent-alive"), headers=auth_headers + ) + await app_client.post( + "/api/v1/heartbeat", json=make_heartbeat("agent-stale"), headers=auth_headers + ) + await app_client.post( + "/api/v1/heartbeat", json=make_heartbeat("agent-dead"), headers=auth_headers + ) + + now = datetime.now(UTC) + await session.execute( + update(Agent) + .where(Agent.agent_id == "agent-stale") + .values(last_seen_at=now - timedelta(seconds=120)) + ) + await session.execute( + update(Agent) + .where(Agent.agent_id == "agent-dead") + .values(last_seen_at=now - timedelta(minutes=10)) + ) + await session.commit() + + sm = async_sessionmaker(engine, expire_on_commit=False) + await _tick(sm) + + statuses = { + r.agent_id: r.status for r in (await session.execute(select(Agent))).scalars().all() + } + assert statuses["agent-alive"] == "alive" + assert statuses["agent-stale"] == "stale" + assert statuses["agent-dead"] == "dead" + + +@pytest.mark.asyncio +async def test_detector_prunes_bounded_history( + app_client, auth_headers, engine, session, monkeypatch +): + monkeypatch.setenv("MONLET_EVENTS_RETENTION_MAX_ROWS", "2") + monkeypatch.setenv("MONLET_OUTBOX_RETENTION_MAX_ROWS", "2") + reset_settings_cache() + try: + await app_client.post( + "/api/v1/heartbeat", json=make_heartbeat("agent-prune"), headers=auth_headers + ) + events = [make_event(check_id="prune_check") for _ in range(5)] + await app_client.post( + "/api/v1/events", + json={"agent_id": "agent-prune", "events": events}, + headers=auth_headers, + ) + critical = make_event(check_id="critical_prune", status="critical", exit_code=2) + await app_client.post( + "/api/v1/events", + json={"agent_id": "agent-prune", "events": [critical]}, + headers=auth_headers, + ) + + inc = ( + await session.execute(select(Incident).where(Incident.check_id == "critical_prune")) + ).scalar_one() + now = datetime.now(UTC) + for _ in range(5): + session.add( + NotificationOutbox( + id=uuid4(), + incident_id=inc.id, + notifier="debug", + event_type="firing", + state="sent", + attempts=1, + next_attempt_at=None, + payload={}, + created_at=now, + updated_at=now, + ) + ) + await session.commit() + + sm = async_sessionmaker(engine, expire_on_commit=False) + await _tick(sm) + + assert (await session.execute(select(func.count()).select_from(Event))).scalar_one() == 2 + terminal_outbox = ( + await session.execute( + select(func.count()) + .select_from(NotificationOutbox) + .where(NotificationOutbox.state == "sent") + ) + ).scalar_one() + assert terminal_outbox == 2 + finally: + reset_settings_cache() diff --git a/server/tests/test_events_ingestion.py b/server/tests/test_events_ingestion.py new file mode 100644 index 0000000..2246622 --- /dev/null +++ b/server/tests/test_events_ingestion.py @@ -0,0 +1,73 @@ +import pytest +from sqlalchemy import func, select + +from monlet_server.models import Check, Event + +from ._helpers import make_event, make_heartbeat + + +@pytest.mark.asyncio +async def test_event_accepted_and_state(app_client, auth_headers, session): + await app_client.post("/api/v1/heartbeat", json=make_heartbeat(), headers=auth_headers) + ev = make_event(status="ok") + r = await app_client.post( + "/api/v1/events", json={"agent_id": "agent-1", "events": [ev]}, headers=auth_headers + ) + assert r.status_code == 202 + assert r.json() == {"accepted": 1, "deduplicated": 0} + n = (await session.execute(select(func.count()).select_from(Event))).scalar_one() + assert n == 1 + chk = (await session.execute(select(Check))).scalar_one() + assert chk.status == "ok" + + +@pytest.mark.asyncio +async def test_duplicate_event_deduplicated(app_client, auth_headers): + await app_client.post("/api/v1/heartbeat", json=make_heartbeat(), headers=auth_headers) + ev = make_event(status="critical", exit_code=2) + r1 = await app_client.post( + "/api/v1/events", json={"agent_id": "agent-1", "events": [ev]}, headers=auth_headers + ) + r2 = await app_client.post( + "/api/v1/events", json={"agent_id": "agent-1", "events": [ev]}, headers=auth_headers + ) + assert r1.json() == {"accepted": 1, "deduplicated": 0} + assert r2.json() == {"accepted": 0, "deduplicated": 1} + + +@pytest.mark.asyncio +async def test_empty_batch_400(app_client, auth_headers): + r = await app_client.post( + "/api/v1/events", json={"agent_id": "agent-1", "events": []}, headers=auth_headers + ) + assert r.status_code == 400 + + +@pytest.mark.asyncio +async def test_oversize_batch_400(app_client, auth_headers): + evs = [make_event() for _ in range(201)] + r = await app_client.post( + "/api/v1/events", json={"agent_id": "agent-1", "events": evs}, headers=auth_headers + ) + assert r.status_code == 400 + + +@pytest.mark.asyncio +async def test_late_event_does_not_move_state(app_client, auth_headers, session): + from datetime import UTC, datetime, timedelta + + await app_client.post("/api/v1/heartbeat", json=make_heartbeat(), headers=auth_headers) + newer = datetime.now(UTC).replace(microsecond=0) + older = (newer - timedelta(minutes=5)).isoformat() + ev_now = make_event(status="critical", exit_code=2, observed_at=newer.isoformat()) + ev_old = make_event(status="ok", exit_code=0, observed_at=older) + await app_client.post( + "/api/v1/events", json={"agent_id": "agent-1", "events": [ev_now]}, headers=auth_headers + ) + await app_client.post( + "/api/v1/events", json={"agent_id": "agent-1", "events": [ev_old]}, headers=auth_headers + ) + chk = (await session.execute(select(Check))).scalar_one() + assert chk.status == "critical" + n = (await session.execute(select(func.count()).select_from(Event))).scalar_one() + assert n == 2 diff --git a/server/tests/test_heartbeat.py b/server/tests/test_heartbeat.py new file mode 100644 index 0000000..ef356ee --- /dev/null +++ b/server/tests/test_heartbeat.py @@ -0,0 +1,61 @@ +import pytest +from sqlalchemy import select + +from monlet_server.models import Agent + +from ._helpers import make_heartbeat + + +@pytest.mark.asyncio +async def test_heartbeat_creates_agent(app_client, auth_headers, session): + r = await app_client.post("/api/v1/heartbeat", json=make_heartbeat(), headers=auth_headers) + assert r.status_code == 202 + res = await session.execute(select(Agent).where(Agent.agent_id == "agent-1")) + a = res.scalar_one() + assert a.hostname == "host-1" + assert a.status == "alive" + + +@pytest.mark.asyncio +async def test_heartbeat_upsert(app_client, auth_headers, session): + await app_client.post("/api/v1/heartbeat", json=make_heartbeat(), headers=auth_headers) + hb = make_heartbeat() + hb["features"] = {"push": True, "metrics": False} + await app_client.post("/api/v1/heartbeat", json=hb, headers=auth_headers) + res = await session.execute(select(Agent).where(Agent.agent_id == "agent-1")) + a = res.scalar_one() + assert a.features == {"push": True, "metrics": False} + + +@pytest.mark.asyncio +async def test_heartbeat_validation_400(app_client, auth_headers): + r = await app_client.post( + "/api/v1/heartbeat", + json={ + "agent_id": "bad id!", + "observed_at": "x", + "hostname": "h", + "features": {"push": True, "metrics": False}, + }, + headers=auth_headers, + ) + assert r.status_code == 400 + assert r.json()["error"]["code"] == "validation" + + +@pytest.mark.asyncio +async def test_heartbeat_rejects_extra_fields(app_client, auth_headers): + hb = make_heartbeat() + hb["mode"] = "hybrid" + r = await app_client.post("/api/v1/heartbeat", json=hb, headers=auth_headers) + assert r.status_code == 400 + assert r.json()["error"]["code"] == "validation" + + +@pytest.mark.asyncio +async def test_heartbeat_rejects_sensitive_label_keys(app_client, auth_headers): + hb = make_heartbeat() + hb["labels"] = {"api_key": "secret"} + r = await app_client.post("/api/v1/heartbeat", json=hb, headers=auth_headers) + assert r.status_code == 400 + assert r.json()["error"]["code"] == "validation" diff --git a/server/tests/test_incident_key.py b/server/tests/test_incident_key.py new file mode 100644 index 0000000..e616bdb --- /dev/null +++ b/server/tests/test_incident_key.py @@ -0,0 +1,55 @@ +import pytest + +from ._helpers import make_event, make_heartbeat + + +@pytest.mark.asyncio +async def test_incident_key_ownership_rejected(app_client, auth_headers): + await app_client.post("/api/v1/heartbeat", json=make_heartbeat("agent-1"), headers=auth_headers) + await app_client.post("/api/v1/heartbeat", json=make_heartbeat("agent-2"), headers=auth_headers) + + ev1 = make_event(status="critical", exit_code=2, incident_key="shared-key") + r1 = await app_client.post( + "/api/v1/events", json={"agent_id": "agent-1", "events": [ev1]}, headers=auth_headers + ) + assert r1.status_code == 202 + + ev2 = make_event(status="critical", exit_code=2, incident_key="shared-key") + r2 = await app_client.post( + "/api/v1/events", json={"agent_id": "agent-2", "events": [ev2]}, headers=auth_headers + ) + assert r2.status_code == 400 + assert r2.json()["error"]["code"] == "validation" + + +@pytest.mark.asyncio +async def test_incident_key_too_long(app_client, auth_headers): + await app_client.post("/api/v1/heartbeat", json=make_heartbeat(), headers=auth_headers) + ev = make_event(status="critical", exit_code=2, incident_key="x" * 257) + r = await app_client.post( + "/api/v1/events", json={"agent_id": "agent-1", "events": [ev]}, headers=auth_headers + ) + assert r.status_code == 400 + + +@pytest.mark.asyncio +async def test_events_query_cursor_pagination(app_client, auth_headers): + await app_client.post("/api/v1/heartbeat", json=make_heartbeat(), headers=auth_headers) + evs = [make_event() for _ in range(5)] + await app_client.post( + "/api/v1/events", json={"agent_id": "agent-1", "events": evs}, headers=auth_headers + ) + r = await app_client.get("/api/v1/events/query?limit=2", headers=auth_headers) + assert r.status_code == 200 + page1 = r.json() + assert len(page1["items"]) == 2 + assert page1["next_cursor"] + + r2 = await app_client.get( + f"/api/v1/events/query?limit=2&cursor={page1['next_cursor']}", headers=auth_headers + ) + page2 = r2.json() + assert len(page2["items"]) == 2 + ids1 = {i["event_id"] for i in page1["items"]} + ids2 = {i["event_id"] for i in page2["items"]} + assert ids1.isdisjoint(ids2) diff --git a/server/tests/test_incidents.py b/server/tests/test_incidents.py new file mode 100644 index 0000000..c11721c --- /dev/null +++ b/server/tests/test_incidents.py @@ -0,0 +1,79 @@ +import pytest +from sqlalchemy import select + +from monlet_server.models import Incident, NotificationOutbox + +from ._helpers import make_event, make_heartbeat + + +@pytest.mark.asyncio +async def test_open_escalate_resolve_reopen(app_client, auth_headers, session): + await app_client.post("/api/v1/heartbeat", json=make_heartbeat(), headers=auth_headers) + a = "agent-1" + + # warning -> open warning + ev1 = make_event(status="warning", exit_code=1) + await app_client.post( + "/api/v1/events", json={"agent_id": a, "events": [ev1]}, headers=auth_headers + ) + inc = (await session.execute(select(Incident))).scalar_one() + assert inc.state == "open" + assert inc.severity == "warning" + + # critical -> escalated, still single open + ev2 = make_event(status="critical", exit_code=2) + await app_client.post( + "/api/v1/events", json={"agent_id": a, "events": [ev2]}, headers=auth_headers + ) + session.expire_all() + incs = (await session.execute(select(Incident))).scalars().all() + assert len(incs) == 1 + assert incs[0].severity == "critical" + + # ok -> resolved + ev3 = make_event(status="ok", exit_code=0) + await app_client.post( + "/api/v1/events", json={"agent_id": a, "events": [ev3]}, headers=auth_headers + ) + session.expire_all() + incs = (await session.execute(select(Incident))).scalars().all() + assert len(incs) == 1 + assert incs[0].state == "resolved" + + # critical again -> new open + ev4 = make_event(status="critical", exit_code=2) + await app_client.post( + "/api/v1/events", json={"agent_id": a, "events": [ev4]}, headers=auth_headers + ) + incs = (await session.execute(select(Incident).order_by(Incident.opened_at))).scalars().all() + assert len(incs) == 2 + states = {i.state for i in incs} + assert states == {"open", "resolved"} + + +@pytest.mark.asyncio +async def test_outbox_skipped_when_notifications_disabled(app_client, auth_headers, session): + await app_client.post("/api/v1/heartbeat", json=make_heartbeat(), headers=auth_headers) + ev = make_event(status="critical", exit_code=2, notifications_enabled=False) + await app_client.post( + "/api/v1/events", json={"agent_id": "agent-1", "events": [ev]}, headers=auth_headers + ) + rows = (await session.execute(select(NotificationOutbox))).scalars().all() + assert rows == [] + + +@pytest.mark.asyncio +async def test_outbox_firing_and_resolved(app_client, auth_headers, session): + await app_client.post("/api/v1/heartbeat", json=make_heartbeat(), headers=auth_headers) + a = "agent-1" + ev_open = make_event(status="critical", exit_code=2) + await app_client.post( + "/api/v1/events", json={"agent_id": a, "events": [ev_open]}, headers=auth_headers + ) + ev_res = make_event(status="ok", exit_code=0) + await app_client.post( + "/api/v1/events", json={"agent_id": a, "events": [ev_res]}, headers=auth_headers + ) + rows = (await session.execute(select(NotificationOutbox))).scalars().all() + types = sorted(r.event_type for r in rows) + assert types == ["firing", "resolved"] diff --git a/server/tests/test_integration_agent.py b/server/tests/test_integration_agent.py new file mode 100644 index 0000000..7301a42 --- /dev/null +++ b/server/tests/test_integration_agent.py @@ -0,0 +1,33 @@ +import json +import os + +import pytest +from sqlalchemy import select + +from monlet_server.models import Agent, Check, Incident + +EXAMPLES = os.path.join(os.path.dirname(__file__), "..", "..", "examples") + + +@pytest.mark.asyncio +async def test_examples_payload(app_client, auth_headers, session): + with open(os.path.join(EXAMPLES, "heartbeat.json")) as f: + hb = json.load(f) + with open(os.path.join(EXAMPLES, "event-batch.json")) as f: + batch = json.load(f) + + r = await app_client.post("/api/v1/heartbeat", json=hb, headers=auth_headers) + assert r.status_code == 202 + + r = await app_client.post("/api/v1/events", json=batch, headers=auth_headers) + assert r.status_code == 202 + + agents = (await session.execute(select(Agent))).scalars().all() + assert len(agents) == 1 + checks = (await session.execute(select(Check))).scalars().all() + assert {c.check_id for c in checks} == {"disk_root", "postgres_up"} + open_inc = ( + (await session.execute(select(Incident).where(Incident.state == "open"))).scalars().all() + ) + assert len(open_inc) == 1 + assert open_inc[0].check_id == "postgres_up" diff --git a/server/tests/test_metrics.py b/server/tests/test_metrics.py new file mode 100644 index 0000000..b91dd07 --- /dev/null +++ b/server/tests/test_metrics.py @@ -0,0 +1,18 @@ +import pytest + +from ._helpers import make_event, make_heartbeat + + +@pytest.mark.asyncio +async def test_metrics_endpoint(app_client, auth_headers): + await app_client.post("/api/v1/heartbeat", json=make_heartbeat(), headers=auth_headers) + ev = make_event(status="critical", exit_code=2) + await app_client.post( + "/api/v1/events", json={"agent_id": "agent-1", "events": [ev]}, headers=auth_headers + ) + r = await app_client.get("/metrics") + assert r.status_code == 200 + body = r.text + assert "monlet_server_heartbeats_total" in body + assert "monlet_server_events_total" in body + assert "monlet_server_incidents_opened_total" in body diff --git a/server/tests/test_notifiers.py b/server/tests/test_notifiers.py new file mode 100644 index 0000000..0e3f5a5 --- /dev/null +++ b/server/tests/test_notifiers.py @@ -0,0 +1,417 @@ +from __future__ import annotations + +import os +from datetime import UTC, datetime, timedelta +from uuid import uuid4 + +import httpx +import pytest +from sqlalchemy import select +from sqlalchemy.ext.asyncio import async_sessionmaker + +from monlet_server.models import Incident, NotificationOutbox +from monlet_server.services.notifier_worker import _backoff, tick +from monlet_server.services.notifiers.alertmanager import AlertmanagerNotifier +from monlet_server.services.notifiers.base import DeliveryResult +from monlet_server.services.notifiers.debug import DebugNotifier +from monlet_server.services.notifiers.telegram import TelegramNotifier +from monlet_server.services.notifiers.webhook import WebhookNotifier +from monlet_server.settings import Settings + + +def _payload() -> dict: + return { + "incident_id": str(uuid4()), + "agent_id": "agent-1", + "check_id": "chk-1", + "status": "critical", + "severity": "critical", + "incident_key": "agent-1:chk-1", + "observed_at": datetime.now(UTC).isoformat(), + "opened_at": datetime.now(UTC).isoformat(), + "summary": "boom", + "output": "stderr: boom token=abc123", + } + + +class _StubTransport(httpx.AsyncBaseTransport): + def __init__(self, handler): + self._handler = handler + self.requests: list[httpx.Request] = [] + + async def handle_async_request(self, request): + self.requests.append(request) + return self._handler(request) + + +def _client(handler) -> tuple[httpx.AsyncClient, _StubTransport]: + t = _StubTransport(handler) + return httpx.AsyncClient(transport=t, base_url="http://x"), t + + +@pytest.mark.asyncio +async def test_debug_notifier_always_ok(): + res = await DebugNotifier().deliver("firing", _payload()) + assert res.ok and not res.permanent + + +@pytest.mark.asyncio +async def test_telegram_success_and_redaction(): + def handler(req): + body = req.content.decode() + assert "token=abc123" not in body + assert "***" in body + return httpx.Response(200, json={"ok": True}) + + client, t = _client(handler) + try: + n = TelegramNotifier(client, "TOK", "123") + res = await n.deliver("firing", _payload()) + assert res.ok + assert "/botTOK/sendMessage" in str(t.requests[0].url) + finally: + await client.aclose() + + +@pytest.mark.asyncio +async def test_telegram_4xx_permanent(): + client, _ = _client(lambda r: httpx.Response(400, json={})) + try: + n = TelegramNotifier(client, "TOK", "123") + res = await n.deliver("firing", _payload()) + assert not res.ok and res.permanent + finally: + await client.aclose() + + +@pytest.mark.asyncio +async def test_telegram_5xx_transient(): + client, _ = _client(lambda r: httpx.Response(503, json={})) + try: + n = TelegramNotifier(client, "TOK", "123") + res = await n.deliver("firing", _payload()) + assert not res.ok and not res.permanent + finally: + await client.aclose() + + +@pytest.mark.asyncio +async def test_telegram_network_error_transient(): + def handler(req): + raise httpx.ConnectError("nope") + + client, _ = _client(handler) + try: + n = TelegramNotifier(client, "TOK", "123") + res = await n.deliver("firing", _payload()) + assert not res.ok and not res.permanent + finally: + await client.aclose() + + +@pytest.mark.asyncio +async def test_webhook_sends_bearer_and_redacts_payload(): + captured: dict = {} + + def handler(req): + captured["auth"] = req.headers.get("authorization") + captured["body"] = req.content.decode() + return httpx.Response(202) + + client, _ = _client(handler) + try: + n = WebhookNotifier(client, "http://x/hook", token="WTOK") + res = await n.deliver("firing", _payload()) + assert res.ok + assert captured["auth"] == "Bearer WTOK" + assert "token=abc123" not in captured["body"] + finally: + await client.aclose() + + +@pytest.mark.asyncio +async def test_alertmanager_payload_shape(): + captured: dict = {} + + def handler(req): + captured["url"] = str(req.url) + captured["body"] = req.content.decode() + return httpx.Response(200) + + client, _ = _client(handler) + try: + n = AlertmanagerNotifier(client, "http://am/") + res = await n.deliver("firing", _payload()) + assert res.ok + assert captured["url"].endswith("/api/v2/alerts") + assert '"alertname":"monlet_incident"' in captured["body"] + finally: + await client.aclose() + + +def test_backoff_matches_adr_0005(): + # ADR-0005: 5s, 15s, 60s, 5m, 30m, 1h, 2h, 4h. + expected = [5, 15, 60, 300, 1800, 3600, 7200, 14400] + for i, sec in enumerate(expected, start=1): + assert _backoff(i).total_seconds() == sec + # Caps at last entry. + assert _backoff(99).total_seconds() == 14400 + + +# ---- Worker integration with real PG ---- + + +@pytest.fixture +def settings_full() -> Settings: + s = Settings() + s.notifier_max_attempts = 3 + s.notifier_batch_size = 50 + return s + + +async def _seed_incident_and_outbox( + sm: async_sessionmaker, notifier: str +) -> tuple[NotificationOutbox, Incident]: + async with sm() as session: + # Need agent for FK on incidents.agent_id? No FK on incidents.agent_id per schema. + inc = Incident( + id=uuid4(), + incident_key=f"k-{uuid4()}", + agent_id="agent-1", + check_id="chk-1", + state="open", + severity="critical", + opened_at=datetime.now(UTC), + last_event_id=uuid4(), + summary="boom", + ) + session.add(inc) + await session.flush() + row = NotificationOutbox( + id=uuid4(), + incident_id=inc.id, + notifier=notifier, + event_type="firing", + state="pending", + attempts=0, + next_attempt_at=datetime.now(UTC), + payload=_payload(), + ) + session.add(row) + await session.commit() + return row, inc + + +class _StubNotifier: + def __init__(self, name: str, results: list[DeliveryResult]) -> None: + self.name = name + self._results = list(results) + self.calls = 0 + + async def deliver(self, event_type: str, payload: dict) -> DeliveryResult: + self.calls += 1 + if self._results: + return self._results.pop(0) + return DeliveryResult(ok=True) + + +@pytest.mark.asyncio +async def test_worker_marks_sent(engine, settings_full): + sm = async_sessionmaker(engine, expire_on_commit=False) + row, _ = await _seed_incident_and_outbox(sm, "stub") + registry = {"stub": _StubNotifier("stub", [DeliveryResult(ok=True)])} + n = await tick(sm, registry, settings_full) + assert n == 1 + async with sm() as session: + fresh = ( + await session.execute(select(NotificationOutbox).where(NotificationOutbox.id == row.id)) + ).scalar_one() + assert fresh.state == "sent" + assert fresh.attempts == 1 + assert fresh.last_error is None + + +@pytest.mark.asyncio +async def test_worker_retries_then_succeeds(engine, settings_full): + sm = async_sessionmaker(engine, expire_on_commit=False) + row, _ = await _seed_incident_and_outbox(sm, "stub") + notifier = _StubNotifier( + "stub", + [DeliveryResult(ok=False, permanent=False, error="net"), DeliveryResult(ok=True)], + ) + registry = {"stub": notifier} + await tick(sm, registry, settings_full) + async with sm() as session: + fresh = ( + await session.execute(select(NotificationOutbox).where(NotificationOutbox.id == row.id)) + ).scalar_one() + assert fresh.state == "retry" + assert fresh.attempts == 1 + assert fresh.next_attempt_at is not None + # Force eligibility for second tick. + fresh.next_attempt_at = datetime.now(UTC) - timedelta(seconds=1) + await session.commit() + await tick(sm, registry, settings_full) + async with sm() as session: + fresh = ( + await session.execute(select(NotificationOutbox).where(NotificationOutbox.id == row.id)) + ).scalar_one() + assert fresh.state == "sent" + assert fresh.attempts == 2 + + +@pytest.mark.asyncio +async def test_worker_permanent_failure_marked_failed(engine, settings_full): + sm = async_sessionmaker(engine, expire_on_commit=False) + row, _ = await _seed_incident_and_outbox(sm, "stub") + registry = { + "stub": _StubNotifier("stub", [DeliveryResult(ok=False, permanent=True, error="http_400")]) + } + await tick(sm, registry, settings_full) + async with sm() as session: + fresh = ( + await session.execute(select(NotificationOutbox).where(NotificationOutbox.id == row.id)) + ).scalar_one() + assert fresh.state == "failed" + assert fresh.last_error == "http_400" + + +@pytest.mark.asyncio +async def test_worker_unknown_notifier_discarded(engine, settings_full): + sm = async_sessionmaker(engine, expire_on_commit=False) + row, _ = await _seed_incident_and_outbox(sm, "ghost") + await tick(sm, {}, settings_full) + async with sm() as session: + fresh = ( + await session.execute(select(NotificationOutbox).where(NotificationOutbox.id == row.id)) + ).scalar_one() + assert fresh.state == "discarded" + + +@pytest.mark.asyncio +async def test_worker_exhausts_attempts(engine, settings_full): + sm = async_sessionmaker(engine, expire_on_commit=False) + row, _ = await _seed_incident_and_outbox(sm, "stub") + notifier = _StubNotifier( + "stub", + [DeliveryResult(ok=False, permanent=False, error="boom")] * 10, + ) + registry = {"stub": notifier} + for _ in range(settings_full.notifier_max_attempts): + async with sm() as session: + await session.execute(select(NotificationOutbox).where(NotificationOutbox.id == row.id)) + # Force ready. + await session.execute( + NotificationOutbox.__table__.update() + .where(NotificationOutbox.id == row.id) + .values(next_attempt_at=datetime.now(UTC) - timedelta(seconds=1)) + ) + await session.commit() + await tick(sm, registry, settings_full) + async with sm() as session: + fresh = ( + await session.execute(select(NotificationOutbox).where(NotificationOutbox.id == row.id)) + ).scalar_one() + assert fresh.state == "failed" + assert fresh.attempts == settings_full.notifier_max_attempts + + +@pytest.mark.asyncio +async def test_worker_recovers_stuck_sending(engine, settings_full): + sm = async_sessionmaker(engine, expire_on_commit=False) + row, _ = await _seed_incident_and_outbox(sm, "stub") + async with sm() as session: + await session.execute( + NotificationOutbox.__table__.update() + .where(NotificationOutbox.id == row.id) + .values(state="sending", updated_at=datetime.now(UTC) - timedelta(hours=1)) + ) + await session.commit() + settings_full.notifier_lease_sec = 60 + registry = {"stub": _StubNotifier("stub", [DeliveryResult(ok=True)])} + n = await tick(sm, registry, settings_full) + assert n == 1 + async with sm() as session: + fresh = ( + await session.execute(select(NotificationOutbox).where(NotificationOutbox.id == row.id)) + ).scalar_one() + assert fresh.state == "sent" + + +@pytest.mark.asyncio +async def test_alertmanager_severity_not_in_labels(): + captured: dict = {} + + def handler(req): + captured["body"] = req.content.decode() + return httpx.Response(200) + + client, _ = _client(handler) + try: + n = AlertmanagerNotifier(client, "http://am") + await n.deliver("firing", _payload()) + import json as _json + + alerts = _json.loads(captured["body"]) + assert "severity" not in alerts[0]["labels"] + assert alerts[0]["annotations"]["severity"] == "critical" + finally: + await client.aclose() + + +@pytest.mark.asyncio +async def test_telegram_redacts_secret_in_incident_key(): + captured: dict = {} + + def handler(req): + captured["body"] = req.content.decode() + return httpx.Response(200) + + client, _ = _client(handler) + try: + p = _payload() + p["incident_key"] = "agent-1:chk-1:token=leakedsecret" + n = TelegramNotifier(client, "TOK", "1") + await n.deliver("firing", p) + assert "leakedsecret" not in captured["body"] + finally: + await client.aclose() + + +@pytest.mark.asyncio +async def test_fanout_creates_row_per_enabled_notifier( + app_client, auth_headers, session, monkeypatch +): + # Enable telegram (token+chat_id satisfied) and webhook in addition to debug. + os.environ["MONLET_NOTIFIER_TELEGRAM_ENABLED"] = "true" + os.environ["MONLET_NOTIFIER_TELEGRAM_TOKEN"] = "tok" + os.environ["MONLET_NOTIFIER_TELEGRAM_CHAT_ID"] = "1" + os.environ["MONLET_NOTIFIER_WEBHOOK_ENABLED"] = "true" + os.environ["MONLET_NOTIFIER_WEBHOOK_URL"] = "http://x/hook" + from monlet_server.settings import reset_settings_cache + + reset_settings_cache() + try: + from tests._helpers import make_event, make_heartbeat + + await app_client.post("/api/v1/heartbeat", json=make_heartbeat(), headers=auth_headers) + ev = make_event(status="critical", exit_code=2) + r = await app_client.post( + "/api/v1/events", + json={"agent_id": "agent-1", "events": [ev]}, + headers=auth_headers, + ) + assert r.status_code == 202 + rows = (await session.execute(select(NotificationOutbox))).scalars().all() + names = sorted(r.notifier for r in rows) + assert names == ["debug", "telegram", "webhook"] + finally: + for k in ( + "MONLET_NOTIFIER_TELEGRAM_ENABLED", + "MONLET_NOTIFIER_TELEGRAM_TOKEN", + "MONLET_NOTIFIER_TELEGRAM_CHAT_ID", + "MONLET_NOTIFIER_WEBHOOK_ENABLED", + "MONLET_NOTIFIER_WEBHOOK_URL", + ): + os.environ.pop(k, None) + reset_settings_cache() diff --git a/server/tests/test_pagination.py b/server/tests/test_pagination.py new file mode 100644 index 0000000..ce50e30 --- /dev/null +++ b/server/tests/test_pagination.py @@ -0,0 +1,100 @@ +import pytest + +from ._helpers import make_event, make_heartbeat + + +@pytest.mark.asyncio +async def test_agents_cursor(app_client, auth_headers): + for i in range(5): + await app_client.post( + "/api/v1/heartbeat", json=make_heartbeat(f"a-{i}"), headers=auth_headers + ) + r1 = await app_client.get("/api/v1/agents?limit=2", headers=auth_headers) + p1 = r1.json() + assert len(p1["items"]) == 2 + assert p1["next_cursor"] + + r2 = await app_client.get( + f"/api/v1/agents?limit=2&cursor={p1['next_cursor']}", headers=auth_headers + ) + p2 = r2.json() + assert len(p2["items"]) == 2 + ids1 = {x["agent_id"] for x in p1["items"]} + ids2 = {x["agent_id"] for x in p2["items"]} + assert ids1.isdisjoint(ids2) + + +@pytest.mark.asyncio +async def test_checks_cursor(app_client, auth_headers): + await app_client.post("/api/v1/heartbeat", json=make_heartbeat(), headers=auth_headers) + for i in range(4): + ev = make_event(check_id=f"chk-{i}") + await app_client.post( + "/api/v1/events", json={"agent_id": "agent-1", "events": [ev]}, headers=auth_headers + ) + r1 = await app_client.get("/api/v1/checks?limit=2", headers=auth_headers) + p1 = r1.json() + assert len(p1["items"]) == 2 + assert p1["next_cursor"] + r2 = await app_client.get( + f"/api/v1/checks?limit=2&cursor={p1['next_cursor']}", headers=auth_headers + ) + p2 = r2.json() + assert len(p2["items"]) == 2 + + +@pytest.mark.asyncio +async def test_checks_rejects_invalid_agent_id_query(app_client, auth_headers): + r = await app_client.get("/api/v1/checks?agent_id=bad%20id", headers=auth_headers) + assert r.status_code == 400 + assert r.json()["error"]["code"] == "validation" + + +@pytest.mark.asyncio +async def test_events_query_rejects_invalid_id_queries(app_client, auth_headers): + r = await app_client.get("/api/v1/events/query?agent_id=bad%20id", headers=auth_headers) + assert r.status_code == 400 + assert r.json()["error"]["code"] == "validation" + + r = await app_client.get("/api/v1/events/query?check_id=bad%20id", headers=auth_headers) + assert r.status_code == 400 + assert r.json()["error"]["code"] == "validation" + + +@pytest.mark.asyncio +async def test_incidents_cursor(app_client, auth_headers): + await app_client.post("/api/v1/heartbeat", json=make_heartbeat(), headers=auth_headers) + for i in range(3): + ev = make_event(check_id=f"chk-{i}", status="critical", exit_code=2) + await app_client.post( + "/api/v1/events", json={"agent_id": "agent-1", "events": [ev]}, headers=auth_headers + ) + r1 = await app_client.get("/api/v1/incidents?limit=2", headers=auth_headers) + p1 = r1.json() + assert len(p1["items"]) == 2 + assert p1["next_cursor"] + r2 = await app_client.get( + f"/api/v1/incidents?limit=2&cursor={p1['next_cursor']}", headers=auth_headers + ) + p2 = r2.json() + assert len(p2["items"]) == 1 + + +@pytest.mark.asyncio +async def test_outbox_cursor(app_client, auth_headers): + await app_client.post("/api/v1/heartbeat", json=make_heartbeat(), headers=auth_headers) + for i in range(3): + ev = make_event(check_id=f"chk-{i}", status="critical", exit_code=2) + await app_client.post( + "/api/v1/events", json={"agent_id": "agent-1", "events": [ev]}, headers=auth_headers + ) + r1 = await app_client.get("/api/v1/notifiers/outbox?limit=2", headers=auth_headers) + p1 = r1.json() + assert len(p1["items"]) == 2 + assert p1["next_cursor"] + + +@pytest.mark.asyncio +async def test_invalid_cursor_400(app_client, auth_headers): + r = await app_client.get("/api/v1/agents?cursor=$$$bad", headers=auth_headers) + assert r.status_code == 400 diff --git a/server/tests/test_redaction.py b/server/tests/test_redaction.py new file mode 100644 index 0000000..0d94ff9 --- /dev/null +++ b/server/tests/test_redaction.py @@ -0,0 +1,47 @@ +from monlet_server.redaction import redact + + +def test_redact_authorization_header_full_value(): + # Per security.md the entire Authorization value is redacted. + assert redact("Authorization: Bearer abcdef123") == "Authorization: ***" + + +def test_redact_bearer_outside_header(): + assert redact("token sent as Bearer abcdef123 to peer") == "token sent as Bearer *** to peer" + + +def test_redact_token_kv(): + out = redact('token="abc123secret"') + assert "abc123secret" not in out + assert "***" in out + + +def test_redact_password_kv(): + out = redact("password=qwerty12345") + assert "qwerty12345" not in out + + +def test_redact_authorization_header_value(): + out = redact("authorization: deadbeefcafe") + assert "deadbeefcafe" not in out + assert "***" in out + + +def test_redact_cookie_header(): + out = redact("Cookie: session=abc; user=u1") + assert "abc" not in out + assert "***" in out + + +def test_redact_set_cookie_header(): + out = redact("Set-Cookie: sid=verysecret123; Path=/") + assert "verysecret123" not in out + + +def test_redact_credential_env(): + out = redact("MY_CREDENTIAL=topsecret") + assert "topsecret" not in out + + +def test_none_passthrough(): + assert redact(None) is None diff --git a/server/tests/test_request_id.py b/server/tests/test_request_id.py new file mode 100644 index 0000000..53adaa9 --- /dev/null +++ b/server/tests/test_request_id.py @@ -0,0 +1,26 @@ +import uuid + +import pytest + + +@pytest.mark.asyncio +async def test_generated_request_id(app_client): + r = await app_client.get("/api/v1/health") + rid = r.headers.get("X-Request-Id") + assert rid + uuid.UUID(rid) + + +@pytest.mark.asyncio +async def test_incoming_request_id_preserved(app_client): + rid_in = str(uuid.uuid4()) + r = await app_client.get("/api/v1/health", headers={"X-Request-Id": rid_in}) + assert r.headers.get("X-Request-Id") == rid_in + + +@pytest.mark.asyncio +async def test_error_includes_request_id(app_client): + rid_in = str(uuid.uuid4()) + r = await app_client.get("/api/v1/agents", headers={"X-Request-Id": rid_in}) + assert r.status_code == 401 + assert r.json()["error"]["request_id"] == rid_in diff --git a/server/uv.lock b/server/uv.lock new file mode 100644 index 0000000..c6f5ee8 --- /dev/null +++ b/server/uv.lock @@ -0,0 +1,912 @@ +version = 1 +revision = 3 +requires-python = "==3.14.*" + +[[package]] +name = "alembic" +version = "1.18.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mako" }, + { name = "sqlalchemy" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/94/13/8b084e0f2efb0275a1d534838844926f798bd766566b1375174e2448cd31/alembic-1.18.4.tar.gz", hash = "sha256:cb6e1fd84b6174ab8dbb2329f86d631ba9559dd78df550b57804d607672cedbc", size = 2056725, upload-time = "2026-02-10T16:00:47.195Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/29/6533c317b74f707ea28f8d633734dbda2119bbadfc61b2f3640ba835d0f7/alembic-1.18.4-py3-none-any.whl", hash = "sha256:a5ed4adcf6d8a4cb575f3d759f071b03cd6e5c7618eb796cb52497be25bfe19a", size = 263893, upload-time = "2026-02-10T16:00:49.997Z" }, +] + +[[package]] +name = "annotated-doc" +version = "0.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" }, +] + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "anyio" +version = "4.13.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" }, +] + +[[package]] +name = "asyncpg" +version = "0.31.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fe/cc/d18065ce2380d80b1bcce927c24a2642efd38918e33fd724bc4bca904877/asyncpg-0.31.0.tar.gz", hash = "sha256:c989386c83940bfbd787180f2b1519415e2d3d6277a70d9d0f0145ac73500735", size = 993667, upload-time = "2025-11-24T23:27:00.812Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3c/36/e9450d62e84a13aea6580c83a47a437f26c7ca6fa0f0fd40b6670793ea30/asyncpg-0.31.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:f6b56b91bb0ffc328c4e3ed113136cddd9deefdf5f79ab448598b9772831df44", size = 660867, upload-time = "2025-11-24T23:26:17.631Z" }, + { url = "https://files.pythonhosted.org/packages/82/4b/1d0a2b33b3102d210439338e1beea616a6122267c0df459ff0265cd5807a/asyncpg-0.31.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:334dec28cf20d7f5bb9e45b39546ddf247f8042a690bff9b9573d00086e69cb5", size = 638349, upload-time = "2025-11-24T23:26:19.689Z" }, + { url = "https://files.pythonhosted.org/packages/41/aa/e7f7ac9a7974f08eff9183e392b2d62516f90412686532d27e196c0f0eeb/asyncpg-0.31.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:98cc158c53f46de7bb677fd20c417e264fc02b36d901cc2a43bd6cb0dc6dbfd2", size = 3410428, upload-time = "2025-11-24T23:26:21.275Z" }, + { url = "https://files.pythonhosted.org/packages/6f/de/bf1b60de3dede5c2731e6788617a512bc0ebd9693eac297ee74086f101d7/asyncpg-0.31.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9322b563e2661a52e3cdbc93eed3be7748b289f792e0011cb2720d278b366ce2", size = 3471678, upload-time = "2025-11-24T23:26:23.627Z" }, + { url = "https://files.pythonhosted.org/packages/46/78/fc3ade003e22d8bd53aaf8f75f4be48f0b460fa73738f0391b9c856a9147/asyncpg-0.31.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19857a358fc811d82227449b7ca40afb46e75b33eb8897240c3839dd8b744218", size = 3313505, upload-time = "2025-11-24T23:26:25.235Z" }, + { url = "https://files.pythonhosted.org/packages/bf/e9/73eb8a6789e927816f4705291be21f2225687bfa97321e40cd23055e903a/asyncpg-0.31.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ba5f8886e850882ff2c2ace5732300e99193823e8107e2c53ef01c1ebfa1e85d", size = 3434744, upload-time = "2025-11-24T23:26:26.944Z" }, + { url = "https://files.pythonhosted.org/packages/08/4b/f10b880534413c65c5b5862f79b8e81553a8f364e5238832ad4c0af71b7f/asyncpg-0.31.0-cp314-cp314-win32.whl", hash = "sha256:cea3a0b2a14f95834cee29432e4ddc399b95700eb1d51bbc5bfee8f31fa07b2b", size = 532251, upload-time = "2025-11-24T23:26:28.404Z" }, + { url = "https://files.pythonhosted.org/packages/d3/2d/7aa40750b7a19efa5d66e67fc06008ca0f27ba1bd082e457ad82f59aba49/asyncpg-0.31.0-cp314-cp314-win_amd64.whl", hash = "sha256:04d19392716af6b029411a0264d92093b6e5e8285ae97a39957b9a9c14ea72be", size = 604901, upload-time = "2025-11-24T23:26:30.34Z" }, + { url = "https://files.pythonhosted.org/packages/ce/fe/b9dfe349b83b9dee28cc42360d2c86b2cdce4cb551a2c2d27e156bcac84d/asyncpg-0.31.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:bdb957706da132e982cc6856bb2f7b740603472b54c3ebc77fe60ea3e57e1bd2", size = 702280, upload-time = "2025-11-24T23:26:32Z" }, + { url = "https://files.pythonhosted.org/packages/6a/81/e6be6e37e560bd91e6c23ea8a6138a04fd057b08cf63d3c5055c98e81c1d/asyncpg-0.31.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6d11b198111a72f47154fa03b85799f9be63701e068b43f84ac25da0bda9cb31", size = 682931, upload-time = "2025-11-24T23:26:33.572Z" }, + { url = "https://files.pythonhosted.org/packages/a6/45/6009040da85a1648dd5bc75b3b0a062081c483e75a1a29041ae63a0bf0dc/asyncpg-0.31.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:18c83b03bc0d1b23e6230f5bf8d4f217dc9bc08644ce0502a9d91dc9e634a9c7", size = 3581608, upload-time = "2025-11-24T23:26:35.638Z" }, + { url = "https://files.pythonhosted.org/packages/7e/06/2e3d4d7608b0b2b3adbee0d0bd6a2d29ca0fc4d8a78f8277df04e2d1fd7b/asyncpg-0.31.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e009abc333464ff18b8f6fd146addffd9aaf63e79aa3bb40ab7a4c332d0c5e9e", size = 3498738, upload-time = "2025-11-24T23:26:37.275Z" }, + { url = "https://files.pythonhosted.org/packages/7d/aa/7d75ede780033141c51d83577ea23236ba7d3a23593929b32b49db8ed36e/asyncpg-0.31.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3b1fbcb0e396a5ca435a8826a87e5c2c2cc0c8c68eb6fadf82168056b0e53a8c", size = 3401026, upload-time = "2025-11-24T23:26:39.423Z" }, + { url = "https://files.pythonhosted.org/packages/ba/7a/15e37d45e7f7c94facc1e9148c0e455e8f33c08f0b8a0b1deb2c5171771b/asyncpg-0.31.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8df714dba348efcc162d2adf02d213e5fab1bd9f557e1305633e851a61814a7a", size = 3429426, upload-time = "2025-11-24T23:26:41.032Z" }, + { url = "https://files.pythonhosted.org/packages/13/d5/71437c5f6ae5f307828710efbe62163974e71237d5d46ebd2869ea052d10/asyncpg-0.31.0-cp314-cp314t-win32.whl", hash = "sha256:1b41f1afb1033f2b44f3234993b15096ddc9cd71b21a42dbd87fc6a57b43d65d", size = 614495, upload-time = "2025-11-24T23:26:42.659Z" }, + { url = "https://files.pythonhosted.org/packages/3c/d7/8fb3044eaef08a310acfe23dae9a8e2e07d305edc29a53497e52bc76eca7/asyncpg-0.31.0-cp314-cp314t-win_amd64.whl", hash = "sha256:bd4107bb7cdd0e9e65fae66a62afd3a249663b844fa34d479f6d5b3bef9c04c3", size = 706062, upload-time = "2025-11-24T23:26:44.086Z" }, +] + +[[package]] +name = "certifi" +version = "2026.5.20" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/ce/ee2ecad540810a79593028e88299baeae54d346cc7a0d94b6199988b89b1/certifi-2026.5.20.tar.gz", hash = "sha256:69dea482ab64caa7b9f6aba1c6bf48bb6a5448d1c0f1b17ab42ad8c763a5344d", size = 135422, upload-time = "2026-05-20T11:46:50.073Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/59/8c/57e832b7af6d7c5abe66eb3fbe3a3a32f4d11ea23a1aa7131371035be991/certifi-2026.5.20-py3-none-any.whl", hash = "sha256:3c52e209ba0a4ad7aebe60436a4ab349c39e1e602e8c134221e546902ad25897", size = 134134, upload-time = "2026-05-20T11:46:48.578Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271, upload-time = "2026-04-02T09:28:39.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/97/c8/c67cb8c70e19ef1960b97b22ed2a1567711de46c4ddf19799923adc836c2/charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0", size = 309234, upload-time = "2026-04-02T09:27:07.194Z" }, + { url = "https://files.pythonhosted.org/packages/99/85/c091fdee33f20de70d6c8b522743b6f831a2f1cd3ff86de4c6a827c48a76/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a", size = 208042, upload-time = "2026-04-02T09:27:08.749Z" }, + { url = "https://files.pythonhosted.org/packages/87/1c/ab2ce611b984d2fd5d86a5a8a19c1ae26acac6bad967da4967562c75114d/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b", size = 228706, upload-time = "2026-04-02T09:27:09.951Z" }, + { url = "https://files.pythonhosted.org/packages/a8/29/2b1d2cb00bf085f59d29eb773ce58ec2d325430f8c216804a0a5cd83cbca/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41", size = 224727, upload-time = "2026-04-02T09:27:11.175Z" }, + { url = "https://files.pythonhosted.org/packages/47/5c/032c2d5a07fe4d4855fea851209cca2b6f03ebeb6d4e3afdb3358386a684/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e", size = 215882, upload-time = "2026-04-02T09:27:12.446Z" }, + { url = "https://files.pythonhosted.org/packages/2c/c2/356065d5a8b78ed04499cae5f339f091946a6a74f91e03476c33f0ab7100/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae", size = 200860, upload-time = "2026-04-02T09:27:13.721Z" }, + { url = "https://files.pythonhosted.org/packages/0c/cd/a32a84217ced5039f53b29f460962abb2d4420def55afabe45b1c3c7483d/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18", size = 211564, upload-time = "2026-04-02T09:27:15.272Z" }, + { url = "https://files.pythonhosted.org/packages/44/86/58e6f13ce26cc3b8f4a36b94a0f22ae2f00a72534520f4ae6857c4b81f89/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b", size = 211276, upload-time = "2026-04-02T09:27:16.834Z" }, + { url = "https://files.pythonhosted.org/packages/8f/fe/d17c32dc72e17e155e06883efa84514ca375f8a528ba2546bee73fc4df81/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356", size = 201238, upload-time = "2026-04-02T09:27:18.229Z" }, + { url = "https://files.pythonhosted.org/packages/6a/29/f33daa50b06525a237451cdb6c69da366c381a3dadcd833fa5676bc468b3/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab", size = 230189, upload-time = "2026-04-02T09:27:19.445Z" }, + { url = "https://files.pythonhosted.org/packages/b6/6e/52c84015394a6a0bdcd435210a7e944c5f94ea1055f5cc5d56c5fe368e7b/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46", size = 211352, upload-time = "2026-04-02T09:27:20.79Z" }, + { url = "https://files.pythonhosted.org/packages/8c/d7/4353be581b373033fb9198bf1da3cf8f09c1082561e8e922aa7b39bf9fe8/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44", size = 227024, upload-time = "2026-04-02T09:27:22.063Z" }, + { url = "https://files.pythonhosted.org/packages/30/45/99d18aa925bd1740098ccd3060e238e21115fffbfdcb8f3ece837d0ace6c/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72", size = 217869, upload-time = "2026-04-02T09:27:23.486Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/5ee478aa53f4bb7996482153d4bfe1b89e0f087f0ab6b294fcf92d595873/charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10", size = 148541, upload-time = "2026-04-02T09:27:25.146Z" }, + { url = "https://files.pythonhosted.org/packages/48/77/72dcb0921b2ce86420b2d79d454c7022bf5be40202a2a07906b9f2a35c97/charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f", size = 159634, upload-time = "2026-04-02T09:27:26.642Z" }, + { url = "https://files.pythonhosted.org/packages/c6/a3/c2369911cd72f02386e4e340770f6e158c7980267da16af8f668217abaa0/charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246", size = 148384, upload-time = "2026-04-02T09:27:28.271Z" }, + { url = "https://files.pythonhosted.org/packages/94/09/7e8a7f73d24dba1f0035fbbf014d2c36828fc1bf9c88f84093e57d315935/charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24", size = 330133, upload-time = "2026-04-02T09:27:29.474Z" }, + { url = "https://files.pythonhosted.org/packages/8d/da/96975ddb11f8e977f706f45cddd8540fd8242f71ecdb5d18a80723dcf62c/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79", size = 216257, upload-time = "2026-04-02T09:27:30.793Z" }, + { url = "https://files.pythonhosted.org/packages/e5/e8/1d63bf8ef2d388e95c64b2098f45f84758f6d102a087552da1485912637b/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960", size = 234851, upload-time = "2026-04-02T09:27:32.44Z" }, + { url = "https://files.pythonhosted.org/packages/9b/40/e5ff04233e70da2681fa43969ad6f66ca5611d7e669be0246c4c7aaf6dc8/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4", size = 233393, upload-time = "2026-04-02T09:27:34.03Z" }, + { url = "https://files.pythonhosted.org/packages/be/c1/06c6c49d5a5450f76899992f1ee40b41d076aee9279b49cf9974d2f313d5/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e", size = 223251, upload-time = "2026-04-02T09:27:35.369Z" }, + { url = "https://files.pythonhosted.org/packages/2b/9f/f2ff16fb050946169e3e1f82134d107e5d4ae72647ec8a1b1446c148480f/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1", size = 206609, upload-time = "2026-04-02T09:27:36.661Z" }, + { url = "https://files.pythonhosted.org/packages/69/d5/a527c0cd8d64d2eab7459784fb4169a0ac76e5a6fc5237337982fd61347e/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44", size = 220014, upload-time = "2026-04-02T09:27:38.019Z" }, + { url = "https://files.pythonhosted.org/packages/7e/80/8a7b8104a3e203074dc9aa2c613d4b726c0e136bad1cc734594b02867972/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e", size = 218979, upload-time = "2026-04-02T09:27:39.37Z" }, + { url = "https://files.pythonhosted.org/packages/02/9a/b759b503d507f375b2b5c153e4d2ee0a75aa215b7f2489cf314f4541f2c0/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3", size = 209238, upload-time = "2026-04-02T09:27:40.722Z" }, + { url = "https://files.pythonhosted.org/packages/c2/4e/0f3f5d47b86bdb79256e7290b26ac847a2832d9a4033f7eb2cd4bcf4bb5b/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0", size = 236110, upload-time = "2026-04-02T09:27:42.33Z" }, + { url = "https://files.pythonhosted.org/packages/96/23/bce28734eb3ed2c91dcf93abeb8a5cf393a7b2749725030bb630e554fdd8/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e", size = 219824, upload-time = "2026-04-02T09:27:43.924Z" }, + { url = "https://files.pythonhosted.org/packages/2c/6f/6e897c6984cc4d41af319b077f2f600fc8214eb2fe2d6bcb79141b882400/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb", size = 233103, upload-time = "2026-04-02T09:27:45.348Z" }, + { url = "https://files.pythonhosted.org/packages/76/22/ef7bd0fe480a0ae9b656189ec00744b60933f68b4f42a7bb06589f6f576a/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe", size = 225194, upload-time = "2026-04-02T09:27:46.706Z" }, + { url = "https://files.pythonhosted.org/packages/c5/a7/0e0ab3e0b5bc1219bd80a6a0d4d72ca74d9250cb2382b7c699c147e06017/charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0", size = 159827, upload-time = "2026-04-02T09:27:48.053Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1d/29d32e0fb40864b1f878c7f5a0b343ae676c6e2b271a2d55cc3a152391da/charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c", size = 174168, upload-time = "2026-04-02T09:27:49.795Z" }, + { url = "https://files.pythonhosted.org/packages/de/32/d92444ad05c7a6e41fb2036749777c163baf7a0301a040cb672d6b2b1ae9/charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d", size = 153018, upload-time = "2026-04-02T09:27:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" }, +] + +[[package]] +name = "click" +version = "8.4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9b/98/518d8e5081007684232226f475082b30087d0f585e8457db087298259f49/click-8.4.1.tar.gz", hash = "sha256:918b5633eddf6b41c32d4f454bf0de810065c74e3f7dbf8ee5452f8be88d3e96", size = 353007, upload-time = "2026-05-22T04:08:37.769Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/0d/67e5b4109ea4a837e80daa87c2c696711955e40449a97e8926672534def2/click-8.4.1-py3-none-any.whl", hash = "sha256:482be17c6991b8c19c5429a1e995d9b0efdbb63172824c41f99965dc0ade8ec2", size = 116639, upload-time = "2026-05-22T04:08:35.26Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "docker" +version = "7.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pywin32", marker = "sys_platform == 'win32'" }, + { name = "requests" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/91/9b/4a2ea29aeba62471211598dac5d96825bb49348fa07e906ea930394a83ce/docker-7.1.0.tar.gz", hash = "sha256:ad8c70e6e3f8926cb8a92619b832b4ea5299e2831c14284663184e200546fa6c", size = 117834, upload-time = "2024-05-23T11:13:57.216Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e3/26/57c6fb270950d476074c087527a558ccb6f4436657314bfb6cdf484114c4/docker-7.1.0-py3-none-any.whl", hash = "sha256:c96b93b7f0a746f9e77d325bcfb87422a3d8bd4f03136ae8a85b37f1898d5fc0", size = 147774, upload-time = "2024-05-23T11:13:55.01Z" }, +] + +[[package]] +name = "fastapi" +version = "0.136.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/81/2d/ff8d91d7b564d464629a0fd50a4489c97fcb836ac230bf3a7269232a9b1f/fastapi-0.136.3.tar.gz", hash = "sha256:e487fae93ad408e6f47641ee4dfe389864fd7bec92e547ea8498fc13f43e83ab", size = 396410, upload-time = "2026-05-23T18:53:15.192Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/82/45359b62a067409bd929ae8a56b8ed13e5a8c8a61194b3c236920999ab83/fastapi-0.136.3-py3-none-any.whl", hash = "sha256:3d2a69bdf04b7e9f3afa292c3bc7a98816bbfafa10bc9b45f3f3700d2f761620", size = 117481, upload-time = "2026-05-23T18:53:16.924Z" }, +] + +[[package]] +name = "freezegun" +version = "1.5.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "python-dateutil" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/95/dd/23e2f4e357f8fd3bdff613c1fe4466d21bfb00a6177f238079b17f7b1c84/freezegun-1.5.5.tar.gz", hash = "sha256:ac7742a6cc6c25a2c35e9292dfd554b897b517d2dec26891a2e8debf205cb94a", size = 35914, upload-time = "2025-08-09T10:39:08.338Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5e/2e/b41d8a1a917d6581fc27a35d05561037b048e47df50f27f8ac9c7e27a710/freezegun-1.5.5-py3-none-any.whl", hash = "sha256:cd557f4a75cf074e84bc374249b9dd491eaeacd61376b9eb3c423282211619d2", size = 19266, upload-time = "2025-08-09T10:39:06.636Z" }, +] + +[[package]] +name = "greenlet" +version = "3.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6d/6e/802acd792aebb2256fbbee8cacf2727faaeb6f240ac11008f09eae4414bc/greenlet-3.5.1.tar.gz", hash = "sha256:5a56aeb7d5d9cc4b3a735efb5095bd4b4f6f0e4f93e5ca876d0e2315137b7829", size = 197356, upload-time = "2026-05-20T15:05:03.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/cb/c62454606daf5640369c94d8a9dd540599b1bfc090e2d2180cb77f4038d2/greenlet-3.5.1-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:d8ab31c9de8651a2facdd5c5bb0011f2380dd1a7af78ce2adf4b56095294fc07", size = 285579, upload-time = "2026-05-20T13:08:56.396Z" }, + { url = "https://files.pythonhosted.org/packages/ec/71/c4270398c2eba968a6071af1dfbdcaeee6ec1c24bc8b435b8cc452700da6/greenlet-3.5.1-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5e300185139abc337ade480c327183adf42a875ac7181bfe66d7d4efea31fbea", size = 651106, upload-time = "2026-05-20T14:00:09.448Z" }, + { url = "https://files.pythonhosted.org/packages/1a/ab/71e34b78a44ec271fb5f550c17bc46d301ddc5953890d935f270b0dcdb5a/greenlet-3.5.1-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7ffdb990dcaa0234cf9845aead5df2e3c3a8b6507d409274dd87e0d5ab05ffc2", size = 663478, upload-time = "2026-05-20T14:05:45.88Z" }, + { url = "https://files.pythonhosted.org/packages/c6/2d/2d80842910da44f78c286532d084b8a5c3717c844ae80ceb3858738ae89a/greenlet-3.5.1-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6c09df69dc1712d131332054a858a3e5cca400967fa3a672e2324fbb0971448c", size = 667767, upload-time = "2026-05-20T14:09:12.15Z" }, + { url = "https://files.pythonhosted.org/packages/77/96/4efd6fa5c62c85426a0c19077a586258ebc3a2a146ff2493e4312a697a22/greenlet-3.5.1-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2f82b3597e9d83b63408affed0b48fd0f54935edac4302237b9a837be0dae33c", size = 660800, upload-time = "2026-05-20T13:14:29.129Z" }, + { url = "https://files.pythonhosted.org/packages/e9/d3/dad2eecedfbb1ed7050a20dcfae40c1442b74bc7423608be2c7e03ee7133/greenlet-3.5.1-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:a4764e0bfc6a4d114c865b32520805c16a990ef5f286a514413b05d5ecd6a23d", size = 470786, upload-time = "2026-05-20T14:01:42.064Z" }, + { url = "https://files.pythonhosted.org/packages/7a/e0/6c71401a25cac7000261304e866a2f2cc04dc74810d40e2f118aa4799495/greenlet-3.5.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c0141e37414c10164e702b8fb1473304221ad98f71600850c6ef7ff4880feba0", size = 1617518, upload-time = "2026-05-20T14:02:28.662Z" }, + { url = "https://files.pythonhosted.org/packages/41/26/c5c06643e8c0af9e7bf18e16cb51d0ab7625155f0392e1c9015d66d556cd/greenlet-3.5.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:50ae25a67bea74ea41fb14b960bc532df73eb713417b2d61892dced82fe8d3bc", size = 1681593, upload-time = "2026-05-20T13:14:39.417Z" }, + { url = "https://files.pythonhosted.org/packages/8a/bd/e11a108317485075e68af9d23039619b86b28130c3b50d227d42edece64b/greenlet-3.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:8a17c42330e261299766b75ac1ea32caa437a9453c8f65d16a13140db378ecd3", size = 239800, upload-time = "2026-05-20T13:09:30.128Z" }, + { url = "https://files.pythonhosted.org/packages/47/f8/8e8e8417b7bf28639a5a56356ef934d0375e1d0c70a57e04d7701e870ffe/greenlet-3.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:7b5f5fae05b8ac6d176a61b60c394a8cbdc2b5b91b81793066e68745cf165e54", size = 236862, upload-time = "2026-05-20T13:09:10.498Z" }, + { url = "https://files.pythonhosted.org/packages/90/12/41bf27fde4d3605d3773ae57751eda182b8be2f5398011c041173b1d9534/greenlet-3.5.1-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:ea8da1e900d758d078810d4255d8c6aa572181896a31ec79d779eb79c3adc9ad", size = 293637, upload-time = "2026-05-20T13:12:35.529Z" }, + { url = "https://files.pythonhosted.org/packages/44/44/ba14b23e9757707050c2f397d305bbcae62e5d7cad122f8b6baec5ae4a1f/greenlet-3.5.1-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a19570c52a21420dcbc94e661994bc325c0b5b11304540fed514586da5dc8f2e", size = 650840, upload-time = "2026-05-20T14:00:11.079Z" }, + { url = "https://files.pythonhosted.org/packages/a8/37/5ddc2b686a6844f91abecef43411842426da2e1573f60b49ecf2547f4ae1/greenlet-3.5.1-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3d955c89b75eeca4723d7cc14135f393cd47c32e2a6cb4a8e4c6e760a26b0986", size = 656416, upload-time = "2026-05-20T14:05:47.118Z" }, + { url = "https://files.pythonhosted.org/packages/8c/46/5987dcd1a2570ba84f3b187536b2ca3ae97613387e57f5cfa99df068fe5e/greenlet-3.5.1-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ea37d5a157eb9493820d3792ac4ece28619a394391d2b9f2f78057d396ff0f0f", size = 656607, upload-time = "2026-05-20T14:09:13.949Z" }, + { url = "https://files.pythonhosted.org/packages/e1/f0/d17510297c35a2992712f0bf84de3779749999f7d3d63aa1f09db7c62dbe/greenlet-3.5.1-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:de2daaaebd1a5aa88c49045b6baf9310b3263796bd88db713edf37cf53e7bb4e", size = 654397, upload-time = "2026-05-20T13:14:30.696Z" }, + { url = "https://files.pythonhosted.org/packages/2c/c1/6da0a9ddcc29d7e51ef14883fa3dc1e53b3f4ffba00582106c7bf55da1d8/greenlet-3.5.1-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:8d8a23250ea3ec7b36de8fa4b541e9e2db3ee82915cc060ab0631609ad8b28de", size = 488287, upload-time = "2026-05-20T14:01:43.143Z" }, + { url = "https://files.pythonhosted.org/packages/37/eb/147387705bb89092645b012586e7273cb5ed3c90ef7eaf3a69173eaf0209/greenlet-3.5.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3bfbd69cc349e43bf3a8ae1c85548ff0718efc887615c2db16c3833d7b0b072d", size = 1614469, upload-time = "2026-05-20T14:02:30.192Z" }, + { url = "https://files.pythonhosted.org/packages/a6/4e/37ee0da7732b7aa9896f17e15579a9df34b9fcb9dd494f0adfa749af6623/greenlet-3.5.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:4378720dd888136c27215a0214d32a4d37c3852765d45bc37aad0623423cfd78", size = 1675115, upload-time = "2026-05-20T13:14:40.972Z" }, + { url = "https://files.pythonhosted.org/packages/57/f3/97dfcf4a6eb5077f8a672234216fb5923eb89f2cab7081cb10b2cf75b605/greenlet-3.5.1-cp314-cp314t-win_amd64.whl", hash = "sha256:45718441607f9325d948db98cbc691276059316d0358c188c246da4e1d4d23d2", size = 245246, upload-time = "2026-05-20T13:12:22.646Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httptools" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/e5/d471fcb0e14523fe1c3f4ba58ca52480e7bd70ad7109a3846bc75892f7fb/httptools-0.8.0.tar.gz", hash = "sha256:6b2a32f18d97e16e90827d7a819ffa8dbd8cc245fc4e1fa9d1095b54ef4bd999", size = 271342, upload-time = "2026-05-25T22:17:48.841Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1a/12/fa3fbf5f9517b273edea2dc982aa82a8c634091e67c590792b729017bc6f/httptools-0.8.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:de242a49b5d18e0a8776e654e9f6bf6d89f3875a5c35b425a0e7ce940feb3fd6", size = 206183, upload-time = "2026-05-25T22:17:24.004Z" }, + { url = "https://files.pythonhosted.org/packages/30/fc/5e7c4cb443370f2090a3aba0453a07384d29ff66b7435bb90e77e1037599/httptools-0.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:159e9ab5f701ccd42e555a12f1ad8ff69702910fc1c996cf2bb66e5fcb7a231b", size = 112079, upload-time = "2026-05-25T22:17:25.216Z" }, + { url = "https://files.pythonhosted.org/packages/ba/53/771bd891eb0f236f32145d6a1775777ec85745f3cc983a1f23d1a3b8ddfe/httptools-0.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c4a9f1707e4823d54dfec6c33fa3697d302aed536ed352a7ebb5a061ddb869d0", size = 481596, upload-time = "2026-05-25T22:17:26.186Z" }, + { url = "https://files.pythonhosted.org/packages/62/42/94e15bc68ce3d423243c45d7f1b0c7561f13844f97dc52ae23182fb65628/httptools-0.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d76ad7b951387e3632c8716a9bb03ac5b45c5f16119aa409db0459520887944e", size = 480865, upload-time = "2026-05-25T22:17:27.542Z" }, + { url = "https://files.pythonhosted.org/packages/1c/7c/fe2980fc03723272e30f135b62360b075f513dfe7cc73aef36c7f04012bd/httptools-0.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a3b7387147361c3fd47a0bde763c5c91b5b4cd4dc9989b8ece84ff436c99843b", size = 463189, upload-time = "2026-05-25T22:17:28.546Z" }, + { url = "https://files.pythonhosted.org/packages/15/1b/47fc5fff68acd1bfa20b4734059c9a06cadb88119dcd5258b5b0d21d91c8/httptools-0.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f256d6ce930c52ca1cb2a960b7da03548c454e7d28b06059ad41bfe789036ce0", size = 466610, upload-time = "2026-05-25T22:17:29.816Z" }, + { url = "https://files.pythonhosted.org/packages/60/bd/07b13c93ffd9bec9546e0d43f8e19378dd696dbd278511406bc07371ef1f/httptools-0.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:19d1ee275bb59ba2643ba9a3a1e51cc0c788caf2b8df506368e03f56fdd08527", size = 92705, upload-time = "2026-05-25T22:17:31.133Z" }, + { url = "https://files.pythonhosted.org/packages/fd/c4/121648f68ce066d7bd762d6b6d97e620847642d38d54f3d90ff11d947629/httptools-0.8.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:de1ed58a974e75d56560acc7e7fed01a454994429456f65209789992e41f2568", size = 215023, upload-time = "2026-05-25T22:17:32.401Z" }, + { url = "https://files.pythonhosted.org/packages/b9/b0/312a062ae741ae3e8baa8c8bf20be81b2e67337b259ab4349bebc7b6142e/httptools-0.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e93c227b595c6926c1acee96891dd9da4be338cfbe82e5cd3bb9d8dd7dc4ac0b", size = 117405, upload-time = "2026-05-25T22:17:33.742Z" }, + { url = "https://files.pythonhosted.org/packages/fc/37/fccd705f795386bb05bf413012fecff2a33e5aa8c2f069096de3e9fd8702/httptools-0.8.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2a021c3a8e65cc125390d72f59b968afca3bdcaff25bd67965e0a055a14946ca", size = 558497, upload-time = "2026-05-25T22:17:34.732Z" }, + { url = "https://files.pythonhosted.org/packages/bd/39/f172e8003576de35f5ba77ff417cf0e34429d35dc014deef15afa337a72c/httptools-0.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:48774d39cbb70e2b1f71f88852a3087ae1d3a1eb80482bb48c13067ab080c14f", size = 571585, upload-time = "2026-05-25T22:17:35.813Z" }, + { url = "https://files.pythonhosted.org/packages/3e/b9/f5564760af99f3dbbf3f9104dc00e5da27e96cf433c6bdcf77617f70bf3f/httptools-0.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:88eead8ec8680a9f146c655bc88445a325bd7921cfd8194c7337e9467282427d", size = 543297, upload-time = "2026-05-25T22:17:37.08Z" }, + { url = "https://files.pythonhosted.org/packages/99/67/8d9f2c313618e161b82f3873188e7196126da1d6e29688df40eb3997c77a/httptools-0.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2c032fa028f46871ec7e1fc59fc15e8023eab3e6bbe6ece786a1611719a5d081", size = 539535, upload-time = "2026-05-25T22:17:38.032Z" }, + { url = "https://files.pythonhosted.org/packages/48/63/b906c01e53f50d432c0defe43ce52764a111dc1bdd028bafbeb54dcfd008/httptools-0.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:384c17174464c8e873398b7af24f0b1f44d992c820328413951a625323155d77", size = 108209, upload-time = "2026-05-25T22:17:39.473Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "idna" +version = "3.16" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1a/88/bcf9709822fe69d02c2a6a77956c98ce6ea8ca8767a9aadcedc7eb6a2390/idna-3.16.tar.gz", hash = "sha256:d7a6da03db833450fca25d2358ac9ff06cd624577a4aea3a596d5c0f77b8e03d", size = 203770, upload-time = "2026-05-22T00:16:18.781Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/16/70255075a9859a0e3adb789b68ceb0e210dec03934245fd98d248226572f/idna-3.16-py3-none-any.whl", hash = "sha256:cc246e3a3f89580c3a951b5ad298ca4638078b2cdd4f115654332b5c26daded5", size = 74165, upload-time = "2026-05-22T00:16:16.698Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "mako" +version = "1.3.12" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/00/62/791b31e69ae182791ec67f04850f2f062716bbd205483d63a215f3e062d3/mako-1.3.12.tar.gz", hash = "sha256:9f778e93289bd410bb35daadeb4fc66d95a746f0b75777b942088b7fd7af550a", size = 400219, upload-time = "2026-04-28T19:01:08.512Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bc/b1/a0ec7a5a9db730a08daef1fdfb8090435b82465abbf758a596f0ea88727e/mako-1.3.12-py3-none-any.whl", hash = "sha256:8f61569480282dbf557145ce441e4ba888be453c30989f879f0d652e39f53ea9", size = 78521, upload-time = "2026-04-28T19:01:10.393Z" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, + { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, + { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, + { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, + { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, + { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, + { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, + { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, + { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, + { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, + { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, + { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, +] + +[[package]] +name = "monlet-server" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "alembic" }, + { name = "asyncpg" }, + { name = "fastapi" }, + { name = "httpx" }, + { name = "prometheus-client" }, + { name = "psycopg", extra = ["binary"] }, + { name = "pydantic" }, + { name = "pydantic-settings" }, + { name = "python-multipart" }, + { name = "sqlalchemy", extra = ["asyncio"] }, + { name = "structlog" }, + { name = "uvicorn", extra = ["standard"] }, +] + +[package.optional-dependencies] +dev = [ + { name = "freezegun" }, + { name = "httpx" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "ruff" }, + { name = "testcontainers" }, +] + +[package.metadata] +requires-dist = [ + { name = "alembic", specifier = ">=1.13" }, + { name = "asyncpg", specifier = ">=0.30" }, + { name = "fastapi", specifier = ">=0.115" }, + { name = "freezegun", marker = "extra == 'dev'", specifier = ">=1.5" }, + { name = "httpx", specifier = ">=0.27" }, + { name = "httpx", marker = "extra == 'dev'", specifier = ">=0.27" }, + { name = "prometheus-client", specifier = ">=0.21" }, + { name = "psycopg", extras = ["binary"], specifier = ">=3.2" }, + { name = "pydantic", specifier = ">=2.9" }, + { name = "pydantic-settings", specifier = ">=2.6" }, + { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.3" }, + { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.24" }, + { name = "python-multipart", specifier = ">=0.0.12" }, + { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.7" }, + { name = "sqlalchemy", extras = ["asyncio"], specifier = ">=2.0.36" }, + { name = "structlog", specifier = ">=24.4" }, + { name = "testcontainers", extras = ["postgresql"], marker = "extra == 'dev'", specifier = ">=4.8" }, + { name = "uvicorn", extras = ["standard"], specifier = ">=0.32" }, +] +provides-extras = ["dev"] + +[[package]] +name = "packaging" +version = "26.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "prometheus-client" +version = "0.25.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/fb/d9aa83ffe43ce1f19e557c0971d04b90561b0cfd50762aafb01968285553/prometheus_client-0.25.0.tar.gz", hash = "sha256:5e373b75c31afb3c86f1a52fa1ad470c9aace18082d39ec0d2f918d11cc9ba28", size = 86035, upload-time = "2026-04-09T19:53:42.359Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8d/9b/d4b1e644385499c8346fa9b622a3f030dce14cd6ef8a1871c221a17a67e7/prometheus_client-0.25.0-py3-none-any.whl", hash = "sha256:d5aec89e349a6ec230805d0df882f3807f74fd6c1a2fa86864e3c2279059fed1", size = 64154, upload-time = "2026-04-09T19:53:41.324Z" }, +] + +[[package]] +name = "psycopg" +version = "3.3.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "tzdata", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/db/2f/cb91e5502ec9de1de6f1b76cfbf69531932725361168bb06963620c77e2e/psycopg-3.3.4.tar.gz", hash = "sha256:e21207764952cff81b6b8bdacad9a3939f2793367fdac2987b3aac36a651b5bc", size = 165799, upload-time = "2026-05-01T23:31:55.179Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/e0/7b3dee031daae7743609ce3c746565d4a3ed7c2c186479eb48e34e838c64/psycopg-3.3.4-py3-none-any.whl", hash = "sha256:b6bbc25ccf05c8fad3b061d9db2ef0909a555171b84b07f29458a447253d679a", size = 213001, upload-time = "2026-05-01T23:20:50.816Z" }, +] + +[package.optional-dependencies] +binary = [ + { name = "psycopg-binary", marker = "implementation_name != 'pypy'" }, +] + +[[package]] +name = "psycopg-binary" +version = "3.3.4" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/48/a6/828c9185701dab71b234c2a76c38a08b098ebfec5020716b4e93807492b5/psycopg_binary-3.3.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:28b7398fdd19db3232c884fb24550bdfe951221f510e195e233299e4c9b78f97", size = 4607292, upload-time = "2026-05-01T23:30:38.962Z" }, + { url = "https://files.pythonhosted.org/packages/92/58/5b40dbc9d839045c9dae956960e4fb6d20bcabe6c59a2aa34fc3a371913f/psycopg_binary-3.3.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1fbaa292a3c8bb61b45df1ad3da1908ccee7cb889db9425e3557d9e34e2a4829", size = 4687023, upload-time = "2026-05-01T23:30:47.227Z" }, + { url = "https://files.pythonhosted.org/packages/85/a9/793f0ac107a9003b48441d0d1f9f616d96e0f37458dd8dc12528ceff55fb/psycopg_binary-3.3.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:94596f9e7633ee3f6440711d43bb70aa31cc0a46a900ab8b4201a366ace5c9e7", size = 5486985, upload-time = "2026-05-01T23:30:55.517Z" }, + { url = "https://files.pythonhosted.org/packages/8f/26/42e8533497e2592334f68ec529cf5f840f7fa4e99575a4bb61aa184dbfbf/psycopg_binary-3.3.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8c0056529e68dbe9184cd4019a1f3d8f3a4ead2f6fc7a5afcf27d3314edd1277", size = 5168745, upload-time = "2026-05-01T23:31:01.904Z" }, + { url = "https://files.pythonhosted.org/packages/15/af/b7151776cc08d5935d45c833ec818a9beb417cf7c08239af1aafbdae78ee/psycopg_binary-3.3.4-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c09aad7051326e7603c14e50636db9c01f78272dc54b3accff03d46370461e6", size = 6761486, upload-time = "2026-05-01T23:31:14.511Z" }, + { url = "https://files.pythonhosted.org/packages/d0/ed/c92533b9124712d592cbf1cd6c76da933a2e0acea81dfe1fbe7e735f0cff/psycopg_binary-3.3.4-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:514404ed543efd620c85602b747df2a23cf1241b4067199e1a66f2d2757aaa41", size = 4997427, upload-time = "2026-05-01T23:31:20.901Z" }, + { url = "https://files.pythonhosted.org/packages/a2/23/ccadfd0de416aa188356daa199453af24087b042e296088706d190ae0295/psycopg_binary-3.3.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:46893c26858be12cc49ca4226ed6a60b4bfccadd946b3bebb783a60b38788228", size = 4533549, upload-time = "2026-05-01T23:31:26.204Z" }, + { url = "https://files.pythonhosted.org/packages/fd/a0/c8f43cee36386f7bc891ab41a9d31ea07cf9826038e732da79f26b1e5f34/psycopg_binary-3.3.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:df1d567fc430f6df15c9fcf67d87685fc49bdb325adc0db5af1adfb2f44eb5c9", size = 4210256, upload-time = "2026-05-01T23:31:33.884Z" }, + { url = "https://files.pythonhosted.org/packages/4e/2c/c1547871be3790676e8868b38655496422f94f0978dfb66b74bdba2f1676/psycopg_binary-3.3.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:6b9016b1714da4dd5ecaaa75b82098aa5a0b87854ce9b092e21c27c4ae23e014", size = 3946204, upload-time = "2026-05-01T23:31:39.626Z" }, + { url = "https://files.pythonhosted.org/packages/c4/b1/f6670f00fa7ea601584623f6c11602ab92117d83eaff885e0210f6de7418/psycopg_binary-3.3.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:47c656a8a7ba6eb0cff1801a4caaa9c8bdc12d03080e273aff1c8ac39971a77e", size = 4255811, upload-time = "2026-05-01T23:31:44.986Z" }, + { url = "https://files.pythonhosted.org/packages/eb/e6/5fff07a70d1f945ed90ae131c3bd76cab32beff7c58c6db15ad5820b6d1f/psycopg_binary-3.3.4-cp314-cp314-win_amd64.whl", hash = "sha256:c37e024c07308cd06cf3ec51bfd0e7f6157585a4d84d1bce4a7f5f7913719bf8", size = 3666849, upload-time = "2026-05-01T23:31:51.165Z" }, +] + +[[package]] +name = "pydantic" +version = "2.13.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" }, + { url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" }, + { url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785, upload-time = "2026-05-06T13:38:01.995Z" }, + { url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733, upload-time = "2026-05-06T13:40:50.371Z" }, + { url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534, upload-time = "2026-05-06T13:37:21.531Z" }, + { url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732, upload-time = "2026-05-06T13:39:31.942Z" }, + { url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627, upload-time = "2026-05-06T13:37:25.033Z" }, + { url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141, upload-time = "2026-05-06T13:37:14.046Z" }, + { url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325, upload-time = "2026-05-06T13:36:53.615Z" }, + { url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990, upload-time = "2026-05-06T13:40:29.971Z" }, + { url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978, upload-time = "2026-05-06T13:37:23.027Z" }, + { url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354, upload-time = "2026-05-06T13:38:03.499Z" }, + { url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238, upload-time = "2026-05-06T13:39:40.807Z" }, + { url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251, upload-time = "2026-05-06T13:37:26.72Z" }, + { url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593, upload-time = "2026-05-06T13:39:47.682Z" }, + { url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226, upload-time = "2026-05-06T13:40:40.428Z" }, + { url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605, upload-time = "2026-05-06T13:37:32.029Z" }, + { url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777, upload-time = "2026-05-06T13:38:55.239Z" }, + { url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641, upload-time = "2026-05-06T13:37:08.096Z" }, + { url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404, upload-time = "2026-05-06T13:40:20.221Z" }, + { url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219, upload-time = "2026-05-06T13:38:12.153Z" }, + { url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594, upload-time = "2026-05-06T13:40:02.971Z" }, + { url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542, upload-time = "2026-05-06T13:39:27.506Z" }, + { url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146, upload-time = "2026-05-06T13:38:31.93Z" }, + { url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309, upload-time = "2026-05-06T13:37:44.717Z" }, + { url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736, upload-time = "2026-05-06T13:37:05.645Z" }, + { url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" }, + { url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" }, +] + +[[package]] +name = "pydantic-settings" +version = "2.14.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/07/60/1d1e59c9c90d54591469ada7d268251f71c24bdb765f1a8a832cee8c6653/pydantic_settings-2.14.1.tar.gz", hash = "sha256:e874d3bec7e787b0c9958277956ed9b4dd5de6a80e162188fdaff7c5e26fd5fa", size = 235551, upload-time = "2026-05-08T13:40:06.542Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ae/8d/f1af3832f5e6eb13ba94ee809e72b8ecb5eef226d27ee0bef7d963d943c7/pydantic_settings-2.14.1-py3-none-any.whl", hash = "sha256:6e3c7edfd8277687cdc598f56e5cff0e9bfff0910a3749deaa8d4401c3a2b9de", size = 60964, upload-time = "2026-05-08T13:40:04.958Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pytest" +version = "9.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, +] + +[[package]] +name = "pytest-asyncio" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/7c/d36d04db312ecf4298932ef77e6e4a9e8ad017906e24e34f0b0c361a2473/pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42", size = 58514, upload-time = "2026-05-26T09:56:04.083Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" }, +] + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, +] + +[[package]] +name = "python-dotenv" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, +] + +[[package]] +name = "python-multipart" +version = "0.0.29" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4e/fe/70bd71a6738b09a0bdf6480ca6436b167469ca4578b2a0efbe390b4b0e70/python_multipart-0.0.29.tar.gz", hash = "sha256:643e93849196645e2dbdd81a0f8829a23123ad7f797a84a364c6fb3563f18904", size = 45678, upload-time = "2026-05-17T17:29:47.654Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8f/cb/769cfc37177252872a45a71f3fbdde9d51b471a3f3c14bfe95dde3407386/python_multipart-0.0.29-py3-none-any.whl", hash = "sha256:2ddcc971cef266225f54f552d8fa10bcfbb1f14446caec199060daac59ff2d69", size = 29640, upload-time = "2026-05-17T17:29:45.69Z" }, +] + +[[package]] +name = "pywin32" +version = "311" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c9/31/097f2e132c4f16d99a22bfb777e0fd88bd8e1c634304e102f313af69ace5/pywin32-311-cp314-cp314-win32.whl", hash = "sha256:b7a2c10b93f8986666d0c803ee19b5990885872a7de910fc460f9b0c2fbf92ee", size = 8840714, upload-time = "2025-07-14T20:13:32.449Z" }, + { url = "https://files.pythonhosted.org/packages/90/4b/07c77d8ba0e01349358082713400435347df8426208171ce297da32c313d/pywin32-311-cp314-cp314-win_amd64.whl", hash = "sha256:3aca44c046bd2ed8c90de9cb8427f581c479e594e99b5c0bb19b29c10fd6cb87", size = 9656800, upload-time = "2025-07-14T20:13:34.312Z" }, + { url = "https://files.pythonhosted.org/packages/c0/d2/21af5c535501a7233e734b8af901574572da66fcc254cb35d0609c9080dd/pywin32-311-cp314-cp314-win_arm64.whl", hash = "sha256:a508e2d9025764a8270f93111a970e1d0fbfc33f4153b388bb649b7eec4f9b42", size = 8932540, upload-time = "2025-07-14T20:13:36.379Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "requests" +version = "2.34.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, +] + +[[package]] +name = "ruff" +version = "0.15.14" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/dc/8a/8bce2894573e9dae6ff4d77fe34ad727d79b9e6238ad288c5638990d90f6/ruff-0.15.14.tar.gz", hash = "sha256:48e866b165be4a9bdbf310f7d3c9a07edef2fe8cd63ffeb4e00bb590506ebf9f", size = 4700910, upload-time = "2026-05-21T14:34:55.177Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b9/c8/74a92c6ff9fcfb4f1f947126d3ebee8389276e161ecc85de5bda7cda51bd/ruff-0.15.14-py3-none-linux_armv6l.whl", hash = "sha256:8dd2db9416e487c8d4b01fa7056bb02c4d05969d4f8d17a08c229c2f4ff3c108", size = 10739177, upload-time = "2026-05-21T14:34:37.332Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/254a35c20acc38a7223c9d2d594af12e794432464f2cdeb52af1dc4a892d/ruff-0.15.14-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:be4ff55af755bd71a00ab3dc6bd7ffc467bd76e0df6881e286c2e3d23e8fb43b", size = 11144969, upload-time = "2026-05-21T14:34:43.978Z" }, + { url = "https://files.pythonhosted.org/packages/56/9e/d13e40f83b8d0a94430e6778ce1d94a43b38cf2efe63278bdd2b4c65abbf/ruff-0.15.14-py3-none-macosx_11_0_arm64.whl", hash = "sha256:48d5909d7d06276ce7dde6d32bfa4b0d4cb2651145cd8ee4b440722cbc77832f", size = 10478207, upload-time = "2026-05-21T14:34:48.378Z" }, + { url = "https://files.pythonhosted.org/packages/8d/f1/b15a7839fa4f332f8acec78e20564f26bb2d866e3d21710b877fd0263000/ruff-0.15.14-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca8cbfa94c4f90984a67561978602746d4cd27103568f745fa90eee3f0d4107d", size = 10818459, upload-time = "2026-05-21T14:34:22.318Z" }, + { url = "https://files.pythonhosted.org/packages/45/33/53d651177f84f94b400a0e27f8824eeada3dddc9d5ee8aeb048f4352a520/ruff-0.15.14-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9a6bbc0333f1ab053423bcbf6226477d266ca7cec7738c4c8e3f55647803f3c4", size = 10541800, upload-time = "2026-05-21T14:34:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/b8/a6/868f87e0bf9786ed24b5d0d0ad8676b8a94fd1912f42cddf9cfc7857818a/ruff-0.15.14-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8a24a4f7605d7003a6674d4387651effd939dead3fddd0f36561eb77a9a2e542", size = 11342149, upload-time = "2026-05-21T14:34:46.365Z" }, + { url = "https://files.pythonhosted.org/packages/a7/8b/38cd5c19faffdcc05a408d2b78edccc69492ab9720eadb49ea15ef80d768/ruff-0.15.14-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:049b5326e53ed80978f2fc041a280603f69dd6b0c95464342a2bb4572d9d9e2f", size = 12212563, upload-time = "2026-05-21T14:34:28.579Z" }, + { url = "https://files.pythonhosted.org/packages/3e/4d/a3c5b874a556d5731e3e657aaf04311bb76f0a5c3ec220ed43051be6b64b/ruff-0.15.14-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d4ed42e6696c8dfa5f06728e6441993901f548eb92d73bc472cb5a38d1395fbf", size = 11493299, upload-time = "2026-05-21T14:34:41.836Z" }, + { url = "https://files.pythonhosted.org/packages/1e/c0/56472c251d09858a53e51efbd485b09e1995d8731668b76d52e5dd6ee0f1/ruff-0.15.14-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:715c543cf450c4888251f91c52f1942a800541d9bddd7ac060aa4e6b77ae7cba", size = 11455931, upload-time = "2026-05-21T14:34:57.276Z" }, + { url = "https://files.pythonhosted.org/packages/2c/4a/e2e7b4d8dbf233d4eace59c75bc3435fa6d8bd3bae82d351d4e4300c0fd1/ruff-0.15.14-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:72ebab6013ec887d439d8b7593737a0a4ffb06d45d209d4e4bf2e92813082d3f", size = 11400794, upload-time = "2026-05-21T14:34:39.773Z" }, + { url = "https://files.pythonhosted.org/packages/97/c7/83c0539fe34c3e09136204d1e75d6052492364e0b3cb05e9465423f567d7/ruff-0.15.14-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:49072d36abdbe97a8dd7f480afe9c675699c0c495d4c84076e2c1203c4550581", size = 10804759, upload-time = "2026-05-21T14:34:31.045Z" }, + { url = "https://files.pythonhosted.org/packages/86/a6/18f2bfc095a2ab4a78745644e428205532ce6653a5d0fa8501572891534d/ruff-0.15.14-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:958522aee105068640c2c2ceae08f413ae44d922f52a1374ac13d6a96032fc93", size = 10539517, upload-time = "2026-05-21T14:34:53.064Z" }, + { url = "https://files.pythonhosted.org/packages/54/3a/5a8b3b69c654d4e4bf1d246ac5b49cbcdac6eaab6905925f8915f31e3b80/ruff-0.15.14-py3-none-musllinux_1_2_i686.whl", hash = "sha256:f3707da619a143a2e8830e2abab8224478d69ace2d28cb6c20543ae97c36bf61", size = 11065169, upload-time = "2026-05-21T14:34:24.484Z" }, + { url = "https://files.pythonhosted.org/packages/ed/c5/8864e4e7925b836ea354b31d57641ec03830564e281a8b6f061f8c3e0ec1/ruff-0.15.14-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:bb01d645694e3ec0102105d07ef2d53703970407d59c04e59d3ba0b7a1d53553", size = 11560214, upload-time = "2026-05-21T14:34:50.975Z" }, + { url = "https://files.pythonhosted.org/packages/36/38/012bf76752e1f89ed50b77b99532d90f3a3e287bc7918e1fc0948ac866ac/ruff-0.15.14-py3-none-win32.whl", hash = "sha256:6d0c1ad2a0ab718d39b6d8fd2217981ce4d625cd96a720095f798fb47d8b13e6", size = 10805548, upload-time = "2026-05-21T14:34:33.453Z" }, + { url = "https://files.pythonhosted.org/packages/d1/b7/4ea2c170f10ad760fff2a5250beb18897719dc8b52b53a24cddbb9dd3f19/ruff-0.15.14-py3-none-win_amd64.whl", hash = "sha256:802342981e056db3851a7836e5b070f8f15f67d4a685ae2a6160939d364b2902", size = 11939523, upload-time = "2026-05-21T14:34:18.077Z" }, + { url = "https://files.pythonhosted.org/packages/62/d5/bc97ff895ec35cf3925d4bd60f3b39d822f377a446906ec9bcc87405e59b/ruff-0.15.14-py3-none-win_arm64.whl", hash = "sha256:ff47b90a9ef6a40c9e2f3b479c1fb78531adf055b94c1eba0a7ba04b31951826", size = 11208607, upload-time = "2026-05-21T14:34:26.525Z" }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + +[[package]] +name = "sqlalchemy" +version = "2.0.50" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "greenlet", marker = "platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64'" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/57/da/6fbf010c8ebb347679d0d100b22fe9ba5e13fd04046c5df7280d2f0bf706/sqlalchemy-2.0.50.tar.gz", hash = "sha256:af5607d11ef90fd6a5c0549fe0045dce1663d427426bcfb506dcb5346a85a3b9", size = 9907424, upload-time = "2026-05-24T19:20:04.018Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/32/10ac51b4be7cdecd7e93d069251c86dfbf70b7adbd7c67b48ccea6c49e1c/sqlalchemy-2.0.50-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c966932507a4d7d0a37314927dbfcd89720e3f37d2a1e3352e7ae7939fa8e8a0", size = 2158519, upload-time = "2026-05-24T19:27:56.472Z" }, + { url = "https://files.pythonhosted.org/packages/5a/76/e703d2f7681d7d66c4c891af3f07c7ccf4c76ad7f18351de035b5eda007a/sqlalchemy-2.0.50-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:faffef4bcc20a1892e65e155293d99d60855bbbc79250ab712819cfd56a8e6bb", size = 3282063, upload-time = "2026-05-24T20:09:38.57Z" }, + { url = "https://files.pythonhosted.org/packages/31/26/ef168b184a25701f9995e8fb7e503fafd7a99c1c77cda1bc1a26ea2ed486/sqlalchemy-2.0.50-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c206aec519a2e7bd08abbfb33436e325fd22c632d9c21a9047e376ce241646e", size = 3287069, upload-time = "2026-05-24T20:17:21.942Z" }, + { url = "https://files.pythonhosted.org/packages/c2/15/765acc2bc693bccc43ca4a95d5b69750da8aaf6db1b5c616536e087f8920/sqlalchemy-2.0.50-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:bef4ac756363227ef6402a75fee025a4bc690f92328e825868939b3b3a446a6d", size = 3230453, upload-time = "2026-05-24T20:09:40.398Z" }, + { url = "https://files.pythonhosted.org/packages/63/61/08e03c3adbf5db0087a0b6816746fec8f3032fb2f7fc899a9bb9b2a48ce4/sqlalchemy-2.0.50-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:96fbee6b19c19cd1556c8bf9419447cf2ec149ffcab7ab64348c23e54ef8547f", size = 3252413, upload-time = "2026-05-24T20:17:24.067Z" }, + { url = "https://files.pythonhosted.org/packages/03/0c/370a1f2db38436c615e10134c8a37de3688e74084792380695f3f5083860/sqlalchemy-2.0.50-cp314-cp314-win32.whl", hash = "sha256:8f00e3eb43ba30eb1b238ee03a8a62309486d1321eda3328bb611e0340033ad8", size = 2120063, upload-time = "2026-05-24T19:50:11.08Z" }, + { url = "https://files.pythonhosted.org/packages/7f/a0/fe92bb9817863bc13ba093bda931979a26cc2ca69f8e8f26d07add3d7c6f/sqlalchemy-2.0.50-cp314-cp314-win_amd64.whl", hash = "sha256:15708c613cd5005b7dffe1f66ee6a63ee8f5e46799f71c70ebad74178c676a39", size = 2145830, upload-time = "2026-05-24T19:50:12.452Z" }, + { url = "https://files.pythonhosted.org/packages/cc/ff/e5640a98a0b2f491eb8fde10fb6c773621a2e44340de231fafcc9370f4a9/sqlalchemy-2.0.50-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3699dac4be410e97049a1658e9480da9cde956594aa0f3aebc60b88f21c5ba70", size = 2178435, upload-time = "2026-05-24T19:42:58.889Z" }, + { url = "https://files.pythonhosted.org/packages/b7/85/337116e186f1236375b5fb70c21cfac98e8e8ab0d3a47be838dc47a59e08/sqlalchemy-2.0.50-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f96233858e3df43932ac11589e22520da6e8aeb624b03fedfeebb0e8ea213086", size = 3566059, upload-time = "2026-05-24T20:01:20.848Z" }, + { url = "https://files.pythonhosted.org/packages/96/34/bb0e190e161c3c2c24314a65add57218be14a4a9486886b7f5047c1ff7c8/sqlalchemy-2.0.50-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c4e70c46fad30c3bcc6a4708bc0130a3173e11a5b25f0ea4a9d8911b450f1f52", size = 3535366, upload-time = "2026-05-24T20:03:56.768Z" }, + { url = "https://files.pythonhosted.org/packages/df/5a/a7f759f97e4fd499c5d4e4488c760d5a7fbecf3028b465a04274fcd52384/sqlalchemy-2.0.50-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1918a3cf564d16d95bca7301005f41ab2ad50b07cd3b9da50d3ed986db148d6a", size = 3474879, upload-time = "2026-05-24T20:01:23.058Z" }, + { url = "https://files.pythonhosted.org/packages/9d/d9/2907ea38eb60687d297bf9c39e5ee58053c87b57fe8a9cae97090cecbf10/sqlalchemy-2.0.50-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b00098cdbdbd38c7be3d568b0c9c3122b8c0ec62b911b57cd5e6e0254d60a76d", size = 3486117, upload-time = "2026-05-24T20:03:59.052Z" }, + { url = "https://files.pythonhosted.org/packages/f2/e3/5aa06f167559f8c0bdae487e297d23ba548150ab016a3418265d617a4985/sqlalchemy-2.0.50-cp314-cp314t-win32.whl", hash = "sha256:1fbd55a969d7ac44a98e3dec75016074f809fa08f871585ace58dde110d1bf3e", size = 2150823, upload-time = "2026-05-24T20:08:58.644Z" }, + { url = "https://files.pythonhosted.org/packages/65/9b/112fb8f977582d7489d036e409e3723948bcf5320b3ac465f3c481bbe8f9/sqlalchemy-2.0.50-cp314-cp314t-win_amd64.whl", hash = "sha256:c5c3cdb753a9004183e1ccb634b41611654c989e61bc68617ce878e46d6f1e51", size = 2185794, upload-time = "2026-05-24T20:09:00.319Z" }, + { url = "https://files.pythonhosted.org/packages/d0/10/f7220e9b784d295d241c86ed99aeb537f92afcd469a64861f2717e9bb077/sqlalchemy-2.0.50-py3-none-any.whl", hash = "sha256:92064363517a3ff8212b5a93b8c62876579d8dfd1ca5b561335f30152d884fa9", size = 1943861, upload-time = "2026-05-24T19:59:01.119Z" }, +] + +[package.optional-dependencies] +asyncio = [ + { name = "greenlet" }, +] + +[[package]] +name = "starlette" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/95/66/4d20cdf39a8d6a51e663b7038e3b828ff211d3891a43a713fe7e4643f3a8/starlette-1.1.0.tar.gz", hash = "sha256:e83c7fe0ddecd8719c5b840080325aec0260acec86e9832899e377b91d65e90f", size = 2660060, upload-time = "2026-05-23T16:55:41.376Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/93/79/920b8e0a8b20f793e8d64855095cb8febabf6175b8550b6f7a547d813891/starlette-1.1.0-py3-none-any.whl", hash = "sha256:7f0dfd38e428aad5cb6f9f667f0ca1d2d8ca3f3385dccac8305f79ec98458382", size = 72899, upload-time = "2026-05-23T16:55:39.201Z" }, +] + +[[package]] +name = "structlog" +version = "25.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ef/52/9ba0f43b686e7f3ddfeaa78ac3af750292662284b3661e91ad5494f21dbc/structlog-25.5.0.tar.gz", hash = "sha256:098522a3bebed9153d4570c6d0288abf80a031dfdb2048d59a49e9dc2190fc98", size = 1460830, upload-time = "2025-10-27T08:28:23.028Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a8/45/a132b9074aa18e799b891b91ad72133c98d8042c70f6240e4c5f9dabee2f/structlog-25.5.0-py3-none-any.whl", hash = "sha256:a8453e9b9e636ec59bd9e79bbd4a72f025981b3ba0f5837aebf48f02f37a7f9f", size = 72510, upload-time = "2025-10-27T08:28:21.535Z" }, +] + +[[package]] +name = "testcontainers" +version = "4.14.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "docker" }, + { name = "python-dotenv" }, + { name = "typing-extensions" }, + { name = "urllib3" }, + { name = "wrapt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ca/ac/a597c3a0e02b26cbed6dd07df68be1e57684766fd1c381dee9b170a99690/testcontainers-4.14.2.tar.gz", hash = "sha256:1340ccf16fe3acd9389a6c9e1d9ab21d9fe99a8afdf8165f89c3e69c1967d239", size = 166841, upload-time = "2026-03-18T05:19:16.696Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/13/2d/26b8b30067d94339afee62c3edc9b803a6eb9332f521ba77d8aaab5de873/testcontainers-4.14.2-py3-none-any.whl", hash = "sha256:0d0522c3cd8f8d9627cda41f7a6b51b639fa57bdc492923c045117933c668d68", size = 125712, upload-time = "2026-03-18T05:19:15.29Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + +[[package]] +name = "tzdata" +version = "2026.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ba/19/1b9b0e29f30c6d35cb345486df41110984ea67ae69dddbc0e8a100999493/tzdata-2026.2.tar.gz", hash = "sha256:9173fde7d80d9018e02a662e168e5a2d04f87c41ea174b139fbef642eda62d10", size = 198254, upload-time = "2026-04-24T15:22:08.651Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/e4/dccd7f47c4b64213ac01ef921a1337ee6e30e8c6466046018326977efd95/tzdata-2026.2-py2.py3-none-any.whl", hash = "sha256:bbe9af844f658da81a5f95019480da3a89415801f6cc966806612cc7169bffe7", size = 349321, upload-time = "2026-04-24T15:22:05.876Z" }, +] + +[[package]] +name = "urllib3" +version = "2.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, +] + +[[package]] +name = "uvicorn" +version = "0.48.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e6/bf/f6544ba992ddb9a6077343a576f9844f7f8f06ab819aefd00206e9255f18/uvicorn-0.48.0.tar.gz", hash = "sha256:a5504207195d08c2511bf9125ede5ac4a4b71725d519e758d01dcf0bc2d31c37", size = 91074, upload-time = "2026-05-24T12:08:41.925Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/01/be/72532be3da7acc5fdfbccdb95215cd04f995a0886532a5b423f929cda4cc/uvicorn-0.48.0-py3-none-any.whl", hash = "sha256:48097851328b87ec36117d3d575234519eb58c2b22d79666e9bbc6c49a761dad", size = 71410, upload-time = "2026-05-24T12:08:40.258Z" }, +] + +[package.optional-dependencies] +standard = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "httptools" }, + { name = "python-dotenv" }, + { name = "pyyaml" }, + { name = "uvloop", marker = "platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'" }, + { name = "watchfiles" }, + { name = "websockets" }, +] + +[[package]] +name = "uvloop" +version = "0.22.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/06/f0/18d39dbd1971d6d62c4629cc7fa67f74821b0dc1f5a77af43719de7936a7/uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f", size = 2443250, upload-time = "2025-10-16T22:17:19.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/90/cd/b62bdeaa429758aee8de8b00ac0dd26593a9de93d302bff3d21439e9791d/uvloop-0.22.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3879b88423ec7e97cd4eba2a443aa26ed4e59b45e6b76aabf13fe2f27023a142", size = 1362067, upload-time = "2025-10-16T22:16:44.503Z" }, + { url = "https://files.pythonhosted.org/packages/0d/f8/a132124dfda0777e489ca86732e85e69afcd1ff7686647000050ba670689/uvloop-0.22.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4baa86acedf1d62115c1dc6ad1e17134476688f08c6efd8a2ab076e815665c74", size = 752423, upload-time = "2025-10-16T22:16:45.968Z" }, + { url = "https://files.pythonhosted.org/packages/a3/94/94af78c156f88da4b3a733773ad5ba0b164393e357cc4bd0ab2e2677a7d6/uvloop-0.22.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:297c27d8003520596236bdb2335e6b3f649480bd09e00d1e3a99144b691d2a35", size = 4272437, upload-time = "2025-10-16T22:16:47.451Z" }, + { url = "https://files.pythonhosted.org/packages/b5/35/60249e9fd07b32c665192cec7af29e06c7cd96fa1d08b84f012a56a0b38e/uvloop-0.22.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c1955d5a1dd43198244d47664a5858082a3239766a839b2102a269aaff7a4e25", size = 4292101, upload-time = "2025-10-16T22:16:49.318Z" }, + { url = "https://files.pythonhosted.org/packages/02/62/67d382dfcb25d0a98ce73c11ed1a6fba5037a1a1d533dcbb7cab033a2636/uvloop-0.22.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b31dc2fccbd42adc73bc4e7cdbae4fc5086cf378979e53ca5d0301838c5682c6", size = 4114158, upload-time = "2025-10-16T22:16:50.517Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/f1171b4a882a5d13c8b7576f348acfe6074d72eaf52cccef752f748d4a9f/uvloop-0.22.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:93f617675b2d03af4e72a5333ef89450dfaa5321303ede6e67ba9c9d26878079", size = 4177360, upload-time = "2025-10-16T22:16:52.646Z" }, + { url = "https://files.pythonhosted.org/packages/79/7b/b01414f31546caf0919da80ad57cbfe24c56b151d12af68cee1b04922ca8/uvloop-0.22.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:37554f70528f60cad66945b885eb01f1bb514f132d92b6eeed1c90fd54ed6289", size = 1454790, upload-time = "2025-10-16T22:16:54.355Z" }, + { url = "https://files.pythonhosted.org/packages/d4/31/0bb232318dd838cad3fa8fb0c68c8b40e1145b32025581975e18b11fab40/uvloop-0.22.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:b76324e2dc033a0b2f435f33eb88ff9913c156ef78e153fb210e03c13da746b3", size = 796783, upload-time = "2025-10-16T22:16:55.906Z" }, + { url = "https://files.pythonhosted.org/packages/42/38/c9b09f3271a7a723a5de69f8e237ab8e7803183131bc57c890db0b6bb872/uvloop-0.22.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:badb4d8e58ee08dad957002027830d5c3b06aea446a6a3744483c2b3b745345c", size = 4647548, upload-time = "2025-10-16T22:16:57.008Z" }, + { url = "https://files.pythonhosted.org/packages/c1/37/945b4ca0ac27e3dc4952642d4c900edd030b3da6c9634875af6e13ae80e5/uvloop-0.22.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b91328c72635f6f9e0282e4a57da7470c7350ab1c9f48546c0f2866205349d21", size = 4467065, upload-time = "2025-10-16T22:16:58.206Z" }, + { url = "https://files.pythonhosted.org/packages/97/cc/48d232f33d60e2e2e0b42f4e73455b146b76ebe216487e862700457fbf3c/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:daf620c2995d193449393d6c62131b3fbd40a63bf7b307a1527856ace637fe88", size = 4328384, upload-time = "2025-10-16T22:16:59.36Z" }, + { url = "https://files.pythonhosted.org/packages/e4/16/c1fd27e9549f3c4baf1dc9c20c456cd2f822dbf8de9f463824b0c0357e06/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6cde23eeda1a25c75b2e07d39970f3374105d5eafbaab2a4482be82f272d5a5e", size = 4296730, upload-time = "2025-10-16T22:17:00.744Z" }, +] + +[[package]] +name = "watchfiles" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cd/41/5e1a4bb12aac5f1493fa1bdc11154eca3b258ca4eba65d39c473fe19d8e9/watchfiles-1.2.0.tar.gz", hash = "sha256:c995fba777f1ea992f090f9236e9284cf7a5d1a0130dd5a3d82c598cacd76838", size = 108252, upload-time = "2026-05-18T04:32:04.251Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/54/a9c7ea9a82a4ac65e7004c0a03920b5cdd2f9c3b678757d9cd425aa51d53/watchfiles-1.2.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:b8c8358484d5fa12ef34f05b7f4168eaf1932f408725ff6d023c33ec17bd79d4", size = 400205, upload-time = "2026-05-18T04:32:05.153Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5d/c9ab3534374a4a67450696905d6ef16a04405448b8dc52bd752ae50423d4/watchfiles-1.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9f04b092229ad2c50126dd3c922c8822e51e605993764a33058d4a791ab42281", size = 392508, upload-time = "2026-05-18T04:30:54.849Z" }, + { url = "https://files.pythonhosted.org/packages/26/ca/1ad30103535cf0cecd7b993e8d50edc5351b1820e38f2d22e3df58962feb/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7a7ce236284f002a156f70add88efe5c70879cccbb658be0822c54b1306fc09d", size = 452448, upload-time = "2026-05-18T04:30:53.727Z" }, + { url = "https://files.pythonhosted.org/packages/37/a1/ceee2cdf2afbd715fa07758d39c9859513eae411b23196f7fd039e5feedd/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b9909cc2b48468b575eefa944919e1fe8a36c5849d5c7c168f80a8c1db69398e", size = 459605, upload-time = "2026-05-18T04:30:23.312Z" }, + { url = "https://files.pythonhosted.org/packages/e8/f6/421e30fd1cb3907a84ed92ab3f1983e37ba2dca015e9a894a048418417a2/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0a37faaed405c67e28e6be45a1fa4f206ef5a2860f27c237db9fa30704c38242", size = 490757, upload-time = "2026-05-18T04:30:47.358Z" }, + { url = "https://files.pythonhosted.org/packages/41/b0/55ed1b97ed08be7bba6f9a541cac15f2a858e1d74d2b07b6da70a82aab00/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9649193aa27bd9ff2e80ff29bfaa93085496c7a3a377592823cc58b77ee88add", size = 568672, upload-time = "2026-05-18T04:30:38.915Z" }, + { url = "https://files.pythonhosted.org/packages/d1/cf/d8ae8a80dd7bafab395ea7681c10237311bbf34d37704a8c744e7cf31fc7/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e4ff8e37f99cf1da89e255e07c9c4b37c214038c4283707bdec308cb1b0ea1f", size = 464197, upload-time = "2026-05-18T04:30:09.914Z" }, + { url = "https://files.pythonhosted.org/packages/7c/8a/3076c496ca8dafe0e8cd03fcebdfc47be4b1174b4e5b24ff6e396e6b3af2/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:054dc20fd2e3132b4c3883b4a00d72fd6e1f56fdaf89fccd12e8057d74cd74d7", size = 453181, upload-time = "2026-05-18T04:30:14.829Z" }, + { url = "https://files.pythonhosted.org/packages/e5/10/9745e17c98e7b8a86454df0a3c7b5686bd650383f1e9f26e4ebcbd6cc0c0/watchfiles-1.2.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:e140ed30ebde76796b686e67c182cff10ea2fbab186fafd1560f74bb5a473a6e", size = 465109, upload-time = "2026-05-18T04:30:28.123Z" }, + { url = "https://files.pythonhosted.org/packages/8f/95/8ef4a95481d3e0cb52d62a06fa6e972e81424be2d9698b91a2fecca9904c/watchfiles-1.2.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:bb7e52ecf68ba46d22df23467b87cffeb2146908aa523ebfe803019618cfda06", size = 630653, upload-time = "2026-05-18T04:31:49.304Z" }, + { url = "https://files.pythonhosted.org/packages/fd/e4/3b3bf36b0f829b50c6ebcb8d031583863c59f923d6a6af3d485e470d0fac/watchfiles-1.2.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:23282a321c8baf9b3a3c4afff673f9fe65eb7fdc2338d765ccad9d3d1916a5ba", size = 657838, upload-time = "2026-05-18T04:31:06.497Z" }, + { url = "https://files.pythonhosted.org/packages/21/b1/6cbbb50c1f3002ab568777d44aa21206dfb8807a840990c4037523b51812/watchfiles-1.2.0-cp314-cp314-win32.whl", hash = "sha256:c0db965c5f79aa49fe672d297cf1febc5ad149b658594944f49a54a2b96270a7", size = 275108, upload-time = "2026-05-18T04:30:06.891Z" }, + { url = "https://files.pythonhosted.org/packages/92/45/190ce6db8dcb4536682cf75d3889ff1a27182a58cb519d343cb6d9ea63d8/watchfiles-1.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:71283b39fd17e5408eb123bd37aeecfd9d54c81fc184421943208aadb879d103", size = 288441, upload-time = "2026-05-18T04:32:12.901Z" }, + { url = "https://files.pythonhosted.org/packages/74/0d/3eae1c2313ab08378431d907c3f8095ecca00f3eda33111cf4f0f2591799/watchfiles-1.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:c5c19526f4e54a00f2666a6c0e9e40d582c09e865055ea7378bf0009aab857b3", size = 280684, upload-time = "2026-05-18T04:31:26.902Z" }, + { url = "https://files.pythonhosted.org/packages/b1/75/fb64e6c25d6b5ca636d03df34ffb1c6e9873303e76d27967e045f8df088f/watchfiles-1.2.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:d73a585accffa5ae39c17264c36ec3166d2fad7000c780f5ef83b2722afb9dd2", size = 398857, upload-time = "2026-05-18T04:32:17.108Z" }, + { url = "https://files.pythonhosted.org/packages/73/4e/9f7adf01754cbf81843722ccfec169d8f26c69778281a302855cecd2ee08/watchfiles-1.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ae99b14c5f21e026e0e9d96f40e07d8570ebee6cafd9d8fc318354606daa7a28", size = 392413, upload-time = "2026-05-18T04:31:07.911Z" }, + { url = "https://files.pythonhosted.org/packages/47/c8/bec626bcc2d69f44b9acb24ce7d60ed7b16b73628eea747fcbd169d8edda/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4429f3b105524a10b72c3a819b091c495d2811d419c1e1e8df773a5a5974f831", size = 452409, upload-time = "2026-05-18T04:31:20.142Z" }, + { url = "https://files.pythonhosted.org/packages/00/b7/b6362068e81e7c556d155a34c35d40ac3ef42d747b06d7f6e5bf58e359c2/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:43d818978d06062d9b22c4fab2ebe44cf5213d42dc8e62bda8c2760cfa2eeb33", size = 458827, upload-time = "2026-05-18T04:32:06.219Z" }, + { url = "https://files.pythonhosted.org/packages/67/f8/9a813fa42afb1e0b4625e75f0479826644d3ee8dc287e093799bc01f390c/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b9f732dc58b2dbe69e464ccf8fff7a03b0dd0be439da4c0720d3558527d3d6b4", size = 490104, upload-time = "2026-05-18T04:31:56.034Z" }, + { url = "https://files.pythonhosted.org/packages/2f/bf/27dfb6094ca4c9aad21298b5525b6c53cb36121ee454331d05161e58d130/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8f200104103feb097de4cab8fe4f5dd18a2026934c7dea98c55a2f5fd6d5a33b", size = 571360, upload-time = "2026-05-18T04:31:57.133Z" }, + { url = "https://files.pythonhosted.org/packages/fb/39/44a096d67270ea93df91d33877dbe91fbda3aa4f8ec2edf799d93eda8736/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:63ac26eefbf4af1741247d6fb68b11c49a25b2f7413fbd318a83a12aaa9cf666", size = 464644, upload-time = "2026-05-18T04:30:57.33Z" }, + { url = "https://files.pythonhosted.org/packages/0e/80/c7472203bad6268e3ef1ad260739704847898938ad7ea8b63a5131f46b50/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c4997d4e4a55f0d02b6cde327322daf3a0400e5df6c6b15948994bf72497925", size = 454771, upload-time = "2026-05-18T04:30:48.736Z" }, + { url = "https://files.pythonhosted.org/packages/51/cf/3b10b268b4b7f0fc26e9debb5eef1998b515887840f444cd3ec80c688755/watchfiles-1.2.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:4c887eba18b7945ac73067a8b4a66f21cd46c2539b2bc68588f7be6c7eb6d26b", size = 463494, upload-time = "2026-05-18T04:31:33.826Z" }, + { url = "https://files.pythonhosted.org/packages/3d/3e/a4302545cd589262a0dc7d140e86f7688eba3f9c72776c27f7e23b8864c4/watchfiles-1.2.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:3416ff151bb6b5a8d8d11664974fbef4d9305b9b2957839ab5a270468fd8df30", size = 629383, upload-time = "2026-05-18T04:31:15.596Z" }, + { url = "https://files.pythonhosted.org/packages/db/99/d5649df0a9a410d45b7c882304d0b790903ac9b6e8f2cfd12114e0c6b9f2/watchfiles-1.2.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:0e831a271c035d89789cffc386b6aa1375f39f1cd25eb7ca0997e4970d152fc5", size = 656093, upload-time = "2026-05-18T04:31:58.707Z" }, +] + +[[package]] +name = "websockets" +version = "16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/04/24/4b2031d72e840ce4c1ccb255f693b15c334757fc50023e4db9537080b8c4/websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5", size = 179346, upload-time = "2026-01-10T09:23:47.181Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f3/1d/e88022630271f5bd349ed82417136281931e558d628dd52c4d8621b4a0b2/websockets-16.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8cc451a50f2aee53042ac52d2d053d08bf89bcb31ae799cb4487587661c038a0", size = 177406, upload-time = "2026-01-10T09:23:12.178Z" }, + { url = "https://files.pythonhosted.org/packages/f2/78/e63be1bf0724eeb4616efb1ae1c9044f7c3953b7957799abb5915bffd38e/websockets-16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:daa3b6ff70a9241cf6c7fc9e949d41232d9d7d26fd3522b1ad2b4d62487e9904", size = 175085, upload-time = "2026-01-10T09:23:13.511Z" }, + { url = "https://files.pythonhosted.org/packages/bb/f4/d3c9220d818ee955ae390cf319a7c7a467beceb24f05ee7aaaa2414345ba/websockets-16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fd3cb4adb94a2a6e2b7c0d8d05cb94e6f1c81a0cf9dc2694fb65c7e8d94c42e4", size = 175328, upload-time = "2026-01-10T09:23:14.727Z" }, + { url = "https://files.pythonhosted.org/packages/63/bc/d3e208028de777087e6fb2b122051a6ff7bbcca0d6df9d9c2bf1dd869ae9/websockets-16.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:781caf5e8eee67f663126490c2f96f40906594cb86b408a703630f95550a8c3e", size = 185044, upload-time = "2026-01-10T09:23:15.939Z" }, + { url = "https://files.pythonhosted.org/packages/ad/6e/9a0927ac24bd33a0a9af834d89e0abc7cfd8e13bed17a86407a66773cc0e/websockets-16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caab51a72c51973ca21fa8a18bd8165e1a0183f1ac7066a182ff27107b71e1a4", size = 186279, upload-time = "2026-01-10T09:23:17.148Z" }, + { url = "https://files.pythonhosted.org/packages/b9/ca/bf1c68440d7a868180e11be653c85959502efd3a709323230314fda6e0b3/websockets-16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19c4dc84098e523fd63711e563077d39e90ec6702aff4b5d9e344a60cb3c0cb1", size = 185711, upload-time = "2026-01-10T09:23:18.372Z" }, + { url = "https://files.pythonhosted.org/packages/c4/f8/fdc34643a989561f217bb477cbc47a3a07212cbda91c0e4389c43c296ebf/websockets-16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5e18a238a2b2249c9a9235466b90e96ae4795672598a58772dd806edc7ac6d3", size = 184982, upload-time = "2026-01-10T09:23:19.652Z" }, + { url = "https://files.pythonhosted.org/packages/dd/d1/574fa27e233764dbac9c52730d63fcf2823b16f0856b3329fc6268d6ae4f/websockets-16.0-cp314-cp314-win32.whl", hash = "sha256:a069d734c4a043182729edd3e9f247c3b2a4035415a9172fd0f1b71658a320a8", size = 177915, upload-time = "2026-01-10T09:23:21.458Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f1/ae6b937bf3126b5134ce1f482365fde31a357c784ac51852978768b5eff4/websockets-16.0-cp314-cp314-win_amd64.whl", hash = "sha256:c0ee0e63f23914732c6d7e0cce24915c48f3f1512ec1d079ed01fc629dab269d", size = 178381, upload-time = "2026-01-10T09:23:22.715Z" }, + { url = "https://files.pythonhosted.org/packages/06/9b/f791d1db48403e1f0a27577a6beb37afae94254a8c6f08be4a23e4930bc0/websockets-16.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a35539cacc3febb22b8f4d4a99cc79b104226a756aa7400adc722e83b0d03244", size = 177737, upload-time = "2026-01-10T09:23:24.523Z" }, + { url = "https://files.pythonhosted.org/packages/bd/40/53ad02341fa33b3ce489023f635367a4ac98b73570102ad2cdd770dacc9a/websockets-16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b784ca5de850f4ce93ec85d3269d24d4c82f22b7212023c974c401d4980ebc5e", size = 175268, upload-time = "2026-01-10T09:23:25.781Z" }, + { url = "https://files.pythonhosted.org/packages/74/9b/6158d4e459b984f949dcbbb0c5d270154c7618e11c01029b9bbd1bb4c4f9/websockets-16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:569d01a4e7fba956c5ae4fc988f0d4e187900f5497ce46339c996dbf24f17641", size = 175486, upload-time = "2026-01-10T09:23:27.033Z" }, + { url = "https://files.pythonhosted.org/packages/e5/2d/7583b30208b639c8090206f95073646c2c9ffd66f44df967981a64f849ad/websockets-16.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:50f23cdd8343b984957e4077839841146f67a3d31ab0d00e6b824e74c5b2f6e8", size = 185331, upload-time = "2026-01-10T09:23:28.259Z" }, + { url = "https://files.pythonhosted.org/packages/45/b0/cce3784eb519b7b5ad680d14b9673a31ab8dcb7aad8b64d81709d2430aa8/websockets-16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:152284a83a00c59b759697b7f9e9cddf4e3c7861dd0d964b472b70f78f89e80e", size = 186501, upload-time = "2026-01-10T09:23:29.449Z" }, + { url = "https://files.pythonhosted.org/packages/19/60/b8ebe4c7e89fb5f6cdf080623c9d92789a53636950f7abacfc33fe2b3135/websockets-16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bc59589ab64b0022385f429b94697348a6a234e8ce22544e3681b2e9331b5944", size = 186062, upload-time = "2026-01-10T09:23:31.368Z" }, + { url = "https://files.pythonhosted.org/packages/88/a8/a080593f89b0138b6cba1b28f8df5673b5506f72879322288b031337c0b8/websockets-16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32da954ffa2814258030e5a57bc73a3635463238e797c7375dc8091327434206", size = 185356, upload-time = "2026-01-10T09:23:32.627Z" }, + { url = "https://files.pythonhosted.org/packages/c2/b6/b9afed2afadddaf5ebb2afa801abf4b0868f42f8539bfe4b071b5266c9fe/websockets-16.0-cp314-cp314t-win32.whl", hash = "sha256:5a4b4cc550cb665dd8a47f868c8d04c8230f857363ad3c9caf7a0c3bf8c61ca6", size = 178085, upload-time = "2026-01-10T09:23:33.816Z" }, + { url = "https://files.pythonhosted.org/packages/9f/3e/28135a24e384493fa804216b79a6a6759a38cc4ff59118787b9fb693df93/websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd", size = 178531, upload-time = "2026-01-10T09:23:35.016Z" }, + { url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" }, +] + +[[package]] +name = "wrapt" +version = "2.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2d/9f/06263fcd8ad6c405f05a3905fd7a84dd3176eb5ad46e44bccc0cd16348bb/wrapt-2.2.1.tar.gz", hash = "sha256:6744f504375775d7609c82c8d3d94af1c9a6f05586984536905908ba905277b9", size = 127620, upload-time = "2026-05-22T14:49:43.056Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0a/a3/11d7f34ebbf3231bc907a3e6d5ee051b14d034c1bc7b65a97d5cc00516df/wrapt-2.2.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:6f56a647e4eaf5f0ca40330fb070f566bdf9f7b0db89a1af20d71c28dcd7a0ab", size = 80879, upload-time = "2026-05-22T14:48:51.802Z" }, + { url = "https://files.pythonhosted.org/packages/13/3c/b74cfd984cef560b900fb1a727af20352d89e1f06bf2e1114dd3f00f5f5a/wrapt-2.2.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:64b7deeda4b70408e382328d8bbe52a256fe9bc63ae3db86d804608367e5422c", size = 81462, upload-time = "2026-05-22T14:48:53.18Z" }, + { url = "https://files.pythonhosted.org/packages/15/a3/7c8f704b8dc07dfe0a5d01c2edbfd88317aa8e5e3fa7c743eb7a085ae767/wrapt-2.2.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b9cf53ba90717db2e292401de290776c498d4bbfb0d4a559ca2895db8b9dcb5c", size = 167251, upload-time = "2026-05-22T14:48:54.562Z" }, + { url = "https://files.pythonhosted.org/packages/80/85/a34d1888d97247da6c2ff6118c3a721c73ed8cc4dd198c00208bb73b6f80/wrapt-2.2.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cf3638274ab9d9b724c9baa0b4c04e132cd6faefb78b4dd3dd1a02a4bdaad41e", size = 166316, upload-time = "2026-05-22T14:48:56.065Z" }, + { url = "https://files.pythonhosted.org/packages/e9/d7/72ffaeb01eebc704afe3fb99e840480f4bda45f0fa66e3381b6a39251c8f/wrapt-2.2.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:aed9658797d0b45d6c49adcfc6b41f66e6f2d0c6de3ec79e16cf4b1855df240f", size = 157952, upload-time = "2026-05-22T14:48:57.924Z" }, + { url = "https://files.pythonhosted.org/packages/24/5b/36f5d6b024e4edfdd90b140742d11ebcf7836daf5c9daf326c55c24db412/wrapt-2.2.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:1d676ee388bc42a04d56dd7deb5605244dac2e35cc2fadbb43c9fa25bbd93508", size = 166130, upload-time = "2026-05-22T14:48:59.384Z" }, + { url = "https://files.pythonhosted.org/packages/81/06/9296d9e97bfdef5483dfcc859d57b095b257144b2bc5300ab521e06f4bc7/wrapt-2.2.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e395f7bc31851ef9b612050368cb446e9bc14cd7454b025018980349caf25ae5", size = 156604, upload-time = "2026-05-22T14:49:00.921Z" }, + { url = "https://files.pythonhosted.org/packages/53/37/16953929ed6776175720e58fc966e779926d8d71e2c7b2273230590ca71f/wrapt-2.2.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5f1845c2a8cc1180ccccfa45785dd06f562730d19ef75be180334254012b6283", size = 166007, upload-time = "2026-05-22T14:49:02.332Z" }, + { url = "https://files.pythonhosted.org/packages/b9/73/20ee58c0612dae7c31131a7095345812ed2c7b389019e175f68cde34e5b4/wrapt-2.2.1-cp314-cp314-win32.whl", hash = "sha256:436addbc4bb4fc0a88c702577f51195d7d73683a7f3e0e5b253d8404d7847243", size = 78327, upload-time = "2026-05-22T14:49:03.722Z" }, + { url = "https://files.pythonhosted.org/packages/22/b3/ef7c3295d02e0448a71c639a36a057f46d524d057c9486291a7a3039e65c/wrapt-2.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:50972a1d974ea07725a7f6b1cec5f8759008afd030a0024843ebe7d52de47f2b", size = 81144, upload-time = "2026-05-22T14:49:05.093Z" }, + { url = "https://files.pythonhosted.org/packages/ac/dc/7bdf336953f99f4ceb0a584bb8870e42c8f26f93ea10c87834dad62f1668/wrapt-2.2.1-cp314-cp314-win_arm64.whl", hash = "sha256:1c9934ea5d92957e3cd0adbc0845539dccfd62710ebe16195a8c66c53954db36", size = 79569, upload-time = "2026-05-22T14:49:06.413Z" }, + { url = "https://files.pythonhosted.org/packages/6a/6d/6dfae80150ff1919c356d1dd528f049bcdfaae29b4d284bc957e022caef4/wrapt-2.2.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:17de18fc12cea55b8a9587314cb830573e37fb33b247a7515696350863714188", size = 82892, upload-time = "2026-05-22T14:49:07.925Z" }, + { url = "https://files.pythonhosted.org/packages/82/7b/4e34766a7d7804ffce9e71befe47e9b3225dc350c49c94493c4ab39fd3a5/wrapt-2.2.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a9dec1aca52dddde7df94818310fa2fe79739c8f385b2014c4cb1035f5508199", size = 83333, upload-time = "2026-05-22T14:49:09.257Z" }, + { url = "https://files.pythonhosted.org/packages/9d/57/0b34db3e8de44ccfece62d7b337abd1631dd810f5adc5f3db571727836b5/wrapt-2.2.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:69f2e9244542cb34dd59c7f073445b9e54ad9f3fce8d93606c368a1b499fc413", size = 202899, upload-time = "2026-05-22T14:49:10.572Z" }, + { url = "https://files.pythonhosted.org/packages/e5/45/ac0c459f154b99d92789a6cba7ca727185b83513b986f8ec7fe2aacddcbf/wrapt-2.2.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2d83966dc7f4f45e8b97b5933685ac2e6e67fc0e19246ea314bceb9a8970c956", size = 209986, upload-time = "2026-05-22T14:49:12.229Z" }, + { url = "https://files.pythonhosted.org/packages/b7/e4/77e37ff33ad018fa81ade52c25fa327b80b56f81d734279a63614fcb4cbc/wrapt-2.2.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:78b0aa6bfb7be8deed0ab23e7aa028cc5210c29bc2d32a04d52b50e517a7307e", size = 194893, upload-time = "2026-05-22T14:49:14.139Z" }, + { url = "https://files.pythonhosted.org/packages/dd/9d/7ea651d1ab032fc5fa222fbec91d0f8a1397f6ae04ebb93fa7219aa921d7/wrapt-2.2.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:05d5cb74d1b232ec8cfa130a8f900708699ff2491d97b8f85a4cdc5996294b85", size = 205636, upload-time = "2026-05-22T14:49:15.714Z" }, + { url = "https://files.pythonhosted.org/packages/09/af/8e88031a701275b9085c54e64bc88c0b1cd55c77eadd400691c371cd76c4/wrapt-2.2.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f6518b94edb9150452e9aba08027d4cc293433753ec1fbefb4629a21cbc74181", size = 192267, upload-time = "2026-05-22T14:49:17.283Z" }, + { url = "https://files.pythonhosted.org/packages/bf/a8/e657ca876b06710194f243d81c4b0896ade646e244bdbec2d87c8c56a8bd/wrapt-2.2.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ed55af48b3eb28f43228ca2306788892bcb629eb2b5c4876e2a3659872c2f17a", size = 198378, upload-time = "2026-05-22T14:49:18.785Z" }, + { url = "https://files.pythonhosted.org/packages/c8/59/822efe4ea722a3961331bfa35b7d90937790d2c20f0616de1997ccc3aebd/wrapt-2.2.1-cp314-cp314t-win32.whl", hash = "sha256:2e08688ab16525897da6589d56d0aebaf417bbe91c2d8e3b96203b1efa596e85", size = 80226, upload-time = "2026-05-22T14:49:20.264Z" }, + { url = "https://files.pythonhosted.org/packages/ab/31/2a7dc5f6abb2fca0b6e1610e120419f603650aceb4f1d3ac4cae0354e162/wrapt-2.2.1-cp314-cp314t-win_amd64.whl", hash = "sha256:fd0135d34387f5fd087d9be368ea77ea89cf2451dc1cd1c622d35021bcb3ab50", size = 83835, upload-time = "2026-05-22T14:49:21.634Z" }, + { url = "https://files.pythonhosted.org/packages/9f/c0/782b86e28d1ceebeb74cccea12d2cd3d2ba0bd68e3dec20b1bc5873f6127/wrapt-2.2.1-cp314-cp314t-win_arm64.whl", hash = "sha256:f70db64e8266d7c45d3b735f2e08eeb434b5e03da9a479ae42b2e2e486a21a00", size = 80722, upload-time = "2026-05-22T14:49:23.59Z" }, + { url = "https://files.pythonhosted.org/packages/53/46/29ac9daf11a86c22a8c38cd9236c62928ccae83f7ceb06bd3b0467cf9d05/wrapt-2.2.1-py3-none-any.whl", hash = "sha256:3aafea2975caef8ca49400640dde02cc7426e798f24870ed01f490bc3cffd32f", size = 61000, upload-time = "2026-05-22T14:49:41.593Z" }, +] diff --git a/web/.dockerignore b/web/.dockerignore new file mode 100644 index 0000000..59325e9 --- /dev/null +++ b/web/.dockerignore @@ -0,0 +1,8 @@ +node_modules +.next +test-results +playwright-report +.env* +README.md +Dockerfile +.dockerignore diff --git a/web/.gitignore b/web/.gitignore new file mode 100644 index 0000000..1578133 --- /dev/null +++ b/web/.gitignore @@ -0,0 +1,45 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# env files (can opt-in for committing if needed) +.env* + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts + +# playwright +/test-results/ +/playwright-report/ diff --git a/web/Dockerfile b/web/Dockerfile new file mode 100644 index 0000000..952d6e5 --- /dev/null +++ b/web/Dockerfile @@ -0,0 +1,22 @@ +FROM node:22-alpine AS deps +WORKDIR /app +COPY package.json package-lock.json ./ +RUN npm ci --no-audit --no-fund + +FROM node:22-alpine AS builder +WORKDIR /app +COPY --from=deps /app/node_modules ./node_modules +COPY . . +RUN npm run build + +FROM node:22-alpine AS runner +WORKDIR /app +ENV NODE_ENV=production \ + PORT=3000 \ + HOSTNAME=0.0.0.0 +COPY --from=builder /app/.next ./.next +COPY --from=builder /app/public ./public +COPY --from=builder /app/node_modules ./node_modules +COPY --from=builder /app/package.json ./package.json +EXPOSE 3000 +CMD ["npm", "run", "start"] diff --git a/web/README.md b/web/README.md index c79b0e1..5ae343b 100644 --- a/web/README.md +++ b/web/README.md @@ -1,32 +1,53 @@ # Monlet Web -Placeholder for the read-only dashboard. +Read-only dashboard for Monlet. -Planned stack: +## Stack -- Next.js App Router; -- TypeScript; -- Tailwind CSS; -- read-only API client; -- reverse-proxy/basic-auth friendly deployment. +- Next.js 16 (App Router) +- React 19 +- TypeScript +- Tailwind CSS v4 +- Typed API client generated from `api/openapi.yaml` -No web UI is implemented in Stage 0. - -## Planned Commands +## Commands ```sh +cd web npm_config_cache=../.cache/npm npm install npm_config_cache=../.cache/npm npm run dev ``` +Build / lint / typecheck / smoke: + +```sh +npm_config_cache=../.cache/npm npm run build +npm_config_cache=../.cache/npm npm run lint +npm_config_cache=../.cache/npm npm run typecheck +npm_config_cache=../.cache/npm npm run smoke +``` + Do not install Node packages globally. Project dependencies live in `web/node_modules`; npm cache lives under repository `.cache/npm`. -## Planned Pages +## Environment -- overview; -- agents; -- agent detail; -- checks; -- incidents; -- events; -- notifiers/outbox. +| Var | Default | Purpose | +|---|---|---| +| `MONLET_API_BASE_URL` | `http://127.0.0.1:8000` | Server base URL (server-side fetch) | +| `MONLET_API_TOKEN` | _(none)_ | Bearer token sent to server | + +The token never reaches the browser — all server calls happen in Server Components / route handlers. + +## Pages + +- `/` — overview +- `/agents` — agent list +- `/agents/[id]` — agent detail +- `/checks` — current check states +- `/incidents` — incident list +- `/events` — events query +- `/outbox` — notification outbox + +## API client + +Types are generated from `../api/openapi.yaml` into `src/lib/api-types.ts` via `npm run gen:api`. diff --git a/web/eslint.config.mjs b/web/eslint.config.mjs new file mode 100644 index 0000000..05e726d --- /dev/null +++ b/web/eslint.config.mjs @@ -0,0 +1,18 @@ +import { defineConfig, globalIgnores } from "eslint/config"; +import nextVitals from "eslint-config-next/core-web-vitals"; +import nextTs from "eslint-config-next/typescript"; + +const eslintConfig = defineConfig([ + ...nextVitals, + ...nextTs, + // Override default ignores of eslint-config-next. + globalIgnores([ + // Default ignores of eslint-config-next: + ".next/**", + "out/**", + "build/**", + "next-env.d.ts", + ]), +]); + +export default eslintConfig; diff --git a/web/next.config.ts b/web/next.config.ts new file mode 100644 index 0000000..5c0235f --- /dev/null +++ b/web/next.config.ts @@ -0,0 +1,7 @@ +import type { NextConfig } from "next"; + +const nextConfig: NextConfig = { + allowedDevOrigins: ["127.0.0.1"], +}; + +export default nextConfig; diff --git a/web/package-lock.json b/web/package-lock.json new file mode 100644 index 0000000..c153e70 --- /dev/null +++ b/web/package-lock.json @@ -0,0 +1,6969 @@ +{ + "name": "web", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "web", + "version": "0.1.0", + "dependencies": { + "next": "16.2.6", + "react": "19.2.4", + "react-dom": "19.2.4", + "server-only": "^0.0.1" + }, + "devDependencies": { + "@playwright/test": "^1.60.0", + "@tailwindcss/postcss": "^4", + "@types/node": "^20", + "@types/react": "^19", + "@types/react-dom": "^19", + "eslint": "^9", + "eslint-config-next": "16.2.6", + "openapi-typescript": "^7.13.0", + "tailwindcss": "^4", + "typescript": "^5" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@emnapi/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.2", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", + "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.5" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.5.tgz", + "integrity": "sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.14.0", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.1", + "minimatch": "^3.1.5", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/js": { + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.4.tgz", + "integrity": "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", + "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", + "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", + "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", + "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", + "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", + "cpu": [ + "arm" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", + "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", + "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", + "cpu": [ + "ppc64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", + "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", + "cpu": [ + "riscv64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", + "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", + "cpu": [ + "s390x" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", + "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", + "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", + "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", + "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", + "cpu": [ + "arm" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", + "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", + "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", + "cpu": [ + "ppc64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", + "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", + "cpu": [ + "riscv64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", + "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", + "cpu": [ + "s390x" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", + "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", + "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", + "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", + "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.7.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", + "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", + "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", + "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", + "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@next/env": { + "version": "16.2.6", + "resolved": "https://registry.npmjs.org/@next/env/-/env-16.2.6.tgz", + "integrity": "sha512-gd8HoHN4ufj73WmR3JmVolrpJR47ILK6LouP5xElPglaVxir6e1a7VzvTvDWkOoPXT9rkkTzyCxBu4yeZfZwcw==", + "license": "MIT" + }, + "node_modules/@next/eslint-plugin-next": { + "version": "16.2.6", + "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-16.2.6.tgz", + "integrity": "sha512-Z8l6o4JWKUl755x4R+wogD86KPeU+Ckw4K+SYG4kHeOJtRenDeK+OSbGcqZpDtbwn9DsJVdir2UxmwXuinUbUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-glob": "3.3.1" + } + }, + "node_modules/@next/swc-darwin-arm64": { + "version": "16.2.6", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.2.6.tgz", + "integrity": "sha512-ZJGkkcNfYgrrMkqOdZ7zoLa1TOy0qpcMfk/z4Mh/FKUz40gVO+HNQWqmLxf67Z5WB64DRp0dhEbyHfel+6sJUg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-darwin-x64": { + "version": "16.2.6", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.2.6.tgz", + "integrity": "sha512-v/YLBHIY132Ced3puBJ7YJKw1lqsCrgcNo2aRJlCEyQrrCeRJlvGlnmxhPxNQI3KE3N1DN5r9TPNPvka3nq5RQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-gnu": { + "version": "16.2.6", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.2.6.tgz", + "integrity": "sha512-RPOvqlYBbcQjkz9VQQDZ2T2bARIjXZV1KFlt+V2Mr6SW/e4I9fcKsaA0hdyf2FHoTlsV2xnBd5Y912rP/1Ce6w==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-musl": { + "version": "16.2.6", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.2.6.tgz", + "integrity": "sha512-URUTu1+dMkxJsPFgm+OeEvq9wf5sujw0EvgYy80TDGHTSLTnIHeqb0Eu8A3sC95IRgjejQL+kC4mw+4yPxiAXA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-gnu": { + "version": "16.2.6", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.2.6.tgz", + "integrity": "sha512-DOj182mPV8G3UkrayLoREM5YEYI+Dk5wv7Ox9xl1fFibAELEsFD0lDPfHIeILlutMMfdyhlzYPELG3peuKaurw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-musl": { + "version": "16.2.6", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.2.6.tgz", + "integrity": "sha512-HKQ5SP/V/ub73UvF7n/zeJlxk2kLmtL7Wzrg4WfmkjmNos5onJ2tKu7yZOPdL18A6Svfn3max29ym+ry7NkK4g==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-arm64-msvc": { + "version": "16.2.6", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.2.6.tgz", + "integrity": "sha512-LZXpTlPyS5v7HhSmnvsLGP3iIYgYOBnc8r8ArlT55sGHV89bR2HlDdBjWQ+PY6SJMmk8TuVGFuxalnP3k/0Dwg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-x64-msvc": { + "version": "16.2.6", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.2.6.tgz", + "integrity": "sha512-F0+4i0h9J6C4eE3EAPWsoCk7UW/dbzOjyzxY0qnDUOYFu6FFmdZ6l97/XdV3/Nz3VYyO7UWjyEJUXkGqcoXfMA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nolyfill/is-core-module": { + "version": "1.0.39", + "resolved": "https://registry.npmjs.org/@nolyfill/is-core-module/-/is-core-module-1.0.39.tgz", + "integrity": "sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.4.0" + } + }, + "node_modules/@playwright/test": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.60.0.tgz", + "integrity": "sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.60.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@redocly/ajv": { + "version": "8.11.2", + "resolved": "https://registry.npmjs.org/@redocly/ajv/-/ajv-8.11.2.tgz", + "integrity": "sha512-io1JpnwtIcvojV7QKDUSIuMN/ikdOUd1ReEnUnMKGfDVridQZ31J0MmIuqwuRjWDZfmvr+Q0MqCcfHM2gTivOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2", + "uri-js-replace": "^1.0.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@redocly/ajv/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, + "node_modules/@redocly/config": { + "version": "0.22.0", + "resolved": "https://registry.npmjs.org/@redocly/config/-/config-0.22.0.tgz", + "integrity": "sha512-gAy93Ddo01Z3bHuVdPWfCwzgfaYgMdaZPcfL7JZ7hWJoK9V0lXDbigTWkhiPFAaLWzbOJ+kbUQG1+XwIm0KRGQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@redocly/openapi-core": { + "version": "1.34.15", + "resolved": "https://registry.npmjs.org/@redocly/openapi-core/-/openapi-core-1.34.15.tgz", + "integrity": "sha512-HAwCnNyKcs5XGQqms+9t7OdAPM/5TDstmhF+0i7tdCFato2QKuYIlyWETwkXd8c5zbltr1oB+6y9NTeQLr2d6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@redocly/ajv": "8.11.2", + "@redocly/config": "0.22.0", + "colorette": "1.4.0", + "https-proxy-agent": "7.0.6", + "js-levenshtein": "1.1.6", + "js-yaml": "4.1.1", + "minimatch": "5.1.9", + "pluralize": "8.0.0", + "yaml-ast-parser": "0.0.43" + }, + "engines": { + "node": ">=18.17.0", + "npm": ">=9.5.0" + } + }, + "node_modules/@redocly/openapi-core/node_modules/brace-expansion": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz", + "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@redocly/openapi-core/node_modules/minimatch": { + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", + "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@rtsao/scc": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", + "integrity": "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@swc/helpers": { + "version": "0.5.15", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", + "integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.8.0" + } + }, + "node_modules/@tailwindcss/node": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.0.tgz", + "integrity": "sha512-aFb4gUhFOgdh9AXo4IzBEOzBkkAxm9VigwDJnMIYv3lcfXCJVesNfbEaBl4BNgVRyid92AmdviqwBUBRKSeY3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.21.0", + "jiti": "^2.6.1", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.3.0" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.0.tgz", + "integrity": "sha512-F7HZGBeN9I0/AuuJS5PwcD8xayx5ri5GhjYUDBEVYUkexyA/giwbDNjRVrxSezE3T250OU2K/wp/ltWx3UOefg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.3.0", + "@tailwindcss/oxide-darwin-arm64": "4.3.0", + "@tailwindcss/oxide-darwin-x64": "4.3.0", + "@tailwindcss/oxide-freebsd-x64": "4.3.0", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.0", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.0", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.0", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.0", + "@tailwindcss/oxide-linux-x64-musl": "4.3.0", + "@tailwindcss/oxide-wasm32-wasi": "4.3.0", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.0", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.0" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.0.tgz", + "integrity": "sha512-TJPiq67tKlLuObP6RkwvVGDoxCMBVtDgKkLfa/uyj7/FyxvQwHS+UOnVrXXgbEsfUaMgiVvC4KbJnRr26ho4Ng==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.0.tgz", + "integrity": "sha512-oMN/WZRb+SO37BmUElEgeEWuU8E/HXRkiODxJxLe1UTHVXLrdVSgfaJV7pSlhRGMSOiXLuxTIjfsF3wYvz8cgQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.0.tgz", + "integrity": "sha512-N6CUmu4a6bKVADfw77p+iw6Yd9Q3OBhe0veaDX+QazfuVYlQsHfDgxBrsjQ/IW+zywL8mTrNd0SdJT/zgtvMdA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.0.tgz", + "integrity": "sha512-zDL5hBkQdH5C6MpqbK3gQAgP80tsMwSI26vjOzjJtNCMUo0lFgOItzHKBIupOZNQxt3ouPH7RPhvNhiTfCe5CQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.0.tgz", + "integrity": "sha512-R06HdNi7A7OEoMsf6d4tjZ71RCWnZQPHj2mnotSFURjNLdBC+cIgXQ7l81CqeoiQftjf6OOblxXMInMgN2VzMA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.0.tgz", + "integrity": "sha512-qTJHELX8jetjhRQHCLilkVLmybpzNQAtaI/gaoVoidn/ufbNDbAo8KlK2J+yPoc8wQxvDxCmh/5lr8nC1+lTbg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.0.tgz", + "integrity": "sha512-Z6sukiQsngnWO+l39X4pPbiWT81IC+PLKF+PHxIlyZbGNb9MODfYlXEVlFvej5BOZInWX01kVyzeLvHsXhfczQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.0.tgz", + "integrity": "sha512-DRNdQRpSGzRGfARVuVkxvM8Q12nh19l4BF/G7zGA1oe+9wcC6saFBHTISrpIcKzhiXtSrlSrluCfvMuledoCTQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.0.tgz", + "integrity": "sha512-Z0IADbDo8bh6I7h2IQMx601AdXBLfFpEdUotft86evd/8ZPflZe9COPO8Q1vw+pfLWIUo9zN/JGZvwuAJqduqg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.0.tgz", + "integrity": "sha512-HNZGOUxEmElksYR7S6sC5jTeNGpobAsy9u7Gu0AskJ8/20FR9GqebUyB+HBcU/ax6BHuiuJi+Oda4B+YX6H1yA==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.10.0", + "@emnapi/runtime": "^1.10.0", + "@emnapi/wasi-threads": "^1.2.1", + "@napi-rs/wasm-runtime": "^1.1.4", + "@tybys/wasm-util": "^0.10.1", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.0.tgz", + "integrity": "sha512-Pe+RPVTi1T+qymuuRpcdvwSVZjnll/f7n8gBxMMh3xLTctMDKqpdfGimbMyioqtLhUYZxdJ9wGNhV7MKHvgZsQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.0.tgz", + "integrity": "sha512-Mvrf2kXW/yeW/OTezZlCGOirXRcUuLIBx/5Y12BaPM7wJoryG6dfS/NJL8aBPqtTEx/Vm4T4vKzFUcKDT+TKUA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/postcss": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.3.0.tgz", + "integrity": "sha512-Jm05Tjx+9yCLGv5qw1c+84Psds8MnyrEQYCB+FFk2lgGiUjlRqdxke4mVTuYrj2xnVZqKim2Apr5ySuQRYAw/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "@tailwindcss/node": "4.3.0", + "@tailwindcss/oxide": "4.3.0", + "postcss": "^8.5.10", + "tailwindcss": "4.3.0" + } + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", + "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json5": { + "version": "0.0.29", + "resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz", + "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "20.19.41", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.41.tgz", + "integrity": "sha512-ECymXOukMnOoVkC2bb1Vc/w/836DXncOg5m8Xj1RH7xSHZJWNYY6Zh7EH477vcnD5egKNNfy2RpNOmuChhFPgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.15", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.15.tgz", + "integrity": "sha512-eRwcGNHve+E8qtEQSSRl6urh+rFop4v8gm6O8rGv25CodbvFdLjA1vVQ1KkiFE0w0UPOnb8tDiFKL5lp0rtY5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.60.0.tgz", + "integrity": "sha512-QYb/sa74/s7OKMbACMjrYnGspj9Hs5YI5aaffSL65UfeBUzVzBJfVo3oWSpbzPurvm7yaCCo2Lk7lVj610HqKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.60.0", + "@typescript-eslint/type-utils": "8.60.0", + "@typescript-eslint/utils": "8.60.0", + "@typescript-eslint/visitor-keys": "8.60.0", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.60.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.60.0.tgz", + "integrity": "sha512-fcqpj/MyK4sxDPcbe7STNPbpQL4RLZOPWuaTmwZYuc+hJKzRf58yRxfhqGpc6PIq9ZyfSBpfHgmUHmHs0KwHwg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.60.0", + "@typescript-eslint/types": "8.60.0", + "@typescript-eslint/typescript-estree": "8.60.0", + "@typescript-eslint/visitor-keys": "8.60.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.60.0.tgz", + "integrity": "sha512-aZu74NNKJeUWqCjDddzdiKaS82dgYgV/vmf+Ui3ZdZejmgfXR/q+pRumgobnQ2cCJTgGTWp4ypiwsuofFubavg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.60.0", + "@typescript-eslint/types": "^8.60.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.60.0.tgz", + "integrity": "sha512-pFzqhllJMs+jghLQWzV00ds39xLzuyqPSev5pd8f4Ir0rtKR3ZLUB4/4dhjOFighWb9larvtfJvqL+4yKDI3Xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.60.0", + "@typescript-eslint/visitor-keys": "8.60.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.60.0.tgz", + "integrity": "sha512-BZPR3RGYlAXnly6ymAxfkVn5rCbZzQNou0rxv3GfWZ8cTQp+hhVd73khbGLAd8k1TlAPLISH337M+tAgAnaJDQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.60.0.tgz", + "integrity": "sha512-SX46wEUtitCpq7AN38HkUU/+zvUpdKf7ephtWAFgckH8O7PQIyL5gvrhQgBLuEYgLfuKWOVvWVskMbuFHAz5xg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.60.0", + "@typescript-eslint/typescript-estree": "8.60.0", + "@typescript-eslint/utils": "8.60.0", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.60.0.tgz", + "integrity": "sha512-AsE7x2XaAK+CVbeih0Fvbn+r1qHxtpLDJ3XUuFcIinT318T90yHMJC+Zgv+jUuDjQQd06HKwxnDu6sz1IcTilA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.60.0.tgz", + "integrity": "sha512-3AcZNBGMClm6CXDyo8kYvVGT/sx29sS0oBsIb9oZI2gunA4Vm2M3YHzRLPvsUBBsl+yB5FPtltq7gGH0iTlp9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.60.0", + "@typescript-eslint/tsconfig-utils": "8.60.0", + "@typescript-eslint/types": "8.60.0", + "@typescript-eslint/visitor-keys": "8.60.0", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz", + "integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.60.0.tgz", + "integrity": "sha512-HtXuPfrHTyBDkameWpl+vJb1Uevu2tznAyahM1Oc4AENidCLTPiZDWIo4GfcxNdC/RcfGcadzzkqbRG87dUrQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.60.0", + "@typescript-eslint/types": "8.60.0", + "@typescript-eslint/typescript-estree": "8.60.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.60.0.tgz", + "integrity": "sha512-9WI52t8ZGLVGrPMBet25yAftqY/n95+zmoUUtJBBQTKDSKUu7OsPTroT2op7U9JatkoRccL0YkWDNMFfC4Sjxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.60.0", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@unrs/resolver-binding-android-arm-eabi": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.12.2.tgz", + "integrity": "sha512-g5T90pqg1bo/7mytQx6F4iBNC0Wsh9cu+z9veDbFjc7HjpesJFWD7QMS0NGStXM075+7dJPPVvBbpZlnrdpi/w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@unrs/resolver-binding-android-arm64": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm64/-/resolver-binding-android-arm64-1.12.2.tgz", + "integrity": "sha512-YGCRZv/9GLhwmz6mYDeTsm/92BAyR28l6c2ReweVW5pWgfsitWLY8upvfRlGdoyD8HjeTHSYJWyZGD4KJA/nFQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@unrs/resolver-binding-darwin-arm64": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-arm64/-/resolver-binding-darwin-arm64-1.12.2.tgz", + "integrity": "sha512-u9DiNT1auQMO20A9SyTuG3wUgQWB9Z7KjAg0uFuCDR1FsAY8A0CG2S6JpHS1xwm/w1G08bjXZDcyOCjv1WAm2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@unrs/resolver-binding-darwin-x64": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-x64/-/resolver-binding-darwin-x64-1.12.2.tgz", + "integrity": "sha512-f7rPLi/T1HVKZu/u6t87lroib16n8vrSzcyxI7lg4BGO9UF26KhQL44sd9eOUgrTYhvRXtWOIZT5PejdPyJfUA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@unrs/resolver-binding-freebsd-x64": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-freebsd-x64/-/resolver-binding-freebsd-x64-1.12.2.tgz", + "integrity": "sha512-BpcOjWCJub6nRZUS2zA20pmLvjtqAtGejETaIyRLiZiQf++cbrjltLA5NN/xaXfqeOBOSlMFbemIl5/S5tljmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm-gnueabihf": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-gnueabihf/-/resolver-binding-linux-arm-gnueabihf-1.12.2.tgz", + "integrity": "sha512-vZTDvdSISZjJx66OzJqtsOhzifbqRjbmI1Mnu49fQDwog5GtDI4QidRiEAYbZCRj9C8YZEW+3ZjqsyS9GR4k2A==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm-musleabihf": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-musleabihf/-/resolver-binding-linux-arm-musleabihf-1.12.2.tgz", + "integrity": "sha512-BiPI+IrIlwcW4nLLMM21+B1dFPzd55yAVgVGrdgDjNef+ch03GdxrcyaIz8X9SsQirh/kCQ7mviyWlMxdh2D7g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm64-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-gnu/-/resolver-binding-linux-arm64-gnu-1.12.2.tgz", + "integrity": "sha512-zJc0H99FEPoFfSrNpa91HYfxzfAJCr502oxNK1cfdC9hlaFI43RT+JFCann9JUgZmLzzntChHyn13Sgn9ljHNg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm64-musl": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-musl/-/resolver-binding-linux-arm64-musl-1.12.2.tgz", + "integrity": "sha512-KQ3Lki6l+Pz1k/eBipN41ES+YUK30beLGb9YqcB1O542cyLCNE6GaxrfcY3T6EezmGGk84wb5XyO9loTM9tkcA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-loong64-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-loong64-gnu/-/resolver-binding-linux-loong64-gnu-1.12.2.tgz", + "integrity": "sha512-3SJGEh1DborhG6pyxvhPzCT4bbSIVihsvgJc13P1bHG7KLdNDaF9T3gsTwFc7Jw/5Y5/iWOjkEx7Zy0NvCGX3Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-loong64-musl": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-loong64-musl/-/resolver-binding-linux-loong64-musl-1.12.2.tgz", + "integrity": "sha512-jiuG/Obbel7uw1PwHNFfrkiKhLAF6mnyZ6aWlOAVN9WqKm8v0OFGnciJIHu8+CMvXLQ8AD51LPzAoUfT21D5Ew==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-ppc64-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-ppc64-gnu/-/resolver-binding-linux-ppc64-gnu-1.12.2.tgz", + "integrity": "sha512-q7xRvVpmcfeL+LlZg8Pbbo6QaTZwDU5BaGZbwfhkEsXJn3Was8xYfE0RBH266xZt0rM6B7i8xAYIvjthuUIWHg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-riscv64-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-gnu/-/resolver-binding-linux-riscv64-gnu-1.12.2.tgz", + "integrity": "sha512-0CVdx6lcnT3Q9inOH8tsMIOJ6ImndllMjqJHg8RLVdB7Vq4SfkEXl9mCSsVNuNA4MCYycRicCUxPCabVHJRr6A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-riscv64-musl": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-musl/-/resolver-binding-linux-riscv64-musl-1.12.2.tgz", + "integrity": "sha512-iOwlRo9vnp6R6ohHQS11n0NnfdXx/omhkocmIfaPRpQhKZ+3BDMkkdRVh53qjkFkpPddf+FETA28NwGN7l5l+w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-s390x-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-s390x-gnu/-/resolver-binding-linux-s390x-gnu-1.12.2.tgz", + "integrity": "sha512-HYJtLfXq94q8iZNFT1lknx258wlkkWhZeUXJRqzKBBUJ00CvZ+N33zgbCqimLjsyw5Va6uUxhVa12mI+kaveEw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-x64-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-gnu/-/resolver-binding-linux-x64-gnu-1.12.2.tgz", + "integrity": "sha512-mPsUhunKKDih5O96Y6enDQyHc1SqBPlY1E/SfMWDM3EdJ95Z9CArPeCVwCCqbP45ljvivdEk8Fxn+SIb1rDAJQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-x64-musl": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-musl/-/resolver-binding-linux-x64-musl-1.12.2.tgz", + "integrity": "sha512-azrt6+5ydLd8Vt210AAFis/lZevSfPw93EJRIJG+xPu4WCJ8K0kppCTpMyLPcKT7H15M4Jnt2tMp5bOvCkRC6A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-openharmony-arm64": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-openharmony-arm64/-/resolver-binding-openharmony-arm64-1.12.2.tgz", + "integrity": "sha512-YZ9hP4O0X9PQb8eO980qmLNGH4zT3I9+SZTdt0Pr0YyuGQhYKoOZkV02VzrzyOZJ5xIJ3UFIenKkUkGg8GjgWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@unrs/resolver-binding-wasm32-wasi": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-wasm32-wasi/-/resolver-binding-wasm32-wasi-1.12.2.tgz", + "integrity": "sha512-tYFDIkMxSflfEc/h92ZWNsZlHSwgimbNHSO3PL2JWQHfCuC2q316jMyYU9TIWZsFK2bQwyK5VAdYgn8ygPj69A==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", + "@napi-rs/wasm-runtime": "^1.1.4" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@unrs/resolver-binding-win32-arm64-msvc": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.12.2.tgz", + "integrity": "sha512-qzNyg3xL0VPQmCaUh+N5jSitce6k+uCBfMDesWRnlULOZaqUkaJ0ybdT+UqlAWJoQjuqfIU/0Ptx9bteN4D82g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@unrs/resolver-binding-win32-ia32-msvc": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-ia32-msvc/-/resolver-binding-win32-ia32-msvc-1.12.2.tgz", + "integrity": "sha512-WD9sY00OfpHVGfsnHZoA8jVT+esS/Bg8z8jzxp5BnDCjjwsuKsPQrzswwpFy4J1AUJbXPRfkpcX0mXrzeXW79g==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@unrs/resolver-binding-win32-x64-msvc": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.12.2.tgz", + "integrity": "sha512-nAB74NfSNKknqQ1RrYj6uz8FcXEomu/MATJZxh/x+BArzN2U3JbOYC0APYzUIGhVY3m5hRxA8VPNdPBoG8txlA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-colors": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz", + "integrity": "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/aria-query": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", + "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/array-buffer-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", + "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "is-array-buffer": "^3.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array-includes": { + "version": "3.1.9", + "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.9.tgz", + "integrity": "sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.24.0", + "es-object-atoms": "^1.1.1", + "get-intrinsic": "^1.3.0", + "is-string": "^1.1.1", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.findlast": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/array.prototype.findlast/-/array.prototype.findlast-1.2.5.tgz", + "integrity": "sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.findlastindex": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/array.prototype.findlastindex/-/array.prototype.findlastindex-1.2.6.tgz", + "integrity": "sha512-F/TKATkzseUExPlfvmwQKGITM3DGTK+vkAsCZoDc5daVygbJBnjEUCbgkAvVFsgfXfX4YIqZ/27G3k3tdXrTxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.9", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "es-shim-unscopables": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flat": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.3.tgz", + "integrity": "sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flatmap": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.3.3.tgz", + "integrity": "sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.tosorted": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/array.prototype.tosorted/-/array.prototype.tosorted-1.1.4.tgz", + "integrity": "sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.3", + "es-errors": "^1.3.0", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/arraybuffer.prototype.slice": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.4.tgz", + "integrity": "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.1", + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "is-array-buffer": "^3.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/ast-types-flow": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz", + "integrity": "sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/async-function": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", + "integrity": "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/available-typed-arrays": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/axe-core": { + "version": "4.11.4", + "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.11.4.tgz", + "integrity": "sha512-KunSNx+TVpkAw/6ULfhnx+HWRecjqZGTOyquAoWHYLRSdK1tB5Ihce1ZW+UY3fj33bYAFWPu7W/GRSmmrCGuxA==", + "dev": true, + "license": "MPL-2.0", + "engines": { + "node": ">=4" + } + }, + "node_modules/axobject-query": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", + "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.32", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.32.tgz", + "integrity": "sha512-wbPvpyjJPC0zdfdKXxqEL3Ea+bOMD/87X4lftiJkkaBiuG6ALQy1SLmEd7BSmVCuwCQsBrCamgBoLyfFDD1EPg==", + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", + "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/call-bind": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", + "integrity": "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "get-intrinsic": "^1.3.0", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001793", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001793.tgz", + "integrity": "sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/change-case": { + "version": "5.4.4", + "resolved": "https://registry.npmjs.org/change-case/-/change-case-5.4.4.tgz", + "integrity": "sha512-HRQyTk2/YPEkt9TnUPbOpr64Uw3KOicFWPVBb+xiHvd6eBx/qPr9xqfBFDT8P2vWsvvz4jbEkfDe71W3VyNu2w==", + "dev": true, + "license": "MIT" + }, + "node_modules/client-only": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", + "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", + "license": "MIT" + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/colorette": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/colorette/-/colorette-1.4.0.tgz", + "integrity": "sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g==", + "dev": true, + "license": "MIT" + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/damerau-levenshtein": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", + "integrity": "sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/data-view-buffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", + "integrity": "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/data-view-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.2.tgz", + "integrity": "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/inspect-js" + } + }, + "node_modules/data-view-byte-offset": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.1.tgz", + "integrity": "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "devOptional": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/doctrine": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", + "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.361", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.361.tgz", + "integrity": "sha512-Q6Hts7N9FnJc5LeGRINFvLhCI9xZmNtTDe5ZbcVezQz7cU4a8Aua3GH1b8J2XY8Al9PF+OCwYqhgsOOheMdvkA==", + "dev": true, + "license": "ISC" + }, + "node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/enhanced-resolve": { + "version": "5.22.0", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.22.0.tgz", + "integrity": "sha512-xYcDWrpELkFzz9SpZ3PlI6Eu6eD93Yf0WLDRxikGhWJ3MAir2SNZTIVCVZqZ/NUyx8AdMc2gT9C0gPiw18kG+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/es-abstract": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.2.tgz", + "integrity": "sha512-2FpH9Q5i2RRwyEP1AylXe6nYLR5OhaJTZwmlcP0dL/+JCbgg7yyEo/sEK6HeGZRf3dFpWwThaRHVApXSkW3xeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.2", + "arraybuffer.prototype.slice": "^1.0.4", + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "data-view-buffer": "^1.0.2", + "data-view-byte-length": "^1.0.2", + "data-view-byte-offset": "^1.0.1", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "es-set-tostringtag": "^2.1.0", + "es-to-primitive": "^1.3.0", + "function.prototype.name": "^1.1.8", + "get-intrinsic": "^1.3.0", + "get-proto": "^1.0.1", + "get-symbol-description": "^1.1.0", + "globalthis": "^1.0.4", + "gopd": "^1.2.0", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "internal-slot": "^1.1.0", + "is-array-buffer": "^3.0.5", + "is-callable": "^1.2.7", + "is-data-view": "^1.0.2", + "is-negative-zero": "^2.0.3", + "is-regex": "^1.2.1", + "is-set": "^2.0.3", + "is-shared-array-buffer": "^1.0.4", + "is-string": "^1.1.1", + "is-typed-array": "^1.1.15", + "is-weakref": "^1.1.1", + "math-intrinsics": "^1.1.0", + "object-inspect": "^1.13.4", + "object-keys": "^1.1.1", + "object.assign": "^4.1.7", + "own-keys": "^1.0.1", + "regexp.prototype.flags": "^1.5.4", + "safe-array-concat": "^1.1.3", + "safe-push-apply": "^1.0.0", + "safe-regex-test": "^1.1.0", + "set-proto": "^1.0.0", + "stop-iteration-iterator": "^1.1.0", + "string.prototype.trim": "^1.2.10", + "string.prototype.trimend": "^1.0.9", + "string.prototype.trimstart": "^1.0.8", + "typed-array-buffer": "^1.0.3", + "typed-array-byte-length": "^1.0.3", + "typed-array-byte-offset": "^1.0.4", + "typed-array-length": "^1.0.7", + "unbox-primitive": "^1.1.0", + "which-typed-array": "^1.1.19" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-iterator-helpers": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/es-iterator-helpers/-/es-iterator-helpers-1.3.2.tgz", + "integrity": "sha512-HVLACW1TppGYjJ8H6/jqH/pqOtKRw6wMlrB23xfExmFWxFquAIWCmwoLsOyN96K4a5KbmOf5At9ZUO3GZbetAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.24.2", + "es-errors": "^1.3.0", + "es-set-tostringtag": "^2.1.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.3.0", + "globalthis": "^1.0.4", + "gopd": "^1.2.0", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.2.0", + "has-symbols": "^1.1.0", + "internal-slot": "^1.1.0", + "iterator.prototype": "^1.1.5", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-shim-unscopables": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.1.0.tgz", + "integrity": "sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-to-primitive": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.3.0.tgz", + "integrity": "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7", + "is-date-object": "^1.0.5", + "is-symbol": "^1.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.4.tgz", + "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.2", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.5", + "@eslint/js": "9.39.4", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-config-next": { + "version": "16.2.6", + "resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-16.2.6.tgz", + "integrity": "sha512-z2ELYSkyrrJ6cuunTU8vhsT/RpouPkjaSah06nVW6Rg2Hpg0Vs8s497/e5s8G8qtdp4ccsiovz5P1rv+5VSW2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@next/eslint-plugin-next": "16.2.6", + "eslint-import-resolver-node": "^0.3.6", + "eslint-import-resolver-typescript": "^3.5.2", + "eslint-plugin-import": "^2.32.0", + "eslint-plugin-jsx-a11y": "^6.10.0", + "eslint-plugin-react": "^7.37.0", + "eslint-plugin-react-hooks": "^7.0.0", + "globals": "16.4.0", + "typescript-eslint": "^8.46.0" + }, + "peerDependencies": { + "eslint": ">=9.0.0", + "typescript": ">=3.3.1" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/eslint-config-next/node_modules/globals": { + "version": "16.4.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-16.4.0.tgz", + "integrity": "sha512-ob/2LcVVaVGCYN+r14cnwnoDPUufjiYgSqRhiFD0Q1iI4Odora5RE8Iv1D24hAz5oMophRGkGz+yuvQmmUMnMw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint-import-resolver-node": { + "version": "0.3.10", + "resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.10.tgz", + "integrity": "sha512-tRrKqFyCaKict5hOd244sL6EQFNycnMQnBe+j8uqGNXYzsImGbGUU4ibtoaBmv5FLwJwcFJNeg1GeVjQfbMrDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^3.2.7", + "is-core-module": "^2.16.1", + "resolve": "^2.0.0-next.6" + } + }, + "node_modules/eslint-import-resolver-node/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-import-resolver-typescript": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/eslint-import-resolver-typescript/-/eslint-import-resolver-typescript-3.10.1.tgz", + "integrity": "sha512-A1rHYb06zjMGAxdLSkN2fXPBwuSaQ0iO5M/hdyS0Ajj1VBaRp0sPD3dn1FhME3c/JluGFbwSxyCfqdSbtQLAHQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "@nolyfill/is-core-module": "1.0.39", + "debug": "^4.4.0", + "get-tsconfig": "^4.10.0", + "is-bun-module": "^2.0.0", + "stable-hash": "^0.0.5", + "tinyglobby": "^0.2.13", + "unrs-resolver": "^1.6.2" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint-import-resolver-typescript" + }, + "peerDependencies": { + "eslint": "*", + "eslint-plugin-import": "*", + "eslint-plugin-import-x": "*" + }, + "peerDependenciesMeta": { + "eslint-plugin-import": { + "optional": true + }, + "eslint-plugin-import-x": { + "optional": true + } + } + }, + "node_modules/eslint-module-utils": { + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.12.1.tgz", + "integrity": "sha512-L8jSWTze7K2mTg0vos/RuLRS5soomksDPoJLXIslC7c8Wmut3bx7CPpJijDcBZtxQ5lrbUdM+s0OlNbz0DCDNw==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^3.2.7" + }, + "engines": { + "node": ">=4" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/eslint-module-utils/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-plugin-import": { + "version": "2.32.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.32.0.tgz", + "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rtsao/scc": "^1.1.0", + "array-includes": "^3.1.9", + "array.prototype.findlastindex": "^1.2.6", + "array.prototype.flat": "^1.3.3", + "array.prototype.flatmap": "^1.3.3", + "debug": "^3.2.7", + "doctrine": "^2.1.0", + "eslint-import-resolver-node": "^0.3.9", + "eslint-module-utils": "^2.12.1", + "hasown": "^2.0.2", + "is-core-module": "^2.16.1", + "is-glob": "^4.0.3", + "minimatch": "^3.1.2", + "object.fromentries": "^2.0.8", + "object.groupby": "^1.0.3", + "object.values": "^1.2.1", + "semver": "^6.3.1", + "string.prototype.trimend": "^1.0.9", + "tsconfig-paths": "^3.15.0" + }, + "engines": { + "node": ">=4" + }, + "peerDependencies": { + "eslint": "^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9" + } + }, + "node_modules/eslint-plugin-import/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-plugin-jsx-a11y": { + "version": "6.10.2", + "resolved": "https://registry.npmjs.org/eslint-plugin-jsx-a11y/-/eslint-plugin-jsx-a11y-6.10.2.tgz", + "integrity": "sha512-scB3nz4WmG75pV8+3eRUQOHZlNSUhFNq37xnpgRkCCELU3XMvXAxLk1eqWWyE22Ki4Q01Fnsw9BA3cJHDPgn2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "aria-query": "^5.3.2", + "array-includes": "^3.1.8", + "array.prototype.flatmap": "^1.3.2", + "ast-types-flow": "^0.0.8", + "axe-core": "^4.10.0", + "axobject-query": "^4.1.0", + "damerau-levenshtein": "^1.0.8", + "emoji-regex": "^9.2.2", + "hasown": "^2.0.2", + "jsx-ast-utils": "^3.3.5", + "language-tags": "^1.0.9", + "minimatch": "^3.1.2", + "object.fromentries": "^2.0.8", + "safe-regex-test": "^1.0.3", + "string.prototype.includes": "^2.0.1" + }, + "engines": { + "node": ">=4.0" + }, + "peerDependencies": { + "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9" + } + }, + "node_modules/eslint-plugin-react": { + "version": "7.37.5", + "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.37.5.tgz", + "integrity": "sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-includes": "^3.1.8", + "array.prototype.findlast": "^1.2.5", + "array.prototype.flatmap": "^1.3.3", + "array.prototype.tosorted": "^1.1.4", + "doctrine": "^2.1.0", + "es-iterator-helpers": "^1.2.1", + "estraverse": "^5.3.0", + "hasown": "^2.0.2", + "jsx-ast-utils": "^2.4.1 || ^3.0.0", + "minimatch": "^3.1.2", + "object.entries": "^1.1.9", + "object.fromentries": "^2.0.8", + "object.values": "^1.2.1", + "prop-types": "^15.8.1", + "resolve": "^2.0.0-next.5", + "semver": "^6.3.1", + "string.prototype.matchall": "^4.0.12", + "string.prototype.repeat": "^1.0.0" + }, + "engines": { + "node": ">=4" + }, + "peerDependencies": { + "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7" + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.1.1.tgz", + "integrity": "sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.24.4", + "@babel/parser": "^7.24.4", + "hermes-parser": "^0.25.1", + "zod": "^3.25.0 || ^4.0.0", + "zod-validation-error": "^3.5.0 || ^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0" + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-glob": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.1.tgz", + "integrity": "sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.4" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "dev": true, + "license": "ISC" + }, + "node_modules/for-each": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/function.prototype.name": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.8.tgz", + "integrity": "sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "functions-have-names": "^1.2.3", + "hasown": "^2.0.2", + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/functions-have-names": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", + "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/generator-function": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", + "integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-symbol-description": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz", + "integrity": "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-tsconfig": { + "version": "4.14.0", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.0.tgz", + "integrity": "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/globalthis": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", + "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-properties": "^1.2.1", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/has-bigints": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", + "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-proto": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.2.0.tgz", + "integrity": "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", + "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hermes-estree": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", + "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==", + "dev": true, + "license": "MIT" + }, + "node_modules/hermes-parser": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz", + "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hermes-estree": "0.25.1" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/index-to-position": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/index-to-position/-/index-to-position-1.2.0.tgz", + "integrity": "sha512-Yg7+ztRkqslMAS2iFaU+Oa4KTSidr63OsFGlOrJoW981kIYO3CGCS3wA95P1mUi/IVSJkn0D479KTJpVpvFNuw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/internal-slot": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", + "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "hasown": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/is-array-buffer": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", + "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-async-function": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.1.tgz", + "integrity": "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "async-function": "^1.0.0", + "call-bound": "^1.0.3", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-bigint": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz", + "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-bigints": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-boolean-object": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz", + "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-bun-module": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-bun-module/-/is-bun-module-2.0.0.tgz", + "integrity": "sha512-gNCGbnnnnFAUGKeZ9PdbyeGYJqewpmc2aKHUEMO5nQPWU9lOmv7jcmQIv+qHD8fXW6W7qfuCwX4rY9LNRjXrkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.7.1" + } + }, + "node_modules/is-bun-module/node_modules/semver": { + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz", + "integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-core-module": { + "version": "2.16.2", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", + "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-data-view": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.2.tgz", + "integrity": "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "get-intrinsic": "^1.2.6", + "is-typed-array": "^1.1.13" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-date-object": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz", + "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-finalizationregistry": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-finalizationregistry/-/is-finalizationregistry-1.1.1.tgz", + "integrity": "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-generator-function": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", + "integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.4", + "generator-function": "^2.0.0", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-map": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", + "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-negative-zero": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz", + "integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-number-object": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz", + "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-regex": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", + "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-set": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz", + "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-shared-array-buffer": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz", + "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-string": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", + "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-symbol": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz", + "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-symbols": "^1.1.0", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-typed-array": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", + "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakmap": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", + "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakref": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.1.1.tgz", + "integrity": "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakset": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz", + "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true, + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/iterator.prototype": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.5.tgz", + "integrity": "sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.6", + "get-proto": "^1.0.0", + "has-symbols": "^1.1.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/js-levenshtein": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/js-levenshtein/-/js-levenshtein-1.1.6.tgz", + "integrity": "sha512-X2BB11YZtrRqY4EnQcLX5Rh373zbK4alC1FW7D7MBhL2gtcC17cTnr6DmfHZeS0s2rTHjUTMMHfG7gO8SSdw+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jsx-ast-utils": { + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz", + "integrity": "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-includes": "^3.1.6", + "array.prototype.flat": "^1.3.1", + "object.assign": "^4.1.4", + "object.values": "^1.1.6" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/language-subtag-registry": { + "version": "0.3.23", + "resolved": "https://registry.npmjs.org/language-subtag-registry/-/language-subtag-registry-0.3.23.tgz", + "integrity": "sha512-0K65Lea881pHotoGEa5gDlMxt3pctLi2RplBb7Ezh4rRdLEOtgi7n4EwK9lamnUCkKBqaeKRVebTq6BAxSkpXQ==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/language-tags": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/language-tags/-/language-tags-1.0.9.tgz", + "integrity": "sha512-MbjN408fEndfiQXbFQ1vnd+1NoLDsnQW41410oQBXiyXDMYH5z505juWa4KUE1LqxRC7DgOgZDbKLxHIwm27hA==", + "dev": true, + "license": "MIT", + "dependencies": { + "language-subtag-registry": "^0.3.20" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/napi-postinstall": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.4.tgz", + "integrity": "sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==", + "dev": true, + "license": "MIT", + "bin": { + "napi-postinstall": "lib/cli.js" + }, + "engines": { + "node": "^12.20.0 || ^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/napi-postinstall" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/next": { + "version": "16.2.6", + "resolved": "https://registry.npmjs.org/next/-/next-16.2.6.tgz", + "integrity": "sha512-qOVgKJg1+At15NpeUP+eJgCHvTCgXsogweq87Ri/Ix7PkqQHg4sdaXmSFqKlgaIXE4kW0g25LE68W87UANlHtw==", + "license": "MIT", + "dependencies": { + "@next/env": "16.2.6", + "@swc/helpers": "0.5.15", + "baseline-browser-mapping": "^2.9.19", + "caniuse-lite": "^1.0.30001579", + "postcss": "8.4.31", + "styled-jsx": "5.1.6" + }, + "bin": { + "next": "dist/bin/next" + }, + "engines": { + "node": ">=20.9.0" + }, + "optionalDependencies": { + "@next/swc-darwin-arm64": "16.2.6", + "@next/swc-darwin-x64": "16.2.6", + "@next/swc-linux-arm64-gnu": "16.2.6", + "@next/swc-linux-arm64-musl": "16.2.6", + "@next/swc-linux-x64-gnu": "16.2.6", + "@next/swc-linux-x64-musl": "16.2.6", + "@next/swc-win32-arm64-msvc": "16.2.6", + "@next/swc-win32-x64-msvc": "16.2.6", + "sharp": "^0.34.5" + }, + "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_modules/next/node_modules/postcss": { + "version": "8.4.31", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", + "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.6", + "picocolors": "^1.0.0", + "source-map-js": "^1.0.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/node-exports-info": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/node-exports-info/-/node-exports-info-1.6.0.tgz", + "integrity": "sha512-pyFS63ptit/P5WqUkt+UUfe+4oevH+bFeIiPPdfb0pFeYEu/1ELnJu5l+5EcTKYL5M7zaAa7S8ddywgXypqKCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "array.prototype.flatmap": "^1.3.3", + "es-errors": "^1.3.0", + "object.entries": "^1.1.9", + "semver": "^6.3.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/node-releases": { + "version": "2.0.46", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.46.tgz", + "integrity": "sha512-GYVXHE2KnrzAfsAjl4uP++evGFCrAU1jta4ubEjIG7YWt/64Gqv66a30yKwWczVjA6j3bM4nBwH7Pk1JmDHaxQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.assign": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", + "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0", + "has-symbols": "^1.1.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.entries": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/object.entries/-/object.entries-1.1.9.tgz", + "integrity": "sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.fromentries": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.8.tgz", + "integrity": "sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.groupby": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/object.groupby/-/object.groupby-1.0.3.tgz", + "integrity": "sha512-+Lhy3TQTuzXI5hevh8sBGqbmurHbbIjAi0Z4S63nthVLmLxfbj4T54a4CfZrXIrt9iP4mVAPYMo/v99taj3wjQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.values": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.2.1.tgz", + "integrity": "sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/openapi-typescript": { + "version": "7.13.0", + "resolved": "https://registry.npmjs.org/openapi-typescript/-/openapi-typescript-7.13.0.tgz", + "integrity": "sha512-EFP392gcqXS7ntPvbhBzbF8TyBA+baIYEm791Hy5YkjDYKTnk/Tn5OQeKm5BIZvJihpp8Zzr4hzx0Irde1LNGQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@redocly/openapi-core": "^1.34.6", + "ansi-colors": "^4.1.3", + "change-case": "^5.4.4", + "parse-json": "^8.3.0", + "supports-color": "^10.2.2", + "yargs-parser": "^21.1.1" + }, + "bin": { + "openapi-typescript": "bin/cli.js" + }, + "peerDependencies": { + "typescript": "^5.x" + } + }, + "node_modules/openapi-typescript/node_modules/supports-color": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz", + "integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/own-keys": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz", + "integrity": "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-intrinsic": "^1.2.6", + "object-keys": "^1.1.1", + "safe-push-apply": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse-json": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-8.3.0.tgz", + "integrity": "sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.26.2", + "index-to-position": "^1.1.0", + "type-fest": "^4.39.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/playwright": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz", + "integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.60.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz", + "integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/pluralize": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz", + "integrity": "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/possible-typed-array-names": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", + "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/postcss": { + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "dev": true, + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/react": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", + "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", + "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.4" + } + }, + "node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/reflect.getprototypeof": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", + "integrity": "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.9", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.7", + "get-proto": "^1.0.1", + "which-builtin-type": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/regexp.prototype.flags": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", + "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-errors": "^1.3.0", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve": { + "version": "2.0.0-next.7", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.7.tgz", + "integrity": "sha512-tqt+NBWwyaMgw3zDsnygx4CByWjQEJHOPMdslYhppaQSJUtL/D4JO9CcBBlhPoI8lz9oJIDXkwXfhF4aWqP8xQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.2", + "node-exports-info": "^1.6.0", + "object-keys": "^1.1.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/safe-array-concat": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.4.tgz", + "integrity": "sha512-wtZlHyOje6OZTGqAoaDKxFkgRtkF9CnHAVnCHKfuj200wAgL+bSJhdsCD2l0Qx/2ekEXjPWcyKkfGb5CPboslg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "get-intrinsic": "^1.3.0", + "has-symbols": "^1.1.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">=0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-push-apply": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", + "integrity": "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-regex-test": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", + "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-regex": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/server-only": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/server-only/-/server-only-0.0.1.tgz", + "integrity": "sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA==", + "license": "MIT" + }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-function-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", + "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-proto": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/set-proto/-/set-proto-1.0.0.tgz", + "integrity": "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/sharp": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", + "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", + "hasInstallScript": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@img/colour": "^1.0.0", + "detect-libc": "^2.1.2", + "semver": "^7.7.3" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "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" + } + }, + "node_modules/sharp/node_modules/semver": { + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz", + "integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==", + "license": "ISC", + "optional": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stable-hash": { + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz", + "integrity": "sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==", + "dev": true, + "license": "MIT" + }, + "node_modules/stop-iteration-iterator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", + "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "internal-slot": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/string.prototype.includes": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/string.prototype.includes/-/string.prototype.includes-2.0.1.tgz", + "integrity": "sha512-o7+c9bW6zpAdJHTtujeePODAhkuicdAryFsfVKwA+wGw89wJ4GTY484WTucM9hLtDEOpOvI+aHnzqnC5lHp4Rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.3" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/string.prototype.matchall": { + "version": "4.0.12", + "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.12.tgz", + "integrity": "sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.6", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.6", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "internal-slot": "^1.1.0", + "regexp.prototype.flags": "^1.5.3", + "set-function-name": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.repeat": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/string.prototype.repeat/-/string.prototype.repeat-1.0.0.tgz", + "integrity": "sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-properties": "^1.1.3", + "es-abstract": "^1.17.5" + } + }, + "node_modules/string.prototype.trim": { + "version": "1.2.10", + "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.10.tgz", + "integrity": "sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "define-data-property": "^1.1.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-object-atoms": "^1.0.0", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimend": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.9.tgz", + "integrity": "sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimstart": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz", + "integrity": "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/strip-bom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/styled-jsx": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz", + "integrity": "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==", + "license": "MIT", + "dependencies": { + "client-only": "0.0.1" + }, + "engines": { + "node": ">= 12.0.0" + }, + "peerDependencies": { + "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 + } + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tailwindcss": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.0.tgz", + "integrity": "sha512-y6nxMGB1nMW9R6k96e5gdIFzcfL/gTJRNaqGes1YvkLnPVXzWgbqFF2yLC0T8G774n24cx3Pe8XrKoniCOAH+Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/tsconfig-paths": { + "version": "3.15.0", + "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz", + "integrity": "sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/json5": "^0.0.29", + "json5": "^1.0.2", + "minimist": "^1.2.6", + "strip-bom": "^3.0.0" + } + }, + "node_modules/tsconfig-paths/node_modules/json5": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz", + "integrity": "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "minimist": "^1.2.0" + }, + "bin": { + "json5": "lib/cli.js" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/type-fest": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/typed-array-buffer": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", + "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/typed-array-byte-length": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.3.tgz", + "integrity": "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-byte-offset": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.4.tgz", + "integrity": "sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.15", + "reflect.getprototypeof": "^1.0.9" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-length": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.7.tgz", + "integrity": "sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "for-each": "^0.3.3", + "gopd": "^1.0.1", + "is-typed-array": "^1.1.13", + "possible-typed-array-names": "^1.0.0", + "reflect.getprototypeof": "^1.0.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.60.0.tgz", + "integrity": "sha512-9f65qWLZdAW9m1JaxBDUHcqRUfL8bkxxXL7XxEfI+F09q56PkBvIfCjLF3yInsDM/BBmwkqmCQdCZe/RYlIWEw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.60.0", + "@typescript-eslint/parser": "8.60.0", + "@typescript-eslint/typescript-estree": "8.60.0", + "@typescript-eslint/utils": "8.60.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/unbox-primitive": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", + "integrity": "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-bigints": "^1.0.2", + "has-symbols": "^1.1.0", + "which-boxed-primitive": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/unrs-resolver": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.12.2.tgz", + "integrity": "sha512-dmlRxBJJayXjqTwC+JtF1HhJmgf3ftQ3YejFcZrf4+KKtJv0qDsK1pjqaaVjG7wJ5NJ6UVP1OqRMQ71Z4C3rxQ==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "napi-postinstall": "^0.3.4" + }, + "funding": { + "url": "https://opencollective.com/unrs-resolver" + }, + "optionalDependencies": { + "@unrs/resolver-binding-android-arm-eabi": "1.12.2", + "@unrs/resolver-binding-android-arm64": "1.12.2", + "@unrs/resolver-binding-darwin-arm64": "1.12.2", + "@unrs/resolver-binding-darwin-x64": "1.12.2", + "@unrs/resolver-binding-freebsd-x64": "1.12.2", + "@unrs/resolver-binding-linux-arm-gnueabihf": "1.12.2", + "@unrs/resolver-binding-linux-arm-musleabihf": "1.12.2", + "@unrs/resolver-binding-linux-arm64-gnu": "1.12.2", + "@unrs/resolver-binding-linux-arm64-musl": "1.12.2", + "@unrs/resolver-binding-linux-loong64-gnu": "1.12.2", + "@unrs/resolver-binding-linux-loong64-musl": "1.12.2", + "@unrs/resolver-binding-linux-ppc64-gnu": "1.12.2", + "@unrs/resolver-binding-linux-riscv64-gnu": "1.12.2", + "@unrs/resolver-binding-linux-riscv64-musl": "1.12.2", + "@unrs/resolver-binding-linux-s390x-gnu": "1.12.2", + "@unrs/resolver-binding-linux-x64-gnu": "1.12.2", + "@unrs/resolver-binding-linux-x64-musl": "1.12.2", + "@unrs/resolver-binding-openharmony-arm64": "1.12.2", + "@unrs/resolver-binding-wasm32-wasi": "1.12.2", + "@unrs/resolver-binding-win32-arm64-msvc": "1.12.2", + "@unrs/resolver-binding-win32-ia32-msvc": "1.12.2", + "@unrs/resolver-binding-win32-x64-msvc": "1.12.2" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/uri-js-replace": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/uri-js-replace/-/uri-js-replace-1.0.1.tgz", + "integrity": "sha512-W+C9NWNLFOoBI2QWDp4UT9pv65r2w5Cx+3sTYFvtMdDBxkKt1syCqsUdSFAChbEe1uK5TfS04wt/nGwmaeIQ0g==", + "dev": true, + "license": "MIT" + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/which-boxed-primitive": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz", + "integrity": "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-bigint": "^1.1.0", + "is-boolean-object": "^1.2.1", + "is-number-object": "^1.1.1", + "is-string": "^1.1.1", + "is-symbol": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-builtin-type": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/which-builtin-type/-/which-builtin-type-1.2.1.tgz", + "integrity": "sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "function.prototype.name": "^1.1.6", + "has-tostringtag": "^1.0.2", + "is-async-function": "^2.0.0", + "is-date-object": "^1.1.0", + "is-finalizationregistry": "^1.1.0", + "is-generator-function": "^1.0.10", + "is-regex": "^1.2.1", + "is-weakref": "^1.0.2", + "isarray": "^2.0.5", + "which-boxed-primitive": "^1.1.0", + "which-collection": "^1.0.2", + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-collection": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz", + "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-map": "^2.0.3", + "is-set": "^2.0.3", + "is-weakmap": "^2.0.2", + "is-weakset": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-typed-array": { + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.21.tgz", + "integrity": "sha512-zbRA8cVm6io/d5W8uIe2hblzN76/Wm3v/yiythQvr+dpBWeqhPSWIDNj4zOyHi4zKbMK6DN34Xsr9jPHJERAEw==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "for-each": "^0.3.5", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yaml-ast-parser": { + "version": "0.0.43", + "resolved": "https://registry.npmjs.org/yaml-ast-parser/-/yaml-ast-parser-0.0.43.tgz", + "integrity": "sha512-2PTINUwsRqSd+s8XxKaJWQlUuEMHJQyEuh2edBbW8KNJz0SJPwUSD2zRWqezFEdN7IzAgeuYHFUCF7o8zRdZ0A==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-validation-error": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz", + "integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + } + } + } +} diff --git a/web/package.json b/web/package.json new file mode 100644 index 0000000..99171dc --- /dev/null +++ b/web/package.json @@ -0,0 +1,32 @@ +{ + "name": "web", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "lint": "eslint", + "typecheck": "tsc --noEmit", + "gen:api": "openapi-typescript ../api/openapi.yaml -o src/lib/api-types.ts", + "smoke": "playwright test" + }, + "dependencies": { + "next": "16.2.6", + "react": "19.2.4", + "react-dom": "19.2.4", + "server-only": "^0.0.1" + }, + "devDependencies": { + "@playwright/test": "^1.60.0", + "@tailwindcss/postcss": "^4", + "@types/node": "^20", + "@types/react": "^19", + "@types/react-dom": "^19", + "eslint": "^9", + "eslint-config-next": "16.2.6", + "openapi-typescript": "^7.13.0", + "tailwindcss": "^4", + "typescript": "^5" + } +} diff --git a/web/playwright.config.ts b/web/playwright.config.ts new file mode 100644 index 0000000..d97fae2 --- /dev/null +++ b/web/playwright.config.ts @@ -0,0 +1,33 @@ +import { defineConfig } from "@playwright/test"; + +const WEB_PORT = process.env.WEB_SMOKE_PORT ?? "3100"; +const MOCK_PORT = process.env.MOCK_PORT ?? "8765"; + +export default defineConfig({ + testDir: "./tests", + fullyParallel: false, + timeout: 30_000, + retries: 0, + use: { + baseURL: `http://127.0.0.1:${WEB_PORT}`, + }, + webServer: [ + { + command: `node tests/mock-server.mjs`, + url: `http://127.0.0.1:${MOCK_PORT}/api/v1/health`, + reuseExistingServer: true, + timeout: 10_000, + env: { MOCK_PORT }, + }, + { + command: `next dev -p ${WEB_PORT}`, + url: `http://127.0.0.1:${WEB_PORT}`, + reuseExistingServer: true, + timeout: 60_000, + env: { + MONLET_API_BASE_URL: `http://127.0.0.1:${MOCK_PORT}`, + MONLET_API_TOKEN: "smoke", + }, + }, + ], +}); diff --git a/web/postcss.config.mjs b/web/postcss.config.mjs new file mode 100644 index 0000000..61e3684 --- /dev/null +++ b/web/postcss.config.mjs @@ -0,0 +1,7 @@ +const config = { + plugins: { + "@tailwindcss/postcss": {}, + }, +}; + +export default config; diff --git a/web/public/file.svg b/web/public/file.svg new file mode 100644 index 0000000..004145c --- /dev/null +++ b/web/public/file.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/web/public/globe.svg b/web/public/globe.svg new file mode 100644 index 0000000..567f17b --- /dev/null +++ b/web/public/globe.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/web/public/next.svg b/web/public/next.svg new file mode 100644 index 0000000..5174b28 --- /dev/null +++ b/web/public/next.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/web/public/vercel.svg b/web/public/vercel.svg new file mode 100644 index 0000000..7705396 --- /dev/null +++ b/web/public/vercel.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/web/public/window.svg b/web/public/window.svg new file mode 100644 index 0000000..b2b2a44 --- /dev/null +++ b/web/public/window.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/web/src/app/agents/[agent_id]/page.tsx b/web/src/app/agents/[agent_id]/page.tsx new file mode 100644 index 0000000..144f272 --- /dev/null +++ b/web/src/app/agents/[agent_id]/page.tsx @@ -0,0 +1,231 @@ +import Link from "next/link"; + +import { AgentFeatures } from "@/components/AgentFeatures"; +import { ErrorPanel, Td, Th } from "@/components/DataPanel"; +import { LabelChips } from "@/components/Labels"; +import { ApiError, type Agent, type CheckState, type StoredEvent, api } from "@/lib/api"; +import { fmtDate, statusBadgeClass } from "@/lib/format"; + +export const dynamic = "force-dynamic"; + +type SortKey = "severity" | "check_id" | "last_seen"; +type TabKey = "checks" | "events"; + +type LoadResult = + | { kind: "ok"; agent: Agent; checks: CheckState[]; events: StoredEvent[] } + | { kind: "not_found" } + | { kind: "error"; error: unknown }; + +const severityRank: Record = { + critical: 0, + warning: 1, + unknown: 2, + ok: 3, +}; + +async function load(agentId: string, sort: SortKey, tab: TabKey): Promise { + try { + const [agent, checks, events] = await Promise.all([ + api.getAgent(agentId), + tab === "checks" ? loadChecks(agentId).then((items) => sortChecks(items, sort)) : [], + tab === "events" ? api.queryEvents({ agent_id: agentId, limit: 50 }).then((page) => page.items) : [], + ]); + return { kind: "ok", agent, checks, events }; + } catch (e) { + if (e instanceof ApiError && e.status === 404) return { kind: "not_found" }; + return { kind: "error", error: e }; + } +} + +async function loadChecks(agentId: string): Promise { + const checks: CheckState[] = []; + let cursor: string | undefined; + do { + const page = await api.listChecks({ agent_id: agentId, cursor, limit: 500 }); + checks.push(...page.items); + cursor = page.next_cursor ?? undefined; + } while (cursor); + return checks; +} + +function normalizeSort(sort?: string): SortKey { + return sort === "check_id" || sort === "last_seen" ? sort : "severity"; +} + +function normalizeTab(tab?: string): TabKey { + return tab === "events" ? "events" : "checks"; +} + +function sortChecks(checks: CheckState[], sort: SortKey): CheckState[] { + return [...checks].sort((a, b) => { + if (sort === "check_id") return a.check_id.localeCompare(b.check_id); + if (sort === "last_seen") return b.last_observed_at.localeCompare(a.last_observed_at); + return severityRank[a.status] - severityRank[b.status] || a.check_id.localeCompare(b.check_id); + }); +} + +export default async function AgentDetailPage({ + params, + searchParams, +}: { + params: Promise<{ agent_id: string }>; + searchParams: Promise<{ sort?: string; tab?: string }>; +}) { + const { agent_id } = await params; + const sp = await searchParams; + const sort = normalizeSort(sp.sort); + const tab = normalizeTab(sp.tab); + const r = await load(agent_id, sort, tab); + if (r.kind === "not_found") return
Agent not found.
; + if (r.kind === "error") return ; + const { agent, checks, events } = r; + return ( +
+
+ + ← agents + +

{agent.agent_id}

+
+
+
hostname
+
{agent.hostname}
+
status
+
{agent.status}
+
last_seen_at
+
{fmtDate(agent.last_seen_at)}
+
features
+
+ +
+
labels
+
+ +
+
+ + {tab === "checks" ? ( + + ) : ( + + )} +
+ ); +} + +function TabNav({ agentId, current, sort }: { agentId: string; current: TabKey; sort: SortKey }) { + const base = `/agents/${encodeURIComponent(agentId)}`; + return ( +
+ + Checks + + + Events + +
+ ); +} + +function ChecksTable({ + agentId, + checks, + sort, +}: { + agentId: string; + checks: CheckState[]; + sort: SortKey; +}) { + if (checks.length === 0) return
No checks.
; + return ( +
+ + + + + + + + + + + {checks.map((c) => ( + + + + + + + ))} + +
+ + check_id + + + + status + + exit + + last_observed_at + +
{c.check_id}{c.status}{c.exit_code ?? "—"}{fmtDate(c.last_observed_at)}
+
+ ); +} + +function EventsTable({ events }: { events: StoredEvent[] }) { + if (events.length === 0) return
No events.
; + return ( +
+ + + + + + + + + + + {events.map((e) => ( + + + + + + + ))} + +
observed_atcheck_idstatusduration_ms
{fmtDate(e.observed_at)}{e.check_id}{e.status}{e.duration_ms}
+
+ ); +} + +function SortLink({ + agentId, + sort, + current, + children, +}: { + agentId: string; + sort: SortKey; + current: SortKey; + children: React.ReactNode; +}) { + return ( + + {children} + + ); +} diff --git a/web/src/app/agents/page.tsx b/web/src/app/agents/page.tsx new file mode 100644 index 0000000..0dabd98 --- /dev/null +++ b/web/src/app/agents/page.tsx @@ -0,0 +1,71 @@ +import { redirect } from "next/navigation"; + +import { AgentsBrowser } from "@/components/AgentsBrowser"; +import { ErrorPanel } from "@/components/DataPanel"; +import { api, type Agent, type CheckState } from "@/lib/api"; + +export const dynamic = "force-dynamic"; + +type CheckStatus = CheckState["status"]; +type CheckCounts = Record; + +async function load() { + const [agents, checksByAgent] = await Promise.all([ + loadAgents(), + loadCheckCounts(), + ]); + return { agents, checksByAgent }; +} + +async function loadAgents(): Promise { + const agents: Agent[] = []; + let cursor: string | undefined; + do { + const page = await api.listAgents({ cursor, limit: 500 }); + agents.push(...page.items); + cursor = page.next_cursor ?? undefined; + } while (cursor); + return agents; +} + +async function loadCheckCounts(): Promise> { + const byAgent = new Map(); + let cursor: string | undefined; + do { + const page = await api.listChecks({ cursor, limit: 500 }); + for (const c of page.items) { + const counts = byAgent.get(c.agent_id) ?? emptyCheckCounts(); + counts[c.status] += 1; + byAgent.set(c.agent_id, counts); + } + cursor = page.next_cursor ?? undefined; + } while (cursor); + return byAgent; +} + +function emptyCheckCounts(): CheckCounts { + return { ok: 0, warning: 0, critical: 0, unknown: 0 }; +} + +export default async function AgentsPage({ + searchParams, +}: { + searchParams: Promise>; +}) { + const sp = await searchParams; + if (Object.keys(sp).length > 0) redirect("/agents"); + + let data: Awaited>; + try { + data = await load(); + } catch (e) { + return ; + } + + const rows = data.agents.map((agent) => ({ + agent, + checks: data.checksByAgent.get(agent.agent_id) ?? emptyCheckCounts(), + })); + + return ; +} diff --git a/web/src/app/checks/page.tsx b/web/src/app/checks/page.tsx new file mode 100644 index 0000000..f5b75a9 --- /dev/null +++ b/web/src/app/checks/page.tsx @@ -0,0 +1,63 @@ +import Link from "next/link"; + +import { EmptyPanel, ErrorPanel, PageHeader, Td, Th } from "@/components/DataPanel"; +import { Pager } from "@/components/Pager"; +import { api } from "@/lib/api"; +import { fmtDate, statusBadgeClass } from "@/lib/format"; + +export const dynamic = "force-dynamic"; + +export default async function ChecksPage({ + searchParams, +}: { + searchParams: Promise<{ cursor?: string }>; +}) { + const sp = await searchParams; + let data; + try { + data = await api.listChecks({ cursor: sp.cursor }); + } catch (e) { + return ; + } + return ( + <> + + {data.items.length === 0 ? ( + + ) : ( + + + + + + + + + + + + + {data.items.map((c) => ( + + + + + + + + + ))} + +
agent_idcheck_idstatusexitlast_observed_atincident_key
+ + {c.agent_id} + + {c.check_id}{c.status}{c.exit_code ?? "—"}{fmtDate(c.last_observed_at)}{c.incident_key ?? "—"}
+ )} + + + ); +} diff --git a/web/src/app/events/page.tsx b/web/src/app/events/page.tsx new file mode 100644 index 0000000..a0f0ec1 --- /dev/null +++ b/web/src/app/events/page.tsx @@ -0,0 +1,94 @@ +import { EmptyPanel, ErrorPanel, PageHeader, Td, Th } from "@/components/DataPanel"; +import { Pager } from "@/components/Pager"; +import { api } from "@/lib/api"; +import { fmtDate, statusBadgeClass } from "@/lib/format"; + +export const dynamic = "force-dynamic"; + +export default async function EventsPage({ + searchParams, +}: { + searchParams: Promise<{ cursor?: string; agent_id?: string; check_id?: string }>; +}) { + const sp = await searchParams; + let data; + try { + data = await api.queryEvents({ + agent_id: sp.agent_id, + check_id: sp.check_id, + cursor: sp.cursor, + }); + } catch (e) { + return ; + } + return ( + <> + +
+ + + + + {data.items.length === 0 ? ( + + ) : ( + + + + + + + + + + + + + + + {data.items.map((e) => ( + + + + + + + + + + + ))} + +
observed_atreceived_atagent_idcheck_idstatusexitduration_msoutput
{fmtDate(e.observed_at)}{fmtDate(e.received_at)}{e.agent_id}{e.check_id}{e.status}{e.exit_code}{e.duration_ms}{e.output ?? "—"}
+ )} + + + ); +} + +function Field({ label, name, defaultValue }: { label: string; name: string; defaultValue?: string }) { + return ( + + ); +} diff --git a/web/src/app/favicon.ico b/web/src/app/favicon.ico new file mode 100644 index 0000000..718d6fe Binary files /dev/null and b/web/src/app/favicon.ico differ diff --git a/web/src/app/globals.css b/web/src/app/globals.css new file mode 100644 index 0000000..a2dc41e --- /dev/null +++ b/web/src/app/globals.css @@ -0,0 +1,26 @@ +@import "tailwindcss"; + +:root { + --background: #ffffff; + --foreground: #171717; +} + +@theme inline { + --color-background: var(--background); + --color-foreground: var(--foreground); + --font-sans: var(--font-geist-sans); + --font-mono: var(--font-geist-mono); +} + +@media (prefers-color-scheme: dark) { + :root { + --background: #0a0a0a; + --foreground: #ededed; + } +} + +body { + background: var(--background); + color: var(--foreground); + font-family: Arial, Helvetica, sans-serif; +} diff --git a/web/src/app/incidents/page.tsx b/web/src/app/incidents/page.tsx new file mode 100644 index 0000000..4940fc1 --- /dev/null +++ b/web/src/app/incidents/page.tsx @@ -0,0 +1,81 @@ +import Link from "next/link"; + +import { EmptyPanel, ErrorPanel, PageHeader, Td, Th } from "@/components/DataPanel"; +import { Pager } from "@/components/Pager"; +import { api } from "@/lib/api"; +import { fmtDate, statusBadgeClass } from "@/lib/format"; + +export const dynamic = "force-dynamic"; + +const STATES = ["all", "open", "resolved"] as const; + +export default async function IncidentsPage({ + searchParams, +}: { + searchParams: Promise<{ cursor?: string; state?: string }>; +}) { + const sp = await searchParams; + const state = sp.state === "open" || sp.state === "resolved" ? sp.state : undefined; + let data; + try { + data = await api.listIncidents({ state, cursor: sp.cursor }); + } catch (e) { + return ; + } + return ( + <> + +
+ {STATES.map((s) => { + const href = s === "all" ? "/incidents" : `/incidents?state=${s}`; + const active = (s === "all" && !state) || s === state; + return ( + + {s} + + ); + })} +
+ {data.items.length === 0 ? ( + + ) : ( + + + + + + + + + + + + + + {data.items.map((i) => ( + + + + + + + + + + ))} + +
opened_atstateseverityagent_idcheck_idresolved_atsummary
{fmtDate(i.opened_at)}{i.state}{i.severity}{i.agent_id ?? "—"}{i.check_id ?? "—"}{fmtDate(i.resolved_at)}{i.summary ?? "—"}
+ )} + + + ); +} diff --git a/web/src/app/layout.tsx b/web/src/app/layout.tsx new file mode 100644 index 0000000..143bbfd --- /dev/null +++ b/web/src/app/layout.tsx @@ -0,0 +1,68 @@ +import type { Metadata } from "next"; +import Link from "next/link"; +import { AutoRefresh } from "@/components/AutoRefresh"; +import "./globals.css"; + +export const metadata: Metadata = { + title: "Monlet", + description: "Read-only monitoring dashboard", +}; + +const NAV = [ + { href: "/", label: "Overview" }, + { href: "/agents", label: "Agents" }, + { href: "/checks", label: "Checks" }, + { href: "/incidents", label: "Incidents" }, + { href: "/events", label: "Events" }, + { href: "/outbox", label: "Outbox" }, +]; + +const DEFAULT_REFRESH_MS = 10_000; + +function getRefreshIntervalMs(): number { + const raw = process.env.NEXT_PUBLIC_MONLET_REFRESH_MS?.trim(); + if (!raw) return DEFAULT_REFRESH_MS; + + const parsed = Number(raw); + return Number.isFinite(parsed) ? parsed : DEFAULT_REFRESH_MS; +} + +function Brand() { + return ( + + + + + + + + + + monlet + + + ); +} + +export default function RootLayout({ children }: { children: React.ReactNode }) { + const refreshIntervalMs = getRefreshIntervalMs(); + + return ( + + +
+ + +
+
{children}
+ {refreshIntervalMs > 0 && } + + + ); +} diff --git a/web/src/app/outbox/page.tsx b/web/src/app/outbox/page.tsx new file mode 100644 index 0000000..07ab9ca --- /dev/null +++ b/web/src/app/outbox/page.tsx @@ -0,0 +1,56 @@ +import { EmptyPanel, ErrorPanel, PageHeader, Td, Th } from "@/components/DataPanel"; +import { Pager } from "@/components/Pager"; +import { api } from "@/lib/api"; +import { fmtDate, statusBadgeClass } from "@/lib/format"; + +export const dynamic = "force-dynamic"; + +export default async function OutboxPage({ + searchParams, +}: { + searchParams: Promise<{ cursor?: string }>; +}) { + const sp = await searchParams; + let data; + try { + data = await api.listOutbox({ cursor: sp.cursor }); + } catch (e) { + return ; + } + return ( + <> + + {data.items.length === 0 ? ( + + ) : ( + + + + + + + + + + + + + + {data.items.map((o) => ( + + + + + + + + + + ))} + +
updated_atnotifiereventstateattemptsnext_attempt_atlast_error
{fmtDate(o.updated_at)}{o.notifier}{o.event_type}{o.state}{o.attempts}{fmtDate(o.next_attempt_at)}{o.last_error ?? "—"}
+ )} + + + ); +} diff --git a/web/src/app/page.tsx b/web/src/app/page.tsx new file mode 100644 index 0000000..650e23b --- /dev/null +++ b/web/src/app/page.tsx @@ -0,0 +1,81 @@ +import Link from "next/link"; + +import { ErrorPanel } from "@/components/DataPanel"; +import { api } from "@/lib/api"; +import { statusBadgeClass } from "@/lib/format"; + +export const dynamic = "force-dynamic"; + +async function load() { + const [agents, checks, incidents] = await Promise.all([ + api.listAgents({ limit: 500 }), + api.listChecks({ limit: 500 }), + api.listIncidents({ state: "open", limit: 500 }), + ]); + const tally = (xs: { status?: string }[]) => + xs.reduce>((acc, x) => { + const k = x.status ?? "unknown"; + acc[k] = (acc[k] ?? 0) + 1; + return acc; + }, {}); + return { + agents: tally(agents.items), + checks: tally(checks.items), + openIncidents: incidents.items.length, + totalAgents: agents.items.length, + totalChecks: checks.items.length, + }; +} + +export default async function OverviewPage() { + let data: Awaited>; + try { + data = await load(); + } catch (e) { + return ; + } + return ( +
+

Overview

+
+ + + 0 ? "open" : undefined} + /> +
+
+ ); +} + +function Card({ + title, + total, + href, + tally, + highlight, +}: { + title: string; + total: number; + href: string; + tally: Record; + highlight?: string; +}) { + return ( + +
{title}
+
{total}
+
+ {Object.entries(tally).map(([k, v]) => ( + + {k} {v} + + ))} +
+ + ); +} diff --git a/web/src/components/AgentFeatures.tsx b/web/src/components/AgentFeatures.tsx new file mode 100644 index 0000000..bd7d592 --- /dev/null +++ b/web/src/components/AgentFeatures.tsx @@ -0,0 +1,26 @@ +import type { Agent } from "@/lib/api"; + +export function AgentFeatures({ features }: { features?: Agent["features"] }) { + const enabled = enabledFeatures(features); + if (enabled.length === 0) return ; + return ( +
+ {enabled.map((f) => ( + + {f} + + ))} +
+ ); +} + +function enabledFeatures(features?: Agent["features"]): string[] { + if (!features) return []; + const out: string[] = []; + if (features.push) out.push("push"); + if (features.metrics) out.push("metrics"); + return out; +} diff --git a/web/src/components/AgentsBrowser.tsx b/web/src/components/AgentsBrowser.tsx new file mode 100644 index 0000000..4745865 --- /dev/null +++ b/web/src/components/AgentsBrowser.tsx @@ -0,0 +1,250 @@ +"use client"; + +import Link from "next/link"; +import { useMemo, useState } from "react"; + +import { AgentFeatures } from "@/components/AgentFeatures"; +import { LabelChips } from "@/components/Labels"; +import type { Agent, CheckState } from "@/lib/api"; +import { fmtDate, statusBadgeClass } from "@/lib/format"; + +type CheckStatus = CheckState["status"]; +type CheckCounts = Record; +type SortField = "host" | "status" | "last_seen_at"; +type SortDir = "asc" | "desc"; + +export type AgentRow = { agent: Agent; checks: CheckCounts }; + +const checkStatuses: CheckStatus[] = ["ok", "warning", "critical", "unknown"]; +const statusRank: Record = { alive: 0, stale: 1, dead: 2 }; +const checkSortStatuses: CheckStatus[] = ["critical", "warning", "unknown", "ok"]; + +export function AgentsBrowser({ rows: initialRows }: { rows: AgentRow[] }) { + const [query, setQuery] = useState(""); + const [sort, setSort] = useState("status"); + const [dir, setDir] = useState("desc"); + + const rows = useMemo( + () => sortAgents(filterAgents(initialRows, query), sort, dir), + [dir, initialRows, query, sort], + ); + + const chooseSort = (field: SortField) => { + if (field === sort) { + setDir((current) => (current === "asc" ? "desc" : "asc")); + return; + } + setSort(field); + setDir(fieldDefaultDir(field)); + }; + + return ( + <> +
+

Agents

+ {rows.length} items +
+
+ setQuery(event.target.value)} + placeholder="search agent" + className="min-w-0 flex-1 border border-neutral-800 bg-neutral-950 px-2 py-1.5 text-sm text-neutral-100 outline-none focus:border-neutral-500" + /> + {query ? ( + + ) : null} +
+ {rows.length === 0 ? ( +
No data.
+ ) : ( + + + + + + + + + + + + {rows.map(({ agent: a, checks }) => ( + + + + + + + + ))} + +
+ + HOST + + + + STATUS + + + + LAST_SEEN_AT + + featureslabels
+ + {a.hostname} + {a.agent_id !== a.hostname ? ( + {a.agent_id} + ) : null} + + + + {fmtDate(a.last_seen_at)} + + + +
+ )} + + ); +} + +function Th({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} + +function Td({ children, className = "" }: { children: React.ReactNode; className?: string }) { + return ( + {children} + ); +} + +function SortButton({ + field, + current, + dir, + onClick, + children, +}: { + field: SortField; + current: SortField; + dir: SortDir; + onClick: (field: SortField) => void; + children: React.ReactNode; +}) { + const active = field === current; + return ( + + ); +} + +function filterAgents(rows: AgentRow[], query: string): AgentRow[] { + const needle = query.trim().toLowerCase(); + if (!needle) return rows; + return rows.filter(({ agent }) => + `${agent.hostname} ${agent.agent_id}`.toLowerCase().includes(needle), + ); +} + +function fieldDefaultDir(field: SortField): SortDir { + return field === "host" ? "asc" : "desc"; +} + +function sortAgents(rows: AgentRow[], sort: SortField, dir: SortDir): AgentRow[] { + return [...rows].sort((a, b) => { + const primary = compareByField(a, b, sort); + if (primary !== 0) return dir === "asc" ? primary : -primary; + return hostName(a.agent).localeCompare(hostName(b.agent)) || a.agent.agent_id.localeCompare(b.agent.agent_id); + }); +} + +function compareByField(a: AgentRow, b: AgentRow, sort: SortField): number { + switch (sort) { + case "host": + return hostName(a.agent).localeCompare(hostName(b.agent)) || a.agent.agent_id.localeCompare(b.agent.agent_id); + case "last_seen_at": + return Date.parse(a.agent.last_seen_at) - Date.parse(b.agent.last_seen_at); + case "status": + return compareStatusImportance(a, b); + } +} + +function compareStatusImportance(a: AgentRow, b: AgentRow): number { + const agentStatus = statusRank[a.agent.status] - statusRank[b.agent.status]; + if (agentStatus !== 0) return agentStatus; + for (const status of checkSortStatuses) { + const checkStatus = a.checks[status] - b.checks[status]; + if (checkStatus !== 0) return checkStatus; + } + return 0; +} + +function hostName(agent: Agent): string { + return agent.hostname || agent.agent_id; +} + +function AgentStatus({ + status, + checks, +}: { + status: "alive" | "stale" | "dead"; + checks?: CheckCounts; +}) { + const counts = checks ?? emptyCheckCounts(); + const total = checkStatuses.reduce((sum, s) => sum + counts[s], 0); + return ( +
+ {status} + checks {total} + + {checkStatuses.map((s, i) => ( + + {i > 0 ? / : null} + + {shortStatus(s)} {counts[s]} + + + ))} + +
+ ); +} + +function emptyCheckCounts(): CheckCounts { + return { ok: 0, warning: 0, critical: 0, unknown: 0 }; +} + +function shortStatus(status: CheckStatus): string { + switch (status) { + case "ok": + return "ok"; + case "warning": + return "warn"; + case "critical": + return "crit"; + case "unknown": + return "unknown"; + } +} diff --git a/web/src/components/AutoRefresh.tsx b/web/src/components/AutoRefresh.tsx new file mode 100644 index 0000000..7a2dd0c --- /dev/null +++ b/web/src/components/AutoRefresh.tsx @@ -0,0 +1,51 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { useEffect } from "react"; + +export function AutoRefresh({ intervalMs }: { intervalMs: number }) { + const router = useRouter(); + + useEffect(() => { + if (intervalMs <= 0) return; + + let timer: ReturnType | undefined; + + const stop = () => { + if (timer !== undefined) { + clearInterval(timer); + timer = undefined; + } + }; + + const start = () => { + stop(); + timer = setInterval(() => { + if (document.visibilityState === "visible") { + router.refresh(); + } + }, intervalMs); + }; + + const handleVisibilityChange = () => { + if (document.visibilityState === "visible") { + router.refresh(); + start(); + } else { + stop(); + } + }; + + if (document.visibilityState === "visible") { + start(); + } + + document.addEventListener("visibilitychange", handleVisibilityChange); + return () => { + stop(); + document.removeEventListener("visibilitychange", handleVisibilityChange); + }; + }, [intervalMs, router]); + + return null; +} diff --git a/web/src/components/DataPanel.tsx b/web/src/components/DataPanel.tsx new file mode 100644 index 0000000..1030907 --- /dev/null +++ b/web/src/components/DataPanel.tsx @@ -0,0 +1,39 @@ +import { ApiError } from "@/lib/api"; + +export function PageHeader({ title, count }: { title: string; count?: number }) { + return ( +
+

{title}

+ {count !== undefined && {count} items} +
+ ); +} + +export function ErrorPanel({ error }: { error: unknown }) { + const e = error instanceof Error ? error : new Error(String(error)); + const status = error instanceof ApiError ? error.status : "—"; + return ( +
+
API error ({String(status)})
+
{e.message}
+
+ ); +} + +export function EmptyPanel({ label = "No data" }: { label?: string }) { + return
{label}.
; +} + +export function Th({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} + +export function Td({ children, className = "" }: { children: React.ReactNode; className?: string }) { + return ( + {children} + ); +} diff --git a/web/src/components/Labels.tsx b/web/src/components/Labels.tsx new file mode 100644 index 0000000..2ad5f9e --- /dev/null +++ b/web/src/components/Labels.tsx @@ -0,0 +1,29 @@ +type Labels = Record | null | undefined; + +export function LabelChips({ labels, limit }: { labels: Labels; limit?: number }) { + const entries = Object.entries(labels ?? {}).sort(([a], [b]) => a.localeCompare(b)); + if (entries.length === 0) return ; + + const visible = limit === undefined ? entries : entries.slice(0, limit); + const hidden = entries.length - visible.length; + + return ( +
+ {visible.map(([k, v]) => ( + + {k}= + {v} + + ))} + {hidden > 0 && ( + + +{hidden} + + )} +
+ ); +} diff --git a/web/src/components/Pager.tsx b/web/src/components/Pager.tsx new file mode 100644 index 0000000..a48a3fe --- /dev/null +++ b/web/src/components/Pager.tsx @@ -0,0 +1,33 @@ +import Link from "next/link"; + +export function Pager({ + basePath, + cursor, + nextCursor, + extra = {}, +}: { + basePath: string; + cursor?: string; + nextCursor?: string | null; + extra?: Record; +}) { + const make = (c?: string) => { + const sp = new URLSearchParams(); + for (const [k, v] of Object.entries(extra)) if (v) sp.set(k, v); + if (c) sp.set("cursor", c); + const qs = sp.toString(); + return qs ? `${basePath}?${qs}` : basePath; + }; + return ( +
+ {cursor ? `cursor: ${cursor.slice(0, 12)}…` : ""} + {nextCursor ? ( + + next → + + ) : ( + end + )} +
+ ); +} diff --git a/web/src/lib/api-types.ts b/web/src/lib/api-types.ts new file mode 100644 index 0000000..3d3fead --- /dev/null +++ b/web/src/lib/api-types.ts @@ -0,0 +1,648 @@ +/** + * This file was auto-generated by openapi-typescript. + * Do not make direct changes to the file. + */ + +export interface paths { + "/api/v1/health": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Health check */ + get: operations["getHealth"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/ready": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Readiness check */ + get: operations["getReady"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/heartbeat": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Ingest agent heartbeat */ + post: operations["ingestHeartbeat"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/events": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Ingest agent check result events */ + post: operations["ingestEvents"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/agents": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List agents */ + get: operations["listAgents"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/agents/{agent_id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get agent detail */ + get: operations["getAgent"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/checks": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List current check states */ + get: operations["listChecks"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/incidents": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List incidents */ + get: operations["listIncidents"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/events/query": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Query stored events */ + get: operations["queryEvents"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/notifiers/outbox": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List notification outbox items */ + get: operations["listOutbox"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; +} +export type webhooks = Record; +export interface components { + schemas: { + HealthResponse: { + /** @enum {string} */ + status: "ok"; + }; + ErrorResponse: { + error: { + /** @enum {string} */ + code: "unauthorized" | "not_found" | "validation" | "payload_too_large" | "rate_limited" | "internal" | "not_ready"; + message: string; + /** @description Server-generated request id, mirrored from X-Request-Id. */ + request_id: string; + }; + }; + PageEnvelope: { + /** @description Pass back as `cursor` to fetch the next page. Null/absent if no more results. */ + next_cursor?: string | null; + }; + AcceptedResponse: { + accepted: boolean; + }; + EventBatchAcceptedResponse: { + /** @description Number of events stored on this request. */ + accepted: number; + /** @description Number of events whose `event_id` was already known. */ + deduplicated: number; + }; + HeartbeatRequest: { + agent_id: string; + /** Format: date-time */ + observed_at: string; + hostname: string; + features: components["schemas"]["AgentFeatures"]; + /** @description Up to 32 labels. Key max 64, value max 256. Monlet agents reserve `monlet_agent_version`. Secret-like keys are rejected. */ + labels?: { + [key: string]: string; + }; + }; + AgentFeatures: { + /** @description Agent pushes heartbeats/events to the server. */ + push: boolean; + /** @description Agent exposes a Prometheus-compatible metrics endpoint. */ + metrics: boolean; + }; + EventBatchRequest: { + agent_id: string; + events: components["schemas"]["CheckResultEvent"][]; + }; + /** + * @description Event body used inside `EventBatchRequest.events`. The owning `agent_id` + * is supplied once at the batch envelope; per-event `agent_id` is intentionally + * absent so requests cannot mix or spoof other agents under a shared token. + * Server response endpoints (`GET /events/query`) include `agent_id` separately + * in their item schema. + */ + CheckResultEvent: { + /** @description UUIDv7 string (lower-case, with dashes). See ADR-0006. */ + event_id: string; + check_id: string; + /** Format: date-time */ + observed_at: string; + /** @enum {string} */ + status: "ok" | "warning" | "critical" | "unknown"; + exit_code: number; + duration_ms: number; + /** + * @description Limit is 8 KiB measured in UTF-8 **bytes** (not characters). Agent truncates + * on a UTF-8 boundary before send and appends marker `...[truncated N bytes]`. + * `maxLength: 8192` here is a coarse schema-level upper bound; servers MUST + * additionally reject events whose UTF-8 byte length exceeds 8192 with + * `400 validation`. + */ + output?: string; + /** @default false */ + output_truncated: boolean; + /** + * @description If false, the server stores state/incidents but does not enqueue notifications for this event. + * @default true + */ + notifications_enabled: boolean; + incident_key?: string; + }; + /** @description Stored event returned by `GET /events/query`. Includes the resolved `agent_id` and server-side `received_at`. */ + StoredCheckResultEvent: components["schemas"]["CheckResultEvent"] & { + agent_id: string; + /** Format: date-time */ + received_at: string; + }; + Agent: { + agent_id: string; + hostname: string; + /** @enum {string} */ + status: "alive" | "stale" | "dead"; + /** Format: date-time */ + last_seen_at: string; + features: components["schemas"]["AgentFeatures"]; + labels?: { + [key: string]: string; + }; + }; + CheckState: { + agent_id: string; + check_id: string; + /** @enum {string} */ + status: "ok" | "warning" | "critical" | "unknown"; + /** Format: date-time */ + last_observed_at: string; + exit_code?: number; + incident_key?: string; + }; + Incident: { + id: string; + incident_key: string; + /** @enum {string} */ + state: "open" | "resolved"; + /** @enum {string} */ + severity: "warning" | "critical" | "unknown"; + agent_id?: string; + check_id?: string; + /** Format: date-time */ + opened_at: string; + /** Format: date-time */ + resolved_at?: string | null; + summary?: string; + }; + NotificationOutboxItem: { + id: string; + notifier: string; + /** @enum {string} */ + state: "pending" | "sending" | "sent" | "retry" | "failed" | "discarded"; + incident_id: string; + /** @enum {string} */ + event_type: "firing" | "resolved"; + attempts: number; + /** Format: date-time */ + next_attempt_at?: string | null; + last_error?: string | null; + /** Format: date-time */ + updated_at?: string; + }; + }; + responses: { + /** @description Authentication failed. */ + Unauthorized: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Resource not found. */ + NotFound: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Payload exceeds configured limit (1 MiB). */ + PayloadTooLarge: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Request body or parameters failed validation. */ + ValidationError: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Unexpected server error. */ + InternalError: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + }; + parameters: { + AgentId: string; + /** @description Opaque pagination cursor returned in `next_cursor`. */ + Cursor: string; + Limit: number; + }; + requestBodies: never; + headers: never; + pathItems: never; +} +export type $defs = Record; +export interface operations { + getHealth: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Service is alive. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HealthResponse"]; + }; + }; + }; + }; + getReady: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Service is ready. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HealthResponse"]; + }; + }; + /** @description Service is not ready. */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + }; + }; + ingestHeartbeat: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["HeartbeatRequest"]; + }; + }; + responses: { + /** @description Heartbeat accepted. */ + 202: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["AcceptedResponse"]; + }; + }; + 400: components["responses"]["ValidationError"]; + 401: components["responses"]["Unauthorized"]; + 413: components["responses"]["PayloadTooLarge"]; + 500: components["responses"]["InternalError"]; + }; + }; + ingestEvents: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["EventBatchRequest"]; + }; + }; + responses: { + /** @description Events accepted. */ + 202: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["EventBatchAcceptedResponse"]; + }; + }; + 400: components["responses"]["ValidationError"]; + 401: components["responses"]["Unauthorized"]; + 413: components["responses"]["PayloadTooLarge"]; + 500: components["responses"]["InternalError"]; + }; + }; + listAgents: { + parameters: { + query?: { + /** @description Opaque pagination cursor returned in `next_cursor`. */ + cursor?: components["parameters"]["Cursor"]; + limit?: components["parameters"]["Limit"]; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Agent list. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["PageEnvelope"] & { + items: components["schemas"]["Agent"][]; + }; + }; + }; + 401: components["responses"]["Unauthorized"]; + }; + }; + getAgent: { + parameters: { + query?: never; + header?: never; + path: { + agent_id: components["parameters"]["AgentId"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Agent detail. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Agent"]; + }; + }; + 401: components["responses"]["Unauthorized"]; + 404: components["responses"]["NotFound"]; + }; + }; + listChecks: { + parameters: { + query?: { + agent_id?: string; + /** @description Opaque pagination cursor returned in `next_cursor`. */ + cursor?: components["parameters"]["Cursor"]; + limit?: components["parameters"]["Limit"]; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Check state list. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["PageEnvelope"] & { + items: components["schemas"]["CheckState"][]; + }; + }; + }; + 401: components["responses"]["Unauthorized"]; + }; + }; + listIncidents: { + parameters: { + query?: { + state?: "open" | "resolved"; + /** @description Opaque pagination cursor returned in `next_cursor`. */ + cursor?: components["parameters"]["Cursor"]; + limit?: components["parameters"]["Limit"]; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Incident list. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["PageEnvelope"] & { + items: components["schemas"]["Incident"][]; + }; + }; + }; + 401: components["responses"]["Unauthorized"]; + }; + }; + queryEvents: { + parameters: { + query?: { + agent_id?: string; + check_id?: string; + /** @description Opaque pagination cursor returned in `next_cursor`. */ + cursor?: components["parameters"]["Cursor"]; + limit?: components["parameters"]["Limit"]; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Event list. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["PageEnvelope"] & { + items: components["schemas"]["StoredCheckResultEvent"][]; + }; + }; + }; + 401: components["responses"]["Unauthorized"]; + }; + }; + listOutbox: { + parameters: { + query?: { + /** @description Opaque pagination cursor returned in `next_cursor`. */ + cursor?: components["parameters"]["Cursor"]; + limit?: components["parameters"]["Limit"]; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Outbox list. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["PageEnvelope"] & { + items: components["schemas"]["NotificationOutboxItem"][]; + }; + }; + }; + 401: components["responses"]["Unauthorized"]; + }; + }; +} diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts new file mode 100644 index 0000000..c681633 --- /dev/null +++ b/web/src/lib/api.ts @@ -0,0 +1,67 @@ +import "server-only"; + +import type { components, operations } from "./api-types"; + +export type Agent = components["schemas"]["Agent"]; +export type CheckState = components["schemas"]["CheckState"]; +export type Incident = components["schemas"]["Incident"]; +export type StoredEvent = components["schemas"]["StoredCheckResultEvent"]; +export type OutboxItem = components["schemas"]["NotificationOutboxItem"]; + +type OkBody = O extends { responses: { 200: { content: { "application/json": infer B } } } } + ? B + : never; +type QueryOf = O extends { parameters: { query?: infer Q } } ? NonNullable : never; + +type AgentsBody = OkBody; +type ChecksBody = OkBody; +type IncidentsBody = OkBody; +type EventsBody = OkBody; +type OutboxBody = OkBody; + +const BASE = process.env.MONLET_API_BASE_URL ?? "http://127.0.0.1:8000"; +const TOKEN = process.env.MONLET_API_TOKEN ?? ""; + +export class ApiError extends Error { + constructor(public status: number, public code: string, message: string) { + super(message); + } +} + +async function get(path: string, params?: Record): Promise { + const url = new URL(path, BASE); + if (params) { + for (const [k, v] of Object.entries(params)) { + if (v !== undefined && v !== "") url.searchParams.set(k, String(v)); + } + } + const headers: Record = { Accept: "application/json" }; + if (TOKEN) headers.Authorization = `Bearer ${TOKEN}`; + const res = await fetch(url, { headers, cache: "no-store" }); + if (!res.ok) { + let code = "http_error"; + let message = res.statusText; + try { + const body = await res.json(); + code = body?.error?.code ?? code; + message = body?.error?.message ?? message; + } catch {} + throw new ApiError(res.status, code, message); + } + return res.json() as Promise; +} + +export const api = { + listAgents: (q: QueryOf = {}) => + get("/api/v1/agents", q), + getAgent: (agentId: string) => get(`/api/v1/agents/${encodeURIComponent(agentId)}`), + listChecks: (q: QueryOf = {}) => + get("/api/v1/checks", q), + listIncidents: (q: QueryOf = {}) => + get("/api/v1/incidents", q), + queryEvents: (q: QueryOf = {}) => + get("/api/v1/events/query", q), + listOutbox: (q: QueryOf = {}) => + get("/api/v1/notifiers/outbox", q), + health: () => get<{ status: string }>("/api/v1/health"), +}; diff --git a/web/src/lib/format.ts b/web/src/lib/format.ts new file mode 100644 index 0000000..4a17718 --- /dev/null +++ b/web/src/lib/format.ts @@ -0,0 +1,28 @@ +export function fmtDate(iso?: string | null): string { + if (!iso) return "—"; + const d = new Date(iso); + return Number.isNaN(d.getTime()) ? iso : d.toISOString().replace("T", " ").replace(/\..+$/, "Z"); +} + +export function statusBadgeClass(s: string): string { + switch (s) { + case "ok": + case "alive": + case "resolved": + case "sent": + return "text-emerald-400"; + case "warning": + case "stale": + case "retry": + return "text-amber-400"; + case "critical": + case "dead": + case "open": + case "failed": + return "text-red-400"; + case "discarded": + return "text-neutral-500"; + default: + return "text-neutral-300"; + } +} diff --git a/web/tests/mock-server.mjs b/web/tests/mock-server.mjs new file mode 100644 index 0000000..f8ae7ad --- /dev/null +++ b/web/tests/mock-server.mjs @@ -0,0 +1,133 @@ +import { createServer } from "node:http"; + +const PORT = Number(process.env.MOCK_PORT ?? 8765); + +const now = new Date().toISOString(); + +const fixtures = { + "/api/v1/health": { status: "ok" }, + "/api/v1/agents": { + items: [ + { + agent_id: "agent-1", + hostname: "host-1", + status: "alive", + last_seen_at: now, + features: { push: true, metrics: false }, + labels: { env: "dev", monlet_agent_version: "0.1.0" }, + }, + { + agent_id: "agent-2", + hostname: "host-2", + status: "stale", + last_seen_at: now, + features: { push: true, metrics: false }, + labels: {}, + }, + ], + next_cursor: null, + }, + "/api/v1/agents/agent-1": { + agent_id: "agent-1", + hostname: "host-1", + status: "alive", + last_seen_at: now, + features: { push: true, metrics: false }, + labels: { env: "dev", monlet_agent_version: "0.1.0" }, + }, + "/api/v1/checks": { + items: [ + { + agent_id: "agent-1", + check_id: "disk", + status: "ok", + exit_code: 0, + last_observed_at: now, + incident_key: "agent-1:disk", + }, + { + agent_id: "agent-1", + check_id: "load", + status: "warning", + exit_code: 1, + last_observed_at: now, + incident_key: "agent-1:load", + }, + ], + next_cursor: null, + }, + "/api/v1/incidents": { + items: [ + { + id: "00000000-0000-0000-0000-000000000001", + incident_key: "agent-1:load", + state: "open", + severity: "warning", + agent_id: "agent-1", + check_id: "load", + opened_at: now, + summary: "load high", + }, + ], + next_cursor: null, + }, + "/api/v1/events/query": { + items: [ + { + event_id: "00000000-0000-7000-8000-000000000001", + agent_id: "agent-1", + check_id: "disk", + observed_at: now, + received_at: now, + status: "ok", + exit_code: 0, + duration_ms: 12, + }, + ], + next_cursor: null, + }, + "/api/v1/notifiers/outbox": { + items: [ + { + id: "00000000-0000-0000-0000-000000000002", + notifier: "debug", + state: "sent", + incident_id: "00000000-0000-0000-0000-000000000001", + event_type: "firing", + attempts: 1, + next_attempt_at: null, + last_error: null, + updated_at: now, + }, + ], + next_cursor: null, + }, +}; + +const server = createServer((req, res) => { + const url = new URL(req.url ?? "/", `http://x`); + let body = fixtures[url.pathname]; + if (url.pathname === "/api/v1/checks" && body) { + const agentId = url.searchParams.get("agent_id"); + if (agentId) { + body = { + ...body, + items: body.items.filter((item) => item.agent_id === agentId), + next_cursor: null, + }; + } + } + res.setHeader("Content-Type", "application/json"); + if (!body) { + res.statusCode = 404; + res.end( + JSON.stringify({ error: { code: "not_found", message: "not in fixtures", request_id: "mock" } }), + ); + return; + } + res.end(JSON.stringify(body)); +}); + +server.listen(PORT, "127.0.0.1", () => { + console.log(`mock server on ${PORT}`); +}); diff --git a/web/tests/smoke.spec.ts b/web/tests/smoke.spec.ts new file mode 100644 index 0000000..019fc7d --- /dev/null +++ b/web/tests/smoke.spec.ts @@ -0,0 +1,63 @@ +import { expect, test } from "@playwright/test"; + +test("overview renders tallies", async ({ page }) => { + await page.goto("/"); + await expect(page.getByRole("heading", { name: "Overview" })).toBeVisible(); + await expect(page.locator("body")).toContainText("Agents"); + await expect(page.locator("body")).toContainText("Checks"); +}); + +test("agents list links to detail", async ({ page }) => { + await page.goto("/agents?sort=status&dir=desc"); + await expect(page).toHaveURL(/\/agents$/); + await page.goto("/agents"); + await expect(page.getByRole("heading", { name: "Agents" })).toBeVisible(); + await expect(page.locator("thead")).not.toContainText("hostname"); + await expect(page.locator("thead")).toContainText("features"); + await expect(page.locator("thead")).not.toContainText("asc"); + await expect(page.locator("thead")).not.toContainText("desc"); + await expect(page.locator("thead")).toContainText("↓"); + await expect(page.locator("body")).toContainText("checks 2"); + await expect(page.locator("body")).toContainText("warn 1"); + await page.getByPlaceholder("search agent").fill("host-1"); + await expect(page).toHaveURL(/\/agents$/); + await expect(page.locator("tbody")).toContainText("host-1"); + await expect(page.locator("tbody")).not.toContainText("host-2"); + await page.getByRole("button", { name: "host" }).click(); + await expect(page).toHaveURL(/\/agents$/); + await page.getByRole("button", { name: "clear" }).click(); + await page.getByRole("link", { name: "agent-1" }).first().click(); + await expect(page).toHaveURL(/\/agents\/agent-1$/); + await expect(page.locator("body")).toContainText("hostname"); + await expect(page.getByRole("link", { name: "Checks" }).last()).toBeVisible(); + await expect(page.locator("tbody").first().locator("tr").first()).toContainText("warning"); + await expect(page.locator("thead").first()).not.toContainText("incident_key"); + await page.getByRole("link", { name: "check_id" }).click(); + await expect(page).toHaveURL(/sort=check_id/); + await page.getByRole("link", { name: "Events" }).last().click(); + await expect(page).toHaveURL(/tab=events.*sort=check_id|sort=check_id.*tab=events/); + await expect(page.locator("thead").first()).not.toContainText("exit"); + await page.getByRole("link", { name: "Checks" }).last().click(); + await expect(page).toHaveURL(/tab=checks.*sort=check_id|sort=check_id.*tab=checks/); +}); + +test("checks page lists rows", async ({ page }) => { + await page.goto("/checks"); + await expect(page.locator("body")).toContainText("disk"); + await expect(page.locator("body")).toContainText("load"); +}); + +test("incidents filter renders", async ({ page }) => { + await page.goto("/incidents"); + await expect(page.locator("body")).toContainText("load high"); +}); + +test("events page", async ({ page }) => { + await page.goto("/events"); + await expect(page.getByRole("heading", { name: "Events" })).toBeVisible(); +}); + +test("outbox page", async ({ page }) => { + await page.goto("/outbox"); + await expect(page.locator("body")).toContainText("debug"); +}); diff --git a/web/tsconfig.json b/web/tsconfig.json new file mode 100644 index 0000000..cf9c65d --- /dev/null +++ b/web/tsconfig.json @@ -0,0 +1,34 @@ +{ + "compilerOptions": { + "target": "ES2017", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "react-jsx", + "incremental": true, + "plugins": [ + { + "name": "next" + } + ], + "paths": { + "@/*": ["./src/*"] + } + }, + "include": [ + "next-env.d.ts", + "**/*.ts", + "**/*.tsx", + ".next/types/**/*.ts", + ".next/dev/types/**/*.ts", + "**/*.mts" + ], + "exclude": ["node_modules"] +}