diff --git a/CHANGELOG.md b/CHANGELOG.md index 1fdad7da..44c100b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,54 @@ target. ## [Unreleased] +## [3.9.0] — 2026-10-20 + +The Add to ThinkWatch Lite link from a rotated key now carries the key's +request limits. A database failure during authentication answers 503 or +500 with the usual JSON error instead of an empty 500, and password +hashing no longer runs on the request workers. Monitoring that matches +only on 500 sees a database outage during authentication as a 503 from +this release on. The thinkwatch-core crates stay at v0.77.0. + +No setting, schema or API route changes. + +### Added + +- **The rotated-key dialog's Add to ThinkWatch Lite link carries the key's + request limits.** The key's own enabled, unexpired AI-gateway rules that + count requests over a window of whole minutes up to 60, or whole hours + up to 24 (the console's 1 minute, 5 minutes, 1 hour, 5 hours and 24 + hours among them), go into the link's `limits` parameter as Lite's + rolling windows of the same length; of two rules on one window the + smaller count goes, and at most 16 windows, those allowing the fewest + requests per second. Token rules stay out, since they count weighted + tokens and Lite counts plain ones; so do sliding 7-day rules (Lite's + week is a calendar week), budgets, MCP rules and the owner's limits. + A link with limits needs ThinkWatch Lite 2026.10.26 or later, which the + dialog states under the button. Reading the rules needs + `rate_limits:read` in a scope covering the key; without it, or when the + rules cannot be read, the link is built without limits as before. A new + key has no limits of its own yet, so its link carries none. + +### Fixed + +- **A database outage during authentication is a 503 with an error body, + not an empty 500.** When the session or API-key check could not reach + Postgres (most often a connection-pool timeout), the console and the + gateway answered `500 Internal Server Error` with no body and, for the + permission lookup, no log line. These lookups now answer like every + handler does: `503 Service Unavailable` with the usual JSON error when + the database is unavailable, `500` with it for any other database + error, and an error log naming the lookup that failed. +- **Password hashing no longer holds up other requests.** Logins, + password changes and resets, new users, turning off two-factor + authentication and first-run setup ran Argon2 (tens of milliseconds of + CPU by design) on the server's async workers, so every other request + scheduled on that worker waited for it. They now run it on the blocking + thread pool, at most one run per CPU at a time as before, so a burst of + logins holds no more memory than it did (19 MiB per run). The hashes + and the constant-time login are unchanged. + ## [3.8.3] — 2026-10-20 Tool-call inspection in streamed answers now decides only on arguments @@ -2489,7 +2537,8 @@ unreleased builds should: stop the gateway, run `db/schema.sql` against PostgreSQL, restart against this tag. The schema is idempotent end-to-end, so the apply is safe to repeat. -[Unreleased]: https://github.com/ThinkWatchProject/ThinkWatch/compare/v3.8.3...HEAD +[Unreleased]: https://github.com/ThinkWatchProject/ThinkWatch/compare/v3.9.0...HEAD +[3.9.0]: https://github.com/ThinkWatchProject/ThinkWatch/releases/tag/v3.9.0 [3.8.3]: https://github.com/ThinkWatchProject/ThinkWatch/releases/tag/v3.8.3 [3.8.2]: https://github.com/ThinkWatchProject/ThinkWatch/releases/tag/v3.8.2 [3.8.1]: https://github.com/ThinkWatchProject/ThinkWatch/releases/tag/v3.8.1 diff --git a/Cargo.lock b/Cargo.lock index f570a7f4..f34d5d3c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4189,7 +4189,7 @@ dependencies = [ [[package]] name = "think-watch-auth" -version = "3.8.3" +version = "3.9.0" dependencies = [ "anyhow", "argon2", @@ -4219,7 +4219,7 @@ dependencies = [ [[package]] name = "think-watch-common" -version = "3.8.3" +version = "3.9.0" dependencies = [ "aes-gcm", "anyhow", @@ -4262,7 +4262,7 @@ dependencies = [ [[package]] name = "think-watch-gateway" -version = "3.8.3" +version = "3.9.0" dependencies = [ "anyhow", "arc-swap", @@ -4301,7 +4301,7 @@ dependencies = [ [[package]] name = "think-watch-mcp-gateway" -version = "3.8.3" +version = "3.9.0" dependencies = [ "anyhow", "arc-swap", @@ -4330,7 +4330,7 @@ dependencies = [ [[package]] name = "think-watch-server" -version = "3.8.3" +version = "3.9.0" dependencies = [ "anyhow", "arc-swap", @@ -4381,7 +4381,7 @@ dependencies = [ [[package]] name = "think-watch-test-support" -version = "3.8.3" +version = "3.9.0" dependencies = [ "anyhow", "async-stream", diff --git a/Cargo.toml b/Cargo.toml index 48e44b71..e582ef98 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,7 +10,7 @@ members = [ ] [workspace.package] -version = "3.8.3" +version = "3.9.0" edition = "2024" # The MSRV: the newest `rust-version` among the locked dependencies # (sqlx 0.9 and the aws-smithy crates under aws-sigv4). Without it, diff --git a/crates/auth/src/password.rs b/crates/auth/src/password.rs index 6e62b3b4..4a2c3dae 100644 --- a/crates/auth/src/password.rs +++ b/crates/auth/src/password.rs @@ -1,7 +1,10 @@ +use std::sync::{Arc, LazyLock}; + use argon2::{ Argon2, password_hash::{PasswordHasher, PasswordVerifier, phc::PasswordHash}, }; +use tokio::sync::Semaphore; const RANDOM_PASSWORD_LEN: usize = 16; const CHARSET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%&*"; @@ -52,10 +55,105 @@ pub fn verify_password_or_dummy(password: &str, hash: Option<&str>) -> bool { } } +// Argon2 costs tens of milliseconds of CPU on purpose. Called from a +// request handler it would hold an async worker for that long, stalling +// every other request scheduled on that thread, so the handlers use +// these versions, which run it on the blocking pool. + +/// How many Argon2 runs go at once: one per CPU, as many as the async +/// workers allowed when they ran it. Each run holds 19 MiB (`m=19456`) +/// while it lasts and the blocking pool grows to hundreds of threads, so +/// without this a burst of logins would hold gigabytes at once. +static ARGON2_PERMITS: LazyLock> = LazyLock::new(|| { + Arc::new(Semaphore::new( + std::thread::available_parallelism().map_or(1, |n| n.get()), + )) +}); + +/// `f` on the blocking pool once an [`ARGON2_PERMITS`] place is free. The +/// task keeps the place until it ends, even when the caller has gone. +async fn on_blocking_pool( + f: impl FnOnce() -> T + Send + 'static, +) -> Result { + // Never closed, so the acquire cannot fail; the permit inside the + // result is held all the same. + let permit = Arc::clone(&ARGON2_PERMITS).acquire_owned().await; + tokio::task::spawn_blocking(move || { + let _permit = permit; + f() + }) + .await +} + +/// [`hash_password`] on the blocking pool. +pub async fn hash_password_off_runtime(password: String) -> anyhow::Result { + on_blocking_pool(move || hash_password(&password)) + .await + .map_err(|e| anyhow::anyhow!("Password hashing task failed: {e}"))? +} + +/// [`verify_password`] on the blocking pool. +pub async fn verify_password_off_runtime(password: String, hash: String) -> anyhow::Result { + on_blocking_pool(move || verify_password(&password, &hash)) + .await + .map_err(|e| anyhow::anyhow!("Password verification task failed: {e}"))? +} + +/// [`verify_password_or_dummy`] on the blocking pool. A task that cannot +/// run counts as a mismatch, like an unreadable hash. +pub async fn verify_password_or_dummy_off_runtime(password: String, hash: Option) -> bool { + on_blocking_pool(move || verify_password_or_dummy(&password, hash.as_deref())) + .await + .unwrap_or(false) +} + #[cfg(test)] mod tests { use super::*; + #[tokio::test] + async fn the_off_runtime_versions_agree_with_the_blocking_ones() { + let hash = hash_password_off_runtime("pw".into()).await.unwrap(); + assert!( + verify_password_off_runtime("pw".into(), hash.clone()) + .await + .unwrap() + ); + assert!( + !verify_password_off_runtime("nope".into(), hash.clone()) + .await + .unwrap() + ); + assert!(verify_password_or_dummy_off_runtime("pw".into(), Some(hash)).await); + assert!(!verify_password_or_dummy_off_runtime("pw".into(), None).await); + } + + #[tokio::test(flavor = "multi_thread")] + async fn no_more_runs_at_once_than_there_are_permits() { + use std::sync::atomic::{AtomicUsize, Ordering}; + static RUNNING: AtomicUsize = AtomicUsize::new(0); + static PEAK: AtomicUsize = AtomicUsize::new(0); + let permits = std::thread::available_parallelism().map_or(1, |n| n.get()); + let tasks: Vec<_> = (0..permits * 4) + .map(|_| { + tokio::spawn(on_blocking_pool(|| { + let now = RUNNING.fetch_add(1, Ordering::SeqCst) + 1; + PEAK.fetch_max(now, Ordering::SeqCst); + std::thread::sleep(std::time::Duration::from_millis(20)); + RUNNING.fetch_sub(1, Ordering::SeqCst); + })) + }) + .collect(); + for task in tasks { + task.await.unwrap().unwrap(); + } + let peak = PEAK.load(Ordering::SeqCst); + assert!( + (1..=permits).contains(&peak), + "peak {peak}, permits {permits}" + ); + } + /// A hash written by argon2 0.5.3 (`Argon2::default()`, fixed salt). /// Stored hashes outlive crate upgrades: they must keep verifying. const KAT_HASH: &str = "$argon2id$v=19$m=19456,t=2,p=1$dGhpbmt3YXRjaC1rYXQtc2FsdA$6xffcgfh1b9KJMWg4mBS4qOWsK5MNUM1asLNHvgV0v8"; diff --git a/crates/server/src/handlers/admin/users.rs b/crates/server/src/handlers/admin/users.rs index 5a31d264..e304670f 100644 --- a/crates/server/src/handlers/admin/users.rs +++ b/crates/server/src/handlers/admin/users.rs @@ -458,7 +458,7 @@ pub async fn create_user( return Err(AppError::Conflict("Email already registered".into())); } - let password_hash = password::hash_password(&raw_password)?; + let password_hash = password::hash_password_off_runtime(raw_password.clone()).await?; let mut tx = state.db.begin().await?; @@ -920,7 +920,7 @@ pub async fn reset_user_password( } let new_password = password::generate_random_password(); - let hash = password::hash_password(&new_password)?; + let hash = password::hash_password_off_runtime(new_password.clone()).await?; user_repository::update_password_hash(&state.db, user_id, &hash, true).await?; diff --git a/crates/server/src/handlers/api_keys.rs b/crates/server/src/handlers/api_keys.rs index bfe70a7f..68cf3798 100644 --- a/crates/server/src/handlers/api_keys.rs +++ b/crates/server/src/handlers/api_keys.rs @@ -927,11 +927,10 @@ pub async fn rotate_key( .await; let grace_period_ends_at = chrono::Utc::now() + chrono::Duration::hours(grace_hours); - // Generate new key. Rate-limit / budget rules attached to the - // OLD key id are NOT copied here — they live in `rate_limit_rules` - // / `budget_caps` keyed by api_key UUID. The follow-up phase - // ("limits attached to api_keys") will add an explicit copy step - // when rotation happens. + // Generate new key. Nothing to copy for limits: the key's + // rate-limit rules and budgets are attached to its `lineage_id` + // (`api_key_lineage` subjects, see `think_watch_common::limits`), + // and the new key keeps that lineage, so they carry over as is. let generated = api_key::generate_api_key(); // The new key carries the old one's name verbatim — the chain is diff --git a/crates/server/src/handlers/auth.rs b/crates/server/src/handlers/auth.rs index 71d868e7..4be44119 100644 --- a/crates/server/src/handlers/auth.rs +++ b/crates/server/src/handlers/auth.rs @@ -497,10 +497,11 @@ pub async fn login( // Constant-time login: always run Argon2, against a dummy hash when // there is no account or it has no password, so the response time does // not tell which addresses have accounts. - let password_valid = password::verify_password_or_dummy( - &req.password, - user.as_ref().and_then(|u| u.password_hash.as_deref()), - ); + let password_valid = password::verify_password_or_dummy_off_runtime( + req.password.clone(), + user.as_ref().and_then(|u| u.password_hash.clone()), + ) + .await; if !password_valid || user.is_none() { // Per-email cross-IP failure counter. The `count` above is keyed @@ -1053,7 +1054,7 @@ pub async fn register( let email = normalize_email(&req.email); validate_email(&email)?; - let password_hash = password::hash_password(&req.password)?; + let password_hash = password::hash_password_off_runtime(req.password.clone()).await?; // Use a transaction to ensure user creation + role assignment are atomic let mut tx = state.db.begin().await?; @@ -1467,7 +1468,9 @@ pub async fn change_password( return Err(AppError::Unauthorized); } - if !password::verify_password(&req.old_password, current_hash)? { + if !password::verify_password_off_runtime(req.old_password.clone(), current_hash.to_owned()) + .await? + { let fails = crate::services::auth_lockout::record_failure(&state.redis, &pwchange_fail_key).await?; if let Some(secs) = crate::services::auth_lockout::ladder_secs(fails) { @@ -1481,7 +1484,7 @@ pub async fn change_password( crate::services::auth_lockout::clear(&state.redis, &[&pwchange_fail_key, &pwchange_locked_key]) .await; - let new_hash = password::hash_password(&req.new_password)?; + let new_hash = password::hash_password_off_runtime(req.new_password.clone()).await?; repo::set_own_password(&state.db, user.id, &new_hash).await?; // Revoke all signing public keys for this user (invalidates sessions) @@ -1832,7 +1835,7 @@ pub async fn totp_disable( let hash = user.password_hash.as_ref().ok_or(AppError::BadRequest( "SSO accounts cannot manage TOTP here".into(), ))?; - if !password::verify_password(&req.old_password, hash)? { + if !password::verify_password_off_runtime(req.old_password.clone(), hash.to_owned()).await? { return Err(AppError::Unauthorized); } diff --git a/crates/server/src/handlers/setup.rs b/crates/server/src/handlers/setup.rs index 0084f427..6254e643 100644 --- a/crates/server/src/handlers/setup.rs +++ b/crates/server/src/handlers/setup.rs @@ -145,7 +145,7 @@ pub async fn setup_initialize( // Create the super_admin user with the first API key, and mark // setup done. - let password_hash = password::hash_password(&req.admin.password)?; + let password_hash = password::hash_password_off_runtime(req.admin.password.clone()).await?; let generated = api_key::generate_api_key(); let site_name = req.site_name.as_deref().unwrap_or("ThinkWatch"); let admin_user = repo::create_first_admin( diff --git a/crates/server/src/middleware/api_key_auth.rs b/crates/server/src/middleware/api_key_auth.rs index 1016bba0..802e55c1 100644 --- a/crates/server/src/middleware/api_key_auth.rs +++ b/crates/server/src/middleware/api_key_auth.rs @@ -365,9 +365,16 @@ fn authenticate( .bind(&key_hash) .bind(global_inactivity_days) .fetch_optional(&state.db) - .await - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? - .ok_or(StatusCode::UNAUTHORIZED)?; + .await; + let row = match row { + Ok(row) => row.ok_or(StatusCode::UNAUTHORIZED)?, + Err(e) => { + return Ok(super::auth_guard::db_failure_response( + "Gateway API key lookup failed", + e, + )); + } + }; // Surface gate. Even a valid key gets rejected if its // `surfaces` list doesn't include the gateway being called. @@ -433,9 +440,15 @@ fn authenticate( // the aggregated `surface_constraints` JSON so the gateway // hot path has rate limits + budgets without further lookups. let (role_limits, user_roles, loaded_limits) = if let Some(uid) = row.user_id { - let limits = rbac::compute_user_resource_limits(&state.db, uid) - .await - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + let limits = match rbac::compute_user_resource_limits(&state.db, uid).await { + Ok(limits) => limits, + Err(e) => { + return Ok(super::auth_guard::db_failure_response( + "Loading the key owner's resource limits failed", + e, + )); + } + }; let names = rbac::load_user_role_names(&state.db, uid) .await .unwrap_or_default(); diff --git a/crates/server/src/middleware/auth_guard.rs b/crates/server/src/middleware/auth_guard.rs index c717c3b6..8d39340b 100644 --- a/crates/server/src/middleware/auth_guard.rs +++ b/crates/server/src/middleware/auth_guard.rs @@ -737,7 +737,7 @@ async fn forwarded_ip( async fn load_user_permissions_cached( state: &AppState, user_id: uuid::Uuid, -) -> Result<(Vec, Vec), anyhow::Error> { +) -> Result<(Vec, Vec), sqlx::Error> { use fred::interfaces::KeysInterface; let cache_key = format!("user_perms:{user_id}"); @@ -831,6 +831,15 @@ pub async fn invalidate_team_perms( } } +/// The response for a database failure inside an auth middleware: the +/// same mapping handlers use (`From for AppError`), so a +/// pool timeout is a 503 with a JSON body instead of a bare, empty 500. +/// `context` names the lookup that failed in the log line. +pub(crate) fn db_failure_response(context: &str, err: sqlx::Error) -> Response { + tracing::error!(error = %err, "{context}"); + AppError::from(err).into_response() +} + pub async fn require_auth( State(state): State, mut request: Request, @@ -931,16 +940,21 @@ pub async fn require_auth( // // The same lookup reads `totp_enabled` for the TOTP-requirement // gate further down, so enforcing it costs no extra query. - let user_row: Option<(bool, bool)> = sqlx::query_as( + let user_row: Option<(bool, bool)> = match sqlx::query_as( "SELECT is_active, totp_enabled FROM users WHERE id = $1 AND deleted_at IS NULL", ) .bind(claims.sub) .fetch_optional(&state.db) .await - .map_err(|e| { - tracing::error!(error = %e, "DB check for users.is_active failed"); - StatusCode::INTERNAL_SERVER_ERROR - })?; + { + Ok(row) => row, + Err(e) => { + return Ok(db_failure_response( + "DB check for users.is_active failed", + e, + )); + } + }; let Some((true, totp_enabled)) = user_row else { return Err(StatusCode::UNAUTHORIZED); }; @@ -959,9 +973,11 @@ pub async fn require_auth( .filter(|s| !s.is_empty()); // Load permissions from Redis cache (60s TTL) → DB fallback. - let (permissions, denied_permissions) = load_user_permissions_cached(&state, claims.sub) - .await - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + let (permissions, denied_permissions) = + match load_user_permissions_cached(&state, claims.sub).await { + Ok(loaded) => loaded, + Err(e) => return Ok(db_failure_response("Loading user permissions failed", e)), + }; if let Some(slot) = request .extensions() @@ -1066,9 +1082,11 @@ async fn auth_via_api_key( .bind(&key_hash) .bind(global_inactivity_days) .fetch_optional(&state.db) - .await - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? - .ok_or(StatusCode::UNAUTHORIZED)?; + .await; + let row = match row { + Ok(row) => row.ok_or(StatusCode::UNAUTHORIZED)?, + Err(e) => return Ok(db_failure_response("Console API key lookup failed", e)), + }; if !row.surfaces.iter().any(|s| s == "console") { return Err(StatusCode::FORBIDDEN); @@ -1104,21 +1122,32 @@ async fn auth_via_api_key( // Load current permissions from DB (not a snapshot like JWT claims). // This means permission changes take effect immediately for API key users. let all_perm_keys = crate::handlers::roles::all_permission_keys(); - let permissions = rbac::compute_user_permissions(&state.db, user_id, &all_perm_keys) - .await - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - let denied_permissions = rbac::compute_denied_permissions(&state.db, user_id, &permissions) - .await - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + let permissions = match rbac::compute_user_permissions(&state.db, user_id, &all_perm_keys).await + { + Ok(permissions) => permissions, + Err(e) => return Ok(db_failure_response("Loading user permissions failed", e)), + }; + let denied_permissions = + match rbac::compute_denied_permissions(&state.db, user_id, &permissions).await { + Ok(denied) => denied, + Err(e) => return Ok(db_failure_response("Loading user permissions failed", e)), + }; - let email = sqlx::query_scalar::<_, String>( + let email = match sqlx::query_scalar::<_, String>( "SELECT email FROM users WHERE id = $1 AND is_active = true AND deleted_at IS NULL", ) .bind(user_id) .fetch_optional(&state.db) .await - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? - .ok_or(StatusCode::UNAUTHORIZED)?; + { + Ok(email) => email.ok_or(StatusCode::UNAUTHORIZED)?, + Err(e) => { + return Ok(db_failure_response( + "Console API key owner lookup failed", + e, + )); + } + }; // Best-effort last_used_at update (same pattern as gateway path). let db = state.db.clone(); @@ -1233,3 +1262,31 @@ mod proxy_trust_tests { )); } } + +#[cfg(test)] +mod db_failure_tests { + use super::*; + + async fn status_and_body(err: sqlx::Error) -> (StatusCode, serde_json::Value) { + let resp = db_failure_response("lookup", err); + let status = resp.status(); + let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX) + .await + .unwrap(); + (status, serde_json::from_slice(&bytes).unwrap()) + } + + #[tokio::test] + async fn a_pool_timeout_is_a_503_with_an_error_body() { + let (status, body) = status_and_body(sqlx::Error::PoolTimedOut).await; + assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE); + assert_eq!(body["error"]["type"], "service_unavailable"); + } + + #[tokio::test] + async fn any_other_database_error_is_a_500_with_an_error_body() { + let (status, body) = status_and_body(sqlx::Error::RowNotFound).await; + assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR); + assert!(body["error"]["message"].is_string()); + } +} diff --git a/crates/test-support/src/client.rs b/crates/test-support/src/client.rs index 6a96c4d2..932042f9 100644 --- a/crates/test-support/src/client.rs +++ b/crates/test-support/src/client.rs @@ -199,7 +199,8 @@ impl TestClient { .to_owned(); let challenge_random = challenge["challenge_random"] .as_str() - .context("challenge_random missing")?; + .context("challenge_random missing")? + .to_owned(); let difficulty = challenge["difficulty"] .as_u64() .and_then(|n| u8::try_from(n).ok()) @@ -223,28 +224,38 @@ impl TestClient { "PoW difficulty {difficulty} is too high to grind in a test" ); let limit = 32u64 << difficulty; - let mut nonce: u64 = 0; - loop { - let nonce_str = nonce.to_string(); - if think_watch_auth::pow::verify_pow( - challenge_random, - &bound_email, - &nonce_str, - difficulty, - ) { - return Ok(serde_json::json!({ - "challenge_id": challenge_id, - "nonce": nonce_str, - })); - } - nonce += 1; - if nonce > limit { - anyhow::bail!( - "PoW grinder found no nonce in {limit} tries at difficulty {difficulty}; \ - the server and `verify_pow` disagree" - ); + // Grind on the blocking pool. Tests run on a current-thread + // runtime that also drives the in-process server; grinding + // inline (half a second on average, several seconds in the + // tail on a busy runner) froze every request in flight, long + // enough for their database pool acquires to time out. + let nonce_str = tokio::task::spawn_blocking(move || { + let mut nonce: u64 = 0; + loop { + let nonce_str = nonce.to_string(); + if think_watch_auth::pow::verify_pow( + &challenge_random, + &bound_email, + &nonce_str, + difficulty, + ) { + return Ok(nonce_str); + } + nonce += 1; + if nonce > limit { + anyhow::bail!( + "PoW grinder found no nonce in {limit} tries at difficulty {difficulty}; \ + the server and `verify_pow` disagree" + ); + } } - } + }) + .await + .context("PoW grinder task")??; + Ok(serde_json::json!({ + "challenge_id": challenge_id, + "nonce": nonce_str, + })) } pub async fn post_empty(&self, path: &str) -> Result { diff --git a/crates/test-support/tests/mcp_store_concurrency.rs b/crates/test-support/tests/mcp_store_concurrency.rs index 917fcc2a..a5c7e63f 100644 --- a/crates/test-support/tests/mcp_store_concurrency.rs +++ b/crates/test-support/tests/mcp_store_concurrency.rs @@ -55,15 +55,18 @@ async fn parallel_installs_same_template_keep_count_in_sync() { seed_template(&app.db, &slug_owned, "GitHub IT").await; const N: usize = 5; - let mut handles = Vec::with_capacity(N); + // Each parallel install runs in its own admin session (independent + // cookie jar). That's the closest test-harness analogue to N + // browser tabs hitting the public endpoint at once. Log every + // session in before firing any install, so the N installs race + // each other rather than trickling out one login apart. + let mut sessions = Vec::with_capacity(N); for _ in 0..N { - let app_ref = &app; + sessions.push(admin_session(&app).await); + } + let mut handles = Vec::with_capacity(N); + for con in sessions { let slug = slug_owned.clone(); - // Each parallel install runs in its own admin session - // (independent cookie jar) — same admin user, different - // logins. That's the closest test-harness analogue to N - // browser tabs hitting the public endpoint at once. - let con = admin_session(app_ref).await; handles.push(tokio::spawn(async move { con.post( "/api/mcp/servers", diff --git a/deploy/helm/think-watch/Chart.yaml b/deploy/helm/think-watch/Chart.yaml index 37f927bb..c18b4a17 100644 --- a/deploy/helm/think-watch/Chart.yaml +++ b/deploy/helm/think-watch/Chart.yaml @@ -2,8 +2,8 @@ apiVersion: v2 name: think-watch description: Enterprise AI API Gateway & MCP Management Platform type: application -version: 3.8.3 -appVersion: "3.8.3" +version: 3.9.0 +appVersion: "3.9.0" keywords: - ai - gateway diff --git a/web/package.json b/web/package.json index ba14fbf7..c77e5485 100644 --- a/web/package.json +++ b/web/package.json @@ -1,7 +1,7 @@ { "name": "web", "private": true, - "version": "3.8.3", + "version": "3.9.0", "type": "module", "packageManager": "pnpm@12.14.0", "scripts": { diff --git a/web/src/components/add-to-lite.tsx b/web/src/components/add-to-lite.tsx index a5a31171..33a96a33 100644 --- a/web/src/components/add-to-lite.tsx +++ b/web/src/components/add-to-lite.tsx @@ -1,15 +1,27 @@ import { useId, useState, type ReactNode } from 'react'; import { useTranslation } from 'react-i18next'; import { MonitorDown } from 'lucide-react'; +import { useQuery } from '@tanstack/react-query'; import { Button } from '@/components/ui/button'; import { useGatewayUrl } from '@/hooks/use-gateway-url'; import { hasPermission } from '@/lib/api'; -import { LITE_MIN_VERSION, liteDownloadUrl, liteImportLink } from '@/lib/lite-link'; +import { keyRulesQuery } from '@/components/limits/key-limits-query'; +import { + LITE_LIMITS_MIN_VERSION, + LITE_MIN_VERSION, + liteDownloadUrl, + liteImportLink, + liteLimits, +} from '@/lib/lite-link'; import { openAppLink } from '@/lib/open-app-link'; interface AddToLiteProps { /// The plaintext key, as shown on this screen. apiKey: string; + /// The key's id, where the screen has it: the link then carries the + /// key's own request limits, read with `rate_limits:read`. Without the + /// id, the permission, or a readable answer, the link goes without them. + keyId?: string; /// Whether the key may call the AI gateway (`ai_gateway` in its surfaces). aiGateway: boolean; /// Whether the key's owner holds `ai_gateway:use`, without which the @@ -28,15 +40,26 @@ interface AddToLiteProps { /// key on it — with an error page. Lite is a desktop app: below the `md` /// breakpoint (where the console's mobile layout starts) only the Copy /// button shows. -export function AddToLite({ apiKey, aiGateway, ownerMayUseGateway, children }: AddToLiteProps) { +export function AddToLite({ apiKey, keyId, aiGateway, ownerMayUseGateway, children }: AddToLiteProps) { const { t, i18n } = useTranslation(); const { baseUrl, known, failed } = useGatewayUrl(); const [opened, setOpened] = useState(false); const reasonId = useId(); + const limitsId = useId(); const mayUseGateway = ownerMayUseGateway ?? hasPermission('ai_gateway:use'); - const built = known ? liteImportLink(baseUrl, apiKey) : undefined; + // Until the rules come (and when they cannot be read) the link goes + // without limits rather than holding the button back. + const rules = useQuery({ + ...keyRulesQuery(keyId ?? ''), + enabled: !!keyId && aiGateway && hasPermission('rate_limits:read'), + retry: false, + }); + const limits = rules.data ? liteLimits(rules.data) : []; + const built = known ? liteImportLink(baseUrl, apiKey, limits) : undefined; const link = built && 'link' in built ? built.link : undefined; + const withLimits = !!link && limits.length > 0; + const minVersion = withLimits ? LITE_LIMITS_MIN_VERSION : LITE_MIN_VERSION; let reason: string | undefined; if (!aiGateway) { reason = t('lite.reasonNoAiGateway'); @@ -70,6 +93,7 @@ export function AddToLite({ apiKey, aiGateway, ownerMayUseGateway, children }: A