diff --git a/crates/pm-web/src/main.rs b/crates/pm-web/src/main.rs index 8f3bd48..a7d35c7 100644 --- a/crates/pm-web/src/main.rs +++ b/crates/pm-web/src/main.rs @@ -12,7 +12,7 @@ use pm_auth::{ use pm_core::{ config::AppConfig, db, logging, models::ApprovedEntry, request_id::request_id_middleware, }; -use routes::sso::{OidcCache, SsoSession}; +use routes::sso::{OidcCache, SsoHandoff, SsoSession}; use routes::ws::WsTicket; use serde_json::{json, Value}; use std::{net::SocketAddr, sync::Arc, time::Duration}; @@ -36,6 +36,9 @@ pub struct AppState { pub ws_tickets: Arc>, /// In-memory store for SSO PKCE sessions (state → code_verifier). pub sso_sessions: Arc>, + /// In-memory store for SSO handoff codes (single-use, 60s TTL). + /// See `tasks/sso-token-handoff-spec.md` §4.1. + pub sso_handoffs: Arc>, /// Cached OIDC discovery document and JWKS for SSO id_token verification. pub oidc_cache: Arc>, /// Internal certificate authority for mTLS client cert issuance. @@ -104,6 +107,7 @@ async fn main() -> anyhow::Result<()> { let ws_tickets: Arc> = Arc::new(DashMap::new()); let sso_sessions: Arc> = Arc::new(DashMap::new()); + let sso_handoffs: Arc> = Arc::new(DashMap::new()); let oidc_cache: Arc> = Arc::new(Mutex::new(OidcCache::default())); let approved_enrollments: Arc> = Arc::new(DashMap::new()); @@ -163,6 +167,27 @@ async fn main() -> anyhow::Result<()> { }); } + // Background task: purge expired SSO handoff codes every 60 seconds. + // See `tasks/sso-token-handoff-spec.md` §4.3. Handoffs are also + // atomically removed on exchange (single-use), so this task only + // cleans up codes that the SPA never POSTed back for. + { + let handoffs = sso_handoffs.clone(); + tokio::spawn(async move { + let mut interval = tokio::time::interval(Duration::from_secs(60)); + loop { + interval.tick().await; + let now = std::time::Instant::now(); + let before = handoffs.len(); + handoffs.retain(|_, v| v.expires_at > now); + let removed = before.saturating_sub(handoffs.len()); + if removed > 0 { + tracing::debug!(removed, "Purged expired SSO handoff codes"); + } + } + }); + } + let state = AppState { db: pool, config: Arc::new(config.clone()), @@ -170,6 +195,7 @@ async fn main() -> anyhow::Result<()> { auth_config, ws_tickets, sso_sessions, + sso_handoffs, ca: Arc::new(ca), approved_enrollments, oidc_cache, diff --git a/crates/pm-web/src/routes/sso.rs b/crates/pm-web/src/routes/sso.rs old mode 100755 new mode 100644 index d850866..1c4d680 --- a/crates/pm-web/src/routes/sso.rs +++ b/crates/pm-web/src/routes/sso.rs @@ -12,11 +12,12 @@ use axum::{ extract::State, http::StatusCode, response::{IntoResponse, Json, Redirect}, - routing::get, + routing::{get, post}, Router, }; use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; use chrono::Utc; +use dashmap::DashMap; use jsonwebtoken::{decode, decode_header, Algorithm, DecodingKey, Validation}; use pm_auth::{jwt::issue_access_token, refresh}; use pm_core::audit::{log_event, AuditAction}; @@ -40,6 +41,140 @@ pub struct SsoSession { pub created_at: chrono::DateTime, } +/// Single-use, short-lived payload that the SSO callback hands to the SPA +/// via a `?handoff=` query param. The SPA exchanges it via +/// `POST /api/v1/auth/sso/handoff` for the actual JWT access/refresh +/// tokens. Mirrors the WS-ticket pattern (issue #10): in-memory, atomic +/// single-use consume, TTL enforced on read. +/// +/// See `tasks/sso-token-handoff-spec.md` §4.1 for the full design. +#[derive(Clone)] +pub struct SsoHandoff { + /// JWT access token (short-lived, 15 min TTL). + pub access_token: String, + /// Opaque refresh token (long-lived, rotating). + pub raw_refresh: String, + /// JSON-serialized user object (id, username, display_name, role, etc.). + pub user_json: Value, + /// Access token TTL in seconds (for the `expires_in` field in the response). + pub access_ttl: u64, + /// Expiry instant; the exchange endpoint rejects codes past this time. + pub expires_at: std::time::Instant, +} + +/// TTL for SSO handoff codes. Short by design: the SPA should POST to +/// `/api/v1/auth/sso/handoff` within seconds of the redirect landing. +/// +/// `dead_code` is allowed here because Phase 1 introduces the store +/// ahead of its consumer; the SSO callback rewrite in Phase 2 of +/// `tasks/sso-token-handoff-spec.md` inserts handoffs with this TTL and +/// the exchange handler reads it back to validate freshness. +#[allow(dead_code)] +pub const HANDOFF_TTL_SECS: u64 = 60; + +/// Generate a cryptographically random handoff code (32 bytes, +/// base64url-encoded, ~43 chars). Uses the same `rand` crate family as +/// the WS-ticket path. +/// +/// `dead_code` is allowed here for the same reason as `HANDOFF_TTL_SECS` +/// — Phase 2 wires it into the SSO callback redirect construction. +#[allow(dead_code)] +pub fn generate_handoff_code() -> String { + use rand::RngCore; + let mut bytes = [0u8; 32]; + rand::thread_rng().fill_bytes(&mut bytes); + URL_SAFE_NO_PAD.encode(bytes) +} + +/// Request body for `POST /api/v1/auth/sso/handoff`. +/// +/// The SPA sends the handoff code it received in the SSO callback +/// redirect's `?handoff=...` query param, and the backend exchanges it +/// for the actual access/refresh tokens. The code is single-use and +/// 60-second TTL. +#[derive(Debug, Deserialize)] +pub struct HandoffRequest { + pub handoff_code: String, +} + +// ============================================================ +// Handoff exchange handler +// ============================================================ + +/// `POST /api/v1/auth/sso/handoff` — exchange a single-use handoff code +/// for the JWT access/refresh tokens + user object. Public route (no +/// JWT required) — the handoff code IS the credential. +/// +/// See `tasks/sso-token-handoff-spec.md` §4.2 for the full design. +async fn sso_handoff_exchange( + State(state): State, + Json(req): Json, +) -> (StatusCode, Json) { + sso_handoff_exchange_inner(&state.sso_handoffs, &req.handoff_code).await +} + +/// Core exchange logic, separated from the HTTP handler so tests can +/// drive it with a bare `DashMap` (no need to construct a full +/// `AppState` with a real `sqlx::PgPool` and `Arc`). +/// +/// Marked `async` so the race test can use `tokio::join!` to drive +/// two concurrent exchanges against the same code; the function body +/// has no `.await` points (it only does a DashMap read and a return), +/// so this is a zero-cost abstraction. +async fn sso_handoff_exchange_inner( + handoffs: &DashMap, + code: &str, +) -> (StatusCode, Json) { + // Atomically remove the entry (single-use guarantee). If two + // requests race with the same code, DashMap::remove is atomic so + // only one wins. + let removed = handoffs.remove(code); + let Some((_code, handoff)) = removed else { + tracing::warn!( + reason = "unknown_or_already_consumed", + "SSO handoff exchange failed" + ); + return ( + StatusCode::BAD_REQUEST, + Json(json!({ + "error": { "code": "invalid_handoff", "message": "Handoff code is invalid or has expired" } + })), + ); + }; + + // Check expiry (the cleanup task also removes expired entries, but + // there's a race between expiry and the next cleanup tick — check + // here too so we never return a token for an expired handoff). + if handoff.expires_at <= std::time::Instant::now() { + tracing::warn!(reason = "expired", "SSO handoff exchange failed"); + return ( + StatusCode::BAD_REQUEST, + Json(json!({ + "error": { "code": "invalid_handoff", "message": "Handoff code is invalid or has expired" } + })), + ); + } + + // Log success without leaking the handoff code or the tokens + let user_id = handoff + .user_json + .get("id") + .and_then(|v| v.as_str()) + .unwrap_or("unknown"); + tracing::info!(user_id = %user_id, "SSO handoff exchanged"); + + ( + StatusCode::OK, + Json(json!({ + "access_token": handoff.access_token, + "refresh_token": handoff.raw_refresh, + "token_type": "Bearer", + "expires_in": handoff.access_ttl, + "user": handoff.user_json, + })), + ) +} + #[derive(Debug, Deserialize)] struct TokenResponse { #[allow(dead_code)] @@ -116,6 +251,12 @@ pub fn public_router() -> Router { .route("/login", get(sso_login)) .route("/callback", get(sso_callback)) .route("/config", get(sso_config)) + // Issue #4: single-use handoff exchange. The SPA POSTs the + // `?handoff=` it received from the SSO callback redirect + // and gets the JWT access/refresh tokens in the JSON response. + // Public route (no JWT) — the handoff code IS the credential. + // See `tasks/sso-token-handoff-spec.md` §4.2. + .route("/handoff", post(sso_handoff_exchange)) } /// Backward-compatible Azure SSO routes — redirect to generic SSO endpoints. @@ -604,13 +745,32 @@ async fn sso_callback( "mfa_enabled": user.mfa_enabled, }); - let redirect_url = format!( - "{}?access_token={}&refresh_token={}&token_type=Bearer&expires_in={}&user={}", - callback_url, - urlencoding::encode(&access_token), - urlencoding::encode(&raw_refresh.0), - access_ttl, - urlencoding::encode(&user_json.to_string()), + // Issue #4 fix: instead of embedding access/refresh tokens in the + // redirect URL (which leaks through browser history, proxy access + // logs, and the Referer header), generate a single-use, 60s handoff + // code, store the payload in `sso_handoffs`, and put ONLY the code + // in the redirect. The SPA POSTs to `/api/v1/auth/sso/handoff` to + // exchange the code for tokens. See `tasks/sso-token-handoff-spec.md` + // §4.1. + let handoff_code = generate_handoff_code(); + state.sso_handoffs.insert( + handoff_code.clone(), + SsoHandoff { + access_token: access_token.clone(), + raw_refresh: raw_refresh.0.clone(), + user_json: user_json.clone(), + access_ttl: access_ttl as u64, + expires_at: std::time::Instant::now() + + std::time::Duration::from_secs(HANDOFF_TTL_SECS), + }, + ); + + let redirect_url = format!("{}?handoff={}", callback_url, handoff_code); + + tracing::info!( + user_id = %user.id, + auth_provider = %auth_provider, + "SSO handoff issued" ); Ok(Redirect::to(&redirect_url)) @@ -836,3 +996,168 @@ async fn fetch_jwks(jwks_uri: &str) -> Result { .await .map_err(|e| format!("Failed to parse JWKS response: {}", e)) } + +#[cfg(test)] +mod tests { + //! Unit tests for the SSO handoff exchange endpoint and cleanup task. + //! + //! Per `tasks/sso-token-handoff-spec.md` §6.1–6.2. + //! + //! The tests call `sso_handoff_exchange_inner` directly with a bare + //! `DashMap`. This avoids the need to construct + //! a full `AppState` (which has `sqlx::PgPool` and `Arc` + //! fields that can't be cheaply mocked) and keeps the tests focused + //! on the exchange logic. The HTTP handler is a thin wrapper that + //! extracts the code from the request body and delegates. + + use super::*; + use dashmap::DashMap; + use std::sync::Arc; + use std::time::{Duration, Instant}; + + fn fresh_handoffs() -> Arc> { + Arc::new(DashMap::new()) + } + + fn make_handoff(access: &str, refresh: &str, user_id: &str) -> SsoHandoff { + SsoHandoff { + access_token: access.to_string(), + raw_refresh: refresh.to_string(), + user_json: json!({ "id": user_id, "username": "testuser" }), + access_ttl: 900, + expires_at: Instant::now() + Duration::from_secs(HANDOFF_TTL_SECS), + } + } + + /// 1. handoff_exchange_success — create a handoff, exchange it, + /// expect 200 with the access/refresh/user fields. + #[tokio::test] + async fn handoff_exchange_success() { + let handoffs = fresh_handoffs(); + let code = generate_handoff_code(); + handoffs.insert( + code.clone(), + make_handoff("jwt-access", "refresh-raw", "user-123"), + ); + + let (status, body) = sso_handoff_exchange_inner(&handoffs, &code).await; + + assert_eq!(status, StatusCode::OK); + assert_eq!(body["access_token"], "jwt-access"); + assert_eq!(body["refresh_token"], "refresh-raw"); + assert_eq!(body["token_type"], "Bearer"); + assert_eq!(body["expires_in"], 900); + assert_eq!(body["user"]["id"], "user-123"); + } + + /// 2. handoff_exchange_single_use — exchange once (success), + /// exchange the same code again (expect 400 invalid_handoff). + #[tokio::test] + async fn handoff_exchange_single_use() { + let handoffs = fresh_handoffs(); + let code = generate_handoff_code(); + handoffs.insert(code.clone(), make_handoff("a", "r", "u")); + + // First exchange succeeds + let (status1, _) = sso_handoff_exchange_inner(&handoffs, &code).await; + assert_eq!(status1, StatusCode::OK); + + // Second exchange with the same code fails (entry was removed) + let (status2, body2) = sso_handoff_exchange_inner(&handoffs, &code).await; + assert_eq!(status2, StatusCode::BAD_REQUEST); + assert_eq!(body2["error"]["code"], "invalid_handoff"); + } + + /// 3. handoff_exchange_unknown_code — exchange a code that was + /// never issued (expect 400 invalid_handoff). + #[tokio::test] + async fn handoff_exchange_unknown_code() { + let handoffs = fresh_handoffs(); + let (status, body) = sso_handoff_exchange_inner(&handoffs, "never-issued-code").await; + assert_eq!(status, StatusCode::BAD_REQUEST); + assert_eq!(body["error"]["code"], "invalid_handoff"); + } + + /// 4. handoff_exchange_expired_code — create a handoff with + /// expires_at in the past, exchange (expect 400 invalid_handoff). + #[tokio::test] + async fn handoff_exchange_expired_code() { + let handoffs = fresh_handoffs(); + let code = generate_handoff_code(); + let mut h = make_handoff("a", "r", "u"); + h.expires_at = Instant::now() - Duration::from_secs(1); // already expired + handoffs.insert(code.clone(), h); + + let (status, body) = sso_handoff_exchange_inner(&handoffs, &code).await; + assert_eq!(status, StatusCode::BAD_REQUEST); + assert_eq!(body["error"]["code"], "invalid_handoff"); + } + + /// 5. handoff_exchange_race — two concurrent exchanges with the + /// same code; exactly one succeeds, the other gets 400. + #[tokio::test] + async fn handoff_exchange_race() { + let handoffs = fresh_handoffs(); + let code = generate_handoff_code(); + handoffs.insert(code.clone(), make_handoff("a", "r", "u")); + + // DashMap::remove is atomic, so only one of two concurrent + // calls can win. The other gets None and returns 400. + let h1 = handoffs.clone(); + let h2 = handoffs.clone(); + let c1 = code.clone(); + let c2 = code.clone(); + let (r1, r2) = tokio::join!( + sso_handoff_exchange_inner(&h1, &c1), + sso_handoff_exchange_inner(&h2, &c2), + ); + + let status1 = r1.0; + let status2 = r2.0; + let successes = [status1, status2] + .iter() + .filter(|s| **s == StatusCode::OK) + .count(); + let failures = [status1, status2] + .iter() + .filter(|s| **s == StatusCode::BAD_REQUEST) + .count(); + assert_eq!(successes, 1, "exactly one exchange should succeed"); + assert_eq!(failures, 1, "exactly one exchange should fail"); + } + + /// 6. handoff_exchange_malformed_body — exchange with an empty + /// code (expect 400 invalid_handoff). + #[tokio::test] + async fn handoff_exchange_malformed_body() { + let handoffs = fresh_handoffs(); + let (status, body) = sso_handoff_exchange_inner(&handoffs, "").await; + assert_eq!(status, StatusCode::BAD_REQUEST); + assert_eq!(body["error"]["code"], "invalid_handoff"); + } + + /// 7. handoff_cleanup_removes_expired — create 3 handoffs with + /// varying `expires_at`, run one tick of the cleanup task, + /// assert only the non-expired ones remain. + #[tokio::test] + async fn handoff_cleanup_removes_expired() { + let handoffs = fresh_handoffs(); + // 2 expired, 1 fresh + for (i, expired) in [true, false, true].iter().enumerate() { + let mut h = make_handoff(&format!("a{}", i), "r", "u"); + if *expired { + h.expires_at = Instant::now() - Duration::from_secs(1); + } + handoffs.insert(format!("code-{}", i), h); + } + assert_eq!(handoffs.len(), 3); + + // Simulate one tick of the cleanup task (mirrors the logic + // in main.rs lines 174-188) + let now = Instant::now(); + handoffs.retain(|_, v| v.expires_at > now); + + assert_eq!(handoffs.len(), 1); + assert!(handoffs.contains_key("code-1")); + } +} diff --git a/docs/REST_API.md b/docs/REST_API.md index 7606024..d567dcb 100644 --- a/docs/REST_API.md +++ b/docs/REST_API.md @@ -14,6 +14,16 @@ Security: JWT Bearer Token (except Public Endpoints) | POST | `/auth/mfa/verify` | Verify MFA code | | DELETE | `/auth/mfa` | Disable MFA for user | +## 1b. SSO (Single Sign-On) +*No authentication required.* These endpoints implement the OIDC Authorization Code + PKCE flow. See `tasks/sso-token-handoff-spec.md` for the full design. + +| Method | Endpoint | Description | +|--------|----------|-------------| +| GET | `/auth/sso/login` | Initiate OIDC login: redirects browser to the configured IdP's authorization URL | +| GET | `/auth/sso/callback` | OIDC redirect URI: handles the IdP response, issues a single-use 60s `handoff_code`, stores the JWT access/refresh tokens in memory, and 302-redirects to the SPA with `?handoff=` in the URL (no tokens in the URL — see issue #4) | +| GET | `/auth/sso/config` | Returns minimal SSO configuration for the login page (`enabled`, `display_name`, `auth_url`). No secrets exposed | +| POST | `/auth/sso/handoff` | **(new in issue #4)** Exchange a single-use `handoff_code` for the JWT access/refresh tokens. The SPA calls this from `SsoCallbackPage` after the OIDC callback redirect. Returns `{ access_token, refresh_token, token_type, expires_in, user }`. The code is single-use, 60s TTL, and atomically removed on exchange (concurrent attempts: exactly one wins). `400 invalid_handoff` on unknown/expired/already-consumed codes | + ## 2. Public Endpoints (Self-Enrollment) *No authentication required.* | Method | Endpoint | Description | diff --git a/docs/security-review.md b/docs/security-review.md index ec3a35a..92bbe3e 100644 --- a/docs/security-review.md +++ b/docs/security-review.md @@ -92,6 +92,9 @@ verifying that all mandated security controls are implemented and operational. | OAuth2/OIDC Authorization Code + PKCE | ✅ Verified | Public routes `/api/v1/auth/azure/login` and `/api/v1/auth/azure/callback` implement PKCE flow | | Test connection without enabling | ✅ Verified | `POST /api/v1/settings/azure-sso/test` validates configuration without persisting | | MFA still required after SSO | ✅ Verified | SSO login follows same MFA verification path as local login | +| **No tokens in redirect URL (issue #4 fix)** | ✅ Verified | SSO callback (`crates/pm-web/src/routes/sso.rs` `sso_callback`) now issues a single-use, 60s `handoff_code` and stores the JWT access/refresh tokens in the in-memory `sso_handoffs: Arc>`. The redirect URL contains only `?handoff=`. No `access_token`, `refresh_token`, or `user` parameters are ever placed in the URL. The SPA exchanges the code via `POST /api/v1/auth/sso/handoff`. See `tasks/sso-token-handoff-spec.md` for the full design. | +| **Handoff code is single-use + 60s TTL** | ✅ Verified | `DashMap::remove` in `sso_handoff_exchange_inner` is atomic — concurrent exchange attempts result in exactly one success and one 400. Expired codes (`expires_at < Instant::now()`) are rejected with `400 invalid_handoff`. A background cleanup task removes expired entries every 60s. Verified by `handoff_exchange_single_use`, `handoff_exchange_race`, and `handoff_exchange_expired_code` tests in `crates/pm-web/src/routes/sso.rs`. | +| **Handoff code cleared from browser history** | ✅ Verified | SPA calls `window.history.replaceState({}, '', '/auth/sso/callback')` after a successful exchange, removing the `?handoff=` param from the address bar. Verified by `clears_handoff_code_from_url_after_success` test in `frontend/src/pages/__tests__/SsoCallbackPage.test.tsx`. | --- diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 01f7299..8965b60 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "patch-manager-ui", - "version": "0.1.0", + "version": "0.1.7", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "patch-manager-ui", - "version": "0.1.0", + "version": "0.1.7", "dependencies": { "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.0", @@ -22,6 +22,9 @@ "zustand": "^5.0.3" }, "devDependencies": { + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", "@typescript-eslint/eslint-plugin": "^8.30.0", @@ -29,10 +32,40 @@ "@vitejs/plugin-react": "^4.4.1", "eslint": "^9.24.0", "eslint-plugin-react-hooks": "^5.0.0", + "jsdom": "^25.0.1", "typescript": "^5.8.3", - "vite": "^6.3.3" + "vite": "^6.3.3", + "vitest": "^2.1.9" } }, + "node_modules/@adobe/css-tools": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.5.0.tgz", + "integrity": "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@asamuzakjp/css-color": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", + "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^2.1.3", + "@csstools/css-color-parser": "^3.0.9", + "@csstools/css-parser-algorithms": "^3.0.4", + "@csstools/css-tokenizer": "^3.0.3", + "lru-cache": "^10.4.3" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, "node_modules/@babel/code-frame": { "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", @@ -318,6 +351,121 @@ "node": ">=6.9.0" } }, + "node_modules/@csstools/color-helpers": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", + "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/css-calc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", + "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", + "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^5.1.0", + "@csstools/css-calc": "^2.1.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", + "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", + "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@emotion/babel-plugin": { "version": "11.13.5", "resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.13.5.tgz", @@ -1721,6 +1869,104 @@ "win32" ] }, + "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/@testing-library/user-event": { + "version": "14.6.1", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz", + "integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "peerDependencies": { + "@testing-library/dom": ">=7.21.4" + } + }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -2067,6 +2313,92 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, + "node_modules/@vitest/expect": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz", + "integrity": "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/pretty-format": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.9.tgz", + "integrity": "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.9.tgz", + "integrity": "sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "2.1.9", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.9.tgz", + "integrity": "sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "magic-string": "^0.30.12", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.9.tgz", + "integrity": "sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^3.0.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.9.tgz", + "integrity": "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "loupe": "^3.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/acorn": { "version": "8.16.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", @@ -2088,6 +2420,16 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, "node_modules/ajv": { "version": "6.15.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", @@ -2132,6 +2474,26 @@ "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", "dev": true }, + "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/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", @@ -2227,6 +2589,16 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/call-bind-apply-helpers": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", @@ -2275,6 +2647,23 @@ } ] }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -2291,6 +2680,16 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, "node_modules/cliui": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", @@ -2401,11 +2800,53 @@ "node": ">= 8" } }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cssstyle": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", + "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^3.2.0", + "rrweb-cssom": "^0.8.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/cssstyle/node_modules/rrweb-cssom": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", + "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", + "dev": true, + "license": "MIT" + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==" }, + "node_modules/data-urls": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", + "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -2430,6 +2871,23 @@ "node": ">=0.10.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/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -2444,11 +2902,29 @@ "node": ">=0.4.0" } }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/dijkstrajs": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz", "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==" }, + "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/dom-helpers": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", @@ -2482,6 +2958,19 @@ "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==" }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/error-ex": { "version": "1.3.4", "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", @@ -2506,6 +2995,13 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, "node_modules/es-object-atoms": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", @@ -2802,6 +3298,16 @@ "node": ">=4.0" } }, + "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/esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", @@ -2811,6 +3317,16 @@ "node": ">=0.10.0" } }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -3099,6 +3615,60 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" }, + "node_modules/html-encoding-sniffer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", + "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-encoding": "^3.1.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/ignore": { "version": "7.0.5", "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", @@ -3137,6 +3707,16 @@ "node": ">=0.8.19" } }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", @@ -3190,6 +3770,13 @@ "node": ">=0.10.0" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, "node_modules/isarray": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", @@ -3218,6 +3805,47 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jsdom": { + "version": "25.0.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-25.0.1.tgz", + "integrity": "sha512-8i7LzZj7BF8uplX+ZyOlIz86V6TAsSs+np6m1kpW9u0JWi4z/1t+FzcK1aek+ybTnAC4KhBL4uXCNT0wcUIeCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssstyle": "^4.1.0", + "data-urls": "^5.0.0", + "decimal.js": "^10.4.3", + "form-data": "^4.0.0", + "html-encoding-sniffer": "^4.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.5", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.12", + "parse5": "^7.1.2", + "rrweb-cssom": "^0.7.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^5.0.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0", + "ws": "^8.18.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "canvas": "^2.11.2" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -3342,6 +3970,13 @@ "loose-envify": "cli.js" } }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -3351,6 +3986,27 @@ "yallist": "^3.0.2" } }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "lz-string": "bin/bin.js" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -3378,6 +4034,16 @@ "node": ">= 0.6" } }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/minimatch": { "version": "10.2.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", @@ -3428,6 +4094,13 @@ "integrity": "sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw==", "dev": true }, + "node_modules/nwsapi": { + "version": "2.2.23", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.23.tgz", + "integrity": "sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ==", + "dev": true, + "license": "MIT" + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -3524,6 +4197,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -3554,6 +4240,23 @@ "node": ">=8" } }, + "node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -3616,6 +4319,44 @@ "node": ">= 0.8.0" } }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/pretty-format/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/process-nextick-args": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", @@ -3767,6 +4508,20 @@ "util-deprecate": "~1.0.1" } }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -3852,11 +4607,38 @@ "fsevents": "~2.3.2" } }, + "node_modules/rrweb-cssom": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.7.1.tgz", + "integrity": "sha512-TrEMa7JGdVm0UThDJSx7ddw5nVm3UJS9o9CCIZ72B1vSyEZoziDqBYP3XIoi/12lKrJR8rE3jeFHMok2F/Mnsg==", + "dev": true, + "license": "MIT" + }, "node_modules/safe-buffer": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/scheduler": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", @@ -3910,6 +4692,13 @@ "node": ">=8" } }, + "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": { "version": "0.5.7", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", @@ -3927,6 +4716,20 @@ "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": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, "node_modules/string_decoder": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", @@ -3959,6 +4762,19 @@ "node": ">=8" } }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", @@ -3999,6 +4815,27 @@ "url": "https://github.com/sponsors/ljharb" } }, + "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": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, "node_modules/tinyglobby": { "version": "0.2.16", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", @@ -4015,6 +4852,82 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-1.2.0.tgz", + "integrity": "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz", + "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz", + "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^6.1.86" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz", + "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz", + "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^6.1.32" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/ts-api-utils": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", @@ -4175,6 +5088,1163 @@ } } }, + "node_modules/vite-node": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.9.tgz", + "integrity": "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.3.7", + "es-module-lexer": "^1.5.4", + "pathe": "^1.1.2", + "vite": "^5.0.0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vite-node/node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/vite-node/node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.9.tgz", + "integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "2.1.9", + "@vitest/mocker": "2.1.9", + "@vitest/pretty-format": "^2.1.9", + "@vitest/runner": "2.1.9", + "@vitest/snapshot": "2.1.9", + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "debug": "^4.3.7", + "expect-type": "^1.1.0", + "magic-string": "^0.30.12", + "pathe": "^1.1.2", + "std-env": "^3.8.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.1", + "tinypool": "^1.0.1", + "tinyrainbow": "^1.2.0", + "vite": "^5.0.0", + "vite-node": "2.1.9", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/node": "^18.0.0 || >=20.0.0", + "@vitest/browser": "2.1.9", + "@vitest/ui": "2.1.9", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@vitest/mocker": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.9.tgz", + "integrity": "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.12" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/vitest/node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "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": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-url": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -4195,6 +6265,23 @@ "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==" }, + "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/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", @@ -4217,6 +6304,45 @@ "node": ">=8" } }, + "node_modules/ws": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, "node_modules/y18n": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", @@ -4228,23 +6354,6 @@ "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", "dev": true }, - "node_modules/yaml": { - "version": "2.8.3", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz", - "integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==", - "dev": true, - "optional": true, - "peer": true, - "bin": { - "yaml": "bin.mjs" - }, - "engines": { - "node": ">= 14.6" - }, - "funding": { - "url": "https://github.com/sponsors/eemeli" - } - }, "node_modules/yargs": { "version": "15.4.1", "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index f42ded0..31a4900 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -8,7 +8,9 @@ "build": "tsc && vite build", "preview": "vite preview", "lint": "eslint src/ --ext .ts,.tsx --max-warnings 0", - "type-check": "tsc --noEmit" + "type-check": "tsc --noEmit", + "test": "vitest run", + "test:watch": "vitest" }, "dependencies": { "@emotion/react": "^11.14.0", @@ -25,6 +27,9 @@ "zustand": "^5.0.3" }, "devDependencies": { + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", "@typescript-eslint/eslint-plugin": "^8.30.0", @@ -32,7 +37,9 @@ "@vitejs/plugin-react": "^4.4.1", "eslint": "^9.24.0", "eslint-plugin-react-hooks": "^5.0.0", + "jsdom": "^25.0.1", "typescript": "^5.8.3", - "vite": "^6.3.3" + "vite": "^6.3.3", + "vitest": "^2.1.9" } } diff --git a/frontend/src/pages/SsoCallbackPage.tsx b/frontend/src/pages/SsoCallbackPage.tsx index 0d55305..d3dc988 100644 --- a/frontend/src/pages/SsoCallbackPage.tsx +++ b/frontend/src/pages/SsoCallbackPage.tsx @@ -1,76 +1,97 @@ import { useEffect, useState } from 'react' -import { useNavigate } from 'react-router-dom' +import { useNavigate, useSearchParams } from 'react-router-dom' import { Box, Container, Paper, Typography, Alert, Button, CircularProgress, } from '@mui/material' import { useAuthStore } from '../store/authStore' import type { User } from '../types' +/** + * SSO callback page. + * + * Flow (per `tasks/sso-token-handoff-spec.md`): + * 1. The OIDC provider redirects the browser here with `?handoff=` + * in the URL. The actual JWT access/refresh tokens are NOT in the URL + * (that would leak them through browser history, proxy access logs, + * and the Referer header — see issue #4). + * 2. On mount, we POST the handoff code to + * `POST /api/v1/auth/sso/handoff`. The backend atomically removes + * the entry (single-use) and returns the tokens in the JSON + * response. + * 3. On success, we call `setTokens` + `setUser` on the auth store, + * replace the URL (removing the handoff code from history), and + * navigate to `/dashboard`. + * 4. On failure, we show an error and let the user go back to `/login`. + */ export default function SsoCallbackPage() { const navigate = useNavigate() + const [searchParams] = useSearchParams() const { setTokens, setUser } = useAuthStore() const [error, setError] = useState(null) const [processing, setProcessing] = useState(true) useEffect(() => { - const params = new URLSearchParams(window.location.search) - - // Check for error from backend - const errorCode = params.get('error') - const errorDescription = params.get('error_description') + // Surface upstream OIDC errors (e.g. user denied consent) unchanged. + const errorCode = searchParams.get('error') + const errorDescription = searchParams.get('error_description') if (errorCode) { setError(errorDescription || `SSO authentication failed: ${errorCode}`) setProcessing(false) return } - // Extract tokens - const accessToken = params.get('access_token') - const refreshToken = params.get('refresh_token') - - if (!accessToken || !refreshToken) { - setError('Missing authentication tokens. Please try logging in again.') + const handoffCode = searchParams.get('handoff') + if (!handoffCode) { + setError('Missing handoff code. Please try logging in again.') setProcessing(false) return } - // Parse user JSON from query param - const userParam = params.get('user') - if (!userParam) { - setError('Missing user information. Please try logging in again.') - setProcessing(false) - return - } + // Exchange the handoff code for tokens. The code is single-use and + // 60-second TTL on the backend; the SPA must POST promptly. + (async () => { + try { + const resp = await fetch('/api/v1/auth/sso/handoff', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ handoff_code: handoffCode }), + }) + if (!resp.ok) { + // Try to extract a structured error from the backend + let message = `Failed to complete sign-in (HTTP ${resp.status})` + try { + const errBody = await resp.json() + if (errBody?.error?.message) { + message = errBody.error.message + } + } catch { + // Body wasn't JSON; keep the default message + } + setError(message) + setProcessing(false) + return + } - let parsedUser: Record - try { - parsedUser = JSON.parse(userParam) - } catch { - setError('Malformed user data received. Please try logging in again.') - setProcessing(false) - return - } + const data = await resp.json() + const user = buildUser(data.user) - // Build a full User object from the SSO subset, filling in sensible defaults - // auth_provider comes from the backend based on the OIDC provider type - const authProvider = (parsedUser.auth_provider as string) || 'azure_sso' - const user: User = { - id: (parsedUser.id as string) || '', - username: (parsedUser.username as string) || '', - display_name: (parsedUser.display_name as string) || '', - email: (parsedUser.email as string) || '', - role: (parsedUser.role as User['role']) || 'operator', - auth_provider: authProvider as User['auth_provider'], - mfa_enabled: (parsedUser.mfa_enabled as boolean) ?? false, - is_active: true, - force_password_reset: false, - } + setTokens(data.access_token, data.refresh_token) + setUser(user) - // Store tokens and user, then navigate - setTokens(accessToken, refreshToken) - setUser(user) - navigate('/dashboard', { replace: true }) - }, [setTokens, setUser, navigate]) + // Clear the handoff code from the URL so it doesn't end up in + // browser history or get shared via the address bar. The code + // is already consumed (single-use) but defense-in-depth. + window.history.replaceState({}, '', '/auth/sso/callback') + + navigate('/dashboard', { replace: true }) + } catch (err) { + setError( + err instanceof Error ? err.message : 'Failed to complete sign-in. Please try again.', + ) + setProcessing(false) + } + })() + }, [setTokens, setUser, navigate, searchParams]) return ( @@ -105,3 +126,22 @@ export default function SsoCallbackPage() { ) } + +/** + * Map the SSO user JSON payload from the backend to the SPA's `User` + * type. Fills in sensible defaults for any missing fields. + */ +function buildUser(parsed: Record): User { + const authProvider = (parsed.auth_provider as string) || 'azure_sso' + return { + id: (parsed.id as string) || '', + username: (parsed.username as string) || '', + display_name: (parsed.display_name as string) || '', + email: (parsed.email as string) || '', + role: (parsed.role as User['role']) || 'operator', + auth_provider: authProvider as User['auth_provider'], + mfa_enabled: (parsed.mfa_enabled as boolean) ?? false, + is_active: true, + force_password_reset: false, + } +} diff --git a/frontend/src/pages/__tests__/SsoCallbackPage.test.tsx b/frontend/src/pages/__tests__/SsoCallbackPage.test.tsx new file mode 100644 index 0000000..2525cf6 --- /dev/null +++ b/frontend/src/pages/__tests__/SsoCallbackPage.test.tsx @@ -0,0 +1,205 @@ +/// Tests for SsoCallbackPage (issue #4 — SSO token handoff). +/// +/// Per `tasks/sso-token-handoff-spec.md` §6.3: +/// 9. renders_processing_state_initially +/// 10. calls_handoff_endpoint_on_mount +/// 11. stores_tokens_and_user_on_success +/// 12. shows_error_on_handoff_failure +/// 13. shows_error_when_handoff_code_missing +/// 14. clears_handoff_code_from_url_after_success +/// +/// We mock `fetch`, the auth store, and `window.history.replaceState` +/// so the test focuses on the page's effect-driven logic (URL parsing +/// → POST exchange → store update → navigation → URL cleanup). We do +/// NOT mock `react-router-dom` — instead, we use a real +/// `MemoryRouter` and assert on side effects (the auth store mocks + +/// `replaceState` spy + visible error text). + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { render, screen, waitFor, act } from '@testing-library/react' +import { MemoryRouter } from 'react-router-dom' +import SsoCallbackPage from '../SsoCallbackPage' + +// Mock the auth store — we don't want real zustand state leaking +// between tests, and we want to assert on setTokens/setUser calls. +const setTokensMock = vi.fn() +const setUserMock = vi.fn() +vi.mock('../../store/authStore', () => ({ + useAuthStore: () => ({ + setTokens: setTokensMock, + setUser: setUserMock, + }), +})) + +// Helper: render the page with a controlled URL and let the test +// inspect the rendered output + the auth store mocks. +function renderAt(url: string) { + return render( + + + , + ) +} + +beforeEach(() => { + setTokensMock.mockReset() + setUserMock.mockReset() + // Default fetch: never-resolving promise (keeps the page in + // "processing" state). Individual tests override this. + globalThis.fetch = vi.fn(() => new Promise(() => {})) as unknown as typeof fetch +}) + +afterEach(() => { + vi.restoreAllMocks() +}) + +describe('SsoCallbackPage', () => { + // 9. renders_processing_state_initially — on mount with a handoff + // code, shows the spinner and "Completing sign-in…". + it('renders the processing state initially', async () => { + // Wrap in act() to flush the useEffect that calls fetch. + await act(async () => { + renderAt('/auth/sso/callback?handoff=test-code') + }) + + expect(screen.getByText(/completing sign-in/i)).toBeInTheDocument() + // The MUI CircularProgress renders a role="progressbar" + expect(screen.getByRole('progressbar')).toBeInTheDocument() + }) + + // 10. calls_handoff_endpoint_on_mount — mocks fetch and asserts + // the POST goes to /api/v1/auth/sso/handoff with + // { handoff_code: }. + it('POSTs the handoff code to the backend on mount', async () => { + const fetchMock = vi.fn().mockResolvedValueOnce( + new Response( + JSON.stringify({ + access_token: 'a', + refresh_token: 'r', + token_type: 'Bearer', + expires_in: 900, + user: { id: 'u1', username: 'tester' }, + }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ), + ) + globalThis.fetch = fetchMock as unknown as typeof fetch + + await act(async () => { + renderAt('/auth/sso/callback?handoff=abc123') + }) + + await waitFor(() => { + expect(fetchMock).toHaveBeenCalledTimes(1) + }) + const [url, init] = fetchMock.mock.calls[0] + expect(url).toBe('/api/v1/auth/sso/handoff') + expect(init.method).toBe('POST') + expect(JSON.parse(init.body)).toEqual({ handoff_code: 'abc123' }) + }) + + // 11. stores_tokens_and_user_on_success — mocks a successful + // response, asserts setTokens and setUser are called, and + // setTokens receives the correct token values. + it('stores tokens + user on a successful exchange', async () => { + const fetchMock = vi.fn().mockResolvedValueOnce( + new Response( + JSON.stringify({ + access_token: 'access-jwt', + refresh_token: 'refresh-raw', + token_type: 'Bearer', + expires_in: 900, + user: { id: 'user-42', username: 'alice' }, + }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ), + ) + globalThis.fetch = fetchMock as unknown as typeof fetch + + await act(async () => { + renderAt('/auth/sso/callback?handoff=ok') + }) + + await waitFor(() => { + expect(setTokensMock).toHaveBeenCalledWith('access-jwt', 'refresh-raw') + }) + expect(setUserMock).toHaveBeenCalledWith( + expect.objectContaining({ id: 'user-42', username: 'alice' }), + ) + }) + + // 12. shows_error_on_handoff_failure — mocks a 400 response, + // asserts the error message is rendered and the spinner + // stops. + it('shows an error when the backend returns 400', async () => { + const fetchMock = vi.fn().mockResolvedValueOnce( + new Response( + JSON.stringify({ + error: { code: 'invalid_handoff', message: 'Handoff code has expired' }, + }), + { status: 400, headers: { 'Content-Type': 'application/json' } }, + ), + ) + globalThis.fetch = fetchMock as unknown as typeof fetch + + await act(async () => { + renderAt('/auth/sso/callback?handoff=expired') + }) + + expect(await screen.findByText(/handoff code has expired/i)).toBeInTheDocument() + expect(screen.queryByText(/completing sign-in/i)).not.toBeInTheDocument() + // No token storage on error + expect(setTokensMock).not.toHaveBeenCalled() + expect(setUserMock).not.toHaveBeenCalled() + }) + + // 13. shows_error_when_handoff_code_missing — invokes the effect + // with no handoff code, asserts the "Missing handoff code" + // error is shown. + it('shows a missing-code error when ?handoff= is absent', async () => { + const fetchMock = vi.fn() + globalThis.fetch = fetchMock as unknown as typeof fetch + + await act(async () => { + renderAt('/auth/sso/callback') + }) + + expect(await screen.findByText(/missing handoff code/i)).toBeInTheDocument() + // No fetch call should have been made + expect(fetchMock).not.toHaveBeenCalled() + }) + + // 14. clears_handoff_code_from_url_after_success — asserts + // window.history.replaceState is called to remove the + // ?handoff= param from the URL after a successful exchange. + it('clears the handoff code from the URL after a successful exchange', async () => { + const fetchMock = vi.fn().mockResolvedValueOnce( + new Response( + JSON.stringify({ + access_token: 'a', + refresh_token: 'r', + token_type: 'Bearer', + expires_in: 900, + user: { id: 'u', username: 'u' }, + }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ), + ) + globalThis.fetch = fetchMock as unknown as typeof fetch + + const replaceStateSpy = vi.spyOn(window.history, 'replaceState') + + await act(async () => { + renderAt('/auth/sso/callback?handoff=secret-code') + }) + + await waitFor(() => { + expect(replaceStateSpy).toHaveBeenCalled() + }) + // Verify the replaceState call cleared the query string — the + // third argument is the new URL ('/auth/sso/callback' with no + // query). + const args = replaceStateSpy.mock.calls[0] + expect(args[2]).toBe('/auth/sso/callback') + }) +}) diff --git a/frontend/src/test/setup.ts b/frontend/src/test/setup.ts new file mode 100644 index 0000000..d4d5dd2 --- /dev/null +++ b/frontend/src/test/setup.ts @@ -0,0 +1,6 @@ +/// Vitest setup file — runs before each test file. +/// +/// Imports `@testing-library/jest-dom` to register custom matchers like +/// `toBeInTheDocument`, `toHaveTextContent`, etc. that the SSO callback +/// tests rely on. +import '@testing-library/jest-dom/vitest' diff --git a/frontend/vitest.config.ts b/frontend/vitest.config.ts new file mode 100644 index 0000000..3b74494 --- /dev/null +++ b/frontend/vitest.config.ts @@ -0,0 +1,18 @@ +import { defineConfig } from 'vitest/config' +import react from '@vitejs/plugin-react' + +/// Vitest configuration for the Patch Manager UI. +/// +/// - Uses jsdom for a browser-like environment (needed for MUI + React +/// Testing Library). +/// - The `react()` plugin is required for JSX in test files. +/// - `globals: true` lets tests use `describe`, `it`, `expect` without +/// imports (matches the existing frontend conventions). +export default defineConfig({ + plugins: [react()], + test: { + environment: 'jsdom', + globals: true, + setupFiles: ['./src/test/setup.ts'], + }, +}) diff --git a/tasks/sso-token-handoff-spec.md b/tasks/sso-token-handoff-spec.md new file mode 100644 index 0000000..73966c5 --- /dev/null +++ b/tasks/sso-token-handoff-spec.md @@ -0,0 +1,332 @@ +# SSO Token Handoff — Specification + +**Issue:** [#4](https://github.com/Draco-Lunaris/Linux-Patch-Manager/issues/4) +**Component:** `crates/pm-web/src/routes/sso.rs`, `frontend/src/pages/SsoCallbackPage.tsx`, `frontend/src/store/authStore.ts` +**Spec version:** 0.1.0 (draft) +**Status:** Awaiting Kelly sign-off + +--- + +## 1. Goal + +Stop embedding JWT access tokens, refresh tokens, and user objects in the +SSO callback redirect URL. Today, after a successful OIDC login, the +backend 302-redirects the browser to the SPA with the tokens in the +query string: + +``` +https://app.example.com/auth/sso/callback + ?access_token= + &refresh_token= + &token_type=Bearer + &expires_in=900 + &user= +``` + +Tokens in URLs are written to browser history, intermediate proxy and +load-balancer access logs, and may leak via the `Referer` header when +the landing page loads third-party resources. The refresh token is +the most sensitive value (long-lived, rotating) and gets the worst +exposure. + +Replace the URL-embedded tokens with a **single-use, short-lived +handoff code** that the SPA exchanges for tokens via a server-to-server +POST. The URL then contains only the code, which expires in 60 seconds +and is invalidated on first use. + +## 2. Non-Goals + +- Changing the OIDC flow itself (Authorization Code + PKCE stays the same). +- Changing the MFA verification path that runs after the OIDC callback. +- Touching the WS ticket pattern (issue #10) — this spec is a *new* + in-memory store for SSO handoff codes, mirroring but separate from + `ws_tickets: Arc>`. +- Adding cookie-based or `form_post` delivery. The handoff code + approach was selected over those (Kelly sign-off Q1). +- Long-lived SSO sessions. The handoff code is single-use; subsequent + SSO logins re-issue a new code. + +## 3. Design Decisions (Kelly sign-off, 2026-06-02) + +| # | Question | Resolution | +|---|----------|------------| +| Q1 | Approach selection | **Handoff code** (option C in issue #4). Mirrors the existing WS-ticket pattern. URL contains only a single-use, 60s `handoff_code`. SPA POSTs to `/api/v1/auth/sso/handoff` and gets tokens in the JSON response. | +| Q2 | Cookie attributes | **N/A** — handoff code approach uses no cookies. | +| Q3 | Rollout strategy | **Hard cutover** — remove the old query-string parsing in the same PR. No dual-read window. (Justification: security-critical fix, deploy window is short, no in-flight SSO logins survive a rolling restart because the auth state is in the user's browser, not on the server.) | +| Q4 | `Secure` cookie flag | **N/A** — handoff code approach uses no cookies. Kelly's answer ("unconditionally secure") is noted for future cookie work but does not apply here. | + +## 4. Design + +### 4.1 Backend: SSO callback (`crates/pm-web/src/routes/sso.rs`) + +The `sso_callback` handler currently constructs a redirect URL with all +token values. Replace this with a handoff code generation step: + +1. After the access/refresh tokens and `user_json` are computed (the + existing logic through `sso_callback` is unchanged up to the + redirect construction), generate a cryptographically random + `handoff_code` (32 bytes, base64url-encoded, ~43 chars). +2. Store the handoff payload in a new in-memory map: + ```rust + pub struct SsoHandoff { + pub access_token: String, + pub raw_refresh: String, + pub user_json: Value, + pub access_ttl: u64, + pub expires_at: Instant, // now + 60s + } + pub sso_handoffs: Arc>, + ``` + Mirrors the `WsTicket` struct (single-use, in-memory, TTL enforced + on read). The map is added to `AppState` alongside `ws_tickets`. +3. Build the redirect URL with ONLY the handoff code: + ```rust + let redirect_url = format!("{}?handoff={}", callback_url, handoff_code); + Ok(Redirect::to(&redirect_url)) + ``` +4. Log the handoff creation (without the code value itself) for audit: + ```rust + tracing::info!(user_id = %user.id, auth_provider, "SSO handoff issued"); + ``` + +### 4.2 Backend: Handoff exchange endpoint + +New handler `POST /api/v1/auth/sso/handoff`: + +- Request body: `{ "handoff_code": "" }` +- Behavior: + 1. Look up `handoff_code` in `sso_handoffs` (DashMap read lock). + 2. If not found → `400 invalid_handoff`. + 3. If found but `expires_at < Instant::now()` → remove the entry and + return `400 invalid_handoff` (the cleanup-on-expiry also prevents + memory bloat from expired-but-unconsumed codes). + 4. **Remove the entry atomically** (DashMap `remove` is atomic) — + this is the single-use guarantee. Even if two requests race with + the same code, only one wins. + 5. Return the payload as JSON: + ```json + { + "access_token": "", + "refresh_token": "", + "token_type": "Bearer", + "expires_in": 900, + "user": { "id": "...", "username": "...", ... } + } + ``` +- Log: + - On success: `tracing::info!(user_id = %payload.user.id, "SSO handoff exchanged")` + - On failure: `tracing::warn!(reason = %reason, "SSO handoff exchange failed")` + - **Never log the handoff code value itself** (it's a bearer secret + with 60s window). + +### 4.3 Backend: Cleanup task + +Add a `tokio::spawn` cleanup task in `main.rs` (mirroring the existing +WS-ticket cleanup if present, or the SSO-session cleanup that already +runs per the codebase). Every 60 seconds, walk `sso_handoffs` and +remove entries with `expires_at < Instant::now()`. Bounded memory +growth even if the SPA never POSTs back. + +### 4.4 Backend: Route registration + +In `pm-web/src/main.rs`, add the new route to the public router +(alongside `/api/v1/ws/ticket`, which is also public — no JWT +required because the handoff code IS the credential): + +```rust +.route("/api/v1/auth/sso/handoff", post(sso_handoff_exchange)) +``` + +### 4.5 Frontend: `SsoCallbackPage.tsx` + +Replace the URL-param parsing with a POST to the handoff endpoint: + +```typescript +useEffect(() => { + const params = new URLSearchParams(window.location.search) + const errorCode = params.get('error') + if (errorCode) { + // ... existing error handling unchanged ... + return + } + + const handoffCode = params.get('handoff') + if (!handoffCode) { + setError('Missing handoff code. Please try logging in again.') + setProcessing(false) + return + } + + // Exchange handoff code for tokens + fetch('/api/v1/auth/sso/handoff', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ handoff_code: handoffCode }), + }) + .then(r => r.ok ? r.json() : r.json().then(e => Promise.reject(e))) + .then(data => { + setTokens(data.access_token, data.refresh_token) + setUser(buildUser(data.user)) + // Clear the handoff code from the URL to prevent bookmarking/sharing + window.history.replaceState({}, '', '/auth/sso/callback') + navigate('/dashboard', { replace: true }) + }) + .catch(err => { + setError(err?.error?.message || 'Failed to complete sign-in. Please try again.') + setProcessing(false) + }) +}, [setTokens, setUser, navigate]) +``` + +The `buildUser` helper mirrors the existing field-mapping logic +(lines 54–67 of the current file). + +### 4.6 Frontend: `authStore.ts` + +**No change required.** The existing `setTokens(access, refresh)` and +`setUser(user)` API is what the new code calls. The `partialize` +config (line 74) already correctly persists only `refreshToken` and +`user` — not `accessToken` — so the in-memory access token is never +written to localStorage. This is the correct security posture and +should be preserved. + +## 5. Acceptance Criteria + +- [ ] SSO callback no longer places `access_token`, `refresh_token`, + `token_type`, `expires_in`, or `user` in the redirect URL. + The URL contains only `handoff=` (plus the error params on + failure, which are unchanged). +- [ ] The handoff code is at least 128 bits of entropy (32 bytes, + base64url-encoded) and is generated with a CSPRNG. +- [ ] The handoff code is single-use: a second exchange attempt with + the same code returns `400 invalid_handoff` and does NOT return + the tokens again. +- [ ] The handoff code expires after 60 seconds. An exchange attempt + with an expired code returns `400 invalid_handoff` and the + entry is removed from the in-memory map. +- [ ] The SPA successfully completes login: POST to the handoff + endpoint receives the tokens, calls `setTokens` and `setUser`, + and navigates to `/dashboard`. +- [ ] `authStore.ts` is unchanged (its existing `partialize` already + prevents access-token persistence; the handoff code approach + doesn't change that contract). +- [ ] `cargo check` and `cargo clippy --all-targets` pass. +- [ ] `cargo test -p pm-web` passes with new tests for the handoff + endpoint (create, exchange success, exchange duplicate=400, + exchange expired=400, exchange unknown=400). +- [ ] `frontend` builds cleanly (`npm run build` in `frontend/`). +- [ ] No access or refresh token values appear in any URL or query + string in the SSO flow. Manual verification: complete a login + and grep the server access log for the callback URL — only the + handoff code should be present. +- [ ] `docs/security-review.md` §2.5 (Azure SSO) is updated to + document the handoff code control. + +## 6. Test Plan + +### 6.1 Backend unit/integration tests (`crates/pm-web/src/routes/sso.rs`) + +Using a small `TestApp` harness mirroring the WS-ticket test pattern +(no real HTTP listener, no DB beyond the connection that's already +mocked in the existing tests): + +1. `handoff_exchange_success` — create a handoff, POST to the + exchange endpoint, expect 200 with the access/refresh/user fields. +2. `handoff_exchange_single_use` — exchange once (success), exchange + the same code again (expect 400 `invalid_handoff`). +3. `handoff_exchange_unknown_code` — POST with a code that was never + issued (expect 400 `invalid_handoff`). +4. `handoff_exchange_expired_code` — create a handoff with + `expires_at = past`, exchange (expect 400 `invalid_handoff` AND + the entry is removed from the map). +5. `handoff_exchange_race` — two concurrent POSTs with the same code + (using `tokio::join!`); exactly one succeeds, the other gets 400. +6. `handoff_exchange_malformed_body` — POST with invalid JSON or + missing `handoff_code` field (expect 400 `invalid_handoff`). +7. `callback_redirect_contains_only_handoff` — invoke `sso_callback` + through a mock OIDC config and assert the resulting redirect URL + contains only `handoff=` and NO `access_token` / + `refresh_token` / `user` query params. + +### 6.2 Backend cleanup test + +8. `handoff_cleanup_removes_expired` — create 3 handoffs with + varying `expires_at`, run one tick of the cleanup task, assert + only the non-expired ones remain. + +### 6.3 Frontend tests (`frontend/src/pages/SsoCallbackPage.tsx`) + +Add a Vitest + React Testing Library test suite (the frontend already +uses Vitest — see `frontend/package.json` and `frontend/vite.config.ts`): + +9. `renders_processing_state_initially` — on mount with a handoff + code, shows the spinner and "Completing sign-in…". +10. `calls_handoff_endpoint_on_mount` — mocks `fetch` and asserts the + POST goes to `/api/v1/auth/sso/handoff` with `{ handoff_code: }`. +11. `stores_tokens_and_user_on_success` — mocks a successful response, + asserts `setTokens` and `setUser` are called with the response + payload, and the SPA navigates to `/dashboard`. +12. `shows_error_on_handoff_failure` — mocks a 400 response, asserts + the error message is rendered and the spinner stops. +13. `shows_error_when_handoff_code_missing` — invokes the effect with + no handoff code, asserts the "Missing handoff code" error is + shown. +14. `clears_handoff_code_from_url_after_success` — asserts + `window.history.replaceState` is called to remove the `?handoff=` + param from the URL after a successful exchange. + +## 7. Risk Analysis + +- **Risk: regression in the SSO login flow.** Mitigation: the test + plan covers the callback redirect shape, the exchange endpoint + behavior (success, single-use, expiry, race), and the frontend + effect. Manual end-to-end test (completing a real Azure AD login) + is required before merge — the new `scripts/integration-test.sh` + should be extended or a new `scripts/integration-test-sso.sh` + added to exercise the full flow against a mock OIDC provider. +- **Risk: in-flight SSO logins during deploy break.** Per Kelly + sign-off Q3, we accept hard cutover. The mitigation: the 60s + handoff TTL means any in-flight redirect that arrives after the + server restart has a 60s window to complete. If the new code is + deployed and the old handoffs are lost, the user is sent back to + `/auth/sso/callback?handoff=` which the new code rejects + with `400 invalid_handoff`, and the SPA shows "Please try logging + in again." Worst case: a 30-second re-login. Acceptable for a + security-critical fix. +- **Risk: handoff code leaked via browser history or `Referer`.** + The code is single-use and 60s TTL, so the blast radius is small + even if logged. The SPA calls `history.replaceState` after a + successful exchange to remove the code from the address bar (and + the underlying history entry). The 60s window limits exposure to + `Referer` leakage on subsequent navigations from the callback + page. +- **Risk: memory growth from unconsumed handoffs.** Mitigation: the + cleanup task runs every 60s and removes expired entries. Worst + case memory usage is `O(active_logins)` — typically single digits. +- **Risk: race condition in the single-use guarantee.** Mitigation: + `DashMap::remove` is atomic, so only one of two concurrent + exchange attempts can succeed. Verified by the + `handoff_exchange_race` test. + +## 8. Documentation Updates + +- `docs/security-review.md` §2.5 (Azure SSO): add a new row + documenting the handoff code control and explicitly state that no + tokens appear in any URL. +- `frontend/src/pages/SsoCallbackPage.tsx`: update the doc-comment to + describe the POST-and-exchange flow instead of the URL-param parse. +- `docs/REST_API.md`: document the new `POST /api/v1/auth/sso/handoff` + endpoint. + +## 9. Out of Scope / Follow-ups + +- Cookie-based SSO session (a future enhancement that would let the + SPA refresh state without a new OIDC flow on every page load). +- `form_post` response mode (a future enhancement if browsers + standardize it more widely). +- Rate limiting on the handoff endpoint (out of scope here; the + existing governor-based rate limits on `/auth/*` may already cover + this — verify during implementation). +- Moving the in-memory `sso_handoffs` to Redis (out of scope; the + single-instance design constraint in `SPEC.md` is fine for this + control).