diff --git a/CHANGELOG.md b/CHANGELOG.md index 29968f4a..c270990c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,105 @@ target. ## [Unreleased] +## [3.6.0] — 2026-10-12 + +A key shown once can go straight into ThinkWatch Lite: the console's key +screens offer Add to ThinkWatch Lite, and the Configuration Guide has a tab +for it. Every user, not only administrators, now sees the gateway address +an administrator configured, and the model listings name only the models +the calling key may use. + +### Read before upgrading + +- **Model listings follow the calling key.** `GET /v1/models` and + `GET /v1beta/models` listed every routed model whatever the key; they + now list only the models a request with that key is let through for + (see Fixed). A client that lists models with a narrowed key sees fewer + of them, and a key whose allow-list and owner's roles have nothing in + common lists none. No setting, schema, Helm value or Redis key changes. +- **`general.public_host` must be a host name or an IP address.** Saving + it used to accept anything without `/` or `://`, such as + `gateway.example.com:8443`, and the Configuration Guide then built a + broken URL from it. Such a value is now refused when saved, and one + already stored counts as not set: the guide uses the host the console is + open at instead. If yours carries a port, move the port to + `general.public_port`. +- **The console's Content-Security-Policy allows `frame-src thinkwatch:`.** + The web image's nginx now sends + `frame-src 'self' thinkwatch:`, which Add to ThinkWatch Lite needs to hand + its link to the app (see Added). A policy of your own in front of the + console needs the same directive, or the button does nothing. + +### Added + +- **"Add to ThinkWatch Lite" for a key shown once.** The new-key and + rotated-key dialogs have an Add to ThinkWatch Lite button next to Copy; + at the end of first-run setup it is on its own line under the key. It + opens a `thinkwatch://import` link with this gateway and the key, and + ThinkWatch Lite (2026.10.16 or later) opens its New upstream dialog with + them filled in; the upstream is created when the user confirms it there. + The link is built in the browser from the key on screen and the + gateway's public address, and is sent nowhere. It is opened in a hidden + frame, so a browser without Lite installed keeps the page, and the key + on it, instead of showing an error page in its place. The button is + unavailable, with the reason beside it, for a key without the + `ai_gateway` surface, for a user whose roles do not grant + `ai_gateway:use` (a key is always its creator's, and the gateway would + refuse its every request), and for a gateway whose public address Lite + does not accept. Lite takes `https://`, and plain `http://` only on this + machine or a private network: private IPv4 ranges (`10/8`, `172.16/12`, + `192.168/16`, `100.64/10`, `169.254/16`), private IPv6 ranges + (`fc00::/7`, `fe80::/10`), and names with no dot or ending in `.local`, + `.lan`, `.internal`, `.intranet`, `.corp`, `.home.arpa` or `.localhost`. + Elsewhere, set `general.public_protocol` to `https` where the gateway is + served over TLS. Once clicked, a line under it links to the download + page. Phones get Copy only. +- **The Configuration Guide has a ThinkWatch Lite tab**, first among the + AI gateway clients: what Lite does, and a button that opens the new-key + dialog filled in for it (named "ThinkWatch Lite", AI gateway only). + Users without `api_keys:create` are told how to get a key and add it by + hand. +- **`GET /api/gateway/info`** on the console port gives any signed-in user + the configured parts of the gateway's public address: + `{"public_protocol": "https", "public_host": "gateway.example.com", "public_port": 443, "gateway_port": 3000}`. + `public_protocol`, `public_host` and `public_port` are + `general.public_protocol`, `general.public_host` and + `general.public_port`, each `null` when not set (or when the stored value + is not one the setting accepts); `gateway_port` is the port the gateway + listens on. The console fills in what is not set from the address it is + open at, the browser's scheme and host, and uses `gateway_port` when no + public port is set. The server reads no request headers for it. + +### Fixed + +- **The Configuration Guide shows the configured gateway address to every + user.** It read the address from `GET /api/admin/settings/system`, which + needs `settings:read`: everyone else got 403 and an address built from + the browser's host and port 3000, whatever `general.public_*` said. It + now reads `GET /api/gateway/info`. Until that has loaded, and if it + cannot be read, the examples use the browser's scheme and host with port + 3000, as before. +- **`GET /v1/models` and `GET /v1beta/models` list only the models the + calling key may use**: the key's `allowed_models` intersected with what + its owner's roles grant, matched exactly or by prefix, the check a + request for the model meets. They listed every routed model, and a + request for one outside that scope was then refused. A model named in + that allow-list is listed when a route serves it, so a key narrowed to + `gpt-4o` lists `gpt-4o` when the route is a prefix such as the `gpt-` + route of a provider with no routes of its own. +- **Saving `general.public_host` checks it** (see Read before upgrading): + `PATCH /api/admin/settings` answers 400, naming what the setting takes, + for a value with a scheme, a port, a path, a user or anything else + beyond a host name or an IP address. In Settings, the field's save + marker turns to an error that gives this message. + +### Changed + +- The Public Gateway URL hint in Settings says where an empty protocol or + host comes from (the address the console is open at) and that port 0 is + the gateway's listening port, and names Add to ThinkWatch Lite among + what uses the address. + ## [3.5.0] — 2026-10-11 Limits hold where they used to leak: route caps are enforced, a limits @@ -1923,7 +2022,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.5.0...HEAD +[Unreleased]: https://github.com/ThinkWatchProject/ThinkWatch/compare/v3.6.0...HEAD +[3.6.0]: https://github.com/ThinkWatchProject/ThinkWatch/releases/tag/v3.6.0 [3.5.0]: https://github.com/ThinkWatchProject/ThinkWatch/releases/tag/v3.5.0 [3.4.0]: https://github.com/ThinkWatchProject/ThinkWatch/releases/tag/v3.4.0 [3.3.1]: https://github.com/ThinkWatchProject/ThinkWatch/releases/tag/v3.3.1 diff --git a/Cargo.lock b/Cargo.lock index bd41fd36..1029fd31 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4189,7 +4189,7 @@ dependencies = [ [[package]] name = "think-watch-auth" -version = "3.5.0" +version = "3.6.0" dependencies = [ "anyhow", "argon2", @@ -4219,7 +4219,7 @@ dependencies = [ [[package]] name = "think-watch-common" -version = "3.5.0" +version = "3.6.0" dependencies = [ "aes-gcm", "anyhow", @@ -4262,7 +4262,7 @@ dependencies = [ [[package]] name = "think-watch-gateway" -version = "3.5.0" +version = "3.6.0" dependencies = [ "anyhow", "arc-swap", @@ -4301,7 +4301,7 @@ dependencies = [ [[package]] name = "think-watch-mcp-gateway" -version = "3.5.0" +version = "3.6.0" dependencies = [ "anyhow", "arc-swap", @@ -4330,7 +4330,7 @@ dependencies = [ [[package]] name = "think-watch-server" -version = "3.5.0" +version = "3.6.0" dependencies = [ "anyhow", "arc-swap", @@ -4381,7 +4381,7 @@ dependencies = [ [[package]] name = "think-watch-test-support" -version = "3.5.0" +version = "3.6.0" dependencies = [ "anyhow", "async-stream", diff --git a/Cargo.toml b/Cargo.toml index f01ba91c..93d2705b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,7 +10,7 @@ members = [ ] [workspace.package] -version = "3.5.0" +version = "3.6.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/common/src/dynamic_config.rs b/crates/common/src/dynamic_config.rs index cea9e6f8..ed044a87 100644 --- a/crates/common/src/dynamic_config.rs +++ b/crates/common/src/dynamic_config.rs @@ -699,9 +699,12 @@ fn validate_setting(key: &str, value: &Value) -> anyhow::Result<()> { if s.len() > 253 { anyhow::bail!("{key}: host too long"); } - // Reject schemes / paths — host is just a hostname or IP - if s.contains("://") || s.contains('/') { - anyhow::bail!("{key}: must be a hostname only (no scheme or path)"); + // A host name or an IP address and nothing else: a value the + // gateway address would pass over as not set is refused here. + if !s.is_empty() && crate::validation::bare_host(s).is_none() { + anyhow::bail!( + "{key}: must be a host name or an IP address only (no scheme, port, path or user)" + ); } } "general.public_port" => { diff --git a/crates/common/src/validation.rs b/crates/common/src/validation.rs index f73a68df..dcec6869 100644 --- a/crates/common/src/validation.rs +++ b/crates/common/src/validation.rs @@ -219,6 +219,33 @@ pub fn validate_url(url_str: &str) -> Result<(), AppError> { Ok(()) } +/// `host` as it goes into a URL — a domain name in lower case, an IPv4 +/// address, or an IPv6 address in brackets (given with or without them) — +/// or `None` when it is empty or is anything more than a host: a scheme, a +/// port, a path, a user or anything else that would change the URL's +/// shape. +/// +/// The rule for `general.public_host`: saving a value it refuses is an +/// error, and one already stored counts as not set. +pub fn bare_host(host: &str) -> Option { + let host = host.trim(); + if host.is_empty() { + return None; + } + let bracketed; + let host = if host.parse::().is_ok() { + bracketed = format!("[{host}]"); + bracketed.as_str() + } else { + host + }; + let parsed = url::Host::parse(host).ok()?.to_string(); + parsed + .chars() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_' | ':' | '[' | ']')) + .then_some(parsed) +} + /// Checks an AWS region code such as `us-east-1` or `us-gov-west-1`. /// /// Bedrock providers keep a region where other providers keep a URL, and @@ -507,6 +534,33 @@ mod tests { assert!(!is_private_ip(&"100.63.255.255".parse().unwrap())); } + #[test] + fn bare_host_takes_a_host_name_or_an_ip_address_only() { + assert_eq!( + bare_host("GW.Example.com").as_deref(), + Some("gw.example.com") + ); + assert_eq!(bare_host(" 10.0.0.5 ").as_deref(), Some("10.0.0.5")); + assert_eq!(bare_host("gateway").as_deref(), Some("gateway")); + assert_eq!(bare_host("::1").as_deref(), Some("[::1]")); + assert_eq!(bare_host("[fd00::5]").as_deref(), Some("[fd00::5]")); + for bad in [ + "", + " ", + "gw.example.com:8443", + "https://gw.example.com", + "gw.example.com/v1", + "user@gw.example.com", + "gw.example.com?x=1", + "gw example", + "gw.example.com#top", + "${GATEWAY_HOST}", + "[::1]:3000", + ] { + assert_eq!(bare_host(bad), None, "{bad:?}"); + } + } + #[test] fn is_link_local_ipv4() { assert!(is_link_local(&"169.254.1.1".parse().unwrap())); diff --git a/crates/gateway/src/lifecycle/mod.rs b/crates/gateway/src/lifecycle/mod.rs index 48e6e15b..2113ebbc 100644 --- a/crates/gateway/src/lifecycle/mod.rs +++ b/crates/gateway/src/lifecycle/mod.rs @@ -847,16 +847,10 @@ impl Surface for ChatCompletionSurface { )) } - /// `None` = unrestricted; otherwise the model must match an entry - /// (exactly or by prefix). An empty list allows nothing — it is what - /// a key narrowed to models its owner's roles do not grant ends up - /// with, and the MCP surface reads `[]` the same way. + /// See [`GatewayRequestIdentity::may_call`]. The MCP surface reads + /// an empty list the same way: nothing is allowed. fn is_access_allowed(identity: &Self::Identity, candidate: &str) -> bool { - identity.allowed_models.as_ref().is_none_or(|allowed| { - allowed - .iter() - .any(|m| candidate == *m || candidate.starts_with(m)) - }) + identity.may_call(candidate) } fn access_denied_response(candidate: &str) -> Self::Response { diff --git a/crates/gateway/src/proxy/mod.rs b/crates/gateway/src/proxy/mod.rs index 62567398..76ef7f97 100644 --- a/crates/gateway/src/proxy/mod.rs +++ b/crates/gateway/src/proxy/mod.rs @@ -134,6 +134,29 @@ pub struct GatewayRequestIdentity { pub key_expires_at: Option>, } +impl GatewayRequestIdentity { + /// Whether this caller may call `model` (the name after aliases). + /// `allowed_models` is the key's list intersected with its owner's + /// role grants: `None` is unrestricted; otherwise the model must + /// match an entry exactly or start with it. An empty list allows + /// nothing — it is what a key narrowed to models its owner's roles + /// do not grant ends up with. + /// + /// The one decision behind both the request-time check + /// (`check_access`) and the model listings: `/v1/models` lists only + /// models a request would be let through for. It cannot list every + /// one of them — a prefix entry allows names nobody enumerates — so it + /// lists the routed names and the allow-list's own entries that pass + /// (see `models::callable_models`). + pub fn may_call(&self, model: &str) -> bool { + self.allowed_models.as_ref().is_none_or(|allowed| { + allowed + .iter() + .any(|m| model == m || model.starts_with(m.as_str())) + }) + } +} + /// Thin wrapper kept for call-site readability; delegates to /// `GatewayError::status_code` so the error-path `gateway_logs` row, /// the streaming `StreamOutcome::UpstreamError` log row, and @@ -467,4 +490,21 @@ mod helper_tests { }; assert_eq!(err.retry_after_secs(), Some(3600)); } + + #[test] + fn a_model_is_callable_when_an_entry_matches_it_exactly_or_as_a_prefix() { + let with = |allowed: Option<&[&str]>| GatewayRequestIdentity { + allowed_models: allowed.map(|a| a.iter().map(|m| m.to_string()).collect()), + ..Default::default() + }; + assert!(with(None).may_call("anything")); + let narrowed = with(Some(&["gpt-4o", "claude-"])); + assert!(narrowed.may_call("gpt-4o")); + assert!(narrowed.may_call("gpt-4o-mini")); + assert!(narrowed.may_call("claude-sonnet-4-5")); + assert!(!narrowed.may_call("gpt-4")); + assert!(!narrowed.may_call("gemini-2.5-pro")); + // An empty list allows nothing. + assert!(!with(Some(&[])).may_call("gpt-4o")); + } } diff --git a/crates/gateway/src/proxy/models.rs b/crates/gateway/src/proxy/models.rs index e72565f7..a67c7eb8 100644 --- a/crates/gateway/src/proxy/models.rs +++ b/crates/gateway/src/proxy/models.rs @@ -1,18 +1,59 @@ //! `GET /v1/models` and Gemini's `GET /v1beta/models` — the models this -//! gateway routes, in the shape each kind of client reads. +//! gateway routes that the calling key may use, in the shape each kind of +//! client reads. + +use std::collections::BTreeSet; -use axum::Json; use axum::extract::State; +use axum::{Extension, Json}; -use super::GatewayState; +use super::{GatewayRequestIdentity, GatewayState}; +use crate::model_mapping::ModelMapper; +use crate::router::ModelRouter; -/// GET /v1/models +/// The models `identity` may call, in name order: each name the router +/// serves, and each entry of the caller's allow-list taken as an exact +/// model name — every one put through the alias mapping, the model check +/// and the route lookup a request naming it would meet. /// -/// Returns the list of available models in OpenAI-compatible format. -pub async fn list_models_handler(State(state): State) -> Json { - let models = state.router.load().list_models(); +/// Router names can be prefixes (a provider with no routes of its own is +/// served under `gpt-`, `claude-`, …), so a key narrowed to `gpt-4o` is +/// let through for `gpt-4o` although no router name is `gpt-4o`, and only +/// its allow-list names it. A listed model is one a request for it is let +/// through for and routed, and a model the key may not use is not listed. +fn callable_models(state: &GatewayState, identity: &GatewayRequestIdentity) -> Vec { + callable_models_in(&state.router.load(), &state.model_mapper, identity) +} + +fn callable_models_in( + router: &ModelRouter, + mapper: &ModelMapper, + identity: &GatewayRequestIdentity, +) -> Vec { + let callable = |name: &str| { + let mapped = mapper.map(name); + identity.may_call(&mapped) && router.route(&mapped).is_some() + }; + let mut models: BTreeSet = router + .list_models() + .into_iter() + .filter(|name| callable(name)) + .collect(); + if let Some(allowed) = &identity.allowed_models { + models.extend(allowed.iter().filter(|name| callable(name)).cloned()); + } + models.into_iter().collect() +} - let model_objects: Vec = models +/// GET /v1/models +/// +/// Returns the models the calling key may use, in OpenAI-compatible +/// format. +pub async fn list_models_handler( + State(state): State, + Extension(identity): Extension, +) -> Json { + let model_objects: Vec = callable_models(&state, &identity) .into_iter() .map(|id| { serde_json::json!({ @@ -36,11 +77,9 @@ pub async fn list_models_handler(State(state): State) -> Json, + Extension(identity): Extension, ) -> Json { - let models: Vec = state - .router - .load() - .list_models() + let models: Vec = callable_models(&state, &identity) .into_iter() .map(|id| { serde_json::json!({ @@ -51,3 +90,112 @@ pub async fn list_gemini_models_handler( .collect(); Json(serde_json::json!({ "models": models })) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::router::RouteEntry; + use std::sync::Arc; + use uuid::Uuid; + + fn entry() -> RouteEntry { + RouteEntry { + upstream: Arc::new(crate::proxy::transport::Upstream::new( + "https://example.invalid", + Vec::new(), + crate::proxy::transport::Shape::Standard, + "openai", + )), + provider_id: Uuid::nil(), + route_id: Uuid::nil(), + provider_name: "openai".into(), + upstream_model: None, + weight: 100, + label: None, + rpm_cap: None, + tpm_cap: None, + protocol: crate::protocol::UpstreamProtocol::OpenAiChat, + alternates: Vec::new(), + } + } + + /// A provider served under prefixes only, as one with no routes of + /// its own is, plus one exact route. + fn router() -> ModelRouter { + let mut router = ModelRouter::new(); + for name in ["gpt-", "o1-", "claude-sonnet-4-5"] { + router.register_route(name, entry()); + } + router + } + + fn narrowed(allowed: Option<&[&str]>) -> GatewayRequestIdentity { + GatewayRequestIdentity { + allowed_models: allowed.map(|a| a.iter().map(|m| m.to_string()).collect()), + ..Default::default() + } + } + + #[test] + fn an_unrestricted_key_lists_what_the_router_serves() { + assert_eq!( + callable_models_in(&router(), &ModelMapper::new(), &narrowed(None)), + ["claude-sonnet-4-5", "gpt-", "o1-"] + ); + } + + #[test] + fn a_key_narrowed_to_a_model_behind_a_prefix_route_lists_it() { + assert_eq!( + callable_models_in( + &router(), + &ModelMapper::new(), + &narrowed(Some(&["gpt-4o", "o1-mini", "gemini-2.5-pro"])) + ), + // `gemini-2.5-pro` is allowed but nothing routes it. + ["gpt-4o", "o1-mini"] + ); + } + + #[test] + fn a_name_both_routed_and_allowed_is_listed_once() { + assert_eq!( + callable_models_in( + &router(), + &ModelMapper::new(), + &narrowed(Some(&["gpt-", "claude-sonnet-4-5"])) + ), + ["claude-sonnet-4-5", "gpt-"] + ); + } + + #[test] + fn an_allowed_name_is_listed_as_a_request_names_it() { + // `fast` is an alias of `gpt-4o-mini`: a request for `fast` is + // checked and routed as `gpt-4o-mini`. + let mut mapper = ModelMapper::new(); + mapper.add_exact("fast", "gpt-4o-mini"); + mapper.add_exact("slow", "llama-3"); + assert_eq!( + callable_models_in(&router(), &mapper, &narrowed(Some(&["fast", "slow"]))), + Vec::::new(), + "`fast` maps to a model the key does not allow" + ); + assert_eq!( + callable_models_in( + &router(), + &mapper, + &narrowed(Some(&["fast", "slow", "gpt-4o-mini", "llama-3"])) + ), + // `slow` maps to `llama-3`, which nothing routes. + ["fast", "gpt-4o-mini"] + ); + } + + #[test] + fn an_empty_allow_list_lists_nothing() { + assert!( + callable_models_in(&router(), &ModelMapper::new(), &narrowed(Some(&[]))).is_empty() + ); + } +} diff --git a/crates/server/src/app.rs b/crates/server/src/app.rs index 6358fb7c..4b9d02ed 100644 --- a/crates/server/src/app.rs +++ b/crates/server/src/app.rs @@ -630,6 +630,10 @@ pub fn create_console_app(config: &AppConfig, state: AppState) -> anyhow::Result get(handlers::mcp_store::get_template), ) .route("/api/mcp/logs", get(handlers::mcp_logs::list_mcp_logs)) + .route( + "/api/gateway/info", + get(handlers::gateway_info::get_gateway_info), + ) .route( "/api/gateway/logs", get(handlers::gateway_logs::list_gateway_logs), diff --git a/crates/server/src/handlers/admin/settings.rs b/crates/server/src/handlers/admin/settings.rs index d73fa480..b373948d 100644 --- a/crates/server/src/handlers/admin/settings.rs +++ b/crates/server/src/handlers/admin/settings.rs @@ -574,9 +574,15 @@ fn validate_setting(key: &str, value: &serde_json::Value) -> Result<(), AppError if s.len() > 253 { return Err(AppError::BadRequest("public_host too long".into())); } - if s.contains("://") || s.contains('/') { + // The rule `GET /api/gateway/info` reads the setting with: a + // value it would pass over as not set is refused here, so a + // mistyped host shows up when it is saved. + if !s.is_empty() && think_watch_common::validation::bare_host(s).is_none() { return Err(AppError::BadRequest( - "public_host must be a hostname only (no scheme or path)".into(), + "public_host must be a host name or an IP address only, such as \ + gateway.example.com or 10.0.0.5: no scheme, port, path or user \ + (the port goes in public_port)" + .into(), )); } } @@ -745,6 +751,40 @@ mod tests { assert!(validate_setting("api_keys.default_expiry_days", &json!(-1)).is_err()); } + #[test] + fn validates_the_public_host_with_the_gateway_address_rule() { + for ok in [ + "", + "gateway.example.com", + "10.0.0.5", + "::1", + "[fd00::5]", + "gw", + ] { + assert!( + validate_setting("general.public_host", &json!(ok)).is_ok(), + "{ok:?}" + ); + } + for bad in [ + "gateway.example.com:8443", + "https://gateway.example.com", + "gateway.example.com/v1", + "admin@gateway.example.com", + "gateway.example.com?x=1", + "gateway example", + "${GATEWAY_HOST}", + " ", + ] { + let e = validate_setting("general.public_host", &json!(bad)).unwrap_err(); + assert!( + e.to_string().contains("host name or an IP address only"), + "{bad:?}: {e}" + ); + } + assert!(validate_setting("general.public_host", &json!(5)).is_err()); + } + #[test] fn validates_site_name() { assert!(validate_setting("setup.site_name", &json!("My Site")).is_ok()); diff --git a/crates/server/src/handlers/gateway_info.rs b/crates/server/src/handlers/gateway_info.rs new file mode 100644 index 00000000..6732fe41 --- /dev/null +++ b/crates/server/src/handlers/gateway_info.rs @@ -0,0 +1,162 @@ +//! `GET /api/gateway/info` — where clients reach the gateway, for any +//! signed-in console user. +//! +//! The Configuration Guide and the "Add to ThinkWatch Lite" button need +//! the gateway's public address. It used to be read from +//! `/api/admin/settings/system`, which needs `settings:read`: everyone +//! else got 403 and a guess built from the browser's host and port 3000, +//! whatever `general.public_*` said. +//! +//! The answer is what is configured, and the browser fills in the rest +//! from the address the console is open at — what the settings page means +//! by "auto-detect from the browser". The server does not guess from +//! request headers (`Host`, `X-Forwarded-*`): what a proxy in front of the +//! console sends says nothing certain about where the gateway is reached. + +use axum::Json; +use axum::extract::State; +use serde::Serialize; + +use crate::app::AppState; +use crate::middleware::auth_guard::AuthUser; + +/// The configured parts of the gateway's public address. A part that is +/// `null` is not set: the scheme and host are then those of the address +/// the console is open at, and the port is `gateway_port`. A default port +/// (80 for `http`, 443 for `https`) is left out of the URL. +#[derive(Debug, PartialEq, Eq, Serialize, utoipa::ToSchema)] +pub struct GatewayInfo { + /// `general.public_protocol`: `"http"` or `"https"`. + #[schema(example = "https")] + pub public_protocol: Option, + /// `general.public_host` as it goes into a URL: a domain name in lower + /// case, an IPv4 address, or an IPv6 address in brackets. A stored + /// value that is anything more than a host counts as not set. + #[schema(example = "gateway.example.com")] + pub public_host: Option, + /// `general.public_port`, 1–65535. + #[schema(example = 443)] + pub public_port: Option, + /// The port the gateway listens on. + #[schema(example = 3000)] + pub gateway_port: u16, +} + +#[utoipa::path( + get, + path = "/api/gateway/info", + tag = "Gateway", + responses( + (status = 200, description = "The configured parts of the gateway's public address", body = GatewayInfo), + (status = 401, description = "Not signed in"), + ), + security(("BearerAuth" = [])) +)] +pub async fn get_gateway_info( + _auth_user: AuthUser, + State(state): State, +) -> Json { + let dc = &state.dynamic_config; + let protocol = dc + .get_string("general.public_protocol") + .await + .unwrap_or_default(); + let host = dc + .get_string("general.public_host") + .await + .unwrap_or_default(); + let port = dc.get_i64("general.public_port").await.unwrap_or(0); + Json(gateway_info( + &protocol, + &host, + port, + state.config.gateway_port, + )) +} + +/// The answer for `general.public_protocol`, `general.public_host` and +/// `general.public_port` as stored (empty and 0 mean "not set"). A value +/// the settings would refuse today counts as not set: a protocol other +/// than `http` or `https`, a host that is more than a host +/// ([`think_watch_common::validation::bare_host`]), a port outside +/// 1–65535. +fn gateway_info(protocol: &str, host: &str, port: i64, gateway_port: u16) -> GatewayInfo { + GatewayInfo { + public_protocol: matches!(protocol, "http" | "https").then(|| protocol.to_string()), + public_host: think_watch_common::validation::bare_host(host), + public_port: u16::try_from(port).ok().filter(|p| *p > 0), + gateway_port, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_configured_parts_are_given_as_set() { + assert_eq!( + gateway_info("https", "GW.Example.com", 443, 3000), + GatewayInfo { + public_protocol: Some("https".into()), + public_host: Some("gw.example.com".into()), + public_port: Some(443), + gateway_port: 3000, + } + ); + assert_eq!( + gateway_info("http", "::1", 8080, 3000) + .public_host + .as_deref(), + Some("[::1]") + ); + } + + #[test] + fn what_is_not_set_is_null() { + assert_eq!( + gateway_info("", "", 0, 3100), + GatewayInfo { + public_protocol: None, + public_host: None, + public_port: None, + gateway_port: 3100, + } + ); + } + + #[test] + fn a_stored_value_the_settings_would_refuse_counts_as_not_set() { + let info = gateway_info("ws", "gw.example.com:8443", 70_000, 3000); + assert_eq!(info.public_protocol, None); + assert_eq!(info.public_host, None); + assert_eq!(info.public_port, None); + assert_eq!(gateway_info("https", "", -1, 3000).public_port, None); + for bad in [ + "user@gw.example.com", + "gw.example.com/v1", + "gw example", + "${GATEWAY_HOST}", + ] { + assert_eq!( + gateway_info("https", bad, 0, 3000).public_host, + None, + "{bad}" + ); + } + } + + #[test] + fn the_answer_has_the_four_documented_fields() { + let body = serde_json::to_value(gateway_info("https", "", 0, 3000)).unwrap(); + assert_eq!( + body, + serde_json::json!({ + "public_protocol": "https", + "public_host": null, + "public_port": null, + "gateway_port": 3000, + }) + ); + } +} diff --git a/crates/server/src/handlers/mod.rs b/crates/server/src/handlers/mod.rs index d1656294..a0b0d674 100644 --- a/crates/server/src/handlers/mod.rs +++ b/crates/server/src/handlers/mod.rs @@ -10,6 +10,7 @@ pub(crate) mod clickhouse_util; pub mod client_errors; pub mod cost_forecast; pub mod dashboard; +pub mod gateway_info; pub mod gateway_logs; pub mod health; pub mod key_usage; diff --git a/crates/server/src/openapi.rs b/crates/server/src/openapi.rs index 96cfb560..2994200a 100644 --- a/crates/server/src/openapi.rs +++ b/crates/server/src/openapi.rs @@ -12,6 +12,7 @@ use crate::handlers::{ DashboardLayout, DashboardLive, DashboardStats, LiveLogRow, ProviderHealth, RpmBucket, WsTicketResponse, }, + gateway_info::GatewayInfo, gateway_logs::{GatewayLogBodyResponse, GatewayLogEntry, GatewayLogsResponse}, limits::{ CapListResponse, CapRow, CapUsage, RuleListResponse, RuleRow, RuleUsage, UpsertCapRequest, @@ -184,6 +185,8 @@ use crate::services::team_repository::Team; crate::handlers::dashboard::create_dashboard_ws_ticket, crate::handlers::dashboard::get_dashboard_layout, crate::handlers::dashboard::put_dashboard_layout, + // Gateway + crate::handlers::gateway_info::get_gateway_info, // Audit & Logs crate::handlers::audit::list_audit_logs, crate::handlers::gateway_logs::list_gateway_logs, @@ -248,6 +251,8 @@ use crate::services::team_repository::Team; // Dashboard DashboardStats, ProviderHealth, RpmBucket, LiveLogRow, DashboardLive, WsTicketResponse, DashboardLayout, + // Gateway + GatewayInfo, // Logs GatewayLogEntry, GatewayLogsResponse, GatewayLogBodyResponse, McpLogEntry, McpLogsResponse, McpLogBodyResponse, @@ -287,6 +292,7 @@ use crate::services::team_repository::Team; (name = "Analytics", description = "Usage and cost analytics"), (name = "Dashboard", description = "Real-time dashboard stats and live log stream"), (name = "Audit Logs", description = "Platform audit trail"), + (name = "Gateway", description = "Where clients reach the gateway"), (name = "Gateway Logs", description = "AI gateway request logs"), (name = "MCP Logs", description = "MCP tool call logs"), (name = "System Logs", description = "Platform, access, and application logs"), diff --git a/crates/test-support/tests/console_admin.rs b/crates/test-support/tests/console_admin.rs index de3aae4c..70828ec3 100644 --- a/crates/test-support/tests/console_admin.rs +++ b/crates/test-support/tests/console_admin.rs @@ -389,6 +389,124 @@ async fn settings_round_trip_updates_dynamic_config() { assert_eq!(row, Some(true)); } +// --------------------------------------------------------------------------- +// Where clients reach the gateway +// --------------------------------------------------------------------------- + +#[ignore = "integration test — run via `make test-it`"] +#[tokio::test] +async fn gateway_info_gives_any_signed_in_user_the_configured_gateway_address() { + let app = TestApp::spawn().await; + let viewer = fixtures::create_user_with_role(&app.db, "viewer", "global", None) + .await + .unwrap(); + let con = app.console_client(); + con.post( + "/api/auth/login", + json!({"email": viewer.user.email, "password": viewer.plaintext_password}), + ) + .await + .unwrap() + .assert_ok(); + // The system settings stay an administrator's to read. + con.get("/api/admin/settings/system") + .await + .unwrap() + .assert_status(403); + + let info = |con: TestClient| async move { + let resp = con.get("/api/gateway/info").await.unwrap(); + resp.assert_ok(); + let body: Value = resp.json().unwrap(); + (body, con) + }; + let gateway_port = app.state.config.gateway_port; + + app.set_setting("general.public_protocol", json!("https")) + .await; + app.set_setting("general.public_host", json!("Gateway.Example.com")) + .await; + app.set_setting("general.public_port", json!(443)).await; + let (body, con) = info(con).await; + assert_eq!( + body, + json!({ + "public_protocol": "https", + "public_host": "gateway.example.com", + "public_port": 443, + "gateway_port": gateway_port, + }) + ); + + // What is not set is null, for the browser to fill in from the + // address the console is open at. Forwarded headers change nothing. + app.set_setting("general.public_protocol", json!("")).await; + app.set_setting("general.public_host", json!("")).await; + app.set_setting("general.public_port", json!(0)).await; + con.set_header("x-forwarded-proto", "https"); + con.set_header("x-forwarded-host", "console.example.com:8080"); + let (body, con) = info(con).await; + assert_eq!( + body, + json!({ + "public_protocol": null, + "public_host": null, + "public_port": null, + "gateway_port": gateway_port, + }) + ); + + // A host stored before saving checked it, and that is more than a + // host, counts as not set. + app.set_setting("general.public_host", json!("gateway.example.com:8443")) + .await; + let (body, _) = info(con).await; + assert_eq!(body["public_host"], Value::Null, "{body}"); + + app.console_client() + .get("/api/gateway/info") + .await + .unwrap() + .assert_status(401); +} + +#[ignore = "integration test — run via `make test-it`"] +#[tokio::test] +async fn saving_a_public_host_that_is_more_than_a_host_is_refused() { + let app = TestApp::spawn().await; + let (con, _) = admin_session_with_user(&app).await; + + for bad in [ + "gateway.example.com:8443", + "https://gateway.example.com", + "admin@gateway.example.com", + ] { + let resp = con + .patch( + "/api/admin/settings", + json!({"settings": {"general.public_host": bad}}), + ) + .await + .unwrap(); + resp.assert_status(400); + assert!( + resp.text().contains("host name or an IP address only"), + "{bad}: {}", + resp.text() + ); + } + + for ok in ["gateway.example.com", "10.0.0.5", ""] { + con.patch( + "/api/admin/settings", + json!({"settings": {"general.public_host": ok}}), + ) + .await + .unwrap() + .assert_ok(); + } +} + // --------------------------------------------------------------------------- // Limits CRUD via console // --------------------------------------------------------------------------- diff --git a/crates/test-support/tests/gateway_authz.rs b/crates/test-support/tests/gateway_authz.rs index db79ed05..f56da4c1 100644 --- a/crates/test-support/tests/gateway_authz.rs +++ b/crates/test-support/tests/gateway_authz.rs @@ -10,6 +10,9 @@ //! model to them. //! - The per-key allow-list still narrows what the roles grant, and an //! empty result means nothing is allowed, not everything. +//! - `GET /v1/models` and `GET /v1beta/models` list the models the +//! calling key may use under both, and no others — including a model +//! the key's allow-list names that a prefix route serves. use serde_json::Value; use think_watch_test_support::client::TestResponse; @@ -382,3 +385,180 @@ async fn key_allow_list_within_a_prefix_grant_is_kept() { chat(&gw, MODEL_A).await.assert_ok(); assert_refused(&chat(&gw, MODEL_B).await, "model-b outside the key list"); } + +// --------------------------------------------------------------------------- +// The model listings +// --------------------------------------------------------------------------- + +/// The model ids `/v1/models` lists and those `/v1beta/models` lists +/// (without Gemini's `models/` prefix), in that order. +async fn listed_models(gw: &TestClient) -> (Vec, Vec) { + let openai = gw.get("/v1/models").await.unwrap(); + openai.assert_ok(); + let openai: Value = openai.json().unwrap(); + let gemini = gw.get("/v1beta/models").await.unwrap(); + gemini.assert_ok(); + let gemini: Value = gemini.json().unwrap(); + let ids = openai["data"] + .as_array() + .expect("data array") + .iter() + .map(|m| m["id"].as_str().unwrap().to_string()) + .collect(); + let names = gemini["models"] + .as_array() + .expect("models array") + .iter() + .map(|m| { + let name = m["name"].as_str().unwrap(); + name.strip_prefix("models/").unwrap_or(name).to_string() + }) + .collect(); + (ids, names) +} + +/// Both listings name `expected` (in order) among this test's two models. +async fn assert_lists(gw: &TestClient, expected: &[&str], what: &str) { + let (openai, gemini) = listed_models(gw).await; + for (listing, ids) in [("/v1/models", &openai), ("/v1beta/models", &gemini)] { + let ours: Vec<&str> = ids + .iter() + .map(String::as_str) + .filter(|id| [MODEL_A, MODEL_B].contains(id)) + .collect(); + assert_eq!(ours, expected, "{what}: {listing} listed {ids:?}"); + } +} + +#[ignore = "integration test — run via `make test-it`"] +#[tokio::test] +async fn model_listings_follow_the_key_allow_list() { + let app = TestApp::spawn().await; + let _upstream = two_models(&app).await; + let user = bare_user(&app).await; + fixtures::assign_role_global(&app.db, user.user.id, "developer") + .await + .unwrap(); + + let unrestricted = gateway_for(&app, user.user.id, None).await; + assert_lists(&unrestricted, &[MODEL_A, MODEL_B], "an unrestricted key").await; + + let narrowed = gateway_for(&app, user.user.id, Some(&[MODEL_A])).await; + assert_lists(&narrowed, &[MODEL_A], "a key narrowed to model-a").await; + // What is listed is what a request is let through for. + chat(&narrowed, MODEL_A).await.assert_ok(); + assert_refused(&chat(&narrowed, MODEL_B).await, "model-b, not listed"); + + // A key entry is a prefix, as at request time. + let by_prefix = gateway_for(&app, user.user.id, Some(&["authz-model-"])).await; + assert_lists(&by_prefix, &[MODEL_A, MODEL_B], "a key narrowed by prefix").await; +} + +#[ignore = "integration test — run via `make test-it`"] +#[tokio::test] +async fn model_listings_follow_the_owners_roles() { + let app = TestApp::spawn().await; + let _upstream = two_models(&app).await; + + // A role granting model-a only: model-b is not listed for any key. + let user = bare_user(&app).await; + assign_global(&app.db, user.user.id, only_model_a(&app.db).await).await; + let gw = gateway_for(&app, user.user.id, None).await; + assert_lists(&gw, &[MODEL_A], "a model-a-only role").await; + + // A key list disjoint from the role grants allows nothing, and lists + // nothing at all. + let disjoint = gateway_for(&app, user.user.id, Some(&[MODEL_B])).await; + let (openai, gemini) = listed_models(&disjoint).await; + assert!(openai.is_empty(), "/v1/models listed {openai:?}"); + assert!(gemini.is_empty(), "/v1beta/models listed {gemini:?}"); + + // A prefix grant narrowed by the key keeps the key's model. + let prefixed = bare_user(&app).await; + let prefix = custom_role( + &app.db, + "prefix", + json!({"Effect": "Allow", "Action": ["ai_gateway:use"], "Resource": ["model:authz-model-"]}), + ) + .await; + assign_global(&app.db, prefixed.user.id, prefix).await; + let gw = gateway_for(&app, prefixed.user.id, Some(&[MODEL_B])).await; + assert_lists(&gw, &[MODEL_B], "a prefix grant narrowed to model-b").await; +} + +/// A provider with no routes of its own, which the router serves under its +/// type's model prefixes (`gpt-`, `o1-`, …), behind one mock upstream. +async fn prefix_routed(app: &TestApp) -> MockProvider { + let upstream = MockProvider::openai_chat_ok("gpt-4o").await; + fixtures::create_provider( + &app.db, + &unique_name("authz-prefix-prov"), + "openai", + &upstream.uri(), + None, + ) + .await + .unwrap(); + app.rebuild_gateway_router().await; + upstream +} + +#[ignore = "integration test — run via `make test-it`"] +#[tokio::test] +async fn model_listings_name_allowed_models_that_a_prefix_route_serves() { + let app = TestApp::spawn().await; + let _upstream = prefix_routed(&app).await; + let user = bare_user(&app).await; + fixtures::assign_role_global(&app.db, user.user.id, "developer") + .await + .unwrap(); + + // No route is named `gpt-4o`; the `gpt-` prefix route serves it, and + // the key's allow-list names it. + let narrowed = gateway_for(&app, user.user.id, Some(&["gpt-4o"])).await; + let (openai, gemini) = listed_models(&narrowed).await; + assert_eq!(openai, ["gpt-4o"], "/v1/models"); + assert_eq!(gemini, ["gpt-4o"], "/v1beta/models"); + // What is listed is what a request is let through for. + chat(&narrowed, "gpt-4o").await.assert_ok(); + + // An allowed name nothing routes is not listed; a name that is a + // route and an allow-list entry both is listed once. + let mixed = gateway_for( + &app, + user.user.id, + Some(&["gpt-", "gpt-4o", "gemini-2.5-pro"]), + ) + .await; + let (openai, gemini) = listed_models(&mixed).await; + assert_eq!(openai, ["gpt-", "gpt-4o"], "/v1/models"); + assert_eq!(gemini, ["gpt-", "gpt-4o"], "/v1beta/models"); + + // An unrestricted key lists the routes' own names. + let unrestricted = gateway_for(&app, user.user.id, None).await; + let (openai, _) = listed_models(&unrestricted).await; + assert!(openai.iter().any(|m| m == "gpt-"), "{openai:?}"); + assert!(!openai.iter().any(|m| m == "gpt-4o"), "{openai:?}"); +} + +#[ignore = "integration test — run via `make test-it`"] +#[tokio::test] +async fn model_listings_name_role_granted_models_that_a_prefix_route_serves() { + let app = TestApp::spawn().await; + let _upstream = prefix_routed(&app).await; + let user = bare_user(&app).await; + let gpt_4o = custom_role( + &app.db, + "gpt-4o-only", + json!({"Effect": "Allow", "Action": ["ai_gateway:use"], "Resource": ["model:gpt-4o"]}), + ) + .await; + assign_global(&app.db, user.user.id, gpt_4o).await; + + // The key is not narrowed; its owner's role is. + let gw = gateway_for(&app, user.user.id, None).await; + let (openai, gemini) = listed_models(&gw).await; + assert_eq!(openai, ["gpt-4o"], "/v1/models"); + assert_eq!(gemini, ["gpt-4o"], "/v1beta/models"); + chat(&gw, "gpt-4o").await.assert_ok(); +} diff --git a/deploy/docker/nginx.conf.template b/deploy/docker/nginx.conf.template index 14f66fd3..274b2295 100644 --- a/deploy/docker/nginx.conf.template +++ b/deploy/docker/nginx.conf.template @@ -100,7 +100,12 @@ server { # proxies it to the console port on the same origin) and the # dashboard WebSocket upgrade to /api/dashboard/ws (modern CSP # treats `'self'` as ws/wss to the same origin). - add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self' data:; connect-src 'self'; frame-ancestors 'self';" always; + # + # `frame-src thinkwatch:` lets "Add to ThinkWatch Lite" hand its + # `thinkwatch://import` link to the app through a hidden iframe + # (web/src/lib/open-app-link.ts), so a browser without the app + # shows its error page there instead of in place of the console. + add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self' data:; connect-src 'self'; frame-src 'self' thinkwatch:; frame-ancestors 'self';" always; try_files $uri $uri/ /index.html; } diff --git a/deploy/helm/think-watch/Chart.yaml b/deploy/helm/think-watch/Chart.yaml index b6f42727..f89b3004 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.5.0 -appVersion: "3.5.0" +version: 3.6.0 +appVersion: "3.6.0" keywords: - ai - gateway diff --git a/web/package.json b/web/package.json index 0780cd31..063ae0da 100644 --- a/web/package.json +++ b/web/package.json @@ -1,7 +1,7 @@ { "name": "web", "private": true, - "version": "3.5.0", + "version": "3.6.0", "type": "module", "packageManager": "pnpm@12.9.1", "scripts": { diff --git a/web/src/components/add-to-lite.tsx b/web/src/components/add-to-lite.tsx new file mode 100644 index 00000000..a5a31171 --- /dev/null +++ b/web/src/components/add-to-lite.tsx @@ -0,0 +1,114 @@ +import { useId, useState, type ReactNode } from 'react'; +import { useTranslation } from 'react-i18next'; +import { MonitorDown } from 'lucide-react'; +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 { openAppLink } from '@/lib/open-app-link'; + +interface AddToLiteProps { + /// The plaintext key, as shown on this screen. + apiKey: 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 + /// gateway refuses every request the key makes. Keys are created for + /// oneself, so it defaults to the signed-in user's permission; first-run + /// setup, which has no permissions loaded yet, says so itself. + ownerMayUseGateway?: boolean; + /// The screen's Copy button, which goes beside "Add to ThinkWatch Lite". + children?: ReactNode; +} + +/// "Add to ThinkWatch Lite" for a newly shown key. It opens a +/// `thinkwatch://import` link with this gateway's address and the key, +/// built here in the browser, through a hidden iframe: the page itself +/// never navigates, so a browser without Lite cannot replace it — and the +/// 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) { + const { t, i18n } = useTranslation(); + const { baseUrl, known, failed } = useGatewayUrl(); + const [opened, setOpened] = useState(false); + const reasonId = useId(); + const mayUseGateway = ownerMayUseGateway ?? hasPermission('ai_gateway:use'); + + const built = known ? liteImportLink(baseUrl, apiKey) : undefined; + const link = built && 'link' in built ? built.link : undefined; + let reason: string | undefined; + if (!aiGateway) { + reason = t('lite.reasonNoAiGateway'); + } else if (!mayUseGateway) { + reason = t('lite.reasonNoGatewayUse'); + } else if (failed) { + reason = t('lite.reasonNoGatewayUrl'); + } else if (built && 'problem' in built) { + reason = + built.problem === 'insecure-url' + ? t('lite.reasonInsecureUrl', { url: baseUrl }) + : built.problem === 'bad-url' + ? t('lite.reasonBadUrl', { url: baseUrl }) + : t('lite.reasonBadKey'); + } + + const noResponse = t('lite.noResponse'); + const label = ( + <> + + {t('lite.add')} + + ); + + return ( + // Without a Copy button beside it, the whole block is desktop-only. +
+
+ {children} + {link && !reason ? ( + + ) : ( + + )} +
+ {reason ? ( +

+ {reason} +

+ ) : ( + opened && ( +

+ {noResponse} + {/* A space after "?", none after the full-width "?". */} + {/[\x21-\x7e]$/.test(noResponse) ? ' ' : ''} + + {t('lite.installFirst', { version: LITE_MIN_VERSION })} + +

+ ) + )} +
+ ); +} diff --git a/web/src/hooks/use-gateway-url.test.ts b/web/src/hooks/use-gateway-url.test.ts new file mode 100644 index 00000000..6082e862 --- /dev/null +++ b/web/src/hooks/use-gateway-url.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from 'vitest' +import { gatewayBaseUrl, type GatewayInfo } from './use-gateway-url' + +const unset: GatewayInfo = { + public_protocol: null, + public_host: null, + public_port: null, + gateway_port: 3000, +} +const page = (url: string) => new URL(url) + +describe('gatewayBaseUrl', () => { + it('uses what is configured, leaving a default port out', () => { + const at = page('http://localhost:5173/guide') + expect( + gatewayBaseUrl( + { public_protocol: 'https', public_host: 'gw.example.com', public_port: 443, gateway_port: 3000 }, + at, + ), + ).toBe('https://gw.example.com') + expect( + gatewayBaseUrl( + { public_protocol: 'https', public_host: 'gw.example.com', public_port: 8443, gateway_port: 3000 }, + at, + ), + ).toBe('https://gw.example.com:8443') + expect( + gatewayBaseUrl({ ...unset, public_protocol: 'http', public_host: '10.0.0.5' }, at), + ).toBe('http://10.0.0.5:3000') + expect( + gatewayBaseUrl({ ...unset, public_protocol: 'http', public_host: 'gw.internal', public_port: 80 }, at), + ).toBe('http://gw.internal') + }) + + it('takes what is not set from the address the console is open at', () => { + expect(gatewayBaseUrl(unset, page('https://tw.example.com/guide'))).toBe( + 'https://tw.example.com:3000', + ) + expect(gatewayBaseUrl({ ...unset, public_port: 443 }, page('https://tw.example.com/'))).toBe( + 'https://tw.example.com', + ) + // The gateway's own port, not the console's. + expect(gatewayBaseUrl({ ...unset, gateway_port: 3100 }, page('http://192.168.1.4:3001/'))).toBe( + 'http://192.168.1.4:3100', + ) + expect(gatewayBaseUrl(unset, page('http://[::1]:3001/'))).toBe('http://[::1]:3000') + // A configured host with the browser's scheme. + expect(gatewayBaseUrl({ ...unset, public_host: 'gw.example.com' }, page('https://c.example.com/'))).toBe( + 'https://gw.example.com:3000', + ) + }) + + it('is made from the browser alone until the configuration is known', () => { + expect(gatewayBaseUrl(undefined, page('https://tw.example.com/guide'))).toBe( + 'https://tw.example.com:3000', + ) + expect(gatewayBaseUrl(undefined, page('http://localhost:5173/'))).toBe('http://localhost:3000') + expect(gatewayBaseUrl(undefined, { protocol: 'file:', hostname: '' })).toBe('http://localhost:3000') + }) +}) diff --git a/web/src/hooks/use-gateway-url.ts b/web/src/hooks/use-gateway-url.ts new file mode 100644 index 00000000..efc2d765 --- /dev/null +++ b/web/src/hooks/use-gateway-url.ts @@ -0,0 +1,57 @@ +import { useQuery } from '@tanstack/react-query'; +import { api } from '@/lib/api'; + +/// `GET /api/gateway/info`: the configured parts of the gateway's public +/// address. `null` is a part that is not set. +export interface GatewayInfo { + public_protocol: 'http' | 'https' | null; + /// As it goes into a URL: IPv6 in brackets. + public_host: string | null; + public_port: number | null; + /// The port the gateway listens on. + gateway_port: number; +} + +/// The gateway's port until its configuration is known. +const DEFAULT_GATEWAY_PORT = 3000; + +/// The gateway's base URL (`https://gateway.example.com`): what `info` says +/// is configured, the scheme and host of the address the console is open +/// at (`at`, the page's `location`) for what is not, and the gateway's +/// listening port when no public port is set. A default port is left out. +/// Without `info` (still loading, or not readable) the URL is made from the +/// browser's address and port 3000. +export function gatewayBaseUrl( + info: GatewayInfo | undefined, + at: Pick, +): string { + const scheme = info?.public_protocol ?? (at.protocol === 'https:' ? 'https' : 'http'); + const host = info?.public_host ?? (at.hostname || 'localhost'); + const port = info?.public_port ?? info?.gateway_port ?? DEFAULT_GATEWAY_PORT; + const defaultPort = (scheme === 'http' && port === 80) || (scheme === 'https' && port === 443); + return defaultPort ? `${scheme}://${host}` : `${scheme}://${host}:${port}`; +} + +/// The gateway's public base URL, for any signed-in user. +/// +/// `baseUrl` is never empty: until the configuration has loaded, and when +/// it could not be read, it is made from the browser's address alone. +/// `known` says it comes from the configuration; `failed`, that the +/// configuration could not be read. +/// +/// A 401 does not sign the user out from here: this is read on screens +/// that show a key once (a new key, a rotated key, the end of setup), and +/// leaving them would lose it. +export function useGatewayUrl(): { baseUrl: string; known: boolean; failed: boolean } { + const query = useQuery({ + queryKey: ['gateway', 'info'], + queryFn: ({ signal }) => + api('/api/gateway/info', { signal, no401Redirect: true }), + staleTime: 5 * 60_000, + }); + return { + baseUrl: gatewayBaseUrl(query.data, window.location), + known: query.isSuccess, + failed: query.isError, + }; +} diff --git a/web/src/i18n/en.json b/web/src/i18n/en.json index 80a20942..c2c86fa8 100644 --- a/web/src/i18n/en.json +++ b/web/src/i18n/en.json @@ -1725,7 +1725,7 @@ }, "siteName": "Site Name", "publicGatewayUrl": "Public Gateway URL", - "publicGatewayUrlHint": "How the gateway is reached from the outside world. Used in the Configuration Guide and copy-paste examples. Leave fields empty / 0 to auto-detect from the browser.", + "publicGatewayUrlHint": "How the gateway is reached from the outside world. Used in the Configuration Guide, its copy-paste examples and Add to ThinkWatch Lite. An empty protocol or host is taken from the address the console is open at in the browser; port 0 uses the gateway listening port.", "publicProtocol": "Protocol", "publicHost": "Host", "publicHostPlaceholder": "auto (use browser hostname)", @@ -2029,7 +2029,23 @@ "anthropicSdkBaseUrl": "Set base_url to: {{url}} (no /v1 suffix)", "anthropicSdkApiKey": "Set api_key to the key you just created", "anthropicSdkModel": "Use any Claude model configured in ThinkWatch" - } + }, + "lite": "ThinkWatch Lite", + "liteDesc": "ThinkWatch Lite is a desktop app that sets up Claude Code, Codex and other AI clients on the computer it runs on and records every request they make. With this gateway added as its upstream, those clients reach the gateway's models through one API key.", + "liteCreateKey": "Create a key and add it to Lite", + "liteAskForKey": "This account cannot create API keys. Ask an administrator for an API key that can call the AI gateway, then create an upstream in ThinkWatch Lite with ThinkWatch as the service, the gateway URL above and the key.", + "liteDownload": "Download ThinkWatch Lite" + }, + "lite": { + "add": "Add to ThinkWatch Lite", + "reasonNoAiGateway": "This API key cannot call the AI gateway, so it cannot be added to ThinkWatch Lite.", + "reasonNoGatewayUse": "No role of this account grants use of the AI gateway (ai_gateway:use), so its API keys cannot be used from ThinkWatch Lite.", + "reasonNoGatewayUrl": "The gateway address could not be read.", + "reasonInsecureUrl": "ThinkWatch Lite accepts HTTPS gateway addresses, or HTTP ones on this machine or a private network, and this gateway is at {{url}}.", + "reasonBadUrl": "The gateway address {{url}} cannot be added to ThinkWatch Lite.", + "reasonBadKey": "This API key cannot be added to ThinkWatch Lite.", + "noResponse": "Nothing happened?", + "installFirst": "Install ThinkWatch Lite ({{version}} or later) first" }, "notFound": { "title": "404", diff --git a/web/src/i18n/zh.json b/web/src/i18n/zh.json index c8878723..d2fdfcea 100644 --- a/web/src/i18n/zh.json +++ b/web/src/i18n/zh.json @@ -1725,7 +1725,7 @@ }, "siteName": "站点名称", "publicGatewayUrl": "网关公开地址", - "publicGatewayUrlHint": "外部访问网关时使用的地址。用于配置指南中的复制粘贴示例。留空 / 填 0 表示从浏览器自动获取。", + "publicGatewayUrlHint": "外部访问网关时使用的地址,用于配置指南、其中的复制粘贴示例和「添加到 ThinkWatch Lite」。协议或主机留空时,取浏览器中控制台的地址;端口填 0 时,使用网关监听端口。", "publicProtocol": "协议", "publicHost": "主机", "publicHostPlaceholder": "自动(使用浏览器主机名)", @@ -2029,7 +2029,23 @@ "anthropicSdkBaseUrl": "将 base_url 设置为:{{url}}(不含 /v1 后缀)", "anthropicSdkApiKey": "将 api_key 设置为刚刚创建的 API Key", "anthropicSdkModel": "使用任意在 ThinkWatch 中配置的 Claude 模型" - } + }, + "lite": "ThinkWatch Lite", + "liteDesc": "ThinkWatch Lite 是一款桌面应用,在本机接管 Claude Code、Codex 等 AI 客户端,并记录它们的每一次请求。本网关添加为它的上游后,这些客户端用一把 API 密钥经由本网关访问模型。", + "liteCreateKey": "新建密钥并添加到 Lite", + "liteAskForKey": "当前账号不能创建 API 密钥。请管理员创建一把可调用 AI 网关的 API 密钥,再在 ThinkWatch Lite 中新建上游:服务选择「ThinkWatch」,填入上方的网关地址和这把密钥。", + "liteDownload": "下载 ThinkWatch Lite" + }, + "lite": { + "add": "添加到 ThinkWatch Lite", + "reasonNoAiGateway": "这把 API 密钥不能调用 AI 网关,无法添加到 ThinkWatch Lite。", + "reasonNoGatewayUse": "当前账号没有可使用 AI 网关的角色(ai_gateway:use),它的 API 密钥无法在 ThinkWatch Lite 中使用。", + "reasonNoGatewayUrl": "无法读取网关地址。", + "reasonInsecureUrl": "ThinkWatch Lite 只接受 HTTPS 网关地址,或本机、内网的 HTTP 地址;本网关的地址是 {{url}}。", + "reasonBadUrl": "网关地址 {{url}} 无法添加到 ThinkWatch Lite。", + "reasonBadKey": "这把 API 密钥无法添加到 ThinkWatch Lite。", + "noResponse": "没有反应?", + "installFirst": "先安装 ThinkWatch Lite({{version}} 或更新版本)" }, "notFound": { "title": "404", diff --git a/web/src/lib/lite-link.test.ts b/web/src/lib/lite-link.test.ts new file mode 100644 index 00000000..dc67e902 --- /dev/null +++ b/web/src/lib/lite-link.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, it } from 'vitest' +import { liteDownloadUrl, liteImportLink } from './lite-link' + +const KEY = 'tw-0123456789abcdef' + +describe('liteImportLink', () => { + it('builds the enterprise link with each value encoded', () => { + expect(liteImportLink('https://gateway.example.com', KEY)).toEqual({ + link: + 'thinkwatch://import?service=thinkwatch&name=thinkwatch' + + '&url=https%3A%2F%2Fgateway.example.com&key=tw-0123456789abcdef', + }) + const withPort = liteImportLink('https://gateway.example.com:8443', 'tw-a+b/c=') + expect(withPort).toEqual({ + link: + 'thinkwatch://import?service=thinkwatch&name=thinkwatch' + + '&url=https%3A%2F%2Fgateway.example.com%3A8443&key=tw-a%2Bb%2Fc%3D', + }) + }) + + it('takes plain http on this machine and on private networks only', () => { + const allowed = [ + // This machine. + 'http://localhost:3000', + 'http://127.0.0.1:3000', + 'http://[::1]:3000', + // Private IPv4 ranges, at their edges. + 'http://10.0.0.5:3000', + 'http://10.255.255.255', + 'http://172.16.0.1:3000', + 'http://172.31.255.254', + 'http://192.168.1.10', + 'http://100.64.0.1', + 'http://100.127.255.254', + 'http://169.254.10.20', + // Private IPv6 ranges. + 'http://[fd00::5]:3000', + 'http://[fc00::1]', + 'http://[fe80::1]', + 'http://[febf::1]', + // Intranet-style names. + 'http://corp', + 'http://gateway:3000', + 'http://example.corp', + 'http://gw.example.local', + 'http://gw.lan', + 'http://gw.internal:8080', + 'http://gw.intranet', + 'http://gw.home.arpa', + 'http://gw.localhost', + ] + for (const url of allowed) { + expect(liteImportLink(url, KEY), url).toHaveProperty('link') + } + const refused = [ + 'http://172.15.255.254', + 'http://172.32.0.1', + 'http://100.63.255.254', + 'http://100.128.0.1', + 'http://192.169.0.1', + 'http://169.253.0.1', + 'http://11.0.0.1', + 'http://127.0.0.2', + 'http://8.8.8.8', + 'http://[2001:db8::1]', + 'http://[fec0::1]', + 'http://example.com', + 'http://gw.example.com:3000', + 'http://corp.example.com', + 'http://gw.locals', + ] + for (const url of refused) { + expect(liteImportLink(url, KEY), url).toEqual({ problem: 'insecure-url' }) + } + // https is taken whatever the host. + expect(liteImportLink('https://example.com', KEY)).toHaveProperty('link') + expect(liteImportLink('https://8.8.8.8', KEY)).toHaveProperty('link') + }) + + it('refuses anything but a bare scheme://host[:port]', () => { + for (const url of [ + 'https://gateway.example.com/', + 'https://gateway.example.com/v1', + 'https://gateway.example.com?x=1', + 'https://gateway.example.com#top', + 'https://user:pass@gateway.example.com', + 'https://GATEWAY.example.com', + 'ftp://gateway.example.com', + 'gateway.example.com', + '', + ]) { + expect(liteImportLink(url, KEY), url).toEqual({ problem: 'bad-url' }) + } + }) + + it('refuses a key with characters Lite does not take', () => { + expect(liteImportLink('https://gateway.example.com', 'tw-${HOME}')).toEqual({ + problem: 'bad-key', + }) + expect(liteImportLink('https://gateway.example.com', '')).toEqual({ problem: 'bad-key' }) + expect(liteImportLink('https://gateway.example.com', 'x'.repeat(513))).toEqual({ + problem: 'bad-key', + }) + }) +}) + +describe('liteDownloadUrl', () => { + it('follows the console language', () => { + expect(liteDownloadUrl('zh')).toBe('https://thinkwat.ch/zh-CN/lite') + expect(liteDownloadUrl('en')).toBe('https://thinkwat.ch/lite') + }) +}) diff --git a/web/src/lib/lite-link.ts b/web/src/lib/lite-link.ts new file mode 100644 index 00000000..a3a45511 --- /dev/null +++ b/web/src/lib/lite-link.ts @@ -0,0 +1,101 @@ +// "Add to ThinkWatch Lite": the `thinkwatch://import` link that opens +// ThinkWatch Lite's New upstream dialog with this gateway and an API key +// filled in. The link is built here, in the browser, from a key the +// console shows once; nothing is sent anywhere to build it. +// +// The parameters and their rules are ThinkWatch Lite's import-link +// contract (v2, "Enterprise"): Lite rejects a whole link when any value +// breaks its rule, so a value that would be rejected is reported instead +// of being put in a link. + +/// The first ThinkWatch Lite release that takes `service=thinkwatch`. +export const LITE_MIN_VERSION = '2026.10.16'; + +/// The upstream name the link gives; Lite asks for another when it is +/// taken. +const UPSTREAM_NAME = 'thinkwatch'; + +/// Lite's charset for a key: letters, digits and `- _ . ~ + / = :`. +const KEY_RE = /^[A-Za-z0-9\-_.~+/=:]{1,512}$/; + +/// Hosts Lite takes over plain `http://` (contract v2.2): this machine, and +/// addresses that only resolve inside a private network. +const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '[::1]']); + +/// Domain suffixes of intranet-style names. +const PRIVATE_SUFFIXES = ['.local', '.lan', '.internal', '.intranet', '.corp', '.home.arpa', '.localhost']; + +/// Whether Lite takes `http://` for `hostname` (as `URL` normalises it: +/// lower case, IPv4 dotted, IPv6 in brackets). +export function plainHttpAllowed(hostname: string): boolean { + if (LOOPBACK_HOSTS.has(hostname)) return true; + if (hostname.startsWith('[')) { + // fc00::/7 and fe80::/10, from the first 16 bits. `URL` writes a + // leading run of zeros as `::`, which is neither. + const first = hostname.slice(1).split(':')[0]; + const bits = first === '' ? 0 : parseInt(first, 16); + return (bits & 0xfe00) === 0xfc00 || (bits & 0xffc0) === 0xfe80; + } + if (/^\d+\.\d+\.\d+\.\d+$/.test(hostname)) { + // 10/8, 172.16/12, 192.168/16, 100.64/10 (shared address space), + // 169.254/16 (link-local). + const [a, b] = hostname.split('.').map(Number); + return ( + a === 10 || + (a === 172 && b >= 16 && b <= 31) || + (a === 192 && b === 168) || + (a === 100 && b >= 64 && b <= 127) || + (a === 169 && b === 254) + ); + } + return !hostname.includes('.') || PRIVATE_SUFFIXES.some((s) => hostname.endsWith(s)); +} + +export type LiteLinkProblem = + /// The gateway is reached over plain HTTP at a public address. + | 'insecure-url' + /// The base URL is not a bare `scheme://host[:port]`. + | 'bad-url' + /// The key has characters Lite does not take. + | 'bad-key'; + +export type LiteLink = { link: string } | { problem: LiteLinkProblem }; + +/// The import link for the gateway at `baseUrl` (its root: no path, no +/// trailing slash) with `key`, or why Lite would refuse one. +export function liteImportLink(baseUrl: string, key: string): LiteLink { + let url: URL; + try { + url = new URL(baseUrl); + } catch { + return { problem: 'bad-url' }; + } + const bare = + url.origin === baseUrl && + url.username === '' && + url.password === '' && + !/[${}\s]/.test(baseUrl); + if (!bare || (url.protocol !== 'https:' && url.protocol !== 'http:')) { + return { problem: 'bad-url' }; + } + if (url.protocol === 'http:' && !plainHttpAllowed(url.hostname)) { + return { problem: 'insecure-url' }; + } + if (!KEY_RE.test(key)) { + return { problem: 'bad-key' }; + } + const params = [ + ['service', 'thinkwatch'], + ['name', UPSTREAM_NAME], + ['url', baseUrl], + ['key', key], + ] + .map(([k, v]) => `${k}=${encodeURIComponent(v)}`) + .join('&'); + return { link: `thinkwatch://import?${params}` }; +} + +/// Where ThinkWatch Lite is downloaded, in the console's language. +export function liteDownloadUrl(language: string): string { + return language.startsWith('zh') ? 'https://thinkwat.ch/zh-CN/lite' : 'https://thinkwat.ch/lite'; +} diff --git a/web/src/lib/open-app-link.test.ts b/web/src/lib/open-app-link.test.ts new file mode 100644 index 00000000..81490b5f --- /dev/null +++ b/web/src/lib/open-app-link.test.ts @@ -0,0 +1,30 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { openAppLink } from './open-app-link' + +const frames = () => Array.from(document.querySelectorAll('iframe')) + +afterEach(() => { + for (const f of frames()) f.remove() +}) + +describe('openAppLink', () => { + it('loads the link in a hidden iframe, not in the page', () => { + const before = window.location.href + openAppLink('thinkwatch://import?service=thinkwatch&key=tw-a') + + expect(window.location.href).toBe(before) + const [frame] = frames() + expect(frame).toBeDefined() + expect(frame.getAttribute('src')).toBe('thinkwatch://import?service=thinkwatch&key=tw-a') + expect(frame.hidden).toBe(true) + expect(frame).toHaveAttribute('aria-hidden', 'true') + expect(frame.tabIndex).toBe(-1) + }) + + it('replaces the iframe of an earlier call', () => { + openAppLink('thinkwatch://import?key=first') + openAppLink('thinkwatch://import?key=second') + + expect(frames().map((f) => f.getAttribute('src'))).toEqual(['thinkwatch://import?key=second']) + }) +}) diff --git a/web/src/lib/open-app-link.ts b/web/src/lib/open-app-link.ts new file mode 100644 index 00000000..02cc5d27 --- /dev/null +++ b/web/src/lib/open-app-link.ts @@ -0,0 +1,31 @@ +// Opening a link that belongs to an app (`thinkwatch://…`) without leaving +// the page. +// +// Following such a link in the page itself is a navigation of the page: +// where no app takes the scheme, Firefox and Safari replace the page with +// an error page, and a screen that shows an API key once loses the key. +// Loaded in a hidden iframe instead, the link reaches the app all the same +// when one is installed, and an error page stays inside the iframe, where +// no one sees it. + +const FRAME_ID = 'tw-app-link-frame'; + +/// Opens `link` through a hidden iframe made for it. Call it from the +/// click that asks for it: browsers hand a link to an app only on a user's +/// action. The iframe stays until the next call replaces it — removing it +/// sooner could cancel the browser's "open this app?" prompt. +/// +/// The console's Content-Security-Policy must allow the scheme in +/// `frame-src` (deploy/docker/nginx.conf.template). +export function openAppLink(link: string, doc: Document = document): void { + doc.getElementById(FRAME_ID)?.remove(); + const frame = doc.createElement('iframe'); + frame.id = FRAME_ID; + frame.hidden = true; + frame.tabIndex = -1; + frame.setAttribute('aria-hidden', 'true'); + // Set before the iframe joins the document, so that loading it replaces + // its initial blank page instead of adding an entry to the tab's history. + frame.src = link; + doc.body.appendChild(frame); +} diff --git a/web/src/routes/api-key-catalogs.ts b/web/src/routes/api-key-catalogs.ts new file mode 100644 index 00000000..dbaca22c --- /dev/null +++ b/web/src/routes/api-key-catalogs.ts @@ -0,0 +1,141 @@ +import { useMemo } from 'react'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { api } from '@/lib/api'; +import { fetchAllPaginated } from '@/lib/paginated-fetch'; +import type { ModelsByProvider, McpToolsByServer } from '@/components/roles/PermissionTree'; + +interface ModelRow { + id: string; + model_id: string; + display_name: string; +} + +interface McpToolRow { + id: string; + server_name: string; + name: string; + namespaced_name: string; +} + +interface PolicyScope { + allowed_models: string[] | null; + allowed_mcp_tools: string[] | null; +} + +/// True when `id` is covered by the caller's role-granted `allowed` list. +/// Mirrors the gateway's `request.model == g || request.model.starts_with(g)` +/// so the picker only surfaces models the eventual request would accept. +function modelAllowed(id: string, allowed: string[] | null): boolean { + if (allowed === null) return true; + return allowed.some((g) => id === g || id.startsWith(g)); +} + +/// Mirrors the MCP access-control pattern grammar from +/// `crates/mcp-gateway/src/access_control.rs`: `*`, `__*`, or +/// `__`. +function mcpToolAllowed(key: string, allowed: string[] | null): boolean { + if (allowed === null) return true; + return allowed.some((p) => { + if (p === '*' || p === key) return true; + if (p.endsWith('__*')) { + const prefix = p.slice(0, -'__*'.length); + return key.startsWith(prefix + '__'); + } + return false; + }); +} + +const NO_COST_CENTERS: string[] = []; +const NO_MODELS: ModelRow[] = []; +const NO_MCP_TOOLS: McpToolRow[] = []; +const UNRESTRICTED_SCOPE: PolicyScope = { allowed_models: null, allowed_mcp_tools: null }; + +/// What the create and edit key dialogs pick from: the models and MCP +/// tools the caller's roles grant, and the cost centers in use. Loaded +/// only while `enabled`, so a page that opens a dialog now and then +/// fetches the catalogs when it does. +export function useKeyDialogCatalogs(enabled: boolean) { + const queryClient = useQueryClient(); + + // A catalog that fails to load leaves its picker empty — or, for the + // policy scope, unrestricted. + const costCentersQuery = useQuery({ + queryKey: ['keys', 'cost-centers'], + queryFn: ({ signal }) => api('/api/keys/cost-centers', { signal }), + enabled, + }); + // /api/admin/models and /api/mcp/tools are both paginated and clamp + // page_size to 200; the dialog pickers want the complete catalog so + // they can be filtered down to the caller's policy scope locally. + const modelsQuery = useQuery({ + queryKey: ['admin', 'models', { all: true }], + queryFn: () => fetchAllPaginated('/api/admin/models'), + enabled, + }); + // `include_user_tools=true` unions the system catalog (`mcp_tools`) + // with this caller's per-user catalog (`mcp_user_tools` rows where + // user_id = self), so an admin can grant their own GitHub / + // Atlassian / Feishu personal tools to an API key. Without this + // flag the picker would be empty for auth-required servers, since + // those servers never populate the system-level table by design. + // (serde's bool deserializer requires `true`/`false` — `1`/`0` 400s.) + const mcpToolsQuery = useQuery({ + queryKey: ['mcp', 'tools', { all: true, include_user_tools: true }], + queryFn: () => fetchAllPaginated('/api/mcp/tools?include_user_tools=true'), + enabled, + }); + const policyScopeQuery = useQuery({ + queryKey: ['keys', 'policy-scope'], + queryFn: ({ signal }) => api('/api/keys/policy-scope', { signal }), + enabled, + }); + const costCenterOptions = costCentersQuery.data ?? NO_COST_CENTERS; + // Full catalog — filtered down to the caller's policy scope below + // before it reaches the dialog pickers. + const availableModels = modelsQuery.data ?? NO_MODELS; + const availableMcpTools = mcpToolsQuery.data ?? NO_MCP_TOOLS; + const policyScope = policyScopeQuery.data ?? UNRESTRICTED_SCOPE; + + // Shape + filter the raw catalogs into the maps the scope dropdowns + // consume. `allowed_models === null` means the caller's roles grant + // everything, so the picker shows the full catalog. + const modelsByProvider = useMemo(() => { + const bucket = availableModels + .filter((m) => modelAllowed(m.model_id, policyScope.allowed_models)) + .map((m) => ({ modelId: m.model_id, displayName: m.display_name })); + const out = new Map(); + if (bucket.length > 0) out.set('', bucket); + return out; + }, [availableModels, policyScope.allowed_models]); + + const mcpToolsByServer = useMemo(() => { + const out: McpToolsByServer = new Map(); + for (const tool of availableMcpTools) { + if (!mcpToolAllowed(tool.namespaced_name, policyScope.allowed_mcp_tools)) { + continue; + } + // namespaced_name is `__` (tool_name itself may + // contain single underscores but never `__`). + const sep = tool.namespaced_name.indexOf('__'); + const prefix = sep >= 0 ? tool.namespaced_name.slice(0, sep) : ''; + const entry = out.get(tool.server_name) ?? { + serverName: tool.server_name, + prefix, + tools: [] as { key: string; toolName: string }[], + }; + entry.tools.push({ key: tool.namespaced_name, toolName: tool.name }); + out.set(tool.server_name, entry); + } + return out; + }, [availableMcpTools, policyScope.allowed_mcp_tools]); + + const handleCostCenterAdded = (tag: string) => { + // A new tag reaches the server only with the key that carries it; list + // it now so the dialog can select it before then. + queryClient.setQueryData(['keys', 'cost-centers'], (prev = []) => + prev.includes(tag) ? prev : [...prev, tag].sort(), + ); + }; + + return { modelsByProvider, mcpToolsByServer, costCenterOptions, handleCostCenterAdded }; +} diff --git a/web/src/routes/api-key-dialogs.tsx b/web/src/routes/api-key-dialogs.tsx index ca063d3f..1c273a4b 100644 --- a/web/src/routes/api-key-dialogs.tsx +++ b/web/src/routes/api-key-dialogs.tsx @@ -19,6 +19,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Copy, Check, AlertCircle } from 'lucide-react'; import { api, apiPost, apiPatch, apiDelete, hasPermission } from '@/lib/api'; import { ConfirmDialog } from '@/components/confirm-dialog'; +import { AddToLite } from '@/components/add-to-lite'; import { KeyLimitsTab } from '@/components/limits/key-limits-tab'; import { isForbidden, keyLimitsQuery } from '@/components/limits/key-limits-query'; import { @@ -34,7 +35,16 @@ import { // Must match ALLOWED_SURFACES in crates/server/src/handlers/api_keys.rs. const ALL_SURFACES = ['ai_gateway', 'mcp_gateway', 'console'] as const; -type Surface = (typeof ALL_SURFACES)[number]; +export type Surface = (typeof ALL_SURFACES)[number]; + +/// What a new key's form starts from (and returns to when the dialog +/// closes). +export interface ApiKeyPreset { + name: string; + surfaces: Surface[]; +} + +const DEFAULT_PRESET: ApiKeyPreset = { name: '', surfaces: ['ai_gateway', 'mcp_gateway'] }; export interface ApiKey { id: string; @@ -105,6 +115,9 @@ interface CreateApiKeyDialogProps { mcpToolsByServer: McpToolsByServer; costCenterOptions: string[]; onCostCenterAdded: (tag: string) => void; + /// The name and surfaces the form starts with; by default no name and + /// both gateways. + preset?: ApiKeyPreset; } export function CreateApiKeyDialog({ @@ -115,16 +128,17 @@ export function CreateApiKeyDialog({ mcpToolsByServer, costCenterOptions, onCostCenterAdded, + preset = DEFAULT_PRESET, }: CreateApiKeyDialogProps) { const { t } = useTranslation(); - const [name, setName] = useState(''); + const [name, setName] = useState(preset.name); // null = unrestricted (use every model/tool the role grants). Any Set // = explicit subset. Scope pickers collapse empty Set → null on // change, so "clear all" from the dropdown returns us to unrestricted. const [selectedModels, setSelectedModels] = useState | null>(null); const [selectedMcpTools, setSelectedMcpTools] = useState | null>(null); - const [createSurfaces, setCreateSurfaces] = useState(['ai_gateway', 'mcp_gateway']); + const [createSurfaces, setCreateSurfaces] = useState(preset.surfaces); const [expiresInDays, setExpiresInDays] = useState(''); const [createCostCenter, setCreateCostCenter] = useState(''); @@ -134,10 +148,10 @@ export function CreateApiKeyDialog({ const [copied, setCopied] = useState(false); const resetForm = () => { - setName(''); + setName(preset.name); setSelectedModels(null); setSelectedMcpTools(null); - setCreateSurfaces(['ai_gateway', 'mcp_gateway']); + setCreateSurfaces(preset.surfaces); setExpiresInDays(''); setCreateCostCenter(''); setFormError(''); @@ -216,10 +230,12 @@ export function CreateApiKeyDialog({
{createdKey}
- + + + @@ -730,10 +746,15 @@ export function RotateApiKeyDialog({
{rotatedNewKey}
- + + + diff --git a/web/src/routes/api-keys.test.tsx b/web/src/routes/api-keys.test.tsx index 9819e3fd..3fe6e88d 100644 --- a/web/src/routes/api-keys.test.tsx +++ b/web/src/routes/api-keys.test.tsx @@ -22,9 +22,10 @@ vi.mock('@/lib/paginated-fetch', () => ({ fetchAllPaginated: vi.fn().mockResolvedValue([]), })) -import { api, hasPermission } from '@/lib/api' +import { api, apiPost, hasPermission } from '@/lib/api' const mockApi = vi.mocked(api) +const mockApiPost = vi.mocked(apiPost) const mockHasPermission = vi.mocked(hasPermission) const makeKey = (overrides: Record = {}) => ({ @@ -62,6 +63,13 @@ function mockKeysFetch( if (url.startsWith('/api/keys/policy-scope')) return { allowed_models: null, allowed_mcp_tools: null } if (url.startsWith('/api/admin/limits/')) return limits(url) + if (url === '/api/gateway/info') + return { + public_protocol: 'https', + public_host: 'gateway.example.com', + public_port: 443, + gateway_port: 3000, + } return { data: keys, total: keys.length, page: 1, page_size: 20 } }) } @@ -150,6 +158,74 @@ describe('ApiKeysPage', () => { }) }) + it('offers a new key to ThinkWatch Lite beside Copy, without leaving the page', async () => { + mockKeysFetch([]) + mockApiPost.mockResolvedValue({ id: 'new', key: 'tw-new-key' }) + const user = userEvent.setup() + renderWithQueryClient() + + await user.click((await screen.findAllByRole('button', { name: /create api key/i }))[0]) + await user.type(screen.getByLabelText('Name'), 'laptop') + await user.click(screen.getByRole('button', { name: 'Create Key' })) + + const add = await screen.findByRole('button', { name: 'Add to ThinkWatch Lite' }) + await waitFor(() => expect(add).toBeEnabled()) + expect(screen.getByRole('button', { name: 'Copy to Clipboard' })).toBeInTheDocument() + // The button is no link the page itself would follow. + expect(screen.queryByRole('link', { name: 'Add to ThinkWatch Lite' })).not.toBeInTheDocument() + + const page = window.location.href + await user.click(add) + // The link goes to a hidden iframe; the page and its key stay. + expect(window.location.href).toBe(page) + const frame = document.querySelector('iframe') + expect(frame).toHaveAttribute( + 'src', + 'thinkwatch://import?service=thinkwatch&name=thinkwatch' + + '&url=https%3A%2F%2Fgateway.example.com&key=tw-new-key', + ) + expect(frame).not.toBeVisible() + expect(screen.getByText('tw-new-key')).toBeInTheDocument() + expect(screen.getByText('Nothing happened?')).toBeInTheDocument() + frame?.remove() + }) + + it('names why a key cannot be added to Lite when its owner may not use the gateway', async () => { + mockKeysFetch([]) + mockHasPermission.mockImplementation((perm: string) => perm !== 'ai_gateway:use') + mockApiPost.mockResolvedValue({ id: 'new', key: 'tw-new-key' }) + const user = userEvent.setup() + renderWithQueryClient() + + await user.click((await screen.findAllByRole('button', { name: /create api key/i }))[0]) + await user.type(screen.getByLabelText('Name'), 'laptop') + await user.click(screen.getByRole('button', { name: 'Create Key' })) + + const add = await screen.findByRole('button', { name: 'Add to ThinkWatch Lite' }) + expect(add).toBeDisabled() + expect(add).toHaveAccessibleDescription( + 'No role of this account grants use of the AI gateway (ai_gateway:use), so its API keys cannot be used from ThinkWatch Lite.', + ) + }) + + it('names why a key without the AI gateway cannot be added to Lite', async () => { + mockKeysFetch([]) + mockApiPost.mockResolvedValue({ id: 'new', key: 'tw-new-key' }) + const user = userEvent.setup() + renderWithQueryClient() + + await user.click((await screen.findAllByRole('button', { name: /create api key/i }))[0]) + await user.type(screen.getByLabelText('Name'), 'tools only') + await user.click(screen.getByRole('checkbox', { name: /AI gateway/ })) + await user.click(screen.getByRole('button', { name: 'Create Key' })) + + const add = await screen.findByRole('button', { name: 'Add to ThinkWatch Lite' }) + expect(add).toBeDisabled() + expect(add).toHaveAccessibleDescription( + 'This API key cannot call the AI gateway, so it cannot be added to ThinkWatch Lite.', + ) + }) + it("edits the key's own limits in a tab of the edit dialog", async () => { mockKeysFetch([makeKey()], (url) => url.endsWith('/usage') diff --git a/web/src/routes/api-keys.tsx b/web/src/routes/api-keys.tsx index e433ec8f..2500b4f3 100644 --- a/web/src/routes/api-keys.tsx +++ b/web/src/routes/api-keys.tsx @@ -16,7 +16,6 @@ import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Plus, Ban, RotateCw, Pencil, KeyRound, AlertCircle } from 'lucide-react'; import { Alert, AlertDescription } from '@/components/ui/alert'; import { api, hasPermission } from '@/lib/api'; -import { fetchAllPaginated } from '@/lib/paginated-fetch'; import { Skeleton } from '@/components/ui/skeleton'; import { toast } from 'sonner'; import { @@ -26,51 +25,7 @@ import { DeleteApiKeyDialog, type ApiKey, } from './api-key-dialogs'; -import type { - ModelsByProvider, - McpToolsByServer, -} from '@/components/roles/PermissionTree'; - -interface ModelRow { - id: string; - model_id: string; - display_name: string; -} - -interface McpToolRow { - id: string; - server_name: string; - name: string; - namespaced_name: string; -} - -interface PolicyScope { - allowed_models: string[] | null; - allowed_mcp_tools: string[] | null; -} - -/// True when `id` is covered by the caller's role-granted `allowed` list. -/// Mirrors the gateway's `request.model == g || request.model.starts_with(g)` -/// so the picker only surfaces models the eventual request would accept. -function modelAllowed(id: string, allowed: string[] | null): boolean { - if (allowed === null) return true; - return allowed.some((g) => id === g || id.startsWith(g)); -} - -/// Mirrors the MCP access-control pattern grammar from -/// `crates/mcp-gateway/src/access_control.rs`: `*`, `__*`, or -/// `__`. -function mcpToolAllowed(key: string, allowed: string[] | null): boolean { - if (allowed === null) return true; - return allowed.some((p) => { - if (p === '*' || p === key) return true; - if (p.endsWith('__*')) { - const prefix = p.slice(0, -'__*'.length); - return key.startsWith(prefix + '__'); - } - return false; - }); -} +import { useKeyDialogCatalogs } from './api-key-catalogs'; // --------------------------------------------------------------------------- // Types @@ -83,10 +38,6 @@ interface PaginatedResponse { per_page: number; } -const NO_COST_CENTERS: string[] = []; -const NO_MODELS: ModelRow[] = []; -const NO_MCP_TOOLS: McpToolRow[] = []; -const UNRESTRICTED_SCOPE: PolicyScope = { allowed_models: null, allowed_mcp_tools: null }; // --------------------------------------------------------------------------- // Helpers @@ -233,73 +184,9 @@ export function ApiKeysPage() { const error = (liveKeysQuery.error ?? (tab === 'inactive' ? archivedKeysQuery.error : null))?.message ?? ''; - // Shared data for dialogs. A catalog that fails to load leaves its picker - // empty — or, for the policy scope, unrestricted. - const costCentersQuery = useQuery({ - queryKey: ['keys', 'cost-centers'], - queryFn: ({ signal }) => api('/api/keys/cost-centers', { signal }), - }); - // /api/admin/models and /api/mcp/tools are both paginated and clamp - // page_size to 200; the dialog pickers want the complete catalog so - // they can be filtered down to the caller's policy scope locally. - const modelsQuery = useQuery({ - queryKey: ['admin', 'models', { all: true }], - queryFn: () => fetchAllPaginated('/api/admin/models'), - }); - // `include_user_tools=true` unions the system catalog (`mcp_tools`) - // with this caller's per-user catalog (`mcp_user_tools` rows where - // user_id = self), so an admin can grant their own GitHub / - // Atlassian / Feishu personal tools to an API key. Without this - // flag the picker would be empty for auth-required servers, since - // those servers never populate the system-level table by design. - // (serde's bool deserializer requires `true`/`false` — `1`/`0` 400s.) - const mcpToolsQuery = useQuery({ - queryKey: ['mcp', 'tools', { all: true, include_user_tools: true }], - queryFn: () => fetchAllPaginated('/api/mcp/tools?include_user_tools=true'), - }); - const policyScopeQuery = useQuery({ - queryKey: ['keys', 'policy-scope'], - queryFn: ({ signal }) => api('/api/keys/policy-scope', { signal }), - }); - const costCenterOptions = costCentersQuery.data ?? NO_COST_CENTERS; - // Full catalog — filtered down to the caller's policy scope below - // before it reaches the dialog pickers. - const availableModels = modelsQuery.data ?? NO_MODELS; - const availableMcpTools = mcpToolsQuery.data ?? NO_MCP_TOOLS; - const policyScope = policyScopeQuery.data ?? UNRESTRICTED_SCOPE; - - // Shape + filter the raw catalogs into the maps the scope dropdowns - // consume. `allowed_models === null` means the caller's roles grant - // everything, so the picker shows the full catalog. - const modelsByProvider = useMemo(() => { - const bucket = availableModels - .filter((m) => modelAllowed(m.model_id, policyScope.allowed_models)) - .map((m) => ({ modelId: m.model_id, displayName: m.display_name })); - const out = new Map(); - if (bucket.length > 0) out.set('', bucket); - return out; - }, [availableModels, policyScope.allowed_models]); - - const mcpToolsByServer = useMemo(() => { - const out: McpToolsByServer = new Map(); - for (const tool of availableMcpTools) { - if (!mcpToolAllowed(tool.namespaced_name, policyScope.allowed_mcp_tools)) { - continue; - } - // namespaced_name is `__` (tool_name itself may - // contain single underscores but never `__`). - const sep = tool.namespaced_name.indexOf('__'); - const prefix = sep >= 0 ? tool.namespaced_name.slice(0, sep) : ''; - const entry = out.get(tool.server_name) ?? { - serverName: tool.server_name, - prefix, - tools: [] as { key: string; toolName: string }[], - }; - entry.tools.push({ key: tool.namespaced_name, toolName: tool.name }); - out.set(tool.server_name, entry); - } - return out; - }, [availableMcpTools, policyScope.allowed_mcp_tools]); + // Shared data for the dialogs' pickers. + const { modelsByProvider, mcpToolsByServer, costCenterOptions, handleCostCenterAdded } = + useKeyDialogCatalogs(true); // --------------------------------------------------------------------------- // Filtered keys @@ -320,14 +207,6 @@ export function ApiKeysPage() { // Callbacks // --------------------------------------------------------------------------- - const handleCostCenterAdded = (tag: string) => { - // A new tag reaches the server only with the key that carries it; list - // it now so the dialog can select it before then. - queryClient.setQueryData(['keys', 'cost-centers'], (prev = []) => - prev.includes(tag) ? prev : [...prev, tag].sort(), - ); - }; - const openEditDialog = (k: ApiKey) => { setEditingKey(k); setEditDialogOpen(true); diff --git a/web/src/routes/guide.test.tsx b/web/src/routes/guide.test.tsx new file mode 100644 index 00000000..ee6aec99 --- /dev/null +++ b/web/src/routes/guide.test.tsx @@ -0,0 +1,66 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import type { ReactNode } from 'react' +import { renderWithQueryClient } from '@/test/render' +import { GuidePage } from './guide' + +vi.mock('@/lib/api', async (importOriginal) => ({ + ...(await importOriginal()), + api: vi.fn(), + hasPermission: vi.fn(() => false), +})) + +// The guide links to other console pages; there is no router here. +vi.mock('@tanstack/react-router', () => ({ + Link: ({ to, children }: { to: string; children: ReactNode }) => {children}, +})) + +import { api } from '@/lib/api' + +const mockApi = vi.mocked(api) + +// jsdom's page: http://localhost:3000/. Its address without the +// configuration — the browser's scheme and host, port 3000. +const FROM_BROWSER = `${window.location.protocol}//${window.location.hostname}:3000` + +async function claudeCodeSnippet() { + const user = userEvent.setup() + await user.click(screen.getByRole('tab', { name: 'Claude Code' })) + return (await screen.findByText(/ANTHROPIC_BASE_URL=/)).textContent ?? '' +} + +beforeEach(() => { + vi.clearAllMocks() +}) + +describe('GuidePage', () => { + it('shows the configured gateway address', async () => { + mockApi.mockResolvedValue({ + public_protocol: 'https', + public_host: 'gw.example.com', + public_port: null, + gateway_port: 8443, + }) + renderWithQueryClient() + + expect(await screen.findByText('https://gw.example.com:8443')).toBeInTheDocument() + expect(await claudeCodeSnippet()).toContain('ANTHROPIC_BASE_URL=https://gw.example.com:8443\n') + }) + + it("uses the browser's address while the configured one loads", async () => { + mockApi.mockReturnValue(new Promise(() => {})) + renderWithQueryClient() + + expect(await claudeCodeSnippet()).toContain(`ANTHROPIC_BASE_URL=${FROM_BROWSER}\n`) + }) + + it("uses the browser's address when the configured one cannot be read", async () => { + mockApi.mockRejectedValue(new Error('500')) + renderWithQueryClient() + + await waitFor(() => expect(screen.getByText(FROM_BROWSER)).toBeInTheDocument()) + expect(screen.getByText(`${FROM_BROWSER}/mcp`)).toBeInTheDocument() + expect(await claudeCodeSnippet()).toContain(`ANTHROPIC_BASE_URL=${FROM_BROWSER}\n`) + }) +}) diff --git a/web/src/routes/guide.tsx b/web/src/routes/guide.tsx index 0166bbab..f0572519 100644 --- a/web/src/routes/guide.tsx +++ b/web/src/routes/guide.tsx @@ -1,7 +1,13 @@ -import { useState, useCallback, useEffect, useMemo } from 'react'; +import { useState, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { Link } from '@tanstack/react-router'; -import { api } from '@/lib/api'; +import { useQueryClient } from '@tanstack/react-query'; +import { hasPermission } from '@/lib/api'; +import { liteDownloadUrl } from '@/lib/lite-link'; +import { useGatewayUrl } from '@/hooks/use-gateway-url'; +import { Skeleton } from '@/components/ui/skeleton'; +import { CreateApiKeyDialog, type ApiKeyPreset } from './api-key-dialogs'; +import { useKeyDialogCatalogs } from './api-key-catalogs'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; @@ -20,43 +26,14 @@ import { MonitorSmartphone, Workflow, Bot, + MonitorDown, + ExternalLink, } from 'lucide-react'; // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- -interface PublicGatewayConfig { - gateway_port: number; - public_protocol: string; - public_host: string; - public_port: number; -} - -function useGatewayUrl(): string { - const [config, setConfig] = useState(null); - - useEffect(() => { - api('/api/admin/settings/system') - .then(setConfig) - .catch((e) => console.error('Failed to load gateway public config:', e)); - }, []); - - return useMemo(() => { - const browserProto = window.location.protocol.replace(':', ''); - const browserHost = window.location.hostname; - const protocol = config?.public_protocol || browserProto || 'http'; - const host = config?.public_host || browserHost || 'localhost'; - const port = config?.public_port && config.public_port > 0 - ? config.public_port - : config?.gateway_port || 3000; - // Omit the port suffix for the standard port of the chosen protocol - const isStandardPort = - (protocol === 'http' && port === 80) || (protocol === 'https' && port === 443); - return isStandardPort ? `${protocol}://${host}` : `${protocol}://${host}:${port}`; - }, [config]); -} - function CopyButton({ text }: { text: string }) { const { t } = useTranslation(); const [copied, setCopied] = useState(false); @@ -153,6 +130,55 @@ function McpPromptTab({ mcpUrl }: { mcpUrl: string }) { // AI Gateway tab contents // =========================================================================== +/// The key the Lite tab's button creates: named for Lite, AI gateway only. +const LITE_KEY_PRESET: ApiKeyPreset = { name: 'ThinkWatch Lite', surfaces: ['ai_gateway'] }; + +function LiteTab() { + const { t, i18n } = useTranslation(); + const queryClient = useQueryClient(); + const canCreate = hasPermission('api_keys:create'); + const [createOpen, setCreateOpen] = useState(false); + // The pickers' catalogs load when the dialog opens, not with the guide. + const catalogs = useKeyDialogCatalogs(createOpen); + + return ( +
+
+ +

ThinkWatch Lite

+
+

{t('guide.liteDesc')}

+ {!canCreate && {t('guide.liteAskForKey')}} +
+ {/* Lite is a desktop app: phones are not offered the key for it. */} + {canCreate && ( + + )} + +
+ {canCreate && ( + void queryClient.invalidateQueries({ queryKey: ['keys'] })} + modelsByProvider={catalogs.modelsByProvider} + mcpToolsByServer={catalogs.mcpToolsByServer} + costCenterOptions={catalogs.costCenterOptions} + onCostCenterAdded={catalogs.handleCostCenterAdded} + preset={LITE_KEY_PRESET} + /> + )} +
+ ); +} + function ClaudeCodeTab({ gatewayUrl }: { gatewayUrl: string }) { const { t } = useTranslation(); const code = `# Set ThinkWatch as Anthropic API proxy @@ -671,8 +697,22 @@ curl -X POST ${mcpUrl}/mcp \\ export function GuidePage() { const { t } = useTranslation(); - const gatewayUrl = useGatewayUrl(); + // Until the configured address has loaded, and when it cannot be read, + // the address is the browser's: the examples and Copy always have one. + const { baseUrl: gatewayUrl, known, failed } = useGatewayUrl(); const mcpUrl = gatewayUrl; // MCP endpoint is on the gateway port + // While the address loads, its boxes hold a placeholder. + const urlBox = (suffix = '') => + known || failed ? ( + <> + + {`${gatewayUrl}${suffix}`} + + + + ) : ( + + ); return (
@@ -696,10 +736,7 @@ export function GuidePage() {
- - {gatewayUrl} - - + {urlBox()}

{t('guide.gatewayUrlDesc')}

@@ -726,10 +763,7 @@ export function GuidePage() {
- - {mcpUrl}/mcp - - + {urlBox('/mcp')}

{t('guide.mcpEndpointDesc')}

@@ -795,8 +829,9 @@ export function GuidePage() { {/* AI Gateway tool tabs */} - + + {t('guide.lite')} {t('guide.claudeCode')} {t('guide.cursor')} {t('guide.continue')} @@ -806,6 +841,9 @@ export function GuidePage() { {t('guide.curl')} + + + diff --git a/web/src/routes/setup.test.tsx b/web/src/routes/setup.test.tsx index 5cbf0265..6203ce03 100644 --- a/web/src/routes/setup.test.tsx +++ b/web/src/routes/setup.test.tsx @@ -1,6 +1,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' import { render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' +import { renderWithQueryClient } from '@/test/render' import { SetupPage } from './setup' // SetupPage dynamically imports @/lib/api once init succeeds to register @@ -14,6 +15,21 @@ vi.mock('@/lib/api', () => ({ registerKeyPair: vi.fn().mockResolvedValue(undefined), invalidateSetupStatusCache: vi.fn(), API_BASE: '', + // The complete step reads the gateway's address for "Add to + // ThinkWatch Lite". + api: vi.fn(async (path: string) => { + if (path === '/api/gateway/info') + return { + public_protocol: 'https', + public_host: 'gateway.example.com', + public_port: 443, + gateway_port: 3000, + } + throw new Error(`unexpected ${path}`) + }), + // No permissions are loaded during setup; the first administrator's + // key is offered all the same. + hasPermission: vi.fn(() => false), })) beforeEach(() => { @@ -93,7 +109,7 @@ describe('SetupPage', () => { }) vi.stubGlobal('fetch', mockFetch) - render() + renderWithQueryClient() // Welcome -> Admin await user.click(screen.getByRole('button', { name: /get started/i })) @@ -113,5 +129,23 @@ describe('SetupPage', () => { expect(screen.getByText('Setup Complete!')).toBeInTheDocument() }) expect(screen.getByDisplayValue('tw-test-key-xyz789')).toBeInTheDocument() + + // The first key can be added to ThinkWatch Lite with the gateway's + // public address, through a hidden iframe: the page stays. + const add = await screen.findByRole('button', { name: 'Add to ThinkWatch Lite' }) + await waitFor(() => expect(add).toBeEnabled()) + const page = window.location.href + await user.click(add) + expect(window.location.href).toBe(page) + expect(document.querySelector('iframe')).toHaveAttribute( + 'src', + 'thinkwatch://import?service=thinkwatch&name=thinkwatch' + + '&url=https%3A%2F%2Fgateway.example.com&key=tw-test-key-xyz789', + ) + expect(screen.getByDisplayValue('tw-test-key-xyz789')).toBeInTheDocument() + expect(screen.getByText('Nothing happened?')).toBeInTheDocument() + expect( + screen.getByRole('link', { name: 'Install ThinkWatch Lite (2026.10.16 or later) first' }), + ).toHaveAttribute('href', 'https://thinkwat.ch/lite') }) }) diff --git a/web/src/routes/setup.tsx b/web/src/routes/setup.tsx index e4d61b02..dace2813 100644 --- a/web/src/routes/setup.tsx +++ b/web/src/routes/setup.tsx @@ -22,6 +22,7 @@ import { import { Check, Copy, Globe, ArrowRight, ArrowLeft, AlertTriangle, AlertCircle, Eye, EyeOff, Wand2 } from 'lucide-react'; import { ThinkWatchMark } from '@/components/brand/think-watch-mark'; import { Alert, AlertDescription } from '@/components/ui/alert'; +import { AddToLite } from '@/components/add-to-lite'; import { API_BASE, registerKeyPair } from '@/lib/api'; const STEPS = ['welcome', 'admin', 'settings', 'complete'] as const; @@ -446,6 +447,10 @@ export function SetupPage() { {copied ? : }
+ {/* The first key opens every surface, the AI gateway included, + and its owner, the first administrator, holds every + permission; none are loaded on this page to ask. */} +
{t('setup.complete.apiKeyWarning')}