diff --git a/CHANGELOG.md b/CHANGELOG.md index 29968f4a..4cd61170 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,59 @@ target. ## [Unreleased] +### 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. + +### Added + +- **"Add to ThinkWatch Lite" beside a key shown once.** The new-key and + rotated-key dialogs and the end of first-run setup have an Add to + ThinkWatch Lite button next to Copy. It opens a `thinkwatch://import` + link that ThinkWatch Lite (2026.10.16 or later) turns into a new upstream + for this gateway with the key filled in. The link is built in the browser + from the key on screen and the gateway's public address, and is sent + nowhere. The button is unavailable, with the reason beside it, for a key + without the `ai_gateway` surface 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 gateway's public base URL, `{"base_url": "https://gateway.example.com"}`, + from `general.public_protocol`, `general.public_host` and + `general.public_port`. What is not set comes from the address the console + was opened at: the scheme from `X-Forwarded-Proto` (else `http`), the + host from `X-Forwarded-Host` or `Host`, the port from the gateway's + listening port. A default port is left out. + +### 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`. +- **`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. + ## [3.5.0] — 2026-10-11 Limits hold where they used to leak: route caps are enforced, a limits 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..34175f4d 100644 --- a/crates/gateway/src/proxy/mod.rs +++ b/crates/gateway/src/proxy/mod.rs @@ -134,6 +134,26 @@ 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, so `/v1/models` lists + /// exactly the models a request would be let through for. + 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 +487,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..ae84b60f 100644 --- a/crates/gateway/src/proxy/models.rs +++ b/crates/gateway/src/proxy/models.rs @@ -1,18 +1,35 @@ //! `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 axum::Json; use axum::extract::State; +use axum::{Extension, Json}; -use super::GatewayState; +use super::{GatewayRequestIdentity, GatewayState}; + +/// The routed models `identity` may call: each name the router serves, +/// put through the same alias mapping and model check a request for it +/// would meet. A listed model is one a request for it is let through for, +/// and a model the key may not use is not listed. +fn callable_models(state: &GatewayState, identity: &GatewayRequestIdentity) -> Vec { + state + .router + .load() + .list_models() + .into_iter() + .filter(|id| identity.may_call(&state.model_mapper.map(id))) + .collect() +} /// GET /v1/models /// -/// 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(); - - let model_objects: Vec = 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 +53,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!({ 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/gateway_info.rs b/crates/server/src/handlers/gateway_info.rs new file mode 100644 index 00000000..4a3c5327 --- /dev/null +++ b/crates/server/src/handlers/gateway_info.rs @@ -0,0 +1,299 @@ +//! `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. + +use axum::Json; +use axum::extract::State; +use axum::http::HeaderMap; +use serde::Serialize; + +use crate::app::AppState; +use crate::middleware::auth_guard::AuthUser; + +#[derive(Debug, Serialize, utoipa::ToSchema)] +pub struct GatewayInfo { + /// The gateway's root as clients reach it: scheme, host and port + /// (left out when it is the scheme's default), no path and no + /// trailing slash — `https://gateway.example.com`, + /// `http://10.0.0.5:3000`. The API paths (`/v1/...`, `/mcp`) go + /// after it. + pub base_url: String, +} + +#[utoipa::path( + get, + path = "/api/gateway/info", + tag = "Gateway", + responses( + (status = 200, description = "The gateway's public base URL", body = GatewayInfo), + (status = 401, description = "Not signed in"), + ), + security(("BearerAuth" = [])) +)] +pub async fn get_gateway_info( + _auth_user: AuthUser, + State(state): State, + headers: HeaderMap, +) -> Json { + let dc = &state.dynamic_config; + let configured = PublicAddress { + protocol: dc + .get_string("general.public_protocol") + .await + .unwrap_or_default(), + host: dc + .get_string("general.public_host") + .await + .unwrap_or_default(), + port: dc.get_i64("general.public_port").await.unwrap_or(0), + }; + Json(GatewayInfo { + base_url: public_base_url(&configured, &headers, state.config.gateway_port), + }) +} + +/// `general.public_protocol`, `general.public_host`, `general.public_port` +/// as stored: empty and 0 mean "not set". +pub(crate) struct PublicAddress { + pub protocol: String, + pub host: String, + pub port: i64, +} + +/// The gateway's base URL from the `general.public_*` settings. A part +/// that is not set comes from the address the console was opened at — +/// what the settings page means by "auto-detect from the browser" — and +/// the port from the gateway's own listening port: +/// +/// - scheme: the setting, else the request's `X-Forwarded-Proto` (the +/// bundled nginx sets it to the scheme the browser used), else `http`; +/// - host: the setting, else `X-Forwarded-Host`, else `Host`, without its +/// port, else `localhost`; +/// - port: the setting when it is 1–65535, else the gateway port; left out +/// when it is the scheme's default. +/// +/// The request headers only fill in what an administrator left unset, and +/// the answer goes back to the same caller, so a forged header misleads no +/// one but the client that sent it. A configured host that is not a bare +/// host name or IP address (a port, a path or anything else that would +/// change the URL's shape) is passed over like an unset one. +pub(crate) fn public_base_url( + configured: &PublicAddress, + headers: &HeaderMap, + gateway_port: u16, +) -> String { + let scheme = match configured.protocol.as_str() { + p @ ("http" | "https") => p, + _ => first_value(headers, "x-forwarded-proto") + .map(|p| p.to_ascii_lowercase()) + .and_then(|p| match p.as_str() { + "https" => Some("https"), + "http" => Some("http"), + _ => None, + }) + .unwrap_or("http"), + }; + let host = bare_host(&configured.host) + .or_else(|| first_value(headers, "x-forwarded-host").and_then(host_of_authority)) + .or_else(|| first_value(headers, "host").and_then(host_of_authority)) + .unwrap_or_else(|| "localhost".to_string()); + let port = u16::try_from(configured.port) + .ok() + .filter(|p| *p > 0) + .unwrap_or(gateway_port); + let default_port = matches!((scheme, port), ("http", 80) | ("https", 443)); + if default_port { + format!("{scheme}://{host}") + } else { + format!("{scheme}://{host}:{port}") + } +} + +/// The first of a header's comma-separated values, trimmed; `None` when +/// absent or empty. +fn first_value<'a>(headers: &'a HeaderMap, name: &str) -> Option<&'a str> { + headers + .get(name)? + .to_str() + .ok()? + .split(',') + .next() + .map(str::trim) + .filter(|v| !v.is_empty()) +} + +/// The host of a `Host`-style authority (`example.com:8080`, +/// `[::1]:3001`), checked like a configured host. +fn host_of_authority(authority: &str) -> Option { + let parsed: axum::http::uri::Authority = authority.parse().ok()?; + bare_host(parsed.host()) +} + +/// `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. +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) +} + +#[cfg(test)] +mod tests { + use super::*; + use axum::http::HeaderValue; + + fn configured(protocol: &str, host: &str, port: i64) -> PublicAddress { + PublicAddress { + protocol: protocol.into(), + host: host.into(), + port, + } + } + + fn headers(pairs: &[(&'static str, &str)]) -> HeaderMap { + let mut h = HeaderMap::new(); + for (k, v) in pairs { + h.insert(*k, HeaderValue::from_str(v).unwrap()); + } + h + } + + #[test] + fn the_settings_make_the_url_and_a_default_port_is_left_out() { + let none = HeaderMap::new(); + assert_eq!( + public_base_url(&configured("https", "gw.example.com", 443), &none, 3000), + "https://gw.example.com" + ); + assert_eq!( + public_base_url(&configured("https", "gw.example.com", 8443), &none, 3000), + "https://gw.example.com:8443" + ); + assert_eq!( + public_base_url(&configured("http", "10.0.0.5", 0), &none, 3000), + "http://10.0.0.5:3000" + ); + assert_eq!( + public_base_url(&configured("http", "gw.internal", 80), &none, 3000), + "http://gw.internal" + ); + // A configured host wins over the request's. + assert_eq!( + public_base_url( + &configured("https", "GW.Example.com", 0), + &headers(&[("host", "console.example.com")]), + 443 + ), + "https://gw.example.com" + ); + } + + #[test] + fn unset_parts_come_from_the_address_the_console_was_opened_at() { + assert_eq!( + public_base_url( + &configured("", "", 0), + &headers(&[ + ("host", "console.example.com:8080"), + ("x-forwarded-proto", "https"), + ]), + 3000 + ), + "https://console.example.com:3000" + ); + assert_eq!( + public_base_url( + &configured("", "", 443), + &headers(&[ + ("host", "internal:3001"), + ("x-forwarded-host", "tw.example.com, proxy.internal"), + ("x-forwarded-proto", "HTTPS"), + ]), + 3000 + ), + "https://tw.example.com" + ); + // No forwarded scheme: plain http, as the console port serves. + assert_eq!( + public_base_url( + &configured("", "", 0), + &headers(&[("host", "localhost:5173")]), + 3000 + ), + "http://localhost:3000" + ); + // Nothing to go on. + assert_eq!( + public_base_url(&configured("", "", 0), &HeaderMap::new(), 3000), + "http://localhost:3000" + ); + // A scheme that is neither is ignored. + assert_eq!( + public_base_url( + &configured("", "gw", 0), + &headers(&[("x-forwarded-proto", "ws")]), + 3000 + ), + "http://gw:3000" + ); + } + + #[test] + fn ipv6_hosts_are_bracketed() { + let none = HeaderMap::new(); + assert_eq!( + public_base_url(&configured("http", "::1", 0), &none, 3000), + "http://[::1]:3000" + ); + assert_eq!( + public_base_url(&configured("http", "[fd00::5]", 0), &none, 3000), + "http://[fd00::5]:3000" + ); + assert_eq!( + public_base_url( + &configured("", "", 0), + &headers(&[("host", "[::1]:3001")]), + 3000 + ), + "http://[::1]:3000" + ); + } + + #[test] + fn a_configured_host_that_is_more_than_a_host_is_passed_over() { + let from_request = headers(&[("host", "console.example.com")]); + for bad in [ + "gw.example.com:8443", + "user@gw.example.com", + "gw.example.com?x=1", + "gw example", + "gw.example.com#top", + "${GATEWAY_HOST}", + ] { + assert_eq!( + public_base_url(&configured("https", bad, 0), &from_request, 443), + "https://console.example.com", + "{bad}" + ); + } + } +} 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..e2b988f6 100644 --- a/crates/test-support/tests/console_admin.rs +++ b/crates/test-support/tests/console_admin.rs @@ -389,6 +389,62 @@ 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_public_gateway_url() { + 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 base_url = |con: TestClient| async move { + let resp = con.get("/api/gateway/info").await.unwrap(); + resp.assert_ok(); + let body: Value = resp.json().unwrap(); + (body["base_url"].as_str().unwrap().to_string(), con) + }; + + 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 (url, con) = base_url(con).await; + assert_eq!(url, "https://gateway.example.com"); + + // What is not set comes from the address the console was opened at. + app.set_setting("general.public_protocol", json!("")).await; + app.set_setting("general.public_host", json!("")).await; + app.set_setting("general.public_port", json!(8443)).await; + con.set_header("x-forwarded-proto", "https"); + con.set_header("x-forwarded-host", "console.example.com:8080"); + let (url, _) = base_url(con).await; + assert_eq!(url, "https://console.example.com:8443"); + + app.console_client() + .get("/api/gateway/info") + .await + .unwrap() + .assert_status(401); +} + // --------------------------------------------------------------------------- // Limits CRUD via console // --------------------------------------------------------------------------- diff --git a/crates/test-support/tests/gateway_authz.rs b/crates/test-support/tests/gateway_authz.rs index db79ed05..fd9f3432 100644 --- a/crates/test-support/tests/gateway_authz.rs +++ b/crates/test-support/tests/gateway_authz.rs @@ -10,6 +10,8 @@ //! 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. use serde_json::Value; use think_watch_test_support::client::TestResponse; @@ -382,3 +384,103 @@ 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; +} diff --git a/web/src/components/add-to-lite.tsx b/web/src/components/add-to-lite.tsx new file mode 100644 index 00000000..59eaa426 --- /dev/null +++ b/web/src/components/add-to-lite.tsx @@ -0,0 +1,97 @@ +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 { LITE_MIN_VERSION, liteDownloadUrl, liteImportLink } from '@/lib/lite-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; + /// The screen's Copy button, which goes beside "Add to ThinkWatch Lite". + children?: ReactNode; +} + +/// "Add to ThinkWatch Lite" beside a newly shown key's Copy button. It +/// opens a `thinkwatch://import` link with this gateway's address and the +/// key, built here in the browser. 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, children }: AddToLiteProps) { + const { t, i18n } = useTranslation(); + const { baseUrl, failed } = useGatewayUrl(); + const [opened, setOpened] = useState(false); + const reasonId = useId(); + + const built = baseUrl ? 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 (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.ts b/web/src/hooks/use-gateway-url.ts new file mode 100644 index 00000000..c2cfcc7c --- /dev/null +++ b/web/src/hooks/use-gateway-url.ts @@ -0,0 +1,24 @@ +import { useQuery } from '@tanstack/react-query'; +import { api } from '@/lib/api'; + +interface GatewayInfo { + /// The gateway's root as clients reach it, e.g. `https://gateway.example.com`. + base_url: string; +} + +/// The gateway's public base URL (`GET /api/gateway/info`), which any +/// signed-in user may read. `undefined` until it has loaded, and when it +/// 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 | undefined; failed: boolean } { + const query = useQuery({ + queryKey: ['gateway', 'info'], + queryFn: ({ signal }) => + api('/api/gateway/info', { signal, no401Redirect: true }), + staleTime: 5 * 60_000, + }); + return { baseUrl: query.data?.base_url, failed: query.isError }; +} diff --git a/web/src/i18n/en.json b/web/src/i18n/en.json index 80a20942..fa3048a2 100644 --- a/web/src/i18n/en.json +++ b/web/src/i18n/en.json @@ -2029,7 +2029,22 @@ "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.", + "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..11cbe225 100644 --- a/web/src/i18n/zh.json +++ b/web/src/i18n/zh.json @@ -2029,7 +2029,22 @@ "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。", + "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/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..32bb5254 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,7 @@ 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 { base_url: 'https://gateway.example.com' } return { data: keys, total: keys.length, page: 1, page_size: 20 } }) } @@ -150,6 +152,43 @@ describe('ApiKeysPage', () => { }) }) + it('offers a new key to ThinkWatch Lite beside Copy', 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('link', { name: 'Add to ThinkWatch Lite' }) + expect(add).toHaveAttribute( + 'href', + 'thinkwatch://import?service=thinkwatch&name=thinkwatch' + + '&url=https%3A%2F%2Fgateway.example.com&key=tw-new-key', + ) + expect(screen.getByRole('button', { name: 'Copy to Clipboard' })).toBeInTheDocument() + }) + + 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.tsx b/web/src/routes/guide.tsx index 0166bbab..19b064c2 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,21 @@ curl -X POST ${mcpUrl}/mcp \\ export function GuidePage() { const { t } = useTranslation(); - const gatewayUrl = useGatewayUrl(); + const { baseUrl, failed } = useGatewayUrl(); + const gatewayUrl = baseUrl ?? ''; const mcpUrl = gatewayUrl; // MCP endpoint is on the gateway port + // Until the address has loaded, its boxes hold a placeholder. + const urlBox = (suffix = '') => + baseUrl || failed ? ( + <> + + {baseUrl ? `${baseUrl}${suffix}` : '—'} + + + + ) : ( + + ); return (
@@ -696,10 +735,7 @@ export function GuidePage() {
- - {gatewayUrl} - - + {urlBox()}

{t('guide.gatewayUrlDesc')}

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

{t('guide.mcpEndpointDesc')}

@@ -795,8 +828,9 @@ export function GuidePage() { {/* AI Gateway tool tabs */} - + + {t('guide.lite')} {t('guide.claudeCode')} {t('guide.cursor')} {t('guide.continue')} @@ -806,6 +840,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..dab73836 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,12 @@ 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 { base_url: 'https://gateway.example.com' } + throw new Error(`unexpected ${path}`) + }), })) beforeEach(() => { @@ -93,7 +100,7 @@ describe('SetupPage', () => { }) vi.stubGlobal('fetch', mockFetch) - render() + renderWithQueryClient() // Welcome -> Admin await user.click(screen.getByRole('button', { name: /get started/i })) @@ -113,5 +120,21 @@ 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. + const add = await screen.findByRole('link', { name: 'Add to ThinkWatch Lite' }) + expect(add).toHaveAttribute( + 'href', + 'thinkwatch://import?service=thinkwatch&name=thinkwatch' + + '&url=https%3A%2F%2Fgateway.example.com&key=tw-test-key-xyz789', + ) + // jsdom cannot follow the link; the app would open. + add.addEventListener('click', (e) => e.preventDefault()) + await user.click(add) + 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..587e052b 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,8 @@ export function SetupPage() { {copied ? : }
+ {/* The first key opens every surface, the AI gateway included. */} +
{t('setup.complete.apiKeyWarning')}