diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..9f95860 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,122 @@ +name: Build + +on: + push: + branches: [main] + tags: + - 'v*' + +jobs: + console: + name: Build Console + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v6.0.2 + + - uses: oven-sh/setup-bun@3d267786b128fe76c2f16a390aa2448b815359f3 # v2.1.2 + + - name: Install dependencies + working-directory: console + run: bun install --frozen-lockfile + + - name: Build console + working-directory: console + run: bun run build + + - name: Upload console artifacts + uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 # v4.5.0 + with: + name: lucid-console + path: console/dist/ + if-no-files-found: error + + build: + name: Build (${{ matrix.target }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + include: + - target: x86_64-unknown-linux-gnu + os: ubuntu-latest + - target: x86_64-unknown-linux-musl + os: ubuntu-latest + - target: aarch64-unknown-linux-gnu + os: ubuntu-latest + - target: aarch64-unknown-linux-musl + os: ubuntu-latest + - target: x86_64-apple-darwin + os: macos-latest + - target: aarch64-apple-darwin + os: macos-latest + + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v6.0.2 + + - uses: dtolnay/rust-toolchain@7b1c307e0dcbda6122208f10795a713336a9b35a # v1 + with: + toolchain: stable + targets: ${{ matrix.target }} + + - uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2.8.2 + with: + key: ${{ matrix.target }} + + - name: Install cross-compilation tools + if: runner.os == 'Linux' && contains(matrix.target, 'musl') + run: | + sudo apt-get update + sudo apt-get install -y musl-tools + + - name: Install cross-compilation tools (aarch64) + if: runner.os == 'Linux' && contains(matrix.target, 'aarch64') + run: | + sudo apt-get update + sudo apt-get install -y gcc-aarch64-linux-gnu + + - name: Build + run: cargo build --release --target ${{ matrix.target }} --workspace + + - name: Upload artifacts + uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 # v4.5.0 + with: + name: lucid-${{ matrix.target }} + path: | + target/${{ matrix.target }}/release/lucid-api + target/${{ matrix.target }}/release/lucid-agent + if-no-files-found: error + + release: + name: Create Release + if: startsWith(github.ref, 'refs/tags/') + needs: [build, console] + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v6.0.2 + + - name: Download all artifacts + uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4.1.8 + + - name: Create archives + run: | + for dir in lucid-*; do + if [ "$dir" = "lucid-console" ]; then + cd "$dir" + tar czf "../lucid-console.tar.gz" . + cd .. + else + target="${dir#lucid-}" + cd "$dir" + tar czf "../${dir}.tar.gz" * + cd .. + fi + done + + - name: Create release + uses: softprops/action-gh-release@e7a8f85e1c67a31e6ed99a94b41bd0b71bbee6b8 # v2.2.0 + with: + draft: true + files: lucid-*.tar.gz + generate_release_notes: true diff --git a/.github/workflows/pr-console.yml b/.github/workflows/pr-console.yml new file mode 100644 index 0000000..7bc3fae --- /dev/null +++ b/.github/workflows/pr-console.yml @@ -0,0 +1,65 @@ +name: PR Validation (Console) + +on: + pull_request: + branches: [main] + paths: + - 'console/**' + - '.github/workflows/pr-console.yml' + merge_group: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + test: + name: Tests + runs-on: ubuntu-latest + defaults: + run: + working-directory: console + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v6.0.2 + + - uses: oven-sh/setup-bun@3d267786b128fe76c2f16a390aa2448b815359f3 # v2.1.2 + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Run tests + run: bun run test + + check: + name: Lint & Format + runs-on: ubuntu-latest + defaults: + run: + working-directory: console + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v6.0.2 + + - uses: oven-sh/setup-bun@3d267786b128fe76c2f16a390aa2448b815359f3 # v2.1.2 + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Run biome check + run: bun check + + build: + name: Build + runs-on: ubuntu-latest + defaults: + run: + working-directory: console + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v6.0.2 + + - uses: oven-sh/setup-bun@3d267786b128fe76c2f16a390aa2448b815359f3 # v2.1.2 + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Build + run: bun run build diff --git a/.github/workflows/pr-rust.yml b/.github/workflows/pr-rust.yml new file mode 100644 index 0000000..36bb41a --- /dev/null +++ b/.github/workflows/pr-rust.yml @@ -0,0 +1,99 @@ +name: PR Validation (Rust) + +on: + pull_request: + branches: [main] + paths: + - 'agent/**' + - 'api/**' + - 'common/**' + - 'Cargo.toml' + - 'Cargo.lock' + - '.github/workflows/pr-rust.yml' + merge_group: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + test: + name: Test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v6.0.2 + + - uses: dtolnay/rust-toolchain@7b1c307e0dcbda6122208f10795a713336a9b35a # v1 + with: + toolchain: stable + components: rustfmt, clippy + + - uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2.8.2 + + - name: Run tests + run: cargo test --workspace --all-features + + fmt: + name: Formatting + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v6.0.2 + + - uses: dtolnay/rust-toolchain@7b1c307e0dcbda6122208f10795a713336a9b35a # v1 + with: + toolchain: stable + components: rustfmt + + - name: Check formatting + run: cargo fmt --all -- --check + + clippy: + name: Clippy + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v6.0.2 + + - uses: dtolnay/rust-toolchain@7b1c307e0dcbda6122208f10795a713336a9b35a # v1 + with: + toolchain: stable + components: clippy + + - uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2.8.2 + + - name: Run clippy + run: cargo clippy --workspace --all-features -- -D warnings + + openapi: + name: OpenAPI Schema + runs-on: ubuntu-latest + services: + mongodb: + image: mongodb/mongodb-community-server:8.0-ubi8 + ports: [27017:27017] + env: + LUCID_API_DB_URL: mongodb://localhost:27017/lucid + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v6.0.2 + + - uses: dtolnay/rust-toolchain@7b1c307e0dcbda6122208f10795a713336a9b35a # v1 + with: + toolchain: stable + + - uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2.8.2 + + - name: Generate signing key + run: openssl genpkey -algorithm ED25519 -out signing_key.pem + + - name: Generate OpenAPI schema + env: + LUCID_API_SIGNING_KEY_FILE: signing_key.pem + run: cargo run --bin lucid-api -- --dump-openapi > /tmp/openapi.json + + - name: Check schema is up to date + run: | + if ! diff -q api/openapi.json /tmp/openapi.json; then + echo "❌ OpenAPI schema is out of date" + echo "Run: cargo run --bin lucid-api -- --dump-openapi > api/openapi.json" + exit 1 + fi + echo "✅ OpenAPI schema is up to date" diff --git a/.gitignore b/.gitignore index b21b885..c53b3a7 100644 --- a/.gitignore +++ b/.gitignore @@ -158,3 +158,6 @@ Cargo.lock # Antora Docs build/ + +mise.local.toml +config/local diff --git a/.opencode/skills/db-model-impl/SKILL.md b/.opencode/skills/db-model-impl/SKILL.md new file mode 100644 index 0000000..e367846 --- /dev/null +++ b/.opencode/skills/db-model-impl/SKILL.md @@ -0,0 +1,21 @@ +--- +name: db-model-impl +description: Finish implementing the logic for a database model written by a developer. +license: Apache-2.0 +compatibility: opencode +metadata: + audience: maintainers + workflow: github +--- + +## What I do + +- Create a storage filter struct for the created model +- Create a `Store` trait for the model (i.e. `HostStore`) +- Implement the new `Store` trait for all `Storage` trait implementations +- Add any relevant permissions to the `Permissions` enum in `lucid-common` + +## When to use me + +Use this when you are tasked with finishing implementation of a database model, +or when writing a _new_ database model. diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..2130383 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,22 @@ +# Lucid Agent Guidelines + +Be sure to read the [README](./README.adoc) for information on what Lucid +actually is and aims to enable. + +Lucid is built from three key components: + +- `api/`, the API service that everything interfaces with +- `agent/`, the host agent responsible for collecting host telemetry +- `console/`, the web UI used by administrators to interface with the system + +## Coding Guidelines + +### Rust + +- All dependencies must be declared in the root `Cargo.toml`, not locally to + individual crates. + +- After editing any Rust code, you **must** run `cargo fmt` on the changed + files. + +- Always write unit tests for logic, write integration tests where relevant. diff --git a/CONTRIBUTING.adoc b/CONTRIBUTING.adoc index d2294eb..8feee9d 100644 --- a/CONTRIBUTING.adoc +++ b/CONTRIBUTING.adoc @@ -50,6 +50,15 @@ If you would like to contribute to Lucid, but are not exactly sure what to work on, you can find a number of open issues that are awaiting contributions in https://github.com/roostmoe/lucid/issues[Issues]. +=== Updating the API documentation + +When updating the API, ensure you have updated the OpenAPI specification. + +[source,bash] +---- +$ cargo run -p lucid-api -- --dump-openapi > api/openapi.json +---- + == Reporting Vulnerabilities If you have found a security vulnerability in Lucid, please follow the diff --git a/Cargo.toml b/Cargo.toml index c516a98..525c02c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,53 @@ [workspace] resolver = "2" +members = ["agent","api", "common", "ctl", "db"] [workspace.package] edition = "2024" license = "Apache-2.0" + +[workspace.dependencies] +aes-gcm = "0.10" +anyhow = "1.0.102" +argon2 = { version = "0.5.3", features = ["std"] } +async-trait = "0.1.89" +axum = { version = "0.8.8", features = ["macros"] } +base64 = "0.22" +bson = { version = "3.1.0", features = ["chrono-0_4", "serde"] } +chrono = { version = "0.4.43", features = ["serde"] } +clap = { version = "4.5.60", features = ["derive", "env"] } +ed25519-dalek = { version = "2.1.1", features = ["pkcs8", "rand_core"] } +futures = "0.3.32" +getrandom = "0.2" +hex = "0.4" +hostname = "0.4" +jsonwebtoken = { version = "10.3.0", features = ["rust_crypto"] } +mongodb = { version = "3.5.1", features = ["bson-3", "tracing-unstable"] } +pem-rfc7468 = "0.7" +pkcs8 = { version = "0.10.2", features = ["pem"] } +rand = "0.9" +rcgen = { version = "0.13", features = ["pem", "x509-parser"] } +reqwest = { version = "0.12", features = ["rustls-tls", "json"] } +rustls = { version = "0.23", features = ["aws_lc_rs"] } +rustls-pemfile = "2.0" +axum-server = { version = "0.7", features = ["tls-rustls"] } +serde = { version = "1.0.228", features = ["derive"] } +serde_json = "1.0.149" +serde_with = "3.16.1" +sha2 = "0.10" +thiserror = "2.0.18" +time = "0.3" +tokio = { version = "1.49.0", features = ["macros", "rt-multi-thread"] } +tokio-util = "0.7.18" +toml = { version = "0.9.8", features = ["serde"] } +tower = "0.5.3" +tower-http = { version = "0.6.8", features = ["cors", "request-id", "trace", "validate-request"] } +tracing = "0.1.44" +tracing-subscriber = { version = "0.3.22", features = ["env-filter", "json"] } +ulid = { version = "1.2.1", features = ["serde"] } +utoipa = { version = "5.4.0", features = ["axum_extras", "chrono", "ulid"] } +utoipa-axum = "0.2.0" +x509-parser = "0.16" + +lucid-common = { path = "./common" } +lucid-db = { path = "./db" } diff --git a/README.adoc b/README.adoc index b020c20..9c0f204 100644 --- a/README.adoc +++ b/README.adoc @@ -10,6 +10,86 @@ day maintenance tasks of systems administrators. Lucid provides inventory tracking, vulnerability alerting, compliance scanning and more. +== Features + +- [ ] Inventory host tracking +- [ ] Continuous compliance scanning +- [ ] Package vulnerability alerting + +== Configuration + +=== API Service + +The Lucid API service can be configured via environment variables or command-line +flags. + +==== Authentication & Signing + +The API uses Ed25519 digital signatures for session token authentication. You +must provide a signing key for the API to start. + +All authenticated requests are associated with a **Caller** (user, agent, service +account, or system) who has specific **Roles** and **Permissions**. See +link:./docs/ARCHITECTURE_AUTH.adoc[Authentication & Authorization Architecture] +for details on Lucid's RBAC system. + +**Generate a signing key:** + +[source,bash] +---- +openssl genpkey -algorithm ED25519 -out signing_key.pem +---- + +**Configure the key** using one of these methods: + +*Option 1: Environment variable (inline PEM)* + +[source,bash] +---- +export LUCID_API_SIGNING_KEY="-----BEGIN PRIVATE KEY----- +MC4CAQAwBQYDK2VwBCIEI... +-----END PRIVATE KEY-----" +---- + +*Option 2: Environment variable (file path)* + +[source,bash] +---- +export LUCID_API_SIGNING_KEY_FILE="/path/to/signing_key.pem" +---- + +*Option 3: Command-line flag* + +[source,bash] +---- +lucid-api --signing-key-file /path/to/signing_key.pem +---- + +IMPORTANT: Keep the signing key secure. Anyone with access to this key can forge +session tokens. Rotate keys regularly and never commit them to version control. +See link:./docs/SECURITY_ED25519.adoc[security considerations] for best practices. + +TIP: Upgrading from a previous version? See link:./docs/MIGRATION_HMAC_TO_ED25519.adoc[the migration guide]. + +==== Other Configuration Options + +[cols="2,3,2"] +|=== +|Environment Variable |Description |Default + +|`LUCID_API_BIND_ADDR` +|API server bind address +|`0.0.0.0:4000` + +|`LUCID_API_PUBLIC_URL` +|Public-facing API URL (for redirects, links) +|`http://localhost:4000` + +|`LUCID_API_MONGODB_URI` +|MongoDB connection string +|`mongodb://localhost:27017/lucid` +|=== + == Help & Documentation * https://roostmoe.github.io/lucid[Documentation] (Coming soon) diff --git a/agent/.gitignore b/agent/.gitignore new file mode 100644 index 0000000..74f84a0 --- /dev/null +++ b/agent/.gitignore @@ -0,0 +1,2 @@ +# dev data files +data/ diff --git a/agent/Cargo.toml b/agent/Cargo.toml new file mode 100644 index 0000000..0c6430b --- /dev/null +++ b/agent/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "lucid-agent" +version = "0.1.0" +edition.workspace = true +license.workspace = true + +[dependencies] +anyhow.workspace = true +async-trait.workspace = true +base64.workspace = true +clap.workspace = true +hostname.workspace = true +rcgen.workspace = true +reqwest.workspace = true +serde.workspace = true +serde_json.workspace = true +thiserror.workspace = true +toml.workspace = true +tokio.workspace = true +tokio-util.workspace = true + +lucid-common.workspace = true diff --git a/agent/src/client.rs b/agent/src/client.rs new file mode 100644 index 0000000..5375f3d --- /dev/null +++ b/agent/src/client.rs @@ -0,0 +1,181 @@ +use anyhow::{Context, Result}; +use lucid_common::{ + params::RegisterAgentRequest, + views::{ApiErrorResponse, RegisterAgentResponse}, +}; +use reqwest::{ + Certificate, Client, Identity, + header::{HeaderMap, HeaderValue}, +}; +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum ApiClientError { + #[allow(dead_code)] + #[error("Missing credentials for API client")] + MissingCredentials, + + #[error("API error")] + ApiError(ApiErrorResponse), + + #[error(transparent)] + Anyhow(#[from] anyhow::Error), + + #[error("Failed to load identity from PEM: {0}")] + IdentityError(reqwest::Error), + + #[error("Request failed: {0}")] + ReqwestError(#[from] reqwest::Error), +} + +#[derive(Default)] +pub struct ApiClient { + api_url: String, + client: Client, + identity: Option, + cert: Option, +} + +impl ApiClient { + pub fn new( + api_url: String, + key_pem: Option, + cert_pem: Option, + ca_cert_pem: Option, + ) -> Result { + let mut api_client = ApiClient { + api_url, + ..Default::default() + }; + + let mut client_builder = + Client::builder().user_agent(format!("lucid-agent/{}", env!("CARGO_PKG_VERSION"))); + + if let (Some(key_pem), Some(cert_pem), Some(ca_cert_pem)) = (key_pem, cert_pem, ca_cert_pem) + { + let identity = Identity::from_pem( + &(key_pem + .into_bytes() + .into_iter() + .chain(cert_pem.into_bytes()) + .collect::>()), + ) + .map_err(ApiClientError::IdentityError)?; + + let cert = Certificate::from_pem(&ca_cert_pem.into_bytes()) + .map_err(ApiClientError::IdentityError)?; + + client_builder = client_builder + .identity(identity.clone()) + .add_root_certificate(cert.clone()); + + api_client.identity = Some(identity); + api_client.cert = Some(cert); + } + + api_client.client = client_builder + .build() + .map_err(ApiClientError::IdentityError)?; + + Ok(api_client) + } + + #[allow(dead_code)] + async fn get(&self, path: &str) -> Result + where + TResult: serde::de::DeserializeOwned, + { + let url = format!( + "{}/{}", + self.api_url.trim_end_matches('/'), + path.trim_start_matches('/') + ); + let response = self.client.get(&url).send().await?; + + if !response.status().is_success() { + let status = response.status(); + let body = response.json::().await.map_err(|e| { + anyhow::anyhow!( + "POST {} failed with status {} and invalid error response: {}", + url, + status, + e + ) + })?; + return Err(ApiClientError::ApiError(body)); + } + + response + .json::() + .await + .map_err(ApiClientError::ReqwestError) + } + + async fn post( + &self, + path: &str, + body: &TBody, + headers: Option>, + ) -> Result + where + TBody: serde::ser::Serialize, + TResult: serde::de::DeserializeOwned, + { + let url = format!( + "{}/{}", + self.api_url.trim_end_matches('/'), + path.trim_start_matches('/') + ); + let mut req = self + .client + .post(&url) + .header("Content-Type", "application/json") + .json(body); + + if let Some(headers) = headers { + req = req.headers(headers); + } + + let response = req.send().await?; + + if !response.status().is_success() { + let status = response.status(); + let body = response.json::().await.map_err(|e| { + anyhow::anyhow!( + "POST {} failed with status {} and invalid error response: {}", + url, + status, + e + ) + })?; + return Err(ApiClientError::ApiError(body)); + } + + Ok(response + .json::() + .await + .context("Failed to parse registration response")?) + } + + pub async fn register( + &self, + token: String, + csr_pem: String, + hostname: String, + ) -> Result { + let request = RegisterAgentRequest { csr_pem, hostname }; + self.post( + "/api/v1/agents/register", + &request, + Some({ + let mut headers = HeaderMap::new(); + headers.insert( + "Authorization", + HeaderValue::from_str(&format!("Bearer {}", token)).unwrap(), + ); + headers + }), + ) + .await + } +} diff --git a/agent/src/commands/mod.rs b/agent/src/commands/mod.rs new file mode 100644 index 0000000..dc427be --- /dev/null +++ b/agent/src/commands/mod.rs @@ -0,0 +1,2 @@ +pub mod registration; +pub mod run; diff --git a/agent/src/commands/registration.rs b/agent/src/commands/registration.rs new file mode 100644 index 0000000..511dc0b --- /dev/null +++ b/agent/src/commands/registration.rs @@ -0,0 +1,135 @@ +use crate::client::ApiClient; +use crate::config::AgentConfig; +use crate::util::crypto::{create_csr, generate_keypair}; +use anyhow::{Context, Result, bail}; +use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD}; +use serde::Deserialize; +use std::fs; +use std::os::unix::fs::PermissionsExt; +use std::path::Path; + +#[derive(Deserialize)] +struct JwtClaims { + iss: String, + // other fields we don't need +} + +pub async fn register(token: &str, config: AgentConfig) -> Result<()> { + // 1. Check if already registered + if config.auth_key_path().exists() { + bail!( + "Agent already registered. Delete {} to re-register.", + config.auth_key_path().display() + ); + } + + // 2. Decode JWT to get API URL + let api_url = extract_issuer_from_jwt(token)?; + println!("Registering with API at: {}", api_url); + + // 3. Generate keypair + let key_pair = generate_keypair()?; + let private_key_pem = key_pair.serialize_pem(); + + // 4. Get hostname + let hostname = hostname::get() + .context("Failed to get hostname")? + .to_string_lossy() + .to_string(); + println!("Hostname: {}", hostname); + + // 5. Create CSR + let csr_pem = create_csr(&key_pair, &hostname)?; + + // 6. Make registration request + let client = + ApiClient::new(api_url, None, None, None).context("Failed to create API client")?; + + let reg_response = client + .register(token.to_string(), csr_pem, hostname) + .await + .context("Failed to register agent")?; + + // 7. Create directory if needed + fs::create_dir_all(config.data_dir.clone()) + .context(format!("Failed to create {:?}", config.data_dir.clone()))?; + + // 8. Write files atomically + write_file_atomic(&config.auth_key_path(), &private_key_pem, 0o600)?; + write_file_atomic( + &config.auth_cert_path(), + ®_response.certificate_pem, + 0o644, + )?; + write_file_atomic( + &config.ca_cert_path(), + ®_response.ca_certificate_pem, + 0o644, + )?; + + println!("✓ Registered as agent {}", reg_response.agent_id); + println!(" Certificate expires: {}", reg_response.expires_at); + println!(" API URL: {}", reg_response.api_base_url); + + Ok(()) +} + +fn extract_issuer_from_jwt(token: &str) -> Result { + let parts: Vec<&str> = token.split('.').collect(); + if parts.len() != 3 { + bail!("Invalid JWT format"); + } + + let claims_b64 = parts[1]; + let claims_json = URL_SAFE_NO_PAD + .decode(claims_b64) + .context("Failed to decode JWT claims")?; + + let claims: JwtClaims = + serde_json::from_slice(&claims_json).context("Failed to parse JWT claims")?; + + Ok(claims.iss) +} + +fn write_file_atomic(path: &Path, content: &str, mode: u32) -> Result<()> { + // Write to temp file first, then rename for atomicity + let temp_path = path.with_extension("tmp"); + + fs::write(&temp_path, content).context(format!("Failed to write {}", temp_path.display()))?; + + // Set permissions + let mut perms = fs::metadata(&temp_path)?.permissions(); + perms.set_mode(mode); + fs::set_permissions(&temp_path, perms)?; + + // Atomic rename + fs::rename(&temp_path, path).context(format!("Failed to rename to {}", path.display()))?; + + Ok(()) +} + +pub fn unregister(config: AgentConfig) -> anyhow::Result<()> { + let mut removed = false; + + for path in [ + config.auth_key_path(), + config.auth_cert_path(), + config.ca_cert_path(), + ] { + if path.exists() { + std::fs::remove_file(&path)?; + println!("Removed: {}", path.display()); + removed = true; + } + } + + if removed { + println!("✓ Local credentials removed"); + println!(" Note: The agent is still registered on the server."); + println!(" An admin must revoke it via the API."); + } else { + println!("No credentials found - agent was not registered."); + } + + Ok(()) +} diff --git a/agent/src/commands/run.rs b/agent/src/commands/run.rs new file mode 100644 index 0000000..11a5109 --- /dev/null +++ b/agent/src/commands/run.rs @@ -0,0 +1,125 @@ +use std::{collections::HashMap, sync::Arc}; + +use anyhow::Result; +use tokio::{sync::mpsc, task::JoinSet}; +use tokio_util::sync::CancellationToken; + +use crate::{ + client::ApiClient, + config::AgentConfig, + plugins::{Plugin, PluginContext, ServicePlugin, TaskEnvelope}, +}; + +pub struct AgentDaemon { + config: AgentConfig, + scheduled: Vec>, + services: Vec>, + task_tx: mpsc::Sender, + task_rx: mpsc::Receiver, + shutdown: CancellationToken, + api_client: ApiClient, +} + +impl AgentDaemon { + pub fn new(config: AgentConfig) -> Result { + let (task_tx, task_rx) = mpsc::channel(100); + Ok(AgentDaemon { + config: config.clone(), + scheduled: Vec::new(), + services: Vec::new(), + task_tx, + task_rx, + shutdown: CancellationToken::new(), + api_client: ApiClient::new( + "".into(), + Some(std::fs::read_to_string(config.auth_key_path())?), + Some(std::fs::read_to_string(config.auth_cert_path())?), + Some(std::fs::read_to_string(config.ca_cert_path())?), + )?, + }) + } + + #[allow(dead_code)] + pub fn register_plugin(&mut self, plugin: impl Plugin) { + self.scheduled.push(Box::new(plugin)); + } + + #[allow(dead_code)] + pub fn register_service(&mut self, service: impl ServicePlugin) { + self.services.push(Box::new(service)); + } + + pub async fn run(mut self) -> Result<()> { + let mut join_set = JoinSet::new(); + let ctx = Arc::new(PluginContext { + config: self.config.clone(), + api_client: self.api_client, + }); + + // drain services into Arc so we can move owned values into tasks + let services: Vec> = + self.services.drain(..).map(Arc::from).collect(); + + for service in services { + let task_tx = self.task_tx.clone(); + let shutdown = self.shutdown.clone(); + let ctx = ctx.clone(); + join_set.spawn(async move { service.run(&ctx, task_tx, shutdown).await }); + } + + for plugin in &self.scheduled { + let schedule = plugin.schedule().unwrap(); + let task_tx = self.task_tx.clone(); + let plugin_id = plugin.id().to_string(); + let shutdown = self.shutdown.clone(); + join_set.spawn(async move { + let mut interval = tokio::time::interval(schedule); + loop { + tokio::select! { + _ = interval.tick() => { + let _ = task_tx.send(TaskEnvelope { + plugin_id: plugin_id.clone(), + ack_tx: None, + }).await; + } + _ = shutdown.cancelled() => break, + } + } + Ok(()) + }); + } + + let plugins: Arc>> = Arc::new( + self.scheduled + .into_iter() + .map(|p| (p.id().to_string(), p)) + .collect(), + ); + + join_set.spawn(async move { + while let Some(envelope) = self.task_rx.recv().await { + if let Some(plugin) = plugins.get(&envelope.plugin_id) { + let ctx = ctx.clone(); + // run in a separate task so plugins don't block the executor + let result = plugin.run(&ctx).await; + if let Some(ack_tx) = envelope.ack_tx { + let _ = ack_tx.send(result.unwrap()); + } + } + } + Ok(()) + }); + + join_set.join_all().await; + + Ok(()) + } +} + +pub async fn run(config: AgentConfig) -> Result<()> { + let daemon = AgentDaemon::new(config)?; + + daemon.run().await?; + + Ok(()) +} diff --git a/agent/src/config.rs b/agent/src/config.rs new file mode 100644 index 0000000..515c940 --- /dev/null +++ b/agent/src/config.rs @@ -0,0 +1,36 @@ +use std::path::PathBuf; + +use serde::Deserialize; + +#[derive(Debug, Clone, Deserialize)] +pub struct AgentConfig { + pub data_dir: PathBuf, +} + +impl AgentConfig { + pub fn from_file(path: PathBuf) -> anyhow::Result { + let config_str = std::fs::read_to_string(&path) + .map_err(|e| anyhow::anyhow!("Failed to read config file {}: {}", path.display(), e))?; + let config = toml::from_str(&config_str).map_err(|e| { + anyhow::anyhow!("Failed to parse config file {}: {}", path.display(), e) + })?; + Ok(config) + } + + /// The path to the agent's private key file (PEM format) + pub fn auth_key_path(&self) -> PathBuf { + self.data_dir.join("auth.key") + } + + /// The path to the agent's certificate file (PEM format) issued by the + /// server. + pub fn auth_cert_path(&self) -> PathBuf { + self.data_dir.join("auth.crt") + } + + /// The path to the CA certificate file (PEM format) used to verify the + /// server's TLS certificate. + pub fn ca_cert_path(&self) -> PathBuf { + self.data_dir.join("ca.crt") + } +} diff --git a/agent/src/main.rs b/agent/src/main.rs new file mode 100644 index 0000000..5784807 --- /dev/null +++ b/agent/src/main.rs @@ -0,0 +1,57 @@ +use std::path::PathBuf; + +use clap::{Parser, Subcommand}; + +use crate::config::AgentConfig; + +mod config; +mod util; + +mod client; +mod commands; +mod plugins; + +#[derive(Parser)] +#[command(name = "lucid-agent")] +pub struct Args { + #[command(subcommand)] + command: Command, + + #[arg( + long, + short, + global = true, + env = "LUCID_AGENT_CONFIG_PATH", + default_value = "/etc/lucid/agent.toml" + )] + config_path: PathBuf, +} + +#[derive(Subcommand)] +pub enum Command { + /// Run the agent daemon + Run, + + /// Register this agent with the Lucid API + Register { + /// Activation key JWT from the Lucid console + #[arg(long, short)] + token: String, + }, + + /// Remove local registration credentials + Unregister, +} + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + let args = Args::parse(); + let config = AgentConfig::from_file(args.config_path) + .expect("Failed to load config - ensure the config file exists and is valid TOML"); + + match args.command { + Command::Run => commands::run::run(config).await, + Command::Register { token } => commands::registration::register(&token, config).await, + Command::Unregister => commands::registration::unregister(config), + } +} diff --git a/agent/src/plugins/mod.rs b/agent/src/plugins/mod.rs new file mode 100644 index 0000000..a94c40c --- /dev/null +++ b/agent/src/plugins/mod.rs @@ -0,0 +1,66 @@ +use std::time::Duration; + +use async_trait::async_trait; +use serde::{Deserialize, Serialize}; +use tokio::sync::{mpsc, oneshot}; +use tokio_util::sync::CancellationToken; + +use crate::{client::ApiClient, config::AgentConfig}; + +#[derive(Debug)] +pub struct TaskEnvelope { + pub plugin_id: String, + /// optional: ack channel for command-triggered tasks + pub ack_tx: Option>, +} + +#[allow(dead_code)] +pub struct PluginContext { + pub config: AgentConfig, + pub api_client: ApiClient, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TaskResult { + pub plugin_id: &'static str, + pub payload: serde_json::Value, +} + +#[async_trait] +pub trait Plugin: Send + Sync + 'static { + /// Unique identifier for the plugin, used in task definitions and results. + fn id(&self) -> &'static str; + + /// The schedule method returns an optional Duration indicating when the + /// plugin should be executed next. + /// + /// Plugins that return `None` will not be scheduled for execution, but can + /// still be triggered manually via the API or CLI. + /// + /// Plugins that return `Some(Duration)` will be automatically scheduled to + /// run after the specified duration has elapsed. After execution, the + /// schedule method will be called again to determine the next execution + /// time. + fn schedule(&self) -> Option; + + /// The unit of work to be performed by the plugin. This method will be + /// called when the plugin is executed, either on a schedule or via manual + /// trigger. + /// + /// The plugin should perform its task and return a TaskResult that will be + /// sent back to the server. + async fn run(&self, ctx: &PluginContext) -> anyhow::Result; +} + +#[async_trait] +pub trait ServicePlugin: Send + Sync + 'static { + #[allow(dead_code)] + fn id(&self) -> &'static str; + + async fn run( + &self, + ctx: &PluginContext, + task_tx: mpsc::Sender, + mut shutdown: CancellationToken, + ) -> anyhow::Result<()>; +} diff --git a/agent/src/util/crypto.rs b/agent/src/util/crypto.rs new file mode 100644 index 0000000..d5267b9 --- /dev/null +++ b/agent/src/util/crypto.rs @@ -0,0 +1,18 @@ +use rcgen::{CertificateParams, DnType, KeyPair, PKCS_ED25519}; + +pub fn generate_keypair() -> Result { + KeyPair::generate_for(&PKCS_ED25519) + .map_err(|e| anyhow::anyhow!("Failed to generate keypair: {}", e)) +} + +pub fn create_csr(key_pair: &KeyPair, hostname: &str) -> Result { + let mut params = CertificateParams::default(); + params.distinguished_name.push(DnType::CommonName, hostname); + + let csr = params + .serialize_request(key_pair) + .map_err(|e| anyhow::anyhow!("Failed to create CSR: {}", e))?; + + csr.pem() + .map_err(|e| anyhow::anyhow!("Failed to encode CSR: {}", e)) +} diff --git a/agent/src/util/mod.rs b/agent/src/util/mod.rs new file mode 100644 index 0000000..274f0ed --- /dev/null +++ b/agent/src/util/mod.rs @@ -0,0 +1 @@ +pub mod crypto; diff --git a/api/Cargo.toml b/api/Cargo.toml new file mode 100644 index 0000000..9a1d910 --- /dev/null +++ b/api/Cargo.toml @@ -0,0 +1,48 @@ +[package] +name = "lucid-api" +version = "0.1.0" +edition.workspace = true +license.workspace = true + +[[bin]] +name = "lucid-api" +path = "src/main.rs" + +[dependencies] +aes-gcm.workspace = true +anyhow.workspace = true +axum.workspace = true +chrono.workspace = true +clap.workspace = true +getrandom.workspace = true +hex.workspace = true +mongodb.workspace = true +serde.workspace = true +serde_json.workspace = true +sha2.workspace = true +thiserror.workspace = true +time.workspace = true +tokio.workspace = true +tower.workspace = true +tower-http.workspace = true +tracing-subscriber.workspace = true +tracing.workspace = true +utoipa.workspace = true +utoipa-axum.workspace = true +ulid.workspace = true + +lucid-common.workspace = true +lucid-db.workspace = true + +async-trait.workspace = true +rand.workspace = true +base64.workspace = true +ed25519-dalek.workspace = true +jsonwebtoken.workspace = true +pem-rfc7468.workspace = true +pkcs8.workspace = true +rcgen.workspace = true +x509-parser.workspace = true +rustls.workspace = true +rustls-pemfile.workspace = true +axum-server.workspace = true diff --git a/api/openapi.json b/api/openapi.json new file mode 100644 index 0000000..c07c5d2 --- /dev/null +++ b/api/openapi.json @@ -0,0 +1,1611 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "Lucid API Reference", + "contact": { + "name": "Roost team", + "url": "https://github.com/roostmoe/lucid", + "email": "hello@roost.moe" + }, + "license": { + "name": "Apache 2.0 License", + "url": "https://github.com/roostmoe/lucid/blob/main/LICENSE", + "identifier": "Apache-2.0" + }, + "version": "0.1.0" + }, + "paths": { + "/.well-known/jwks.json": { + "get": { + "tags": [ + "auth" + ], + "summary": "Retrieve the server's public JSON Web Key Set.", + "description": "Returns the Ed25519 public key(s) used by this server to sign tokens.\nExternal services can use this endpoint to verify JWTs without needing\na shared secret.\n\nThe key is represented as an OKP (Octet Key Pair) JWK per RFC 8037.\n\n# Example\n\n```bash\ncurl http://localhost:4000/.well-known/jwks.json\n```\n\n```json\n{\n \"keys\": [\n {\n \"kty\": \"OKP\",\n \"crv\": \"Ed25519\",\n \"x\": \"11qYAYKxCrfVS_7TyWQHOg7hcvPapiMlrwIaaPcHURo\",\n \"kid\": \"11qYAYKxCrfV\",\n \"use\": \"sig\",\n \"alg\": \"EdDSA\"\n }\n ]\n}\n```", + "operationId": "get_jwks", + "responses": { + "200": { + "description": "JSON Web Key Set", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JwkSet" + } + } + } + }, + "400": { + "$ref": "#/components/schemas/ApiErrorResponse", + "summary": "Client or validation error" + }, + "401": { + "$ref": "#/components/schemas/ApiErrorResponse", + "summary": "Unauthorized" + }, + "403": { + "$ref": "#/components/schemas/ApiErrorResponse", + "summary": "Forbidden" + }, + "404": { + "$ref": "#/components/schemas/ApiErrorResponse", + "summary": "Not found" + }, + "500": { + "$ref": "#/components/schemas/ApiErrorResponse", + "summary": "Internal server error" + } + } + } + }, + "/.well-known/lucid/agent": { + "get": { + "tags": [ + "well-known" + ], + "summary": "GET /.well-known/lucid/agent\nReturns CA certificate information for agents.", + "operationId": "get_agent_well_known", + "responses": { + "200": { + "description": "Agent configuration", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AgentWellKnownResponse" + } + } + } + }, + "400": { + "$ref": "#/components/schemas/ApiErrorResponse", + "summary": "Client or validation error" + }, + "401": { + "$ref": "#/components/schemas/ApiErrorResponse", + "summary": "Unauthorized" + }, + "403": { + "$ref": "#/components/schemas/ApiErrorResponse", + "summary": "Forbidden" + }, + "404": { + "$ref": "#/components/schemas/ApiErrorResponse", + "summary": "Not found" + }, + "500": { + "$ref": "#/components/schemas/ApiErrorResponse", + "summary": "Internal server error" + }, + "503": { + "description": "CA not initialized" + } + } + } + }, + "/.well-known/lucid/server": { + "get": { + "tags": [ + "well-known" + ], + "summary": "GET /.well-known/lucid/agent\nReturns CA certificate information for agents.", + "operationId": "get_server_well_known", + "responses": { + "200": { + "description": "Server configuration", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AgentWellKnownResponse" + } + } + } + }, + "400": { + "$ref": "#/components/schemas/ApiErrorResponse", + "summary": "Client or validation error" + }, + "401": { + "$ref": "#/components/schemas/ApiErrorResponse", + "summary": "Unauthorized" + }, + "403": { + "$ref": "#/components/schemas/ApiErrorResponse", + "summary": "Forbidden" + }, + "404": { + "$ref": "#/components/schemas/ApiErrorResponse", + "summary": "Not found" + }, + "500": { + "$ref": "#/components/schemas/ApiErrorResponse", + "summary": "Internal server error" + } + } + } + }, + "/.well-known/openid-configuration": { + "get": { + "tags": [ + "auth" + ], + "summary": "OpenID Connect discovery endpoint.", + "description": "Returns minimal OIDC configuration needed for JWT verification.\nOnly includes `jwks_uri` pointing to the JWKS endpoint.\n\n# Example\n\n```bash\ncurl http://localhost:4000/.well-known/openid-configuration\n```\n\n```json\n{\n \"jwks_uri\": \"http://localhost:4000/.well-known/jwks.json\"\n}\n```", + "operationId": "get_openid_configuration", + "responses": { + "200": { + "description": "OpenID Connect discovery document", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OpenIdConfiguration" + } + } + } + }, + "400": { + "$ref": "#/components/schemas/ApiErrorResponse", + "summary": "Client or validation error" + }, + "401": { + "$ref": "#/components/schemas/ApiErrorResponse", + "summary": "Unauthorized" + }, + "403": { + "$ref": "#/components/schemas/ApiErrorResponse", + "summary": "Forbidden" + }, + "404": { + "$ref": "#/components/schemas/ApiErrorResponse", + "summary": "Not found" + }, + "500": { + "$ref": "#/components/schemas/ApiErrorResponse", + "summary": "Internal server error" + } + } + } + }, + "/api/v1/activation-keys": { + "get": { + "tags": [ + "activation-keys" + ], + "operationId": "list_activation_keys", + "responses": { + "200": { + "description": "List of activation keys", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PaginatedList_ActivationKey" + } + } + } + }, + "400": { + "$ref": "#/components/schemas/ApiErrorResponse", + "summary": "Client or validation error" + }, + "401": { + "$ref": "#/components/schemas/ApiErrorResponse", + "summary": "Unauthorized" + }, + "403": { + "$ref": "#/components/schemas/ApiErrorResponse", + "summary": "Forbidden" + }, + "404": { + "$ref": "#/components/schemas/ApiErrorResponse", + "summary": "Not found" + }, + "500": { + "$ref": "#/components/schemas/ApiErrorResponse", + "summary": "Internal server error" + } + } + }, + "post": { + "tags": [ + "activation-keys" + ], + "operationId": "create_activation_key", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateActivationKeyRequest" + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "Activation key created", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateActivationKeyResponse" + } + } + } + }, + "400": { + "$ref": "#/components/schemas/ApiErrorResponse", + "summary": "Client or validation error" + }, + "401": { + "$ref": "#/components/schemas/ApiErrorResponse", + "summary": "Unauthorized" + }, + "403": { + "$ref": "#/components/schemas/ApiErrorResponse", + "summary": "Forbidden" + }, + "404": { + "$ref": "#/components/schemas/ApiErrorResponse", + "summary": "Not found" + }, + "500": { + "$ref": "#/components/schemas/ApiErrorResponse", + "summary": "Internal server error" + } + } + } + }, + "/api/v1/activation-keys/{id}": { + "get": { + "tags": [ + "activation-keys" + ], + "operationId": "get_activation_key", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "ulid" + } + } + ], + "responses": { + "200": { + "description": "Activation key details", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ActivationKey" + } + } + } + }, + "400": { + "$ref": "#/components/schemas/ApiErrorResponse", + "summary": "Client or validation error" + }, + "401": { + "$ref": "#/components/schemas/ApiErrorResponse", + "summary": "Unauthorized" + }, + "403": { + "$ref": "#/components/schemas/ApiErrorResponse", + "summary": "Forbidden" + }, + "404": { + "$ref": "#/components/schemas/ApiErrorResponse", + "summary": "Not found" + }, + "500": { + "$ref": "#/components/schemas/ApiErrorResponse", + "summary": "Internal server error" + } + } + }, + "delete": { + "tags": [ + "activation-keys" + ], + "operationId": "delete_activation_key", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "ulid" + } + } + ], + "responses": { + "204": { + "description": "Activation key deleted" + }, + "400": { + "$ref": "#/components/schemas/ApiErrorResponse", + "summary": "Client or validation error" + }, + "401": { + "$ref": "#/components/schemas/ApiErrorResponse", + "summary": "Unauthorized" + }, + "403": { + "$ref": "#/components/schemas/ApiErrorResponse", + "summary": "Forbidden" + }, + "404": { + "$ref": "#/components/schemas/ApiErrorResponse", + "summary": "Not found" + }, + "500": { + "$ref": "#/components/schemas/ApiErrorResponse", + "summary": "Internal server error" + } + } + } + }, + "/api/v1/agents/register": { + "post": { + "tags": [ + "agents" + ], + "summary": "POST /api/v1/agents/register", + "description": "Register a new agent using an activation key JWT.", + "operationId": "register_agent", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RegisterAgentRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Agent registered successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RegisterAgentResponse" + } + } + } + }, + "400": { + "$ref": "#/components/schemas/ApiErrorResponse", + "summary": "Client or validation error" + }, + "401": { + "$ref": "#/components/schemas/ApiErrorResponse", + "summary": "Unauthorized" + }, + "403": { + "$ref": "#/components/schemas/ApiErrorResponse", + "summary": "Forbidden" + }, + "404": { + "$ref": "#/components/schemas/ApiErrorResponse", + "summary": "Not found" + }, + "409": { + "description": "Activation key already used" + }, + "500": { + "$ref": "#/components/schemas/ApiErrorResponse", + "summary": "Internal server error" + }, + "503": { + "description": "CA not initialized" + } + }, + "security": [ + { + "activation_key": [] + } + ] + } + }, + "/api/v1/auth/login": { + "post": { + "tags": [ + "auth", + "console_sessions" + ], + "summary": "Authenticate user and create session.", + "description": "This endpoint validates user credentials and creates a new session stored in the database.\nOn success, it returns a session cookie and a CSRF token.\n\n# Flow\n\n1. Validates username/password against database\n2. Generates unique session_id (ULID) and csrf_token (32 random chars)\n3. Creates session in database with 30-day TTL\n4. Signs session_id with Ed25519 key\n5. Returns signed token in `lucid_session` cookie + CSRF token in response body\n\n# Cookie Format\n\n- Name: `lucid_session`\n- Value: `{session_id}.{ed25519_signature}`\n- Flags: HttpOnly, SameSite=Lax, Path=/, Max-Age=2592000 (30 days)\n- Secure: Only set when `public_url` starts with https://\n\n# CSRF Token\n\nThe CSRF token must be stored by the client (e.g., in memory or localStorage) and sent\nin the `X-CSRF-Token` header for all state-changing requests (POST, PUT, DELETE).\n\n# Example\n\n```bash\ncurl -X POST http://localhost:3000/v1/auth/login \\\n -H \"Content-Type: application/json\" \\\n -d '{\"username\": \"admin\", \"password\": \"secret\"}' \\\n -c cookies.txt\n```\n\n# Errors\n\n- 401 Unauthorized: Invalid username or password\n- 500 Internal Server Error: Database or signing failure", + "operationId": "auth_login", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AuthLoginParams" + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "Successful login", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AuthLoginResponse" + } + } + } + }, + "400": { + "$ref": "#/components/schemas/ApiErrorResponse", + "summary": "Client or validation error" + }, + "401": { + "$ref": "#/components/schemas/ApiErrorResponse", + "summary": "Unauthorized" + }, + "403": { + "$ref": "#/components/schemas/ApiErrorResponse", + "summary": "Forbidden" + }, + "404": { + "$ref": "#/components/schemas/ApiErrorResponse", + "summary": "Not found" + }, + "500": { + "$ref": "#/components/schemas/ApiErrorResponse", + "summary": "Internal server error" + } + } + } + }, + "/api/v1/auth/logout": { + "post": { + "tags": [ + "auth", + "console_sessions" + ], + "summary": "End the current session.", + "description": "This endpoint deletes the user's session from the database and clears the session cookie.\nRequires both session cookie authentication AND the CSRF token.\n\n# Flow\n\n1. Extracts and verifies session cookie from request\n2. Validates CSRF token (via Auth extractor)\n3. Deletes session from database\n4. Returns cookie with Max-Age=0 to clear it from browser\n\n# Security\n\nThis is a state-changing operation, so it requires CSRF protection. The session cookie\nalone is not sufficient - the CSRF token must also be provided.\n\n# Example\n\n```bash\ncurl -X POST http://localhost:3000/v1/auth/logout \\\n -H \"X-CSRF-Token: {csrf_token_from_login}\" \\\n -b cookies.txt\n```\n\n# Errors\n\n- 401 Unauthorized: Missing or invalid session cookie\n- 403 Forbidden: Invalid CSRF token\n- 500 Internal Server Error: Database failure", + "operationId": "auth_logout", + "responses": { + "200": { + "description": "Successful logout" + }, + "400": { + "$ref": "#/components/schemas/ApiErrorResponse", + "summary": "Client or validation error" + }, + "401": { + "$ref": "#/components/schemas/ApiErrorResponse", + "summary": "Unauthorized" + }, + "403": { + "$ref": "#/components/schemas/ApiErrorResponse", + "summary": "Forbidden" + }, + "404": { + "$ref": "#/components/schemas/ApiErrorResponse", + "summary": "Not found" + }, + "500": { + "$ref": "#/components/schemas/ApiErrorResponse", + "summary": "Internal server error" + } + } + } + }, + "/api/v1/auth/me": { + "get": { + "tags": [ + "auth" + ], + "summary": "Get information about the authenticated user.", + "description": "Returns the current user's profile information including ID, username, display name,\nand email. Requires session cookie authentication (no CSRF token needed for GET requests).\n\n# Example\n\n```bash\ncurl http://localhost:3000/v1/auth/me \\\n -b cookies.txt\n```\n\n# Response\n\n```json\n{\n \"id\": \"user_object_id\",\n \"username\": \"admin\",\n \"display_name\": \"Administrator\",\n \"email\": \"admin@example.com\"\n}\n```\n\n# Errors\n\n- 401 Unauthorized: Missing or invalid session cookie\n- 404 Not Found: User no longer exists in database (stale session)\n- 500 Internal Server Error: Database failure", + "operationId": "auth_whoami", + "responses": { + "200": { + "description": "User information", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/User" + } + } + } + }, + "400": { + "$ref": "#/components/schemas/ApiErrorResponse", + "summary": "Client or validation error" + }, + "401": { + "$ref": "#/components/schemas/ApiErrorResponse", + "summary": "Unauthorized" + }, + "403": { + "$ref": "#/components/schemas/ApiErrorResponse", + "summary": "Forbidden" + }, + "404": { + "$ref": "#/components/schemas/ApiErrorResponse", + "summary": "Not found" + }, + "500": { + "$ref": "#/components/schemas/ApiErrorResponse", + "summary": "Internal server error" + } + } + } + }, + "/api/v1/cas": { + "get": { + "tags": [ + "cas" + ], + "operationId": "list_cas", + "responses": { + "200": { + "description": "List of certificate authorities", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PaginatedList_Ca" + } + } + } + }, + "400": { + "$ref": "#/components/schemas/ApiErrorResponse", + "summary": "Client or validation error" + }, + "401": { + "$ref": "#/components/schemas/ApiErrorResponse", + "summary": "Unauthorized" + }, + "403": { + "$ref": "#/components/schemas/ApiErrorResponse", + "summary": "Forbidden" + }, + "404": { + "$ref": "#/components/schemas/ApiErrorResponse", + "summary": "Not found" + }, + "500": { + "$ref": "#/components/schemas/ApiErrorResponse", + "summary": "Internal server error" + } + } + }, + "post": { + "tags": [ + "cas" + ], + "summary": "Generate a new self-signed Ed25519 CA certificate and store it.", + "operationId": "generate_ca", + "responses": { + "201": { + "description": "Certificate authority generated", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Ca" + } + } + } + }, + "400": { + "$ref": "#/components/schemas/ApiErrorResponse", + "summary": "Client or validation error" + }, + "401": { + "$ref": "#/components/schemas/ApiErrorResponse", + "summary": "Unauthorized" + }, + "403": { + "$ref": "#/components/schemas/ApiErrorResponse", + "summary": "Forbidden" + }, + "404": { + "$ref": "#/components/schemas/ApiErrorResponse", + "summary": "Not found" + }, + "500": { + "$ref": "#/components/schemas/ApiErrorResponse", + "summary": "Internal server error" + } + } + } + }, + "/api/v1/cas/import": { + "post": { + "tags": [ + "cas" + ], + "summary": "Import an existing CA certificate and private key.", + "operationId": "import_ca", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ImportCaRequest" + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "Certificate authority imported", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Ca" + } + } + } + }, + "400": { + "$ref": "#/components/schemas/ApiErrorResponse", + "summary": "Client or validation error" + }, + "401": { + "$ref": "#/components/schemas/ApiErrorResponse", + "summary": "Unauthorized" + }, + "403": { + "$ref": "#/components/schemas/ApiErrorResponse", + "summary": "Forbidden" + }, + "404": { + "$ref": "#/components/schemas/ApiErrorResponse", + "summary": "Not found" + }, + "500": { + "$ref": "#/components/schemas/ApiErrorResponse", + "summary": "Internal server error" + } + } + } + }, + "/api/v1/cas/{id}": { + "get": { + "tags": [ + "cas" + ], + "operationId": "get_ca", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "ulid" + } + } + ], + "responses": { + "200": { + "description": "Certificate authority details", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Ca" + } + } + } + }, + "400": { + "$ref": "#/components/schemas/ApiErrorResponse", + "summary": "Client or validation error" + }, + "401": { + "$ref": "#/components/schemas/ApiErrorResponse", + "summary": "Unauthorized" + }, + "403": { + "$ref": "#/components/schemas/ApiErrorResponse", + "summary": "Forbidden" + }, + "404": { + "$ref": "#/components/schemas/ApiErrorResponse", + "summary": "Not found" + }, + "500": { + "$ref": "#/components/schemas/ApiErrorResponse", + "summary": "Internal server error" + } + } + }, + "delete": { + "tags": [ + "cas" + ], + "operationId": "delete_ca", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "ulid" + } + } + ], + "responses": { + "204": { + "description": "Certificate authority deleted" + }, + "400": { + "$ref": "#/components/schemas/ApiErrorResponse", + "summary": "Client or validation error" + }, + "401": { + "$ref": "#/components/schemas/ApiErrorResponse", + "summary": "Unauthorized" + }, + "403": { + "$ref": "#/components/schemas/ApiErrorResponse", + "summary": "Forbidden" + }, + "404": { + "$ref": "#/components/schemas/ApiErrorResponse", + "summary": "Not found" + }, + "500": { + "$ref": "#/components/schemas/ApiErrorResponse", + "summary": "Internal server error" + } + } + } + }, + "/api/v1/hosts": { + "get": { + "tags": [ + "hosts" + ], + "operationId": "list_hosts", + "responses": { + "200": { + "description": "List of hosts", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PaginatedList_Host" + } + } + } + }, + "400": { + "$ref": "#/components/schemas/ApiErrorResponse", + "summary": "Client or validation error" + }, + "401": { + "$ref": "#/components/schemas/ApiErrorResponse", + "summary": "Unauthorized" + }, + "403": { + "$ref": "#/components/schemas/ApiErrorResponse", + "summary": "Forbidden" + }, + "404": { + "$ref": "#/components/schemas/ApiErrorResponse", + "summary": "Not found" + }, + "500": { + "$ref": "#/components/schemas/ApiErrorResponse", + "summary": "Internal server error" + } + } + } + }, + "/api/v1/hosts/{id}": { + "get": { + "tags": [ + "hosts" + ], + "operationId": "get_host", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "ulid" + } + } + ], + "responses": { + "200": { + "description": "Resolved host", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Host" + } + } + } + }, + "400": { + "$ref": "#/components/schemas/ApiErrorResponse", + "summary": "Client or validation error" + }, + "401": { + "$ref": "#/components/schemas/ApiErrorResponse", + "summary": "Unauthorized" + }, + "403": { + "$ref": "#/components/schemas/ApiErrorResponse", + "summary": "Forbidden" + }, + "404": { + "$ref": "#/components/schemas/ApiErrorResponse", + "summary": "Not found" + }, + "500": { + "$ref": "#/components/schemas/ApiErrorResponse", + "summary": "Internal server error" + } + } + } + } + }, + "components": { + "schemas": { + "ActivationKey": { + "type": "object", + "description": "An activation key used to bootstrap host agent configuration.", + "required": [ + "id", + "key_id", + "description", + "used", + "created_at" + ], + "properties": { + "created_at": { + "type": "string", + "format": "date-time", + "description": "When the key was created" + }, + "description": { + "type": "string", + "description": "Human-readable description" + }, + "id": { + "type": "string", + "format": "ulid", + "description": "Internal database ID" + }, + "key_id": { + "type": "string", + "description": "User-provided key identifier" + }, + "used": { + "type": "boolean", + "description": "Whether or not the key has been used to register an agent" + } + } + }, + "AgentWellKnownResponse": { + "type": "object", + "required": [ + "server_version", + "cas" + ], + "properties": { + "cas": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CaInfoResponse" + } + }, + "server_version": { + "type": "string" + } + } + }, + "ApiErrorResponse": { + "type": "object", + "description": "An error response for an API endpoint. This is used to return errors to the\nclient in a consistent format.", + "required": [ + "message" + ], + "properties": { + "code": { + "type": [ + "string", + "null" + ], + "description": "An optional error code that can be used to identify the type of error\nthat occurred." + }, + "details": { + "type": [ + "string", + "null" + ] + }, + "message": { + "type": "string", + "description": "A human-readable message describing the error that occurred." + } + } + }, + "AuthLoginParams": { + "type": "object", + "required": [ + "username", + "password" + ], + "properties": { + "password": { + "type": "string", + "description": "The password of the user to authenticate as." + }, + "username": { + "type": "string", + "description": "The username or email of the user to authenticate as." + } + } + }, + "AuthLoginResponse": { + "oneOf": [ + { + "type": "object", + "description": "The session cookie for the authenticated user. This cookie should be\nincluded in subsequent requests to authenticate the user.", + "required": [ + "csrf_token", + "token_type" + ], + "properties": { + "csrf_token": { + "type": "string", + "description": "CSRF token that must be included in X-CSRF-Token header for mutating requests" + }, + "token_type": { + "type": "string", + "enum": [ + "Session" + ] + } + } + }, + { + "type": "object", + "description": "The access token for the authenticated user. This token should be\nincluded in the `Authorization` header of subsequent requests to\nauthenticate the user.", + "required": [ + "access_token", + "refresh_token", + "expires_in", + "token_type" + ], + "properties": { + "access_token": { + "type": "string", + "description": "The access token for the authenticated user." + }, + "expires_in": { + "type": "integer", + "format": "int64", + "description": "How long the token is valid for, in seconds. After this time has\nelapsed, the user will need to use the refresh token to obtain a\nnew access token." + }, + "refresh_token": { + "type": "string", + "description": "The refresh token for the user's new session." + }, + "token_type": { + "type": "string", + "enum": [ + "Bearer" + ] + } + } + } + ], + "description": "Response for the login endpoint." + }, + "Ca": { + "type": "object", + "description": "A certificate authority managed by Lucid.", + "required": [ + "id", + "cert_pem", + "fingerprint", + "created_at" + ], + "properties": { + "cert_pem": { + "type": "string", + "description": "CA certificate in PEM format" + }, + "created_at": { + "type": "string", + "format": "date-time", + "description": "When this CA was created" + }, + "fingerprint": { + "type": "string", + "description": "SHA-256 fingerprint of the certificate (format: `sha256:`)" + }, + "id": { + "type": "string", + "format": "ulid", + "description": "Internal database ID" + } + } + }, + "CaInfoResponse": { + "type": "object", + "required": [ + "cert_pem", + "fingerprint", + "issued_at", + "expires_at" + ], + "properties": { + "cert_pem": { + "type": "string" + }, + "expires_at": { + "type": "string", + "format": "date-time" + }, + "fingerprint": { + "type": "string" + }, + "issued_at": { + "type": "string", + "format": "date-time" + } + } + }, + "CreateActivationKeyRequest": { + "type": "object", + "description": "Request body for creating an activation key.", + "required": [ + "key_id", + "description" + ], + "properties": { + "description": { + "type": "string", + "description": "Human-readable description" + }, + "key_id": { + "type": "string", + "description": "User-provided identifier for this key" + } + } + }, + "CreateActivationKeyResponse": { + "type": "object", + "description": "Response for activation key creation - includes the JWT token.", + "required": [ + "key", + "token" + ], + "properties": { + "key": { + "$ref": "#/components/schemas/ActivationKey", + "description": "The created activation key metadata" + }, + "token": { + "type": "string", + "description": "The JWT token - only returned on creation, store it securely!" + } + } + }, + "Host": { + "type": "object", + "required": [ + "id", + "hostname", + "os_id", + "os_name", + "os_version", + "architecture", + "ifaces", + "created_at", + "last_seen_at", + "updated_at" + ], + "properties": { + "architecture": { + "type": "string", + "description": "The CPU architecture of the host, read from `uname -m`." + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "hostname": { + "type": "string", + "description": "Hostname of the machine. This is a human-readable identifier for the\nhost, and is not guaranteed to be unique." + }, + "id": { + "type": "string", + "format": "ulid", + "description": "The unique identifier for this host." + }, + "ifaces": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NetworkInterface" + }, + "description": "Network interfaces associated with this host. This is a one-to-many\nrelationship, as a host can have multiple network interfaces." + }, + "last_seen_at": { + "type": "string", + "format": "date-time" + }, + "os_id": { + "type": "string", + "description": "The ID of the host's operating system, read from /etc/os-release." + }, + "os_name": { + "type": "string", + "description": "The name of the host's operating system, read from /etc/os-release." + }, + "os_version": { + "type": "string", + "description": "The version of the host's operating system, read from /etc/os-release." + }, + "updated_at": { + "type": "string", + "format": "date-time" + } + } + }, + "ImportCaRequest": { + "type": "object", + "description": "Request body for importing an existing certificate authority.", + "required": [ + "cert_pem", + "private_key_pem" + ], + "properties": { + "cert_pem": { + "type": "string", + "description": "PEM-encoded CA certificate." + }, + "private_key_pem": { + "type": "string", + "description": "PEM-encoded private key for the CA certificate (will be encrypted at\nrest using the server's `LUCID_CA_ENCRYPTION_KEY`)." + } + } + }, + "Jwk": { + "type": "object", + "description": "A single JSON Web Key representing an OKP (Octet Key Pair) Ed25519 public key.", + "required": [ + "kty", + "crv", + "x", + "kid", + "use", + "alg" + ], + "properties": { + "alg": { + "type": "string", + "description": "Algorithms this key supports — `\"EdDSA\"` for Ed25519." + }, + "crv": { + "type": "string", + "description": "Curve — always `\"Ed25519\"`." + }, + "kid": { + "type": "string", + "description": "Key ID — a base64url-encoded prefix of the public key bytes,\nused to identify which key was used to sign a token." + }, + "kty": { + "type": "string", + "description": "Key type — always `\"OKP\"` for Ed25519 keys (RFC 8037)." + }, + "use": { + "type": "string", + "description": "Intended use of the key. `\"sig\"` indicates this key is for signing." + }, + "x": { + "type": "string", + "description": "Base64url-encoded public key bytes (32 bytes for Ed25519)." + } + } + }, + "JwkSet": { + "type": "object", + "description": "The JSON Web Key Set response, containing one or more public keys.", + "required": [ + "keys" + ], + "properties": { + "keys": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Jwk" + } + } + } + }, + "NetworkInterface": { + "type": "object", + "required": [ + "id", + "iface", + "state", + "ip_addrs" + ], + "properties": { + "host_id": { + "type": [ + "string", + "null" + ], + "format": "ulid", + "description": "The unique identifier for the host that this network interface is\nassociated with." + }, + "id": { + "type": "string", + "format": "ulid", + "description": "The unique identifier for this network interface." + }, + "iface": { + "type": "string", + "description": "The name of the network interface." + }, + "ip_addrs": { + "type": "array", + "items": { + "type": "string" + }, + "description": "The IP addresses associated with this network interface." + }, + "state": { + "$ref": "#/components/schemas/NetworkInterfaceState", + "description": "The state of the network interface. This can be \"up\", \"down\", or\n\"unknown\"." + } + } + }, + "NetworkInterfaceState": { + "type": "string", + "enum": [ + "Up", + "Down", + "Unknown" + ] + }, + "OpenIdConfiguration": { + "type": "object", + "description": "OpenID Connect discovery response (minimal, for JWT verification only).", + "required": [ + "jwks_uri" + ], + "properties": { + "jwks_uri": { + "type": "string", + "description": "URL to the JWKS endpoint for retrieving public keys." + } + } + }, + "PaginatedList_ActivationKey": { + "type": "object", + "description": "Parameters for paginating through a list of records. This is used by the\nvarious list endpoints to allow clients to paginate through large sets of\nrecords.", + "required": [ + "items" + ], + "properties": { + "items": { + "type": "array", + "items": { + "type": "object", + "description": "An activation key used to bootstrap host agent configuration.", + "required": [ + "id", + "key_id", + "description", + "used", + "created_at" + ], + "properties": { + "created_at": { + "type": "string", + "format": "date-time", + "description": "When the key was created" + }, + "description": { + "type": "string", + "description": "Human-readable description" + }, + "id": { + "type": "string", + "format": "ulid", + "description": "Internal database ID" + }, + "key_id": { + "type": "string", + "description": "User-provided key identifier" + }, + "used": { + "type": "boolean", + "description": "Whether or not the key has been used to register an agent" + } + } + } + }, + "limit": { + "type": [ + "integer", + "null" + ], + "format": "int64", + "description": "The maximum number of results to return.", + "minimum": 0 + }, + "next_token": { + "type": [ + "string", + "null" + ], + "description": "The next page token, if any. This is acquired by requesting a paginated\nset of records and looking at the `next_token` or `prev_token` field." + } + } + }, + "PaginatedList_Ca": { + "type": "object", + "description": "Parameters for paginating through a list of records. This is used by the\nvarious list endpoints to allow clients to paginate through large sets of\nrecords.", + "required": [ + "items" + ], + "properties": { + "items": { + "type": "array", + "items": { + "type": "object", + "description": "A certificate authority managed by Lucid.", + "required": [ + "id", + "cert_pem", + "fingerprint", + "created_at" + ], + "properties": { + "cert_pem": { + "type": "string", + "description": "CA certificate in PEM format" + }, + "created_at": { + "type": "string", + "format": "date-time", + "description": "When this CA was created" + }, + "fingerprint": { + "type": "string", + "description": "SHA-256 fingerprint of the certificate (format: `sha256:`)" + }, + "id": { + "type": "string", + "format": "ulid", + "description": "Internal database ID" + } + } + } + }, + "limit": { + "type": [ + "integer", + "null" + ], + "format": "int64", + "description": "The maximum number of results to return.", + "minimum": 0 + }, + "next_token": { + "type": [ + "string", + "null" + ], + "description": "The next page token, if any. This is acquired by requesting a paginated\nset of records and looking at the `next_token` or `prev_token` field." + } + } + }, + "PaginatedList_Host": { + "type": "object", + "description": "Parameters for paginating through a list of records. This is used by the\nvarious list endpoints to allow clients to paginate through large sets of\nrecords.", + "required": [ + "items" + ], + "properties": { + "items": { + "type": "array", + "items": { + "type": "object", + "required": [ + "id", + "hostname", + "os_id", + "os_name", + "os_version", + "architecture", + "ifaces", + "created_at", + "last_seen_at", + "updated_at" + ], + "properties": { + "architecture": { + "type": "string", + "description": "The CPU architecture of the host, read from `uname -m`." + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "hostname": { + "type": "string", + "description": "Hostname of the machine. This is a human-readable identifier for the\nhost, and is not guaranteed to be unique." + }, + "id": { + "type": "string", + "format": "ulid", + "description": "The unique identifier for this host." + }, + "ifaces": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NetworkInterface" + }, + "description": "Network interfaces associated with this host. This is a one-to-many\nrelationship, as a host can have multiple network interfaces." + }, + "last_seen_at": { + "type": "string", + "format": "date-time" + }, + "os_id": { + "type": "string", + "description": "The ID of the host's operating system, read from /etc/os-release." + }, + "os_name": { + "type": "string", + "description": "The name of the host's operating system, read from /etc/os-release." + }, + "os_version": { + "type": "string", + "description": "The version of the host's operating system, read from /etc/os-release." + }, + "updated_at": { + "type": "string", + "format": "date-time" + } + } + } + }, + "limit": { + "type": [ + "integer", + "null" + ], + "format": "int64", + "description": "The maximum number of results to return.", + "minimum": 0 + }, + "next_token": { + "type": [ + "string", + "null" + ], + "description": "The next page token, if any. This is acquired by requesting a paginated\nset of records and looking at the `next_token` or `prev_token` field." + } + } + }, + "RegisterAgentRequest": { + "type": "object", + "description": "Request body for agent registration.", + "required": [ + "csr_pem", + "hostname" + ], + "properties": { + "csr_pem": { + "type": "string", + "description": "CSR in PEM format" + }, + "hostname": { + "type": "string", + "description": "Hostname of the agent" + } + } + }, + "RegisterAgentResponse": { + "type": "object", + "description": "Response body for a successful agent registration.", + "required": [ + "agent_id", + "certificate_pem", + "ca_certificate_pem", + "expires_at", + "api_base_url" + ], + "properties": { + "agent_id": { + "type": "string", + "description": "Agent UUID (ObjectId as hex string)" + }, + "api_base_url": { + "type": "string", + "description": "API base URL for future requests" + }, + "ca_certificate_pem": { + "type": "string", + "description": "CA certificate in PEM format" + }, + "certificate_pem": { + "type": "string", + "description": "Signed certificate in PEM format" + }, + "expires_at": { + "type": "string", + "format": "date-time", + "description": "Certificate expiration time" + } + } + }, + "User": { + "type": "object", + "required": [ + "id", + "display_name", + "email", + "created_at", + "updated_at" + ], + "properties": { + "created_at": { + "type": "string", + "format": "date-time" + }, + "display_name": { + "type": "string", + "description": "The user's display name." + }, + "email": { + "type": "string", + "description": "The user's email address." + }, + "id": { + "type": "string", + "format": "ulid", + "description": "The unique identifier for this user." + }, + "updated_at": { + "type": "string", + "format": "date-time" + } + } + } + } + } +} \ No newline at end of file diff --git a/api/src/auth/ca.rs b/api/src/auth/ca.rs new file mode 100644 index 0000000..b4539c3 --- /dev/null +++ b/api/src/auth/ca.rs @@ -0,0 +1,49 @@ +use async_trait::async_trait; +use chrono::{DateTime, Utc}; +use serde::Serialize; +use ulid::Ulid; +use utoipa::ToSchema; + +#[async_trait] +pub trait CertificateAuthority: Send + Sync { + /// Sign a CSR and return a PEM-encoded certificate valid for 24 hours. + /// The CN is set to the agent_id. + async fn sign_csr(&self, csr_pem: &str, agent_id: Ulid) -> Result; + + /// Get the CA certificate in PEM format. + async fn get_ca_cert_pem(&self) -> Result; + + /// Get CA certificate metadata for the well-known endpoint. + async fn get_ca_info(&self) -> Result; +} + +#[derive(Debug, Clone)] +pub struct SignedCertificate { + pub cert_pem: String, + pub issued_at: DateTime, + pub expires_at: DateTime, +} + +#[derive(Debug, Clone, Serialize, ToSchema)] +pub struct CaInfo { + pub cert_pem: String, + pub fingerprint: String, // sha256:hex + pub issued_at: DateTime, + pub expires_at: DateTime, +} + +#[derive(Debug, thiserror::Error)] +pub enum CaError { + #[error("CA not initialized - run `lucid-api generate-ca` first")] + NotInitialized, + #[error("Invalid CSR: {0}")] + InvalidCsr(String), + #[error("Encryption error: {0}")] + Encryption(String), + #[error("Decryption error: {0}")] + Decryption(String), + #[error("Certificate generation error: {0}")] + Generation(String), + #[error("Storage error: {0}")] + Storage(String), +} diff --git a/api/src/auth/encrypted_ca.rs b/api/src/auth/encrypted_ca.rs new file mode 100644 index 0000000..638b928 --- /dev/null +++ b/api/src/auth/encrypted_ca.rs @@ -0,0 +1,287 @@ +use std::sync::Arc; + +use async_trait::async_trait; +use base64::Engine; +use chrono::{DateTime, Duration, Utc}; +use lucid_common::caller::Caller; +use lucid_db::{ + models::DbCa, + storage::{CaStore, Storage}, +}; +use rcgen::{CertificateParams, CertificateSigningRequestParams, DistinguishedName, KeyPair}; +use sha2::{Digest, Sha256}; +use ulid::Ulid; +use x509_parser::prelude::*; + +use super::ca::{CaError, CaInfo, CertificateAuthority, SignedCertificate}; +use crate::crypto::aes; + +const AGENT_CERT_VALIDITY_HOURS: i64 = 24; +const CA_CERT_VALIDITY_YEARS: i64 = 10; + +pub struct EncryptedCa { + storage: Arc, + encryption_key: [u8; 32], +} + +impl EncryptedCa { + pub fn new(storage: Arc, encryption_key: [u8; 32]) -> Self { + Self { + storage, + encryption_key, + } + } + + /// Load encryption key from base64 environment variable. + pub fn encryption_key_from_env() -> Result<[u8; 32], CaError> { + let key_b64 = std::env::var("LUCID_CA_ENCRYPTION_KEY") + .map_err(|_| CaError::Encryption("LUCID_CA_ENCRYPTION_KEY not set".into()))?; + + let key_bytes = base64::engine::general_purpose::STANDARD + .decode(&key_b64) + .map_err(|e| CaError::Encryption(format!("Invalid base64: {}", e)))?; + + key_bytes + .try_into() + .map_err(|_| CaError::Encryption("Key must be exactly 32 bytes".into())) + } + + /// Decrypt the CA private key from storage. + async fn decrypt_private_key(&self, ca: &DbCa) -> Result { + // Use CA ID as AAD to prevent ciphertext transplantation + let aad = ca.id.inner().to_bytes().to_vec(); + + let private_key_pem = aes::decrypt(&self.encryption_key, &ca.encrypted_private_key, &aad) + .map_err(|e| CaError::Decryption(e.to_string()))?; + + let private_key_str = std::str::from_utf8(&private_key_pem) + .map_err(|e| CaError::Decryption(format!("Invalid UTF-8: {}", e)))?; + + KeyPair::from_pem(private_key_str) + .map_err(|e| CaError::Decryption(format!("Invalid private key PEM: {}", e))) + } +} + +#[async_trait] +impl CertificateAuthority for EncryptedCa { + async fn sign_csr(&self, csr_pem: &str, agent_id: Ulid) -> Result { + // Load CA from store + let ca = CaStore::list(self.storage.as_ref(), Caller::System) + .await + .map_err(|e| CaError::Storage(e.to_string()))? + .into_iter() + .next() + .ok_or(CaError::NotInitialized)?; + + // Decrypt CA private key + let ca_key_pair = self.decrypt_private_key(&ca).await?; + + // Parse CA certificate params to reconstruct Certificate + let ca_params = CertificateParams::from_ca_cert_pem(&ca.cert_pem) + .map_err(|e| CaError::Generation(format!("Failed to parse CA cert: {}", e)))?; + + // Self-sign with the CA key to recreate the Certificate object + let ca_cert = ca_params + .self_signed(&ca_key_pair) + .map_err(|e| CaError::Generation(format!("Failed to reconstruct CA cert: {}", e)))?; + + // Parse CSR + let mut csr = CertificateSigningRequestParams::from_pem(csr_pem) + .map_err(|e| CaError::InvalidCsr(format!("Failed to parse CSR: {}", e)))?; + + // Override DN with agent_id as CN + let mut dn = DistinguishedName::new(); + dn.push(rcgen::DnType::CommonName, agent_id.to_string()); + csr.params.distinguished_name = dn; + + // Set validity period (24 hours) + let issued_at = Utc::now(); + let expires_at = issued_at + Duration::hours(AGENT_CERT_VALIDITY_HOURS); + + csr.params.not_before = ::time::OffsetDateTime::from_unix_timestamp(issued_at.timestamp()) + .map_err(|e| CaError::Generation(format!("Invalid timestamp: {}", e)))?; + csr.params.not_after = ::time::OffsetDateTime::from_unix_timestamp(expires_at.timestamp()) + .map_err(|e| CaError::Generation(format!("Invalid timestamp: {}", e)))?; + + // Set key usage for client authentication + csr.params.key_usages = vec![ + rcgen::KeyUsagePurpose::DigitalSignature, + rcgen::KeyUsagePurpose::KeyEncipherment, + ]; + csr.params.extended_key_usages = vec![rcgen::ExtendedKeyUsagePurpose::ClientAuth]; + + // Ensure NOT a CA cert + csr.params.is_ca = rcgen::IsCa::NoCa; + + // Sign the CSR with the CA + let cert = csr + .signed_by(&ca_cert, &ca_key_pair) + .map_err(|e| CaError::Generation(format!("Failed to sign certificate: {}", e)))?; + + let cert_pem = cert.pem(); + + Ok(SignedCertificate { + cert_pem, + issued_at, + expires_at, + }) + } + + async fn get_ca_cert_pem(&self) -> Result { + let ca = CaStore::list(self.storage.as_ref(), Caller::System) + .await + .map_err(|e| CaError::Storage(e.to_string()))? + .into_iter() + .next() + .ok_or(CaError::NotInitialized)?; + + Ok(ca.cert_pem) + } + + async fn get_ca_info(&self) -> Result { + let ca = CaStore::list(self.storage.as_ref(), Caller::System) + .await + .map_err(|e| CaError::Storage(e.to_string()))? + .into_iter() + .next() + .ok_or(CaError::NotInitialized)?; + + // Parse certificate to get dates + let cert_der = pem_rfc7468::decode_vec(ca.cert_pem.as_bytes()) + .map_err(|e| CaError::Generation(format!("Failed to decode CA cert PEM: {}", e)))? + .1; + + let (_, cert) = X509Certificate::from_der(&cert_der) + .map_err(|e| CaError::Generation(format!("Failed to parse CA cert: {}", e)))?; + + let issued_at = DateTime::from_timestamp(cert.validity().not_before.timestamp(), 0) + .unwrap_or(ca.created_at); + + let expires_at = DateTime::from_timestamp(cert.validity().not_after.timestamp(), 0) + .ok_or_else(|| CaError::Generation("Invalid expiry date".into()))?; + + // Calculate SHA256 fingerprint + let mut hasher = Sha256::new(); + hasher.update(&cert_der); + let fingerprint = format!("sha256:{}", hex::encode(hasher.finalize())); + + Ok(CaInfo { + cert_pem: ca.cert_pem, + fingerprint, + issued_at, + expires_at, + }) + } +} + +/// Generate a new CA certificate and store it encrypted in the database. +pub async fn generate_ca( + storage: &dyn Storage, + encryption_key: &[u8; 32], + force: bool, +) -> Result { + // Check if CA exists + if CaStore::list(storage, Caller::System) + .await + .map_err(|e| CaError::Storage(e.to_string()))? + .into_iter() + .next() + .is_some() + && !force + { + return Err(CaError::Generation( + "CA already exists. Use --force to overwrite.".into(), + )); + } + + // Generate Ed25519 keypair + let key_pair = KeyPair::generate_for(&rcgen::PKCS_ED25519) + .map_err(|e| CaError::Generation(format!("Failed to generate keypair: {}", e)))?; + + // Create CA certificate parameters + let mut params = CertificateParams::default(); + + // Set distinguished name + let mut dn = DistinguishedName::new(); + dn.push(rcgen::DnType::CommonName, "Lucid CA"); + dn.push(rcgen::DnType::OrganizationName, "Lucid"); + params.distinguished_name = dn; + + // Set as CA + params.is_ca = rcgen::IsCa::Ca(rcgen::BasicConstraints::Unconstrained); + + // Set validity (10 years) + let now = Utc::now(); + let expires = now + Duration::days(365 * CA_CERT_VALIDITY_YEARS); + params.not_before = ::time::OffsetDateTime::from_unix_timestamp(now.timestamp()) + .map_err(|e| CaError::Generation(format!("Invalid timestamp: {}", e)))?; + params.not_after = ::time::OffsetDateTime::from_unix_timestamp(expires.timestamp()) + .map_err(|e| CaError::Generation(format!("Invalid timestamp: {}", e)))?; + + // Set key usage + params.key_usages = vec![ + rcgen::KeyUsagePurpose::DigitalSignature, + rcgen::KeyUsagePurpose::KeyCertSign, + rcgen::KeyUsagePurpose::CrlSign, + ]; + + // Self-sign + let cert = params + .self_signed(&key_pair) + .map_err(|e| CaError::Generation(format!("Failed to self-sign CA: {}", e)))?; + + let cert_pem = cert.pem(); + let private_key_pem = key_pair.serialize_pem(); + + // Pre-generate the ObjectId so we can use it as AAD during encryption. + // This prevents ciphertext transplantation: the encrypted key is bound to + // this specific CA record and cannot be decrypted if moved to another. + let ca_id = Ulid::new(); + + // Encrypt private key + let encrypted_private_key = aes::encrypt( + encryption_key, + private_key_pem.as_bytes(), + &ca_id.to_bytes(), + ) + .map_err(|e| CaError::Encryption(e.to_string()))?; + + // Create DbCa + let db_ca = DbCa { + id: ca_id.into(), + cert_pem: cert_pem.clone(), + encrypted_private_key, + created_at: now, + }; + + // Store in DB + let stored_ca = CaStore::create(storage, Caller::System, db_ca) + .await + .map_err(|e| CaError::Storage(e.to_string()))?; + + // Parse certificate for info + let cert_der = pem_rfc7468::decode_vec(cert_pem.as_bytes()) + .map_err(|e| CaError::Generation(format!("Failed to decode CA cert PEM: {}", e)))? + .1; + + let (_, cert_parsed) = X509Certificate::from_der(&cert_der) + .map_err(|e| CaError::Generation(format!("Failed to parse CA cert: {}", e)))?; + + let issued_at = + DateTime::from_timestamp(cert_parsed.validity().not_before.timestamp(), 0).unwrap_or(now); + + let expires_at = DateTime::from_timestamp(cert_parsed.validity().not_after.timestamp(), 0) + .ok_or_else(|| CaError::Generation("Invalid expiry date".into()))?; + + // Calculate SHA256 fingerprint + let mut hasher = Sha256::new(); + hasher.update(&cert_der); + let fingerprint = format!("sha256:{}", hex::encode(hasher.finalize())); + + Ok(CaInfo { + cert_pem: stored_ca.cert_pem, + fingerprint, + issued_at, + expires_at, + }) +} diff --git a/api/src/auth/error.rs b/api/src/auth/error.rs new file mode 100644 index 0000000..336daca --- /dev/null +++ b/api/src/auth/error.rs @@ -0,0 +1,25 @@ +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum AuthError { + #[error("Missing credentials")] + MissingCredentials, + + #[error("Invalid credentials")] + InvalidCredentials, + + #[error("Credentials expired")] + Expired, + + #[error("CSRF validation failed")] + CsrfFailed, + + #[error("Internal error: {0}")] + Internal(String), + + #[error(transparent)] + Storage(#[from] lucid_db::storage::StoreError), + + #[error(transparent)] + Other(#[from] anyhow::Error), +} diff --git a/api/src/auth/extractor.rs b/api/src/auth/extractor.rs new file mode 100644 index 0000000..a49003a --- /dev/null +++ b/api/src/auth/extractor.rs @@ -0,0 +1,64 @@ +use std::future::Future; +use std::sync::Arc; + +use axum::{extract::FromRequestParts, http::request::Parts}; +use lucid_common::caller::{Caller, CallerError}; + +use crate::{context::ApiContext, error::ApiError}; + +/// Extractor that REQUIRES authentication. +/// +/// Returns 401 Unauthorized if authentication fails. +/// Use this in handlers that need a valid authenticated caller. +/// +/// # Examples +/// +/// ```rust,ignore +/// use lucid_api::auth::extractor::Auth; +/// use lucid_common::caller::Permission; +/// +/// pub async fn delete_host( +/// Auth(caller): Auth, // ← extracts authenticated caller +/// Path(id): Path, +/// ) -> Result<(), ApiError> { +/// caller.require(Permission::HostsDelete)?; +/// // ... delete host +/// Ok(()) +/// } +/// ``` +/// +/// For optional authentication, use `Option`: +/// +/// ```rust,ignore +/// pub async fn list_hosts( +/// auth: Option, // ← works with or without auth +/// ) -> Result>, ApiError> { +/// if let Some(Auth(caller)) = auth { +/// // return full host data +/// } else { +/// // return public host data only +/// } +/// Ok(Json(hosts)) +/// } +/// ``` +pub struct Auth(pub Caller); + +impl FromRequestParts for Auth { + type Rejection = ApiError; + + fn from_request_parts( + parts: &mut Parts, + state: &ApiContext, + ) -> impl Future> + Send { + let auth_manager = Arc::clone(&state.auth_manager); + async move { + let caller = auth_manager.authenticate(parts).await.map_err(|e| { + ApiError::CallerError(CallerError::unauthorized(Some(e.to_string()))) + })?; + Ok(Auth(caller)) + } + } +} + +/// Alias for Auth - both require authentication now +pub type RequireAuth = Auth; diff --git a/api/src/auth/jwt.rs b/api/src/auth/jwt.rs new file mode 100644 index 0000000..532e30c --- /dev/null +++ b/api/src/auth/jwt.rs @@ -0,0 +1,267 @@ +//! JWT generation for activation keys. + +use jsonwebtoken::{Algorithm, EncodingKey, Header, encode}; +use serde::{Deserialize, Serialize}; +use ulid::Ulid; + +use crate::auth::signing::Signer; + +use super::signing::SigningError; + +/// Claims for activation key JWTs. +#[derive(Debug, Serialize, Deserialize)] +pub struct ActivationKeyClaims { + /// Issuer - the public URL of this Lucid instance + pub iss: String, + /// Subject - the user-provided key_id + pub sub: String, + /// Activation key internal ID for DB lookup + pub ak: Ulid, + /// Issued at timestamp + pub iat: i64, +} + +/// Generate a JWT for an activation key. +pub fn generate_activation_key_jwt( + signer: impl Signer, + pem_key: &str, + public_url: &str, + key_id: &str, + internal_id: Ulid, +) -> Result { + let claims = ActivationKeyClaims { + iss: public_url.to_string(), + sub: key_id.to_string(), + ak: internal_id, + iat: chrono::Utc::now().timestamp(), + }; + + let mut header = Header::new(Algorithm::EdDSA); + header.kid = Some(signer.key_id()); + header.jku = Some(format!("{}/.well-known/jwks.json", public_url)); + let encoding_key = EncodingKey::from_ed_pem(pem_key.as_bytes()) + .map_err(|e| SigningError::InvalidPem(e.to_string()))?; + + encode(&header, &claims, &encoding_key).map_err(|e| SigningError::SigningFailed(e.to_string())) +} + +#[cfg(test)] +mod tests { + use crate::auth::signing::Ed25519Signer; + + use super::*; + + // Test keypair from signing.rs tests + const TEST_PRIVATE_KEY_PEM: &str = r#"-----BEGIN PRIVATE KEY----- +MC4CAQAwBQYDK2VwBCIEIJ+DYvh6SEqVTm50DFtMDoQikTmiCqirVv9mWG9qfSnF +-----END PRIVATE KEY-----"#; + + #[test] + fn test_jwt_has_three_parts() { + let signer = Ed25519Signer::from_pem(TEST_PRIVATE_KEY_PEM).unwrap(); + + let jwt = generate_activation_key_jwt( + signer, + TEST_PRIVATE_KEY_PEM, + "https://lucid.example.com", + "test-key-id", + Ulid::new(), + ) + .unwrap(); + + // JWT should be in format: header.payload.signature + let parts: Vec<&str> = jwt.split('.').collect(); + assert_eq!(parts.len(), 3, "JWT should have exactly 3 parts"); + + // Each part should be non-empty + assert!(!parts[0].is_empty(), "header should not be empty"); + assert!(!parts[1].is_empty(), "payload should not be empty"); + assert!(!parts[2].is_empty(), "signature should not be empty"); + } + + #[test] + fn test_jwt_claims_are_correctly_encoded() { + use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD}; + use jsonwebtoken::{Algorithm, DecodingKey, Validation, decode}; + + let signer = Ed25519Signer::from_pem(TEST_PRIVATE_KEY_PEM).unwrap(); + let public_url = "https://lucid.example.com"; + let key_id = "test-key-id"; + let internal_id = Ulid::from_string("01KK6T3XB6B0XCJEXM0GC4V9ZT").unwrap(); + + let jwt = generate_activation_key_jwt( + signer, + TEST_PRIVATE_KEY_PEM, + public_url, + key_id, + internal_id, + ) + .unwrap(); + + // Extract and decode the payload manually to verify structure + let parts: Vec<&str> = jwt.split('.').collect(); + let payload_b64 = parts[1]; + + // Decode the payload + let payload_json = URL_SAFE_NO_PAD.decode(payload_b64).unwrap(); + let payload_str = String::from_utf8(payload_json).unwrap(); + + // Verify the claims are present in JSON + assert!( + payload_str.contains(r#""iss":"https://lucid.example.com""#), + "iss claim should match" + ); + assert!( + payload_str.contains(r#""sub":"test-key-id""#), + "sub claim should match" + ); + assert!( + payload_str.contains(r#""ak":"01KK6T3XB6B0XCJEXM0GC4V9ZT""#), + "ak claim should match" + ); + assert!( + payload_str.contains(r#""iat":"#), + "iat claim should be present" + ); + + // Now decode properly with jsonwebtoken to verify full structure + // Extract public key from the PEM + use crate::auth::signing::Ed25519Signer; + let signer = Ed25519Signer::from_pem(TEST_PRIVATE_KEY_PEM).unwrap(); + let public_key_bytes = signer.public_key_bytes(); + + let decoding_key = DecodingKey::from_ed_der(public_key_bytes); + let mut validation = Validation::new(Algorithm::EdDSA); + validation.validate_exp = false; // No expiration in our tokens + validation.required_spec_claims.clear(); // Don't require exp claim + validation.set_issuer(&[public_url]); + + let decoded = decode::(&jwt, &decoding_key, &validation).unwrap(); + + assert_eq!(decoded.claims.iss, public_url); + assert_eq!(decoded.claims.sub, key_id); + assert_eq!(decoded.claims.ak, internal_id); + assert!(decoded.claims.iat > 0, "iat should be a valid timestamp"); + } + + #[test] + fn test_jwt_invalid_pem_returns_error() { + let signer = Ed25519Signer::from_pem(TEST_PRIVATE_KEY_PEM).unwrap(); + + let result = generate_activation_key_jwt( + signer, + "not a valid pem", + "https://lucid.example.com", + "test-key", + Ulid::new(), + ); + + assert!(result.is_err(), "should reject invalid PEM"); + match result { + Err(SigningError::InvalidPem(_)) => {} // expected + _ => panic!("expected InvalidPem error"), + } + } + + #[test] + fn test_jwt_different_keys_produce_different_signatures() { + // Second test key + const TEST_PRIVATE_KEY_PEM_2: &str = r#"-----BEGIN PRIVATE KEY----- +MC4CAQAwBQYDK2VwBCIEIBcUIT7KhLMKX9R1oJf+dFUDux98dVbI5mB3HuhMglFF +-----END PRIVATE KEY-----"#; + + let signer = Ed25519Signer::from_pem(TEST_PRIVATE_KEY_PEM).unwrap(); + let signer_2 = Ed25519Signer::from_pem(TEST_PRIVATE_KEY_PEM_2).unwrap(); + + let id = Ulid::new(); + + let jwt1 = generate_activation_key_jwt( + signer, + TEST_PRIVATE_KEY_PEM, + "https://lucid.example.com", + "same-key-id", + id.clone(), + ) + .unwrap(); + + let jwt2 = generate_activation_key_jwt( + signer_2, + TEST_PRIVATE_KEY_PEM_2, + "https://lucid.example.com", + "same-key-id", + id.clone(), + ) + .unwrap(); + + // Headers and payloads might be the same, but signatures MUST differ + assert_ne!(jwt1, jwt2, "different keys should produce different JWTs"); + + let parts1: Vec<&str> = jwt1.split('.').collect(); + let parts2: Vec<&str> = jwt2.split('.').collect(); + + // Signatures (third part) must be different + assert_ne!(parts1[2], parts2[2], "signatures must differ"); + } + + #[test] + fn test_jwt_same_inputs_produce_different_tokens_due_to_timestamp() { + use std::{thread, time::Duration}; + + let signer = Ed25519Signer::from_pem(TEST_PRIVATE_KEY_PEM).unwrap(); + + let id = Ulid::new(); + let jwt1 = generate_activation_key_jwt( + signer.clone(), + TEST_PRIVATE_KEY_PEM, + "https://lucid.example.com", + "test-key", + id.clone(), + ) + .unwrap(); + + // Sleep to ensure different timestamp + thread::sleep(Duration::from_millis(1001)); + + let jwt2 = generate_activation_key_jwt( + signer, + TEST_PRIVATE_KEY_PEM, + "https://lucid.example.com", + "test-key", + id, + ) + .unwrap(); + + // Should be different due to different iat timestamps + assert_ne!( + jwt1, jwt2, + "JWTs generated at different times should differ" + ); + } + + #[test] + fn test_jwt_header_algorithm_is_eddsa() { + use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD}; + + let signer = Ed25519Signer::from_pem(TEST_PRIVATE_KEY_PEM).unwrap(); + + let jwt = generate_activation_key_jwt( + signer, + TEST_PRIVATE_KEY_PEM, + "https://lucid.example.com", + "test-key", + Ulid::new(), + ) + .unwrap(); + + let parts: Vec<&str> = jwt.split('.').collect(); + let header_b64 = parts[0]; + let header_json = URL_SAFE_NO_PAD.decode(header_b64).unwrap(); + let header_str = String::from_utf8(header_json).unwrap(); + + assert!( + header_str.contains(r#""alg":"EdDSA""#), + "algorithm should be EdDSA" + ); + assert!(header_str.contains(r#""typ":"JWT""#), "type should be JWT"); + } +} diff --git a/api/src/auth/manager.rs b/api/src/auth/manager.rs new file mode 100644 index 0000000..a416642 --- /dev/null +++ b/api/src/auth/manager.rs @@ -0,0 +1,76 @@ +use axum::http::request::Parts; +use lucid_common::caller::Caller; +use tracing::{debug, instrument, trace}; + +use super::{error::AuthError, provider::AuthProvider}; + +/// Coordinates multiple authentication providers in priority order. +/// +/// The `AuthManager` tries each registered provider until one successfully +/// authenticates the request. Providers are tried in registration order. +/// +/// # Flow +/// +/// 1. Request comes in with some credentials (cookie, API key, etc.) +/// 2. AuthManager asks each provider if it can authenticate +/// 3. If provider returns `MissingCredentials`, try next provider +/// 4. If provider returns success, return the `Caller` +/// 5. If provider returns other error, fail immediately (stop trying) +/// +/// # Examples +/// +/// ```rust,ignore +/// let auth_manager = AuthManager::new() +/// .with_provider(SessionProvider::new(db.clone(), signing_key)) +/// .with_provider(ApiKeyProvider::new(db.clone())); +/// +/// // In extractor: +/// let caller = auth_manager.authenticate(&request_parts).await?; +/// ``` +pub struct AuthManager { + providers: Vec>, +} + +impl AuthManager { + pub fn new() -> Self { + Self { + providers: Vec::new(), + } + } + + pub fn with_provider(mut self, provider: P) -> Self { + self.providers.push(Box::new(provider)); + self + } + + /// Try each provider in order until one succeeds + #[instrument(skip(self))] + pub async fn authenticate(&self, parts: &Parts) -> Result { + for provider in &self.providers { + trace!(scheme = provider.scheme(), "Trying auth provider"); + + match provider.authenticate(parts).await { + Ok(caller) => { + debug!(scheme = provider.scheme(), "Auth succeeded"); + return Ok(caller); + } + Err(AuthError::MissingCredentials) => { + trace!(scheme = provider.scheme(), "No credentials for this scheme"); + continue; + } + Err(e) => { + debug!(scheme = provider.scheme(), error = %e, "Auth failed"); + return Err(e); + } + } + } + + Err(AuthError::MissingCredentials) + } +} + +impl Default for AuthManager { + fn default() -> Self { + Self::new() + } +} diff --git a/api/src/auth/mod.rs b/api/src/auth/mod.rs new file mode 100644 index 0000000..693389a --- /dev/null +++ b/api/src/auth/mod.rs @@ -0,0 +1,20 @@ +pub mod ca; +pub mod encrypted_ca; +pub mod error; +pub mod extractor; +pub mod jwt; +pub mod manager; +pub mod provider; +pub mod providers; +pub mod signing; + +pub use ca::{CaError, CaInfo, CertificateAuthority, SignedCertificate}; +pub use encrypted_ca::EncryptedCa; +pub use error::AuthError; +pub use extractor::{Auth, RequireAuth}; +pub use manager::AuthManager; +pub use provider::AuthProvider; +pub use providers::activation_key::ActivationKeyAuthProvider; +pub use providers::mtls::MtlsAuthProvider; +pub use providers::session::SessionAuthProvider; +pub use signing::SessionSigner; diff --git a/api/src/auth/provider.rs b/api/src/auth/provider.rs new file mode 100644 index 0000000..c12b68a --- /dev/null +++ b/api/src/auth/provider.rs @@ -0,0 +1,78 @@ +use async_trait::async_trait; +use axum::http::request::Parts; +use lucid_common::caller::Caller; + +use super::error::AuthError; + +/// Authentication provider that can extract and verify credentials. +/// +/// Implement this trait to add new authentication methods to Lucid. +/// Providers are registered with `AuthManager` and tried in order. +/// +/// # Implementing a Provider +/// +/// Your provider should: +/// +/// 1. Check if the request contains credentials for this auth scheme +/// 2. If not, return `Err(AuthError::MissingCredentials)` so other providers can try +/// 3. If yes, verify the credentials +/// 4. On success, return `Ok(Caller)` +/// 5. On failure, return an appropriate `AuthError` +/// +/// # Examples +/// +/// ```rust,ignore +/// use async_trait::async_trait; +/// use axum::http::request::Parts; +/// use lucid_common::caller::{Caller, Role}; +/// use crate::auth::{AuthProvider, AuthError}; +/// +/// pub struct ApiKeyProvider { +/// db: Database, +/// } +/// +/// #[async_trait] +/// impl AuthProvider for ApiKeyProvider { +/// fn scheme(&self) -> &'static str { +/// "api-key" +/// } +/// +/// async fn authenticate(&self, parts: &Parts) -> Result { +/// // 1. Check for API key in Authorization header +/// let api_key = parts +/// .headers +/// .get("authorization") +/// .and_then(|v| v.to_str().ok()) +/// .and_then(|v| v.strip_prefix("Bearer ")) +/// .ok_or(AuthError::MissingCredentials)?; // ← no API key, try next provider +/// +/// // 2. Look up service account +/// let service_account = self.db +/// .find_service_account_by_key(api_key) +/// .await +/// .map_err(|_| AuthError::InvalidCredentials)?; // ← bad key, fail +/// +/// // 3. Return caller +/// Ok(Caller::ServiceAccount { +/// id: service_account.id, +/// name: service_account.name, +/// description: service_account.description, +/// roles: service_account.roles, +/// }) +/// } +/// } +/// ``` +#[async_trait] +pub trait AuthProvider: Send + Sync { + /// Attempt to authenticate from request parts. + /// + /// Return `Err(AuthError::MissingCredentials)` if this request doesn't + /// contain credentials for your scheme. Return other errors if credentials + /// are present but invalid. + async fn authenticate(&self, parts: &Parts) -> Result; + + /// Name of this auth scheme for debugging/logging. + /// + /// Examples: "session", "api-key", "mtls" + fn scheme(&self) -> &'static str; +} diff --git a/api/src/auth/providers/activation_key.rs b/api/src/auth/providers/activation_key.rs new file mode 100644 index 0000000..5e50ed6 --- /dev/null +++ b/api/src/auth/providers/activation_key.rs @@ -0,0 +1,170 @@ +//! Activation key JWT authentication provider. +//! +//! This provider validates Bearer tokens that are activation key JWTs, issued +//! when an activation key is created. These JWTs are single-use tokens for +//! agent registration. + +use std::sync::Arc; + +use async_trait::async_trait; +use axum::http::{header, request::Parts}; +use jsonwebtoken::{Algorithm, DecodingKey, Validation, decode}; +use lucid_common::caller::Caller; +use lucid_db::storage::{ActivationKeyStore, Storage}; +use tracing::{debug, instrument}; + +use crate::auth::{ + error::AuthError, + jwt::ActivationKeyClaims, + provider::AuthProvider, + signing::{Ed25519Signer, SessionSigner}, +}; + +/// Authentication provider for activation key JWTs. +/// +/// Validates Bearer tokens that contain activation key JWTs and returns +/// a System caller for registration. The activation key ID is stored in +/// request extensions for the handler to consume. +pub struct ActivationKeyAuthProvider { + db: Arc, + public_url: String, + session_signer: SessionSigner, +} + +impl ActivationKeyAuthProvider { + pub fn new( + db: Arc, + public_url: String, + session_signer: SessionSigner, + ) -> Self { + Self { + db, + public_url, + session_signer, + } + } + + /// Extract Bearer token from Authorization header + fn extract_bearer_token(headers: &header::HeaderMap) -> Option { + headers + .get(header::AUTHORIZATION)? + .to_str() + .ok()? + .strip_prefix("Bearer ") + .map(|s| s.to_string()) + } +} + +#[async_trait] +impl AuthProvider for ActivationKeyAuthProvider { + #[instrument(skip(self, parts), fields(scheme = "activation-key"))] + async fn authenticate(&self, parts: &Parts) -> Result { + // 1. Extract Bearer token + let token = + Self::extract_bearer_token(&parts.headers).ok_or(AuthError::MissingCredentials)?; + + debug!("Found Bearer token, decoding JWT..."); + + // 2. Decode and verify JWT + let public_key_bytes = self.session_signer.inner().public_key_bytes(); + let decoding_key = DecodingKey::from_ed_der(public_key_bytes); + let mut validation = Validation::new(Algorithm::EdDSA); + validation.validate_exp = false; // No expiration in activation key tokens + validation.required_spec_claims.clear(); + validation.set_issuer(&[&self.public_url]); + + let token_data = decode::(&token, &decoding_key, &validation) + .map_err(|e| { + debug!("JWT decode failed: {}", e); + AuthError::InvalidCredentials + })?; + + let claims = token_data.claims; + debug!(ak = %claims.ak, "JWT decoded successfully"); + + // 3. Look up activation key in DB + let activation_key = + ActivationKeyStore::get_by_internal_id(&*self.db, &claims.ak.to_string()) + .await? + .ok_or_else(|| { + debug!("Activation key not found"); + AuthError::InvalidCredentials + })?; + + debug!(key_id = %activation_key.key_id, "Found activation key"); + + // 4. Check if already used + if activation_key.used_by_agent_id.is_some() { + debug!("Activation key already used"); + return Err(AuthError::InvalidCredentials); + } + + debug!("Activation key valid and unused"); + + // 5. Store activation key ID in extensions for handler to retrieve + // We can't modify parts here, so we'll return System caller + // The handler will need to re-decode the JWT to get the activation key ID + // (This is a limitation of the current auth architecture) + Ok(Caller::System) + } + + fn scheme(&self) -> &'static str { + "activation-key" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use axum::http::header::HeaderMap; + + #[test] + fn test_extract_bearer_token_valid() { + let mut headers = HeaderMap::new(); + headers.insert( + header::AUTHORIZATION, + "Bearer test_token_123".parse().unwrap(), + ); + + let result = ActivationKeyAuthProvider::extract_bearer_token(&headers); + assert_eq!(result, Some("test_token_123".to_string())); + } + + #[test] + fn test_extract_bearer_token_missing_header() { + let headers = HeaderMap::new(); + let result = ActivationKeyAuthProvider::extract_bearer_token(&headers); + assert_eq!(result, None); + } + + #[test] + fn test_extract_bearer_token_wrong_scheme() { + let mut headers = HeaderMap::new(); + headers.insert(header::AUTHORIZATION, "Basic dXNlcjpwYXNz".parse().unwrap()); + + let result = ActivationKeyAuthProvider::extract_bearer_token(&headers); + assert_eq!(result, None); + } + + #[test] + fn test_extract_bearer_token_no_token() { + let mut headers = HeaderMap::new(); + headers.insert(header::AUTHORIZATION, "Bearer ".parse().unwrap()); + + let result = ActivationKeyAuthProvider::extract_bearer_token(&headers); + assert_eq!(result, Some("".to_string())); + } + + #[test] + fn test_extract_bearer_token_with_spaces() { + let mut headers = HeaderMap::new(); + headers.insert( + header::AUTHORIZATION, + "Bearer token_with_leading_spaces".parse().unwrap(), + ); + + // strip_prefix only removes "Bearer ", so extra spaces remain + let result = ActivationKeyAuthProvider::extract_bearer_token(&headers); + assert_eq!(result, Some(" token_with_leading_spaces".to_string())); + } +} diff --git a/api/src/auth/providers/mod.rs b/api/src/auth/providers/mod.rs new file mode 100644 index 0000000..cccdacf --- /dev/null +++ b/api/src/auth/providers/mod.rs @@ -0,0 +1,3 @@ +pub mod activation_key; +pub mod mtls; +pub mod session; diff --git a/api/src/auth/providers/mtls.rs b/api/src/auth/providers/mtls.rs new file mode 100644 index 0000000..81171b2 --- /dev/null +++ b/api/src/auth/providers/mtls.rs @@ -0,0 +1,165 @@ +//! mTLS client certificate authentication provider. +//! +//! Authenticates agents via client certificates presented during TLS handshake. + +use std::{str::FromStr, sync::Arc}; + +use async_trait::async_trait; +use axum::http::request::Parts; +use chrono::{Duration, Utc}; +use lucid_common::caller::{Caller, Role}; +use lucid_db::storage::{AgentStore, Storage}; +use tracing::{debug, instrument, warn}; +use ulid::Ulid; +use x509_parser::prelude::*; + +use crate::auth::{error::AuthError, provider::AuthProvider}; + +/// Authentication provider for mTLS client certificates. +/// +/// Validates agent client certificates and extracts agent identity from the CN. +pub struct MtlsAuthProvider { + db: Arc, +} + +impl MtlsAuthProvider { + pub fn new(db: Arc) -> Self { + Self { db } + } +} + +#[async_trait] +impl AuthProvider for MtlsAuthProvider { + fn scheme(&self) -> &'static str { + "mtls" + } + + #[instrument(skip(self, parts), fields(scheme = "mtls"))] + async fn authenticate(&self, parts: &Parts) -> Result { + // 1. Extract client certificate from request extensions + // The certificate is inserted by rustls/axum-server as Vec + let certs: &Vec = parts + .extensions + .get() + .ok_or(AuthError::MissingCredentials)?; + + if certs.is_empty() { + return Err(AuthError::MissingCredentials); + } + + let cert_der = &certs[0]; + debug!("Client certificate found, parsing..."); + + // 2. Parse the certificate + let (_, cert) = X509Certificate::from_der(cert_der.as_ref()).map_err(|e| { + warn!("Failed to parse client certificate: {}", e); + AuthError::InvalidCredentials + })?; + + // 3. Check validity period with 5-minute clock skew grace + let now = Utc::now(); + let grace = Duration::minutes(5); + + // Convert time::OffsetDateTime to chrono::DateTime + let not_before_unix = cert.validity().not_before.to_datetime().unix_timestamp(); + let not_after_unix = cert.validity().not_after.to_datetime().unix_timestamp(); + + let not_before = chrono::DateTime::from_timestamp(not_before_unix, 0).ok_or_else(|| { + warn!("Invalid not_before timestamp"); + AuthError::InvalidCredentials + })?; + let not_after = chrono::DateTime::from_timestamp(not_after_unix, 0).ok_or_else(|| { + warn!("Invalid not_after timestamp"); + AuthError::InvalidCredentials + })?; + + if now + grace < not_before { + warn!("Certificate not yet valid"); + return Err(AuthError::InvalidCredentials); + } + if now - grace > not_after { + warn!("Certificate expired"); + return Err(AuthError::Expired); + } + + // 4. Extract agent ID from CN + let cn = cert + .subject() + .iter_common_name() + .next() + .and_then(|attr| attr.as_str().ok()) + .ok_or_else(|| { + warn!("Certificate missing CN"); + AuthError::InvalidCredentials + })?; + + debug!(cn = %cn, "Extracted CN from certificate"); + + // 5. Parse CN as ObjectId (agent ID) + let agent_id = Ulid::from_str(cn).map_err(|e| { + warn!("Invalid agent ID in CN: {}", e); + AuthError::InvalidCredentials + })?; + + // 6. Look up agent in database + let agent = AgentStore::get(&*self.db, agent_id.into()) + .await + .map_err(|e| { + warn!("Database error looking up agent: {}", e); + AuthError::Internal(e.to_string()) + })? + .ok_or_else(|| { + warn!("Agent not found: {}", agent_id); + AuthError::InvalidCredentials + })?; + + // 7. Check not revoked + if agent.revoked_at.is_some() { + warn!(agent_id = %agent_id, "Agent is revoked"); + return Err(AuthError::InvalidCredentials); + } + + // 8. Verify certificate matches stored certificate + // Convert presented cert to PEM for comparison + let presented_pem = pem_rfc7468::encode_string( + "CERTIFICATE", + pem_rfc7468::LineEnding::LF, + cert_der.as_ref(), + ) + .map_err(|e| { + warn!("Failed to encode certificate as PEM: {}", e); + AuthError::Internal(e.to_string()) + })?; + + // Normalize whitespace for comparison + let stored_normalized: String = agent + .certificate_pem + .chars() + .filter(|c| !c.is_whitespace()) + .collect(); + let presented_normalized: String = presented_pem + .chars() + .filter(|c| !c.is_whitespace()) + .collect(); + + if stored_normalized != presented_normalized { + warn!(agent_id = %agent_id, "Certificate mismatch"); + return Err(AuthError::InvalidCredentials); + } + + // 9. Update last_seen_at + if let Err(e) = AgentStore::update_last_seen(&*self.db, agent.id).await { + warn!("Failed to update last_seen_at: {}", e); + // Don't fail auth for this + } + + debug!(agent_id = %agent_id, agent_name = %agent.name, "Agent authenticated successfully"); + + // 10. Return Caller::Agent + Ok(Caller::Agent { + id: agent_id.to_string(), + name: agent.name, + roles: vec![Role::Agent], + }) + } +} diff --git a/api/src/auth/providers/session.rs b/api/src/auth/providers/session.rs new file mode 100644 index 0000000..6e9743e --- /dev/null +++ b/api/src/auth/providers/session.rs @@ -0,0 +1,294 @@ +//! Session-based authentication provider. +//! +//! This module implements authentication via signed session cookies. It's designed for +//! web console users and provides CSRF protection for state-changing requests. +//! +//! # Cookie-Based Sessions +//! +//! Session tokens are stored in HttpOnly cookies named `lucid_session`. The cookie value +//! is a signed token in the format: `{session_id}.{ed25519_signature}` +//! +//! See `docs/API_SESSIONS.adoc` for complete API documentation. +//! +//! # CSRF Protection +//! +//! For mutating requests (POST, PUT, PATCH, DELETE), the provider requires a CSRF token +//! in the `X-CSRF-Token` header. This token is returned by the login endpoint and must +//! be stored by the client. +//! +//! Read-only requests (GET, HEAD, OPTIONS) do not require the CSRF token. +//! +//! # Authentication Flow +//! +//! 1. Extract `lucid_session` cookie from request +//! 2. Verify Ed25519 signature on cookie value +//! 3. Extract session ID from signed token +//! 4. Fetch session from database, check expiry +//! 5. For mutating requests: validate CSRF token against session's stored token +//! 6. Fetch user from database +//! 7. Update session's `last_used_at` timestamp (sliding expiry) +//! 8. Return authenticated `Caller::User` +//! +//! # Example +//! +//! ```no_run +//! use std::sync::Arc; +//! use lucid_api::auth::{SessionSigner, signing::Ed25519Signer}; +//! use lucid_api::auth::providers::session::SessionAuthProvider; +//! use lucid_db::storage::Storage; +//! +//! # async fn example(db: Arc) -> Result<(), Box> { +//! // Load signing key +//! let pem = std::fs::read_to_string("signing_key.pem")?; +//! let ed25519 = Ed25519Signer::from_pem(&pem)?; +//! let signer = SessionSigner::new(ed25519); +//! +//! // Create provider +//! let provider = SessionAuthProvider::new(signer, db); +//! +//! // Provider is used by AuthManager to authenticate requests +//! # Ok(()) +//! # } +//! ``` + +use std::sync::Arc; + +use async_trait::async_trait; +use axum::http::{Method, header, request::Parts}; +use lucid_common::caller::Caller; +use lucid_db::storage::{SessionStore, Storage, UserStore}; +use tracing::{info, instrument}; + +use crate::auth::{ + error::AuthError, + provider::AuthProvider, + signing::{SessionSigner, Signer}, +}; + +const COOKIE_NAME: &str = "lucid_session"; +const CSRF_HEADER: &str = "X-CSRF-Token"; + +/// Authentication provider for session-based auth. +/// +/// Authenticates users via signed session cookies with optional CSRF protection. +/// +/// # Cookie Security +/// +/// - HttpOnly: JavaScript cannot access the cookie (XSS protection) +/// - SameSite=Lax: Cookie not sent on cross-site POST (baseline CSRF protection) +/// - Secure: Cookie only sent over HTTPS in production +/// - Max-Age: 30 days (sliding expiry via `touch_session`) +/// +/// # CSRF Protection +/// +/// Mutating requests (POST/PUT/PATCH/DELETE) require the `X-CSRF-Token` header. +/// The CSRF token is returned by the login endpoint and stored in the session. +/// +/// Read-only requests (GET/HEAD/OPTIONS) do not require CSRF validation. +pub struct SessionAuthProvider { + signer: SessionSigner, + db: Arc, +} + +impl SessionAuthProvider { + pub fn new(signer: SessionSigner, db: Arc) -> Self { + Self { signer, db } + } + + /// Sign a session ID: returns "session_id.signature" + pub fn sign(&self, session_id: &str) -> Result { + self.signer.sign(session_id) + } + + /// Verify a signed session ID, returns the session_id if valid + pub fn verify(&self, signed: &str) -> Option { + self.signer.verify(signed) + } + + /// Extract cookie value from Cookie header + fn extract_cookie(headers: &header::HeaderMap, name: &str) -> Option { + headers + .get(header::COOKIE)? + .to_str() + .ok()? + .split(';') + .map(|s| s.trim()) + .find(|s| s.starts_with(&format!("{}=", name)))? + .strip_prefix(&format!("{}=", name)) + .map(|s| s.to_string()) + } + + /// Check if this method requires CSRF validation + fn requires_csrf(method: &Method) -> bool { + !matches!(method, &Method::GET | &Method::HEAD | &Method::OPTIONS) + } +} + +#[async_trait] +impl AuthProvider for SessionAuthProvider { + #[instrument(skip(self, parts), fields(scheme = "session"))] + async fn authenticate(&self, parts: &Parts) -> Result { + // 1. Extract session cookie + let signed_cookie = Self::extract_cookie(&parts.headers, COOKIE_NAME) + .ok_or(AuthError::MissingCredentials)?; + + info!(?signed_cookie, "Found session cookie, verifying..."); + + // 2. Verify signature + let session_id = self + .verify(&signed_cookie) + .ok_or(AuthError::InvalidCredentials)?; + + info!(?session_id, "Found session ID, loading from DB..."); + + // 3. Fetch session from DB + let session = SessionStore::get_session(&*self.db, &session_id) + .await? + .ok_or(AuthError::InvalidCredentials)?; + + info!(?session, "Found session, checking expiry..."); + + // 4. Check expiry + if session.expires_at < chrono::Utc::now() { + return Err(AuthError::Expired); + } + + info!("Session valid, checking CSRF..."); + + // 5. Validate CSRF for mutating requests + if Self::requires_csrf(&parts.method) { + let csrf_token = parts + .headers + .get(CSRF_HEADER) + .and_then(|v| v.to_str().ok()) + .ok_or(AuthError::CsrfFailed)?; + + if csrf_token != session.csrf_token { + return Err(AuthError::CsrfFailed); + } + } + + info!(?session.user_id, "CSRF check passed, loading user..."); + + // 6. Fetch user + let user = UserStore::get(&*self.db, Caller::System, session.user_id) + .await? + .ok_or(AuthError::InvalidCredentials)?; + + info!(user_id = ?user.id, "User authenticated successfully"); + + // 7. Touch session (update last_used_at) - fire and forget + let _ = SessionStore::touch_session(&*self.db, &session_id).await; + + // 8. Return authenticated caller + Ok(user.to_caller()) + } + + fn scheme(&self) -> &'static str { + "session" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::auth::signing::Ed25519Signer; + + // Helper type for tests + type TestSessionAuthProvider = SessionAuthProvider; + + #[test] + fn test_extract_cookie_basic() { + let mut headers = header::HeaderMap::new(); + headers.insert( + header::COOKIE, + "foo=bar; lucid_session=abc123; other=value" + .parse() + .unwrap(), + ); + + let result = TestSessionAuthProvider::extract_cookie(&headers, "lucid_session"); + assert_eq!(result, Some("abc123".to_string())); + + let result = TestSessionAuthProvider::extract_cookie(&headers, "foo"); + assert_eq!(result, Some("bar".to_string())); + + let result = TestSessionAuthProvider::extract_cookie(&headers, "other"); + assert_eq!(result, Some("value".to_string())); + } + + #[test] + fn test_extract_cookie_not_found() { + let mut headers = header::HeaderMap::new(); + headers.insert(header::COOKIE, "foo=bar".parse().unwrap()); + + let result = TestSessionAuthProvider::extract_cookie(&headers, "nonexistent"); + assert_eq!(result, None); + } + + #[test] + fn test_extract_cookie_no_cookie_header() { + let headers = header::HeaderMap::new(); + let result = TestSessionAuthProvider::extract_cookie(&headers, "anything"); + assert_eq!(result, None); + } + + #[test] + fn test_extract_cookie_with_spaces() { + let mut headers = header::HeaderMap::new(); + headers.insert( + header::COOKIE, + "cookie1=value1; cookie2=value2 ;cookie3=value3" + .parse() + .unwrap(), + ); + + let result = TestSessionAuthProvider::extract_cookie(&headers, "cookie2"); + assert_eq!(result, Some("value2".to_string())); + } + + #[test] + fn test_extract_cookie_empty_value() { + let mut headers = header::HeaderMap::new(); + headers.insert(header::COOKIE, "empty=; other=value".parse().unwrap()); + + let result = TestSessionAuthProvider::extract_cookie(&headers, "empty"); + assert_eq!(result, Some("".to_string())); + } + + #[test] + fn test_extract_cookie_similar_names() { + let mut headers = header::HeaderMap::new(); + headers.insert( + header::COOKIE, + "session=old; lucid_session=new".parse().unwrap(), + ); + + let result = TestSessionAuthProvider::extract_cookie(&headers, "session"); + assert_eq!(result, Some("old".to_string())); + + let result = TestSessionAuthProvider::extract_cookie(&headers, "lucid_session"); + assert_eq!(result, Some("new".to_string())); + } + + #[test] + fn test_requires_csrf_safe_methods() { + assert!(!TestSessionAuthProvider::requires_csrf(&Method::GET)); + assert!(!TestSessionAuthProvider::requires_csrf(&Method::HEAD)); + assert!(!TestSessionAuthProvider::requires_csrf(&Method::OPTIONS)); + } + + #[test] + fn test_requires_csrf_mutating_methods() { + assert!(TestSessionAuthProvider::requires_csrf(&Method::POST)); + assert!(TestSessionAuthProvider::requires_csrf(&Method::PUT)); + assert!(TestSessionAuthProvider::requires_csrf(&Method::PATCH)); + assert!(TestSessionAuthProvider::requires_csrf(&Method::DELETE)); + } + + #[test] + fn test_requires_csrf_trace_method() { + // TRACE should also require CSRF + assert!(TestSessionAuthProvider::requires_csrf(&Method::TRACE)); + } +} diff --git a/api/src/auth/signing.rs b/api/src/auth/signing.rs new file mode 100644 index 0000000..6a9fc95 --- /dev/null +++ b/api/src/auth/signing.rs @@ -0,0 +1,511 @@ +//! Cryptographic signing and verification for session tokens and future JWT support. +//! +//! This module provides a generic [`Signer`] trait and concrete implementations for +//! creating and verifying digital signatures. The primary use case is session token +//! authentication, but the design supports future JWT signing needs. +//! +//! # Architecture +//! +//! - [`Signer`]: Generic trait for any signing implementation +//! - [`Ed25519Signer`]: Ed25519 implementation using PEM-formatted PKCS#8 keys +//! - [`SessionSigner`]: Wrapper that applies session-specific token formatting +//! +//! # Example +//! +//! ```no_run +//! use lucid_api::auth::signing::{Ed25519Signer, SessionSigner, Signer}; +//! +//! # fn example() -> Result<(), Box> { +//! // Load key from PEM +//! let pem = std::fs::read_to_string("signing_key.pem")?; +//! let ed25519 = Ed25519Signer::from_pem(&pem)?; +//! +//! // Wrap for session token format +//! let session_signer = SessionSigner::new(ed25519); +//! +//! // Sign a session ID +//! let token = session_signer.sign("user_session_123")?; +//! // Returns: "user_session_123.{base64_signature}" +//! +//! // Verify and extract session ID +//! if let Some(session_id) = session_signer.verify(&token) { +//! println!("Valid session: {}", session_id); +//! } +//! # Ok(()) +//! # } +//! ``` + +use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD}; +use ed25519_dalek::{ + SECRET_KEY_LENGTH, Signature, Signer as DalekSigner, SigningKey, Verifier, VerifyingKey, +}; +use thiserror::Error; + +/// Generic signing trait for any payload. +/// +/// This trait abstracts over different signing algorithms, allowing the same +/// session/JWT logic to work with different cryptographic backends. +/// +/// Implementations must be thread-safe (`Send + Sync`) for use in async contexts. +pub trait Signer: Send + Sync { + /// Sign a payload, returning the raw signature bytes. + /// + /// # Errors + /// + /// Returns [`SigningError`] if signing fails (e.g., invalid key state). + fn sign(&self, payload: &[u8]) -> Result, SigningError>; + + /// Verify a payload against a signature. + /// + /// Returns `true` if the signature is valid for the given payload. + /// Returns `false` for any verification failure (invalid signature, wrong key, etc.). + fn verify(&self, payload: &[u8], signature: &[u8]) -> bool; + + /// Return a unique identifier for the signing key, if available. + fn key_id(&self) -> String; +} + +/// Errors that can occur during signing operations. +#[derive(Debug, Error)] +pub enum SigningError { + /// PEM parsing or key format errors. + #[error("invalid PEM format: {0}")] + InvalidPem(String), + + /// Signing operation failures. + #[error("signing failed: {0}")] + SigningFailed(String), +} + +/// Ed25519 digital signature implementation of [`Signer`]. +/// +/// Uses the Ed25519 signature scheme with SHA-512 hashing. Keys must be provided +/// in PEM-encoded PKCS#8 format. +/// +/// # Key Format +/// +/// Expected PEM structure: +/// ```text +/// -----BEGIN PRIVATE KEY----- +/// MC4CAQAwBQYDK2VwBCIEI... +/// -----END PRIVATE KEY----- +/// ``` +/// +/// Generate a compatible key using OpenSSL: +/// ```bash +/// openssl genpkey -algorithm ED25519 -out signing_key.pem +/// ``` +/// +/// # Example +/// +/// ```no_run +/// use lucid_api::auth::signing::Ed25519Signer; +/// +/// # fn example() -> Result<(), Box> { +/// let pem = std::fs::read_to_string("signing_key.pem")?; +/// let signer = Ed25519Signer::from_pem(&pem)?; +/// # Ok(()) +/// # } +/// ``` +#[derive(Clone)] +pub struct Ed25519Signer { + signing_key: SigningKey, + verifying_key: VerifyingKey, +} + +impl Ed25519Signer { + /// Create a new Ed25519 signer from PEM-formatted PKCS#8 private key data. + /// + /// # Errors + /// + /// Returns [`SigningError::InvalidPem`] if: + /// - PEM format is invalid + /// - PKCS#8 structure is malformed + /// - Key is not exactly 32 bytes (Ed25519 requirement) + /// - PEM label is not "PRIVATE KEY" + pub fn from_pem(pem_data: &str) -> Result { + // Parse PEM using pem-rfc7468 + use pkcs8::der::Decode; + let (label, der_bytes) = pem_rfc7468::decode_vec(pem_data.as_bytes()) + .map_err(|e| SigningError::InvalidPem(format!("PEM decode failed: {}", e)))?; + + if label != "PRIVATE KEY" { + return Err(SigningError::InvalidPem(format!( + "expected PRIVATE KEY label, got {}", + label + ))); + } + + // Extract the raw secret key bytes from PKCS#8 + let private_key_info = pkcs8::PrivateKeyInfo::from_der(&der_bytes) + .map_err(|e| SigningError::InvalidPem(format!("invalid PKCS#8 structure: {}", e)))?; + + // The private key is wrapped in an OCTET STRING, decode it + let secret_octet_string: &[u8] = + pkcs8::der::asn1::OctetStringRef::from_der(private_key_info.private_key) + .map_err(|e| SigningError::InvalidPem(format!("invalid octet string: {}", e)))? + .as_bytes(); + + if secret_octet_string.len() != SECRET_KEY_LENGTH { + return Err(SigningError::InvalidPem(format!( + "expected {} byte key, got {}", + SECRET_KEY_LENGTH, + secret_octet_string.len() + ))); + } + + let mut key_array = [0u8; SECRET_KEY_LENGTH]; + key_array.copy_from_slice(secret_octet_string); + + let signing_key = SigningKey::from_bytes(&key_array); + let verifying_key = signing_key.verifying_key(); + + Ok(Self { + signing_key, + verifying_key, + }) + } + + /// Get the public verifying key. + /// + /// Useful for debugging, logging, or distributing the public key for + /// external verification. + pub fn public_key(&self) -> &VerifyingKey { + &self.verifying_key + } + + /// Get the raw 32-byte public key material. + /// + /// Used for constructing JWK representations of this key. + pub fn public_key_bytes(&self) -> &[u8; 32] { + self.verifying_key.as_bytes() + } +} + +impl Signer for Ed25519Signer { + fn sign(&self, payload: &[u8]) -> Result, SigningError> { + let signature = self.signing_key.sign(payload); + Ok(signature.to_bytes().to_vec()) + } + + fn verify(&self, payload: &[u8], signature: &[u8]) -> bool { + let Ok(sig) = Signature::from_slice(signature) else { + return false; + }; + self.verifying_key.verify(payload, &sig).is_ok() + } + + fn key_id(&self) -> String { + let kid = &self.public_key_bytes()[..8]; + URL_SAFE_NO_PAD.encode(kid) + } +} + +/// Session token signing wrapper. +/// +/// Wraps any [`Signer`] implementation to produce and verify session tokens in the +/// format: `{session_id}.{base64_signature}`. +/// +/// The signature covers only the session ID portion. The base64 encoding uses +/// URL-safe characters without padding for compatibility with HTTP headers. +/// +/// # Token Format +/// +/// ```text +/// session_abc123.dGVzdHNpZ25hdHVyZQ +/// ^ ^ +/// session ID base64(signature) +/// ``` +/// +/// The session ID and signature are separated by a single dot (`.`). The signature +/// is base64-encoded using URL-safe characters (no padding). +/// +/// # Example +/// +/// ```no_run +/// use lucid_api::auth::signing::{Ed25519Signer, SessionSigner}; +/// +/// # fn example() -> Result<(), Box> { +/// let pem = std::fs::read_to_string("signing_key.pem")?; +/// let ed25519 = Ed25519Signer::from_pem(&pem)?; +/// let session_signer = SessionSigner::new(ed25519); +/// +/// // Create signed token +/// let token = session_signer.sign("user_session_123")?; +/// +/// // Verify token and extract session ID +/// match session_signer.verify(&token) { +/// Some(session_id) => println!("Valid: {}", session_id), +/// None => println!("Invalid or tampered token"), +/// } +/// # Ok(()) +/// # } +/// ``` +#[derive(Clone)] +pub struct SessionSigner { + signer: S, +} + +impl SessionSigner { + /// Create a new session signer with the given underlying signer. + pub fn new(signer: S) -> Self { + Self { signer } + } + + /// Get a reference to the underlying signer. + pub fn inner(&self) -> &S { + &self.signer + } + + /// Sign a session ID, returning a token in the format `{session_id}.{signature}`. + /// + /// # Errors + /// + /// Returns [`SigningError`] if the underlying signer fails. + pub fn sign(&self, session_id: &str) -> Result { + let signature = self.signer.sign(session_id.as_bytes())?; + let encoded = URL_SAFE_NO_PAD.encode(&signature); + Ok(format!("{}.{}", session_id, encoded)) + } + + /// Verify a signed session token and extract the session ID. + /// + /// Returns `Some(session_id)` if the signature is valid, `None` otherwise. + /// + /// # Validation + /// + /// Returns `None` if: + /// - Token format is invalid (no dot separator) + /// - Signature portion is not valid base64 + /// - Signature verification fails + /// - Session ID has been tampered with + pub fn verify(&self, signed: &str) -> Option { + let (session_id, signature_b64) = signed.rsplit_once('.')?; + + let signature = URL_SAFE_NO_PAD.decode(signature_b64).ok()?; + + if self.signer.verify(session_id.as_bytes(), &signature) { + Some(session_id.to_string()) + } else { + None + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + // Test keypair generated for reproducible tests + const TEST_PRIVATE_KEY_PEM: &str = r#"-----BEGIN PRIVATE KEY----- +MC4CAQAwBQYDK2VwBCIEIJ+DYvh6SEqVTm50DFtMDoQikTmiCqirVv9mWG9qfSnF +-----END PRIVATE KEY-----"#; + + // Second test key for different-key tests + const TEST_PRIVATE_KEY_PEM_2: &str = r#"-----BEGIN PRIVATE KEY----- +MC4CAQAwBQYDK2VwBCIEIBcUIT7KhLMKX9R1oJf+dFUDux98dVbI5mB3HuhMglFF +-----END PRIVATE KEY-----"#; + + #[test] + fn test_pem_key_loading() { + let signer = Ed25519Signer::from_pem(TEST_PRIVATE_KEY_PEM); + assert!(signer.is_ok(), "should load valid PEM key"); + } + + #[test] + fn test_invalid_pem_handling() { + let invalid_pem = "not a valid pem"; + let result = Ed25519Signer::from_pem(invalid_pem); + assert!(result.is_err(), "should reject invalid PEM"); + + let empty_pem = ""; + let result = Ed25519Signer::from_pem(empty_pem); + assert!(result.is_err(), "should reject empty PEM"); + } + + #[test] + fn test_sign_verify_roundtrip() { + let signer = Ed25519Signer::from_pem(TEST_PRIVATE_KEY_PEM).unwrap(); + + let payload = b"test payload"; + let signature = signer.sign(payload).unwrap(); + + assert!( + signer.verify(payload, &signature), + "should verify own signature" + ); + } + + #[test] + fn test_signature_rejection_wrong_key() { + let signer1 = Ed25519Signer::from_pem(TEST_PRIVATE_KEY_PEM).unwrap(); + let signer2 = Ed25519Signer::from_pem(TEST_PRIVATE_KEY_PEM_2).unwrap(); + + let payload = b"test payload"; + let signature1 = signer1.sign(payload).unwrap(); + + assert!( + !signer2.verify(payload, &signature1), + "should reject signature from different key" + ); + } + + #[test] + fn test_signature_rejection_tampered_payload() { + let signer = Ed25519Signer::from_pem(TEST_PRIVATE_KEY_PEM).unwrap(); + + let payload = b"original payload"; + let signature = signer.sign(payload).unwrap(); + + let tampered = b"tampered payload"; + assert!( + !signer.verify(tampered, &signature), + "should reject tampered payload" + ); + } + + #[test] + fn test_signature_rejection_tampered_signature() { + let signer = Ed25519Signer::from_pem(TEST_PRIVATE_KEY_PEM).unwrap(); + + let payload = b"test payload"; + let mut signature = signer.sign(payload).unwrap(); + + // Flip a bit + signature[0] ^= 0x01; + + assert!( + !signer.verify(payload, &signature), + "should reject tampered signature" + ); + } + + #[test] + fn test_session_signer_roundtrip() { + let ed25519 = Ed25519Signer::from_pem(TEST_PRIVATE_KEY_PEM).unwrap(); + let session_signer = SessionSigner::new(ed25519); + + let session_id = "test_session_abc123"; + let signed = session_signer.sign(session_id).unwrap(); + + // Should contain a dot + assert!(signed.contains('.'), "should have dot separator"); + + // Should verify correctly + let verified = session_signer.verify(&signed); + assert_eq!(verified, Some(session_id.to_string())); + } + + #[test] + fn test_session_signer_rejects_tampered_signature() { + let ed25519 = Ed25519Signer::from_pem(TEST_PRIVATE_KEY_PEM).unwrap(); + let session_signer = SessionSigner::new(ed25519); + + let session_id = "test_session"; + let signed = session_signer.sign(session_id).unwrap(); + + // Tamper with the signature + let tampered = format!("{}x", signed); + assert_eq!(session_signer.verify(&tampered), None); + } + + #[test] + fn test_session_signer_rejects_tampered_session_id() { + let ed25519 = Ed25519Signer::from_pem(TEST_PRIVATE_KEY_PEM).unwrap(); + let session_signer = SessionSigner::new(ed25519); + + let signed = session_signer.sign("original_session").unwrap(); + + // Replace session ID but keep signature + let parts: Vec<&str> = signed.split('.').collect(); + let tampered = format!("different_session.{}", parts[1]); + + assert_eq!(session_signer.verify(&tampered), None); + } + + #[test] + fn test_session_signer_rejects_missing_signature() { + let ed25519 = Ed25519Signer::from_pem(TEST_PRIVATE_KEY_PEM).unwrap(); + let session_signer = SessionSigner::new(ed25519); + + assert_eq!(session_signer.verify("no_signature_here"), None); + } + + #[test] + fn test_session_signer_different_keys_different_signatures() { + let ed25519_1 = Ed25519Signer::from_pem(TEST_PRIVATE_KEY_PEM).unwrap(); + let session_signer1 = SessionSigner::new(ed25519_1); + + let ed25519_2 = Ed25519Signer::from_pem(TEST_PRIVATE_KEY_PEM_2).unwrap(); + let session_signer2 = SessionSigner::new(ed25519_2); + + let session_id = "same_session"; + let signed1 = session_signer1.sign(session_id).unwrap(); + let signed2 = session_signer2.sign(session_id).unwrap(); + + assert_ne!( + signed1, signed2, + "different keys should produce different signatures" + ); + + // Cross-verification should fail + assert_eq!(session_signer1.verify(&signed2), None); + assert_eq!(session_signer2.verify(&signed1), None); + } + + #[test] + fn test_session_signer_empty_session_id() { + let ed25519 = Ed25519Signer::from_pem(TEST_PRIVATE_KEY_PEM).unwrap(); + let session_signer = SessionSigner::new(ed25519); + + let signed = session_signer.sign("").unwrap(); + + // Should still produce valid format + assert!(signed.contains('.')); + + // And should verify + assert_eq!(session_signer.verify(&signed), Some("".to_string())); + } + + #[test] + fn test_session_signer_special_characters() { + let ed25519 = Ed25519Signer::from_pem(TEST_PRIVATE_KEY_PEM).unwrap(); + let session_signer = SessionSigner::new(ed25519); + + let session_id = "session_with-special.chars_123!@#"; + let signed = session_signer.sign(session_id).unwrap(); + let verified = session_signer.verify(&signed); + + assert_eq!(verified, Some(session_id.to_string())); + } + + #[test] + fn test_session_signer_rejects_multiple_dots() { + let ed25519 = Ed25519Signer::from_pem(TEST_PRIVATE_KEY_PEM).unwrap(); + let session_signer = SessionSigner::new(ed25519); + + let valid = session_signer.sign("test").unwrap(); + + // Add extra dots + let invalid = format!("{}.extra", valid); + + // Should fail because rsplit_once takes the LAST dot + assert_eq!(session_signer.verify(&invalid), None); + } + + #[test] + fn test_session_signer_rejects_only_dot() { + let ed25519 = Ed25519Signer::from_pem(TEST_PRIVATE_KEY_PEM).unwrap(); + let session_signer = SessionSigner::new(ed25519); + + assert_eq!(session_signer.verify("."), None); + } + + #[test] + fn test_session_signer_rejects_empty_string() { + let ed25519 = Ed25519Signer::from_pem(TEST_PRIVATE_KEY_PEM).unwrap(); + let session_signer = SessionSigner::new(ed25519); + + assert_eq!(session_signer.verify(""), None); + } +} diff --git a/api/src/config.rs b/api/src/config.rs new file mode 100644 index 0000000..79c8967 --- /dev/null +++ b/api/src/config.rs @@ -0,0 +1,107 @@ +use clap::Parser; +use std::{net::SocketAddr, path::PathBuf}; + +#[derive(Clone, Debug, Parser)] +pub struct TlsConfig { + /// Enable TLS for the API server + #[clap(long, env = "LUCID_API_TLS_ENABLED", default_value_t = false)] + pub enabled: bool, + + /// Path to server TLS certificate (PEM) + #[clap(long, env = "LUCID_API_TLS_CERT")] + pub cert_path: Option, + + /// Path to server TLS private key (PEM) + #[clap(long, env = "LUCID_API_TLS_KEY")] + pub key_path: Option, + + /// Path to CA certificate for client verification (PEM). If set, enables mTLS. + #[clap(long, env = "LUCID_API_TLS_CA_CERT")] + pub ca_cert_path: Option, +} + +#[derive(Clone, Debug, Parser)] +pub struct LucidApiConfig { + #[clap( + short, + long, + env = "LUCID_API_BIND_ADDR", + default_value = "0.0.0.0:4000" + )] + pub bind_addr: SocketAddr, + + #[clap( + long, + env = "LUCID_API_PUBLIC_URL", + default_value = "http://localhost:4000" + )] + pub public_url: String, + + #[clap(long, default_value_t = false)] + pub dump_openapi: bool, + + #[clap( + long, + env = "LUCID_API_MONGODB_URI", + default_value = "mongodb://localhost:27017/lucid" + )] + pub mongodb_uri: String, + + /// Ed25519 private key for signing session tokens (PEM format). + /// + /// Provide the key as an inline PEM string. For security, prefer using + /// `signing_key_file` instead of embedding the key directly. + /// + /// Example PEM format: + /// ```text + /// -----BEGIN PRIVATE KEY----- + /// MC4CAQAwBQYDK2VwBCIEI... + /// -----END PRIVATE KEY----- + /// ``` + /// + /// Mutually exclusive with `signing_key_file`. + #[clap(long, env = "LUCID_API_SIGNING_KEY")] + pub signing_key: Option, + + /// Path to Ed25519 private key file (PEM format). + /// + /// The file should contain a PEM-encoded PKCS#8 Ed25519 private key. + /// Generate one using: + /// ```bash + /// openssl genpkey -algorithm ED25519 -out signing_key.pem + /// ``` + /// + /// Mutually exclusive with `signing_key`. + #[clap(long, env = "LUCID_API_SIGNING_KEY_FILE")] + pub signing_key_file: Option, + + #[clap(flatten)] + pub tls: TlsConfig, +} + +impl LucidApiConfig { + /// Get the signing key PEM data from either inline config or file. + /// + /// Checks `signing_key` first (inline PEM), then falls back to reading + /// from `signing_key_file`. Returns an error if neither is configured. + /// + /// # Errors + /// + /// Returns an error if: + /// - Neither `signing_key` nor `signing_key_file` is configured + /// - `signing_key_file` path doesn't exist or can't be read + pub fn get_signing_key_pem(&self) -> anyhow::Result { + if let Some(ref key) = self.signing_key { + return Ok(key.clone()); + } + + if let Some(ref path) = self.signing_key_file { + return std::fs::read_to_string(path) + .map_err(|e| anyhow::anyhow!("failed to read signing key file: {}", e)); + } + + Err(anyhow::anyhow!( + "no signing key configured (set LUCID_API_SIGNING_KEY or LUCID_API_SIGNING_KEY_FILE)" + )) + } +} diff --git a/api/src/context/mod.rs b/api/src/context/mod.rs new file mode 100644 index 0000000..349b17b --- /dev/null +++ b/api/src/context/mod.rs @@ -0,0 +1,64 @@ +use std::sync::Arc; + +use lucid_db::storage::{Storage, mongodb::MongoDBStorage}; + +use crate::{ + auth::{ + ActivationKeyAuthProvider, AuthManager, CertificateAuthority, MtlsAuthProvider, + encrypted_ca::EncryptedCa, + providers::session::SessionAuthProvider, + signing::{Ed25519Signer, SessionSigner}, + }, + config::LucidApiConfig, +}; + +#[derive(Clone)] +pub struct ApiContext { + pub _config: LucidApiConfig, + pub db: Arc, + pub auth_manager: Arc, + pub session_signer: SessionSigner, + pub ca: Option>, +} + +impl ApiContext { + pub async fn new(config: LucidApiConfig, _auth_manager: AuthManager) -> anyhow::Result { + let db: Arc = Arc::new(MongoDBStorage::new(&config.mongodb_uri).await?); + + // Initialize Ed25519 session signing + // This loads the private key from config and creates a session token signer + let signing_key_pem = config.get_signing_key_pem()?; + let ed25519_signer = Ed25519Signer::from_pem(&signing_key_pem)?; + let session_signer = SessionSigner::new(ed25519_signer); + + // Wire up auth providers + // mTLS is tried first (for agent connections), then session (for web console) + let auth_manager = AuthManager::new() + .with_provider(ActivationKeyAuthProvider::new( + Arc::clone(&db), + config.public_url.clone(), + session_signer.clone(), + )) + .with_provider(MtlsAuthProvider::new(Arc::clone(&db))) + .with_provider(SessionAuthProvider::new( + session_signer.clone(), + Arc::clone(&db), + )); + + // Initialize CA if encryption key is available + let ca: Option> = + if let Ok(encryption_key) = EncryptedCa::encryption_key_from_env() { + Some(Arc::new(EncryptedCa::new(Arc::clone(&db), encryption_key))) + } else { + None + }; + + Ok(Self { + _config: config, + db, + auth_manager: Arc::new(auth_manager), + session_signer, + ca, + }) + } +} diff --git a/api/src/crypto/aes.rs b/api/src/crypto/aes.rs new file mode 100644 index 0000000..e87f532 --- /dev/null +++ b/api/src/crypto/aes.rs @@ -0,0 +1,218 @@ +use aes_gcm::{ + Aes256Gcm, Key, Nonce, + aead::{Aead, KeyInit}, +}; +use thiserror::Error; + +const NONCE_SIZE: usize = 12; // 96 bits for GCM +const TAG_SIZE: usize = 16; // 128 bits for GCM + +#[derive(Debug, Error)] +pub enum AesError { + #[error("Encryption failed: {0}")] + EncryptionFailed(String), + + #[error("Decryption failed: {0}")] + DecryptionFailed(String), + + #[error("Invalid ciphertext: expected at least {expected} bytes, got {actual}")] + InvalidCiphertext { expected: usize, actual: usize }, + + #[error("Invalid key: expected {expected} bytes, got {actual}")] + InvalidKey { expected: usize, actual: usize }, +} + +/// Encrypt plaintext using AES-256-GCM with Additional Authenticated Data (AAD). +/// +/// # Format +/// The output is: `nonce (12 bytes) || ciphertext || tag (16 bytes)` +/// +/// # Arguments +/// * `key` - 32-byte encryption key +/// * `plaintext` - Data to encrypt +/// * `aad` - Additional authenticated data (e.g., record ID to prevent ciphertext transplantation) +/// +/// # Returns +/// Combined nonce + ciphertext + tag as a single Vec +pub fn encrypt(key: &[u8], plaintext: &[u8], aad: &[u8]) -> Result, AesError> { + if key.len() != 32 { + return Err(AesError::InvalidKey { + expected: 32, + actual: key.len(), + }); + } + + let key = Key::::from_slice(key); + let cipher = Aes256Gcm::new(key); + + // Generate random nonce + let mut nonce_bytes = [0u8; NONCE_SIZE]; + getrandom::getrandom(&mut nonce_bytes).map_err(|e| { + AesError::EncryptionFailed(format!("Failed to generate random nonce: {}", e)) + })?; + let nonce = Nonce::from_slice(&nonce_bytes); + + // Encrypt with AAD + let ciphertext = cipher + .encrypt( + nonce, + aes_gcm::aead::Payload { + msg: plaintext, + aad, + }, + ) + .map_err(|e| AesError::EncryptionFailed(e.to_string()))?; + + // Combine: nonce || ciphertext+tag + let mut result = Vec::with_capacity(NONCE_SIZE + ciphertext.len()); + result.extend_from_slice(&nonce_bytes); + result.extend_from_slice(&ciphertext); + + Ok(result) +} + +/// Decrypt ciphertext using AES-256-GCM with Additional Authenticated Data (AAD). +/// +/// # Format +/// Input is expected to be: `nonce (12 bytes) || ciphertext || tag (16 bytes)` +/// +/// # Arguments +/// * `key` - 32-byte encryption key +/// * `ciphertext` - Combined nonce + encrypted data + tag +/// * `aad` - Additional authenticated data (must match what was used during encryption) +/// +/// # Returns +/// Decrypted plaintext +pub fn decrypt(key: &[u8], ciphertext: &[u8], aad: &[u8]) -> Result, AesError> { + if key.len() != 32 { + return Err(AesError::InvalidKey { + expected: 32, + actual: key.len(), + }); + } + + let min_size = NONCE_SIZE + TAG_SIZE; + if ciphertext.len() < min_size { + return Err(AesError::InvalidCiphertext { + expected: min_size, + actual: ciphertext.len(), + }); + } + + // Extract nonce and ciphertext+tag + let nonce = Nonce::from_slice(&ciphertext[..NONCE_SIZE]); + let encrypted_data = &ciphertext[NONCE_SIZE..]; + + let key = Key::::from_slice(key); + let cipher = Aes256Gcm::new(key); + + // Decrypt with AAD + let plaintext = cipher + .decrypt( + nonce, + aes_gcm::aead::Payload { + msg: encrypted_data, + aad, + }, + ) + .map_err(|e| AesError::DecryptionFailed(e.to_string()))?; + + Ok(plaintext) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn test_key() -> [u8; 32] { + [0x42; 32] // Simple test key + } + + #[test] + fn test_encrypt_decrypt_roundtrip() { + let key = test_key(); + let plaintext = b"super secret data"; + let aad = b"record-id-12345"; + + let ciphertext = encrypt(&key, plaintext, aad).expect("encryption failed"); + let decrypted = decrypt(&key, &ciphertext, aad).expect("decryption failed"); + + assert_eq!(plaintext, decrypted.as_slice()); + } + + #[test] + fn test_nonce_uniqueness() { + let key = test_key(); + let plaintext = b"same data"; + let aad = b"record-id"; + + let ct1 = encrypt(&key, plaintext, aad).unwrap(); + let ct2 = encrypt(&key, plaintext, aad).unwrap(); + + // Ciphertexts should differ due to different nonces + assert_ne!(ct1, ct2); + + // But both should decrypt to same plaintext + assert_eq!( + decrypt(&key, &ct1, aad).unwrap(), + decrypt(&key, &ct2, aad).unwrap() + ); + } + + #[test] + fn test_aad_binding() { + let key = test_key(); + let plaintext = b"data"; + let aad1 = b"record-id-1"; + let aad2 = b"record-id-2"; + + let ciphertext = encrypt(&key, plaintext, aad1).unwrap(); + + // Should decrypt with correct AAD + assert!(decrypt(&key, &ciphertext, aad1).is_ok()); + + // Should fail with wrong AAD (prevents ciphertext transplantation) + assert!(decrypt(&key, &ciphertext, aad2).is_err()); + } + + #[test] + fn test_invalid_key_size() { + let short_key = [0u8; 16]; // Too short + let plaintext = b"data"; + let aad = b"aad"; + + assert!(matches!( + encrypt(&short_key, plaintext, aad), + Err(AesError::InvalidKey { .. }) + )); + } + + #[test] + fn test_invalid_ciphertext_size() { + let key = test_key(); + let short_ciphertext = [0u8; 10]; // Too short + let aad = b"aad"; + + assert!(matches!( + decrypt(&key, &short_ciphertext, aad), + Err(AesError::InvalidCiphertext { .. }) + )); + } + + #[test] + fn test_tampered_ciphertext() { + let key = test_key(); + let plaintext = b"data"; + let aad = b"aad"; + + let mut ciphertext = encrypt(&key, plaintext, aad).unwrap(); + + // Tamper with a byte in the encrypted portion + if ciphertext.len() > NONCE_SIZE { + ciphertext[NONCE_SIZE] ^= 0xFF; + } + + // Decryption should fail + assert!(decrypt(&key, &ciphertext, aad).is_err()); + } +} diff --git a/api/src/crypto/mod.rs b/api/src/crypto/mod.rs new file mode 100644 index 0000000..5657404 --- /dev/null +++ b/api/src/crypto/mod.rs @@ -0,0 +1 @@ +pub mod aes; diff --git a/api/src/error.rs b/api/src/error.rs new file mode 100644 index 0000000..b373648 --- /dev/null +++ b/api/src/error.rs @@ -0,0 +1,138 @@ +use axum::{Json, response::IntoResponse}; +use lucid_common::{caller::CallerError, views::ApiErrorResponse}; +use lucid_db::storage::StoreError; +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum ApiError { + #[error("Not found")] + NotFound, + + #[error("Service unavailable: {0}")] + ServiceUnavailable(String), + + #[error("Internal error: {0}")] + Internal(String), + + #[error(transparent)] + Storage(#[from] lucid_db::storage::StoreError), + + #[error(transparent)] + CallerError(#[from] lucid_common::caller::CallerError), + + #[error("Invalid ULID: {0}")] + InvalidUlid(#[from] ulid::DecodeError), + + #[error(transparent)] + InternalAnyhow(#[from] anyhow::Error), +} + +impl ApiError { + pub fn not_found() -> Self { + Self::NotFound + } + + pub fn service_unavailable(msg: impl Into) -> Self { + Self::ServiceUnavailable(msg.into()) + } + + pub fn internal(msg: impl Into) -> Self { + Self::Internal(msg.into()) + } + + pub fn bad_request(msg: impl Into) -> Self { + Self::Internal(msg.into()) + } + + pub fn unauthorized(msg: impl Into) -> Self { + Self::CallerError(CallerError::unauthorized(Some(msg.into()))) + } + + pub fn conflict(msg: impl Into) -> Self { + Self::Internal(msg.into()) + } +} + +impl From for ApiErrorResponse { + fn from(err: ApiError) -> Self { + ApiErrorResponse { + code: match &err { + ApiError::NotFound => Some("NotFound".into()), + ApiError::ServiceUnavailable(_) => Some("ServiceUnavailable".into()), + ApiError::Internal(_) => Some("InternalError".into()), + ApiError::Storage(se) => match se { + StoreError::NotFound => Some("NotFound".into()), + StoreError::PermissionDenied => Some("Forbidden".into()), + _ => Some("InternalError".into()), + }, + ApiError::InvalidUlid(_) => Some("InvalidUlid".into()), + ApiError::InternalAnyhow(_) => Some("InternalError".into()), + ApiError::CallerError(ce) => match ce { + CallerError::Forbidden { .. } => Some("Forbidden".into()), + CallerError::Unauthorized { .. } => Some("Unauthorized".into()), + CallerError::Anyhow(_) => Some("InternalError".into()), + }, + }, + + message: match &err { + ApiError::NotFound => "The requested resource was not found.".into(), + ApiError::ServiceUnavailable(msg) => msg.clone(), + ApiError::Internal(msg) => msg.clone(), + ApiError::Storage(se) => match se { + StoreError::NotFound => "The requested resource was not found.".into(), + StoreError::PermissionDenied => { + "You do not have permission to perform this action.".into() + } + _ => "Something went wrong on our end. Please try again later.".into(), + }, + ApiError::InvalidUlid(_) => "You provided an invalid ULID.".into(), + ApiError::CallerError(ce) => match ce { + CallerError::Forbidden { .. } => { + "You do not have permission to perform this action.".into() + } + CallerError::Unauthorized { .. } => { + "You are not authenticated to perform this action.".into() + } + CallerError::Anyhow(_) => { + "Something went wrong on our end. Please try again later.".into() + } + }, + ApiError::InternalAnyhow(_) => { + "Something went wrong on our end. Please try again later.".into() + } + }, + + #[cfg(debug_assertions)] + details: Some(err.to_string()), + + #[cfg(not(debug_assertions))] + details: None, + } + } +} + +impl IntoResponse for ApiError { + fn into_response(self) -> axum::response::Response { + tracing::error!("Error returned by handler: {self}"); + + let status_code = match &self { + Self::NotFound => axum::http::StatusCode::NOT_FOUND, + Self::ServiceUnavailable(_) => axum::http::StatusCode::SERVICE_UNAVAILABLE, + Self::Internal(_) => axum::http::StatusCode::INTERNAL_SERVER_ERROR, + Self::InvalidUlid(_) => axum::http::StatusCode::BAD_REQUEST, + Self::Storage(se) => match se { + StoreError::NotFound => axum::http::StatusCode::NOT_FOUND, + StoreError::PermissionDenied => axum::http::StatusCode::FORBIDDEN, + _ => axum::http::StatusCode::INTERNAL_SERVER_ERROR, + }, + Self::CallerError(ce) => match ce { + CallerError::Forbidden { .. } => axum::http::StatusCode::FORBIDDEN, + CallerError::Unauthorized { .. } => axum::http::StatusCode::UNAUTHORIZED, + CallerError::Anyhow(_) => axum::http::StatusCode::INTERNAL_SERVER_ERROR, + }, + Self::InternalAnyhow(_) => axum::http::StatusCode::INTERNAL_SERVER_ERROR, + }; + + (status_code, Json(Into::::into(self))).into_response() + } +} diff --git a/api/src/handlers/activation_keys.rs b/api/src/handlers/activation_keys.rs new file mode 100644 index 0000000..56538d4 --- /dev/null +++ b/api/src/handlers/activation_keys.rs @@ -0,0 +1,148 @@ +use axum::{ + Json, + extract::{Path, Query, State}, + http::StatusCode, +}; +use lucid_common::{ + params::PaginationParams, + views::{ActivationKey, PaginatedList}, +}; +use lucid_db::{ + models::DbActivationKey, + storage::{ActivationKeyFilter, ActivationKeyStore}, +}; +use serde::{Deserialize, Serialize}; +use ulid::Ulid; +use utoipa::ToSchema; + +use crate::{ + auth::{Auth, jwt::generate_activation_key_jwt}, + context::ApiContext, + error::ApiError, +}; + +/// Request body for creating an activation key. +#[derive(Debug, Deserialize, ToSchema)] +pub struct CreateActivationKeyRequest { + /// User-provided identifier for this key + pub key_id: String, + /// Human-readable description + pub description: String, +} + +/// Response for activation key creation - includes the JWT token. +#[derive(Debug, Serialize, ToSchema)] +pub struct CreateActivationKeyResponse { + /// The created activation key metadata + pub key: ActivationKey, + /// The JWT token - only returned on creation, store it securely! + pub token: String, +} + +#[utoipa::path( + post, + path = "/api/v1/activation-keys", + tags = ["activation-keys"], + request_body = CreateActivationKeyRequest, + responses( + (status = 201, description = "Activation key created", body = CreateActivationKeyResponse), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden"), + ) +)] +pub async fn create_activation_key( + State(ctx): State, + Auth(caller): Auth, + Json(req): Json, +) -> Result<(StatusCode, Json), ApiError> { + let db_key = DbActivationKey::new(req.key_id, req.description); + + let created = ActivationKeyStore::create(&*ctx.db, caller, db_key).await?; + + // Generate JWT + let pem = ctx._config.get_signing_key_pem()?; + + let token = generate_activation_key_jwt( + ctx.session_signer.inner().clone(), + &pem, + &ctx._config.public_url, + &created.key_id, + created.id.clone().into(), + ) + .map_err(|e| anyhow::anyhow!(e))?; + + let key: ActivationKey = created.into(); + + Ok(( + StatusCode::CREATED, + Json(CreateActivationKeyResponse { key, token }), + )) +} + +#[utoipa::path( + get, + path = "/api/v1/activation-keys", + tags = ["activation-keys"], + responses( + (status = 200, description = "List of activation keys", body = PaginatedList), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden"), + ) +)] +pub async fn list_activation_keys( + State(ctx): State, + Auth(caller): Auth, + Query(query): Query, +) -> Result>, ApiError> { + let keys = + ActivationKeyStore::list(&*ctx.db, caller, ActivationKeyFilter::default(), query).await?; + + Ok(Json(PaginatedList { + items: keys.into_iter().map(|k| k.into()).collect(), + next_token: None, + limit: None, + })) +} + +#[utoipa::path( + get, + path = "/api/v1/activation-keys/{id}", + tags = ["activation-keys"], + responses( + (status = 200, description = "Activation key details", body = ActivationKey), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden"), + (status = 404, description = "Not found"), + ) +)] +pub async fn get_activation_key( + State(ctx): State, + Auth(caller): Auth, + Path(id): Path, +) -> Result, ApiError> { + let key = ActivationKeyStore::get(&*ctx.db, caller, id.into()) + .await? + .ok_or(ApiError::NotFound)?; + + Ok(Json(key.into())) +} + +#[utoipa::path( + delete, + path = "/api/v1/activation-keys/{id}", + tags = ["activation-keys"], + responses( + (status = 204, description = "Activation key deleted"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden"), + (status = 404, description = "Not found"), + ) +)] +pub async fn delete_activation_key( + State(ctx): State, + Auth(caller): Auth, + Path(id): Path, +) -> Result { + ActivationKeyStore::delete(&*ctx.db, caller, id.into()).await?; + Ok(StatusCode::NO_CONTENT) +} diff --git a/api/src/handlers/agents.rs b/api/src/handlers/agents.rs new file mode 100644 index 0000000..8cf13f9 --- /dev/null +++ b/api/src/handlers/agents.rs @@ -0,0 +1,226 @@ +use axum::{ + Json, + extract::State, + http::{HeaderMap, StatusCode, header}, +}; +use chrono::Utc; +use jsonwebtoken::{Algorithm, DecodingKey, Validation, decode}; +use lucid_common::{caller::Caller, params::RegisterAgentRequest, views::RegisterAgentResponse}; +use lucid_db::{ + models::{DbAgent, DbHost, OperatingSystem}, + storage::{ActivationKeyStore, AgentStore, HostStore}, +}; +use tracing::{debug, info, instrument}; +use ulid::Ulid; +use x509_parser::prelude::*; + +use crate::{auth::jwt::ActivationKeyClaims, context::ApiContext, error::ApiError}; + +/// POST /api/v1/agents/register +/// +/// Register a new agent using an activation key JWT. +#[utoipa::path( + post, + path = "/api/v1/agents/register", + tags = ["agents"], + request_body = RegisterAgentRequest, + responses( + (status = 200, description = "Agent registered successfully", body = RegisterAgentResponse), + (status = 400, description = "Invalid CSR"), + (status = 401, description = "Invalid or expired activation key"), + (status = 409, description = "Activation key already used"), + (status = 503, description = "CA not initialized"), + ), + security( + ("activation_key" = []) + ) +)] +#[instrument(skip(ctx))] +pub async fn register_agent( + State(ctx): State, + headers: HeaderMap, + Json(req): Json, +) -> Result<(StatusCode, Json), ApiError> { + debug!("Agent registration request received"); + + // 1. Extract Bearer token from Authorization header + let token = extract_bearer_token(&headers)?; + + // 2. Manually validate activation key JWT to extract the activation key ID + let (claims, activation_key) = validate_activation_key_jwt(&ctx, &token).await?; + + debug!( + key_id = %activation_key.key_id, + ak = %claims.ak, + "Activation key validated" + ); + + // 2. Check activation key not used + if ActivationKeyStore::is_used(&*ctx.db, activation_key.id.clone()) + .await + .map_err(|e| ApiError::internal(format!("Failed to check key usage: {}", e)))? + { + return Err(ApiError::conflict("Activation key already used")); + } + + debug!("Activation key unused, proceeding with registration"); + + // 3. Get CA from context + let ca = ctx + .ca + .as_ref() + .ok_or_else(|| ApiError::service_unavailable("CA not initialized"))?; + + // 4. Extract public key from CSR + let public_key_pem = extract_public_key_pem(&req.csr_pem)?; + + debug!("Public key extracted from CSR"); + + // 5. Create new agent UUID + let agent_id = Ulid::new(); + + debug!(agent_id = %agent_id, "Generated agent ID"); + + // 6. Sign CSR via CA + let signed_cert = ca + .sign_csr(&req.csr_pem, agent_id) + .await + .map_err(|e| ApiError::bad_request(format!("Failed to sign CSR: {}", e)))?; + + debug!("CSR signed successfully"); + + // 7. Create DbHost with minimal info + let host = DbHost { + id: Ulid::new().into(), + hostname: req.hostname.clone(), + architecture: "unknown".to_string(), + operating_system: OperatingSystem { + id: "unknown".to_string(), + name: "Unknown".to_string(), + version: "0".to_string(), + }, + agent_id: Some(agent_id.into()), + updated_at: Utc::now(), + last_seen_at: Utc::now(), + }; + + let created_host = HostStore::create(&*ctx.db, lucid_common::caller::Caller::System, host) + .await + .map_err(|e| ApiError::internal(format!("Failed to create host: {}", e)))?; + + debug!(host_id = %created_host.id, "Host created"); + + // 8. Create DbAgent linking to host + let agent = DbAgent { + id: agent_id.into(), + name: req.hostname.clone(), + host_id: created_host.id, + public_key_pem, + certificate_pem: signed_cert.cert_pem.clone(), + cert_issued_at: signed_cert.issued_at, + cert_expires_at: signed_cert.expires_at, + last_seen_at: None, + revoked_at: None, + created_at: Utc::now(), + updated_at: Utc::now(), + }; + + AgentStore::create(&*ctx.db, agent) + .await + .map_err(|e| ApiError::internal(format!("Failed to create agent: {}", e)))?; + + debug!(agent_id = %agent_id, "Agent created"); + + // 9. Mark activation key as used + ActivationKeyStore::mark_as_used(&*ctx.db, activation_key.id, agent_id.into()) + .await + .map_err(|e| ApiError::internal(format!("Failed to mark key as used: {}", e)))?; + + debug!("Activation key marked as used"); + + // 10. Get CA certificate + let ca_cert_pem = ca + .get_ca_cert_pem() + .await + .map_err(|e| ApiError::internal(format!("Failed to get CA cert: {}", e)))?; + + // 11. Return response + Ok(( + StatusCode::OK, + Json(RegisterAgentResponse { + agent_id: agent_id.to_string(), + certificate_pem: signed_cert.cert_pem, + ca_certificate_pem: ca_cert_pem, + expires_at: signed_cert.expires_at, + api_base_url: ctx._config.public_url.clone(), + }), + )) +} + +/// Validate activation key JWT and return claims + activation key record +async fn validate_activation_key_jwt( + ctx: &ApiContext, + token: &str, +) -> Result<(ActivationKeyClaims, lucid_db::models::DbActivationKey), ApiError> { + // Decode and verify JWT + let public_key_bytes = ctx.session_signer.inner().public_key_bytes(); + let decoding_key = DecodingKey::from_ed_der(public_key_bytes); + let mut validation = Validation::new(Algorithm::EdDSA); + validation.validate_exp = false; + validation.required_spec_claims.clear(); + validation.set_issuer(&[&ctx._config.public_url]); + + let token_data = decode::(token, &decoding_key, &validation) + .map_err(|e| ApiError::unauthorized(format!("Invalid JWT: {}", e)))?; + + let claims = token_data.claims; + info!(?claims, "Validating token with claims..."); + + // Look up activation key + let activation_key = ActivationKeyStore::get(&*ctx.db, Caller::System, claims.ak.into()) + .await + .map_err(|e| ApiError::internal(format!("DB error: {}", e)))? + .ok_or_else(|| ApiError::unauthorized("Invalid activation key"))?; + + Ok((claims, activation_key)) +} + +/// Extract Ed25519 public key from CSR in PEM format +fn extract_public_key_pem(csr_pem: &str) -> Result { + // Parse the PEM-encoded CSR + let (_, pem) = parse_x509_pem(csr_pem.as_bytes()) + .map_err(|e| ApiError::bad_request(format!("Invalid PEM format: {}", e)))?; + + // Parse the CSR + let (_, csr) = X509CertificationRequest::from_der(&pem.contents) + .map_err(|e| ApiError::bad_request(format!("Invalid CSR: {}", e)))?; + + // Verify the CSR signature + csr.verify_signature() + .map_err(|e| ApiError::bad_request(format!("CSR signature verification failed: {}", e)))?; + + // Extract the public key bytes + let spki = csr.certification_request_info.subject_pki; + + // Convert to PEM format + // Ed25519 public keys in PEM format use the "PUBLIC KEY" label with SPKI structure + use pem_rfc7468::{LineEnding, encode_string}; + let public_key_pem = encode_string("PUBLIC KEY", LineEnding::LF, spki.raw) + .map_err(|e| ApiError::internal(format!("Failed to encode public key as PEM: {}", e)))?; + + Ok(public_key_pem) +} + +/// Extract Bearer token from Authorization header +fn extract_bearer_token(headers: &HeaderMap) -> Result { + let auth_header = headers + .get(header::AUTHORIZATION) + .ok_or_else(|| ApiError::unauthorized("Missing Authorization header"))? + .to_str() + .map_err(|_| ApiError::unauthorized("Invalid Authorization header"))?; + + auth_header + .strip_prefix("Bearer ") + .map(|s| s.to_string()) + .ok_or_else(|| ApiError::unauthorized("Invalid Bearer token format")) +} diff --git a/api/src/handlers/auth/mod.rs b/api/src/handlers/auth/mod.rs new file mode 100644 index 0000000..f0a7c7d --- /dev/null +++ b/api/src/handlers/auth/mod.rs @@ -0,0 +1,267 @@ +use std::str::FromStr; + +use axum::{ + Json, + extract::State, + http::{HeaderMap, HeaderValue, header}, +}; +use lucid_common::{ + caller::Caller, + params::AuthLoginParams, + views::{AuthLoginResponse, User}, +}; +use lucid_db::storage::{SessionStore, UserStore}; +use rand::Rng; +use tracing::info; +use ulid::Ulid; + +use crate::{auth::Auth, context::ApiContext, error::ApiError}; + +/// Authenticate user and create session. +/// +/// This endpoint validates user credentials and creates a new session stored in the database. +/// On success, it returns a session cookie and a CSRF token. +/// +/// # Flow +/// +/// 1. Validates username/password against database +/// 2. Generates unique session_id (ULID) and csrf_token (32 random chars) +/// 3. Creates session in database with 30-day TTL +/// 4. Signs session_id with Ed25519 key +/// 5. Returns signed token in `lucid_session` cookie + CSRF token in response body +/// +/// # Cookie Format +/// +/// - Name: `lucid_session` +/// - Value: `{session_id}.{ed25519_signature}` +/// - Flags: HttpOnly, SameSite=Lax, Path=/, Max-Age=2592000 (30 days) +/// - Secure: Only set when `public_url` starts with https:// +/// +/// # CSRF Token +/// +/// The CSRF token must be stored by the client (e.g., in memory or localStorage) and sent +/// in the `X-CSRF-Token` header for all state-changing requests (POST, PUT, DELETE). +/// +/// # Example +/// +/// ```bash +/// curl -X POST http://localhost:3000/v1/auth/login \ +/// -H "Content-Type: application/json" \ +/// -d '{"username": "admin", "password": "secret"}' \ +/// -c cookies.txt +/// ``` +/// +/// # Errors +/// +/// - 401 Unauthorized: Invalid username or password +/// - 500 Internal Server Error: Database or signing failure +#[utoipa::path( + post, + path = "/api/v1/auth/login", + tags = ["auth", "console_sessions"], + request_body(content = AuthLoginParams, content_type = "application/json"), + responses((status = 201, description = "Successful login", body = AuthLoginResponse)) +)] +pub async fn auth_login( + State(ctx): State, + Json(body): Json, +) -> Result<(HeaderMap, Json), ApiError> { + // 1. Authenticate user + let caller = + UserStore::auth_local(&*ctx.db, Caller::System, body.username, body.password).await?; + + // 2. Extract user_id from Caller + let user_id = match &caller { + Caller::User { id, .. } => { + Ulid::from_str(id).map_err(|e| anyhow::anyhow!("invalid user id: {}", e))? + } + _ => return Err(anyhow::anyhow!("expected user caller").into()), + }; + + // 3. Generate session_id and csrf_token + let session_id = ulid::Ulid::new().to_string(); + let csrf_token: String = rand::rng() + .sample_iter(rand::distr::Alphanumeric) + .take(32) + .map(char::from) + .collect(); + + // 4. Create session in DB (30 day TTL) + SessionStore::create_session( + &*ctx.db, + user_id.into(), + session_id.clone(), + csrf_token.clone(), + chrono::Duration::days(30), + ) + .await?; + + info!("Logged in user {}", caller.id()); + + // 5. Sign the session_id + let signed_token = ctx + .session_signer + .sign(&session_id) + .map_err(|e| anyhow::anyhow!("failed to sign session: {}", e))?; + + // 6. Build cookie + let secure_flag = if ctx._config.public_url.starts_with("https://") { + "; Secure" + } else { + "" + }; + let cookie = format!( + "lucid_session={}; HttpOnly; SameSite=Lax; Path=/; Max-Age={}{}", + signed_token, + 30 * 24 * 60 * 60, // 30 days in seconds + secure_flag + ); + + // 7. Set cookie header + let mut headers = HeaderMap::new(); + headers.insert( + header::SET_COOKIE, + HeaderValue::from_str(&cookie) + .map_err(|e| anyhow::anyhow!("invalid cookie value: {}", e))?, + ); + + Ok((headers, Json(AuthLoginResponse::Session { csrf_token }))) +} + +/// End the current session. +/// +/// This endpoint deletes the user's session from the database and clears the session cookie. +/// Requires both session cookie authentication AND the CSRF token. +/// +/// # Flow +/// +/// 1. Extracts and verifies session cookie from request +/// 2. Validates CSRF token (via Auth extractor) +/// 3. Deletes session from database +/// 4. Returns cookie with Max-Age=0 to clear it from browser +/// +/// # Security +/// +/// This is a state-changing operation, so it requires CSRF protection. The session cookie +/// alone is not sufficient - the CSRF token must also be provided. +/// +/// # Example +/// +/// ```bash +/// curl -X POST http://localhost:3000/v1/auth/logout \ +/// -H "X-CSRF-Token: {csrf_token_from_login}" \ +/// -b cookies.txt +/// ``` +/// +/// # Errors +/// +/// - 401 Unauthorized: Missing or invalid session cookie +/// - 403 Forbidden: Invalid CSRF token +/// - 500 Internal Server Error: Database failure +#[utoipa::path( + post, + path = "/api/v1/auth/logout", + tags = ["auth", "console_sessions"], + responses((status = 200, description = "Successful logout")) +)] +pub async fn auth_logout( + State(ctx): State, + Auth(caller): Auth, + headers: HeaderMap, +) -> Result<(HeaderMap, &'static str), ApiError> { + // 1. Extract session cookie + let signed_cookie = headers + .get(header::COOKIE) + .and_then(|v| v.to_str().ok()) + .and_then(|cookies| { + cookies + .split(';') + .map(|s| s.trim()) + .find(|s| s.starts_with("lucid_session=")) + .and_then(|s| s.strip_prefix("lucid_session=")) + }) + .ok_or_else(|| anyhow::anyhow!("session cookie not found"))?; + + // 2. Verify and extract session_id + let session_id = ctx + .session_signer + .verify(signed_cookie) + .ok_or_else(|| anyhow::anyhow!("invalid session signature"))?; + + // 3. Delete session from DB + SessionStore::delete_session(&*ctx.db, &session_id).await?; + + info!("Logged out user {}", caller.id()); + + // 4. Clear cookie (must match login cookie flags, especially Secure) + let secure_flag = if ctx._config.public_url.starts_with("https://") { + "; Secure" + } else { + "" + }; + let cookie = format!( + "lucid_session=; HttpOnly; SameSite=Lax; Path=/; Max-Age=0{}", + secure_flag + ); + let mut response_headers = HeaderMap::new(); + response_headers.insert( + header::SET_COOKIE, + HeaderValue::from_str(&cookie) + .map_err(|e| anyhow::anyhow!("invalid cookie value: {}", e))?, + ); + + Ok((response_headers, "Logged out successfully")) +} + +/// Get information about the authenticated user. +/// +/// Returns the current user's profile information including ID, username, display name, +/// and email. Requires session cookie authentication (no CSRF token needed for GET requests). +/// +/// # Example +/// +/// ```bash +/// curl http://localhost:3000/v1/auth/me \ +/// -b cookies.txt +/// ``` +/// +/// # Response +/// +/// ```json +/// { +/// "id": "user_object_id", +/// "username": "admin", +/// "display_name": "Administrator", +/// "email": "admin@example.com" +/// } +/// ``` +/// +/// # Errors +/// +/// - 401 Unauthorized: Missing or invalid session cookie +/// - 404 Not Found: User no longer exists in database (stale session) +/// - 500 Internal Server Error: Database failure +#[utoipa::path( + get, + path = "/api/v1/auth/me", + tags = ["auth"], + responses((status = 200, description = "User information", body = User)) +)] +pub async fn auth_whoami( + State(ctx): State, + Auth(caller): Auth, +) -> Result, ApiError> { + // Fetch full user from database + let user = UserStore::get( + &*ctx.db, + caller.clone(), + Ulid::from_string(caller.id())?.into(), + ) + .await? + .ok_or_else(|| anyhow::anyhow!("user not found"))?; + + Ok(Json(user.into())) +} + +#[cfg(test)] +mod tests; diff --git a/api/src/handlers/auth/tests.rs b/api/src/handlers/auth/tests.rs new file mode 100644 index 0000000..085f07e --- /dev/null +++ b/api/src/handlers/auth/tests.rs @@ -0,0 +1,156 @@ +use rand::Rng; + +#[test] +fn test_csrf_token_format() { + // CSRF tokens should be 32 alphanumeric characters + let csrf_token: String = rand::rng() + .sample_iter(rand::distr::Alphanumeric) + .take(32) + .map(char::from) + .collect(); + + assert_eq!(csrf_token.len(), 32); + assert!(csrf_token.chars().all(|c: char| c.is_ascii_alphanumeric())); +} + +#[test] +fn test_session_id_is_ulid() { + // Session IDs should be valid ULIDs + let session_id = ulid::Ulid::new().to_string(); + + assert_eq!(session_id.len(), 26); + assert!(ulid::Ulid::from_string(&session_id).is_ok()); +} + +#[test] +fn test_secure_cookie_flag_https() { + let public_url = "https://example.com"; + let secure_flag = if public_url.starts_with("https://") { + "; Secure" + } else { + "" + }; + + assert_eq!(secure_flag, "; Secure"); +} + +#[test] +fn test_secure_cookie_flag_http() { + let public_url = "http://localhost:8080"; + let secure_flag = if public_url.starts_with("https://") { + "; Secure" + } else { + "" + }; + + assert_eq!(secure_flag, ""); +} + +#[test] +fn test_cookie_max_age_calculation() { + let max_age = 30 * 24 * 60 * 60; // 30 days in seconds + assert_eq!(max_age, 2_592_000); +} + +#[test] +fn test_cookie_parsing_logic() { + // Simulate the cookie extraction logic used in auth_logout + let cookie_header = "other=value; lucid_session=test_token; foo=bar"; + let signed_cookie = cookie_header + .split(';') + .map(|s| s.trim()) + .find(|s| s.starts_with("lucid_session=")) + .and_then(|s| s.strip_prefix("lucid_session=")); + + assert_eq!(signed_cookie, Some("test_token")); +} + +#[test] +fn test_cookie_parsing_not_found() { + let cookie_header = "other=value; foo=bar"; + let signed_cookie = cookie_header + .split(';') + .map(|s| s.trim()) + .find(|s| s.starts_with("lucid_session=")) + .and_then(|s| s.strip_prefix("lucid_session=")); + + assert_eq!(signed_cookie, None); +} + +#[test] +fn test_cookie_parsing_empty_value() { + let cookie_header = "lucid_session="; + let signed_cookie = cookie_header + .split(';') + .map(|s| s.trim()) + .find(|s| s.starts_with("lucid_session=")) + .and_then(|s| s.strip_prefix("lucid_session=")); + + assert_eq!(signed_cookie, Some("")); +} + +#[test] +fn test_logout_cookie_format() { + // Cookie for logout should have Max-Age=0 and all security flags + // Testing HTTP version + let public_url = "http://localhost:8080"; + let secure_flag = if public_url.starts_with("https://") { + "; Secure" + } else { + "" + }; + let cookie = format!( + "lucid_session=; HttpOnly; SameSite=Lax; Path=/; Max-Age=0{}", + secure_flag + ); + + assert!(cookie.contains("HttpOnly")); + assert!(cookie.contains("SameSite=Lax")); + assert!(cookie.contains("Path=/")); + assert!(cookie.contains("Max-Age=0")); + assert!(cookie.starts_with("lucid_session=;")); + assert!(!cookie.contains("Secure")); + + // Testing HTTPS version + let public_url_https = "https://example.com"; + let secure_flag_https = if public_url_https.starts_with("https://") { + "; Secure" + } else { + "" + }; + let cookie_https = format!( + "lucid_session=; HttpOnly; SameSite=Lax; Path=/; Max-Age=0{}", + secure_flag_https + ); + + assert!(cookie_https.contains("Secure")); + assert!(cookie_https.contains("HttpOnly")); + assert!(cookie_https.contains("Max-Age=0")); +} + +#[test] +fn test_ulid_uniqueness() { + // ULIDs should be unique + let id1 = ulid::Ulid::new().to_string(); + let id2 = ulid::Ulid::new().to_string(); + + assert_ne!(id1, id2); +} + +#[test] +fn test_csrf_token_uniqueness() { + // CSRF tokens should be unique + let token1: String = rand::rng() + .sample_iter(rand::distr::Alphanumeric) + .take(32) + .map(char::from) + .collect(); + + let token2: String = rand::rng() + .sample_iter(rand::distr::Alphanumeric) + .take(32) + .map(char::from) + .collect(); + + assert_ne!(token1, token2); +} diff --git a/api/src/handlers/ca.rs b/api/src/handlers/ca.rs new file mode 100644 index 0000000..a503432 --- /dev/null +++ b/api/src/handlers/ca.rs @@ -0,0 +1,222 @@ +use axum::{ + Json, + extract::{Path, State}, + http::StatusCode, +}; +use lucid_common::views::{Ca, PaginatedList}; +use lucid_db::{models::DbCa, storage::CaStore}; +use pem_rfc7468::decode_vec; +use rcgen::{CertificateParams, KeyPair}; +use serde::Deserialize; +use sha2::{Digest, Sha256}; +use ulid::Ulid; +use utoipa::ToSchema; + +use crate::{ + auth::{Auth, encrypted_ca::EncryptedCa}, + context::ApiContext, + error::ApiError, +}; + +// --------------------------------------------------------------------------- +// Helper +// --------------------------------------------------------------------------- + +/// Convert a `DbCa` to its public view type, computing the SHA-256 fingerprint +/// from the PEM cert in the process. +fn db_ca_to_view(ca: DbCa) -> Result { + let cert_der = decode_vec(ca.cert_pem.as_bytes()) + .map_err(|e| anyhow::anyhow!("Failed to decode CA cert PEM: {e}"))? + .1; + + let mut hasher = Sha256::new(); + hasher.update(&cert_der); + let fingerprint = format!("sha256:{}", hex::encode(hasher.finalize())); + + Ok(Ca { + id: ca.id.into(), + cert_pem: ca.cert_pem, + fingerprint, + created_at: ca.created_at, + }) +} + +/// Load the server's CA encryption key or return a 500. +fn get_encryption_key() -> Result<[u8; 32], ApiError> { + EncryptedCa::encryption_key_from_env() + .map_err(|e| anyhow::anyhow!("CA encryption key unavailable: {e}").into()) +} + +// --------------------------------------------------------------------------- +// Request bodies +// --------------------------------------------------------------------------- + +/// Request body for importing an existing certificate authority. +#[derive(Debug, Deserialize, ToSchema)] +pub struct ImportCaRequest { + /// PEM-encoded CA certificate. + pub cert_pem: String, + /// PEM-encoded private key for the CA certificate (will be encrypted at + /// rest using the server's `LUCID_CA_ENCRYPTION_KEY`). + pub private_key_pem: String, +} + +// --------------------------------------------------------------------------- +// Handlers +// --------------------------------------------------------------------------- + +/// Generate a new self-signed Ed25519 CA certificate and store it. +#[utoipa::path( + post, + path = "/api/v1/cas", + tags = ["cas"], + responses( + (status = 201, description = "Certificate authority generated", body = Ca), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden"), + ) +)] +pub async fn generate_ca( + State(ctx): State, + Auth(caller): Auth, +) -> Result<(StatusCode, Json), ApiError> { + caller.require(lucid_common::caller::Permission::CaWrite)?; + + let encryption_key = get_encryption_key()?; + + let ca_info = crate::auth::encrypted_ca::generate_ca(&*ctx.db, &encryption_key, false) + .await + .map_err(|e| anyhow::anyhow!("Failed to generate CA: {e}"))?; + + // Fetch the stored record back so we have an ID to return. + let db_ca = lucid_db::storage::CaStore::list(&*ctx.db, lucid_common::caller::Caller::System) + .await? + .into_iter() + .find(|c| c.cert_pem == ca_info.cert_pem) + .ok_or_else(|| anyhow::anyhow!("Stored CA not found immediately after creation"))?; + + Ok((StatusCode::CREATED, Json(db_ca_to_view(db_ca)?))) +} + +/// Import an existing CA certificate and private key. +#[utoipa::path( + post, + path = "/api/v1/cas/import", + tags = ["cas"], + request_body = ImportCaRequest, + responses( + (status = 201, description = "Certificate authority imported", body = Ca), + (status = 400, description = "Invalid certificate or key"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden"), + ) +)] +pub async fn import_ca( + State(ctx): State, + Auth(caller): Auth, + Json(req): Json, +) -> Result<(StatusCode, Json), ApiError> { + // Validate that the cert and key are actually a matching CA pair before + // storing them. + let key_pair = KeyPair::from_pem(&req.private_key_pem) + .map_err(|e| ApiError::bad_request(format!("Invalid private key PEM: {e}")))?; + CertificateParams::from_ca_cert_pem(&req.cert_pem) + .map_err(|e| ApiError::bad_request(format!("Invalid CA certificate PEM: {e}")))?; + + let encryption_key = get_encryption_key()?; + + // Pre-generate the ObjectId so we can bind the encrypted key to this + // specific CA record via AAD (prevents ciphertext transplantation). + let ca_id = Ulid::new(); + let aad = ca_id.to_string(); + + let encrypted_private_key = crate::crypto::aes::encrypt( + &encryption_key, + req.private_key_pem.as_bytes(), + aad.as_bytes(), + ) + .map_err(|e| anyhow::anyhow!("Failed to encrypt private key: {e}"))?; + + drop(key_pair); + + let db_ca = DbCa { + id: ca_id.into(), + cert_pem: req.cert_pem, + encrypted_private_key, + created_at: chrono::Utc::now(), + }; + + let created = CaStore::create(&*ctx.db, caller, db_ca).await?; + Ok((StatusCode::CREATED, Json(db_ca_to_view(created)?))) +} + +#[utoipa::path( + get, + path = "/api/v1/cas", + tags = ["cas"], + responses( + (status = 200, description = "List of certificate authorities", body = PaginatedList), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden"), + ) +)] +pub async fn list_cas( + State(ctx): State, + Auth(caller): Auth, +) -> Result>, ApiError> { + let cas = CaStore::list(&*ctx.db, caller).await?; + + let items = cas + .into_iter() + .map(db_ca_to_view) + .collect::, _>>()?; + + Ok(Json(PaginatedList { + items, + next_token: None, + limit: None, + })) +} + +#[utoipa::path( + get, + path = "/api/v1/cas/{id}", + tags = ["cas"], + responses( + (status = 200, description = "Certificate authority details", body = Ca), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden"), + (status = 404, description = "Not found"), + ) +)] +pub async fn get_ca( + State(ctx): State, + Auth(caller): Auth, + Path(id): Path, +) -> Result, ApiError> { + let ca = CaStore::get(&*ctx.db, caller, id.into()) + .await? + .ok_or(ApiError::NotFound)?; + + Ok(Json(db_ca_to_view(ca)?)) +} + +#[utoipa::path( + delete, + path = "/api/v1/cas/{id}", + tags = ["cas"], + responses( + (status = 204, description = "Certificate authority deleted"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden"), + (status = 404, description = "Not found"), + ) +)] +pub async fn delete_ca( + State(ctx): State, + Auth(caller): Auth, + Path(id): Path, +) -> Result { + CaStore::delete(&*ctx.db, caller, id.into()).await?; + Ok(StatusCode::NO_CONTENT) +} diff --git a/api/src/handlers/hosts/mod.rs b/api/src/handlers/hosts/mod.rs new file mode 100644 index 0000000..221c506 --- /dev/null +++ b/api/src/handlers/hosts/mod.rs @@ -0,0 +1,57 @@ +use axum::{ + Json, + extract::{Path, Query, State}, +}; +use chrono::Utc; +use lucid_common::{ + params::PaginationParams, + views::{Host, PaginatedList}, +}; +use lucid_db::storage::{HostFilter, HostStore}; +use ulid::Ulid; + +use crate::{auth::Auth, context::ApiContext, error::ApiError}; + +#[utoipa::path( + get, + path = "/api/v1/hosts", + tags = ["hosts"], + responses((status = 200, description = "List of hosts", body = PaginatedList)) +)] +pub async fn list_hosts( + State(ctx): State, + Auth(caller): Auth, + Query(query): Query, +) -> Result>, ApiError> { + let hosts = HostStore::list(&*ctx.db, caller, HostFilter::default(), query).await?; + + Ok(Json(PaginatedList { + // TODO: Find a way to do this without cloning + items: hosts.iter().map(|h| h.clone().into()).collect(), + next_token: None, + limit: None, + })) +} + +#[utoipa::path( + get, + path = "/api/v1/hosts/{id}", + tags = ["hosts"], + responses((status = 200, description = "Resolved host", body = Host)) +)] +pub async fn get_host(Path(id): Path) -> Result, ApiError> { + let created_at = Utc::now(); + + Ok(Json(Host { + id, + hostname: "example.com".into(), + os_id: "".into(), + os_name: "".into(), + os_version: "".into(), + architecture: "x86_64".into(), + ifaces: vec![], + created_at, + updated_at: created_at, + last_seen_at: created_at, + })) +} diff --git a/api/src/handlers/jwks.rs b/api/src/handlers/jwks.rs new file mode 100644 index 0000000..bde6570 --- /dev/null +++ b/api/src/handlers/jwks.rs @@ -0,0 +1,178 @@ +//! JWKS (JSON Web Key Set) endpoint handler. +//! +//! Exposes the server's Ed25519 public signing key as a JWKS document, enabling +//! external consumers to verify JWTs issued by this service. +//! +//! The endpoint follows [RFC 7517](https://www.rfc-editor.org/rfc/rfc7517) and +//! [RFC 8037](https://www.rfc-editor.org/rfc/rfc8037) for OKP key representation. + +use axum::{Json, extract::State}; +use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD}; +use serde::Serialize; + +use crate::{context::ApiContext, error::ApiError}; + +/// A single JSON Web Key representing an OKP (Octet Key Pair) Ed25519 public key. +#[derive(Debug, Serialize, utoipa::ToSchema)] +pub struct Jwk { + /// Key type — always `"OKP"` for Ed25519 keys (RFC 8037). + kty: &'static str, + + /// Curve — always `"Ed25519"`. + crv: &'static str, + + /// Base64url-encoded public key bytes (32 bytes for Ed25519). + x: String, + + /// Key ID — a base64url-encoded prefix of the public key bytes, + /// used to identify which key was used to sign a token. + kid: String, + + /// Intended use of the key. `"sig"` indicates this key is for signing. + #[serde(rename = "use")] + key_use: &'static str, + + /// Algorithms this key supports — `"EdDSA"` for Ed25519. + #[serde(rename = "alg")] + algorithm: &'static str, +} + +/// The JSON Web Key Set response, containing one or more public keys. +#[derive(Debug, Serialize, utoipa::ToSchema)] +pub struct JwkSet { + keys: Vec, +} + +/// OpenID Connect discovery response (minimal, for JWT verification only). +#[derive(Debug, Serialize, utoipa::ToSchema)] +pub struct OpenIdConfiguration { + /// URL to the JWKS endpoint for retrieving public keys. + jwks_uri: String, +} + +/// Retrieve the server's public JSON Web Key Set. +/// +/// Returns the Ed25519 public key(s) used by this server to sign tokens. +/// External services can use this endpoint to verify JWTs without needing +/// a shared secret. +/// +/// The key is represented as an OKP (Octet Key Pair) JWK per RFC 8037. +/// +/// # Example +/// +/// ```bash +/// curl http://localhost:4000/.well-known/jwks.json +/// ``` +/// +/// ```json +/// { +/// "keys": [ +/// { +/// "kty": "OKP", +/// "crv": "Ed25519", +/// "x": "11qYAYKxCrfVS_7TyWQHOg7hcvPapiMlrwIaaPcHURo", +/// "kid": "11qYAYKxCrfV", +/// "use": "sig", +/// "alg": "EdDSA" +/// } +/// ] +/// } +/// ``` +#[utoipa::path( + get, + path = "/.well-known/jwks.json", + tags = ["auth"], + responses((status = 200, description = "JSON Web Key Set", body = JwkSet)) +)] +pub async fn get_jwks(State(ctx): State) -> Result, ApiError> { + let pub_bytes = ctx.session_signer.inner().public_key_bytes(); + + let x = URL_SAFE_NO_PAD.encode(pub_bytes); + // Use the first 8 bytes as a short key ID — deterministic, no extra deps needed. + let kid = URL_SAFE_NO_PAD.encode(&pub_bytes[..8]); + + let key = Jwk { + kty: "OKP", + crv: "Ed25519", + x, + kid, + key_use: "sig", + algorithm: "EdDSA", + }; + + Ok(Json(JwkSet { keys: vec![key] })) +} + +/// OpenID Connect discovery endpoint. +/// +/// Returns minimal OIDC configuration needed for JWT verification. +/// Only includes `jwks_uri` pointing to the JWKS endpoint. +/// +/// # Example +/// +/// ```bash +/// curl http://localhost:4000/.well-known/openid-configuration +/// ``` +/// +/// ```json +/// { +/// "jwks_uri": "http://localhost:4000/.well-known/jwks.json" +/// } +/// ``` +#[utoipa::path( + get, + path = "/.well-known/openid-configuration", + tags = ["auth"], + responses((status = 200, description = "OpenID Connect discovery document", body = OpenIdConfiguration)) +)] +pub async fn get_openid_configuration( + State(ctx): State, +) -> Result, ApiError> { + let jwks_uri = format!("{}/.well-known/jwks.json", ctx._config.public_url); + + Ok(Json(OpenIdConfiguration { jwks_uri })) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::auth::signing::Ed25519Signer; + + const TEST_PRIVATE_KEY_PEM: &str = r#"-----BEGIN PRIVATE KEY----- +MC4CAQAwBQYDK2VwBCIEIJ+DYvh6SEqVTm50DFtMDoQikTmiCqirVv9mWG9qfSnF +-----END PRIVATE KEY-----"#; + + #[test] + fn test_public_key_bytes_roundtrip() { + let signer = Ed25519Signer::from_pem(TEST_PRIVATE_KEY_PEM).unwrap(); + let bytes = signer.public_key_bytes(); + + // Should be exactly 32 bytes for Ed25519 + assert_eq!(bytes.len(), 32); + + // Base64url encode should be 43 chars (32 bytes, no padding) + let encoded = URL_SAFE_NO_PAD.encode(bytes); + assert_eq!(encoded.len(), 43); + } + + #[test] + fn test_kid_derived_from_public_key() { + let signer = Ed25519Signer::from_pem(TEST_PRIVATE_KEY_PEM).unwrap(); + let bytes = signer.public_key_bytes(); + let kid = URL_SAFE_NO_PAD.encode(&bytes[..8]); + + // Kid should be 11 chars (8 bytes base64url no-pad) + assert_eq!(kid.len(), 11); + } + + #[test] + fn test_same_key_same_kid() { + let signer1 = Ed25519Signer::from_pem(TEST_PRIVATE_KEY_PEM).unwrap(); + let signer2 = Ed25519Signer::from_pem(TEST_PRIVATE_KEY_PEM).unwrap(); + + let kid1 = URL_SAFE_NO_PAD.encode(&signer1.public_key_bytes()[..8]); + let kid2 = URL_SAFE_NO_PAD.encode(&signer2.public_key_bytes()[..8]); + + assert_eq!(kid1, kid2, "same key should always produce same kid"); + } +} diff --git a/api/src/handlers/mod.rs b/api/src/handlers/mod.rs new file mode 100644 index 0000000..efd05d6 --- /dev/null +++ b/api/src/handlers/mod.rs @@ -0,0 +1,16 @@ +use axum::extract::State; + +use crate::{context::ApiContext, error::ApiError}; + +pub mod activation_keys; +pub mod agents; +pub mod auth; +pub mod ca; +pub mod hosts; +pub mod jwks; +pub mod well_known; + +pub async fn health_check(State(ctx): State) -> Result<&'static str, ApiError> { + ctx.db.ping().await?; + Ok("Healthy") +} diff --git a/api/src/handlers/well_known.rs b/api/src/handlers/well_known.rs new file mode 100644 index 0000000..7849fb4 --- /dev/null +++ b/api/src/handlers/well_known.rs @@ -0,0 +1,79 @@ +use axum::{Json, extract::State}; +use serde::Serialize; +use utoipa::ToSchema; + +use crate::{context::ApiContext, error::ApiError}; + +/// GET /.well-known/lucid/agent +/// Returns CA certificate information for agents. +#[utoipa::path( + get, + path = "/.well-known/lucid/agent", + tags = ["well-known"], + responses( + (status = 200, description = "Agent configuration", body = AgentWellKnownResponse), + (status = 503, description = "CA not initialized"), + ) +)] +pub async fn get_agent_well_known( + State(ctx): State, +) -> Result, ApiError> { + let ca = ctx + .ca + .as_ref() + .ok_or_else(|| ApiError::service_unavailable("CA not initialized"))?; + + let ca_info = ca + .get_ca_info() + .await + .map_err(|e| ApiError::internal(format!("Failed to get CA info: {}", e)))?; + + let response = AgentWellKnownResponse { + server_version: env!("CARGO_PKG_VERSION").to_string(), + cas: vec![CaInfoResponse { + cert_pem: ca_info.cert_pem, + fingerprint: ca_info.fingerprint, + issued_at: ca_info.issued_at, + expires_at: ca_info.expires_at, + }], + }; + + Ok(Json(response)) +} + +#[derive(Serialize, ToSchema)] +pub struct AgentWellKnownResponse { + pub server_version: String, + pub cas: Vec, +} + +#[derive(Serialize, ToSchema)] +pub struct CaInfoResponse { + pub cert_pem: String, + pub fingerprint: String, + pub issued_at: chrono::DateTime, + pub expires_at: chrono::DateTime, +} + +#[derive(Serialize, ToSchema)] +pub struct ServerWellKnownResponse { + pub server_version: String, +} + +/// GET /.well-known/lucid/agent +/// Returns CA certificate information for agents. +#[utoipa::path( + get, + path = "/.well-known/lucid/server", + tags = ["well-known"], + responses( + (status = 200, description = "Server configuration", body = AgentWellKnownResponse), + ) +)] +pub async fn get_server_well_known() -> Result, ApiError> { + let response = ServerWellKnownResponse { + server_version: env!("CARGO_PKG_VERSION").to_string(), + }; + + Ok(Json(response)) +} diff --git a/api/src/lib.rs b/api/src/lib.rs new file mode 100644 index 0000000..8c9ac67 --- /dev/null +++ b/api/src/lib.rs @@ -0,0 +1,22 @@ +//! Lucid API service. +//! +//! Provides REST API endpoints for fleet management, authentication, and telemetry. +//! +//! # Configuration +//! +//! The API requires an Ed25519 signing key for session authentication. See +//! [`config::LucidApiConfig`] for configuration options. +//! +//! # Authentication +//! +//! Session-based authentication using Ed25519 signatures. See [`auth::signing`] +//! for implementation details. + +pub mod auth; +pub mod config; +pub mod crypto; +pub mod server; + +pub(crate) mod context; +pub(crate) mod error; +pub(crate) mod handlers; diff --git a/api/src/main.rs b/api/src/main.rs new file mode 100644 index 0000000..c188ae7 --- /dev/null +++ b/api/src/main.rs @@ -0,0 +1,188 @@ +use clap::{Parser, Subcommand}; +use lucid_api::{ + auth::encrypted_ca::{EncryptedCa, generate_ca}, + config::LucidApiConfig, + server, +}; +use lucid_db::storage::mongodb::MongoDBStorage; +use tokio::net::TcpListener; +use tracing::info; +use tracing_subscriber::EnvFilter; + +#[derive(Parser)] +#[command(name = "lucid-api")] +struct Cli { + #[command(subcommand)] + command: Option, + + #[command(flatten)] + config: LucidApiConfig, +} + +#[derive(Subcommand)] +enum Commands { + /// Generate a new Certificate Authority + GenerateCa { + /// Overwrite existing CA (DANGER: invalidates all agent certs) + #[arg(long)] + force: bool, + }, + /// Run the API server (default) + Serve, +} + +#[tokio::main] +async fn main() { + let cli = Cli::parse(); + + match cli.command { + Some(Commands::GenerateCa { force }) => { + // Initialize minimal logging for CLI + tracing_subscriber::fmt() + .with_env_filter(EnvFilter::new("info")) + .init(); + + if let Err(e) = run_generate_ca(&cli.config, force).await { + eprintln!("Error generating CA: {}", e); + std::process::exit(1); + } + } + Some(Commands::Serve) | None => { + run_server(cli.config).await; + } + } +} + +async fn run_generate_ca(config: &LucidApiConfig, force: bool) -> anyhow::Result<()> { + // Load encryption key + let encryption_key = EncryptedCa::encryption_key_from_env() + .map_err(|e| anyhow::anyhow!("Failed to load encryption key: {}", e))?; + + // Connect to MongoDB + let db = MongoDBStorage::new(&config.mongodb_uri).await?; + + // Generate CA + info!("Generating CA certificate..."); + let ca_info = generate_ca(&db, &encryption_key, force) + .await + .map_err(|e| anyhow::anyhow!("Failed to generate CA: {}", e))?; + + println!("\n✅ CA certificate generated successfully!\n"); + println!("Fingerprint: {}", ca_info.fingerprint); + println!("Issued: {}", ca_info.issued_at); + println!("Expires: {}", ca_info.expires_at); + println!( + "\nAgents can fetch the CA certificate from: {}/.well-known/lucid/agent", + config.public_url + ); + + Ok(()) +} + +async fn run_server(config: LucidApiConfig) { + let (router, api) = server::make(config.clone()).await; + + if config.dump_openapi { + let json = api.to_pretty_json().unwrap(); + print!("{}", json); + return; + } + + tracing_subscriber::fmt() + .with_env_filter( + EnvFilter::try_from_default_env() + .unwrap_or("lucid_api=info,lucid_common=info,lucid_db=info".into()), + ) + .pretty() + .init(); + + if config.tls.enabled { + run_tls_server(config, router).await; + } else { + run_plain_server(config, router).await; + } +} + +async fn run_plain_server(config: LucidApiConfig, router: axum::Router) { + let listener = TcpListener::bind(config.bind_addr) + .await + .expect("Failed to bind to address"); + + info!("Listening on http://{}", config.bind_addr); + + axum::serve(listener, router) + .await + .expect("Failed to start server"); +} + +async fn run_tls_server(config: LucidApiConfig, router: axum::Router) { + use axum_server::tls_rustls::RustlsConfig; + use std::sync::Arc; + + let cert_path = config + .tls + .cert_path + .as_ref() + .expect("TLS enabled but LUCID_API_TLS_CERT not set"); + let key_path = config + .tls + .key_path + .as_ref() + .expect("TLS enabled but LUCID_API_TLS_KEY not set"); + + // Load rustls config + let tls_config = if let Some(ca_cert_path) = &config.tls.ca_cert_path { + // mTLS mode - verify client certificates + info!("Configuring mTLS with CA cert from {:?}", ca_cert_path); + + let ca_cert = std::fs::read(ca_cert_path).expect("Failed to read CA certificate"); + let server_cert = std::fs::read(cert_path).expect("Failed to read server certificate"); + let server_key = std::fs::read(key_path).expect("Failed to read server key"); + + // Parse CA cert for client verification + let ca_certs: Vec<_> = rustls_pemfile::certs(&mut ca_cert.as_slice()) + .collect::, _>>() + .expect("Failed to parse CA certificate"); + + let mut root_store = rustls::RootCertStore::empty(); + for cert in ca_certs { + root_store + .add(cert) + .expect("Failed to add CA to root store"); + } + + // Parse server cert chain + let server_certs: Vec<_> = rustls_pemfile::certs(&mut server_cert.as_slice()) + .collect::, _>>() + .expect("Failed to parse server certificate"); + + // Parse server key + let server_key = rustls_pemfile::private_key(&mut server_key.as_slice()) + .expect("Failed to parse server key") + .expect("No private key found in file"); + + // Build client verifier that requests certs + let client_verifier = rustls::server::WebPkiClientVerifier::builder(Arc::new(root_store)) + .build() + .expect("Failed to build client verifier"); + + let rustls_config = rustls::ServerConfig::builder() + .with_client_cert_verifier(client_verifier) + .with_single_cert(server_certs, server_key) + .expect("Failed to build TLS config"); + + RustlsConfig::from_config(Arc::new(rustls_config)) + } else { + // TLS only, no client cert verification + RustlsConfig::from_pem_file(cert_path, key_path) + .await + .expect("Failed to load TLS configuration") + }; + + info!("Listening on https://{}", config.bind_addr); + + axum_server::bind_rustls(config.bind_addr, tls_config) + .serve(router.into_make_service()) + .await + .expect("Failed to start TLS server"); +} diff --git a/api/src/server.rs b/api/src/server.rs new file mode 100644 index 0000000..7a93d92 --- /dev/null +++ b/api/src/server.rs @@ -0,0 +1,176 @@ +use axum::{ + Router, + extract::MatchedPath, + http::{HeaderName, Request}, + routing::get, +}; +use lucid_common::views::ApiErrorResponse; +use tower::ServiceBuilder; +use tower_http::{ + cors::{AllowOrigin, CorsLayer}, + request_id::{MakeRequestUuid, PropagateRequestIdLayer, SetRequestIdLayer}, + trace::TraceLayer, +}; +use tracing::info_span; +use utoipa::{ + PartialSchema, ToSchema, + openapi::{Contact, Info, License, OpenApi, RefOr, Response, path::Operation}, +}; +use utoipa_axum::{router::OpenApiRouter, routes}; + +use crate::{ + auth::AuthManager, config::LucidApiConfig, context::ApiContext, error::ApiError, handlers, +}; + +const REQUEST_ID_HEADER: &str = "x-request-id"; + +pub async fn make(cfg: LucidApiConfig) -> (Router, OpenApi) { + // TODO: Wire up auth providers properly + let auth_manager = AuthManager::new(); + + let context = ApiContext::new(cfg.clone(), auth_manager) + .await + .expect("Failed to initialize API context"); + + let cors_public_url = cfg.public_url.clone(); + let x_request_id = HeaderName::from_static(REQUEST_ID_HEADER); + let middleware = ServiceBuilder::new() + .layer(SetRequestIdLayer::new( + x_request_id.clone(), + MakeRequestUuid, + )) + .layer( + TraceLayer::new_for_http().make_span_with(|req: &Request<_>| { + // Log the request ID as generated + let request_id = req.headers().get(REQUEST_ID_HEADER); + let span = info_span!( + "http_request", + http.request.method = req.method().to_string(), + http.request.id = Option::<&str>::None, + http.route = Option::<&str>::None, + url.full = Option::<&str>::None, + ); + + span.record("url.full", req.uri().path()); + + if let Some(request_id) = request_id { + span.record("http.request.id", request_id.to_str().unwrap()); + } + + if let Some(path) = req.extensions().get::() { + span.record("http.route", path.as_str()); + } + + span + }), + ) + .layer( + CorsLayer::new() + .allow_credentials(true) + .allow_origin(AllowOrigin::predicate(move |origin, request_parts| { + if request_parts.uri.path().starts_with("/.well-known") { + return true; + } + origin.as_bytes().starts_with(cors_public_url.as_bytes()) + })), + ) + .layer(PropagateRequestIdLayer::new(x_request_id)); + + let openapi = OpenApi::builder() + .info( + Info::builder() + .title("Lucid API Reference") + .version(env!("CARGO_PKG_VERSION")) + .license(Some( + License::builder() + .name("Apache 2.0 License") + .identifier(Some(env!("CARGO_PKG_LICENSE"))) + .url("https://github.com/roostmoe/lucid/blob/main/LICENSE".into()) + .build(), + )) + .contact(Some( + Contact::builder() + .name(Some("Roost team")) + .email("hello@roost.moe".into()) + .url("https://github.com/roostmoe/lucid".into()) + .build(), + )), + ) + .build(); + + let (r, mut a) = OpenApiRouter::with_openapi(openapi) + .routes(routes!(handlers::activation_keys::create_activation_key)) + .routes(routes!(handlers::activation_keys::list_activation_keys)) + .routes(routes!(handlers::activation_keys::get_activation_key)) + .routes(routes!(handlers::activation_keys::delete_activation_key)) + .routes(routes!(handlers::ca::generate_ca)) + .routes(routes!(handlers::ca::import_ca)) + .routes(routes!(handlers::ca::list_cas)) + .routes(routes!(handlers::ca::get_ca)) + .routes(routes!(handlers::ca::delete_ca)) + .routes(routes!(handlers::agents::register_agent)) + .routes(routes!(handlers::auth::auth_login)) + .routes(routes!(handlers::auth::auth_logout)) + .routes(routes!(handlers::auth::auth_whoami)) + .routes(routes!(handlers::hosts::list_hosts)) + .routes(routes!(handlers::hosts::get_host)) + .routes(routes!(handlers::jwks::get_jwks)) + .routes(routes!(handlers::jwks::get_openid_configuration)) + .routes(routes!(handlers::well_known::get_agent_well_known)) + .routes(routes!(handlers::well_known::get_server_well_known)) + .route("/healthz", get(handlers::health_check)) + .fallback(not_found_handler) + .layer(middleware) + .with_state(context) + .split_for_parts(); + + a.components.as_mut().unwrap().schemas.insert( + ApiErrorResponse::name().to_string(), + ApiErrorResponse::schema(), + ); + a.paths.paths.iter_mut().for_each(|(_path, item)| { + apply_default_errors(&mut item.get); + apply_default_errors(&mut item.post); + apply_default_errors(&mut item.patch); + apply_default_errors(&mut item.put); + apply_default_errors(&mut item.delete); + apply_default_errors(&mut item.trace); + apply_default_errors(&mut item.head); + apply_default_errors(&mut item.options); + }); + + (r, a) +} + +async fn not_found_handler() -> ApiError { + ApiError::not_found() +} + +fn apply_default_errors(item: &mut Option) { + if let Some(item) = item { + item.responses + .responses + .insert("400".into(), error_resp("Client or validation error")); + item.responses + .responses + .insert("401".into(), error_resp("Unauthorized")); + item.responses + .responses + .insert("403".into(), error_resp("Forbidden")); + item.responses + .responses + .insert("404".into(), error_resp("Not found")); + item.responses + .responses + .insert("500".into(), error_resp("Internal server error")); + } +} + +fn error_resp(summary: &str) -> RefOr { + RefOr::Ref( + utoipa::openapi::Ref::builder() + .summary(summary) + .ref_location_from_schema_name(ApiErrorResponse::name()) + .build(), + ) +} diff --git a/common/Cargo.toml b/common/Cargo.toml new file mode 100644 index 0000000..1b7dec3 --- /dev/null +++ b/common/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "lucid-common" +version = "0.1.0" +edition.workspace = true +license.workspace = true + +[dependencies] +anyhow.workspace = true +async-trait.workspace = true +chrono.workspace = true +serde.workspace = true +thiserror.workspace = true +tracing.workspace = true +utoipa.workspace = true +ulid.workspace = true + +[dev-dependencies] +tokio.workspace = true diff --git a/common/src/caller.rs b/common/src/caller.rs new file mode 100644 index 0000000..4cae73e --- /dev/null +++ b/common/src/caller.rs @@ -0,0 +1,539 @@ +//! Authentication and authorization primitives. +//! +//! This module provides Lucid's Role-Based Access Control (RBAC) system through +//! the [`Caller`], [`Role`], and [`Permission`] types. +//! +//! # Overview +//! +//! - **[`Caller`]**: Who is making the request (user, agent, service account, system) +//! - **[`Role`]**: What level of access they have (Admin, Viewer, etc.) +//! - **[`Permission`]**: What specific actions they can perform (read, write, delete) +//! +//! # Quick Start +//! +//! ``` +//! use lucid_common::caller::{Caller, Role, Permission}; +//! +//! let caller = Caller::User { +//! id: "user123".into(), +//! display_name: "Alice".into(), +//! email: "alice@example.com".into(), +//! roles: vec![Role::Admin], +//! }; +//! +//! // Check permissions +//! if caller.can(Permission::HostsRead) { +//! println!("Can view hosts"); +//! } +//! +//! // Require permissions (fails with error if missing) +//! caller.require(Permission::HostsWrite)?; +//! # Ok::<(), lucid_common::caller::CallerError>(()) +//! ``` +//! +//! # See Also +//! +//! For detailed documentation on how authentication and authorization work in Lucid, +//! see `docs/ARCHITECTURE_AUTH.adoc` in the repository root. + +use std::fmt::{self, Display}; +use thiserror::Error; + +/// Fine-grained permissions for Lucid's RBAC system. +/// +/// Permissions are atomic capabilities that control access to specific operations. +/// They're grouped by resource type (hosts, users, service accounts) and action +/// (read, write, delete). +/// +/// # Examples +/// +/// ``` +/// use lucid_common::caller::Permission; +/// +/// // Check if a permission allows reading +/// match Permission::HostsRead { +/// Permission::HostsRead | Permission::UsersRead => println!("read-only"), +/// _ => println!("write or delete"), +/// } +/// ``` +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum Permission { + /// View host inventory, metadata, and telemetry + HostsRead, + /// Create and update hosts + HostsWrite, + /// Delete hosts from inventory + HostsDelete, + + /// View user profiles and roles + UsersRead, + /// Create and update users + UsersWrite, + /// Delete user accounts + UsersDelete, + + /// View service account details + ServiceAccountsRead, + /// Create and update service accounts + ServiceAccountsWrite, + /// Delete service accounts + ServiceAccountsDelete, + + /// View activation keys + ActivationKeysRead, + /// Create activation keys + ActivationKeysWrite, + /// Delete activation keys + ActivationKeysDelete, + + /// View certificate authorities + CaRead, + /// Create certificate authorities + CaWrite, + /// Delete certificate authorities + CaDelete, + + // Agent-specific permissions + /// Submit telemetry data + TelemetrySubmit, + /// Refresh agent certificate + AgentRefresh, + /// Read own agent details + AgentReadSelf, + /// Update own host information + HostUpdateSelf, +} + +impl Permission { + pub fn as_str(&self) -> &'static str { + match self { + Permission::HostsRead => "hosts:read", + Permission::HostsWrite => "hosts:write", + Permission::HostsDelete => "hosts:delete", + Permission::UsersRead => "users:read", + Permission::UsersWrite => "users:write", + Permission::UsersDelete => "users:delete", + Permission::ServiceAccountsRead => "service_accounts:read", + Permission::ServiceAccountsWrite => "service_accounts:write", + Permission::ServiceAccountsDelete => "service_accounts:delete", + Permission::ActivationKeysRead => "activation_keys:read", + Permission::ActivationKeysWrite => "activation_keys:write", + Permission::ActivationKeysDelete => "activation_keys:delete", + Permission::CaRead => "ca:read", + Permission::CaWrite => "ca:write", + Permission::CaDelete => "ca:delete", + Permission::TelemetrySubmit => "telemetry:submit", + Permission::AgentRefresh => "agent:refresh", + Permission::AgentReadSelf => "agent:read_self", + Permission::HostUpdateSelf => "host:update_self", + } + } +} + +/// Roles bundle permissions together for easier assignment. +/// +/// Instead of assigning individual permissions, you assign roles to callers. +/// Each role grants a curated set of permissions appropriate for that access level. +/// +/// # Available Roles +/// +/// - **Admin**: Full access to all resources and operations +/// - **Viewer**: Read-only access to hosts, users, and service accounts +/// - **Agent**: Agent-specific permissions for telemetry and self-management +/// +/// # Examples +/// +/// ``` +/// use lucid_common::caller::{Role, Permission}; +/// +/// let admin = Role::Admin; +/// assert!(admin.has_permission(Permission::HostsDelete)); +/// +/// let viewer = Role::Viewer; +/// assert!(viewer.has_permission(Permission::HostsRead)); +/// assert!(!viewer.has_permission(Permission::HostsWrite)); +/// ``` +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Role { + /// Full administrative access - all permissions granted + Admin, + /// Read-only access to all resources + Viewer, + /// Agent access - can submit telemetry and manage self + Agent, +} + +impl Role { + pub fn permissions(&self) -> &'static [Permission] { + match self { + Role::Admin => &[ + Permission::ActivationKeysRead, + Permission::ActivationKeysWrite, + Permission::ActivationKeysDelete, + Permission::CaRead, + Permission::CaWrite, + Permission::CaDelete, + Permission::HostsRead, + Permission::HostsWrite, + Permission::HostsDelete, + Permission::UsersRead, + Permission::UsersWrite, + Permission::UsersDelete, + Permission::ServiceAccountsRead, + Permission::ServiceAccountsWrite, + Permission::ServiceAccountsDelete, + Permission::TelemetrySubmit, + Permission::AgentRefresh, + Permission::AgentReadSelf, + Permission::HostUpdateSelf, + ], + Role::Viewer => &[ + Permission::ActivationKeysRead, + Permission::CaRead, + Permission::HostsRead, + Permission::UsersRead, + Permission::ServiceAccountsRead, + ], + Role::Agent => &[ + Permission::TelemetrySubmit, + Permission::AgentRefresh, + Permission::AgentReadSelf, + Permission::HostUpdateSelf, + ], + } + } + + pub fn has_permission(&self, permission: Permission) -> bool { + self.permissions().contains(&permission) + } +} + +/// Authenticated identity that can make API requests. +/// +/// `Caller` represents who is making a request and what they're allowed to do. +/// All API operations receive a `Caller` and check permissions before proceeding. +/// +/// # Variants +/// +/// - **User**: Human user authenticated via session token +/// - **Agent**: Host agent reporting telemetry (future: agent-specific permissions) +/// - **ServiceAccount**: API token for automation/integrations +/// - **System**: Internal operations with unrestricted access +/// +/// # Permission Checking +/// +/// Use [`can()`](Caller::can) to check permissions without failing: +/// ``` +/// # use lucid_common::caller::{Caller, Permission, Role}; +/// let caller = Caller::User { +/// id: "user123".into(), +/// display_name: "Alice".into(), +/// email: "alice@example.com".into(), +/// roles: vec![Role::Viewer], +/// }; +/// +/// if caller.can(Permission::HostsRead) { +/// // fetch hosts +/// } +/// ``` +/// +/// Use [`require()`](Caller::require) to enforce permissions and fail with CallerError: +/// ``` +/// # use lucid_common::caller::{Caller, Permission, Role}; +/// # let caller = Caller::User { +/// # id: "user123".into(), +/// # display_name: "Alice".into(), +/// # email: "alice@example.com".into(), +/// # roles: vec![Role::Admin], +/// # }; +/// caller.require(Permission::HostsWrite)?; // fails if missing permission +/// // proceed with write operation +/// # Ok::<(), lucid_common::caller::CallerError>(()) +/// ``` +/// +/// # Creating Callers +/// +/// Callers are typically created by: +/// - Auth extractors (from session tokens, API keys, etc.) +/// - Database models via `DbUser::to_caller()` +/// - System-level operations using `Caller::System` +#[derive(Debug, Clone)] +pub enum Caller { + User { + id: String, + display_name: String, + email: String, + roles: Vec, + }, + Agent { + id: String, + name: String, + roles: Vec, + }, + System, + ServiceAccount { + id: String, + name: String, + description: Option, + roles: Vec, + }, +} + +impl Caller { + pub fn id(&self) -> &str { + match self { + Caller::User { id, .. } + | Caller::Agent { id, .. } + | Caller::ServiceAccount { id, .. } => id, + Caller::System => "system", + } + } + + pub fn display_name(&self) -> Option<&str> { + match self { + Caller::User { display_name, .. } => Some(display_name), + Caller::Agent { name, .. } => Some(name), + Caller::ServiceAccount { name, .. } => Some(name), + Caller::System => None, + } + } + + pub fn kind(&self) -> &'static str { + match self { + Caller::User { .. } => "user", + Caller::Agent { .. } => "agent", + Caller::System => "system", + Caller::ServiceAccount { .. } => "service_account", + } + } + + pub fn has_role(&self, role: Role) -> bool { + match self { + Caller::User { roles, .. } + | Caller::Agent { roles, .. } + | Caller::ServiceAccount { roles, .. } => roles.contains(&role), + Caller::System => true, + } + } + + /// Check if caller has a specific permission without failing. + /// + /// Returns `true` if the caller's roles include this permission. + /// System callers always return `true`. + /// + /// # Examples + /// + /// ``` + /// # use lucid_common::caller::{Caller, Permission}; + /// let caller = Caller::System; + /// assert!(caller.can(Permission::HostsDelete)); + /// ``` + pub fn can(&self, permission: Permission) -> bool { + match self { + Caller::System => true, + Caller::User { roles, .. } + | Caller::Agent { roles, .. } + | Caller::ServiceAccount { roles, .. } => { + roles.iter().any(|r| r.has_permission(permission)) + } + } + } + + /// Require a permission or return an error. + /// + /// Use this at the start of operations that need specific permissions. + /// Returns `Ok(())` if allowed, `Err(CallerError::Forbidden)` if not. + /// + /// # Examples + /// + /// ``` + /// # use lucid_common::caller::{Caller, Permission, Role}; + /// # let caller = Caller::User { + /// # id: "user123".into(), + /// # display_name: "Alice".into(), + /// # email: "alice@example.com".into(), + /// # roles: vec![Role::Viewer], + /// # }; + /// // This will fail because Viewer doesn't have write permission + /// let result = caller.require(Permission::HostsWrite); + /// assert!(result.is_err()); + /// ``` + pub fn require(&self, permission: Permission) -> Result<(), CallerError> { + if self.can(permission) { + Ok(()) + } else { + Err(CallerError::forbidden(permission.as_str())) + } + } + + /// Require a specific role or return an error. + /// + /// Less common than permission checks, but useful when you need + /// to restrict operations to specific roles rather than individual permissions. + /// + /// # Examples + /// + /// ``` + /// # use lucid_common::caller::{Caller, Role}; + /// let caller = Caller::System; + /// assert!(caller.require_role(Role::Admin).is_ok()); // System has all roles + /// ``` + pub fn require_role(&self, role: Role) -> Result<(), CallerError> { + if self.has_role(role) { + Ok(()) + } else { + Err(CallerError::Forbidden { + permission: format!("role:{:?}", role), + }) + } + } +} + +impl Display for Caller { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Caller::User { + id, display_name, .. + } => { + write!(f, "User({id}, {display_name})") + } + Caller::Agent { id, name, .. } => { + write!(f, "Agent({id}, {name})") + } + Caller::System => write!(f, "System"), + Caller::ServiceAccount { id, name, .. } => { + write!(f, "ServiceAccount({id}, {name})") + } + } + } +} + +/// Errors that occur during authentication or authorization. +#[derive(Debug, Error)] +pub enum CallerError { + /// Authentication failed - invalid or missing credentials + #[error("Unauthorized: {reason}")] + Unauthorized { reason: String }, + + /// Authorization failed - authenticated but lacks permission + #[error("Missing permission: {permission}")] + Forbidden { permission: String }, + + /// Catch-all for unexpected errors + #[error("An unspecified error occurred: {0}")] + Anyhow(#[from] anyhow::Error), +} + +impl CallerError { + pub fn unauthorized(reason: Option) -> Self { + Self::Unauthorized { + reason: reason.unwrap_or_else(|| "No reason provided".to_string()), + } + } + + pub fn forbidden(permission: &str) -> Self { + Self::Forbidden { + permission: permission.into(), + } + } +} + +#[cfg(test)] +mod test { + use super::*; + + fn test_user() -> Caller { + Caller::User { + id: "user123".to_string(), + display_name: "Test User".to_string(), + email: "test@example.com".to_string(), + roles: vec![Role::Viewer], + } + } + + fn test_admin() -> Caller { + Caller::User { + id: "admin456".to_string(), + display_name: "Admin User".to_string(), + email: "admin@example.com".to_string(), + roles: vec![Role::Admin], + } + } + + #[test] + fn caller_id_returns_correct_value() { + let caller = test_user(); + assert_eq!(caller.id(), "user123"); + assert_eq!(Caller::System.id(), "system"); + } + + #[test] + fn caller_display_name_returns_correct_value() { + let caller = test_user(); + assert_eq!(caller.display_name(), Some("Test User")); + assert_eq!(Caller::System.display_name(), None); + } + + #[test] + fn caller_kind_returns_correct_string() { + assert_eq!(test_user().kind(), "user"); + assert_eq!(Caller::System.kind(), "system"); + } + + #[test] + fn viewer_can_read_but_not_write() { + let caller = test_user(); + assert!(caller.can(Permission::HostsRead)); + assert!(!caller.can(Permission::HostsWrite)); + assert!(!caller.can(Permission::HostsDelete)); + } + + #[test] + fn admin_can_do_everything() { + let caller = test_admin(); + assert!(caller.can(Permission::HostsRead)); + assert!(caller.can(Permission::HostsWrite)); + assert!(caller.can(Permission::HostsDelete)); + assert!(caller.can(Permission::UsersRead)); + assert!(caller.can(Permission::UsersWrite)); + } + + #[test] + fn system_can_do_everything() { + let caller = Caller::System; + assert!(caller.can(Permission::HostsRead)); + assert!(caller.can(Permission::HostsWrite)); + assert!(caller.can(Permission::HostsDelete)); + assert!(caller.can(Permission::ServiceAccountsDelete)); + } + + #[test] + fn require_fails_on_missing_permission() { + let caller = test_user(); + assert!(caller.require(Permission::HostsWrite).is_err()); + } + + #[test] + fn require_succeeds_on_present_permission() { + let caller = test_user(); + assert!(caller.require(Permission::HostsRead).is_ok()); + } + + #[test] + fn has_role_works_correctly() { + let viewer = test_user(); + let admin = test_admin(); + + assert!(viewer.has_role(Role::Viewer)); + assert!(!viewer.has_role(Role::Admin)); + assert!(admin.has_role(Role::Admin)); + assert!(Caller::System.has_role(Role::Admin)); + } + + #[test] + fn display_formats_correctly() { + let caller = test_user(); + assert_eq!(format!("{}", caller), "User(user123, Test User)"); + assert_eq!(format!("{}", Caller::System), "System"); + } +} diff --git a/common/src/lib.rs b/common/src/lib.rs new file mode 100644 index 0000000..6ef68ba --- /dev/null +++ b/common/src/lib.rs @@ -0,0 +1,4 @@ +pub mod params; +pub mod views; + +pub mod caller; diff --git a/common/src/params/agent.rs b/common/src/params/agent.rs new file mode 100644 index 0000000..a626999 --- /dev/null +++ b/common/src/params/agent.rs @@ -0,0 +1,11 @@ +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; + +/// Request body for agent registration. +#[derive(Debug, Clone, Deserialize, Serialize, ToSchema)] +pub struct RegisterAgentRequest { + /// CSR in PEM format + pub csr_pem: String, + /// Hostname of the agent + pub hostname: String, +} diff --git a/common/src/params/auth/mod.rs b/common/src/params/auth/mod.rs new file mode 100644 index 0000000..05bf032 --- /dev/null +++ b/common/src/params/auth/mod.rs @@ -0,0 +1,11 @@ +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; + +#[derive(Debug, Clone, Deserialize, Serialize, ToSchema)] +pub struct AuthLoginParams { + /// The username or email of the user to authenticate as. + pub username: String, + + /// The password of the user to authenticate as. + pub password: String, +} diff --git a/common/src/params/host.rs b/common/src/params/host.rs new file mode 100644 index 0000000..394aa2e --- /dev/null +++ b/common/src/params/host.rs @@ -0,0 +1,9 @@ +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +pub struct CreateHostParams { + /// The hostname of the host to create. This should be a fully qualified + /// domain name (FQDN) or an IP address. + pub hostname: String, +} diff --git a/common/src/params/mod.rs b/common/src/params/mod.rs new file mode 100644 index 0000000..f9b9ca8 --- /dev/null +++ b/common/src/params/mod.rs @@ -0,0 +1,29 @@ +//! Input parameters for the various functions within Lucid. + +use serde::{Deserialize, Serialize}; +use utoipa::{IntoParams, ToSchema}; + +mod agent; +pub use agent::*; + +mod auth; +pub use auth::*; + +mod host; +pub use host::*; + +mod user; +pub use user::*; + +/// Parameters for paginating through a list of records. This is used by the +/// various list endpoints to allow clients to paginate through large sets of +/// records. +#[derive(Debug, Clone, Deserialize, Serialize, IntoParams, ToSchema)] +pub struct PaginationParams { + /// The next page token, if any. This is acquired by requesting a paginated + /// set of records and looking at the `next_token` or `prev_token` field. + pub page: Option, + + /// The maximum number of results to return. + pub limit: Option, +} diff --git a/common/src/params/user.rs b/common/src/params/user.rs new file mode 100644 index 0000000..8fb27e6 --- /dev/null +++ b/common/src/params/user.rs @@ -0,0 +1,5 @@ +pub struct CreateLocalUserParams { + pub display_name: String, + pub email: String, + pub password: String, +} diff --git a/common/src/views/activation_key.rs b/common/src/views/activation_key.rs new file mode 100644 index 0000000..64d6bc8 --- /dev/null +++ b/common/src/views/activation_key.rs @@ -0,0 +1,19 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use ulid::Ulid; +use utoipa::ToSchema; + +/// An activation key used to bootstrap host agent configuration. +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +pub struct ActivationKey { + /// Internal database ID + pub id: Ulid, + /// User-provided key identifier + pub key_id: String, + /// Human-readable description + pub description: String, + /// Whether or not the key has been used to register an agent + pub used: bool, + /// When the key was created + pub created_at: DateTime, +} diff --git a/common/src/views/agent.rs b/common/src/views/agent.rs new file mode 100644 index 0000000..2e0d054 --- /dev/null +++ b/common/src/views/agent.rs @@ -0,0 +1,19 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; + +/// Response body for a successful agent registration. +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +pub struct RegisterAgentResponse { + /// Agent UUID (ObjectId as hex string) + pub agent_id: String, + /// Signed certificate in PEM format + pub certificate_pem: String, + /// CA certificate in PEM format + pub ca_certificate_pem: String, + /// Certificate expiration time + #[serde(with = "chrono::serde::ts_seconds")] + pub expires_at: DateTime, + /// API base URL for future requests + pub api_base_url: String, +} diff --git a/common/src/views/auth/mod.rs b/common/src/views/auth/mod.rs new file mode 100644 index 0000000..a5e31b2 --- /dev/null +++ b/common/src/views/auth/mod.rs @@ -0,0 +1,30 @@ +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; + +/// Response for the login endpoint. +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +#[serde(tag = "token_type", rename_all = "PascalCase")] +pub enum AuthLoginResponse { + /// The session cookie for the authenticated user. This cookie should be + /// included in subsequent requests to authenticate the user. + Session { + /// CSRF token that must be included in X-CSRF-Token header for mutating requests + csrf_token: String, + }, + + /// The access token for the authenticated user. This token should be + /// included in the `Authorization` header of subsequent requests to + /// authenticate the user. + Bearer { + /// The access token for the authenticated user. + access_token: String, + + /// The refresh token for the user's new session. + refresh_token: String, + + /// How long the token is valid for, in seconds. After this time has + /// elapsed, the user will need to use the refresh token to obtain a + /// new access token. + expires_in: i64, + }, +} diff --git a/common/src/views/ca.rs b/common/src/views/ca.rs new file mode 100644 index 0000000..54d64a0 --- /dev/null +++ b/common/src/views/ca.rs @@ -0,0 +1,17 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use ulid::Ulid; +use utoipa::ToSchema; + +/// A certificate authority managed by Lucid. +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +pub struct Ca { + /// Internal database ID + pub id: Ulid, + /// CA certificate in PEM format + pub cert_pem: String, + /// SHA-256 fingerprint of the certificate (format: `sha256:`) + pub fingerprint: String, + /// When this CA was created + pub created_at: DateTime, +} diff --git a/common/src/views/host.rs b/common/src/views/host.rs new file mode 100644 index 0000000..7a1c4a5 --- /dev/null +++ b/common/src/views/host.rs @@ -0,0 +1,69 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use ulid::Ulid; +use utoipa::ToSchema; + +#[derive(Debug, Clone, Deserialize, Serialize, ToSchema)] +pub struct Host { + /// The unique identifier for this host. + pub id: Ulid, + + /// Hostname of the machine. This is a human-readable identifier for the + /// host, and is not guaranteed to be unique. + pub hostname: String, + + /// The ID of the host's operating system, read from /etc/os-release. + pub os_id: String, + + /// The name of the host's operating system, read from /etc/os-release. + pub os_name: String, + + /// The version of the host's operating system, read from /etc/os-release. + pub os_version: String, + + /// The CPU architecture of the host, read from `uname -m`. + pub architecture: String, + + /// Network interfaces associated with this host. This is a one-to-many + /// relationship, as a host can have multiple network interfaces. + pub ifaces: Vec, + + pub created_at: DateTime, + pub last_seen_at: DateTime, + pub updated_at: DateTime, +} + +#[derive(Debug, Clone, Deserialize, Serialize, ToSchema)] +pub struct NetworkInterface { + /// The unique identifier for this network interface. + pub id: Ulid, + + /// The unique identifier for the host that this network interface is + /// associated with. + #[serde(skip_serializing_if = "Option::is_none")] + pub host_id: Option, + + /// The name of the network interface. + pub iface: String, + + /// The state of the network interface. This can be "up", "down", or + /// "unknown". + pub state: NetworkInterfaceState, + + /// The IP addresses associated with this network interface. + pub ip_addrs: Vec, +} + +#[derive(Debug, Clone, Deserialize, Serialize, ToSchema, Default)] +pub enum NetworkInterfaceState { + /// The network interface is up and operational. + Up, + + /// The network interface is down and not operational. + Down, + #[default] + + /// The state of the network interface is unknown. This can occur if the + /// state cannot be determined for some reason. + Unknown, +} diff --git a/common/src/views/mod.rs b/common/src/views/mod.rs new file mode 100644 index 0000000..a8923de --- /dev/null +++ b/common/src/views/mod.rs @@ -0,0 +1,54 @@ +//! Output views for the various functions within Lucid. + +use std::fmt::Debug; + +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; + +mod activation_key; +pub use activation_key::*; + +mod agent; +pub use agent::*; + +mod auth; +pub use auth::*; + +mod ca; +pub use ca::*; + +mod host; +pub use host::*; + +mod user; +pub use user::*; + +/// Parameters for paginating through a list of records. This is used by the +/// various list endpoints to allow clients to paginate through large sets of +/// records. +#[derive(Debug, Clone, Deserialize, Serialize, ToSchema)] +pub struct PaginatedList { + pub items: Vec, + + /// The next page token, if any. This is acquired by requesting a paginated + /// set of records and looking at the `next_token` or `prev_token` field. + pub next_token: Option, + + /// The maximum number of results to return. + pub limit: Option, +} + +/// An error response for an API endpoint. This is used to return errors to the +/// client in a consistent format. +#[derive(Debug, Clone, Deserialize, Serialize, ToSchema)] +pub struct ApiErrorResponse { + /// An optional error code that can be used to identify the type of error + /// that occurred. + pub code: Option, + + /// A human-readable message describing the error that occurred. + pub message: String, + + #[serde(skip_serializing_if = "Option::is_none")] + pub details: Option, +} diff --git a/common/src/views/user.rs b/common/src/views/user.rs new file mode 100644 index 0000000..3993488 --- /dev/null +++ b/common/src/views/user.rs @@ -0,0 +1,19 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use ulid::Ulid; +use utoipa::ToSchema; + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +pub struct User { + /// The unique identifier for this user. + pub id: Ulid, + + /// The user's display name. + pub display_name: String, + + /// The user's email address. + pub email: String, + + pub created_at: DateTime, + pub updated_at: DateTime, +} diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..07334a2 --- /dev/null +++ b/compose.yaml @@ -0,0 +1,12 @@ +--- +volumes: + mongodb: + +services: + mongo: + image: mongodb/mongodb-community-server:8.0-ubi8 + restart: always + ports: + - 27017:27017 + volumes: + - mongodb:/data/db diff --git a/console/.gitignore b/console/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/console/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/console/README.md b/console/README.md new file mode 100644 index 0000000..d4b9dd4 --- /dev/null +++ b/console/README.md @@ -0,0 +1,3 @@ +# React + TypeScript + Vite + shadcn/ui + +This is a template for a new Vite project with React, TypeScript, and shadcn/ui. diff --git a/console/bun.lock b/console/bun.lock new file mode 100644 index 0000000..511d09b --- /dev/null +++ b/console/bun.lock @@ -0,0 +1,1364 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "vite-app", + "dependencies": { + "@base-ui/react": "^1.2.0", + "@fontsource-variable/inter": "^5.2.8", + "@tabler/icons-react": "^3.37.1", + "@tailwindcss/vite": "^4.1.17", + "@tanstack/react-query": "^5.90.21", + "@tanstack/react-query-devtools": "^5.91.3", + "@tanstack/react-router": "^1.162.6", + "@tanstack/react-router-devtools": "^1.162.6", + "@tanstack/react-table": "^8.21.3", + "axios": "^1.13.5", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "font-logos": "^1.3.0", + "react": "^19.2.0", + "react-dom": "^19.2.0", + "shadcn": "^3.8.5", + "tailwind-merge": "^3.5.0", + "tailwindcss": "^4.1.17", + "tw-animate-css": "^1.4.0", + }, + "devDependencies": { + "@eslint/js": "^9.39.1", + "@hey-api/client-axios": "^0.9.1", + "@hey-api/openapi-ts": "^0.92.4", + "@tanstack/router-plugin": "^1.162.6", + "@types/node": "^24.10.1", + "@types/react": "^19.2.5", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^5.1.1", + "eslint": "^9.39.1", + "eslint-plugin-react-hooks": "^7.0.1", + "eslint-plugin-react-refresh": "^0.4.24", + "globals": "^16.5.0", + "typescript": "~5.9.3", + "typescript-eslint": "^8.46.4", + "vite": "^7.2.4", + }, + }, + }, + "packages": { + "@antfu/ni": ["@antfu/ni@25.0.0", "", { "dependencies": { "ansis": "^4.0.0", "fzf": "^0.5.2", "package-manager-detector": "^1.3.0", "tinyexec": "^1.0.1" }, "bin": { "na": "bin/na.mjs", "ni": "bin/ni.mjs", "nr": "bin/nr.mjs", "nci": "bin/nci.mjs", "nlx": "bin/nlx.mjs", "nun": "bin/nun.mjs", "nup": "bin/nup.mjs" } }, "sha512-9q/yCljni37pkMr4sPrI3G4jqdIk074+iukc5aFJl7kmDCCsiJrbZ6zKxnES1Gwg+i9RcDZwvktl23puGslmvA=="], + + "@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="], + + "@babel/compat-data": ["@babel/compat-data@7.29.0", "", {}, "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg=="], + + "@babel/core": ["@babel/core@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-compilation-targets": "^7.28.6", "@babel/helper-module-transforms": "^7.28.6", "@babel/helpers": "^7.28.6", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/traverse": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA=="], + + "@babel/generator": ["@babel/generator@7.29.1", "", { "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw=="], + + "@babel/helper-annotate-as-pure": ["@babel/helper-annotate-as-pure@7.27.3", "", { "dependencies": { "@babel/types": "^7.27.3" } }, "sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg=="], + + "@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.28.6", "", { "dependencies": { "@babel/compat-data": "^7.28.6", "@babel/helper-validator-option": "^7.27.1", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA=="], + + "@babel/helper-create-class-features-plugin": ["@babel/helper-create-class-features-plugin@7.28.6", "", { "dependencies": { "@babel/helper-annotate-as-pure": "^7.27.3", "@babel/helper-member-expression-to-functions": "^7.28.5", "@babel/helper-optimise-call-expression": "^7.27.1", "@babel/helper-replace-supers": "^7.28.6", "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", "@babel/traverse": "^7.28.6", "semver": "^6.3.1" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-dTOdvsjnG3xNT9Y0AUg1wAl38y+4Rl4sf9caSQZOXdNqVn+H+HbbJ4IyyHaIqNR6SW9oJpA/RuRjsjCw2IdIow=="], + + "@babel/helper-globals": ["@babel/helper-globals@7.28.0", "", {}, "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw=="], + + "@babel/helper-member-expression-to-functions": ["@babel/helper-member-expression-to-functions@7.28.5", "", { "dependencies": { "@babel/traverse": "^7.28.5", "@babel/types": "^7.28.5" } }, "sha512-cwM7SBRZcPCLgl8a7cY0soT1SptSzAlMH39vwiRpOQkJlh53r5hdHwLSCZpQdVLT39sZt+CRpNwYG4Y2v77atg=="], + + "@babel/helper-module-imports": ["@babel/helper-module-imports@7.28.6", "", { "dependencies": { "@babel/traverse": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw=="], + + "@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.28.6", "", { "dependencies": { "@babel/helper-module-imports": "^7.28.6", "@babel/helper-validator-identifier": "^7.28.5", "@babel/traverse": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA=="], + + "@babel/helper-optimise-call-expression": ["@babel/helper-optimise-call-expression@7.27.1", "", { "dependencies": { "@babel/types": "^7.27.1" } }, "sha512-URMGH08NzYFhubNSGJrpUEphGKQwMQYBySzat5cAByY1/YgIRkULnIy3tAMeszlL/so2HbeilYloUmSpd7GdVw=="], + + "@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.28.6", "", {}, "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug=="], + + "@babel/helper-replace-supers": ["@babel/helper-replace-supers@7.28.6", "", { "dependencies": { "@babel/helper-member-expression-to-functions": "^7.28.5", "@babel/helper-optimise-call-expression": "^7.27.1", "@babel/traverse": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-mq8e+laIk94/yFec3DxSjCRD2Z0TAjhVbEJY3UQrlwVo15Lmt7C2wAUbK4bjnTs4APkwsYLTahXRraQXhb1WCg=="], + + "@babel/helper-skip-transparent-expression-wrappers": ["@babel/helper-skip-transparent-expression-wrappers@7.27.1", "", { "dependencies": { "@babel/traverse": "^7.27.1", "@babel/types": "^7.27.1" } }, "sha512-Tub4ZKEXqbPjXgWLl2+3JpQAYBJ8+ikpQ2Ocj/q/r0LwE3UhENh7EUabyHjz2kCEsrRY83ew2DQdHluuiDQFzg=="], + + "@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="], + + "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], + + "@babel/helper-validator-option": ["@babel/helper-validator-option@7.27.1", "", {}, "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg=="], + + "@babel/helpers": ["@babel/helpers@7.28.6", "", { "dependencies": { "@babel/template": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw=="], + + "@babel/parser": ["@babel/parser@7.29.0", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww=="], + + "@babel/plugin-syntax-jsx": ["@babel/plugin-syntax-jsx@7.28.6", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w=="], + + "@babel/plugin-syntax-typescript": ["@babel/plugin-syntax-typescript@7.28.6", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-+nDNmQye7nlnuuHDboPbGm00Vqg3oO8niRRL27/4LYHUsHYh0zJ1xWOz0uRwNFmM1Avzk8wZbc6rdiYhomzv/A=="], + + "@babel/plugin-transform-modules-commonjs": ["@babel/plugin-transform-modules-commonjs@7.28.6", "", { "dependencies": { "@babel/helper-module-transforms": "^7.28.6", "@babel/helper-plugin-utils": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-jppVbf8IV9iWWwWTQIxJMAJCWBuuKx71475wHwYytrRGQ2CWiDvYlADQno3tcYpS/T2UUWFQp3nVtYfK/YBQrA=="], + + "@babel/plugin-transform-react-jsx-self": ["@babel/plugin-transform-react-jsx-self@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw=="], + + "@babel/plugin-transform-react-jsx-source": ["@babel/plugin-transform-react-jsx-source@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw=="], + + "@babel/plugin-transform-typescript": ["@babel/plugin-transform-typescript@7.28.6", "", { "dependencies": { "@babel/helper-annotate-as-pure": "^7.27.3", "@babel/helper-create-class-features-plugin": "^7.28.6", "@babel/helper-plugin-utils": "^7.28.6", "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", "@babel/plugin-syntax-typescript": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-0YWL2RFxOqEm9Efk5PvreamxPME8OyY0wM5wh5lHjF+VtVhdneCWGzZeSqzOfiobVqQaNCd2z0tQvnI9DaPWPw=="], + + "@babel/preset-typescript": ["@babel/preset-typescript@7.28.5", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1", "@babel/helper-validator-option": "^7.27.1", "@babel/plugin-syntax-jsx": "^7.27.1", "@babel/plugin-transform-modules-commonjs": "^7.27.1", "@babel/plugin-transform-typescript": "^7.28.5" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-+bQy5WOI2V6LJZpPVxY+yp66XdZ2yifu0Mc1aP5CQKgjn4QM5IN2i5fAZ4xKop47pr8rpVhiAeu+nDQa12C8+g=="], + + "@babel/runtime": ["@babel/runtime@7.28.6", "", {}, "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA=="], + + "@babel/template": ["@babel/template@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ=="], + + "@babel/traverse": ["@babel/traverse@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/types": "^7.29.0", "debug": "^4.3.1" } }, "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA=="], + + "@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="], + + "@base-ui/react": ["@base-ui/react@1.2.0", "", { "dependencies": { "@babel/runtime": "^7.28.6", "@base-ui/utils": "0.2.5", "@floating-ui/react-dom": "^2.1.6", "@floating-ui/utils": "^0.2.10", "tabbable": "^6.4.0", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "@types/react": "^17 || ^18 || ^19", "react": "^17 || ^18 || ^19", "react-dom": "^17 || ^18 || ^19" }, "optionalPeers": ["@types/react"] }, "sha512-O6aEQHcm+QyGTFY28xuwRD3SEJGZOBDpyjN2WvpfWYFVhg+3zfXPysAILqtM0C1kWC82MccOE/v1j+GHXE4qIw=="], + + "@base-ui/utils": ["@base-ui/utils@0.2.5", "", { "dependencies": { "@babel/runtime": "^7.28.6", "@floating-ui/utils": "^0.2.10", "reselect": "^5.1.1", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "@types/react": "^17 || ^18 || ^19", "react": "^17 || ^18 || ^19", "react-dom": "^17 || ^18 || ^19" }, "optionalPeers": ["@types/react"] }, "sha512-oYC7w0gp76RI5MxprlGLV0wze0SErZaRl3AAkeP3OnNB/UBMb6RqNf6ZSIlxOc9Qp68Ab3C2VOcJQyRs7Xc7Vw=="], + + "@dotenvx/dotenvx": ["@dotenvx/dotenvx@1.52.0", "", { "dependencies": { "commander": "^11.1.0", "dotenv": "^17.2.1", "eciesjs": "^0.4.10", "execa": "^5.1.1", "fdir": "^6.2.0", "ignore": "^5.3.0", "object-treeify": "1.1.33", "picomatch": "^4.0.2", "which": "^4.0.0" }, "bin": { "dotenvx": "src/cli/dotenvx.js" } }, "sha512-CaQcc8JvtzQhUSm9877b6V4Tb7HCotkcyud9X2YwdqtQKwgljkMRwU96fVYKnzN3V0Hj74oP7Es+vZ0mS+Aa1w=="], + + "@ecies/ciphers": ["@ecies/ciphers@0.2.5", "", { "peerDependencies": { "@noble/ciphers": "^1.0.0" } }, "sha512-GalEZH4JgOMHYYcYmVqnFirFsjZHeoGMDt9IxEnM9F7GRUUyUksJ7Ou53L83WHJq3RWKD3AcBpo0iQh0oMpf8A=="], + + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.3", "", { "os": "aix", "cpu": "ppc64" }, "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg=="], + + "@esbuild/android-arm": ["@esbuild/android-arm@0.27.3", "", { "os": "android", "cpu": "arm" }, "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA=="], + + "@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.3", "", { "os": "android", "cpu": "arm64" }, "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg=="], + + "@esbuild/android-x64": ["@esbuild/android-x64@0.27.3", "", { "os": "android", "cpu": "x64" }, "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ=="], + + "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg=="], + + "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg=="], + + "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.3", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w=="], + + "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.3", "", { "os": "freebsd", "cpu": "x64" }, "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA=="], + + "@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.3", "", { "os": "linux", "cpu": "arm" }, "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw=="], + + "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg=="], + + "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.3", "", { "os": "linux", "cpu": "ia32" }, "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg=="], + + "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA=="], + + "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw=="], + + "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA=="], + + "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ=="], + + "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.3", "", { "os": "linux", "cpu": "s390x" }, "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw=="], + + "@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.3", "", { "os": "linux", "cpu": "x64" }, "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA=="], + + "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.3", "", { "os": "none", "cpu": "arm64" }, "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA=="], + + "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.3", "", { "os": "none", "cpu": "x64" }, "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA=="], + + "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.3", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw=="], + + "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.3", "", { "os": "openbsd", "cpu": "x64" }, "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ=="], + + "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.27.3", "", { "os": "none", "cpu": "arm64" }, "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g=="], + + "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.3", "", { "os": "sunos", "cpu": "x64" }, "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA=="], + + "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA=="], + + "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.3", "", { "os": "win32", "cpu": "ia32" }, "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q=="], + + "@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.3", "", { "os": "win32", "cpu": "x64" }, "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA=="], + + "@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.9.1", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ=="], + + "@eslint-community/regexpp": ["@eslint-community/regexpp@4.12.2", "", {}, "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew=="], + + "@eslint/config-array": ["@eslint/config-array@0.21.1", "", { "dependencies": { "@eslint/object-schema": "^2.1.7", "debug": "^4.3.1", "minimatch": "^3.1.2" } }, "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA=="], + + "@eslint/config-helpers": ["@eslint/config-helpers@0.4.2", "", { "dependencies": { "@eslint/core": "^0.17.0" } }, "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw=="], + + "@eslint/core": ["@eslint/core@0.17.0", "", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ=="], + + "@eslint/eslintrc": ["@eslint/eslintrc@3.3.3", "", { "dependencies": { "ajv": "^6.12.4", "debug": "^4.3.2", "espree": "^10.0.1", "globals": "^14.0.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", "js-yaml": "^4.1.1", "minimatch": "^3.1.2", "strip-json-comments": "^3.1.1" } }, "sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ=="], + + "@eslint/js": ["@eslint/js@9.39.3", "", {}, "sha512-1B1VkCq6FuUNlQvlBYb+1jDu/gV297TIs/OeiaSR9l1H27SVW55ONE1e1Vp16NqP683+xEGzxYtv4XCiDPaQiw=="], + + "@eslint/object-schema": ["@eslint/object-schema@2.1.7", "", {}, "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA=="], + + "@eslint/plugin-kit": ["@eslint/plugin-kit@0.4.1", "", { "dependencies": { "@eslint/core": "^0.17.0", "levn": "^0.4.1" } }, "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA=="], + + "@floating-ui/core": ["@floating-ui/core@1.7.4", "", { "dependencies": { "@floating-ui/utils": "^0.2.10" } }, "sha512-C3HlIdsBxszvm5McXlB8PeOEWfBhcGBTZGkGlWc2U0KFY5IwG5OQEuQ8rq52DZmcHDlPLd+YFBK+cZcytwIFWg=="], + + "@floating-ui/dom": ["@floating-ui/dom@1.7.5", "", { "dependencies": { "@floating-ui/core": "^1.7.4", "@floating-ui/utils": "^0.2.10" } }, "sha512-N0bD2kIPInNHUHehXhMke1rBGs1dwqvC9O9KYMyyjK7iXt7GAhnro7UlcuYcGdS/yYOlq0MAVgrow8IbWJwyqg=="], + + "@floating-ui/react-dom": ["@floating-ui/react-dom@2.1.7", "", { "dependencies": { "@floating-ui/dom": "^1.7.5" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-0tLRojf/1Go2JgEVm+3Frg9A3IW8bJgKgdO0BN5RkF//ufuz2joZM63Npau2ff3J6lUVYgDSNzNkR+aH3IVfjg=="], + + "@floating-ui/utils": ["@floating-ui/utils@0.2.10", "", {}, "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ=="], + + "@fontsource-variable/inter": ["@fontsource-variable/inter@5.2.8", "", {}, "sha512-kOfP2D+ykbcX/P3IFnokOhVRNoTozo5/JxhAIVYLpea/UBmCQ/YWPBfWIDuBImXX/15KH+eKh4xpEUyS2sQQGQ=="], + + "@hey-api/client-axios": ["@hey-api/client-axios@0.9.1", "", { "peerDependencies": { "@hey-api/openapi-ts": "< 2", "axios": ">= 1.0.0 < 2" } }, "sha512-fvpOdnEz6tu5T2+IMNZW3g9mAZwaXavqpsvtapEZNtYxyYtQ+lQs9wJn/VPhZEvdXAXu8HPTCRpmfa0t1aRATA=="], + + "@hey-api/codegen-core": ["@hey-api/codegen-core@0.7.0", "", { "dependencies": { "@hey-api/types": "0.1.3", "ansi-colors": "4.1.3", "c12": "3.3.3", "color-support": "1.1.3" }, "peerDependencies": { "typescript": ">=5.5.3" } }, "sha512-HglL4B4QwpzocE+c8qDU6XK8zMf8W8Pcv0RpFDYxHuYALWLTnpDUuEsglC7NQ4vC1maoXsBpMbmwpco0N4QviA=="], + + "@hey-api/json-schema-ref-parser": ["@hey-api/json-schema-ref-parser@1.3.0", "", { "dependencies": { "@jsdevtools/ono": "7.1.3", "@types/json-schema": "7.0.15", "js-yaml": "4.1.1" } }, "sha512-3tQJ8N2egHXZjQWUeceoWrl88APWjo7gRrQ/L4HWJKnh6HowczCv7yNNFeSusPoWGV6HGdoFiCvq6UsLkrwKhg=="], + + "@hey-api/openapi-ts": ["@hey-api/openapi-ts@0.92.4", "", { "dependencies": { "@hey-api/codegen-core": "0.7.0", "@hey-api/json-schema-ref-parser": "1.3.0", "@hey-api/shared": "0.2.0", "@hey-api/types": "0.1.3", "ansi-colors": "4.1.3", "color-support": "1.1.3", "commander": "14.0.3" }, "peerDependencies": { "typescript": ">=5.5.3" }, "bin": { "openapi-ts": "bin/run.js" } }, "sha512-RA3wnL7Odr5xczuS3xpvnPClgJ/K8jivK3hvD8J0m5GBuvJFkZ1A1xp+6Ve1G0BV8p4LwxwgN1Qhb+4BFsLfMg=="], + + "@hey-api/shared": ["@hey-api/shared@0.2.0", "", { "dependencies": { "@hey-api/codegen-core": "0.7.0", "@hey-api/json-schema-ref-parser": "1.3.0", "@hey-api/types": "0.1.3", "ansi-colors": "4.1.3", "cross-spawn": "7.0.6", "open": "11.0.0", "semver": "7.7.3" }, "peerDependencies": { "typescript": ">=5.5.3" } }, "sha512-t7C+65ES12OqAE5k6DB/y5nDuTjydtqdxf/Qe4zflVn2AzGs7hO/7KjXvGXZYnpNVF7QISAcj0LEObASU9I53Q=="], + + "@hey-api/types": ["@hey-api/types@0.1.3", "", { "peerDependencies": { "typescript": ">=5.5.3" } }, "sha512-mZaiPOWH761yD4GjDQvtjS2ZYLu5o5pI1TVSvV/u7cmbybv51/FVtinFBeaE1kFQCKZ8OQpn2ezjLBJrKsGATw=="], + + "@hono/node-server": ["@hono/node-server@1.19.9", "", { "peerDependencies": { "hono": "^4" } }, "sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw=="], + + "@humanfs/core": ["@humanfs/core@0.19.1", "", {}, "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA=="], + + "@humanfs/node": ["@humanfs/node@0.16.7", "", { "dependencies": { "@humanfs/core": "^0.19.1", "@humanwhocodes/retry": "^0.4.0" } }, "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ=="], + + "@humanwhocodes/module-importer": ["@humanwhocodes/module-importer@1.0.1", "", {}, "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA=="], + + "@humanwhocodes/retry": ["@humanwhocodes/retry@0.4.3", "", {}, "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ=="], + + "@inquirer/ansi": ["@inquirer/ansi@1.0.2", "", {}, "sha512-S8qNSZiYzFd0wAcyG5AXCvUHC5Sr7xpZ9wZ2py9XR88jUz8wooStVx5M6dRzczbBWjic9NP7+rY0Xi7qqK/aMQ=="], + + "@inquirer/confirm": ["@inquirer/confirm@5.1.21", "", { "dependencies": { "@inquirer/core": "^10.3.2", "@inquirer/type": "^3.0.10" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-KR8edRkIsUayMXV+o3Gv+q4jlhENF9nMYUZs9PA2HzrXeHI8M5uDag70U7RJn9yyiMZSbtF5/UexBtAVtZGSbQ=="], + + "@inquirer/core": ["@inquirer/core@10.3.2", "", { "dependencies": { "@inquirer/ansi": "^1.0.2", "@inquirer/figures": "^1.0.15", "@inquirer/type": "^3.0.10", "cli-width": "^4.1.0", "mute-stream": "^2.0.0", "signal-exit": "^4.1.0", "wrap-ansi": "^6.2.0", "yoctocolors-cjs": "^2.1.3" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-43RTuEbfP8MbKzedNqBrlhhNKVwoK//vUFNW3Q3vZ88BLcrs4kYpGg+B2mm5p2K/HfygoCxuKwJJiv8PbGmE0A=="], + + "@inquirer/figures": ["@inquirer/figures@1.0.15", "", {}, "sha512-t2IEY+unGHOzAaVM5Xx6DEWKeXlDDcNPeDyUpsRc6CUhBfU3VQOEl+Vssh7VNp1dR8MdUJBWhuObjXCsVpjN5g=="], + + "@inquirer/type": ["@inquirer/type@3.0.10", "", { "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-BvziSRxfz5Ov8ch0z/n3oijRSEcEsHnhggm4xFZe93DHcUCTlutlq9Ox4SVENAfcRD22UQq7T/atg9Wr3k09eA=="], + + "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], + + "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="], + + "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], + + "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], + + "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], + + "@jsdevtools/ono": ["@jsdevtools/ono@7.1.3", "", {}, "sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg=="], + + "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.26.0", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-Y5RmPncpiDtTXDbLKswIJzTqu2hyBKxTNsgKqKclDbhIgg1wgtf1fRuvxgTnRfcnxtvvgbIEcqUOzZrJ6iSReg=="], + + "@mswjs/interceptors": ["@mswjs/interceptors@0.41.3", "", { "dependencies": { "@open-draft/deferred-promise": "^2.2.0", "@open-draft/logger": "^0.3.0", "@open-draft/until": "^2.0.0", "is-node-process": "^1.2.0", "outvariant": "^1.4.3", "strict-event-emitter": "^0.5.1" } }, "sha512-cXu86tF4VQVfwz8W1SPbhoRyHJkti6mjH/XJIxp40jhO4j2k1m4KYrEykxqWPkFF3vrK4rgQppBh//AwyGSXPA=="], + + "@noble/ciphers": ["@noble/ciphers@1.3.0", "", {}, "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw=="], + + "@noble/curves": ["@noble/curves@1.9.7", "", { "dependencies": { "@noble/hashes": "1.8.0" } }, "sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw=="], + + "@noble/hashes": ["@noble/hashes@1.8.0", "", {}, "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A=="], + + "@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="], + + "@nodelib/fs.stat": ["@nodelib/fs.stat@2.0.5", "", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="], + + "@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="], + + "@open-draft/deferred-promise": ["@open-draft/deferred-promise@2.2.0", "", {}, "sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA=="], + + "@open-draft/logger": ["@open-draft/logger@0.3.0", "", { "dependencies": { "is-node-process": "^1.2.0", "outvariant": "^1.4.0" } }, "sha512-X2g45fzhxH238HKO4xbSr7+wBS8Fvw6ixhTDuvLd5mqh6bJJCFAPwU9mPDxbcrRtfxv4u5IHCEH77BmxvXmmxQ=="], + + "@open-draft/until": ["@open-draft/until@2.1.0", "", {}, "sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg=="], + + "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.3", "", {}, "sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q=="], + + "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.59.0", "", { "os": "android", "cpu": "arm" }, "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg=="], + + "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.59.0", "", { "os": "android", "cpu": "arm64" }, "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q=="], + + "@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.59.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg=="], + + "@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.59.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w=="], + + "@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.59.0", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA=="], + + "@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.59.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg=="], + + "@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.59.0", "", { "os": "linux", "cpu": "arm" }, "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw=="], + + "@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.59.0", "", { "os": "linux", "cpu": "arm" }, "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA=="], + + "@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.59.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA=="], + + "@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.59.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA=="], + + "@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.59.0", "", { "os": "linux", "cpu": "none" }, "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg=="], + + "@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.59.0", "", { "os": "linux", "cpu": "none" }, "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q=="], + + "@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.59.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA=="], + + "@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.59.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA=="], + + "@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.59.0", "", { "os": "linux", "cpu": "none" }, "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg=="], + + "@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.59.0", "", { "os": "linux", "cpu": "none" }, "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg=="], + + "@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.59.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w=="], + + "@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.59.0", "", { "os": "linux", "cpu": "x64" }, "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg=="], + + "@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.59.0", "", { "os": "linux", "cpu": "x64" }, "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg=="], + + "@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.59.0", "", { "os": "openbsd", "cpu": "x64" }, "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ=="], + + "@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.59.0", "", { "os": "none", "cpu": "arm64" }, "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA=="], + + "@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.59.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A=="], + + "@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.59.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA=="], + + "@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.59.0", "", { "os": "win32", "cpu": "x64" }, "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA=="], + + "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.59.0", "", { "os": "win32", "cpu": "x64" }, "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA=="], + + "@sec-ant/readable-stream": ["@sec-ant/readable-stream@0.4.1", "", {}, "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg=="], + + "@sindresorhus/merge-streams": ["@sindresorhus/merge-streams@4.0.0", "", {}, "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ=="], + + "@tabler/icons": ["@tabler/icons@3.37.1", "", {}, "sha512-neLCWkuyNHEPXCyYu6nbN4S3g/59BTa4qyITAugYVpq1YzYNDOZooW7/vRWH98ZItXAudxdKU8muFT7y1PqzuA=="], + + "@tabler/icons-react": ["@tabler/icons-react@3.37.1", "", { "dependencies": { "@tabler/icons": "" }, "peerDependencies": { "react": ">= 16" } }, "sha512-R7UE71Jji7i4Su56Y9zU1uYEBakUejuDJvyuYVmBuUoqp/x3Pn4cv2huarexR3P0GJ2eHg4rUj9l5zccqS6K/Q=="], + + "@tailwindcss/node": ["@tailwindcss/node@4.2.1", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.19.0", "jiti": "^2.6.1", "lightningcss": "1.31.1", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.2.1" } }, "sha512-jlx6sLk4EOwO6hHe1oCGm1Q4AN/s0rSrTTPBGPM0/RQ6Uylwq17FuU8IeJJKEjtc6K6O07zsvP+gDO6MMWo7pg=="], + + "@tailwindcss/oxide": ["@tailwindcss/oxide@4.2.1", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.2.1", "@tailwindcss/oxide-darwin-arm64": "4.2.1", "@tailwindcss/oxide-darwin-x64": "4.2.1", "@tailwindcss/oxide-freebsd-x64": "4.2.1", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.1", "@tailwindcss/oxide-linux-arm64-gnu": "4.2.1", "@tailwindcss/oxide-linux-arm64-musl": "4.2.1", "@tailwindcss/oxide-linux-x64-gnu": "4.2.1", "@tailwindcss/oxide-linux-x64-musl": "4.2.1", "@tailwindcss/oxide-wasm32-wasi": "4.2.1", "@tailwindcss/oxide-win32-arm64-msvc": "4.2.1", "@tailwindcss/oxide-win32-x64-msvc": "4.2.1" } }, "sha512-yv9jeEFWnjKCI6/T3Oq50yQEOqmpmpfzG1hcZsAOaXFQPfzWprWrlHSdGPEF3WQTi8zu8ohC9Mh9J470nT5pUw=="], + + "@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.2.1", "", { "os": "android", "cpu": "arm64" }, "sha512-eZ7G1Zm5EC8OOKaesIKuw77jw++QJ2lL9N+dDpdQiAB/c/B2wDh0QPFHbkBVrXnwNugvrbJFk1gK2SsVjwWReg=="], + + "@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.2.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-q/LHkOstoJ7pI1J0q6djesLzRvQSIfEto148ppAd+BVQK0JYjQIFSK3JgYZJa+Yzi0DDa52ZsQx2rqytBnf8Hw=="], + + "@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.2.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-/f/ozlaXGY6QLbpvd/kFTro2l18f7dHKpB+ieXz+Cijl4Mt9AI2rTrpq7V+t04nK+j9XBQHnSMdeQRhbGyt6fw=="], + + "@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.2.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-5e/AkgYJT/cpbkys/OU2Ei2jdETCLlifwm7ogMC7/hksI2fC3iiq6OcXwjibcIjPung0kRtR3TxEITkqgn0TcA=="], + + "@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.2.1", "", { "os": "linux", "cpu": "arm" }, "sha512-Uny1EcVTTmerCKt/1ZuKTkb0x8ZaiuYucg2/kImO5A5Y/kBz41/+j0gxUZl+hTF3xkWpDmHX+TaWhOtba2Fyuw=="], + + "@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.2.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-CTrwomI+c7n6aSSQlsPL0roRiNMDQ/YzMD9EjcR+H4f0I1SQ8QqIuPnsVp7QgMkC1Qi8rtkekLkOFjo7OlEFRQ=="], + + "@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.2.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-WZA0CHRL/SP1TRbA5mp9htsppSEkWuQ4KsSUumYQnyl8ZdT39ntwqmz4IUHGN6p4XdSlYfJwM4rRzZLShHsGAQ=="], + + "@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.2.1", "", { "os": "linux", "cpu": "x64" }, "sha512-qMFzxI2YlBOLW5PhblzuSWlWfwLHaneBE0xHzLrBgNtqN6mWfs+qYbhryGSXQjFYB1Dzf5w+LN5qbUTPhW7Y5g=="], + + "@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.2.1", "", { "os": "linux", "cpu": "x64" }, "sha512-5r1X2FKnCMUPlXTWRYpHdPYUY6a1Ar/t7P24OuiEdEOmms5lyqjDRvVY1yy9Rmioh+AunQ0rWiOTPE8F9A3v5g=="], + + "@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.2.1", "", { "dependencies": { "@emnapi/core": "^1.8.1", "@emnapi/runtime": "^1.8.1", "@emnapi/wasi-threads": "^1.1.0", "@napi-rs/wasm-runtime": "^1.1.1", "@tybys/wasm-util": "^0.10.1", "tslib": "^2.8.1" }, "cpu": "none" }, "sha512-MGFB5cVPvshR85MTJkEvqDUnuNoysrsRxd6vnk1Lf2tbiqNlXpHYZqkqOQalydienEWOHHFyyuTSYRsLfxFJ2Q=="], + + "@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.2.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-YlUEHRHBGnCMh4Nj4GnqQyBtsshUPdiNroZj8VPkvTZSoHsilRCwXcVKnG9kyi0ZFAS/3u+qKHBdDc81SADTRA=="], + + "@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.2.1", "", { "os": "win32", "cpu": "x64" }, "sha512-rbO34G5sMWWyrN/idLeVxAZgAKWrn5LiR3/I90Q9MkA67s6T1oB0xtTe+0heoBvHSpbU9Mk7i6uwJnpo4u21XQ=="], + + "@tailwindcss/vite": ["@tailwindcss/vite@4.2.1", "", { "dependencies": { "@tailwindcss/node": "4.2.1", "@tailwindcss/oxide": "4.2.1", "tailwindcss": "4.2.1" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7" } }, "sha512-TBf2sJjYeb28jD2U/OhwdW0bbOsxkWPwQ7SrqGf9sVcoYwZj7rkXljroBO9wKBut9XnmQLXanuDUeqQK0lGg/w=="], + + "@tanstack/history": ["@tanstack/history@1.161.4", "", {}, "sha512-Kp/WSt411ZWYvgXy6uiv5RmhHrz9cAml05AQPrtdAp7eUqvIDbMGPnML25OKbzR3RJ1q4wgENxDTvlGPa9+Mww=="], + + "@tanstack/query-core": ["@tanstack/query-core@5.90.20", "", {}, "sha512-OMD2HLpNouXEfZJWcKeVKUgQ5n+n3A2JFmBaScpNDUqSrQSjiveC7dKMe53uJUg1nDG16ttFPz2xfilz6i2uVg=="], + + "@tanstack/query-devtools": ["@tanstack/query-devtools@5.93.0", "", {}, "sha512-+kpsx1NQnOFTZsw6HAFCW3HkKg0+2cepGtAWXjiiSOJJ1CtQpt72EE2nyZb+AjAbLRPoeRmPJ8MtQd8r8gsPdg=="], + + "@tanstack/react-query": ["@tanstack/react-query@5.90.21", "", { "dependencies": { "@tanstack/query-core": "5.90.20" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-0Lu6y5t+tvlTJMTO7oh5NSpJfpg/5D41LlThfepTixPYkJ0sE2Jj0m0f6yYqujBwIXlId87e234+MxG3D3g7kg=="], + + "@tanstack/react-query-devtools": ["@tanstack/react-query-devtools@5.91.3", "", { "dependencies": { "@tanstack/query-devtools": "5.93.0" }, "peerDependencies": { "@tanstack/react-query": "^5.90.20", "react": "^18 || ^19" } }, "sha512-nlahjMtd/J1h7IzOOfqeyDh5LNfG0eULwlltPEonYy0QL+nqrBB+nyzJfULV+moL7sZyxc2sHdNJki+vLA9BSA=="], + + "@tanstack/react-router": ["@tanstack/react-router@1.162.6", "", { "dependencies": { "@tanstack/history": "1.161.4", "@tanstack/react-store": "^0.9.1", "@tanstack/router-core": "1.162.6", "isbot": "^5.1.22", "tiny-invariant": "^1.3.3", "tiny-warning": "^1.0.3" }, "peerDependencies": { "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" } }, "sha512-aj/gQ+TrODVjQYG5spXAlJTd4ZGaqUuRG/CJaQn8mMdc7h7NrATCnxDOugz99WPOl0bzMYQum7cTEhjCe2zOgA=="], + + "@tanstack/react-router-devtools": ["@tanstack/react-router-devtools@1.162.6", "", { "dependencies": { "@tanstack/router-devtools-core": "1.162.6" }, "peerDependencies": { "@tanstack/react-router": "^1.162.6", "@tanstack/router-core": "^1.162.6", "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" }, "optionalPeers": ["@tanstack/router-core"] }, "sha512-oSUmF5IrBUc67apoQlJ1lvIRD0EalXuAmfY9GIzW0x10BrdV/ecgCudT4Mo0U/mdXQuF4BHg4Et6MMIvuvdtaA=="], + + "@tanstack/react-store": ["@tanstack/react-store@0.9.1", "", { "dependencies": { "@tanstack/store": "0.9.1", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-YzJLnRvy5lIEFTLWBAZmcOjK3+2AepnBv/sr6NZmiqJvq7zTQggyK99Gw8fqYdMdHPQWXjz0epFKJXC+9V2xDA=="], + + "@tanstack/react-table": ["@tanstack/react-table@8.21.3", "", { "dependencies": { "@tanstack/table-core": "8.21.3" }, "peerDependencies": { "react": ">=16.8", "react-dom": ">=16.8" } }, "sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww=="], + + "@tanstack/router-core": ["@tanstack/router-core@1.162.6", "", { "dependencies": { "@tanstack/history": "1.161.4", "@tanstack/store": "^0.9.1", "cookie-es": "^2.0.0", "seroval": "^1.4.2", "seroval-plugins": "^1.4.2", "tiny-invariant": "^1.3.3", "tiny-warning": "^1.0.3" } }, "sha512-WFMNysDsDtnlM0G0L4LPWJuvpGatlPvBLGlPnieWYKem/Ed4mRHu7Hqw78MR/CMuFSRi9Gvv91/h8F3EVswAJw=="], + + "@tanstack/router-devtools-core": ["@tanstack/router-devtools-core@1.162.6", "", { "dependencies": { "clsx": "^2.1.1", "goober": "^2.1.16", "tiny-invariant": "^1.3.3" }, "peerDependencies": { "@tanstack/router-core": "^1.162.6", "csstype": "^3.0.10" }, "optionalPeers": ["csstype"] }, "sha512-ni+9XmQOg9ale1e6FnhNrBymVVQAkzQ02SfAB6MgobXLp97MHiBk7d0k7DkoyVLk3tXRqmrCERWYRC8IGrcQmw=="], + + "@tanstack/router-generator": ["@tanstack/router-generator@1.162.6", "", { "dependencies": { "@tanstack/router-core": "1.162.6", "@tanstack/router-utils": "1.161.4", "@tanstack/virtual-file-routes": "1.161.4", "prettier": "^3.5.0", "recast": "^0.23.11", "source-map": "^0.7.4", "tsx": "^4.19.2", "zod": "^3.24.2" } }, "sha512-mzkD3kfPW50xgX1hI8YrQx76+hshsUmpI9fVvS741L0cRQKH7bCIYTvcNHkz3sftZwmjt/lh+k7arV1AMLaWhA=="], + + "@tanstack/router-plugin": ["@tanstack/router-plugin@1.162.6", "", { "dependencies": { "@babel/core": "^7.28.5", "@babel/plugin-syntax-jsx": "^7.27.1", "@babel/plugin-syntax-typescript": "^7.27.1", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.5", "@babel/types": "^7.28.5", "@tanstack/router-core": "1.162.6", "@tanstack/router-generator": "1.162.6", "@tanstack/router-utils": "1.161.4", "@tanstack/virtual-file-routes": "1.161.4", "chokidar": "^3.6.0", "unplugin": "^2.1.2", "zod": "^3.24.2" }, "peerDependencies": { "@rsbuild/core": ">=1.0.2", "@tanstack/react-router": "^1.162.6", "vite": ">=5.0.0 || >=6.0.0 || >=7.0.0", "vite-plugin-solid": "^2.11.10", "webpack": ">=5.92.0" }, "optionalPeers": ["@rsbuild/core", "@tanstack/react-router", "vite", "vite-plugin-solid", "webpack"] }, "sha512-4Q+MtwHqqCawazM6I3NG6wVFDJdBfJ4uJYggUzGY0ir2bgbOULvvAlDD2tBHEOIoNhQwLcnr2AQ0JQJSWl8iZA=="], + + "@tanstack/router-utils": ["@tanstack/router-utils@1.161.4", "", { "dependencies": { "@babel/core": "^7.28.5", "@babel/generator": "^7.28.5", "@babel/parser": "^7.28.5", "@babel/types": "^7.28.5", "ansis": "^4.1.0", "babel-dead-code-elimination": "^1.0.12", "diff": "^8.0.2", "pathe": "^2.0.3", "tinyglobby": "^0.2.15" } }, "sha512-r8TpjyIZoqrXXaf2DDyjd44gjGBoyE+/oEaaH68yLI9ySPO1gUWmQENZ1MZnmBnpUGN24NOZxdjDLc8npK0SAw=="], + + "@tanstack/store": ["@tanstack/store@0.9.1", "", {}, "sha512-+qcNkOy0N1qSGsP7omVCW0SDrXtaDcycPqBDE726yryiA5eTDFpjBReaYjghVJwNf1pcPMyzIwTGlYjCSQR0Fg=="], + + "@tanstack/table-core": ["@tanstack/table-core@8.21.3", "", {}, "sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg=="], + + "@tanstack/virtual-file-routes": ["@tanstack/virtual-file-routes@1.161.4", "", {}, "sha512-42WoRePf8v690qG8yGRe/YOh+oHni9vUaUUfoqlS91U2scd3a5rkLtVsc6b7z60w3RogH0I00vdrC5AaeiZ18w=="], + + "@ts-morph/common": ["@ts-morph/common@0.27.0", "", { "dependencies": { "fast-glob": "^3.3.3", "minimatch": "^10.0.1", "path-browserify": "^1.0.1" } }, "sha512-Wf29UqxWDpc+i61k3oIOzcUfQt79PIT9y/MWfAGlrkjg6lBC1hwDECLXPVJAhWjiGbfBCxZd65F/LIZF3+jeJQ=="], + + "@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="], + + "@types/babel__generator": ["@types/babel__generator@7.27.0", "", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg=="], + + "@types/babel__template": ["@types/babel__template@7.4.4", "", { "dependencies": { "@babel/parser": "^7.1.0", "@babel/types": "^7.0.0" } }, "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A=="], + + "@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="], + + "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], + + "@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="], + + "@types/node": ["@types/node@24.10.13", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-oH72nZRfDv9lADUBSo104Aq7gPHpQZc4BTx38r9xf9pg5LfP6EzSyH2n7qFmmxRQXh7YlUXODcYsg6PuTDSxGg=="], + + "@types/react": ["@types/react@19.2.14", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w=="], + + "@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="], + + "@types/statuses": ["@types/statuses@2.0.6", "", {}, "sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA=="], + + "@types/validate-npm-package-name": ["@types/validate-npm-package-name@4.0.2", "", {}, "sha512-lrpDziQipxCEeK5kWxvljWYhUvOiB2A9izZd9B2AFarYAkqZshb4lPbRs7zKEic6eGtH8V/2qJW+dPp9OtF6bw=="], + + "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.56.1", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.56.1", "@typescript-eslint/type-utils": "8.56.1", "@typescript-eslint/utils": "8.56.1", "@typescript-eslint/visitor-keys": "8.56.1", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.56.1", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-Jz9ZztpB37dNC+HU2HI28Bs9QXpzCz+y/twHOwhyrIRdbuVDxSytJNDl6z/aAKlaRIwC7y8wJdkBv7FxYGgi0A=="], + + "@typescript-eslint/parser": ["@typescript-eslint/parser@8.56.1", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.56.1", "@typescript-eslint/types": "8.56.1", "@typescript-eslint/typescript-estree": "8.56.1", "@typescript-eslint/visitor-keys": "8.56.1", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg=="], + + "@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.56.1", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.56.1", "@typescript-eslint/types": "^8.56.1", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-TAdqQTzHNNvlVFfR+hu2PDJrURiwKsUvxFn1M0h95BB8ah5jejas08jUWG4dBA68jDMI988IvtfdAI53JzEHOQ=="], + + "@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.56.1", "", { "dependencies": { "@typescript-eslint/types": "8.56.1", "@typescript-eslint/visitor-keys": "8.56.1" } }, "sha512-YAi4VDKcIZp0O4tz/haYKhmIDZFEUPOreKbfdAN3SzUDMcPhJ8QI99xQXqX+HoUVq8cs85eRKnD+rne2UAnj2w=="], + + "@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.56.1", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-qOtCYzKEeyr3aR9f28mPJqBty7+DBqsdd63eO0yyDwc6vgThj2UjWfJIcsFeSucYydqcuudMOprZ+x1SpF3ZuQ=="], + + "@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.56.1", "", { "dependencies": { "@typescript-eslint/types": "8.56.1", "@typescript-eslint/typescript-estree": "8.56.1", "@typescript-eslint/utils": "8.56.1", "debug": "^4.4.3", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-yB/7dxi7MgTtGhZdaHCemf7PuwrHMenHjmzgUW1aJpO+bBU43OycnM3Wn+DdvDO/8zzA9HlhaJ0AUGuvri4oGg=="], + + "@typescript-eslint/types": ["@typescript-eslint/types@8.56.1", "", {}, "sha512-dbMkdIUkIkchgGDIv7KLUpa0Mda4IYjo4IAMJUZ+3xNoUXxMsk9YtKpTHSChRS85o+H9ftm51gsK1dZReY9CVw=="], + + "@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.56.1", "", { "dependencies": { "@typescript-eslint/project-service": "8.56.1", "@typescript-eslint/tsconfig-utils": "8.56.1", "@typescript-eslint/types": "8.56.1", "@typescript-eslint/visitor-keys": "8.56.1", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-qzUL1qgalIvKWAf9C1HpvBjif+Vm6rcT5wZd4VoMb9+Km3iS3Cv9DY6dMRMDtPnwRAFyAi7YXJpTIEXLvdfPxg=="], + + "@typescript-eslint/utils": ["@typescript-eslint/utils@8.56.1", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.56.1", "@typescript-eslint/types": "8.56.1", "@typescript-eslint/typescript-estree": "8.56.1" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-HPAVNIME3tABJ61siYlHzSWCGtOoeP2RTIaHXFMPqjrQKCGB9OgUVdiNgH7TJS2JNIQ5qQ4RsAUDuGaGme/KOA=="], + + "@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.56.1", "", { "dependencies": { "@typescript-eslint/types": "8.56.1", "eslint-visitor-keys": "^5.0.0" } }, "sha512-KiROIzYdEV85YygXw6BI/Dx4fnBlFQu6Mq4QE4MOH9fFnhohw6wX/OAvDY2/C+ut0I3RSPKenvZJIVYqJNkhEw=="], + + "@vitejs/plugin-react": ["@vitejs/plugin-react@5.1.4", "", { "dependencies": { "@babel/core": "^7.29.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-rc.3", "@types/babel__core": "^7.20.5", "react-refresh": "^0.18.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-VIcFLdRi/VYRU8OL/puL7QXMYafHmqOnwTZY50U1JPlCNj30PxCMx65c494b1K9be9hX83KVt0+gTEwTWLqToA=="], + + "accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="], + + "acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="], + + "acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="], + + "agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], + + "ajv": ["ajv@6.14.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw=="], + + "ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="], + + "ansi-colors": ["ansi-colors@4.1.3", "", {}, "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw=="], + + "ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], + + "ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], + + "ansis": ["ansis@4.2.0", "", {}, "sha512-HqZ5rWlFjGiV0tDm3UxxgNRqsOTniqoKZu0pIAfh7TZQMGuZK+hH0drySty0si0QXj1ieop4+SkSfPZBPPkHig=="], + + "anymatch": ["anymatch@3.1.3", "", { "dependencies": { "normalize-path": "^3.0.0", "picomatch": "^2.0.4" } }, "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw=="], + + "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], + + "ast-types": ["ast-types@0.16.1", "", { "dependencies": { "tslib": "^2.0.1" } }, "sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg=="], + + "asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="], + + "axios": ["axios@1.13.5", "", { "dependencies": { "follow-redirects": "^1.15.11", "form-data": "^4.0.5", "proxy-from-env": "^1.1.0" } }, "sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q=="], + + "babel-dead-code-elimination": ["babel-dead-code-elimination@1.0.12", "", { "dependencies": { "@babel/core": "^7.23.7", "@babel/parser": "^7.23.6", "@babel/traverse": "^7.23.7", "@babel/types": "^7.23.6" } }, "sha512-GERT7L2TiYcYDtYk1IpD+ASAYXjKbLTDPhBtYj7X1NuRMDTMtAx9kyBenub1Ev41lo91OHCKdmP+egTDmfQ7Ig=="], + + "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], + + "baseline-browser-mapping": ["baseline-browser-mapping@2.10.0", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA=="], + + "binary-extensions": ["binary-extensions@2.3.0", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="], + + "body-parser": ["body-parser@2.2.2", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.1", "raw-body": "^3.0.1", "type-is": "^2.0.1" } }, "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA=="], + + "brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="], + + "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="], + + "browserslist": ["browserslist@4.28.1", "", { "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", "electron-to-chromium": "^1.5.263", "node-releases": "^2.0.27", "update-browserslist-db": "^1.2.0" }, "bin": { "browserslist": "cli.js" } }, "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA=="], + + "bundle-name": ["bundle-name@4.1.0", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q=="], + + "bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="], + + "c12": ["c12@3.3.3", "", { "dependencies": { "chokidar": "^5.0.0", "confbox": "^0.2.2", "defu": "^6.1.4", "dotenv": "^17.2.3", "exsolve": "^1.0.8", "giget": "^2.0.0", "jiti": "^2.6.1", "ohash": "^2.0.11", "pathe": "^2.0.3", "perfect-debounce": "^2.0.0", "pkg-types": "^2.3.0", "rc9": "^2.1.2" }, "peerDependencies": { "magicast": "*" }, "optionalPeers": ["magicast"] }, "sha512-750hTRvgBy5kcMNPdh95Qo+XUBeGo8C7nsKSmedDmaQI+E0r82DwHeM6vBewDe4rGFbnxoa4V9pw+sPh5+Iz8Q=="], + + "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="], + + "call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="], + + "callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="], + + "caniuse-lite": ["caniuse-lite@1.0.30001774", "", {}, "sha512-DDdwPGz99nmIEv216hKSgLD+D4ikHQHjBC/seF98N9CPqRX4M5mSxT9eTV6oyisnJcuzxtZy4n17yKKQYmYQOA=="], + + "chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], + + "chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="], + + "citty": ["citty@0.1.6", "", { "dependencies": { "consola": "^3.2.3" } }, "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ=="], + + "class-variance-authority": ["class-variance-authority@0.7.1", "", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="], + + "cli-cursor": ["cli-cursor@5.0.0", "", { "dependencies": { "restore-cursor": "^5.0.0" } }, "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw=="], + + "cli-spinners": ["cli-spinners@2.9.2", "", {}, "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg=="], + + "cli-width": ["cli-width@4.1.0", "", {}, "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ=="], + + "cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="], + + "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], + + "code-block-writer": ["code-block-writer@13.0.3", "", {}, "sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg=="], + + "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], + + "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], + + "color-support": ["color-support@1.1.3", "", { "bin": { "color-support": "bin.js" } }, "sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg=="], + + "combined-stream": ["combined-stream@1.0.8", "", { "dependencies": { "delayed-stream": "~1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="], + + "commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="], + + "concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="], + + "confbox": ["confbox@0.2.4", "", {}, "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ=="], + + "consola": ["consola@3.4.2", "", {}, "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA=="], + + "content-disposition": ["content-disposition@1.0.1", "", {}, "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q=="], + + "content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="], + + "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], + + "cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="], + + "cookie-es": ["cookie-es@2.0.0", "", {}, "sha512-RAj4E421UYRgqokKUmotqAwuplYw15qtdXfY+hGzgCJ/MBjCVZcSoHK/kH9kocfjRjcDME7IiDWR/1WX1TM2Pg=="], + + "cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="], + + "cors": ["cors@2.8.6", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw=="], + + "cosmiconfig": ["cosmiconfig@9.0.0", "", { "dependencies": { "env-paths": "^2.2.1", "import-fresh": "^3.3.0", "js-yaml": "^4.1.0", "parse-json": "^5.2.0" }, "peerDependencies": { "typescript": ">=4.9.5" }, "optionalPeers": ["typescript"] }, "sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg=="], + + "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], + + "cssesc": ["cssesc@3.0.0", "", { "bin": { "cssesc": "bin/cssesc" } }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="], + + "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], + + "data-uri-to-buffer": ["data-uri-to-buffer@4.0.1", "", {}, "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A=="], + + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "dedent": ["dedent@1.7.1", "", { "peerDependencies": { "babel-plugin-macros": "^3.1.0" }, "optionalPeers": ["babel-plugin-macros"] }, "sha512-9JmrhGZpOlEgOLdQgSm0zxFaYoQon408V1v49aqTWuXENVlnCuY9JBZcXZiCsZQWDjTm5Qf/nIvAy77mXDAjEg=="], + + "deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="], + + "deepmerge": ["deepmerge@4.3.1", "", {}, "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="], + + "default-browser": ["default-browser@5.5.0", "", { "dependencies": { "bundle-name": "^4.1.0", "default-browser-id": "^5.0.0" } }, "sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw=="], + + "default-browser-id": ["default-browser-id@5.0.1", "", {}, "sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q=="], + + "define-lazy-prop": ["define-lazy-prop@3.0.0", "", {}, "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg=="], + + "defu": ["defu@6.1.4", "", {}, "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg=="], + + "delayed-stream": ["delayed-stream@1.0.0", "", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="], + + "depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="], + + "destr": ["destr@2.0.5", "", {}, "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA=="], + + "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], + + "diff": ["diff@8.0.3", "", {}, "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ=="], + + "dotenv": ["dotenv@17.3.1", "", {}, "sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA=="], + + "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], + + "eciesjs": ["eciesjs@0.4.17", "", { "dependencies": { "@ecies/ciphers": "^0.2.5", "@noble/ciphers": "^1.3.0", "@noble/curves": "^1.9.7", "@noble/hashes": "^1.8.0" } }, "sha512-TOOURki4G7sD1wDCjj7NfLaXZZ49dFOeEb5y39IXpb8p0hRzVvfvzZHOi5JcT+PpyAbi/Y+lxPb8eTag2WYH8w=="], + + "ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="], + + "electron-to-chromium": ["electron-to-chromium@1.5.302", "", {}, "sha512-sM6HAN2LyK82IyPBpznDRqlTQAtuSaO+ShzFiWTvoMJLHyZ+Y39r8VMfHzwbU8MVBzQ4Wdn85+wlZl2TLGIlwg=="], + + "emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], + + "encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="], + + "enhanced-resolve": ["enhanced-resolve@5.19.0", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.0" } }, "sha512-phv3E1Xl4tQOShqSte26C7Fl84EwUdZsyOuSSk9qtAGyyQs2s3jJzComh+Abf4g187lUUAvH+H26omrqia2aGg=="], + + "env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="], + + "error-ex": ["error-ex@1.3.4", "", { "dependencies": { "is-arrayish": "^0.2.1" } }, "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ=="], + + "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="], + + "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], + + "es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="], + + "es-set-tostringtag": ["es-set-tostringtag@2.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="], + + "esbuild": ["esbuild@0.27.3", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.3", "@esbuild/android-arm": "0.27.3", "@esbuild/android-arm64": "0.27.3", "@esbuild/android-x64": "0.27.3", "@esbuild/darwin-arm64": "0.27.3", "@esbuild/darwin-x64": "0.27.3", "@esbuild/freebsd-arm64": "0.27.3", "@esbuild/freebsd-x64": "0.27.3", "@esbuild/linux-arm": "0.27.3", "@esbuild/linux-arm64": "0.27.3", "@esbuild/linux-ia32": "0.27.3", "@esbuild/linux-loong64": "0.27.3", "@esbuild/linux-mips64el": "0.27.3", "@esbuild/linux-ppc64": "0.27.3", "@esbuild/linux-riscv64": "0.27.3", "@esbuild/linux-s390x": "0.27.3", "@esbuild/linux-x64": "0.27.3", "@esbuild/netbsd-arm64": "0.27.3", "@esbuild/netbsd-x64": "0.27.3", "@esbuild/openbsd-arm64": "0.27.3", "@esbuild/openbsd-x64": "0.27.3", "@esbuild/openharmony-arm64": "0.27.3", "@esbuild/sunos-x64": "0.27.3", "@esbuild/win32-arm64": "0.27.3", "@esbuild/win32-ia32": "0.27.3", "@esbuild/win32-x64": "0.27.3" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg=="], + + "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], + + "escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="], + + "escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], + + "eslint": ["eslint@9.39.3", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", "@eslint/config-array": "^0.21.1", "@eslint/config-helpers": "^0.4.2", "@eslint/core": "^0.17.0", "@eslint/eslintrc": "^3.3.1", "@eslint/js": "9.39.3", "@eslint/plugin-kit": "^0.4.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.12.4", "chalk": "^4.0.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^8.4.0", "eslint-visitor-keys": "^4.2.1", "espree": "^10.4.0", "esquery": "^1.5.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "lodash.merge": "^4.6.2", "minimatch": "^3.1.2", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-VmQ+sifHUbI/IcSopBCF/HO3YiHQx/AVd3UVyYL6weuwW+HvON9VYn5l6Zl1WZzPWXPNZrSQpxwkkZ/VuvJZzg=="], + + "eslint-plugin-react-hooks": ["eslint-plugin-react-hooks@7.0.1", "", { "dependencies": { "@babel/core": "^7.24.4", "@babel/parser": "^7.24.4", "hermes-parser": "^0.25.1", "zod": "^3.25.0 || ^4.0.0", "zod-validation-error": "^3.5.0 || ^4.0.0" }, "peerDependencies": { "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" } }, "sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA=="], + + "eslint-plugin-react-refresh": ["eslint-plugin-react-refresh@0.4.26", "", { "peerDependencies": { "eslint": ">=8.40" } }, "sha512-1RETEylht2O6FM/MvgnyvT+8K21wLqDNg4qD51Zj3guhjt433XbnnkVttHMyaVyAFD03QSV4LPS5iE3VQmO7XQ=="], + + "eslint-scope": ["eslint-scope@8.4.0", "", { "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg=="], + + "eslint-visitor-keys": ["eslint-visitor-keys@4.2.1", "", {}, "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ=="], + + "espree": ["espree@10.4.0", "", { "dependencies": { "acorn": "^8.15.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^4.2.1" } }, "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ=="], + + "esprima": ["esprima@4.0.1", "", { "bin": { "esparse": "./bin/esparse.js", "esvalidate": "./bin/esvalidate.js" } }, "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A=="], + + "esquery": ["esquery@1.7.0", "", { "dependencies": { "estraverse": "^5.1.0" } }, "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g=="], + + "esrecurse": ["esrecurse@4.3.0", "", { "dependencies": { "estraverse": "^5.2.0" } }, "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag=="], + + "estraverse": ["estraverse@5.3.0", "", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="], + + "esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="], + + "etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="], + + "eventsource": ["eventsource@3.0.7", "", { "dependencies": { "eventsource-parser": "^3.0.1" } }, "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA=="], + + "eventsource-parser": ["eventsource-parser@3.0.6", "", {}, "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg=="], + + "execa": ["execa@9.6.1", "", { "dependencies": { "@sindresorhus/merge-streams": "^4.0.0", "cross-spawn": "^7.0.6", "figures": "^6.1.0", "get-stream": "^9.0.0", "human-signals": "^8.0.1", "is-plain-obj": "^4.1.0", "is-stream": "^4.0.1", "npm-run-path": "^6.0.0", "pretty-ms": "^9.2.0", "signal-exit": "^4.1.0", "strip-final-newline": "^4.0.0", "yoctocolors": "^2.1.1" } }, "sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA=="], + + "express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="], + + "express-rate-limit": ["express-rate-limit@8.2.1", "", { "dependencies": { "ip-address": "10.0.1" }, "peerDependencies": { "express": ">= 4.11" } }, "sha512-PCZEIEIxqwhzw4KF0n7QF4QqruVTcF73O5kFKUnGOyjbCCgizBBiFaYpd/fnBLUMPw/BWw9OsiN7GgrNYr7j6g=="], + + "exsolve": ["exsolve@1.0.8", "", {}, "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA=="], + + "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], + + "fast-glob": ["fast-glob@3.3.3", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="], + + "fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="], + + "fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="], + + "fast-uri": ["fast-uri@3.1.0", "", {}, "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA=="], + + "fastq": ["fastq@1.20.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw=="], + + "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], + + "fetch-blob": ["fetch-blob@3.2.0", "", { "dependencies": { "node-domexception": "^1.0.0", "web-streams-polyfill": "^3.0.3" } }, "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ=="], + + "figures": ["figures@6.1.0", "", { "dependencies": { "is-unicode-supported": "^2.0.0" } }, "sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg=="], + + "file-entry-cache": ["file-entry-cache@8.0.0", "", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="], + + "fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="], + + "finalhandler": ["finalhandler@2.1.1", "", { "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "on-finished": "^2.4.1", "parseurl": "^1.3.3", "statuses": "^2.0.1" } }, "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA=="], + + "find-up": ["find-up@5.0.0", "", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="], + + "flat-cache": ["flat-cache@4.0.1", "", { "dependencies": { "flatted": "^3.2.9", "keyv": "^4.5.4" } }, "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw=="], + + "flatted": ["flatted@3.3.3", "", {}, "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg=="], + + "follow-redirects": ["follow-redirects@1.15.11", "", {}, "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ=="], + + "font-logos": ["font-logos@1.3.0", "", {}, "sha512-YGqFNu8+0bWFTU7bh3aveSl4CSsUEOEPEkRW4tP1EnmLHzAunpeHagD3ICVttn7/oPHRWO/UStMkS/tYTZTt5g=="], + + "form-data": ["form-data@4.0.5", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w=="], + + "formdata-polyfill": ["formdata-polyfill@4.0.10", "", { "dependencies": { "fetch-blob": "^3.1.2" } }, "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g=="], + + "forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="], + + "fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="], + + "fs-extra": ["fs-extra@11.3.3", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-VWSRii4t0AFm6ixFFmLLx1t7wS1gh+ckoa84aOeapGum0h+EZd1EhEumSB+ZdDLnEPuucsVB9oB7cxJHap6Afg=="], + + "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + + "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], + + "fuzzysort": ["fuzzysort@3.1.0", "", {}, "sha512-sR9BNCjBg6LNgwvxlBd0sBABvQitkLzoVY9MYYROQVX/FvfJ4Mai9LsGhDgd8qYdds0bY77VzYd5iuB+v5rwQQ=="], + + "fzf": ["fzf@0.5.2", "", {}, "sha512-Tt4kuxLXFKHy8KT40zwsUPUkg1CrsgY25FxA2U/j/0WgEDCk3ddc/zLTCCcbSHX9FcKtLuVaDGtGE/STWC+j3Q=="], + + "gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="], + + "get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="], + + "get-east-asian-width": ["get-east-asian-width@1.5.0", "", {}, "sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA=="], + + "get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="], + + "get-own-enumerable-keys": ["get-own-enumerable-keys@1.0.0", "", {}, "sha512-PKsK2FSrQCyxcGHsGrLDcK0lx+0Ke+6e8KFFozA9/fIQLhQzPaRvJFdcz7+Axg3jUH/Mq+NI4xa5u/UT2tQskA=="], + + "get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="], + + "get-stream": ["get-stream@9.0.1", "", { "dependencies": { "@sec-ant/readable-stream": "^0.4.1", "is-stream": "^4.0.1" } }, "sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA=="], + + "get-tsconfig": ["get-tsconfig@4.13.6", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw=="], + + "giget": ["giget@2.0.0", "", { "dependencies": { "citty": "^0.1.6", "consola": "^3.4.0", "defu": "^6.1.4", "node-fetch-native": "^1.6.6", "nypm": "^0.6.0", "pathe": "^2.0.3" }, "bin": { "giget": "dist/cli.mjs" } }, "sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA=="], + + "glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="], + + "globals": ["globals@16.5.0", "", {}, "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ=="], + + "goober": ["goober@2.1.18", "", { "peerDependencies": { "csstype": "^3.0.10" } }, "sha512-2vFqsaDVIT9Gz7N6kAL++pLpp41l3PfDuusHcjnGLfR6+huZkl6ziX+zgVC3ZxpqWhzH6pyDdGrCeDhMIvwaxw=="], + + "gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="], + + "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], + + "graphql": ["graphql@16.12.0", "", {}, "sha512-DKKrynuQRne0PNpEbzuEdHlYOMksHSUI8Zc9Unei5gTsMNA2/vMpoMz/yKba50pejK56qj98qM0SjYxAKi13gQ=="], + + "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], + + "has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="], + + "has-tostringtag": ["has-tostringtag@1.0.2", "", { "dependencies": { "has-symbols": "^1.0.3" } }, "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw=="], + + "hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="], + + "headers-polyfill": ["headers-polyfill@4.0.3", "", {}, "sha512-IScLbePpkvO846sIwOtOTDjutRMWdXdJmXdMvk6gCBHxFO8d+QKOQedyZSxFTTFYRSmlgSTDtXqqq4pcenBXLQ=="], + + "hermes-estree": ["hermes-estree@0.25.1", "", {}, "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw=="], + + "hermes-parser": ["hermes-parser@0.25.1", "", { "dependencies": { "hermes-estree": "0.25.1" } }, "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA=="], + + "hono": ["hono@4.12.2", "", {}, "sha512-gJnaDHXKDayjt8ue0n8Gs0A007yKXj4Xzb8+cNjZeYsSzzwKc0Lr+OZgYwVfB0pHfUs17EPoLvrOsEaJ9mj+Tg=="], + + "http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="], + + "https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], + + "human-signals": ["human-signals@8.0.1", "", {}, "sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ=="], + + "iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="], + + "ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], + + "import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="], + + "imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="], + + "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], + + "ip-address": ["ip-address@10.0.1", "", {}, "sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA=="], + + "ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="], + + "is-arrayish": ["is-arrayish@0.2.1", "", {}, "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg=="], + + "is-binary-path": ["is-binary-path@2.1.0", "", { "dependencies": { "binary-extensions": "^2.0.0" } }, "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw=="], + + "is-docker": ["is-docker@3.0.0", "", { "bin": { "is-docker": "cli.js" } }, "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ=="], + + "is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="], + + "is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], + + "is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="], + + "is-in-ssh": ["is-in-ssh@1.0.0", "", {}, "sha512-jYa6Q9rH90kR1vKB6NM7qqd1mge3Fx4Dhw5TVlK1MUBqhEOuCagrEHMevNuCcbECmXZ0ThXkRm+Ymr51HwEPAw=="], + + "is-inside-container": ["is-inside-container@1.0.0", "", { "dependencies": { "is-docker": "^3.0.0" }, "bin": { "is-inside-container": "cli.js" } }, "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA=="], + + "is-interactive": ["is-interactive@2.0.0", "", {}, "sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ=="], + + "is-node-process": ["is-node-process@1.2.0", "", {}, "sha512-Vg4o6/fqPxIjtxgUH5QLJhwZ7gW5diGCVlXpuUfELC62CuxM1iHcRe51f2W1FDy04Ai4KJkagKjx3XaqyfRKXw=="], + + "is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="], + + "is-obj": ["is-obj@3.0.0", "", {}, "sha512-IlsXEHOjtKhpN8r/tRFj2nDyTmHvcfNeu/nrRIcXE17ROeatXchkojffa1SpdqW4cr/Fj6QkEf/Gn4zf6KKvEQ=="], + + "is-plain-obj": ["is-plain-obj@4.1.0", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="], + + "is-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="], + + "is-regexp": ["is-regexp@3.1.0", "", {}, "sha512-rbku49cWloU5bSMI+zaRaXdQHXnthP6DZ/vLnfdSKyL4zUzuWnomtOEiZZOd+ioQ+avFo/qau3KPTc7Fjy1uPA=="], + + "is-stream": ["is-stream@4.0.1", "", {}, "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A=="], + + "is-unicode-supported": ["is-unicode-supported@2.1.0", "", {}, "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ=="], + + "is-wsl": ["is-wsl@3.1.1", "", { "dependencies": { "is-inside-container": "^1.0.0" } }, "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw=="], + + "isbot": ["isbot@5.1.35", "", {}, "sha512-waFfC72ZNfwLLuJ2iLaoVaqcNo+CAaLR7xCpAn0Y5WfGzkNHv7ZN39Vbi1y+kb+Zs46XHOX3tZNExroFUPX+Kg=="], + + "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + + "jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], + + "jose": ["jose@6.1.3", "", {}, "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ=="], + + "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], + + "js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], + + "jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="], + + "json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="], + + "json-parse-even-better-errors": ["json-parse-even-better-errors@2.3.1", "", {}, "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w=="], + + "json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], + + "json-schema-typed": ["json-schema-typed@8.0.2", "", {}, "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA=="], + + "json-stable-stringify-without-jsonify": ["json-stable-stringify-without-jsonify@1.0.1", "", {}, "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw=="], + + "json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="], + + "jsonfile": ["jsonfile@6.2.0", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg=="], + + "keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="], + + "kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="], + + "levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="], + + "lightningcss": ["lightningcss@1.31.1", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.31.1", "lightningcss-darwin-arm64": "1.31.1", "lightningcss-darwin-x64": "1.31.1", "lightningcss-freebsd-x64": "1.31.1", "lightningcss-linux-arm-gnueabihf": "1.31.1", "lightningcss-linux-arm64-gnu": "1.31.1", "lightningcss-linux-arm64-musl": "1.31.1", "lightningcss-linux-x64-gnu": "1.31.1", "lightningcss-linux-x64-musl": "1.31.1", "lightningcss-win32-arm64-msvc": "1.31.1", "lightningcss-win32-x64-msvc": "1.31.1" } }, "sha512-l51N2r93WmGUye3WuFoN5k10zyvrVs0qfKBhyC5ogUQ6Ew6JUSswh78mbSO+IU3nTWsyOArqPCcShdQSadghBQ=="], + + "lightningcss-android-arm64": ["lightningcss-android-arm64@1.31.1", "", { "os": "android", "cpu": "arm64" }, "sha512-HXJF3x8w9nQ4jbXRiNppBCqeZPIAfUo8zE/kOEGbW5NZvGc/K7nMxbhIr+YlFlHW5mpbg/YFPdbnCh1wAXCKFg=="], + + "lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.31.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-02uTEqf3vIfNMq3h/z2cJfcOXnQ0GRwQrkmPafhueLb2h7mqEidiCzkE4gBMEH65abHRiQvhdcQ+aP0D0g67sg=="], + + "lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.31.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-1ObhyoCY+tGxtsz1lSx5NXCj3nirk0Y0kB/g8B8DT+sSx4G9djitg9ejFnjb3gJNWo7qXH4DIy2SUHvpoFwfTA=="], + + "lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.31.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-1RINmQKAItO6ISxYgPwszQE1BrsVU5aB45ho6O42mu96UiZBxEXsuQ7cJW4zs4CEodPUioj/QrXW1r9pLUM74A=="], + + "lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.31.1", "", { "os": "linux", "cpu": "arm" }, "sha512-OOCm2//MZJ87CdDK62rZIu+aw9gBv4azMJuA8/KB74wmfS3lnC4yoPHm0uXZ/dvNNHmnZnB8XLAZzObeG0nS1g=="], + + "lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.31.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-WKyLWztD71rTnou4xAD5kQT+982wvca7E6QoLpoawZ1gP9JM0GJj4Tp5jMUh9B3AitHbRZ2/H3W5xQmdEOUlLg=="], + + "lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.31.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg=="], + + "lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.31.1", "", { "os": "linux", "cpu": "x64" }, "sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA=="], + + "lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.31.1", "", { "os": "linux", "cpu": "x64" }, "sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA=="], + + "lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.31.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w=="], + + "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.31.1", "", { "os": "win32", "cpu": "x64" }, "sha512-I9aiFrbd7oYHwlnQDqr1Roz+fTz61oDDJX7n9tYF9FJymH1cIN1DtKw3iYt6b8WZgEjoNwVSncwF4wx/ZedMhw=="], + + "lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="], + + "locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="], + + "lodash.merge": ["lodash.merge@4.6.2", "", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="], + + "log-symbols": ["log-symbols@6.0.0", "", { "dependencies": { "chalk": "^5.3.0", "is-unicode-supported": "^1.3.0" } }, "sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw=="], + + "lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], + + "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], + + "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], + + "media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="], + + "merge-descriptors": ["merge-descriptors@2.0.0", "", {}, "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g=="], + + "merge-stream": ["merge-stream@2.0.0", "", {}, "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w=="], + + "merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="], + + "micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="], + + "mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], + + "mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], + + "mimic-fn": ["mimic-fn@2.1.0", "", {}, "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg=="], + + "mimic-function": ["mimic-function@5.0.1", "", {}, "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA=="], + + "minimatch": ["minimatch@3.1.3", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-M2GCs7Vk83NxkUyQV1bkABc4yxgz9kILhHImZiBPAZ9ybuvCb0/H7lEl5XvIg3g+9d4eNotkZA5IWwYl0tibaA=="], + + "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], + + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + + "msw": ["msw@2.12.10", "", { "dependencies": { "@inquirer/confirm": "^5.0.0", "@mswjs/interceptors": "^0.41.2", "@open-draft/deferred-promise": "^2.2.0", "@types/statuses": "^2.0.6", "cookie": "^1.0.2", "graphql": "^16.12.0", "headers-polyfill": "^4.0.2", "is-node-process": "^1.2.0", "outvariant": "^1.4.3", "path-to-regexp": "^6.3.0", "picocolors": "^1.1.1", "rettime": "^0.10.1", "statuses": "^2.0.2", "strict-event-emitter": "^0.5.1", "tough-cookie": "^6.0.0", "type-fest": "^5.2.0", "until-async": "^3.0.2", "yargs": "^17.7.2" }, "peerDependencies": { "typescript": ">= 4.8.x" }, "optionalPeers": ["typescript"], "bin": { "msw": "cli/index.js" } }, "sha512-G3VUymSE0/iegFnuipujpwyTM2GuZAKXNeerUSrG2+Eg391wW63xFs5ixWsK9MWzr1AGoSkYGmyAzNgbR3+urw=="], + + "mute-stream": ["mute-stream@2.0.0", "", {}, "sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA=="], + + "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], + + "natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="], + + "negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="], + + "node-domexception": ["node-domexception@1.0.0", "", {}, "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ=="], + + "node-fetch": ["node-fetch@3.3.2", "", { "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.4", "formdata-polyfill": "^4.0.10" } }, "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA=="], + + "node-fetch-native": ["node-fetch-native@1.6.7", "", {}, "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q=="], + + "node-releases": ["node-releases@2.0.27", "", {}, "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA=="], + + "normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="], + + "npm-run-path": ["npm-run-path@6.0.0", "", { "dependencies": { "path-key": "^4.0.0", "unicorn-magic": "^0.3.0" } }, "sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA=="], + + "nypm": ["nypm@0.6.5", "", { "dependencies": { "citty": "^0.2.0", "pathe": "^2.0.3", "tinyexec": "^1.0.2" }, "bin": { "nypm": "dist/cli.mjs" } }, "sha512-K6AJy1GMVyfyMXRVB88700BJqNUkByijGJM8kEHpLdcAt+vSQAVfkWWHYzuRXHSY6xA2sNc5RjTj0p9rE2izVQ=="], + + "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], + + "object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="], + + "object-treeify": ["object-treeify@1.1.33", "", {}, "sha512-EFVjAYfzWqWsBMRHPMAXLCDIJnpMhdWAqR7xG6M6a2cs6PMFpl/+Z20w9zDW4vkxOFfddegBKq9Rehd0bxWE7A=="], + + "ohash": ["ohash@2.0.11", "", {}, "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ=="], + + "on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="], + + "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], + + "onetime": ["onetime@5.1.2", "", { "dependencies": { "mimic-fn": "^2.1.0" } }, "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg=="], + + "open": ["open@11.0.0", "", { "dependencies": { "default-browser": "^5.4.0", "define-lazy-prop": "^3.0.0", "is-in-ssh": "^1.0.0", "is-inside-container": "^1.0.0", "powershell-utils": "^0.1.0", "wsl-utils": "^0.3.0" } }, "sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw=="], + + "optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="], + + "ora": ["ora@8.2.0", "", { "dependencies": { "chalk": "^5.3.0", "cli-cursor": "^5.0.0", "cli-spinners": "^2.9.2", "is-interactive": "^2.0.0", "is-unicode-supported": "^2.0.0", "log-symbols": "^6.0.0", "stdin-discarder": "^0.2.2", "string-width": "^7.2.0", "strip-ansi": "^7.1.0" } }, "sha512-weP+BZ8MVNnlCm8c0Qdc1WSWq4Qn7I+9CJGm7Qali6g44e/PUzbjNqJX5NJ9ljlNMosfJvg1fKEGILklK9cwnw=="], + + "outvariant": ["outvariant@1.4.3", "", {}, "sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA=="], + + "p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="], + + "p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="], + + "package-manager-detector": ["package-manager-detector@1.6.0", "", {}, "sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA=="], + + "parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="], + + "parse-json": ["parse-json@5.2.0", "", { "dependencies": { "@babel/code-frame": "^7.0.0", "error-ex": "^1.3.1", "json-parse-even-better-errors": "^2.3.0", "lines-and-columns": "^1.1.6" } }, "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg=="], + + "parse-ms": ["parse-ms@4.0.0", "", {}, "sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw=="], + + "parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="], + + "path-browserify": ["path-browserify@1.0.1", "", {}, "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g=="], + + "path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="], + + "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], + + "path-to-regexp": ["path-to-regexp@6.3.0", "", {}, "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ=="], + + "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], + + "perfect-debounce": ["perfect-debounce@2.1.0", "", {}, "sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g=="], + + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], + + "picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="], + + "pkce-challenge": ["pkce-challenge@5.0.1", "", {}, "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ=="], + + "pkg-types": ["pkg-types@2.3.0", "", { "dependencies": { "confbox": "^0.2.2", "exsolve": "^1.0.7", "pathe": "^2.0.3" } }, "sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig=="], + + "postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="], + + "postcss-selector-parser": ["postcss-selector-parser@7.1.1", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg=="], + + "powershell-utils": ["powershell-utils@0.1.0", "", {}, "sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A=="], + + "prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="], + + "prettier": ["prettier@3.8.1", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg=="], + + "pretty-ms": ["pretty-ms@9.3.0", "", { "dependencies": { "parse-ms": "^4.0.0" } }, "sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ=="], + + "prompts": ["prompts@2.4.2", "", { "dependencies": { "kleur": "^3.0.3", "sisteransi": "^1.0.5" } }, "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q=="], + + "proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="], + + "proxy-from-env": ["proxy-from-env@1.1.0", "", {}, "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="], + + "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], + + "qs": ["qs@6.15.0", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ=="], + + "queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="], + + "range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="], + + "raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="], + + "rc9": ["rc9@2.1.2", "", { "dependencies": { "defu": "^6.1.4", "destr": "^2.0.3" } }, "sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg=="], + + "react": ["react@19.2.4", "", {}, "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="], + + "react-dom": ["react-dom@19.2.4", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.4" } }, "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ=="], + + "react-refresh": ["react-refresh@0.18.0", "", {}, "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw=="], + + "readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="], + + "recast": ["recast@0.23.11", "", { "dependencies": { "ast-types": "^0.16.1", "esprima": "~4.0.0", "source-map": "~0.6.1", "tiny-invariant": "^1.3.3", "tslib": "^2.0.1" } }, "sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA=="], + + "require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="], + + "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], + + "reselect": ["reselect@5.1.1", "", {}, "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w=="], + + "resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="], + + "resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="], + + "restore-cursor": ["restore-cursor@5.1.0", "", { "dependencies": { "onetime": "^7.0.0", "signal-exit": "^4.1.0" } }, "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA=="], + + "rettime": ["rettime@0.10.1", "", {}, "sha512-uyDrIlUEH37cinabq0AX4QbgV4HbFZ/gqoiunWQ1UqBtRvTTytwhNYjE++pO/MjPTZL5KQCf2bEoJ/BJNVQ5Kw=="], + + "reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="], + + "rollup": ["rollup@4.59.0", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.59.0", "@rollup/rollup-android-arm64": "4.59.0", "@rollup/rollup-darwin-arm64": "4.59.0", "@rollup/rollup-darwin-x64": "4.59.0", "@rollup/rollup-freebsd-arm64": "4.59.0", "@rollup/rollup-freebsd-x64": "4.59.0", "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", "@rollup/rollup-linux-arm-musleabihf": "4.59.0", "@rollup/rollup-linux-arm64-gnu": "4.59.0", "@rollup/rollup-linux-arm64-musl": "4.59.0", "@rollup/rollup-linux-loong64-gnu": "4.59.0", "@rollup/rollup-linux-loong64-musl": "4.59.0", "@rollup/rollup-linux-ppc64-gnu": "4.59.0", "@rollup/rollup-linux-ppc64-musl": "4.59.0", "@rollup/rollup-linux-riscv64-gnu": "4.59.0", "@rollup/rollup-linux-riscv64-musl": "4.59.0", "@rollup/rollup-linux-s390x-gnu": "4.59.0", "@rollup/rollup-linux-x64-gnu": "4.59.0", "@rollup/rollup-linux-x64-musl": "4.59.0", "@rollup/rollup-openbsd-x64": "4.59.0", "@rollup/rollup-openharmony-arm64": "4.59.0", "@rollup/rollup-win32-arm64-msvc": "4.59.0", "@rollup/rollup-win32-ia32-msvc": "4.59.0", "@rollup/rollup-win32-x64-gnu": "4.59.0", "@rollup/rollup-win32-x64-msvc": "4.59.0", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg=="], + + "router": ["router@2.2.0", "", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="], + + "run-applescript": ["run-applescript@7.1.0", "", {}, "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q=="], + + "run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="], + + "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], + + "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], + + "semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], + + "send": ["send@1.2.1", "", { "dependencies": { "debug": "^4.4.3", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.1", "mime-types": "^3.0.2", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.2" } }, "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ=="], + + "seroval": ["seroval@1.5.0", "", {}, "sha512-OE4cvmJ1uSPrKorFIH9/w/Qwuvi/IMcGbv5RKgcJ/zjA/IohDLU6SVaxFN9FwajbP7nsX0dQqMDes1whk3y+yw=="], + + "seroval-plugins": ["seroval-plugins@1.5.0", "", { "peerDependencies": { "seroval": "^1.0" } }, "sha512-EAHqADIQondwRZIdeW2I636zgsODzoBDwb3PT/+7TLDWyw1Dy/Xv7iGUIEXXav7usHDE9HVhOU61irI3EnyyHA=="], + + "serve-static": ["serve-static@2.2.1", "", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw=="], + + "setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="], + + "shadcn": ["shadcn@3.8.5", "", { "dependencies": { "@antfu/ni": "^25.0.0", "@babel/core": "^7.28.0", "@babel/parser": "^7.28.0", "@babel/plugin-transform-typescript": "^7.28.0", "@babel/preset-typescript": "^7.27.1", "@dotenvx/dotenvx": "^1.48.4", "@modelcontextprotocol/sdk": "^1.26.0", "@types/validate-npm-package-name": "^4.0.2", "browserslist": "^4.26.2", "commander": "^14.0.0", "cosmiconfig": "^9.0.0", "dedent": "^1.6.0", "deepmerge": "^4.3.1", "diff": "^8.0.2", "execa": "^9.6.0", "fast-glob": "^3.3.3", "fs-extra": "^11.3.1", "fuzzysort": "^3.1.0", "https-proxy-agent": "^7.0.6", "kleur": "^4.1.5", "msw": "^2.10.4", "node-fetch": "^3.3.2", "open": "^11.0.0", "ora": "^8.2.0", "postcss": "^8.5.6", "postcss-selector-parser": "^7.1.0", "prompts": "^2.4.2", "recast": "^0.23.11", "stringify-object": "^5.0.0", "tailwind-merge": "^3.0.1", "ts-morph": "^26.0.0", "tsconfig-paths": "^4.2.0", "validate-npm-package-name": "^7.0.1", "zod": "^3.24.1", "zod-to-json-schema": "^3.24.6" }, "bin": { "shadcn": "dist/index.js" } }, "sha512-jPRx44e+eyeV7xwY3BLJXcfrks00+M0h5BGB9l6DdcBW4BpAj4x3lVmVy0TXPEs2iHEisxejr62sZAAw6B1EVA=="], + + "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], + + "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], + + "side-channel": ["side-channel@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="], + + "side-channel-list": ["side-channel-list@1.0.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3" } }, "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA=="], + + "side-channel-map": ["side-channel-map@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3" } }, "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA=="], + + "side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="], + + "signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], + + "sisteransi": ["sisteransi@1.0.5", "", {}, "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="], + + "source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="], + + "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + + "statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="], + + "stdin-discarder": ["stdin-discarder@0.2.2", "", {}, "sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ=="], + + "strict-event-emitter": ["strict-event-emitter@0.5.1", "", {}, "sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ=="], + + "string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], + + "stringify-object": ["stringify-object@5.0.0", "", { "dependencies": { "get-own-enumerable-keys": "^1.0.0", "is-obj": "^3.0.0", "is-regexp": "^3.1.0" } }, "sha512-zaJYxz2FtcMb4f+g60KsRNFOpVMUyuJgA51Zi5Z1DOTC3S59+OQiVOzE9GZt0x72uBGWKsQIuBKeF9iusmKFsg=="], + + "strip-ansi": ["strip-ansi@7.1.2", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="], + + "strip-bom": ["strip-bom@3.0.0", "", {}, "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA=="], + + "strip-final-newline": ["strip-final-newline@4.0.0", "", {}, "sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw=="], + + "strip-json-comments": ["strip-json-comments@3.1.1", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="], + + "supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], + + "tabbable": ["tabbable@6.4.0", "", {}, "sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg=="], + + "tagged-tag": ["tagged-tag@1.0.0", "", {}, "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng=="], + + "tailwind-merge": ["tailwind-merge@3.5.0", "", {}, "sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A=="], + + "tailwindcss": ["tailwindcss@4.2.1", "", {}, "sha512-/tBrSQ36vCleJkAOsy9kbNTgaxvGbyOamC30PRePTQe/o1MFwEKHQk4Cn7BNGaPtjp+PuUrByJehM1hgxfq4sw=="], + + "tapable": ["tapable@2.3.0", "", {}, "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg=="], + + "tiny-invariant": ["tiny-invariant@1.3.3", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="], + + "tiny-warning": ["tiny-warning@1.0.3", "", {}, "sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA=="], + + "tinyexec": ["tinyexec@1.0.2", "", {}, "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg=="], + + "tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="], + + "tldts": ["tldts@7.0.23", "", { "dependencies": { "tldts-core": "^7.0.23" }, "bin": { "tldts": "bin/cli.js" } }, "sha512-ASdhgQIBSay0R/eXggAkQ53G4nTJqTXqC2kbaBbdDwM7SkjyZyO0OaaN1/FH7U/yCeqOHDwFO5j8+Os/IS1dXw=="], + + "tldts-core": ["tldts-core@7.0.23", "", {}, "sha512-0g9vrtDQLrNIiCj22HSe9d4mLVG3g5ph5DZ8zCKBr4OtrspmNB6ss7hVyzArAeE88ceZocIEGkyW1Ime7fxPtQ=="], + + "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="], + + "toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="], + + "tough-cookie": ["tough-cookie@6.0.0", "", { "dependencies": { "tldts": "^7.0.5" } }, "sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w=="], + + "ts-api-utils": ["ts-api-utils@2.4.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA=="], + + "ts-morph": ["ts-morph@26.0.0", "", { "dependencies": { "@ts-morph/common": "~0.27.0", "code-block-writer": "^13.0.3" } }, "sha512-ztMO++owQnz8c/gIENcM9XfCEzgoGphTv+nKpYNM1bgsdOVC/jRZuEBf6N+mLLDNg68Kl+GgUZfOySaRiG1/Ug=="], + + "tsconfig-paths": ["tsconfig-paths@4.2.0", "", { "dependencies": { "json5": "^2.2.2", "minimist": "^1.2.6", "strip-bom": "^3.0.0" } }, "sha512-NoZ4roiN7LnbKn9QqE1amc9DJfzvZXxF4xDavcOWt1BPkdx+m+0gJuPM+S0vCe7zTJMYUP0R8pO2XMr+Y8oLIg=="], + + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "tsx": ["tsx@4.21.0", "", { "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw=="], + + "tw-animate-css": ["tw-animate-css@1.4.0", "", {}, "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ=="], + + "type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="], + + "type-fest": ["type-fest@5.4.4", "", { "dependencies": { "tagged-tag": "^1.0.0" } }, "sha512-JnTrzGu+zPV3aXIUhnyWJj4z/wigMsdYajGLIYakqyOW1nPllzXEJee0QQbHj+CTIQtXGlAjuK0UY+2xTyjVAw=="], + + "type-is": ["type-is@2.0.1", "", { "dependencies": { "content-type": "^1.0.5", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "typescript-eslint": ["typescript-eslint@8.56.1", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.56.1", "@typescript-eslint/parser": "8.56.1", "@typescript-eslint/typescript-estree": "8.56.1", "@typescript-eslint/utils": "8.56.1" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-U4lM6pjmBX7J5wk4szltF7I1cGBHXZopnAXCMXb3+fZ3B/0Z3hq3wS/CCUB2NZBNAExK92mCU2tEohWuwVMsDQ=="], + + "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], + + "unicorn-magic": ["unicorn-magic@0.3.0", "", {}, "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA=="], + + "universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="], + + "unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="], + + "unplugin": ["unplugin@2.3.11", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "acorn": "^8.15.0", "picomatch": "^4.0.3", "webpack-virtual-modules": "^0.6.2" } }, "sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww=="], + + "until-async": ["until-async@3.0.2", "", {}, "sha512-IiSk4HlzAMqTUseHHe3VhIGyuFmN90zMTpD3Z3y8jeQbzLIq500MVM7Jq2vUAnTKAFPJrqwkzr6PoTcPhGcOiw=="], + + "update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="], + + "uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="], + + "use-sync-external-store": ["use-sync-external-store@1.6.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="], + + "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], + + "validate-npm-package-name": ["validate-npm-package-name@7.0.2", "", {}, "sha512-hVDIBwsRruT73PbK7uP5ebUt+ezEtCmzZz3F59BSr2F6OVFnJ/6h8liuvdLrQ88Xmnk6/+xGGuq+pG9WwTuy3A=="], + + "vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="], + + "vite": ["vite@7.3.1", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA=="], + + "web-streams-polyfill": ["web-streams-polyfill@3.3.3", "", {}, "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw=="], + + "webpack-virtual-modules": ["webpack-virtual-modules@0.6.2", "", {}, "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ=="], + + "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + + "word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="], + + "wrap-ansi": ["wrap-ansi@6.2.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="], + + "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], + + "wsl-utils": ["wsl-utils@0.3.1", "", { "dependencies": { "is-wsl": "^3.1.0", "powershell-utils": "^0.1.0" } }, "sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg=="], + + "y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="], + + "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], + + "yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="], + + "yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="], + + "yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], + + "yoctocolors": ["yoctocolors@2.1.2", "", {}, "sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug=="], + + "yoctocolors-cjs": ["yoctocolors-cjs@2.1.3", "", {}, "sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw=="], + + "zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + + "zod-to-json-schema": ["zod-to-json-schema@3.25.1", "", { "peerDependencies": { "zod": "^3.25 || ^4" } }, "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA=="], + + "zod-validation-error": ["zod-validation-error@4.0.2", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ=="], + + "@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + + "@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + + "@babel/helper-create-class-features-plugin/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + + "@dotenvx/dotenvx/commander": ["commander@11.1.0", "", {}, "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ=="], + + "@dotenvx/dotenvx/execa": ["execa@5.1.1", "", { "dependencies": { "cross-spawn": "^7.0.3", "get-stream": "^6.0.0", "human-signals": "^2.1.0", "is-stream": "^2.0.0", "merge-stream": "^2.0.0", "npm-run-path": "^4.0.1", "onetime": "^5.1.2", "signal-exit": "^3.0.3", "strip-final-newline": "^2.0.0" } }, "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg=="], + + "@dotenvx/dotenvx/which": ["which@4.0.0", "", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg=="], + + "@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], + + "@eslint/eslintrc/globals": ["globals@14.0.0", "", {}, "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ=="], + + "@modelcontextprotocol/sdk/ajv": ["ajv@8.18.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A=="], + + "@modelcontextprotocol/sdk/zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], + + "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.8.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg=="], + + "@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.8.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg=="], + + "@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.1.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ=="], + + "@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.1", "", { "dependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1", "@tybys/wasm-util": "^0.10.1" }, "bundled": true }, "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A=="], + + "@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="], + + "@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "@ts-morph/common/minimatch": ["minimatch@10.2.2", "", { "dependencies": { "brace-expansion": "^5.0.2" } }, "sha512-+G4CpNBxa5MprY+04MbgOw1v7So6n5JY166pFi9KfYwT78fxScCeSNQSNzp6dpPSW2rONOps6Ocam1wFhCgoVw=="], + + "@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="], + + "@typescript-eslint/typescript-estree/minimatch": ["minimatch@10.2.2", "", { "dependencies": { "brace-expansion": "^5.0.2" } }, "sha512-+G4CpNBxa5MprY+04MbgOw1v7So6n5JY166pFi9KfYwT78fxScCeSNQSNzp6dpPSW2rONOps6Ocam1wFhCgoVw=="], + + "@typescript-eslint/typescript-estree/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], + + "@typescript-eslint/visitor-keys/eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="], + + "accepts/mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="], + + "ajv-formats/ajv": ["ajv@8.18.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A=="], + + "anymatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], + + "c12/chokidar": ["chokidar@5.0.0", "", { "dependencies": { "readdirp": "^5.0.0" } }, "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw=="], + + "chokidar/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], + + "cliui/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], + + "cliui/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + + "cliui/wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], + + "eslint-plugin-react-hooks/zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], + + "express/cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="], + + "express/mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="], + + "fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], + + "log-symbols/chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], + + "log-symbols/is-unicode-supported": ["is-unicode-supported@1.3.0", "", {}, "sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ=="], + + "micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], + + "npm-run-path/path-key": ["path-key@4.0.0", "", {}, "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ=="], + + "nypm/citty": ["citty@0.2.1", "", {}, "sha512-kEV95lFBhQgtogAPlQfJJ0WGVSokvLr/UEoFPiKKOXF7pl98HfUVUD0ejsuTCld/9xH9vogSywZ5KqHzXrZpqg=="], + + "ora/chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], + + "prompts/kleur": ["kleur@3.0.3", "", {}, "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w=="], + + "readdirp/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], + + "recast/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], + + "restore-cursor/onetime": ["onetime@7.0.0", "", { "dependencies": { "mimic-function": "^5.0.0" } }, "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ=="], + + "router/path-to-regexp": ["path-to-regexp@8.3.0", "", {}, "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA=="], + + "send/mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="], + + "type-is/mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="], + + "wrap-ansi/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], + + "wrap-ansi/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + + "yargs/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], + + "@dotenvx/dotenvx/execa/get-stream": ["get-stream@6.0.1", "", {}, "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg=="], + + "@dotenvx/dotenvx/execa/human-signals": ["human-signals@2.1.0", "", {}, "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw=="], + + "@dotenvx/dotenvx/execa/is-stream": ["is-stream@2.0.1", "", {}, "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg=="], + + "@dotenvx/dotenvx/execa/npm-run-path": ["npm-run-path@4.0.1", "", { "dependencies": { "path-key": "^3.0.0" } }, "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw=="], + + "@dotenvx/dotenvx/execa/signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="], + + "@dotenvx/dotenvx/execa/strip-final-newline": ["strip-final-newline@2.0.0", "", {}, "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA=="], + + "@dotenvx/dotenvx/which/isexe": ["isexe@3.1.5", "", {}, "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w=="], + + "@modelcontextprotocol/sdk/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], + + "@ts-morph/common/minimatch/brace-expansion": ["brace-expansion@5.0.3", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA=="], + + "@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@5.0.3", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA=="], + + "accepts/mime-types/mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], + + "ajv-formats/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], + + "c12/chokidar/readdirp": ["readdirp@5.0.0", "", {}, "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ=="], + + "cliui/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], + + "cliui/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + + "express/mime-types/mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], + + "send/mime-types/mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], + + "type-is/mime-types/mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], + + "wrap-ansi/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], + + "wrap-ansi/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + + "yargs/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], + + "yargs/string-width/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + + "@ts-morph/common/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], + + "@typescript-eslint/typescript-estree/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], + + "yargs/string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + } +} diff --git a/console/components.json b/console/components.json new file mode 100644 index 0000000..b813d25 --- /dev/null +++ b/console/components.json @@ -0,0 +1,25 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "base-nova", + "rsc": false, + "tsx": true, + "tailwind": { + "config": "", + "css": "src/index.css", + "baseColor": "zinc", + "cssVariables": true, + "prefix": "" + }, + "iconLibrary": "tabler", + "rtl": false, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "menuColor": "default", + "menuAccent": "subtle", + "registries": {} +} diff --git a/console/eslint.config.js b/console/eslint.config.js new file mode 100644 index 0000000..5e6b472 --- /dev/null +++ b/console/eslint.config.js @@ -0,0 +1,23 @@ +import js from '@eslint/js' +import globals from 'globals' +import reactHooks from 'eslint-plugin-react-hooks' +import reactRefresh from 'eslint-plugin-react-refresh' +import tseslint from 'typescript-eslint' +import { defineConfig, globalIgnores } from 'eslint/config' + +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + js.configs.recommended, + tseslint.configs.recommended, + reactHooks.configs.flat.recommended, + reactRefresh.configs.vite, + ], + languageOptions: { + ecmaVersion: 2020, + globals: globals.browser, + }, + }, +]) diff --git a/console/index.html b/console/index.html new file mode 100644 index 0000000..e6ae61e --- /dev/null +++ b/console/index.html @@ -0,0 +1,13 @@ + + + + + + + Lucid + + +
+ + + diff --git a/console/openapi-ts.config.ts b/console/openapi-ts.config.ts new file mode 100644 index 0000000..86645db --- /dev/null +++ b/console/openapi-ts.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from '@hey-api/openapi-ts'; + +export default defineConfig({ + input: '../api/openapi.json', + output: 'src/lib/client', + plugins: ['@hey-api/client-axios', '@tanstack/react-query'], +}); diff --git a/console/package.json b/console/package.json new file mode 100644 index 0000000..fae9d8d --- /dev/null +++ b/console/package.json @@ -0,0 +1,52 @@ +{ + "name": "lucid-console", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "lint": "eslint .", + "preview": "vite preview", + "check": "tsc -b --noEmit && eslint .", + "test": "echo 'No tests yet' && exit 0" + }, + "dependencies": { + "@base-ui/react": "^1.2.0", + "@fontsource-variable/inter": "^5.2.8", + "@tabler/icons-react": "^3.37.1", + "@tailwindcss/vite": "^4.1.17", + "@tanstack/react-query": "^5.90.21", + "@tanstack/react-query-devtools": "^5.91.3", + "@tanstack/react-router": "^1.162.6", + "@tanstack/react-router-devtools": "^1.162.6", + "@tanstack/react-table": "^8.21.3", + "axios": "^1.13.5", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "font-logos": "^1.3.0", + "react": "^19.2.0", + "react-dom": "^19.2.0", + "shadcn": "^3.8.5", + "tailwind-merge": "^3.5.0", + "tailwindcss": "^4.1.17", + "tw-animate-css": "^1.4.0" + }, + "devDependencies": { + "@eslint/js": "^9.39.1", + "@hey-api/client-axios": "^0.9.1", + "@hey-api/openapi-ts": "^0.92.4", + "@tanstack/router-plugin": "^1.162.6", + "@types/node": "^24.10.1", + "@types/react": "^19.2.5", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^5.1.1", + "eslint": "^9.39.1", + "eslint-plugin-react-hooks": "^7.0.1", + "eslint-plugin-react-refresh": "^0.4.24", + "globals": "^16.5.0", + "typescript": "~5.9.3", + "typescript-eslint": "^8.46.4", + "vite": "^7.2.4" + } +} \ No newline at end of file diff --git a/console/public/vite.svg b/console/public/vite.svg new file mode 100644 index 0000000..e7b8dfb --- /dev/null +++ b/console/public/vite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/console/src/assets/react.svg b/console/src/assets/react.svg new file mode 100644 index 0000000..6c87de9 --- /dev/null +++ b/console/src/assets/react.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/console/src/components/component-example.tsx b/console/src/components/component-example.tsx new file mode 100644 index 0000000..8b03743 --- /dev/null +++ b/console/src/components/component-example.tsx @@ -0,0 +1,501 @@ +"use client" + +import * as React from "react" + +import { + Example, + ExampleWrapper, +} from "@/components/example" +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogMedia, + AlertDialogTitle, + AlertDialogTrigger, +} from "@/components/ui/alert-dialog" +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { + Card, + CardAction, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from "@/components/ui/card" +import { + Combobox, + ComboboxContent, + ComboboxEmpty, + ComboboxInput, + ComboboxItem, + ComboboxList, +} from "@/components/ui/combobox" +import { + DropdownMenu, + DropdownMenuCheckboxItem, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuPortal, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" +import { Field, FieldGroup, FieldLabel } from "@/components/ui/field" +import { Input } from "@/components/ui/input" +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { Textarea } from "@/components/ui/textarea" +import { IconPlus, IconBluetooth, IconDotsVertical, IconFile, IconFolder, IconFolderOpen, IconFileCode, IconDots, IconFolderSearch, IconDeviceFloppy, IconDownload, IconEye, IconLayout, IconPalette, IconSun, IconMoon, IconDeviceDesktop, IconUser, IconCreditCard, IconSettings, IconKeyboard, IconLanguage, IconBell, IconMail, IconShield, IconHelpCircle, IconFileText, IconLogout } from "@tabler/icons-react" + +export function ComponentExample() { + return ( + + + + + ) +} + +function CardExample() { + return ( + + +
+ Photo by mymind on Unsplash + + Observability Plus is replacing Monitoring + + Switch to the improved way to explore your data, with natural + language. Monitoring will no longer be available on the Pro plan in + November, 2025 + + + + + }> + + Show Dialog + + + + + + + Allow accessory to connect? + + Do you want to allow the USB accessory to connect to this + device? + + + + Don't allow + Allow + + + + + Warning + + + + + ) +} + +const frameworks = [ + "Next.js", + "SvelteKit", + "Nuxt.js", + "Remix", + "Astro", +] as const + +const roleItems = [ + { label: "Developer", value: "developer" }, + { label: "Designer", value: "designer" }, + { label: "Manager", value: "manager" }, + { label: "Other", value: "other" }, +] + +function FormExample() { + const [notifications, setNotifications] = React.useState({ + email: true, + sms: false, + push: true, + }) + const [theme, setTheme] = React.useState("light") + + return ( + + + + User Information + Please fill in your details below + + + } + > + + More options + + + + File + + + New File + ⌘N + + + + New Folder + ⇧⌘N + + + + + Open Recent + + + + + Recent Projects + + + Project Alpha + + + + Project Beta + + + + + More Projects + + + + + + Project Gamma + + + + Project Delta + + + + + + + + + + Browse... + + + + + + + + + Save + ⌘S + + + + Export + ⇧⌘E + + + + + View + + setNotifications({ + ...notifications, + email: checked === true, + }) + } + > + + Show Sidebar + + + setNotifications({ + ...notifications, + sms: checked === true, + }) + } + > + + Show Status Bar + + + + + Theme + + + + + Appearance + + + + Light + + + + Dark + + + + System + + + + + + + + + + Account + + + Profile + ⇧⌘P + + + + Billing + + + + + Settings + + + + + Preferences + + + Keyboard Shortcuts + + + + Language + + + + + Notifications + + + + + + Notification Types + + + setNotifications({ + ...notifications, + push: checked === true, + }) + } + > + + Push Notifications + + + setNotifications({ + ...notifications, + email: checked === true, + }) + } + > + + Email Notifications + + + + + + + + + + + Privacy & Security + + + + + + + + + + + Help & Support + + + + Documentation + + + + + + + Sign Out + ⇧⌘Q + + + + + + + +
+ +
+ + Name + + + + Role + + +
+ + + Framework + + + + + No frameworks found. + + {(item) => ( + + {item} + + )} + + + + + + Comments +