From 9547b7ab3b066b5bceaebb2dba5b13a2691b0125 Mon Sep 17 00:00:00 2001 From: Lorem Dev Date: Thu, 13 Aug 2026 01:19:50 +0200 Subject: [PATCH 01/32] build: share the icons between the docs build and the frontend The icons move to `assets/` at the root, where both builds can reach them: mkdocs serves only what is under `docs/`, so a hook stages them into `docs/assets/`, and the frontend's `prebuild` copies the favicon into `public/`. `favicon.ico` is generated from a small-size variant of the mark, because the full one collapses into a smudge at 16 pixels. Tracked through git-lfs, except `icon-128.png`: Docker Hub reads it through `raw.githubusercontent.com`, which serves the LFS pointer rather than the bytes -- verified, not assumed -- so the overview image would render broken with nothing in this repository failing to say so. --- .gitattributes | 27 ++++++++++++++ .gitignore | 14 ++++++++ DOCKERHUB.md | 2 +- README.md | 2 +- assets/favicon.ico | 3 ++ assets/favicon.svg | 51 +++++++++++++++++++++++++++ {docs/assets => assets}/icon-128.png | Bin assets/icon.png | 3 ++ {docs/assets => assets}/icon.svg | 0 docs/assets/icon.png | Bin 138045 -> 0 bytes docs/index.md | 2 ++ mkdocs.yml | 8 ++++- scripts/mkdocs_assets.py | 28 +++++++++++++++ 13 files changed, 137 insertions(+), 3 deletions(-) create mode 100644 .gitattributes create mode 100644 assets/favicon.ico create mode 100644 assets/favicon.svg rename {docs/assets => assets}/icon-128.png (100%) create mode 100644 assets/icon.png rename {docs/assets => assets}/icon.svg (100%) delete mode 100644 docs/assets/icon.png create mode 100644 scripts/mkdocs_assets.py diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..253dc92 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,27 @@ +# Line endings are normalised on commit, so a checkout on Windows does not turn +# every file into a diff, and the ASCII-only rule in AGENTS.md is not quietly +# broken by a stray CR. +* text=auto eol=lf + +# The icons are binary. git-lfs keeps them out of the packfile, and the `-text` +# marker stops git from ever trying to reconcile two versions line by line. +# +# Any workflow that reads these files must check out with `lfs: true`. Without +# it the working tree holds a pointer file, and the documentation build then +# ships a few lines of text where an image was expected -- which fails as a +# broken image rather than as a missing file, so it is worth stating here. +*.png filter=lfs diff=lfs merge=lfs -text +*.ico filter=lfs diff=lfs merge=lfs -text + +# One exemption, and it is not a matter of taste. DOCKERHUB.md points Docker +# Hub's overview image at +# raw.githubusercontent.com/lorem-dev/doppel/main/assets/icon-128.png, and that +# endpoint serves the LFS *pointer* rather than the bytes -- verified on +# 2026-08-11, 130 bytes of ASCII beginning "version +# https://git-lfs.github.com/spec/v1". Tracked through LFS, the overview would +# render a broken image, and nothing in this repository would fail to say so. +# This file is small enough that a plain blob costs nothing. +assets/icon-128.png -filter -diff -merge text=auto + +# An SVG is XML, and a readable diff is worth more than treating it as opaque. +*.svg text diff --git a/.gitignore b/.gitignore index 97987e3..eb0f17c 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,20 @@ main.yaml # mkdocs build output /site +# Staged into the docs tree from assets/ by scripts/mkdocs_assets.py on every +# build. Committing the copy would be two files claiming to be the same icon. +/docs/assets + +# Frontend build and tooling output. `dist` is what the admin crate's build +# script embeds, so it is generated on the way to a binary and never committed; +# `public/favicon.ico` is copied in from assets/ by the prebuild script. +/frontend/node_modules +/frontend/dist +/frontend/coverage +/frontend/playwright-report +/frontend/test-results +/frontend/public/favicon.ico + # Python tooling for mkdocs .venv __pycache__/ diff --git a/DOCKERHUB.md b/DOCKERHUB.md index 45127aa..c043f82 100644 --- a/DOCKERHUB.md +++ b/DOCKERHUB.md @@ -14,7 +14,7 @@ a version that exists. The version checked in is the last one released; the tags table stays illustrative, like the documentation's. --> -![Doppel](https://raw.githubusercontent.com/lorem-dev/doppel/main/docs/assets/icon-128.png) +![Doppel](https://raw.githubusercontent.com/lorem-dev/doppel/main/assets/icon-128.png) # Doppel diff --git a/README.md b/README.md index f79549b..bd4b5df 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@

- Doppel Logo + Doppel Logo

$$Doppel{\color{lightblue}ganger}$$

diff --git a/assets/favicon.ico b/assets/favicon.ico new file mode 100644 index 0000000..55f16f4 --- /dev/null +++ b/assets/favicon.ico @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1a3eb6f7a5c8987dc6cf20c2a3c57bd414ac48ffb4165253b2c29bad50a06c2b +size 32038 diff --git a/assets/favicon.svg b/assets/favicon.svg new file mode 100644 index 0000000..c36a0c0 --- /dev/null +++ b/assets/favicon.svg @@ -0,0 +1,51 @@ + +doppel favicon + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/assets/icon-128.png b/assets/icon-128.png similarity index 100% rename from docs/assets/icon-128.png rename to assets/icon-128.png diff --git a/assets/icon.png b/assets/icon.png new file mode 100644 index 0000000..b7bb027 --- /dev/null +++ b/assets/icon.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:31e61af193e98ab55c431b9dd13c22921384a209917718c9140ecabf23685306 +size 138045 diff --git a/docs/assets/icon.svg b/assets/icon.svg similarity index 100% rename from docs/assets/icon.svg rename to assets/icon.svg diff --git a/docs/assets/icon.png b/docs/assets/icon.png deleted file mode 100644 index 31e3500068e78089ed1e0dabd39f5d6b4b4b9bf1..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 138045 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 None: # noqa: ARG001 -- mkdocs passes its config + DESTINATION.mkdir(parents=True, exist_ok=True) + for source in sorted(SOURCE.iterdir()): + if source.is_file(): + shutil.copy2(source, DESTINATION / source.name) From 7f2d227f6cf3ec4df33fdce9caabdd4e304de7d1 Mon Sep 17 00:00:00 2001 From: Lorem Dev Date: Tue, 11 Aug 2026 22:51:52 +0200 Subject: [PATCH 02/32] refactor: store the configuration as JSON, not a column per field 0002, 0003 and 0004 each added one column and nothing else. Their whole content was ADD COLUMN, plus a matching edit in load.rs, in save.rs, and in two hand-written statements a test existed solely to keep in step. Nothing ever queried admin_host or latency_min in SQL, so a column per field bought nothing and charged a migration for every field the format gained. configurations.settings and proxies.document now hold the documents; admin_tokens and mocks are folded into them. What stays a column is what SQL uses: name and revision for the compare-and-swap, ordinal for proxy order, config for the foreign key. load.rs loses twenty-five per-column helpers, and the rows are parsed by the same Deserialize impls the YAML loader uses, so a stored row is held to exactly the rules a configuration file is held to. The backfill in 0005 is checked by tests/migrate.rs, which plants rows in the old shape, applies the migration, and asserts the result parses back into the same configuration with the same revision. It has to: the revision is a hash of the canonical YAML and every write compare-and-swaps against it, so a backfill that changed one would refuse the next write as a conflict that never happened. One property moves rather than disappears: the unique index on (config, token) is gone, and rule V26 refuses duplicate token values on every write instead. --- .../migrations/0005_settings_as_jsonb.sql | 218 +++++++++ crates/doppel-store-postgres/src/load.rs | 462 +++--------------- crates/doppel-store-postgres/src/save.rs | 449 ++++++----------- .../doppel-store-postgres/src/test_support.rs | 17 + crates/doppel-store-postgres/tests/connect.rs | 8 +- crates/doppel-store-postgres/tests/load.rs | 147 ++---- crates/doppel-store-postgres/tests/migrate.rs | 397 +++++++++++++++ crates/doppel-store-postgres/tests/save.rs | 5 +- 8 files changed, 881 insertions(+), 822 deletions(-) create mode 100644 crates/doppel-store-postgres/migrations/0005_settings_as_jsonb.sql create mode 100644 crates/doppel-store-postgres/tests/migrate.rs diff --git a/crates/doppel-store-postgres/migrations/0005_settings_as_jsonb.sql b/crates/doppel-store-postgres/migrations/0005_settings_as_jsonb.sql new file mode 100644 index 0000000..d2ee4ac --- /dev/null +++ b/crates/doppel-store-postgres/migrations/0005_settings_as_jsonb.sql @@ -0,0 +1,218 @@ +-- Store the configuration as JSON instead of a column per field. +-- +-- Why: 0002, 0003 and 0004 each added one column and nothing else. Their whole +-- content was `ADD COLUMN`, plus a matching edit in load.rs, in save.rs, and in +-- two hand-written statements a test existed solely to keep in step. Nothing +-- ever queried `admin_host` or `latency_min` in SQL. A column per field bought +-- nothing and charged a migration for every field the configuration format +-- gained. +-- +-- What stays a column is what SQL uses: `name` and `revision` for the +-- compare-and-swap, `ordinal` for proxy order, `config` for the foreign key. +-- +-- The backfill below has one job, and it is not cosmetic: the revision of a +-- stored configuration is a hash of its canonical YAML, and every write +-- compare-and-swaps against it. If the JSON these statements build parsed back +-- into a configuration that differed in any way from the one the old columns +-- described, the revision would change, and the next write from a client holding +-- the old revision would be refused as a conflict that never happened. So the +-- reconstruction has to be exact, including the difference between a key that is +-- absent and one that is present and null -- `jsonb_strip_nulls` is what removes +-- the latter, and the sections that are wholly optional (`sentry`, `loss`, +-- `latency`) are built conditionally rather than stripped, because an empty +-- object is not the same as a missing one for a struct with required fields. +-- +-- One property is given up here: the unique index on (config, token) that made +-- duplicate token values impossible at the database level. Rule V26 refuses them +-- while the document is validated, which every write goes through, so the +-- guarantee moves rather than disappears. + +-- --------------------------------------------------------------------------- +-- configurations +-- --------------------------------------------------------------------------- + +ALTER TABLE configurations ADD COLUMN settings JSONB; + +UPDATE configurations c +SET settings = jsonb_strip_nulls( + jsonb_build_object( + 'server', jsonb_build_object( + 'host', c.server_host, + 'port', c.server_port + ), + 'logging', jsonb_build_object( + 'level', c.log_level, + 'format', c.log_format + ), + 'control', jsonb_build_object('socket', c.control_socket), + 'templates', jsonb_build_object('dir', c.templates_dir), + 'admin', jsonb_strip_nulls( + jsonb_build_object( + 'enable', c.admin_enable, + 'host', c.admin_host, + 'port', c.admin_port, + 'auth', jsonb_build_object('header', c.admin_auth_header), + 'upload', jsonb_build_object('limit', c.admin_upload_limit), + 'access', c.admin_access, + 'public', c.admin_public, + 'groups', c.admin_groups + ) + ) || jsonb_build_object( + -- Built separately because `||` is how a key is added to an + -- already-stripped object without stripping this one: an empty + -- token list must survive as `[]`. + 'tokens', COALESCE( + ( + SELECT jsonb_agg( + jsonb_build_object( + 'name', t.name, + 'group', t."group", + 'token', t.token + ) + ORDER BY t.ordinal + ) + FROM admin_tokens t + WHERE t.config = c.name + ), + '[]'::jsonb + ) + ) + ) + -- `sentry` is an optional section with a required `dsn`, so it is either + -- a whole object or absent. Stripping nulls from `{"dsn": null}` would + -- leave `{}`, which fails to parse. + || CASE + WHEN c.sentry_dsn IS NULL THEN '{}'::jsonb + ELSE jsonb_build_object('sentry', jsonb_build_object('dsn', c.sentry_dsn)) + END + ); + +ALTER TABLE configurations ALTER COLUMN settings SET NOT NULL; + +ALTER TABLE configurations + DROP COLUMN server_host, + DROP COLUMN server_port, + DROP COLUMN log_level, + DROP COLUMN log_format, + DROP COLUMN control_socket, + DROP COLUMN templates_dir, + DROP COLUMN sentry_dsn, + DROP COLUMN admin_enable, + DROP COLUMN admin_host, + DROP COLUMN admin_port, + DROP COLUMN admin_auth_header, + DROP COLUMN admin_upload_limit, + DROP COLUMN admin_access, + DROP COLUMN admin_public, + DROP COLUMN admin_groups; + +DROP TABLE admin_tokens; + +-- --------------------------------------------------------------------------- +-- proxies, with their mocks folded in +-- --------------------------------------------------------------------------- + +ALTER TABLE proxies ADD COLUMN document JSONB; + +UPDATE proxies p +SET document = jsonb_strip_nulls( + jsonb_build_object( + 'name', p.name, + 'type', p.kind, + 'url', p.url, + 'timeout', p.timeout_seconds, + 'body_limit', p.body_limit, + 'replace', p.replace_ratio, + 'rewrite_redirects', p.rewrite_redirects, + -- `type`, not `kind`: the Rust field is `kind` and the wire name is + -- `type`, and this document is read by the wire name. + 'resolve', jsonb_strip_nulls( + jsonb_build_object('type', p.resolve_kind, 'header', p.resolve_header) + ), + 'headers', p.headers, + 'access', p.access + ) + || CASE + WHEN p.loss_percentage IS NULL THEN '{}'::jsonb + ELSE jsonb_build_object( + 'loss', + jsonb_build_object( + 'percentage', p.loss_percentage, + 'status', p.loss_status + ) + ) + END + || CASE + WHEN p.latency_percentage IS NULL THEN '{}'::jsonb + ELSE jsonb_build_object( + 'latency', + jsonb_build_object( + 'percentage', p.latency_percentage, + 'min', p.latency_min, + 'max', p.latency_max + ) + ) + END + || jsonb_build_object( + -- `[]` rather than absent. `save` omits an empty mock list and + -- this writes it out, which is a difference in the JSON and not a + -- difference in the configuration: both parse to the same empty + -- vector. That is the invariant the whole backfill is held to -- + -- the document must *parse into* the configuration the old + -- columns described, not match `save` byte for byte -- and it is + -- what keeps every stored revision unchanged. + 'mocks', COALESCE( + ( + SELECT jsonb_agg( + jsonb_strip_nulls( + jsonb_build_object( + 'name', m.name, + 'request', jsonb_build_object( + 'method', m.method, + 'url', m.url_pattern, + 'headers', m.request_headers, + 'query', m.request_query, + 'body', m.request_body + ), + 'response', jsonb_strip_nulls( + jsonb_build_object( + 'status', m.status, + 'body', m.body, + 'json', m.json, + 'template', m.template, + 'headers', m.response_headers + ) + ), + 'proxy', m.proxy_override + ) + ) + ORDER BY m.ordinal + ) + FROM mocks m + WHERE m.config = p.config AND m.proxy = p.name + ), + '[]'::jsonb + ) + ) + ); + +ALTER TABLE proxies ALTER COLUMN document SET NOT NULL; + +ALTER TABLE proxies + DROP COLUMN kind, + DROP COLUMN url, + DROP COLUMN timeout_seconds, + DROP COLUMN body_limit, + DROP COLUMN replace_ratio, + DROP COLUMN rewrite_redirects, + DROP COLUMN resolve_kind, + DROP COLUMN resolve_header, + DROP COLUMN loss_percentage, + DROP COLUMN loss_status, + DROP COLUMN latency_percentage, + DROP COLUMN latency_min, + DROP COLUMN latency_max, + DROP COLUMN headers, + DROP COLUMN access; + +DROP TABLE mocks; diff --git a/crates/doppel-store-postgres/src/load.rs b/crates/doppel-store-postgres/src/load.rs index c547a83..6091ce8 100644 --- a/crates/doppel-store-postgres/src/load.rs +++ b/crates/doppel-store-postgres/src/load.rs @@ -1,11 +1,13 @@ -//! Reading a configuration back out of the five tables. - -use doppel_core::config::{ - AccessConfig, AdminConfig, AuthConfig, ByteSize, Config, ControlConfig, LatencyConfig, - LogFormat, LogLevel, LoggingConfig, LossConfig, MockConfig, MockProxyOverride, MockRequest, - MockResponse, ProxyAccessConfig, ProxyConfig, ProxyKind, ResolveConfig, ResolveKind, - SentryConfig, ServerConfig, TemplatesConfig, TokenConfig, UploadConfig, -}; +//! Reading a configuration back out of two tables. +//! +//! Both tables hold JSON documents, so this file has no per-column mapping and +//! no per-column checks: `serde_json::from_value` runs the same `Deserialize` +//! impls the YAML loader runs, which is what makes a row read here subject to +//! exactly the rules a configuration file is subject to. The previous version of +//! this file carried twenty-five helpers doing that job by hand, one per column +//! type, and every new configuration field needed another edit here. + +use doppel_core::config::{Config, ProxyConfig}; use doppel_core::store::{Revision, StoreError}; use sqlx::{Row, postgres::PgRow}; @@ -13,10 +15,6 @@ use crate::PostgresStore; impl PostgresStore { /// Read this store's configuration. - /// - /// Public rather than crate-private because the `ConfigStore` impl that - /// will delegate to it lands with `save` (task 5), and a method nothing - /// can call is a method nothing tests. pub async fn load_config(&self) -> Result<(Config, Revision), StoreError> { // One transaction at `REPEATABLE READ`, so every statement below sees // the same snapshot. @@ -41,7 +39,7 @@ impl PostgresStore { .await .map_err(query_failed)?; - let row = sqlx::query("SELECT * FROM configurations WHERE name = $1") + let row = sqlx::query("SELECT revision, settings FROM configurations WHERE name = $1") .bind(&self.config_name) .fetch_optional(&mut *tx) .await @@ -54,21 +52,17 @@ impl PostgresStore { .to_ne_bytes(), )); - let config = Config { - server: server_from(&row)?, - logging: logging_from(&row)?, - control: control_from(&row)?, - templates: templates_from(&row)?, - sentry: sentry_from(&row)?, - admin: self.admin_from(&mut tx, &row).await?, - proxies: self.proxies(&mut tx).await?, - }; + // The stored document omits `proxies`, which is a `#[serde(default)]` + // field, so this yields a configuration with an empty proxy list and the + // rows below fill it in. + let mut config: Config = document(&row, "settings")?; + config.proxies = self.proxies(&mut tx).await?; // The stored revision has to agree with the content it labels. They // diverge when a row was edited by hand, or when this code forgot to - // read a column that `save` wrote -- and either way every - // compare-and-swap downstream would then fail in a way that looks - // like contention rather than like corruption. + // read something `save` wrote -- and either way every compare-and-swap + // downstream would then fail in a way that looks like contention rather + // than like corruption. let computed = Revision::of_config(&config); if computed != stored_revision { return Err(StoreError::Invalid(vec![doppel_core::Violation::new( @@ -83,406 +77,70 @@ impl PostgresStore { Ok((config, stored_revision)) } - async fn admin_from( - &self, - tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, - row: &PgRow, - ) -> Result { - let tokens = sqlx::query( - "SELECT name, \"group\", token FROM admin_tokens WHERE config = $1 ORDER BY ordinal", - ) - .bind(&self.config_name) - .fetch_all(&mut **tx) - .await - .map_err(query_failed)? - .into_iter() - .map(|token| { - Ok(TokenConfig { - name: name(&token, "name")?, - group: name(&token, "group")?, - token: stored_token(&token, "token")?, - }) - }) - .collect::, StoreError>>()?; - - Ok(AdminConfig { - enable: row.try_get("admin_enable").map_err(query_failed)?, - host: parse_host(&text(row, "admin_host")?)?, - port: port(row, "admin_port")?, - auth: AuthConfig { - header: header_name(row, "admin_auth_header")?, - }, - tokens, - access: json_column::(row, "admin_access")?, - public: row.try_get("admin_public").map_err(query_failed)?, - groups: optional_json::>(row, "admin_groups")?, - upload: UploadConfig { - limit: byte_size(row, "admin_upload_limit")?, - }, - }) - } - async fn proxies( &self, tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, ) -> Result, StoreError> { - let rows = sqlx::query("SELECT * FROM proxies WHERE config = $1 ORDER BY ordinal") - .bind(&self.config_name) - .fetch_all(&mut **tx) - .await - .map_err(query_failed)?; + let rows = + sqlx::query("SELECT name, document FROM proxies WHERE config = $1 ORDER BY ordinal") + .bind(&self.config_name) + .fetch_all(&mut **tx) + .await + .map_err(query_failed)?; let mut proxies = Vec::with_capacity(rows.len()); for row in &rows { - let name = name(row, "name")?; - proxies.push(ProxyConfig { - mocks: self.mocks(tx, name.as_str()).await?, - name, - kind: match text(row, "kind")?.as_str() { - "http" => ProxyKind::Http, - // Including `tcp`, which earlier versions of this schema - // could store: the variant is gone, so a row holding it is - // a configuration this binary cannot serve, reported rather - // than silently coerced to `http`. - other => return Err(corrupt("proxies.kind", &format!("is `{other}`"))), - }, - url: url(row, "url")?, - timeout: timeout(row, "timeout_seconds")?, - resolve: ResolveConfig { - kind: match text(row, "resolve_kind")?.as_str() { - "default" => ResolveKind::Default, - "header" => ResolveKind::Header, - other => { - return Err(corrupt("proxies.resolve_kind", &format!("is `{other}`"))); - } - }, - header: optional_header_name(row, "resolve_header")?, - }, - access: optional_json::(row, "access")?, - headers: json_column(row, "headers")?, - loss: loss_from(row)?, - latency: latency_from(row)?, - replace: optional_ratio(row, "replace_ratio")?, - rewrite_redirects: row.try_get("rewrite_redirects").map_err(query_failed)?, - body_limit: byte_size(row, "body_limit")?, - }); + // The keyed name is read first so a document that fails to parse can + // be reported against the proxy it belongs to. serde names the field + // it could not read (`missing field 'max'`) but not the row, and + // "some proxy is malformed" is not a report anybody can act on. + let keyed: String = row.try_get("name").map_err(query_failed)?; + let proxy: ProxyConfig = document(row, &format!("proxies[{keyed}].document"))?; + + // The name is both a column and a field of the document, because the + // column is the primary key and the field is what the configuration + // format holds. A row whose two disagree is addressable under one + // name and serves another, so it is reported rather than resolved in + // favour of either. + if proxy.name.as_str() != keyed { + return Err(corrupt( + "proxies.name", + &format!( + "row `{keyed}` holds a document naming `{}`", + proxy.name.as_str() + ), + )); + } + + proxies.push(proxy); } Ok(proxies) } - - async fn mocks( - &self, - tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, - proxy: &str, - ) -> Result, StoreError> { - sqlx::query("SELECT * FROM mocks WHERE config = $1 AND proxy = $2 ORDER BY ordinal") - .bind(&self.config_name) - .bind(proxy) - .fetch_all(&mut **tx) - .await - .map_err(query_failed)? - .iter() - .map(|row| { - Ok(MockConfig { - name: name(row, "name")?, - request: MockRequest { - method: method(row, "method")?, - url: pattern(row, "url_pattern")?, - headers: json_column(row, "request_headers")?, - query: json_column(row, "request_query")?, - body: json_column(row, "request_body")?, - }, - response: MockResponse { - status: status(row, "status")?, - body: row.try_get("body").map_err(query_failed)?, - json: row.try_get("json").map_err(query_failed)?, - template: optional_template_name(row, "template")?, - headers: json_column(row, "response_headers")?, - }, - proxy: optional_json::(row, "proxy_override")?, - }) - }) - .collect() - } -} - -fn server_from(row: &PgRow) -> Result { - Ok(ServerConfig { - host: parse_host(&text(row, "server_host")?)?, - port: port(row, "server_port")?, - }) -} - -fn logging_from(row: &PgRow) -> Result { - // Through serde rather than a hand-written match: the string in the - // column is the one the YAML uses, and going through the same - // deserializer keeps the two spellings from drifting apart. - Ok(LoggingConfig { - level: enum_from_text::("log_level", &text(row, "log_level")?)?, - format: enum_from_text::("log_format", &text(row, "log_format")?)?, - }) -} - -fn control_from(row: &PgRow) -> Result { - Ok(ControlConfig { - socket: text(row, "control_socket")?.into(), - }) -} - -fn templates_from(row: &PgRow) -> Result { - Ok(TemplatesConfig { - dir: text(row, "templates_dir")?.into(), - }) -} - -/// An absent `sentry_dsn` and an empty one both mean the section is absent. -/// -/// `Some(SentryConfig { dsn: String::new() })` would round-trip as -/// `sentry:\n dsn: ""`, which is a different document from one with no -/// `sentry` section -- and a different revision. -fn sentry_from(row: &PgRow) -> Result, StoreError> { - let dsn: Option = row.try_get("sentry_dsn").map_err(query_failed)?; - Ok(dsn - .filter(|dsn| !dsn.is_empty()) - .map(|dsn| SentryConfig { dsn })) -} - -/// `loss` is present exactly when both its columns are. -/// -/// One of the two set on its own is a row nobody could have written through -/// `save`, so it is reported rather than silently treated as absent. -fn loss_from(row: &PgRow) -> Result, StoreError> { - let percentage: Option = row.try_get("loss_percentage").map_err(query_failed)?; - let status: Option = row.try_get("loss_status").map_err(query_failed)?; - match (percentage, status) { - (None, None) => Ok(None), - (Some(percentage), Some(status)) => Ok(Some(LossConfig { - percentage: ratio(percentage, "proxies.loss_percentage")?, - status: narrow_status(status, "proxies.loss_status")?, - })), - _ => Err(corrupt( - "proxies.loss_*", - "has some columns set and others null", - )), - } -} - -fn latency_from(row: &PgRow) -> Result, StoreError> { - let percentage: Option = row.try_get("latency_percentage").map_err(query_failed)?; - let min: Option = row.try_get("latency_min").map_err(query_failed)?; - let max: Option = row.try_get("latency_max").map_err(query_failed)?; - match (percentage, min, max) { - (None, None, None) => Ok(None), - (Some(percentage), Some(min), Some(max)) => Ok(Some(LatencyConfig { - percentage: ratio(percentage, "proxies.latency_percentage")?, - min: seconds(min, "proxies.latency_min")?, - max: seconds(max, "proxies.latency_max")?, - })), - _ => Err(corrupt( - "proxies.latency_*", - "has some columns set and others null", - )), - } -} - -fn text(row: &PgRow, column: &str) -> Result { - row.try_get(column).map_err(query_failed) -} - -/// A stored name, checked on the way in. -/// -/// The database has no opinion about what a name may contain, so a row edited -/// by hand can hold one the configuration format would refuse. Parsing here -/// means a `Config` this store produces is subject to the same rule as one -/// read from YAML, rather than a second, laxer standard nobody wrote down. -/// Generic over the cap so one function serves both a `Name` and the tighter -/// `ProxyName`, and the column is checked against the limit that type actually -/// carries rather than against whichever one this helper happened to name. -fn name( - row: &PgRow, - column: &str, -) -> Result, StoreError> { - let raw: String = row.try_get(column).map_err(query_failed)?; - doppel_core::config::Name::parse(raw).map_err(|err| corrupt(column, &err.to_string())) -} - -/// A stored token, checked on the way in, for the same reason as `name`. -/// -/// The error names the column and not the value: a corruption message about a -/// token is still somewhere a token would end up. -fn stored_token(row: &PgRow, column: &str) -> Result { - let raw: String = row.try_get(column).map_err(query_failed)?; - doppel_core::config::Token::parse(raw).map_err(|err| corrupt(column, &err.to_string())) -} - -/// A stored port, checked on the way in. -/// -/// `i32` is the column type, so the range check has two halves: the value has -/// to fit a `u16` at all, and then it has to be one `Port` accepts. Both are -/// reachable from a hand-edited row, and neither is reachable from YAML. -fn port(row: &PgRow, column: &str) -> Result { - let value: i32 = row.try_get(column).map_err(query_failed)?; - let narrowed = - u16::try_from(value).map_err(|_| corrupt(column, &format!("is {value}, not a port")))?; - doppel_core::config::Port::parse(narrowed).map_err(|err| corrupt(column, &err.to_string())) -} - -/// A stored status, checked on the way in. -/// -/// Two halves, as with `port`: the `i32` column has to narrow to a `u16` at -/// all, and then the number has to be one `HttpStatus` accepts. Both are -/// reachable from a hand-edited row and neither is reachable from YAML. -fn status(row: &PgRow, column: &str) -> Result { - let value: i32 = row.try_get(column).map_err(query_failed)?; - narrow_status(value, column) -} - -fn narrow_status(value: i32, column: &str) -> Result { - let narrowed = - u16::try_from(value).map_err(|_| corrupt(column, &format!("is {value}, not a status")))?; - doppel_core::config::HttpStatus::parse(narrowed) - .map_err(|err| corrupt(column, &err.to_string())) } -/// A stored method, checked on the way in. -fn method(row: &PgRow, column: &str) -> Result { - let raw: String = row.try_get(column).map_err(query_failed)?; - raw.parse() - .map_err(|err: doppel_core::config::MethodError| corrupt(column, &err.to_string())) -} - -/// A stored timeout, checked on the way in. +/// A stored JSON document, parsed through the configuration's own types. /// -/// The column is a nullable `bigint`, so both halves of the range are -/// reachable from a hand-edited row: a negative value, and a positive one -/// past what a timeout may be. -fn timeout( - row: &PgRow, - column: &str, -) -> Result, StoreError> { - optional_u64(row, column)? - .map(|value| { - doppel_core::config::TimeoutSeconds::parse(value) - .map_err(|err| corrupt(column, &err.to_string())) - }) - .transpose() -} - -/// A stored probability, checked on the way in. -fn ratio(value: f64, column: &str) -> Result { - doppel_core::config::Ratio::parse(value).map_err(|err| corrupt(column, &err.to_string())) -} - -fn optional_ratio( - row: &PgRow, - column: &str, -) -> Result, StoreError> { - let value: Option = row.try_get(column).map_err(query_failed)?; - value.map(|value| ratio(value, column)).transpose() -} - -/// A stored latency, checked on the way in. -fn seconds(value: f64, column: &str) -> Result { - doppel_core::config::Seconds::parse(value).map_err(|err| corrupt(column, &err.to_string())) -} - -/// A stored template file name, checked on the way in. -fn optional_template_name( - row: &PgRow, - column: &str, -) -> Result, StoreError> { - let raw: Option = row.try_get(column).map_err(query_failed)?; - raw.map(|raw| { - doppel_core::config::TemplateName::parse(raw) - .map_err(|err| corrupt(column, &err.to_string())) - }) - .transpose() -} - -/// A stored url pattern, checked on the way in. -fn pattern(row: &PgRow, column: &str) -> Result { - let raw: String = row.try_get(column).map_err(query_failed)?; - doppel_core::config::Pattern::parse(raw).map_err(|err| corrupt(column, &err.to_string())) -} - -/// A stored header name, checked on the way in. -fn header_name(row: &PgRow, column: &str) -> Result { - let raw: String = row.try_get(column).map_err(query_failed)?; - doppel_core::config::HeaderName::parse(raw).map_err(|err| corrupt(column, &err.to_string())) -} - -fn optional_header_name( - row: &PgRow, - column: &str, -) -> Result, StoreError> { - let raw: Option = row.try_get(column).map_err(query_failed)?; - raw.map(|raw| { - doppel_core::config::HeaderName::parse(raw).map_err(|err| corrupt(column, &err.to_string())) - }) - .transpose() -} - -/// A stored upstream URL, checked on the way in. -fn url(row: &PgRow, column: &str) -> Result { - let raw: String = row.try_get(column).map_err(query_failed)?; - doppel_core::config::UpstreamUrl::parse(&raw).map_err(|err| corrupt(column, &err.to_string())) -} - -/// A stored byte limit, checked on the way in. -fn byte_size(row: &PgRow, column: &str) -> Result { - let value: i64 = row.try_get(column).map_err(query_failed)?; - let unsigned = u64::try_from(value).map_err(|_| corrupt(column, "is negative"))?; - ByteSize::parse(unsigned).map_err(|err| corrupt(column, &err.to_string())) -} - -fn optional_u64(row: &PgRow, column: &str) -> Result, StoreError> { - let value: Option = row.try_get(column).map_err(query_failed)?; - value - .map(|value| u64::try_from(value).map_err(|_| corrupt(column, "is negative"))) - .transpose() -} - -fn json_column(row: &PgRow, column: &str) -> Result { +/// `label` is what the violation is reported under and is not always the column +/// name -- a proxy's document is reported as `proxies[alpha].document`, because +/// serde says which field it could not read but nothing about which row held it. +fn document(row: &PgRow, label: &str) -> Result { + let column = label.rsplit('.').next().unwrap_or(label); let value: serde_json::Value = row.try_get(column).map_err(query_failed)?; - serde_json::from_value(value).map_err(|err| corrupt(column, &err.to_string())) -} - -fn optional_json( - row: &PgRow, - column: &str, -) -> Result, StoreError> { - let value: Option = row.try_get(column).map_err(query_failed)?; - value - .map(|value| serde_json::from_value(value).map_err(|err| corrupt(column, &err.to_string()))) - .transpose() -} - -/// Deserialize an enum from the same text its YAML representation uses. -fn enum_from_text( - column: &str, - value: &str, -) -> Result { - serde_json::from_value(serde_json::Value::String(value.to_owned())) - .map_err(|_| corrupt(column, &format!("is `{value}`"))) -} - -fn parse_host(value: &str) -> Result { - value - .parse() - .map_err(|_| corrupt("host", &format!("`{value}` is not an IP address"))) + serde_json::from_value(value).map_err(|err| corrupt(label, &err.to_string())) } fn query_failed(err: sqlx::Error) -> StoreError { StoreError::Unavailable(format!("query failed: {err}")) } -/// A row that cannot be turned into a configuration. +/// A row the database accepted and the configuration format does not. /// -/// Reported as `Invalid` rather than `Unavailable`: the database answered -/// perfectly well, and what it holds is the problem. +/// Reported as `Invalid` rather than `Unavailable`: the database is working +/// perfectly, and what is wrong is the content -- which is a different thing for +/// an operator to do something about. fn corrupt(column: &str, detail: &str) -> StoreError { StoreError::Invalid(vec![doppel_core::Violation::new( column, - format!("stored value {detail}"), + format!("stored value is not one this version accepts: {detail}"), )]) } diff --git a/crates/doppel-store-postgres/src/save.rs b/crates/doppel-store-postgres/src/save.rs index 27604c4..774075d 100644 --- a/crates/doppel-store-postgres/src/save.rs +++ b/crates/doppel-store-postgres/src/save.rs @@ -1,6 +1,18 @@ -//! Writing a configuration into the five tables. - -use doppel_core::config::{Config, ProxyConfig}; +//! Writing a configuration into two tables. +//! +//! The document is stored as JSON rather than spread across a column per field. +//! That is a deliberate reversal of the original schema, and the reason is the +//! migration it saves: between 0.3.0 and 0.4.1 three fields were added to the +//! configuration format, and each one needed a migration whose only content was +//! `ADD COLUMN` -- plus a matching edit here, in `load`, and in two hand-written +//! statements that a test existed solely to keep in step. A field added to the +//! serde model now reaches the database with no schema change at all. +//! +//! What stays a column is what SQL actually uses: `name` and `revision` for the +//! compare-and-swap, `ordinal` for proxy order, `config` for the foreign key. +//! Nothing ever queried `admin_host`. + +use doppel_core::config::Config; use doppel_core::store::{Revision, StoreError}; use doppel_core::validate::validate; use sqlx::{Postgres, Transaction}; @@ -24,6 +36,7 @@ impl PostgresStore { ) -> Result { validate(config).map_err(StoreError::Invalid)?; let revision = Revision::of_config(config); + let settings = settings_of(config)?; let mut tx = self .pool() @@ -33,10 +46,10 @@ impl PostgresStore { match expected { Some(expected) => { - self.update_header(&mut tx, config, revision, expected) - .await? + self.update_header(&mut tx, &settings, revision, expected) + .await?; } - None => self.upsert_header(&mut tx, config, revision).await?, + None => self.upsert_header(&mut tx, &settings, revision).await?, } // Deleted and rewritten rather than diffed. A diff is more code whose @@ -45,41 +58,28 @@ impl PostgresStore { // transaction either way, so the rewrite costs nothing an operator can // observe. // - // `mocks` and `admin_tokens` go with their parents through - // `ON DELETE CASCADE`; `templates` deliberately does not, so it is - // untouched here. Dropping a proxy's files is a separate decision made - // after the configuration write, never as part of it. - self.clear_children(&mut tx).await?; + // `templates` deliberately does not cascade from here: dropping a + // proxy's files is a separate decision made after the configuration + // write, never as part of it. + sqlx::query("DELETE FROM proxies WHERE config = $1") + .bind(&self.config_name) + .execute(&mut *tx) + .await + .map_err(write_failed)?; - for (ordinal, token) in config.admin.tokens.iter().enumerate() { + for (ordinal, proxy) in config.proxies.iter().enumerate() { sqlx::query( - "INSERT INTO admin_tokens (config, name, \"group\", token, ordinal) \ - VALUES ($1, $2, $3, $4, $5)", + "INSERT INTO proxies (config, name, ordinal, document) VALUES ($1, $2, $3, $4)", ) .bind(&self.config_name) - .bind(token.name.as_str()) - .bind(token.group.as_str()) - .bind(token.token.as_str()) + .bind(proxy.name.as_str()) .bind(ordinal_of(ordinal)?) + .bind(as_json(proxy)?) .execute(&mut *tx) .await .map_err(write_failed)?; } - for (ordinal, proxy) in config.proxies.iter().enumerate() { - self.insert_proxy(&mut tx, proxy, ordinal_of(ordinal)?) - .await?; - for (mock_ordinal, mock) in proxy.mocks.iter().enumerate() { - self.insert_mock( - &mut tx, - proxy.name.as_str(), - mock, - ordinal_of(mock_ordinal)?, - ) - .await?; - } - } - tx.commit() .await .map_err(|err| StoreError::Unavailable(format!("cannot commit: {err}")))?; @@ -95,7 +95,7 @@ impl PostgresStore { async fn update_header( &self, tx: &mut Transaction<'_, Postgres>, - config: &Config, + settings: &serde_json::Value, revision: Revision, expected: Revision, ) -> Result<(), StoreError> { @@ -103,7 +103,7 @@ impl PostgresStore { .bind(as_i64(revision)) .bind(&self.config_name) .bind(as_i64(expected)) - .pipe_header(config) + .bind(settings) .execute(&mut **tx) .await .map_err(write_failed)? @@ -132,231 +132,70 @@ impl PostgresStore { async fn upsert_header( &self, tx: &mut Transaction<'_, Postgres>, - config: &Config, + settings: &serde_json::Value, revision: Revision, ) -> Result<(), StoreError> { sqlx::query(UPSERT_HEADER) .bind(&self.config_name) .bind(as_i64(revision)) - .pipe_header(config) + .bind(settings) .execute(&mut **tx) .await .map_err(write_failed)?; Ok(()) } - - async fn clear_children(&self, tx: &mut Transaction<'_, Postgres>) -> Result<(), StoreError> { - // Written out rather than looped over a table name, so no SQL here is - // built by formatting. Order matters only in that `proxies` comes - // last: `mocks` cascades from it, and deleting the parent first would - // make the explicit child delete a no-op that reads as if it did - // something. - for statement in [ - "DELETE FROM mocks WHERE config = $1", - "DELETE FROM admin_tokens WHERE config = $1", - "DELETE FROM proxies WHERE config = $1", - ] { - sqlx::query(statement) - .bind(&self.config_name) - .execute(&mut **tx) - .await - .map_err(write_failed)?; - } - Ok(()) - } - - async fn insert_proxy( - &self, - tx: &mut Transaction<'_, Postgres>, - proxy: &ProxyConfig, - ordinal: i32, - ) -> Result<(), StoreError> { - sqlx::query( - "INSERT INTO proxies (config, name, ordinal, kind, url, timeout_seconds, body_limit, \ - replace_ratio, rewrite_redirects, resolve_kind, resolve_header, loss_percentage, \ - loss_status, latency_percentage, latency_min, latency_max, headers, access) \ - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, \ - $18)", - ) - .bind(&self.config_name) - .bind(proxy.name.as_str()) - .bind(ordinal) - .bind(as_text(&proxy.kind)?) - .bind(proxy.url.as_str()) - .bind( - proxy - .timeout - .map(|t| i64::try_from(t.get()).unwrap_or(i64::MAX)), - ) - .bind(i64::try_from(proxy.body_limit.get()).unwrap_or(i64::MAX)) - .bind(proxy.replace.map(doppel_core::config::Ratio::get)) - .bind(proxy.rewrite_redirects) - .bind(as_text(&proxy.resolve.kind)?) - .bind( - proxy - .resolve - .header - .as_ref() - .map(doppel_core::config::HeaderName::as_str), - ) - .bind(proxy.loss.as_ref().map(|l| l.percentage.get())) - .bind(proxy.loss.as_ref().map(|l| i32::from(l.status.get()))) - .bind(proxy.latency.as_ref().map(|l| l.percentage.get())) - .bind(proxy.latency.as_ref().map(|l| l.min.get())) - .bind(proxy.latency.as_ref().map(|l| l.max.get())) - .bind(as_json(&proxy.headers)?) - .bind(proxy.access.as_ref().map(as_json).transpose()?) - .execute(&mut **tx) - .await - .map_err(write_failed)?; - Ok(()) - } - - async fn insert_mock( - &self, - tx: &mut Transaction<'_, Postgres>, - proxy: &str, - mock: &doppel_core::config::MockConfig, - ordinal: i32, - ) -> Result<(), StoreError> { - sqlx::query( - "INSERT INTO mocks (config, proxy, name, ordinal, method, url_pattern, status, body, \ - json, template, request_headers, request_query, request_body, response_headers, \ - proxy_override) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, \ - $14, $15)", - ) - .bind(&self.config_name) - .bind(proxy) - .bind(mock.name.as_str()) - .bind(ordinal) - .bind(mock.request.method.as_str()) - .bind(mock.request.url.as_str()) - .bind(i32::from(mock.response.status.get())) - .bind(mock.response.body.as_deref()) - .bind(mock.response.json.as_deref()) - .bind( - mock.response - .template - .as_ref() - .map(doppel_core::config::TemplateName::as_str), - ) - .bind(as_json(&mock.request.headers)?) - .bind(as_json(&mock.request.query)?) - .bind(as_json(&mock.request.body)?) - .bind(as_json(&mock.response.headers)?) - .bind(mock.proxy.as_ref().map(as_json).transpose()?) - .execute(&mut **tx) - .await - .map_err(write_failed)?; - Ok(()) - } } /// The two header statements, written out rather than assembled. /// -/// Assembling them from a shared column list would keep them in step -/// automatically, but only by building SQL with `format!` -- which sqlx -/// refuses by design, because that is where injection lives, and silencing -/// the refusal here would weaken it everywhere. They are literals instead, -/// and `the_two_header_statements_write_the_same_columns` below is what keeps -/// them in step: a column added to one and forgotten in the other would make -/// a configuration differ depending on whether it was created or updated. +/// sqlx refuses SQL built by formatting, by design, because that is where +/// injection lives -- so these are literals. There are two columns to keep in +/// step now rather than seventeen, which is most of the point of storing the +/// document as JSON. +const UPDATE_HEADER: &str = "UPDATE configurations SET revision = $1, settings = $4, \ + updated_at = now() WHERE name = $2 AND revision = $3"; + +const UPSERT_HEADER: &str = "INSERT INTO configurations (name, revision, settings) \ + VALUES ($1, $2, $3) \ + ON CONFLICT (name) DO UPDATE SET revision = $2, settings = $3, updated_at = now()"; + +/// The configuration as it is stored: the whole document except its proxies. /// -/// The bind order is the one `pipe_header` writes, and the tests below -/// check the statements against it. -const UPDATE_HEADER: &str = "UPDATE configurations SET revision = $1, \ - admin_enable = $4, server_host = $5, server_port = $6, log_level = $7, \ - log_format = $8, control_socket = $9, templates_dir = $10, sentry_dsn = $11, \ - admin_host = $12, admin_port = $13, admin_auth_header = $14, \ - admin_upload_limit = $15, admin_access = $16, admin_groups = $17, \ - admin_public = $18, updated_at = now() \ - WHERE name = $2 AND revision = $3"; - -const UPSERT_HEADER: &str = "INSERT INTO configurations \ - (name, revision, admin_enable, server_host, server_port, log_level, log_format, \ - control_socket, templates_dir, sentry_dsn, admin_host, admin_port, \ - admin_auth_header, admin_upload_limit, admin_access, admin_groups, \ - admin_public) \ - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, \ - $17) \ - ON CONFLICT (name) DO UPDATE SET revision = $2, \ - admin_enable = $3, server_host = $4, server_port = $5, log_level = $6, \ - log_format = $7, control_socket = $8, templates_dir = $9, sentry_dsn = $10, \ - admin_host = $11, admin_port = $12, admin_auth_header = $13, \ - admin_upload_limit = $14, admin_access = $15, admin_groups = $16, \ - admin_public = $17, updated_at = now()"; - -/// Bind the header values in `HEADER_COLUMNS` order. -/// -/// A trait rather than a free function so the call reads in the same place the -/// query does: the order of these binds and the order of that list are the one -/// thing a reader has to check, and putting them side by side is what makes -/// that possible. -trait BindHeader<'q> { - fn pipe_header(self, config: &'q Config) -> Self; -} - -impl<'q> BindHeader<'q> for sqlx::query::Query<'q, Postgres, sqlx::postgres::PgArguments> { - fn pipe_header(self, config: &'q Config) -> Self { - self.bind(config.admin.enable) - .bind(config.server.host.to_string()) - .bind(i32::from(config.server.port.get())) - .bind(text_of(&config.logging.level)) - .bind(text_of(&config.logging.format)) - .bind(config.control.socket.to_string_lossy().into_owned()) - .bind(config.templates.dir.to_string_lossy().into_owned()) - .bind(config.sentry.as_ref().map(|s| s.dsn.clone())) - .bind(config.admin.host.to_string()) - .bind(i32::from(config.admin.port.get())) - .bind(config.admin.auth.header.as_str()) - .bind(i64::try_from(config.admin.upload.limit.get()).unwrap_or(i64::MAX)) - .bind(serde_json::to_value(&config.admin.access).unwrap_or(serde_json::Value::Null)) - // `None` binds SQL NULL, which is what an absent `groups` has to - // round-trip as: see the 0003 migration for why materialising the - // default would break every pre-existing configuration's revision. - .bind( - config - .admin - .groups - .as_ref() - .map(|groups| serde_json::to_value(groups).unwrap_or(serde_json::Value::Null)), - ) - .bind(config.admin.public) - } -} - -/// An enum's wire spelling, which is the one the YAML and the column share. -fn text_of(value: &T) -> String { - serde_json::to_value(value) - .ok() - .and_then(|value| value.as_str().map(ToOwned::to_owned)) - .unwrap_or_default() -} - -fn as_text(value: &T) -> Result { - serde_json::to_value(value) - .ok() - .and_then(|value| value.as_str().map(ToOwned::to_owned)) - .ok_or_else(|| StoreError::Serialize("an enum did not serialize as a string".to_owned())) +/// The proxies come out because each one is a row of its own -- they are +/// addressed by name, ordered by `ordinal`, and read back individually. Nothing +/// else is transformed, which is what makes this immune to a field being added: +/// `serde_json::to_value` follows the same model `Deserialize` reads back, so +/// there is no second description of the configuration to keep in step. +fn settings_of(config: &Config) -> Result { + let mut document = as_json(config)?; + document + .as_object_mut() + .ok_or_else(|| { + StoreError::Unavailable("a configuration did not serialize to an object".to_owned()) + })? + .remove("proxies"); + Ok(document) } fn as_json(value: &T) -> Result { - serde_json::to_value(value).map_err(|err| StoreError::Serialize(err.to_string())) + serde_json::to_value(value) + .map_err(|err| StoreError::Unavailable(format!("cannot serialize for storage: {err}"))) } -/// A list position as the column's type. +/// A list index as the column's type. /// /// Fails rather than truncating: a configuration with more than two billion -/// proxies is not one anybody wrote, and silently wrapping the ordinal would -/// reorder the document. +/// proxies is not a real configuration, and a wrapped ordinal would silently +/// reorder the list. fn ordinal_of(index: usize) -> Result { i32::try_from(index) - .map_err(|_| StoreError::Serialize(format!("position {index} does not fit a column"))) + .map_err(|_| StoreError::Unavailable(format!("ordinal {index} does not fit a column"))) } -/// The revision as the column holds it: the same bits, not a numeric -/// conversion. PostgreSQL has no unsigned 64-bit integer, and a value above -/// `i64::MAX` is perfectly ordinary for a hash. +/// A `u64` revision in a signed column, bit for bit. +/// +/// Postgres has no unsigned integer, and the alternative -- storing it as text +/// -- would make the compare-and-swap a string comparison. fn as_i64(revision: Revision) -> i64 { i64::from_ne_bytes(revision.0.to_ne_bytes()) } @@ -373,92 +212,100 @@ fn write_failed(err: sqlx::Error) -> StoreError { mod tests { use super::*; - /// The header columns, in the order `pipe_header` binds them. + /// Both statements have to write both columns. /// - /// Lives here rather than beside the statements because only these tests - /// consume it, and a constant nothing in the binary reads is dead code the - /// compiler is right to refuse. - const HEADER_BINDS: &[&str] = &[ - "admin_enable", - "server_host", - "server_port", - "log_level", - "log_format", - "control_socket", - "templates_dir", - "sentry_dsn", - "admin_host", - "admin_port", - "admin_auth_header", - "admin_upload_limit", - "admin_access", - "admin_groups", - "admin_public", - ]; - - /// Every `column = $n` assignment in a statement. - fn assigned_columns(sql: &str) -> Vec<&str> { - sql.split(',') - .filter_map(|fragment| fragment.split_once('=')) - .map(|(column, _)| column.trim().rsplit(' ').next().unwrap_or(column).trim()) - .filter(|column| !column.is_empty()) - .collect() + /// The hazard this guards used to be much larger -- seventeen columns + /// spread across two literals, where one added to the update and forgotten + /// in the upsert made a configuration differ depending on whether it was + /// created or replaced. Two columns is small enough to read, and cheap + /// enough to keep asserting. + #[test] + fn both_header_statements_write_the_revision_and_the_settings() { + for statement in [UPDATE_HEADER, UPSERT_HEADER] { + assert!( + statement.contains("revision = $"), + "no revision assignment: {statement}" + ); + assert!( + statement.contains("settings = $"), + "no settings assignment: {statement}" + ); + assert!( + statement.contains("updated_at = now()"), + "no updated_at: {statement}" + ); + } } + /// The conditional half must stay conditional. + /// + /// Without the revision in the `WHERE`, `save_config` would overwrite a + /// configuration that had moved under the caller, which is the one thing + /// compare-and-swap exists to prevent, and no other test here would notice. #[test] - fn the_two_header_statements_write_the_same_columns() { - // The one thing a shared, formatted column list would have given for - // free, bought back with a test instead -- because building SQL by - // formatting is what sqlx refuses, and silencing that refusal here - // would weaken it everywhere else in the crate. - // - // A column added to one statement and forgotten in the other makes a - // configuration differ depending on whether it was created or - // updated, which is the kind of bug that only appears on the second - // deployment. - let mut update: Vec<_> = assigned_columns(UPDATE_HEADER) - .into_iter() - .filter(|column| *column != "revision" && *column != "name") - .collect(); - let mut upsert: Vec<_> = assigned_columns(UPSERT_HEADER) - .into_iter() - .filter(|column| *column != "revision") - .collect(); - update.sort_unstable(); - upsert.sort_unstable(); - assert_eq!(update, upsert, "the two header statements have drifted"); + fn the_update_is_guarded_by_the_expected_revision() { + assert!( + UPDATE_HEADER.contains("WHERE name = $2 AND revision = $3"), + "{UPDATE_HEADER}" + ); } #[test] - fn both_statements_write_exactly_the_columns_pipe_header_binds() { - // The other half: the statements agreeing with each other is no use if - // they disagree with the bind order. - let mut expected: Vec<_> = HEADER_BINDS.to_vec(); - expected.push("updated_at"); - expected.sort_unstable(); + fn the_settings_document_holds_everything_except_the_proxies() { + let config = doppel_core::config::load_from_str( + r#" +server: + host: "127.0.0.1" + port: 8080 +admin: + host: "127.0.0.1" + port: 8081 + tokens: [] + access: {} + upload: + limit: 1Mi +proxies: + - name: p1 + type: http + url: "https://example.com/" +"#, + ) + .unwrap(); + + let settings = settings_of(&config).unwrap(); + let object = settings.as_object().unwrap(); + assert!( + !object.contains_key("proxies"), + "proxies belong in their own rows: {settings}" + ); + for section in ["server", "admin", "logging", "control", "templates"] { + assert!(object.contains_key(section), "{section} is missing"); + } - let mut written: Vec<_> = assigned_columns(UPDATE_HEADER) - .into_iter() - .filter(|column| *column != "revision" && *column != "name") - .collect(); - written.sort_unstable(); - assert_eq!(written, expected); + // And what came out parses straight back into a configuration, which is + // what `load` relies on. An absent `proxies` is legal, so the reparsed + // document is a whole configuration with an empty proxy list. + let reparsed: Config = serde_json::from_value(settings).unwrap(); + assert!(reparsed.proxies.is_empty()); + assert_eq!(reparsed.admin.port, config.admin.port); } #[test] fn a_revision_survives_the_trip_through_a_signed_column() { - // PostgreSQL has no unsigned 64-bit integer, and a hash above - // `i64::MAX` is perfectly ordinary. The mapping reuses the bits rather - // than converting the value, so it has to be exactly reversible. - for raw in [0, 1, u64::MAX, u64::MAX / 2, 0x8000_0000_0000_0000] { - assert_eq!(from_i64(as_i64(Revision(raw))), Revision(raw)); + // The high bit is the case that matters: a revision past i64::MAX has to + // come back as the same u64 rather than as a negative number. + for revision in [ + Revision(0), + Revision(1), + Revision(u64::MAX), + Revision(1 << 63), + ] { + assert_eq!(from_i64(as_i64(revision)), revision); } } #[test] fn an_ordinal_that_cannot_fit_is_refused_rather_than_wrapped() { - // Wrapping would reorder the document silently, and order decides - // which mock answers a request. assert!(ordinal_of(0).is_ok()); assert!(ordinal_of(usize::MAX).is_err()); } diff --git a/crates/doppel-store-postgres/src/test_support.rs b/crates/doppel-store-postgres/src/test_support.rs index c527398..e7e2d3f 100644 --- a/crates/doppel-store-postgres/src/test_support.rs +++ b/crates/doppel-store-postgres/src/test_support.rs @@ -194,6 +194,23 @@ impl TestSchema { pool.close().await; } + /// Every row of a one-column JSON query, in the order the query asks for. + /// + /// For the migration suite, which reads the documents a backfill produced + /// and parses them itself rather than through the store: the store refuses + /// to connect to a schema whose `_sqlx_migrations` bookkeeping is not + /// complete, and a test that applies migration files by hand has no way to + /// forge that bookkeeping -- sqlx checksums it. + pub async fn json_rows(&self, sql: &str) -> Vec { + let pool = PgPool::connect(&self.url()).await.expect("connect"); + let rows = sqlx::raw_sql(AssertSqlSafe(sql.to_owned())) + .fetch_all(&pool) + .await + .expect("fetch json"); + pool.close().await; + rows.iter().map(|row| row.get(0)).collect() + } + pub async fn count(&self, table: &str) -> i64 { let pool = PgPool::connect(&self.url()).await.expect("connect"); let count: i64 = sqlx::raw_sql(AssertSqlSafe(format!("SELECT count(*) FROM {table}"))) diff --git a/crates/doppel-store-postgres/tests/connect.rs b/crates/doppel-store-postgres/tests/connect.rs index 42110aa..c54e5f5 100644 --- a/crates/doppel-store-postgres/tests/connect.rs +++ b/crates/doppel-store-postgres/tests/connect.rs @@ -77,12 +77,8 @@ async fn two_schemas_do_not_see_each_other() { assert_ne!(first.name(), second.name()); first .execute( - "INSERT INTO configurations (name, revision, server_host, server_port, \ - log_level, log_format, control_socket, templates_dir, admin_host, \ - admin_port, admin_auth_header, admin_upload_limit, admin_access) \ - VALUES ('only-in-first', 0, '127.0.0.1', 8080, 'info', 'json', \ - '/tmp/d.sock', './templates', '127.0.0.1', 8081, 'X-Proxy-Authorization', \ - 1048576, '{}')", + "INSERT INTO configurations (name, revision, settings) \ + VALUES ('only-in-first', 0, '{}')", ) .await; diff --git a/crates/doppel-store-postgres/tests/load.rs b/crates/doppel-store-postgres/tests/load.rs index 72cbbe7..69e993c 100644 --- a/crates/doppel-store-postgres/tests/load.rs +++ b/crates/doppel-store-postgres/tests/load.rs @@ -105,124 +105,38 @@ fn parse(yaml: &str) -> doppel_core::Config { /// Write a configuration into the tables the long way, so `load` is tested /// against rows this test wrote rather than against `save`'s idea of them. -/// Once `save` exists the conformance suite covers the pair; until then this -/// is the only way `load` can be wrong in isolation and be caught. +/// +/// "The long way" is much shorter than it was: both tables hold a JSON +/// document, so this plants the same two shapes `load` reads instead of +/// interpolating a column per field. The point is unchanged -- these are rows +/// the store did not produce, so `load` can be wrong in isolation and be +/// caught. +/// +/// Dollar quoting rather than escaped apostrophes: a document contains `"` and +/// may contain `'`, and `$tag$` sidesteps both. async fn insert(schema: &TestSchema, name: &str, config: &doppel_core::Config, revision: i64) { - let admin = &config.admin; + let mut settings = serde_json::to_value(config).expect("a config serializes"); + settings + .as_object_mut() + .expect("a config is an object") + .remove("proxies"); + schema .execute(&format!( - "INSERT INTO configurations (name, revision, server_host, server_port, log_level, \ - log_format, control_socket, templates_dir, sentry_dsn, admin_host, admin_port, \ - admin_auth_header, admin_upload_limit, admin_access) VALUES \ - ('{name}', {revision}, '{}', {}, '{}', '{}', '{}', '{}', {}, '{}', {}, '{}', {}, '{}')", - config.server.host, - config.server.port, - serde_json::to_value(config.logging.level).unwrap().as_str().unwrap(), - serde_json::to_value(config.logging.format).unwrap().as_str().unwrap(), - config.control.socket.display(), - config.templates.dir.display(), - config - .sentry - .as_ref() - .map_or("NULL".to_owned(), |s| format!("'{}'", s.dsn)), - admin.host, - admin.port, - admin.auth.header, - admin.upload.limit.get(), - serde_json::to_string(&admin.access).unwrap(), + "INSERT INTO configurations (name, revision, settings) \ + VALUES ('{name}', {revision}, $tag${settings}$tag$)" )) .await; - for (ordinal, token) in admin.tokens.iter().enumerate() { - schema - .execute(&format!( - "INSERT INTO admin_tokens (config, name, \"group\", token, ordinal) \ - VALUES ('{name}', '{}', '{}', '{}', {ordinal})", - // `as_str`, not `{}`: `Token`'s `Display` is redacted, so - // interpolating it would write the literal `` into - // every row. This is a test writing raw SQL on purpose -- the - // point is to plant rows the store did not produce -- so the - // deliberate escape hatch has to be spelled out. - token.name, - token.group, - token.token.as_str() - )) - .await; - } - for (ordinal, proxy) in config.proxies.iter().enumerate() { - let nullable = |value: Option| value.unwrap_or_else(|| "NULL".to_owned()); + let document = serde_json::to_value(proxy).expect("a proxy serializes"); schema .execute(&format!( - "INSERT INTO proxies (config, name, ordinal, kind, url, timeout_seconds, \ - body_limit, replace_ratio, resolve_kind, resolve_header, loss_percentage, \ - loss_status, latency_percentage, latency_min, latency_max, headers, access) \ - VALUES ('{name}', '{}', {ordinal}, '{}', '{}', {}, {}, {}, '{}', {}, {}, {}, \ - {}, {}, {}, '{}', {})", - proxy.name, - serde_json::to_value(proxy.kind).unwrap().as_str().unwrap(), - proxy.url, - nullable(proxy.timeout.map(|t| t.to_string())), - proxy.body_limit.get(), - nullable(proxy.replace.map(|r| r.to_string())), - serde_json::to_value(proxy.resolve.kind) - .unwrap() - .as_str() - .unwrap(), - nullable(proxy.resolve.header.as_ref().map(|h| format!("'{h}'"))), - nullable(proxy.loss.as_ref().map(|l| l.percentage.to_string())), - nullable(proxy.loss.as_ref().map(|l| l.status.to_string())), - nullable(proxy.latency.as_ref().map(|l| l.percentage.to_string())), - nullable(proxy.latency.as_ref().map(|l| l.min.to_string())), - nullable(proxy.latency.as_ref().map(|l| l.max.to_string())), - serde_json::to_string(&proxy.headers).unwrap(), - nullable( - proxy - .access - .as_ref() - .map(|a| format!("'{}'", serde_json::to_string(a).unwrap())) - ), + "INSERT INTO proxies (config, name, ordinal, document) \ + VALUES ('{name}', '{}', {ordinal}, $tag${document}$tag$)", + proxy.name )) .await; - - for (mock_ordinal, mock) in proxy.mocks.iter().enumerate() { - schema - .execute(&format!( - "INSERT INTO mocks (config, proxy, name, ordinal, method, url_pattern, \ - status, body, json, template, request_headers, request_query, \ - request_body, response_headers, proxy_override) VALUES \ - ('{name}', '{}', '{}', {mock_ordinal}, '{}', $tag${}$tag$, {}, {}, {}, {}, \ - '{}', '{}', '{}', '{}', {})", - proxy.name, - mock.name, - mock.request.method, - mock.request.url, - mock.response.status, - nullable( - mock.response - .body - .as_ref() - .map(|b| format!("$tag${b}$tag$")) - ), - nullable( - mock.response - .json - .as_ref() - .map(|j| format!("$tag${j}$tag$")) - ), - nullable(mock.response.template.as_ref().map(|t| format!("'{t}'"))), - serde_json::to_string(&mock.request.headers).unwrap(), - serde_json::to_string(&mock.request.query).unwrap(), - serde_json::to_string(&mock.request.body).unwrap(), - serde_json::to_string(&mock.response.headers).unwrap(), - nullable( - mock.proxy - .as_ref() - .map(|p| format!("'{}'", serde_json::to_string(p).unwrap())) - ), - )) - .await; - } } } @@ -343,9 +257,14 @@ async fn an_unknown_configuration_name_is_not_found() { #[tokio::test] async fn a_half_written_latency_is_reported_rather_than_read_as_absent() { - // Three columns hold one optional section. A row with some of them set is - // one nothing could have written through `save`, so treating it as "no - // latency configured" would serve a configuration nobody wrote. + // `latency` is an optional section with three required fields. A stored + // object missing one is something nothing could have written through + // `save`, so reading it as "no latency configured" -- or as a zero -- would + // serve a configuration nobody wrote. + // + // This used to be three columns, where the same hazard was a row with some + // of them set. The shape changed; the hazard did not, so neither did the + // test. let Some(url) = require_database() else { return; }; @@ -362,7 +281,7 @@ async fn a_half_written_latency_is_reported_rather_than_read_as_absent() { ) .await; schema - .execute("UPDATE proxies SET latency_max = NULL WHERE name = 'alpha'") + .execute("UPDATE proxies SET document = document #- '{latency,max}' WHERE name = 'alpha'") .await; let store = PostgresStore::connect(&schema.url(), "default", schema.templates_dir()) @@ -370,7 +289,11 @@ async fn a_half_written_latency_is_reported_rather_than_read_as_absent() { .expect("connect"); let err = store.load_config().await.expect_err("must be refused"); let message = format!("{err:?}"); - assert!(message.contains("latency"), "{message}"); + // The field serde could not read, and the proxy whose row held it. Either + // alone leaves an operator guessing: `max` does not say where, and the proxy + // name does not say what. + assert!(message.contains("max"), "{message}"); + assert!(message.contains("alpha"), "{message}"); schema.drop().await; } diff --git a/crates/doppel-store-postgres/tests/migrate.rs b/crates/doppel-store-postgres/tests/migrate.rs new file mode 100644 index 0000000..1636053 --- /dev/null +++ b/crates/doppel-store-postgres/tests/migrate.rs @@ -0,0 +1,397 @@ +//! The 0005 backfill, held to the one property that matters. +//! +//! Migration 0005 replaces a column per configuration field with two JSON +//! documents. The revision of a stored configuration is a hash of its canonical +//! YAML, and every write compare-and-swaps against it -- so if the JSON the +//! backfill builds parsed into a configuration that differed from the one the +//! old columns described, in any way at all, the revision would move. The next +//! write from a client holding the revision it had read would be refused as a +//! conflict that never happened, and the operator would see contention where +//! there was corruption. +//! +//! So this suite plants rows in the pre-0005 shape, applies the migration file, +//! and checks that what comes out parses back into exactly the configuration +//! that went in -- and that its revision is unchanged. +//! +//! It does not go through `PostgresStore`: `connect` refuses a schema whose +//! `_sqlx_migrations` bookkeeping is incomplete, and a test that applies +//! migration files by hand cannot forge that bookkeeping, because sqlx +//! checksums it. The store's own read path is covered by `load.rs` and +//! `conformance.rs`; what is under test here is the SQL. + +use doppel_core::config::{Config, ProxyConfig}; +use doppel_core::store::Revision; +use doppel_store_postgres::test_support::{TestSchema, require_database}; + +/// The schema as it stood before 0005, applied in order. +const BEFORE: &[&str] = &[ + include_str!("../migrations/0001_initial.sql"), + include_str!("../migrations/0002_proxy_rewrite_redirects.sql"), + include_str!("../migrations/0003_admin_groups.sql"), + include_str!("../migrations/0004_admin_public.sql"), +]; + +const JSONB: &str = include_str!("../migrations/0005_settings_as_jsonb.sql"); + +/// A configuration touching every column 0005 has to carry across: a sentry +/// section, tokens, a group allow-list, a non-default `enable` and auth header, +/// both resolve kinds, both fault sections, a replace ratio, an explicit +/// `rewrite_redirects`, a per-proxy access override, all three mock body forms, +/// a mock-level proxy override, all three selector maps, and a proxy with no +/// mocks at all. +/// +/// Deliberately without `dashboard` and `title`: this is what a configuration +/// stored before those fields existed looks like, which is the only kind of row +/// a backfill can encounter. +const FIXTURE: &str = r#" +server: + host: "127.0.0.1" + port: 18080 +logging: + level: debug + format: text +control: + socket: /tmp/doppel-migrate.sock +templates: + dir: /tmp/doppel-migrate-templates +sentry: + dsn: "https://key@sentry.example.com/1" +admin: + enable: false + host: "127.0.0.1" + port: 18081 + auth: + header: X-Admin-Token + public: false + groups: ["admin", "ci"] + tokens: + - name: ci + group: ci + token: "0123456789abcdef0123456789abcdef" + access: + list: public + read: [ci] + create: [admin] + update: [admin] + delete: [admin] + upload: [admin] + upload: + limit: 2Mi +proxies: + - name: alpha + type: http + url: "https://alpha.example.com/api/" + timeout: 12 + body_limit: 4Mi + replace: 0.25 + rewrite_redirects: false + headers: + X-Injected: "yes" + access: + read: [ci] + loss: + percentage: 0.055 + status: 503 + latency: + percentage: 0.5 + min: 0.1 + max: 1.5 + mocks: + - name: first + request: + method: POST + url: "^/widgets$" + headers: + trace: X-Trace-Id + query: + filter: ".filter" + body: + items: ".content.items" + response: + status: 201 + json: '{"ok": true}' + headers: + X-Mocked: "first" + proxy: + replace: 1.0 + latency: + percentage: 1.0 + min: 0.2 + max: 0.4 + - name: second + request: + method: GET + url: "^/health$" + response: + status: 200 + body: "ok" + - name: third + request: + method: GET + url: "^/rendered$" + response: + status: 200 + template: page.json.j2 + - name: beta + type: http + url: "https://beta.example.com/api/" + resolve: + type: header + header: X-Proxy-Name +"#; + +#[tokio::test] +async fn the_backfill_preserves_every_stored_revision() { + let Some(url) = require_database() else { + return; + }; + let schema = TestSchema::create(&url).await; + for migration in BEFORE { + schema.execute(migration).await; + } + + let expected = doppel_core::config::load_from_str(FIXTURE).expect("the fixture parses"); + let revision = Revision::of_config(&expected); + insert_pre_0005(&schema, "default", &expected, as_i64(revision)).await; + + schema.execute(JSONB).await; + + // Reassembled the way `load` does: the settings document holds everything + // but the proxies, which come from their own rows in `ordinal` order. + let settings = schema + .json_rows("SELECT settings FROM configurations WHERE name = 'default'") + .await; + let mut loaded: Config = + serde_json::from_value(settings[0].clone()).expect("the backfilled settings parse"); + loaded.proxies = schema + .json_rows("SELECT document FROM proxies WHERE config = 'default' ORDER BY ordinal") + .await + .into_iter() + .map(|document| { + serde_json::from_value::(document).expect("a backfilled proxy parses") + }) + .collect(); + + // Compared whole rather than field by field: a field this test forgot to + // list is exactly the field the backfill is most likely to have dropped. + assert_eq!(loaded, expected); + assert_eq!( + Revision::of_config(&loaded), + revision, + "the backfill changed a stored revision, which would refuse the next write as a conflict" + ); + + // And the tables the documents replaced are gone, so nothing can read them + // and quietly disagree with the document. + for table in ["admin_tokens", "mocks"] { + let present: Vec = schema + .json_rows(&format!( + "SELECT to_jsonb(count(*)) FROM information_schema.tables \ + WHERE table_schema = current_schema() AND table_name = '{table}'" + )) + .await; + assert_eq!(present[0], serde_json::json!(0), "{table} still exists"); + } + + schema.drop().await; +} + +#[tokio::test] +async fn a_configuration_with_no_tokens_no_sentry_and_no_mocks_survives() { + // The absent cases are where a backfill goes wrong: an empty token list has + // to stay `[]` rather than becoming null, and `sentry` has a required field, + // so `{"dsn": null}` stripped down to `{}` would fail to parse -- which is + // why that section is built conditionally rather than stripped. + let Some(url) = require_database() else { + return; + }; + let schema = TestSchema::create(&url).await; + for migration in BEFORE { + schema.execute(migration).await; + } + + let bare = doppel_core::config::load_from_str( + r#" +server: + host: "127.0.0.1" + port: 8080 +admin: + host: "127.0.0.1" + port: 8081 + tokens: [] + access: {} + upload: + limit: 1Mi +proxies: + - name: solo + type: http + url: "https://example.com/" +"#, + ) + .expect("the fixture parses"); + let revision = Revision::of_config(&bare); + insert_pre_0005(&schema, "default", &bare, as_i64(revision)).await; + + schema.execute(JSONB).await; + + let settings = schema + .json_rows("SELECT settings FROM configurations WHERE name = 'default'") + .await; + let mut loaded: Config = + serde_json::from_value(settings[0].clone()).expect("the backfilled settings parse"); + assert!(loaded.sentry.is_none(), "sentry must stay absent"); + assert!(loaded.admin.tokens.is_empty()); + + loaded.proxies = schema + .json_rows("SELECT document FROM proxies WHERE config = 'default' ORDER BY ordinal") + .await + .into_iter() + .map(|document| serde_json::from_value::(document).expect("a proxy parses")) + .collect(); + assert_eq!(loaded, bare); + assert_eq!(Revision::of_config(&loaded), revision); + + schema.drop().await; +} + +fn as_i64(revision: Revision) -> i64 { + i64::from_ne_bytes(revision.0.to_ne_bytes()) +} + +/// Write a configuration in the shape the schema had before 0005. +/// +/// Lifted from the version of `tests/load.rs` that planted rows this way, which +/// is why it interpolates SQL by hand: the whole point is to produce rows the +/// current code cannot write, so it cannot go through `save`. +#[allow(clippy::too_many_lines)] +async fn insert_pre_0005(schema: &TestSchema, name: &str, config: &Config, revision: i64) { + let admin = &config.admin; + let nullable = |value: Option| value.unwrap_or_else(|| "NULL".to_owned()); + + schema + .execute(&format!( + "INSERT INTO configurations (name, revision, server_host, server_port, log_level, \ + log_format, control_socket, templates_dir, sentry_dsn, admin_enable, admin_host, \ + admin_port, admin_auth_header, admin_upload_limit, admin_access, admin_public, \ + admin_groups) VALUES \ + ('{name}', {revision}, '{}', {}, '{}', '{}', '{}', '{}', {}, {}, '{}', {}, '{}', {}, \ + '{}', {}, {})", + config.server.host, + config.server.port, + serde_json::to_value(config.logging.level) + .unwrap() + .as_str() + .unwrap(), + serde_json::to_value(config.logging.format) + .unwrap() + .as_str() + .unwrap(), + config.control.socket.display(), + config.templates.dir.display(), + nullable(config.sentry.as_ref().map(|s| format!("'{}'", s.dsn))), + admin.enable, + admin.host, + admin.port, + admin.auth.header, + admin.upload.limit.get(), + serde_json::to_string(&admin.access).unwrap(), + nullable(admin.public.map(|p| p.to_string())), + nullable( + admin + .groups + .as_ref() + .map(|g| format!("'{}'", serde_json::to_string(g).unwrap())) + ), + )) + .await; + + for (ordinal, token) in admin.tokens.iter().enumerate() { + schema + .execute(&format!( + "INSERT INTO admin_tokens (config, name, \"group\", token, ordinal) \ + VALUES ('{name}', '{}', '{}', '{}', {ordinal})", + // `as_str`, not `{}`: `Token`'s `Display` is redacted, so + // interpolating it would write the literal `` into + // every row. + token.name, + token.group, + token.token.as_str() + )) + .await; + } + + for (ordinal, proxy) in config.proxies.iter().enumerate() { + schema + .execute(&format!( + "INSERT INTO proxies (config, name, ordinal, kind, url, timeout_seconds, \ + body_limit, replace_ratio, rewrite_redirects, resolve_kind, resolve_header, \ + loss_percentage, loss_status, latency_percentage, latency_min, latency_max, \ + headers, access) VALUES ('{name}', '{}', {ordinal}, '{}', '{}', {}, {}, {}, {}, \ + '{}', {}, {}, {}, {}, {}, {}, '{}', {})", + proxy.name, + serde_json::to_value(proxy.kind).unwrap().as_str().unwrap(), + proxy.url, + nullable(proxy.timeout.map(|t| t.to_string())), + proxy.body_limit.get(), + nullable(proxy.replace.map(|r| r.to_string())), + nullable(proxy.rewrite_redirects.map(|r| r.to_string())), + serde_json::to_value(proxy.resolve.kind) + .unwrap() + .as_str() + .unwrap(), + nullable(proxy.resolve.header.as_ref().map(|h| format!("'{h}'"))), + nullable(proxy.loss.as_ref().map(|l| l.percentage.to_string())), + nullable(proxy.loss.as_ref().map(|l| l.status.to_string())), + nullable(proxy.latency.as_ref().map(|l| l.percentage.to_string())), + nullable(proxy.latency.as_ref().map(|l| l.min.to_string())), + nullable(proxy.latency.as_ref().map(|l| l.max.to_string())), + serde_json::to_string(&proxy.headers).unwrap(), + nullable( + proxy + .access + .as_ref() + .map(|a| format!("'{}'", serde_json::to_string(a).unwrap())) + ), + )) + .await; + + for (mock_ordinal, mock) in proxy.mocks.iter().enumerate() { + schema + .execute(&format!( + "INSERT INTO mocks (config, proxy, name, ordinal, method, url_pattern, \ + status, body, json, template, request_headers, request_query, \ + request_body, response_headers, proxy_override) VALUES \ + ('{name}', '{}', '{}', {mock_ordinal}, '{}', $tag${}$tag$, {}, {}, {}, {}, \ + '{}', '{}', '{}', '{}', {})", + proxy.name, + mock.name, + mock.request.method, + mock.request.url, + mock.response.status, + nullable( + mock.response + .body + .as_ref() + .map(|b| format!("$tag${b}$tag$")) + ), + nullable( + mock.response + .json + .as_ref() + .map(|j| format!("$tag${j}$tag$")) + ), + nullable(mock.response.template.as_ref().map(|t| format!("'{t}'"))), + serde_json::to_string(&mock.request.headers).unwrap(), + serde_json::to_string(&mock.request.query).unwrap(), + serde_json::to_string(&mock.request.body).unwrap(), + serde_json::to_string(&mock.response.headers).unwrap(), + nullable( + mock.proxy + .as_ref() + .map(|p| format!("'{}'", serde_json::to_string(p).unwrap())) + ), + )) + .await; + } + } +} diff --git a/crates/doppel-store-postgres/tests/save.rs b/crates/doppel-store-postgres/tests/save.rs index 4c75d30..3d29d4f 100644 --- a/crates/doppel-store-postgres/tests/save.rs +++ b/crates/doppel-store-postgres/tests/save.rs @@ -200,9 +200,12 @@ async fn a_rewrite_leaves_no_rows_from_the_previous_version() { .expect("shrink"); assert_eq!(schema.count("proxies").await, 1); - assert_eq!(schema.count("mocks").await, 1); let (loaded, _) = store.load_config().await.expect("load"); assert_eq!(loaded, one_proxy); + // A dropped proxy's mocks used to be a `mocks` row count; they now live + // inside the proxy's own document, so deleting the row takes them with it and + // what is left to check is that the surviving document kept its own. + assert_eq!(loaded.proxies[0].mocks.len(), 1); schema.drop().await; } From e9e23b536d4a86b8b3b8b62229ce389ee2ef12ec Mon Sep 17 00:00:00 2001 From: Lorem Dev Date: Tue, 11 Aug 2026 22:52:04 +0200 Subject: [PATCH 03/32] feat: add admin.dashboard and admin.title Two fields the browser dashboard needs: whether to serve it, and what heading to show. Both default to something an existing configuration gets on upgrade without being edited -- on, and "Doppel". Both are Option and skipped when absent, so the canonical YAML of a configuration that mentions neither is unchanged and no stored revision moves. Neither needed a migration, which is the first dividend of storing the document as JSON; the conformance fixture now carries both, so a store that went back to needing a column per field would fail there rather than in production. AdminTitle is a type rather than a String: non-empty, at most 64 characters, no control characters, counted in characters so the bound does not depend on the language the title is written in. It deliberately accepts markup -- escaping belongs where the HTML is written, not in a type that would forbid a legitimate heading to paper over a rendering bug. --- crates/doppel-core/src/config/admin.rs | 39 +++++ crates/doppel-core/src/config/mod.rs | 43 +++++ crates/doppel-core/src/config/title.rs | 219 +++++++++++++++++++++++++ crates/doppel-core/src/conformance.rs | 7 + doppel-config.schema.json | 24 +++ main.example.yaml | 7 + 6 files changed, 339 insertions(+) create mode 100644 crates/doppel-core/src/config/title.rs diff --git a/crates/doppel-core/src/config/admin.rs b/crates/doppel-core/src/config/admin.rs index 3b75940..647f8b3 100644 --- a/crates/doppel-core/src/config/admin.rs +++ b/crates/doppel-core/src/config/admin.rs @@ -174,6 +174,27 @@ pub struct AdminConfig { pub access: AccessConfig, /// Bounds an uploaded template file. pub upload: UploadConfig, + /// Serve the browser dashboard from the admin listener's root. + /// + /// On by default. Off means `/`, `/static/*` and `/robots.txt` are not + /// routed at all and answer 404 like any other unknown path; the JSON API is + /// untouched either way. + /// + /// An `Option` and skipped when absent, for the reason given on `groups`: + /// adding it must not change the canonical YAML of configurations written + /// before it existed. Read it through + /// [`AdminConfig::is_dashboard_enabled`]. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub dashboard: Option, + /// The heading the dashboard shows, and the browser tab's title. + /// + /// Defaults to `Doppel`. Useful when several Doppels are open at once and + /// the tabs are otherwise identical. + /// + /// An `Option` and skipped when absent, for the same reason as `dashboard`. + /// Read it through [`AdminConfig::title`]. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub title: Option, } impl AdminConfig { @@ -203,6 +224,24 @@ impl AdminConfig { self.public.unwrap_or(false) || self.groups.as_deref().is_some_and(<[_]>::is_empty) } + /// Whether the browser dashboard is served. + /// + /// One place resolves the absent case, so no caller has to remember which + /// way an unwritten field falls -- and the default is on, so an existing + /// configuration gains the dashboard on upgrade without being edited. + #[must_use] + pub fn is_dashboard_enabled(&self) -> bool { + self.dashboard.unwrap_or(true) + } + + /// The dashboard's heading, with an absent `title` resolved to its default. + #[must_use] + pub fn title(&self) -> &str { + self.title + .as_ref() + .map_or(super::title::DEFAULT, super::AdminTitle::as_str) + } + /// What `access` amounts to once `is_public` is taken into account. /// /// `None` when the configuration is public, which every caller reads as diff --git a/crates/doppel-core/src/config/mod.rs b/crates/doppel-core/src/config/mod.rs index d37f5af..1660819 100644 --- a/crates/doppel-core/src/config/mod.rs +++ b/crates/doppel-core/src/config/mod.rs @@ -16,6 +16,7 @@ pub mod server; pub mod size; pub mod status; pub mod template; +pub mod title; pub mod token; pub mod url; @@ -44,6 +45,7 @@ pub use server::{ pub use size::{ByteSize, ByteSizeError}; pub use status::{HttpStatus, StatusError}; pub use template::{TemplateName, TemplateNameError}; +pub use title::{AdminTitle, AdminTitleError}; pub use token::{Token, TokenError}; pub use url::{UpstreamUrl, UrlError}; @@ -198,6 +200,47 @@ proxies: assert!(!load_from_str(&off).unwrap().admin.enable); } + #[test] + fn the_dashboard_is_on_and_titled_doppel_unless_told_otherwise() { + // Both defaults are what an existing configuration gets on upgrade + // without being edited, so they are the interesting half of the + // feature rather than a formality. + let default = load_from_str(MINIMAL).unwrap(); + assert!(default.admin.is_dashboard_enabled()); + assert_eq!(default.admin.title(), "Doppel"); + + let written = MINIMAL.replace( + " port: 8081", + " port: 8081\n dashboard: false\n title: \"Doppel (staging)\"", + ); + let written = load_from_str(&written).unwrap(); + assert!(!written.admin.is_dashboard_enabled()); + assert_eq!(written.admin.title(), "Doppel (staging)"); + } + + #[test] + fn adding_the_dashboard_fields_did_not_change_any_stored_revision() { + // The revision is a hash of the canonical YAML, and every stored + // configuration is compare-and-swapped against its own. A default that + // materialised into the document would change the revision of every + // configuration written before these fields existed, and each would fail + // its own revision check on the first load after the upgrade -- which is + // why both fields are `Option` and skipped when absent. + let config = load_from_str(MINIMAL).unwrap(); + let yaml = canonical_yaml(&config).unwrap(); + assert!(!yaml.contains("dashboard"), "{yaml}"); + assert!(!yaml.contains("title"), "{yaml}"); + + // And the pair does serialize once written, or the field would be + // unsettable rather than merely absent. + let mut written = config; + written.admin.dashboard = Some(false); + written.admin.title = Some(AdminTitle::parse("Doppel (staging)").unwrap()); + let yaml = canonical_yaml(&written).unwrap(); + assert!(yaml.contains("dashboard: false"), "{yaml}"); + assert!(yaml.contains("title: Doppel (staging)"), "{yaml}"); + } + #[test] fn server_workers_is_no_longer_a_configuration_field() { // It sizes the tokio runtime, and a database store cannot be opened diff --git a/crates/doppel-core/src/config/title.rs b/crates/doppel-core/src/config/title.rs new file mode 100644 index 0000000..84afb59 --- /dev/null +++ b/crates/doppel-core/src/config/title.rs @@ -0,0 +1,219 @@ +//! The dashboard's heading, parsed rather than validated. +//! +//! A title is the one configuration value this project renders into a web page, +//! so it is the one value whose bounds are about a browser rather than about a +//! filesystem or a metric label. It is checked while the document is parsed, +//! like every other value, so no unchecked title exists at any later moment. +//! +//! Escaping is *not* this type's job and deliberately so. A title is legal text +//! that happens to be displayed; making the type refuse `<` would forbid a +//! perfectly reasonable heading in order to paper over a rendering bug, and +//! would still leave every other injection sink unprotected. The escaping +//! belongs where the HTML is produced, in `doppel-admin`, and has a test of its +//! own there. + +use std::fmt; +use std::str::FromStr; + +use serde::{Deserialize, Deserializer, Serialize, Serializer}; + +/// The longest title accepted. +/// +/// It sits in a page header next to the theme and sign-in controls, so the bound +/// is about what fits rather than about what a machine can hold: 64 characters +/// is a long service name and a short sentence, and anything past it is being +/// used as a subtitle. +pub const MAX_LEN: usize = 64; + +/// The default heading, used when `admin.title` is absent. +pub const DEFAULT: &str = "Doppel"; + +/// A validated dashboard title. +/// +/// Non-empty, at most [`MAX_LEN`] characters, no control characters. Not +/// restricted to ASCII: this is a UI string, which `AGENTS.md` names as the +/// exception to the repository's ASCII rule, and an operator naming their +/// staging environment in their own language is doing nothing wrong. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct AdminTitle(String); + +/// Why a title was refused. +/// +/// One variant per rule, so the message says what is wrong rather than restating +/// the rule and leaving the reader to work out which part they broke. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum AdminTitleError { + /// Empty and whitespace-only are one case on purpose: both render as a + /// header with nothing in it, and an operator who wrote `title: " "` meant + /// to write something. + #[error("a title must not be empty")] + Empty, + #[error("a title must be at most {MAX_LEN} characters, this one is {0}")] + TooLong(usize), + /// A tab or a newline in a heading is either a copy-paste accident or an + /// attempt to do something to the markup. Neither is worth accepting, and + /// naming the character is what turns this into a fixable report. + #[error("a title must not contain control characters; this one contains {0:?}")] + Control(char), +} + +impl AdminTitle { + /// Check a string and keep it, or say why not. + pub fn parse(value: impl Into) -> Result { + let value = value.into(); + + if value.trim().is_empty() { + return Err(AdminTitleError::Empty); + } + // Counted in characters rather than bytes. A title is displayed, and + // what a reader sees is characters; measuring the bound in bytes would + // make the limit depend on the language the title is written in. + let length = value.chars().count(); + if length > MAX_LEN { + return Err(AdminTitleError::TooLong(length)); + } + // `char::is_control` rather than `is_ascii_control`: the Unicode class + // includes the ASCII one, and a title is not restricted to ASCII, so + // checking only the ASCII range would let a directional override + // through -- a character whose whole purpose is to make displayed text + // disagree with the text that is stored. + if let Some(bad) = value.chars().find(|c| c.is_control()) { + return Err(AdminTitleError::Control(bad)); + } + + Ok(Self(value)) + } + + #[must_use] + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl fmt::Display for AdminTitle { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + f.write_str(&self.0) + } +} + +impl FromStr for AdminTitle { + type Err = AdminTitleError; + + fn from_str(value: &str) -> Result { + Self::parse(value) + } +} + +impl AsRef for AdminTitle { + fn as_ref(&self) -> &str { + &self.0 + } +} + +impl Serialize for AdminTitle { + fn serialize(&self, s: S) -> Result { + s.serialize_str(&self.0) + } +} + +impl<'de> Deserialize<'de> for AdminTitle { + fn deserialize>(d: D) -> Result { + let value = String::deserialize(d)?; + Self::parse(value).map_err(serde::de::Error::custom) + } +} + +impl utoipa::PartialSchema for AdminTitle { + fn schema() -> utoipa::openapi::RefOr { + utoipa::openapi::schema::ObjectBuilder::new() + .schema_type(utoipa::openapi::schema::Type::String) + .min_length(Some(1)) + .max_length(Some(MAX_LEN)) + .description(Some(format!( + "The heading the dashboard shows, at most {MAX_LEN} characters \ + and free of control characters. Defaults to `{DEFAULT}`." + ))) + .into() + } +} + +impl utoipa::ToSchema for AdminTitle {} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_titles_people_actually_write_are_accepted() { + for title in [ + DEFAULT, + "Doppel (staging)", + "billing-api doppelganger", + "a".repeat(MAX_LEN).as_str(), + ] { + assert!( + AdminTitle::parse(title).is_ok(), + "`{title}` should be a legal title" + ); + } + } + + #[test] + fn an_empty_or_blank_title_is_refused() { + assert_eq!(AdminTitle::parse(""), Err(AdminTitleError::Empty)); + assert_eq!(AdminTitle::parse(" "), Err(AdminTitleError::Empty)); + assert_eq!(AdminTitle::parse("\t"), Err(AdminTitleError::Empty)); + } + + #[test] + fn the_bound_is_counted_in_characters_not_bytes() { + // Two bytes per character, so a byte-counted bound would refuse this at + // half the stated length -- making the limit depend on the language the + // title happens to be written in. + let cyrillic = "\u{044f}".repeat(MAX_LEN); + assert_eq!( + cyrillic.len(), + MAX_LEN * 2, + "fixture is not two bytes a char" + ); + assert!(AdminTitle::parse(&cyrillic).is_ok()); + + assert_eq!( + AdminTitle::parse("x".repeat(MAX_LEN + 1)), + Err(AdminTitleError::TooLong(MAX_LEN + 1)) + ); + } + + #[test] + fn a_control_character_is_refused_and_named() { + assert_eq!( + AdminTitle::parse("two\nlines"), + Err(AdminTitleError::Control('\n')) + ); + assert_eq!( + AdminTitle::parse("a\u{0}b"), + Err(AdminTitleError::Control('\u{0}')) + ); + } + + /// Markup is legal text, and refusing it here would be the wrong place to + /// fix the wrong problem: the escaping belongs where the HTML is written, + /// and `doppel-admin` has a test that a title like this cannot close the + /// script element it is injected into. + #[test] + fn markup_is_accepted_because_escaping_is_not_this_types_job() { + let hostile = ""; + assert!(AdminTitle::parse(hostile).is_ok()); + } + + #[test] + fn a_title_round_trips_through_yaml() { + let title: AdminTitle = serde_norway::from_str("\"Doppel (staging)\"").unwrap(); + assert_eq!(title.as_str(), "Doppel (staging)"); + assert_eq!( + serde_norway::to_string(&title).unwrap().trim(), + "Doppel (staging)" + ); + assert!(serde_norway::from_str::("\"\"").is_err()); + } +} diff --git a/crates/doppel-core/src/conformance.rs b/crates/doppel-core/src/conformance.rs index 03b03c8..37f16e8 100644 --- a/crates/doppel-core/src/conformance.rs +++ b/crates/doppel-core/src/conformance.rs @@ -31,6 +31,13 @@ admin: access: {} upload: limit: 1Mi + # Both present and both non-default, for the same reason as + # `rewrite_redirects` below -- and with one more thing to say: since the + # PostgreSQL store keeps the document as JSON, these two arrived without a + # migration of their own. A store that had gone on needing a column per field + # would fail here rather than in production. + dashboard: false + title: "Doppel (conformance)" proxies: - name: alpha type: http diff --git a/doppel-config.schema.json b/doppel-config.schema.json index be21fd1..46c7f37 100644 --- a/doppel-config.schema.json +++ b/doppel-config.schema.json @@ -41,6 +41,13 @@ "$ref": "#/$defs/AuthConfig", "description": "Which header carries the bearer token." }, + "dashboard": { + "description": "Serve the browser dashboard from the admin listener's root.\n\nOn by default. Off means `/`, `/static/*` and `/robots.txt` are not\nrouted at all and answer 404 like any other unknown path; the JSON API is\nuntouched either way.\n\nAn `Option` and skipped when absent, for the reason given on `groups`:\nadding it must not change the canonical YAML of configurations written\nbefore it existed. Read it through\n[`AdminConfig::is_dashboard_enabled`].", + "type": [ + "boolean", + "null" + ] + }, "enable": { "description": "Whether to run the admin listener at all.\n\nDefaults to on. Off means the port is never bound and no admin task\nstarts; the proxy and the control socket are untouched, so\n`doppel config reload` still works and is then the only way in.\n\nThe validation rules do not consult this. A configuration that is only\nsafe because nothing serves it is a trap set for whoever turns the\nlistener on later, and they will not re-read the rules first.", "type": "boolean" @@ -73,6 +80,17 @@ "null" ] }, + "title": { + "description": "The heading the dashboard shows, and the browser tab's title.\n\nDefaults to `Doppel`. Useful when several Doppels are open at once and\nthe tabs are otherwise identical.\n\nAn `Option` and skipped when absent, for the same reason as `dashboard`.\nRead it through [`AdminConfig::title`].", + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/$defs/AdminTitle" + } + ] + }, "tokens": { "description": "The tokens that may call the admin API. Names and token values are\neach unique. `DOPPEL_ADMIN_TOKENS` can supply these instead.", "items": { @@ -92,6 +110,12 @@ ], "type": "object" }, + "AdminTitle": { + "description": "The heading the dashboard shows, at most 64 characters and free of control characters. Defaults to `Doppel`.", + "maxLength": 64, + "minLength": 1, + "type": "string" + }, "AllowedGroup": { "description": "A token or group name `access` may reference, or `*` for any of them.", "examples": [ diff --git a/main.example.yaml b/main.example.yaml index 0bb5d79..f0af3d9 100644 --- a/main.example.yaml +++ b/main.example.yaml @@ -40,6 +40,13 @@ admin: # `public` and `admin` are always available whatever the list says. # [] names nobody, which means the same as `public: true`. Rule V36. groups: ["*"] + # Serve the browser dashboard from this listener's root. On by default; false + # leaves `/`, `/static/*` and `/robots.txt` unrouted and the JSON API + # untouched. Takes effect on restart. + dashboard: true + # The heading the dashboard shows, and the browser tab's title. Defaults to + # "Doppel"; useful when several Doppels are open at once. + title: "Doppel" auth: header: X-Proxy-Authorization # expects "Bearer {token}" tokens: From 382d61af1002060a5dbc1770f4212daaccb8f495 Mon Sep 17 00:00:00 2001 From: Lorem Dev Date: Tue, 11 Aug 2026 22:56:48 +0200 Subject: [PATCH 04/32] feat: report the caller's own rights at /api/v1/access The dashboard has to know what the caller may do, because a button for an action the server will refuse is worse than no button: the operator finds out by trying. Nothing else in the API says anything about the caller's rights. Answers 200 for everybody, anonymous included -- an endpoint whose purpose is to report that the caller may do nothing cannot itself demand a right, and it discloses nothing that attempting the six actions would not. Every field is authorize() evaluated, never a second reading of the access blocks: a copy of that decision is a copy that can disagree with the one enforcing it. the_report_agrees_with_what_the_request_actually_answers issues every action for every token and asserts a reported false comes back 401 or 403 and a reported true does not, which is what catches the drift. The per-proxy map is omitted, not emptied, for a caller who may not list: keyed by proxy name it would be a proxy listing by another route, and access.rs is explicit that a caller without read access must not be able to tell a real proxy from an invented one. --- crates/doppel-admin/src/lib.rs | 2 + crates/doppel-admin/src/openapi.rs | 6 + crates/doppel-admin/src/rights.rs | 200 ++++++++++++++++++++++++++++ crates/doppel-admin/tests/rights.rs | 196 +++++++++++++++++++++++++++ 4 files changed, 404 insertions(+) create mode 100644 crates/doppel-admin/src/rights.rs create mode 100644 crates/doppel-admin/tests/rights.rs diff --git a/crates/doppel-admin/src/lib.rs b/crates/doppel-admin/src/lib.rs index 39a55d5..95b8212 100644 --- a/crates/doppel-admin/src/lib.rs +++ b/crates/doppel-admin/src/lib.rs @@ -5,6 +5,7 @@ pub mod body; pub mod openapi; pub mod proxies; pub mod response; +pub mod rights; pub mod state; pub mod status; pub mod templates; @@ -22,6 +23,7 @@ pub fn router(state: AdminState) -> axum::Router { .merge(proxies::routes()) .merge(templates::routes()) .merge(status::routes()) + .merge(rights::routes()) .merge(openapi::routes()) // Without these two, a typo'd path answers 404 with an empty body and // a wrong method answers 405 with one. The contract is that every diff --git a/crates/doppel-admin/src/openapi.rs b/crates/doppel-admin/src/openapi.rs index 82b83a9..02a5bfa 100644 --- a/crates/doppel-admin/src/openapi.rs +++ b/crates/doppel-admin/src/openapi.rs @@ -28,6 +28,7 @@ use crate::state::AdminState; crate::templates::list, crate::templates::upload, crate::templates::remove, + crate::rights::rights, crate::status::reload, crate::status::status, crate::status::exposition, @@ -38,6 +39,10 @@ use crate::state::AdminState; crate::proxies::ProxyRequest, crate::templates::TemplateEntry, crate::templates::TemplateList, + crate::rights::AccessReport, + crate::rights::ActionRights, + crate::rights::ProxyRights, + crate::rights::CallerView, crate::status::Status, crate::status::ProxyStatus, crate::status::ReloadReport, @@ -106,6 +111,7 @@ mod tests { ("post", "/api/v1/proxies/{name}/templates/{file}"), ("delete", "/api/v1/proxies/{name}/templates/{file}"), ("post", "/api/v1/config/reload"), + ("get", "/api/v1/access"), ("get", "/status"), ("get", "/metrics"), ]; diff --git a/crates/doppel-admin/src/rights.rs b/crates/doppel-admin/src/rights.rs new file mode 100644 index 0000000..0946693 --- /dev/null +++ b/crates/doppel-admin/src/rights.rs @@ -0,0 +1,200 @@ +//! What the caller may do, reported to the caller. +//! +//! The dashboard needs this: a button for an action the server will refuse is +//! worse than no button, because the operator finds out by trying. Nothing else +//! in the API says anything about the *caller's* rights -- every other endpoint +//! either performs an action or refuses it -- so this is the one endpoint whose +//! subject is the request rather than the configuration. + +use axum::Router; +use axum::extract::State; +use axum::http::HeaderMap; +use axum::routing::get; +use doppel_core::config::ProxyConfig; +use serde::Serialize; +use std::collections::BTreeMap; + +use crate::access::{self, Action, Caller}; +use crate::state::AdminState; + +pub fn routes() -> Router { + Router::new().route("/api/v1/access", get(rights)) +} + +/// Who the caller is, as far as this process is concerned. +/// +/// Returning the token's own name and group leaks nothing -- the caller +/// presented it -- and it is what lets the dashboard say who is signed in +/// rather than only that somebody is. +#[derive(Debug, Serialize, utoipa::ToSchema)] +#[serde(tag = "kind", rename_all = "lowercase")] +pub enum CallerView { + /// No token, or one this configuration does not recognise. The two are not + /// distinguished, for the reason `Caller` gives: telling them apart would + /// confirm which tokens exist. + Anonymous, + Token { + name: String, + group: String, + }, +} + +/// The six actions, as booleans. +#[derive(Debug, Serialize, utoipa::ToSchema)] +pub struct ActionRights { + pub list: bool, + pub read: bool, + pub create: bool, + pub update: bool, + pub delete: bool, + pub upload: bool, +} + +/// The four actions a proxy's own `access` block may override. +/// +/// `list` and `create` are not about one proxy, so they are absent here rather +/// than repeated per proxy -- the same reason `ProxyAccessConfig` cannot spell +/// them. +#[derive(Debug, Serialize, utoipa::ToSchema)] +pub struct ProxyRights { + pub read: bool, + pub update: bool, + pub delete: bool, + pub upload: bool, +} + +#[derive(Debug, Serialize, utoipa::ToSchema)] +pub struct AccessReport { + pub caller: CallerView, + /// The global `access` block, evaluated for this caller. + pub global: ActionRights, + /// Per proxy, where a proxy's own `access` overrides the global answer. + /// + /// Absent -- not empty -- for a caller who may not `list`. The map is keyed + /// by proxy name, so returning it would be a proxy listing by another route, + /// and `access.rs` is explicit that a caller without read access must not be + /// able to tell a real proxy from an invented one. + #[serde(skip_serializing_if = "Option::is_none")] + pub proxies: Option>, +} + +impl From for CallerView { + fn from(caller: Caller) -> Self { + match caller { + Caller::Anonymous => Self::Anonymous, + Caller::Token { name, group } => Self::Token { name, group }, + } + } +} + +/// `GET /api/v1/access` -- what the caller may do. +/// +/// Answers 200 for everybody, anonymous included: an endpoint whose purpose is +/// to report that the caller may do nothing cannot itself demand a right, and +/// there is nothing here a caller could not learn by attempting the six actions +/// and reading the statuses. +/// +/// Every field is `authorize` evaluated, never a second reading of the `access` +/// blocks. A copy of that decision would be a copy that can disagree with the +/// one enforcing it, and the disagreement would reach the operator as a button +/// that does nothing -- or, worse, as a hidden action they were allowed to take. +#[utoipa::path( + get, path = "/api/v1/access", tag = "process", + responses((status = 200, description = "What the calling token may do", body = AccessReport)), + security(("token" = [])), +)] +pub(crate) async fn rights( + State(state): State, + headers: HeaderMap, +) -> axum::Json { + // The policy the last reload put into effect, like every other handler -- + // not whatever is in the store right now. Reporting rights from an unloaded + // document would describe a server nobody is running. + let config = access::policy(&state); + let caller = access::caller_from_headers_with_env(&config.admin, state.env_tokens(), &headers); + + let permits = |proxy: Option<&ProxyConfig>, action: Action| { + access::authorize(&config.admin, proxy, action, &caller).is_ok() + }; + + let global = ActionRights { + list: permits(None, Action::List), + read: permits(None, Action::Read), + create: permits(None, Action::Create), + update: permits(None, Action::Update), + delete: permits(None, Action::Delete), + upload: permits(None, Action::Upload), + }; + + let proxies = global.list.then(|| { + config + .proxies + .iter() + .map(|proxy| { + ( + proxy.name.to_string(), + ProxyRights { + read: permits(Some(proxy), Action::Read), + update: permits(Some(proxy), Action::Update), + delete: permits(Some(proxy), Action::Delete), + upload: permits(Some(proxy), Action::Upload), + }, + ) + }) + .collect() + }); + + axum::Json(AccessReport { + caller: caller.into(), + global, + proxies, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn an_anonymous_caller_is_reported_without_a_name() { + let json = serde_json::to_value(CallerView::from(Caller::Anonymous)).unwrap(); + assert_eq!(json, serde_json::json!({"kind": "anonymous"})); + } + + #[test] + fn a_token_is_reported_by_its_name_and_group() { + let json = serde_json::to_value(CallerView::from(Caller::Token { + name: "ci".to_owned(), + group: "ops".to_owned(), + })) + .unwrap(); + assert_eq!( + json, + serde_json::json!({"kind": "token", "name": "ci", "group": "ops"}) + ); + } + + #[test] + fn an_absent_proxy_map_is_absent_rather_than_empty() { + // An empty object says "there are no proxies", which is a statement about + // the configuration. The point of withholding the map is to say nothing + // at all, so the key has to be gone. + let report = AccessReport { + caller: CallerView::Anonymous, + global: ActionRights { + list: false, + read: false, + create: false, + update: false, + delete: false, + upload: false, + }, + proxies: None, + }; + let json = serde_json::to_value(&report).unwrap(); + assert!( + json.get("proxies").is_none(), + "proxies must be absent, not null or empty: {json}" + ); + } +} diff --git a/crates/doppel-admin/tests/rights.rs b/crates/doppel-admin/tests/rights.rs new file mode 100644 index 0000000..0d9db4c --- /dev/null +++ b/crates/doppel-admin/tests/rights.rs @@ -0,0 +1,196 @@ +//! `GET /api/v1/access`: what the caller may do. + +mod common; + +use common::{BASE_CONFIG, Call, Harness, proxy_json}; + +const ROOT: &str = "root-token-0000000000000000000000000"; +const READER: &str = "reader-token-00000000000000000000000"; + +/// `BASE_CONFIG` with the global `access` block replaced. +/// +/// `replacen` with a count of one: the document has an `access` key under +/// `admin` and can have one under a proxy, and a plain `replace` would edit +/// whichever came first in a way that reads as working. +fn with_access(block: &str) -> String { + BASE_CONFIG.replacen(" access:\n list: public\n read: public\n", block, 1) +} + +#[tokio::test] +async fn an_anonymous_caller_gets_the_public_actions_and_nothing_else() { + // `BASE_CONFIG` makes `list` and `read` public and the four writes + // admin-only, which is the shape an operator most often has. + let harness = Harness::new(); + let body = Call::get("/api/v1/access") + .send(harness.router()) + .await + .json(); + + assert_eq!(body["caller"]["kind"], "anonymous"); + assert_eq!(body["global"]["list"], true); + assert_eq!(body["global"]["read"], true); + for action in ["create", "update", "delete", "upload"] { + assert_eq!(body["global"][action], false, "{action} must be refused"); + } +} + +#[tokio::test] +async fn a_token_is_reported_by_its_own_name_and_group() { + let harness = Harness::new(); + let body = Call::get("/api/v1/access") + .token(READER) + .send(harness.router()) + .await + .json(); + + assert_eq!(body["caller"]["kind"], "token"); + assert_eq!(body["caller"]["name"], "reader"); + assert_eq!(body["caller"]["group"], "user"); + // `reader` is in group `user`, and the writes want `admin`. + assert_eq!(body["global"]["read"], true); + assert_eq!(body["global"]["create"], false); +} + +#[tokio::test] +async fn an_admin_token_may_do_everything() { + let harness = Harness::new(); + let body = Call::get("/api/v1/access") + .token(ROOT) + .send(harness.router()) + .await + .json(); + + for action in ["list", "read", "create", "update", "delete", "upload"] { + assert_eq!(body["global"][action], true, "{action} must be allowed"); + } + assert_eq!(body["proxies"]["alpha"]["delete"], true); +} + +#[tokio::test] +async fn the_proxy_map_names_every_proxy_for_a_caller_who_may_list() { + let harness = Harness::new(); + let body = Call::get("/api/v1/access") + .send(harness.router()) + .await + .json(); + + let proxies = body["proxies"].as_object().expect("a proxy map"); + let mut names: Vec<_> = proxies.keys().map(String::as_str).collect(); + names.sort_unstable(); + assert_eq!(names, ["alpha", "beta"]); + assert_eq!(body["proxies"]["alpha"]["read"], true); + assert_eq!(body["proxies"]["alpha"]["update"], false); +} + +#[tokio::test] +async fn a_caller_who_may_not_list_gets_no_proxy_map_at_all() { + // Keyed by proxy name, the map is a proxy listing by another route. An empty + // object would still say "there are no proxies"; the key has to be gone. + let config = with_access(" access:\n list: [\"admin\"]\n read: [\"admin\"]\n"); + let harness = Harness::with_config(&config); + let body = Call::get("/api/v1/access") + .send(harness.router()) + .await + .json(); + + assert_eq!(body["global"]["list"], false); + assert!( + body.get("proxies").is_none(), + "a caller without `list` must not learn the proxy names: {body}" + ); +} + +#[tokio::test] +async fn a_proxy_override_is_reported_instead_of_the_global_answer() { + // The whole reason the map exists: `reader` may read `alpha` and no other + // proxy, and a report built from the global block alone would say `false` + // for both. + let config = with_access(" access:\n list: public\n read: [\"admin\"]\n").replacen( + " - name: alpha\n type: http\n url: \"https://alpha.example.com/api/\"\n", + " - name: alpha\n type: http\n url: \"https://alpha.example.com/api/\"\n \ + access:\n read: [\"reader\"]\n", + 1, + ); + let harness = Harness::with_config(&config); + let body = Call::get("/api/v1/access") + .token(READER) + .send(harness.router()) + .await + .json(); + + assert_eq!(body["global"]["read"], false); + assert_eq!(body["proxies"]["alpha"]["read"], true); + assert_eq!(body["proxies"]["beta"]["read"], false); +} + +#[tokio::test] +async fn a_public_configuration_reports_everything_true_for_anonymous() { + let config = BASE_CONFIG.replacen(" tokens:", " public: true\n tokens:", 1); + let harness = Harness::with_config(&config); + let body = Call::get("/api/v1/access") + .send(harness.router()) + .await + .json(); + + for action in ["list", "read", "create", "update", "delete", "upload"] { + assert_eq!(body["global"][action], true, "{action} under public: true"); + } +} + +#[tokio::test] +async fn the_report_agrees_with_what_the_request_actually_answers() { + // The test that matters. The report is `authorize` evaluated, and this is + // what catches it drifting from `authorize` as enforced: a reported `false` + // has to come back 401 or 403, and a reported `true` must not. + // + // A harness per action, because `delete` really deletes and `create` really + // creates -- sharing one would make the later actions depend on the earlier. + for token in [None, Some(READER), Some(ROOT)] { + let report = { + let harness = Harness::new(); + let mut call = Call::get("/api/v1/access"); + if let Some(token) = token { + call = call.token(token); + } + call.send(harness.router()).await.json() + }; + + for action in ["list", "read", "create", "update", "delete", "upload"] { + let harness = Harness::new(); + // The revision `update` needs, read before the attempt so a refusal + // is about access rather than about a missing `If-Match`. + let revision = Call::get("/api/v1/proxies/alpha") + .token(ROOT) + .send(harness.router()) + .await + .revision(); + + let mut call = match action { + "list" => Call::get("/api/v1/proxies"), + "read" => Call::get("/api/v1/proxies/alpha"), + "create" => Call::post("/api/v1/proxies") + .json(proxy_json("gamma", "https://gamma.example.com/")), + "update" => Call::put("/api/v1/proxies/alpha") + .if_match(&revision) + .json(proxy_json("alpha", "https://moved.example.com/")), + "delete" => Call::delete("/api/v1/proxies/alpha").if_match(&revision), + "upload" => Call::post("/api/v1/proxies/alpha/templates/page.json.j2").raw("hello"), + other => unreachable!("{other}"), + }; + if let Some(token) = token { + call = call.token(token); + } + let status = call.send(harness.router()).await.status; + + let permitted = report["global"][action] + .as_bool() + .unwrap_or_else(|| panic!("{action} is not a boolean in {report}")); + let refused = status == 401 || status == 403; + assert_eq!( + permitted, !refused, + "{action} as {token:?}: the report said {permitted} and the request answered \ + {status}" + ); + } + } +} From 32042fd49d9c4f2390d1168ecc7348d00f515204 Mon Sep 17 00:00:00 2001 From: Lorem Dev Date: Thu, 13 Aug 2026 01:19:55 +0200 Subject: [PATCH 05/32] feat: a browser dashboard on the admin listener Compiled into the binary and served from the admin listener's root: the proxy list, a form over the whole proxy document including its mocks, status and reload. Most of what the admin API does, without curl. Vite, React and Tailwind, embedded by a build script rather than by a crate -- forty lines, no new dependency. A build with no `frontend/dist` still compiles and answers 503 at `/`, so `cargo install` works on a machine with no Node; CI sets `DOPPEL_REQUIRE_DASHBOARD_ASSETS` so that concession cannot apply there. Usable without a token wherever the API is: the dialog opens once and has "Continue without a token", and signing out returns to the anonymous view rather than a wall. Controls for actions the caller lacks are disabled with the reason, not hidden -- the page asks `GET /api/v1/access` what the caller may do instead of guessing. Settings reach the page as the contents of a JSON script element, which is what allows `script-src 'self'` with no `unsafe-inline`; every less-than in that JSON is escaped, because `admin.title` accepts markup on purpose. Not indexable three ways -- header, meta element and `robots.txt` -- because crawlers fail differently. The Playwright suite earned itself on the first run by finding three bugs nothing else could: every rights-gated control disabled for the life of the page (a selector returning a stable reference, so the report's arrival re-rendered nothing), a template table rendering empty cells (the API calls the field `name`, the model called it `file`), and the code editor crashing the built bundle with React error #130 while working on the dev server. Also here: the Makefile, because the assets are embedded at compile time and `npm run build` alone changes nothing about a binary; the npm packages in `THIRD-PARTY.md`; the dashboard's documentation page; and the server's complaint attached to the field it is about rather than only to the banner. --- .agents/skills/bump-version/SKILL.md | 6 + .agents/skills/check-docs/SKILL.md | 27 + .agents/skills/check-licenses/SKILL.md | 55 +- .agents/skills/pre-release-check/SKILL.md | 22 + .agents/skills/run-tests-and-linters/SKILL.md | 42 +- .github/workflows/ci.yml | 128 + .github/workflows/docs.yml | 5 + .github/workflows/release.yml | 89 + AGENTS.md | 66 +- CHANGES.md | 14 + DOCKERHUB.md | 11 + LICENSE | 20 +- Makefile | 201 + README.md | 23 +- THIRD-PARTY.md | 765 +- crates/doppel-admin/build.rs | 146 + crates/doppel-admin/src/dashboard.rs | 233 + crates/doppel-admin/src/lib.rs | 16 +- crates/doppel-admin/tests/common/mod.rs | 27 +- crates/doppel-admin/tests/dashboard.rs | 286 + crates/doppel-core/src/error.rs | 12 +- crates/doppel-store-postgres/src/load.rs | 19 +- docs/development/architecture.md | 64 +- docs/index.md | 7 +- docs/usage/admin-api.md | 40 + docs/usage/configuration.md | 10 + docs/usage/dashboard.md | 160 + docs/usage/getting-started.md | 13 + docs/usage/storage.md | 45 +- docs/usage/troubleshooting.md | 30 + frontend/e2e/src/configs.ts | 105 + frontend/e2e/src/server.ts | 101 + frontend/e2e/tests/auth.spec.ts | 82 + frontend/e2e/tests/chrome.spec.ts | 59 + frontend/e2e/tests/off.spec.ts | 27 + frontend/e2e/tests/proxies.spec.ts | 140 + frontend/e2e/tests/refresh.spec.ts | 41 + frontend/eslint.config.js | 46 + frontend/index.html | 26 + frontend/jest.config.ts | 35 + frontend/package-lock.json | 8673 +++++++++++++++++ frontend/package.json | 47 + frontend/playwright.config.ts | 27 + frontend/scripts/copy-favicon.mjs | 19 + frontend/src/__mocks__/style.ts | 2 + frontend/src/__tests__/bundle-size.test.ts | 94 + frontend/src/__tests__/schema-drift.test.ts | 67 + frontend/src/app/App.tsx | 54 + frontend/src/app/routes.tsx | 36 + frontend/src/app/usePolling.ts | 41 + frontend/src/components/Banner.tsx | 30 + frontend/src/components/Button.tsx | 37 + frontend/src/components/CodeEditor.tsx | 78 + frontend/src/components/ConfirmDialog.tsx | 42 + frontend/src/components/Field.tsx | 41 + frontend/src/components/Footer.tsx | 17 + frontend/src/components/Header.tsx | 64 + frontend/src/components/KeyValueRows.tsx | 95 + frontend/src/components/MockEditor.tsx | 229 + frontend/src/components/Spinner.tsx | 8 + frontend/src/components/ThemeToggle.tsx | 37 + frontend/src/components/Toasts.tsx | 30 + frontend/src/components/TokenDialog.tsx | 75 + .../src/components/__tests__/shell.test.tsx | 86 + frontend/src/index.css | 7 + frontend/src/main.tsx | 21 + frontend/src/pages/ProxiesPage.tsx | 173 + frontend/src/pages/ProxyFormPage.tsx | 441 + frontend/src/pages/StatusPage.tsx | 111 + frontend/src/pages/TemplatesPage.tsx | 184 + frontend/src/services/__tests__/auth.test.ts | 62 + frontend/src/services/__tests__/http.test.ts | 140 + .../services/__tests__/runtimeConfig.test.ts | 54 + frontend/src/services/access.ts | 8 + frontend/src/services/auth.ts | 85 + frontend/src/services/http.ts | 116 + frontend/src/services/proxies.ts | 46 + frontend/src/services/runtimeConfig.ts | 51 + frontend/src/services/status.ts | 12 + frontend/src/services/templates.ts | 34 + frontend/src/store/__tests__/access.test.ts | 62 + frontend/src/store/__tests__/auth.test.ts | 84 + frontend/src/store/__tests__/theme.test.ts | 84 + frontend/src/store/access.ts | 88 + frontend/src/store/auth.ts | 81 + frontend/src/store/proxies.ts | 39 + frontend/src/store/theme.ts | 92 + frontend/src/store/toast.ts | 35 + frontend/src/test-setup.ts | 3 + frontend/src/types/__tests__/error.test.ts | 82 + frontend/src/types/api.ts | 60 + frontend/src/types/error.ts | 111 + frontend/src/types/proxy.ts | 100 + frontend/tsconfig.e2e.json | 7 + frontend/tsconfig.json | 22 + frontend/vite.config.ts | 21 + mkdocs.yml | 1 + scripts/third_party.py | 133 +- 98 files changed, 15397 insertions(+), 426 deletions(-) create mode 100644 Makefile create mode 100644 crates/doppel-admin/build.rs create mode 100644 crates/doppel-admin/src/dashboard.rs create mode 100644 crates/doppel-admin/tests/dashboard.rs create mode 100644 docs/usage/dashboard.md create mode 100644 frontend/e2e/src/configs.ts create mode 100644 frontend/e2e/src/server.ts create mode 100644 frontend/e2e/tests/auth.spec.ts create mode 100644 frontend/e2e/tests/chrome.spec.ts create mode 100644 frontend/e2e/tests/off.spec.ts create mode 100644 frontend/e2e/tests/proxies.spec.ts create mode 100644 frontend/e2e/tests/refresh.spec.ts create mode 100644 frontend/eslint.config.js create mode 100644 frontend/index.html create mode 100644 frontend/jest.config.ts create mode 100644 frontend/package-lock.json create mode 100644 frontend/package.json create mode 100644 frontend/playwright.config.ts create mode 100644 frontend/scripts/copy-favicon.mjs create mode 100644 frontend/src/__mocks__/style.ts create mode 100644 frontend/src/__tests__/bundle-size.test.ts create mode 100644 frontend/src/__tests__/schema-drift.test.ts create mode 100644 frontend/src/app/App.tsx create mode 100644 frontend/src/app/routes.tsx create mode 100644 frontend/src/app/usePolling.ts create mode 100644 frontend/src/components/Banner.tsx create mode 100644 frontend/src/components/Button.tsx create mode 100644 frontend/src/components/CodeEditor.tsx create mode 100644 frontend/src/components/ConfirmDialog.tsx create mode 100644 frontend/src/components/Field.tsx create mode 100644 frontend/src/components/Footer.tsx create mode 100644 frontend/src/components/Header.tsx create mode 100644 frontend/src/components/KeyValueRows.tsx create mode 100644 frontend/src/components/MockEditor.tsx create mode 100644 frontend/src/components/Spinner.tsx create mode 100644 frontend/src/components/ThemeToggle.tsx create mode 100644 frontend/src/components/Toasts.tsx create mode 100644 frontend/src/components/TokenDialog.tsx create mode 100644 frontend/src/components/__tests__/shell.test.tsx create mode 100644 frontend/src/index.css create mode 100644 frontend/src/main.tsx create mode 100644 frontend/src/pages/ProxiesPage.tsx create mode 100644 frontend/src/pages/ProxyFormPage.tsx create mode 100644 frontend/src/pages/StatusPage.tsx create mode 100644 frontend/src/pages/TemplatesPage.tsx create mode 100644 frontend/src/services/__tests__/auth.test.ts create mode 100644 frontend/src/services/__tests__/http.test.ts create mode 100644 frontend/src/services/__tests__/runtimeConfig.test.ts create mode 100644 frontend/src/services/access.ts create mode 100644 frontend/src/services/auth.ts create mode 100644 frontend/src/services/http.ts create mode 100644 frontend/src/services/proxies.ts create mode 100644 frontend/src/services/runtimeConfig.ts create mode 100644 frontend/src/services/status.ts create mode 100644 frontend/src/services/templates.ts create mode 100644 frontend/src/store/__tests__/access.test.ts create mode 100644 frontend/src/store/__tests__/auth.test.ts create mode 100644 frontend/src/store/__tests__/theme.test.ts create mode 100644 frontend/src/store/access.ts create mode 100644 frontend/src/store/auth.ts create mode 100644 frontend/src/store/proxies.ts create mode 100644 frontend/src/store/theme.ts create mode 100644 frontend/src/store/toast.ts create mode 100644 frontend/src/test-setup.ts create mode 100644 frontend/src/types/__tests__/error.test.ts create mode 100644 frontend/src/types/api.ts create mode 100644 frontend/src/types/error.ts create mode 100644 frontend/src/types/proxy.ts create mode 100644 frontend/tsconfig.e2e.json create mode 100644 frontend/tsconfig.json create mode 100644 frontend/vite.config.ts diff --git a/.agents/skills/bump-version/SKILL.md b/.agents/skills/bump-version/SKILL.md index 4473605..063ecf0 100644 --- a/.agents/skills/bump-version/SKILL.md +++ b/.agents/skills/bump-version/SKILL.md @@ -14,6 +14,12 @@ part of it -- see the end of this file. uv run scripts/bump_version.py ``` +There is exactly one place a version lives: `[workspace.package]` in +`Cargo.toml`. `frontend/package.json` deliberately has no `version` field -- the +dashboard reports the binary's, read from the configuration the listener injects -- +so nothing there needs bumping, and adding a version to it would create a second +thing to forget. + It sets the version under `[workspace.package]` in `Cargo.toml`, renames `## Development` in CHANGES.md to `## -- `, and opens a fresh empty Development section above it. It prints what it changed and what to do diff --git a/.agents/skills/check-docs/SKILL.md b/.agents/skills/check-docs/SKILL.md index fbdc767..bd39a99 100644 --- a/.agents/skills/check-docs/SKILL.md +++ b/.agents/skills/check-docs/SKILL.md @@ -120,3 +120,30 @@ Say what you compared, not just that you checked. "Every config field has a reference entry; the rule-map tests pass; two `usage/` pages named crates and were reworded; `mkdocs build --strict` is clean" is a useful report. "Docs look fine" is not. + +## The dashboard and the frontend + +`docs/usage/dashboard.md` is the operator-facing page: the two configuration +fields, who may use it, what each screen does, the theme, and what a 503 at the +root means. `docs/development/architecture.md` carries the developer-facing half. + +Check these together, because they go stale as a set: + +- `admin.dashboard` and `admin.title` in `docs/usage/configuration.md` and in + `main.example.yaml`. +- `GET /api/v1/access` and the three dashboard routes in + `docs/usage/admin-api.md`, plus `DASHBOARD_NOT_BUILT` in its error table. +- The build order in `README.md`: assets first, then the binary. A reader who + follows a stale version of that gets a 503 and no idea why. +- `make help`. Every target carries its own description, so the list cannot go + stale on its own -- but a documented command that no longer matches a target is + worth catching here. +- The four pages a reader reaches for before the dashboard's own: `docs/index.md` + (the feature list and the reading order), `usage/getting-started.md` (a first run + ends by opening it), `usage/troubleshooting.md` (the 503, disabled controls, the + refresh timer) and `DOCKERHUB.md` (the image serves it too). A feature documented + only on its own page is a feature nobody finds. + +The icons live in `assets/` at the repository root and are staged into +`docs/assets/` by `scripts/mkdocs_assets.py` on every build. `docs/assets/` is +git-ignored: a link that points there is fine, a file committed there is not. diff --git a/.agents/skills/check-licenses/SKILL.md b/.agents/skills/check-licenses/SKILL.md index f03b6f5..bf2755e 100644 --- a/.agents/skills/check-licenses/SKILL.md +++ b/.agents/skills/check-licenses/SKILL.md @@ -1,6 +1,6 @@ --- name: check-licenses -description: Use after editing any Cargo.toml, adding or removing a dependency, or before cutting a release. Verifies every direct dependency's licence against the project policy and updates LICENSE. +description: Use after editing any Cargo.toml or frontend/package.json, adding or removing a dependency, or before cutting a release. Verifies every direct dependency's licence against the project policy and updates LICENSE and THIRD-PARTY.md. --- # Check licences @@ -21,6 +21,20 @@ so the dynamic-linking exception does not apply. ## How to check +Both halves are generated, so start by running the generator and reading what it +says: + +```bash +uv run scripts/third_party.py # rewrites THIRD-PARTY.md +uv run scripts/third_party.py --check # what CI runs +``` + +It exits non-zero and prints `SOME LICENCES ARE NOT COVERED BY THE POLICY` with +the offending packages named. That is the check; the rest of this skill is for +deciding what to do about a new dependency. + +### The cargo half + Read the licence from the registry rather than from memory: ```bash @@ -33,6 +47,30 @@ Transitive crates come in under their parents' justification; if one of them carries a forbidden licence that is a real problem, but it is found by auditing the tree, not by this skill. +### The npm half + +The direct set is `dependencies` and `devDependencies` in +`frontend/package.json`. The two are not equivalent, and the difference decides +whether a licence matters at all: + +- `dependencies` is compiled into `frontend/dist` and embedded in the binary, so + it is **redistributed**. Its licences bind, and it belongs in `LICENSE` and + `THIRD-PARTY.md`. +- `devDependencies` -- vite, jest, eslint, tailwind's compiler, the tooling + underneath them -- runs on a developer's machine and ships nowhere. It is + deliberately absent from both files. + +A build tool in `dependencies` is therefore a real mistake and not a matter of +taste: it drags the policy over licences the project never distributes. +Tailwind sat there once, and the tooling closure it pulled in carried Blue Oak +and CC-BY -- which would have meant widening the permitted set for code nobody +ships. + +```bash +npm view license # one package +npm ls --omit=dev --all --prefix frontend # exactly what ships +``` + ## Ask the question the policy is actually for For each dependency that is new since the last check, answer in one line: what @@ -51,8 +89,17 @@ sweep late in phase 1, each declared and referenced by no source file. grep -rn "" crates --include='*.rs' | head ``` -## Update LICENSE +## Update LICENSE and THIRD-PARTY.md + +`LICENSE` carries the Apache 2.0 text plus a table of the *direct* set, from both +ecosystems. `THIRD-PARTY.md` carries the whole closure and is generated -- never +edit it by hand. + +```bash +uv run scripts/third_party.py +``` -`LICENSE` carries the Apache 2.0 text plus the third-party notices. When the -direct set changes, update the notices to match. Report what you changed and +Both modes need `frontend/node_modules` present and refuse to run without it, +rather than writing a cargo-only file that looks complete or reporting a stale one +that is not. Report what you changed and what you found -- including "nothing changed", which is a useful result. diff --git a/.agents/skills/pre-release-check/SKILL.md b/.agents/skills/pre-release-check/SKILL.md index dd3a004..3fe5282 100644 --- a/.agents/skills/pre-release-check/SKILL.md +++ b/.agents/skills/pre-release-check/SKILL.md @@ -27,6 +27,28 @@ changes things. `regenerate-config-schema` is the one exception, and only because the thing it writes is generated: if it produces a diff, that diff is a commit of its own before the release, not part of the release commit. +## Then check the dashboard + +`run-tests-and-linters` covers the frontend gate, but a release has two questions +of its own, because the dashboard is embedded at compile time and a binary without +it still builds: + +```bash +make e2e # the browser suite, which the frontend gate leaves out +``` + +Then confirm the release will not ship a page-less binary. The workflow greps each +archive's binary for the configuration element before publishing -- read that step +and check it is still there, since it is the only thing standing between a missing +artifact and a release whose root answers 503: + +```bash +grep -n "carries the dashboard" .github/workflows/release.yml +``` + +The dashboard's own version is not bumped and must not be: `frontend/package.json` +has no `version` field, and the page reports the binary's. + ## Then check the version ```bash diff --git a/.agents/skills/run-tests-and-linters/SKILL.md b/.agents/skills/run-tests-and-linters/SKILL.md index 00db585..8545edc 100644 --- a/.agents/skills/run-tests-and-linters/SKILL.md +++ b/.agents/skills/run-tests-and-linters/SKILL.md @@ -5,7 +5,18 @@ description: Use before marking any task done, before opening a pull request, or # Run tests and linters -The gate is three commands, in this order, all of which must pass: +The whole gate, in one command: + +```bash +make gate +``` + +That is `fmt-check`, `lint`, `test`, `test-frontend`, `size`, `docs`, `schema` +and `licences`. It deliberately leaves out the browser suite -- `make e2e` +downloads Chromium on first run -- so run that too whenever the change touches +`frontend/` or the dashboard routes. + +The Rust half by hand, in this order, all of which must pass: ```bash cargo fmt --check @@ -13,6 +24,35 @@ cargo clippy --all-targets -- -D warnings cargo test ``` +## The frontend half + +The dashboard has its own gate, and one ordering that matters: the assets are +embedded into the binary at compile time, so they are built before anything that +reads them. + +```bash +npm --prefix frontend ci +npm --prefix frontend run lint # eslint +npm --prefix frontend run typecheck # tsc, the application +(cd frontend && npx tsc --noEmit -p tsconfig.e2e.json) # tsc, the specs +npm --prefix frontend run build # writes frontend/dist +npm --prefix frontend test # jest, including the bundle-size budgets +cargo build -p doppel-cli # re-embeds what the build just wrote +npm --prefix frontend run e2e # Playwright, against that binary +``` + +Two traps this order exists to avoid: + +- `npm run build` alone changes nothing about a running binary. Rebuilding the + frontend without rebuilding the binary is how half an hour goes into chasing a + fixed bug that is still in the embedded bundle. +- The bundle-size suite measures `frontend/dist`. Running jest before the build + measures the previous one, or fails because there is nothing to measure. + +`DOPPEL_REQUIRE_DASHBOARD_ASSETS=1` on `cargo test` turns a skipped +asset-delivery suite into a failure, exactly as `DOPPEL_REQUIRE_DATABASE` does +for the store. `make test` sets it. + ## Capture the output, do not recall it Run the gate so its output lands in a file, and quote the file: diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index df46591..26298ad 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -64,10 +64,33 @@ jobs: # supposed to exercise the store and silently did not is worse than one # that failed. DOPPEL_REQUIRE_DATABASE: "1" + # Same idea as DOPPEL_REQUIRE_DATABASE, for the dashboard: the suites that + # exercise real asset delivery skip themselves when the binary was built + # without `frontend/dist`, which is right on a laptop and wrong here. + DOPPEL_REQUIRE_DASHBOARD_ASSETS: "1" steps: + # `lfs: true` because the icons are tracked through git-lfs and the frontend + # build copies the favicon out of `assets/`. Without it the checkout holds a + # pointer file and the copy lands a few lines of text where an image goes. - name: Checkout repository uses: actions/checkout@v7 + with: + lfs: true + + - name: Install Node + uses: actions/setup-node@v7 + with: + node-version: 24 + cache: npm + cache-dependency-path: frontend/package-lock.json + + # Built here rather than pulled from the frontend job as an artifact: this + # keeps the two jobs independent and parallel, and twenty seconds of npm is + # cheaper than serialising the graph. The admin crate's build script embeds + # whatever is in `frontend/dist`, so this has to happen before `cargo test`. + - name: Build the dashboard for the admin crate to embed + run: npm --prefix frontend ci && npm --prefix frontend run build # No toolchain is named here. `rust-toolchain.toml` pins the channel and # the components, and rustup honours it; naming a version in the workflow @@ -116,8 +139,11 @@ jobs: timeout-minutes: 10 steps: + # `lfs: true` for the icons the documentation shows; see the rust job. - name: Checkout repository uses: actions/checkout@v7 + with: + lfs: true # Pinned to an exact release, unlike every other action here. astral-sh # publishes `v9.0.0` but stopped publishing the sliding major tag after @@ -131,3 +157,105 @@ jobs: # moved. - name: Build the site (strict) run: uv run --with-requirements docs/requirements.txt mkdocs build --strict + + frontend: + name: Frontend lint, types, tests, size + runs-on: ubuntu-latest + timeout-minutes: 15 + + steps: + - name: Checkout repository + uses: actions/checkout@v7 + with: + lfs: true + + - name: Install Node + uses: actions/setup-node@v7 + with: + node-version: 24 + cache: npm + cache-dependency-path: frontend/package-lock.json + + - name: Install dependencies + run: npm ci + working-directory: frontend + + - name: Lint + run: npm run lint + working-directory: frontend + + # Two projects: the application and the Playwright suite. They have + # different globals -- jest's and Playwright's `expect` collide -- so a + # single project would have to pick one and leave the other unchecked. + - name: Types + run: npm run typecheck && npx tsc --noEmit -p tsconfig.e2e.json + working-directory: frontend + + # Before the tests, because the bundle-size suite measures what this + # produces. A test that silently skipped when `dist` was missing would make + # the budget a preference again. + - name: Build + run: npm run build + working-directory: frontend + + - name: Tests, including the bundle-size budgets + run: npm test + working-directory: frontend + + e2e: + name: Dashboard end to end + runs-on: ubuntu-latest + timeout-minutes: 20 + + steps: + - name: Checkout repository + uses: actions/checkout@v7 + with: + lfs: true + + - name: Install the toolchain pinned in rust-toolchain.toml + run: rustup show active-toolchain + + - name: Cache cargo registry and target + uses: Swatinem/rust-cache@v2 + with: + cache-on-failure: true + + - name: Install Node + uses: actions/setup-node@v7 + with: + node-version: 24 + cache: npm + cache-dependency-path: frontend/package-lock.json + + - name: Install dependencies + run: npm ci + working-directory: frontend + + # The dashboard is embedded at compile time, so the order matters: build the + # assets, then the binary. Building the binary first embeds nothing and every + # scenario then fails against a 503. + - name: Build the dashboard + run: npm run build + working-directory: frontend + + - name: Build the binary the specs drive + run: cargo build -p doppel-cli + + - name: Install Chromium + run: npx playwright install --with-deps chromium + working-directory: frontend + + - name: Run the scenarios + run: npm run e2e + working-directory: frontend + + # Only on a failure, and only the report: a trace for a green run is an + # upload nobody opens. + - name: Upload the report + if: failure() + uses: actions/upload-artifact@v7 + with: + name: playwright-report + path: frontend/playwright-report + retention-days: 7 diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index d4a0028..7b4c983 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -63,6 +63,11 @@ jobs: # mike reads and rewrites the gh-pages branch, so it needs history # rather than the default shallow clone of one ref. fetch-depth: 0 + # The icons are tracked through git-lfs, and the build hook copies them + # into the docs tree. Without this the published site would carry a + # pointer file where the logo goes -- which renders as a broken image + # rather than failing the build. + lfs: true # Pinned to an exact release, unlike every other action here. astral-sh # publishes `v9.0.0` but stopped publishing the sliding major tag after diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index afe9811..79e6c2b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -30,10 +30,47 @@ concurrency: cancel-in-progress: false jobs: + frontend: + name: Build the dashboard + runs-on: ubuntu-latest + timeout-minutes: 15 + + steps: + # `lfs: true` for the favicon the build copies out of `assets/`. + - name: Checkout repository + uses: actions/checkout@v7 + with: + ref: ${{ github.event.inputs.tag || github.ref }} + lfs: true + + - name: Install Node + uses: actions/setup-node@v7 + with: + node-version: 24 + cache: npm + cache-dependency-path: frontend/package-lock.json + + - name: Build + run: npm ci && npm run build + working-directory: frontend + + # Built once and handed to every build job. The opposite choice from CI, + # and for a reason: there are five of them here, so five npm builds would + # cost more than the artifact round trip -- and every binary published + # under one tag has to carry the same bytes. + - name: Upload the built dashboard + uses: actions/upload-artifact@v7 + with: + name: dashboard + path: frontend/dist + retention-days: 1 + if-no-files-found: error + build: name: Build ${{ matrix.target }} runs-on: ${{ matrix.runner }} timeout-minutes: 45 + needs: frontend strategy: # One target failing should not throw away the others: they take @@ -62,6 +99,16 @@ jobs: # `rust-toolchain.toml` pins the channel; only the target has to be added # on top of it, and `rustup target add` uses that same pinned toolchain. + # The admin crate's build script embeds whatever is in `frontend/dist`, so + # this has to land before `cargo build`. Without it the binary compiles and + # answers 503 at `/`, which is a release nobody would notice was broken + # until they opened it. + - name: Restore the built dashboard + uses: actions/download-artifact@v8 + with: + name: dashboard + path: frontend/dist + - name: Install the toolchain pinned in rust-toolchain.toml run: | rustup show active-toolchain @@ -98,6 +145,7 @@ jobs: musl: name: Build ${{ matrix.target }} (for the image) + needs: frontend runs-on: ${{ matrix.runner }} timeout-minutes: 45 @@ -135,6 +183,16 @@ jobs: sudo apt-get install -y --no-install-recommends \ -o DPkg::Lock::Timeout=300 musl-tools + # The admin crate's build script embeds whatever is in `frontend/dist`, so + # this has to land before `cargo build`. Without it the binary compiles and + # answers 503 at `/`, which is a release nobody would notice was broken + # until they opened it. + - name: Restore the built dashboard + uses: actions/download-artifact@v8 + with: + name: dashboard + path: frontend/dist + - name: Install the toolchain pinned in rust-toolchain.toml run: | rustup show active-toolchain @@ -326,6 +384,37 @@ jobs: # flatten them into one directory. merge-multiple: true + # The one thing that cannot be checked before the archives exist: that the + # binaries about to be published actually carry the dashboard. The build + # script embeds `frontend/dist` silently -- a missing artifact yields a + # binary that compiles, passes nothing that would notice, and answers 503 at + # `/` for whoever downloads it. Grepping for the configuration element is + # enough: it comes from `index.html`, so its presence means the page was + # embedded. + - name: Every published binary carries the dashboard + run: | + set -euo pipefail + found=0 + for archive in dist/doppel-*.tar.gz; do + tmp=$(mktemp -d) + tar -xzf "$archive" -C "$tmp" + binary=$(find "$tmp" -type f -name doppel -perm -u+x | head -1) + if [ -z "$binary" ]; then + echo "no doppel binary inside $archive" >&2 + exit 1 + fi + if ! grep -qa 'id="doppel-config"' "$binary"; then + echo "$archive was built without the dashboard" >&2 + exit 1 + fi + echo "$archive carries the dashboard" + found=$((found + 1)) + done + if [ "$found" -eq 0 ]; then + echo "no archives were checked" >&2 + exit 1 + fi + # Attached so a configuration can be validated against the exact release # it will run under, rather than against whatever `main` says today. The # checked-in file is taken as-is: CI has already refused a stale one diff --git a/AGENTS.md b/AGENTS.md index 7e9571e..660cbf2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -23,12 +23,23 @@ cannot drift. `usage/` for how to use it, `development/` for how to work on it. `.superpowers/specs/` (git-ignored, not shipped) holds the full design. +`frontend/` is the browser dashboard the admin listener serves; see +[the frontend](#the-frontend) below before touching it. + --- ## Running the Gates Prerequisites: a stable Rust toolchain via rustup (pinned in -`rust-toolchain.toml`, with `rustfmt` and `clippy`) +`rust-toolchain.toml`, with `rustfmt` and `clippy`), Node for the dashboard, and +`uv` for the Python tooling. + +```bash +make gate # everything below, in the right order +make help # every target, with a description +``` + +The Rust half by hand: ```bash cargo fmt --check # formatting @@ -36,7 +47,9 @@ cargo clippy --all-targets -- -D warnings # lints, warnings are errors cargo test # tests ``` -All of these must pass before a pull request is ready. +All of these must pass before a pull request is ready. `make gate` leaves out +`make e2e`, which downloads a browser on first run -- run it too when the change +touches `frontend/` or the dashboard routes. ### Verification workflow @@ -127,9 +140,56 @@ for it: | `check-changes` | After a batch of commits -- verify CHANGES.md (Development section) reflects every change. | | `check-docs` | Before a release or after updating commands/options -- verify docs/ and README.md are current. | | `run-tests-and-linters` | Before marking any task done -- run `cargo fmt --check`, `cargo clippy --all-targets -- -D warnings` and `cargo test`, and capture the output. | -| `check-licenses` | After editing any `Cargo.toml` -- verify every direct dependency is licence-compliant and update LICENSE. | +| `check-licenses` | After editing any `Cargo.toml` or `frontend/package.json` -- verify every direct dependency is licence-compliant and regenerate LICENSE and THIRD-PARTY.md. | | `pre-release-check` | Before cutting a release -- runs the five `check-*` and `run-*` skills above (not `bump-version`) plus version-bump and commit-format checks. | +## The frontend + +`frontend/` holds the dashboard: TypeScript, React, react-router, zustand, +Tailwind, built by vite. `docs/development/architecture.md` has the reasoning; +what follows is what you need before editing it. + +``` +frontend/src/ + components/ presentational; may not import a service (eslint enforces it) + pages/ one screen each: fetches, holds its state, renders components + services/ the only code that talks to the API + store/ state that outlives one screen (zustand) + types/ the API's shapes, and the one error type +frontend/e2e/ + src/ the harness the specs drive: the server, its configurations + tests/ the Playwright specs +``` + +Four things that will otherwise cost you an hour each: + +- **The assets are embedded at compile time.** `npm run build` alone changes + nothing about a running binary; `cargo build` after it is what re-embeds them. + A binary built with no `frontend/dist` compiles and answers 503 at `/`. +- **jest cannot render anything that routes.** react-router ships ESM only and + uses `import.meta`, which does not survive the transform to CommonJS. Anything + that navigates belongs in the Playwright suite, against a real browser and a + real binary. +- **Rights are read through `useMay()`, and that is not decoration.** It + subscribes to the report so the component re-renders when rights arrive. A hook + that returned the same callback without subscribing would leave every gated + control disabled for the life of the page -- which is what happened, and what + the Playwright suite caught. `permits()` holds the precedence rules; there is + deliberately no second way to ask. +- **TypeScript is pinned to 5.9 on purpose.** typescript-eslint declares + `<6.1.0` and ts-jest declares `<7`, so the latest compiler is one no linter + accepts yet. Raising it breaks the lint, not the build. + +Commands: `make frontend`, `make test-frontend`, `make size`, `make e2e`. + +`frontend/package.json` carries no `version`: the page shows the *binary's* +version, taken from the configuration the listener injects, and a second version +in a second file is a second thing to forget at release time. + +The favicon is generated from `assets/favicon.svg`; the command that rebuilds +`assets/favicon.ico` is in a comment at the top of that file. `assets/` is staged +into `docs/assets/` by the mkdocs build hook, and `docs/assets/` is git-ignored. + ## CodeGraph diff --git a/CHANGES.md b/CHANGES.md index 40b0878..cfa2049 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -8,6 +8,20 @@ release promotes it to a version heading; the `bump-version` skill does that. ## Development +### Added + +- A browser dashboard on the admin listener's root, compiled into the binary: + proxy CRUD, mock templates, status and reload. `admin.dashboard`, `admin.title`. +- `GET /api/v1/access` reports the calling token's own rights, so a client can + disable an action instead of offering it and being refused. +- A Makefile: `make help` lists every target, `make gate` runs the whole check + suite. + +### Changed + +- The PostgreSQL store keeps the configuration as JSON, so a new field no longer + needs a migration. Migration 0005 backfills it. + ## 0.4.1 -- 2026-08-11 ### Added diff --git a/DOCKERHUB.md b/DOCKERHUB.md index c043f82..93d4d58 100644 --- a/DOCKERHUB.md +++ b/DOCKERHUB.md @@ -140,6 +140,17 @@ compiled and both listeners are bound. `-f` matters: without it `curl` exits 0 on a 500 and the check passes for answering at all rather than for answering correctly. +## The dashboard + +The admin port serves a browser dashboard from its root -- the proxy set, a form +over every field of a proxy, mock templates, status and reload -- compiled into +the image. Publish `8081` as the quick start above does and open +`http://127.0.0.1:8081/`. + +It is a client of the admin API and bound by the same token rules, so it offers +only what the caller's token may actually do. `admin.dashboard: false` turns it +off; the JSON API is unaffected either way. + ## Tokens `DOPPEL_ADMIN_TOKENS` keeps secrets out of the configuration file that gets diff --git a/LICENSE b/LICENSE index 0620daa..bd0273e 100644 --- a/LICENSE +++ b/LICENSE @@ -210,11 +210,18 @@ This project uses the following third-party libraries. Each library retains its original copyright and is distributed under its respective license. -The 26 crates below are the ones this workspace names directly. A -released binary links its dependencies statically, so it also contains their -dependencies: the complete list, generated from the resolve graph, is in +The packages below are the ones this project names directly: the crates in +`Cargo.toml`, and the npm packages in `frontend/package.json` whose code is +compiled into the dashboard. A released binary links its crates statically and +embeds that dashboard, so it also contains everything those depend on: the +complete list, generated from both resolve graphs, is in [THIRD-PARTY.md](THIRD-PARTY.md). +Build-time npm packages -- vite, jest, eslint, tailwind's compiler and the +tooling underneath them -- are deliberately absent. They run on a developer's +machine and ship nowhere, so listing them would claim a redistribution that does +not happen. + | Package | Ecosystem | Version | License | |---|---|---|---| | anyhow | crates.io | 1.0.104 | MIT OR Apache-2.0 | @@ -243,4 +250,9 @@ dependencies: the complete list, generated from the resolve graph, is in | tracing-subscriber | crates.io | 0.3.23 | MIT | | utoipa | crates.io | 5.5.0 | MIT OR Apache-2.0 | | utoipa-swagger-ui | crates.io | 9.0.2 | MIT OR Apache-2.0 | - +| prismjs | npm | 1.30.0 | MIT | +| react | npm | 19.2.8 | MIT | +| react-dom | npm | 19.2.8 | MIT | +| react-router | npm | 8.3.0 | MIT | +| react-simple-code-editor | npm | 0.14.1 | MIT | +| zustand | npm | 5.0.14 | MIT | diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..dac297d --- /dev/null +++ b/Makefile @@ -0,0 +1,201 @@ +# Doppel, in one command at a time. +# +# Nothing here is required to work on the project -- every target is a short +# sequence of `cargo`, `npm` and `uv` that could be typed out instead. What it +# buys is the ordering that is easy to get wrong: the dashboard is embedded into +# the binary at compile time, so the assets have to be built *before* cargo runs, +# and the image needs a musl binary staged where the Dockerfile expects it. +# +# `make` on its own prints the list. + +CARGO ?= cargo +NPM ?= npm +UV ?= uv +FRONTEND ?= frontend + +# The image's tag. Override for a real publish: `make image IMAGE=loremdev/doppel:1.2.3-alpine`. +IMAGE ?= doppel:dev + +# The musl target the image needs. The Dockerfile is explicit that a glibc build +# does not run there, and that the failure is a bare "not found" from the shell +# rather than anything that says why. +# +# `uname -m` says `arm64` on Apple Silicon where rustc says `aarch64`, so the +# name is translated rather than interpolated: `rustup target add +# arm64-unknown-linux-musl` fails with "does not support target", which reads as +# a broken toolchain rather than a wrong string. +MUSL_ARCH ?= $(shell uname -m | sed 's/^arm64$$/aarch64/') +MUSL_TARGET ?= $(MUSL_ARCH)-unknown-linux-musl + +# A database for the store suites. The port is deliberately not 5432; see +# docker-compose.yml. +TEST_DATABASE_URL ?= postgres://doppel:doppel@127.0.0.1:55432/doppel + +.DEFAULT_GOAL := help + +# Every target here is a name, not a file, so make must not go looking for one +# and must not skip a target because a directory of that name happens to exist -- +# `frontend`, `docs` and `dist` all do. +.PHONY: build release frontend run gate fmt fmt-check lint test test-db \ + test-frontend size e2e schema schema-write licences licences-write \ + docs docs-serve image image-rebuild image-run db-up db-down migrate \ + clean clean-frontend help + +# --------------------------------------------------------------------------- +# Building +# --------------------------------------------------------------------------- + +build: frontend ## Build the debug binary with the dashboard embedded + $(CARGO) build + +release: frontend ## Build the release binary with the dashboard embedded + $(CARGO) build --release + +frontend: ## Build the dashboard into frontend/dist + $(NPM) --prefix $(FRONTEND) ci + $(NPM) --prefix $(FRONTEND) run build + +run: build ## Run the debug binary against ./main.yaml + ./target/debug/doppel serve --config main.yaml + +# --------------------------------------------------------------------------- +# Checks +# --------------------------------------------------------------------------- + +gate: fmt-check lint test test-frontend size docs schema licences ## Everything CI checks, except the browser suite + @echo "gate: clean" + +fmt: ## Format the Rust sources + $(CARGO) fmt --all + +fmt-check: ## Fail if anything is unformatted + $(CARGO) fmt --all --check + +lint: ## Clippy with warnings as errors, eslint, and both TypeScript projects + $(CARGO) clippy --workspace --all-targets -- -D warnings + $(NPM) --prefix $(FRONTEND) run lint + $(NPM) --prefix $(FRONTEND) run typecheck + cd $(FRONTEND) && npx tsc --noEmit -p tsconfig.e2e.json + +# The dashboard is built first and required, so the asset-delivery suites cannot +# skip themselves the way they may on a machine with no Node. Same reasoning as +# DOPPEL_REQUIRE_DATABASE, which is set only when a database is actually up. +test: frontend ## Run the Rust suites, requiring the embedded dashboard + DOPPEL_REQUIRE_DASHBOARD_ASSETS=1 $(CARGO) test --workspace + +test-db: frontend ## Run the Rust suites with the PostgreSQL store required + DOPPEL_REQUIRE_DASHBOARD_ASSETS=1 \ + DOPPEL_REQUIRE_DATABASE=1 \ + DOPPEL_TEST_DATABASE_URL=$(TEST_DATABASE_URL) \ + $(CARGO) test --workspace + +test-frontend: frontend ## Run the jest suites and the bundle-size budgets + $(NPM) --prefix $(FRONTEND) test + +size: frontend ## Report what the dashboard weighs, gzipped + @cd $(FRONTEND) && node -e "\ + const { gzipSync } = require('node:zlib'); \ + const { readdirSync, readFileSync } = require('node:fs'); \ + const dir = 'dist/assets'; let total = 0; \ + for (const file of readdirSync(dir).sort()) { \ + const kb = gzipSync(readFileSync(dir + '/' + file)).length / 1024; \ + total += kb; \ + console.log(kb.toFixed(1).padStart(7) + ' KB ' + file); \ + } \ + console.log(total.toFixed(1).padStart(7) + ' KB total'); \ + " + +# Not part of `gate`: it downloads a browser on first run and takes half a minute, +# which is the wrong trade for a check meant to be run constantly. +e2e: build ## Drive the built binary with Playwright + cd $(FRONTEND) && npx playwright install chromium + $(NPM) --prefix $(FRONTEND) run e2e + +schema: ## Fail if doppel-config.schema.json is stale + $(UV) run scripts/config_schema.py --check + +schema-write: ## Regenerate doppel-config.schema.json + $(UV) run scripts/config_schema.py + +licences: ## Fail if THIRD-PARTY.md is stale, or a licence is outside the policy + $(UV) run scripts/third_party.py --check + +licences-write: frontend ## Regenerate THIRD-PARTY.md from both dependency graphs + $(UV) run scripts/third_party.py + +# --------------------------------------------------------------------------- +# Documentation +# --------------------------------------------------------------------------- + +docs: ## Build the documentation site, strictly + $(UV) run --with-requirements docs/requirements.txt mkdocs build --strict + +docs-serve: ## Serve the documentation with live reload + $(UV) run --with-requirements docs/requirements.txt mkdocs serve + +# --------------------------------------------------------------------------- +# The image +# --------------------------------------------------------------------------- + +# The Dockerfile takes an already-built binary rather than compiling inside a +# multi-architecture build, where the non-native half runs under emulation and +# turns a two-minute compile into most of an hour. So the binary is built here, +# for this machine's architecture, and staged where the default BIN points. +image: frontend ## Build the container image for this architecture + rustup target add $(MUSL_TARGET) + $(CARGO) build --release --target $(MUSL_TARGET) -p doppel-cli + mkdir -p dist + cp target/$(MUSL_TARGET)/release/doppel dist/doppel + docker build --build-arg BIN=dist/doppel -t $(IMAGE) . + +image-rebuild: clean-frontend ## Build the image from scratch: no npm, cargo or docker cache + $(NPM) --prefix $(FRONTEND) ci + $(NPM) --prefix $(FRONTEND) run build + rustup target add $(MUSL_TARGET) + $(CARGO) clean -p doppel-cli -p doppel-admin + $(CARGO) build --release --target $(MUSL_TARGET) -p doppel-cli + mkdir -p dist + cp target/$(MUSL_TARGET)/release/doppel dist/doppel + docker build --no-cache --build-arg BIN=dist/doppel -t $(IMAGE) . + +image-run: ## Run the built image against ./main.yaml + docker run --rm -p 8080:8080 -p 8081:8081 \ + -v "$(PWD)/main.yaml:/etc/doppel/main.yaml:ro" \ + $(IMAGE) + +# --------------------------------------------------------------------------- +# The development database +# --------------------------------------------------------------------------- + +db-up: ## Start PostgreSQL and wait for it + docker compose up -d --wait postgres + +db-down: ## Stop PostgreSQL and remove its volume + docker compose down -v + +migrate: build ## Apply the migrations to the development database + DOPPEL_DATABASE_URL=$(TEST_DATABASE_URL) ./target/debug/doppel config migrate + +# --------------------------------------------------------------------------- +# Cleaning +# --------------------------------------------------------------------------- + +clean: clean-frontend ## Remove every build artifact + $(CARGO) clean + rm -rf dist site + +clean-frontend: ## Remove the dashboard's build output + rm -rf $(FRONTEND)/dist $(FRONTEND)/playwright-report $(FRONTEND)/test-results + +# --------------------------------------------------------------------------- +# Help +# --------------------------------------------------------------------------- + +# Every target documented with `## ...` on its own line, in file order, so the +# list cannot drift from the targets: a target without a comment simply does not +# appear, and one that is renamed takes its description with it. +help: ## Print this list + @printf 'Doppel. Targets:\n\n' + @grep -hE '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) \ + | awk 'BEGIN { FS = ":.*?## " } { printf " \033[1m%-16s\033[0m %s\n", $$1, $$2 }' + @printf '\nVariables: IMAGE=%s MUSL_TARGET=%s\n' '$(IMAGE)' '$(MUSL_TARGET)' diff --git a/README.md b/README.md index bd4b5df..6b05451 100644 --- a/README.md +++ b/README.md @@ -61,6 +61,12 @@ version. See concurrency, template upload, reload, a status endpoint, Prometheus metrics and a generated Swagger UI, behind token access control. See [the documentation](https://lorem-dev.github.io/doppel/usage/admin-api/). +- Serves a browser dashboard from that same port's root, compiled into the + binary: the proxy list, a form over the whole proxy document, mock templates, + status and reload. It is a client of the admin API and bound by the same token + rules, so it offers only what the caller may actually do -- and works without a + token wherever the API does. `admin.dashboard: false` turns it off. See + [the documentation](https://lorem-dev.github.io/doppel/usage/dashboard/). - Reports errors to Sentry, optionally, behind the `sentry` cargo feature. - Stores its configuration in a YAML file or in PostgreSQL, selected by `--store`, with `config push` and `config pull` to move a configuration @@ -86,6 +92,16 @@ never `pip`: uv run --with-requirements docs/requirements.txt mkdocs serve ``` +The dashboard's source is `frontend/`; building it needs Node, and the binary +embeds whatever `frontend/dist` holds at compile time: + +```bash +npm --prefix frontend ci && npm --prefix frontend run build +cargo build --release -p doppel-cli +``` + +Skipping that leaves a working binary whose root answers 503 and says so. + The workspace layout -- the crates, what each owns, and the dependency direction -- is in [the architecture page](https://lorem-dev.github.io/doppel/development/architecture/). @@ -99,11 +115,16 @@ Prerequisites: a stable Rust toolchain via rustup, edition 2024 (see `Cargo.toml` for the pinned `rust-version`). ```bash -cargo build --release +make release # the dashboard, then the binary cp main.example.yaml main.yaml # edit to taste; main.yaml is git-ignored ./target/release/doppel serve --config main.yaml ``` +`make help` lists every target: `make gate` runs the whole check suite, +`make image-rebuild` builds the container image from scratch, `make docs-serve` +serves the documentation. `cargo build --release` works too, and skips the +dashboard -- which then answers 503 at the admin root and says so. + `cargo run -- ` works the same way during development. ## Commands diff --git a/THIRD-PARTY.md b/THIRD-PARTY.md index 94bbb47..9b82b4f 100644 --- a/THIRD-PARTY.md +++ b/THIRD-PARTY.md @@ -1,386 +1,397 @@ # Third-party notices -Doppel links its dependencies statically, so a released binary contains the -work below. Each retains its own copyright and is distributed under its own +Doppel links its dependencies statically and embeds the dashboard's compiled +JavaScript, so a released binary contains the work below -- crates and npm +packages alike. Each retains its own copyright and is distributed under its own licence. Generated from the resolved dependency graph by `scripts/third_party.py`. Do not edit by hand -- run the script. -372 packages. +382 packages: 372 from crates.io, 10 from npm. -| Package | Version | Licence | -|---|---|---| -| [addr2line](https://github.com/gimli-rs/addr2line) | 0.25.1 | Apache-2.0 OR MIT | -| [adler2](https://github.com/oyvindln/adler2) | 2.0.1 | 0BSD OR MIT OR Apache-2.0 | -| [aho-corasick](https://github.com/BurntSushi/aho-corasick) | 1.1.4 | Unlicense OR MIT | -| [allocator-api2](https://github.com/zakarumych/allocator-api2) | 0.2.21 | MIT OR Apache-2.0 | -| [android_system_properties](https://github.com/nical/android_system_properties) | 0.1.5 | MIT/Apache-2.0 | -| [anstream](https://github.com/rust-cli/anstyle.git) | 1.0.0 | MIT OR Apache-2.0 | -| [anstyle](https://github.com/rust-cli/anstyle.git) | 1.0.14 | MIT OR Apache-2.0 | -| [anstyle-parse](https://github.com/rust-cli/anstyle.git) | 1.0.0 | MIT OR Apache-2.0 | -| [anstyle-query](https://github.com/rust-cli/anstyle.git) | 1.1.5 | MIT OR Apache-2.0 | -| [anstyle-wincon](https://github.com/rust-cli/anstyle.git) | 3.0.11 | MIT OR Apache-2.0 | -| [anyhow](https://github.com/dtolnay/anyhow) | 1.0.104 | MIT OR Apache-2.0 | -| [arbitrary](https://github.com/rust-fuzz/arbitrary/) | 1.4.2 | MIT OR Apache-2.0 | -| [arc-swap](https://github.com/vorner/arc-swap) | 1.9.2 | MIT OR Apache-2.0 | -| [async-trait](https://github.com/dtolnay/async-trait) | 0.1.91 | MIT OR Apache-2.0 | -| [atoi](https://github.com/pacman82/atoi-rs) | 2.0.0 | MIT | -| [atomic-waker](https://github.com/smol-rs/atomic-waker) | 1.1.2 | Apache-2.0 OR MIT | -| [autocfg](https://github.com/cuviper/autocfg) | 1.5.1 | Apache-2.0 OR MIT | -| [aws-lc-rs](https://github.com/aws/aws-lc-rs) | 1.17.3 | ISC AND (Apache-2.0 OR ISC) | -| [aws-lc-sys](https://github.com/aws/aws-lc-rs) | 0.43.0 | ISC AND (Apache-2.0 OR ISC) AND Apache-2.0 AND MIT AND BSD-3-Clause AND (Apache-2.0 OR ISC OR MIT) AND (Apache-2.0 OR ISC OR MIT-0) | -| [axum](https://github.com/tokio-rs/axum) | 0.8.9 | MIT | -| [axum-core](https://github.com/tokio-rs/axum) | 0.5.6 | MIT | -| [backtrace](https://github.com/rust-lang/backtrace-rs) | 0.3.76 | MIT OR Apache-2.0 | -| [base64](https://github.com/marshallpierce/rust-base64) | 0.22.1 | MIT OR Apache-2.0 | -| [bitflags](https://github.com/bitflags/bitflags) | 2.13.1 | MIT OR Apache-2.0 | -| [block-buffer](https://github.com/RustCrypto/utils) | 0.10.4 | MIT OR Apache-2.0 | -| [block-buffer](https://github.com/RustCrypto/utils) | 0.12.1 | MIT OR Apache-2.0 | -| [block2](https://github.com/madsmtm/objc2) | 0.6.2 | MIT | -| [bumpalo](https://github.com/fitzgen/bumpalo) | 3.20.3 | MIT OR Apache-2.0 | -| [byteorder](https://github.com/BurntSushi/byteorder) | 1.5.0 | Unlicense OR MIT | -| [bytes](https://github.com/tokio-rs/bytes) | 1.12.1 | MIT | -| [cc](https://github.com/rust-lang/cc-rs) | 1.4.0 | MIT OR Apache-2.0 | -| [cfg-if](https://github.com/rust-lang/cfg-if) | 1.0.4 | MIT OR Apache-2.0 | -| [cfg_aliases](https://github.com/katharostech/cfg_aliases) | 0.2.2 | MIT | -| [chacha20](https://github.com/RustCrypto/stream-ciphers) | 0.10.1 | MIT OR Apache-2.0 | -| [clap](https://github.com/clap-rs/clap) | 4.6.5 | MIT OR Apache-2.0 | -| [clap_builder](https://github.com/clap-rs/clap) | 4.6.5 | MIT OR Apache-2.0 | -| [clap_derive](https://github.com/clap-rs/clap) | 4.6.4 | MIT OR Apache-2.0 | -| [clap_lex](https://github.com/clap-rs/clap) | 1.1.0 | MIT OR Apache-2.0 | -| [cmake](https://github.com/rust-lang/cmake-rs) | 0.1.58 | MIT OR Apache-2.0 | -| [cmov](https://github.com/RustCrypto/utils) | 0.5.4 | Apache-2.0 OR MIT | -| [colorchoice](https://github.com/rust-cli/anstyle.git) | 1.0.5 | MIT OR Apache-2.0 | -| [combine](https://github.com/Marwes/combine) | 4.6.7 | MIT | -| [const-oid](https://github.com/RustCrypto/formats) | 0.10.2 | Apache-2.0 OR MIT | -| [core-foundation](https://github.com/servo/core-foundation-rs) | 0.10.1 | MIT OR Apache-2.0 | -| [core-foundation-sys](https://github.com/servo/core-foundation-rs) | 0.8.7 | MIT OR Apache-2.0 | -| [cpufeatures](https://github.com/RustCrypto/utils) | 0.2.17 | MIT OR Apache-2.0 | -| [cpufeatures](https://github.com/RustCrypto/utils) | 0.3.0 | MIT OR Apache-2.0 | -| [crc](https://github.com/mrhooray/crc-rs.git) | 3.4.0 | MIT OR Apache-2.0 | -| [crc-catalog](https://github.com/akhilles/crc-catalog.git) | 2.5.0 | MIT OR Apache-2.0 | -| [crc32fast](https://github.com/srijs/rust-crc32fast) | 1.5.0 | MIT OR Apache-2.0 | -| [crossbeam-epoch](https://github.com/crossbeam-rs/crossbeam) | 0.9.20 | MIT OR Apache-2.0 | -| [crossbeam-queue](https://github.com/crossbeam-rs/crossbeam) | 0.3.13 | MIT OR Apache-2.0 | -| [crossbeam-utils](https://github.com/crossbeam-rs/crossbeam) | 0.8.22 | MIT OR Apache-2.0 | -| [crypto-common](https://github.com/RustCrypto/traits) | 0.1.6 | MIT OR Apache-2.0 | -| [crypto-common](https://github.com/RustCrypto/traits) | 0.2.2 | MIT OR Apache-2.0 | -| [ctutils](https://github.com/RustCrypto/utils) | 0.4.2 | Apache-2.0 OR MIT | -| [debugid](https://github.com/getsentry/rust-debugid) | 0.8.0 | Apache-2.0 | -| [deranged](https://github.com/jhpratt/deranged) | 0.5.8 | MIT OR Apache-2.0 | -| [derive_arbitrary](https://github.com/rust-fuzz/arbitrary) | 1.4.2 | MIT OR Apache-2.0 | -| [digest](https://github.com/RustCrypto/traits) | 0.10.7 | MIT OR Apache-2.0 | -| [digest](https://github.com/RustCrypto/traits) | 0.11.3 | MIT OR Apache-2.0 | -| [dispatch2](https://github.com/madsmtm/objc2) | 0.3.1 | Zlib OR Apache-2.0 OR MIT | -| [displaydoc](https://github.com/yaahc/displaydoc) | 0.2.7 | MIT OR Apache-2.0 | -| [dotenvy](https://github.com/allan2/dotenvy) | 0.15.7 | MIT | -| [dunce](https://gitlab.com/kornelski/dunce) | 1.0.5 | CC0-1.0 OR MIT-0 OR Apache-2.0 | -| [either](https://github.com/rayon-rs/either) | 1.17.0 | MIT OR Apache-2.0 | -| [equivalent](https://github.com/indexmap-rs/equivalent) | 1.0.2 | Apache-2.0 OR MIT | -| [errno](https://github.com/lambda-fairy/rust-errno) | 0.3.14 | MIT OR Apache-2.0 | -| [etcetera](https://github.com/lunacookies/etcetera) | 0.11.0 | MIT OR Apache-2.0 | -| [event-listener](https://github.com/smol-rs/event-listener) | 5.4.2 | Apache-2.0 OR MIT | -| [evmap](https://github.com/jonhoo/evmap.git) | 11.0.0 | MIT OR Apache-2.0 | -| [fastrand](https://github.com/smol-rs/fastrand) | 2.5.0 | Apache-2.0 OR MIT | -| [find-msvc-tools](https://github.com/rust-lang/cc-rs) | 0.1.9 | MIT OR Apache-2.0 | -| [flate2](https://github.com/rust-lang/flate2-rs) | 1.1.9 | MIT OR Apache-2.0 | -| [flume](https://github.com/zesterer/flume) | 0.12.0 | Apache-2.0/MIT | -| [foldhash](https://github.com/orlp/foldhash) | 0.2.0 | Zlib | -| [form_urlencoded](https://github.com/servo/rust-url) | 1.2.2 | MIT OR Apache-2.0 | -| [fs_extra](https://github.com/webdesus/fs_extra) | 1.3.0 | MIT | -| [futures-channel](https://github.com/rust-lang/futures-rs) | 0.3.33 | MIT OR Apache-2.0 | -| [futures-core](https://github.com/rust-lang/futures-rs) | 0.3.33 | MIT OR Apache-2.0 | -| [futures-executor](https://github.com/rust-lang/futures-rs) | 0.3.33 | MIT OR Apache-2.0 | -| [futures-intrusive](https://github.com/Matthias247/futures-intrusive) | 0.5.0 | MIT OR Apache-2.0 | -| [futures-io](https://github.com/rust-lang/futures-rs) | 0.3.33 | MIT OR Apache-2.0 | -| [futures-macro](https://github.com/rust-lang/futures-rs) | 0.3.33 | MIT OR Apache-2.0 | -| [futures-sink](https://github.com/rust-lang/futures-rs) | 0.3.33 | MIT OR Apache-2.0 | -| [futures-task](https://github.com/rust-lang/futures-rs) | 0.3.33 | MIT OR Apache-2.0 | -| [futures-util](https://github.com/rust-lang/futures-rs) | 0.3.33 | MIT OR Apache-2.0 | -| [generator](https://github.com/Xudong-Huang/generator-rs.git) | 0.8.9 | MIT/Apache-2.0 | -| [generic-array](https://github.com/fizyk20/generic-array.git) | 0.14.9 | MIT | -| [getrandom](https://github.com/rust-random/getrandom) | 0.2.17 | MIT OR Apache-2.0 | -| [getrandom](https://github.com/rust-random/getrandom) | 0.3.4 | MIT OR Apache-2.0 | -| [getrandom](https://github.com/rust-random/getrandom) | 0.4.3 | MIT OR Apache-2.0 | -| [gimli](https://github.com/gimli-rs/gimli) | 0.32.3 | MIT OR Apache-2.0 | -| [hashbag](https://github.com/jonhoo/hashbag.git) | 0.1.13 | MIT OR Apache-2.0 | -| [hashbrown](https://github.com/rust-lang/hashbrown) | 0.16.1 | MIT OR Apache-2.0 | -| [hashbrown](https://github.com/rust-lang/hashbrown) | 0.17.1 | MIT OR Apache-2.0 | -| [hashlink](https://github.com/djc/hashlink) | 0.11.1 | MIT OR Apache-2.0 | -| [heck](https://github.com/withoutboats/heck) | 0.5.0 | MIT OR Apache-2.0 | -| [hex](https://github.com/KokaKiwi/rust-hex) | 0.4.3 | MIT OR Apache-2.0 | -| [hkdf](https://github.com/RustCrypto/KDFs/) | 0.13.0 | MIT OR Apache-2.0 | -| [hmac](https://github.com/RustCrypto/MACs) | 0.13.0 | MIT OR Apache-2.0 | -| [hostname](https://github.com/djc/hostname) | 0.4.2 | MIT | -| [http](https://github.com/hyperium/http) | 1.5.0 | MIT OR Apache-2.0 | -| [http-body](https://github.com/hyperium/http-body) | 1.1.0 | MIT | -| [http-body-util](https://github.com/hyperium/http-body) | 0.1.4 | MIT | -| [httparse](https://github.com/seanmonstar/httparse) | 1.10.1 | MIT OR Apache-2.0 | -| [httpdate](https://github.com/pyfisch/httpdate) | 1.0.3 | MIT OR Apache-2.0 | -| [hybrid-array](https://github.com/RustCrypto/hybrid-array) | 0.4.14 | MIT OR Apache-2.0 | -| [hyper](https://github.com/hyperium/hyper) | 1.11.0 | MIT | -| [hyper-rustls](https://github.com/rustls/hyper-rustls) | 0.27.9 | Apache-2.0 OR ISC OR MIT | -| [hyper-util](https://github.com/hyperium/hyper-util) | 0.1.20 | MIT | -| [icu_collections](https://github.com/unicode-org/icu4x) | 2.2.0 | Unicode-3.0 | -| [icu_locale_core](https://github.com/unicode-org/icu4x) | 2.2.0 | Unicode-3.0 | -| [icu_normalizer](https://github.com/unicode-org/icu4x) | 2.2.0 | Unicode-3.0 | -| [icu_normalizer_data](https://github.com/unicode-org/icu4x) | 2.2.0 | Unicode-3.0 | -| [icu_properties](https://github.com/unicode-org/icu4x) | 2.2.0 | Unicode-3.0 | -| [icu_properties_data](https://github.com/unicode-org/icu4x) | 2.2.0 | Unicode-3.0 | -| [icu_provider](https://github.com/unicode-org/icu4x) | 2.2.0 | Unicode-3.0 | -| [idna](https://github.com/servo/rust-url/) | 1.1.0 | MIT OR Apache-2.0 | -| [idna_adapter](https://github.com/hsivonen/idna_adapter) | 1.2.2 | Apache-2.0 OR MIT | -| [indexmap](https://github.com/indexmap-rs/indexmap) | 2.14.0 | Apache-2.0 OR MIT | -| [ipnet](https://github.com/krisprice/ipnet) | 2.12.0 | MIT OR Apache-2.0 | -| [is_terminal_polyfill](https://github.com/polyfill-rs/is_terminal_polyfill) | 1.70.2 | MIT OR Apache-2.0 | -| [itoa](https://github.com/dtolnay/itoa) | 1.0.18 | MIT OR Apache-2.0 | -| [jni](https://github.com/jni-rs/jni-rs) | 0.22.4 | MIT OR Apache-2.0 | -| [jni-macros](https://github.com/jni-rs/jni-rs) | 0.22.4 | MIT OR Apache-2.0 | -| [jni-sys](https://github.com/jni-rs/jni-sys) | 0.4.1 | MIT OR Apache-2.0 | -| [jni-sys-macros](https://github.com/jni-rs/jni-sys) | 0.4.1 | MIT OR Apache-2.0 | -| [jobserver](https://github.com/rust-lang/jobserver-rs) | 0.1.35 | MIT OR Apache-2.0 | -| [js-sys](https://github.com/wasm-bindgen/wasm-bindgen/tree/master/crates/js-sys) | 0.3.103 | MIT OR Apache-2.0 | -| [lazy_static](https://github.com/rust-lang-nursery/lazy-static.rs) | 1.5.0 | MIT OR Apache-2.0 | -| [left-right](https://github.com/jonhoo/left-right.git) | 0.11.8 | MIT OR Apache-2.0 | -| [libc](https://github.com/rust-lang/libc) | 0.2.189 | MIT OR Apache-2.0 | -| [libsqlite3-sys](https://github.com/rusqlite/rusqlite) | 0.37.0 | MIT | -| [linux-raw-sys](https://github.com/sunfishcode/linux-raw-sys) | 0.12.1 | Apache-2.0 WITH LLVM-exception OR Apache-2.0 OR MIT | -| [litemap](https://github.com/unicode-org/icu4x) | 0.8.2 | Unicode-3.0 | -| [lock_api](https://github.com/Amanieu/parking_lot) | 0.4.14 | MIT OR Apache-2.0 | -| [log](https://github.com/rust-lang/log) | 0.4.33 | MIT OR Apache-2.0 | -| [loom](https://github.com/tokio-rs/loom) | 0.7.2 | MIT | -| [lru-slab](https://github.com/Ralith/lru-slab) | 0.1.2 | MIT OR Apache-2.0 OR Zlib | -| [matchers](https://github.com/hawkw/matchers) | 0.2.0 | MIT | -| [matchit](https://github.com/ibraheemdev/matchit) | 0.8.4 | MIT AND BSD-3-Clause | -| [md-5](https://github.com/RustCrypto/hashes) | 0.11.0 | MIT OR Apache-2.0 | -| [memchr](https://github.com/BurntSushi/memchr) | 2.8.3 | Unlicense OR MIT | -| [memo-map](https://github.com/mitsuhiko/memo-map) | 0.3.3 | Apache-2.0 | -| [metrics](https://github.com/metrics-rs/metrics) | 0.24.6 | MIT | -| [metrics-exporter-prometheus](https://github.com/metrics-rs/metrics) | 0.18.3 | MIT AND Apache-2.0 | -| [metrics-util](https://github.com/metrics-rs/metrics) | 0.20.4 | MIT | -| [mime](https://github.com/hyperium/mime) | 0.3.17 | MIT OR Apache-2.0 | -| [mime_guess](https://github.com/abonander/mime_guess) | 2.0.5 | MIT | -| [minijinja](https://github.com/mitsuhiko/minijinja) | 2.21.0 | Apache-2.0 | -| [miniz_oxide](https://github.com/Frommi/miniz_oxide/tree/master/miniz_oxide) | 0.8.9 | MIT OR Zlib OR Apache-2.0 | -| [mio](https://github.com/tokio-rs/mio) | 1.2.2 | MIT | -| [nix](https://github.com/nix-rust/nix) | 0.31.3 | MIT | -| [nu-ansi-term](https://github.com/nushell/nu-ansi-term) | 0.50.3 | MIT | -| [num-conv](https://github.com/jhpratt/num-conv) | 0.2.2 | MIT OR Apache-2.0 | -| [num-traits](https://github.com/rust-num/num-traits) | 0.2.19 | MIT OR Apache-2.0 | -| [objc2](https://github.com/madsmtm/objc2) | 0.6.4 | MIT | -| [objc2-cloud-kit](https://github.com/madsmtm/objc2) | 0.3.2 | Zlib OR Apache-2.0 OR MIT | -| [objc2-core-data](https://github.com/madsmtm/objc2) | 0.3.2 | Zlib OR Apache-2.0 OR MIT | -| [objc2-core-foundation](https://github.com/madsmtm/objc2) | 0.3.2 | Zlib OR Apache-2.0 OR MIT | -| [objc2-core-graphics](https://github.com/madsmtm/objc2) | 0.3.2 | Zlib OR Apache-2.0 OR MIT | -| [objc2-core-image](https://github.com/madsmtm/objc2) | 0.3.2 | Zlib OR Apache-2.0 OR MIT | -| [objc2-core-location](https://github.com/madsmtm/objc2) | 0.3.2 | Zlib OR Apache-2.0 OR MIT | -| [objc2-core-text](https://github.com/madsmtm/objc2) | 0.3.2 | Zlib OR Apache-2.0 OR MIT | -| [objc2-encode](https://github.com/madsmtm/objc2) | 4.1.0 | MIT | -| [objc2-foundation](https://github.com/madsmtm/objc2) | 0.3.2 | MIT | -| [objc2-io-surface](https://github.com/madsmtm/objc2) | 0.3.2 | Zlib OR Apache-2.0 OR MIT | -| [objc2-quartz-core](https://github.com/madsmtm/objc2) | 0.3.2 | Zlib OR Apache-2.0 OR MIT | -| [objc2-ui-kit](https://github.com/madsmtm/objc2) | 0.3.2 | Zlib OR Apache-2.0 OR MIT | -| [objc2-user-notifications](https://github.com/madsmtm/objc2) | 0.3.2 | Zlib OR Apache-2.0 OR MIT | -| [object](https://github.com/gimli-rs/object) | 0.37.3 | Apache-2.0 OR MIT | -| [once_cell](https://github.com/matklad/once_cell) | 1.21.4 | MIT OR Apache-2.0 | -| [once_cell_polyfill](https://github.com/polyfill-rs/once_cell_polyfill) | 1.70.2 | MIT OR Apache-2.0 | -| [openssl-probe](https://github.com/rustls/openssl-probe) | 0.2.1 | MIT OR Apache-2.0 | -| [os_info](https://github.com/stanislav-tkach/os_info) | 3.15.0 | MIT | -| [parking](https://github.com/smol-rs/parking) | 2.2.1 | Apache-2.0 OR MIT | -| [parking_lot](https://github.com/Amanieu/parking_lot) | 0.12.5 | MIT OR Apache-2.0 | -| [parking_lot_core](https://github.com/Amanieu/parking_lot) | 0.9.12 | MIT OR Apache-2.0 | -| [percent-encoding](https://github.com/servo/rust-url/) | 2.3.2 | MIT OR Apache-2.0 | -| [pin-project-lite](https://github.com/taiki-e/pin-project-lite) | 0.2.17 | Apache-2.0 OR MIT | -| [pkg-config](https://github.com/rust-lang/pkg-config-rs) | 0.3.33 | MIT OR Apache-2.0 | -| [portable-atomic](https://github.com/taiki-e/portable-atomic) | 1.14.0 | Apache-2.0 OR MIT | -| [potential_utf](https://github.com/unicode-org/icu4x) | 0.1.5 | Unicode-3.0 | -| [powerfmt](https://github.com/jhpratt/powerfmt) | 0.2.0 | MIT OR Apache-2.0 | -| [ppv-lite86](https://github.com/cryptocorrosion/cryptocorrosion) | 0.2.21 | MIT OR Apache-2.0 | -| [proc-macro2](https://github.com/dtolnay/proc-macro2) | 1.0.107 | MIT OR Apache-2.0 | -| [quanta](https://github.com/metrics-rs/quanta) | 0.12.6 | MIT | -| [quinn](https://github.com/quinn-rs/quinn) | 0.11.11 | MIT OR Apache-2.0 | -| [quinn-proto](https://github.com/quinn-rs/quinn) | 0.11.16 | MIT OR Apache-2.0 | -| [quinn-udp](https://github.com/quinn-rs/quinn) | 0.5.15 | MIT OR Apache-2.0 | -| [quote](https://github.com/dtolnay/quote) | 1.0.47 | MIT OR Apache-2.0 | -| [r-efi](https://github.com/r-efi/r-efi) | 5.3.0 | MIT OR Apache-2.0 OR LGPL-2.1-or-later | -| [r-efi](https://github.com/r-efi/r-efi) | 6.0.0 | MIT OR Apache-2.0 OR LGPL-2.1-or-later | -| [rand](https://github.com/rust-random/rand) | 0.10.2 | MIT OR Apache-2.0 | -| [rand](https://github.com/rust-random/rand) | 0.9.5 | MIT OR Apache-2.0 | -| [rand_chacha](https://github.com/rust-random/rand) | 0.9.0 | MIT OR Apache-2.0 | -| [rand_core](https://github.com/rust-random/rand_core) | 0.10.1 | MIT OR Apache-2.0 | -| [rand_core](https://github.com/rust-random/rand) | 0.9.5 | MIT OR Apache-2.0 | -| [rand_pcg](https://github.com/rust-random/rngs) | 0.10.2 | MIT OR Apache-2.0 | -| [rand_xoshiro](https://github.com/rust-random/rngs) | 0.7.0 | MIT OR Apache-2.0 | -| [rapidhash](https://github.com/hoxxep/rapidhash) | 4.5.1 | MIT OR Apache-2.0 | -| [raw-cpuid](https://github.com/gz/rust-cpuid) | 11.6.0 | MIT | -| [redox_syscall](https://gitlab.redox-os.org/redox-os/syscall) | 0.5.18 | MIT | -| [regex](https://github.com/rust-lang/regex) | 1.13.1 | MIT OR Apache-2.0 | -| [regex-automata](https://github.com/rust-lang/regex) | 0.4.16 | MIT OR Apache-2.0 | -| [regex-syntax](https://github.com/rust-lang/regex) | 0.8.11 | MIT OR Apache-2.0 | -| [reqwest](https://github.com/seanmonstar/reqwest) | 0.12.28 | MIT OR Apache-2.0 | -| [reqwest](https://github.com/seanmonstar/reqwest) | 0.13.4 | MIT OR Apache-2.0 | -| [ring](https://github.com/briansmith/ring) | 0.17.14 | Apache-2.0 AND ISC | -| [rust-embed](https://pyrossh.dev/repos/rust-embed) | 8.12.0 | MIT | -| [rust-embed-impl](https://pyrossh.dev/repos/rust-embed) | 8.12.0 | MIT | -| [rust-embed-utils](https://pyrossh.dev/repos/rust-embed) | 8.12.0 | MIT | -| [rustc-demangle](https://github.com/rust-lang/rustc-demangle) | 0.1.28 | MIT/Apache-2.0 | -| [rustc-hash](https://github.com/rust-lang/rustc-hash) | 2.1.3 | Apache-2.0 OR MIT | -| [rustc_version](https://github.com/djc/rustc-version-rs) | 0.4.1 | MIT OR Apache-2.0 | -| [rustix](https://github.com/bytecodealliance/rustix) | 1.1.4 | Apache-2.0 WITH LLVM-exception OR Apache-2.0 OR MIT | -| [rustls](https://github.com/rustls/rustls) | 0.23.43 | Apache-2.0 OR ISC OR MIT | -| [rustls-native-certs](https://github.com/rustls/rustls-native-certs) | 0.8.4 | Apache-2.0 OR ISC OR MIT | -| [rustls-pki-types](https://github.com/rustls/pki-types) | 1.15.1 | MIT OR Apache-2.0 | -| [rustls-platform-verifier](https://github.com/rustls/rustls-platform-verifier) | 0.7.0 | MIT OR Apache-2.0 | -| [rustls-platform-verifier-android](https://github.com/rustls/rustls-platform-verifier) | 0.1.1 | MIT OR Apache-2.0 | -| [rustls-webpki](https://github.com/rustls/webpki) | 0.103.13 | ISC | -| [rustversion](https://github.com/dtolnay/rustversion) | 1.0.23 | MIT OR Apache-2.0 | -| [ryu](https://github.com/dtolnay/ryu) | 1.0.23 | Apache-2.0 OR BSL-1.0 | -| [same-file](https://github.com/BurntSushi/same-file) | 1.0.6 | Unlicense/MIT | -| [schannel](https://github.com/steffengy/schannel-rs) | 0.1.29 | MIT | -| [scoped-tls](https://github.com/alexcrichton/scoped-tls) | 1.0.1 | MIT/Apache-2.0 | -| [scopeguard](https://github.com/bluss/scopeguard) | 1.2.0 | MIT OR Apache-2.0 | -| [security-framework](https://github.com/kornelski/rust-security-framework) | 3.7.0 | MIT OR Apache-2.0 | -| [security-framework-sys](https://github.com/kornelski/rust-security-framework) | 2.17.0 | MIT OR Apache-2.0 | -| [semver](https://github.com/dtolnay/semver) | 1.0.28 | MIT OR Apache-2.0 | -| [sentry](https://github.com/getsentry/sentry-rust) | 0.49.0 | MIT | -| [sentry-backtrace](https://github.com/getsentry/sentry-rust) | 0.49.0 | MIT | -| [sentry-contexts](https://github.com/getsentry/sentry-rust) | 0.49.0 | MIT | -| [sentry-core](https://github.com/getsentry/sentry-rust) | 0.49.0 | MIT | -| [sentry-panic](https://github.com/getsentry/sentry-rust) | 0.49.0 | MIT | -| [sentry-tracing](https://github.com/getsentry/sentry-rust) | 0.49.0 | MIT | -| [sentry-types](https://github.com/getsentry/sentry-rust) | 0.49.0 | MIT | -| [serde](https://github.com/serde-rs/serde) | 1.0.229 | MIT OR Apache-2.0 | -| [serde_core](https://github.com/serde-rs/serde) | 1.0.229 | MIT OR Apache-2.0 | -| [serde_derive](https://github.com/serde-rs/serde) | 1.0.229 | MIT OR Apache-2.0 | -| [serde_json](https://github.com/serde-rs/json) | 1.0.151 | MIT OR Apache-2.0 | -| [serde_norway](https://github.com/cafkafk/serde-yaml) | 0.9.42 | MIT OR Apache-2.0 | -| [serde_path_to_error](https://github.com/dtolnay/path-to-error) | 0.1.20 | MIT OR Apache-2.0 | -| [serde_urlencoded](https://github.com/nox/serde_urlencoded) | 0.7.1 | MIT/Apache-2.0 | -| [sha1](https://github.com/RustCrypto/hashes) | 0.11.0 | MIT OR Apache-2.0 | -| [sha2](https://github.com/RustCrypto/hashes) | 0.10.9 | MIT OR Apache-2.0 | -| [sha2](https://github.com/RustCrypto/hashes) | 0.11.0 | MIT OR Apache-2.0 | -| [sharded-slab](https://github.com/hawkw/sharded-slab) | 0.1.7 | MIT | -| [shlex](https://github.com/comex/rust-shlex) | 2.0.1 | MIT OR Apache-2.0 | -| [signal-hook-registry](https://github.com/vorner/signal-hook) | 1.4.8 | MIT OR Apache-2.0 | -| [simd-adler32](https://github.com/mcountryman/simd-adler32) | 0.3.10 | MIT | -| [simd_cesu8](https://github.com/seancroach/simd_cesu8) | 1.2.0 | Apache-2.0 OR MIT | -| [simdutf8](https://github.com/rusticstuff/simdutf8) | 0.1.5 | MIT OR Apache-2.0 | -| [sketches-ddsketch](https://github.com/mheffner/rust-sketches-ddsketch) | 0.3.1 | Apache-2.0 | -| [slab](https://github.com/tokio-rs/slab) | 0.4.12 | MIT | -| [smallvec](https://github.com/servo/rust-smallvec) | 1.15.2 | MIT OR Apache-2.0 | -| [socket2](https://github.com/rust-lang/socket2) | 0.6.5 | MIT OR Apache-2.0 | -| [spin](https://github.com/mvdnes/spin-rs.git) | 0.9.9 | MIT | -| [sqlx](https://github.com/launchbadge/sqlx) | 0.9.0 | MIT OR Apache-2.0 | -| [sqlx-core](https://github.com/launchbadge/sqlx) | 0.9.0 | MIT OR Apache-2.0 | -| [sqlx-macros](https://github.com/launchbadge/sqlx) | 0.9.0 | MIT OR Apache-2.0 | -| [sqlx-macros-core](https://github.com/launchbadge/sqlx) | 0.9.0 | MIT OR Apache-2.0 | -| [sqlx-mysql](https://github.com/launchbadge/sqlx) | 0.9.0 | MIT OR Apache-2.0 | -| [sqlx-postgres](https://github.com/launchbadge/sqlx) | 0.9.0 | MIT OR Apache-2.0 | -| [sqlx-sqlite](https://github.com/launchbadge/sqlx) | 0.9.0 | MIT OR Apache-2.0 | -| [stable_deref_trait](https://github.com/storyyeller/stable_deref_trait) | 1.2.1 | MIT OR Apache-2.0 | -| [stringprep](https://github.com/sfackler/rust-stringprep) | 0.1.5 | MIT/Apache-2.0 | -| [strsim](https://github.com/rapidfuzz/strsim-rs) | 0.11.1 | MIT | -| [subtle](https://github.com/dalek-cryptography/subtle) | 2.6.1 | BSD-3-Clause | -| [syn](https://github.com/dtolnay/syn) | 2.0.119 | MIT OR Apache-2.0 | -| [syn](https://github.com/dtolnay/syn) | 3.0.3 | MIT OR Apache-2.0 | -| [sync_wrapper](https://github.com/Actyx/sync_wrapper) | 1.0.2 | Apache-2.0 | -| [synstructure](https://github.com/mystor/synstructure) | 0.13.2 | MIT | -| [tempfile](https://github.com/Stebalien/tempfile) | 3.27.0 | MIT OR Apache-2.0 | -| [thiserror](https://github.com/dtolnay/thiserror) | 2.0.19 | MIT OR Apache-2.0 | -| [thiserror-impl](https://github.com/dtolnay/thiserror) | 2.0.19 | MIT OR Apache-2.0 | -| [thread_local](https://github.com/Amanieu/thread_local-rs) | 1.1.10 | MIT OR Apache-2.0 | -| [time](https://github.com/time-rs/time) | 0.3.55 | MIT OR Apache-2.0 | -| [time-core](https://github.com/time-rs/time) | 0.1.9 | MIT OR Apache-2.0 | -| [time-macros](https://github.com/time-rs/time) | 0.2.32 | MIT OR Apache-2.0 | -| [tinystr](https://github.com/unicode-org/icu4x) | 0.8.3 | Unicode-3.0 | -| [tinyvec](https://github.com/Lokathor/tinyvec) | 1.12.0 | Zlib OR Apache-2.0 OR MIT | -| [tinyvec_macros](https://github.com/Soveu/tinyvec_macros) | 0.1.1 | MIT OR Apache-2.0 OR Zlib | -| [tokio](https://github.com/tokio-rs/tokio) | 1.53.1 | MIT | -| [tokio-macros](https://github.com/tokio-rs/tokio) | 2.7.2 | MIT | -| [tokio-rustls](https://github.com/rustls/tokio-rustls) | 0.26.4 | MIT OR Apache-2.0 | -| [tokio-stream](https://github.com/tokio-rs/tokio) | 0.1.19 | MIT | -| [tokio-util](https://github.com/tokio-rs/tokio) | 0.7.19 | MIT | -| [tower](https://github.com/tower-rs/tower) | 0.5.3 | MIT | -| [tower-http](https://github.com/tower-rs/tower-http) | 0.6.11 | MIT | -| [tower-layer](https://github.com/tower-rs/tower) | 0.3.3 | MIT | -| [tower-service](https://github.com/tower-rs/tower) | 0.3.3 | MIT | -| [tracing](https://github.com/tokio-rs/tracing) | 0.1.44 | MIT | -| [tracing-attributes](https://github.com/tokio-rs/tracing) | 0.1.31 | MIT | -| [tracing-core](https://github.com/tokio-rs/tracing) | 0.1.36 | MIT | -| [tracing-log](https://github.com/tokio-rs/tracing) | 0.2.0 | MIT | -| [tracing-serde](https://github.com/tokio-rs/tracing) | 0.2.0 | MIT | -| [tracing-subscriber](https://github.com/tokio-rs/tracing) | 0.3.23 | MIT | -| [try-lock](https://github.com/seanmonstar/try-lock) | 0.2.5 | MIT | -| [typenum](https://github.com/paholg/typenum) | 1.20.1 | MIT OR Apache-2.0 | -| [uname](https://github.com/icorderi/rust-uname) | 0.1.1 | MIT/Apache-2.0 | -| [unicase](https://github.com/seanmonstar/unicase) | 2.9.0 | MIT OR Apache-2.0 | -| [unicode-bidi](https://github.com/servo/unicode-bidi) | 0.3.18 | MIT OR Apache-2.0 | -| [unicode-ident](https://github.com/dtolnay/unicode-ident) | 1.0.24 | (MIT OR Apache-2.0) AND Unicode-3.0 | -| [unicode-normalization](https://github.com/unicode-rs/unicode-normalization) | 0.1.25 | MIT OR Apache-2.0 | -| [unicode-properties](https://github.com/unicode-rs/unicode-properties) | 0.1.4 | MIT/Apache-2.0 | -| [unsafe-libyaml-norway](https://github.com/cafkafk/unsafe-libyaml-norway) | 0.2.15 | MIT | -| [untrusted](https://github.com/briansmith/untrusted) | 0.9.0 | ISC | -| [ureq](https://github.com/algesten/ureq) | 3.3.0 | MIT OR Apache-2.0 | -| [ureq-proto](https://github.com/algesten/ureq-proto) | 0.6.0 | MIT OR Apache-2.0 | -| [url](https://github.com/servo/rust-url) | 2.5.8 | MIT OR Apache-2.0 | -| [utf8-zero](https://github.com/algesten/utf8-zero) | 0.8.1 | MIT OR Apache-2.0 | -| [utf8_iter](https://github.com/hsivonen/utf8_iter) | 1.0.4 | Apache-2.0 OR MIT | -| [utf8parse](https://github.com/alacritty/vte) | 0.2.2 | Apache-2.0 OR MIT | -| [utoipa](https://github.com/juhaku/utoipa) | 5.5.0 | MIT OR Apache-2.0 | -| [utoipa-gen](https://github.com/juhaku/utoipa) | 5.5.0 | MIT OR Apache-2.0 | -| [utoipa-swagger-ui](https://github.com/juhaku/utoipa) | 9.0.2 | MIT OR Apache-2.0 | -| [utoipa-swagger-ui-vendored](https://github.com/juhaku/utoipa) | 0.1.2 | MIT OR Apache-2.0 | -| [uuid](https://github.com/uuid-rs/uuid) | 1.24.0 | Apache-2.0 OR MIT | -| [valuable](https://github.com/tokio-rs/valuable) | 0.1.1 | MIT | -| [vcpkg](https://github.com/mcgoo/vcpkg-rs) | 0.2.15 | MIT/Apache-2.0 | -| [version_check](https://github.com/SergioBenitez/version_check) | 0.9.5 | MIT/Apache-2.0 | -| [walkdir](https://github.com/BurntSushi/walkdir) | 2.5.0 | Unlicense/MIT | -| [want](https://github.com/seanmonstar/want) | 0.3.1 | MIT | -| [wasi](https://github.com/bytecodealliance/wasi) | 0.11.1+wasi-snapshot-preview1 | Apache-2.0 WITH LLVM-exception OR Apache-2.0 OR MIT | -| [wasip2](https://github.com/bytecodealliance/wasi-rs) | 1.0.4+wasi-0.2.12 | Apache-2.0 WITH LLVM-exception OR Apache-2.0 OR MIT | -| [wasm-bindgen](https://github.com/wasm-bindgen/wasm-bindgen) | 0.2.126 | MIT OR Apache-2.0 | -| [wasm-bindgen-futures](https://github.com/wasm-bindgen/wasm-bindgen/tree/master/crates/futures) | 0.4.76 | MIT OR Apache-2.0 | -| [wasm-bindgen-macro](https://github.com/wasm-bindgen/wasm-bindgen/tree/master/crates/macro) | 0.2.126 | MIT OR Apache-2.0 | -| [wasm-bindgen-macro-support](https://github.com/wasm-bindgen/wasm-bindgen/tree/master/crates/macro-support) | 0.2.126 | MIT OR Apache-2.0 | -| [wasm-bindgen-shared](https://github.com/wasm-bindgen/wasm-bindgen/tree/master/crates/shared) | 0.2.126 | MIT OR Apache-2.0 | -| [wasm-streams](https://github.com/MattiasBuelens/wasm-streams/) | 0.4.2 | MIT OR Apache-2.0 | -| [web-sys](https://github.com/wasm-bindgen/wasm-bindgen/tree/master/crates/web-sys) | 0.3.103 | MIT OR Apache-2.0 | -| [web-time](https://github.com/daxpedda/web-time) | 1.1.0 | MIT OR Apache-2.0 | -| [webpki-root-certs](https://github.com/rustls/webpki-roots) | 1.0.9 | CDLA-Permissive-2.0 | -| [webpki-roots](https://github.com/rustls/webpki-roots) | 1.0.9 | CDLA-Permissive-2.0 | -| [whoami](https://github.com/ardaku/whoami) | 2.1.2 | Apache-2.0 OR BSL-1.0 OR MIT | -| [winapi](https://github.com/retep998/winapi-rs) | 0.3.9 | MIT/Apache-2.0 | -| [winapi-i686-pc-windows-gnu](https://github.com/retep998/winapi-rs) | 0.4.0 | MIT/Apache-2.0 | -| [winapi-util](https://github.com/BurntSushi/winapi-util) | 0.1.11 | Unlicense OR MIT | -| [winapi-x86_64-pc-windows-gnu](https://github.com/retep998/winapi-rs) | 0.4.0 | MIT/Apache-2.0 | -| [windows-link](https://github.com/microsoft/windows-rs) | 0.2.1 | MIT OR Apache-2.0 | -| [windows-result](https://github.com/microsoft/windows-rs) | 0.4.1 | MIT OR Apache-2.0 | -| [windows-sys](https://github.com/microsoft/windows-rs) | 0.52.0 | MIT OR Apache-2.0 | -| [windows-sys](https://github.com/microsoft/windows-rs) | 0.61.2 | MIT OR Apache-2.0 | -| [windows-targets](https://github.com/microsoft/windows-rs) | 0.52.6 | MIT OR Apache-2.0 | -| [windows_aarch64_gnullvm](https://github.com/microsoft/windows-rs) | 0.52.6 | MIT OR Apache-2.0 | -| [windows_aarch64_msvc](https://github.com/microsoft/windows-rs) | 0.52.6 | MIT OR Apache-2.0 | -| [windows_i686_gnu](https://github.com/microsoft/windows-rs) | 0.52.6 | MIT OR Apache-2.0 | -| [windows_i686_gnullvm](https://github.com/microsoft/windows-rs) | 0.52.6 | MIT OR Apache-2.0 | -| [windows_i686_msvc](https://github.com/microsoft/windows-rs) | 0.52.6 | MIT OR Apache-2.0 | -| [windows_x86_64_gnu](https://github.com/microsoft/windows-rs) | 0.52.6 | MIT OR Apache-2.0 | -| [windows_x86_64_gnullvm](https://github.com/microsoft/windows-rs) | 0.52.6 | MIT OR Apache-2.0 | -| [windows_x86_64_msvc](https://github.com/microsoft/windows-rs) | 0.52.6 | MIT OR Apache-2.0 | -| [wit-bindgen](https://github.com/bytecodealliance/wit-bindgen) | 0.57.1 | Apache-2.0 WITH LLVM-exception OR Apache-2.0 OR MIT | -| [writeable](https://github.com/unicode-org/icu4x) | 0.6.3 | Unicode-3.0 | -| [yoke](https://github.com/unicode-org/icu4x) | 0.8.3 | Unicode-3.0 | -| [yoke-derive](https://github.com/unicode-org/icu4x) | 0.8.2 | Unicode-3.0 | -| [zerocopy](https://github.com/google/zerocopy) | 0.8.55 | BSD-2-Clause OR Apache-2.0 OR MIT | -| [zerocopy-derive](https://github.com/google/zerocopy) | 0.8.55 | BSD-2-Clause OR Apache-2.0 OR MIT | -| [zerofrom](https://github.com/unicode-org/icu4x) | 0.1.8 | Unicode-3.0 | -| [zerofrom-derive](https://github.com/unicode-org/icu4x) | 0.1.7 | Unicode-3.0 | -| [zeroize](https://github.com/RustCrypto/utils) | 1.9.0 | Apache-2.0 OR MIT | -| [zerotrie](https://github.com/unicode-org/icu4x) | 0.2.4 | Unicode-3.0 | -| [zerovec](https://github.com/unicode-org/icu4x) | 0.11.6 | Unicode-3.0 | -| [zerovec-derive](https://github.com/unicode-org/icu4x) | 0.11.3 | Unicode-3.0 | -| [zip](https://github.com/zip-rs/zip2.git) | 3.0.0 | MIT | -| [zlib-rs](https://github.com/trifectatechfoundation/zlib-rs) | 0.6.6 | Zlib | -| [zmij](https://github.com/dtolnay/zmij) | 1.0.23 | MIT | -| [zopfli](https://github.com/zopfli-rs/zopfli) | 0.8.3 | Apache-2.0 | +| Package | Ecosystem | Version | Licence | +|---|---|---|---| +| [addr2line](https://github.com/gimli-rs/addr2line) | cargo | 0.25.1 | Apache-2.0 OR MIT | +| [adler2](https://github.com/oyvindln/adler2) | cargo | 2.0.1 | 0BSD OR MIT OR Apache-2.0 | +| [aho-corasick](https://github.com/BurntSushi/aho-corasick) | cargo | 1.1.4 | Unlicense OR MIT | +| [allocator-api2](https://github.com/zakarumych/allocator-api2) | cargo | 0.2.21 | MIT OR Apache-2.0 | +| [android_system_properties](https://github.com/nical/android_system_properties) | cargo | 0.1.5 | MIT/Apache-2.0 | +| [anstream](https://github.com/rust-cli/anstyle.git) | cargo | 1.0.0 | MIT OR Apache-2.0 | +| [anstyle](https://github.com/rust-cli/anstyle.git) | cargo | 1.0.14 | MIT OR Apache-2.0 | +| [anstyle-parse](https://github.com/rust-cli/anstyle.git) | cargo | 1.0.0 | MIT OR Apache-2.0 | +| [anstyle-query](https://github.com/rust-cli/anstyle.git) | cargo | 1.1.5 | MIT OR Apache-2.0 | +| [anstyle-wincon](https://github.com/rust-cli/anstyle.git) | cargo | 3.0.11 | MIT OR Apache-2.0 | +| [anyhow](https://github.com/dtolnay/anyhow) | cargo | 1.0.104 | MIT OR Apache-2.0 | +| [arbitrary](https://github.com/rust-fuzz/arbitrary/) | cargo | 1.4.2 | MIT OR Apache-2.0 | +| [arc-swap](https://github.com/vorner/arc-swap) | cargo | 1.9.2 | MIT OR Apache-2.0 | +| [async-trait](https://github.com/dtolnay/async-trait) | cargo | 0.1.91 | MIT OR Apache-2.0 | +| [atoi](https://github.com/pacman82/atoi-rs) | cargo | 2.0.0 | MIT | +| [atomic-waker](https://github.com/smol-rs/atomic-waker) | cargo | 1.1.2 | Apache-2.0 OR MIT | +| [autocfg](https://github.com/cuviper/autocfg) | cargo | 1.5.1 | Apache-2.0 OR MIT | +| [aws-lc-rs](https://github.com/aws/aws-lc-rs) | cargo | 1.17.3 | ISC AND (Apache-2.0 OR ISC) | +| [aws-lc-sys](https://github.com/aws/aws-lc-rs) | cargo | 0.43.0 | ISC AND (Apache-2.0 OR ISC) AND Apache-2.0 AND MIT AND BSD-3-Clause AND (Apache-2.0 OR ISC OR MIT) AND (Apache-2.0 OR ISC OR MIT-0) | +| [axum](https://github.com/tokio-rs/axum) | cargo | 0.8.9 | MIT | +| [axum-core](https://github.com/tokio-rs/axum) | cargo | 0.5.6 | MIT | +| [backtrace](https://github.com/rust-lang/backtrace-rs) | cargo | 0.3.76 | MIT OR Apache-2.0 | +| [base64](https://github.com/marshallpierce/rust-base64) | cargo | 0.22.1 | MIT OR Apache-2.0 | +| [bitflags](https://github.com/bitflags/bitflags) | cargo | 2.13.1 | MIT OR Apache-2.0 | +| [block-buffer](https://github.com/RustCrypto/utils) | cargo | 0.10.4 | MIT OR Apache-2.0 | +| [block-buffer](https://github.com/RustCrypto/utils) | cargo | 0.12.1 | MIT OR Apache-2.0 | +| [block2](https://github.com/madsmtm/objc2) | cargo | 0.6.2 | MIT | +| [bumpalo](https://github.com/fitzgen/bumpalo) | cargo | 3.20.3 | MIT OR Apache-2.0 | +| [byteorder](https://github.com/BurntSushi/byteorder) | cargo | 1.5.0 | Unlicense OR MIT | +| [bytes](https://github.com/tokio-rs/bytes) | cargo | 1.12.1 | MIT | +| [cc](https://github.com/rust-lang/cc-rs) | cargo | 1.4.0 | MIT OR Apache-2.0 | +| [cfg-if](https://github.com/rust-lang/cfg-if) | cargo | 1.0.4 | MIT OR Apache-2.0 | +| [cfg_aliases](https://github.com/katharostech/cfg_aliases) | cargo | 0.2.2 | MIT | +| [chacha20](https://github.com/RustCrypto/stream-ciphers) | cargo | 0.10.1 | MIT OR Apache-2.0 | +| [clap](https://github.com/clap-rs/clap) | cargo | 4.6.5 | MIT OR Apache-2.0 | +| [clap_builder](https://github.com/clap-rs/clap) | cargo | 4.6.5 | MIT OR Apache-2.0 | +| [clap_derive](https://github.com/clap-rs/clap) | cargo | 4.6.4 | MIT OR Apache-2.0 | +| [clap_lex](https://github.com/clap-rs/clap) | cargo | 1.1.0 | MIT OR Apache-2.0 | +| [cmake](https://github.com/rust-lang/cmake-rs) | cargo | 0.1.58 | MIT OR Apache-2.0 | +| [cmov](https://github.com/RustCrypto/utils) | cargo | 0.5.4 | Apache-2.0 OR MIT | +| [colorchoice](https://github.com/rust-cli/anstyle.git) | cargo | 1.0.5 | MIT OR Apache-2.0 | +| [combine](https://github.com/Marwes/combine) | cargo | 4.6.7 | MIT | +| [const-oid](https://github.com/RustCrypto/formats) | cargo | 0.10.2 | Apache-2.0 OR MIT | +| [core-foundation](https://github.com/servo/core-foundation-rs) | cargo | 0.10.1 | MIT OR Apache-2.0 | +| [core-foundation-sys](https://github.com/servo/core-foundation-rs) | cargo | 0.8.7 | MIT OR Apache-2.0 | +| [cpufeatures](https://github.com/RustCrypto/utils) | cargo | 0.2.17 | MIT OR Apache-2.0 | +| [cpufeatures](https://github.com/RustCrypto/utils) | cargo | 0.3.0 | MIT OR Apache-2.0 | +| [crc](https://github.com/mrhooray/crc-rs.git) | cargo | 3.4.0 | MIT OR Apache-2.0 | +| [crc-catalog](https://github.com/akhilles/crc-catalog.git) | cargo | 2.5.0 | MIT OR Apache-2.0 | +| [crc32fast](https://github.com/srijs/rust-crc32fast) | cargo | 1.5.0 | MIT OR Apache-2.0 | +| [crossbeam-epoch](https://github.com/crossbeam-rs/crossbeam) | cargo | 0.9.20 | MIT OR Apache-2.0 | +| [crossbeam-queue](https://github.com/crossbeam-rs/crossbeam) | cargo | 0.3.13 | MIT OR Apache-2.0 | +| [crossbeam-utils](https://github.com/crossbeam-rs/crossbeam) | cargo | 0.8.22 | MIT OR Apache-2.0 | +| [crypto-common](https://github.com/RustCrypto/traits) | cargo | 0.1.6 | MIT OR Apache-2.0 | +| [crypto-common](https://github.com/RustCrypto/traits) | cargo | 0.2.2 | MIT OR Apache-2.0 | +| [ctutils](https://github.com/RustCrypto/utils) | cargo | 0.4.2 | Apache-2.0 OR MIT | +| [debugid](https://github.com/getsentry/rust-debugid) | cargo | 0.8.0 | Apache-2.0 | +| [deranged](https://github.com/jhpratt/deranged) | cargo | 0.5.8 | MIT OR Apache-2.0 | +| [derive_arbitrary](https://github.com/rust-fuzz/arbitrary) | cargo | 1.4.2 | MIT OR Apache-2.0 | +| [digest](https://github.com/RustCrypto/traits) | cargo | 0.10.7 | MIT OR Apache-2.0 | +| [digest](https://github.com/RustCrypto/traits) | cargo | 0.11.3 | MIT OR Apache-2.0 | +| [dispatch2](https://github.com/madsmtm/objc2) | cargo | 0.3.1 | Zlib OR Apache-2.0 OR MIT | +| [displaydoc](https://github.com/yaahc/displaydoc) | cargo | 0.2.7 | MIT OR Apache-2.0 | +| [dotenvy](https://github.com/allan2/dotenvy) | cargo | 0.15.7 | MIT | +| [dunce](https://gitlab.com/kornelski/dunce) | cargo | 1.0.5 | CC0-1.0 OR MIT-0 OR Apache-2.0 | +| [either](https://github.com/rayon-rs/either) | cargo | 1.17.0 | MIT OR Apache-2.0 | +| [equivalent](https://github.com/indexmap-rs/equivalent) | cargo | 1.0.2 | Apache-2.0 OR MIT | +| [errno](https://github.com/lambda-fairy/rust-errno) | cargo | 0.3.14 | MIT OR Apache-2.0 | +| [etcetera](https://github.com/lunacookies/etcetera) | cargo | 0.11.0 | MIT OR Apache-2.0 | +| [event-listener](https://github.com/smol-rs/event-listener) | cargo | 5.4.2 | Apache-2.0 OR MIT | +| [evmap](https://github.com/jonhoo/evmap.git) | cargo | 11.0.0 | MIT OR Apache-2.0 | +| [fastrand](https://github.com/smol-rs/fastrand) | cargo | 2.5.0 | Apache-2.0 OR MIT | +| [find-msvc-tools](https://github.com/rust-lang/cc-rs) | cargo | 0.1.9 | MIT OR Apache-2.0 | +| [flate2](https://github.com/rust-lang/flate2-rs) | cargo | 1.1.9 | MIT OR Apache-2.0 | +| [flume](https://github.com/zesterer/flume) | cargo | 0.12.0 | Apache-2.0/MIT | +| [foldhash](https://github.com/orlp/foldhash) | cargo | 0.2.0 | Zlib | +| [form_urlencoded](https://github.com/servo/rust-url) | cargo | 1.2.2 | MIT OR Apache-2.0 | +| [fs_extra](https://github.com/webdesus/fs_extra) | cargo | 1.3.0 | MIT | +| [futures-channel](https://github.com/rust-lang/futures-rs) | cargo | 0.3.33 | MIT OR Apache-2.0 | +| [futures-core](https://github.com/rust-lang/futures-rs) | cargo | 0.3.33 | MIT OR Apache-2.0 | +| [futures-executor](https://github.com/rust-lang/futures-rs) | cargo | 0.3.33 | MIT OR Apache-2.0 | +| [futures-intrusive](https://github.com/Matthias247/futures-intrusive) | cargo | 0.5.0 | MIT OR Apache-2.0 | +| [futures-io](https://github.com/rust-lang/futures-rs) | cargo | 0.3.33 | MIT OR Apache-2.0 | +| [futures-macro](https://github.com/rust-lang/futures-rs) | cargo | 0.3.33 | MIT OR Apache-2.0 | +| [futures-sink](https://github.com/rust-lang/futures-rs) | cargo | 0.3.33 | MIT OR Apache-2.0 | +| [futures-task](https://github.com/rust-lang/futures-rs) | cargo | 0.3.33 | MIT OR Apache-2.0 | +| [futures-util](https://github.com/rust-lang/futures-rs) | cargo | 0.3.33 | MIT OR Apache-2.0 | +| [generator](https://github.com/Xudong-Huang/generator-rs.git) | cargo | 0.8.9 | MIT/Apache-2.0 | +| [generic-array](https://github.com/fizyk20/generic-array.git) | cargo | 0.14.9 | MIT | +| [getrandom](https://github.com/rust-random/getrandom) | cargo | 0.2.17 | MIT OR Apache-2.0 | +| [getrandom](https://github.com/rust-random/getrandom) | cargo | 0.3.4 | MIT OR Apache-2.0 | +| [getrandom](https://github.com/rust-random/getrandom) | cargo | 0.4.3 | MIT OR Apache-2.0 | +| [gimli](https://github.com/gimli-rs/gimli) | cargo | 0.32.3 | MIT OR Apache-2.0 | +| [hashbag](https://github.com/jonhoo/hashbag.git) | cargo | 0.1.13 | MIT OR Apache-2.0 | +| [hashbrown](https://github.com/rust-lang/hashbrown) | cargo | 0.16.1 | MIT OR Apache-2.0 | +| [hashbrown](https://github.com/rust-lang/hashbrown) | cargo | 0.17.1 | MIT OR Apache-2.0 | +| [hashlink](https://github.com/djc/hashlink) | cargo | 0.11.1 | MIT OR Apache-2.0 | +| [heck](https://github.com/withoutboats/heck) | cargo | 0.5.0 | MIT OR Apache-2.0 | +| [hex](https://github.com/KokaKiwi/rust-hex) | cargo | 0.4.3 | MIT OR Apache-2.0 | +| [hkdf](https://github.com/RustCrypto/KDFs/) | cargo | 0.13.0 | MIT OR Apache-2.0 | +| [hmac](https://github.com/RustCrypto/MACs) | cargo | 0.13.0 | MIT OR Apache-2.0 | +| [hostname](https://github.com/djc/hostname) | cargo | 0.4.2 | MIT | +| [http](https://github.com/hyperium/http) | cargo | 1.5.0 | MIT OR Apache-2.0 | +| [http-body](https://github.com/hyperium/http-body) | cargo | 1.1.0 | MIT | +| [http-body-util](https://github.com/hyperium/http-body) | cargo | 0.1.4 | MIT | +| [httparse](https://github.com/seanmonstar/httparse) | cargo | 1.10.1 | MIT OR Apache-2.0 | +| [httpdate](https://github.com/pyfisch/httpdate) | cargo | 1.0.3 | MIT OR Apache-2.0 | +| [hybrid-array](https://github.com/RustCrypto/hybrid-array) | cargo | 0.4.14 | MIT OR Apache-2.0 | +| [hyper](https://github.com/hyperium/hyper) | cargo | 1.11.0 | MIT | +| [hyper-rustls](https://github.com/rustls/hyper-rustls) | cargo | 0.27.9 | Apache-2.0 OR ISC OR MIT | +| [hyper-util](https://github.com/hyperium/hyper-util) | cargo | 0.1.20 | MIT | +| [icu_collections](https://github.com/unicode-org/icu4x) | cargo | 2.2.0 | Unicode-3.0 | +| [icu_locale_core](https://github.com/unicode-org/icu4x) | cargo | 2.2.0 | Unicode-3.0 | +| [icu_normalizer](https://github.com/unicode-org/icu4x) | cargo | 2.2.0 | Unicode-3.0 | +| [icu_normalizer_data](https://github.com/unicode-org/icu4x) | cargo | 2.2.0 | Unicode-3.0 | +| [icu_properties](https://github.com/unicode-org/icu4x) | cargo | 2.2.0 | Unicode-3.0 | +| [icu_properties_data](https://github.com/unicode-org/icu4x) | cargo | 2.2.0 | Unicode-3.0 | +| [icu_provider](https://github.com/unicode-org/icu4x) | cargo | 2.2.0 | Unicode-3.0 | +| [idna](https://github.com/servo/rust-url/) | cargo | 1.1.0 | MIT OR Apache-2.0 | +| [idna_adapter](https://github.com/hsivonen/idna_adapter) | cargo | 1.2.2 | Apache-2.0 OR MIT | +| [indexmap](https://github.com/indexmap-rs/indexmap) | cargo | 2.14.0 | Apache-2.0 OR MIT | +| [ipnet](https://github.com/krisprice/ipnet) | cargo | 2.12.0 | MIT OR Apache-2.0 | +| [is_terminal_polyfill](https://github.com/polyfill-rs/is_terminal_polyfill) | cargo | 1.70.2 | MIT OR Apache-2.0 | +| [itoa](https://github.com/dtolnay/itoa) | cargo | 1.0.18 | MIT OR Apache-2.0 | +| [jni](https://github.com/jni-rs/jni-rs) | cargo | 0.22.4 | MIT OR Apache-2.0 | +| [jni-macros](https://github.com/jni-rs/jni-rs) | cargo | 0.22.4 | MIT OR Apache-2.0 | +| [jni-sys](https://github.com/jni-rs/jni-sys) | cargo | 0.4.1 | MIT OR Apache-2.0 | +| [jni-sys-macros](https://github.com/jni-rs/jni-sys) | cargo | 0.4.1 | MIT OR Apache-2.0 | +| [jobserver](https://github.com/rust-lang/jobserver-rs) | cargo | 0.1.35 | MIT OR Apache-2.0 | +| [js-sys](https://github.com/wasm-bindgen/wasm-bindgen/tree/master/crates/js-sys) | cargo | 0.3.103 | MIT OR Apache-2.0 | +| [lazy_static](https://github.com/rust-lang-nursery/lazy-static.rs) | cargo | 1.5.0 | MIT OR Apache-2.0 | +| [left-right](https://github.com/jonhoo/left-right.git) | cargo | 0.11.8 | MIT OR Apache-2.0 | +| [libc](https://github.com/rust-lang/libc) | cargo | 0.2.189 | MIT OR Apache-2.0 | +| [libsqlite3-sys](https://github.com/rusqlite/rusqlite) | cargo | 0.37.0 | MIT | +| [linux-raw-sys](https://github.com/sunfishcode/linux-raw-sys) | cargo | 0.12.1 | Apache-2.0 WITH LLVM-exception OR Apache-2.0 OR MIT | +| [litemap](https://github.com/unicode-org/icu4x) | cargo | 0.8.2 | Unicode-3.0 | +| [lock_api](https://github.com/Amanieu/parking_lot) | cargo | 0.4.14 | MIT OR Apache-2.0 | +| [log](https://github.com/rust-lang/log) | cargo | 0.4.33 | MIT OR Apache-2.0 | +| [loom](https://github.com/tokio-rs/loom) | cargo | 0.7.2 | MIT | +| [lru-slab](https://github.com/Ralith/lru-slab) | cargo | 0.1.2 | MIT OR Apache-2.0 OR Zlib | +| [matchers](https://github.com/hawkw/matchers) | cargo | 0.2.0 | MIT | +| [matchit](https://github.com/ibraheemdev/matchit) | cargo | 0.8.4 | MIT AND BSD-3-Clause | +| [md-5](https://github.com/RustCrypto/hashes) | cargo | 0.11.0 | MIT OR Apache-2.0 | +| [memchr](https://github.com/BurntSushi/memchr) | cargo | 2.8.3 | Unlicense OR MIT | +| [memo-map](https://github.com/mitsuhiko/memo-map) | cargo | 0.3.3 | Apache-2.0 | +| [metrics](https://github.com/metrics-rs/metrics) | cargo | 0.24.6 | MIT | +| [metrics-exporter-prometheus](https://github.com/metrics-rs/metrics) | cargo | 0.18.3 | MIT AND Apache-2.0 | +| [metrics-util](https://github.com/metrics-rs/metrics) | cargo | 0.20.4 | MIT | +| [mime](https://github.com/hyperium/mime) | cargo | 0.3.17 | MIT OR Apache-2.0 | +| [mime_guess](https://github.com/abonander/mime_guess) | cargo | 2.0.5 | MIT | +| [minijinja](https://github.com/mitsuhiko/minijinja) | cargo | 2.21.0 | Apache-2.0 | +| [miniz_oxide](https://github.com/Frommi/miniz_oxide/tree/master/miniz_oxide) | cargo | 0.8.9 | MIT OR Zlib OR Apache-2.0 | +| [mio](https://github.com/tokio-rs/mio) | cargo | 1.2.2 | MIT | +| [nix](https://github.com/nix-rust/nix) | cargo | 0.31.3 | MIT | +| [nu-ansi-term](https://github.com/nushell/nu-ansi-term) | cargo | 0.50.3 | MIT | +| [num-conv](https://github.com/jhpratt/num-conv) | cargo | 0.2.2 | MIT OR Apache-2.0 | +| [num-traits](https://github.com/rust-num/num-traits) | cargo | 0.2.19 | MIT OR Apache-2.0 | +| [objc2](https://github.com/madsmtm/objc2) | cargo | 0.6.4 | MIT | +| [objc2-cloud-kit](https://github.com/madsmtm/objc2) | cargo | 0.3.2 | Zlib OR Apache-2.0 OR MIT | +| [objc2-core-data](https://github.com/madsmtm/objc2) | cargo | 0.3.2 | Zlib OR Apache-2.0 OR MIT | +| [objc2-core-foundation](https://github.com/madsmtm/objc2) | cargo | 0.3.2 | Zlib OR Apache-2.0 OR MIT | +| [objc2-core-graphics](https://github.com/madsmtm/objc2) | cargo | 0.3.2 | Zlib OR Apache-2.0 OR MIT | +| [objc2-core-image](https://github.com/madsmtm/objc2) | cargo | 0.3.2 | Zlib OR Apache-2.0 OR MIT | +| [objc2-core-location](https://github.com/madsmtm/objc2) | cargo | 0.3.2 | Zlib OR Apache-2.0 OR MIT | +| [objc2-core-text](https://github.com/madsmtm/objc2) | cargo | 0.3.2 | Zlib OR Apache-2.0 OR MIT | +| [objc2-encode](https://github.com/madsmtm/objc2) | cargo | 4.1.0 | MIT | +| [objc2-foundation](https://github.com/madsmtm/objc2) | cargo | 0.3.2 | MIT | +| [objc2-io-surface](https://github.com/madsmtm/objc2) | cargo | 0.3.2 | Zlib OR Apache-2.0 OR MIT | +| [objc2-quartz-core](https://github.com/madsmtm/objc2) | cargo | 0.3.2 | Zlib OR Apache-2.0 OR MIT | +| [objc2-ui-kit](https://github.com/madsmtm/objc2) | cargo | 0.3.2 | Zlib OR Apache-2.0 OR MIT | +| [objc2-user-notifications](https://github.com/madsmtm/objc2) | cargo | 0.3.2 | Zlib OR Apache-2.0 OR MIT | +| [object](https://github.com/gimli-rs/object) | cargo | 0.37.3 | Apache-2.0 OR MIT | +| [once_cell](https://github.com/matklad/once_cell) | cargo | 1.21.4 | MIT OR Apache-2.0 | +| [once_cell_polyfill](https://github.com/polyfill-rs/once_cell_polyfill) | cargo | 1.70.2 | MIT OR Apache-2.0 | +| [openssl-probe](https://github.com/rustls/openssl-probe) | cargo | 0.2.1 | MIT OR Apache-2.0 | +| [os_info](https://github.com/stanislav-tkach/os_info) | cargo | 3.15.0 | MIT | +| [parking](https://github.com/smol-rs/parking) | cargo | 2.2.1 | Apache-2.0 OR MIT | +| [parking_lot](https://github.com/Amanieu/parking_lot) | cargo | 0.12.5 | MIT OR Apache-2.0 | +| [parking_lot_core](https://github.com/Amanieu/parking_lot) | cargo | 0.9.12 | MIT OR Apache-2.0 | +| [percent-encoding](https://github.com/servo/rust-url/) | cargo | 2.3.2 | MIT OR Apache-2.0 | +| [pin-project-lite](https://github.com/taiki-e/pin-project-lite) | cargo | 0.2.17 | Apache-2.0 OR MIT | +| [pkg-config](https://github.com/rust-lang/pkg-config-rs) | cargo | 0.3.33 | MIT OR Apache-2.0 | +| [portable-atomic](https://github.com/taiki-e/portable-atomic) | cargo | 1.14.0 | Apache-2.0 OR MIT | +| [potential_utf](https://github.com/unicode-org/icu4x) | cargo | 0.1.5 | Unicode-3.0 | +| [powerfmt](https://github.com/jhpratt/powerfmt) | cargo | 0.2.0 | MIT OR Apache-2.0 | +| [ppv-lite86](https://github.com/cryptocorrosion/cryptocorrosion) | cargo | 0.2.21 | MIT OR Apache-2.0 | +| [proc-macro2](https://github.com/dtolnay/proc-macro2) | cargo | 1.0.107 | MIT OR Apache-2.0 | +| [quanta](https://github.com/metrics-rs/quanta) | cargo | 0.12.6 | MIT | +| [quinn](https://github.com/quinn-rs/quinn) | cargo | 0.11.11 | MIT OR Apache-2.0 | +| [quinn-proto](https://github.com/quinn-rs/quinn) | cargo | 0.11.16 | MIT OR Apache-2.0 | +| [quinn-udp](https://github.com/quinn-rs/quinn) | cargo | 0.5.15 | MIT OR Apache-2.0 | +| [quote](https://github.com/dtolnay/quote) | cargo | 1.0.47 | MIT OR Apache-2.0 | +| [r-efi](https://github.com/r-efi/r-efi) | cargo | 5.3.0 | MIT OR Apache-2.0 OR LGPL-2.1-or-later | +| [r-efi](https://github.com/r-efi/r-efi) | cargo | 6.0.0 | MIT OR Apache-2.0 OR LGPL-2.1-or-later | +| [rand](https://github.com/rust-random/rand) | cargo | 0.10.2 | MIT OR Apache-2.0 | +| [rand](https://github.com/rust-random/rand) | cargo | 0.9.5 | MIT OR Apache-2.0 | +| [rand_chacha](https://github.com/rust-random/rand) | cargo | 0.9.0 | MIT OR Apache-2.0 | +| [rand_core](https://github.com/rust-random/rand_core) | cargo | 0.10.1 | MIT OR Apache-2.0 | +| [rand_core](https://github.com/rust-random/rand) | cargo | 0.9.5 | MIT OR Apache-2.0 | +| [rand_pcg](https://github.com/rust-random/rngs) | cargo | 0.10.2 | MIT OR Apache-2.0 | +| [rand_xoshiro](https://github.com/rust-random/rngs) | cargo | 0.7.0 | MIT OR Apache-2.0 | +| [rapidhash](https://github.com/hoxxep/rapidhash) | cargo | 4.5.1 | MIT OR Apache-2.0 | +| [raw-cpuid](https://github.com/gz/rust-cpuid) | cargo | 11.6.0 | MIT | +| [redox_syscall](https://gitlab.redox-os.org/redox-os/syscall) | cargo | 0.5.18 | MIT | +| [regex](https://github.com/rust-lang/regex) | cargo | 1.13.1 | MIT OR Apache-2.0 | +| [regex-automata](https://github.com/rust-lang/regex) | cargo | 0.4.16 | MIT OR Apache-2.0 | +| [regex-syntax](https://github.com/rust-lang/regex) | cargo | 0.8.11 | MIT OR Apache-2.0 | +| [reqwest](https://github.com/seanmonstar/reqwest) | cargo | 0.12.28 | MIT OR Apache-2.0 | +| [reqwest](https://github.com/seanmonstar/reqwest) | cargo | 0.13.4 | MIT OR Apache-2.0 | +| [ring](https://github.com/briansmith/ring) | cargo | 0.17.14 | Apache-2.0 AND ISC | +| [rust-embed](https://pyrossh.dev/repos/rust-embed) | cargo | 8.12.0 | MIT | +| [rust-embed-impl](https://pyrossh.dev/repos/rust-embed) | cargo | 8.12.0 | MIT | +| [rust-embed-utils](https://pyrossh.dev/repos/rust-embed) | cargo | 8.12.0 | MIT | +| [rustc-demangle](https://github.com/rust-lang/rustc-demangle) | cargo | 0.1.28 | MIT/Apache-2.0 | +| [rustc-hash](https://github.com/rust-lang/rustc-hash) | cargo | 2.1.3 | Apache-2.0 OR MIT | +| [rustc_version](https://github.com/djc/rustc-version-rs) | cargo | 0.4.1 | MIT OR Apache-2.0 | +| [rustix](https://github.com/bytecodealliance/rustix) | cargo | 1.1.4 | Apache-2.0 WITH LLVM-exception OR Apache-2.0 OR MIT | +| [rustls](https://github.com/rustls/rustls) | cargo | 0.23.43 | Apache-2.0 OR ISC OR MIT | +| [rustls-native-certs](https://github.com/rustls/rustls-native-certs) | cargo | 0.8.4 | Apache-2.0 OR ISC OR MIT | +| [rustls-pki-types](https://github.com/rustls/pki-types) | cargo | 1.15.1 | MIT OR Apache-2.0 | +| [rustls-platform-verifier](https://github.com/rustls/rustls-platform-verifier) | cargo | 0.7.0 | MIT OR Apache-2.0 | +| [rustls-platform-verifier-android](https://github.com/rustls/rustls-platform-verifier) | cargo | 0.1.1 | MIT OR Apache-2.0 | +| [rustls-webpki](https://github.com/rustls/webpki) | cargo | 0.103.13 | ISC | +| [rustversion](https://github.com/dtolnay/rustversion) | cargo | 1.0.23 | MIT OR Apache-2.0 | +| [ryu](https://github.com/dtolnay/ryu) | cargo | 1.0.23 | Apache-2.0 OR BSL-1.0 | +| [same-file](https://github.com/BurntSushi/same-file) | cargo | 1.0.6 | Unlicense/MIT | +| [schannel](https://github.com/steffengy/schannel-rs) | cargo | 0.1.29 | MIT | +| [scoped-tls](https://github.com/alexcrichton/scoped-tls) | cargo | 1.0.1 | MIT/Apache-2.0 | +| [scopeguard](https://github.com/bluss/scopeguard) | cargo | 1.2.0 | MIT OR Apache-2.0 | +| [security-framework](https://github.com/kornelski/rust-security-framework) | cargo | 3.7.0 | MIT OR Apache-2.0 | +| [security-framework-sys](https://github.com/kornelski/rust-security-framework) | cargo | 2.17.0 | MIT OR Apache-2.0 | +| [semver](https://github.com/dtolnay/semver) | cargo | 1.0.28 | MIT OR Apache-2.0 | +| [sentry](https://github.com/getsentry/sentry-rust) | cargo | 0.49.0 | MIT | +| [sentry-backtrace](https://github.com/getsentry/sentry-rust) | cargo | 0.49.0 | MIT | +| [sentry-contexts](https://github.com/getsentry/sentry-rust) | cargo | 0.49.0 | MIT | +| [sentry-core](https://github.com/getsentry/sentry-rust) | cargo | 0.49.0 | MIT | +| [sentry-panic](https://github.com/getsentry/sentry-rust) | cargo | 0.49.0 | MIT | +| [sentry-tracing](https://github.com/getsentry/sentry-rust) | cargo | 0.49.0 | MIT | +| [sentry-types](https://github.com/getsentry/sentry-rust) | cargo | 0.49.0 | MIT | +| [serde](https://github.com/serde-rs/serde) | cargo | 1.0.229 | MIT OR Apache-2.0 | +| [serde_core](https://github.com/serde-rs/serde) | cargo | 1.0.229 | MIT OR Apache-2.0 | +| [serde_derive](https://github.com/serde-rs/serde) | cargo | 1.0.229 | MIT OR Apache-2.0 | +| [serde_json](https://github.com/serde-rs/json) | cargo | 1.0.151 | MIT OR Apache-2.0 | +| [serde_norway](https://github.com/cafkafk/serde-yaml) | cargo | 0.9.42 | MIT OR Apache-2.0 | +| [serde_path_to_error](https://github.com/dtolnay/path-to-error) | cargo | 0.1.20 | MIT OR Apache-2.0 | +| [serde_urlencoded](https://github.com/nox/serde_urlencoded) | cargo | 0.7.1 | MIT/Apache-2.0 | +| [sha1](https://github.com/RustCrypto/hashes) | cargo | 0.11.0 | MIT OR Apache-2.0 | +| [sha2](https://github.com/RustCrypto/hashes) | cargo | 0.10.9 | MIT OR Apache-2.0 | +| [sha2](https://github.com/RustCrypto/hashes) | cargo | 0.11.0 | MIT OR Apache-2.0 | +| [sharded-slab](https://github.com/hawkw/sharded-slab) | cargo | 0.1.7 | MIT | +| [shlex](https://github.com/comex/rust-shlex) | cargo | 2.0.1 | MIT OR Apache-2.0 | +| [signal-hook-registry](https://github.com/vorner/signal-hook) | cargo | 1.4.8 | MIT OR Apache-2.0 | +| [simd-adler32](https://github.com/mcountryman/simd-adler32) | cargo | 0.3.10 | MIT | +| [simd_cesu8](https://github.com/seancroach/simd_cesu8) | cargo | 1.2.0 | Apache-2.0 OR MIT | +| [simdutf8](https://github.com/rusticstuff/simdutf8) | cargo | 0.1.5 | MIT OR Apache-2.0 | +| [sketches-ddsketch](https://github.com/mheffner/rust-sketches-ddsketch) | cargo | 0.3.1 | Apache-2.0 | +| [slab](https://github.com/tokio-rs/slab) | cargo | 0.4.12 | MIT | +| [smallvec](https://github.com/servo/rust-smallvec) | cargo | 1.15.2 | MIT OR Apache-2.0 | +| [socket2](https://github.com/rust-lang/socket2) | cargo | 0.6.5 | MIT OR Apache-2.0 | +| [spin](https://github.com/mvdnes/spin-rs.git) | cargo | 0.9.9 | MIT | +| [sqlx](https://github.com/launchbadge/sqlx) | cargo | 0.9.0 | MIT OR Apache-2.0 | +| [sqlx-core](https://github.com/launchbadge/sqlx) | cargo | 0.9.0 | MIT OR Apache-2.0 | +| [sqlx-macros](https://github.com/launchbadge/sqlx) | cargo | 0.9.0 | MIT OR Apache-2.0 | +| [sqlx-macros-core](https://github.com/launchbadge/sqlx) | cargo | 0.9.0 | MIT OR Apache-2.0 | +| [sqlx-mysql](https://github.com/launchbadge/sqlx) | cargo | 0.9.0 | MIT OR Apache-2.0 | +| [sqlx-postgres](https://github.com/launchbadge/sqlx) | cargo | 0.9.0 | MIT OR Apache-2.0 | +| [sqlx-sqlite](https://github.com/launchbadge/sqlx) | cargo | 0.9.0 | MIT OR Apache-2.0 | +| [stable_deref_trait](https://github.com/storyyeller/stable_deref_trait) | cargo | 1.2.1 | MIT OR Apache-2.0 | +| [stringprep](https://github.com/sfackler/rust-stringprep) | cargo | 0.1.5 | MIT/Apache-2.0 | +| [strsim](https://github.com/rapidfuzz/strsim-rs) | cargo | 0.11.1 | MIT | +| [subtle](https://github.com/dalek-cryptography/subtle) | cargo | 2.6.1 | BSD-3-Clause | +| [syn](https://github.com/dtolnay/syn) | cargo | 2.0.119 | MIT OR Apache-2.0 | +| [syn](https://github.com/dtolnay/syn) | cargo | 3.0.3 | MIT OR Apache-2.0 | +| [sync_wrapper](https://github.com/Actyx/sync_wrapper) | cargo | 1.0.2 | Apache-2.0 | +| [synstructure](https://github.com/mystor/synstructure) | cargo | 0.13.2 | MIT | +| [tempfile](https://github.com/Stebalien/tempfile) | cargo | 3.27.0 | MIT OR Apache-2.0 | +| [thiserror](https://github.com/dtolnay/thiserror) | cargo | 2.0.19 | MIT OR Apache-2.0 | +| [thiserror-impl](https://github.com/dtolnay/thiserror) | cargo | 2.0.19 | MIT OR Apache-2.0 | +| [thread_local](https://github.com/Amanieu/thread_local-rs) | cargo | 1.1.10 | MIT OR Apache-2.0 | +| [time](https://github.com/time-rs/time) | cargo | 0.3.55 | MIT OR Apache-2.0 | +| [time-core](https://github.com/time-rs/time) | cargo | 0.1.9 | MIT OR Apache-2.0 | +| [time-macros](https://github.com/time-rs/time) | cargo | 0.2.32 | MIT OR Apache-2.0 | +| [tinystr](https://github.com/unicode-org/icu4x) | cargo | 0.8.3 | Unicode-3.0 | +| [tinyvec](https://github.com/Lokathor/tinyvec) | cargo | 1.12.0 | Zlib OR Apache-2.0 OR MIT | +| [tinyvec_macros](https://github.com/Soveu/tinyvec_macros) | cargo | 0.1.1 | MIT OR Apache-2.0 OR Zlib | +| [tokio](https://github.com/tokio-rs/tokio) | cargo | 1.53.1 | MIT | +| [tokio-macros](https://github.com/tokio-rs/tokio) | cargo | 2.7.2 | MIT | +| [tokio-rustls](https://github.com/rustls/tokio-rustls) | cargo | 0.26.4 | MIT OR Apache-2.0 | +| [tokio-stream](https://github.com/tokio-rs/tokio) | cargo | 0.1.19 | MIT | +| [tokio-util](https://github.com/tokio-rs/tokio) | cargo | 0.7.19 | MIT | +| [tower](https://github.com/tower-rs/tower) | cargo | 0.5.3 | MIT | +| [tower-http](https://github.com/tower-rs/tower-http) | cargo | 0.6.11 | MIT | +| [tower-layer](https://github.com/tower-rs/tower) | cargo | 0.3.3 | MIT | +| [tower-service](https://github.com/tower-rs/tower) | cargo | 0.3.3 | MIT | +| [tracing](https://github.com/tokio-rs/tracing) | cargo | 0.1.44 | MIT | +| [tracing-attributes](https://github.com/tokio-rs/tracing) | cargo | 0.1.31 | MIT | +| [tracing-core](https://github.com/tokio-rs/tracing) | cargo | 0.1.36 | MIT | +| [tracing-log](https://github.com/tokio-rs/tracing) | cargo | 0.2.0 | MIT | +| [tracing-serde](https://github.com/tokio-rs/tracing) | cargo | 0.2.0 | MIT | +| [tracing-subscriber](https://github.com/tokio-rs/tracing) | cargo | 0.3.23 | MIT | +| [try-lock](https://github.com/seanmonstar/try-lock) | cargo | 0.2.5 | MIT | +| [typenum](https://github.com/paholg/typenum) | cargo | 1.20.1 | MIT OR Apache-2.0 | +| [uname](https://github.com/icorderi/rust-uname) | cargo | 0.1.1 | MIT/Apache-2.0 | +| [unicase](https://github.com/seanmonstar/unicase) | cargo | 2.9.0 | MIT OR Apache-2.0 | +| [unicode-bidi](https://github.com/servo/unicode-bidi) | cargo | 0.3.18 | MIT OR Apache-2.0 | +| [unicode-ident](https://github.com/dtolnay/unicode-ident) | cargo | 1.0.24 | (MIT OR Apache-2.0) AND Unicode-3.0 | +| [unicode-normalization](https://github.com/unicode-rs/unicode-normalization) | cargo | 0.1.25 | MIT OR Apache-2.0 | +| [unicode-properties](https://github.com/unicode-rs/unicode-properties) | cargo | 0.1.4 | MIT/Apache-2.0 | +| [unsafe-libyaml-norway](https://github.com/cafkafk/unsafe-libyaml-norway) | cargo | 0.2.15 | MIT | +| [untrusted](https://github.com/briansmith/untrusted) | cargo | 0.9.0 | ISC | +| [ureq](https://github.com/algesten/ureq) | cargo | 3.3.0 | MIT OR Apache-2.0 | +| [ureq-proto](https://github.com/algesten/ureq-proto) | cargo | 0.6.0 | MIT OR Apache-2.0 | +| [url](https://github.com/servo/rust-url) | cargo | 2.5.8 | MIT OR Apache-2.0 | +| [utf8-zero](https://github.com/algesten/utf8-zero) | cargo | 0.8.1 | MIT OR Apache-2.0 | +| [utf8_iter](https://github.com/hsivonen/utf8_iter) | cargo | 1.0.4 | Apache-2.0 OR MIT | +| [utf8parse](https://github.com/alacritty/vte) | cargo | 0.2.2 | Apache-2.0 OR MIT | +| [utoipa](https://github.com/juhaku/utoipa) | cargo | 5.5.0 | MIT OR Apache-2.0 | +| [utoipa-gen](https://github.com/juhaku/utoipa) | cargo | 5.5.0 | MIT OR Apache-2.0 | +| [utoipa-swagger-ui](https://github.com/juhaku/utoipa) | cargo | 9.0.2 | MIT OR Apache-2.0 | +| [utoipa-swagger-ui-vendored](https://github.com/juhaku/utoipa) | cargo | 0.1.2 | MIT OR Apache-2.0 | +| [uuid](https://github.com/uuid-rs/uuid) | cargo | 1.24.0 | Apache-2.0 OR MIT | +| [valuable](https://github.com/tokio-rs/valuable) | cargo | 0.1.1 | MIT | +| [vcpkg](https://github.com/mcgoo/vcpkg-rs) | cargo | 0.2.15 | MIT/Apache-2.0 | +| [version_check](https://github.com/SergioBenitez/version_check) | cargo | 0.9.5 | MIT/Apache-2.0 | +| [walkdir](https://github.com/BurntSushi/walkdir) | cargo | 2.5.0 | Unlicense/MIT | +| [want](https://github.com/seanmonstar/want) | cargo | 0.3.1 | MIT | +| [wasi](https://github.com/bytecodealliance/wasi) | cargo | 0.11.1+wasi-snapshot-preview1 | Apache-2.0 WITH LLVM-exception OR Apache-2.0 OR MIT | +| [wasip2](https://github.com/bytecodealliance/wasi-rs) | cargo | 1.0.4+wasi-0.2.12 | Apache-2.0 WITH LLVM-exception OR Apache-2.0 OR MIT | +| [wasm-bindgen](https://github.com/wasm-bindgen/wasm-bindgen) | cargo | 0.2.126 | MIT OR Apache-2.0 | +| [wasm-bindgen-futures](https://github.com/wasm-bindgen/wasm-bindgen/tree/master/crates/futures) | cargo | 0.4.76 | MIT OR Apache-2.0 | +| [wasm-bindgen-macro](https://github.com/wasm-bindgen/wasm-bindgen/tree/master/crates/macro) | cargo | 0.2.126 | MIT OR Apache-2.0 | +| [wasm-bindgen-macro-support](https://github.com/wasm-bindgen/wasm-bindgen/tree/master/crates/macro-support) | cargo | 0.2.126 | MIT OR Apache-2.0 | +| [wasm-bindgen-shared](https://github.com/wasm-bindgen/wasm-bindgen/tree/master/crates/shared) | cargo | 0.2.126 | MIT OR Apache-2.0 | +| [wasm-streams](https://github.com/MattiasBuelens/wasm-streams/) | cargo | 0.4.2 | MIT OR Apache-2.0 | +| [web-sys](https://github.com/wasm-bindgen/wasm-bindgen/tree/master/crates/web-sys) | cargo | 0.3.103 | MIT OR Apache-2.0 | +| [web-time](https://github.com/daxpedda/web-time) | cargo | 1.1.0 | MIT OR Apache-2.0 | +| [webpki-root-certs](https://github.com/rustls/webpki-roots) | cargo | 1.0.9 | CDLA-Permissive-2.0 | +| [webpki-roots](https://github.com/rustls/webpki-roots) | cargo | 1.0.9 | CDLA-Permissive-2.0 | +| [whoami](https://github.com/ardaku/whoami) | cargo | 2.1.2 | Apache-2.0 OR BSL-1.0 OR MIT | +| [winapi](https://github.com/retep998/winapi-rs) | cargo | 0.3.9 | MIT/Apache-2.0 | +| [winapi-i686-pc-windows-gnu](https://github.com/retep998/winapi-rs) | cargo | 0.4.0 | MIT/Apache-2.0 | +| [winapi-util](https://github.com/BurntSushi/winapi-util) | cargo | 0.1.11 | Unlicense OR MIT | +| [winapi-x86_64-pc-windows-gnu](https://github.com/retep998/winapi-rs) | cargo | 0.4.0 | MIT/Apache-2.0 | +| [windows-link](https://github.com/microsoft/windows-rs) | cargo | 0.2.1 | MIT OR Apache-2.0 | +| [windows-result](https://github.com/microsoft/windows-rs) | cargo | 0.4.1 | MIT OR Apache-2.0 | +| [windows-sys](https://github.com/microsoft/windows-rs) | cargo | 0.52.0 | MIT OR Apache-2.0 | +| [windows-sys](https://github.com/microsoft/windows-rs) | cargo | 0.61.2 | MIT OR Apache-2.0 | +| [windows-targets](https://github.com/microsoft/windows-rs) | cargo | 0.52.6 | MIT OR Apache-2.0 | +| [windows_aarch64_gnullvm](https://github.com/microsoft/windows-rs) | cargo | 0.52.6 | MIT OR Apache-2.0 | +| [windows_aarch64_msvc](https://github.com/microsoft/windows-rs) | cargo | 0.52.6 | MIT OR Apache-2.0 | +| [windows_i686_gnu](https://github.com/microsoft/windows-rs) | cargo | 0.52.6 | MIT OR Apache-2.0 | +| [windows_i686_gnullvm](https://github.com/microsoft/windows-rs) | cargo | 0.52.6 | MIT OR Apache-2.0 | +| [windows_i686_msvc](https://github.com/microsoft/windows-rs) | cargo | 0.52.6 | MIT OR Apache-2.0 | +| [windows_x86_64_gnu](https://github.com/microsoft/windows-rs) | cargo | 0.52.6 | MIT OR Apache-2.0 | +| [windows_x86_64_gnullvm](https://github.com/microsoft/windows-rs) | cargo | 0.52.6 | MIT OR Apache-2.0 | +| [windows_x86_64_msvc](https://github.com/microsoft/windows-rs) | cargo | 0.52.6 | MIT OR Apache-2.0 | +| [wit-bindgen](https://github.com/bytecodealliance/wit-bindgen) | cargo | 0.57.1 | Apache-2.0 WITH LLVM-exception OR Apache-2.0 OR MIT | +| [writeable](https://github.com/unicode-org/icu4x) | cargo | 0.6.3 | Unicode-3.0 | +| [yoke](https://github.com/unicode-org/icu4x) | cargo | 0.8.3 | Unicode-3.0 | +| [yoke-derive](https://github.com/unicode-org/icu4x) | cargo | 0.8.2 | Unicode-3.0 | +| [zerocopy](https://github.com/google/zerocopy) | cargo | 0.8.55 | BSD-2-Clause OR Apache-2.0 OR MIT | +| [zerocopy-derive](https://github.com/google/zerocopy) | cargo | 0.8.55 | BSD-2-Clause OR Apache-2.0 OR MIT | +| [zerofrom](https://github.com/unicode-org/icu4x) | cargo | 0.1.8 | Unicode-3.0 | +| [zerofrom-derive](https://github.com/unicode-org/icu4x) | cargo | 0.1.7 | Unicode-3.0 | +| [zeroize](https://github.com/RustCrypto/utils) | cargo | 1.9.0 | Apache-2.0 OR MIT | +| [zerotrie](https://github.com/unicode-org/icu4x) | cargo | 0.2.4 | Unicode-3.0 | +| [zerovec](https://github.com/unicode-org/icu4x) | cargo | 0.11.6 | Unicode-3.0 | +| [zerovec-derive](https://github.com/unicode-org/icu4x) | cargo | 0.11.3 | Unicode-3.0 | +| [zip](https://github.com/zip-rs/zip2.git) | cargo | 3.0.0 | MIT | +| [zlib-rs](https://github.com/trifectatechfoundation/zlib-rs) | cargo | 0.6.6 | Zlib | +| [zmij](https://github.com/dtolnay/zmij) | cargo | 1.0.23 | MIT | +| [zopfli](https://github.com/zopfli-rs/zopfli) | cargo | 0.8.3 | Apache-2.0 | +| [@types/react](https://github.com/DefinitelyTyped/DefinitelyTyped) | npm | 19.2.18 | MIT | +| cookie-es | npm | 3.1.1 | MIT | +| [csstype](https://github.com/frenic/csstype) | npm | 3.2.3 | MIT | +| [prismjs](https://github.com/PrismJS/prism) | npm | 1.30.0 | MIT | +| [react](https://github.com/react/react) | npm | 19.2.8 | MIT | +| [react-dom](https://github.com/react/react) | npm | 19.2.8 | MIT | +| [react-router](https://github.com/remix-run/react-router) | npm | 8.3.0 | MIT | +| [react-simple-code-editor](https://github.com/react-simple-code-editor/react-simple-code-editor) | npm | 0.14.1 | MIT | +| [scheduler](https://github.com/facebook/react) | npm | 0.27.0 | MIT | +| [zustand](https://github.com/pmndrs/zustand) | npm | 5.0.14 | MIT | diff --git a/crates/doppel-admin/build.rs b/crates/doppel-admin/build.rs new file mode 100644 index 0000000..0dfeaf5 --- /dev/null +++ b/crates/doppel-admin/build.rs @@ -0,0 +1,146 @@ +//! Embed the built dashboard into the binary. +//! +//! Walks `frontend/dist` and writes a table of `include_bytes!` into `OUT_DIR`. +//! No crate does this for us on purpose: `rust-embed` and `include_dir` would +//! each be a direct dependency for a directory walk, and `CONTRIBUTING.md` asks +//! what a new one does that the standard library cannot. This is forty lines. +//! +//! When `frontend/dist` is absent the tables are emitted empty and the +//! `dashboard_assets` cfg is not set, so the crate still compiles and +//! `cargo install --path crates/doppel-cli` works on a machine with no Node. +//! `GET /` then answers 503 naming the command that builds it. + +use std::fmt::Write as _; +use std::path::{Path, PathBuf}; + +fn main() { + // `cargo::` rather than `cargo:` -- the older single-colon form is + // deprecated, and `rustc-check-cfg` is what stops the `unexpected_cfgs` + // lint from firing on our own conditional. + println!("cargo::rustc-check-cfg=cfg(dashboard_assets)"); + + let dist = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../../frontend/dist") + .canonicalize() + .ok() + .filter(|dist| dist.join("index.html").is_file()); + + // Rebuilt when the dashboard is rebuilt. Watching the directory covers a + // file appearing or changing inside it; watching the parent covers `dist` + // itself appearing for the first time, which is the case a first + // `npm run build` produces. + println!("cargo::rerun-if-changed=../../frontend/dist"); + println!("cargo::rerun-if-changed=../../frontend"); + + let mut generated = String::new(); + let Some(dist) = dist else { + writeln!( + generated, + "/// No `frontend/dist` was present when this binary was built.\n\ + pub static ASSETS: &[(&str, &[u8], &str)] = &[];\n\ + pub static INDEX_HTML: &str = \"\";" + ) + .expect("writing to a String cannot fail"); + write(&generated); + return; + }; + + println!("cargo::rustc-cfg=dashboard_assets"); + + let mut files = Vec::new(); + collect(&dist, &dist, &mut files); + files.sort(); + + writeln!( + generated, + "/// Every file of `frontend/dist` except `index.html`, as\n\ + /// (request path under `/static/`, bytes, content type).\n\ + pub static ASSETS: &[(&str, &[u8], &str)] = &[" + ) + .expect("writing to a String cannot fail"); + for (request_path, absolute) in &files { + writeln!( + generated, + " ({request_path:?}, include_bytes!({absolute:?}), {:?}),", + content_type(request_path) + ) + .expect("writing to a String cannot fail"); + } + writeln!(generated, "];").expect("writing to a String cannot fail"); + + // The page has to carry the element the listener substitutes the runtime + // configuration into. Checked here rather than at request time: it is a + // property of the build, so a mismatch between `frontend/index.html` and + // `dashboard.rs` should stop the build rather than serve a page whose + // configuration is the development placeholder. + let index = dist.join("index.html"); + let markup = std::fs::read_to_string(&index).expect("frontend/dist/index.html is readable"); + assert!( + markup.contains("id=\"doppel-config\""), + "{} has no doppel-config element; frontend/index.html and \ + crates/doppel-admin/src/dashboard.rs have diverged", + index.display() + ); + + writeln!( + generated, + "/// The page itself, with the configuration placeholder still in it.\n\ + pub static INDEX_HTML: &str = include_str!({:?});", + index + ) + .expect("writing to a String cannot fail"); + + write(&generated); +} + +fn write(generated: &str) { + let out = PathBuf::from(std::env::var_os("OUT_DIR").expect("cargo sets OUT_DIR")); + std::fs::write(out.join("assets.rs"), generated).expect("write the asset table"); +} + +/// Every file under `dir`, as (path relative to `root`, absolute path). +/// +/// `index.html` is skipped: it is served from `/` with the configuration +/// substituted into it, not from `/static/` verbatim, so having it in both +/// places would mean two ways to reach one page and only one of them configured. +fn collect(root: &Path, dir: &Path, out: &mut Vec<(String, PathBuf)>) { + let entries = std::fs::read_dir(dir).expect("frontend/dist is readable"); + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + collect(root, &path, out); + continue; + } + let relative = path + .strip_prefix(root) + .expect("walked below the root") + .to_string_lossy() + // A request path uses forward slashes whatever the build host does. + .replace('\\', "/"); + if relative == "index.html" { + continue; + } + out.push((relative, path)); + } +} + +/// The content type for a built asset, by extension. +/// +/// Only the extensions vite emits. Anything else is served as bytes rather than +/// guessed at: a wrong `Content-Type` on a script is a page that silently does +/// not run. +fn content_type(path: &str) -> &'static str { + match path.rsplit_once('.').map(|(_, extension)| extension) { + Some("js" | "mjs") => "text/javascript; charset=utf-8", + Some("css") => "text/css; charset=utf-8", + Some("html") => "text/html; charset=utf-8", + Some("json") => "application/json", + Some("svg") => "image/svg+xml", + Some("ico") => "image/x-icon", + Some("png") => "image/png", + Some("woff2") => "font/woff2", + Some("woff") => "font/woff", + Some("map") => "application/json", + _ => "application/octet-stream", + } +} diff --git a/crates/doppel-admin/src/dashboard.rs b/crates/doppel-admin/src/dashboard.rs new file mode 100644 index 0000000..f5de78f --- /dev/null +++ b/crates/doppel-admin/src/dashboard.rs @@ -0,0 +1,233 @@ +//! The browser dashboard: the page, its assets, and `robots.txt`. +//! +//! The assets are embedded by `build.rs`, which walks `frontend/dist` and writes +//! the table included below. A binary built without that directory carries an +//! empty table and answers 503 here, so a source build on a machine with no Node +//! still works and says why the dashboard is missing. + +use axum::Router; +use axum::extract::{Path, State}; +use axum::http::{HeaderName, HeaderValue, header}; +use axum::response::{IntoResponse, Response}; +use axum::routing::get; +use doppel_core::{Error, ErrorCode}; +use serde::Serialize; + +use crate::response::ApiError; +use crate::state::AdminState; + +// `ASSETS` and `INDEX_HTML`, generated at build time. +include!(concat!(env!("OUT_DIR"), "/assets.rs")); + +/// The placeholder `index.html` carries, replaced per request. +/// +/// Matched as a whole element rather than by editing its contents: a partial +/// match that silently failed would leave the development placeholder in a +/// served page, and the page would then report the wrong title, the wrong +/// version, and `public: false` for a public deployment. +const CONFIG_ELEMENT_ID: &str = "doppel-config"; + +/// Kept out of a search index three ways, because they fail differently: a +/// crawler that reads headers sees this, one that reads markup sees the `meta` +/// element in `index.html`, and one that reads `robots.txt` sees the route +/// below. +const ROBOTS_TAG: (HeaderName, HeaderValue) = ( + HeaderName::from_static("x-robots-tag"), + HeaderValue::from_static("noindex, nofollow, noarchive"), +); + +/// Sent with everything here. +/// +/// `nosniff` matters most on the asset route: without it a browser may decide a +/// file is something other than what its `Content-Type` says, and the whole +/// point of serving hashed assets with an exact type is that it does not have to +/// guess. +const NO_SNIFF: (HeaderName, HeaderValue) = ( + HeaderName::from_static("x-content-type-options"), + HeaderValue::from_static("nosniff"), +); + +/// No inline script, no inline style, no framing, nothing fetched from anywhere +/// but this origin. +/// +/// The configuration reaches the page as the *contents* of a +/// `application/json` element rather than as inline JavaScript, which is what +/// makes `script-src 'self'` sufficient -- an inline `")? + open_ends; + Some((&template[..open_ends], &template[close_begins..])) +} + +async fn asset(Path(path): Path) -> Response { + let Some((_, bytes, content_type)) = ASSETS.iter().find(|(name, _, _)| *name == path) else { + // The envelope, not the page. A mistyped API path must not answer 200 + // with an HTML document: a client would parse it as a successful + // response, and a human would see a working dashboard where they asked + // for something that is not there. + return refuse(Error::new( + ErrorCode::NotFound, + format!("no asset /static/{path}"), + )); + }; + + ( + [ + (header::CONTENT_TYPE, HeaderValue::from_static(content_type)), + // Safe because vite content-hashes every filename: a changed file is + // a different URL, so nothing has to expire. + ( + header::CACHE_CONTROL, + HeaderValue::from_static("public, max-age=31536000, immutable"), + ), + ], + [ROBOTS_TAG, NO_SNIFF], + *bytes, + ) + .into_response() +} + +/// An error from a dashboard route, carrying the same headers its successes do. +/// +/// The robots header belongs on a refusal as much as on a page: a crawler that +/// reached `/static/typo.js` would otherwise be free to index the error body, and +/// "every dashboard response forbids indexing" is easier to hold to than a list +/// of the ones that do. +fn refuse(error: Error) -> Response { + let mut response = ApiError::from(error).into_response(); + response.headers_mut().extend([ROBOTS_TAG, NO_SNIFF]); + response +} + +async fn robots() -> Response { + ( + [( + header::CONTENT_TYPE, + HeaderValue::from_static("text/plain; charset=utf-8"), + )], + [ROBOTS_TAG, NO_SNIFF], + "User-agent: *\nDisallow: /\n", + ) + .into_response() +} diff --git a/crates/doppel-admin/src/lib.rs b/crates/doppel-admin/src/lib.rs index 95b8212..2ff8218 100644 --- a/crates/doppel-admin/src/lib.rs +++ b/crates/doppel-admin/src/lib.rs @@ -2,6 +2,7 @@ pub mod access; pub mod body; +pub mod dashboard; pub mod openapi; pub mod proxies; pub mod response; @@ -19,12 +20,23 @@ pub use state::AdminState; /// A router rather than a server: `serve` owns the listener and the shutdown /// signal, and the tests drive this directly without binding a port. pub fn router(state: AdminState) -> axum::Router { - axum::Router::new() + // Read from the startup configuration, not the reloaded one: routes are + // built once, so turning the dashboard on or off takes a restart -- the same + // rule `admin.enable` already follows, and `main.example.yaml` says so. + let dashboard = state.startup().admin.is_dashboard_enabled(); + + let mut router = axum::Router::new() .merge(proxies::routes()) .merge(templates::routes()) .merge(status::routes()) .merge(rights::routes()) - .merge(openapi::routes()) + .merge(openapi::routes()); + + if dashboard { + router = router.merge(dashboard::routes()); + } + + router // Without these two, a typo'd path answers 404 with an empty body and // a wrong method answers 405 with one. The contract is that every // error carries the envelope, and the paths a client is most likely diff --git a/crates/doppel-admin/tests/common/mod.rs b/crates/doppel-admin/tests/common/mod.rs index 8083a62..9e1cd6e 100644 --- a/crates/doppel-admin/tests/common/mod.rs +++ b/crates/doppel-admin/tests/common/mod.rs @@ -306,6 +306,12 @@ pub struct Reply { pub content_type: Option, pub allow: Option, pub body: String, + /// The body as it arrived. An embedded asset is not necessarily text -- the + /// favicon is not -- so `body` cannot be the only view of it. + pub bytes: Vec, + /// Every response header, lower-cased, for the assertions that are about a + /// header this struct has no field for. + pub headers: std::collections::HashMap, } impl Reply { @@ -322,6 +328,16 @@ impl Reply { let location = header("location"); let content_type = header("content-type"); let allow = header("allow"); + let headers = response + .headers() + .iter() + .filter_map(|(name, value)| { + value + .to_str() + .ok() + .map(|value| (name.as_str().to_owned(), value.to_owned())) + }) + .collect(); let bytes = axum::body::to_bytes(response.into_body(), 1 << 20) .await .expect("collect body"); @@ -331,10 +347,19 @@ impl Reply { location, content_type, allow, - body: String::from_utf8(bytes.to_vec()).expect("body is utf-8"), + // Lossy rather than strict: an asset response is bytes, and a test + // asserting on a header should not panic on the body's encoding. + body: String::from_utf8_lossy(&bytes).into_owned(), + bytes: bytes.to_vec(), + headers, } } + /// One response header, or `None`. + pub fn header(&self, name: &str) -> Option<&str> { + self.headers.get(name).map(String::as_str) + } + pub fn json(&self) -> serde_json::Value { serde_json::from_str(&self.body) .unwrap_or_else(|err| panic!("body is not JSON ({err}): {}", self.body)) diff --git a/crates/doppel-admin/tests/dashboard.rs b/crates/doppel-admin/tests/dashboard.rs new file mode 100644 index 0000000..5e8ecfa --- /dev/null +++ b/crates/doppel-admin/tests/dashboard.rs @@ -0,0 +1,286 @@ +//! The dashboard routes: the page, its assets, and `robots.txt`. + +mod common; + +use common::{BASE_CONFIG, Call, Harness}; + +/// Whether this binary carries the built dashboard. +/// +/// A source build with no `frontend/dist` has no assets to serve, and the tests +/// below that need real ones say so rather than failing for the wrong reason. +/// CI sets `DOPPEL_REQUIRE_DASHBOARD_ASSETS`, which +/// `the_assets_are_present_when_they_are_required` turns into a failure -- the +/// same arrangement `DOPPEL_REQUIRE_DATABASE` makes for the store suites, and for +/// the same reason: `cargo test` captures the output of passing tests, so a skip +/// notice reaches nobody. +fn built() -> bool { + doppel_admin::dashboard::is_built() +} + +/// The configuration element's contents, parsed. +fn injected(body: &str) -> serde_json::Value { + let at = body + .find("id=\"doppel-config\"") + .unwrap_or_else(|| panic!("no config element in the page:\n{body}")); + let open_ends = body[at..].find('>').expect("an unclosed opening tag") + at + 1; + let close = body[open_ends..].find("").expect("no closing tag") + open_ends; + serde_json::from_str(body[open_ends..close].trim()).unwrap_or_else(|err| { + panic!( + "the injected block is not JSON ({err}): {}", + &body[open_ends..close] + ) + }) +} + +#[tokio::test] +async fn the_assets_are_present_when_they_are_required() { + // Not a skip: this is the test that makes the others' skips visible. + if std::env::var_os("DOPPEL_REQUIRE_DASHBOARD_ASSETS").is_some() { + assert!( + built(), + "DOPPEL_REQUIRE_DASHBOARD_ASSETS is set and this binary has no embedded dashboard; \ + run `npm --prefix frontend ci && npm --prefix frontend run build` before `cargo test`" + ); + } +} + +#[tokio::test] +async fn the_root_serves_the_page_with_the_injected_configuration() { + if !built() { + return; + } + let harness = Harness::new(); + let reply = Call::get("/").send(harness.router()).await; + + assert_eq!(reply.status, 200); + assert_eq!( + reply.content_type.as_deref(), + Some("text/html; charset=utf-8") + ); + // Never cached: the document carries the configuration, so a cached copy + // would keep showing values from before the last reload. + assert_eq!(reply.header("cache-control"), Some("no-store")); + + let config = injected(&reply.body); + assert_eq!(config["title"], "Doppel"); + assert_eq!(config["public"], false); + assert_eq!(config["version"], env!("CARGO_PKG_VERSION")); + assert_eq!(config["authHeader"], "X-Proxy-Authorization"); + assert_eq!(config["refreshMs"], 60_000); +} + +#[tokio::test] +async fn the_page_reports_the_configured_title_and_auth_header() { + if !built() { + return; + } + let config = BASE_CONFIG + .replacen(" tokens:", " title: \"Doppel (staging)\"\n tokens:", 1) + .replacen( + " access:", + " auth:\n header: X-Admin-Token\n access:", + 1, + ); + let harness = Harness::with_config(&config); + let body = Call::get("/").send(harness.router()).await.body; + + let injected = injected(&body); + assert_eq!(injected["title"], "Doppel (staging)"); + assert_eq!(injected["authHeader"], "X-Admin-Token"); +} + +#[tokio::test] +async fn a_public_configuration_is_reported_to_the_page() { + if !built() { + return; + } + // The page uses this to decide whether to ask for a token at all, so getting + // it wrong means a public deployment showing a pointless dialog. + let config = BASE_CONFIG.replacen(" tokens:", " public: true\n tokens:", 1); + let harness = Harness::with_config(&config); + let body = Call::get("/").send(harness.router()).await.body; + + assert_eq!(injected(&body)["public"], true); +} + +#[tokio::test] +async fn a_hostile_title_cannot_close_the_script_element() { + if !built() { + return; + } + // `AdminTitle` accepts markup on purpose -- it is legal text -- so this is + // the place the escaping has to hold. Without it the title would end the + // JSON element early and the rest would be parsed as HTML, which is a + // scripting hole reachable by editing one configuration field. + let hostile = ""; + let config = BASE_CONFIG.replacen( + " tokens:", + &format!(" title: \"{hostile}\"\n tokens:"), + 1, + ); + let harness = Harness::with_config(&config); + let body = Call::get("/").send(harness.router()).await.body; + + // The title survives intact where it belongs... + assert_eq!(injected(&body)["title"], hostile); + // ...and the page contains exactly the script elements it is supposed to: + // the config block and the module. An injected one would make three. + assert_eq!( + body.matches(" u16 { match self { Self::ProxyNotResolved | Self::NotFound => 404, - Self::NoProxiesConfigured => 503, + // Both mean "this binary cannot serve that, and no request will change + // it": one because nothing is configured to proxy to, the other because + // the binary was built without the dashboard's static assets. + Self::NoProxiesConfigured | Self::DashboardNotBuilt => 503, Self::TemplateRenderError | Self::TemplateNotFound | Self::BodyExtractionError @@ -99,6 +103,7 @@ impl ErrorCode { Self::RevisionMismatch => "REVISION_MISMATCH", Self::RevisionRequired => "REVISION_REQUIRED", Self::InvalidRequestPath => "INVALID_REQUEST_PATH", + Self::DashboardNotBuilt => "DASHBOARD_NOT_BUILT", } } } @@ -136,6 +141,7 @@ impl<'de> Deserialize<'de> for ErrorCode { "REVISION_MISMATCH" => Ok(Self::RevisionMismatch), "REVISION_REQUIRED" => Ok(Self::RevisionRequired), "INVALID_REQUEST_PATH" => Ok(Self::InvalidRequestPath), + "DASHBOARD_NOT_BUILT" => Ok(Self::DashboardNotBuilt), other => Err(serde::de::Error::custom(format!( "unknown error code `{other}`" ))), @@ -224,6 +230,7 @@ mod tests { (ErrorCode::RevisionMismatch, "REVISION_MISMATCH", 409), (ErrorCode::RevisionRequired, "REVISION_REQUIRED", 428), (ErrorCode::InvalidRequestPath, "INVALID_REQUEST_PATH", 400), + (ErrorCode::DashboardNotBuilt, "DASHBOARD_NOT_BUILT", 503), ]; /// See `ALL_CODES`'s doc comment: this match's lack of a wildcard arm is @@ -249,7 +256,8 @@ mod tests { | ErrorCode::StoreError | ErrorCode::RevisionMismatch | ErrorCode::RevisionRequired - | ErrorCode::InvalidRequestPath => { + | ErrorCode::InvalidRequestPath + | ErrorCode::DashboardNotBuilt => { let count = ALL_CODES.iter().filter(|(c, _, _)| *c == code).count(); assert_eq!(count, 1, "{code:?} must appear exactly once in ALL_CODES"); } diff --git a/crates/doppel-store-postgres/src/load.rs b/crates/doppel-store-postgres/src/load.rs index 6091ce8..d630faa 100644 --- a/crates/doppel-store-postgres/src/load.rs +++ b/crates/doppel-store-postgres/src/load.rs @@ -55,7 +55,7 @@ impl PostgresStore { // The stored document omits `proxies`, which is a `#[serde(default)]` // field, so this yields a configuration with an empty proxy list and the // rows below fill it in. - let mut config: Config = document(&row, "settings")?; + let mut config: Config = document(&row, "settings", "settings")?; config.proxies = self.proxies(&mut tx).await?; // The stored revision has to agree with the content it labels. They @@ -95,7 +95,8 @@ impl PostgresStore { // it could not read (`missing field 'max'`) but not the row, and // "some proxy is malformed" is not a report anybody can act on. let keyed: String = row.try_get("name").map_err(query_failed)?; - let proxy: ProxyConfig = document(row, &format!("proxies[{keyed}].document"))?; + let proxy: ProxyConfig = + document(row, "document", &format!("proxies[{keyed}].document"))?; // The name is both a column and a field of the document, because the // column is the primary key and the field is what the configuration @@ -120,11 +121,15 @@ impl PostgresStore { /// A stored JSON document, parsed through the configuration's own types. /// -/// `label` is what the violation is reported under and is not always the column -/// name -- a proxy's document is reported as `proxies[alpha].document`, because -/// serde says which field it could not read but nothing about which row held it. -fn document(row: &PgRow, label: &str) -> Result { - let column = label.rsplit('.').next().unwrap_or(label); +/// `column` is where to read it; `label` is what a failure is reported under, and +/// the two are not the same thing -- a proxy's document lives in `document` and is +/// reported as `proxies[alpha].document`, because serde names the field it could +/// not read but nothing about which row held it. +fn document( + row: &PgRow, + column: &str, + label: &str, +) -> Result { let value: serde_json::Value = row.try_get(column).map_err(query_failed)?; serde_json::from_value(value).map_err(|err| corrupt(label, &err.to_string())) } diff --git a/docs/development/architecture.md b/docs/development/architecture.md index c297e4a..7cc886c 100644 --- a/docs/development/architecture.md +++ b/docs/development/architecture.md @@ -11,7 +11,7 @@ a network, rendering without HTTP, and the proxy logic without an admin API. | `doppel-core` | The configuration model and its types, YAML loading, validation, the `ConfigStore` trait and its file-backed implementation, the compiled runtime, the error model | | `doppel-render` | Variable extraction and Jinja2 rendering for mock responses; knows nothing about HTTP | | `doppel-proxy` | The proxy listener, resolution, fault injection, mock matching, upstream forwarding, the request pipeline | -| `doppel-admin` | The admin HTTP API: token access control, proxy CRUD, template files, reload, status, metrics, the OpenAPI document | +| `doppel-admin` | The admin HTTP API: token access control, proxy CRUD, template files, reload, status, metrics, the OpenAPI document, and the dashboard the build script embeds | | `doppel-store-postgres` | `PostgresStore`, and the sqlx migrations that own its schema | | `doppel-telemetry` | Logging initialization and optional Sentry | | `doppel-cli` | The `doppel` binary: argument parsing, the control channel, and wiring the rest together | @@ -82,7 +82,7 @@ a handler gets the configuration it judges by. `ConfigStore` is the boundary. `FileStore` serialises writers with an advisory lock on a sibling `.lock` file; `PostgresStore` uses a conditional `UPDATE` inside a transaction. Reads on the PostgreSQL side take one -`REPEATABLE READ` snapshot, because a configuration is five tables and a +`REPEATABLE READ` snapshot, because a configuration spans two tables and a per-statement snapshot would assemble one from two of them. One conformance suite runs against both, which is what keeps the trait's @@ -100,3 +100,63 @@ needs a token the operator may be trying to create. The socket is bound inside a private directory and published with a hard link, which is atomic *and* exclusive: a second bind fails loudly rather than silently taking over a live channel. + +## The dashboard + +A React application in `frontend/`, built by vite into `frontend/dist`, walked at +compile time by `crates/doppel-admin/build.rs` and embedded with `include_bytes!`. +The listener serves `index.html` at `/`, the hashed assets under `/static/`, and +`robots.txt`; the operator-facing half of this is in +[The dashboard](../usage/dashboard.md). + +Three decisions are worth knowing before changing any of it. + +**The assets are embedded, not served from disk.** So `npm run build` alone +changes nothing about a running binary -- the binary has to be rebuilt after it. +The build script watches `frontend/dist`, so `cargo build` does re-embed; the trap +is forgetting the `cargo build`. A binary compiled without `frontend/dist` +compiles fine and answers 503 at `/`, which keeps +`cargo install --path crates/doppel-cli` working on a machine with no Node. CI +sets `DOPPEL_REQUIRE_DASHBOARD_ASSETS` so that concession cannot silently apply +there, and the release refuses to publish a binary that lacks the page. + +**Settings reach the page through the HTML, not an endpoint.** The listener +substitutes `{title, public, version, authHeader, refreshMs}` into a +`application/json` script element per request. Every one of those is known when +the HTML is written, and a fetch would mean the page cannot draw its own header +until a round trip finishes. The JSON has its less-than characters escaped, which +is what makes a hostile `admin.title` a non-event and what allows a content +security policy with no `unsafe-inline`. + +**The page never decides what the caller may do.** `GET /api/v1/access` reports +that, evaluated by the same function that enforces it, so a control is disabled +because the server would have refused rather than because the page guessed. + +### Layers + +``` +frontend/src/ + components/ presentational; may not import a service + pages/ one screen each: fetches, holds its own state, renders components + services/ the only code that talks to the API + store/ state that outlives one screen (zustand) + types/ the API's shapes, and the one error type +``` + +The rule that a component may not reach a service is in `eslint.config.js`, not +only in review: the first component to grow a `fetch` becomes the one nothing can +be tested without. + +Tests split by what each tool is good for. jest covers services, stores and the +components that do not route; anything that navigates is covered by Playwright, +against a real browser and a real binary -- react-router ships ESM only and uses +`import.meta`, which cannot survive jest's transform to CommonJS. That split is +not a workaround for a build problem alone: the three bugs the suite found on its +first run were a stale rights subscription, a field name that disagreed with the +API, and a CommonJS interop failure that appeared only in the built bundle. None +were reachable from a unit test. + +`frontend/src/__tests__/bundle-size.test.ts` holds the payload to a measured +budget, and asserts that the entry chunk does not contain prism -- a size limit +alone would let the highlighter hide in the entry's slack, and then every visitor +pays for it to list proxies. diff --git a/docs/index.md b/docs/index.md index a023c55..bc9f764 100644 --- a/docs/index.md +++ b/docs/index.md @@ -35,6 +35,9 @@ including verifying a download and the macOS quarantine case, are on - **Serves an admin API** -- proxy CRUD, template upload, reload, status, Prometheus metrics and Swagger UI -- behind token access control. See [Admin API](usage/admin-api.md). +- **Serves a dashboard** on that same port's root, compiled into the binary: + the proxy set, a form over the whole proxy document, mock templates, status and + reload. See [The dashboard](usage/dashboard.md). - **Reports to Sentry**, optionally, behind a cargo feature. - **Stores its configuration** in a YAML file or in PostgreSQL, with `config push` and `config pull` to move between them. See @@ -74,7 +77,9 @@ the six words the rest of it leans on and shows how a request is handled. 6. [Changing configuration while it runs](usage/runtime-changes.md) -- reloads, tokens, and editing one proxy over HTTP. 7. [The admin API](usage/admin-api.md) and - [PostgreSQL](usage/storage.md) for anything running in more than one place. + [the dashboard](usage/dashboard.md) -- driving all of the above over HTTP, or + in a browser. +8. [PostgreSQL](usage/storage.md) for anything running in more than one place. with the [configuration reference](usage/configuration.md) and the [CLI reference](usage/cli.md) at the end for looking things up. diff --git a/docs/usage/admin-api.md b/docs/usage/admin-api.md index 7b2d15c..23f9ab9 100644 --- a/docs/usage/admin-api.md +++ b/docs/usage/admin-api.md @@ -126,6 +126,7 @@ would not help. | POST | `/api/v1/proxies/{name}/templates/{file}` | `upload` | `204` | | DELETE | `/api/v1/proxies/{name}/templates/{file}` | `upload` | `204` | | POST | `/api/v1/config/reload` | `update` | `200` | +| GET | `/api/v1/access` | none | `200` | | GET | `/status` | none | `200` | | GET | `/metrics` | none | `200` | | GET | `/openapi.json` | none | `200` | @@ -135,6 +136,44 @@ would not help. `/api/v1` because they are not resources of the API; they describe or observe the process. +The dashboard adds three more routes -- `/`, `/static/{path}` and `/robots.txt` -- +when `admin.dashboard` is on. They serve a browser page rather than the API, so +they are outside the OpenAPI document; see [The dashboard](dashboard.md). + +### What the caller may do + +`GET /api/v1/access` reports the calling token's own rights. It answers `200` for +everybody, anonymous included: an endpoint whose purpose is to say "you may do +nothing" cannot itself require a right, and it discloses nothing that attempting +the six actions would not. + +```json +{ + "caller": { "kind": "token", "name": "ci", "group": "ops" }, + "global": { + "list": true, "read": true, "create": false, + "update": false, "delete": false, "upload": false + }, + "proxies": { + "alpha": { "read": true, "update": false, "delete": false, "upload": false } + } +} +``` + +- `caller` is `{"kind": "anonymous"}` for a request with no token, or with one + this configuration does not recognise -- the two are deliberately not + distinguished. +- `global` is the six actions as `admin.access` decides them for this caller. +- `proxies` gives the four overridable actions per proxy, with that proxy's own + `access` block applied. It is **absent** -- not empty -- when the caller may not + `list`: the map is keyed by proxy name, so returning it would be a proxy listing + by another route. + +Every value is the same decision the endpoint itself would make, evaluated by the +same code. It exists so a client can disable an action instead of offering it and +being refused; it is not a substitute for handling `401` and `403`, since rights +can change under a running page. + ### Bodies Create and update take the same shape, so what a client reads is what it sends @@ -244,6 +283,7 @@ and an oversized body all carry the envelope rather than an empty body. | `METHOD_NOT_ALLOWED` | 405 | The path exists and does not accept that verb; the response also carries `Allow` | | `PROXY_NOT_RESOLVED` | 404 | No proxy matched and there is no default | | `NO_PROXIES_CONFIGURED` | 503 | The configuration names no proxies at all. See [No proxies configured](proxying.md#no-proxies-configured) | +| `DASHBOARD_NOT_BUILT` | 503 | `GET /` on a binary compiled without the dashboard's assets. See [When the root answers 503](dashboard.md#when-the-root-answers-503) | | `CONFLICT` | 409 | The name exists, or the store is under sustained contention | | `REVISION_MISMATCH` | 409 | The proxy changed since it was read | | `UPLOAD_TOO_LARGE` | 413 | A template body over `admin.upload.limit`, or a configuration document over 1 MiB | diff --git a/docs/usage/configuration.md b/docs/usage/configuration.md index d894bcf..3d989d9 100644 --- a/docs/usage/configuration.md +++ b/docs/usage/configuration.md @@ -115,6 +115,8 @@ The admin listener's address, its tokens, and who may do what. ```yaml admin: enable: true + dashboard: true + title: "Doppel" host: "0.0.0.0" port: 8081 public: false @@ -147,6 +149,14 @@ because nothing serves it is a trap set for whoever turns the listener on later, and they will not re-read the rules first -- so rule V34 still refuses a public write action with the listener off. +`dashboard` defaults to `true` and serves the browser dashboard from this +listener's root: `/`, `/static/{path}` and `/robots.txt`. `false` leaves those +three unrouted -- they answer 404 like any other unknown path -- and changes +nothing about the JSON API. `title` is the heading that dashboard shows and the +browser tab's name, at most 64 characters and free of control characters, +defaulting to `Doppel`. Both take effect on restart, since the routes are built +once. See [The dashboard](dashboard.md). + Toggling it takes effect on restart, not on reload; a reload reports `admin` among the sections it could not apply. diff --git a/docs/usage/dashboard.md b/docs/usage/dashboard.md new file mode 100644 index 0000000..93268e7 --- /dev/null +++ b/docs/usage/dashboard.md @@ -0,0 +1,160 @@ +# The dashboard + +The admin listener serves a browser dashboard from its own root. It lists the +proxies, edits them, writes mock templates, shows what the process is doing, and +reloads the configuration -- everything the admin API does, without curl. + +It is on by default. Open the admin port in a browser: + +``` +http://127.0.0.1:8081/ +``` + +The whole thing is compiled into the binary. There is nothing to deploy, no +directory to serve, and no version of the page that can disagree with the version +of Doppel serving it. + +--- + +## Turning it on and off + +```yaml +admin: + dashboard: true # the default + title: "Doppel" # the default +``` + +`dashboard: false` leaves `/`, `/static/*` and `/robots.txt` unrouted -- they +answer 404 like any other unknown path -- and changes nothing about the JSON API. +Use it when the admin port is reachable from somewhere a browser page has no +business being. To turn off the admin API entirely, that is `admin.enable`, not +this. + +Both fields take effect on restart, because the routes are built once at startup. + +`title` is what the header shows and what the browser tab is called. It is worth +setting when several Doppels are open at once: + +```yaml +admin: + title: "billing-api (staging)" +``` + +At most 64 characters, no control characters, and any language you like -- it is a +heading, not an identifier. + +--- + +## Who can use it + +The dashboard is a client of the admin API and is bound by exactly the same +`access` rules. It has no privileges of its own, and there is no dashboard +password: the token is the one from `admin.tokens`. + +What follows from that is worth stating plainly, because it surprises people: + +**The page works without a token.** If `access.list` and `access.read` are +`public`, an anonymous visitor sees the proxy list and can open a proxy. The token +dialog opens once, and it has a "Continue without a token" button -- declining +leaves a working page rather than a wall. + +**Actions the caller may not perform are visible but disabled**, with the reason +in the tooltip. The page asks the server what the caller may do +(`GET /api/v1/access`) rather than guessing, so a disabled button means the server +would have refused. A missing button reads as a broken page; a disabled one +explains itself. + +**Signing out returns to the anonymous view.** It does not lock the page. + +### The token in the browser + +Entering a token stores it in `localStorage` for one hour, then the page forgets +it and asks again. Two things that is not: + +- **Not a session.** The token does not expire on the server. Forgetting it here + only means this browser stops presenting it. Revoking a token is a + configuration change -- edit `admin.tokens` and reload. +- **Not a security boundary.** Anything running in the page can read + `localStorage`. The hour exists so an unattended browser stops holding a working + admin token indefinitely, which is a smaller and more honest claim. + +On a `public: true` deployment the page never mentions tokens at all. + +--- + +## What the pages do + +**Proxies** lists every proxy with its upstream, how it is resolved, the faults +configured on it and how many mocks it has. The list refetches itself once a +minute -- so a change made by another operator, by `doppel config push`, or by a +reload appears without anyone pressing anything. It pauses while the tab is +hidden. + +Editing opens a form over the whole proxy document: the upstream, timeouts, body +limits, resolution, injected headers, the three fault settings, per-proxy access +overrides, and the mocks with their matching rules and responses. Saving sends the +revision the form was loaded at, so a proxy someone else changed in the meantime +is refused rather than overwritten -- the page says so and asks you to reload. + +**Templates**, per proxy, lists the template files and lets you write one: a name, +one of `json.j2`, `html.j2` or `text.j2`, and the content, with syntax colouring. +There is no file upload; the content is typed or pasted, which is what the API has +always taken. + +One rule the server enforces and the page cannot: a template file has to be +declared by one of the proxy's mocks before it may be written. Add the mock first, +then the file. + +**Status** shows uptime, the configuration revision in effect and the resolution +state of each proxy, and carries the reload button. Reload re-reads the store and +reports what it applied, including the sections that need a restart instead. + +--- + +## Theme + +Light, dark, or whatever the operating system says. The default is the last of +those, and the choice is remembered per browser. + +--- + +## Not indexed + +The dashboard refuses search engines three ways, because crawlers fail +differently: an `X-Robots-Tag: noindex, nofollow, noarchive` header on every +response including the errors, a `robots` meta element in the page for a crawler +that reads markup and ignores headers, and `robots.txt` disallowing everything. + +The page also carries a content security policy that permits scripts, styles and +connections from this origin only, and forbids framing. It needs no +`unsafe-inline`: the configuration reaches the page as the contents of a JSON +element rather than as an inline script. + +--- + +## When the root answers 503 + +```json +{ + "status": "error", + "code": "DASHBOARD_NOT_BUILT", + "message": "this binary was built without the dashboard's static assets ..." +} +``` + +The binary was compiled without `frontend/dist`. Released binaries and the +published image always carry the dashboard -- the release refuses to publish one +that does not -- so this is a binary built from source without building the +frontend first: + +```bash +npm --prefix frontend ci +npm --prefix frontend run build +cargo build --release -p doppel-cli +``` + +That order matters: the assets are embedded at compile time, so rebuilding them +without rebuilding the binary changes nothing about what it serves. + +If Node is not available and the JSON API is enough, `admin.dashboard: false` +stops the route being served at all, which is a clearer answer than a 503. diff --git a/docs/usage/getting-started.md b/docs/usage/getting-started.md index 8b8efac..bb508a7 100644 --- a/docs/usage/getting-started.md +++ b/docs/usage/getting-started.md @@ -123,3 +123,16 @@ forwarding it: Everything else still goes upstream. See [Mocks and templating](mocks.md) for variables, templates, and the ordering rule that matters once you have more than one mock. + +## Or do all of that in a browser + +The admin port serves a dashboard from its root: + +``` +http://127.0.0.1:8081/ +``` + +The proxy set, a form over every field of a proxy, the mock templates, and the +reload button -- the same admin API this page drives with curl, with the same +token rules. `admin.dashboard: false` turns it off. See +[The dashboard](dashboard.md). diff --git a/docs/usage/storage.md b/docs/usage/storage.md index 551bf30..3de20fb 100644 --- a/docs/usage/storage.md +++ b/docs/usage/storage.md @@ -99,25 +99,42 @@ release, migrations are append-only. ### The schema -Five tables. Deliberately between one opaque blob and full normalisation: -**anything with its own identity and lifecycle is a row, and a leaf map -hanging off one is a JSONB column on that row.** +Three tables. **Anything SQL actually uses is a column; everything else is the +document, as JSON.** | Table | Holds | |---|---| -| `configurations` | One row per named configuration: the revision, and the settings that are single values | -| `admin_tokens` | One row per token | -| `proxies` | One row per proxy; `headers` and `access` are JSONB on it | -| `mocks` | One row per mock; the selector maps and response headers are JSONB on it | +| `configurations` | One row per named configuration: `name`, `revision`, and `settings` -- the whole document except its proxies | +| `proxies` | One row per proxy: `name`, `ordinal`, and `document` -- the proxy with its mocks inside | | `templates` | One row per template file, content included | -A blob would make the row opaque, and rewriting a whole document to change one -field throws away the per-proxy concurrency the admin API is built on. A table -per leaf map would buy joins nobody will write. +It was five tables with a column per configuration field, and the change is +worth explaining because it looks like a step backwards. -`ordinal` columns preserve document order. That is not decoration: mock -patterns are unanchored, so a general one placed before a specific one shadows -it, and a store that reordered them would change which mock answers a request. +Nothing ever queried `admin_host` or `latency_min` in SQL. What that +normalisation bought was a migration for every field the configuration format +gained -- three of them between 0.3.0 and 0.4.1, each one `ADD COLUMN` and +nothing else, plus a matching edit in the loader, the writer, and two +hand-written statements that a test existed solely to keep in step. A field +added to the format now reaches the database with no schema change at all. + +What did *not* change is the part that matters: a proxy is still its own row, +addressed by name and carrying its own revision, so the per-proxy concurrency +the admin API is built on is untouched. A single blob per configuration would +have thrown that away. + +The rows are parsed by the same code that parses `main.yaml`, so a row edited by +hand is held to exactly the rules a configuration file is held to -- a value the +format refuses is refused here too, reported against the field and the proxy that +carry it. + +`ordinal` preserves document order. That is not decoration: mock patterns are +unanchored, so a general one placed before a specific one shadows it, and a store +that reordered them would change which mock answers a request. + +One thing moved rather than disappeared. The old schema had a unique index on +`(config, token)`, making duplicate token values impossible in the database; rule +V26 refuses them on every write instead, and every write goes through validation. `templates` has no cascading foreign key on purpose. Deleting a proxy's files is a decision the store makes explicitly and *after* the configuration write -- @@ -148,7 +165,7 @@ second writer that blocks on it re-evaluates its own `WHERE` against the row as the winner left it -- finds the revision moved, matches nothing, and is told it lost. -A load is also one transaction, at `REPEATABLE READ`. A configuration is five +A load is also one transaction, at `REPEATABLE READ`. A configuration spans two tables, and each query used to take its own connection and its own snapshot, so a save committing in between produced a configuration assembled from two of them. The revision check at the end of a load caught it and reported "the rows diff --git a/docs/usage/troubleshooting.md b/docs/usage/troubleshooting.md index a8fbd77..90b4070 100644 --- a/docs/usage/troubleshooting.md +++ b/docs/usage/troubleshooting.md @@ -158,3 +158,33 @@ remove it from `DOPPEL_ADMIN_TOKENS`. Anything not covered here is worth an issue on the [tracker](https://github.com/lorem-dev/doppel/issues). + +## The dashboard + +### The admin root answers 503 + +`DASHBOARD_NOT_BUILT`. This binary was compiled without the dashboard's static +assets -- a source build that skipped the frontend. Released binaries and the +published image always carry it. + +```bash +npm --prefix frontend ci && npm --prefix frontend run build +cargo build --release -p doppel-cli +``` + +The order is the whole answer: the assets are embedded at compile time, so +building them without rebuilding the binary changes nothing about what it serves. +If Node is not available and the JSON API is enough, `admin.dashboard: false` +stops the route being served at all, which is a clearer answer than a 503. + +### The page loads but every button is disabled + +The caller has no rights to spend. The dashboard asks `GET /api/v1/access` what it +may do and disables what the server would refuse; the tooltip on each control says +so. Enter a token, or check `admin.access` -- the same two causes as a 401 above, +since it is the same decision. + +### A change made elsewhere is not on screen + +It will be within a minute: the list refetches on a timer, and the timer is paused +while the tab is hidden. Switching back to the tab refetches immediately. diff --git a/frontend/e2e/src/configs.ts b/frontend/e2e/src/configs.ts new file mode 100644 index 0000000..9e56c07 --- /dev/null +++ b/frontend/e2e/src/configs.ts @@ -0,0 +1,105 @@ +// The configurations the specs run against. +// +// Ports and the template directory are placeholders the harness fills in, so two +// spec files running at once cannot collide. + +export const ROOT_TOKEN = 'root-token-0000000000000000000000000' +export const READER_TOKEN = 'reader-token-00000000000000000000000' + +/** Tokens for both a full-rights caller and a read-only one. */ +const TOKENS = ` + tokens: + - name: root + group: admin + token: ${ROOT_TOKEN} + - name: reader + group: user + token: ${READER_TOKEN} +` + +const PROXIES = ` +proxies: + - name: alpha + type: http + url: "https://alpha.example.com/api/" + - name: beta + type: http + url: "https://beta.example.com/api/" + # Resolved by header, because only one proxy may be the default one -- rule + # V12, and a fixture that broke it would fail every spec at startup. + resolve: + type: header + header: X-Proxy-Name +` + +/** Reads are public, writes need `admin`: the shape most deployments have. */ +export const PRIVATE_CONFIG = ` +server: + host: "127.0.0.1" + port: {proxyPort} +admin: + host: "127.0.0.1" + port: {adminPort} + title: "Doppel (e2e)" +${TOKENS} + access: + list: public + read: public + create: ["admin"] + update: ["admin"] + delete: ["admin"] + upload: ["admin"] + upload: + limit: 1Mi +control: + socket: {controlSocket} +templates: + dir: {templatesDir} +${PROXIES} +` + +/** Everything unauthenticated: the page must not ask for a token at all. */ +export const PUBLIC_CONFIG = ` +server: + host: "127.0.0.1" + port: {proxyPort} +admin: + host: "127.0.0.1" + port: {adminPort} + public: true + tokens: [] + access: {} + upload: + limit: 1Mi +control: + socket: {controlSocket} +templates: + dir: {templatesDir} +${PROXIES} +` + +/** The dashboard turned off: the API stays, the three routes go. */ +export const DASHBOARD_OFF_CONFIG = ` +server: + host: "127.0.0.1" + port: {proxyPort} +admin: + host: "127.0.0.1" + port: {adminPort} + dashboard: false +${TOKENS} + access: + list: public + read: public + create: ["admin"] + update: ["admin"] + delete: ["admin"] + upload: ["admin"] + upload: + limit: 1Mi +control: + socket: {controlSocket} +templates: + dir: {templatesDir} +${PROXIES} +` diff --git a/frontend/e2e/src/server.ts b/frontend/e2e/src/server.ts new file mode 100644 index 0000000..86fcd57 --- /dev/null +++ b/frontend/e2e/src/server.ts @@ -0,0 +1,101 @@ +// A real Doppel, per spec file. +// +// Not Playwright's `webServer`: that starts one process for the whole run, and +// these specs need different configurations -- public, private, dashboard off -- +// which is a property of the server rather than of the page. Each file starts its +// own on ports nobody else is using. + +import { spawn, type ChildProcess } from 'node:child_process' +import { createServer } from 'node:net' +import { mkdtempSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' + +export interface Doppel { + /** Where the admin listener, and therefore the dashboard, answers. */ + baseURL: string + stop: () => void +} + +/** A port the operating system has just confirmed is free. */ +async function freePort(): Promise { + return new Promise((accept, reject) => { + const probe = createServer() + probe.once('error', reject) + probe.listen(0, '127.0.0.1', () => { + const address = probe.address() + if (typeof address === 'string' || address === null) { + reject(new Error('no port')) + return + } + const { port } = address + probe.close(() => accept(port)) + }) + }) +} + +/** + * Start Doppel with `yaml`, with `{proxyPort}`, `{adminPort}` and + * `{templatesDir}` substituted. + * + * The binary comes from `DOPPEL_BIN` or `target/debug/doppel`, and it has to have + * been built with `frontend/dist` in place -- otherwise `/` answers 503 and every + * spec here fails for the same uninformative reason, which is why the wait below + * checks the dashboard rather than only the API. + */ +export async function startDoppel(yaml: string): Promise { + const dir = mkdtempSync(join(tmpdir(), 'doppel-e2e-')) + const proxyPort = await freePort() + const adminPort = await freePort() + const templatesDir = join(dir, 'templates') + + const configPath = join(dir, 'main.yaml') + writeFileSync( + configPath, + yaml + .replaceAll('{proxyPort}', String(proxyPort)) + .replaceAll('{adminPort}', String(adminPort)) + .replaceAll('{templatesDir}', templatesDir) + // Short, and outside the temp directory: a unix socket path is capped near + // 104 bytes on macOS and the temp directory alone can spend most of that. + // Per-instance because the default is one path for every Doppel on the + // machine, and spec files run in parallel -- the second server would exit + // 1 with the first still holding the socket. + .replaceAll('{controlSocket}', `/tmp/doppel-e2e-${adminPort}.sock`), + ) + + const binary = process.env.DOPPEL_BIN ?? resolve(import.meta.dirname, '../../../target/debug/doppel') + const child: ChildProcess = spawn(binary, ['serve', '--config', configPath], { + stdio: ['ignore', 'pipe', 'pipe'], + }) + + // Kept, and printed only when the wait fails: a server that refused its own + // configuration says why on stderr, and without this the failure is a timeout + // with no cause. + let log = '' + child.stdout?.on('data', (chunk: Buffer) => (log += chunk.toString())) + child.stderr?.on('data', (chunk: Buffer) => (log += chunk.toString())) + + const baseURL = `http://127.0.0.1:${adminPort}` + const stop = () => child.kill('SIGTERM') + + const deadline = Date.now() + 20_000 + for (;;) { + if (child.exitCode !== null) { + throw new Error(`doppel exited with ${child.exitCode}:\n${log}`) + } + try { + const response = await fetch(`${baseURL}/status`) + if (response.ok) { + return { baseURL, stop } + } + } catch { + // Not up yet. + } + if (Date.now() > deadline) { + stop() + throw new Error(`doppel did not start within 20s:\n${log}`) + } + await new Promise((accept) => setTimeout(accept, 100)) + } +} diff --git a/frontend/e2e/tests/auth.spec.ts b/frontend/e2e/tests/auth.spec.ts new file mode 100644 index 0000000..bc3ab75 --- /dev/null +++ b/frontend/e2e/tests/auth.spec.ts @@ -0,0 +1,82 @@ +import { expect, test } from '@playwright/test' + +import { PRIVATE_CONFIG, PUBLIC_CONFIG, ROOT_TOKEN } from '../src/configs' +import { startDoppel, type Doppel } from '../src/server' + +test.describe('a private configuration', () => { + let doppel: Doppel + test.beforeAll(async () => { + doppel = await startDoppel(PRIVATE_CONFIG) + }) + test.afterAll(() => doppel.stop()) + + test('asks for a token, and takes no for an answer', async ({ page }) => { + await page.goto(doppel.baseURL) + const dialog = page.getByRole('form', { name: 'Admin token' }) + await expect(dialog).toBeVisible() + + await page.getByRole('button', { name: 'Continue without a token' }).click() + await expect(dialog).toBeHidden() + + // `list` and `read` are public here, so declining leaves a working page + // rather than an empty one. + await expect(page.getByRole('cell', { name: 'alpha', exact: true })).toBeVisible() + // ...and the writes are visibly unavailable rather than absent. + await expect(page.getByRole('button', { name: 'Add a proxy' })).toBeDisabled() + }) + + test('does not ask again after being declined once', async ({ page }) => { + await page.goto(doppel.baseURL) + await page.getByRole('button', { name: 'Continue without a token' }).click() + await page.reload() + await expect(page.getByRole('form', { name: 'Admin token' })).toBeHidden() + }) + + test('enables the writes once a token is given, and names the caller', async ({ page }) => { + await page.goto(doppel.baseURL) + await page.getByRole('textbox', { name: 'Token' }).fill(ROOT_TOKEN) + await page.getByRole('button', { name: 'Use this token' }).click() + + await expect(page.getByText('root (admin)')).toBeVisible() + await expect(page.getByRole('button', { name: 'Add a proxy' })).toBeEnabled() + }) + + test('a wrong token leaves the writes disabled', async ({ page }) => { + await page.goto(doppel.baseURL) + await page.getByRole('textbox', { name: 'Token' }).fill('wrong-token-000000000000000000000000') + await page.getByRole('button', { name: 'Use this token' }).click() + + // An unrecognised token is anonymous, deliberately: telling it apart from a + // recognised one would confirm which tokens exist. + await expect(page.getByRole('button', { name: 'Add a proxy' })).toBeDisabled() + }) + + test('signing out returns to the anonymous view without blocking the page', async ({ page }) => { + await page.goto(doppel.baseURL) + await page.getByRole('textbox', { name: 'Token' }).fill(ROOT_TOKEN) + await page.getByRole('button', { name: 'Use this token' }).click() + await expect(page.getByRole('button', { name: 'Add a proxy' })).toBeEnabled() + + await page.getByRole('button', { name: 'Sign out' }).click() + await expect(page.getByRole('button', { name: 'Add a proxy' })).toBeDisabled() + // Not a wall: what is public is still on screen. + await expect(page.getByRole('cell', { name: 'alpha', exact: true })).toBeVisible() + await expect(page.getByRole('form', { name: 'Admin token' })).toBeHidden() + }) +}) + +test.describe('a public configuration', () => { + let doppel: Doppel + test.beforeAll(async () => { + doppel = await startDoppel(PUBLIC_CONFIG) + }) + test.afterAll(() => doppel.stop()) + + test('never mentions tokens', async ({ page }) => { + await page.goto(doppel.baseURL) + await expect(page.getByRole('form', { name: 'Admin token' })).toBeHidden() + await expect(page.getByRole('button', { name: 'Sign in' })).toBeHidden() + // And everything is permitted, because it is. + await expect(page.getByRole('button', { name: 'Add a proxy' })).toBeEnabled() + }) +}) diff --git a/frontend/e2e/tests/chrome.spec.ts b/frontend/e2e/tests/chrome.spec.ts new file mode 100644 index 0000000..5493a38 --- /dev/null +++ b/frontend/e2e/tests/chrome.spec.ts @@ -0,0 +1,59 @@ +import { expect, test } from '@playwright/test' + +import { PRIVATE_CONFIG } from '../src/configs' +import { startDoppel, type Doppel } from '../src/server' + +let doppel: Doppel + +test.beforeAll(async () => { + doppel = await startDoppel(PRIVATE_CONFIG) +}) +test.afterAll(() => doppel.stop()) + +test('the header shows admin.title and the tab takes it too', async ({ page }) => { + await page.goto(doppel.baseURL) + await expect(page.getByRole('heading', { name: 'Doppel (e2e)' })).toBeVisible() + await expect(page).toHaveTitle('Doppel (e2e)') +}) + +test('the footer names the copyright and the version of the binary', async ({ page }) => { + await page.goto(doppel.baseURL) + // The version comes from the running process, so this also catches a page + // served by a binary other than the one under test. + await expect(page.getByText(/\(c\) 2026 Lorem Dev/)).toBeVisible() + await expect(page.getByText(/Doppel \d+\.\d+\.\d+/)).toBeVisible() +}) + +test('the theme choice survives a reload', async ({ page }) => { + await page.goto(doppel.baseURL) + await page.getByLabel('Theme').selectOption('dark') + await expect(page.locator('html')).toHaveClass(/dark/) + + await page.reload() + // Read from localStorage on the way back up: a choice that did not survive a + // reload would be a choice nobody bothers making twice. + await expect(page.getByLabel('Theme')).toHaveValue('dark') + await expect(page.locator('html')).toHaveClass(/dark/) +}) + +test('the dashboard refuses to be indexed', async ({ page }) => { + const response = await page.goto(doppel.baseURL) + expect(response?.headers()['x-robots-tag']).toContain('noindex') + + const robots = await page.request.get(`${doppel.baseURL}/robots.txt`) + expect(await robots.text()).toContain('Disallow: /') +}) + +test('navigation is real: a proxy has its own URL and the back button works', async ({ page }) => { + // The reason react-router is here rather than a state variable. + await page.goto(doppel.baseURL) + // The dialog is a fixed overlay, so it has to be out of the way before anything + // underneath it can be clicked -- which is also what an operator does. + await page.getByRole('button', { name: 'Continue without a token' }).click() + await page.getByRole('link', { name: 'alpha' }).click() + await expect(page).toHaveURL(/\/proxies\/alpha$/) + await expect(page.getByRole('heading', { name: 'Edit alpha' })).toBeVisible() + + await page.goBack() + await expect(page.getByRole('heading', { name: 'Proxies' })).toBeVisible() +}) diff --git a/frontend/e2e/tests/off.spec.ts b/frontend/e2e/tests/off.spec.ts new file mode 100644 index 0000000..78d7c4a --- /dev/null +++ b/frontend/e2e/tests/off.spec.ts @@ -0,0 +1,27 @@ +import { expect, test } from '@playwright/test' + +import { DASHBOARD_OFF_CONFIG } from '../src/configs' +import { startDoppel, type Doppel } from '../src/server' + +let doppel: Doppel + +test.beforeAll(async () => { + doppel = await startDoppel(DASHBOARD_OFF_CONFIG) +}) +test.afterAll(() => doppel.stop()) + +test('admin.dashboard: false leaves the three routes unrouted', async ({ page }) => { + for (const path of ['/', '/robots.txt', '/static/anything.js']) { + const response = await page.request.get(`${doppel.baseURL}${path}`) + expect(response.status(), path).toBe(404) + expect((await response.json()).code, path).toBe('NOT_FOUND') + } +}) + +test('the JSON API is untouched', async ({ page }) => { + // The flag is about the dashboard, not about the listener: `admin.enable` is + // the one that turns the whole thing off. + const response = await page.request.get(`${doppel.baseURL}/api/v1/proxies`) + expect(response.status()).toBe(200) + expect((await response.json()).proxies).toHaveLength(2) +}) diff --git a/frontend/e2e/tests/proxies.spec.ts b/frontend/e2e/tests/proxies.spec.ts new file mode 100644 index 0000000..8697bf4 --- /dev/null +++ b/frontend/e2e/tests/proxies.spec.ts @@ -0,0 +1,140 @@ +import { expect, test } from '@playwright/test' + +import { PRIVATE_CONFIG, READER_TOKEN, ROOT_TOKEN } from '../src/configs' +import { startDoppel, type Doppel } from '../src/server' + +let doppel: Doppel + +test.beforeAll(async () => { + doppel = await startDoppel(PRIVATE_CONFIG) +}) +test.afterAll(() => doppel.stop()) + +/** Arrive holding a token, without going through the dialog every time. */ +async function signedIn( + page: import('@playwright/test').Page, + token = ROOT_TOKEN, + caller = 'root (admin)', +) { + await page.goto(doppel.baseURL) + await page.getByRole('textbox', { name: 'Token' }).fill(token) + await page.getByRole('button', { name: 'Use this token' }).click() + // Waiting for the caller's name rather than for the table: the table is there + // anonymously too, so it proves nothing about the token having been accepted, + // and every later assertion about a control's state depends on the rights + // report having arrived. + await expect(page.getByText(caller)).toBeVisible() +} + +test('the list shows what each proxy does', async ({ page }) => { + await signedIn(page) + const row = page.getByRole('row', { name: /alpha/ }) + await expect(row).toContainText('https://alpha.example.com/api/') + await expect(row).toContainText('default') +}) + +test('a proxy can be created, edited and deleted', async ({ page }) => { + await signedIn(page) + + await page.getByRole('button', { name: 'Add a proxy' }).click() + await page.getByLabel('Name').fill('gamma') + await page.getByLabel('Upstream URL').fill('https://gamma.example.com/') + // Resolved by header, because `alpha` is already the default proxy and rule V12 + // allows only one. Leaving this alone is how an operator meets that rule for + // the first time -- as a refusal from the server, which is the next test. + await page.getByLabel('Resolve by').selectOption('header') + await page.getByLabel('Header name').fill('X-Proxy-Name') + await page.getByRole('button', { name: 'Create proxy' }).click() + await expect(page.getByRole('cell', { name: 'gamma', exact: true })).toBeVisible() + + await page.getByRole('link', { name: 'gamma' }).click() + await page.getByLabel('Upstream URL').fill('https://moved.example.com/') + await page.getByRole('button', { name: 'Save changes' }).click() + await expect(page.getByRole('row', { name: /gamma/ })).toContainText('moved.example.com') + + await page.getByRole('row', { name: /gamma/ }).getByRole('button', { name: 'Delete' }).click() + await page.getByRole('dialog').getByRole('button', { name: 'Delete' }).click() + await expect(page.getByRole('cell', { name: 'gamma', exact: true })).toBeHidden() +}) + +test("a rejected document lands on the field the server complained about", async ({ page }) => { + await signedIn(page) + await page.getByRole('button', { name: 'Add a proxy' }).click() + await page.getByLabel('Name').fill('bad') + // Not a URL Doppel accepts as an upstream. The server is the arbiter of that, + // and its complaint has to arrive under the field it is about rather than only + // in the banner. + await page.getByLabel('Upstream URL').fill('not-a-url') + await page.getByRole('button', { name: 'Create proxy' }).click() + + // Under the field, not merely somewhere on the page. Asserting on the banner + // alone is what let a parser that never matched anything pass: the banner + // carries the same words. + const field = page.getByText('Upstream URL').locator('..') + await expect(field.getByRole('alert')).toContainText('must be absolute') + // And nothing was created. + await page.goto(doppel.baseURL) + await expect(page.getByRole('cell', { name: 'bad', exact: true })).toBeHidden() +}) + +test('a read-only token sees the writes disabled with the reason', async ({ page }) => { + await signedIn(page, READER_TOKEN, 'reader (user)') + + const create = page.getByRole('button', { name: 'Add a proxy' }) + await expect(create).toBeDisabled() + // Disabled rather than hidden, and it says why: a missing button reads as a + // broken page. + await expect(create).toHaveAttribute('title', /may not create/) + + const remove = page.getByRole('row', { name: /alpha/ }).getByRole('button', { name: 'Delete' }) + await expect(remove).toBeDisabled() +}) + +test('a template is written from the page and listed afterwards', async ({ page }) => { + await signedIn(page) + + // A template file has to be declared by a mock before it may be uploaded -- + // the server answers 422 TEMPLATE_NOT_DECLARED otherwise. Declared through the + // API rather than the form, so this spec stays about templates. + const read = await page.request.get(`${doppel.baseURL}/api/v1/proxies/alpha`, { + headers: { 'X-Proxy-Authorization': `Bearer ${ROOT_TOKEN}` }, + }) + const { revision, proxy } = (await read.json()) as { + revision: string + proxy: Record + } + const declared = await page.request.put(`${doppel.baseURL}/api/v1/proxies/alpha`, { + headers: { + 'X-Proxy-Authorization': `Bearer ${ROOT_TOKEN}`, + 'If-Match': `"${revision}"`, + }, + data: { + revision, + proxy: { + ...proxy, + mocks: [ + { + name: 'rendered', + request: { method: 'GET', url: '^/rendered$' }, + response: { status: 200, template: 'page.json.j2' }, + }, + ], + }, + }, + }) + expect(declared.status()).toBe(200) + + await page.reload() + await page.getByRole('row', { name: /alpha/ }).getByRole('link', { name: 'Templates' }).click() + + await page.getByLabel('File name').fill('page') + await page.getByLabel('Kind').selectOption('json.j2') + // The editor is a lazy chunk, so it arrives after the page does. + const editor = page.getByLabel('Contents of page.json.j2') + await editor.fill('{"ok": true}') + await page.getByRole('button', { name: /Save page\.json\.j2/ }).click() + + // The file's own row: the editor's label and the save button both mention the + // name, so the assertion has to be about the table. + await expect(page.getByRole('cell', { name: 'page.json.j2', exact: true })).toBeVisible() +}) diff --git a/frontend/e2e/tests/refresh.spec.ts b/frontend/e2e/tests/refresh.spec.ts new file mode 100644 index 0000000..35af527 --- /dev/null +++ b/frontend/e2e/tests/refresh.spec.ts @@ -0,0 +1,41 @@ +import { expect, test } from '@playwright/test' + +import { PRIVATE_CONFIG, ROOT_TOKEN } from '../src/configs' +import { startDoppel, type Doppel } from '../src/server' + +let doppel: Doppel + +test.beforeAll(async () => { + doppel = await startDoppel(PRIVATE_CONFIG) +}) +test.afterAll(() => doppel.stop()) + +test('the list refetches once a minute without being asked', async ({ page }) => { + // The clock is installed before the page loads, so the interval the app sets is + // the one being fast-forwarded. + await page.clock.install() + await page.goto(doppel.baseURL) + await page.getByRole('textbox', { name: 'Token' }).fill(ROOT_TOKEN) + await page.getByRole('button', { name: 'Use this token' }).click() + await expect(page.getByRole('cell', { name: 'alpha', exact: true })).toBeVisible() + + // Changed out from under the page, through the API, exactly as another operator + // or a `config push` would. + const created = await page.request.post(`${doppel.baseURL}/api/v1/proxies`, { + headers: { 'X-Proxy-Authorization': `Bearer ${ROOT_TOKEN}` }, + data: { + proxy: { + name: 'gamma', + type: 'http', + url: 'https://gamma.example.com/', + // `alpha` is the default proxy already, and only one may be. + resolve: { type: 'header', header: 'X-Proxy-Name' }, + }, + }, + }) + expect(created.status()).toBe(201) + await expect(page.getByRole('cell', { name: 'gamma', exact: true })).toBeHidden() + + await page.clock.runFor('01:05') + await expect(page.getByRole('cell', { name: 'gamma', exact: true })).toBeVisible() +}) diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js new file mode 100644 index 0000000..6a5fc47 --- /dev/null +++ b/frontend/eslint.config.js @@ -0,0 +1,46 @@ +// Flat config, eslint 10. Three layers and no more: the language defaults, +// typescript-eslint's recommended set, and the React hook rules -- which are the +// ones that catch real bugs rather than style. +import js from '@eslint/js' +import reactHooks from 'eslint-plugin-react-hooks' +import tseslint from 'typescript-eslint' + +export default tseslint.config( + { ignores: ['dist', 'coverage', 'playwright-report', 'test-results', 'node_modules'] }, + { + // The build helpers run under node, not in a browser, so `console` and + // `process` are globals there and nowhere else. + files: ['scripts/**/*.mjs', '*.config.{js,ts}'], + languageOptions: { globals: { console: 'readonly', process: 'readonly' } }, + }, + js.configs.recommended, + tseslint.configs.recommended, + { + files: ['**/*.{ts,tsx}'], + plugins: { 'react-hooks': reactHooks }, + rules: { ...reactHooks.configs.recommended.rules }, + }, + { + files: ['src/components/**/*.tsx'], + rules: { + // The layer rule, stated where a machine can check it: a presentational + // component knows nothing about the API. Pages do -- a page is the layer + // that fetches, holds the screen's state and hands data down -- and shared + // state that outlives one screen lives in a store instead. Without this + // rule the first component to grow a `fetch` would be the one nothing can + // be tested without. + 'no-restricted-imports': [ + 'error', + { + patterns: [ + { + group: ['**/services/*', '!**/services/runtimeConfig'], + message: + 'a presentational component must not call the API; a page or a store does that', + }, + ], + }, + ], + }, + }, +) diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..0306bad --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,26 @@ + + + + + + + + + + Doppel + + + + +

+ + + diff --git a/frontend/jest.config.ts b/frontend/jest.config.ts new file mode 100644 index 0000000..7bd51ab --- /dev/null +++ b/frontend/jest.config.ts @@ -0,0 +1,35 @@ +import type { Config } from 'jest' + +// `@swc/jest` rather than `ts-jest`: ts-jest pins `typescript <7` and needs +// `@babel/core` as a peer, and this transform needs neither. Types are checked +// by `npm run typecheck`, not by the test run -- one job each. +const config: Config = { + testEnvironment: 'jsdom', + setupFilesAfterEnv: ['/src/test-setup.ts'], + transform: { + '^.+\\.(t|j)sx?$': [ + '@swc/jest', + { + jsc: { + parser: { syntax: 'typescript', tsx: true }, + transform: { react: { runtime: 'automatic' } }, + }, + }, + ], + }, + // react-router is deliberately absent from these suites. It ships ESM only and + // uses `import.meta`, which cannot survive a transform to CommonJS, and running + // jest in ESM mode would change how every mock in this project is written. So + // anything that renders a link or navigates is covered by the Playwright suite, + // against a real browser and the real router -- which is the stronger test of + // navigation regardless. What stays here is what is genuinely a unit: services, + // stores, and the components that do not route. + moduleNameMapper: { + // Styles are a vite concern; under jest they resolve to nothing rather than + // failing to parse. + '\\.css$': '/src/__mocks__/style.ts', + }, + testMatch: ['/src/**/*.test.ts', '/src/**/*.test.tsx'], +} + +export default config diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..48bdf3b --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,8673 @@ +{ + "name": "doppel-dashboard", + "version": "0.4.1", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "doppel-dashboard", + "license": "Apache-2.0", + "dependencies": { + "prismjs": "^1.30.0", + "react": "^19.2.8", + "react-dom": "^19.2.8", + "react-router": "^8.3.0", + "react-simple-code-editor": "^0.14.1", + "zustand": "^5.0.14" + }, + "devDependencies": { + "@eslint/js": "^10.0.1", + "@playwright/test": "^1.62.1", + "@swc/core": "^1.15.47", + "@swc/jest": "^0.2.39", + "@tailwindcss/vite": "^4.3.3", + "@testing-library/jest-dom": "^7.0.1", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.3", + "@types/jest": "^30.0.0", + "@types/prismjs": "^1.26.5", + "@types/react": "^19.2.18", + "@types/react-dom": "^19.2.4", + "@vitejs/plugin-react": "^6.0.5", + "eslint": "^10.8.1", + "eslint-plugin-react-hooks": "^7.1.1", + "jest": "^30.4.2", + "jest-environment-jsdom": "^30.4.1", + "tailwindcss": "^4.3.3", + "typescript": "~5.9.3", + "typescript-eslint": "^8.67.0", + "vite": "^8.2.1" + } + }, + "node_modules/@adobe/css-tools": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.5.0.tgz", + "integrity": "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@asamuzakjp/css-color": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", + "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^2.1.3", + "@csstools/css-color-parser": "^3.0.9", + "@csstools/css-parser-algorithms": "^3.0.4", + "@csstools/css-tokenizer": "^3.0.3", + "lru-cache": "^10.4.3" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.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-import-attributes": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.29.7.tgz", + "integrity": "sha512-zGYcYfq/WmZ4V+kBIXQon9dSSc8ircGZqw9ZaNhhGj9nZkeBu1jHLBDQqYYi5WA9uawvA2sIMbry2nCFhf5Djg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "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.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.29.7.tgz", + "integrity": "sha512-TSu8+mHCoEaaCDEZ0I3+6mvTBYR4PCxQwf2z9/r5Tbztv6NaLR3B9thGTTxX2WGuGHJqRiAbKPeGTJ5XWXVg6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "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.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.29.7.tgz", + "integrity": "sha512-ngr+82Sh0xMz25TPCZi+nC2iTzjfCdWS2ONXTp/PtSCHCgaCNBpdMqgvJ2ccdLlClVZ7sisIgB914j/JFe+RZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz", + "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.8", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.8", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@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/color-helpers": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", + "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/css-calc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", + "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", + "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^5.1.0", + "@csstools/css-calc": "^2.1.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", + "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", + "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@emnapi/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz", + "integrity": "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.23.5", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", + "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^3.0.5", + "debug": "^4.3.1", + "minimatch": "^10.2.4" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.7.0.tgz", + "integrity": "sha512-DObd/KKUsU+FaFv4PLxSRenpXfQWmPXXP3pPZ6/K1PCrMu2vQpMDMuQe/BqYeoLcz8ro0bVDF1RxOJgfVEdhUw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/core": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", + "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/js": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz", + "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "eslint": "^10.0.0" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/@eslint/object-schema": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", + "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.2.tgz", + "integrity": "sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1", + "levn": "^0.4.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "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/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/@istanbuljs/load-nyc-config/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/@istanbuljs/load-nyc-config/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/@istanbuljs/load-nyc-config/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/@istanbuljs/schema": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.6.tgz", + "integrity": "sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@jest/console": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/console/-/console-30.4.1.tgz", + "integrity": "sha512-v3bhyxUh9Hgmo5p6hAOXe14/R3ZxZDOsvHleh4B07z3m/x4/ngPUXEm9XwK4sF4u+f+P2ORb0Ge+MgpaqRMVDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "30.4.1", + "@types/node": "*", + "chalk": "^4.1.2", + "jest-message-util": "30.4.1", + "jest-util": "30.4.1", + "slash": "^3.0.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/core": { + "version": "30.4.2", + "resolved": "https://registry.npmjs.org/@jest/core/-/core-30.4.2.tgz", + "integrity": "sha512-TZJA6cPJUFxoWhxaLo8t0VX/MZX2wPWr0uIDvLSHIvN4gu9h02vSzqI2kBADG1ExqQlC+cY09xKMSreivvrChQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/console": "30.4.1", + "@jest/pattern": "30.4.0", + "@jest/reporters": "30.4.1", + "@jest/test-result": "30.4.1", + "@jest/transform": "30.4.1", + "@jest/types": "30.4.1", + "@types/node": "*", + "ansi-escapes": "^4.3.2", + "chalk": "^4.1.2", + "ci-info": "^4.2.0", + "exit-x": "^0.2.2", + "fast-json-stable-stringify": "^2.1.0", + "graceful-fs": "^4.2.11", + "jest-changed-files": "30.4.1", + "jest-config": "30.4.2", + "jest-haste-map": "30.4.1", + "jest-message-util": "30.4.1", + "jest-regex-util": "30.4.0", + "jest-resolve": "30.4.1", + "jest-resolve-dependencies": "30.4.2", + "jest-runner": "30.4.2", + "jest-runtime": "30.4.2", + "jest-snapshot": "30.4.1", + "jest-util": "30.4.1", + "jest-validate": "30.4.1", + "jest-watcher": "30.4.1", + "pretty-format": "30.4.1", + "slash": "^3.0.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.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/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/core/node_modules/pretty-format": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.4.1.tgz", + "integrity": "sha512-K6KiKMHTL4jjX4u3Kir2EW07nRfcqVTXIImx50wbjHQTcZPgg+gjVeNTIT3l3L1Rd4UefxfogquC9J37SoFyyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "30.4.1", + "ansi-styles": "^5.2.0", + "react-is-18": "npm:react-is@^18.3.1", + "react-is-19": "npm:react-is@^19.2.5" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/create-cache-key-function": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/create-cache-key-function/-/create-cache-key-function-30.4.1.tgz", + "integrity": "sha512-R+xGEtzA95NIsvpXJSROG4t01956dDOt17KpamguY4XOnGvdHNFFXE7Er0C1OAsRjOwiIxpKqOvGlznIGZIQlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "30.4.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/diff-sequences": { + "version": "30.4.0", + "resolved": "https://registry.npmjs.org/@jest/diff-sequences/-/diff-sequences-30.4.0.tgz", + "integrity": "sha512-zOpzlfUs45l6u7jm39qr87JCHUDsaeCtvL+kQe/Vn9jSnRB4/5IPXISm0h9I1vZW/o00Kn4UTJ2MOlhnUGwv3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/environment": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-30.4.1.tgz", + "integrity": "sha512-AK9yNRqgKxiabqMoe4oW+3/TSSeV8vkdC7BGaxZdU0AFXfOpofTLqdru2GXKZghP3sdgwE9XXpnVwfZ8JnFV4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/fake-timers": "30.4.1", + "@jest/types": "30.4.1", + "@types/node": "*", + "jest-mock": "30.4.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/environment-jsdom-abstract": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/environment-jsdom-abstract/-/environment-jsdom-abstract-30.4.1.tgz", + "integrity": "sha512-dSlKrqug3siYNHVnjwIldShY12wAH3spwRltO/+8VOjg0X+xEq7vOs3DbBs4LRKsu7OH+NUb9kuZUNBF9Ho3TA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "30.4.1", + "@jest/fake-timers": "30.4.1", + "@jest/types": "30.4.1", + "@types/jsdom": "^21.1.7", + "@types/node": "*", + "jest-mock": "30.4.1", + "jest-util": "30.4.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/@jest/expect": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/expect/-/expect-30.4.1.tgz", + "integrity": "sha512-ginrj6TMgh2GshLUGCjO94Ptx9HhdZA/I6A9iUfyeLKFtdAjnKzHDgzgP9HYQgbxM1lbXScQ2eUBz2lGeVDPWA==", + "dev": true, + "license": "MIT", + "dependencies": { + "expect": "30.4.1", + "jest-snapshot": "30.4.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/expect-utils": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-30.4.1.tgz", + "integrity": "sha512-ZBn5CglH8fBsQsvs4VWNzD4aWfUYks+IdOOQU3MEK71ol/BcVm+P+rtb1KpiFBpSWSCE27uOahyyf1vfqOVbcQ==", + "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": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-30.4.1.tgz", + "integrity": "sha512-iW5umdmfPeWzehrVhugFQZqCchSCud5S1l2YT0O9ZhjRR0ExclANDZkiSBwzqtnlOn0J1JXvO+HZ6rkuyOVOgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "30.4.1", + "@sinonjs/fake-timers": "^15.4.0", + "@types/node": "*", + "jest-message-util": "30.4.1", + "jest-mock": "30.4.1", + "jest-util": "30.4.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "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": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-30.4.1.tgz", + "integrity": "sha512-ZbuY4cmXC8DkxYjfvT2DbcHWL2T6vmsMhXCDcmTB2T0y0gaezBI77ufq5ZAIdcRkYZ7NEQEDg1xFeKbxUJ5v5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "30.4.1", + "@jest/expect": "30.4.1", + "@jest/types": "30.4.1", + "jest-mock": "30.4.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/pattern": { + "version": "30.4.0", + "resolved": "https://registry.npmjs.org/@jest/pattern/-/pattern-30.4.0.tgz", + "integrity": "sha512-RAWn3+f9u8BsHijKJ71uHcFp6vmyEt6VvoWXkl6hKF3qVIuWNmudVjg12DlBPGup/frIl5UcUlH5HfEuvHpEXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "jest-regex-util": "30.4.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/reporters": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-30.4.1.tgz", + "integrity": "sha512-/SnkPCzEQpUaBH81kjdEdDdo2WZl5hxw+BmLDGWjRkm8o7XlhjwsU36cqwe5PGBE5WYpBvDzRSdXx9rbGuJtNA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bcoe/v8-coverage": "^0.2.3", + "@jest/console": "30.4.1", + "@jest/test-result": "30.4.1", + "@jest/transform": "30.4.1", + "@jest/types": "30.4.1", + "@jridgewell/trace-mapping": "^0.3.25", + "@types/node": "*", + "chalk": "^4.1.2", + "collect-v8-coverage": "^1.0.2", + "exit-x": "^0.2.2", + "glob": "^10.5.0", + "graceful-fs": "^4.2.11", + "istanbul-lib-coverage": "^3.0.0", + "istanbul-lib-instrument": "^6.0.0", + "istanbul-lib-report": "^3.0.0", + "istanbul-lib-source-maps": "^5.0.0", + "istanbul-reports": "^3.1.3", + "jest-message-util": "30.4.1", + "jest-util": "30.4.1", + "jest-worker": "30.4.1", + "slash": "^3.0.0", + "string-length": "^4.0.2", + "v8-to-istanbul": "^9.0.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/@jest/schemas": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.4.1.tgz", + "integrity": "sha512-i6b4qw5qnP8c5FEeBJg/uZQ4ddrkN6Ca8qISJh0pr7a5hfn3h3v5x60BEbOC7OYAGZNMs1LfFLwnW2CuK8F57Q==", + "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/snapshot-utils": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/snapshot-utils/-/snapshot-utils-30.4.1.tgz", + "integrity": "sha512-ObY4ljvQ95mt6iwKtVLetR/4yXiAgl3H4nJxhztr0MTjrN97TwDYrnCp/kF60Ec9HdhkWTHSu+Hg05aXfngpOA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "30.4.1", + "chalk": "^4.1.2", + "graceful-fs": "^4.2.11", + "natural-compare": "^1.4.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/source-map": { + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/@jest/source-map/-/source-map-30.0.1.tgz", + "integrity": "sha512-MIRWMUUR3sdbP36oyNyhbThLHyJ2eEDClPCiHVbrYAe5g3CHRArIVpBw7cdSB5fr+ofSfIb2Tnsw8iEHL0PYQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.25", + "callsites": "^3.1.0", + "graceful-fs": "^4.2.11" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/test-result": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-30.4.1.tgz", + "integrity": "sha512-/ZG7pgEiOmmWkN9TplKbOu4id2N5lh7FHwRwlkgBVAzGdRH+OkkQ8wX/kIxg4zmd3ZQvAL1RwL2yWsvNYYECTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/console": "30.4.1", + "@jest/types": "30.4.1", + "@types/istanbul-lib-coverage": "^2.0.6", + "collect-v8-coverage": "^1.0.2" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/test-sequencer": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/test-sequencer/-/test-sequencer-30.4.1.tgz", + "integrity": "sha512-PeYE+4td5rKjoRPxztObrXU+H8hsjZfxKMXOcmrr34JerSyB/ROOxbbicz8B7A5j9R9VayDnVPvBmedqCsFCdw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/test-result": "30.4.1", + "graceful-fs": "^4.2.11", + "jest-haste-map": "30.4.1", + "slash": "^3.0.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/transform": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-30.4.1.tgz", + "integrity": "sha512-Wz0LyktlTvRefoymh+n64hQ84KNXsRGcwdoZ8CSa0Ea+fgYcHZlnk+hDP7v2MS7il2bQ5uTEIxf4/NNfhMN4KQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.27.4", + "@jest/types": "30.4.1", + "@jridgewell/trace-mapping": "^0.3.25", + "babel-plugin-istanbul": "^7.0.1", + "chalk": "^4.1.2", + "convert-source-map": "^2.0.0", + "fast-json-stable-stringify": "^2.1.0", + "graceful-fs": "^4.2.11", + "jest-haste-map": "30.4.1", + "jest-regex-util": "30.4.0", + "jest-util": "30.4.1", + "pirates": "^4.0.7", + "slash": "^3.0.0", + "write-file-atomic": "^5.0.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/types": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.4.1.tgz", + "integrity": "sha512-f1x/vJXIfjOlEmejYpbkbgw1gOqpPECwMvMEtBqe47j7H2Hg8h8w3o3ikhSXq3MI15kg+oQ0exWO0uCtTNJLoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/pattern": "30.4.0", + "@jest/schemas": "30.4.1", + "@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/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.2.tgz", + "integrity": "sha512-JfB4kuJQjaoHuCTseIINHtHWeJnvgEcxjwA5t/Y00ZgaOO1Crz3fjT/p8kT28zA/Caz7oiUMn3d6H2yOVCVwuw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.3" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=23.5.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1 || ^2.0.0-alpha.3", + "@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.3" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.143.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.143.0.tgz", + "integrity": "sha512-u6JZdLBTLotrNC9Vd6vPssINdzcCzleKAH6EJKImQb7GtYvX5keN2dxkoK44stCc4tffE6QQRtZTXVSzsLUlWA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@pkgr/core": { + "version": "0.3.6", + "resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.3.6.tgz", + "integrity": "sha512-SEeaJLb3qBNF/OaXnaR1NmmBbFYk1zC0ZH/52fATcRPLFg/p791YrcyFFy44Bo9sLaGuSuLp5Q6axbb/O+v/RA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/pkgr" + } + }, + "node_modules/@playwright/test": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.1.tgz", + "integrity": "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.62.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.3.tgz", + "integrity": "sha512-zrJtHDcaZJ1Fp7xf4hNl+7seH9Cn/N5TwLYkhgXREtBwAd/jaqW3uqeHxpDugJLVICWg4eW44kOQEGJ1r6jCGw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.3.tgz", + "integrity": "sha512-ieIiibVCp0tX7TLu2cafoNPv8wJyYi01ekXpbf8q2j7F4rGAhhXb/eQh7ge9DRBY78GwmRQtvjZDux7EDbA8kA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.3.tgz", + "integrity": "sha512-Zh9tCon19eDXJoihx0rqKhMUlMYqzwj3aPsSuHmI4RWZh62dWUL+DJN4C5YQya5TcQBJU/Fe8+rY0jhXTQITqA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.3.tgz", + "integrity": "sha512-nGbJWewA1wrXXZiQhjAT5rhibGfns5ZNkDVqxsO6zJ3f3YvpoDNNmGMSbbhLuXKjNScaBJVOAboztAWVespQMg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.3.tgz", + "integrity": "sha512-QNniJr5Kml0kDEB98jiDOJjXNroxIIi0IXIbdYzY26Xt1pVbeP62+KnoIZLwirOymX/0jDk/2gI/bNUv7A7OIw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.3.tgz", + "integrity": "sha512-TkqEAcmmvH3I/q4114NB4RVt6241Dao48pF45uLcFGrwAaIn0iITgTAKP/dLjbN0R4buJjGb91+UHSoFmpgIWw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.3.tgz", + "integrity": "sha512-NHqjnxpsndf4MPymxteFAWHHfkTL8HjWh1KB7z23ofZ6QO2euONuxDXjat69dKZRALnGypg8k8SsK8vZJoXv1Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.3.tgz", + "integrity": "sha512-6tbrbwfz5GB9DQ4Jwo6hy9v+vR31xZlvzZ6n5Xut6Hhx5PvrA9q/HsK8KMaYQp063iqZGXwNvZtYNLD7EM/x0w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.3.tgz", + "integrity": "sha512-oyuXxXmoZHjXC917IAPFAAv4wWAa0cM9afk8nx1+9/jNNOX1uPf8yDA6p7G0RypOfw/X0PQt5IfoquY1um+zSg==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.3.tgz", + "integrity": "sha512-TytMwF2KVGqP2tgd0I1OY0PAv78dZRAYcF5ssDzjM34SUXCED3uXvSd5+lHoC0bTD6eEdFz7LdQNCO1y0oVk9w==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.3.tgz", + "integrity": "sha512-/E9m3qstrJFVPoULV25mVQblSNExY2+kBsYe4sy0Tn0yOOgJ8wZbZt3KnRbF/XeU2Gl1STKUQnDNTqhIE5MD4A==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.3.tgz", + "integrity": "sha512-Kr0OcsoQI816i6HOl3vFHpd1K0eZyh76zgfj4c1nTyaTsd5r2Mj1lwM4R90y/qaCfmTn9eHy0SKwi98eitRxug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.3.tgz", + "integrity": "sha512-hOtMwTqnME+/gJcH/PCZ0wn0zPUjiWOgkHpxbSJpfGKMezHltx1S7/k1SitzVa7Ww2cqrDDaFbZEhcJZO8o+Jw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.3.tgz", + "integrity": "sha512-ekcqMMkI2PlhYnfzQnB/cEdYUVVJViWvoUyLrbzgDoi3Snfc1mVBwdnc306ufA5ejy8JSPjT2RlW1nQSjW7efg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sinclair/typebox": { + "version": "0.34.52", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.52.tgz", + "integrity": "sha512-XiMQh7qqVlxZzcVD+kkGMNGMzcTrDMLWI7S4x7z1MkCkbDPrekpZXEUK0eZqZFMuHQg2a2DZOcDIh9o5v3Gonw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sinonjs/commons": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz", + "integrity": "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "type-detect": "4.0.8" + } + }, + "node_modules/@sinonjs/fake-timers": { + "version": "15.4.0", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-15.4.0.tgz", + "integrity": "sha512-DsG+8/LscQIQg68J6Ef3dv10u6nVyetYn923s3/sus5eaGfTo1of5WMZSLf0UJc9KDuKPilPH0UDJCjvNbDNCA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^3.0.1" + } + }, + "node_modules/@swc/core": { + "version": "1.15.47", + "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.15.47.tgz", + "integrity": "sha512-FbsO5JcfOjfH38W/rohBRBweJeERsAuIP4f377lmkmxTcq9exjtx4SkRuZY5CdfhR2CBVwDIJegBpJDffwNsOg==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@swc/counter": "^0.1.3", + "@swc/types": "^0.1.27" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/swc" + }, + "optionalDependencies": { + "@swc/core-darwin-arm64": "1.15.47", + "@swc/core-darwin-x64": "1.15.47", + "@swc/core-linux-arm-gnueabihf": "1.15.47", + "@swc/core-linux-arm64-gnu": "1.15.47", + "@swc/core-linux-arm64-musl": "1.15.47", + "@swc/core-linux-ppc64-gnu": "1.15.47", + "@swc/core-linux-s390x-gnu": "1.15.47", + "@swc/core-linux-x64-gnu": "1.15.47", + "@swc/core-linux-x64-musl": "1.15.47", + "@swc/core-win32-arm64-msvc": "1.15.47", + "@swc/core-win32-ia32-msvc": "1.15.47", + "@swc/core-win32-x64-msvc": "1.15.47" + }, + "peerDependencies": { + "@swc/helpers": ">=0.5.17" + }, + "peerDependenciesMeta": { + "@swc/helpers": { + "optional": true + } + } + }, + "node_modules/@swc/core-darwin-arm64": { + "version": "1.15.47", + "resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.15.47.tgz", + "integrity": "sha512-GsoMtan3ojGGMGFbl31mmRu5ctZ56re8grGE8mO/OHJ8O+JRkzod02fe7X6ZQ8JvamA3imkEkx/h3u+vsOgPgA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-darwin-x64": { + "version": "1.15.47", + "resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.15.47.tgz", + "integrity": "sha512-leTi7Rx3KF4zcC637iqWgk9SoV8VXAD8ppQYXsep63px5A/UftOcxLN1pmr8Z1si/YvX90ompP/rHgpYkgwXWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-arm-gnueabihf": { + "version": "1.15.47", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.15.47.tgz", + "integrity": "sha512-hBqHuoWKKIsKmDBn9qVeWqj5GWZhtlcczVaqQmNRXsDfq+voR5CxKRfamA367QjJXtceYuliLFfEL8QsskRM2g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-arm64-gnu": { + "version": "1.15.47", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.15.47.tgz", + "integrity": "sha512-TBxvRz+B4K205TWHHZxWVxkC2RFNP/Mz3PNcECBos5PsKwxjg3QSJzdoebr0VCf0Bfh8HOPldKxAP/8XkFe9gA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-arm64-musl": { + "version": "1.15.47", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.15.47.tgz", + "integrity": "sha512-3Yu3Uq/VgytqsPjTMbkPU1ExADytbdWbruJYhA584E9jrpE2Ki+R6VVPoZCeAVk1Cb7QxcRTgblw6bSa6a/R+w==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-ppc64-gnu": { + "version": "1.15.47", + "resolved": "https://registry.npmjs.org/@swc/core-linux-ppc64-gnu/-/core-linux-ppc64-gnu-1.15.47.tgz", + "integrity": "sha512-wfdMi5IaOaNtmh2/6geRoxIdNfqylUZFdtzTKS655y1axWfIWyx7As74vv0wVdjeCIZ3WmCI9odDd4rUttXOSQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-s390x-gnu": { + "version": "1.15.47", + "resolved": "https://registry.npmjs.org/@swc/core-linux-s390x-gnu/-/core-linux-s390x-gnu-1.15.47.tgz", + "integrity": "sha512-3hHYBY0yx8Ez7GMRrkhXHQzMdR5IZA6Wq5Ee4svlgwvSECLpnAJ9+0AimEGUFDvuLwE7nV/2+PYe8+Nm4rvNcQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-x64-gnu": { + "version": "1.15.47", + "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.15.47.tgz", + "integrity": "sha512-TjfhjgP/jGCfFHYC3JQPhJA1HwErbIJ9JfREDc1KNkvY6P0LodCgKVIlQ5deeTbkG7ih3bF5PHJLuLpaZjdRyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-x64-musl": { + "version": "1.15.47", + "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.15.47.tgz", + "integrity": "sha512-CQpS8Ge/avfjZd0UEwG/sds83Uu32deQXcV1Jo3jD0mmvQQqtYAjpsDZXugmheeAwmt+YIuoVtVHro8LMYHqsQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-win32-arm64-msvc": { + "version": "1.15.47", + "resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.15.47.tgz", + "integrity": "sha512-0W8IKHsUTYiT7G2RqtOoVWk+89yzZikIiDUb/sCK6BmQDBhN91hQSfyUtW12jhEWLzYgcfmisfsZrmZE+84U1A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-win32-ia32-msvc": { + "version": "1.15.47", + "resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.15.47.tgz", + "integrity": "sha512-ZIp49d2Z4/ka2jO9otOg4hDvTdPmp86kVOgS2M5FCPI7eKKZ1W0boxWn+8XeZrfERtFGW0AlMRm4JhlJa7l3NA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-win32-x64-msvc": { + "version": "1.15.47", + "resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.15.47.tgz", + "integrity": "sha512-2h8Iek95vnixkBRCo+H8p09+Q5ll2NgSMFrWTy0iKt7+/t+8/T5mBpiT6c0ZxSS7wcWjwZ9sGZkK70tTSYHdDw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/counter": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz", + "integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/@swc/jest": { + "version": "0.2.39", + "resolved": "https://registry.npmjs.org/@swc/jest/-/jest-0.2.39.tgz", + "integrity": "sha512-eyokjOwYd0Q8RnMHri+8/FS1HIrIUKK/sRrFp8c1dThUOfNeCWbLmBP1P5VsKdvmkd25JaH+OKYwEYiAYg9YAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/create-cache-key-function": "^30.0.0", + "@swc/counter": "^0.1.3", + "jsonc-parser": "^3.2.0" + }, + "engines": { + "npm": ">= 7.0.0" + }, + "peerDependencies": { + "@swc/core": "*" + } + }, + "node_modules/@swc/types": { + "version": "0.1.28", + "resolved": "https://registry.npmjs.org/@swc/types/-/types-0.1.28.tgz", + "integrity": "sha512-V6Mnml8v09QALx6K0elJ7o9K/MkVDtW3t6L+7Ou/JcWtb3xwId2AH4FeOceySd2JaO87IMw4+6vSZxLm34LPbw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@swc/counter": "^0.1.3" + } + }, + "node_modules/@tailwindcss/node": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz", + "integrity": "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.24.1", + "jiti": "^2.7.0", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.3.3" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.3.tgz", + "integrity": "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-x64": "4.3.3", + "@tailwindcss/oxide-freebsd-x64": "4.3.3", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.3", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.3", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-x64-musl": "4.3.3", + "@tailwindcss/oxide-wasm32-wasi": "4.3.3", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.3", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.3" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.3.tgz", + "integrity": "sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.3.tgz", + "integrity": "sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.3.tgz", + "integrity": "sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.3.tgz", + "integrity": "sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.3.tgz", + "integrity": "sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.3.tgz", + "integrity": "sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.3.tgz", + "integrity": "sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.3.tgz", + "integrity": "sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.3.tgz", + "integrity": "sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.3.tgz", + "integrity": "sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.11.1", + "@emnapi/runtime": "^1.11.1", + "@emnapi/wasi-threads": "^1.2.2", + "@napi-rs/wasm-runtime": "^1.1.4", + "@tybys/wasm-util": "^0.10.2", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.11.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.11.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.4", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": { + "version": "0.10.2", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": { + "version": "2.8.1", + "dev": true, + "inBundle": true, + "license": "0BSD", + "optional": true + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz", + "integrity": "sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.3.tgz", + "integrity": "sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.3.tgz", + "integrity": "sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.3.3", + "@tailwindcss/oxide": "4.3.3", + "tailwindcss": "4.3.3" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7 || ^8" + } + }, + "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": "7.0.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-7.0.1.tgz", + "integrity": "sha512-oMDTC3oA+6CXSO2JZnvOI7CA6oVub6kij5ggk9ohwye5slmkwxYDXcPOVxgMw/RQlticjtO0C1RZkR97HgrWMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=22", + "npm": ">=6", + "yarn": ">=1" + }, + "peerDependencies": { + "@testing-library/dom": ">=10 <11", + "vitest": ">= 0.32" + }, + "peerDependenciesMeta": { + "vitest": { + "optional": true + } + } + }, + "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/react": { + "version": "16.3.2", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", + "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@testing-library/user-event": { + "version": "14.6.3", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.3.tgz", + "integrity": "sha512-6dBq67jT8lE+JTE8Exm02Kt6ze43hz1jdiSpSJwtTZiT1xQQ6b7nZYTTQ9njdArdU8XklOwaDp/AbT/eYSKF4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "peerDependencies": { + "@testing-library/dom": ">=7.21.4" + } + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "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", + "peer": true + }, + "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/esrecurse": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/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.4.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.4.1.tgz", + "integrity": "sha512-K6KiKMHTL4jjX4u3Kir2EW07nRfcqVTXIImx50wbjHQTcZPgg+gjVeNTIT3l3L1Rd4UefxfogquC9J37SoFyyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "30.4.1", + "ansi-styles": "^5.2.0", + "react-is-18": "npm:react-is@^18.3.1", + "react-is-19": "npm:react-is@^19.2.5" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@types/jsdom": { + "version": "21.1.7", + "resolved": "https://registry.npmjs.org/@types/jsdom/-/jsdom-21.1.7.tgz", + "integrity": "sha512-yOriVnggzrnQ3a9OKOCxaVuSug3w3/SbOj5i7VwXWZEyUNl3bLF9V3MfxGbZKuwqJOQyRfqXyROBB1CoZLFWzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/tough-cookie": "*", + "parse5": "^7.0.0" + } + }, + "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/node": { + "version": "26.2.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.2.0.tgz", + "integrity": "sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~8.3.0" + } + }, + "node_modules/@types/prismjs": { + "version": "1.26.6", + "resolved": "https://registry.npmjs.org/@types/prismjs/-/prismjs-1.26.6.tgz", + "integrity": "sha512-vqlvI7qlMvcCBbVe0AKAb4f97//Hy0EBTaiW8AalRnG/xAN5zOiWWyrNqNXeq8+KAuvRewjCVY1+IPxk4RdNYw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "19.2.18", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz", + "integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.4.tgz", + "integrity": "sha512-Bsc+QHgp+P/F02XDzNCY9jnZNCUuLki36KT7VKrTXXLdHf+vHMNZnW1rVu5DNW/rCK+fya3DATySbLM4yhtKUw==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "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/tough-cookie": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.5.tgz", + "integrity": "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==", + "dev": true, + "license": "MIT" + }, + "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": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.67.0.tgz", + "integrity": "sha512-Un7Heoyj65NREbKAyIrFxeM143NZpExWmy1Nep4DLeQOeLlTeumPjoNKnBrU5D5moWXbPJgRa5Uwcdu0faVNGQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.67.0", + "@typescript-eslint/type-utils": "8.67.0", + "@typescript-eslint/utils": "8.67.0", + "@typescript-eslint/visitor-keys": "8.67.0", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.67.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.6.tgz", + "integrity": "sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.67.0.tgz", + "integrity": "sha512-fUBfTuuEulWqX6V8+O3PtScV01tzYYRUDTAirHFKoRAt7nOzoGiPt0M/bB47wWNy0coOOcgEwAMUtBpykMxl6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.67.0", + "@typescript-eslint/types": "8.67.0", + "@typescript-eslint/typescript-estree": "8.67.0", + "@typescript-eslint/visitor-keys": "8.67.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.67.0.tgz", + "integrity": "sha512-cvE8c7ulYeXN9fYuszhCeCsbzyVEXuhrRCybnBre7TUmqb5nRmBfQAwCj0O3WJFDeyAZt4VYv51vMCC9LHSdYw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.67.0", + "@typescript-eslint/types": "^8.67.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.67.0.tgz", + "integrity": "sha512-EgvsleTwS4E+WzzSvem8fAUubLwatMNF1B5hHSLQxcvs7q2dtRhGyujHwLJSYlG41niJ7GP24Aha2+0mb1b2kg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.67.0", + "@typescript-eslint/visitor-keys": "8.67.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.67.0.tgz", + "integrity": "sha512-vV+LUSv5njUWsknE71fqKTlXUva+R76SaeORd6Zojcunk/6DvKFXONU3BrAs2H49mbygUXt6gbYunzwqNwlhdg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.67.0.tgz", + "integrity": "sha512-aVWDXbRmdXO9siTfX4ditQI1T9+zVcNazT48EJCD0v40/9RIFoUgZ05CmGEq9H2gixRpjUn/iplwvlcvutJW/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.67.0", + "@typescript-eslint/typescript-estree": "8.67.0", + "@typescript-eslint/utils": "8.67.0", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.67.0.tgz", + "integrity": "sha512-sBtgslww8nsMYUjhdPBiSyUqSzT8uR6g93A2QXnQC8+cGdjz0CyaOdqHDRJb1AtORbZCNUJBBeFA/tNR2uQmww==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.67.0.tgz", + "integrity": "sha512-EKQBCE9yNlRJYm7jdTW5AhDacDUmSwQb0FAJAmK2EKYrNXIsa2vxcSZx6PvJ/dEdI6lS+Y9W+EXckLj0iPFGcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.67.0", + "@typescript-eslint/tsconfig-utils": "8.67.0", + "@typescript-eslint/types": "8.67.0", + "@typescript-eslint/visitor-keys": "8.67.0", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.67.0.tgz", + "integrity": "sha512-U9D1FdwEWBwok3hxxSdhclMb0twvt9QnjIQ0VfQ1AiX2epnpSgv2ubVDsayOFyY8K6FX+AQ7E0FKWVG3iKsj1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.67.0", + "@typescript-eslint/types": "8.67.0", + "@typescript-eslint/typescript-estree": "8.67.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.67.0.tgz", + "integrity": "sha512-fkv8dHRDqfGtTHuJeebdrQ7cX6Ad4WAS00rgHh9UGvMycF1mjBfsxry1XsLIFhWZ6Judlh6UdzK+TYlbpCXgnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.67.0", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.3.tgz", + "integrity": "sha512-60YRaenCQcVjYEKOcG824+DRGGIQ3VKErcBoAEDJZz5bKIs2ZG+X/H9Nk+Q6EVkwJk5QNApxbrc5QtBSwtrXAg==", + "dev": true, + "license": "ISC" + }, + "node_modules/@unrs/resolver-binding-android-arm-eabi": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.12.2.tgz", + "integrity": "sha512-g5T90pqg1bo/7mytQx6F4iBNC0Wsh9cu+z9veDbFjc7HjpesJFWD7QMS0NGStXM075+7dJPPVvBbpZlnrdpi/w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@unrs/resolver-binding-android-arm64": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm64/-/resolver-binding-android-arm64-1.12.2.tgz", + "integrity": "sha512-YGCRZv/9GLhwmz6mYDeTsm/92BAyR28l6c2ReweVW5pWgfsitWLY8upvfRlGdoyD8HjeTHSYJWyZGD4KJA/nFQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@unrs/resolver-binding-darwin-arm64": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-arm64/-/resolver-binding-darwin-arm64-1.12.2.tgz", + "integrity": "sha512-u9DiNT1auQMO20A9SyTuG3wUgQWB9Z7KjAg0uFuCDR1FsAY8A0CG2S6JpHS1xwm/w1G08bjXZDcyOCjv1WAm2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@unrs/resolver-binding-darwin-x64": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-x64/-/resolver-binding-darwin-x64-1.12.2.tgz", + "integrity": "sha512-f7rPLi/T1HVKZu/u6t87lroib16n8vrSzcyxI7lg4BGO9UF26KhQL44sd9eOUgrTYhvRXtWOIZT5PejdPyJfUA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@unrs/resolver-binding-freebsd-x64": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-freebsd-x64/-/resolver-binding-freebsd-x64-1.12.2.tgz", + "integrity": "sha512-BpcOjWCJub6nRZUS2zA20pmLvjtqAtGejETaIyRLiZiQf++cbrjltLA5NN/xaXfqeOBOSlMFbemIl5/S5tljmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm-gnueabihf": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-gnueabihf/-/resolver-binding-linux-arm-gnueabihf-1.12.2.tgz", + "integrity": "sha512-vZTDvdSISZjJx66OzJqtsOhzifbqRjbmI1Mnu49fQDwog5GtDI4QidRiEAYbZCRj9C8YZEW+3ZjqsyS9GR4k2A==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm-musleabihf": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-musleabihf/-/resolver-binding-linux-arm-musleabihf-1.12.2.tgz", + "integrity": "sha512-BiPI+IrIlwcW4nLLMM21+B1dFPzd55yAVgVGrdgDjNef+ch03GdxrcyaIz8X9SsQirh/kCQ7mviyWlMxdh2D7g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm64-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-gnu/-/resolver-binding-linux-arm64-gnu-1.12.2.tgz", + "integrity": "sha512-zJc0H99FEPoFfSrNpa91HYfxzfAJCr502oxNK1cfdC9hlaFI43RT+JFCann9JUgZmLzzntChHyn13Sgn9ljHNg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm64-musl": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-musl/-/resolver-binding-linux-arm64-musl-1.12.2.tgz", + "integrity": "sha512-KQ3Lki6l+Pz1k/eBipN41ES+YUK30beLGb9YqcB1O542cyLCNE6GaxrfcY3T6EezmGGk84wb5XyO9loTM9tkcA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-loong64-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-loong64-gnu/-/resolver-binding-linux-loong64-gnu-1.12.2.tgz", + "integrity": "sha512-3SJGEh1DborhG6pyxvhPzCT4bbSIVihsvgJc13P1bHG7KLdNDaF9T3gsTwFc7Jw/5Y5/iWOjkEx7Zy0NvCGX3Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-loong64-musl": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-loong64-musl/-/resolver-binding-linux-loong64-musl-1.12.2.tgz", + "integrity": "sha512-jiuG/Obbel7uw1PwHNFfrkiKhLAF6mnyZ6aWlOAVN9WqKm8v0OFGnciJIHu8+CMvXLQ8AD51LPzAoUfT21D5Ew==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-ppc64-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-ppc64-gnu/-/resolver-binding-linux-ppc64-gnu-1.12.2.tgz", + "integrity": "sha512-q7xRvVpmcfeL+LlZg8Pbbo6QaTZwDU5BaGZbwfhkEsXJn3Was8xYfE0RBH266xZt0rM6B7i8xAYIvjthuUIWHg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-riscv64-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-gnu/-/resolver-binding-linux-riscv64-gnu-1.12.2.tgz", + "integrity": "sha512-0CVdx6lcnT3Q9inOH8tsMIOJ6ImndllMjqJHg8RLVdB7Vq4SfkEXl9mCSsVNuNA4MCYycRicCUxPCabVHJRr6A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-riscv64-musl": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-musl/-/resolver-binding-linux-riscv64-musl-1.12.2.tgz", + "integrity": "sha512-iOwlRo9vnp6R6ohHQS11n0NnfdXx/omhkocmIfaPRpQhKZ+3BDMkkdRVh53qjkFkpPddf+FETA28NwGN7l5l+w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-s390x-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-s390x-gnu/-/resolver-binding-linux-s390x-gnu-1.12.2.tgz", + "integrity": "sha512-HYJtLfXq94q8iZNFT1lknx258wlkkWhZeUXJRqzKBBUJ00CvZ+N33zgbCqimLjsyw5Va6uUxhVa12mI+kaveEw==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-x64-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-gnu/-/resolver-binding-linux-x64-gnu-1.12.2.tgz", + "integrity": "sha512-mPsUhunKKDih5O96Y6enDQyHc1SqBPlY1E/SfMWDM3EdJ95Z9CArPeCVwCCqbP45ljvivdEk8Fxn+SIb1rDAJQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-x64-musl": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-musl/-/resolver-binding-linux-x64-musl-1.12.2.tgz", + "integrity": "sha512-azrt6+5ydLd8Vt210AAFis/lZevSfPw93EJRIJG+xPu4WCJ8K0kppCTpMyLPcKT7H15M4Jnt2tMp5bOvCkRC6A==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-openharmony-arm64": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-openharmony-arm64/-/resolver-binding-openharmony-arm64-1.12.2.tgz", + "integrity": "sha512-YZ9hP4O0X9PQb8eO980qmLNGH4zT3I9+SZTdt0Pr0YyuGQhYKoOZkV02VzrzyOZJ5xIJ3UFIenKkUkGg8GjgWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@unrs/resolver-binding-wasm32-wasi": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-wasm32-wasi/-/resolver-binding-wasm32-wasi-1.12.2.tgz", + "integrity": "sha512-tYFDIkMxSflfEc/h92ZWNsZlHSwgimbNHSO3PL2JWQHfCuC2q316jMyYU9TIWZsFK2bQwyK5VAdYgn8ygPj69A==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", + "@napi-rs/wasm-runtime": "^1.1.4" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@unrs/resolver-binding-win32-arm64-msvc": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.12.2.tgz", + "integrity": "sha512-qzNyg3xL0VPQmCaUh+N5jSitce6k+uCBfMDesWRnlULOZaqUkaJ0ybdT+UqlAWJoQjuqfIU/0Ptx9bteN4D82g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@unrs/resolver-binding-win32-ia32-msvc": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-ia32-msvc/-/resolver-binding-win32-ia32-msvc-1.12.2.tgz", + "integrity": "sha512-WD9sY00OfpHVGfsnHZoA8jVT+esS/Bg8z8jzxp5BnDCjjwsuKsPQrzswwpFy4J1AUJbXPRfkpcX0mXrzeXW79g==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@unrs/resolver-binding-win32-x64-msvc": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.12.2.tgz", + "integrity": "sha512-nAB74NfSNKknqQ1RrYj6uz8FcXEomu/MATJZxh/x+BArzN2U3JbOYC0APYzUIGhVY3m5hRxA8VPNdPBoG8txlA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@vitejs/plugin-react": { + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.5.tgz", + "integrity": "sha512-BOVzne/NL162sMdResB25mUv+vWMF5NoAjNf09TeGlE7ZpszZWSD3winycicLJw72yeVsoCn/2kOhEuCvEShMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + } + } + }, + "node_modules/acorn": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", + "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-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-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/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/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/babel-jest": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-30.4.1.tgz", + "integrity": "sha512-fATAbM8piYxkiXQp3RBXmZHxZVNJZAVXXfyeyCN2Tida3+qJ8ea9UxhiJ2y4fLO90ZImKt6k9FlcH2+rLkJGhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/transform": "30.4.1", + "@types/babel__core": "^7.20.5", + "babel-plugin-istanbul": "^7.0.1", + "babel-preset-jest": "30.4.0", + "chalk": "^4.1.2", + "graceful-fs": "^4.2.11", + "slash": "^3.0.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.11.0 || ^8.0.0-0" + } + }, + "node_modules/babel-plugin-istanbul": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-7.0.1.tgz", + "integrity": "sha512-D8Z6Qm8jCvVXtIRkBnqNHX0zJ37rQcFJ9u8WOS6tkYOsRdHBzypCstaxWiu5ZIlqQtviRYbgnRLSoCEvjqcqbA==", + "dev": true, + "license": "BSD-3-Clause", + "workspaces": [ + "test/babel-8" + ], + "dependencies": { + "@babel/helper-plugin-utils": "^7.0.0", + "@istanbuljs/load-nyc-config": "^1.0.0", + "@istanbuljs/schema": "^0.1.3", + "istanbul-lib-instrument": "^6.0.2", + "test-exclude": "^6.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/babel-plugin-jest-hoist": { + "version": "30.4.0", + "resolved": "https://registry.npmjs.org/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-30.4.0.tgz", + "integrity": "sha512-9EdtWM/sSfXLOGLwSn+GS6pIXyBnL07/8gyJlwFXjWy4DxMOyItqyUT29d4lQiS380EZwYlX7/At4PgBS+m2aA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/babel__core": "^7.20.5" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "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": "30.4.0", + "resolved": "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-30.4.0.tgz", + "integrity": "sha512-lBY4jxsNmCnSiu7kquw8ZC9F4+XLMOKypT3RnNHPvU2Kpd4W0xaPuLr5ZkRyOsvLYAY4yaW1ZwTW4xB7NIiZzg==", + "dev": true, + "license": "MIT", + "dependencies": { + "babel-plugin-jest-hoist": "30.4.0", + "babel-preset-current-node-syntax": "^1.2.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.11.0 || ^8.0.0-beta.1" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.13", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.13.tgz", + "integrity": "sha512-k9HNuUVMlqVjQ9UHzfPjIqiDbWw7WqT1AoT7GL8VwvF3r0ZfArtgiSPAlmupyNquNgOJHTuH4CKYf8ttMTWBTQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/browserslist": { + "version": "4.28.8", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.8.tgz", + "integrity": "sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==", + "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.11.12", + "caniuse-lite": "^1.0.30001809", + "electron-to-chromium": "^1.5.402", + "node-releases": "^2.0.53", + "update-browserslist-db": "^1.3.0" + }, + "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/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/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/caniuse-lite": { + "version": "1.0.30001809", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001809.tgz", + "integrity": "sha512-xxWVywk6a6Arlk+hymeycyn/VgqEfLDxupvhH/xiY5SJ/18kmi9o6MiO320DCUzypORHLtvh0I4i04tUhCNHNQ==", + "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/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/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/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": "2.2.0", + "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-2.2.0.tgz", + "integrity": "sha512-4bHTS2YuzUvtoLjdy+98ykbNB5jS0+07EvFNXerqZQJ89F7DI6ET7OQo/HJuW6K0aVsKA9hj9/RVb2kQVOrPDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/cliui/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/cliui/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/cliui/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/cliui/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/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/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/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie-es": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/cookie-es/-/cookie-es-3.1.1.tgz", + "integrity": "sha512-UaXxwISYJPTr9hwQxMFYZ7kNhSXboMXP+Z3TRX6f1/NyaGPfuNUZOWP1pUEb75B2HjfklIYLVRfWiFZJyC6Npg==", + "license": "MIT" + }, + "node_modules/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/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cssstyle": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", + "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^3.2.0", + "rrweb-cssom": "^0.8.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/data-urls": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", + "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/dedent": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.2.tgz", + "integrity": "sha512-WzMx3mW98SN+zn3hgemf4OzdmyNhhhKz5Ay0pUfQiMQ3e1g+xmTJWp/pKdwKVXhdSkAEGIIzqeuWrL3mV/AXbA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "babel-plugin-macros": "^3.1.0" + }, + "peerDependenciesMeta": { + "babel-plugin-macros": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/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/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/detect-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/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", + "peer": true + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true, + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.404", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.404.tgz", + "integrity": "sha512-3WJtd7/lVq2Jnuz6wed1l9+1ZD2u2Tet1/1NBc4Iedkmgbu+I7YuAqdAQ8T+VZtnwysMsAf3IqSq9D1gyZjA2g==", + "dev": true, + "license": "ISC" + }, + "node_modules/emittery": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/emittery/-/emittery-0.13.1.tgz", + "integrity": "sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "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/enhanced-resolve": { + "version": "5.24.5", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.5.tgz", + "integrity": "sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/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/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "10.8.1", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.8.1.tgz", + "integrity": "sha512-wqA7W2jbsC/BnV9Iv1UZpKVFkO1AdNoSmYW8NWG4HNOBbkAMvIqDZ27pI2f07dqn583NcIC44ckjAcOXDL1QbQ==", + "dev": true, + "license": "MIT", + "workspaces": [ + "packages/*" + ], + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.2", + "@eslint/config-array": "^0.23.5", + "@eslint/config-helpers": "^0.7.0", + "@eslint/core": "^1.2.1", + "@eslint/plugin-kit": "^0.7.2", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^9.1.2", + "eslint-visitor-keys": "^5.0.1", + "espree": "^11.2.0", + "esquery": "^1.7.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "minimatch": "^10.2.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.1.1.tgz", + "integrity": "sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.24.4", + "@babel/parser": "^7.24.4", + "hermes-parser": "^0.25.1", + "zod": "^3.25.0 || ^4.0.0", + "zod-validation-error": "^3.5.0 || ^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0" + } + }, + "node_modules/eslint-scope": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@types/esrecurse": "^4.3.1", + "@types/estree": "^1.0.8", + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", + "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.16.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^5.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "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/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/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/execa/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/exit-x": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/exit-x/-/exit-x-0.2.2.tgz", + "integrity": "sha512-+I6B/IkJc1o/2tiURyz/ivu/O0nKNEArIUB5O7zBrlDVJr22SCLH3xTeEry428LvFhRzIA1g8izguxJ/gbNcVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/expect": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/expect/-/expect-30.4.1.tgz", + "integrity": "sha512-PMARsyh/JtqC20HoGqlFcIlQAyqUtW4PlI1rup1uhYJtKuwAjbvWi3GQMAn+STdHum/dk8xrKfUM1+5SAwpolA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/expect-utils": "30.4.1", + "@jest/get-type": "30.1.0", + "jest-matcher-utils": "30.4.1", + "jest-message-util": "30.4.1", + "jest-mock": "30.4.1", + "jest-util": "30.4.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-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/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": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.4.tgz", + "integrity": "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==", + "dev": true, + "license": "ISC" + }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "dev": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "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/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-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-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/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "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": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "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/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/glob/node_modules/brace-expansion": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", + "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/has-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/hermes-estree": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", + "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==", + "dev": true, + "license": "MIT" + }, + "node_modules/hermes-parser": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz", + "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hermes-estree": "0.25.1" + } + }, + "node_modules/html-encoding-sniffer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", + "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-encoding": "^3.1.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/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/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/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/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-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/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-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-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-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-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-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/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": "6.0.3", + "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-6.0.3.tgz", + "integrity": "sha512-Vtgk7L/R2JHyyGW07spoFlB8/lpjiOLTjMdms6AFMraYt3BaJauod/NGrfnVG/y4Ix1JEuMRPDPEj2ua+zz1/Q==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@babel/core": "^7.23.9", + "@babel/parser": "^7.23.9", + "@istanbuljs/schema": "^0.1.3", + "istanbul-lib-coverage": "^3.2.0", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-instrument/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "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-source-maps": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-5.0.6.tgz", + "integrity": "sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.23", + "debug": "^4.1.1", + "istanbul-lib-coverage": "^3.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "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/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/jest": { + "version": "30.4.2", + "resolved": "https://registry.npmjs.org/jest/-/jest-30.4.2.tgz", + "integrity": "sha512-Yi1jqNC/Oq0N4hBgNH/YvBpP1P57QqundgytzYqy3yqAa7NZPNjSoi4SGbRAXDMdBzNE6xBCi5U7RgfrvMEUVQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/core": "30.4.2", + "@jest/types": "30.4.1", + "import-local": "^3.2.0", + "jest-cli": "30.4.2" + }, + "bin": { + "jest": "bin/jest.js" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.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": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-changed-files/-/jest-changed-files-30.4.1.tgz", + "integrity": "sha512-IuctmYrxi21iOSOaIXpJWalHyPAsVv0GeBHKDn8C1CA4W5htHn7INL+wdnL4Bo0+olEndvAFkmb++tIQJG+vvg==", + "dev": true, + "license": "MIT", + "dependencies": { + "execa": "^5.1.1", + "jest-util": "30.4.1", + "p-limit": "^3.1.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-circus": { + "version": "30.4.2", + "resolved": "https://registry.npmjs.org/jest-circus/-/jest-circus-30.4.2.tgz", + "integrity": "sha512-rvHH7VlY6LgbJXJTQ87GW62g1FntOtbhh0zT+v04kC+pgL6aBKyYINXxWukCpj3dcIBMw5/XUbtDS9dU9JTXeQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "30.4.1", + "@jest/expect": "30.4.1", + "@jest/test-result": "30.4.1", + "@jest/types": "30.4.1", + "@types/node": "*", + "chalk": "^4.1.2", + "co": "^4.6.0", + "dedent": "^1.6.0", + "is-generator-fn": "^2.1.0", + "jest-each": "30.4.1", + "jest-matcher-utils": "30.4.1", + "jest-message-util": "30.4.1", + "jest-runtime": "30.4.2", + "jest-snapshot": "30.4.1", + "jest-util": "30.4.1", + "p-limit": "^3.1.0", + "pretty-format": "30.4.1", + "pure-rand": "^7.0.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-circus/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-circus/node_modules/pretty-format": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.4.1.tgz", + "integrity": "sha512-K6KiKMHTL4jjX4u3Kir2EW07nRfcqVTXIImx50wbjHQTcZPgg+gjVeNTIT3l3L1Rd4UefxfogquC9J37SoFyyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "30.4.1", + "ansi-styles": "^5.2.0", + "react-is-18": "npm:react-is@^18.3.1", + "react-is-19": "npm:react-is@^19.2.5" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-cli": { + "version": "30.4.2", + "resolved": "https://registry.npmjs.org/jest-cli/-/jest-cli-30.4.2.tgz", + "integrity": "sha512-jfA2ocvVHMXS2QijrJ0d31ektP+d/W0T5RpcTX2Pq+3sVqHlsXVCM2+FmwpL+bdY8OfHpIg9xMxLF17Zg0U49Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/core": "30.4.2", + "@jest/test-result": "30.4.1", + "@jest/types": "30.4.1", + "chalk": "^4.1.2", + "exit-x": "^0.2.2", + "import-local": "^3.2.0", + "jest-config": "30.4.2", + "jest-util": "30.4.1", + "jest-validate": "30.4.1", + "yargs": "^17.7.2" + }, + "bin": { + "jest": "bin/jest.js" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/jest-config": { + "version": "30.4.2", + "resolved": "https://registry.npmjs.org/jest-config/-/jest-config-30.4.2.tgz", + "integrity": "sha512-rNHAShJQqQwFNoL0hbf3BphSBOWnpOUAKvidLS/AjNVLPfoj5mSf4jQMfW3cYOs6hXeZC7nF7mDHaBnbxELOzg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.27.4", + "@jest/get-type": "30.1.0", + "@jest/pattern": "30.4.0", + "@jest/test-sequencer": "30.4.1", + "@jest/types": "30.4.1", + "babel-jest": "30.4.1", + "chalk": "^4.1.2", + "ci-info": "^4.2.0", + "deepmerge": "^4.3.1", + "glob": "^10.5.0", + "graceful-fs": "^4.2.11", + "jest-circus": "30.4.2", + "jest-docblock": "30.4.0", + "jest-environment-node": "30.4.1", + "jest-regex-util": "30.4.0", + "jest-resolve": "30.4.1", + "jest-runner": "30.4.2", + "jest-util": "30.4.1", + "jest-validate": "30.4.1", + "parse-json": "^5.2.0", + "pretty-format": "30.4.1", + "slash": "^3.0.0", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "@types/node": "*", + "esbuild-register": ">=3.4.0", + "ts-node": ">=9.0.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "esbuild-register": { + "optional": true + }, + "ts-node": { + "optional": true + } + } + }, + "node_modules/jest-config/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-config/node_modules/pretty-format": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.4.1.tgz", + "integrity": "sha512-K6KiKMHTL4jjX4u3Kir2EW07nRfcqVTXIImx50wbjHQTcZPgg+gjVeNTIT3l3L1Rd4UefxfogquC9J37SoFyyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "30.4.1", + "ansi-styles": "^5.2.0", + "react-is-18": "npm:react-is@^18.3.1", + "react-is-19": "npm:react-is@^19.2.5" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-diff": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-30.4.1.tgz", + "integrity": "sha512-CRpFK0RtLriVDGcPPAnR6HMVI8bSR2jnUIgralhauzYQZIb4RH9AtEInTuQr65LmmGggGcRT6HIASxwqsVsmlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/diff-sequences": "30.4.0", + "@jest/get-type": "30.1.0", + "chalk": "^4.1.2", + "pretty-format": "30.4.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-diff/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/pretty-format": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.4.1.tgz", + "integrity": "sha512-K6KiKMHTL4jjX4u3Kir2EW07nRfcqVTXIImx50wbjHQTcZPgg+gjVeNTIT3l3L1Rd4UefxfogquC9J37SoFyyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "30.4.1", + "ansi-styles": "^5.2.0", + "react-is-18": "npm:react-is@^18.3.1", + "react-is-19": "npm:react-is@^19.2.5" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-docblock": { + "version": "30.4.0", + "resolved": "https://registry.npmjs.org/jest-docblock/-/jest-docblock-30.4.0.tgz", + "integrity": "sha512-ZPMabUZCx5MpbZ2eBYSvZ0J8fvo3dR9oM+eeUpb3aKNQFuS2tu3Duw1TNlMoP8k3WQgKGJuhcMFvwcVuq6T7oA==", + "dev": true, + "license": "MIT", + "dependencies": { + "detect-newline": "^3.1.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-each": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-each/-/jest-each-30.4.1.tgz", + "integrity": "sha512-/8MJbH6fuj48TstjrMf+u/pd06Qezz5xOXvZA6442heNOWr8bdeoGZX2d9fCn028CoMgYmroH9//zky5GfyYmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/get-type": "30.1.0", + "@jest/types": "30.4.1", + "chalk": "^4.1.2", + "jest-util": "30.4.1", + "pretty-format": "30.4.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-each/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-each/node_modules/pretty-format": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.4.1.tgz", + "integrity": "sha512-K6KiKMHTL4jjX4u3Kir2EW07nRfcqVTXIImx50wbjHQTcZPgg+gjVeNTIT3l3L1Rd4UefxfogquC9J37SoFyyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "30.4.1", + "ansi-styles": "^5.2.0", + "react-is-18": "npm:react-is@^18.3.1", + "react-is-19": "npm:react-is@^19.2.5" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-environment-jsdom": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-environment-jsdom/-/jest-environment-jsdom-30.4.1.tgz", + "integrity": "sha512-o3nfaN4zej7qgk2X0j8Jhq/S9nAVKs2xK3QeQxeHVvpkEPxaA1yxDGydR+iVI7zPy7Cp62Aq2h3Ja46QvfWHGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "30.4.1", + "@jest/environment-jsdom-abstract": "30.4.1", + "jsdom": "^26.1.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jest-environment-node": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-30.4.1.tgz", + "integrity": "sha512-4FZYVOk85hz2AyT6BbarKy9u37g6DbrDyCdFhsnDdXqyrueYQvB+0zO4f/kqLCRD0BsPRXPMNJeQwihKZV8naw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "30.4.1", + "@jest/fake-timers": "30.4.1", + "@jest/types": "30.4.1", + "@types/node": "*", + "jest-mock": "30.4.1", + "jest-util": "30.4.1", + "jest-validate": "30.4.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-haste-map": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-30.4.1.tgz", + "integrity": "sha512-rFrcONd8jeFsyw+Z9CrScJgglRf2+NFmNam8dKu7n+SoHqNYT47mn0DdEcVUZJpvh7Iz6/si7f7yUH7GJHVgnw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "30.4.1", + "@types/node": "*", + "anymatch": "^3.1.3", + "fb-watchman": "^2.0.2", + "graceful-fs": "^4.2.11", + "jest-regex-util": "30.4.0", + "jest-util": "30.4.1", + "jest-worker": "30.4.1", + "picomatch": "^4.0.3", + "walker": "^1.0.8" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "optionalDependencies": { + "fsevents": "^2.3.3" + } + }, + "node_modules/jest-leak-detector": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-30.4.1.tgz", + "integrity": "sha512-IpmyiioeHxiWDhesHnUFmOxcTzwCwKpgACgWajtAP+nYQXiY7DakTxB6Bx9JFiRMljr0AX1PvnQdaU1KFoz6NQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/get-type": "30.1.0", + "pretty-format": "30.4.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-leak-detector/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-leak-detector/node_modules/pretty-format": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.4.1.tgz", + "integrity": "sha512-K6KiKMHTL4jjX4u3Kir2EW07nRfcqVTXIImx50wbjHQTcZPgg+gjVeNTIT3l3L1Rd4UefxfogquC9J37SoFyyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "30.4.1", + "ansi-styles": "^5.2.0", + "react-is-18": "npm:react-is@^18.3.1", + "react-is-19": "npm:react-is@^19.2.5" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-matcher-utils": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-30.4.1.tgz", + "integrity": "sha512-zvYfX5CaeEkFrrLS9suWe9rvJrm9J1Iv3ua8kIBv9GEPzcnsfBf0bob37la7s67fs0nlBC3EuvkOLnXQKxtx4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/get-type": "30.1.0", + "chalk": "^4.1.2", + "jest-diff": "30.4.1", + "pretty-format": "30.4.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-matcher-utils/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/pretty-format": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.4.1.tgz", + "integrity": "sha512-K6KiKMHTL4jjX4u3Kir2EW07nRfcqVTXIImx50wbjHQTcZPgg+gjVeNTIT3l3L1Rd4UefxfogquC9J37SoFyyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "30.4.1", + "ansi-styles": "^5.2.0", + "react-is-18": "npm:react-is@^18.3.1", + "react-is-19": "npm:react-is@^19.2.5" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-message-util": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-30.4.1.tgz", + "integrity": "sha512-kwCKIvq0MCW1HzLoGola9Te6JUdzgV0loyKJ3Qghrkz9i5/RRIHsL95BMQc2HBBhlBKC4j22K9p11TGHH8RBpQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@jest/types": "30.4.1", + "@types/stack-utils": "^2.0.3", + "chalk": "^4.1.2", + "graceful-fs": "^4.2.11", + "jest-util": "30.4.1", + "picomatch": "^4.0.3", + "pretty-format": "30.4.1", + "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/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/pretty-format": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.4.1.tgz", + "integrity": "sha512-K6KiKMHTL4jjX4u3Kir2EW07nRfcqVTXIImx50wbjHQTcZPgg+gjVeNTIT3l3L1Rd4UefxfogquC9J37SoFyyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "30.4.1", + "ansi-styles": "^5.2.0", + "react-is-18": "npm:react-is@^18.3.1", + "react-is-19": "npm:react-is@^19.2.5" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-mock": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-30.4.1.tgz", + "integrity": "sha512-/i8SVb8/NSB7RfNi8gfqu8gxLV23KaL5EpAttyb9iz8qWRIqXRLflycz/32wXsYkOnaUlx8NAKnJYtpsmXUmfw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "30.4.1", + "@types/node": "*", + "jest-util": "30.4.1" + }, + "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.4.0", + "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-30.4.0.tgz", + "integrity": "sha512-mWlvLviKIgIQ8VCuM1xRdD0TWp3zlzionlmDBjuXVBs+VkmXq6FgW9T4Emr7oGz/Rk6feDCGyiugolcQEyp3mg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-resolve": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-resolve/-/jest-resolve-30.4.1.tgz", + "integrity": "sha512-Zry8Yq/yJcNAZ7dJ5F2heic8AheXvbFZ7XI5V+h28nrYZ7Qoyy4dItq8OodjnYD270mvX+ZudmrNV9cysqhW5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.1.2", + "graceful-fs": "^4.2.11", + "jest-haste-map": "30.4.1", + "jest-pnp-resolver": "^1.2.3", + "jest-util": "30.4.1", + "jest-validate": "30.4.1", + "slash": "^3.0.0", + "unrs-resolver": "^1.7.11" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-resolve-dependencies": { + "version": "30.4.2", + "resolved": "https://registry.npmjs.org/jest-resolve-dependencies/-/jest-resolve-dependencies-30.4.2.tgz", + "integrity": "sha512-gDiVh1I+GxYzz9oXlyw+1wv6VOYX1WYxMOfjsA3iGKePV2oxmbHhwxfkALxNxYy1ciw6APWwkW2zZONwP97aEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "jest-regex-util": "30.4.0", + "jest-snapshot": "30.4.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-runner": { + "version": "30.4.2", + "resolved": "https://registry.npmjs.org/jest-runner/-/jest-runner-30.4.2.tgz", + "integrity": "sha512-2dw0PslVYXxffXGpLo+Ejad+KcI1Qkjn7f4X4619gf21oCUmL+SPfjqIa/losUem3yEOvfNZe/F1HWUcNpODcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/console": "30.4.1", + "@jest/environment": "30.4.1", + "@jest/test-result": "30.4.1", + "@jest/transform": "30.4.1", + "@jest/types": "30.4.1", + "@types/node": "*", + "chalk": "^4.1.2", + "emittery": "^0.13.1", + "exit-x": "^0.2.2", + "graceful-fs": "^4.2.11", + "jest-docblock": "30.4.0", + "jest-environment-node": "30.4.1", + "jest-haste-map": "30.4.1", + "jest-leak-detector": "30.4.1", + "jest-message-util": "30.4.1", + "jest-resolve": "30.4.1", + "jest-runtime": "30.4.2", + "jest-util": "30.4.1", + "jest-watcher": "30.4.1", + "jest-worker": "30.4.1", + "p-limit": "^3.1.0", + "source-map-support": "0.5.13" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-runtime": { + "version": "30.4.2", + "resolved": "https://registry.npmjs.org/jest-runtime/-/jest-runtime-30.4.2.tgz", + "integrity": "sha512-3/5e8iPz2k/VLqlr8DgTftYyLUv8Su3FkCAO2/Od81UsUTpSxOrS6O5x5KkoQwyUjmpYyDJKeyAvg2T2nvpNkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "30.4.1", + "@jest/fake-timers": "30.4.1", + "@jest/globals": "30.4.1", + "@jest/source-map": "30.0.1", + "@jest/test-result": "30.4.1", + "@jest/transform": "30.4.1", + "@jest/types": "30.4.1", + "@types/node": "*", + "chalk": "^4.1.2", + "cjs-module-lexer": "^2.1.0", + "collect-v8-coverage": "^1.0.2", + "glob": "^10.5.0", + "graceful-fs": "^4.2.11", + "jest-haste-map": "30.4.1", + "jest-message-util": "30.4.1", + "jest-mock": "30.4.1", + "jest-regex-util": "30.4.0", + "jest-resolve": "30.4.1", + "jest-snapshot": "30.4.1", + "jest-util": "30.4.1", + "slash": "^3.0.0", + "strip-bom": "^4.0.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-snapshot": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-30.4.1.tgz", + "integrity": "sha512-tEOkkfOMppUyeiHwjZswOQ3lcnoTnws/q5FnGIaeIh/jmoU0ZlgMYRR8sTlTj+nNGCoJ0RDq6SfxGxCsyMTPmw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.27.4", + "@babel/generator": "^7.27.5", + "@babel/plugin-syntax-jsx": "^7.27.1", + "@babel/plugin-syntax-typescript": "^7.27.1", + "@babel/types": "^7.27.3", + "@jest/expect-utils": "30.4.1", + "@jest/get-type": "30.1.0", + "@jest/snapshot-utils": "30.4.1", + "@jest/transform": "30.4.1", + "@jest/types": "30.4.1", + "babel-preset-current-node-syntax": "^1.2.0", + "chalk": "^4.1.2", + "expect": "30.4.1", + "graceful-fs": "^4.2.11", + "jest-diff": "30.4.1", + "jest-matcher-utils": "30.4.1", + "jest-message-util": "30.4.1", + "jest-util": "30.4.1", + "pretty-format": "30.4.1", + "semver": "^7.7.2", + "synckit": "^0.11.8" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-snapshot/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-snapshot/node_modules/pretty-format": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.4.1.tgz", + "integrity": "sha512-K6KiKMHTL4jjX4u3Kir2EW07nRfcqVTXIImx50wbjHQTcZPgg+gjVeNTIT3l3L1Rd4UefxfogquC9J37SoFyyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "30.4.1", + "ansi-styles": "^5.2.0", + "react-is-18": "npm:react-is@^18.3.1", + "react-is-19": "npm:react-is@^19.2.5" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-snapshot/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/jest-util": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.4.1.tgz", + "integrity": "sha512-vjQb1sACEiv13DKJMDToJpzVW0joCsIQrmbg0fi7CyOOt+g9jTuQl2A216pWRBYhOVt53XbL/2LbMKg1BECWOw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "30.4.1", + "@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-validate": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-30.4.1.tgz", + "integrity": "sha512-PDWi4SOwLnwqNDfHZjOcsEFyZ4fc/2W2gVL3DEoyqnB6jCQMLRtfBong8s6omIw3lI0HWOus12xfnFmQtjW3fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/get-type": "30.1.0", + "@jest/types": "30.4.1", + "camelcase": "^6.3.0", + "chalk": "^4.1.2", + "leven": "^3.1.0", + "pretty-format": "30.4.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-validate/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-validate/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/jest-validate/node_modules/pretty-format": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.4.1.tgz", + "integrity": "sha512-K6KiKMHTL4jjX4u3Kir2EW07nRfcqVTXIImx50wbjHQTcZPgg+gjVeNTIT3l3L1Rd4UefxfogquC9J37SoFyyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "30.4.1", + "ansi-styles": "^5.2.0", + "react-is-18": "npm:react-is@^18.3.1", + "react-is-19": "npm:react-is@^19.2.5" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-watcher": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-watcher/-/jest-watcher-30.4.1.tgz", + "integrity": "sha512-/l9UonmvCwjHH7d2h3iAwIloLc1H0S8mJZ/LNK3i86hqwPAz8otUJjP9MfYtz9Tt77Su5FD2xGjZn8d31IZHlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/test-result": "30.4.1", + "@jest/types": "30.4.1", + "@types/node": "*", + "ansi-escapes": "^4.3.2", + "chalk": "^4.1.2", + "emittery": "^0.13.1", + "jest-util": "30.4.1", + "string-length": "^4.0.2" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-worker": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-30.4.1.tgz", + "integrity": "sha512-SHynN/q/QD++iNyvMdy+WMmbCGk8jIsNcRxycXbWubSOhvo6T+j2afcfUSl+3hYsiBebOTo0cT7c2H7CXugu1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@ungap/structured-clone": "^1.3.0", + "jest-util": "30.4.1", + "merge-stream": "^2.0.0", + "supports-color": "^8.1.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.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": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "3.15.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.1.tgz", + "integrity": "sha512-S99WuO3HlhO3XN41EtYUNl9zzXjoJx7QvmipxsJVxtCBT0YHEFy+iOJhjSvrmV12nYhWpZaM8lPHkJm0yUMbag==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsdom": { + "version": "26.1.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-26.1.0.tgz", + "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssstyle": "^4.2.1", + "data-urls": "^5.0.0", + "decimal.js": "^10.5.0", + "html-encoding-sniffer": "^4.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.6", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.16", + "parse5": "^7.2.1", + "rrweb-cssom": "^0.8.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^5.1.1", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.1.1", + "ws": "^8.18.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/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/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/jsonc-parser": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz", + "integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==", + "dev": true, + "license": "MIT" + }, + "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/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/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/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/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/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", + "peer": true, + "bin": { + "lz-string": "bin/bin.js" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/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/make-dir/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "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/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/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/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "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/napi-postinstall": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.4.tgz", + "integrity": "sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==", + "dev": true, + "license": "MIT", + "bin": { + "napi-postinstall": "lib/cli.js" + }, + "engines": { + "node": "^12.20.0 || ^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/napi-postinstall" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/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.53", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.53.tgz", + "integrity": "sha512-D9UOmYG3UH1V+ENW56t5QXBwJw1YEY18ruVeus89Rw+SyIgjPkCO84bRzO3uNIYosJbNwiabWVn48o3uJLjxFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "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/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/nwsapi": { + "version": "2.2.24", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.24.tgz", + "integrity": "sha512-7YRhZ3jS45LwmSCT4b2sVFHt/WuovaktDU07QrtOBY2PXskss5a9jfmR9jptyumwXST+rFjrmppMY1KT/yn35A==", + "dev": true, + "license": "MIT" + }, + "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/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/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/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/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true, + "license": "BlueOak-1.0.0" + }, + "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": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/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-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/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.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "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-dir/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/pkg-dir/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/pkg-dir/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/pkg-dir/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/playwright": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz", + "integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.62.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz", + "integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/postcss": { + "version": "8.5.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", + "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.17", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/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", + "peer": true, + "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", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/prismjs": { + "version": "1.30.0", + "resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.30.0.tgz", + "integrity": "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "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/pure-rand": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-7.0.1.tgz", + "integrity": "sha512-oTUZM/NAZS8p7ANR3SHh30kXB+zK2r2BPcEn/awJIbOvq82WoMN4p62AWWp3Hhw50G0xMsw1mhIBLqHw64EcNQ==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT" + }, + "node_modules/react": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", + "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz", + "integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.8" + } + }, + "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", + "peer": true + }, + "node_modules/react-is-18": { + "name": "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/react-is-19": { + "name": "react-is", + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.8.tgz", + "integrity": "sha512-s5un28nYxKJw5gvUHyW5PCC28CvBqLu9r3cWgzHT4Vo/5fqqkFcdRYsGcKf50WMPpjjFZS5d76fn3YCo2njKwQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/react-router": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-8.3.0.tgz", + "integrity": "sha512-qyPMvW83jGIct3yiieisxdk9M745anqhpIMKN5m1t6yBMfgVPpt77aHOqs5fUlEJRMCGffg9BaQLH9oPVOL7xQ==", + "license": "MIT", + "dependencies": { + "cookie-es": "^3.1.1" + }, + "engines": { + "node": ">=22.22.0" + }, + "peerDependencies": { + "react": ">=19.2.7", + "react-dom": ">=19.2.7" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-simple-code-editor": { + "version": "0.14.1", + "resolved": "https://registry.npmjs.org/react-simple-code-editor/-/react-simple-code-editor-0.14.1.tgz", + "integrity": "sha512-BR5DtNRy+AswWJECyA17qhUDvrrCZ6zXOCfkQY5zSmb96BVUbpVAv03WpcjcwtCwiLbIANx3gebHOcXYn1EHow==", + "license": "MIT", + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.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/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/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/rolldown": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.3.tgz", + "integrity": "sha512-rn9wpmxplLf7NLNyCk9FyWh3FM43DbY8jOzCdEPzH7uflhTftRbCEpqi6Ly2osgoU8OwObtmavMbWLaWy4LX7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.143.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.2.3", + "@rolldown/binding-darwin-arm64": "1.2.3", + "@rolldown/binding-darwin-x64": "1.2.3", + "@rolldown/binding-freebsd-x64": "1.2.3", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.3", + "@rolldown/binding-linux-arm64-gnu": "1.2.3", + "@rolldown/binding-linux-arm64-musl": "1.2.3", + "@rolldown/binding-linux-ppc64-gnu": "1.2.3", + "@rolldown/binding-linux-s390x-gnu": "1.2.3", + "@rolldown/binding-linux-x64-gnu": "1.2.3", + "@rolldown/binding-linux-x64-musl": "1.2.3", + "@rolldown/binding-openharmony-arm64": "1.2.3", + "@rolldown/binding-win32-arm64-msvc": "1.2.3", + "@rolldown/binding-win32-x64-msvc": "1.2.3" + } + }, + "node_modules/rrweb-cssom": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", + "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/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/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "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/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/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-support": { + "version": "0.5.13", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.13.tgz", + "integrity": "sha512-SHSKFHadjVA5oR4PPqhtAVdcBWwRYVd6g6cAXnIbRiIwc2EhPrTuKUBdSLvlEKyIP3GCf89fltvcZiP9MMFA1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "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/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/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-length/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/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string-width-cjs": { + "name": "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-cjs/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-width-cjs/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-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/strip-ansi-cjs": { + "name": "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-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/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-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/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/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/synckit": { + "version": "0.11.13", + "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.13.tgz", + "integrity": "sha512-eNRKgb3z66Yp3D2CixVujOUvXLFUTij/zVnV8KRyvFdQwpz7I5DS8UfRkTeLzb64u+dkzDSdelE24izu+zSSUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@pkgr/core": "^0.3.6" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/synckit" + } + }, + "node_modules/tailwindcss": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz", + "integrity": "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/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/test-exclude/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/test-exclude/node_modules/brace-expansion": { + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/test-exclude/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/test-exclude/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/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tldts": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz", + "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^6.1.86" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz", + "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/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/tough-cookie": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz", + "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^6.1.32" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/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", + "optional": true + }, + "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.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/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.67.0.tgz", + "integrity": "sha512-S2udFs8tCKEKffuJ4TB1idGUZiXdCPGi3IPBGWXarbLQ5UPXORV8QEVzJ4gCRduURMb5EkpNCdjbk0eDIuI8Yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.67.0", + "@typescript-eslint/parser": "8.67.0", + "@typescript-eslint/typescript-estree": "8.67.0", + "@typescript-eslint/utils": "8.67.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/undici-types": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", + "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/unrs-resolver": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.12.2.tgz", + "integrity": "sha512-dmlRxBJJayXjqTwC+JtF1HhJmgf3ftQ3YejFcZrf4+KKtJv0qDsK1pjqaaVjG7wJ5NJ6UVP1OqRMQ71Z4C3rxQ==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "napi-postinstall": "^0.3.4" + }, + "funding": { + "url": "https://opencollective.com/unrs-resolver" + }, + "optionalDependencies": { + "@unrs/resolver-binding-android-arm-eabi": "1.12.2", + "@unrs/resolver-binding-android-arm64": "1.12.2", + "@unrs/resolver-binding-darwin-arm64": "1.12.2", + "@unrs/resolver-binding-darwin-x64": "1.12.2", + "@unrs/resolver-binding-freebsd-x64": "1.12.2", + "@unrs/resolver-binding-linux-arm-gnueabihf": "1.12.2", + "@unrs/resolver-binding-linux-arm-musleabihf": "1.12.2", + "@unrs/resolver-binding-linux-arm64-gnu": "1.12.2", + "@unrs/resolver-binding-linux-arm64-musl": "1.12.2", + "@unrs/resolver-binding-linux-loong64-gnu": "1.12.2", + "@unrs/resolver-binding-linux-loong64-musl": "1.12.2", + "@unrs/resolver-binding-linux-ppc64-gnu": "1.12.2", + "@unrs/resolver-binding-linux-riscv64-gnu": "1.12.2", + "@unrs/resolver-binding-linux-riscv64-musl": "1.12.2", + "@unrs/resolver-binding-linux-s390x-gnu": "1.12.2", + "@unrs/resolver-binding-linux-x64-gnu": "1.12.2", + "@unrs/resolver-binding-linux-x64-musl": "1.12.2", + "@unrs/resolver-binding-openharmony-arm64": "1.12.2", + "@unrs/resolver-binding-wasm32-wasi": "1.12.2", + "@unrs/resolver-binding-win32-arm64-msvc": "1.12.2", + "@unrs/resolver-binding-win32-ia32-msvc": "1.12.2", + "@unrs/resolver-binding-win32-x64-msvc": "1.12.2" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.1.tgz", + "integrity": "sha512-ZZ61DsRsOnakl74HAmp3oSN4aXUmEWXf+i/yv0h7tIBfICc3VdrFErQKUUKPgu3AMsTUMbcongALEN4l6GSUrQ==", + "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/v8-to-istanbul": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz", + "integrity": "sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==", + "dev": true, + "license": "ISC", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.12", + "@types/istanbul-lib-coverage": "^2.0.1", + "convert-source-map": "^2.0.0" + }, + "engines": { + "node": ">=10.12.0" + } + }, + "node_modules/vite": { + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.1.tgz", + "integrity": "sha512-EU/eS7BH3XROHh2YnBefjM6DBKA6ZeMZEYQbj7NLWg5wHYlhB8B/Mayd5XsgWq+NFYccDOTemRpdETWR6Ka/lw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.25", + "rolldown": "~1.2.1", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.4.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/vite/node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/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/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-url": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/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/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/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "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/wrap-ansi-cjs/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/wrap-ansi-cjs/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/wrap-ansi-cjs/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/wrap-ansi/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?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": "5.0.1", + "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-5.0.1.tgz", + "integrity": "sha512-+QU2zd6OTD8XWIJCbffaiQeH9U73qIqafo1x6V1snCWYGJf6cVE0cDR4D8xRzcEnfI21IFrUPzPGtcPf8AC+Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "imurmurhash": "^0.1.4", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/ws": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz", + "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, + "node_modules/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/yargs": { + "version": "17.7.3", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.3.tgz", + "integrity": "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs/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/yargs/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/yargs/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/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-validation-error": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz", + "integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + } + }, + "node_modules/zustand": { + "version": "5.0.14", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.14.tgz", + "integrity": "sha512-/8tAspM5LMPr28b3fwLYrtdj77ECpfZviaP75CMTnwO8ISyaE4GDIG/9rDDYq/cH9D2Xw2A2RXglLInmVBQB/g==", + "license": "MIT", + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "@types/react": ">=18.0.0", + "immer": ">=9.0.6", + "react": ">=18.0.0", + "use-sync-external-store": ">=1.2.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + }, + "use-sync-external-store": { + "optional": true + } + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..ad1f510 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,47 @@ +{ + "name": "doppel-dashboard", + "private": true, + "type": "module", + "license": "Apache-2.0", + "description": "The browser dashboard Doppel's admin listener serves from its root.", + "scripts": { + "prebuild": "node scripts/copy-favicon.mjs", + "dev": "vite", + "build": "vite build", + "lint": "eslint .", + "typecheck": "tsc --noEmit", + "test": "jest", + "e2e": "playwright test" + }, + "dependencies": { + "prismjs": "^1.30.0", + "react": "^19.2.8", + "react-dom": "^19.2.8", + "react-router": "^8.3.0", + "react-simple-code-editor": "^0.14.1", + "zustand": "^5.0.14" + }, + "devDependencies": { + "@eslint/js": "^10.0.1", + "@playwright/test": "^1.62.1", + "@swc/core": "^1.15.47", + "@swc/jest": "^0.2.39", + "@tailwindcss/vite": "^4.3.3", + "@testing-library/jest-dom": "^7.0.1", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.3", + "@types/jest": "^30.0.0", + "@types/prismjs": "^1.26.5", + "@types/react": "^19.2.18", + "@types/react-dom": "^19.2.4", + "@vitejs/plugin-react": "^6.0.5", + "eslint": "^10.8.1", + "eslint-plugin-react-hooks": "^7.1.1", + "jest": "^30.4.2", + "jest-environment-jsdom": "^30.4.1", + "tailwindcss": "^4.3.3", + "typescript": "~5.9.3", + "typescript-eslint": "^8.67.0", + "vite": "^8.2.1" + } +} diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts new file mode 100644 index 0000000..93db844 --- /dev/null +++ b/frontend/playwright.config.ts @@ -0,0 +1,27 @@ +import { defineConfig, devices } from '@playwright/test' + +/** + * The dashboard, in a real browser, against a real Doppel. + * + * No `webServer`: each spec file starts its own server, because the + * configuration under test -- public, private, dashboard off -- is a property of + * the server rather than of the page. See e2e/src/server.ts. + */ +export default defineConfig({ + // The specs live under `e2e/tests`; `e2e/src` is the harness they drive, and + // Playwright must not go looking for tests in it. + testDir: './e2e/tests', + // One browser. The dashboard uses nothing engine-specific, and three browsers + // would triple a suite whose subject is this application's behaviour. + projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }], + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: 0, + reporter: process.env.CI ? [['html', { open: 'never' }], ['list']] : 'list', + use: { + // Only on a failure: a trace for every passing test is minutes of upload for + // something nobody opens. + trace: 'retain-on-failure', + }, + timeout: 30_000, +}) diff --git a/frontend/scripts/copy-favicon.mjs b/frontend/scripts/copy-favicon.mjs new file mode 100644 index 0000000..bea8192 --- /dev/null +++ b/frontend/scripts/copy-favicon.mjs @@ -0,0 +1,19 @@ +// The favicon comes from `assets/` at the repository root, the same directory the +// documentation build stages into `docs/assets/`. Copied rather than committed +// under `public/` so there is one source of truth for the icon; the copy is +// git-ignored. +// +// Node's own `fs` rather than a vite plugin: a plugin would be a dependency for +// two lines, and `CONTRIBUTING.md` asks what a new one does that the standard +// library cannot. +import { copyFileSync, mkdirSync } from 'node:fs' +import { dirname, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +const here = dirname(fileURLToPath(import.meta.url)) +const source = resolve(here, '../../assets/favicon.ico') +const target = resolve(here, '../public/favicon.ico') + +mkdirSync(dirname(target), { recursive: true }) +copyFileSync(source, target) +console.log(`favicon: ${source} -> ${target}`) diff --git a/frontend/src/__mocks__/style.ts b/frontend/src/__mocks__/style.ts new file mode 100644 index 0000000..8c107ee --- /dev/null +++ b/frontend/src/__mocks__/style.ts @@ -0,0 +1,2 @@ +// Styles are a vite concern. Under jest a stylesheet import resolves here. +export default {} diff --git a/frontend/src/__tests__/bundle-size.test.ts b/frontend/src/__tests__/bundle-size.test.ts new file mode 100644 index 0000000..6e77cb2 --- /dev/null +++ b/frontend/src/__tests__/bundle-size.test.ts @@ -0,0 +1,94 @@ +// The static payload has a budget, and the budget has a test. +// +// "As small as possible" is a preference until something fails when it is +// broken. Measured gzipped, because that is what crosses the wire, and split by +// role: the entry chunk is what every visitor pays for, a page chunk is paid +// only by whoever opens that page, and the editor chunk exists precisely so that +// prism is not paid for by someone who only lists proxies. +// +// The numbers are the measured sizes plus about ten percent. They are recorded +// beside each budget so that a jump shows up in the diff as a number somebody +// chose rather than as a limit quietly raised to make a build pass. +import { gzipSync } from 'node:zlib' +import { existsSync, readFileSync, readdirSync } from 'node:fs' +import { join } from 'node:path' + +const DIST = join(__dirname, '../../dist') +const ASSETS = join(DIST, 'assets') + +/** Gzipped kilobytes of one built file. */ +const gzippedKb = (file: string): number => + gzipSync(readFileSync(join(ASSETS, file))).length / 1024 + +/** The entry chunk is the one `index.html` loads directly. */ +const entryName = (): string => { + const html = readFileSync(join(DIST, 'index.html'), 'utf8') + const match = /\/static\/assets\/(index-[^"']+\.js)/.exec(html) + if (!match?.[1]) { + throw new Error(`index.html does not reference an entry chunk:\n${html}`) + } + return match[1] +} + +describe('the built bundle', () => { + beforeAll(() => { + if (!existsSync(ASSETS)) { + throw new Error( + `${ASSETS} does not exist -- run \`npm run build\` before \`npm test\`, ` + + 'which CI does in that order', + ) + } + }) + + it('keeps the entry chunk within its budget', () => { + // Measured 73.9 KB: react, react-dom, react-router's component API and the + // shell. It was 90 with `createBrowserRouter`, whose loaders and fetchers + // this app does not use -- see app/routes.tsx. + const BUDGET_KB = 82 + const size = gzippedKb(entryName()) + expect(size).toBeLessThanOrEqual(BUDGET_KB) + }) + + it('keeps every lazy chunk within its budget', () => { + // Measured: the largest page chunk is 3.7 KB (the proxy form) and the editor + // is 10.8. The page budget is deliberately looser than measured-plus-ten: + // a page gaining a section is ordinary and should not need this file edited, + // while a page gaining a *library* is what this is here to catch, and any + // library clears 8 KB. + const PAGE_BUDGET_KB = 8 + const EDITOR_BUDGET_KB = 12 + const entry = entryName() + + for (const file of readdirSync(ASSETS).filter((f) => f.endsWith('.js') && f !== entry)) { + const budget = file.includes('CodeEditor') ? EDITOR_BUDGET_KB : PAGE_BUDGET_KB + expect(gzippedKb(file)).toBeLessThanOrEqual(budget) + } + }) + + it('keeps the stylesheet within its budget', () => { + // Measured 3.8 KB. Tailwind emits only the utilities the source actually + // uses, so this grows with the number of distinct classes, not with the app. + const BUDGET_KB = 6 + const total = readdirSync(ASSETS) + .filter((file) => file.endsWith('.css')) + .reduce((sum, file) => sum + gzippedKb(file), 0) + expect(total).toBeLessThanOrEqual(BUDGET_KB) + }) + + it('keeps the whole payload within its budget', () => { + // Measured 100 KB across every chunk and the stylesheet. The sum is here so + // that splitting a chunk in two cannot slip past the per-chunk budgets while + // making the payload heavier overall. + const BUDGET_KB = 110 + const total = readdirSync(ASSETS).reduce((sum, file) => sum + gzippedKb(file), 0) + expect(total).toBeLessThanOrEqual(BUDGET_KB) + }) + + it('does not carry the syntax highlighter in the entry chunk', () => { + // The whole reason the editor is a lazy chunk. A budget alone would not + // catch this: prism is small enough to hide inside the entry's slack, and + // then every visitor pays for it to list proxies. + const entry = readFileSync(join(ASSETS, entryName()), 'utf8') + expect(entry).not.toMatch(/prism/i) + }) +}) diff --git a/frontend/src/__tests__/schema-drift.test.ts b/frontend/src/__tests__/schema-drift.test.ts new file mode 100644 index 0000000..e7c30d0 --- /dev/null +++ b/frontend/src/__tests__/schema-drift.test.ts @@ -0,0 +1,67 @@ +// The form covers every field of a proxy. This is what keeps that true. +// +// A structured form's real failure mode is not a bug in a control -- it is a +// field the configuration format grew that nobody added a control for. Nothing +// fails when that happens: the form saves a document without the field, the +// server accepts it, and the operator quietly loses the setting they had. +// +// So this reads the generated schema -- the same file the Rust drift test holds +// the code to -- and fails when a property is not mentioned anywhere in the form. +// Mentioned is a coarse test, and deliberately so: it cannot prove a control is +// correct, only that the field was not forgotten, which is the failure that +// actually happens. +import { readFileSync } from 'node:fs' +import { join } from 'node:path' + +const schema = JSON.parse( + readFileSync(join(__dirname, '../../../doppel-config.schema.json'), 'utf8'), +) as { $defs: Record }> } + +/** The form's source, concatenated. */ +const source = ['../pages/ProxyFormPage.tsx', '../components/MockEditor.tsx'] + .map((file) => readFileSync(join(__dirname, file), 'utf8')) + .join('\n') + +/** The TypeScript model the form edits. */ +const model = readFileSync(join(__dirname, '../types/proxy.ts'), 'utf8') + +const propertiesOf = (name: string): string[] => { + const definition = schema.$defs[name] + if (!definition?.properties) { + throw new Error(`${name} is not in the schema, or has no properties`) + } + return Object.keys(definition.properties) +} + +describe.each([ + ['ProxyConfig'], + ['MockConfig'], + ['MockRequest'], + ['MockResponse'], + ['ResolveConfig'], + ['LossConfig'], + ['LatencyConfig'], + ['ProxyAccessConfig'], +])('%s', (definition) => { + it('has every property in the form model', () => { + for (const property of propertiesOf(definition)) { + expect(model).toContain(property) + } + }) + + it('has every property mentioned by the form', () => { + for (const property of propertiesOf(definition)) { + expect(source).toContain(property) + } + }) +}) + +describe('the schema itself', () => { + it('is the one the server generates', () => { + // A guard on the two paths above: if the schema moved, both loops would pass + // against an empty file and this suite would report success while checking + // nothing. + expect(Object.keys(schema.$defs).length).toBeGreaterThan(20) + expect(propertiesOf('ProxyConfig')).toContain('url') + }) +}) diff --git a/frontend/src/app/App.tsx b/frontend/src/app/App.tsx new file mode 100644 index 0000000..c17f0ef --- /dev/null +++ b/frontend/src/app/App.tsx @@ -0,0 +1,54 @@ +import { Suspense, useEffect } from 'react' +import { Outlet } from 'react-router' + +import { Footer } from '../components/Footer' +import { Header } from '../components/Header' +import { Spinner } from '../components/Spinner' +import { Toasts } from '../components/Toasts' +import { TokenDialog } from '../components/TokenDialog' +import { runtimeConfig } from '../services/runtimeConfig' +import { useAccess } from '../store/access' +import { useAuth } from '../store/auth' +import { startTheme } from '../store/theme' + +/** + * The shell every page is drawn inside. + * + * It owns two things no page should own: the theme's lifetime, and refetching the + * caller's rights whenever the token changes. Rights are per-token, so a page + * that fetched them for itself would fetch them again on every navigation and + * still miss a sign-out that happened elsewhere. + */ +export function App() { + const token = useAuth((state) => state.token) + const askIfNeeded = useAuth((state) => state.askIfNeeded) + const refreshAccess = useAccess((state) => state.refresh) + + useEffect(() => startTheme(), []) + + useEffect(() => { + document.title = runtimeConfig().title + // Asked here rather than when the store loads: the shell is the first thing + // that exists, and a dialog decided at import time would be decided before + // the page had rendered. + askIfNeeded() + }, [askIfNeeded]) + + useEffect(() => { + void refreshAccess() + }, [token, refreshAccess]) + + return ( +
+
+
+ }> + + +
+
+ + +
+ ) +} diff --git a/frontend/src/app/routes.tsx b/frontend/src/app/routes.tsx new file mode 100644 index 0000000..ae63f1f --- /dev/null +++ b/frontend/src/app/routes.tsx @@ -0,0 +1,36 @@ +import { lazy } from 'react' +import { Route, Routes } from 'react-router' + +import { App } from './App' + +// Every page is its own chunk. That is the requirement -- chunks and lazy +// loading -- and it is also what keeps the entry payload to the shell: someone +// who opens the proxy list never downloads the template editor or prism. +const ProxiesPage = lazy(() => import('../pages/ProxiesPage')) +const ProxyFormPage = lazy(() => import('../pages/ProxyFormPage')) +const StatusPage = lazy(() => import('../pages/StatusPage')) +const TemplatesPage = lazy(() => import('../pages/TemplatesPage')) + +/** + * The route table, in react-router's component form. + * + * Deliberately not `createBrowserRouter`. The data router brings loaders, + * actions, fetchers and their state machine, and this app uses none of it -- the + * pages fetch in an effect and the stores hold what outlives a screen. Measured + * on the first build: the data router put the entry chunk at 90 KB gzipped and + * this leaves it near 76, on a budget written for the smallest static payload + * that will do the job. + */ +export function AppRoutes() { + return ( + + }> + } /> + } /> + } /> + } /> + } /> + + + ) +} diff --git a/frontend/src/app/usePolling.ts b/frontend/src/app/usePolling.ts new file mode 100644 index 0000000..03d6714 --- /dev/null +++ b/frontend/src/app/usePolling.ts @@ -0,0 +1,41 @@ +import { useEffect } from 'react' + +import { runtimeConfig } from '../services/runtimeConfig' + +/** + * Run `tick` now, then every `refreshMs`, but not while the tab is hidden. + * + * The interval comes from the injected configuration, so "once a minute" is one + * value decided by the server rather than a number written in two places. + * + * Pausing on hidden is not an optimisation for the browser's sake: a dashboard + * left open in a background tab overnight would otherwise make about a thousand + * requests nobody reads, and each one carries the admin token. + */ +export function usePolling(tick: () => void): void { + useEffect(() => { + const { refreshMs } = runtimeConfig() + + // Fired immediately as well as on the interval, so opening a page does not + // show an empty table for a minute. + tick() + + let timer = window.setInterval(tick, refreshMs) + + const onVisibility = () => { + window.clearInterval(timer) + if (document.visibilityState === 'visible') { + // Refetched on the way back, because what is on screen is now as stale as + // the time the tab spent hidden. + tick() + timer = window.setInterval(tick, refreshMs) + } + } + + document.addEventListener('visibilitychange', onVisibility) + return () => { + window.clearInterval(timer) + document.removeEventListener('visibilitychange', onVisibility) + } + }, [tick]) +} diff --git a/frontend/src/components/Banner.tsx b/frontend/src/components/Banner.tsx new file mode 100644 index 0000000..7991a41 --- /dev/null +++ b/frontend/src/components/Banner.tsx @@ -0,0 +1,30 @@ +import type { ReactNode } from 'react' + +/** + * A message the operator has to read before the page makes sense. + * + * `action` is for the affordance that fixes it -- "Enter token" beside a 403 -- + * because a message that names a problem without offering the fix leaves the + * reader to work out which of the header's controls they were supposed to use. + */ +export function Banner({ + kind, + children, + action, +}: { + kind: 'error' | 'note' + children: ReactNode + action?: ReactNode +}) { + const tone = + kind === 'error' + ? 'border-red-300 bg-red-50 text-red-900 dark:border-red-800 dark:bg-red-950 dark:text-red-100' + : 'border-slate-300 bg-slate-50 text-slate-800 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100' + + return ( +
+
{children}
+ {action} +
+ ) +} diff --git a/frontend/src/components/Button.tsx b/frontend/src/components/Button.tsx new file mode 100644 index 0000000..fe50373 --- /dev/null +++ b/frontend/src/components/Button.tsx @@ -0,0 +1,37 @@ +import type { ButtonHTMLAttributes, ReactNode } from 'react' + +/** + * A button that says why it is disabled. + * + * `reason` becomes the tooltip and the accessible description. A control the + * caller's token may not use is disabled rather than hidden -- a missing button + * reads as a broken page, a disabled one explains itself -- and that only works + * if the explanation travels with it. + */ +export function Button({ + children, + variant = 'plain', + reason, + ...rest +}: ButtonHTMLAttributes & { + children: ReactNode + variant?: 'primary' | 'plain' | 'danger' + reason?: string +}) { + const tone = { + primary: 'bg-teal-600 text-white hover:bg-teal-500', + plain: 'border border-slate-300 text-slate-700 hover:bg-slate-100 dark:border-slate-600 dark:text-slate-200 dark:hover:bg-slate-800', + danger: 'border border-red-300 text-red-700 hover:bg-red-50 dark:border-red-800 dark:text-red-300 dark:hover:bg-red-950', + }[variant] + + return ( + + ) +} diff --git a/frontend/src/components/CodeEditor.tsx b/frontend/src/components/CodeEditor.tsx new file mode 100644 index 0000000..b3aa315 --- /dev/null +++ b/frontend/src/components/CodeEditor.tsx @@ -0,0 +1,78 @@ +import EditorImport from 'react-simple-code-editor' +import { highlight, languages } from 'prismjs' +import 'prismjs/components/prism-json' +import 'prismjs/components/prism-markup' + +/** + * A textarea with syntax colouring, in its own chunk. + * + * prism and this editor are the only third-party code below the shell, and they + * are here rather than hand-written because a highlighter is a solved problem and + * a home-made one is a tokeniser to maintain. They cost roughly ten kilobytes, + * which is why nothing imports this module directly: the pages that need it load + * it lazily, and `bundle-size.test.ts` fails if prism ever appears in the entry + * chunk. + * + * A template is Jinja inside JSON, HTML or text. prism's `json` and `markup` + * grammars colour the host language; the Jinja braces are left plain rather than + * pulling in a third grammar for the sake of two delimiters. + */ +export type Syntax = 'json' | 'html' | 'text' + +/** + * The editor component, whichever shape the bundler hands over. + * + * `react-simple-code-editor` is CommonJS with `exports.default`, and the two + * builds disagree about what that means: the dev server's interop yields the + * component, while the production build applies Node's semantics and yields the + * whole `module.exports` -- an object, which React refuses with error #130. It + * fails only in the built bundle, so the Playwright suite is what caught it. + * + * Reading `.default` when it is there is correct under both, and costs one line + * instead of pinning a bundler's interop flag that the next major would move. + */ +const Editor = ((EditorImport as unknown as { default?: typeof EditorImport }).default ?? + EditorImport) as typeof EditorImport + +export function CodeEditor({ + value, + syntax, + onChange, + label, + disabled, +}: { + value: string + syntax: Syntax + onChange: (next: string) => void + label: string + disabled?: boolean +}) { + const grammar = syntax === 'json' ? languages.json : syntax === 'html' ? languages.markup : undefined + // The editor renders its own textarea, so the label has to point at it by id -- + // an `aria-label` passed to the component lands on the wrapping element + // instead, which leaves the actual input unlabelled. + const id = `editor-${label.replace(/[^a-zA-Z0-9_-]+/g, '-')}` + + return ( +
+ + + grammar ? highlight(code, grammar, syntax) : code.replace(/[&<>]/g, (c) => `&#${c.charCodeAt(0)};`) + } + padding={8} + textareaClassName="outline-none" + /> +
+ ) +} + +export default CodeEditor diff --git a/frontend/src/components/ConfirmDialog.tsx b/frontend/src/components/ConfirmDialog.tsx new file mode 100644 index 0000000..985f578 --- /dev/null +++ b/frontend/src/components/ConfirmDialog.tsx @@ -0,0 +1,42 @@ +import { Button } from './Button' + +/** + * A yes-or-no question, for the one action that cannot be undone. + * + * Delete is the only caller. Nothing else here destroys anything an operator + * could not put back by editing a field again. + */ +export function ConfirmDialog({ + question, + detail, + confirmLabel, + onConfirm, + onCancel, +}: { + question: string + detail?: string + confirmLabel: string + onConfirm: () => void + onCancel: () => void +}) { + return ( +
+
+

{question}

+ {detail ? ( +

{detail}

+ ) : null} +
+ + +
+
+
+ ) +} diff --git a/frontend/src/components/Field.tsx b/frontend/src/components/Field.tsx new file mode 100644 index 0000000..b026bc1 --- /dev/null +++ b/frontend/src/components/Field.tsx @@ -0,0 +1,41 @@ +import type { ReactNode } from 'react' + +/** + * A labelled control with room for the server's complaint about it. + * + * `error` comes from the server, not from a second copy of the rules here: the + * configuration types are the arbiter of what is legal, and a client-side + * duplicate would be a second, laxer standard nobody wrote down. + */ +export function Field({ + label, + hint, + error, + children, +}: { + label: string + hint?: string + error?: string + children: ReactNode +}) { + return ( + + ) +} + +/** The one input style, so every form field looks like the others. */ +export const inputClass = + 'mt-1 w-full rounded border border-slate-300 bg-white px-2 py-1 text-sm text-slate-900 ' + + 'focus:border-teal-500 focus:outline-none disabled:opacity-50 ' + + 'dark:border-slate-600 dark:bg-slate-800 dark:text-slate-100' diff --git a/frontend/src/components/Footer.tsx b/frontend/src/components/Footer.tsx new file mode 100644 index 0000000..4d925cc --- /dev/null +++ b/frontend/src/components/Footer.tsx @@ -0,0 +1,17 @@ +import { runtimeConfig } from '../services/runtimeConfig' + +/** + * The copyright and the version of the binary serving this page. + * + * The version comes from the injected configuration rather than from + * `package.json`: what matters is which Doppel answered, and a number baked into + * the bundle would keep claiming the version the assets were built at. + */ +export function Footer() { + const { version } = runtimeConfig() + return ( +
+ (c) 2026 Lorem Dev · Doppel {version} +
+ ) +} diff --git a/frontend/src/components/Header.tsx b/frontend/src/components/Header.tsx new file mode 100644 index 0000000..04bd922 --- /dev/null +++ b/frontend/src/components/Header.tsx @@ -0,0 +1,64 @@ +import { NavLink } from 'react-router' + +import { Button } from './Button' +import { ThemeToggle } from './ThemeToggle' +import { runtimeConfig } from '../services/runtimeConfig' +import { useAccess } from '../store/access' +import { useAuth } from '../store/auth' + +const TABS = [ + { to: '/', label: 'Proxies' }, + { to: '/status', label: 'Status' }, +] + +/** + * The title, the tabs, and the two controls that are always available. + * + * The sign-in control is absent entirely on a public deployment: there are no + * tokens to present, so offering the dialog would be offering something that + * does not exist. + */ +export function Header() { + const { title, public: isPublic } = runtimeConfig() + const token = useAuth((state) => state.token) + const openDialog = useAuth((state) => state.openDialog) + const signOut = useAuth((state) => state.signOut) + const caller = useAccess((state) => state.report?.caller) + + return ( +
+

{title}

+ + + +
+ {caller?.kind === 'token' ? ( + + {caller.name} ({caller.group}) + + ) : null} + + {isPublic ? null : token ? ( + + ) : ( + + )} +
+
+ ) +} diff --git a/frontend/src/components/KeyValueRows.tsx b/frontend/src/components/KeyValueRows.tsx new file mode 100644 index 0000000..89a1fa4 --- /dev/null +++ b/frontend/src/components/KeyValueRows.tsx @@ -0,0 +1,95 @@ +import { Button } from './Button' +import { inputClass } from './Field' + +/** + * A string-to-string map as rows. + * + * Used for injected headers and for every selector map a mock declares. Keyed by + * index rather than by the key itself: keying by the key would remount the input + * on every keystroke that changes it, and the field would lose focus after one + * character. + */ +export function KeyValueRows({ + label, + keyLabel, + valueLabel, + rows, + onChange, + disabled, +}: { + label: string + keyLabel: string + valueLabel: string + rows: Array<[string, string]> + onChange: (rows: Array<[string, string]>) => void + disabled?: boolean +}) { + const set = (index: number, pair: [string, string]) => { + const next = [...rows] + next[index] = pair + onChange(next) + } + + return ( +
+ {label} +
+ {rows.map(([key, value], index) => ( +
+ set(index, [event.target.value, value])} + /> + set(index, [key, event.target.value])} + /> + +
+ ))} +
+ +
+ ) +} + +/** A map as rows, for editing. */ +export function toRows(map: Record | undefined): Array<[string, string]> { + return Object.entries(map ?? {}) +} + +/** + * Rows back to a map, dropping incomplete ones. + * + * An empty key cannot be sent: the server would refuse the document, and the row + * is what an operator leaves behind after clicking Add and changing their mind. + */ +export function fromRows(rows: Array<[string, string]>): Record | undefined { + const map: Record = {} + for (const [key, value] of rows) { + if (key.trim()) { + map[key.trim()] = value + } + } + return Object.keys(map).length ? map : undefined +} diff --git a/frontend/src/components/MockEditor.tsx b/frontend/src/components/MockEditor.tsx new file mode 100644 index 0000000..7fb74e6 --- /dev/null +++ b/frontend/src/components/MockEditor.tsx @@ -0,0 +1,229 @@ +import { Suspense, lazy } from 'react' + +import { Button } from './Button' +import { Field, inputClass } from './Field' +import { KeyValueRows, fromRows, toRows } from './KeyValueRows' +import { Spinner } from './Spinner' +import type { Syntax } from './CodeEditor' +import type { HttpMethod, MockConfig } from '../types/proxy' + +const CodeEditor = lazy(() => import('./CodeEditor')) + +const METHODS: HttpMethod[] = ['GET', 'HEAD', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'] + +/** Which of the three exclusive response sources a mock is using. */ +type Source = 'body' | 'json' | 'template' + +function sourceOf(mock: MockConfig): Source { + if (mock.response.json !== undefined) { + return 'json' + } + if (mock.response.template !== undefined) { + return 'template' + } + return 'body' +} + +const SYNTAX: Record = { body: 'text', json: 'json', template: 'text' } + +/** + * One mock: what it matches, what it takes out of the request, what it answers. + * + * The response source is a radio because the three fields are mutually exclusive + * in the configuration -- the server refuses a document with two of them set, so + * the form cannot offer a state that would be refused. Switching clears the other + * two rather than keeping them hidden: a hidden `json` that reappears on the next + * save is a change nobody made. + */ +export function MockEditor({ + mock, + index, + onChange, + onRemove, + disabled, + errorFor, +}: { + mock: MockConfig + /** This mock's position, which is how the server names it in a complaint. */ + index: number + onChange: (next: MockConfig) => void + onRemove: () => void + disabled?: boolean + errorFor: (field: string) => string | undefined +}) { + const source = sourceOf(mock) + // Asked for by path, so the proxy's own `name` does not answer for every mock's. + const complaint = (field: string) => errorFor(`mocks[${index}].${field}`) + + const setResponse = (patch: Partial) => { + onChange({ ...mock, response: { ...mock.response, ...patch } }) + } + + const setSource = (next: Source) => { + const { status, headers } = mock.response + onChange({ + ...mock, + response: { + status, + headers, + ...(next === 'body' ? { body: '' } : {}), + ...(next === 'json' ? { json: '' } : {}), + ...(next === 'template' ? { template: '' } : {}), + }, + }) + } + + return ( +
+
+
+ + onChange({ ...mock, name: event.target.value })} + /> + +
+ +
+ +
+ + + +
+ + + onChange({ ...mock, request: { ...mock.request, url: event.target.value } }) + } + /> + +
+
+ + + onChange({ ...mock, request: { ...mock.request, headers: fromRows(rows) } }) + } + /> + onChange({ ...mock, request: { ...mock.request, query: fromRows(rows) } })} + /> + onChange({ ...mock, request: { ...mock.request, body: fromRows(rows) } })} + /> + +
+ + setResponse({ status: Number(event.target.value) })} + /> + + + + +
+ + {source === 'template' ? ( + + setResponse({ template: event.target.value })} + /> + + ) : ( + }> + setResponse(source === 'json' ? { json: next } : { body: next })} + /> + + )} + + setResponse({ headers: fromRows(rows) })} + /> +
+ ) +} + +/** A new mock, with the fields the server requires already filled in. */ +export function emptyMock(index: number): MockConfig { + return { + name: `mock-${index + 1}`, + request: { method: 'GET', url: '^/$' }, + response: { status: 200, body: '' }, + } +} diff --git a/frontend/src/components/Spinner.tsx b/frontend/src/components/Spinner.tsx new file mode 100644 index 0000000..df6aa0b --- /dev/null +++ b/frontend/src/components/Spinner.tsx @@ -0,0 +1,8 @@ +/** Shown while the first load is in flight, never for a refetch. */ +export function Spinner({ label }: { label: string }) { + return ( +

+ {label} +

+ ) +} diff --git a/frontend/src/components/ThemeToggle.tsx b/frontend/src/components/ThemeToggle.tsx new file mode 100644 index 0000000..6b232a5 --- /dev/null +++ b/frontend/src/components/ThemeToggle.tsx @@ -0,0 +1,37 @@ +import { useTheme } from '../store/theme' +import type { ThemeChoice } from '../store/theme' + +const CHOICES: Array<{ value: ThemeChoice; label: string }> = [ + { value: 'light', label: 'Light' }, + { value: 'dark', label: 'Dark' }, + { value: 'system', label: 'System' }, +] + +/** + * Three states, not two. + * + * A two-state switch cannot express "whatever the desktop says", which is the + * default and the one most operators never change. + */ +export function ThemeToggle() { + const choice = useTheme((state) => state.choice) + const set = useTheme((state) => state.set) + + return ( + + ) +} diff --git a/frontend/src/components/Toasts.tsx b/frontend/src/components/Toasts.tsx new file mode 100644 index 0000000..28d6ec0 --- /dev/null +++ b/frontend/src/components/Toasts.tsx @@ -0,0 +1,30 @@ +import { useToasts } from '../store/toast' + +/** What the last write did, in the corner, dismissible. */ +export function Toasts() { + const items = useToasts((state) => state.items) + const dismiss = useToasts((state) => state.dismiss) + + if (items.length === 0) { + return null + } + + return ( +
+ {items.map((toast) => ( + + ))} +
+ ) +} diff --git a/frontend/src/components/TokenDialog.tsx b/frontend/src/components/TokenDialog.tsx new file mode 100644 index 0000000..e538a76 --- /dev/null +++ b/frontend/src/components/TokenDialog.tsx @@ -0,0 +1,75 @@ +import { useState } from 'react' + +import { Button } from './Button' +import { inputClass } from './Field' +import { useAuth } from '../store/auth' + +/** + * Ask for a token, and take no for an answer. + * + * "Continue without a token" is not politeness: `access.list` and `access.read` + * can be `public`, and a proxy's own block can open one proxy to a group, so a + * caller with no token may have plenty to look at. A dialog that could not be + * dismissed would hide a working page behind a demand for a secret the operator + * may not have and may not need. + */ +export function TokenDialog() { + const open = useAuth((state) => state.dialogOpen) + const signIn = useAuth((state) => state.signIn) + const decline = useAuth((state) => state.decline) + const close = useAuth((state) => state.closeDialog) + const hasToken = useAuth((state) => state.token !== undefined) + const [value, setValue] = useState('') + + if (!open) { + return null + } + + return ( +
+
{ + event.preventDefault() + if (value.trim()) { + signIn(value.trim()) + setValue('') + } + }} + > +

+ Admin token +

+

+ One of admin.tokens. Kept in this browser for an hour; the token itself + does not expire. +

+ setValue(event.target.value)} + /> +
+ + +
+
+
+ ) +} diff --git a/frontend/src/components/__tests__/shell.test.tsx b/frontend/src/components/__tests__/shell.test.tsx new file mode 100644 index 0000000..05e2536 --- /dev/null +++ b/frontend/src/components/__tests__/shell.test.tsx @@ -0,0 +1,86 @@ +// The shell's components, minus the ones that need a router. +// +// react-router ships ESM only and reaches for `import.meta`, which jest's CJS +// transform cannot parse -- so anything rendering a NavLink or calling +// useNavigate is covered by the Playwright suite instead, where a real browser +// runs the real router. That is a better test of navigation anyway; what is left +// here is the part that is genuinely a unit. +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' + +import { Footer } from '../Footer' +import { TokenDialog } from '../TokenDialog' +import { forgetRuntimeConfig } from '../../services/runtimeConfig' +import { useAuth } from '../../store/auth' + +/** + * Serve the page with this configuration. + * + * No module reset: nothing reads the configuration at import time any more, and + * resetting would hand the test a different store instance than the one the + * rendered component is subscribed to -- which looks like a component that + * ignores its own state. + */ +function serve(overrides: { public?: boolean; title?: string; version?: string } = {}): void { + document.head.innerHTML = `` + forgetRuntimeConfig() +} + +beforeEach(() => { + localStorage.clear() + sessionStorage.clear() +}) + +describe('the footer', () => { + it('shows the copyright and the version of the binary serving the page', () => { + // From the injected configuration, not from package.json: what matters is + // which Doppel answered. + serve({ version: '9.9.9' }) + render(