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