From 4da8dadd887edc3c73d4b08d1f7e7067e5445622 Mon Sep 17 00:00:00 2001 From: CMGS Date: Sat, 18 Jul 2026 16:28:08 +0800 Subject: [PATCH 01/19] Add control-plane admin APIs --- crates/state/src/configstore.rs | 76 ++++++++- crates/views/src/lib.rs | 282 +++++++++++++++++++++++++++++++- docs/api.md | 7 +- 3 files changed, 362 insertions(+), 3 deletions(-) diff --git a/crates/state/src/configstore.rs b/crates/state/src/configstore.rs index d3fe002..b386af2 100644 --- a/crates/state/src/configstore.rs +++ b/crates/state/src/configstore.rs @@ -11,6 +11,13 @@ const KEEP_VERSIONS: i64 = 20; /// The Postgres NOTIFY channel a publish fires on (payload = version id). pub const CONFIG_CHANNEL: &str = "gw_config"; +/// Metadata for one retained config document, newest first in list responses. +#[derive(Debug, Clone, serde::Serialize)] +pub struct ConfigVersion { + pub id: i64, + pub created_at_epoch_secs: i64, +} + /// Versioned gateway config in Postgres — the source of truth when /// `storage.postgres_url` is set (the local file only seeds an empty store). #[derive(Debug)] @@ -46,6 +53,34 @@ impl PostgresConfigStore { Ok(row.map(|r| (r.get(0), r.get(1)))) } + /// Load one retained version by id. + pub async fn load_version(&self, id: i64) -> GResult> { + sqlx::query_scalar("SELECT yaml FROM gw_config WHERE id = $1") + .bind(id) + .fetch_optional(&self.pool) + .await + .map_err(|e| crate::sqlx_err("read config version", e)) + } + + /// List retained versions newest first. The store keeps at most 20. + pub async fn list_versions(&self, limit: usize) -> GResult> { + let rows = sqlx::query( + "SELECT id, EXTRACT(EPOCH FROM created_at)::BIGINT + FROM gw_config ORDER BY id DESC LIMIT $1", + ) + .bind(limit.min(KEEP_VERSIONS as usize) as i64) + .fetch_all(&self.pool) + .await + .map_err(|e| crate::sqlx_err("list config versions", e))?; + Ok(rows + .iter() + .map(|r| ConfigVersion { + id: r.get(0), + created_at_epoch_secs: r.get(1), + }) + .collect()) + } + /// Store a new version and notify every listening instance. The caller /// validates the YAML first — the store never holds an unparsable config. pub async fn publish(&self, yaml: &str) -> GResult { @@ -58,12 +93,37 @@ impl PostgresConfigStore { .fetch_one(&self.pool) .await .map_err(|e| crate::sqlx_err("publish config", e))?; + self.prune().await?; + Ok(id) + } + + /// Publish the exact document from a retained version as a new head. + /// Returns `None` when `source_id` has already been pruned or never existed. + pub async fn rollback(&self, source_id: i64) -> GResult> { + let id = sqlx::query_scalar( + "WITH ins AS ( + INSERT INTO gw_config (yaml) SELECT yaml FROM gw_config WHERE id = $1 + RETURNING id) + SELECT id FROM ins, pg_notify($2, ins.id::text)", + ) + .bind(source_id) + .bind(CONFIG_CHANNEL) + .fetch_optional(&self.pool) + .await + .map_err(|e| crate::sqlx_err("rollback config version", e))?; + if id.is_some() { + self.prune().await?; + } + Ok(id) + } + + async fn prune(&self) -> GResult<()> { sqlx::query("DELETE FROM gw_config WHERE id <= (SELECT MAX(id) FROM gw_config) - $1") .bind(KEEP_VERSIONS) .execute(&self.pool) .await .map_err(|e| crate::sqlx_err("prune config versions", e))?; - Ok(id) + Ok(()) } } @@ -118,6 +178,20 @@ mod tests { assert_eq!(id, v2); assert!(yaml.contains("host: b")); + let versions = store.list_versions(20).await.unwrap(); + assert_eq!(versions[0].id, v2); + assert_eq!( + store.load_version(v1).await.unwrap().as_deref(), + Some("listen: {host: a, port: 1}") + ); + + let v3 = store.rollback(v1).await.unwrap().expect("retained version"); + assert!(v3 > v2); + let (id, yaml) = store.load_latest().await.unwrap().expect("latest"); + assert_eq!(id, v3); + assert!(yaml.contains("host: a")); + assert!(store.rollback(i64::MAX).await.unwrap().is_none()); + let n = listener.recv().await.expect("notify"); assert_eq!(n.channel(), CONFIG_CHANNEL); assert_eq!(n.payload(), v1.to_string()); diff --git a/crates/views/src/lib.rs b/crates/views/src/lib.rs index ccf5990..ed0c562 100644 --- a/crates/views/src/lib.rs +++ b/crates/views/src/lib.rs @@ -35,6 +35,8 @@ use serde_json::{Value, json}; const LEDGER_PAGE_DEFAULT: usize = 100; const KEY_PAGE_DEFAULT: usize = 200; +const CONFIG_VERSION_PAGE_DEFAULT: usize = 20; +const USAGE_SERIES_MAX_POINTS: i64 = 400; const STREAM_CHANNEL_CAP: usize = 64; /// Per-turn token reserve against the AK daily quota; settled to actuals at billing. const REALTIME_TURN_RESERVE: i64 = 1_000; @@ -123,10 +125,17 @@ pub fn app(state: AppState) -> Router { .route("/internal/ledger", get(ledger)) .route("/internal/accounts", get(accounts)) .route("/admin/reload", post(admin_reload)) - .route("/admin/config", axum::routing::put(admin_config_put)) + .route("/admin/config", get(admin_config_get).put(admin_config_put)) + .route("/admin/config/validate", post(admin_config_validate)) + .route("/admin/config/versions", get(admin_config_versions)) + .route( + "/admin/config/versions/{id}/rollback", + post(admin_config_rollback), + ) .route("/admin/keys", post(admin_key_create).get(admin_key_list)) .route("/admin/usage", get(admin_usage)) .route("/admin/usage/users", get(admin_usage_users)) + .route("/admin/usage/series", get(admin_usage_series)) .route("/admin/models/status", get(admin_models_status)) .route("/admin/audit/events", get(admin_security_events)) .route("/admin/audit/ops", get(admin_audit_ops)) @@ -1313,6 +1322,7 @@ async fn scoped_key( /// The admin surfaces' public view of a key — one shape for PATCH and GET. fn ak_public_json(k: &AkInfo) -> Value { + let status = k.status_at(gw_state::epoch_secs()); json!({ "ak": k.ak, "product": k.product, "tenant": k.tenant, "owner": k.owner, "qps": k.qps, "daily_token_quota": k.daily_token_quota, @@ -1320,6 +1330,13 @@ fn ak_public_json(k: &AkInfo) -> Value { "expires_at_epoch_secs": k.expires_at_epoch_secs, "banned": k.banned, "suspended_until_epoch_secs": k.suspended_until_epoch_secs, + "status": match status { + gw_state::KeyStatus::Active => "active", + gw_state::KeyStatus::Banned => "banned", + gw_state::KeyStatus::Expired => "expired", + gw_state::KeyStatus::Suspended => "suspended", + }, + "available": status == gw_state::KeyStatus::Active, }) } @@ -1525,6 +1542,71 @@ async fn admin_key_delete( } } +/// GET /admin/config — the current fleet config document. Global admin only. +async fn admin_config_get(State(s): State, headers: HeaderMap) -> Response { + if let Err(r) = require_global_admin(&s, &headers) { + return r; + } + let Some(store) = &s.config_store else { + return error_response( + 400, + "config store not configured (set storage.postgres_url)", + ); + }; + match store.load_latest().await { + Ok(Some((version, yaml))) => { + Json(json!({ "version": version, "yaml": yaml })).into_response() + } + Ok(None) => error_response(404, "config store is empty"), + Err(e) => gateway_error(e), + } +} + +/// POST /admin/config/validate — parse and validate without publishing. +async fn admin_config_validate( + State(s): State, + headers: HeaderMap, + body: String, +) -> Response { + if let Err(r) = require_global_admin(&s, &headers) { + return r; + } + match GatewayConfig::from_yaml(&body) { + Ok(cfg) => Json(json!({ + "valid": true, + "generation": cfg.generation(), + "access_keys": cfg.access_keys.len(), + "models": cfg.models.len(), + "accounts": cfg.accounts.len(), + "tenants": cfg.tenants.len(), + })) + .into_response(), + Err(e) => error_response(400, format!("invalid config: {e}")), + } +} + +/// GET /admin/config/versions — retained config heads, newest first. +async fn admin_config_versions( + State(s): State, + headers: HeaderMap, + axum::extract::Query(q): axum::extract::Query>, +) -> Response { + if let Err(r) = require_global_admin(&s, &headers) { + return r; + } + let Some(store) = &s.config_store else { + return error_response( + 400, + "config store not configured (set storage.postgres_url)", + ); + }; + let limit = q_num(&q, "limit", CONFIG_VERSION_PAGE_DEFAULT); + match store.list_versions(limit).await { + Ok(versions) => Json(json!({ "versions": versions })).into_response(), + Err(e) => gateway_error(e), + } +} + /// PUT /admin/config — validate, publish to the fleet config store, and reload /// this instance; peers converge via the store's change feed. Global admin only. async fn admin_config_put( @@ -1577,6 +1659,59 @@ async fn admin_config_put( } } +/// POST /admin/config/versions/{id}/rollback — republish a retained document +/// as a new head and reload this instance. Global admin only. +async fn admin_config_rollback( + State(s): State, + headers: HeaderMap, + AuditSourceIp(source): AuditSourceIp, + Path(source_id): Path, +) -> Response { + if let Err(r) = require_global_admin(&s, &headers) { + return r; + } + let Some(store) = &s.config_store else { + return error_response( + 400, + "config store not configured (set storage.postgres_url)", + ); + }; + let version = match store.rollback(source_id).await { + Ok(Some(v)) => v, + Ok(None) => return error_response(404, format!("config version {source_id} not found")), + Err(e) => return gateway_error(e), + }; + let reload = s.reload().await; + let detail = match &reload { + Ok(()) => format!("source_version={source_id}"), + Err(e) => format!("source_version={source_id}; local reload failed: {e}"), + }; + audit_admin( + &s, + &AdminScope::Global, + source, + "config_rollback", + &version.to_string(), + detail, + ) + .await; + match reload { + Ok(()) => ( + StatusCode::OK, + Json(json!({ + "status": "rolled_back", + "source_version": source_id, + "version": version, + })), + ) + .into_response(), + Err(e) => error_response( + 500, + format!("published rollback v{version} but local reload failed: {e}"), + ), + } +} + /// GET /admin/keys?offset=&limit= — a page of the key table, scoped: a tenant /// admin sees only its own keys. Paginated so a fleet key table never loads whole. async fn admin_key_list( @@ -1702,6 +1837,84 @@ async fn admin_usage_users( Json(json!({ "usage": usage })).into_response() } +/// GET /admin/usage/series?bucket=hour|day&since=&until=&user= — bounded +/// tenant/user usage totals for dashboard charts. Tenant-scoped like the other +/// usage reads; every point is computed from the durable rollup plus raw tail. +async fn admin_usage_series( + State(s): State, + scope: AdminScope, + axum::extract::Query(q): axum::extract::Query>, +) -> Response { + let now = gw_state::epoch_secs(); + let bucket_name = q.get("bucket").map(String::as_str).unwrap_or("day"); + let bucket_secs = match bucket_name { + "hour" => 3_600, + "day" => 86_400, + _ => return error_response(400, "bucket must be `hour` or `day`"), + }; + let since = q_num(&q, "since", now.saturating_sub(29 * 86_400)); + let until = q_num(&q, "until", now); + if since < 0 || until < since { + return error_response(400, "since/until must be a valid non-negative range"); + } + let first = since - since.rem_euclid(bucket_secs); + let points = (until - first) / bucket_secs + 1; + if points > USAGE_SERIES_MAX_POINTS { + return error_response( + 400, + format!("usage series is limited to {USAGE_SERIES_MAX_POINTS} points"), + ); + } + let tenant = scope.tenant_filter(&q); + let user = q.get("user").map(String::as_str); + let mut series = Vec::with_capacity(points as usize); + for idx in 0..points { + let start = first.saturating_add(idx.saturating_mul(bucket_secs)); + let end = start.saturating_add(bucket_secs - 1).min(until); + let rows = match s + .handler + .state() + .store + .usage_by_user(tenant, user, start.max(since), end) + .await + { + Ok(rows) => rows, + Err(e) => return gateway_error(e), + }; + let mut requests = 0_i64; + let mut prompt_tokens = 0_i64; + let mut completion_tokens = 0_i64; + let mut total_tokens = 0_i64; + let mut cost_micros = 0_i64; + let mut vendor_cost_micros = 0_i64; + for row in rows { + requests = requests.saturating_add(row.requests); + prompt_tokens = prompt_tokens.saturating_add(row.prompt_tokens); + completion_tokens = completion_tokens.saturating_add(row.completion_tokens); + total_tokens = total_tokens.saturating_add(row.total_tokens); + cost_micros = cost_micros.saturating_add(row.cost_micros); + vendor_cost_micros = vendor_cost_micros.saturating_add(row.vendor_cost_micros); + } + series.push(json!({ + "start": start, + "end": end, + "requests": requests, + "prompt_tokens": prompt_tokens, + "completion_tokens": completion_tokens, + "total_tokens": total_tokens, + "cost_micros": cost_micros, + "vendor_cost_micros": vendor_cost_micros, + })); + } + Json(json!({ + "bucket": bucket_name, + "since": since, + "until": until, + "series": series, + })) + .into_response() +} + /// A CSV field, RFC-4180 quoted AND neutralized against spreadsheet formula /// injection: a field opening with a formula trigger (`= + - @` / tab / CR) is /// prefixed with `'` so Excel/Sheets treat it as text (the value is @@ -3474,6 +3687,73 @@ mod tests { assert!(by_user[0].total_tokens > 0); } + #[tokio::test] + async fn admin_keys_expose_lifecycle_and_usage_series_is_bucketed() { + let yaml = "listen: {host: h, port: 1}\nadmin: {token_env: GW_TEST_CP_ADMIN}\nmodels: [{name: m, protocol: openai-chat}]\naccess_keys: [{ak: active, product: p, qps: 1, daily_token_quota: 100}, {ak: banned, product: p, qps: 1, daily_token_quota: 100, banned: true}, {ak: expired, product: p, qps: 1, daily_token_quota: 100, expires_at_epoch_secs: 1}]"; + // SAFETY: this test owns a unique env var name. + unsafe { std::env::set_var("GW_TEST_CP_ADMIN", "cp-root") }; + let cfg = Arc::new(GatewayConfig::from_yaml(yaml).unwrap()); + let state = Arc::new(GatewayState::from_config(&cfg)); + let app_state = AppState::new(cfg, state, Arc::new(gw_engines::MockTransport)); + let store = app_state.handler.state().store.clone(); + for (created_at_epoch_secs, tokens, cost_micros) in [(3_700, 10, 25), (7_300, 20, 50)] { + store + .ledger_add(&gw_state::BillingRecord { + ak: "active".into(), + product: "p".into(), + tenant: "default".into(), + user_id: "alice".into(), + request_id: format!("r-{created_at_epoch_secs}"), + created_at_epoch_secs, + model: "m".into(), + served_model: "m".into(), + protocol: "openai-chat".into(), + account: "a".into(), + prompt_tokens: tokens, + completion_tokens: 0, + total_tokens: tokens, + cost_micros, + vendor_cost_micros: cost_micros / 2, + ptu_spillover: false, + estimated: false, + }) + .await + .unwrap(); + } + let router = app(app_state); + let get = |uri: &'static str| { + Request::builder() + .method("GET") + .uri(uri) + .header("authorization", "Bearer cp-root") + .body(Body::empty()) + .unwrap() + }; + + let resp = router.clone().oneshot(get("/admin/keys")).await.unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let keys = body_json(resp).await["keys"].as_array().unwrap().clone(); + let key = |name: &str| keys.iter().find(|k| k["ak"] == name).unwrap(); + assert_eq!(key("active")["status"], "active"); + assert_eq!(key("active")["available"], true); + assert_eq!(key("banned")["status"], "banned"); + assert_eq!(key("expired")["status"], "expired"); + + let resp = router + .oneshot(get( + "/admin/usage/series?bucket=hour&since=3600&until=10799&user=alice", + )) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let series = body_json(resp).await; + let points = series["series"].as_array().unwrap(); + assert_eq!(points.len(), 2); + assert_eq!(points[0]["total_tokens"], 10); + assert_eq!(points[1]["total_tokens"], 20); + assert_eq!(points[1]["cost_micros"], 50); + } + #[test] fn source_ip_roots_at_peer_and_ignores_forgeable_headers_untrusted() { let peer: std::net::SocketAddr = "203.0.113.7:5000".parse().unwrap(); diff --git a/docs/api.md b/docs/api.md index 9cef96f..6438ba8 100644 --- a/docs/api.md +++ b/docs/api.md @@ -142,13 +142,18 @@ regardless. | Method | Path | Notes | |--------|------|-------| | POST | `/admin/reload` | re-read config from source and swap it in atomically (global token only) | +| GET | `/admin/config` | current fleet config version and raw YAML (global token; needs `storage.postgres_url`) | +| POST | `/admin/config/validate` | validate a config document without publishing it (global token) | | PUT | `/admin/config` | validate + publish a new config document to the fleet config store; every instance reloads via the change feed (global token; needs `storage.postgres_url`) | -| GET | `/admin/keys` | list keys, `?offset=&limit=` paged (default 200; a tenant token sees only its own tenant's) | +| GET | `/admin/config/versions` | retained config versions, newest first (global token; needs `storage.postgres_url`) | +| POST | `/admin/config/versions/{id}/rollback` | republish a retained document as a new head and reload (global token; needs `storage.postgres_url`) | +| GET | `/admin/keys` | list keys with computed `status` / `available`, `?offset=&limit=` paged (default 200; a tenant token sees only its own tenant's) | | POST | `/admin/keys` | create/replace a key: `{ak, product, tenant?, owner?, qps, daily_token_quota, tokens_per_minute?, expires_at_epoch_secs?, banned?, model_quotas?}` (`owner` binds the key to one end user — authoritative for attribution) | | PATCH | `/admin/keys/{ak}` | update any of `qps` / `daily_token_quota` / `tokens_per_minute` / `expires_at_epoch_secs` (null clears) / `banned` / `suspended_until_epoch_secs` (null lifts an abuse suspension early) | | DELETE | `/admin/keys/{ak}` | revoke a key | | GET | `/admin/usage` | ledger rollup by tenant × model (requests, tokens, charged `cost_micros`, `vendor_cost_micros` for margin); `?tenant=` filter for the global token | | GET | `/admin/usage/users` | per-user cost rollup (user × model) over a billing period: `?since=&until=` (unix secs), `?user=` filter, `?format=csv` export; tenant-scoped | +| GET | `/admin/usage/series` | bounded dashboard series: `?bucket=hour|day&since=&until=&user=`; tenant-scoped, maximum 400 points | | GET | `/admin/models/status` | per-model availability over the recent window (`available` / `unstable` / `unavailable` / `no_data`), judged from client-visible outcomes against `stability.*` thresholds; attributes to the requested public name under a `variants` split; realtime models sample per billed turn and on session-fatal upstream errors; tenant-scoped | | GET | `/admin/audit/events` | content-safety hits (blocklist / regex / DLP / moderation) recorded without prompt text; `?limit=`; tenant-scoped | | GET | `/admin/audit/ops` | admin-operation trail (key CRUD, config publish, reload) with actor, target, and source IP; `?limit=`; global token only | From dfe27e45d883682014daa525549e6b7df75672ab Mon Sep 17 00:00:00 2001 From: CMGS Date: Sat, 18 Jul 2026 17:00:12 +0800 Subject: [PATCH 02/19] Build control plane backend --- control-plane/cmd/control-plane/main.go | 165 +++++++ control-plane/go.mod | 40 ++ control-plane/go.sum | 409 ++++++++++++++++++ control-plane/internal/auth/password.go | 74 ++++ control-plane/internal/auth/password_test.go | 25 ++ control-plane/internal/config/config.go | 91 ++++ control-plane/internal/gateway/gateway.go | 141 ++++++ control-plane/internal/gateway/http.go | 306 +++++++++++++ control-plane/internal/gateway/mock.go | 242 +++++++++++ control-plane/internal/httpapi/admin.go | 315 ++++++++++++++ control-plane/internal/httpapi/auth.go | 73 ++++ control-plane/internal/httpapi/data.go | 111 +++++ control-plane/internal/httpapi/server.go | 275 ++++++++++++ control-plane/internal/httpapi/server_test.go | 155 +++++++ control-plane/internal/kv/memory/session.go | 53 +++ control-plane/internal/kv/redis/session.go | 78 ++++ control-plane/internal/kv/session.go | 23 + control-plane/internal/store/memory/store.go | 94 ++++ .../internal/store/memory/store_test.go | 45 ++ .../store/postgres/migrations/001_users.sql | 12 + .../internal/store/postgres/store.go | 176 ++++++++ control-plane/internal/user/user.go | 71 +++ 22 files changed, 2974 insertions(+) create mode 100644 control-plane/cmd/control-plane/main.go create mode 100644 control-plane/go.mod create mode 100644 control-plane/go.sum create mode 100644 control-plane/internal/auth/password.go create mode 100644 control-plane/internal/auth/password_test.go create mode 100644 control-plane/internal/config/config.go create mode 100644 control-plane/internal/gateway/gateway.go create mode 100644 control-plane/internal/gateway/http.go create mode 100644 control-plane/internal/gateway/mock.go create mode 100644 control-plane/internal/httpapi/admin.go create mode 100644 control-plane/internal/httpapi/auth.go create mode 100644 control-plane/internal/httpapi/data.go create mode 100644 control-plane/internal/httpapi/server.go create mode 100644 control-plane/internal/httpapi/server_test.go create mode 100644 control-plane/internal/kv/memory/session.go create mode 100644 control-plane/internal/kv/redis/session.go create mode 100644 control-plane/internal/kv/session.go create mode 100644 control-plane/internal/store/memory/store.go create mode 100644 control-plane/internal/store/memory/store_test.go create mode 100644 control-plane/internal/store/postgres/migrations/001_users.sql create mode 100644 control-plane/internal/store/postgres/store.go create mode 100644 control-plane/internal/user/user.go diff --git a/control-plane/cmd/control-plane/main.go b/control-plane/cmd/control-plane/main.go new file mode 100644 index 0000000..d721d96 --- /dev/null +++ b/control-plane/cmd/control-plane/main.go @@ -0,0 +1,165 @@ +package main + +import ( + "context" + "errors" + "fmt" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "github.com/projecteru2/core/log" + "github.com/projecteru2/core/types" + + "github.com/cocoonstack/gateway/control-plane/internal/auth" + "github.com/cocoonstack/gateway/control-plane/internal/config" + "github.com/cocoonstack/gateway/control-plane/internal/gateway" + "github.com/cocoonstack/gateway/control-plane/internal/httpapi" + "github.com/cocoonstack/gateway/control-plane/internal/kv" + kvmemory "github.com/cocoonstack/gateway/control-plane/internal/kv/memory" + kvredis "github.com/cocoonstack/gateway/control-plane/internal/kv/redis" + storememory "github.com/cocoonstack/gateway/control-plane/internal/store/memory" + storepostgres "github.com/cocoonstack/gateway/control-plane/internal/store/postgres" + "github.com/cocoonstack/gateway/control-plane/internal/user" +) + +func main() { + ctx := context.Background() + cfg, err := config.Load() + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + if err := log.SetupLog(ctx, &types.ServerLogConfig{Level: cfg.LogLevel}, ""); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + ctx, stop := signal.NotifyContext(ctx, syscall.SIGINT, syscall.SIGTERM) + defer stop() + if err := run(ctx, cfg); err != nil { + log.WithFunc("main.run").Error(ctx, err, "control plane stopped") + os.Exit(1) + } +} + +func run(ctx context.Context, cfg config.Config) error { + users, closeUsers, err := buildUserStore(ctx, cfg) + if err != nil { + return err + } + defer closeUsers() + sessions, err := buildSessionStore(ctx, cfg) + if err != nil { + return err + } + defer sessions.Close() + gw, err := buildGateway(cfg) + if err != nil { + return err + } + if err := seedUsers(ctx, users, cfg); err != nil { + return err + } + + api := httpapi.New(users, sessions, gw, cfg.SessionTTL, cfg.CookieSecure, cfg.WebDir) + server := &http.Server{ + Addr: cfg.ListenAddr, + Handler: api.Handler(), + ReadHeaderTimeout: 5 * time.Second, + IdleTimeout: 60 * time.Second, + } + done := make(chan struct{}) + go func() { + defer close(done) + <-ctx.Done() + shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + if err := server.Shutdown(shutdownCtx); err != nil { + log.WithFunc("main.shutdown").Error(shutdownCtx, err, "drain HTTP server") + } + }() + log.WithFunc("main.run").Infof(ctx, "control plane listening on http://%s", cfg.ListenAddr) + err = server.ListenAndServe() + if err != nil && !errors.Is(err, http.ErrServerClosed) { + return fmt.Errorf("serve control plane: %w", err) + } + <-done + return nil +} + +func buildUserStore(ctx context.Context, cfg config.Config) (user.Store, func(), error) { + if cfg.StoreDriver == "memory" { + return storememory.New(), func() {}, nil + } + store, err := storepostgres.Connect(ctx, cfg.DatabaseURL) + if err != nil { + return nil, nil, err + } + return store, store.Close, nil +} + +func buildSessionStore(ctx context.Context, cfg config.Config) (kv.Sessions, error) { + if cfg.KVDriver == "memory" { + return kvmemory.New(), nil + } + return kvredis.Connect(ctx, cfg.RedisURL) +} + +func buildGateway(cfg config.Config) (gateway.Client, error) { + if cfg.GatewayMode == "mock" { + return gateway.NewMock(), nil + } + return gateway.NewHTTP(cfg.GatewayTargets, cfg.GatewayAdminToken) +} + +func seedUsers(ctx context.Context, store user.Store, cfg config.Config) error { + if cfg.DevSeed { + seeds := []struct { + id, email, displayName, password, tenant, gatewayUserID string + role user.Role + }{ + {"dev-admin", "admin@example.com", "System Admin", "admin12345!", "", "", user.RoleSystemAdmin}, + {"dev-tenant-admin", "manager@example.com", "Acme Admin", "manager123!", "acme", "", user.RoleTenantAdmin}, + {"dev-member", "user@example.com", "Alice Chen", "user12345!", "acme", "alice", user.RoleMember}, + } + for _, seed := range seeds { + if err := ensureUser(ctx, store, seed.id, seed.email, seed.displayName, seed.password, seed.tenant, seed.gatewayUserID, seed.role); err != nil { + return err + } + } + } + if cfg.BootstrapEmail != "" { + if err := ensureUser(ctx, store, "bootstrap-admin", cfg.BootstrapEmail, "System Admin", cfg.BootstrapPassword, "", "", user.RoleSystemAdmin); err != nil { + return err + } + } + return nil +} + +func ensureUser( + ctx context.Context, + store user.Store, + id, email, displayName, password, tenant, gatewayUserID string, + role user.Role, +) error { + if _, err := store.ByEmail(ctx, email); err == nil { + return nil + } else if !errors.Is(err, user.ErrNotFound) { + return err + } + hash, err := auth.HashPassword(password) + if err != nil { + return fmt.Errorf("hash seed password: %w", err) + } + now := time.Now().Unix() + if err := store.Create(ctx, user.User{ + ID: id, Email: email, DisplayName: displayName, PasswordHash: hash, + Tenant: tenant, GatewayUserID: gatewayUserID, Role: role, + CreatedAt: now, UpdatedAt: now, + }); err != nil { + return fmt.Errorf("seed user %s: %w", email, err) + } + return nil +} diff --git a/control-plane/go.mod b/control-plane/go.mod new file mode 100644 index 0000000..1f28374 --- /dev/null +++ b/control-plane/go.mod @@ -0,0 +1,40 @@ +module github.com/cocoonstack/gateway/control-plane + +go 1.25.6 + +require ( + github.com/jackc/pgx/v5 v5.10.0 + github.com/projecteru2/core v0.0.0-20241016125006-ff909eefe04c + github.com/redis/go-redis/v9 v9.21.0 + golang.org/x/crypto v0.54.0 +) + +require ( + github.com/alphadose/haxmap v1.2.0 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/cockroachdb/errors v1.9.1 // indirect + github.com/cockroachdb/logtags v0.0.0-20230118201751-21c54148d20b // indirect + github.com/cockroachdb/redact v1.1.3 // indirect + github.com/getsentry/sentry-go v0.20.0 // indirect + github.com/gogo/protobuf v1.3.2 // indirect + github.com/golang/protobuf v1.5.4 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect + github.com/kr/pretty v0.3.1 // indirect + github.com/kr/text v0.2.0 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.18 // indirect + github.com/mitchellh/mapstructure v1.5.0 // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/rogpeppe/go-internal v1.11.0 // indirect + github.com/rs/zerolog v1.29.1 // indirect + go.uber.org/atomic v1.11.0 // indirect + golang.org/x/exp v0.0.0-20230425010034-47ecfdc1ba53 // indirect + golang.org/x/sync v0.22.0 // indirect + golang.org/x/sys v0.47.0 // indirect + golang.org/x/text v0.40.0 // indirect + google.golang.org/grpc v1.60.1 // indirect + google.golang.org/protobuf v1.33.0 // indirect + gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect +) diff --git a/control-plane/go.sum b/control-plane/go.sum new file mode 100644 index 0000000..fb58ca4 --- /dev/null +++ b/control-plane/go.sum @@ -0,0 +1,409 @@ +cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +github.com/AndreasBriese/bbloom v0.0.0-20190306092124-e2d15f34fcf9/go.mod h1:bOvUY6CB00SOBii9/FifXqc0awNKxLFCL/+pkDPuyl8= +github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/CloudyKit/fastprinter v0.0.0-20200109182630-33d98a066a53/go.mod h1:+3IMCy2vIlbG1XG/0ggNQv0SvxCAIpPM5b1nCz56Xno= +github.com/CloudyKit/jet/v3 v3.0.0/go.mod h1:HKQPgSJmdK8hdoAbKUUWajkHyHo4RaU5rMdUywE7VMo= +github.com/Joker/hpp v1.0.0/go.mod h1:8x5n+M1Hp5hC0g8okX3sR3vFQwynaX/UgSOM9MeBKzY= +github.com/Shopify/goreferrer v0.0.0-20181106222321-ec9c9a553398/go.mod h1:a1uqRtAwp2Xwc6WNPJEufxJ7fx3npB4UV/JOLmbu5I0= +github.com/ajg/form v1.5.1/go.mod h1:uL1WgH+h2mgNtvBq0339dVnzXdBETtL2LeUXaIv25UY= +github.com/alphadose/haxmap v1.2.0 h1:noGrAmCE+gNheZ4KpW+sYj9W5uMcO1UAjbAq9XBOAfM= +github.com/alphadose/haxmap v1.2.0/go.mod h1:rjHw1IAqbxm0S3U5tD16GoKsiAd8FWx5BJ2IYqXwgmM= +github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8= +github.com/aymerick/raymond v2.0.3-0.20180322193309-b565731e1464+incompatible/go.mod h1:osfaiScAUVup+UC9Nfq76eWqDhXlp+4UYaA8uhTBO6g= +github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= +github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= +github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= +github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= +github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= +github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= +github.com/cockroachdb/datadriven v1.0.2/go.mod h1:a9RdTaap04u637JoCzcUoIcDmvwSUtcUFtT/C3kJlTU= +github.com/cockroachdb/errors v1.9.1 h1:yFVvsI0VxmRShfawbt/laCIDy/mtTqqnvoNgiy5bEV8= +github.com/cockroachdb/errors v1.9.1/go.mod h1:2sxOtL2WIc096WSZqZ5h8fa17rdDq9HZOZLBCor4mBk= +github.com/cockroachdb/logtags v0.0.0-20211118104740-dabe8e521a4f/go.mod h1:Vz9DsVWQQhf3vs21MhPMZpMGSht7O/2vFW2xusFUVOs= +github.com/cockroachdb/logtags v0.0.0-20230118201751-21c54148d20b h1:r6VH0faHjZeQy818SGhaone5OnYfxFR/+AzdY3sf5aE= +github.com/cockroachdb/logtags v0.0.0-20230118201751-21c54148d20b/go.mod h1:Vz9DsVWQQhf3vs21MhPMZpMGSht7O/2vFW2xusFUVOs= +github.com/cockroachdb/redact v1.1.3 h1:AKZds10rFSIj7qADf0g46UixK8NNLwWTNdCIGS5wfSQ= +github.com/cockroachdb/redact v1.1.3/go.mod h1:BVNblN9mBWFyMyqK1k3AAiSxhvhfK2oOZZ2lK+dpvRg= +github.com/codegangsta/inject v0.0.0-20150114235600-33e0aa1cb7c0/go.mod h1:4Zcjuz89kmFXt9morQgcfYZAYZ5n8WHjt81YYWIwtTM= +github.com/coreos/etcd v3.3.10+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE= +github.com/coreos/go-etcd v2.0.0+incompatible/go.mod h1:Jez6KQU2B/sWsbdaef3ED8NzMklzPG4d5KIOhIy30Tk= +github.com/coreos/go-semver v0.2.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk= +github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= +github.com/cpuguy83/go-md2man v1.0.10/go.mod h1:SmD6nW6nTyfqj6ABTjUi3V3JVMnlJmwcJI5acqYI6dE= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dgraph-io/badger v1.6.0/go.mod h1:zwt7syl517jmP8s94KqSxTlM6IMsdhYy6psNgSztDR4= +github.com/dgryski/go-farm v0.0.0-20190423205320-6a90982ecee2/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw= +github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= +github.com/eknkc/amber v0.0.0-20171010120322-cdade1c07385/go.mod h1:0vRUJqYpeSZifjYj7uP3BG/gKcuzL9xWVV/Y+cK33KM= +github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= +github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= +github.com/envoyproxy/go-control-plane v0.9.9-0.20210217033140-668b12f5399d/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk= +github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= +github.com/etcd-io/bbolt v1.3.3/go.mod h1:ZF2nL25h33cCyBtcyWeZ2/I3HQOfTP+0PIEvHjkjCrw= +github.com/fasthttp-contrib/websocket v0.0.0-20160511215533-1f3b11f56072/go.mod h1:duJ4Jxv5lDcvg4QuQr0oowTf7dz4/CR8NtyCooz9HL8= +github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M= +github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= +github.com/gavv/httpexpect v2.0.0+incompatible/go.mod h1:x+9tiU1YnrOvnB725RkpoLv1M62hOWzwo5OXotisrKc= +github.com/getsentry/sentry-go v0.12.0/go.mod h1:NSap0JBYWzHND8oMbyi0+XZhUalc1TBdRL1M71JZW2c= +github.com/getsentry/sentry-go v0.20.0 h1:bwXW98iMRIWxn+4FgPW7vMrjmbym6HblXALmhjHmQaQ= +github.com/getsentry/sentry-go v0.20.0/go.mod h1:lc76E2QywIyW8WuBnwl8Lc4bkmQH4+w1gwTf25trprY= +github.com/gin-contrib/sse v0.0.0-20190301062529-5545eab6dad3/go.mod h1:VJ0WA2NBN22VlZ2dKZQPAPnyWw5XTlK1KymzLKsr59s= +github.com/gin-gonic/gin v1.4.0/go.mod h1:OW2EZn3DO8Ln9oIKOvM++LBO+5UPHJJDH72/q/3rZdM= +github.com/go-check/check v0.0.0-20180628173108-788fd7840127/go.mod h1:9ES+weclKsC9YodN5RgxqK/VD9HM9JsCSh7rNhMZE98= +github.com/go-errors/errors v1.0.1/go.mod h1:f4zRHt4oKfwPJE5k8C9vpYG+aDHdBFUsgrm6/TyX73Q= +github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA= +github.com/go-errors/errors v1.4.2/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og= +github.com/go-martini/martini v0.0.0-20170121215854-22fa46961aab/go.mod h1:/P9AEU963A2AYjv4d1V5eVL1CQbEJq6aCNHDDjibzu8= +github.com/gobwas/httphead v0.0.0-20180130184737-2c6c146eadee/go.mod h1:L0fX3K22YWvt/FAX9NnzrNzcI4wNYi9Yku4O0LKYflo= +github.com/gobwas/pool v0.2.0/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw= +github.com/gobwas/ws v1.0.2/go.mod h1:szmBTxLgaFppYjEmNtny/v3w89xOydFnnZMcgRRu/EM= +github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/gogo/googleapis v0.0.0-20180223154316-0cd9801be74a/go.mod h1:gf4bu3Q80BeJ6H1S1vYPm8/ELATdvryBaNFGgqEef3s= +github.com/gogo/googleapis v1.4.1/go.mod h1:2lpHqI5OcWCtVElxXnPt+s8oJvMpySlOyM6xDCrzib4= +github.com/gogo/protobuf v1.2.0/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/gogo/status v1.1.0/go.mod h1:BFv9nrluPLmrS0EmGVvLaPNmRosr9KapBYd5/hpY1WM= +github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I= +github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= +github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= +github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= +github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= +github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= +github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= +github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8= +github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/gomodule/redigo v1.7.1-0.20190724094224-574c33c3df38/go.mod h1:B4C85qUVwatsJoIUNIfCRsp7qO0iAmpGFZ4EELWSbC4= +github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= +github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= +github.com/gorilla/websocket v1.4.1/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/hashicorp/go-version v1.2.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= +github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= +github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= +github.com/hydrogen18/memlistener v0.0.0-20200120041712-dcc25e7acd91/go.mod h1:qEIFzExnS6016fRpRfxrExeVn2gbClQA99gQhnIcdhE= +github.com/imkira/go-interpol v1.1.0/go.mod h1:z0h2/2T3XF8kyEPpRgJ3kmNv+C43p+I/CoI+jC3w2iA= +github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= +github.com/iris-contrib/blackfriday v2.0.0+incompatible/go.mod h1:UzZ2bDEoaSGPbkg6SAB4att1aAwTmVIx/5gCVqeyUdI= +github.com/iris-contrib/go.uuid v2.0.0+incompatible/go.mod h1:iz2lgM/1UnEf1kP0L/+fafWORmlnuysV2EMP8MW+qe0= +github.com/iris-contrib/jade v1.1.3/go.mod h1:H/geBymxJhShH5kecoiOCSssPX7QWYH7UaeZTSWddIk= +github.com/iris-contrib/pongo2 v0.0.1/go.mod h1:Ssh+00+3GAZqSQb30AvBRNxBx7rf0GqwkjqxNd0u65g= +github.com/iris-contrib/schema v0.0.1/go.mod h1:urYA3uvUNG1TIIjOSCzHr9/LmbQo8LrOcOqfqxa4hXw= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0= +github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= +github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= +github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= +github.com/k0kubun/colorstring v0.0.0-20150214042306-9440f1994b88/go.mod h1:3w7q1U84EfirKl04SVQ/s7nPm1ZPhiXd34z40TNz36k= +github.com/kataras/golog v0.0.10/go.mod h1:yJ8YKCmyL+nWjERB90Qwn+bdyBZsaQwU3bTVFgkFIp8= +github.com/kataras/iris/v12 v12.1.8/go.mod h1:LMYy4VlP67TQ3Zgriz8RE2h2kMZV2SgMYbq3UhfoFmE= +github.com/kataras/neffos v0.0.14/go.mod h1:8lqADm8PnbeFfL7CLXh1WHw53dG27MC3pgi2R1rmoTE= +github.com/kataras/pio v0.0.2/go.mod h1:hAoW0t9UmXi4R5Oyq5Z4irTbaTsOemSrDGUtaTl7Dro= +github.com/kataras/sitemap v0.0.5/go.mod h1:KY2eugMKiPwsJgx7+U103YZehfvNGOXURubcGyk0Bz8= +github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/klauspost/compress v1.8.2/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0guNDohfE1A= +github.com/klauspost/compress v1.9.7/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0guNDohfE1A= +github.com/klauspost/cpuid v1.2.1 h1:vJi+O/nMdFt0vqm8NZBI6wzALWdA2X+egi0ogNyrC/w= +github.com/klauspost/cpuid v1.2.1/go.mod h1:Pj4uuM528wm8OyEC2QMXAi2YiTZ96dNQPGgoMS4s3ek= +github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE= +github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/labstack/echo/v4 v4.5.0/go.mod h1:czIriw4a0C1dFun+ObrXp7ok03xON0N1awStJ6ArI7Y= +github.com/labstack/gommon v0.3.0/go.mod h1:MULnywXg0yavhxWKc+lOruYdAhDwPK9wf0OL7NoOu+k= +github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ= +github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= +github.com/mattn/go-colorable v0.1.8/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= +github.com/mattn/go-colorable v0.1.11/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= +github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.7/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= +github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= +github.com/mattn/go-isatty v0.0.9/go.mod h1:YNRxwqDuOph6SZLI9vUUz6OYw3QyUt7WiY2yME+cCiQ= +github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= +github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.18 h1:DOKFKCQ7FNG2L1rbrmstDN4QVRdS89Nkh85u68Uwp98= +github.com/mattn/go-isatty v0.0.18/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/goveralls v0.0.2/go.mod h1:8d1ZMHsd7fW6IRPKQh46F2WRpyib5/X4FOpevwGNQEw= +github.com/mediocregopher/radix/v3 v3.4.2/go.mod h1:8FL3F6UQRXHXIBSPUs5h0RybMF8i4n7wVopoX3x7Bv8= +github.com/microcosm-cc/bluemonday v1.0.2/go.mod h1:iVP4YcDBq+n/5fb23BhYFvIMq/leAFZyRl6bYmGDlGc= +github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= +github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= +github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= +github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= +github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= +github.com/moul/http2curl v1.0.0/go.mod h1:8UbvGypXm98wA/IqH45anm5Y2Z6ep6O31QGOAZ3H0fQ= +github.com/nats-io/jwt v0.3.0/go.mod h1:fRYCDE99xlTsqUzISS1Bi75UBJ6ljOJQOAAu5VglpSg= +github.com/nats-io/nats.go v1.9.1/go.mod h1:ZjDU1L/7fJ09jvUSRVBR2e7+RnLiiIQyqyzEE/Zbp4w= +github.com/nats-io/nkeys v0.1.0/go.mod h1:xpnFELMwJABBLVhffcfd1MZx6VsNRFpEugbxziKVo7w= +github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c= +github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.10.3/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY= +github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic= +github.com/pingcap/errors v0.11.4 h1:lFuQV/oaUMGcD2tqt+01ROSmJs75VG1ToEOkZIZ4nE4= +github.com/pingcap/errors v0.11.4/go.mod h1:Oi8TUi2kEtXXLMJk9l1cGmz20kV3TaQ0usTwv5KuLY8= +github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= +github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/projecteru2/core v0.0.0-20241016125006-ff909eefe04c h1:5KVBuw+0Kcy7NhnR1j0eETNRH/AdI8Koodw+WWhWIMw= +github.com/projecteru2/core v0.0.0-20241016125006-ff909eefe04c/go.mod h1:MmpwqgDuc9Wx7JZzyCvEzAqNoYfDgBfKhcrkeWhBHvc= +github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/redis/go-redis/v9 v9.21.0 h1:FPBE4hhbAke+TLmcY3WkpbDffJEomdqPn3HYiqAtL9E= +github.com/redis/go-redis/v9 v9.21.0/go.mod h1:v/M13XI1PVCDcm01VtPFOADfZtHf8YW3baQf57KlIkA= +github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= +github.com/rogpeppe/go-internal v1.8.1/go.mod h1:JeRgkft04UBgHMgCIwADu4Pn6Mtm5d4nPKWu0nJ5d+o= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M= +github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA= +github.com/rs/xid v1.4.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= +github.com/rs/zerolog v1.29.1 h1:cO+d60CHkknCbvzEWxP0S9K6KqyTjrCNUy1LdQLCGPc= +github.com/rs/zerolog v1.29.1/go.mod h1:Le6ESbR7hc+DP6Lt1THiV8CQSdkkNrd3R0XbEgp3ZBU= +github.com/russross/blackfriday v1.5.2/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g= +github.com/ryanuber/columnize v2.1.0+incompatible/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts= +github.com/schollz/closestmatch v2.1.0+incompatible/go.mod h1:RtP1ddjLong6gTkbtmuhtR2uUrrJOpYzYRvbcPAid+g= +github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo= +github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= +github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= +github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA= +github.com/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ= +github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= +github.com/spf13/cobra v0.0.5/go.mod h1:3K3wKZymM7VvHMDS9+Akkh4K60UwM26emMESw8tLCHU= +github.com/spf13/jwalterweatherman v1.0.0/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo= +github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= +github.com/spf13/viper v1.3.2/go.mod h1:ZiWeW+zYFKm7srdB9IoDzzZXaJaI5eL9QjNiN/DMA2s= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.5.0 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/ugorji/go v1.1.4/go.mod h1:uQMGLiO92mf5W77hV/PUCpI3pbzQx3CRekS0kk+RGrc= +github.com/ugorji/go v1.1.7/go.mod h1:kZn38zHttfInRq0xu/PH0az30d+z6vm202qpg1oXVMw= +github.com/ugorji/go/codec v0.0.0-20181204163529-d75b2dcb6bc8/go.mod h1:VFNgLljTbGfSG7qAOspJ7OScBnGdDN/yBr0sguwnwf0= +github.com/ugorji/go/codec v1.1.7/go.mod h1:Ax+UKWsSmolVDwsd+7N3ZtXu+yMGCf907BLYF3GoBXY= +github.com/urfave/negroni v1.0.0/go.mod h1:Meg73S6kFm/4PpbYdq35yYWoCZ9mS/YSx+lKnmiohz4= +github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= +github.com/valyala/fasthttp v1.6.0/go.mod h1:FstJa9V+Pj9vQ7OJie2qMHdwemEDaDiSdBnvPM1Su9w= +github.com/valyala/fasttemplate v1.0.1/go.mod h1:UQGH1tvbgY+Nz5t2n7tXsz52dQxojPUpymEIMZ47gx8= +github.com/valyala/fasttemplate v1.2.1/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= +github.com/valyala/tcplisten v0.0.0-20161114210144-ceec8f93295a/go.mod h1:v3UYOV9WzVtRmSR+PDvWpU/qWl4Wa5LApYYX4ZtKbio= +github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= +github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ= +github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y= +github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:aYKd//L2LvnjZzWKhF00oedf4jCCReLcmhLdhm1A27Q= +github.com/yalp/jsonpath v0.0.0-20180802001716-5cc68e5049a0/go.mod h1:/LWChgwKmvncFJFHJ7Gvn9wZArjbV5/FppcK2fKk/tI= +github.com/yudai/gojsondiff v1.0.0/go.mod h1:AY32+k2cwILAkW1fbgxQ5mUmMiZFgLIV+FBNExI05xg= +github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82/go.mod h1:lgjkn3NuSvDfVJdfcVVdX+jpBxNmX4rDAzaS45IcYoM= +github.com/yudai/pp v2.0.1+incompatible/go.mod h1:PuxR/8QJ7cyCkFp/aUDS+JY727OFEZkTdatxwunjIkc= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= +github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs= +github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s= +go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= +go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= +go.uber.org/zap v1.24.0 h1:FiJd5l1UOLj0wCgbSE0rwwXHzEdAZS6hiiSnxJN/D60= +go.uber.org/zap v1.24.0/go.mod h1:2kMP+WWQ8aoFoedH3T2sq6iJ2yDWpHbP0f6MQbS9Gkg= +golang.org/x/crypto v0.0.0-20181203042331-505ab145d0a9/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20190701094942-4def268fd1a4/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20191227163750-53104e6ec876/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20210322153248-0c34fe9e7dc2/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= +golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= +golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/exp v0.0.0-20230425010034-47ecfdc1ba53 h1:5llv2sWeaMSnA3w2kS57ouQQ4pudlXrR0dCgw51QK9o= +golang.org/x/exp v0.0.0-20230425010034-47ecfdc1ba53/go.mod h1:V1LtkGg67GoY2N1AnLN78QLrzxkLyJw7RJb1gzOOz9w= +golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= +golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= +golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/lint v0.0.0-20210508222113-6edffad5e616/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= +golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20181220203305-927f97764cc3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190327091125-710a502c58a2/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20190827160401-ba9fcec4b297/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= +golang.org/x/net v0.0.0-20211008194852-3b03d305991f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= +golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= +golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20181205085412-a5c9d58dba9a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190626221950-04f50cda93cb/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190813064441-fde4db37ae7a/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210403161142-5e06dd20ab57/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20211007075335-d3039528d8ac/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220209214540-3681064d5158/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= +golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= +golang.org/x/time v0.0.0-20201208040808-7e3f01d25324/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20181221001348-537d06c36207/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= +golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190327201419-c70d86f8b7cf/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.1.3/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= +google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/genproto v0.0.0-20180518175338-11a468237815/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= +google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= +google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= +google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= +google.golang.org/genproto v0.0.0-20210624195500-8bfb893ecb84/go.mod h1:SzzZ/N+nwJDaO1kznhnlzqS8ocJICar6hYhVyhi++24= +google.golang.org/genproto v0.0.0-20231002182017-d307bd883b97 h1:SeZZZx0cP0fqUyA+oRzP9k7cSwJlvDFiROO72uwD6i0= +google.golang.org/genproto/googleapis/rpc v0.0.0-20231002182017-d307bd883b97 h1:6GQBEOdGkX6MMTLT9V+TjtIRZCw9VPD5Z+yHY9wMgS0= +google.golang.org/genproto/googleapis/rpc v0.0.0-20231002182017-d307bd883b97/go.mod h1:v7nGkzlmW8P3n/bKmWBn2WpBjpOEx8Q6gMueudAmKfY= +google.golang.org/grpc v1.12.0/go.mod h1:yo6s7OP7yaDglbqo1J04qKzAhqBH6lvTonzMVmEdcZw= +google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= +google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= +google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= +google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= +google.golang.org/grpc v1.38.0/go.mod h1:NREThFqKR1f3iQ6oBuvc5LadQuXVGo9rkm5ZGrQdJfM= +google.golang.org/grpc v1.60.1 h1:26+wFr+cNqSGFcOXcabYC0lUVJVRa2Sb2ortSK7VrEU= +google.golang.org/grpc v1.60.1/go.mod h1:OlCHIeLYqSSsLi6i49B5QGdzaMZK9+M7LXN2FKz4eGM= +google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= +google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= +google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= +google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= +google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= +google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= +google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI= +google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= +gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= +gopkg.in/go-playground/assert.v1 v1.2.1/go.mod h1:9RXL0bg/zibRAgZUYszZSwO/z8Y/a8bDuhia5mkpMnE= +gopkg.in/go-playground/validator.v8 v8.18.2/go.mod h1:RX2a/7Ha8BgOhfk7j780h4/u/RRjR0eouCJSH80/M2Y= +gopkg.in/ini.v1 v1.51.1/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= +gopkg.in/mgo.v2 v2.0.0-20180705113604-9856a29383ce/go.mod h1:yeKp02qBN3iKW1OzL3MGk2IdtZzaj7SFntXj72NppTA= +gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc= +gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v3 v3.0.0-20191120175047-4206685974f2/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= diff --git a/control-plane/internal/auth/password.go b/control-plane/internal/auth/password.go new file mode 100644 index 0000000..db7998f --- /dev/null +++ b/control-plane/internal/auth/password.go @@ -0,0 +1,74 @@ +// Package auth implements local password and opaque-session authentication. +package auth + +import ( + "crypto/rand" + "crypto/subtle" + "encoding/base64" + "fmt" + "strings" + + "golang.org/x/crypto/argon2" +) + +const ( + argonTime = 2 + argonMemory = 64 * 1024 + argonThreads = 2 + argonKeyLen = 32 + argonSaltLen = 16 +) + +func HashPassword(password string) (string, error) { + if len(password) < 10 { + return "", fmt.Errorf("password must be at least 10 characters") + } + salt := make([]byte, argonSaltLen) + if _, err := rand.Read(salt); err != nil { + return "", fmt.Errorf("read password salt: %w", err) + } + hash := argon2.IDKey([]byte(password), salt, argonTime, argonMemory, argonThreads, argonKeyLen) + enc := base64.RawStdEncoding + return fmt.Sprintf( + "$argon2id$v=19$m=%d,t=%d,p=%d$%s$%s", + argonMemory, + argonTime, + argonThreads, + enc.EncodeToString(salt), + enc.EncodeToString(hash), + ), nil +} + +func VerifyPassword(encoded, password string) bool { + parts := strings.Split(encoded, "$") + if len(parts) != 6 || parts[1] != "argon2id" || parts[2] != "v=19" { + return false + } + var memory, timeCost uint32 + var threads uint8 + if _, err := fmt.Sscanf(parts[3], "m=%d,t=%d,p=%d", &memory, &timeCost, &threads); err != nil { + return false + } + if memory > 256*1024 || timeCost > 10 || threads > 16 || memory == 0 || timeCost == 0 || threads == 0 { + return false + } + enc := base64.RawStdEncoding + salt, err := enc.DecodeString(parts[4]) + if err != nil || len(salt) < 8 { + return false + } + want, err := enc.DecodeString(parts[5]) + if err != nil || len(want) == 0 || len(want) > 64 { + return false + } + got := argon2.IDKey([]byte(password), salt, timeCost, memory, threads, uint32(len(want))) + return subtle.ConstantTimeCompare(got, want) == 1 +} + +func RandomToken(bytes int) (string, error) { + buf := make([]byte, bytes) + if _, err := rand.Read(buf); err != nil { + return "", fmt.Errorf("read random token: %w", err) + } + return base64.RawURLEncoding.EncodeToString(buf), nil +} diff --git a/control-plane/internal/auth/password_test.go b/control-plane/internal/auth/password_test.go new file mode 100644 index 0000000..060bd9e --- /dev/null +++ b/control-plane/internal/auth/password_test.go @@ -0,0 +1,25 @@ +package auth + +import "testing" + +func TestPasswordRoundTrip(t *testing.T) { + hash, err := HashPassword("correct-horse") + if err != nil { + t.Fatalf("hash password: %v", err) + } + if !VerifyPassword(hash, "correct-horse") { + t.Fatal("correct password did not verify") + } + if VerifyPassword(hash, "wrong-password") { + t.Fatal("wrong password verified") + } + if VerifyPassword("not-a-hash", "correct-horse") { + t.Fatal("malformed hash verified") + } +} + +func TestPasswordMinimum(t *testing.T) { + if _, err := HashPassword("short"); err == nil { + t.Fatal("short password accepted") + } +} diff --git a/control-plane/internal/config/config.go b/control-plane/internal/config/config.go new file mode 100644 index 0000000..4fcd674 --- /dev/null +++ b/control-plane/internal/config/config.go @@ -0,0 +1,91 @@ +// Package config loads control-plane runtime configuration from environment. +package config + +import ( + "cmp" + "fmt" + "os" + "strconv" + "strings" + "time" +) + +type Config struct { + ListenAddr string + StoreDriver string + DatabaseURL string + KVDriver string + RedisURL string + GatewayMode string + GatewayTargets string + GatewayAdminToken string + WebDir string + SessionTTL time.Duration + CookieSecure bool + DevSeed bool + BootstrapEmail string + BootstrapPassword string + LogLevel string +} + +func Load() (Config, error) { + cfg := Config{ + ListenAddr: cmp.Or(os.Getenv("CP_LISTEN"), "127.0.0.1:8090"), + StoreDriver: cmp.Or(os.Getenv("CP_STORE"), "memory"), + DatabaseURL: os.Getenv("CP_DATABASE_URL"), + KVDriver: cmp.Or(os.Getenv("CP_KV"), "memory"), + RedisURL: os.Getenv("CP_REDIS_URL"), + GatewayMode: cmp.Or(os.Getenv("CP_GATEWAY_MODE"), "mock"), + GatewayTargets: cmp.Or(os.Getenv("CP_GATEWAY_TARGETS"), "local=http://127.0.0.1:8080"), + GatewayAdminToken: os.Getenv("CP_GATEWAY_ADMIN_TOKEN"), + WebDir: cmp.Or(os.Getenv("CP_WEB_DIR"), "web/dist"), + SessionTTL: 12 * time.Hour, + CookieSecure: envBool("CP_COOKIE_SECURE", false), + BootstrapEmail: os.Getenv("CP_BOOTSTRAP_ADMIN_EMAIL"), + BootstrapPassword: os.Getenv("CP_BOOTSTRAP_ADMIN_PASSWORD"), + LogLevel: cmp.Or(os.Getenv("CP_LOG_LEVEL"), "info"), + } + if _, set := os.LookupEnv("CP_DEV_SEED"); set { + cfg.DevSeed = envBool("CP_DEV_SEED", false) + } else { + cfg.DevSeed = cfg.StoreDriver == "memory" + } + if raw := os.Getenv("CP_SESSION_TTL"); raw != "" { + ttl, err := time.ParseDuration(raw) + if err != nil || ttl <= 0 { + return Config{}, fmt.Errorf("CP_SESSION_TTL must be a positive duration") + } + cfg.SessionTTL = ttl + } + if cfg.StoreDriver != "memory" && cfg.StoreDriver != "postgres" { + return Config{}, fmt.Errorf("CP_STORE must be memory or postgres") + } + if cfg.StoreDriver == "postgres" && cfg.DatabaseURL == "" { + return Config{}, fmt.Errorf("CP_DATABASE_URL is required for postgres") + } + if cfg.KVDriver != "memory" && cfg.KVDriver != "redis" { + return Config{}, fmt.Errorf("CP_KV must be memory or redis") + } + if cfg.KVDriver == "redis" && cfg.RedisURL == "" { + return Config{}, fmt.Errorf("CP_REDIS_URL is required for redis") + } + if cfg.GatewayMode != "mock" && cfg.GatewayMode != "http" { + return Config{}, fmt.Errorf("CP_GATEWAY_MODE must be mock or http") + } + if cfg.GatewayMode == "http" && strings.TrimSpace(cfg.GatewayAdminToken) == "" { + return Config{}, fmt.Errorf("CP_GATEWAY_ADMIN_TOKEN is required for http gateway mode") + } + if (cfg.BootstrapEmail == "") != (cfg.BootstrapPassword == "") { + return Config{}, fmt.Errorf("bootstrap admin email and password must be set together") + } + return cfg, nil +} + +func envBool(name string, fallback bool) bool { + raw := os.Getenv(name) + if raw == "" { + return fallback + } + v, err := strconv.ParseBool(raw) + return err == nil && v +} diff --git a/control-plane/internal/gateway/gateway.go b/control-plane/internal/gateway/gateway.go new file mode 100644 index 0000000..d81836e --- /dev/null +++ b/control-plane/internal/gateway/gateway.go @@ -0,0 +1,141 @@ +// Package gateway adapts the Rust gateway admin API for the control plane. +package gateway + +import ( + "context" + "encoding/json" +) + +type UsageRow struct { + UserID string `json:"user_id"` + Model string `json:"model"` + Requests int64 `json:"requests"` + PromptTokens int64 `json:"prompt_tokens"` + CompletionTokens int64 `json:"completion_tokens"` + TotalTokens int64 `json:"total_tokens"` + CostMicros int64 `json:"cost_micros"` + VendorCostMicros int64 `json:"vendor_cost_micros"` +} + +type SeriesPoint struct { + Start int64 `json:"start"` + End int64 `json:"end"` + Requests int64 `json:"requests"` + PromptTokens int64 `json:"prompt_tokens"` + CompletionTokens int64 `json:"completion_tokens"` + TotalTokens int64 `json:"total_tokens"` + CostMicros int64 `json:"cost_micros"` + VendorCostMicros int64 `json:"vendor_cost_micros"` +} + +type Series struct { + Bucket string `json:"bucket"` + Since int64 `json:"since"` + Until int64 `json:"until"` + Points []SeriesPoint `json:"series"` +} + +type ModelStatus struct { + Model string `json:"model"` + State string `json:"state"` + Requests int64 `json:"requests"` + Errors int64 `json:"errors"` + WindowMinutes int64 `json:"window_minutes"` +} + +type Key struct { + AK string `json:"ak"` + Product string `json:"product"` + Tenant string `json:"tenant"` + Owner *string `json:"owner"` + QPS float64 `json:"qps"` + DailyTokenQuota int64 `json:"daily_token_quota"` + TokensPerMinute *int64 `json:"tokens_per_minute"` + ExpiresAtEpochSecs *int64 `json:"expires_at_epoch_secs"` + Banned bool `json:"banned"` + SuspendedUntilEpochSecs *int64 `json:"suspended_until_epoch_secs"` + Status string `json:"status"` + Available bool `json:"available"` + ModelQuotas map[string]int64 `json:"model_quotas,omitempty"` +} + +type Account struct { + Name string `json:"name"` + Provider string `json:"provider"` + Priority int64 `json:"priority"` + Tier string `json:"tier"` + Health string `json:"health"` + Protocols []string `json:"protocols"` +} + +type Instance struct { + ID string `json:"id"` + URL string `json:"url"` + Status string `json:"status"` + LatencyMS int64 `json:"latency_ms"` + Error string `json:"error,omitempty"` + Accounts []Account `json:"accounts"` +} + +type ConfigDocument struct { + Version int64 `json:"version"` + YAML string `json:"yaml"` +} + +type ConfigVersion struct { + ID int64 `json:"id"` + CreatedAtEpochSecs int64 `json:"created_at_epoch_secs"` +} + +type AuditEntry struct { + CreatedAtEpochSecs int64 `json:"created_at_epoch_secs"` + Actor string `json:"actor"` + Scope string `json:"scope"` + Action string `json:"action"` + Target string `json:"target"` + Summary string `json:"summary"` + SourceIP string `json:"source_ip"` +} + +type SecurityEvent struct { + CreatedAtEpochSecs int64 `json:"created_at_epoch_secs"` + RequestID string `json:"request_id"` + AK string `json:"ak"` + UserID string `json:"user_id"` + Tenant string `json:"tenant"` + Surface string `json:"surface"` + Rule string `json:"rule"` + Action string `json:"action"` + Hits int64 `json:"hits"` +} + +type Scope struct { + Tenant string + User string +} + +// Client is the control plane's only dependency on the Rust gateway. +type Client interface { + Usage(context.Context, Scope, int64, int64) ([]UsageRow, error) + UsageSeries(context.Context, Scope, string, int64, int64) (Series, error) + Models(context.Context, Scope) ([]ModelStatus, error) + Keys(context.Context, string) ([]Key, error) + CreateKey(context.Context, Key) error + PatchKey(context.Context, string, map[string]any) (Key, error) + DeleteKey(context.Context, string) error + Instances(context.Context) ([]Instance, error) + Config(context.Context) (ConfigDocument, error) + ValidateConfig(context.Context, string) (map[string]any, error) + PublishConfig(context.Context, string) (int64, error) + ConfigVersions(context.Context) ([]ConfigVersion, error) + RollbackConfig(context.Context, int64) (int64, error) + Audit(context.Context) ([]AuditEntry, error) + SecurityEvents(context.Context, string) ([]SecurityEvent, error) +} + +func cloneJSON[T any](value T) T { + body, _ := json.Marshal(value) + var out T + _ = json.Unmarshal(body, &out) + return out +} diff --git a/control-plane/internal/gateway/http.go b/control-plane/internal/gateway/http.go new file mode 100644 index 0000000..169025d --- /dev/null +++ b/control-plane/internal/gateway/http.go @@ -0,0 +1,306 @@ +package gateway + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "sort" + "strconv" + "strings" + "time" +) + +type Target struct { + ID string + URL string +} + +type HTTPClient struct { + targets []Target + adminToken string + client *http.Client +} + +func NewHTTP(rawTargets, adminToken string) (*HTTPClient, error) { + targets, err := parseTargets(rawTargets) + if err != nil { + return nil, err + } + return &HTTPClient{ + targets: targets, + adminToken: adminToken, + client: &http.Client{Timeout: 8 * time.Second}, + }, nil +} + +func parseTargets(raw string) ([]Target, error) { + parts := strings.Split(raw, ",") + targets := make([]Target, 0, len(parts)) + seen := make(map[string]struct{}) + for _, part := range parts { + id, endpoint, ok := strings.Cut(strings.TrimSpace(part), "=") + if !ok || id == "" || endpoint == "" { + return nil, fmt.Errorf("CP_GATEWAY_TARGETS entries must be id=url") + } + parsed, err := url.Parse(endpoint) + if err != nil || parsed.Scheme == "" || parsed.Host == "" { + return nil, fmt.Errorf("gateway target %s has an invalid URL", id) + } + if _, ok := seen[id]; ok { + return nil, fmt.Errorf("duplicate gateway target %s", id) + } + seen[id] = struct{}{} + targets = append(targets, Target{ID: id, URL: strings.TrimRight(endpoint, "/")}) + } + if len(targets) == 0 { + return nil, fmt.Errorf("at least one gateway target is required") + } + return targets, nil +} + +func (c *HTTPClient) Usage(ctx context.Context, scope Scope, since, until int64) ([]UsageRow, error) { + q := scopeQuery(scope) + q.Set("since", strconv.FormatInt(since, 10)) + q.Set("until", strconv.FormatInt(until, 10)) + var resp struct { + Usage []UsageRow `json:"usage"` + } + if err := c.doJSON(ctx, c.primary(), http.MethodGet, "/admin/usage/users?"+q.Encode(), nil, &resp, true); err != nil { + return nil, err + } + return resp.Usage, nil +} + +func (c *HTTPClient) UsageSeries(ctx context.Context, scope Scope, bucket string, since, until int64) (Series, error) { + q := scopeQuery(scope) + q.Set("bucket", bucket) + q.Set("since", strconv.FormatInt(since, 10)) + q.Set("until", strconv.FormatInt(until, 10)) + var series Series + err := c.doJSON(ctx, c.primary(), http.MethodGet, "/admin/usage/series?"+q.Encode(), nil, &series, true) + return series, err +} + +func (c *HTTPClient) Models(ctx context.Context, scope Scope) ([]ModelStatus, error) { + q := scopeQuery(scope) + path := "/admin/models/status" + if len(q) > 0 { + path += "?" + q.Encode() + } + var resp struct { + Models []ModelStatus `json:"models"` + } + if err := c.doJSON(ctx, c.primary(), http.MethodGet, path, nil, &resp, true); err != nil { + return nil, err + } + return resp.Models, nil +} + +func (c *HTTPClient) Keys(ctx context.Context, tenant string) ([]Key, error) { + q := make(url.Values) + q.Set("limit", "1000") + if tenant != "" { + q.Set("tenant", tenant) + } + var resp struct { + Keys []Key `json:"keys"` + } + if err := c.doJSON(ctx, c.primary(), http.MethodGet, "/admin/keys?"+q.Encode(), nil, &resp, true); err != nil { + return nil, err + } + return resp.Keys, nil +} + +func (c *HTTPClient) CreateKey(ctx context.Context, key Key) error { + return c.doJSON(ctx, c.primary(), http.MethodPost, "/admin/keys", key, nil, true) +} + +func (c *HTTPClient) PatchKey(ctx context.Context, ak string, patch map[string]any) (Key, error) { + var key Key + err := c.doJSON(ctx, c.primary(), http.MethodPatch, "/admin/keys/"+url.PathEscape(ak), patch, &key, true) + return key, err +} + +func (c *HTTPClient) DeleteKey(ctx context.Context, ak string) error { + return c.doJSON(ctx, c.primary(), http.MethodDelete, "/admin/keys/"+url.PathEscape(ak), nil, nil, true) +} + +func (c *HTTPClient) Instances(ctx context.Context) ([]Instance, error) { + type result struct { + instance Instance + } + ch := make(chan result, len(c.targets)) + for _, target := range c.targets { + go func(target Target) { + started := time.Now() + instance := Instance{ID: target.ID, URL: target.URL, Status: "unavailable", Accounts: []Account{}} + var health struct { + Status string `json:"status"` + } + if err := c.doJSON(ctx, target, http.MethodGet, "/health", nil, &health, false); err != nil { + instance.Error = err.Error() + instance.LatencyMS = time.Since(started).Milliseconds() + ch <- result{instance: instance} + return + } + var accounts struct { + Accounts []Account `json:"accounts"` + } + if err := c.doJSON(ctx, target, http.MethodGet, "/internal/accounts", nil, &accounts, false); err != nil { + instance.Status = "degraded" + instance.Error = err.Error() + } else { + instance.Status = "available" + instance.Accounts = accounts.Accounts + } + instance.LatencyMS = time.Since(started).Milliseconds() + ch <- result{instance: instance} + }(target) + } + instances := make([]Instance, 0, len(c.targets)) + for range c.targets { + instances = append(instances, (<-ch).instance) + } + sort.Slice(instances, func(i, j int) bool { return instances[i].ID < instances[j].ID }) + return instances, nil +} + +func (c *HTTPClient) Config(ctx context.Context) (ConfigDocument, error) { + var doc ConfigDocument + err := c.doJSON(ctx, c.primary(), http.MethodGet, "/admin/config", nil, &doc, true) + return doc, err +} + +func (c *HTTPClient) ValidateConfig(ctx context.Context, yaml string) (map[string]any, error) { + var result map[string]any + err := c.doText(ctx, c.primary(), http.MethodPost, "/admin/config/validate", yaml, &result) + return result, err +} + +func (c *HTTPClient) PublishConfig(ctx context.Context, yaml string) (int64, error) { + var result struct { + Version int64 `json:"version"` + } + err := c.doText(ctx, c.primary(), http.MethodPut, "/admin/config", yaml, &result) + return result.Version, err +} + +func (c *HTTPClient) ConfigVersions(ctx context.Context) ([]ConfigVersion, error) { + var result struct { + Versions []ConfigVersion `json:"versions"` + } + err := c.doJSON(ctx, c.primary(), http.MethodGet, "/admin/config/versions", nil, &result, true) + return result.Versions, err +} + +func (c *HTTPClient) RollbackConfig(ctx context.Context, id int64) (int64, error) { + var result struct { + Version int64 `json:"version"` + } + path := fmt.Sprintf("/admin/config/versions/%d/rollback", id) + err := c.doJSON(ctx, c.primary(), http.MethodPost, path, nil, &result, true) + return result.Version, err +} + +func (c *HTTPClient) Audit(ctx context.Context) ([]AuditEntry, error) { + var result struct { + Entries []AuditEntry `json:"entries"` + } + err := c.doJSON(ctx, c.primary(), http.MethodGet, "/admin/audit/ops?limit=200", nil, &result, true) + return result.Entries, err +} + +func (c *HTTPClient) SecurityEvents(ctx context.Context, tenant string) ([]SecurityEvent, error) { + q := make(url.Values) + q.Set("limit", "200") + if tenant != "" { + q.Set("tenant", tenant) + } + var result struct { + Events []SecurityEvent `json:"events"` + } + err := c.doJSON(ctx, c.primary(), http.MethodGet, "/admin/audit/events?"+q.Encode(), nil, &result, true) + return result.Events, err +} + +func (c *HTTPClient) primary() Target { return c.targets[0] } + +func scopeQuery(scope Scope) url.Values { + q := make(url.Values) + if scope.Tenant != "" { + q.Set("tenant", scope.Tenant) + } + if scope.User != "" { + q.Set("user", scope.User) + } + return q +} + +func (c *HTTPClient) doJSON(ctx context.Context, target Target, method, path string, input, output any, admin bool) error { + var body io.Reader + if input != nil { + encoded, err := json.Marshal(input) + if err != nil { + return fmt.Errorf("encode gateway request: %w", err) + } + body = bytes.NewReader(encoded) + } + req, err := http.NewRequestWithContext(ctx, method, target.URL+path, body) + if err != nil { + return fmt.Errorf("create gateway request: %w", err) + } + if input != nil { + req.Header.Set("Content-Type", "application/json") + } + if admin { + req.Header.Set("Authorization", "Bearer "+c.adminToken) + } + return c.send(req, output) +} + +func (c *HTTPClient) doText(ctx context.Context, target Target, method, path, input string, output any) error { + req, err := http.NewRequestWithContext(ctx, method, target.URL+path, strings.NewReader(input)) + if err != nil { + return fmt.Errorf("create gateway request: %w", err) + } + req.Header.Set("Content-Type", "application/yaml") + req.Header.Set("Authorization", "Bearer "+c.adminToken) + return c.send(req, output) +} + +func (c *HTTPClient) send(req *http.Request, output any) error { + resp, err := c.client.Do(req) + if err != nil { + return fmt.Errorf("request gateway: %w", err) + } + defer resp.Body.Close() + body, err := io.ReadAll(io.LimitReader(resp.Body, 4<<20)) + if err != nil { + return fmt.Errorf("read gateway response: %w", err) + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + var envelope struct { + Error struct { + Message string `json:"message"` + } `json:"error"` + } + _ = json.Unmarshal(body, &envelope) + message := strings.TrimSpace(envelope.Error.Message) + if message == "" { + message = strings.TrimSpace(string(body)) + } + return fmt.Errorf("gateway %s: %s", resp.Status, message) + } + if output != nil && len(body) > 0 { + if err := json.Unmarshal(body, output); err != nil { + return fmt.Errorf("decode gateway response: %w", err) + } + } + return nil +} + +var _ Client = (*HTTPClient)(nil) diff --git a/control-plane/internal/gateway/mock.go b/control-plane/internal/gateway/mock.go new file mode 100644 index 0000000..b5fc775 --- /dev/null +++ b/control-plane/internal/gateway/mock.go @@ -0,0 +1,242 @@ +package gateway + +import ( + "context" + "fmt" + "sort" + "strings" + "sync" + "time" +) + +type MockClient struct { + mu sync.RWMutex + yaml string + version int64 + versions []ConfigVersion + keys map[string]Key + audit []AuditEntry +} + +func NewMock() *MockClient { + now := time.Now().Unix() + owner := "alice" + expires := now + 30*86_400 + return &MockClient{ + yaml: "listen: {host: 0.0.0.0, port: 8080}\nstorage: {}\nmodels:\n - {name: gpt-4o, protocol: openai-chat}\naccounts:\n - {name: primary-openai, provider: openai, protocols: [openai-chat]}\ntenants:\n - {name: acme}\naccess_keys: []\n", + version: 3, + versions: []ConfigVersion{ + {ID: 3, CreatedAtEpochSecs: now - 300}, + {ID: 2, CreatedAtEpochSecs: now - 3_600}, + {ID: 1, CreatedAtEpochSecs: now - 86_400}, + }, + keys: map[string]Key{ + "ak-acme-alice": { + AK: "ak-acme-alice", Product: "standard", Tenant: "acme", Owner: &owner, + QPS: 10, DailyTokenQuota: 1_000_000, Status: "active", Available: true, + }, + "ak-acme-batch": { + AK: "ak-acme-batch", Product: "batch", Tenant: "acme", QPS: 2, + DailyTokenQuota: 500_000, ExpiresAtEpochSecs: &expires, Status: "active", Available: true, + }, + "ak-labs-paused": { + AK: "ak-labs-paused", Product: "research", Tenant: "labs", QPS: 1, + DailyTokenQuota: 100_000, Banned: true, Status: "banned", Available: false, + }, + }, + audit: []AuditEntry{ + {CreatedAtEpochSecs: now - 300, Actor: "global", Scope: "global", Action: "config_publish", Target: "3", SourceIP: "127.0.0.1"}, + {CreatedAtEpochSecs: now - 900, Actor: "global", Scope: "global", Action: "key_patch", Target: "ak-labs-paused", SourceIP: "127.0.0.1"}, + }, + } +} + +func (m *MockClient) Usage(_ context.Context, scope Scope, _, _ int64) ([]UsageRow, error) { + userID := scope.User + if userID == "" { + userID = "alice" + } + rows := []UsageRow{ + {UserID: userID, Model: "gpt-4o", Requests: 184, PromptTokens: 128_400, CompletionTokens: 42_700, TotalTokens: 171_100, CostMicros: 748_000, VendorCostMicros: 422_000}, + {UserID: userID, Model: "claude-sonnet", Requests: 62, PromptTokens: 84_200, CompletionTokens: 19_600, TotalTokens: 103_800, CostMicros: 512_000, VendorCostMicros: 331_000}, + } + if scope.User == "" { + rows = append(rows, UsageRow{UserID: "bob", Model: "gpt-4o-mini", Requests: 323, PromptTokens: 212_000, CompletionTokens: 31_000, TotalTokens: 243_000, CostMicros: 192_000, VendorCostMicros: 89_000}) + } + return rows, nil +} + +func (m *MockClient) UsageSeries(_ context.Context, _ Scope, bucket string, since, until int64) (Series, error) { + seconds := int64(86_400) + if bucket == "hour" { + seconds = 3_600 + } + first := since - since%seconds + points := make([]SeriesPoint, 0) + for start, idx := first, int64(0); start <= until && len(points) < 400; start, idx = start+seconds, idx+1 { + requests := 18 + (idx*7)%19 + tokens := requests * (730 + (idx%5)*120) + points = append(points, SeriesPoint{ + Start: start, End: min(start+seconds-1, until), Requests: requests, + PromptTokens: tokens * 3 / 4, CompletionTokens: tokens / 4, TotalTokens: tokens, + CostMicros: tokens * 4, VendorCostMicros: tokens * 2, + }) + } + return Series{Bucket: bucket, Since: since, Until: until, Points: points}, nil +} + +func (m *MockClient) Models(_ context.Context, _ Scope) ([]ModelStatus, error) { + return []ModelStatus{ + {Model: "gpt-4o", State: "available", Requests: 986, Errors: 4, WindowMinutes: 15}, + {Model: "gpt-4o-mini", State: "available", Requests: 1_422, Errors: 8, WindowMinutes: 15}, + {Model: "claude-sonnet", State: "unstable", Requests: 412, Errors: 57, WindowMinutes: 15}, + {Model: "realtime", State: "no_data", Requests: 0, Errors: 0, WindowMinutes: 15}, + }, nil +} + +func (m *MockClient) Keys(_ context.Context, tenant string) ([]Key, error) { + m.mu.RLock() + defer m.mu.RUnlock() + keys := make([]Key, 0, len(m.keys)) + for _, key := range m.keys { + if tenant == "" || key.Tenant == tenant { + keys = append(keys, cloneJSON(key)) + } + } + sort.Slice(keys, func(i, j int) bool { return keys[i].AK < keys[j].AK }) + return keys, nil +} + +func (m *MockClient) CreateKey(_ context.Context, key Key) error { + m.mu.Lock() + defer m.mu.Unlock() + if key.AK == "" || key.Product == "" || key.Tenant == "" { + return fmt.Errorf("ak, product and tenant are required") + } + key.Status = "active" + key.Available = true + m.keys[key.AK] = cloneJSON(key) + m.record("key_create", key.AK) + return nil +} + +func (m *MockClient) PatchKey(_ context.Context, ak string, patch map[string]any) (Key, error) { + m.mu.Lock() + defer m.mu.Unlock() + key, ok := m.keys[ak] + if !ok { + return Key{}, fmt.Errorf("key %s not found", ak) + } + if value, ok := patch["qps"].(float64); ok { + key.QPS = value + } + if value, ok := patch["daily_token_quota"].(float64); ok { + key.DailyTokenQuota = int64(value) + } + if value, ok := patch["banned"].(bool); ok { + key.Banned = value + key.Available = !value + if value { + key.Status = "banned" + } else { + key.Status = "active" + } + } + m.keys[ak] = key + m.record("key_patch", ak) + return cloneJSON(key), nil +} + +func (m *MockClient) DeleteKey(_ context.Context, ak string) error { + m.mu.Lock() + defer m.mu.Unlock() + if _, ok := m.keys[ak]; !ok { + return fmt.Errorf("key %s not found", ak) + } + delete(m.keys, ak) + m.record("key_delete", ak) + return nil +} + +func (m *MockClient) Instances(context.Context) ([]Instance, error) { + return []Instance{ + {ID: "gw-a", URL: "http://gw-a:8080", Status: "available", LatencyMS: 8, Accounts: []Account{{Name: "openai-primary", Provider: "openai", Tier: "paygo", Health: "healthy", Protocols: []string{"openai-chat"}}}}, + {ID: "gw-b", URL: "http://gw-b:8080", Status: "available", LatencyMS: 11, Accounts: []Account{{Name: "anthropic-primary", Provider: "anthropic", Tier: "paygo", Health: "healthy", Protocols: []string{"anthropic-messages"}}}}, + }, nil +} + +func (m *MockClient) Config(context.Context) (ConfigDocument, error) { + m.mu.RLock() + defer m.mu.RUnlock() + return ConfigDocument{Version: m.version, YAML: m.yaml}, nil +} + +func (m *MockClient) ValidateConfig(_ context.Context, yaml string) (map[string]any, error) { + if !strings.Contains(yaml, "listen:") || !strings.Contains(yaml, "models:") { + return nil, fmt.Errorf("invalid config: listen and models are required") + } + return map[string]any{"valid": true, "models": strings.Count(yaml, "name:")}, nil +} + +func (m *MockClient) PublishConfig(_ context.Context, yaml string) (int64, error) { + if _, err := m.ValidateConfig(context.Background(), yaml); err != nil { + return 0, err + } + m.mu.Lock() + defer m.mu.Unlock() + m.version++ + m.yaml = yaml + m.versions = append([]ConfigVersion{{ID: m.version, CreatedAtEpochSecs: time.Now().Unix()}}, m.versions...) + m.record("config_publish", fmt.Sprint(m.version)) + return m.version, nil +} + +func (m *MockClient) ConfigVersions(context.Context) ([]ConfigVersion, error) { + m.mu.RLock() + defer m.mu.RUnlock() + return append([]ConfigVersion(nil), m.versions...), nil +} + +func (m *MockClient) RollbackConfig(_ context.Context, id int64) (int64, error) { + m.mu.Lock() + defer m.mu.Unlock() + found := false + for _, version := range m.versions { + found = found || version.ID == id + } + if !found { + return 0, fmt.Errorf("config version %d not found", id) + } + m.version++ + m.versions = append([]ConfigVersion{{ID: m.version, CreatedAtEpochSecs: time.Now().Unix()}}, m.versions...) + m.record("config_rollback", fmt.Sprint(m.version)) + return m.version, nil +} + +func (m *MockClient) Audit(context.Context) ([]AuditEntry, error) { + m.mu.RLock() + defer m.mu.RUnlock() + entries := append([]AuditEntry(nil), m.audit...) + sort.Slice(entries, func(i, j int) bool { return entries[i].CreatedAtEpochSecs > entries[j].CreatedAtEpochSecs }) + return entries, nil +} + +func (m *MockClient) SecurityEvents(_ context.Context, tenant string) ([]SecurityEvent, error) { + now := time.Now().Unix() + if tenant == "" { + tenant = "acme" + } + return []SecurityEvent{ + {CreatedAtEpochSecs: now - 120, RequestID: "req-42", AK: "ak-acme-alice", UserID: "alice", Tenant: tenant, Surface: "chat", Rule: "dlp", Action: "redact", Hits: 1}, + {CreatedAtEpochSecs: now - 500, RequestID: "req-39", AK: "ak-acme-batch", UserID: "bob", Tenant: tenant, Surface: "batch", Rule: "blocklist", Action: "flag", Hits: 2}, + }, nil +} + +func (m *MockClient) record(action, target string) { + m.audit = append(m.audit, AuditEntry{ + CreatedAtEpochSecs: time.Now().Unix(), Actor: "control-plane", Scope: "global", + Action: action, Target: target, SourceIP: "127.0.0.1", + }) +} + +var _ Client = (*MockClient)(nil) diff --git a/control-plane/internal/httpapi/admin.go b/control-plane/internal/httpapi/admin.go new file mode 100644 index 0000000..75756af --- /dev/null +++ b/control-plane/internal/httpapi/admin.go @@ -0,0 +1,315 @@ +package httpapi + +import ( + "errors" + "net/http" + "strconv" + "time" + + "github.com/cocoonstack/gateway/control-plane/internal/auth" + "github.com/cocoonstack/gateway/control-plane/internal/gateway" + "github.com/cocoonstack/gateway/control-plane/internal/user" +) + +func (s *Server) listUsers(w http.ResponseWriter, r *http.Request) { + users, err := s.users.List(r.Context()) + if err != nil { + mapError(w, err) + return + } + for idx := range users { + users[idx] = publicUser(users[idx]) + } + writeJSON(w, http.StatusOK, map[string]any{"users": users}) +} + +func (s *Server) createUser(w http.ResponseWriter, r *http.Request) { + var body struct { + Email string `json:"email"` + DisplayName string `json:"display_name"` + Password string `json:"password"` + Tenant string `json:"tenant"` + GatewayUserID string `json:"gateway_user_id"` + Role user.Role `json:"role"` + } + if !decodeJSON(w, r, maxJSONBody, &body) { + return + } + hash, err := auth.HashPassword(body.Password) + if err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + id, err := auth.RandomToken(12) + if err != nil { + mapError(w, err) + return + } + now := time.Now().Unix() + u := user.User{ + ID: id, Email: body.Email, DisplayName: body.DisplayName, PasswordHash: hash, + Tenant: body.Tenant, GatewayUserID: body.GatewayUserID, Role: body.Role, + CreatedAt: now, UpdatedAt: now, + } + if err := s.users.Create(r.Context(), u); err != nil { + if errors.Is(err, user.ErrConflict) { + writeError(w, http.StatusConflict, "email already exists") + } else { + writeError(w, http.StatusBadRequest, err.Error()) + } + return + } + writeJSON(w, http.StatusCreated, publicUser(u)) +} + +func (s *Server) patchUser(w http.ResponseWriter, r *http.Request) { + u, err := s.users.ByID(r.Context(), r.PathValue("id")) + if err != nil { + mapError(w, err) + return + } + var body struct { + Email *string `json:"email"` + DisplayName *string `json:"display_name"` + Password *string `json:"password"` + Tenant *string `json:"tenant"` + GatewayUserID *string `json:"gateway_user_id"` + Role *user.Role `json:"role"` + Disabled *bool `json:"disabled"` + } + if !decodeJSON(w, r, maxJSONBody, &body) { + return + } + if body.Email != nil { + u.Email = *body.Email + } + if body.DisplayName != nil { + u.DisplayName = *body.DisplayName + } + if body.Password != nil && *body.Password != "" { + u.PasswordHash, err = auth.HashPassword(*body.Password) + if err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + } + if body.Tenant != nil { + u.Tenant = *body.Tenant + } + if body.GatewayUserID != nil { + u.GatewayUserID = *body.GatewayUserID + } + if body.Role != nil { + u.Role = *body.Role + } + if body.Disabled != nil { + u.Disabled = *body.Disabled + } + u.UpdatedAt = time.Now().Unix() + if err := s.users.Update(r.Context(), u); err != nil { + if errors.Is(err, user.ErrConflict) { + writeError(w, http.StatusConflict, "email already exists") + } else { + writeError(w, http.StatusBadRequest, err.Error()) + } + return + } + writeJSON(w, http.StatusOK, publicUser(u)) +} + +func (s *Server) listKeys(w http.ResponseWriter, r *http.Request) { + p := current(r) + tenant := r.URL.Query().Get("tenant") + if p.User.Role == user.RoleTenantAdmin { + tenant = p.User.Tenant + } + keys, err := s.gateway.Keys(r.Context(), tenant) + if err != nil { + mapError(w, err) + return + } + writeJSON(w, http.StatusOK, map[string]any{"keys": keys}) +} + +func (s *Server) createKey(w http.ResponseWriter, r *http.Request) { + var key gateway.Key + if !decodeJSON(w, r, maxJSONBody, &key) { + return + } + p := current(r) + if p.User.Role == user.RoleTenantAdmin { + key.Tenant = p.User.Tenant + } + if key.AK == "" || key.Product == "" || key.Tenant == "" { + writeError(w, http.StatusBadRequest, "ak, product and tenant are required") + return + } + if err := s.gateway.CreateKey(r.Context(), key); err != nil { + mapError(w, err) + return + } + writeJSON(w, http.StatusCreated, map[string]string{"status": "created", "ak": key.AK}) +} + +func (s *Server) patchKey(w http.ResponseWriter, r *http.Request) { + ak := r.PathValue("ak") + p := current(r) + if p.User.Role == user.RoleTenantAdmin && !s.keyBelongsTo(r, ak, p.User.Tenant) { + writeError(w, http.StatusNotFound, "key not found") + return + } + var patch map[string]any + if !decodeJSON(w, r, maxJSONBody, &patch) { + return + } + allowed := map[string]bool{ + "qps": true, "daily_token_quota": true, "tokens_per_minute": true, + "expires_at_epoch_secs": true, "banned": true, "suspended_until_epoch_secs": true, + } + for field := range patch { + if !allowed[field] { + writeError(w, http.StatusBadRequest, "unsupported key field: "+field) + return + } + } + key, err := s.gateway.PatchKey(r.Context(), ak, patch) + if err != nil { + mapError(w, err) + return + } + writeJSON(w, http.StatusOK, key) +} + +func (s *Server) deleteKey(w http.ResponseWriter, r *http.Request) { + ak := r.PathValue("ak") + p := current(r) + if p.User.Role == user.RoleTenantAdmin && !s.keyBelongsTo(r, ak, p.User.Tenant) { + writeError(w, http.StatusNotFound, "key not found") + return + } + if err := s.gateway.DeleteKey(r.Context(), ak); err != nil { + mapError(w, err) + return + } + w.WriteHeader(http.StatusNoContent) +} + +func (s *Server) keyBelongsTo(r *http.Request, ak, tenant string) bool { + keys, err := s.gateway.Keys(r.Context(), tenant) + if err != nil { + return false + } + for _, key := range keys { + if key.AK == ak { + return true + } + } + return false +} + +func (s *Server) getConfig(w http.ResponseWriter, r *http.Request) { + doc, err := s.gateway.Config(r.Context()) + if err != nil { + mapError(w, err) + return + } + writeJSON(w, http.StatusOK, doc) +} + +func (s *Server) validateConfig(w http.ResponseWriter, r *http.Request) { + yaml, ok := configBody(w, r) + if !ok { + return + } + result, err := s.gateway.ValidateConfig(r.Context(), yaml) + if err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + writeJSON(w, http.StatusOK, result) +} + +func (s *Server) publishConfig(w http.ResponseWriter, r *http.Request) { + yaml, ok := configBody(w, r) + if !ok { + return + } + version, err := s.gateway.PublishConfig(r.Context(), yaml) + if err != nil { + mapError(w, err) + return + } + writeJSON(w, http.StatusOK, map[string]any{"status": "published", "version": version}) +} + +func (s *Server) configVersions(w http.ResponseWriter, r *http.Request) { + versions, err := s.gateway.ConfigVersions(r.Context()) + if err != nil { + mapError(w, err) + return + } + writeJSON(w, http.StatusOK, map[string]any{"versions": versions}) +} + +func (s *Server) rollbackConfig(w http.ResponseWriter, r *http.Request) { + id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) + if err != nil || id <= 0 { + writeError(w, http.StatusBadRequest, "invalid config version") + return + } + version, err := s.gateway.RollbackConfig(r.Context(), id) + if err != nil { + mapError(w, err) + return + } + writeJSON(w, http.StatusOK, map[string]any{"status": "rolled_back", "version": version}) +} + +func configBody(w http.ResponseWriter, r *http.Request) (string, bool) { + var body struct { + YAML string `json:"yaml"` + } + if !decodeJSON(w, r, maxConfigBody, &body) { + return "", false + } + if body.YAML == "" { + writeError(w, http.StatusBadRequest, "yaml is required") + return "", false + } + return body.YAML, true +} + +func (s *Server) audit(w http.ResponseWriter, r *http.Request) { + p := current(r) + kind := r.URL.Query().Get("kind") + if kind == "" { + kind = "ops" + } + if kind == "ops" { + if p.User.Role != user.RoleSystemAdmin { + writeError(w, http.StatusForbidden, "system admin role required") + return + } + entries, err := s.gateway.Audit(r.Context()) + if err != nil { + mapError(w, err) + return + } + writeJSON(w, http.StatusOK, map[string]any{"entries": entries}) + return + } + if kind != "security" { + writeError(w, http.StatusBadRequest, "kind must be ops or security") + return + } + tenant := r.URL.Query().Get("tenant") + if p.User.Role == user.RoleTenantAdmin { + tenant = p.User.Tenant + } + events, err := s.gateway.SecurityEvents(r.Context(), tenant) + if err != nil { + mapError(w, err) + return + } + writeJSON(w, http.StatusOK, map[string]any{"events": events}) +} diff --git a/control-plane/internal/httpapi/auth.go b/control-plane/internal/httpapi/auth.go new file mode 100644 index 0000000..7964a5a --- /dev/null +++ b/control-plane/internal/httpapi/auth.go @@ -0,0 +1,73 @@ +package httpapi + +import ( + "errors" + "net/http" + "time" + + "github.com/cocoonstack/gateway/control-plane/internal/auth" + "github.com/cocoonstack/gateway/control-plane/internal/kv" +) + +func (s *Server) health(w http.ResponseWriter, _ *http.Request) { + writeJSON(w, http.StatusOK, map[string]string{"status": "ok", "service": "gateway-control-plane"}) +} + +func (s *Server) login(w http.ResponseWriter, r *http.Request) { + var body struct { + Email string `json:"email"` + Password string `json:"password"` + } + if !decodeJSON(w, r, maxJSONBody, &body) { + return + } + u, err := s.users.ByEmail(r.Context(), body.Email) + if err != nil || u.Disabled || !auth.VerifyPassword(u.PasswordHash, body.Password) { + writeError(w, http.StatusUnauthorized, "invalid email or password") + return + } + sessionID, err := auth.RandomToken(32) + if err != nil { + mapError(w, err) + return + } + csrf, err := auth.RandomToken(24) + if err != nil { + mapError(w, err) + return + } + session := kv.Session{ + ID: sessionID, UserID: u.ID, CSRFToken: csrf, + ExpiresAt: time.Now().Add(s.sessionTTL).Unix(), + } + if err := s.sessions.Put(r.Context(), session); err != nil { + mapError(w, err) + return + } + http.SetCookie(w, &http.Cookie{ + Name: sessionCookie, Value: session.ID, Path: "/", HttpOnly: true, + Secure: s.cookieSecure, SameSite: http.SameSiteLaxMode, + MaxAge: int(s.sessionTTL.Seconds()), Expires: time.Unix(session.ExpiresAt, 0), + }) + writeJSON(w, http.StatusOK, map[string]any{"user": publicUser(u), "csrf_token": csrf}) +} + +func (s *Server) logout(w http.ResponseWriter, r *http.Request) { + p := current(r) + if err := s.sessions.Delete(r.Context(), p.Session.ID); err != nil && !errors.Is(err, kv.ErrNotFound) { + mapError(w, err) + return + } + http.SetCookie(w, &http.Cookie{ + Name: sessionCookie, Path: "/", HttpOnly: true, Secure: s.cookieSecure, + SameSite: http.SameSiteLaxMode, MaxAge: -1, Expires: time.Unix(1, 0), + }) + w.WriteHeader(http.StatusNoContent) +} + +func (s *Server) session(w http.ResponseWriter, r *http.Request) { + p := current(r) + writeJSON(w, http.StatusOK, map[string]any{ + "user": publicUser(p.User), "csrf_token": p.Session.CSRFToken, + }) +} diff --git a/control-plane/internal/httpapi/data.go b/control-plane/internal/httpapi/data.go new file mode 100644 index 0000000..30fc9d5 --- /dev/null +++ b/control-plane/internal/httpapi/data.go @@ -0,0 +1,111 @@ +package httpapi + +import ( + "net/http" + + "github.com/cocoonstack/gateway/control-plane/internal/gateway" + "github.com/cocoonstack/gateway/control-plane/internal/user" +) + +func (s *Server) overview(w http.ResponseWriter, r *http.Request) { + since, until, bucket, err := period(r) + if err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + p := current(r) + scope := scopeFor(p.User) + usage, err := s.gateway.Usage(r.Context(), scope, since, until) + if err != nil { + mapError(w, err) + return + } + series, err := s.gateway.UsageSeries(r.Context(), scope, bucket, since, until) + if err != nil { + mapError(w, err) + return + } + models, err := s.gateway.Models(r.Context(), scope) + if err != nil { + mapError(w, err) + return + } + stripVendorCost(p.User.Role, usage, series.Points) + var totals struct { + Requests int64 `json:"requests"` + TotalTokens int64 `json:"total_tokens"` + CostMicros int64 `json:"cost_micros"` + VendorCostMicros int64 `json:"vendor_cost_micros,omitempty"` + } + for _, row := range usage { + totals.Requests += row.Requests + totals.TotalTokens += row.TotalTokens + totals.CostMicros += row.CostMicros + totals.VendorCostMicros += row.VendorCostMicros + } + writeJSON(w, http.StatusOK, map[string]any{ + "totals": totals, "usage": usage, "series": series, "models": models, + }) +} + +func (s *Server) usage(w http.ResponseWriter, r *http.Request) { + since, until, _, err := period(r) + if err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + p := current(r) + rows, err := s.gateway.Usage(r.Context(), scopeFor(p.User), since, until) + if err != nil { + mapError(w, err) + return + } + stripVendorCost(p.User.Role, rows, nil) + writeJSON(w, http.StatusOK, map[string]any{"usage": rows, "since": since, "until": until}) +} + +func (s *Server) usageSeries(w http.ResponseWriter, r *http.Request) { + since, until, bucket, err := period(r) + if err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + p := current(r) + series, err := s.gateway.UsageSeries(r.Context(), scopeFor(p.User), bucket, since, until) + if err != nil { + mapError(w, err) + return + } + stripVendorCost(p.User.Role, nil, series.Points) + writeJSON(w, http.StatusOK, series) +} + +func (s *Server) models(w http.ResponseWriter, r *http.Request) { + models, err := s.gateway.Models(r.Context(), scopeFor(current(r).User)) + if err != nil { + mapError(w, err) + return + } + writeJSON(w, http.StatusOK, map[string]any{"models": models}) +} + +func (s *Server) instances(w http.ResponseWriter, r *http.Request) { + instances, err := s.gateway.Instances(r.Context()) + if err != nil { + mapError(w, err) + return + } + writeJSON(w, http.StatusOK, map[string]any{"instances": instances}) +} + +func stripVendorCost(role user.Role, usage []gateway.UsageRow, points []gateway.SeriesPoint) { + if role == user.RoleSystemAdmin { + return + } + for idx := range usage { + usage[idx].VendorCostMicros = 0 + } + for idx := range points { + points[idx].VendorCostMicros = 0 + } +} diff --git a/control-plane/internal/httpapi/server.go b/control-plane/internal/httpapi/server.go new file mode 100644 index 0000000..ce0e80f --- /dev/null +++ b/control-plane/internal/httpapi/server.go @@ -0,0 +1,275 @@ +// Package httpapi serves the browser-facing control-plane API and static UI. +package httpapi + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "mime" + "net/http" + "os" + "path/filepath" + "strconv" + "strings" + "time" + + "github.com/projecteru2/core/log" + + "github.com/cocoonstack/gateway/control-plane/internal/gateway" + "github.com/cocoonstack/gateway/control-plane/internal/kv" + "github.com/cocoonstack/gateway/control-plane/internal/user" +) + +const ( + sessionCookie = "cp_session" + maxJSONBody = 1 << 20 + maxConfigBody = 4 << 20 +) + +type principal struct { + User user.User + Session kv.Session +} + +type principalKey struct{} + +type Server struct { + users user.Store + sessions kv.Sessions + gateway gateway.Client + sessionTTL time.Duration + cookieSecure bool + webDir string +} + +func New( + users user.Store, + sessions kv.Sessions, + gw gateway.Client, + sessionTTL time.Duration, + cookieSecure bool, + webDir string, +) *Server { + return &Server{ + users: users, + sessions: sessions, + gateway: gw, + sessionTTL: sessionTTL, + cookieSecure: cookieSecure, + webDir: webDir, + } +} + +func (s *Server) Handler() http.Handler { + mux := http.NewServeMux() + mux.HandleFunc("GET /api/v1/health", s.health) + mux.HandleFunc("POST /api/v1/auth/login", s.login) + mux.Handle("POST /api/v1/auth/logout", s.requireAuth(http.HandlerFunc(s.logout))) + mux.Handle("GET /api/v1/session", s.requireAuth(http.HandlerFunc(s.session))) + mux.Handle("GET /api/v1/overview", s.requireAuth(http.HandlerFunc(s.overview))) + mux.Handle("GET /api/v1/usage", s.requireAuth(http.HandlerFunc(s.usage))) + mux.Handle("GET /api/v1/usage/series", s.requireAuth(http.HandlerFunc(s.usageSeries))) + mux.Handle("GET /api/v1/models/status", s.requireAuth(http.HandlerFunc(s.models))) + mux.Handle("GET /api/v1/admin/instances", s.requireSystem(http.HandlerFunc(s.instances))) + mux.Handle("GET /api/v1/admin/users", s.requireSystem(http.HandlerFunc(s.listUsers))) + mux.Handle("POST /api/v1/admin/users", s.requireSystem(http.HandlerFunc(s.createUser))) + mux.Handle("PATCH /api/v1/admin/users/{id}", s.requireSystem(http.HandlerFunc(s.patchUser))) + mux.Handle("GET /api/v1/admin/keys", s.requireAdmin(http.HandlerFunc(s.listKeys))) + mux.Handle("POST /api/v1/admin/keys", s.requireAdmin(http.HandlerFunc(s.createKey))) + mux.Handle("PATCH /api/v1/admin/keys/{ak}", s.requireAdmin(http.HandlerFunc(s.patchKey))) + mux.Handle("DELETE /api/v1/admin/keys/{ak}", s.requireAdmin(http.HandlerFunc(s.deleteKey))) + mux.Handle("GET /api/v1/admin/config", s.requireSystem(http.HandlerFunc(s.getConfig))) + mux.Handle("POST /api/v1/admin/config/validate", s.requireSystem(http.HandlerFunc(s.validateConfig))) + mux.Handle("PUT /api/v1/admin/config", s.requireSystem(http.HandlerFunc(s.publishConfig))) + mux.Handle("GET /api/v1/admin/config/versions", s.requireSystem(http.HandlerFunc(s.configVersions))) + mux.Handle("POST /api/v1/admin/config/versions/{id}/rollback", s.requireSystem(http.HandlerFunc(s.rollbackConfig))) + mux.Handle("GET /api/v1/admin/audit", s.requireAdmin(http.HandlerFunc(s.audit))) + mux.HandleFunc("/", s.serveWeb) + return s.accessLog(mux) +} + +func (s *Server) requireAuth(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + cookie, err := r.Cookie(sessionCookie) + if err != nil || cookie.Value == "" { + writeError(w, http.StatusUnauthorized, "authentication required") + return + } + session, err := s.sessions.Get(r.Context(), cookie.Value) + if err != nil { + writeError(w, http.StatusUnauthorized, "authentication required") + return + } + u, err := s.users.ByID(r.Context(), session.UserID) + if err != nil || u.Disabled { + _ = s.sessions.Delete(r.Context(), session.ID) + writeError(w, http.StatusUnauthorized, "authentication required") + return + } + if mutating(r.Method) && r.Header.Get("X-CSRF-Token") != session.CSRFToken { + writeError(w, http.StatusForbidden, "invalid CSRF token") + return + } + ctx := context.WithValue(r.Context(), principalKey{}, principal{User: u, Session: session}) + next.ServeHTTP(w, r.WithContext(ctx)) + }) +} + +func (s *Server) requireAdmin(next http.Handler) http.Handler { + return s.requireAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if current(r).User.Role == user.RoleMember { + writeError(w, http.StatusForbidden, "admin role required") + return + } + next.ServeHTTP(w, r) + })) +} + +func (s *Server) requireSystem(next http.Handler) http.Handler { + return s.requireAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if current(r).User.Role != user.RoleSystemAdmin { + writeError(w, http.StatusForbidden, "system admin role required") + return + } + next.ServeHTTP(w, r) + })) +} + +func (s *Server) accessLog(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + started := time.Now() + wrapped := &statusWriter{ResponseWriter: w, status: http.StatusOK} + next.ServeHTTP(wrapped, r) + log.WithFunc("httpapi.access").Infof( + r.Context(), + "%s %s status=%d duration_ms=%d", + r.Method, + r.URL.Path, + wrapped.status, + time.Since(started).Milliseconds(), + ) + }) +} + +type statusWriter struct { + http.ResponseWriter + status int +} + +func (w *statusWriter) WriteHeader(status int) { + w.status = status + w.ResponseWriter.WriteHeader(status) +} + +func (s *Server) serveWeb(w http.ResponseWriter, r *http.Request) { + if strings.HasPrefix(r.URL.Path, "/api/") { + writeError(w, http.StatusNotFound, "not found") + return + } + path := filepath.Join(s.webDir, filepath.Clean("/"+r.URL.Path)) + if info, err := os.Stat(path); err == nil && !info.IsDir() { + if contentType := mime.TypeByExtension(filepath.Ext(path)); contentType != "" { + w.Header().Set("Content-Type", contentType) + } + http.ServeFile(w, r, path) + return + } + index := filepath.Join(s.webDir, "index.html") + if _, err := os.Stat(index); err != nil { + writeError(w, http.StatusNotFound, "web assets are not built") + return + } + http.ServeFile(w, r, index) +} + +func current(r *http.Request) principal { + value, _ := r.Context().Value(principalKey{}).(principal) + return value +} + +func scopeFor(u user.User) gateway.Scope { + switch u.Role { + case user.RoleSystemAdmin: + return gateway.Scope{} + case user.RoleTenantAdmin: + return gateway.Scope{Tenant: u.Tenant} + default: + userID := u.GatewayUserID + if userID == "" { + userID = u.ID + } + return gateway.Scope{Tenant: u.Tenant, User: userID} + } +} + +func period(r *http.Request) (int64, int64, string, error) { + now := time.Now().Unix() + since := queryInt(r, "since", now-29*86_400) + until := queryInt(r, "until", now) + bucket := r.URL.Query().Get("bucket") + if bucket == "" { + bucket = "day" + } + if since < 0 || until < since { + return 0, 0, "", fmt.Errorf("since/until must be a valid non-negative range") + } + if bucket != "hour" && bucket != "day" { + return 0, 0, "", fmt.Errorf("bucket must be hour or day") + } + return since, until, bucket, nil +} + +func queryInt(r *http.Request, name string, fallback int64) int64 { + value, err := strconv.ParseInt(r.URL.Query().Get(name), 10, 64) + if err != nil { + return fallback + } + return value +} + +func decodeJSON(w http.ResponseWriter, r *http.Request, limit int64, value any) bool { + decoder := json.NewDecoder(io.LimitReader(r.Body, limit)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(value); err != nil { + writeError(w, http.StatusBadRequest, "invalid JSON body") + return false + } + return true +} + +func writeJSON(w http.ResponseWriter, status int, value any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + if err := json.NewEncoder(w).Encode(value); err != nil { + log.WithFunc("httpapi.writeJSON").Error(wrappedContext(), err, "encode response") + } +} + +func writeError(w http.ResponseWriter, status int, message string) { + writeJSON(w, status, map[string]any{"error": map[string]string{"message": message}}) +} + +func publicUser(u user.User) user.User { + u.PasswordHash = "" + return u +} + +func mutating(method string) bool { + return method != http.MethodGet && method != http.MethodHead && method != http.MethodOptions +} + +func wrappedContext() context.Context { return context.Background() } + +func mapError(w http.ResponseWriter, err error) { + switch { + case errors.Is(err, user.ErrNotFound): + writeError(w, http.StatusNotFound, err.Error()) + case errors.Is(err, user.ErrConflict): + writeError(w, http.StatusConflict, err.Error()) + default: + log.WithFunc("httpapi.mapError").Error(wrappedContext(), err, "request failed") + writeError(w, http.StatusBadGateway, err.Error()) + } +} diff --git a/control-plane/internal/httpapi/server_test.go b/control-plane/internal/httpapi/server_test.go new file mode 100644 index 0000000..56e41b5 --- /dev/null +++ b/control-plane/internal/httpapi/server_test.go @@ -0,0 +1,155 @@ +package httpapi + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/cocoonstack/gateway/control-plane/internal/auth" + "github.com/cocoonstack/gateway/control-plane/internal/gateway" + kvmemory "github.com/cocoonstack/gateway/control-plane/internal/kv/memory" + storememory "github.com/cocoonstack/gateway/control-plane/internal/store/memory" + "github.com/cocoonstack/gateway/control-plane/internal/user" +) + +type loginState struct { + cookie *http.Cookie + csrf string +} + +func testServer(t *testing.T) http.Handler { + t.Helper() + store := storememory.New() + for _, seed := range []struct { + id, email, tenant, gatewayUserID string + role user.Role + }{ + {"admin", "admin@example.com", "", "", user.RoleSystemAdmin}, + {"manager", "manager@example.com", "acme", "", user.RoleTenantAdmin}, + {"member", "user@example.com", "acme", "alice", user.RoleMember}, + } { + hash, err := auth.HashPassword("password123!") + if err != nil { + t.Fatalf("hash password: %v", err) + } + now := time.Now().Unix() + if err := store.Create(context.Background(), user.User{ + ID: seed.id, Email: seed.email, DisplayName: seed.id, PasswordHash: hash, + Tenant: seed.tenant, GatewayUserID: seed.gatewayUserID, Role: seed.role, + CreatedAt: now, UpdatedAt: now, + }); err != nil { + t.Fatalf("seed user: %v", err) + } + } + return New(store, kvmemory.New(), gateway.NewMock(), time.Hour, false, t.TempDir()).Handler() +} + +func loginAs(t *testing.T, handler http.Handler, email string) loginState { + t.Helper() + body, _ := json.Marshal(map[string]string{"email": email, "password": "password123!"}) + req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("login status = %d, body = %s", rec.Code, rec.Body.String()) + } + var response struct { + CSRF string `json:"csrf_token"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &response); err != nil { + t.Fatalf("decode login: %v", err) + } + return loginState{cookie: rec.Result().Cookies()[0], csrf: response.CSRF} +} + +func request(t *testing.T, handler http.Handler, state loginState, method, path string, body any, csrf bool) *httptest.ResponseRecorder { + t.Helper() + var payload bytes.Buffer + if body != nil { + if err := json.NewEncoder(&payload).Encode(body); err != nil { + t.Fatalf("encode request: %v", err) + } + } + req := httptest.NewRequest(method, path, &payload) + req.AddCookie(state.cookie) + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + if csrf { + req.Header.Set("X-CSRF-Token", state.csrf) + } + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + return rec +} + +func TestMemberIsScopedAndCannotReadAdminSurfaces(t *testing.T) { + handler := testServer(t) + member := loginAs(t, handler, "user@example.com") + + rec := request(t, handler, member, http.MethodGet, "/api/v1/usage?user=bob", nil, false) + if rec.Code != http.StatusOK { + t.Fatalf("usage status = %d, body = %s", rec.Code, rec.Body.String()) + } + var usage struct { + Rows []gateway.UsageRow `json:"usage"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &usage); err != nil { + t.Fatalf("decode usage: %v", err) + } + for _, row := range usage.Rows { + if row.UserID != "alice" { + t.Errorf("row user = %q, want session user alice", row.UserID) + } + if row.VendorCostMicros != 0 { + t.Errorf("member vendor cost = %d, want hidden", row.VendorCostMicros) + } + } + + rec = request(t, handler, member, http.MethodGet, "/api/v1/admin/instances", nil, false) + if rec.Code != http.StatusForbidden { + t.Fatalf("instances status = %d, want 403", rec.Code) + } +} + +func TestSystemAdminConfigAndInstances(t *testing.T) { + handler := testServer(t) + admin := loginAs(t, handler, "admin@example.com") + + rec := request(t, handler, admin, http.MethodGet, "/api/v1/admin/instances", nil, false) + if rec.Code != http.StatusOK || !bytes.Contains(rec.Body.Bytes(), []byte("gw-a")) { + t.Fatalf("instances status = %d, body = %s", rec.Code, rec.Body.String()) + } + + body := map[string]string{"yaml": "listen: {host: h, port: 1}\nmodels: []\n"} + rec = request(t, handler, admin, http.MethodPost, "/api/v1/admin/config/validate", body, false) + if rec.Code != http.StatusForbidden { + t.Fatalf("missing csrf status = %d, want 403", rec.Code) + } + rec = request(t, handler, admin, http.MethodPost, "/api/v1/admin/config/validate", body, true) + if rec.Code != http.StatusOK { + t.Fatalf("validate status = %d, body = %s", rec.Code, rec.Body.String()) + } +} + +func TestTenantAdminCannotMutateAnotherTenantKey(t *testing.T) { + handler := testServer(t) + manager := loginAs(t, handler, "manager@example.com") + rec := request( + t, + handler, + manager, + http.MethodPatch, + "/api/v1/admin/keys/ak-labs-paused", + map[string]any{"banned": false}, + true, + ) + if rec.Code != http.StatusNotFound { + t.Fatalf("cross-tenant patch status = %d, want 404; body = %s", rec.Code, rec.Body.String()) + } +} diff --git a/control-plane/internal/kv/memory/session.go b/control-plane/internal/kv/memory/session.go new file mode 100644 index 0000000..4ff948c --- /dev/null +++ b/control-plane/internal/kv/memory/session.go @@ -0,0 +1,53 @@ +// Package memory provides the dependency-free development session store. +package memory + +import ( + "context" + "sync" + "time" + + "github.com/cocoonstack/gateway/control-plane/internal/kv" +) + +var _ kv.Sessions = (*Sessions)(nil) + +type Sessions struct { + mu sync.RWMutex + sessions map[string]kv.Session +} + +func New() *Sessions { + return &Sessions{sessions: make(map[string]kv.Session)} +} + +func (s *Sessions) Put(_ context.Context, session kv.Session) error { + s.mu.Lock() + defer s.mu.Unlock() + s.sessions[session.ID] = session + return nil +} + +func (s *Sessions) Get(_ context.Context, id string) (kv.Session, error) { + s.mu.RLock() + session, ok := s.sessions[id] + s.mu.RUnlock() + if !ok { + return kv.Session{}, kv.ErrNotFound + } + if session.ExpiresAt <= time.Now().Unix() { + s.mu.Lock() + delete(s.sessions, id) + s.mu.Unlock() + return kv.Session{}, kv.ErrNotFound + } + return session, nil +} + +func (s *Sessions) Delete(_ context.Context, id string) error { + s.mu.Lock() + defer s.mu.Unlock() + delete(s.sessions, id) + return nil +} + +func (s *Sessions) Close() error { return nil } diff --git a/control-plane/internal/kv/redis/session.go b/control-plane/internal/kv/redis/session.go new file mode 100644 index 0000000..b4ab299 --- /dev/null +++ b/control-plane/internal/kv/redis/session.go @@ -0,0 +1,78 @@ +// Package redis provides fleet-shared control-plane sessions. +package redis + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "time" + + redisclient "github.com/redis/go-redis/v9" + + "github.com/cocoonstack/gateway/control-plane/internal/kv" +) + +const keyPrefix = "gateway:control-plane:session:" + +var _ kv.Sessions = (*Sessions)(nil) + +type Sessions struct { + client *redisclient.Client +} + +func Connect(ctx context.Context, rawURL string) (*Sessions, error) { + opts, err := redisclient.ParseURL(rawURL) + if err != nil { + return nil, fmt.Errorf("parse redis url: %w", err) + } + client := redisclient.NewClient(opts) + if err := client.Ping(ctx).Err(); err != nil { + _ = client.Close() + return nil, fmt.Errorf("ping redis: %w", err) + } + return &Sessions{client: client}, nil +} + +func (s *Sessions) Put(ctx context.Context, session kv.Session) error { + body, err := json.Marshal(session) + if err != nil { + return fmt.Errorf("encode session: %w", err) + } + ttl := time.Until(time.Unix(session.ExpiresAt, 0)) + if ttl <= 0 { + return kv.ErrNotFound + } + if err := s.client.Set(ctx, keyPrefix+session.ID, body, ttl).Err(); err != nil { + return fmt.Errorf("store session: %w", err) + } + return nil +} + +func (s *Sessions) Get(ctx context.Context, id string) (kv.Session, error) { + body, err := s.client.Get(ctx, keyPrefix+id).Bytes() + if errors.Is(err, redisclient.Nil) { + return kv.Session{}, kv.ErrNotFound + } + if err != nil { + return kv.Session{}, fmt.Errorf("read session: %w", err) + } + var session kv.Session + if err := json.Unmarshal(body, &session); err != nil { + return kv.Session{}, fmt.Errorf("decode session: %w", err) + } + if session.ExpiresAt <= time.Now().Unix() { + _ = s.Delete(ctx, id) + return kv.Session{}, kv.ErrNotFound + } + return session, nil +} + +func (s *Sessions) Delete(ctx context.Context, id string) error { + if err := s.client.Del(ctx, keyPrefix+id).Err(); err != nil { + return fmt.Errorf("delete session: %w", err) + } + return nil +} + +func (s *Sessions) Close() error { return s.client.Close() } diff --git a/control-plane/internal/kv/session.go b/control-plane/internal/kv/session.go new file mode 100644 index 0000000..0dd58ed --- /dev/null +++ b/control-plane/internal/kv/session.go @@ -0,0 +1,23 @@ +// Package kv defines the control-plane session seam. +package kv + +import ( + "context" + "errors" +) + +var ErrNotFound = errors.New("session not found") + +type Session struct { + ID string `json:"id"` + UserID string `json:"user_id"` + CSRFToken string `json:"csrf_token"` + ExpiresAt int64 `json:"expires_at"` +} + +type Sessions interface { + Put(context.Context, Session) error + Get(context.Context, string) (Session, error) + Delete(context.Context, string) error + Close() error +} diff --git a/control-plane/internal/store/memory/store.go b/control-plane/internal/store/memory/store.go new file mode 100644 index 0000000..eb8c7a8 --- /dev/null +++ b/control-plane/internal/store/memory/store.go @@ -0,0 +1,94 @@ +// Package memory provides the dependency-free development identity store. +package memory + +import ( + "context" + "sort" + "sync" + + "github.com/cocoonstack/gateway/control-plane/internal/user" +) + +var _ user.Store = (*Store)(nil) + +type Store struct { + mu sync.RWMutex + byID map[string]user.User + byEmail map[string]string +} + +func New() *Store { + return &Store{ + byID: make(map[string]user.User), + byEmail: make(map[string]string), + } +} + +func (s *Store) Create(_ context.Context, u user.User) error { + if err := u.Validate(); err != nil { + return err + } + u.Email = user.NormalizeEmail(u.Email) + s.mu.Lock() + defer s.mu.Unlock() + if _, ok := s.byID[u.ID]; ok { + return user.ErrConflict + } + if _, ok := s.byEmail[u.Email]; ok { + return user.ErrConflict + } + s.byID[u.ID] = u + s.byEmail[u.Email] = u.ID + return nil +} + +func (s *Store) ByID(_ context.Context, id string) (user.User, error) { + s.mu.RLock() + defer s.mu.RUnlock() + u, ok := s.byID[id] + if !ok { + return user.User{}, user.ErrNotFound + } + return u, nil +} + +func (s *Store) ByEmail(_ context.Context, email string) (user.User, error) { + s.mu.RLock() + defer s.mu.RUnlock() + id, ok := s.byEmail[user.NormalizeEmail(email)] + if !ok { + return user.User{}, user.ErrNotFound + } + return s.byID[id], nil +} + +func (s *Store) List(_ context.Context) ([]user.User, error) { + s.mu.RLock() + defer s.mu.RUnlock() + users := make([]user.User, 0, len(s.byID)) + for _, u := range s.byID { + users = append(users, u) + } + sort.Slice(users, func(i, j int) bool { return users[i].Email < users[j].Email }) + return users, nil +} + +func (s *Store) Update(_ context.Context, u user.User) error { + if err := u.Validate(); err != nil { + return err + } + u.Email = user.NormalizeEmail(u.Email) + s.mu.Lock() + defer s.mu.Unlock() + old, ok := s.byID[u.ID] + if !ok { + return user.ErrNotFound + } + if id, ok := s.byEmail[u.Email]; ok && id != u.ID { + return user.ErrConflict + } + delete(s.byEmail, old.Email) + s.byID[u.ID] = u + s.byEmail[u.Email] = u.ID + return nil +} diff --git a/control-plane/internal/store/memory/store_test.go b/control-plane/internal/store/memory/store_test.go new file mode 100644 index 0000000..9e4be45 --- /dev/null +++ b/control-plane/internal/store/memory/store_test.go @@ -0,0 +1,45 @@ +package memory + +import ( + "context" + "errors" + "testing" + + "github.com/cocoonstack/gateway/control-plane/internal/user" +) + +func TestStoreContract(t *testing.T) { + ctx := context.Background() + store := New() + alice := user.User{ + ID: "u1", Email: "ALICE@example.com", DisplayName: "Alice", PasswordHash: "hash", + Tenant: "acme", GatewayUserID: "alice", Role: user.RoleMember, + CreatedAt: 1, UpdatedAt: 1, + } + if err := store.Create(ctx, alice); err != nil { + t.Fatalf("create user: %v", err) + } + got, err := store.ByEmail(ctx, " alice@EXAMPLE.com ") + if err != nil { + t.Fatalf("find by normalized email: %v", err) + } + if got.ID != alice.ID || got.Email != "alice@example.com" { + t.Errorf("got %+v, want normalized alice", got) + } + if err := store.Create(ctx, user.User{ID: "u2", Email: "alice@example.com", PasswordHash: "hash", Tenant: "acme", Role: user.RoleMember}); !errors.Is(err, user.ErrConflict) { + t.Fatalf("duplicate email error = %v, want conflict", err) + } + got.Disabled = true + got.UpdatedAt = 2 + if err := store.Update(ctx, got); err != nil { + t.Fatalf("update user: %v", err) + } + updated, err := store.ByID(ctx, alice.ID) + if err != nil || !updated.Disabled { + t.Fatalf("updated user = %+v, %v", updated, err) + } + users, err := store.List(ctx) + if err != nil || len(users) != 1 { + t.Fatalf("users = %+v, %v", users, err) + } +} diff --git a/control-plane/internal/store/postgres/migrations/001_users.sql b/control-plane/internal/store/postgres/migrations/001_users.sql new file mode 100644 index 0000000..8e486c9 --- /dev/null +++ b/control-plane/internal/store/postgres/migrations/001_users.sql @@ -0,0 +1,12 @@ +CREATE TABLE users ( + id TEXT PRIMARY KEY, + email TEXT NOT NULL UNIQUE, + display_name TEXT NOT NULL, + password_hash TEXT NOT NULL, + tenant TEXT NOT NULL DEFAULT '', + gateway_user_id TEXT NOT NULL DEFAULT '', + role TEXT NOT NULL, + disabled BOOLEAN NOT NULL DEFAULT FALSE, + created_at BIGINT NOT NULL, + updated_at BIGINT NOT NULL +); diff --git a/control-plane/internal/store/postgres/store.go b/control-plane/internal/store/postgres/store.go new file mode 100644 index 0000000..ba56278 --- /dev/null +++ b/control-plane/internal/store/postgres/store.go @@ -0,0 +1,176 @@ +// Package postgres provides the production identity store. +package postgres + +import ( + "context" + "embed" + "errors" + "fmt" + "sort" + "strings" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" + + "github.com/cocoonstack/gateway/control-plane/internal/user" +) + +//go:embed migrations/*.sql +var migrationFiles embed.FS + +var _ user.Store = (*Store)(nil) + +type Store struct { + pool *pgxpool.Pool +} + +func Connect(ctx context.Context, rawURL string) (*Store, error) { + pool, err := pgxpool.New(ctx, rawURL) + if err != nil { + return nil, fmt.Errorf("create postgres pool: %w", err) + } + if err := pool.Ping(ctx); err != nil { + pool.Close() + return nil, fmt.Errorf("ping postgres: %w", err) + } + s := &Store{pool: pool} + if err := s.migrate(ctx); err != nil { + pool.Close() + return nil, err + } + return s, nil +} + +func (s *Store) Close() { s.pool.Close() } + +func (s *Store) migrate(ctx context.Context) error { + if _, err := s.pool.Exec(ctx, "CREATE TABLE IF NOT EXISTS schema_migrations (name TEXT PRIMARY KEY, applied_at TIMESTAMPTZ NOT NULL DEFAULT now())"); err != nil { + return fmt.Errorf("create schema migrations: %w", err) + } + entries, err := migrationFiles.ReadDir("migrations") + if err != nil { + return fmt.Errorf("read migrations: %w", err) + } + sort.Slice(entries, func(i, j int) bool { return entries[i].Name() < entries[j].Name() }) + for _, entry := range entries { + name := entry.Name() + var applied bool + if err := s.pool.QueryRow(ctx, "SELECT EXISTS(SELECT 1 FROM schema_migrations WHERE name = $1)", name).Scan(&applied); err != nil { + return fmt.Errorf("check migration %s: %w", name, err) + } + if applied { + continue + } + body, err := migrationFiles.ReadFile("migrations/" + name) + if err != nil { + return fmt.Errorf("read migration %s: %w", name, err) + } + tx, err := s.pool.Begin(ctx) + if err != nil { + return fmt.Errorf("begin migration %s: %w", name, err) + } + if _, err := tx.Exec(ctx, string(body)); err != nil { + _ = tx.Rollback(ctx) + return fmt.Errorf("apply migration %s: %w", name, err) + } + if _, err := tx.Exec(ctx, "INSERT INTO schema_migrations (name) VALUES ($1)", name); err != nil { + _ = tx.Rollback(ctx) + return fmt.Errorf("record migration %s: %w", name, err) + } + if err := tx.Commit(ctx); err != nil { + return fmt.Errorf("commit migration %s: %w", name, err) + } + } + return nil +} + +func (s *Store) Create(ctx context.Context, u user.User) error { + if err := u.Validate(); err != nil { + return err + } + _, err := s.pool.Exec(ctx, + `INSERT INTO users (id, email, display_name, password_hash, tenant, + gateway_user_id, role, disabled, created_at, updated_at) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)`, + u.ID, user.NormalizeEmail(u.Email), u.DisplayName, u.PasswordHash, u.Tenant, + u.GatewayUserID, u.Role, u.Disabled, u.CreatedAt, u.UpdatedAt, + ) + if err != nil { + if strings.Contains(err.Error(), "duplicate key") { + return user.ErrConflict + } + return fmt.Errorf("create user: %w", err) + } + return nil +} + +func (s *Store) ByID(ctx context.Context, id string) (user.User, error) { + return scanUser(s.pool.QueryRow(ctx, userSelect+" WHERE id = $1", id)) +} + +func (s *Store) ByEmail(ctx context.Context, email string) (user.User, error) { + return scanUser(s.pool.QueryRow(ctx, userSelect+" WHERE email = $1", user.NormalizeEmail(email))) +} + +func (s *Store) List(ctx context.Context) ([]user.User, error) { + rows, err := s.pool.Query(ctx, userSelect+" ORDER BY email") + if err != nil { + return nil, fmt.Errorf("list users: %w", err) + } + defer rows.Close() + users := make([]user.User, 0) + for rows.Next() { + u, err := scanUser(rows) + if err != nil { + return nil, err + } + users = append(users, u) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate users: %w", err) + } + return users, nil +} + +func (s *Store) Update(ctx context.Context, u user.User) error { + if err := u.Validate(); err != nil { + return err + } + result, err := s.pool.Exec(ctx, + `UPDATE users SET email=$2, display_name=$3, password_hash=$4, tenant=$5, + gateway_user_id=$6, role=$7, disabled=$8, updated_at=$9 WHERE id=$1`, + u.ID, user.NormalizeEmail(u.Email), u.DisplayName, u.PasswordHash, u.Tenant, + u.GatewayUserID, u.Role, u.Disabled, u.UpdatedAt, + ) + if err != nil { + if strings.Contains(err.Error(), "duplicate key") { + return user.ErrConflict + } + return fmt.Errorf("update user: %w", err) + } + if result.RowsAffected() == 0 { + return user.ErrNotFound + } + return nil +} + +const userSelect = `SELECT id, email, display_name, password_hash, tenant, + gateway_user_id, role, disabled, created_at, updated_at FROM users` + +type scanner interface { + Scan(...any) error +} + +func scanUser(row scanner) (user.User, error) { + var u user.User + if err := row.Scan( + &u.ID, &u.Email, &u.DisplayName, &u.PasswordHash, &u.Tenant, + &u.GatewayUserID, &u.Role, &u.Disabled, &u.CreatedAt, &u.UpdatedAt, + ); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return user.User{}, user.ErrNotFound + } + return user.User{}, fmt.Errorf("scan user: %w", err) + } + return u, nil +} diff --git a/control-plane/internal/user/user.go b/control-plane/internal/user/user.go new file mode 100644 index 0000000..ac805e3 --- /dev/null +++ b/control-plane/internal/user/user.go @@ -0,0 +1,71 @@ +// Package user defines control-plane identities and their persistence seam. +package user + +import ( + "context" + "errors" + "strings" +) + +type Role string + +const ( + RoleMember Role = "member" + RoleTenantAdmin Role = "tenant_admin" + RoleSystemAdmin Role = "system_admin" +) + +var ( + ErrNotFound = errors.New("user not found") + ErrConflict = errors.New("user already exists") +) + +// User is one human control-plane identity. Non-system users belong to one +// gateway tenant; GatewayUserID is the billing attribution key. +type User struct { + ID string `json:"id"` + Email string `json:"email"` + DisplayName string `json:"display_name"` + PasswordHash string `json:"-"` + Tenant string `json:"tenant"` + GatewayUserID string `json:"gateway_user_id"` + Role Role `json:"role"` + Disabled bool `json:"disabled"` + CreatedAt int64 `json:"created_at"` + UpdatedAt int64 `json:"updated_at"` +} + +func (u User) Validate() error { + if strings.TrimSpace(u.ID) == "" { + return errors.New("id is required") + } + if NormalizeEmail(u.Email) == "" || !strings.Contains(u.Email, "@") { + return errors.New("valid email is required") + } + if u.PasswordHash == "" { + return errors.New("password hash is required") + } + switch u.Role { + case RoleMember, RoleTenantAdmin: + if strings.TrimSpace(u.Tenant) == "" { + return errors.New("tenant is required for non-system users") + } + case RoleSystemAdmin: + default: + return errors.New("invalid role") + } + return nil +} + +func NormalizeEmail(email string) string { + return strings.ToLower(strings.TrimSpace(email)) +} + +// Store persists human identities. Implementations must enforce unique email. +type Store interface { + Create(context.Context, User) error + ByID(context.Context, string) (User, error) + ByEmail(context.Context, string) (User, error) + List(context.Context) ([]User, error) + Update(context.Context, User) error +} From a29b0bd7a9f5c37be20e4ce596a2a258c63d5ae9 Mon Sep 17 00:00:00 2001 From: CMGS Date: Sat, 18 Jul 2026 17:17:46 +0800 Subject: [PATCH 03/19] Build role-aware control plane UI --- .gitignore | 5 + control-plane/web/index.html | 14 + control-plane/web/package-lock.json | 2693 +++++++++++++++++ control-plane/web/package.json | 30 + control-plane/web/src/App.test.tsx | 64 + control-plane/web/src/App.tsx | 152 + control-plane/web/src/api.ts | 48 + control-plane/web/src/components/UI.tsx | 126 + control-plane/web/src/format.ts | 30 + control-plane/web/src/hooks.ts | 22 + control-plane/web/src/main.tsx | 13 + control-plane/web/src/pages/AuditPage.tsx | 60 + .../web/src/pages/AvailabilityPage.tsx | 46 + control-plane/web/src/pages/ConfigPage.tsx | 123 + control-plane/web/src/pages/KeysPage.tsx | 65 + control-plane/web/src/pages/LoginPage.tsx | 55 + control-plane/web/src/pages/OverviewPage.tsx | 67 + control-plane/web/src/pages/UsagePage.tsx | 42 + control-plane/web/src/pages/UsersPage.tsx | 31 + control-plane/web/src/styles.css | 236 ++ control-plane/web/src/test-setup.ts | 3 + control-plane/web/src/types.ts | 132 + control-plane/web/src/vite-env.d.ts | 1 + control-plane/web/tsconfig.json | 21 + control-plane/web/vite.config.ts | 13 + control-plane/web/vitest.config.ts | 10 + 26 files changed, 4102 insertions(+) create mode 100644 control-plane/web/index.html create mode 100644 control-plane/web/package-lock.json create mode 100644 control-plane/web/package.json create mode 100644 control-plane/web/src/App.test.tsx create mode 100644 control-plane/web/src/App.tsx create mode 100644 control-plane/web/src/api.ts create mode 100644 control-plane/web/src/components/UI.tsx create mode 100644 control-plane/web/src/format.ts create mode 100644 control-plane/web/src/hooks.ts create mode 100644 control-plane/web/src/main.tsx create mode 100644 control-plane/web/src/pages/AuditPage.tsx create mode 100644 control-plane/web/src/pages/AvailabilityPage.tsx create mode 100644 control-plane/web/src/pages/ConfigPage.tsx create mode 100644 control-plane/web/src/pages/KeysPage.tsx create mode 100644 control-plane/web/src/pages/LoginPage.tsx create mode 100644 control-plane/web/src/pages/OverviewPage.tsx create mode 100644 control-plane/web/src/pages/UsagePage.tsx create mode 100644 control-plane/web/src/pages/UsersPage.tsx create mode 100644 control-plane/web/src/styles.css create mode 100644 control-plane/web/src/test-setup.ts create mode 100644 control-plane/web/src/types.ts create mode 100644 control-plane/web/src/vite-env.d.ts create mode 100644 control-plane/web/tsconfig.json create mode 100644 control-plane/web/vite.config.ts create mode 100644 control-plane/web/vitest.config.ts diff --git a/.gitignore b/.gitignore index ea8c4bf..02b1a10 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,6 @@ /target +/control-plane/web/node_modules +/control-plane/web/dist +/control-plane/web/playwright-report +/control-plane/web/test-results +*.tsbuildinfo diff --git a/control-plane/web/index.html b/control-plane/web/index.html new file mode 100644 index 0000000..0dcf876 --- /dev/null +++ b/control-plane/web/index.html @@ -0,0 +1,14 @@ + + + + + + + + Cocoon Gateway + + +
+ + + diff --git a/control-plane/web/package-lock.json b/control-plane/web/package-lock.json new file mode 100644 index 0000000..b7626e3 --- /dev/null +++ b/control-plane/web/package-lock.json @@ -0,0 +1,2693 @@ +{ + "name": "gateway-control-plane-web", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "gateway-control-plane-web", + "version": "0.1.0", + "dependencies": { + "react": "^19.2.7", + "react-dom": "^19.2.7", + "react-router-dom": "^7.18.1" + }, + "devDependencies": { + "@playwright/test": "^1.61.1", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", + "@types/react": "^19.2.17", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.3", + "jsdom": "^29.1.1", + "typescript": "^7.0.2", + "vite": "^8.1.5", + "vitest": "^4.1.10" + } + }, + "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": "5.1.11", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz", + "integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@csstools/css-calc": "^3.2.0", + "@csstools/css-color-parser": "^4.1.0", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz", + "integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/generational-cache": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz", + "integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true, + "license": "MIT" + }, + "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", + "peer": true, + "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/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", + "peer": true, + "engines": { + "node": ">=6.9.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/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.0.tgz", + "integrity": "sha512-064IFJdjTfUqnjpCVpMOdbr8FLQBhinbZj6yRv2An2E41O/pLEXqfFRWqGq/SxlE5PEUYTlvWsG2r8MswAVvkg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.2.1.tgz", + "integrity": "sha512-DtdHlgXh5ZkA43cwBcAm+huzgJiwx3ZTWVjBs94kwz2xKqSimDA3lBgCjphYgwgVUMWatSM0pDd8TILB1yrVVg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.9.tgz", + "integrity": "sha512-paQcIaOO53Rk5+YrBaBjm/SgrV4INImjo2BT1DtQRYr+XeTRbeAYlS+jxXp9drqvKmtFnWRJKIalDLhZZDu42A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.1.0", + "@csstools/css-calc": "^3.2.1" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.6.tgz", + "integrity": "sha512-TcJCWFbXLPpJYq6z7bfOyjWYJDiDg2/I4gyUC9pqPNqHFRIey0EB0q0L5cSnQDfWJg8Jd6VadakxdIez/3zkqQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@emnapi/core": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@exodus/bytes": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", + "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, + "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/@napi-rs/wasm-runtime": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", + "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.3" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.139.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.139.0.tgz", + "integrity": "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@playwright/test": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.1.tgz", + "integrity": "sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.61.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz", + "integrity": "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==", + "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.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.5.tgz", + "integrity": "sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==", + "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.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.5.tgz", + "integrity": "sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==", + "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.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.5.tgz", + "integrity": "sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==", + "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.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.5.tgz", + "integrity": "sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==", + "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.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.5.tgz", + "integrity": "sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==", + "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.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.5.tgz", + "integrity": "sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==", + "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.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.5.tgz", + "integrity": "sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==", + "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.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.5.tgz", + "integrity": "sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==", + "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.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.5.tgz", + "integrity": "sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==", + "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.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.5.tgz", + "integrity": "sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==", + "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.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.5.tgz", + "integrity": "sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.5.tgz", + "integrity": "sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.11.1", + "@emnapi/runtime": "1.11.1", + "@napi-rs/wasm-runtime": "^1.1.6" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.5.tgz", + "integrity": "sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==", + "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.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.5.tgz", + "integrity": "sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==", + "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/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "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": "6.9.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", + "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", + "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": ">=14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "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/@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/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "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/react": { + "version": "19.2.17", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", + "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@typescript/typescript-aix-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz", + "integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz", + "integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz", + "integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz", + "integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz", + "integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz", + "integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz", + "integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-loong64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz", + "integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-mips64el": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz", + "integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz", + "integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-riscv64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz", + "integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-s390x": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz", + "integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz", + "integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz", + "integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz", + "integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz", + "integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz", + "integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-sunos-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz", + "integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz", + "integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz", + "integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.3.tgz", + "integrity": "sha512-vmFvco5/QuC2f9Oj+wTk0+9XeDFkHxSamwZKYc7MxYwKICfvUvlMhqKI0VuICPltGqh1neqBKDvO4kes1ya8vg==", + "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/@vitest/expect": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", + "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz", + "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.10", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", + "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz", + "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.10", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz", + "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "@vitest/utils": "4.1.10", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz", + "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz", + "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "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", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "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/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/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "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/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "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/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/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/entities": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", + "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-module-lexer": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.1.tgz", + "integrity": "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "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/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/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "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/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/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", + "peer": true + }, + "node_modules/jsdom": { + "version": "29.1.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz", + "integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^5.1.11", + "@asamuzakjp/dom-selector": "^7.1.1", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.3", + "@exodus/bytes": "^1.15.0", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.3.5", + "parse5": "^8.0.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.1", + "undici": "^7.25.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.1", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "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/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "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/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, + "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/nanoid": { + "version": "3.3.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", + "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/obug": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz", + "integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.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/playwright": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz", + "integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.61.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz", + "integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/postcss": { + "version": "8.5.19", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.19.tgz", + "integrity": "sha512-Mz8SaolMd8nB+G13WkORcxQKHZ/NE4xXevtkJHVuG+guo9/wYKlIMTKAqGdEmYOXR2ijPjTYNHssizdaVSUNdQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/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/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/react": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", + "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz", + "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.7" + } + }, + "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-router": { + "version": "7.18.1", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.1.tgz", + "integrity": "sha512-GDLgg3i3uM0aeJO3Fm+TCS+sDQ7gu12T6x0qdTEzcwqEfleci7JwugVNIF3U//0FWKnJT7ptG+20B2jfDqnZAg==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.18.1", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.1.tgz", + "integrity": "sha512-KaZh+X/6UtEp28x51AUYZDMg9NGoz2ja3dNHa+ta/tk40vCzKhQ/RypCWBMLbmDr6//E24Vv5uPsrqXFozdkAg==", + "license": "MIT", + "dependencies": { + "react-router": "7.18.1" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, + "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-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rolldown": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz", + "integrity": "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.139.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.1.5", + "@rolldown/binding-darwin-arm64": "1.1.5", + "@rolldown/binding-darwin-x64": "1.1.5", + "@rolldown/binding-freebsd-x64": "1.1.5", + "@rolldown/binding-linux-arm-gnueabihf": "1.1.5", + "@rolldown/binding-linux-arm64-gnu": "1.1.5", + "@rolldown/binding-linux-arm64-musl": "1.1.5", + "@rolldown/binding-linux-ppc64-gnu": "1.1.5", + "@rolldown/binding-linux-s390x-gnu": "1.1.5", + "@rolldown/binding-linux-x64-gnu": "1.1.5", + "@rolldown/binding-linux-x64-musl": "1.1.5", + "@rolldown/binding-openharmony-arm64": "1.1.5", + "@rolldown/binding-wasm32-wasi": "1.1.5", + "@rolldown/binding-win32-arm64-msvc": "1.1.5", + "@rolldown/binding-win32-x64-msvc": "1.1.5" + } + }, + "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/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "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/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", + "dev": true, + "license": "MIT" + }, + "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/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/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "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/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "7.4.9", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.9.tgz", + "integrity": "sha512-3kZ8wQQ/k5DrChD4X4FVvr2D7E5uoRgAqkPyLpSCGUvqOvqu+JEdr3mwMUaVWb+vMHZaKhF5fp2PBigKsui7hA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.4.9" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.4.9", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.9.tgz", + "integrity": "sha512-DxKfPBI52p2msTEu7MPhdpdDTBhhVQg1a/8PjQckeyAvO13eMYElX545grIp6nnTGIMZlRvFZPvFhvI/WIz2Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz", + "integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, + "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/typescript": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", + "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc" + }, + "engines": { + "node": ">=16.20.0" + }, + "optionalDependencies": { + "@typescript/typescript-aix-ppc64": "7.0.2", + "@typescript/typescript-darwin-arm64": "7.0.2", + "@typescript/typescript-darwin-x64": "7.0.2", + "@typescript/typescript-freebsd-arm64": "7.0.2", + "@typescript/typescript-freebsd-x64": "7.0.2", + "@typescript/typescript-linux-arm": "7.0.2", + "@typescript/typescript-linux-arm64": "7.0.2", + "@typescript/typescript-linux-loong64": "7.0.2", + "@typescript/typescript-linux-mips64el": "7.0.2", + "@typescript/typescript-linux-ppc64": "7.0.2", + "@typescript/typescript-linux-riscv64": "7.0.2", + "@typescript/typescript-linux-s390x": "7.0.2", + "@typescript/typescript-linux-x64": "7.0.2", + "@typescript/typescript-netbsd-arm64": "7.0.2", + "@typescript/typescript-netbsd-x64": "7.0.2", + "@typescript/typescript-openbsd-arm64": "7.0.2", + "@typescript/typescript-openbsd-x64": "7.0.2", + "@typescript/typescript-sunos-x64": "7.0.2", + "@typescript/typescript-win32-arm64": "7.0.2", + "@typescript/typescript-win32-x64": "7.0.2" + } + }, + "node_modules/undici": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz", + "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, + "node_modules/vite": { + "version": "8.1.5", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.5.tgz", + "integrity": "sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.17", + "rolldown": "~1.1.5", + "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.3.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/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/vitest": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz", + "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.10", + "@vitest/mocker": "4.1.10", + "@vitest/pretty-format": "4.1.10", + "@vitest/runner": "4.1.10", + "@vitest/snapshot": "4.1.10", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.10", + "@vitest/browser-preview": "4.1.10", + "@vitest/browser-webdriverio": "4.1.10", + "@vitest/coverage-istanbul": "4.1.10", + "@vitest/coverage-v8": "4.1.10", + "@vitest/ui": "4.1.10", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "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/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "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" + } + } +} diff --git a/control-plane/web/package.json b/control-plane/web/package.json new file mode 100644 index 0000000..304b6f0 --- /dev/null +++ b/control-plane/web/package.json @@ -0,0 +1,30 @@ +{ + "name": "gateway-control-plane-web", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "typecheck": "tsc -b --pretty false", + "test": "vitest run", + "e2e": "playwright test" + }, + "dependencies": { + "react": "^19.2.7", + "react-dom": "^19.2.7", + "react-router-dom": "^7.18.1" + }, + "devDependencies": { + "@playwright/test": "^1.61.1", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", + "@types/react": "^19.2.17", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.3", + "jsdom": "^29.1.1", + "typescript": "^7.0.2", + "vite": "^8.1.5", + "vitest": "^4.1.10" + } +} diff --git a/control-plane/web/src/App.test.tsx b/control-plane/web/src/App.test.tsx new file mode 100644 index 0000000..3a69782 --- /dev/null +++ b/control-plane/web/src/App.test.tsx @@ -0,0 +1,64 @@ +import { cleanup, render, screen } from "@testing-library/react"; +import { MemoryRouter } from "react-router-dom"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import App from "./App"; +import type { Role, Session } from "./types"; + +const overview = { + totals: { requests: 12, total_tokens: 4200, cost_micros: 20000, vendor_cost_micros: 12000 }, + usage: [], + series: { bucket: "day", since: 1, until: 2, series: [] }, + models: [{ model: "gpt-test", state: "available", requests: 12, errors: 0, window_minutes: 15 }], +}; + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); +}); + +describe("role navigation", () => { + it("keeps a member on usage and availability surfaces", async () => { + mockAPI("member"); + render(); + + expect(await screen.findByRole("link", { name: /usage & cost/i })).toBeInTheDocument(); + expect(screen.getByRole("link", { name: /availability/i })).toBeInTheDocument(); + expect(screen.queryByRole("link", { name: /access keys/i })).not.toBeInTheDocument(); + expect(screen.queryByRole("link", { name: /configuration/i })).not.toBeInTheDocument(); + }); + + it("exposes fleet and configuration surfaces to a system admin", async () => { + mockAPI("system_admin"); + render(); + + expect(await screen.findByRole("link", { name: /access keys/i })).toBeInTheDocument(); + expect(screen.getByRole("link", { name: /users & roles/i })).toBeInTheDocument(); + expect(screen.getByRole("link", { name: /configuration/i })).toBeInTheDocument(); + expect(await screen.findByText("Revenue")).toBeInTheDocument(); + }); +}); + +function mockAPI(role: Role) { + const session: Session = { + csrf_token: "csrf-test", + user: { + id: "user-1", + email: "person@example.com", + display_name: role === "system_admin" ? "System Admin" : "Member User", + tenant: role === "system_admin" ? "" : "tenant-a", + gateway_user_id: role === "system_admin" ? "" : "gateway-user-1", + role, + disabled: false, + created_at: 1, + updated_at: 1, + }, + }; + vi.stubGlobal("fetch", vi.fn(async (input: RequestInfo | URL) => { + const path = typeof input === "string" ? input : input.toString(); + const body = path.startsWith("/api/v1/session") ? session : overview; + return new Response(JSON.stringify(body), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + })); +} diff --git a/control-plane/web/src/App.tsx b/control-plane/web/src/App.tsx new file mode 100644 index 0000000..c2dea30 --- /dev/null +++ b/control-plane/web/src/App.tsx @@ -0,0 +1,152 @@ +import { createContext, useContext, useEffect, useMemo, useState } from "react"; +import { Navigate, NavLink, Route, Routes, useLocation, useNavigate } from "react-router-dom"; +import { api, APIError, setCSRF } from "./api"; +import { roleLabel } from "./format"; +import type { Session } from "./types"; +import { Loading } from "./components/UI"; +import LoginPage from "./pages/LoginPage"; +import OverviewPage from "./pages/OverviewPage"; +import UsagePage from "./pages/UsagePage"; +import AvailabilityPage from "./pages/AvailabilityPage"; +import KeysPage from "./pages/KeysPage"; +import UsersPage from "./pages/UsersPage"; +import ConfigPage from "./pages/ConfigPage"; +import AuditPage from "./pages/AuditPage"; + +interface AuthState { + session: Session; + setSession: (session: Session) => void; + logout: () => Promise; +} + +const AuthContext = createContext(null); + +export function useAuth(): AuthState { + const value = useContext(AuthContext); + if (!value) throw new Error("AuthContext is unavailable"); + return value; +} + +export default function App() { + const [session, updateSession] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + api("/api/v1/session") + .then((value) => { + setCSRF(value.csrf_token); + updateSession(value); + }) + .catch((error: unknown) => { + if (!(error instanceof APIError) || error.status !== 401) { + console.error(error); + } + }) + .finally(() => setLoading(false)); + }, []); + + const auth = useMemo(() => { + if (!session) return null; + return { + session, + setSession(value) { + setCSRF(value.csrf_token); + updateSession(value); + }, + async logout() { + await api("/api/v1/auth/logout", { method: "POST" }); + setCSRF(""); + updateSession(null); + }, + }; + }, [session]); + + if (loading) return ; + if (!auth) return { + setCSRF(value.csrf_token); + updateSession(value); + }} />; + + return ( + + + + ); +} + +interface NavItem { + to: string; + label: string; + icon: string; + end?: boolean; +} + +const baseLinks: NavItem[] = [ + { to: "/", label: "Overview", icon: "◫", end: true }, + { to: "/usage", label: "Usage & cost", icon: "⌁" }, + { to: "/availability", label: "Availability", icon: "◉" }, +]; + +const adminLinks: NavItem[] = [ + { to: "/keys", label: "Access keys", icon: "⌘" }, + { to: "/audit", label: "Audit", icon: "≣" }, +]; + +const systemLinks: NavItem[] = [ + { to: "/users", label: "Users & roles", icon: "♙" }, + { to: "/configuration", label: "Configuration", icon: "⌗" }, +]; + +function Shell() { + const { session, logout } = useAuth(); + const role = session.user.role; + const location = useLocation(); + const navigate = useNavigate(); + const links = [ + ...baseLinks, + ...(role !== "member" ? adminLinks : []), + ...(role === "system_admin" ? systemLinks : []), + ]; + + useEffect(() => { + window.scrollTo({ top: 0 }); + }, [location.pathname]); + + return ( +
+ +
+ + } /> + } /> + } /> + : } /> + : } /> + : } /> + : } /> + } /> + +
+
+ ); +} diff --git a/control-plane/web/src/api.ts b/control-plane/web/src/api.ts new file mode 100644 index 0000000..4541c5b --- /dev/null +++ b/control-plane/web/src/api.ts @@ -0,0 +1,48 @@ +let csrfToken = ""; + +export class APIError extends Error { + status: number; + + constructor(status: number, message: string) { + super(message); + this.status = status; + } +} + +export function setCSRF(token: string): void { + csrfToken = token; +} + +export async function api(path: string, init: RequestInit = {}): Promise { + const method = (init.method ?? "GET").toUpperCase(); + const headers = new Headers(init.headers); + if (init.body && !headers.has("Content-Type")) { + headers.set("Content-Type", "application/json"); + } + if (!["GET", "HEAD", "OPTIONS"].includes(method) && csrfToken) { + headers.set("X-CSRF-Token", csrfToken); + } + const response = await fetch(path, { + ...init, + headers, + credentials: "same-origin", + }); + if (!response.ok) { + let message = `${response.status} ${response.statusText}`; + try { + const body = (await response.json()) as { error?: { message?: string } }; + message = body.error?.message ?? message; + } catch { + // The status line remains the useful fallback for a non-JSON proxy error. + } + throw new APIError(response.status, message); + } + if (response.status === 204) { + return undefined as T; + } + return (await response.json()) as T; +} + +export function jsonBody(value: unknown): RequestInit { + return { body: JSON.stringify(value) }; +} diff --git a/control-plane/web/src/components/UI.tsx b/control-plane/web/src/components/UI.tsx new file mode 100644 index 0000000..1ccba2b --- /dev/null +++ b/control-plane/web/src/components/UI.tsx @@ -0,0 +1,126 @@ +import type { ReactNode } from "react"; +import { compact, money } from "../format"; + +export function PageHeader({ + eyebrow, + title, + description, + actions, +}: { + eyebrow: string; + title: string; + description: string; + actions?: ReactNode; +}) { + return ( +
+
+

{eyebrow}

+

{title}

+

{description}

+
+ {actions &&
{actions}
} +
+ ); +} + +export function Card({ children, className = "" }: { children: ReactNode; className?: string }) { + return
{children}
; +} + +export function Metric({ + label, + value, + detail, + tone = "neutral", +}: { + label: string; + value: string; + detail: string; + tone?: "neutral" | "positive" | "warning"; +}) { + return ( + +

{label}

+ {value} + {detail} +
+ ); +} + +export function Status({ value }: { value: string }) { + return ( + + + ); +} + +export function Loading({ label = "Loading" }: { label?: string }) { + return ( +
+ + {label} +
+ ); +} + +export function Empty({ children }: { children: ReactNode }) { + return
{children}
; +} + +export function ErrorNotice({ message }: { message: string }) { + return
{message}
; +} + +export function LineChart({ + data, + value, + format = compact, +}: { + data: { start: number }[]; + value: string; + format?: (value: number) => string; +}) { + const width = 720; + const height = 230; + const padding = 18; + const values = data.map((item) => (item as Record)[value] ?? 0); + const max = Math.max(...values, 1); + const points = values + .map((item, index) => { + const x = padding + (index / Math.max(values.length - 1, 1)) * (width - padding * 2); + const y = height - padding - (item / max) * (height - padding * 2); + return `${x},${y}`; + }) + .join(" "); + return ( +
+
+ {format(max)} + {format(max / 2)} + 0 +
+ + + + + + + + + {points && ( + <> + + + + )} + +
+ ); +} + +export function Cost({ micros }: { micros: number }) { + return <>{money(micros)}; +} diff --git a/control-plane/web/src/format.ts b/control-plane/web/src/format.ts new file mode 100644 index 0000000..453e31b --- /dev/null +++ b/control-plane/web/src/format.ts @@ -0,0 +1,30 @@ +export function money(micros: number): string { + return new Intl.NumberFormat("en-US", { + style: "currency", + currency: "USD", + maximumFractionDigits: 2, + }).format(micros / 1_000_000); +} + +export function compact(value: number): string { + return new Intl.NumberFormat("en-US", { + notation: "compact", + maximumFractionDigits: 1, + }).format(value); +} + +export function dateTime(epoch: number): string { + return new Intl.DateTimeFormat("en-US", { + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + }).format(new Date(epoch * 1000)); +} + +export function roleLabel(role: string): string { + return role + .split("_") + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(" "); +} diff --git a/control-plane/web/src/hooks.ts b/control-plane/web/src/hooks.ts new file mode 100644 index 0000000..6ddcf74 --- /dev/null +++ b/control-plane/web/src/hooks.ts @@ -0,0 +1,22 @@ +import { useCallback, useEffect, useState } from "react"; +import { api } from "./api"; + +export function useAPI(path: string | null) { + const [data, setData] = useState(null); + const [error, setError] = useState(""); + const [version, setVersion] = useState(0); + + const reload = useCallback(() => setVersion((value) => value + 1), []); + + useEffect(() => { + if (!path) return; + let active = true; + setError(""); + api(path) + .then((value) => active && setData(value)) + .catch((err: unknown) => active && setError(err instanceof Error ? err.message : "Request failed")); + return () => { active = false; }; + }, [path, version]); + + return { data, error, loading: path !== null && data === null && error === "", reload, setData }; +} diff --git a/control-plane/web/src/main.tsx b/control-plane/web/src/main.tsx new file mode 100644 index 0000000..efbf46d --- /dev/null +++ b/control-plane/web/src/main.tsx @@ -0,0 +1,13 @@ +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import { BrowserRouter } from "react-router-dom"; +import App from "./App"; +import "./styles.css"; + +createRoot(document.getElementById("root")!).render( + + + + + , +); diff --git a/control-plane/web/src/pages/AuditPage.tsx b/control-plane/web/src/pages/AuditPage.tsx new file mode 100644 index 0000000..1ac6dea --- /dev/null +++ b/control-plane/web/src/pages/AuditPage.tsx @@ -0,0 +1,60 @@ +import { useState } from "react"; +import { Card, Empty, ErrorNotice, Loading, PageHeader, Status } from "../components/UI"; +import { dateTime } from "../format"; +import { useAPI } from "../hooks"; +import { useAuth } from "../App"; +import type { AuditEntry, SecurityEvent } from "../types"; + +export default function AuditPage() { + const { session } = useAuth(); + const system = session.user.role === "system_admin"; + const [kind, setKind] = useState<"ops" | "security">(system ? "ops" : "security"); + const ops = useAPI<{ entries: AuditEntry[] }>(kind === "ops" ? "/api/v1/admin/audit?kind=ops" : null); + const security = useAPI<{ events: SecurityEvent[] }>(kind === "security" ? "/api/v1/admin/audit?kind=security" : null); + + return ( + <> + + {system && } + + + } + /> + + {kind === "ops" ? ( + +

Operational changes

Configuration and administrative actions recorded by the gateway.

+ {ops.loading && } + {ops.error && } + {ops.data?.entries.length === 0 && No operational audit entries in this period.} + {ops.data && ops.data.entries.length > 0 && ( +
+ {ops.data.entries.map((entry, index) => + + )} +
TimeActorActionTargetSummary
{dateTime(entry.created_at_epoch_secs)}{entry.actor}{entry.target}{entry.summary}
+ )} +
+ ) : ( + +

Security events

Rule matches and actions, scoped to the tenants you manage.

+ {security.loading && } + {security.error && } + {security.data?.events.length === 0 && No security events in this period.} + {security.data && security.data.events.length > 0 && ( +
+ {security.data.events.map((event, index) => + + )} +
TimeTenant / userSurfaceRuleActionHits
{dateTime(event.created_at_epoch_secs)}{event.tenant || "—"}{event.user_id || event.ak}{event.surface}{event.rule}{event.hits}
+ )} +
+ )} + + ); +} diff --git a/control-plane/web/src/pages/AvailabilityPage.tsx b/control-plane/web/src/pages/AvailabilityPage.tsx new file mode 100644 index 0000000..0b05b18 --- /dev/null +++ b/control-plane/web/src/pages/AvailabilityPage.tsx @@ -0,0 +1,46 @@ +import { useAuth } from "../App"; +import { compact } from "../format"; +import { useAPI } from "../hooks"; +import type { Instance, ModelStatus } from "../types"; +import { Card, ErrorNotice, Loading, PageHeader, Status } from "../components/UI"; + +export default function AvailabilityPage() { + const { session } = useAuth(); + const isSystem = session.user.role === "system_admin"; + const models = useAPI<{ models: ModelStatus[] }>("/api/v1/models/status"); + const instances = useAPI<{ instances: Instance[] }>(isSystem ? "/api/v1/admin/instances" : null); + + return ( + <> + { models.reload(); if (isSystem) instances.reload(); }}>Refresh} + /> + {(models.error || (isSystem && instances.error)) && } + {!models.data ? : ( + +

Shared service view

Models

Recent gateway window
+
{models.data.models.map((model) => ( +
{model.model}
{compact(model.requests)} requests{model.errors} errors{model.window_minutes}m window
+ ))}
+
+ )} + {isSystem && ( + !instances.data ? : ( + +

Configured targets

Gateway instances

Health and local account-pool view
+
{instances.data.instances.map((instance) => ( +
+
{instance.id}{instance.url}
+
Latency {instance.latency_ms} msAccounts {instance.accounts.length}{instance.error && {instance.error}}
+
{instance.accounts.map((account) =>
{account.name}{account.provider} · {account.tier} · {account.protocols.join(", ")}
{account.health}
)}
+
+ ))}
+
+ ) + )} + + ); +} diff --git a/control-plane/web/src/pages/ConfigPage.tsx b/control-plane/web/src/pages/ConfigPage.tsx new file mode 100644 index 0000000..5596ee3 --- /dev/null +++ b/control-plane/web/src/pages/ConfigPage.tsx @@ -0,0 +1,123 @@ +import { useEffect, useState } from "react"; +import { api } from "../api"; +import { Card, ErrorNotice, Loading, PageHeader } from "../components/UI"; +import { dateTime } from "../format"; +import { useAPI } from "../hooks"; +import type { ConfigDocument, ConfigVersion } from "../types"; + +interface VersionList { + versions: ConfigVersion[]; +} + +export default function ConfigPage() { + const current = useAPI("/api/v1/admin/config"); + const history = useAPI("/api/v1/admin/config/versions"); + const [yaml, setYAML] = useState(""); + const [busy, setBusy] = useState(false); + const [message, setMessage] = useState(""); + const [error, setError] = useState(""); + + useEffect(() => { + if (current.data) setYAML(current.data.yaml); + }, [current.data]); + + async function act(kind: "validate" | "publish") { + setBusy(true); + setError(""); + setMessage(""); + try { + if (kind === "validate") { + await api("/api/v1/admin/config/validate", { + method: "POST", + body: JSON.stringify({ yaml }), + }); + setMessage("Configuration is valid and ready to publish."); + } else { + const result = await api<{ version: number }>("/api/v1/admin/config", { + method: "PUT", + body: JSON.stringify({ yaml }), + }); + setMessage(`Published as version ${result.version}.`); + current.reload(); + history.reload(); + } + } catch (cause) { + setError(cause instanceof Error ? cause.message : "Configuration request failed"); + } finally { + setBusy(false); + } + } + + async function rollback(version: number) { + if (!window.confirm(`Roll back to version ${version}? A new version will be published.`)) return; + setBusy(true); + setError(""); + setMessage(""); + try { + const result = await api<{ version: number }>(`/api/v1/admin/config/versions/${version}/rollback`, { + method: "POST", + }); + setMessage(`Version ${version} restored as version ${result.version}.`); + current.reload(); + history.reload(); + } catch (cause) { + setError(cause instanceof Error ? cause.message : "Rollback failed"); + } finally { + setBusy(false); + } + } + + if (current.loading) return ; + + return ( + <> + Current version {current.data?.version ?? "—"}
} + /> + {current.error && } + {error && } + {message &&
{message}
} + +
+ +
+

Gateway YAML

Changes take effect only after a successful publish.

+
+ +