diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 0000000..f31752f --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,12 @@ +[target.x86_64-pc-windows-msvc] +rustflags = ["-Ctarget-feature=+crt-static"] + +[env] +# MSVC linker library paths (ignored on non-Windows targets) +LIB = { value = "C:\\Program Files (x86)\\Microsoft Visual Studio\\2022\\BuildTools\\VC\\Tools\\MSVC\\14.44.35207\\lib\\x64;C:\\Program Files (x86)\\Windows Kits\\10\\Lib\\10.0.26100.0\\ucrt\\x64;C:\\Program Files (x86)\\Windows Kits\\10\\Lib\\10.0.26100.0\\um\\x64", force = true } +# OpenSSL env vars are set via system environment on Windows, not here, +# because openssl-sys panics if the paths don't exist (breaks Linux CI). +# Set these in your shell or system env on Windows: +# OPENSSL_DIR=C:\Program Files\OpenSSL-Win64 +# OPENSSL_LIB_DIR=C:\Program Files\OpenSSL-Win64\lib\VC\x64\MT +# OPENSSL_STATIC=1 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fc24154..895c441 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,6 +11,9 @@ jobs: steps: - uses: actions/checkout@v4 + - name: Install system dependencies + run: sudo apt-get update && sudo apt-get install -y libssl-dev pkg-config + - name: Install Rust stable uses: dtolnay/rust-toolchain@stable with: @@ -21,18 +24,18 @@ jobs: - name: Check formatting run: cargo fmt --check - - name: Clippy (default features) + - name: Clippy run: cargo clippy --workspace -- -D warnings - - name: Clippy (postgres feature) - run: cargo clippy --workspace --features postgres -- -D warnings - test: name: Test runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + - name: Install system dependencies + run: sudo apt-get update && sudo apt-get install -y libssl-dev pkg-config + - name: Install Rust stable uses: dtolnay/rust-toolchain@stable @@ -61,6 +64,9 @@ jobs: steps: - uses: actions/checkout@v4 + - name: Install system dependencies + run: sudo apt-get update && sudo apt-get install -y libssl-dev pkg-config + - name: Install Rust stable uses: dtolnay/rust-toolchain@stable @@ -69,4 +75,4 @@ jobs: - name: Run tests with postgres env: DATABASE_URL: postgres://sao:sao@localhost:5432/sao_test - run: cargo test --workspace --features postgres + run: cargo test --workspace diff --git a/.gitignore b/.gitignore index f100133..16fde66 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,13 @@ master_verifying.key target/ *.pdb +# Node / Frontend +node_modules/ +frontend/dist/ + +# Claude Code +.claude/ + # Runtime data config.json global_settings.json diff --git a/CLAUDE.md b/CLAUDE.md index 96ed9ac..edf8e37 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,43 +1,185 @@ # SAO - Claude Code Project Guide ## Project Overview -SAO (Secure Agent Orchestrator) is the multi-agent management layer. It handles agent identity creation, cryptographic verification, and coordination. It does NOT contain agent-specific logic (that's in abigail). +SAO (Secure Agent Orchestrator) is the centralized key management and multi-agent orchestration platform. It provides a secure vault for all cryptographic keys (Ed25519 identity keys, API provider keys, GPG keys, OAuth tokens), agent identity management, and coordination. It does NOT contain agent-specific logic (that's in abigail). + +## Tech Stack +- **Language**: Rust (primary), TypeScript (frontend) +- **Backend**: Axum (REST + WebSocket) +- **Frontend**: React + TypeScript (served as static files by Axum) +- **Database**: PostgreSQL 16 (via sqlx, compile-time checked queries) +- **Deployment**: Docker + docker-compose (all services containerized) +- **Auth**: WebAuthn/FIDO2 (local auth, Windows Hello), OIDC (SSO) ## Build & Test ```bash +# Development (local) cargo build # Build all crates cargo test # Run all tests cargo run --bin sao-server # Start the orchestration server cargo clippy # Lint + +# Docker (production) +docker compose -f docker/docker-compose.yml up --build +docker compose -f docker/docker-compose.yml down + +# Frontend +cd frontend && npm install && npm run dev # Dev server +cd frontend && npm run build # Production build (output to frontend/dist) ``` ## Architecture Rules ### Separation of Concerns -- SAO manages agent identities and orchestration ONLY +- SAO manages keys, identities, and orchestration ONLY - SAO does NOT run agent logic, LLM providers, or skills - Agent-specific code belongs in the `abigail` repo - Ethical evaluation logic belongs in `Ethical_AI_Reg` - SAO bridges between agents and the ethical platform ### Crate Structure -- `sao-core`: Pure library crate with no server dependencies. Contains identity management, master key operations, and bridge types. -- `sao-server`: Binary crate with Axum server. Depends on sao-core. Contains routes, state, WebSocket handler. +- `sao-core`: Pure library crate with no server dependencies. Contains identity management, master key operations, vault logic, crypto primitives, and bridge types. +- `sao-server`: Binary crate with Axum server. Depends on sao-core. Contains routes, state, WebSocket handler, auth middleware, and serves the React frontend. +- `frontend/`: React + TypeScript SPA. Built separately, output served as static files by sao-server. + +### Database (PostgreSQL) +- All persistent data lives in PostgreSQL (users, key metadata, agent registry, audit logs, SSO config) +- Use sqlx with compile-time checked queries where possible +- Migrations live in `migrations/` directory (sqlx migrate) +- Encrypted key material is stored in the database, never plaintext secrets +- Docker-compose provisions the database automatically +- Connection via `DATABASE_URL` environment variable + +### Docker-First Development +- All services run via docker-compose (SAO server, PostgreSQL, frontend build) +- Multi-stage Dockerfile for minimal production images +- Persistent volumes for PostgreSQL data and SAO vault data +- Health checks on all services +- Environment configuration via `.env` file (not committed to git) + +### Key Management (Full Vault) +SAO manages ALL key types across the ecosystem: + +| Key Type | Examples | Storage | +|----------|----------|---------| +| **Ed25519 Identity Keys** | Master key, agent signing keys | Encrypted in DB, master key also on filesystem | +| **API Provider Keys** | OpenAI, Anthropic, Google, GitHub tokens | Encrypted in DB | +| **GPG Keys** | Mentor signing keys, service keys | Encrypted in DB | +| **OAuth Tokens** | OIDC refresh tokens, service tokens | Encrypted in DB | + +- All secrets encrypted at rest in PostgreSQL (using vault encryption key derived from WebAuthn or passphrase) +- Key metadata (name, type, created, last_used, expiry) stored alongside encrypted blobs +- Audit log for every key access, creation, rotation, and deletion +- Keys organized by owner (user or agent) with RBAC permissions + +### Authentication & Authorization + +#### Local Auth (WebAuthn / FIDO2) - Primary +- First-run setup wizard orchestrates initial credential registration +- Browser calls Windows Hello (or any FIDO2 authenticator) via WebAuthn API +- Server stores credential IDs and public keys in PostgreSQL +- WebAuthn used to unlock the vault and authenticate sessions +- Supports fingerprint, PIN, security keys +- Session tokens (JWT) issued after successful WebAuthn ceremony + +#### SSO (OIDC) - Enterprise +- Admin-configurable OIDC providers (Entra ID, Auth0, Google, etc.) +- Standard Authorization Code flow with PKCE +- OIDC config stored in PostgreSQL (issuer, client_id, client_secret, scopes) +- User accounts linked to OIDC identities +- Agents still authenticate via Ed25519 (OIDC is for human users only) + +#### Roles +- **User**: Manage own keys, manually store/retrieve secrets, register agents/hives, view audit logs for own resources +- **Administrator**: Configure SSO providers, manage persistent data connections, manage all users, view full audit logs, system configuration + +### First-Run Setup +On first launch (no users in database): +1. Redirect to setup wizard +2. Create admin account with WebAuthn credential registration +3. Generate master Ed25519 signing key +4. Initialize vault encryption +5. Optionally configure SSO provider +6. Redirect to dashboard ### Security - Master key never leaves the SAO data directory +- All secrets encrypted at rest (AES-256-GCM with key derived from auth) - Agent public keys are verified against master key signatures - All agent registration requires Ed25519 signature verification - Never log secrets or key material - Use the SSRF validation pattern from abigail for any URL inputs +- WebAuthn challenge-response prevents replay attacks +- CSRF protection on all state-changing endpoints +- Rate limiting on auth endpoints +- Audit logging for all sensitive operations ### Integration Protocol - Agents connect via REST API or WebSocket - SAO verifies agent identity before accepting connections +- Agents retrieve their API keys from SAO vault at startup - Ethical evaluations are forwarded to Ethical_AI_Reg via REST - WebSocket broadcasts for real-time event distribution +- "Hive" = a logical group of agents sharing a key set + +### API Structure +``` +# Public (no auth) +POST /api/auth/webauthn/register/begin # Start WebAuthn registration +POST /api/auth/webauthn/register/complete # Complete registration +POST /api/auth/webauthn/login/begin # Start WebAuthn login +POST /api/auth/webauthn/login/complete # Complete login +GET /api/auth/oidc/:provider/login # Redirect to OIDC provider +GET /api/auth/oidc/:provider/callback # OIDC callback +GET /api/health # Health check +GET /api/setup/status # First-run check + +# Authenticated (user) +GET /api/keys # List own keys +POST /api/keys # Store a key +GET /api/keys/:id # Retrieve a key (decrypted) +DELETE /api/keys/:id # Delete a key +PUT /api/keys/:id # Update a key +GET /api/agents # List registered agents +POST /api/agents # Register an agent +GET /api/audit # Own audit log + +# Authenticated (admin) +GET /api/admin/users # Manage users +POST /api/admin/sso # Configure SSO provider +GET /api/admin/sso # List SSO configs +DELETE /api/admin/sso/:id # Remove SSO provider +GET /api/admin/audit # Full audit log +POST /api/admin/connections # Configure data connections + +# Agent API (Ed25519 auth) +POST /api/agent/auth # Agent authenticates with signature +GET /api/agent/keys # Agent retrieves its assigned keys +WS /ws/agent/:agent_id # WebSocket for real-time events +``` + +## Frontend Structure (React + TypeScript) +``` +frontend/ + src/ + components/ # Reusable UI components + pages/ # Route-level page components + Dashboard.tsx + KeyVault.tsx # Key management UI + AgentRegistry.tsx # Agent/hive management + SetupWizard.tsx # First-run setup + AdminSSO.tsx # SSO configuration (admin) + AdminUsers.tsx # User management (admin) + AuditLog.tsx # Audit log viewer + hooks/ # Custom React hooks + api/ # API client functions + auth/ # WebAuthn + OIDC client logic + types/ # TypeScript type definitions +``` ## Related Repos -- `abigail` - Agent implementation +- `abigail` - Agent implementation (Tauri desktop app) +- `Orion_Dock` - Orion agent platform (uses DPAPI-encrypted keys locally) - `Ethical_AI_Reg` - Ethical alignment platform - `Phoenix` - Coordination and project tracking +- `prometheus-bound` - Infrastructure (GPG signing, host vault) diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..bcc4ecc --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,4039 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common", + "generic-array", +] + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + +[[package]] +name = "aes-gcm-siv" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae0784134ba9375416d469ec31e7c5f9fa94405049cf08c5ce5b4698be673e0d" +dependencies = [ + "aead", + "aes", + "cipher", + "ctr", + "polyval", + "subtle", + "zeroize", +] + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.101" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f0e0fee31ef5ed1ba1316088939cea399010ed7731dba877ed44aeb407a75ea" + +[[package]] +name = "argon2" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" +dependencies = [ + "base64ct", + "blake2", + "cpufeatures", + "password-hash", +] + +[[package]] +name = "asn1-rs" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5493c3bedbacf7fd7382c6346bbd66687d12bbaad3a89a2d2c303ee6cf20b048" +dependencies = [ + "asn1-rs-derive", + "asn1-rs-impl", + "displaydoc", + "nom", + "num-traits", + "rusticata-macros", + "thiserror 1.0.69", + "time", +] + +[[package]] +name = "asn1-rs-derive" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "965c2d33e53cb6b267e148a4cb0760bc01f4904c1cd4bb4002a085bb016d1490" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "asn1-rs-impl" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "atoi" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528" +dependencies = [ + "num-traits", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "axum" +version = "0.7.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f" +dependencies = [ + "async-trait", + "axum-core", + "base64 0.22.1", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "rustversion", + "serde", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sha1", + "sync_wrapper", + "tokio", + "tokio-tungstenite", + "tower 0.5.3", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199" +dependencies = [ + "async-trait", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "rustversion", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "base64urlsafedata" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f7f6be94fa637132933fd0a68b9140bcb60e3d46164cb68e82a2bb8d102b3a" +dependencies = [ + "base64 0.21.7", + "pastey", + "serde", +] + +[[package]] +name = "bitflags" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" +dependencies = [ + "serde_core", +] + +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "cc" +version = "1.2.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "chrono" +version = "0.4.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fac4744fb15ae8337dc853fee7fb3f4e48c0fbaa23d0afe49c447b4fab126118" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" + +[[package]] +name = "crossbeam-queue" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "subtle", + "zeroize", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "typenum", +] + +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher", +] + +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures", + "curve25519-dalek-derive", + "digest", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "darling" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1247195ecd7e3c85f83c8d2a366e4210d588e802133e1e355180a9870b517ea4" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" +dependencies = [ + "darling_core", + "quote", + "syn", +] + +[[package]] +name = "data-encoding" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "pem-rfc7468", + "zeroize", +] + +[[package]] +name = "der-parser" +version = "9.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cd0a5c643689626bec213c4d8bd4d96acc8ffdb4ad4bb6bc16abf27d5f4b553" +dependencies = [ + "asn1-rs", + "displaydoc", + "nom", + "num-bigint", + "num-traits", + "rusticata-macros", +] + +[[package]] +name = "deranged" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc3dc5ad92c2e2d1c193bbbbdf2ea477cb81331de4f3103f267ca18368b988c4" +dependencies = [ + "powerfmt", + "serde_core", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "const-oid", + "crypto-common", + "subtle", +] + +[[package]] +name = "dirs" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.48.0", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "dotenvy" +version = "0.15.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "ecdsa" +version = "0.16.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +dependencies = [ + "der", + "digest", + "elliptic-curve", + "rfc6979", + "signature", + "spki", +] + +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "rand_core 0.6.4", + "serde", + "sha2", + "subtle", + "zeroize", +] + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +dependencies = [ + "serde", +] + +[[package]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct", + "crypto-bigint", + "digest", + "ff", + "generic-array", + "group", + "hkdf", + "pem-rfc7468", + "pkcs8", + "rand_core 0.6.4", + "sec1", + "subtle", + "zeroize", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "etcetera" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "136d1b5283a1ab77bd9257427ffd09d8667ced0570b6f938942bc7568ed5b943" +dependencies = [ + "cfg-if", + "home", + "windows-sys 0.48.0", +] + +[[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "ff" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "flume" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095" +dependencies = [ + "futures-core", + "futures-sink", + "spin", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-intrusive" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d930c203dd0b6ff06e0201a4a2fe9149b43c684fd4420555b26d21b1a02956f" +dependencies = [ + "futures-core", + "lock_api", + "parking_lot", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", + "zeroize", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi", + "wasip2", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139ef39800118c7683f2fd3c98c1b23c09ae076556b435f8e9064ae108aaeeec" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", + "wasip3", +] + +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + +[[package]] +name = "hashlink" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" +dependencies = [ + "hashbrown 0.15.5", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "home" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "http-range-header" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c" + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "pin-utils", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +dependencies = [ + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" + +[[package]] +name = "icu_properties" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" + +[[package]] +name = "icu_provider" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] + +[[package]] +name = "indexmap" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +dependencies = [ + "equivalent", + "hashbrown 0.16.1", + "serde", + "serde_core", +] + +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + +[[package]] +name = "ipnet" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" + +[[package]] +name = "iri-string" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c91338f0783edbd6195decb37bae672fd3b165faffb89bf7b9e6942f8b1a731a" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "itertools" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" + +[[package]] +name = "js-sys" +version = "0.3.85" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c942ebf8e95485ca0d52d97da7c5a2c387d0e7f0ba4c35e93bfcaee045955b3" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "jsonwebtoken" +version = "9.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a87cc7a48537badeae96744432de36f4be2b4a34a05a5ef32e9dd8a1c169dde" +dependencies = [ + "base64 0.22.1", + "js-sys", + "pem", + "ring", + "serde", + "serde_json", + "simple_asn1", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +dependencies = [ + "spin", +] + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.182" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112" + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "libredox" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616" +dependencies = [ + "bitflags", + "libc", + "redox_syscall 0.7.1", +] + +[[package]] +name = "libsqlite3-sys" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" +dependencies = [ + "pkg-config", + "vcpkg", +] + +[[package]] +name = "litemap" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "matchit" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" + +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest", +] + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "mio" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-bigint-dig" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" +dependencies = [ + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand 0.8.5", + "smallvec", + "zeroize", +] + +[[package]] +name = "num-conv" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "oauth2" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51e219e79014df21a225b1860a479e2dcd7cbd9130f4defd4bd0e191ea31d67d" +dependencies = [ + "base64 0.22.1", + "chrono", + "getrandom 0.2.17", + "http", + "rand 0.8.5", + "reqwest", + "serde", + "serde_json", + "serde_path_to_error", + "sha2", + "thiserror 1.0.69", + "url", +] + +[[package]] +name = "oid-registry" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8d8034d9489cdaf79228eb9f6a3b8d7bb32ba00d6645ebd48eef4077ceb5bd9" +dependencies = [ + "asn1-rs", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + +[[package]] +name = "openidconnect" +version = "4.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d8c6709ba2ea764bbed26bce1adf3c10517113ddea6f2d4196e4851757ef2b2" +dependencies = [ + "base64 0.21.7", + "chrono", + "dyn-clone", + "ed25519-dalek", + "hmac", + "http", + "itertools", + "log", + "oauth2", + "p256", + "p384", + "rand 0.8.5", + "rsa", + "serde", + "serde-value", + "serde_json", + "serde_path_to_error", + "serde_plain", + "serde_with", + "sha2", + "subtle", + "thiserror 1.0.69", + "url", +] + +[[package]] +name = "openssl" +version = "0.10.75" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328" +dependencies = [ + "bitflags", + "cfg-if", + "foreign-types", + "libc", + "once_cell", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "openssl-sys" +version = "0.9.111" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82cab2d520aa75e3c58898289429321eb788c3106963d0dc886ec7a5f4adc321" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "ordered-float" +version = "2.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68f19d67e5a2795c94e73e0bb1cc1a7edeb2e28efd39e2e1c9b7a40c1108b11c" +dependencies = [ + "num-traits", +] + +[[package]] +name = "p256" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2", +] + +[[package]] +name = "p384" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall 0.5.18", + "smallvec", + "windows-link", +] + +[[package]] +name = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "pastey" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec" + +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64 0.22.1", + "serde_core", +] + +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der", + "pkcs8", + "spki", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "pkg-config" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if", + "cpufeatures", + "opaque-debug", + "universal-hash", +] + +[[package]] +name = "potential_utf" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "primeorder" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" +dependencies = [ + "elliptic-curve", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror 2.0.18", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31" +dependencies = [ + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand 0.9.2", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.60.2", +] + +[[package]] +name = "quote" +version = "1.0.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "redox_syscall" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35985aa610addc02e24fc232012c86fd11f14111180f902b67e2d5331f8ebf2b" +dependencies = [ + "bitflags", +] + +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 1.0.69", +] + +[[package]] +name = "ref-cast" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a96887878f22d7bad8a3b6dc5b7440e0ada9a245242924394987b21cf2210a4c" + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower 0.5.3", + "tower-http 0.6.8", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots", +] + +[[package]] +name = "rfc6979" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac", + "subtle", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rsa" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" +dependencies = [ + "const-oid", + "digest", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8", + "rand_core 0.6.4", + "signature", + "spki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustc-hash" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rusticata-macros" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632" +dependencies = [ + "nom", +] + +[[package]] +name = "rustls" +version = "0.23.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c665f33d38cea657d9614f766881e4d510e0eda4239891eea56b4cadcf01801b" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "sao-core" +version = "0.0.1" +dependencies = [ + "aes-gcm-siv", + "anyhow", + "argon2", + "base64 0.21.7", + "chrono", + "ed25519-dalek", + "rand 0.8.5", + "reqwest", + "serde", + "serde_json", + "sha2", + "thiserror 1.0.69", + "tracing", + "uuid", + "zeroize", +] + +[[package]] +name = "sao-server" +version = "0.0.1" +dependencies = [ + "anyhow", + "axum", + "base64 0.21.7", + "chrono", + "dirs", + "futures-util", + "jsonwebtoken", + "openidconnect", + "rand 0.8.5", + "reqwest", + "sao-core", + "serde", + "serde_json", + "sha2", + "sqlx", + "tokio", + "tower 0.4.13", + "tower-http 0.5.2", + "tracing", + "tracing-subscriber", + "url", + "uuid", + "webauthn-rs", + "webauthn-rs-proto", +] + +[[package]] +name = "schemars" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct", + "der", + "generic-array", + "pkcs8", + "subtle", + "zeroize", +] + +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde-value" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3a1a3341211875ef120e117ea7fd5228530ae7e7036a779fdc9117be6b3282c" +dependencies = [ + "ordered-float", + "serde", +] + +[[package]] +name = "serde_cbor_2" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34aec2709de9078e077090abd848e967abab63c9fb3fdb5d4799ad359d8d482c" +dependencies = [ + "half", + "serde", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_plain" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ce1fc6db65a611022b23a0dec6975d63fb80a302cb3388835ff02c097258d50" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "serde_with" +version = "3.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fa237f2807440d238e0364a218270b98f767a00d3dada77b1c53ae88940e2e7" +dependencies = [ + "base64 0.22.1", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.13.0", + "schemars 0.9.0", + "schemars 1.2.1", + "serde_core", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52a8e3ca0ca629121f70ab50f95249e5a6f925cc0f6ffe8256c45b728875706c" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest", + "rand_core 0.6.4", +] + +[[package]] +name = "simple_asn1" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d" +dependencies = [ + "num-bigint", + "num-traits", + "thiserror 2.0.18", + "time", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +dependencies = [ + "serde", +] + +[[package]] +name = "socket2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86f4aa3ad99f2088c990dfa82d367e19cb29268ed67c574d10d0a4bfe71f07e0" +dependencies = [ + "libc", + "windows-sys 0.60.2", +] + +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +dependencies = [ + "lock_api", +] + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "sqlx" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fefb893899429669dcdd979aff487bd78f4064e5e7907e4269081e0ef7d97dc" +dependencies = [ + "sqlx-core", + "sqlx-macros", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", +] + +[[package]] +name = "sqlx-core" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6" +dependencies = [ + "base64 0.22.1", + "bytes", + "chrono", + "crc", + "crossbeam-queue", + "either", + "event-listener", + "futures-core", + "futures-intrusive", + "futures-io", + "futures-util", + "hashbrown 0.15.5", + "hashlink", + "indexmap 2.13.0", + "log", + "memchr", + "once_cell", + "percent-encoding", + "serde", + "serde_json", + "sha2", + "smallvec", + "thiserror 2.0.18", + "tokio", + "tokio-stream", + "tracing", + "url", + "uuid", +] + +[[package]] +name = "sqlx-macros" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2d452988ccaacfbf5e0bdbc348fb91d7c8af5bee192173ac3636b5fb6e6715d" +dependencies = [ + "proc-macro2", + "quote", + "sqlx-core", + "sqlx-macros-core", + "syn", +] + +[[package]] +name = "sqlx-macros-core" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19a9c1841124ac5a61741f96e1d9e2ec77424bf323962dd894bdb93f37d5219b" +dependencies = [ + "dotenvy", + "either", + "heck", + "hex", + "once_cell", + "proc-macro2", + "quote", + "serde", + "serde_json", + "sha2", + "sqlx-core", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", + "syn", + "tokio", + "url", +] + +[[package]] +name = "sqlx-mysql" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526" +dependencies = [ + "atoi", + "base64 0.22.1", + "bitflags", + "byteorder", + "bytes", + "chrono", + "crc", + "digest", + "dotenvy", + "either", + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "generic-array", + "hex", + "hkdf", + "hmac", + "itoa", + "log", + "md-5", + "memchr", + "once_cell", + "percent-encoding", + "rand 0.8.5", + "rsa", + "serde", + "sha1", + "sha2", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror 2.0.18", + "tracing", + "uuid", + "whoami", +] + +[[package]] +name = "sqlx-postgres" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46" +dependencies = [ + "atoi", + "base64 0.22.1", + "bitflags", + "byteorder", + "chrono", + "crc", + "dotenvy", + "etcetera", + "futures-channel", + "futures-core", + "futures-util", + "hex", + "hkdf", + "hmac", + "home", + "itoa", + "log", + "md-5", + "memchr", + "once_cell", + "rand 0.8.5", + "serde", + "serde_json", + "sha2", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror 2.0.18", + "tracing", + "uuid", + "whoami", +] + +[[package]] +name = "sqlx-sqlite" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2d12fe70b2c1b4401038055f90f151b78208de1f9f89a7dbfd41587a10c3eea" +dependencies = [ + "atoi", + "chrono", + "flume", + "futures-channel", + "futures-core", + "futures-executor", + "futures-intrusive", + "futures-util", + "libsqlite3-sys", + "log", + "percent-encoding", + "serde", + "serde_urlencoded", + "sqlx-core", + "thiserror 2.0.18", + "tracing", + "url", + "uuid", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "stringprep" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1" +dependencies = [ + "unicode-bidi", + "unicode-normalization", + "unicode-properties", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.116" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3df424c70518695237746f84cede799c9c58fcb37450d7b23716568cc8bc69cb" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "time" +version = "0.3.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" + +[[package]] +name = "time-macros" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.49.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-stream" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edc5f74e248dc973e0dbb7b74c7e0d6fcc301c694ff50049504004ef4d0cdcd9" +dependencies = [ + "futures-util", + "log", + "tokio", + "tungstenite", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tower" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c" +dependencies = [ + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e9cd434a998747dd2c4276bc96ee2e0c7a2eadf3cae88e52be55a05fa9053f5" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "http-range-header", + "httpdate", + "mime", + "mime_guess", + "percent-encoding", + "pin-project-lite", + "tokio", + "tokio-util", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "iri-string", + "pin-project-lite", + "tower 0.5.3", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "tungstenite" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18e5b8366ee7a95b16d32197d0b2604b43a0be89dc5fac9f8e96ccafbaedda8a" +dependencies = [ + "byteorder", + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand 0.8.5", + "sha1", + "thiserror 1.0.69", + "utf-8", +] + +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-properties" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common", + "subtle", +] + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", + "serde_derive", +] + +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid" +version = "1.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b672338555252d43fd2240c714dc444b8c6fb0a5c5335e65a07bba7742735ddb" +dependencies = [ + "getrandom 0.4.1", + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64024a30ec1e37399cf85a7ffefebdb72205ca1c972291c51512360d90bd8566" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70a6e77fd0ae8029c9ea0063f87c46fde723e7d887703d74ad2616d792e51e6f" +dependencies = [ + "cfg-if", + "futures-util", + "js-sys", + "once_cell", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "008b239d9c740232e71bd39e8ef6429d27097518b6b30bdf9086833bd5b6d608" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5256bae2d58f54820e6490f9839c49780dff84c65aeab9e772f15d5f0e913a55" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f01b580c9ac74c8d8f0c0e4afb04eeef2acf145458e52c03845ee9cd23e3d12" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap 2.13.0", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap 2.13.0", + "semver", +] + +[[package]] +name = "web-sys" +version = "0.3.85" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "312e32e551d92129218ea9a2452120f4aabc03529ef03e4d0d82fb2780608598" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webauthn-attestation-ca" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fafcf13f7dc1fb292ed4aea22cdd3757c285d7559e9748950ee390249da4da6b" +dependencies = [ + "base64urlsafedata", + "openssl", + "openssl-sys", + "serde", + "tracing", + "uuid", +] + +[[package]] +name = "webauthn-rs" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b24d082d3360258fefb6ffe56123beef7d6868c765c779f97b7a2fcf06727f8" +dependencies = [ + "base64urlsafedata", + "serde", + "tracing", + "url", + "uuid", + "webauthn-rs-core", +] + +[[package]] +name = "webauthn-rs-core" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15784340a24c170ce60567282fb956a0938742dbfbf9eff5df793a686a009b8b" +dependencies = [ + "base64 0.21.7", + "base64urlsafedata", + "der-parser", + "hex", + "nom", + "openssl", + "openssl-sys", + "rand 0.9.2", + "rand_chacha 0.9.0", + "serde", + "serde_cbor_2", + "serde_json", + "thiserror 1.0.69", + "tracing", + "url", + "uuid", + "webauthn-attestation-ca", + "webauthn-rs-proto", + "x509-parser", +] + +[[package]] +name = "webauthn-rs-proto" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16a1fb2580ce73baa42d3011a24de2ceab0d428de1879ece06e02e8c416e497c" +dependencies = [ + "base64 0.21.7", + "base64urlsafedata", + "serde", + "serde_json", + "url", +] + +[[package]] +name = "webpki-roots" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "whoami" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d4a4db5077702ca3015d3d02d74974948aba2ad9e12ab7df718ee64ccd7e97d" +dependencies = [ + "libredox", + "wasite", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap 2.13.0", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap 2.13.0", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap 2.13.0", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "writeable" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" + +[[package]] +name = "x509-parser" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcbc162f30700d6f3f82a24bf7cc62ffe7caea42c0b2cba8bf7f3ae50cf51f69" +dependencies = [ + "asn1-rs", + "data-encoding", + "der-parser", + "lazy_static", + "nom", + "oid-registry", + "rusticata-macros", + "thiserror 1.0.69", + "time", +] + +[[package]] +name = "yoke" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.39" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db6d35d663eadb6c932438e763b262fe1a70987f9ae936e60158176d710cae4a" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.39" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4122cd3169e94605190e77839c9a40d40ed048d305bfdc146e7df40ab0f3e517" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerofrom" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerotrie" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml index 6a896e0..a2de8c5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -25,8 +25,17 @@ chrono = { version = "0.4", features = ["serde"] } uuid = { version = "1.6", features = ["v4", "serde"] } axum = { version = "0.7", features = ["ws"] } tower = "0.4" -tower-http = { version = "0.5", features = ["cors", "trace"] } -reqwest = { version = "0.12", features = ["json"] } +tower-http = { version = "0.5", features = ["cors", "trace", "fs"] } +reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } futures-util = "0.3" dirs = "5.0" -sqlx = { version = "0.8", features = ["runtime-tokio", "postgres", "chrono", "uuid"] } +sqlx = { version = "0.8", features = ["runtime-tokio", "postgres", "chrono", "uuid", "migrate", "json"] } +aes-gcm-siv = "0.11" +argon2 = "0.5" +sha2 = "0.10" +zeroize = { version = "1", features = ["derive"] } +jsonwebtoken = "9" +webauthn-rs = { version = "0.5", features = ["danger-allow-state-serialisation"] } +webauthn-rs-proto = "0.5" +openidconnect = { version = "4", default-features = false, features = ["reqwest", "rustls-tls"] } +url = "2" diff --git a/crates/sao-core/Cargo.toml b/crates/sao-core/Cargo.toml index 3bb8779..d71a30f 100644 --- a/crates/sao-core/Cargo.toml +++ b/crates/sao-core/Cargo.toml @@ -15,3 +15,7 @@ tracing = { workspace = true } uuid = { workspace = true } chrono = { workspace = true } reqwest = { workspace = true } +aes-gcm-siv = { workspace = true } +argon2 = { workspace = true } +sha2 = { workspace = true } +zeroize = { workspace = true } diff --git a/crates/sao-core/src/agent_bridge.rs b/crates/sao-core/src/agent_bridge.rs index 2e06ab4..58d1f16 100644 --- a/crates/sao-core/src/agent_bridge.rs +++ b/crates/sao-core/src/agent_bridge.rs @@ -7,8 +7,8 @@ use serde::{Deserialize, Serialize}; #[derive(Debug, Serialize, Deserialize)] pub struct AgentRegistration { pub agent_id: String, - pub public_key: String, // base64-encoded Ed25519 public key - pub signature: String, // base64-encoded signature from master key + pub public_key: String, // base64-encoded Ed25519 public key + pub signature: String, // base64-encoded signature from master key pub name: String, pub capabilities: Vec, } diff --git a/crates/sao-core/src/ethical_bridge.rs b/crates/sao-core/src/ethical_bridge.rs index 7643d26..3152ab2 100644 --- a/crates/sao-core/src/ethical_bridge.rs +++ b/crates/sao-core/src/ethical_bridge.rs @@ -47,9 +47,14 @@ impl EthicalBridgeClient { } } /// Submit a response for ethical evaluation. - pub async fn evaluate(&self, request: &EthicalEvaluationRequest) -> anyhow::Result { + pub async fn evaluate( + &self, + request: &EthicalEvaluationRequest, + ) -> anyhow::Result { let url = format!("{}/api/analyze", self.base_url); - let resp = self.client.post(&url) + let resp = self + .client + .post(&url) .json(request) .send() .await? diff --git a/crates/sao-core/src/global_config.rs b/crates/sao-core/src/global_config.rs index 50ee337..6a11685 100644 --- a/crates/sao-core/src/global_config.rs +++ b/crates/sao-core/src/global_config.rs @@ -1,4 +1,4 @@ -use serde::{Deserialize, Serialize}; +use serde::{Deserialize, Serialize}; use std::path::{Path, PathBuf}; /// An entry in the global config representing a registered agent. @@ -160,5 +160,3 @@ mod tests { let _ = fs::remove_dir_all(&tmp); } } - - diff --git a/crates/sao-core/src/identity_manager.rs b/crates/sao-core/src/identity_manager.rs index 07c03b9..c02510f 100644 --- a/crates/sao-core/src/identity_manager.rs +++ b/crates/sao-core/src/identity_manager.rs @@ -1,6 +1,5 @@ use crate::{ - generate_master_key, load_master_key, sign_agent_key, verify_agent_signature, AgentEntry, - GlobalConfig, + generate_master_key, load_master_key, verify_agent_signature, AgentEntry, GlobalConfig, }; use ed25519_dalek::{SigningKey, VerifyingKey}; use serde::{Deserialize, Serialize}; diff --git a/crates/sao-core/src/lib.rs b/crates/sao-core/src/lib.rs index 1dbb6cc..694adf1 100644 --- a/crates/sao-core/src/lib.rs +++ b/crates/sao-core/src/lib.rs @@ -1,7 +1,9 @@ -pub mod identity_manager; -pub mod global_config; -pub mod master_key; pub mod agent_bridge; pub mod ethical_bridge; -pub use identity_manager::*; +pub mod global_config; +pub mod identity_manager; +pub mod master_key; +pub mod vault; pub use global_config::*; +pub use identity_manager::*; +pub use master_key::*; diff --git a/crates/sao-core/src/vault/encryption.rs b/crates/sao-core/src/vault/encryption.rs new file mode 100644 index 0000000..dc58573 --- /dev/null +++ b/crates/sao-core/src/vault/encryption.rs @@ -0,0 +1,136 @@ +use aes_gcm_siv::{ + aead::{Aead, KeyInit, OsRng}, + Aes256GcmSiv, Nonce, +}; +use rand::RngCore; +use zeroize::{Zeroize, ZeroizeOnDrop}; + +/// AES-256-GCM-SIV encryption key. Zeroized on drop. +#[derive(Zeroize, ZeroizeOnDrop)] +pub struct VaultMasterKey { + key_bytes: [u8; 32], +} + +impl VaultMasterKey { + /// Create a new random VMK. + pub fn generate() -> Self { + let mut key_bytes = [0u8; 32]; + OsRng.fill_bytes(&mut key_bytes); + Self { key_bytes } + } + + /// Reconstruct a VMK from raw bytes (e.g., after unsealing). + pub fn from_bytes(bytes: [u8; 32]) -> Self { + Self { key_bytes: bytes } + } + + /// Export raw key bytes (for sealing with passphrase KDF). + pub fn as_bytes(&self) -> &[u8; 32] { + &self.key_bytes + } + + /// Encrypt plaintext. Returns (ciphertext, nonce). + pub fn encrypt(&self, plaintext: &[u8]) -> Result<(Vec, Vec), String> { + let cipher = Aes256GcmSiv::new_from_slice(&self.key_bytes) + .map_err(|e| format!("Failed to create cipher: {}", e))?; + + let mut nonce_bytes = [0u8; 12]; + OsRng.fill_bytes(&mut nonce_bytes); + let nonce = Nonce::from_slice(&nonce_bytes); + + let ciphertext = cipher + .encrypt(nonce, plaintext) + .map_err(|e| format!("Encryption failed: {}", e))?; + + Ok((ciphertext, nonce_bytes.to_vec())) + } + + /// Decrypt ciphertext with the given nonce. + pub fn decrypt(&self, ciphertext: &[u8], nonce: &[u8]) -> Result, String> { + let cipher = Aes256GcmSiv::new_from_slice(&self.key_bytes) + .map_err(|e| format!("Failed to create cipher: {}", e))?; + + let nonce = Nonce::from_slice(nonce); + + cipher + .decrypt(nonce, ciphertext) + .map_err(|e| format!("Decryption failed: {}", e)) + } + + /// Seal the VMK with a passphrase-derived key. Returns encrypted VMK bytes. + pub fn seal(&self, passphrase_key: &[u8; 32]) -> Result<(Vec, Vec), String> { + let cipher = Aes256GcmSiv::new_from_slice(passphrase_key) + .map_err(|e| format!("Failed to create seal cipher: {}", e))?; + + let mut nonce_bytes = [0u8; 12]; + OsRng.fill_bytes(&mut nonce_bytes); + let nonce = Nonce::from_slice(&nonce_bytes); + + let sealed = cipher + .encrypt(nonce, self.key_bytes.as_slice()) + .map_err(|e| format!("Failed to seal VMK: {}", e))?; + + Ok((sealed, nonce_bytes.to_vec())) + } + + /// Unseal a VMK from encrypted bytes using a passphrase-derived key. + pub fn unseal( + sealed_bytes: &[u8], + nonce: &[u8], + passphrase_key: &[u8; 32], + ) -> Result { + let cipher = Aes256GcmSiv::new_from_slice(passphrase_key) + .map_err(|e| format!("Failed to create unseal cipher: {}", e))?; + + let nonce = Nonce::from_slice(nonce); + + let key_bytes_vec = cipher + .decrypt(nonce, sealed_bytes) + .map_err(|_| "Failed to unseal VMK: wrong passphrase or corrupted data".to_string())?; + + let key_bytes: [u8; 32] = key_bytes_vec + .try_into() + .map_err(|_| "Unsealed VMK has wrong length".to_string())?; + + Ok(Self { key_bytes }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn encrypt_decrypt_roundtrip() { + let vmk = VaultMasterKey::generate(); + let plaintext = b"secret api key value"; + + let (ciphertext, nonce) = vmk.encrypt(plaintext).unwrap(); + assert_ne!(ciphertext.as_slice(), plaintext); + + let decrypted = vmk.decrypt(&ciphertext, &nonce).unwrap(); + assert_eq!(decrypted, plaintext); + } + + #[test] + fn seal_unseal_roundtrip() { + let vmk = VaultMasterKey::generate(); + let passphrase_key = [42u8; 32]; + + let (sealed, nonce) = vmk.seal(&passphrase_key).unwrap(); + let unsealed = VaultMasterKey::unseal(&sealed, &nonce, &passphrase_key).unwrap(); + + assert_eq!(vmk.as_bytes(), unsealed.as_bytes()); + } + + #[test] + fn unseal_wrong_passphrase_fails() { + let vmk = VaultMasterKey::generate(); + let passphrase_key = [42u8; 32]; + let wrong_key = [99u8; 32]; + + let (sealed, nonce) = vmk.seal(&passphrase_key).unwrap(); + let result = VaultMasterKey::unseal(&sealed, &nonce, &wrong_key); + assert!(result.is_err()); + } +} diff --git a/crates/sao-core/src/vault/kdf.rs b/crates/sao-core/src/vault/kdf.rs new file mode 100644 index 0000000..eaa38dc --- /dev/null +++ b/crates/sao-core/src/vault/kdf.rs @@ -0,0 +1,76 @@ +use argon2::{Argon2, Params, Version}; +use rand::RngCore; + +/// Default Argon2id parameters. +pub const DEFAULT_MEMORY_COST: u32 = 65536; // 64 MiB +pub const DEFAULT_TIME_COST: u32 = 3; +pub const DEFAULT_PARALLELISM: u32 = 1; + +/// Generate a random 32-byte salt. +pub fn generate_salt() -> [u8; 32] { + let mut salt = [0u8; 32]; + rand::rngs::OsRng.fill_bytes(&mut salt); + salt +} + +/// Derive a 256-bit key from a passphrase using Argon2id. +pub fn derive_key_from_passphrase( + passphrase: &str, + salt: &[u8], + memory_cost: u32, + time_cost: u32, + parallelism: u32, +) -> Result<[u8; 32], String> { + let params = Params::new(memory_cost, time_cost, parallelism, Some(32)) + .map_err(|e| format!("Invalid Argon2 params: {}", e))?; + + let argon2 = Argon2::new(argon2::Algorithm::Argon2id, Version::V0x13, params); + + let mut output = [0u8; 32]; + argon2 + .hash_password_into(passphrase.as_bytes(), salt, &mut output) + .map_err(|e| format!("Argon2 KDF failed: {}", e))?; + + Ok(output) +} + +/// Derive key with default parameters. +pub fn derive_key_default(passphrase: &str, salt: &[u8]) -> Result<[u8; 32], String> { + derive_key_from_passphrase( + passphrase, + salt, + DEFAULT_MEMORY_COST, + DEFAULT_TIME_COST, + DEFAULT_PARALLELISM, + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn derive_key_deterministic() { + let salt = [1u8; 32]; + let key1 = derive_key_default("test passphrase", &salt).unwrap(); + let key2 = derive_key_default("test passphrase", &salt).unwrap(); + assert_eq!(key1, key2); + } + + #[test] + fn different_passphrases_different_keys() { + let salt = [1u8; 32]; + let key1 = derive_key_default("passphrase one", &salt).unwrap(); + let key2 = derive_key_default("passphrase two", &salt).unwrap(); + assert_ne!(key1, key2); + } + + #[test] + fn different_salts_different_keys() { + let salt1 = [1u8; 32]; + let salt2 = [2u8; 32]; + let key1 = derive_key_default("same passphrase", &salt1).unwrap(); + let key2 = derive_key_default("same passphrase", &salt2).unwrap(); + assert_ne!(key1, key2); + } +} diff --git a/crates/sao-core/src/vault/mod.rs b/crates/sao-core/src/vault/mod.rs new file mode 100644 index 0000000..49cb2a3 --- /dev/null +++ b/crates/sao-core/src/vault/mod.rs @@ -0,0 +1,7 @@ +pub mod encryption; +pub mod kdf; +pub mod types; + +pub use encryption::VaultMasterKey; +pub use kdf::{derive_key_from_passphrase, generate_salt}; +pub use types::{SealedSecret, SecretMetadata, SecretType}; diff --git a/crates/sao-core/src/vault/types.rs b/crates/sao-core/src/vault/types.rs new file mode 100644 index 0000000..3746823 --- /dev/null +++ b/crates/sao-core/src/vault/types.rs @@ -0,0 +1,64 @@ +use serde::{Deserialize, Serialize}; +use std::fmt; +use std::str::FromStr; + +/// Types of secrets stored in the vault. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "snake_case")] +pub enum SecretType { + Ed25519, + ApiKey, + Gpg, + OauthToken, + Other, +} + +impl SecretType { + pub fn as_str(&self) -> &'static str { + match self { + Self::Ed25519 => "ed25519", + Self::ApiKey => "api_key", + Self::Gpg => "gpg", + Self::OauthToken => "oauth_token", + Self::Other => "other", + } + } +} + +impl fmt::Display for SecretType { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +impl FromStr for SecretType { + type Err = std::convert::Infallible; + + fn from_str(s: &str) -> Result { + Ok(match s { + "ed25519" => Self::Ed25519, + "api_key" => Self::ApiKey, + "gpg" => Self::Gpg, + "oauth_token" => Self::OauthToken, + _ => Self::Other, + }) + } +} + +/// A secret encrypted at rest. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SealedSecret { + pub ciphertext: Vec, + pub nonce: Vec, +} + +/// Metadata about a stored secret (no sensitive data). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SecretMetadata { + pub id: String, + pub secret_type: SecretType, + pub label: String, + pub provider: Option, + pub created_at: String, + pub updated_at: String, +} diff --git a/crates/sao-server/Cargo.toml b/crates/sao-server/Cargo.toml index 8eb0571..8a33526 100644 --- a/crates/sao-server/Cargo.toml +++ b/crates/sao-server/Cargo.toml @@ -15,10 +15,16 @@ tracing = { workspace = true } tracing-subscriber = { workspace = true } anyhow = { workspace = true } uuid = { workspace = true } +chrono = { workspace = true } dirs = { workspace = true } futures-util = { workspace = true } -sqlx = { workspace = true, optional = true } - -[features] -default = [] -postgres = ["sqlx"] +sqlx = { workspace = true } +jsonwebtoken = { workspace = true } +webauthn-rs = { workspace = true } +webauthn-rs-proto = { workspace = true } +rand = { workspace = true } +sha2 = { workspace = true } +base64 = { workspace = true } +url = { workspace = true } +openidconnect = { workspace = true } +reqwest = { workspace = true } diff --git a/crates/sao-server/src/auth/middleware.rs b/crates/sao-server/src/auth/middleware.rs new file mode 100644 index 0000000..83414fa --- /dev/null +++ b/crates/sao-server/src/auth/middleware.rs @@ -0,0 +1,92 @@ +use axum::{ + extract::FromRequestParts, + http::{header::AUTHORIZATION, request::Parts, StatusCode}, + Json, +}; +use serde_json::json; +use uuid::Uuid; + +use crate::auth::session; +use crate::state::AppState; + +/// Authenticated user info extracted from JWT. +#[derive(Debug, Clone)] +pub struct AuthUser { + pub user_id: Uuid, + pub username: String, + pub role: String, +} + +impl AuthUser { + pub fn is_admin(&self) -> bool { + self.role == "admin" + } +} + +/// Axum extractor that requires a valid JWT Bearer token. +#[axum::async_trait] +impl FromRequestParts for AuthUser { + type Rejection = (StatusCode, Json); + + async fn from_request_parts( + parts: &mut Parts, + state: &AppState, + ) -> Result { + let auth_header = parts + .headers + .get(AUTHORIZATION) + .and_then(|v| v.to_str().ok()) + .ok_or(( + StatusCode::UNAUTHORIZED, + Json(json!({ "error": "Missing Authorization header" })), + ))?; + + let token = auth_header.strip_prefix("Bearer ").ok_or(( + StatusCode::UNAUTHORIZED, + Json(json!({ "error": "Invalid Authorization header format" })), + ))?; + + let claims = session::validate_token(token, &state.inner.jwt_secret).map_err(|_| { + ( + StatusCode::UNAUTHORIZED, + Json(json!({ "error": "Invalid or expired token" })), + ) + })?; + + let user_id = Uuid::parse_str(&claims.sub).map_err(|_| { + ( + StatusCode::UNAUTHORIZED, + Json(json!({ "error": "Invalid token subject" })), + ) + })?; + + Ok(AuthUser { + user_id, + username: claims.username, + role: claims.role, + }) + } +} + +/// Axum extractor that requires admin role. +#[derive(Debug, Clone)] +pub struct AdminUser(pub AuthUser); + +#[axum::async_trait] +impl FromRequestParts for AdminUser { + type Rejection = (StatusCode, Json); + + async fn from_request_parts( + parts: &mut Parts, + state: &AppState, + ) -> Result { + let user = AuthUser::from_request_parts(parts, state).await?; + if !user.is_admin() { + return Err(( + StatusCode::FORBIDDEN, + Json(json!({ "error": "Administrator access required" })), + )); + } + Ok(AdminUser(user)) + } +} diff --git a/crates/sao-server/src/auth/mod.rs b/crates/sao-server/src/auth/mod.rs new file mode 100644 index 0000000..30256a9 --- /dev/null +++ b/crates/sao-server/src/auth/mod.rs @@ -0,0 +1,4 @@ +pub mod middleware; +pub mod oidc; +pub mod session; +pub mod webauthn; diff --git a/crates/sao-server/src/auth/oidc.rs b/crates/sao-server/src/auth/oidc.rs new file mode 100644 index 0000000..1dda9f0 --- /dev/null +++ b/crates/sao-server/src/auth/oidc.rs @@ -0,0 +1,144 @@ +use openidconnect::{ + core::{CoreProviderMetadata, CoreResponseType}, + AuthenticationFlow, AuthorizationCode, ClientId, ClientSecret, CsrfToken, IssuerUrl, Nonce, + RedirectUrl, Scope, TokenResponse, +}; + +/// OIDC provider configuration loaded from DB. +#[allow(dead_code)] +pub struct OidcProviderConfig { + pub id: uuid::Uuid, + pub name: String, + pub issuer_url: String, + pub client_id: String, + pub client_secret: Option, + pub scopes: String, +} + +/// Result of starting an OIDC authorization flow. +pub struct OidcAuthResult { + pub auth_url: url::Url, + pub csrf_token: CsrfToken, + pub nonce: Nonce, +} + +/// User information extracted from OIDC ID token. +pub struct OidcUserInfo { + pub subject: String, + pub email: Option, + pub name: Option, +} + +/// Generate an authorization URL for the OIDC provider. +pub async fn start_authorization( + config: &OidcProviderConfig, + redirect_url: &str, +) -> Result { + let http_client = reqwest::Client::new(); + + let issuer_url = IssuerUrl::new(config.issuer_url.clone()) + .map_err(|e| format!("Invalid issuer URL: {}", e))?; + + let provider_metadata = CoreProviderMetadata::discover_async(issuer_url, &http_client) + .await + .map_err(|e| format!("OIDC discovery failed: {}", e))?; + + let client_id = ClientId::new(config.client_id.clone()); + let client_secret = config + .client_secret + .as_ref() + .map(|s| ClientSecret::new(s.clone())); + + let redirect = RedirectUrl::new(redirect_url.to_string()) + .map_err(|e| format!("Invalid redirect URL: {}", e))?; + + let client = openidconnect::core::CoreClient::from_provider_metadata( + provider_metadata, + client_id, + client_secret, + ) + .set_redirect_uri(redirect); + + let mut auth_request = client.authorize_url( + AuthenticationFlow::::AuthorizationCode, + CsrfToken::new_random, + Nonce::new_random, + ); + + for scope in config.scopes.split_whitespace() { + if scope != "openid" { + auth_request = auth_request.add_scope(Scope::new(scope.to_string())); + } + } + + let (url, csrf, nonce) = auth_request.url(); + + Ok(OidcAuthResult { + auth_url: url, + csrf_token: csrf, + nonce, + }) +} + +/// Exchange an authorization code for tokens and extract user info. +pub async fn exchange_code( + config: &OidcProviderConfig, + redirect_url: &str, + code: &str, +) -> Result { + let http_client = reqwest::Client::new(); + + let issuer_url = IssuerUrl::new(config.issuer_url.clone()) + .map_err(|e| format!("Invalid issuer URL: {}", e))?; + + let provider_metadata = CoreProviderMetadata::discover_async(issuer_url, &http_client) + .await + .map_err(|e| format!("OIDC discovery failed: {}", e))?; + + let client_id = ClientId::new(config.client_id.clone()); + let client_secret = config + .client_secret + .as_ref() + .map(|s| ClientSecret::new(s.clone())); + + let redirect = RedirectUrl::new(redirect_url.to_string()) + .map_err(|e| format!("Invalid redirect URL: {}", e))?; + + let client = openidconnect::core::CoreClient::from_provider_metadata( + provider_metadata, + client_id, + client_secret, + ) + .set_redirect_uri(redirect); + + let token_response = client + .exchange_code(AuthorizationCode::new(code.to_string())) + .map_err(|e| format!("Failed to prepare token exchange: {}", e))? + .request_async(&http_client) + .await + .map_err(|e| format!("Token exchange failed: {}", e))?; + + // Extract ID token claims + let id_token = token_response.id_token().ok_or("No ID token in response")?; + + // Verify token - skip nonce verification for now (stored in DB challenge) + let claims = id_token + .claims(&client.id_token_verifier(), |_: Option<&Nonce>| Ok(())) + .map_err(|e| format!("Failed to verify ID token: {}", e))?; + + let subject = claims.subject().to_string(); + let email: Option = claims.email().map(|e| e.to_string()); + let name: Option = claims + .name() + .and_then(|n| { + let localized: &openidconnect::LocalizedClaim = n; + localized.get(None) + }) + .map(|n| n.to_string()); + + Ok(OidcUserInfo { + subject, + email, + name, + }) +} diff --git a/crates/sao-server/src/auth/session.rs b/crates/sao-server/src/auth/session.rs new file mode 100644 index 0000000..50fde29 --- /dev/null +++ b/crates/sao-server/src/auth/session.rs @@ -0,0 +1,84 @@ +use chrono::{Duration, Utc}; +use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation}; +use rand::RngCore; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use uuid::Uuid; + +/// JWT claims for session tokens. +#[derive(Debug, Serialize, Deserialize)] +pub struct Claims { + pub sub: String, // user_id + pub username: String, + pub role: String, + pub exp: i64, + pub iat: i64, +} + +/// Generate a random JWT secret if not provided via env. +pub fn jwt_secret() -> [u8; 32] { + if let Ok(secret) = std::env::var("SAO_JWT_SECRET") { + let mut hasher = Sha256::new(); + hasher.update(secret.as_bytes()); + let result = hasher.finalize(); + let mut key = [0u8; 32]; + key.copy_from_slice(&result); + key + } else { + let mut key = [0u8; 32]; + rand::rngs::OsRng.fill_bytes(&mut key); + tracing::warn!("No SAO_JWT_SECRET set, using random key (sessions won't survive restarts)"); + key + } +} + +/// Create a JWT access token (30 minute expiry). +pub fn create_access_token( + user_id: Uuid, + username: &str, + role: &str, + secret: &[u8; 32], +) -> Result { + let now = Utc::now(); + let claims = Claims { + sub: user_id.to_string(), + username: username.to_string(), + role: role.to_string(), + exp: (now + Duration::minutes(30)).timestamp(), + iat: now.timestamp(), + }; + + encode( + &Header::default(), + &claims, + &EncodingKey::from_secret(secret), + ) +} + +/// Validate a JWT access token and return the claims. +pub fn validate_token( + token: &str, + secret: &[u8; 32], +) -> Result { + let token_data = decode::( + token, + &DecodingKey::from_secret(secret), + &Validation::default(), + )?; + Ok(token_data.claims) +} + +/// Generate a random refresh token string. +pub fn generate_refresh_token() -> String { + let mut bytes = [0u8; 32]; + rand::rngs::OsRng.fill_bytes(&mut bytes); + base64::Engine::encode(&base64::engine::general_purpose::URL_SAFE_NO_PAD, bytes) +} + +/// Hash a refresh token for storage. +pub fn hash_refresh_token(token: &str) -> String { + let mut hasher = Sha256::new(); + hasher.update(token.as_bytes()); + let result = hasher.finalize(); + base64::Engine::encode(&base64::engine::general_purpose::URL_SAFE_NO_PAD, result) +} diff --git a/crates/sao-server/src/auth/webauthn.rs b/crates/sao-server/src/auth/webauthn.rs new file mode 100644 index 0000000..bd38772 --- /dev/null +++ b/crates/sao-server/src/auth/webauthn.rs @@ -0,0 +1,59 @@ +use webauthn_rs::prelude::*; + +/// Create a Webauthn instance from environment configuration. +pub fn create_webauthn() -> Webauthn { + let rp_id = std::env::var("SAO_RP_ID").unwrap_or_else(|_| "localhost".to_string()); + let rp_origin = + std::env::var("SAO_RP_ORIGIN").unwrap_or_else(|_| "http://localhost:3100".to_string()); + + let rp_origin_url = url::Url::parse(&rp_origin).expect("SAO_RP_ORIGIN must be a valid URL"); + + let builder = WebauthnBuilder::new(&rp_id, &rp_origin_url) + .expect("Failed to create WebauthnBuilder") + .rp_name("SAO - Secure Agent Orchestrator"); + + builder.build().expect("Failed to build Webauthn") +} + +/// Start a WebAuthn registration ceremony for a user. +pub fn start_registration( + webauthn: &Webauthn, + user_id: uuid::Uuid, + username: &str, + display_name: &str, + existing_credentials: Vec, +) -> Result<(CreationChallengeResponse, PasskeyRegistration), WebauthnError> { + let exclude = existing_credentials; + webauthn.start_passkey_registration( + Uuid::from_bytes(*user_id.as_bytes()), + username, + display_name, + Some(exclude), + ) +} + +/// Complete a WebAuthn registration ceremony. +pub fn finish_registration( + webauthn: &Webauthn, + reg_response: &RegisterPublicKeyCredential, + reg_state: &PasskeyRegistration, +) -> Result { + webauthn.finish_passkey_registration(reg_response, reg_state) +} + +/// Start a WebAuthn authentication ceremony. +pub fn start_authentication( + webauthn: &Webauthn, + credentials: Vec, +) -> Result<(RequestChallengeResponse, PasskeyAuthentication), WebauthnError> { + webauthn.start_passkey_authentication(&credentials) +} + +/// Complete a WebAuthn authentication ceremony. +pub fn finish_authentication( + webauthn: &Webauthn, + auth_response: &PublicKeyCredential, + auth_state: &PasskeyAuthentication, +) -> Result { + webauthn.finish_passkey_authentication(auth_response, auth_state) +} diff --git a/crates/sao-server/src/db.rs b/crates/sao-server/src/db.rs deleted file mode 100644 index c94db04..0000000 --- a/crates/sao-server/src/db.rs +++ /dev/null @@ -1,157 +0,0 @@ -//! PostgreSQL connection pool and health checking for AO Manager. -//! -//! Enabled via the `postgres` Cargo feature. When `DATABASE_URL` is set, -//! the server creates a connection pool at startup and exposes it via AppState. -//! The `/health` endpoint includes database connectivity status. - -#[cfg(feature = "postgres")] -use sqlx::postgres::{PgPool, PgPoolOptions}; - -/// Database pool wrapper. When the `postgres` feature is disabled, -/// this is a zero-cost no-op. -#[derive(Clone)] -pub struct DbPool { - #[cfg(feature = "postgres")] - pool: Option, - #[cfg(not(feature = "postgres"))] - _phantom: (), -} - -impl DbPool { - /// Create a no-op pool (postgres feature disabled or no DATABASE_URL). - pub fn none() -> Self { - DbPool { - #[cfg(feature = "postgres")] - pool: None, - #[cfg(not(feature = "postgres"))] - _phantom: (), - } - } - - /// Initialize from DATABASE_URL environment variable. - /// Returns `DbPool::none()` if the variable is unset or the feature is disabled. - pub async fn from_env() -> Self { - #[cfg(feature = "postgres")] - { - match std::env::var("DATABASE_URL") { - Ok(url) if !url.is_empty() => match Self::connect(&url).await { - Ok(pool) => pool, - Err(e) => { - tracing::error!("Failed to connect to PostgreSQL: {}", e); - DbPool::none() - } - }, - _ => { - tracing::info!("DATABASE_URL not set, PostgreSQL disabled"); - DbPool::none() - } - } - } - #[cfg(not(feature = "postgres"))] - { - tracing::info!("PostgreSQL feature not enabled"); - DbPool::none() - } - } - - /// Connect to PostgreSQL with the given connection string. - #[cfg(feature = "postgres")] - pub async fn connect(database_url: &str) -> Result { - let ssl_mode = std::env::var("AO_DB_SSL") - .map(|v| v == "true" || v == "1") - .unwrap_or(false); - - let url = if ssl_mode && !database_url.contains("sslmode=") { - let separator = if database_url.contains('?') { "&" } else { "?" }; - format!("{}{}sslmode=require", database_url, separator) - } else { - database_url.to_string() - }; - - let pool = PgPoolOptions::new() - .max_connections(10) - .min_connections(1) - .acquire_timeout(std::time::Duration::from_secs(5)) - .idle_timeout(std::time::Duration::from_secs(600)) - .connect(&url) - .await?; - - tracing::info!("PostgreSQL connection pool established"); - Ok(DbPool { pool: Some(pool) }) - } - - /// Check if the database is connected and responsive. - pub async fn is_healthy(&self) -> bool { - #[cfg(feature = "postgres")] - { - if let Some(ref pool) = self.pool { - sqlx::query_scalar::<_, i32>("SELECT 1") - .fetch_one(pool) - .await - .is_ok() - } else { - false - } - } - #[cfg(not(feature = "postgres"))] - { - false - } - } - - /// Whether a pool is present (feature enabled + DATABASE_URL was set + connected). - pub fn is_connected(&self) -> bool { - #[cfg(feature = "postgres")] - { - self.pool.is_some() - } - #[cfg(not(feature = "postgres"))] - { - false - } - } - - /// Get a reference to the underlying pool, if available. - #[cfg(feature = "postgres")] - pub fn pool(&self) -> Option<&PgPool> { - self.pool.as_ref() - } -} - -/// Health status for the `/health` endpoint. -#[derive(serde::Serialize)] -pub struct HealthStatus { - pub status: String, - pub service: String, - pub database: DatabaseHealth, -} - -#[derive(serde::Serialize)] -pub struct DatabaseHealth { - pub configured: bool, - pub connected: bool, - pub healthy: bool, -} - -impl DbPool { - pub async fn health_status(&self) -> HealthStatus { - let configured = self.is_connected(); - let healthy = if configured { - self.is_healthy().await - } else { - false - }; - - let overall = if !configured || healthy { "ok" } else { "degraded" }; - - HealthStatus { - status: overall.to_string(), - service: "sao".to_string(), - database: DatabaseHealth { - configured, - connected: configured, - healthy, - }, - } - } -} diff --git a/crates/sao-server/src/db/admin.rs b/crates/sao-server/src/db/admin.rs new file mode 100644 index 0000000..b91e1f1 --- /dev/null +++ b/crates/sao-server/src/db/admin.rs @@ -0,0 +1,43 @@ +use sqlx::PgPool; +use uuid::Uuid; + +#[derive(Debug, sqlx::FromRow, serde::Serialize)] +pub struct AuditLogRow { + pub id: i64, + pub user_id: Option, + pub agent_id: Option, + pub action: String, + pub resource: Option, + pub details: Option, + pub ip_address: Option, + pub user_agent: Option, + pub created_at: chrono::DateTime, +} + +pub async fn query_audit_log( + pool: &PgPool, + user_id: Option, + limit: i64, + offset: i64, +) -> Result, sqlx::Error> { + if let Some(uid) = user_id { + sqlx::query_as::<_, AuditLogRow>( + "SELECT id, user_id, agent_id, action, resource, details, ip_address, user_agent, created_at \ + FROM audit_log WHERE user_id = $1 ORDER BY created_at DESC LIMIT $2 OFFSET $3", + ) + .bind(uid) + .bind(limit) + .bind(offset) + .fetch_all(pool) + .await + } else { + sqlx::query_as::<_, AuditLogRow>( + "SELECT id, user_id, agent_id, action, resource, details, ip_address, user_agent, created_at \ + FROM audit_log ORDER BY created_at DESC LIMIT $1 OFFSET $2", + ) + .bind(limit) + .bind(offset) + .fetch_all(pool) + .await + } +} diff --git a/crates/sao-server/src/db/agents.rs b/crates/sao-server/src/db/agents.rs new file mode 100644 index 0000000..5d09f0e --- /dev/null +++ b/crates/sao-server/src/db/agents.rs @@ -0,0 +1,50 @@ +use sqlx::PgPool; +use uuid::Uuid; + +#[derive(Debug, serde::Serialize, sqlx::FromRow)] +pub struct AgentRow { + pub id: Uuid, + pub name: String, + pub public_key: Option>, + pub state: String, + pub capabilities: serde_json::Value, + pub created_at: chrono::DateTime, + pub updated_at: chrono::DateTime, +} + +pub async fn list_agents(pool: &PgPool) -> Result, sqlx::Error> { + sqlx::query_as::<_, AgentRow>( + "SELECT id, name, public_key, state, capabilities, created_at, updated_at \ + FROM agents ORDER BY created_at", + ) + .fetch_all(pool) + .await +} + +pub async fn create_agent(pool: &PgPool, name: &str) -> Result { + sqlx::query_as::<_, AgentRow>( + "INSERT INTO agents (name) VALUES ($1) \ + RETURNING id, name, public_key, state, capabilities, created_at, updated_at", + ) + .bind(name) + .fetch_one(pool) + .await +} + +pub async fn get_agent(pool: &PgPool, id: Uuid) -> Result, sqlx::Error> { + sqlx::query_as::<_, AgentRow>( + "SELECT id, name, public_key, state, capabilities, created_at, updated_at \ + FROM agents WHERE id = $1", + ) + .bind(id) + .fetch_optional(pool) + .await +} + +pub async fn delete_agent(pool: &PgPool, id: Uuid) -> Result { + let result = sqlx::query("DELETE FROM agents WHERE id = $1") + .bind(id) + .execute(pool) + .await?; + Ok(result.rows_affected() > 0) +} diff --git a/crates/sao-server/src/db/audit.rs b/crates/sao-server/src/db/audit.rs new file mode 100644 index 0000000..083e330 --- /dev/null +++ b/crates/sao-server/src/db/audit.rs @@ -0,0 +1,29 @@ +use sqlx::PgPool; +use uuid::Uuid; + +#[allow(clippy::too_many_arguments)] +pub async fn insert_audit_log( + pool: &PgPool, + user_id: Option, + agent_id: Option, + action: &str, + resource: Option<&str>, + details: Option, + ip_address: Option<&str>, + user_agent: Option<&str>, +) -> Result<(), sqlx::Error> { + sqlx::query( + "INSERT INTO audit_log (user_id, agent_id, action, resource, details, ip_address, user_agent) \ + VALUES ($1, $2, $3, $4, $5, $6, $7)", + ) + .bind(user_id) + .bind(agent_id) + .bind(action) + .bind(resource) + .bind(details) + .bind(ip_address) + .bind(user_agent) + .execute(pool) + .await?; + Ok(()) +} diff --git a/crates/sao-server/src/db/migrate.rs b/crates/sao-server/src/db/migrate.rs new file mode 100644 index 0000000..99de5d7 --- /dev/null +++ b/crates/sao-server/src/db/migrate.rs @@ -0,0 +1,9 @@ +use sqlx::PgPool; + +/// Run all pending database migrations. +pub async fn run_migrations(pool: &PgPool) -> anyhow::Result<()> { + tracing::info!("Running database migrations..."); + sqlx::migrate!("../../migrations").run(pool).await?; + tracing::info!("Database migrations complete"); + Ok(()) +} diff --git a/crates/sao-server/src/db/mod.rs b/crates/sao-server/src/db/mod.rs new file mode 100644 index 0000000..8c192d6 --- /dev/null +++ b/crates/sao-server/src/db/mod.rs @@ -0,0 +1,11 @@ +pub mod admin; +pub mod agents; +pub mod audit; +pub mod migrate; +pub mod oidc; +pub mod pool; +pub mod sessions; +pub mod users; +pub mod vault; +pub mod vault_key; +pub mod webauthn; diff --git a/crates/sao-server/src/db/oidc.rs b/crates/sao-server/src/db/oidc.rs new file mode 100644 index 0000000..65519f6 --- /dev/null +++ b/crates/sao-server/src/db/oidc.rs @@ -0,0 +1,153 @@ +use sqlx::PgPool; +use uuid::Uuid; + +#[derive(Debug, sqlx::FromRow, serde::Serialize)] +pub struct OidcProviderRow { + pub id: Uuid, + pub name: String, + pub issuer_url: String, + pub client_id: String, + pub client_secret_encrypted: Option>, + pub scopes: String, + pub enabled: bool, + pub created_at: chrono::DateTime, + pub updated_at: chrono::DateTime, +} + +#[derive(Debug, sqlx::FromRow, serde::Serialize)] +pub struct OidcProviderPublicRow { + pub id: Uuid, + pub name: String, + pub enabled: bool, +} + +pub async fn list_providers_public( + pool: &PgPool, +) -> Result, sqlx::Error> { + sqlx::query_as::<_, OidcProviderPublicRow>( + "SELECT id, name, enabled FROM oidc_providers WHERE enabled = true ORDER BY name", + ) + .fetch_all(pool) + .await +} + +pub async fn list_providers(pool: &PgPool) -> Result, sqlx::Error> { + sqlx::query_as::<_, OidcProviderRow>( + "SELECT id, name, issuer_url, client_id, client_secret_encrypted, scopes, enabled, created_at, updated_at \ + FROM oidc_providers ORDER BY name", + ) + .fetch_all(pool) + .await +} + +pub async fn get_provider(pool: &PgPool, id: Uuid) -> Result, sqlx::Error> { + sqlx::query_as::<_, OidcProviderRow>( + "SELECT id, name, issuer_url, client_id, client_secret_encrypted, scopes, enabled, created_at, updated_at \ + FROM oidc_providers WHERE id = $1", + ) + .bind(id) + .fetch_optional(pool) + .await +} + +pub async fn create_provider( + pool: &PgPool, + name: &str, + issuer_url: &str, + client_id: &str, + client_secret_encrypted: Option<&[u8]>, + scopes: &str, +) -> Result { + let row: (Uuid,) = sqlx::query_as( + "INSERT INTO oidc_providers (name, issuer_url, client_id, client_secret_encrypted, scopes) \ + VALUES ($1, $2, $3, $4, $5) RETURNING id", + ) + .bind(name) + .bind(issuer_url) + .bind(client_id) + .bind(client_secret_encrypted) + .bind(scopes) + .fetch_one(pool) + .await?; + Ok(row.0) +} + +#[allow(clippy::too_many_arguments)] +pub async fn update_provider( + pool: &PgPool, + id: Uuid, + name: Option<&str>, + issuer_url: Option<&str>, + client_id: Option<&str>, + client_secret_encrypted: Option<&[u8]>, + scopes: Option<&str>, + enabled: Option, +) -> Result { + // Simple full update (all fields) + let result = sqlx::query( + "UPDATE oidc_providers SET \ + name = COALESCE($1, name), \ + issuer_url = COALESCE($2, issuer_url), \ + client_id = COALESCE($3, client_id), \ + client_secret_encrypted = COALESCE($4, client_secret_encrypted), \ + scopes = COALESCE($5, scopes), \ + enabled = COALESCE($6, enabled), \ + updated_at = now() \ + WHERE id = $7", + ) + .bind(name) + .bind(issuer_url) + .bind(client_id) + .bind(client_secret_encrypted) + .bind(scopes) + .bind(enabled) + .bind(id) + .execute(pool) + .await?; + Ok(result.rows_affected() > 0) +} + +pub async fn delete_provider(pool: &PgPool, id: Uuid) -> Result { + let result = sqlx::query("DELETE FROM oidc_providers WHERE id = $1") + .bind(id) + .execute(pool) + .await?; + Ok(result.rows_affected() > 0) +} + +// --- User links --- + +pub async fn find_user_by_oidc( + pool: &PgPool, + provider_id: Uuid, + subject: &str, +) -> Result, sqlx::Error> { + let row: Option<(Uuid,)> = sqlx::query_as( + "SELECT user_id FROM oidc_user_links WHERE provider_id = $1 AND subject = $2", + ) + .bind(provider_id) + .bind(subject) + .fetch_optional(pool) + .await?; + Ok(row.map(|r| r.0)) +} + +pub async fn link_user_to_oidc( + pool: &PgPool, + user_id: Uuid, + provider_id: Uuid, + subject: &str, + email: Option<&str>, +) -> Result<(), sqlx::Error> { + sqlx::query( + "INSERT INTO oidc_user_links (user_id, provider_id, subject, email) \ + VALUES ($1, $2, $3, $4) ON CONFLICT (provider_id, subject) DO NOTHING", + ) + .bind(user_id) + .bind(provider_id) + .bind(subject) + .bind(email) + .execute(pool) + .await?; + Ok(()) +} diff --git a/crates/sao-server/src/db/pool.rs b/crates/sao-server/src/db/pool.rs new file mode 100644 index 0000000..14a6a16 --- /dev/null +++ b/crates/sao-server/src/db/pool.rs @@ -0,0 +1,21 @@ +use sqlx::postgres::PgPoolOptions; +use sqlx::PgPool; + +/// Initialize the PostgreSQL connection pool from DATABASE_URL. +/// Panics if DATABASE_URL is not set or connection fails. +pub async fn init_pool() -> PgPool { + let database_url = + std::env::var("DATABASE_URL").expect("DATABASE_URL environment variable must be set"); + + let pool = PgPoolOptions::new() + .max_connections(10) + .min_connections(1) + .acquire_timeout(std::time::Duration::from_secs(5)) + .idle_timeout(std::time::Duration::from_secs(600)) + .connect(&database_url) + .await + .expect("Failed to connect to PostgreSQL"); + + tracing::info!("PostgreSQL connection pool established"); + pool +} diff --git a/crates/sao-server/src/db/sessions.rs b/crates/sao-server/src/db/sessions.rs new file mode 100644 index 0000000..b6c7721 --- /dev/null +++ b/crates/sao-server/src/db/sessions.rs @@ -0,0 +1,52 @@ +use sqlx::PgPool; +use uuid::Uuid; + +/// Store a refresh token hash. +pub async fn store_refresh_token( + pool: &PgPool, + user_id: Uuid, + token_hash: &str, + expires_at: chrono::DateTime, +) -> Result<(), sqlx::Error> { + sqlx::query("INSERT INTO refresh_tokens (user_id, token_hash, expires_at) VALUES ($1, $2, $3)") + .bind(user_id) + .bind(token_hash) + .bind(expires_at) + .execute(pool) + .await?; + Ok(()) +} + +/// Validate a refresh token hash (not expired, not revoked). +pub async fn validate_refresh_token( + pool: &PgPool, + token_hash: &str, +) -> Result, sqlx::Error> { + let row: Option<(Uuid,)> = sqlx::query_as( + "SELECT user_id FROM refresh_tokens \ + WHERE token_hash = $1 AND expires_at > now() AND revoked = false", + ) + .bind(token_hash) + .fetch_optional(pool) + .await?; + Ok(row.map(|r| r.0)) +} + +/// Revoke a specific refresh token. +pub async fn revoke_refresh_token(pool: &PgPool, token_hash: &str) -> Result<(), sqlx::Error> { + sqlx::query("UPDATE refresh_tokens SET revoked = true WHERE token_hash = $1") + .bind(token_hash) + .execute(pool) + .await?; + Ok(()) +} + +/// Revoke all refresh tokens for a user. +#[allow(dead_code)] +pub async fn revoke_all_user_tokens(pool: &PgPool, user_id: Uuid) -> Result<(), sqlx::Error> { + sqlx::query("UPDATE refresh_tokens SET revoked = true WHERE user_id = $1") + .bind(user_id) + .execute(pool) + .await?; + Ok(()) +} diff --git a/crates/sao-server/src/db/users.rs b/crates/sao-server/src/db/users.rs new file mode 100644 index 0000000..fa9307a --- /dev/null +++ b/crates/sao-server/src/db/users.rs @@ -0,0 +1,83 @@ +use sqlx::PgPool; +use uuid::Uuid; + +#[derive(Debug, sqlx::FromRow, serde::Serialize)] +pub struct UserRow { + pub id: Uuid, + pub username: String, + pub display_name: Option, + pub role: String, + pub created_at: chrono::DateTime, + pub updated_at: chrono::DateTime, +} + +pub async fn create_user( + pool: &PgPool, + username: &str, + display_name: Option<&str>, + role: &str, +) -> Result { + let row: (Uuid,) = sqlx::query_as( + "INSERT INTO users (username, display_name, role) VALUES ($1, $2, $3) RETURNING id", + ) + .bind(username) + .bind(display_name) + .bind(role) + .fetch_one(pool) + .await?; + Ok(row.0) +} + +pub async fn get_user_by_id(pool: &PgPool, id: Uuid) -> Result, sqlx::Error> { + sqlx::query_as::<_, UserRow>( + "SELECT id, username, display_name, role, created_at, updated_at FROM users WHERE id = $1", + ) + .bind(id) + .fetch_optional(pool) + .await +} + +pub async fn get_user_by_username( + pool: &PgPool, + username: &str, +) -> Result, sqlx::Error> { + sqlx::query_as::<_, UserRow>( + "SELECT id, username, display_name, role, created_at, updated_at FROM users WHERE username = $1", + ) + .bind(username) + .fetch_optional(pool) + .await +} + +pub async fn list_users(pool: &PgPool) -> Result, sqlx::Error> { + sqlx::query_as::<_, UserRow>( + "SELECT id, username, display_name, role, created_at, updated_at FROM users ORDER BY created_at", + ) + .fetch_all(pool) + .await +} + +pub async fn update_user_role(pool: &PgPool, id: Uuid, role: &str) -> Result { + let result = sqlx::query("UPDATE users SET role = $1, updated_at = now() WHERE id = $2") + .bind(role) + .bind(id) + .execute(pool) + .await?; + Ok(result.rows_affected() > 0) +} + +pub async fn delete_user(pool: &PgPool, id: Uuid) -> Result { + let result = sqlx::query("DELETE FROM users WHERE id = $1") + .bind(id) + .execute(pool) + .await?; + Ok(result.rows_affected() > 0) +} + +#[allow(dead_code)] +pub async fn user_count(pool: &PgPool) -> Result { + let count: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM users") + .fetch_one(pool) + .await?; + Ok(count.0) +} diff --git a/crates/sao-server/src/db/vault.rs b/crates/sao-server/src/db/vault.rs new file mode 100644 index 0000000..d5400f8 --- /dev/null +++ b/crates/sao-server/src/db/vault.rs @@ -0,0 +1,146 @@ +use sqlx::PgPool; +use uuid::Uuid; + +#[derive(Debug, sqlx::FromRow, serde::Serialize)] +pub struct VaultSecretRow { + pub id: Uuid, + pub owner_user_id: Option, + pub secret_type: String, + pub label: String, + pub provider: Option, + pub ciphertext: Vec, + pub nonce: Vec, + pub metadata: serde_json::Value, + pub created_at: chrono::DateTime, + pub updated_at: chrono::DateTime, +} + +#[derive(Debug, sqlx::FromRow, serde::Serialize)] +pub struct VaultSecretMetadataRow { + pub id: Uuid, + pub secret_type: String, + pub label: String, + pub provider: Option, + pub metadata: serde_json::Value, + pub created_at: chrono::DateTime, + pub updated_at: chrono::DateTime, +} + +#[allow(clippy::too_many_arguments)] +pub async fn create_secret( + pool: &PgPool, + owner_user_id: Option, + secret_type: &str, + label: &str, + provider: Option<&str>, + ciphertext: &[u8], + nonce: &[u8], + metadata: Option, +) -> Result { + let row: (Uuid,) = sqlx::query_as( + "INSERT INTO vault_secrets (owner_user_id, secret_type, label, provider, ciphertext, nonce, metadata) \ + VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING id", + ) + .bind(owner_user_id) + .bind(secret_type) + .bind(label) + .bind(provider) + .bind(ciphertext) + .bind(nonce) + .bind(metadata.unwrap_or(serde_json::json!({}))) + .fetch_one(pool) + .await?; + Ok(row.0) +} + +pub async fn list_secrets_metadata( + pool: &PgPool, + owner_user_id: Option, +) -> Result, sqlx::Error> { + if let Some(uid) = owner_user_id { + sqlx::query_as::<_, VaultSecretMetadataRow>( + "SELECT id, secret_type, label, provider, metadata, created_at, updated_at \ + FROM vault_secrets WHERE owner_user_id = $1 ORDER BY created_at", + ) + .bind(uid) + .fetch_all(pool) + .await + } else { + sqlx::query_as::<_, VaultSecretMetadataRow>( + "SELECT id, secret_type, label, provider, metadata, created_at, updated_at \ + FROM vault_secrets ORDER BY created_at", + ) + .fetch_all(pool) + .await + } +} + +pub async fn get_secret(pool: &PgPool, id: Uuid) -> Result, sqlx::Error> { + sqlx::query_as::<_, VaultSecretRow>( + "SELECT id, owner_user_id, secret_type, label, provider, ciphertext, nonce, metadata, created_at, updated_at \ + FROM vault_secrets WHERE id = $1", + ) + .bind(id) + .fetch_optional(pool) + .await +} + +pub async fn update_secret( + pool: &PgPool, + id: Uuid, + label: Option<&str>, + ciphertext: Option<&[u8]>, + nonce: Option<&[u8]>, + metadata: Option, +) -> Result { + // Build update dynamically based on provided fields + let mut updates = Vec::new(); + let mut param_idx = 1; + + if label.is_some() { + updates.push(format!("label = ${}", param_idx)); + param_idx += 1; + } + if ciphertext.is_some() { + updates.push(format!("ciphertext = ${}", param_idx)); + param_idx += 1; + updates.push(format!("nonce = ${}", param_idx)); + param_idx += 1; + } + if metadata.is_some() { + updates.push(format!("metadata = ${}", param_idx)); + param_idx += 1; + } + updates.push("updated_at = now()".to_string()); + + let sql = format!( + "UPDATE vault_secrets SET {} WHERE id = ${}", + updates.join(", "), + param_idx + ); + + let mut query = sqlx::query(&sql); + + if let Some(l) = label { + query = query.bind(l); + } + if let Some(ct) = ciphertext { + query = query.bind(ct); + query = query.bind(nonce.unwrap_or(&[])); + } + if let Some(m) = metadata { + query = query.bind(m); + } + query = query.bind(id); + + let result = query.execute(pool).await?; + Ok(result.rows_affected() > 0) +} + +pub async fn delete_secret(pool: &PgPool, id: Uuid) -> Result { + let result = sqlx::query("DELETE FROM vault_secrets WHERE id = $1") + .bind(id) + .execute(pool) + .await?; + Ok(result.rows_affected() > 0) +} diff --git a/crates/sao-server/src/db/vault_key.rs b/crates/sao-server/src/db/vault_key.rs new file mode 100644 index 0000000..04aaf59 --- /dev/null +++ b/crates/sao-server/src/db/vault_key.rs @@ -0,0 +1,53 @@ +use sqlx::PgPool; + +#[derive(Debug, sqlx::FromRow)] +#[allow(dead_code)] +pub struct VaultMasterKeyRow { + pub id: i32, + pub encrypted_key: Vec, + pub kdf_salt: Vec, + pub kdf_memory_cost: i32, + pub kdf_time_cost: i32, + pub kdf_parallelism: i32, +} + +/// Check if a VMK has been initialized. +pub async fn vmk_exists(pool: &PgPool) -> Result { + let count: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM vault_master_key") + .fetch_one(pool) + .await?; + Ok(count.0 > 0) +} + +/// Store a sealed VMK envelope. +pub async fn store_vmk( + pool: &PgPool, + encrypted_key: &[u8], + kdf_salt: &[u8], + kdf_memory_cost: i32, + kdf_time_cost: i32, + kdf_parallelism: i32, +) -> Result<(), sqlx::Error> { + sqlx::query( + "INSERT INTO vault_master_key (encrypted_key, kdf_salt, kdf_memory_cost, kdf_time_cost, kdf_parallelism) \ + VALUES ($1, $2, $3, $4, $5)", + ) + .bind(encrypted_key) + .bind(kdf_salt) + .bind(kdf_memory_cost) + .bind(kdf_time_cost) + .bind(kdf_parallelism) + .execute(pool) + .await?; + Ok(()) +} + +/// Retrieve the most recent VMK envelope. +pub async fn get_vmk(pool: &PgPool) -> Result, sqlx::Error> { + sqlx::query_as::<_, VaultMasterKeyRow>( + "SELECT id, encrypted_key, kdf_salt, kdf_memory_cost, kdf_time_cost, kdf_parallelism \ + FROM vault_master_key ORDER BY id DESC LIMIT 1", + ) + .fetch_optional(pool) + .await +} diff --git a/crates/sao-server/src/db/webauthn.rs b/crates/sao-server/src/db/webauthn.rs new file mode 100644 index 0000000..22cf811 --- /dev/null +++ b/crates/sao-server/src/db/webauthn.rs @@ -0,0 +1,99 @@ +use sqlx::PgPool; +use uuid::Uuid; + +#[derive(Debug, sqlx::FromRow)] +#[allow(dead_code)] +pub struct WebauthnCredentialRow { + pub id: i32, + pub user_id: Uuid, + pub credential_id: String, + pub credential_json: serde_json::Value, + pub label: Option, +} + +/// Store a WebAuthn credential for a user. +pub async fn store_credential( + pool: &PgPool, + user_id: Uuid, + credential_id: &str, + credential_json: serde_json::Value, + label: Option<&str>, +) -> Result<(), sqlx::Error> { + sqlx::query( + "INSERT INTO webauthn_credentials (user_id, credential_id, credential_json, label) \ + VALUES ($1, $2, $3, $4)", + ) + .bind(user_id) + .bind(credential_id) + .bind(credential_json) + .bind(label) + .execute(pool) + .await?; + Ok(()) +} + +/// Get all credentials for a user. +pub async fn get_credentials_for_user( + pool: &PgPool, + user_id: Uuid, +) -> Result, sqlx::Error> { + sqlx::query_as::<_, WebauthnCredentialRow>( + "SELECT id, user_id, credential_id, credential_json, label \ + FROM webauthn_credentials WHERE user_id = $1", + ) + .bind(user_id) + .fetch_all(pool) + .await +} + +/// Update credential's last_used_at timestamp. +#[allow(dead_code)] +pub async fn touch_credential(pool: &PgPool, credential_id: &str) -> Result<(), sqlx::Error> { + sqlx::query("UPDATE webauthn_credentials SET last_used_at = now() WHERE credential_id = $1") + .bind(credential_id) + .execute(pool) + .await?; + Ok(()) +} + +/// Store a WebAuthn challenge state (ephemeral). +pub async fn store_challenge( + pool: &PgPool, + challenge_id: &str, + challenge_json: serde_json::Value, + challenge_type: &str, + user_id: Option, +) -> Result<(), sqlx::Error> { + // Clean up expired challenges first + sqlx::query("DELETE FROM webauthn_challenges WHERE expires_at < now()") + .execute(pool) + .await?; + + sqlx::query( + "INSERT INTO webauthn_challenges (id, challenge_json, challenge_type, user_id) \ + VALUES ($1, $2, $3, $4) \ + ON CONFLICT (id) DO UPDATE SET challenge_json = $2, challenge_type = $3", + ) + .bind(challenge_id) + .bind(challenge_json) + .bind(challenge_type) + .bind(user_id) + .execute(pool) + .await?; + Ok(()) +} + +/// Retrieve and delete a challenge state (one-time use). +pub async fn consume_challenge( + pool: &PgPool, + challenge_id: &str, +) -> Result)>, sqlx::Error> { + let row: Option<(serde_json::Value, Option)> = sqlx::query_as( + "DELETE FROM webauthn_challenges WHERE id = $1 AND expires_at > now() \ + RETURNING challenge_json, user_id", + ) + .bind(challenge_id) + .fetch_optional(pool) + .await?; + Ok(row) +} diff --git a/crates/sao-server/src/main.rs b/crates/sao-server/src/main.rs index dd87be5..471497d 100644 --- a/crates/sao-server/src/main.rs +++ b/crates/sao-server/src/main.rs @@ -1,15 +1,17 @@ +mod auth; mod db; mod routes; mod state; +mod vault_state; mod ws; use axum::Router; use tower_http::cors::{Any, CorsLayer}; +use tower_http::services::{ServeDir, ServeFile}; use tracing_subscriber::EnvFilter; #[tokio::main] async fn main() -> anyhow::Result<()> { - // Initialize tracing tracing_subscriber::fmt() .with_env_filter( EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")), @@ -18,11 +20,23 @@ async fn main() -> anyhow::Result<()> { tracing::info!("Starting SAO - Secure Agent Orchestrator"); - // Initialize database pool - let db = db::DbPool::from_env().await; + // Initialize database pool (required) + let pool = db::pool::init_pool().await; + + // Run database migrations + db::migrate::run_migrations(&pool).await?; + + // Determine initial vault state + let vault_state = determine_vault_state(&pool).await; + + // Initialize WebAuthn + let webauthn = auth::webauthn::create_webauthn(); + + // Initialize JWT secret + let jwt_secret = auth::session::jwt_secret(); // Initialize application state - let app_state = state::init_app_state(db); + let app_state = state::init_app_state(pool, vault_state, webauthn, jwt_secret); // Build CORS layer let cors = CorsLayer::new() @@ -30,10 +44,17 @@ async fn main() -> anyhow::Result<()> { .allow_methods(Any) .allow_headers(Any); + // Static file serving for the React SPA + let static_dir = + std::env::var("SAO_STATIC_DIR").unwrap_or_else(|_| "frontend/dist".to_string()); + let spa_fallback = ServeDir::new(&static_dir) + .not_found_service(ServeFile::new(format!("{}/index.html", static_dir))); + // Build the router let app = Router::new() .merge(routes::routes()) .merge(ws::ws_routes()) + .fallback_service(spa_fallback) .layer(cors) .with_state(app_state); @@ -46,3 +67,62 @@ async fn main() -> anyhow::Result<()> { Ok(()) } + +/// Determine initial vault state: Uninitialized, auto-unseal, or Sealed. +async fn determine_vault_state(pool: &sqlx::PgPool) -> vault_state::VaultState { + let vmk_exists = match db::vault_key::vmk_exists(pool).await { + Ok(exists) => exists, + Err(e) => { + tracing::error!("Failed to check VMK status: {}", e); + return vault_state::VaultState::Uninitialized; + } + }; + + if !vmk_exists { + tracing::info!("No vault master key found - vault is uninitialized"); + return vault_state::VaultState::Uninitialized; + } + + // Try auto-unseal from environment variable + if let Ok(passphrase) = std::env::var("SAO_VAULT_PASSPHRASE") { + tracing::info!("Attempting auto-unseal from SAO_VAULT_PASSPHRASE..."); + match auto_unseal(pool, &passphrase).await { + Ok(vmk) => { + tracing::info!("Vault auto-unsealed successfully"); + return vault_state::VaultState::Unsealed(vmk); + } + Err(e) => { + tracing::error!("Auto-unseal failed: {}", e); + } + } + } + + tracing::info!("Vault is sealed - unseal via API to enable encryption"); + vault_state::VaultState::Sealed +} + +async fn auto_unseal( + pool: &sqlx::PgPool, + passphrase: &str, +) -> Result { + let vmk_row = db::vault_key::get_vmk(pool) + .await + .map_err(|e| format!("DB error: {}", e))? + .ok_or("No VMK found")?; + + let passphrase_key = sao_core::vault::kdf::derive_key_from_passphrase( + passphrase, + &vmk_row.kdf_salt, + vmk_row.kdf_memory_cost as u32, + vmk_row.kdf_time_cost as u32, + vmk_row.kdf_parallelism as u32, + )?; + + let encrypted = &vmk_row.encrypted_key; + if encrypted.len() < 12 { + return Err("Corrupted VMK envelope".to_string()); + } + let (ciphertext, nonce) = encrypted.split_at(encrypted.len() - 12); + + sao_core::vault::VaultMasterKey::unseal(ciphertext, nonce, &passphrase_key) +} diff --git a/crates/sao-server/src/routes/admin.rs b/crates/sao-server/src/routes/admin.rs new file mode 100644 index 0000000..6a23334 --- /dev/null +++ b/crates/sao-server/src/routes/admin.rs @@ -0,0 +1,374 @@ +use axum::{ + extract::{Path, Query, State}, + http::StatusCode, + routing::{delete, get, post, put}, + Json, Router, +}; +use serde::Deserialize; +use serde_json::{json, Value}; +use uuid::Uuid; + +use crate::auth::middleware::AdminUser; +use crate::state::AppState; + +pub fn routes() -> Router { + Router::new() + // User management (admin only) + .route("/api/admin/users", get(list_users)) + .route("/api/admin/users/{id}/role", put(update_user_role)) + .route("/api/admin/users/{id}", delete(delete_user)) + // OIDC provider management (admin only) + .route("/api/admin/oidc/providers", post(create_oidc_provider)) + .route("/api/admin/oidc/providers", get(list_oidc_providers)) + .route("/api/admin/oidc/providers/{id}", put(update_oidc_provider)) + .route( + "/api/admin/oidc/providers/{id}", + delete(delete_oidc_provider), + ) + // Audit log (admin only) + .route("/api/admin/audit", get(query_audit_log)) +} + +// --- User management --- + +async fn list_users( + AdminUser(_admin): AdminUser, + State(state): State, +) -> (StatusCode, Json) { + match crate::db::users::list_users(&state.inner.db).await { + Ok(users) => (StatusCode::OK, Json(json!({ "users": users }))), + Err(e) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "error": e.to_string() })), + ), + } +} + +#[derive(Deserialize)] +struct UpdateRoleRequest { + role: String, +} + +async fn update_user_role( + AdminUser(admin): AdminUser, + State(state): State, + Path(id): Path, + Json(req): Json, +) -> (StatusCode, Json) { + if req.role != "user" && req.role != "admin" { + return ( + StatusCode::BAD_REQUEST, + Json(json!({ "error": "Role must be 'user' or 'admin'" })), + ); + } + + match crate::db::users::update_user_role(&state.inner.db, id, &req.role).await { + Ok(true) => { + let _ = crate::db::audit::insert_audit_log( + &state.inner.db, + Some(admin.user_id), + None, + "admin.update_role", + Some("user"), + Some(json!({ "target_user_id": id, "new_role": req.role })), + None, + None, + ) + .await; + (StatusCode::OK, Json(json!({ "updated": true }))) + } + Ok(false) => ( + StatusCode::NOT_FOUND, + Json(json!({ "error": "User not found" })), + ), + Err(e) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "error": e.to_string() })), + ), + } +} + +async fn delete_user( + AdminUser(admin): AdminUser, + State(state): State, + Path(id): Path, +) -> (StatusCode, Json) { + // Prevent self-deletion + if id == admin.user_id { + return ( + StatusCode::BAD_REQUEST, + Json(json!({ "error": "Cannot delete your own account" })), + ); + } + + match crate::db::users::delete_user(&state.inner.db, id).await { + Ok(true) => { + let _ = crate::db::audit::insert_audit_log( + &state.inner.db, + Some(admin.user_id), + None, + "admin.delete_user", + Some("user"), + Some(json!({ "deleted_user_id": id })), + None, + None, + ) + .await; + (StatusCode::OK, Json(json!({ "deleted": true }))) + } + Ok(false) => ( + StatusCode::NOT_FOUND, + Json(json!({ "error": "User not found" })), + ), + Err(e) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "error": e.to_string() })), + ), + } +} + +// --- OIDC provider management --- + +#[derive(Deserialize)] +struct CreateOidcProviderRequest { + name: String, + issuer_url: String, + client_id: String, + client_secret: Option, + scopes: Option, +} + +async fn create_oidc_provider( + AdminUser(admin): AdminUser, + State(state): State, + Json(req): Json, +) -> (StatusCode, Json) { + // Encrypt client secret if provided + let encrypted_secret = if let Some(ref secret) = req.client_secret { + let vs = state.inner.vault_state.read().await; + match vs.vmk() { + Some(vmk) => match vmk.encrypt(secret.as_bytes()) { + Ok((ct, nonce)) => { + let mut combined = ct; + combined.extend_from_slice(&nonce); + Some(combined) + } + Err(e) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "error": format!("Failed to encrypt secret: {}", e) })), + ); + } + }, + None => { + return ( + StatusCode::SERVICE_UNAVAILABLE, + Json(json!({ "error": "Vault is sealed" })), + ); + } + } + } else { + None + }; + + let scopes = req.scopes.as_deref().unwrap_or("openid profile email"); + + match crate::db::oidc::create_provider( + &state.inner.db, + &req.name, + &req.issuer_url, + &req.client_id, + encrypted_secret.as_deref(), + scopes, + ) + .await + { + Ok(id) => { + let _ = crate::db::audit::insert_audit_log( + &state.inner.db, + Some(admin.user_id), + None, + "admin.create_oidc_provider", + Some("oidc_provider"), + Some(json!({ "provider_name": req.name, "provider_id": id })), + None, + None, + ) + .await; + (StatusCode::CREATED, Json(json!({ "id": id }))) + } + Err(e) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "error": e.to_string() })), + ), + } +} + +async fn list_oidc_providers( + AdminUser(_admin): AdminUser, + State(state): State, +) -> (StatusCode, Json) { + match crate::db::oidc::list_providers(&state.inner.db).await { + Ok(providers) => { + // Strip encrypted secrets from response + let sanitized: Vec = providers + .iter() + .map(|p| { + json!({ + "id": p.id, + "name": p.name, + "issuer_url": p.issuer_url, + "client_id": p.client_id, + "has_client_secret": p.client_secret_encrypted.is_some(), + "scopes": p.scopes, + "enabled": p.enabled, + "created_at": p.created_at, + "updated_at": p.updated_at, + }) + }) + .collect(); + (StatusCode::OK, Json(json!({ "providers": sanitized }))) + } + Err(e) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "error": e.to_string() })), + ), + } +} + +#[derive(Deserialize)] +struct UpdateOidcProviderRequest { + name: Option, + issuer_url: Option, + client_id: Option, + client_secret: Option, + scopes: Option, + enabled: Option, +} + +async fn update_oidc_provider( + AdminUser(admin): AdminUser, + State(state): State, + Path(id): Path, + Json(req): Json, +) -> (StatusCode, Json) { + let encrypted_secret = if let Some(ref secret) = req.client_secret { + let vs = state.inner.vault_state.read().await; + match vs.vmk() { + Some(vmk) => match vmk.encrypt(secret.as_bytes()) { + Ok((ct, nonce)) => { + let mut combined = ct; + combined.extend_from_slice(&nonce); + Some(combined) + } + Err(e) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "error": format!("Failed to encrypt secret: {}", e) })), + ); + } + }, + None => { + return ( + StatusCode::SERVICE_UNAVAILABLE, + Json(json!({ "error": "Vault is sealed" })), + ); + } + } + } else { + None + }; + + match crate::db::oidc::update_provider( + &state.inner.db, + id, + req.name.as_deref(), + req.issuer_url.as_deref(), + req.client_id.as_deref(), + encrypted_secret.as_deref(), + req.scopes.as_deref(), + req.enabled, + ) + .await + { + Ok(true) => { + let _ = crate::db::audit::insert_audit_log( + &state.inner.db, + Some(admin.user_id), + None, + "admin.update_oidc_provider", + Some("oidc_provider"), + Some(json!({ "provider_id": id })), + None, + None, + ) + .await; + (StatusCode::OK, Json(json!({ "updated": true }))) + } + Ok(false) => ( + StatusCode::NOT_FOUND, + Json(json!({ "error": "Provider not found" })), + ), + Err(e) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "error": e.to_string() })), + ), + } +} + +async fn delete_oidc_provider( + AdminUser(admin): AdminUser, + State(state): State, + Path(id): Path, +) -> (StatusCode, Json) { + match crate::db::oidc::delete_provider(&state.inner.db, id).await { + Ok(true) => { + let _ = crate::db::audit::insert_audit_log( + &state.inner.db, + Some(admin.user_id), + None, + "admin.delete_oidc_provider", + Some("oidc_provider"), + Some(json!({ "provider_id": id })), + None, + None, + ) + .await; + (StatusCode::OK, Json(json!({ "deleted": true }))) + } + Ok(false) => ( + StatusCode::NOT_FOUND, + Json(json!({ "error": "Provider not found" })), + ), + Err(e) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "error": e.to_string() })), + ), + } +} + +// --- Audit log --- + +#[derive(Deserialize)] +struct AuditLogQuery { + user_id: Option, + limit: Option, + offset: Option, +} + +async fn query_audit_log( + AdminUser(_admin): AdminUser, + State(state): State, + Query(params): Query, +) -> (StatusCode, Json) { + let limit = params.limit.unwrap_or(100).min(1000); + let offset = params.offset.unwrap_or(0); + + match crate::db::admin::query_audit_log(&state.inner.db, params.user_id, limit, offset).await { + Ok(entries) => (StatusCode::OK, Json(json!({ "audit_log": entries }))), + Err(e) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "error": e.to_string() })), + ), + } +} diff --git a/crates/sao-server/src/routes/agents.rs b/crates/sao-server/src/routes/agents.rs index a3c2f51..9fc745d 100644 --- a/crates/sao-server/src/routes/agents.rs +++ b/crates/sao-server/src/routes/agents.rs @@ -1,27 +1,96 @@ -use axum::{extract::State, routing::{get, post}, Json, Router}; +use axum::{ + extract::{Path, State}, + routing::{delete, get, post}, + Json, Router, +}; use serde::Deserialize; use serde_json::{json, Value}; +use uuid::Uuid; + +use crate::auth::middleware::AuthUser; use crate::state::AppState; pub fn routes() -> Router { Router::new() .route("/api/agents", get(list_agents)) .route("/api/agents", post(create_agent)) + .route("/api/agents/{id}", get(get_agent)) + .route("/api/agents/{id}", delete(delete_agent_handler)) } -async fn list_agents(State(state): State) -> Json { - match state.inner.identity_manager.list_agents() { - Ok(agents) => Json(json\!({ "agents": agents })), - Err(e) => Json(json\!({ "error": e })), +async fn list_agents(_user: AuthUser, State(state): State) -> Json { + match crate::db::agents::list_agents(&state.inner.db).await { + Ok(agents) => Json(json!({ "agents": agents })), + Err(e) => Json(json!({ "error": e.to_string() })), } } #[derive(Deserialize)] -struct CreateAgentRequest { name: String } +struct CreateAgentRequest { + name: String, +} + +async fn create_agent( + user: AuthUser, + State(state): State, + Json(req): Json, +) -> Json { + match crate::db::agents::create_agent(&state.inner.db, &req.name).await { + Ok(agent) => { + let _ = crate::db::audit::insert_audit_log( + &state.inner.db, + Some(user.user_id), + None, + "agents.create", + Some("agent"), + Some(json!({ "agent_id": agent.id, "name": agent.name })), + None, + None, + ) + .await; + Json(json!({ + "id": agent.id, + "name": agent.name, + "state": agent.state, + })) + } + Err(e) => Json(json!({ "error": e.to_string() })), + } +} + +async fn get_agent( + _user: AuthUser, + State(state): State, + Path(id): Path, +) -> Json { + match crate::db::agents::get_agent(&state.inner.db, id).await { + Ok(Some(agent)) => Json(json!(agent)), + Ok(None) => Json(json!({ "error": "Agent not found" })), + Err(e) => Json(json!({ "error": e.to_string() })), + } +} -async fn create_agent(State(state): State, Json(req): Json) -> Json { - match state.inner.identity_manager.create_agent(&req.name) { - Ok((uuid, dir)) => Json(json\!({ "id": uuid, "directory": dir.to_string_lossy() })), - Err(e) => Json(json\!({ "error": e })), +async fn delete_agent_handler( + user: AuthUser, + State(state): State, + Path(id): Path, +) -> Json { + match crate::db::agents::delete_agent(&state.inner.db, id).await { + Ok(true) => { + let _ = crate::db::audit::insert_audit_log( + &state.inner.db, + Some(user.user_id), + None, + "agents.delete", + Some("agent"), + Some(json!({ "agent_id": id })), + None, + None, + ) + .await; + Json(json!({ "deleted": true })) + } + Ok(false) => Json(json!({ "error": "Agent not found" })), + Err(e) => Json(json!({ "error": e.to_string() })), } } diff --git a/crates/sao-server/src/routes/auth.rs b/crates/sao-server/src/routes/auth.rs new file mode 100644 index 0000000..d58b50e --- /dev/null +++ b/crates/sao-server/src/routes/auth.rs @@ -0,0 +1,533 @@ +use axum::{ + extract::State, + http::StatusCode, + routing::{get, post}, + Json, Router, +}; +use chrono::{Duration, Utc}; +use serde::Deserialize; +use serde_json::{json, Value}; +use webauthn_rs::prelude::*; + +use crate::auth::middleware::AuthUser; +use crate::auth::session; +use crate::state::AppState; + +pub fn routes() -> Router { + Router::new() + .route("/api/auth/webauthn/register/start", post(register_start)) + .route("/api/auth/webauthn/register/finish", post(register_finish)) + .route("/api/auth/webauthn/login/start", post(login_start)) + .route("/api/auth/webauthn/login/finish", post(login_finish)) + .route("/api/auth/refresh", post(refresh_token)) + .route("/api/auth/logout", post(logout)) + .route("/api/auth/me", get(me)) +} + +// --- Registration --- + +#[derive(Deserialize)] +struct RegisterStartRequest { + username: String, +} + +async fn register_start( + State(state): State, + Json(req): Json, +) -> (StatusCode, Json) { + // Look up or fail if user doesn't exist + let user = match crate::db::users::get_user_by_username(&state.inner.db, &req.username).await { + Ok(Some(u)) => u, + Ok(None) => { + return ( + StatusCode::NOT_FOUND, + Json(json!({ "error": "User not found" })), + ); + } + Err(e) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "error": e.to_string() })), + ); + } + }; + + // Get existing credential IDs for exclusion + let existing_creds = + match crate::db::webauthn::get_credentials_for_user(&state.inner.db, user.id).await { + Ok(creds) => creds + .into_iter() + .filter_map(|c| { + serde_json::from_value::(c.credential_json) + .ok() + .map(|pk| pk.cred_id().clone()) + }) + .collect::>(), + Err(_) => vec![], + }; + + let display_name = user.display_name.as_deref().unwrap_or(&user.username); + match crate::auth::webauthn::start_registration( + &state.inner.webauthn, + user.id, + &user.username, + display_name, + existing_creds, + ) { + Ok((challenge, reg_state)) => { + // Store challenge state in DB + let challenge_id = uuid::Uuid::new_v4().to_string(); + let reg_state_json = serde_json::to_value(®_state).unwrap(); + + if let Err(e) = crate::db::webauthn::store_challenge( + &state.inner.db, + &challenge_id, + reg_state_json, + "registration", + Some(user.id), + ) + .await + { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "error": e.to_string() })), + ); + } + + ( + StatusCode::OK, + Json(json!({ + "challenge": challenge, + "challenge_id": challenge_id, + })), + ) + } + Err(e) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "error": format!("WebAuthn registration failed: {}", e) })), + ), + } +} + +#[derive(Deserialize)] +struct RegisterFinishRequest { + challenge_id: String, + credential: RegisterPublicKeyCredential, +} + +async fn register_finish( + State(state): State, + Json(req): Json, +) -> (StatusCode, Json) { + // Retrieve challenge state + let (challenge_json, user_id) = + match crate::db::webauthn::consume_challenge(&state.inner.db, &req.challenge_id).await { + Ok(Some(data)) => data, + Ok(None) => { + return ( + StatusCode::BAD_REQUEST, + Json(json!({ "error": "Invalid or expired challenge" })), + ); + } + Err(e) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "error": e.to_string() })), + ); + } + }; + + let user_id = match user_id { + Some(id) => id, + None => { + return ( + StatusCode::BAD_REQUEST, + Json(json!({ "error": "Challenge has no associated user" })), + ); + } + }; + + let reg_state: PasskeyRegistration = match serde_json::from_value(challenge_json) { + Ok(s) => s, + Err(e) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "error": format!("Invalid challenge state: {}", e) })), + ); + } + }; + + match crate::auth::webauthn::finish_registration( + &state.inner.webauthn, + &req.credential, + ®_state, + ) { + Ok(passkey) => { + let cred_id = base64::Engine::encode( + &base64::engine::general_purpose::URL_SAFE_NO_PAD, + passkey.cred_id().as_ref(), + ); + let cred_json = serde_json::to_value(&passkey).unwrap(); + + if let Err(e) = crate::db::webauthn::store_credential( + &state.inner.db, + user_id, + &cred_id, + cred_json, + None, + ) + .await + { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "error": e.to_string() })), + ); + } + + ( + StatusCode::CREATED, + Json(json!({ "status": "registered", "credential_id": cred_id })), + ) + } + Err(e) => ( + StatusCode::BAD_REQUEST, + Json(json!({ "error": format!("Registration verification failed: {}", e) })), + ), + } +} + +// --- Authentication --- + +#[derive(Deserialize)] +struct LoginStartRequest { + username: String, +} + +async fn login_start( + State(state): State, + Json(req): Json, +) -> (StatusCode, Json) { + let user = match crate::db::users::get_user_by_username(&state.inner.db, &req.username).await { + Ok(Some(u)) => u, + Ok(None) => { + return ( + StatusCode::NOT_FOUND, + Json(json!({ "error": "User not found" })), + ); + } + Err(e) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "error": e.to_string() })), + ); + } + }; + + // Load user's passkeys + let cred_rows = + match crate::db::webauthn::get_credentials_for_user(&state.inner.db, user.id).await { + Ok(rows) => rows, + Err(e) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "error": e.to_string() })), + ); + } + }; + + if cred_rows.is_empty() { + return ( + StatusCode::BAD_REQUEST, + Json(json!({ "error": "No credentials registered for this user" })), + ); + } + + let passkeys: Vec = cred_rows + .into_iter() + .filter_map(|row| serde_json::from_value(row.credential_json).ok()) + .collect(); + + match crate::auth::webauthn::start_authentication(&state.inner.webauthn, passkeys) { + Ok((challenge, auth_state)) => { + let challenge_id = uuid::Uuid::new_v4().to_string(); + let auth_state_json = serde_json::to_value(&auth_state).unwrap(); + + if let Err(e) = crate::db::webauthn::store_challenge( + &state.inner.db, + &challenge_id, + auth_state_json, + "authentication", + Some(user.id), + ) + .await + { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "error": e.to_string() })), + ); + } + + ( + StatusCode::OK, + Json(json!({ + "challenge": challenge, + "challenge_id": challenge_id, + })), + ) + } + Err(e) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "error": format!("WebAuthn authentication failed: {}", e) })), + ), + } +} + +#[derive(Deserialize)] +struct LoginFinishRequest { + challenge_id: String, + credential: PublicKeyCredential, +} + +async fn login_finish( + State(state): State, + Json(req): Json, +) -> (StatusCode, Json) { + let (challenge_json, user_id) = + match crate::db::webauthn::consume_challenge(&state.inner.db, &req.challenge_id).await { + Ok(Some(data)) => data, + Ok(None) => { + return ( + StatusCode::BAD_REQUEST, + Json(json!({ "error": "Invalid or expired challenge" })), + ); + } + Err(e) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "error": e.to_string() })), + ); + } + }; + + let user_id = match user_id { + Some(id) => id, + None => { + return ( + StatusCode::BAD_REQUEST, + Json(json!({ "error": "Challenge has no associated user" })), + ); + } + }; + + let auth_state: PasskeyAuthentication = match serde_json::from_value(challenge_json) { + Ok(s) => s, + Err(e) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "error": format!("Invalid challenge state: {}", e) })), + ); + } + }; + + match crate::auth::webauthn::finish_authentication( + &state.inner.webauthn, + &req.credential, + &auth_state, + ) { + Ok(_auth_result) => { + // Load user for JWT claims + let user = match crate::db::users::get_user_by_id(&state.inner.db, user_id).await { + Ok(Some(u)) => u, + _ => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "error": "User not found" })), + ); + } + }; + + // Create JWT + let access_token = match session::create_access_token( + user.id, + &user.username, + &user.role, + &state.inner.jwt_secret, + ) { + Ok(t) => t, + Err(e) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "error": format!("Failed to create token: {}", e) })), + ); + } + }; + + // Create refresh token + let refresh_token = session::generate_refresh_token(); + let refresh_hash = session::hash_refresh_token(&refresh_token); + let refresh_expires = Utc::now() + Duration::days(7); + + if let Err(e) = crate::db::sessions::store_refresh_token( + &state.inner.db, + user.id, + &refresh_hash, + refresh_expires, + ) + .await + { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "error": e.to_string() })), + ); + } + + // Audit log + let _ = crate::db::audit::insert_audit_log( + &state.inner.db, + Some(user.id), + None, + "auth.login", + Some("webauthn"), + None, + None, + None, + ) + .await; + + ( + StatusCode::OK, + Json(json!({ + "access_token": access_token, + "refresh_token": refresh_token, + "token_type": "Bearer", + "expires_in": 1800, + "user": { + "id": user.id, + "username": user.username, + "display_name": user.display_name, + "role": user.role, + }, + })), + ) + } + Err(e) => ( + StatusCode::UNAUTHORIZED, + Json(json!({ "error": format!("Authentication failed: {}", e) })), + ), + } +} + +// --- Token refresh --- + +#[derive(Deserialize)] +struct RefreshRequest { + refresh_token: String, +} + +async fn refresh_token( + State(state): State, + Json(req): Json, +) -> (StatusCode, Json) { + let token_hash = session::hash_refresh_token(&req.refresh_token); + + let user_id = + match crate::db::sessions::validate_refresh_token(&state.inner.db, &token_hash).await { + Ok(Some(uid)) => uid, + Ok(None) => { + return ( + StatusCode::UNAUTHORIZED, + Json(json!({ "error": "Invalid or expired refresh token" })), + ); + } + Err(e) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "error": e.to_string() })), + ); + } + }; + + // Revoke old refresh token + let _ = crate::db::sessions::revoke_refresh_token(&state.inner.db, &token_hash).await; + + // Load user + let user = match crate::db::users::get_user_by_id(&state.inner.db, user_id).await { + Ok(Some(u)) => u, + _ => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "error": "User not found" })), + ); + } + }; + + // Issue new tokens + let access_token = match session::create_access_token( + user.id, + &user.username, + &user.role, + &state.inner.jwt_secret, + ) { + Ok(t) => t, + Err(e) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "error": format!("Failed to create token: {}", e) })), + ); + } + }; + + let new_refresh = session::generate_refresh_token(); + let new_hash = session::hash_refresh_token(&new_refresh); + let refresh_expires = Utc::now() + Duration::days(7); + + if let Err(e) = crate::db::sessions::store_refresh_token( + &state.inner.db, + user.id, + &new_hash, + refresh_expires, + ) + .await + { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "error": e.to_string() })), + ); + } + + ( + StatusCode::OK, + Json(json!({ + "access_token": access_token, + "refresh_token": new_refresh, + "token_type": "Bearer", + "expires_in": 1800, + })), + ) +} + +// --- Logout --- + +#[derive(Deserialize)] +struct LogoutRequest { + refresh_token: String, +} + +async fn logout( + State(state): State, + Json(req): Json, +) -> (StatusCode, Json) { + let token_hash = session::hash_refresh_token(&req.refresh_token); + let _ = crate::db::sessions::revoke_refresh_token(&state.inner.db, &token_hash).await; + (StatusCode::OK, Json(json!({ "status": "logged_out" }))) +} + +// --- Current user --- + +async fn me(user: AuthUser) -> Json { + Json(json!({ + "id": user.user_id, + "username": user.username, + "role": user.role, + })) +} diff --git a/crates/sao-server/src/routes/ethical.rs b/crates/sao-server/src/routes/ethical.rs index dcd4546..b810485 100644 --- a/crates/sao-server/src/routes/ethical.rs +++ b/crates/sao-server/src/routes/ethical.rs @@ -1,13 +1,13 @@ -use axum::{routing::post, Json, Router}; -use serde_json::{json, Value}; use crate::state::AppState; +use axum::{routing::post, Json, Router}; +use serde_json::{json, Value}; pub fn routes() -> Router { Router::new().route("/api/ethical/evaluate", post(evaluate)) } async fn evaluate(Json(_body): Json) -> Json { - Json(json\!({ + Json(json!({ "status": "not_configured", "message": "Ethical evaluation service not yet connected", })) diff --git a/crates/sao-server/src/routes/health.rs b/crates/sao-server/src/routes/health.rs index 77e1143..372c40f 100644 --- a/crates/sao-server/src/routes/health.rs +++ b/crates/sao-server/src/routes/health.rs @@ -1,15 +1,27 @@ -use axum::{routing::get, Json, Router}; +use axum::{extract::State, routing::get, Json, Router}; use serde_json::{json, Value}; + use crate::state::AppState; pub fn routes() -> Router { Router::new().route("/api/health", get(health_check)) } -async fn health_check() -> Json { - Json(json\!({ - "status": "ok", +async fn health_check(State(state): State) -> Json { + let db_healthy = sqlx::query_scalar::<_, i32>("SELECT 1") + .fetch_one(&state.inner.db) + .await + .is_ok(); + + let status = if db_healthy { "ok" } else { "degraded" }; + + Json(json!({ + "status": status, "service": "sao", - "version": env\!("CARGO_PKG_VERSION"), + "version": env!("CARGO_PKG_VERSION"), + "database": { + "connected": true, + "healthy": db_healthy, + }, })) } diff --git a/crates/sao-server/src/routes/mod.rs b/crates/sao-server/src/routes/mod.rs index a2066d9..267df6a 100644 --- a/crates/sao-server/src/routes/mod.rs +++ b/crates/sao-server/src/routes/mod.rs @@ -1,13 +1,25 @@ -mod health; -mod agents; -mod ethical; +pub mod admin; +pub mod agents; +pub mod auth; +pub mod ethical; +pub mod health; +pub mod oidc; +pub mod setup; +pub mod vault; -use axum::Router; use crate::state::AppState; +use axum::Router; pub fn routes() -> Router { Router::new() + // Public routes (no auth required) .merge(health::routes()) + .merge(setup::routes()) + .merge(auth::routes()) + .merge(oidc::routes()) + // Protected routes (auth enforced at handler level via extractors) .merge(agents::routes()) + .merge(vault::routes()) .merge(ethical::routes()) + .merge(admin::routes()) } diff --git a/crates/sao-server/src/routes/oidc.rs b/crates/sao-server/src/routes/oidc.rs new file mode 100644 index 0000000..bd16dec --- /dev/null +++ b/crates/sao-server/src/routes/oidc.rs @@ -0,0 +1,339 @@ +use axum::{ + extract::{Path, Query, State}, + http::StatusCode, + response::Redirect, + routing::get, + Json, Router, +}; +use chrono::{Duration, Utc}; +use serde::Deserialize; +use serde_json::{json, Value}; +use uuid::Uuid; + +use crate::auth::session; +use crate::state::AppState; + +pub fn routes() -> Router { + Router::new() + .route("/api/auth/oidc/providers", get(list_providers)) + .route("/api/auth/oidc/{provider_id}/authorize", get(authorize)) + .route("/api/auth/oidc/callback", get(callback)) +} + +/// List enabled OIDC providers (public, for login page). +async fn list_providers(State(state): State) -> (StatusCode, Json) { + match crate::db::oidc::list_providers_public(&state.inner.db).await { + Ok(providers) => (StatusCode::OK, Json(json!({ "providers": providers }))), + Err(e) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "error": e.to_string() })), + ), + } +} + +/// Redirect to OIDC provider's authorization endpoint. +async fn authorize( + State(state): State, + Path(provider_id): Path, +) -> Result)> { + let provider = crate::db::oidc::get_provider(&state.inner.db, provider_id) + .await + .map_err(|e| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "error": e.to_string() })), + ) + })? + .ok_or(( + StatusCode::NOT_FOUND, + Json(json!({ "error": "Provider not found" })), + ))?; + + if !provider.enabled { + return Err(( + StatusCode::BAD_REQUEST, + Json(json!({ "error": "Provider is disabled" })), + )); + } + + // Decrypt client secret if present + let client_secret = if let Some(ref encrypted) = provider.client_secret_encrypted { + let vs = state.inner.vault_state.read().await; + if let Some(vmk) = vs.vmk() { + if encrypted.len() > 12 { + let (ct, nonce) = encrypted.split_at(encrypted.len() - 12); + vmk.decrypt(ct, nonce) + .ok() + .and_then(|b| String::from_utf8(b).ok()) + } else { + None + } + } else { + None + } + } else { + None + }; + + let rp_origin = + std::env::var("SAO_RP_ORIGIN").unwrap_or_else(|_| "http://localhost:3100".to_string()); + let redirect_url = format!("{}/api/auth/oidc/callback", rp_origin); + + let config = crate::auth::oidc::OidcProviderConfig { + id: provider.id, + name: provider.name, + issuer_url: provider.issuer_url, + client_id: provider.client_id, + client_secret, + scopes: provider.scopes, + }; + + let result = crate::auth::oidc::start_authorization(&config, &redirect_url) + .await + .map_err(|e| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "error": e })), + ) + })?; + + // Store CSRF state in DB as a challenge + let state_json = json!({ + "provider_id": provider_id, + "csrf": result.csrf_token.secret(), + "nonce": result.nonce.secret(), + }); + + let _ = crate::db::webauthn::store_challenge( + &state.inner.db, + result.csrf_token.secret(), + state_json, + "oidc", + None, + ) + .await; + + Ok(Redirect::temporary(result.auth_url.as_str())) +} + +#[derive(Deserialize)] +struct CallbackQuery { + code: String, + state: String, +} + +/// Handle OIDC callback after provider authentication. +async fn callback( + State(state): State, + Query(query): Query, +) -> (StatusCode, Json) { + // Retrieve and validate CSRF state + let (state_json, _) = + match crate::db::webauthn::consume_challenge(&state.inner.db, &query.state).await { + Ok(Some(data)) => data, + Ok(None) => { + return ( + StatusCode::BAD_REQUEST, + Json(json!({ "error": "Invalid or expired OIDC state" })), + ); + } + Err(e) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "error": e.to_string() })), + ); + } + }; + + let provider_id: Uuid = match state_json.get("provider_id").and_then(|v| v.as_str()) { + Some(id) => match Uuid::parse_str(id) { + Ok(u) => u, + Err(_) => { + return ( + StatusCode::BAD_REQUEST, + Json(json!({ "error": "Invalid provider ID in state" })), + ); + } + }, + None => { + return ( + StatusCode::BAD_REQUEST, + Json(json!({ "error": "Missing provider_id in state" })), + ); + } + }; + + // Load provider config + let provider = match crate::db::oidc::get_provider(&state.inner.db, provider_id).await { + Ok(Some(p)) => p, + _ => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "error": "Provider not found" })), + ); + } + }; + + // Decrypt client secret + let client_secret = if let Some(ref encrypted) = provider.client_secret_encrypted { + let vs = state.inner.vault_state.read().await; + if let Some(vmk) = vs.vmk() { + if encrypted.len() > 12 { + let (ct, nonce) = encrypted.split_at(encrypted.len() - 12); + vmk.decrypt(ct, nonce) + .ok() + .and_then(|b| String::from_utf8(b).ok()) + } else { + None + } + } else { + None + } + } else { + None + }; + + let rp_origin = + std::env::var("SAO_RP_ORIGIN").unwrap_or_else(|_| "http://localhost:3100".to_string()); + let redirect_url = format!("{}/api/auth/oidc/callback", rp_origin); + + let config = crate::auth::oidc::OidcProviderConfig { + id: provider.id, + name: provider.name.clone(), + issuer_url: provider.issuer_url, + client_id: provider.client_id, + client_secret, + scopes: provider.scopes, + }; + + // Exchange code for tokens + let user_info = + match crate::auth::oidc::exchange_code(&config, &redirect_url, &query.code).await { + Ok(info) => info, + Err(e) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "error": e })), + ); + } + }; + + // Find or create user + let user_id = + match crate::db::oidc::find_user_by_oidc(&state.inner.db, provider_id, &user_info.subject) + .await + { + Ok(Some(uid)) => uid, + Ok(None) => { + // Create new user from OIDC info + let username = user_info.email.as_deref().unwrap_or(&user_info.subject); + let display_name = user_info.name.as_deref().or(user_info.email.as_deref()); + + let uid = match crate::db::users::create_user( + &state.inner.db, + username, + display_name, + "user", + ) + .await + { + Ok(id) => id, + Err(e) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "error": format!("Failed to create user: {}", e) })), + ); + } + }; + + // Link OIDC identity + if let Err(e) = crate::db::oidc::link_user_to_oidc( + &state.inner.db, + uid, + provider_id, + &user_info.subject, + user_info.email.as_deref(), + ) + .await + { + tracing::error!("Failed to link OIDC identity: {}", e); + } + + uid + } + Err(e) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "error": e.to_string() })), + ); + } + }; + + // Load user for JWT + let user = match crate::db::users::get_user_by_id(&state.inner.db, user_id).await { + Ok(Some(u)) => u, + _ => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "error": "User not found after OIDC link" })), + ); + } + }; + + // Issue JWT + let access_token = match session::create_access_token( + user.id, + &user.username, + &user.role, + &state.inner.jwt_secret, + ) { + Ok(t) => t, + Err(e) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "error": format!("Failed to create token: {}", e) })), + ); + } + }; + + let refresh_token = session::generate_refresh_token(); + let refresh_hash = session::hash_refresh_token(&refresh_token); + let refresh_expires = Utc::now() + Duration::days(7); + + let _ = crate::db::sessions::store_refresh_token( + &state.inner.db, + user.id, + &refresh_hash, + refresh_expires, + ) + .await; + + // Audit log + let _ = crate::db::audit::insert_audit_log( + &state.inner.db, + Some(user.id), + None, + "auth.login", + Some("oidc"), + Some(json!({ "provider": provider.name })), + None, + None, + ) + .await; + + ( + StatusCode::OK, + Json(json!({ + "access_token": access_token, + "refresh_token": refresh_token, + "token_type": "Bearer", + "expires_in": 1800, + "user": { + "id": user.id, + "username": user.username, + "display_name": user.display_name, + "role": user.role, + }, + })), + ) +} diff --git a/crates/sao-server/src/routes/setup.rs b/crates/sao-server/src/routes/setup.rs new file mode 100644 index 0000000..484bc38 --- /dev/null +++ b/crates/sao-server/src/routes/setup.rs @@ -0,0 +1,160 @@ +use axum::{ + extract::State, + http::StatusCode, + routing::{get, post}, + Json, Router, +}; +use serde::Deserialize; +use serde_json::{json, Value}; + +use crate::state::AppState; + +pub fn routes() -> Router { + Router::new() + .route("/api/setup/status", get(setup_status)) + .route("/api/setup/initialize", post(initialize)) +} + +async fn setup_status(State(state): State) -> Json { + let vs = state.inner.vault_state.read().await; + let initialized = !matches!(*vs, crate::vault_state::VaultState::Uninitialized); + + // Also check if any users exist + let has_users = sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM users") + .fetch_one(&state.inner.db) + .await + .unwrap_or(0) + > 0; + + Json(json!({ + "initialized": initialized, + "has_users": has_users, + "needs_setup": !initialized || !has_users, + })) +} + +#[derive(Deserialize)] +struct InitializeRequest { + passphrase: String, + admin_username: String, + admin_display_name: Option, +} + +async fn initialize( + State(state): State, + Json(req): Json, +) -> (StatusCode, Json) { + // Check if already initialized + { + let vs = state.inner.vault_state.read().await; + if !matches!(*vs, crate::vault_state::VaultState::Uninitialized) { + return ( + StatusCode::CONFLICT, + Json(json!({ "error": "Vault already initialized" })), + ); + } + } + + if req.passphrase.len() < 8 { + return ( + StatusCode::BAD_REQUEST, + Json(json!({ "error": "Passphrase must be at least 8 characters" })), + ); + } + + // Generate VMK + let vmk = sao_core::vault::VaultMasterKey::generate(); + + // Derive passphrase key + let salt = sao_core::vault::generate_salt(); + let passphrase_key = match sao_core::vault::kdf::derive_key_default(&req.passphrase, &salt) { + Ok(key) => key, + Err(e) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "error": format!("KDF failed: {}", e) })), + ); + } + }; + + // Seal the VMK + let (sealed_ciphertext, sealed_nonce) = match vmk.seal(&passphrase_key) { + Ok(result) => result, + Err(e) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "error": format!("Failed to seal VMK: {}", e) })), + ); + } + }; + + // Store sealed VMK: combine ciphertext + nonce for storage + let mut sealed_envelope = sealed_ciphertext; + sealed_envelope.extend_from_slice(&sealed_nonce); + + if let Err(e) = crate::db::vault_key::store_vmk( + &state.inner.db, + &sealed_envelope, + &salt, + sao_core::vault::kdf::DEFAULT_MEMORY_COST as i32, + sao_core::vault::kdf::DEFAULT_TIME_COST as i32, + sao_core::vault::kdf::DEFAULT_PARALLELISM as i32, + ) + .await + { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "error": format!("Failed to store VMK: {}", e) })), + ); + } + + // Create admin user + let display_name = req + .admin_display_name + .unwrap_or_else(|| req.admin_username.clone()); + + let user_id = match crate::db::users::create_user( + &state.inner.db, + &req.admin_username, + Some(&display_name), + "admin", + ) + .await + { + Ok(id) => id, + Err(e) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "error": format!("Failed to create admin user: {}", e) })), + ); + } + }; + + // Transition vault state to Unsealed + { + let mut vs = state.inner.vault_state.write().await; + *vs = crate::vault_state::VaultState::Unsealed(vmk); + } + + // Audit log + let _ = crate::db::audit::insert_audit_log( + &state.inner.db, + Some(user_id), + None, + "setup.initialize", + Some("vault"), + Some(json!({ "admin_username": req.admin_username })), + None, + None, + ) + .await; + + ( + StatusCode::CREATED, + Json(json!({ + "status": "initialized", + "user_id": user_id, + "vault_status": "unsealed", + })), + ) +} diff --git a/crates/sao-server/src/routes/vault.rs b/crates/sao-server/src/routes/vault.rs new file mode 100644 index 0000000..7b61ed1 --- /dev/null +++ b/crates/sao-server/src/routes/vault.rs @@ -0,0 +1,389 @@ +use axum::{ + extract::{Path, State}, + http::StatusCode, + routing::{delete, get, post, put}, + Json, Router, +}; +use serde::Deserialize; +use serde_json::{json, Value}; +use uuid::Uuid; + +use crate::auth::middleware::AuthUser; +use crate::state::AppState; + +pub fn routes() -> Router { + Router::new() + .route("/api/vault/status", get(vault_status)) + .route("/api/vault/unseal", post(unseal_vault)) + .route("/api/vault/seal", post(seal_vault)) + .route("/api/vault/secrets", get(list_secrets)) + .route("/api/vault/secrets", post(create_secret)) + .route("/api/vault/secrets/{id}", get(get_secret)) + .route("/api/vault/secrets/{id}", put(update_secret)) + .route("/api/vault/secrets/{id}", delete(delete_secret)) +} + +async fn vault_status(State(state): State) -> Json { + let vs = state.inner.vault_state.read().await; + Json(json!({ + "status": vs.status_str(), + })) +} + +#[derive(Deserialize)] +struct UnsealRequest { + passphrase: String, +} + +async fn unseal_vault( + State(state): State, + Json(req): Json, +) -> (StatusCode, Json) { + let vmk_row = match crate::db::vault_key::get_vmk(&state.inner.db).await { + Ok(Some(row)) => row, + Ok(None) => { + return ( + StatusCode::BAD_REQUEST, + Json(json!({ "error": "Vault not initialized. Run setup first." })), + ); + } + Err(e) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "error": e.to_string() })), + ); + } + }; + + let passphrase_key = match sao_core::vault::kdf::derive_key_from_passphrase( + &req.passphrase, + &vmk_row.kdf_salt, + vmk_row.kdf_memory_cost as u32, + vmk_row.kdf_time_cost as u32, + vmk_row.kdf_parallelism as u32, + ) { + Ok(key) => key, + Err(e) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "error": format!("KDF failed: {}", e) })), + ); + } + }; + + let encrypted = &vmk_row.encrypted_key; + if encrypted.len() < 12 { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "error": "Corrupted VMK envelope" })), + ); + } + let (ciphertext, nonce) = encrypted.split_at(encrypted.len() - 12); + + match sao_core::vault::VaultMasterKey::unseal(ciphertext, nonce, &passphrase_key) { + Ok(vmk) => { + let mut vs = state.inner.vault_state.write().await; + *vs = crate::vault_state::VaultState::Unsealed(vmk); + (StatusCode::OK, Json(json!({ "status": "unsealed" }))) + } + Err(e) => (StatusCode::UNAUTHORIZED, Json(json!({ "error": e }))), + } +} + +async fn seal_vault(_user: AuthUser, State(state): State) -> Json { + let mut vs = state.inner.vault_state.write().await; + if vs.is_unsealed() { + *vs = crate::vault_state::VaultState::Sealed; + Json(json!({ "status": "sealed" })) + } else { + Json(json!({ "status": vs.status_str() })) + } +} + +#[derive(Deserialize)] +struct CreateSecretRequest { + secret_type: String, + label: String, + provider: Option, + value: String, + metadata: Option, +} + +async fn create_secret( + user: AuthUser, + State(state): State, + Json(req): Json, +) -> (StatusCode, Json) { + let vs = state.inner.vault_state.read().await; + let vmk = match vs.vmk() { + Some(vmk) => vmk, + None => { + return ( + StatusCode::SERVICE_UNAVAILABLE, + Json(json!({ "error": "Vault is sealed" })), + ); + } + }; + + let (ciphertext, nonce) = match vmk.encrypt(req.value.as_bytes()) { + Ok(result) => result, + Err(e) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "error": format!("Encryption failed: {}", e) })), + ); + } + }; + + match crate::db::vault::create_secret( + &state.inner.db, + Some(user.user_id), + &req.secret_type, + &req.label, + req.provider.as_deref(), + &ciphertext, + &nonce, + req.metadata, + ) + .await + { + Ok(id) => { + let _ = crate::db::audit::insert_audit_log( + &state.inner.db, + Some(user.user_id), + None, + "vault.create_secret", + Some("vault_secret"), + Some(json!({ "secret_id": id, "label": req.label })), + None, + None, + ) + .await; + (StatusCode::CREATED, Json(json!({ "id": id }))) + } + Err(e) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "error": e.to_string() })), + ), + } +} + +async fn list_secrets(user: AuthUser, State(state): State) -> (StatusCode, Json) { + // Users see only their own secrets; admins see all + let owner_filter = if user.is_admin() { + None + } else { + Some(user.user_id) + }; + + match crate::db::vault::list_secrets_metadata(&state.inner.db, owner_filter).await { + Ok(secrets) => (StatusCode::OK, Json(json!({ "secrets": secrets }))), + Err(e) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "error": e.to_string() })), + ), + } +} + +async fn get_secret( + user: AuthUser, + State(state): State, + Path(id): Path, +) -> (StatusCode, Json) { + let vs = state.inner.vault_state.read().await; + let vmk = match vs.vmk() { + Some(vmk) => vmk, + None => { + return ( + StatusCode::SERVICE_UNAVAILABLE, + Json(json!({ "error": "Vault is sealed" })), + ); + } + }; + + let secret = match crate::db::vault::get_secret(&state.inner.db, id).await { + Ok(Some(s)) => s, + Ok(None) => { + return ( + StatusCode::NOT_FOUND, + Json(json!({ "error": "Secret not found" })), + ); + } + Err(e) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "error": e.to_string() })), + ); + } + }; + + // Ownership check: non-admins can only access their own secrets + if !user.is_admin() && secret.owner_user_id != Some(user.user_id) { + return ( + StatusCode::FORBIDDEN, + Json(json!({ "error": "Access denied" })), + ); + } + + let _ = crate::db::audit::insert_audit_log( + &state.inner.db, + Some(user.user_id), + None, + "vault.read_secret", + Some("vault_secret"), + Some(json!({ "secret_id": id })), + None, + None, + ) + .await; + + match vmk.decrypt(&secret.ciphertext, &secret.nonce) { + Ok(plaintext) => { + let value = String::from_utf8_lossy(&plaintext).to_string(); + ( + StatusCode::OK, + Json(json!({ + "id": secret.id, + "secret_type": secret.secret_type, + "label": secret.label, + "provider": secret.provider, + "value": value, + "metadata": secret.metadata, + "created_at": secret.created_at, + "updated_at": secret.updated_at, + })), + ) + } + Err(e) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "error": format!("Decryption failed: {}", e) })), + ), + } +} + +#[derive(Deserialize)] +struct UpdateSecretRequest { + label: Option, + value: Option, + metadata: Option, +} + +async fn update_secret( + user: AuthUser, + State(state): State, + Path(id): Path, + Json(req): Json, +) -> (StatusCode, Json) { + // Check ownership + if !user.is_admin() { + if let Ok(Some(secret)) = crate::db::vault::get_secret(&state.inner.db, id).await { + if secret.owner_user_id != Some(user.user_id) { + return ( + StatusCode::FORBIDDEN, + Json(json!({ "error": "Access denied" })), + ); + } + } + } + + let (new_ct, new_nonce) = if let Some(ref value) = req.value { + let vs = state.inner.vault_state.read().await; + let vmk = match vs.vmk() { + Some(vmk) => vmk, + None => { + return ( + StatusCode::SERVICE_UNAVAILABLE, + Json(json!({ "error": "Vault is sealed" })), + ); + } + }; + match vmk.encrypt(value.as_bytes()) { + Ok((ct, nonce)) => (Some(ct), Some(nonce)), + Err(e) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "error": format!("Encryption failed: {}", e) })), + ); + } + } + } else { + (None, None) + }; + + match crate::db::vault::update_secret( + &state.inner.db, + id, + req.label.as_deref(), + new_ct.as_deref(), + new_nonce.as_deref(), + req.metadata, + ) + .await + { + Ok(true) => { + let _ = crate::db::audit::insert_audit_log( + &state.inner.db, + Some(user.user_id), + None, + "vault.update_secret", + Some("vault_secret"), + Some(json!({ "secret_id": id })), + None, + None, + ) + .await; + (StatusCode::OK, Json(json!({ "updated": true }))) + } + Ok(false) => ( + StatusCode::NOT_FOUND, + Json(json!({ "error": "Secret not found" })), + ), + Err(e) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "error": e.to_string() })), + ), + } +} + +async fn delete_secret( + user: AuthUser, + State(state): State, + Path(id): Path, +) -> (StatusCode, Json) { + // Check ownership + if !user.is_admin() { + if let Ok(Some(secret)) = crate::db::vault::get_secret(&state.inner.db, id).await { + if secret.owner_user_id != Some(user.user_id) { + return ( + StatusCode::FORBIDDEN, + Json(json!({ "error": "Access denied" })), + ); + } + } + } + + match crate::db::vault::delete_secret(&state.inner.db, id).await { + Ok(true) => { + let _ = crate::db::audit::insert_audit_log( + &state.inner.db, + Some(user.user_id), + None, + "vault.delete_secret", + Some("vault_secret"), + Some(json!({ "secret_id": id })), + None, + None, + ) + .await; + (StatusCode::OK, Json(json!({ "deleted": true }))) + } + Ok(false) => ( + StatusCode::NOT_FOUND, + Json(json!({ "error": "Secret not found" })), + ), + Err(e) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "error": e.to_string() })), + ), + } +} diff --git a/crates/sao-server/src/state.rs b/crates/sao-server/src/state.rs index 7dfda13..87d0e80 100644 --- a/crates/sao-server/src/state.rs +++ b/crates/sao-server/src/state.rs @@ -1,8 +1,13 @@ //! Application state for sao-server. use sao_core::IdentityManager; +use sqlx::PgPool; use std::path::PathBuf; use std::sync::Arc; +use tokio::sync::RwLock; +use webauthn_rs::Webauthn; + +use crate::vault_state::VaultState; /// Shared application state for the SAO orchestration server. #[derive(Clone)] @@ -10,13 +15,20 @@ pub struct AppState { pub inner: Arc, } +#[allow(dead_code)] pub struct AppStateInner { pub identity_manager: Arc, pub active_agent_id: std::sync::RwLock>, /// WebSocket broadcast channel for streaming events to connected agents pub ws_tx: tokio::sync::broadcast::Sender, - /// PostgreSQL connection pool (optional) - pub db: crate::db::DbPool, + /// PostgreSQL connection pool + pub db: PgPool, + /// Vault seal state + pub vault_state: RwLock, + /// WebAuthn relying party + pub webauthn: Arc, + /// JWT signing secret + pub jwt_secret: [u8; 32], } /// Events sent to WebSocket clients. @@ -27,15 +39,18 @@ pub struct WsEvent { } /// Initialize the SAO application state. -pub fn init_app_state(db: crate::db::DbPool) -> AppState { +pub fn init_app_state( + db: PgPool, + vault_state: VaultState, + webauthn: Webauthn, + jwt_secret: [u8; 32], +) -> AppState { let data_root = default_data_root(); - let identity_manager = Arc::new( - IdentityManager::new(data_root.clone()).unwrap_or_else(|e| { - tracing::error!("Failed to initialize IdentityManager: {}", e); - panic!("IdentityManager initialization failed: {}", e); - }), - ); + let identity_manager = Arc::new(IdentityManager::new(data_root.clone()).unwrap_or_else(|e| { + tracing::error!("Failed to initialize IdentityManager: {}", e); + panic!("IdentityManager initialization failed: {}", e); + })); let (ws_tx, _) = tokio::sync::broadcast::channel::(256); @@ -45,6 +60,9 @@ pub fn init_app_state(db: crate::db::DbPool) -> AppState { active_agent_id: std::sync::RwLock::new(None), ws_tx, db, + vault_state: RwLock::new(vault_state), + webauthn: Arc::new(webauthn), + jwt_secret, }), } } diff --git a/crates/sao-server/src/vault_state.rs b/crates/sao-server/src/vault_state.rs new file mode 100644 index 0000000..6d5ae7b --- /dev/null +++ b/crates/sao-server/src/vault_state.rs @@ -0,0 +1,33 @@ +use sao_core::vault::VaultMasterKey; + +/// Vault seal state. +pub enum VaultState { + /// No VMK has been initialized yet (first-run). + Uninitialized, + /// VMK exists in DB but is not loaded into memory. + Sealed, + /// VMK is loaded and ready for encrypt/decrypt operations. + Unsealed(VaultMasterKey), +} + +impl VaultState { + pub fn is_unsealed(&self) -> bool { + matches!(self, VaultState::Unsealed(_)) + } + + pub fn status_str(&self) -> &'static str { + match self { + VaultState::Uninitialized => "uninitialized", + VaultState::Sealed => "sealed", + VaultState::Unsealed(_) => "unsealed", + } + } + + /// Get a reference to the VMK if unsealed. + pub fn vmk(&self) -> Option<&VaultMasterKey> { + match self { + VaultState::Unsealed(vmk) => Some(vmk), + _ => None, + } + } +} diff --git a/crates/sao-server/src/ws.rs b/crates/sao-server/src/ws.rs index 6d5a95a..dfd9849 100644 --- a/crates/sao-server/src/ws.rs +++ b/crates/sao-server/src/ws.rs @@ -39,7 +39,7 @@ async fn handle_socket(socket: WebSocket, agent_id: String, state: AppState) { let send_task = tokio::spawn(async move { while let Ok(event) = rx.recv().await { let msg = serde_json::to_string(&event).unwrap_or_default(); - if sender.send(Message::Text(msg.into())).await.is_err() { + if sender.send(Message::Text(msg)).await.is_err() { break; } } diff --git a/docker/Dockerfile b/docker/Dockerfile index adcbd4c..0eacf0c 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -1,6 +1,16 @@ # SAO - Secure Agent Orchestrator # Multi-stage build for minimal production image +# Stage 1: Build frontend +FROM node:20-slim AS frontend-builder + +WORKDIR /app/frontend +COPY frontend/package.json frontend/package-lock.json* ./ +RUN npm install +COPY frontend/ . +RUN npm run build + +# Stage 2: Build Rust backend FROM rust:1.77-slim AS builder RUN apt-get update && apt-get install -y pkg-config libssl-dev && rm -rf /var/lib/apt/lists/* @@ -10,11 +20,12 @@ WORKDIR /app # Copy workspace files COPY Cargo.toml rust-toolchain.toml ./ COPY crates/ crates/ +COPY migrations/ migrations/ -# Build release binary with PostgreSQL support -RUN cargo build --release --bin sao-server --features postgres +# Build release binary +RUN cargo build --release --bin sao-server -# Runtime stage +# Stage 3: Runtime FROM debian:bookworm-slim RUN apt-get update && apt-get install -y \ @@ -25,9 +36,11 @@ RUN apt-get update && apt-get install -y \ && rm -rf /var/lib/apt/lists/* COPY --from=builder /app/target/release/sao-server /usr/local/bin/sao-server +COPY --from=frontend-builder /app/frontend/dist /srv/sao/static ENV SAO_BIND_ADDR=0.0.0.0:3100 ENV SAO_DATA_DIR=/data/sao +ENV SAO_STATIC_DIR=/srv/sao/static ENV RUST_LOG=info EXPOSE 3100 diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 938608f..de43ffd 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -10,8 +10,11 @@ services: environment: - SAO_BIND_ADDR=0.0.0.0:3100 - SAO_DATA_DIR=/data/sao + - SAO_STATIC_DIR=/srv/sao/static - RUST_LOG=info - DATABASE_URL=postgres://sao:sao@db:5432/sao + - SAO_RP_ID=localhost + - SAO_RP_ORIGIN=http://localhost:3100 volumes: - sao-data:/data/sao depends_on: diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..1b4d44d --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + SAO - Secure Agent Orchestrator + + + +
+ + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..bcc7936 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,2562 @@ +{ + "name": "sao-frontend", + "version": "0.0.1", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "sao-frontend", + "version": "0.0.1", + "dependencies": { + "@simplewebauthn/browser": "^10.0.0", + "@tanstack/react-query": "^5.51.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router-dom": "^6.26.0" + }, + "devDependencies": { + "@types/react": "^18.3.3", + "@types/react-dom": "^18.3.0", + "@vitejs/plugin-react": "^4.3.1", + "autoprefixer": "^10.4.19", + "postcss": "^8.4.39", + "tailwindcss": "^3.4.6", + "typescript": "^5.5.3", + "vite": "^5.3.4" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz", + "integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==", + "dev": true, + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", + "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", + "dev": true, + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@remix-run/router": { + "version": "1.23.2", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.2.tgz", + "integrity": "sha512-Ic6m2U/rMjTkhERIa/0ZtXJP17QUi2CbWE7cqx4J58M8aA3QTfW+2UlQ4psvTX9IO1RfNVhK3pcpdjej7L+t2w==", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz", + "integrity": "sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.57.1.tgz", + "integrity": "sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.57.1.tgz", + "integrity": "sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.57.1.tgz", + "integrity": "sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.57.1.tgz", + "integrity": "sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.57.1.tgz", + "integrity": "sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.57.1.tgz", + "integrity": "sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.57.1.tgz", + "integrity": "sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.57.1.tgz", + "integrity": "sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.57.1.tgz", + "integrity": "sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.57.1.tgz", + "integrity": "sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.57.1.tgz", + "integrity": "sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.57.1.tgz", + "integrity": "sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.57.1.tgz", + "integrity": "sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.57.1.tgz", + "integrity": "sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.57.1.tgz", + "integrity": "sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.57.1.tgz", + "integrity": "sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.57.1.tgz", + "integrity": "sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.57.1.tgz", + "integrity": "sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.57.1.tgz", + "integrity": "sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.57.1.tgz", + "integrity": "sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.57.1.tgz", + "integrity": "sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.57.1.tgz", + "integrity": "sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.57.1.tgz", + "integrity": "sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.57.1.tgz", + "integrity": "sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@simplewebauthn/browser": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@simplewebauthn/browser/-/browser-10.0.0.tgz", + "integrity": "sha512-hG0JMZD+LiLUbpQcAjS4d+t4gbprE/dLYop/CkE01ugU/9sKXflxV5s0DRjdz3uNMFecatRfb4ZLG3XvF8m5zg==", + "dependencies": { + "@simplewebauthn/types": "^10.0.0" + } + }, + "node_modules/@simplewebauthn/types": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@simplewebauthn/types/-/types-10.0.0.tgz", + "integrity": "sha512-SFXke7xkgPRowY2E+8djKbdEznTVnD5R6GO7GPTthpHrokLvNKw8C3lFZypTxLI7KkCfGPfhtqB3d7OVGGa9jQ==", + "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info." + }, + "node_modules/@tanstack/query-core": { + "version": "5.90.20", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.20.tgz", + "integrity": "sha512-OMD2HLpNouXEfZJWcKeVKUgQ5n+n3A2JFmBaScpNDUqSrQSjiveC7dKMe53uJUg1nDG16ttFPz2xfilz6i2uVg==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.90.21", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.21.tgz", + "integrity": "sha512-0Lu6y5t+tvlTJMTO7oh5NSpJfpg/5D41LlThfepTixPYkJ0sE2Jj0m0f6yYqujBwIXlId87e234+MxG3D3g7kg==", + "dependencies": { + "@tanstack/query-core": "5.90.20" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "dev": true + }, + "node_modules/@types/react": { + "version": "18.3.28", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz", + "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", + "dev": true, + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "dev": true + }, + "node_modules/autoprefixer": { + "version": "10.4.24", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.24.tgz", + "integrity": "sha512-uHZg7N9ULTVbutaIsDRoUkoS8/h3bdsmVJYZ5l3wv8Cp/6UIIoRDm90hZ+BwxUj/hGBEzLxdHNSKuFpn8WOyZw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "browserslist": "^4.28.1", + "caniuse-lite": "^1.0.30001766", + "fraction.js": "^5.3.4", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.9.19", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz", + "integrity": "sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==", + "dev": true, + "bin": { + "baseline-browser-mapping": "dist/cli.js" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "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" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "dev": true, + "engines": { + "node": ">= 6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001770", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001770.tgz", + "integrity": "sha512-x/2CLQ1jHENRbHg5PSId2sXq1CIO1CISvwWAj027ltMVG2UNgW+w9oH2+HzgEIRFembL8bUlXtfbBHR1fCg2xw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ] + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "engines": { + "node": ">= 6" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/didyoumean": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "dev": true + }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "dev": true + }, + "node_modules/electron-to-chromium": { + "version": "1.5.286", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.286.tgz", + "integrity": "sha512-9tfDXhJ4RKFNerfjdCcZfufu49vg620741MNs26a9+bhLThdB+plgMeou98CAaHu/WATj2iHOOHTp1hWtABj2A==", + "dev": true + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dev": true, + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/fraction.js": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", + "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", + "dev": true, + "engines": { + "node": "*" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dev": true, + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "dev": true, + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/jiti": { + "version": "1.21.7", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", + "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", + "dev": true, + "bin": { + "jiti": "bin/jiti.js" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.27", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", + "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", + "dev": true + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "dev": true, + "engines": { + "node": ">= 6" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "engines": { + "node": ">= 6" + } + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-import": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", + "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "dev": true, + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-js": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz", + "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "camelcase-css": "^2.0.1" + }, + "engines": { + "node": "^12 || ^14 || >= 16" + }, + "peerDependencies": { + "postcss": "^8.4.21" + } + }, + "node_modules/postcss-load-config": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", + "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "lilconfig": "^3.1.1" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "jiti": ">=1.21.0", + "postcss": ">=8.0.9", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + }, + "postcss": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/postcss-nested": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", + "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "postcss-selector-parser": "^6.1.1" + }, + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", + "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "dev": true, + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-router": { + "version": "6.30.3", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.3.tgz", + "integrity": "sha512-XRnlbKMTmktBkjCLE8/XcZFlnHvr2Ltdr1eJX4idL55/9BbORzyZEaIkBFDhFGCEWBBItsVrDxwx3gnisMitdw==", + "dependencies": { + "@remix-run/router": "1.23.2" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8" + } + }, + "node_modules/react-router-dom": { + "version": "6.30.3", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.3.tgz", + "integrity": "sha512-pxPcv1AczD4vso7G4Z3TKcvlxK7g7TNt3/FNGMhfqyntocvYKj+GCatfigGDjbLozC4baguJ0ReCigoDJXb0ag==", + "dependencies": { + "@remix-run/router": "1.23.2", + "react-router": "6.30.3" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/read-cache": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", + "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "dev": true, + "dependencies": { + "pify": "^2.3.0" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/resolve": { + "version": "1.22.11", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", + "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", + "dev": true, + "dependencies": { + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.1.tgz", + "integrity": "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==", + "dev": true, + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.57.1", + "@rollup/rollup-android-arm64": "4.57.1", + "@rollup/rollup-darwin-arm64": "4.57.1", + "@rollup/rollup-darwin-x64": "4.57.1", + "@rollup/rollup-freebsd-arm64": "4.57.1", + "@rollup/rollup-freebsd-x64": "4.57.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.57.1", + "@rollup/rollup-linux-arm-musleabihf": "4.57.1", + "@rollup/rollup-linux-arm64-gnu": "4.57.1", + "@rollup/rollup-linux-arm64-musl": "4.57.1", + "@rollup/rollup-linux-loong64-gnu": "4.57.1", + "@rollup/rollup-linux-loong64-musl": "4.57.1", + "@rollup/rollup-linux-ppc64-gnu": "4.57.1", + "@rollup/rollup-linux-ppc64-musl": "4.57.1", + "@rollup/rollup-linux-riscv64-gnu": "4.57.1", + "@rollup/rollup-linux-riscv64-musl": "4.57.1", + "@rollup/rollup-linux-s390x-gnu": "4.57.1", + "@rollup/rollup-linux-x64-gnu": "4.57.1", + "@rollup/rollup-linux-x64-musl": "4.57.1", + "@rollup/rollup-openbsd-x64": "4.57.1", + "@rollup/rollup-openharmony-arm64": "4.57.1", + "@rollup/rollup-win32-arm64-msvc": "4.57.1", + "@rollup/rollup-win32-ia32-msvc": "4.57.1", + "@rollup/rollup-win32-x64-gnu": "4.57.1", + "@rollup/rollup-win32-x64-msvc": "4.57.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/sucrase": { + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", + "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", + "dev": true, + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "tinyglobby": "^0.2.11", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tailwindcss": { + "version": "3.4.19", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", + "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", + "dev": true, + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "arg": "^5.0.2", + "chokidar": "^3.6.0", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.3.2", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "jiti": "^1.21.7", + "lilconfig": "^3.1.3", + "micromatch": "^4.0.8", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.1.1", + "postcss": "^8.4.47", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", + "postcss-nested": "^6.2.0", + "postcss-selector-parser": "^6.1.2", + "resolve": "^1.22.8", + "sucrase": "^3.35.0" + }, + "bin": { + "tailwind": "lib/cli.js", + "tailwindcss": "lib/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..a0deed0 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,28 @@ +{ + "name": "sao-frontend", + "private": true, + "version": "0.0.1", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview" + }, + "dependencies": { + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router-dom": "^6.26.0", + "@simplewebauthn/browser": "^10.0.0", + "@tanstack/react-query": "^5.51.0" + }, + "devDependencies": { + "@types/react": "^18.3.3", + "@types/react-dom": "^18.3.0", + "@vitejs/plugin-react": "^4.3.1", + "autoprefixer": "^10.4.19", + "postcss": "^8.4.39", + "tailwindcss": "^3.4.6", + "typescript": "^5.5.3", + "vite": "^5.3.4" + } +} diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js new file mode 100644 index 0000000..2aa7205 --- /dev/null +++ b/frontend/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..6777356 --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,90 @@ +import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'; +import { useQuery } from '@tanstack/react-query'; +import { setupStatus } from './api/auth'; +import { useAuth } from './hooks/useAuth'; +import Layout from './components/Layout'; +import ProtectedRoute from './components/ProtectedRoute'; +import SetupWizard from './pages/SetupWizard'; +import Login from './pages/Login'; +import Dashboard from './pages/Dashboard'; +import VaultPage from './pages/VaultPage'; +import AgentsPage from './pages/AgentsPage'; +import AdminUsersPage from './pages/AdminUsersPage'; +import AdminSsoPage from './pages/AdminSsoPage'; +import AuditLogPage from './pages/AuditLogPage'; + +function AppRoutes() { + const { isAuthenticated, isLoading: authLoading } = useAuth(); + + const { data: status, isLoading: setupLoading } = useQuery({ + queryKey: ['setup-status'], + queryFn: setupStatus, + staleTime: 60_000, + }); + + if (authLoading || setupLoading) { + return ( +
+
+
+

Loading SAO...

+
+
+ ); + } + + if (status?.needs_setup) { + return ( + + } /> + } /> + + ); + } + + return ( + + } /> + : + } /> + + + + } + > + } /> + } /> + } /> + } /> + + + + } + /> + + + + } + /> + + } /> + + ); +} + +export default function App() { + return ( + + + + ); +} diff --git a/frontend/src/api/admin.ts b/frontend/src/api/admin.ts new file mode 100644 index 0000000..52e6cd5 --- /dev/null +++ b/frontend/src/api/admin.ts @@ -0,0 +1,74 @@ +import { apiRequest } from './client'; +import type { + User, + OidcProvider, + AuditLogEntry, + CreateOidcProviderData, + UpdateOidcProviderData, + AuditLogParams, +} from '../types'; + +export async function listUsers(): Promise { + return apiRequest('/api/admin/users'); +} + +export async function updateUserRole( + id: string, + role: 'user' | 'admin', +): Promise { + return apiRequest(`/api/admin/users/${id}`, { + method: 'PUT', + body: JSON.stringify({ role }), + }); +} + +export async function deleteUser(id: string): Promise { + return apiRequest(`/api/admin/users/${id}`, { + method: 'DELETE', + }); +} + +export async function createOidcProvider( + data: CreateOidcProviderData, +): Promise { + return apiRequest('/api/admin/sso', { + method: 'POST', + body: JSON.stringify(data), + }); +} + +export async function listOidcProviders(): Promise { + return apiRequest('/api/admin/sso'); +} + +export async function updateOidcProvider( + id: string, + data: UpdateOidcProviderData, +): Promise { + return apiRequest(`/api/admin/sso/${id}`, { + method: 'PUT', + body: JSON.stringify(data), + }); +} + +export async function deleteOidcProvider(id: string): Promise { + return apiRequest(`/api/admin/sso/${id}`, { + method: 'DELETE', + }); +} + +export async function queryAuditLog( + params?: AuditLogParams, +): Promise { + const searchParams = new URLSearchParams(); + if (params?.user_id) searchParams.set('user_id', params.user_id); + if (params?.offset !== undefined) + searchParams.set('offset', String(params.offset)); + if (params?.limit !== undefined) + searchParams.set('limit', String(params.limit)); + + const qs = searchParams.toString(); + return apiRequest( + `/api/admin/audit${qs ? `?${qs}` : ''}`, + ); +} diff --git a/frontend/src/api/agents.ts b/frontend/src/api/agents.ts new file mode 100644 index 0000000..649776e --- /dev/null +++ b/frontend/src/api/agents.ts @@ -0,0 +1,23 @@ +import { apiRequest } from './client'; +import type { Agent } from '../types'; + +export async function listAgents(): Promise { + return apiRequest('/api/agents'); +} + +export async function createAgent(name: string): Promise { + return apiRequest('/api/agents', { + method: 'POST', + body: JSON.stringify({ name }), + }); +} + +export async function getAgent(id: string): Promise { + return apiRequest(`/api/agents/${id}`); +} + +export async function deleteAgent(id: string): Promise { + return apiRequest(`/api/agents/${id}`, { + method: 'DELETE', + }); +} diff --git a/frontend/src/api/auth.ts b/frontend/src/api/auth.ts new file mode 100644 index 0000000..9910d3a --- /dev/null +++ b/frontend/src/api/auth.ts @@ -0,0 +1,79 @@ +import { apiRequest } from './client'; +import type { AuthTokens, SetupStatus, User } from '../types'; + +export async function setupStatus(): Promise { + return apiRequest('/api/setup/status'); +} + +export async function initializeVault( + passphrase: string, + admin_username: string, + admin_display_name?: string, +): Promise { + return apiRequest('/api/setup/initialize', { + method: 'POST', + body: JSON.stringify({ + passphrase, + admin_username, + admin_display_name: admin_display_name || admin_username, + }), + }); +} + +export async function webauthnRegisterStart( + username: string, +): Promise<{ challenge_id: string; options: PublicKeyCredentialCreationOptions }> { + return apiRequest('/api/auth/webauthn/register/begin', { + method: 'POST', + body: JSON.stringify({ username }), + }); +} + +export async function webauthnRegisterFinish( + challenge_id: string, + credential: unknown, +): Promise { + return apiRequest('/api/auth/webauthn/register/complete', { + method: 'POST', + body: JSON.stringify({ challenge_id, credential }), + }); +} + +export async function webauthnLoginStart( + username: string, +): Promise<{ challenge_id: string; options: PublicKeyCredentialRequestOptions }> { + return apiRequest('/api/auth/webauthn/login/begin', { + method: 'POST', + body: JSON.stringify({ username }), + }); +} + +export async function webauthnLoginFinish( + challenge_id: string, + credential: unknown, +): Promise { + return apiRequest('/api/auth/webauthn/login/complete', { + method: 'POST', + body: JSON.stringify({ challenge_id, credential }), + }); +} + +export async function refreshToken( + refresh_token: string, +): Promise { + return apiRequest('/api/auth/refresh', { + method: 'POST', + body: JSON.stringify({ refresh_token }), + }); +} + +export async function logout(refresh_token: string): Promise { + return apiRequest('/api/auth/logout', { + method: 'POST', + body: JSON.stringify({ refresh_token }), + }); +} + +export async function getMe(): Promise { + return apiRequest('/api/auth/me'); +} diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts new file mode 100644 index 0000000..d0de064 --- /dev/null +++ b/frontend/src/api/client.ts @@ -0,0 +1,128 @@ +const BASE_URL = import.meta.env.VITE_API_BASE_URL || ''; + +let isRefreshing = false; +let refreshSubscribers: Array<(token: string) => void> = []; + +function onRefreshed(newToken: string) { + refreshSubscribers.forEach((cb) => cb(newToken)); + refreshSubscribers = []; +} + +function addRefreshSubscriber(cb: (token: string) => void) { + refreshSubscribers.push(cb); +} + +async function attemptTokenRefresh(): Promise { + const refreshToken = localStorage.getItem('sao_refresh_token'); + if (!refreshToken) return null; + + try { + const res = await fetch(`${BASE_URL}/api/auth/refresh`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ refresh_token: refreshToken }), + }); + + if (!res.ok) return null; + + const data = await res.json(); + localStorage.setItem('sao_access_token', data.access_token); + if (data.refresh_token) { + localStorage.setItem('sao_refresh_token', data.refresh_token); + } + return data.access_token; + } catch { + return null; + } +} + +export async function apiRequest( + path: string, + options: RequestInit = {}, +): Promise { + const token = localStorage.getItem('sao_access_token'); + + const headers: Record = { + 'Content-Type': 'application/json', + ...(options.headers as Record), + }; + + if (token) { + headers['Authorization'] = `Bearer ${token}`; + } + + const res = await fetch(`${BASE_URL}${path}`, { + ...options, + headers, + }); + + if (res.status === 401 && token) { + if (!isRefreshing) { + isRefreshing = true; + const newToken = await attemptTokenRefresh(); + isRefreshing = false; + + if (newToken) { + onRefreshed(newToken); + headers['Authorization'] = `Bearer ${newToken}`; + const retryRes = await fetch(`${BASE_URL}${path}`, { + ...options, + headers, + }); + if (!retryRes.ok) { + const errorBody = await retryRes.text(); + throw new ApiError(retryRes.status, errorBody); + } + return retryRes.json(); + } else { + localStorage.removeItem('sao_access_token'); + localStorage.removeItem('sao_refresh_token'); + window.location.href = '/login'; + throw new ApiError(401, 'Session expired'); + } + } else { + return new Promise((resolve, reject) => { + addRefreshSubscriber(async (newToken: string) => { + try { + headers['Authorization'] = `Bearer ${newToken}`; + const retryRes = await fetch(`${BASE_URL}${path}`, { + ...options, + headers, + }); + if (!retryRes.ok) { + const errorBody = await retryRes.text(); + reject(new ApiError(retryRes.status, errorBody)); + return; + } + resolve(retryRes.json()); + } catch (err) { + reject(err); + } + }); + }); + } + } + + if (!res.ok) { + const errorBody = await res.text(); + throw new ApiError(res.status, errorBody); + } + + if (res.status === 204) { + return undefined as T; + } + + return res.json(); +} + +export class ApiError extends Error { + status: number; + body: string; + + constructor(status: number, body: string) { + super(`API error ${status}: ${body}`); + this.name = 'ApiError'; + this.status = status; + this.body = body; + } +} diff --git a/frontend/src/api/vault.ts b/frontend/src/api/vault.ts new file mode 100644 index 0000000..efde004 --- /dev/null +++ b/frontend/src/api/vault.ts @@ -0,0 +1,55 @@ +import { apiRequest } from './client'; +import type { + VaultStatus, + VaultSecret, + CreateSecretData, + UpdateSecretData, +} from '../types'; + +export async function getVaultStatus(): Promise { + return apiRequest('/api/vault/status'); +} + +export async function unsealVault(passphrase: string): Promise { + return apiRequest('/api/vault/unseal', { + method: 'POST', + body: JSON.stringify({ passphrase }), + }); +} + +export async function sealVault(): Promise { + return apiRequest('/api/vault/seal', { + method: 'POST', + }); +} + +export async function listSecrets(): Promise { + return apiRequest('/api/keys'); +} + +export async function createSecret(data: CreateSecretData): Promise { + return apiRequest('/api/keys', { + method: 'POST', + body: JSON.stringify(data), + }); +} + +export async function getSecret(id: string): Promise { + return apiRequest(`/api/keys/${id}`); +} + +export async function updateSecret( + id: string, + data: UpdateSecretData, +): Promise { + return apiRequest(`/api/keys/${id}`, { + method: 'PUT', + body: JSON.stringify(data), + }); +} + +export async function deleteSecret(id: string): Promise { + return apiRequest(`/api/keys/${id}`, { + method: 'DELETE', + }); +} diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx new file mode 100644 index 0000000..162a5bb --- /dev/null +++ b/frontend/src/components/Layout.tsx @@ -0,0 +1,102 @@ +import { NavLink, Outlet, useNavigate } from 'react-router-dom'; +import { useAuth } from '../hooks/useAuth'; + +function SidebarLink({ + to, + label, + icon, +}: { + to: string; + label: string; + icon: string; +}) { + return ( + + `flex items-center gap-3 px-4 py-2.5 rounded-lg text-sm font-medium transition-colors ${ + isActive + ? 'bg-blue-600 text-white' + : 'text-gray-300 hover:bg-gray-700 hover:text-white' + }` + } + > + {icon} + {label} + + ); +} + +export default function Layout() { + const { user, isAdmin, logout } = useAuth(); + const navigate = useNavigate(); + + const handleLogout = async () => { + await logout(); + navigate('/login'); + }; + + return ( +
+ {/* Sidebar */} + + + {/* Main content */} +
+
+ +
+
+
+ ); +} diff --git a/frontend/src/components/ProtectedRoute.tsx b/frontend/src/components/ProtectedRoute.tsx new file mode 100644 index 0000000..55adef9 --- /dev/null +++ b/frontend/src/components/ProtectedRoute.tsx @@ -0,0 +1,33 @@ +import { Navigate, useLocation } from 'react-router-dom'; +import { useAuth } from '../hooks/useAuth'; + +interface ProtectedRouteProps { + children: React.ReactNode; + admin?: boolean; +} + +export default function ProtectedRoute({ + children, + admin = false, +}: ProtectedRouteProps) { + const { isAuthenticated, isAdmin, isLoading } = useAuth(); + const location = useLocation(); + + if (isLoading) { + return ( +
+
Loading...
+
+ ); + } + + if (!isAuthenticated) { + return ; + } + + if (admin && !isAdmin) { + return ; + } + + return <>{children}; +} diff --git a/frontend/src/hooks/useAuth.ts b/frontend/src/hooks/useAuth.ts new file mode 100644 index 0000000..38fd148 --- /dev/null +++ b/frontend/src/hooks/useAuth.ts @@ -0,0 +1,101 @@ +import { + createContext, + useContext, + useState, + useEffect, + useCallback, + type ReactNode, +} from 'react'; +import { createElement } from 'react'; +import type { User, AuthTokens } from '../types'; +import { getMe, logout as apiLogout } from '../api/auth'; + +interface AuthContextValue { + user: User | null; + isAuthenticated: boolean; + isAdmin: boolean; + isLoading: boolean; + login: (tokens: AuthTokens) => Promise; + logout: () => Promise; + setUser: (user: User | null) => void; +} + +const AuthContext = createContext(undefined); + +export function AuthProvider({ children }: { children: ReactNode }) { + const [user, setUser] = useState(null); + const [isLoading, setIsLoading] = useState(true); + + const isAuthenticated = user !== null; + const isAdmin = user?.role === 'admin'; + + const fetchUser = useCallback(async () => { + const token = localStorage.getItem('sao_access_token'); + if (!token) { + setIsLoading(false); + return; + } + + try { + const me = await getMe(); + setUser(me); + } catch { + localStorage.removeItem('sao_access_token'); + localStorage.removeItem('sao_refresh_token'); + setUser(null); + } finally { + setIsLoading(false); + } + }, []); + + useEffect(() => { + fetchUser(); + }, [fetchUser]); + + const login = useCallback(async (tokens: AuthTokens) => { + localStorage.setItem('sao_access_token', tokens.access_token); + localStorage.setItem('sao_refresh_token', tokens.refresh_token); + try { + const me = await getMe(); + setUser(me); + } catch { + localStorage.removeItem('sao_access_token'); + localStorage.removeItem('sao_refresh_token'); + throw new Error('Failed to fetch user after login'); + } + }, []); + + const logout = useCallback(async () => { + const refreshToken = localStorage.getItem('sao_refresh_token'); + if (refreshToken) { + try { + await apiLogout(refreshToken); + } catch { + // Ignore logout errors + } + } + localStorage.removeItem('sao_access_token'); + localStorage.removeItem('sao_refresh_token'); + setUser(null); + }, []); + + const value: AuthContextValue = { + user, + isAuthenticated, + isAdmin, + isLoading, + login, + logout, + setUser, + }; + + return createElement(AuthContext.Provider, { value }, children); +} + +export function useAuth(): AuthContextValue { + const ctx = useContext(AuthContext); + if (ctx === undefined) { + throw new Error('useAuth must be used within an AuthProvider'); + } + return ctx; +} diff --git a/frontend/src/hooks/useVault.ts b/frontend/src/hooks/useVault.ts new file mode 100644 index 0000000..ded9c82 --- /dev/null +++ b/frontend/src/hooks/useVault.ts @@ -0,0 +1,42 @@ +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { getVaultStatus, unsealVault, sealVault } from '../api/vault'; +import type { VaultStatus } from '../types'; +import { useCallback } from 'react'; + +export function useVault() { + const queryClient = useQueryClient(); + + const { + data: vaultStatus, + isLoading, + error, + } = useQuery({ + queryKey: ['vault-status'], + queryFn: getVaultStatus, + refetchInterval: 30_000, + }); + + const unseal = useCallback( + async (passphrase: string) => { + await unsealVault(passphrase); + await queryClient.invalidateQueries({ queryKey: ['vault-status'] }); + }, + [queryClient], + ); + + const seal = useCallback(async () => { + await sealVault(); + await queryClient.invalidateQueries({ queryKey: ['vault-status'] }); + }, [queryClient]); + + return { + vaultStatus, + isLoading, + error, + unseal, + seal, + isSealed: vaultStatus?.status === 'sealed', + isUnsealed: vaultStatus?.status === 'unsealed', + isUninitialized: vaultStatus?.status === 'uninitialized', + }; +} diff --git a/frontend/src/index.css b/frontend/src/index.css new file mode 100644 index 0000000..13fb957 --- /dev/null +++ b/frontend/src/index.css @@ -0,0 +1,18 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +body { + margin: 0; + padding: 0; + background-color: #111827; + color: #f3f4f6; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, + 'Helvetica Neue', Arial, sans-serif; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +#root { + min-height: 100vh; +} diff --git a/frontend/src/lib/webauthn.ts b/frontend/src/lib/webauthn.ts new file mode 100644 index 0000000..7b399df --- /dev/null +++ b/frontend/src/lib/webauthn.ts @@ -0,0 +1,20 @@ +import { + startRegistration, + startAuthentication, +} from '@simplewebauthn/browser'; +import type { + PublicKeyCredentialCreationOptionsJSON, + PublicKeyCredentialRequestOptionsJSON, +} from '@simplewebauthn/types'; + +export async function beginRegistration( + options: PublicKeyCredentialCreationOptionsJSON, +) { + return startRegistration(options); +} + +export async function beginAuthentication( + options: PublicKeyCredentialRequestOptionsJSON, +) { + return startAuthentication(options); +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx new file mode 100644 index 0000000..dd12eff --- /dev/null +++ b/frontend/src/main.tsx @@ -0,0 +1,26 @@ +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import App from './App'; +import { AuthProvider } from './hooks/useAuth'; +import './index.css'; + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: 1, + refetchOnWindowFocus: false, + staleTime: 30_000, + }, + }, +}); + +ReactDOM.createRoot(document.getElementById('root')!).render( + + + + + + + , +); diff --git a/frontend/src/pages/AdminSsoPage.tsx b/frontend/src/pages/AdminSsoPage.tsx new file mode 100644 index 0000000..e4f8147 --- /dev/null +++ b/frontend/src/pages/AdminSsoPage.tsx @@ -0,0 +1,331 @@ +import { useState } from 'react'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { + listOidcProviders, + createOidcProvider, + updateOidcProvider, + deleteOidcProvider, +} from '../api/admin'; +import type { OidcProvider, CreateOidcProviderData } from '../types'; + +const emptyForm: CreateOidcProviderData = { + name: '', + issuer_url: '', + client_id: '', + client_secret: '', + scopes: 'openid profile email', +}; + +export default function AdminSsoPage() { + const queryClient = useQueryClient(); + const [showAdd, setShowAdd] = useState(false); + const [form, setForm] = useState({ ...emptyForm }); + const [error, setError] = useState(''); + const [saving, setSaving] = useState(false); + const [togglingId, setTogglingId] = useState(null); + const [confirmDeleteId, setConfirmDeleteId] = useState(null); + const [deletingId, setDeletingId] = useState(null); + + const { data: providers, isLoading } = useQuery({ + queryKey: ['oidc-providers'], + queryFn: listOidcProviders, + }); + + const handleAdd = async () => { + setError(''); + if (!form.name.trim()) { + setError('Provider name is required'); + return; + } + if (!form.issuer_url.trim()) { + setError('Issuer URL is required'); + return; + } + if (!form.client_id.trim()) { + setError('Client ID is required'); + return; + } + if (!form.client_secret.trim()) { + setError('Client Secret is required'); + return; + } + + setSaving(true); + try { + await createOidcProvider(form); + await queryClient.invalidateQueries({ queryKey: ['oidc-providers'] }); + setForm({ ...emptyForm }); + setShowAdd(false); + } catch (err) { + setError( + err instanceof Error ? err.message : 'Failed to add provider', + ); + } finally { + setSaving(false); + } + }; + + const handleToggle = async (provider: OidcProvider) => { + setTogglingId(provider.id); + try { + await updateOidcProvider(provider.id, { enabled: !provider.enabled }); + await queryClient.invalidateQueries({ queryKey: ['oidc-providers'] }); + } catch (err) { + setError( + err instanceof Error + ? err.message + : 'Failed to update provider', + ); + } finally { + setTogglingId(null); + } + }; + + const handleDelete = async (id: string) => { + setDeletingId(id); + try { + await deleteOidcProvider(id); + await queryClient.invalidateQueries({ queryKey: ['oidc-providers'] }); + setConfirmDeleteId(null); + } catch (err) { + setError( + err instanceof Error + ? err.message + : 'Failed to delete provider', + ); + } finally { + setDeletingId(null); + } + }; + + return ( +
+
+
+

SSO Configuration

+

+ Manage OIDC identity providers for single sign-on +

+
+ +
+ + {error && ( +
+

{error}

+
+ )} + + {/* Add Provider Form */} + {showAdd && ( +
+

+ Add OIDC Provider +

+
+
+ + setForm({ ...form, name: e.target.value })} + placeholder="e.g., Microsoft Entra ID" + className="w-full px-3 py-2 bg-gray-700 border border-gray-600 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:border-blue-500" + /> +
+
+ + + setForm({ ...form, issuer_url: e.target.value }) + } + placeholder="https://login.microsoftonline.com/{tenant}/v2.0" + className="w-full px-3 py-2 bg-gray-700 border border-gray-600 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:border-blue-500" + /> +
+
+ + + setForm({ ...form, client_id: e.target.value }) + } + placeholder="Application (client) ID" + className="w-full px-3 py-2 bg-gray-700 border border-gray-600 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:border-blue-500" + /> +
+
+ + + setForm({ ...form, client_secret: e.target.value }) + } + placeholder="Client secret value" + className="w-full px-3 py-2 bg-gray-700 border border-gray-600 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:border-blue-500" + /> +
+
+ + + setForm({ ...form, scopes: e.target.value }) + } + placeholder="openid profile email" + className="w-full px-3 py-2 bg-gray-700 border border-gray-600 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:border-blue-500" + /> +
+
+ +
+ + +
+
+ )} + + {/* Provider List */} + {isLoading ? ( +
+ Loading providers... +
+ ) : providers && providers.length > 0 ? ( +
+ {providers.map((provider: OidcProvider) => ( +
+
+
+
+

+ {provider.name} +

+ + {provider.enabled ? 'Enabled' : 'Disabled'} + +
+ {provider.issuer_url && ( +

+ {provider.issuer_url} +

+ )} + {provider.client_id && ( +

+ Client ID: {provider.client_id} +

+ )} + {provider.scopes && ( +

+ Scopes: {provider.scopes} +

+ )} +
+ +
+ {/* Enable/Disable Toggle */} + + + {/* Delete */} + {confirmDeleteId === provider.id ? ( +
+ + +
+ ) : ( + + )} +
+
+
+ ))} +
+ ) : ( +
+

+ No SSO providers configured +

+ +
+ )} +
+ ); +} diff --git a/frontend/src/pages/AdminUsersPage.tsx b/frontend/src/pages/AdminUsersPage.tsx new file mode 100644 index 0000000..0f9fdb3 --- /dev/null +++ b/frontend/src/pages/AdminUsersPage.tsx @@ -0,0 +1,157 @@ +import { useState } from 'react'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { listUsers, updateUserRole, deleteUser } from '../api/admin'; +import { useAuth } from '../hooks/useAuth'; +import type { User } from '../types'; + +export default function AdminUsersPage() { + const { user: currentUser } = useAuth(); + const queryClient = useQueryClient(); + const [error, setError] = useState(''); + const [updatingId, setUpdatingId] = useState(null); + const [deletingId, setDeletingId] = useState(null); + const [confirmDeleteId, setConfirmDeleteId] = useState(null); + + const { data: users, isLoading } = useQuery({ + queryKey: ['users'], + queryFn: listUsers, + }); + + const handleRoleChange = async (userId: string, role: 'user' | 'admin') => { + setError(''); + setUpdatingId(userId); + try { + await updateUserRole(userId, role); + await queryClient.invalidateQueries({ queryKey: ['users'] }); + } catch (err) { + setError( + err instanceof Error ? err.message : 'Failed to update role', + ); + } finally { + setUpdatingId(null); + } + }; + + const handleDelete = async (userId: string) => { + setError(''); + setDeletingId(userId); + try { + await deleteUser(userId); + await queryClient.invalidateQueries({ queryKey: ['users'] }); + setConfirmDeleteId(null); + } catch (err) { + setError( + err instanceof Error ? err.message : 'Failed to delete user', + ); + } finally { + setDeletingId(null); + } + }; + + return ( +
+

User Management

+ + {error && ( +
+

{error}

+
+ )} + + {isLoading ? ( +
+ Loading users... +
+ ) : users && users.length > 0 ? ( +
+
+ + + + + + + + + + + + {users.map((user: User) => { + const isSelf = user.id === currentUser?.id; + return ( + + + + + + + + ); + })} + +
UsernameDisplay NameRoleCreatedActions
+ {user.username} + {isSelf && ( + + (you) + + )} + + {user.display_name} + + + + {new Date(user.created_at).toLocaleDateString()} + + {isSelf ? ( + -- + ) : confirmDeleteId === user.id ? ( +
+ + +
+ ) : ( + + )} +
+
+
+ ) : ( +
+ No users found +
+ )} +
+ ); +} diff --git a/frontend/src/pages/AgentsPage.tsx b/frontend/src/pages/AgentsPage.tsx new file mode 100644 index 0000000..b44d0fd --- /dev/null +++ b/frontend/src/pages/AgentsPage.tsx @@ -0,0 +1,217 @@ +import { useState } from 'react'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { listAgents, createAgent, deleteAgent } from '../api/agents'; +import type { Agent } from '../types'; + +export default function AgentsPage() { + const queryClient = useQueryClient(); + const [showRegister, setShowRegister] = useState(false); + const [newAgentName, setNewAgentName] = useState(''); + const [error, setError] = useState(''); + const [creating, setCreating] = useState(false); + const [deletingId, setDeletingId] = useState(null); + const [confirmDeleteId, setConfirmDeleteId] = useState(null); + + const { data: agents, isLoading } = useQuery({ + queryKey: ['agents'], + queryFn: listAgents, + }); + + const handleRegister = async () => { + setError(''); + if (!newAgentName.trim()) { + setError('Agent name is required'); + return; + } + + setCreating(true); + try { + await createAgent(newAgentName.trim()); + await queryClient.invalidateQueries({ queryKey: ['agents'] }); + setNewAgentName(''); + setShowRegister(false); + } catch (err) { + setError( + err instanceof Error ? err.message : 'Failed to register agent', + ); + } finally { + setCreating(false); + } + }; + + const handleDelete = async (id: string) => { + setDeletingId(id); + try { + await deleteAgent(id); + await queryClient.invalidateQueries({ queryKey: ['agents'] }); + setConfirmDeleteId(null); + } catch (err) { + setError( + err instanceof Error ? err.message : 'Failed to delete agent', + ); + } finally { + setDeletingId(null); + } + }; + + const stateColor = (state: string) => { + switch (state) { + case 'active': + case 'online': + return 'bg-green-500'; + case 'inactive': + case 'offline': + return 'bg-gray-500'; + case 'error': + return 'bg-red-500'; + default: + return 'bg-yellow-500'; + } + }; + + return ( +
+
+

Agents

+ +
+ + {/* Register Agent Form */} + {showRegister && ( +
+

+ Register New Agent +

+
+ setNewAgentName(e.target.value)} + placeholder="Agent name (e.g., abigail-main)" + onKeyDown={(e) => e.key === 'Enter' && handleRegister()} + className="flex-1 px-3 py-2 bg-gray-700 border border-gray-600 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:border-blue-500" + /> + + +
+ {error &&

{error}

} +
+ )} + + {/* Agent List */} + {isLoading ? ( +
+ Loading agents... +
+ ) : agents && agents.length > 0 ? ( +
+ {agents.map((agent: Agent) => ( +
+
+
+

{agent.name}

+

+ {agent.id} +

+
+
+ + + {agent.state} + +
+
+ + {agent.capabilities.length > 0 && ( +
+ {agent.capabilities.map((cap) => ( + + {cap} + + ))} +
+ )} + + {agent.public_key && ( +
+

Public Key

+

+ {agent.public_key} +

+
+ )} + +
+ + Created {new Date(agent.created_at).toLocaleDateString()} + + {confirmDeleteId === agent.id ? ( +
+ + +
+ ) : ( + + )} +
+
+ ))} +
+ ) : ( +
+

No agents registered yet

+ +
+ )} +
+ ); +} diff --git a/frontend/src/pages/AuditLogPage.tsx b/frontend/src/pages/AuditLogPage.tsx new file mode 100644 index 0000000..9de5cbf --- /dev/null +++ b/frontend/src/pages/AuditLogPage.tsx @@ -0,0 +1,168 @@ +import { useState, useCallback } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { queryAuditLog } from '../api/admin'; +import type { AuditLogEntry } from '../types'; + +const PAGE_SIZE = 25; + +export default function AuditLogPage() { + const [filterUserId, setFilterUserId] = useState(''); + const [offset, setOffset] = useState(0); + + const { data: entries, isLoading } = useQuery({ + queryKey: ['audit-log', filterUserId, offset], + queryFn: () => + queryAuditLog({ + user_id: filterUserId || undefined, + offset, + limit: PAGE_SIZE, + }), + }); + + const handleFilter = useCallback(() => { + setOffset(0); + }, []); + + const handleClearFilter = useCallback(() => { + setFilterUserId(''); + setOffset(0); + }, []); + + const formatTime = (ts: string) => { + const d = new Date(ts); + return d.toLocaleString(); + }; + + const actionColor = (action: string) => { + if (action.startsWith('create') || action.startsWith('register')) + return 'text-green-400 bg-green-900/30 border-green-800'; + if (action.startsWith('delete') || action.startsWith('revoke')) + return 'text-red-400 bg-red-900/30 border-red-800'; + if (action.startsWith('update') || action.startsWith('rotate')) + return 'text-yellow-400 bg-yellow-900/30 border-yellow-800'; + if (action.startsWith('login') || action.startsWith('auth')) + return 'text-blue-400 bg-blue-900/30 border-blue-800'; + return 'text-gray-400 bg-gray-700 border-gray-600'; + }; + + return ( +
+

Audit Log

+ + {/* Filters */} +
+
+
+ + setFilterUserId(e.target.value)} + placeholder="User ID (optional)" + className="w-full px-3 py-1.5 text-sm bg-gray-700 border border-gray-600 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:border-blue-500" + /> +
+ + {filterUserId && ( + + )} +
+
+ + {/* Table */} + {isLoading ? ( +
+ Loading audit log... +
+ ) : entries && entries.length > 0 ? ( + <> +
+
+ + + + + + + + + + + + {entries.map((entry: AuditLogEntry) => ( + + + + + + + + ))} + +
ActionResourceUser / AgentDetailsTime
+ + {entry.action} + + + {entry.resource} + + {entry.user_id + ? `user:${entry.user_id.slice(0, 8)}...` + : entry.agent_id + ? `agent:${entry.agent_id.slice(0, 8)}...` + : '--'} + + {entry.details || '--'} + + {formatTime(entry.created_at)} +
+
+
+ + {/* Pagination */} +
+
+ Showing {offset + 1} - {offset + entries.length} +
+
+ + +
+
+ + ) : ( +
+ {filterUserId + ? 'No audit log entries found for this filter' + : 'No audit log entries yet'} +
+ )} +
+ ); +} diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx new file mode 100644 index 0000000..ec4b6b3 --- /dev/null +++ b/frontend/src/pages/Dashboard.tsx @@ -0,0 +1,199 @@ +import { useState } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { useVault } from '../hooks/useVault'; +import { listSecrets } from '../api/vault'; +import { listAgents } from '../api/agents'; +import { queryAuditLog } from '../api/admin'; +import { listUsers } from '../api/admin'; +import { useAuth } from '../hooks/useAuth'; +import type { AuditLogEntry } from '../types'; + +export default function Dashboard() { + const { isAdmin } = useAuth(); + const { vaultStatus, isSealed, unseal } = useVault(); + const [passphrase, setPassphrase] = useState(''); + const [unsealError, setUnsealError] = useState(''); + const [unsealLoading, setUnsealLoading] = useState(false); + + const { data: secrets } = useQuery({ + queryKey: ['secrets'], + queryFn: listSecrets, + enabled: vaultStatus?.status === 'unsealed', + }); + + const { data: agents } = useQuery({ + queryKey: ['agents'], + queryFn: listAgents, + }); + + const { data: users } = useQuery({ + queryKey: ['users'], + queryFn: listUsers, + enabled: isAdmin, + }); + + const { data: recentAudit } = useQuery({ + queryKey: ['audit-recent'], + queryFn: () => queryAuditLog({ limit: 10 }), + enabled: isAdmin, + }); + + const handleUnseal = async () => { + setUnsealError(''); + setUnsealLoading(true); + try { + await unseal(passphrase); + setPassphrase(''); + } catch (err) { + setUnsealError( + err instanceof Error ? err.message : 'Failed to unseal vault', + ); + } finally { + setUnsealLoading(false); + } + }; + + const formatTime = (ts: string) => { + const d = new Date(ts); + return d.toLocaleString(); + }; + + return ( +
+

Dashboard

+ +
+ {/* Vault Status Card */} +
+

+ Vault Status +

+
+ + + {vaultStatus?.status || 'Unknown'} + +
+ {isSealed && ( +
+ setPassphrase(e.target.value)} + placeholder="Vault passphrase" + onKeyDown={(e) => e.key === 'Enter' && handleUnseal()} + className="w-full px-3 py-1.5 text-sm bg-gray-700 border border-gray-600 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:border-blue-500" + /> + + {unsealError && ( +

{unsealError}

+ )} +
+ )} +
+ + {/* Secrets Count */} +
+

+ Stored Secrets +

+

+ {vaultStatus?.status === 'unsealed' + ? (secrets?.length ?? '...') + : '--'} +

+

+ {vaultStatus?.status === 'unsealed' + ? 'Keys, tokens, and credentials' + : 'Unseal vault to view'} +

+
+ + {/* Agents Count */} +
+

+ Registered Agents +

+

+ {agents?.length ?? '...'} +

+

+ Connected orchestrated agents +

+
+ + {/* Users Count (Admin Only) */} + {isAdmin && ( +
+

+ Users +

+

+ {users?.length ?? '...'} +

+

+ Registered user accounts +

+
+ )} +
+ + {/* Recent Audit Log */} + {isAdmin && recentAudit && recentAudit.length > 0 && ( +
+
+

+ Recent Activity +

+
+
+ + + + + + + + + + + {recentAudit.map((entry: AuditLogEntry) => ( + + + + + + + ))} + +
ActionResourceDetailsTime
+ + {entry.action} + + + {entry.resource} + + {entry.details || '--'} + + {formatTime(entry.created_at)} +
+
+
+ )} +
+ ); +} diff --git a/frontend/src/pages/Login.tsx b/frontend/src/pages/Login.tsx new file mode 100644 index 0000000..523b3cd --- /dev/null +++ b/frontend/src/pages/Login.tsx @@ -0,0 +1,149 @@ +import { useState, useEffect } from 'react'; +import { useNavigate, useLocation } from 'react-router-dom'; +import { useAuth } from '../hooks/useAuth'; +import { webauthnLoginStart, webauthnLoginFinish } from '../api/auth'; +import { listOidcProviders } from '../api/admin'; +import { beginAuthentication } from '../lib/webauthn'; +import type { OidcProvider } from '../types'; + +export default function Login() { + const navigate = useNavigate(); + const location = useLocation(); + const { login } = useAuth(); + + const [username, setUsername] = useState(''); + const [error, setError] = useState(''); + const [loading, setLoading] = useState(false); + const [oidcProviders, setOidcProviders] = useState([]); + + const from = (location.state as { from?: { pathname: string } })?.from?.pathname || '/'; + + useEffect(() => { + listOidcProviders() + .then((providers) => + setOidcProviders(providers.filter((p) => p.enabled)), + ) + .catch(() => { + // OIDC providers not available, that is fine + }); + }, []); + + const handleWebAuthnLogin = async () => { + setError(''); + if (!username.trim()) { + setError('Please enter your username'); + return; + } + + setLoading(true); + try { + const { challenge_id, options } = await webauthnLoginStart( + username.trim(), + ); + const credential = await beginAuthentication(options as never); + const tokens = await webauthnLoginFinish(challenge_id, credential); + await login(tokens); + navigate(from, { replace: true }); + } catch (err) { + setError( + err instanceof Error + ? err.message + : 'Authentication failed. Please try again.', + ); + } finally { + setLoading(false); + } + }; + + const handleOidcLogin = (providerId: string) => { + window.location.href = `/api/auth/oidc/${providerId}/login`; + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + handleWebAuthnLogin(); + } + }; + + return ( +
+
+
+ {/* Header */} +
+

SAO

+

+ Sign in to Secure Agent Orchestrator +

+
+ + {/* Content */} +
+ {/* Username */} +
+ + setUsername(e.target.value)} + onKeyDown={handleKeyDown} + placeholder="Enter your username" + autoFocus + className="w-full px-3 py-2 bg-gray-700 border border-gray-600 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500" + /> +
+ + {/* WebAuthn Login */} + + + {error && ( +
+

{error}

+
+ )} + + {/* OIDC Providers */} + {oidcProviders.length > 0 && ( + <> +
+
+ + or continue with + +
+
+ +
+ {oidcProviders.map((provider) => ( + + ))} +
+ + )} +
+
+
+
+ ); +} diff --git a/frontend/src/pages/SetupWizard.tsx b/frontend/src/pages/SetupWizard.tsx new file mode 100644 index 0000000..5c33983 --- /dev/null +++ b/frontend/src/pages/SetupWizard.tsx @@ -0,0 +1,254 @@ +import { useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { useQueryClient } from '@tanstack/react-query'; +import { initializeVault } from '../api/auth'; + +type Step = 'welcome' | 'passphrase' | 'admin' | 'initializing' | 'complete'; + +export default function SetupWizard() { + const navigate = useNavigate(); + const queryClient = useQueryClient(); + + const [step, setStep] = useState('welcome'); + const [passphrase, setPassphrase] = useState(''); + const [passphraseConfirm, setPassphraseConfirm] = useState(''); + const [adminUsername, setAdminUsername] = useState(''); + const [adminDisplayName, setAdminDisplayName] = useState(''); + const [error, setError] = useState(''); + + const handlePassphraseNext = () => { + setError(''); + if (passphrase.length < 8) { + setError('Passphrase must be at least 8 characters'); + return; + } + if (passphrase !== passphraseConfirm) { + setError('Passphrases do not match'); + return; + } + setStep('admin'); + }; + + const handleInitialize = async () => { + setError(''); + if (!adminUsername.trim()) { + setError('Username is required'); + return; + } + + setStep('initializing'); + + try { + await initializeVault( + passphrase, + adminUsername.trim(), + adminDisplayName.trim() || undefined, + ); + await queryClient.invalidateQueries({ queryKey: ['setup-status'] }); + setStep('complete'); + } catch (err) { + setStep('admin'); + setError( + err instanceof Error ? err.message : 'Initialization failed', + ); + } + }; + + const handleGoToLogin = () => { + navigate('/login'); + }; + + return ( +
+
+ {/* Card */} +
+ {/* Header */} +
+

SAO Setup

+

+ Secure Agent Orchestrator - First Run Configuration +

+
+ + {/* Content */} +
+ {/* Step: Welcome */} + {step === 'welcome' && ( +
+

+ Welcome to the Secure Agent Orchestrator. This wizard will + guide you through the initial setup: +

+
    +
  • Set a vault passphrase for encrypting all secrets
  • +
  • Create the initial administrator account
  • +
  • Initialize the master signing key
  • +
+

+ Keep the vault passphrase safe. It is required to unseal the + vault after restarts. +

+ +
+ )} + + {/* Step: Passphrase */} + {step === 'passphrase' && ( +
+
+ + setPassphrase(e.target.value)} + placeholder="Minimum 8 characters" + className="w-full px-3 py-2 bg-gray-700 border border-gray-600 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500" + /> +
+
+ + setPassphraseConfirm(e.target.value)} + placeholder="Re-enter passphrase" + className="w-full px-3 py-2 bg-gray-700 border border-gray-600 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500" + /> +
+ {error && ( +

{error}

+ )} +
+ + +
+
+ )} + + {/* Step: Admin Account */} + {step === 'admin' && ( +
+
+ + setAdminUsername(e.target.value)} + placeholder="admin" + className="w-full px-3 py-2 bg-gray-700 border border-gray-600 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500" + /> +
+
+ + setAdminDisplayName(e.target.value)} + placeholder="Administrator" + className="w-full px-3 py-2 bg-gray-700 border border-gray-600 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500" + /> +
+ {error && ( +

{error}

+ )} +
+ + +
+
+ )} + + {/* Step: Initializing */} + {step === 'initializing' && ( +
+
+

Initializing vault...

+

+ Generating master key and encrypting vault +

+
+ )} + + {/* Step: Complete */} + {step === 'complete' && ( +
+
[OK]
+

+ Setup Complete +

+

+ SAO has been initialized. You can now log in with your + admin account. +

+ +
+ )} +
+ + {/* Progress indicator */} +
+
+ {['welcome', 'passphrase', 'admin', 'complete'].map( + (s, i) => ( +
= i + ? 'bg-blue-600' + : 'bg-gray-700' + }`} + /> + ), + )} +
+
+
+
+
+ ); +} diff --git a/frontend/src/pages/VaultPage.tsx b/frontend/src/pages/VaultPage.tsx new file mode 100644 index 0000000..58879ae --- /dev/null +++ b/frontend/src/pages/VaultPage.tsx @@ -0,0 +1,536 @@ +import { useState } from 'react'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { useVault } from '../hooks/useVault'; +import { + listSecrets, + createSecret, + getSecret, + updateSecret, + deleteSecret, +} from '../api/vault'; +import type { VaultSecret, CreateSecretData } from '../types'; + +const SECRET_TYPES = [ + { value: 'api_key', label: 'API Key' }, + { value: 'ed25519', label: 'Ed25519 Key' }, + { value: 'gpg', label: 'GPG Key' }, + { value: 'oauth_token', label: 'OAuth Token' }, + { value: 'password', label: 'Password' }, + { value: 'other', label: 'Other' }, +]; + +interface ModalProps { + onClose: () => void; + children: React.ReactNode; + title: string; +} + +function Modal({ onClose, children, title }: ModalProps) { + return ( +
+
+
+

{title}

+ +
+
{children}
+
+
+ ); +} + +function AddSecretModal({ onClose }: { onClose: () => void }) { + const queryClient = useQueryClient(); + const [formData, setFormData] = useState({ + secret_type: 'api_key', + label: '', + provider: '', + value: '', + }); + const [error, setError] = useState(''); + const [saving, setSaving] = useState(false); + + const handleSubmit = async () => { + setError(''); + if (!formData.label.trim()) { + setError('Label is required'); + return; + } + if (!formData.value.trim()) { + setError('Value is required'); + return; + } + + setSaving(true); + try { + await createSecret(formData); + await queryClient.invalidateQueries({ queryKey: ['secrets'] }); + onClose(); + } catch (err) { + setError( + err instanceof Error ? err.message : 'Failed to create secret', + ); + } finally { + setSaving(false); + } + }; + + return ( + +
+
+ + +
+
+ + + setFormData({ ...formData, label: e.target.value }) + } + placeholder="e.g., OpenAI Production Key" + className="w-full px-3 py-2 bg-gray-700 border border-gray-600 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:border-blue-500" + /> +
+
+ + + setFormData({ ...formData, provider: e.target.value }) + } + placeholder="e.g., openai, anthropic, github" + className="w-full px-3 py-2 bg-gray-700 border border-gray-600 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:border-blue-500" + /> +
+
+ +