From f3e89ab42b95949828ed2752881b918dcb481f67 Mon Sep 17 00:00:00 2001 From: ctkm-aelf <230958228+ctkm-aelf@users.noreply.github.com> Date: Mon, 28 Sep 2026 22:12:43 +0800 Subject: [PATCH 01/36] feat(services): refresh AI service cards, filters and scoped details --- backend/src/handlers/keys.rs | 125 +++ backend/src/handlers/user_endpoints.rs | 70 +- backend/src/handlers/user_services_handler.rs | 23 + backend/src/handlers/users.rs | 300 +++++- backend/src/models/user.rs | 65 ++ backend/src/routes.rs | 4 + backend/src/services/mod.rs | 1 + backend/src/services/service_history/read.rs | 13 +- backend/src/services/service_history/tests.rs | 72 +- .../src/services/user_preferences_service.rs | 51 + docs/AI_SERVICES_ARCHITECTURE.md | 114 +- docs/SERVICE_HISTORY.md | 4 +- docs/plans/ai-service-connection-user-flow.md | 150 +++ docs/plans/consolidated-services-flow.md | 650 ++++++++++++ docs/plans/local-routing-preview.md | 246 +++++ .../references/services-card-reference.html | 63 ++ .../services-consolidated-fable-review.md | 657 ++++++++++++ .../slug-connection-resolution-proposal.md | 483 +++++++++ docs/plans/slug-routing-fable-review.md | 494 +++++++++ frontend/dev/routing-preview.ts | 150 +++ frontend/src/app.css | 82 ++ .../dashboard/grouped-service-cards.tsx | 405 +++++++ .../dashboard/service-connection-table.tsx | 373 +++++++ .../dashboard/service-filter-multiselect.tsx | 155 +++ .../dashboard/service-history.test.tsx | 2 +- .../components/dashboard/service-history.tsx | 4 +- .../dashboard/service-owner-avatar.tsx | 46 + .../service-pool-routing-preview.tsx | 333 ++++++ .../service-routing-preview.test.tsx | 999 ++++++++++++++++++ .../dashboard/service-routing-preview.tsx | 124 +++ .../dashboard/service-saved-views.tsx | 145 +++ .../dashboard/service-view-toolbar.tsx | 414 ++++++++ .../data-table/data-table-controls.tsx | 71 +- .../components/layout/dashboard-layout.tsx | 5 +- .../hooks/use-service-card-transition.test.ts | 134 +++ .../src/hooks/use-service-card-transition.ts | 81 ++ frontend/src/hooks/use-service-view.test.tsx | 208 ++++ frontend/src/hooks/use-service-view.ts | 98 ++ frontend/src/lib/connection-access.ts | 11 + frontend/src/lib/service-groups.ts | 43 + .../src/lib/service-routing-preview.test.ts | 225 ++++ frontend/src/lib/service-routing-preview.ts | 189 ++++ frontend/src/lib/service-view.test.ts | 185 ++++ frontend/src/lib/service-view.ts | 54 + frontend/src/pages/key-detail.test.tsx | 17 +- frontend/src/pages/key-detail.tsx | 68 +- frontend/src/pages/keys.test.tsx | 185 ++-- frontend/src/pages/keys.tsx | 530 ++-------- frontend/src/pages/lazy.ts | 5 +- frontend/src/pages/login.tsx | 5 + frontend/src/pages/service-overview.test.tsx | 224 ++++ frontend/src/pages/service-overview.tsx | 180 ++++ frontend/src/router.tsx | 11 +- frontend/src/schemas/service-view.ts | 91 ++ frontend/src/stores/auth-store.ts | 13 +- .../src/stores/service-card-view-store.ts | 13 + frontend/src/types/api.ts | 2 + frontend/src/types/keys.ts | 1 + frontend/test/routing-preview-gateway.test.ts | 168 +++ frontend/vite.config.ts | 7 +- 60 files changed, 9055 insertions(+), 586 deletions(-) create mode 100644 backend/src/services/user_preferences_service.rs create mode 100644 docs/plans/ai-service-connection-user-flow.md create mode 100644 docs/plans/consolidated-services-flow.md create mode 100644 docs/plans/local-routing-preview.md create mode 100644 docs/plans/references/services-card-reference.html create mode 100644 docs/plans/services-consolidated-fable-review.md create mode 100644 docs/plans/slug-connection-resolution-proposal.md create mode 100644 docs/plans/slug-routing-fable-review.md create mode 100644 frontend/dev/routing-preview.ts create mode 100644 frontend/src/components/dashboard/grouped-service-cards.tsx create mode 100644 frontend/src/components/dashboard/service-connection-table.tsx create mode 100644 frontend/src/components/dashboard/service-filter-multiselect.tsx create mode 100644 frontend/src/components/dashboard/service-owner-avatar.tsx create mode 100644 frontend/src/components/dashboard/service-pool-routing-preview.tsx create mode 100644 frontend/src/components/dashboard/service-routing-preview.test.tsx create mode 100644 frontend/src/components/dashboard/service-routing-preview.tsx create mode 100644 frontend/src/components/dashboard/service-saved-views.tsx create mode 100644 frontend/src/components/dashboard/service-view-toolbar.tsx create mode 100644 frontend/src/hooks/use-service-card-transition.test.ts create mode 100644 frontend/src/hooks/use-service-card-transition.ts create mode 100644 frontend/src/hooks/use-service-view.test.tsx create mode 100644 frontend/src/hooks/use-service-view.ts create mode 100644 frontend/src/lib/connection-access.ts create mode 100644 frontend/src/lib/service-groups.ts create mode 100644 frontend/src/lib/service-routing-preview.test.ts create mode 100644 frontend/src/lib/service-routing-preview.ts create mode 100644 frontend/src/lib/service-view.test.ts create mode 100644 frontend/src/lib/service-view.ts create mode 100644 frontend/src/pages/service-overview.test.tsx create mode 100644 frontend/src/pages/service-overview.tsx create mode 100644 frontend/src/schemas/service-view.ts create mode 100644 frontend/src/stores/service-card-view-store.ts create mode 100644 frontend/test/routing-preview-gateway.test.ts diff --git a/backend/src/handlers/keys.rs b/backend/src/handlers/keys.rs index d566579a1..0762c34c7 100644 --- a/backend/src/handlers/keys.rs +++ b/backend/src/handlers/keys.rs @@ -413,6 +413,8 @@ impl std::fmt::Debug for CreateKeyRequest { #[derive(Debug, Serialize, ToSchema)] pub struct KeyResponse { + /// Whether the current caller may inspect and edit connection configuration. + pub can_edit_configuration: bool, #[serde(skip_serializing_if = "Option::is_none")] pub authorship: Option, pub id: String, @@ -1303,6 +1305,9 @@ pub async fn list_keys( grants.memberships(), ) .await?; + for key in &mut keys { + restrict_connection_configuration(key); + } Ok(Json(KeyListResponse { keys })) } @@ -1345,6 +1350,7 @@ pub async fn get_key( std::slice::from_mut(&mut response), ) .await?; + restrict_connection_configuration(&mut response); Ok(Json(response)) } @@ -2673,6 +2679,7 @@ fn key_response_from_result(result: &unified_key_service::CreateKeyResult) -> Ke .to_string(); KeyResponse { + can_edit_configuration: true, authorship: None, recommended_skill_refs: None, skills_revision: None, @@ -2811,7 +2818,10 @@ fn key_response_from_view(view: unified_key_service::KeyView) -> KeyResponse { .is_some_and(|node_id| !node_id.is_empty()); let endpoint_url = (!view.auto_connected).then_some(view.endpoint_url); + let credential_source: crate::handlers::user_services_handler::CredentialSourceResponse = + view.credential_source.clone().into(); KeyResponse { + can_edit_configuration: !view.auto_connected && credential_source.can_edit_configuration(), authorship: None, recommended_skill_refs: None, skills_revision: None, @@ -2903,6 +2913,31 @@ fn key_response_from_view(view: unified_key_service::KeyView) -> KeyResponse { } } +/// Apply after discovery enrichment, which can add instance configuration. +/// Execution and authorization-evidence projections keep their own contracts. +fn restrict_connection_configuration(key: &mut KeyResponse) { + if key.can_edit_configuration { + return; + } + key.endpoint_url = None; + key.auth_key_name.clear(); + key.identity_jwt_audience = None; + key.delegation_token_scope.clear(); + key.custom_user_agent = None; + key.oauth_client_id = None; + key.default_request_headers = None; + key.ws_frame_injections.clear(); + key.ssh_host = None; + key.ssh_port = None; + key.ssh_ca_public_key = None; + key.ssh_allowed_principals = None; + key.ssh_certificate_ttl_minutes = None; + key.openapi_spec_url = None; + key.permission_setup_url = None; + key.permission_setup_scopes = None; + key.error_message = None; +} + async fn enrich_key_node_metadata( db: &mongodb::Database, ws_manager: &crate::services::node_ws_manager::NodeWsManager, @@ -5498,6 +5533,96 @@ mod tests { assert_eq!(old.keys[0].endpoint_url, "https://api.example.com"); } + #[tokio::test] + async fn connection_configuration_requires_editor_for_list_and_detail() { + let db = + crate::test_utils::connect_transaction_test_database("configuration_read_acl").await; + let actor = uuid::Uuid::new_v4().to_string(); + let org = uuid::Uuid::new_v4().to_string(); + let service = uuid::Uuid::new_v4().to_string(); + insert_user(&db, &actor, UserType::Person).await; + insert_user(&db, &org, UserType::Org).await; + insert_key_fixture(&db, &org, &service, "shared", "Shared").await; + db.collection::("user_services") + .update_one( + doc! { "_id": &service }, + doc! { "$set": { "custom_user_agent": "private-client" } }, + ) + .await + .unwrap(); + let membership = test_membership(&org, &actor, OrgRole::Admin, Some(vec![service.clone()])); + db.collection::("org_memberships") + .insert_one(&membership) + .await + .unwrap(); + let state = test_app_state(db.clone()); + for (role, editable) in [("admin", true), ("member", false), ("viewer", false)] { + db.collection::("org_memberships") + .update_one( + doc! { "_id": &membership.id }, + doc! { "$set": { "role": role } }, + ) + .await + .unwrap(); + let Json(detail) = super::get_key( + State(state.clone()), + test_auth_user(&actor), + Path(service.clone()), + ) + .await + .unwrap(); + assert_eq!(detail.can_edit_configuration, editable); + assert_eq!(detail.endpoint_url.is_some(), editable); + assert!( + detail.authorship.is_some(), + "all scoped readers receive history metadata" + ); + let Json(list) = super::list_keys(State(state.clone()), test_auth_user(&actor)) + .await + .unwrap(); + let row = list.keys.iter().find(|row| row.id == service).unwrap(); + assert_eq!(row.can_edit_configuration, editable); + assert_eq!(row.endpoint_url.is_some(), editable); + assert!(row.authorship.is_some()); + assert_eq!(row.custom_user_agent.is_some(), editable); + let Json(services) = crate::handlers::user_services_handler::list_user_services( + State(state.clone()), + test_auth_user(&actor), + ) + .await + .unwrap(); + let row = services + .services + .iter() + .find(|row| row.id == service) + .unwrap(); + assert_eq!(row.custom_user_agent.is_some(), editable); + if !editable { + assert!(row.default_request_headers.is_none()); + assert!(row.ws_frame_injections.is_empty()); + assert!(row.auth_key_name.is_empty()); + } + } + } + + #[test] + fn configuration_projection_removes_private_values_but_keeps_usage_identity() { + let mut response = poisoned_key_response(); + response.can_edit_configuration = false; + response.openapi_spec_url = Some("https://private.example/spec".into()); + response.ssh_host = Some("private.internal".into()); + response.custom_user_agent = Some("custom-client".into()); + super::restrict_connection_configuration(&mut response); + assert!(response.endpoint_url.is_none()); + assert!(response.openapi_spec_url.is_none()); + assert!(response.ssh_host.is_none()); + assert!(response.custom_user_agent.is_none()); + assert!(response.default_request_headers.is_none()); + assert!(response.ws_frame_injections.is_empty()); + assert_eq!(response.slug, "example"); + assert_eq!(response.id, "service-1"); + } + // ---- get_key org scoping tests ---- #[tokio::test] diff --git a/backend/src/handlers/user_endpoints.rs b/backend/src/handlers/user_endpoints.rs index 5558734a0..a1a057052 100644 --- a/backend/src/handlers/user_endpoints.rs +++ b/backend/src/handlers/user_endpoints.rs @@ -227,7 +227,18 @@ pub async fn list_endpoints( (actor, org_service::OwnerAccess::Direct) }; let mut endpoints = user_endpoint_service::list_endpoints(&state.db, &user_id_str).await?; - if let Some(scope) = auth_user.api_key_service_scope() { + let owner_scope = match &access { + org_service::OwnerAccess::AsOrgAdmin { + allowed_service_ids, + .. + } + | org_service::OwnerAccess::AsOrgMember { + allowed_service_ids, + .. + } => allowed_service_ids.as_deref(), + _ => None, + }; + if let Some(scope) = auth_user.api_key_service_scope().or(owner_scope) { let scope: Vec = scope .iter() .filter(|id| access.allows_resource(id)) @@ -552,7 +563,10 @@ pub async fn list_openapi_endpoints( Ok(Json(UserEndpointOperationsResponse { endpoint_id: endpoint.id, - openapi_spec_url: Some(spec_url.clone()), + openapi_spec_url: org_service::resolve_owner_access(&state.db, &actor, &endpoint.user_id) + .await? + .can_write() + .then(|| spec_url.clone()), operations, })) } @@ -616,6 +630,58 @@ mod tests { assert_eq!(resp.endpoints[0].label, "My Endpoint"); } + #[tokio::test] + async fn scoped_admin_endpoint_list_hides_other_connection_targets() { + use crate::models::org_membership::{OrgMembership, OrgRole}; + let db = + crate::test_utils::connect_transaction_test_database("scoped_endpoint_targets").await; + let actor = uuid::Uuid::new_v4().to_string(); + let org = uuid::Uuid::new_v4().to_string(); + db.collection::(USERS) + .insert_one(test_user(&actor, UserType::Person)) + .await + .unwrap(); + db.collection::(USERS) + .insert_one(test_user(&org, UserType::Org)) + .await + .unwrap(); + for id in ["allowed", "hidden"] { + db.collection::(USER_ENDPOINTS) + .insert_one(test_user_endpoint( + id, + &org, + id, + "https://private.internal", + None, + None, + )) + .await + .unwrap(); + db.collection::(USER_SERVICES) + .insert_one(test_user_service(id, &org, id, id, None, None)) + .await + .unwrap(); + } + db.collection::("org_memberships") + .insert_one(crate::test_utils::test_membership( + &org, + &actor, + OrgRole::Admin, + Some(vec!["allowed".into()]), + )) + .await + .unwrap(); + let Json(response) = list_endpoints( + State(test_app_state(db)), + test_auth_user(&actor), + Query(EndpointListQuery { org_id: Some(org) }), + ) + .await + .unwrap(); + assert_eq!(response.endpoints.len(), 1); + assert_eq!(response.endpoints[0].id, "allowed"); + } + #[tokio::test] async fn list_endpoints_empty_for_new_user() { let Some(db) = connect_test_database("h_user_ep_list_empty").await else { diff --git a/backend/src/handlers/user_services_handler.rs b/backend/src/handlers/user_services_handler.rs index 235eb3c42..df28c28d6 100644 --- a/backend/src/handlers/user_services_handler.rs +++ b/backend/src/handlers/user_services_handler.rs @@ -172,6 +172,21 @@ pub enum CredentialSourceResponse { }, } +impl CredentialSourceResponse { + /// Listing and detail resolvers have already applied the service scope. + pub fn can_edit_configuration(&self) -> bool { + matches!( + self, + Self::Personal + | Self::Org { + role: OrgRoleResponse::Admin, + allowed: true, + .. + } + ) + } +} + #[derive(Debug, Clone, Copy, Serialize, ToSchema)] #[serde(rename_all = "snake_case")] pub enum OrgRoleResponse { @@ -310,6 +325,14 @@ pub async fn list_user_services( let mut response = user_service_with_source_response(&state.config, item); response.label = label; response.catalog_service_name = catalog_service_name; + if !response.credential_source.can_edit_configuration() { + response.auth_key_name.clear(); + response.identity_jwt_audience = None; + response.delegation_token_scope.clear(); + response.custom_user_agent = None; + response.default_request_headers = None; + response.ws_frame_injections.clear(); + } response }) .collect(); diff --git a/backend/src/handlers/users.rs b/backend/src/handlers/users.rs index 4c141ce1e..2a58a6e12 100644 --- a/backend/src/handlers/users.rs +++ b/backend/src/handlers/users.rs @@ -5,7 +5,10 @@ use serde::{Deserialize, Serialize}; use crate::AppState; use crate::errors::{AppError, AppResult}; -use crate::models::user::{COLLECTION_NAME as USERS, User}; +use crate::models::user::{ + COLLECTION_NAME as USERS, ServiceViewPreferences, ServiceViewSource, ServiceViewState, + ServiceViewType, User, +}; use crate::mw::auth::AuthUser; use crate::services::{admin_user_service, audit_service, role_service, telemetry_erasure_service}; use crate::telemetry::{TelemetryContext, TelemetryEvent, emit_event}; @@ -22,9 +25,81 @@ pub struct OnboardingStateResponse { /// User-scoped config / preferences surfaced on `GET /users/me`. #[derive(Debug, Serialize)] pub struct ProfileConfigResponse { + pub services_view: Option, pub onboarding: OnboardingStateResponse, } +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct SaveServiceViewRequest { + #[serde( + default, + alias = "organization_id", + deserialize_with = "crate::models::user::deserialize_service_view_ids" + )] + pub organization_ids: Vec, + #[serde( + default, + alias = "service_group_id", + deserialize_with = "crate::models::user::deserialize_service_view_ids" + )] + pub service_group_ids: Vec, + pub search: String, + pub source: ServiceViewSource, + pub state: ServiceViewState, + pub service_type: ServiceViewType, + pub show_auto_connected: bool, +} + +#[derive(Debug, Serialize)] +pub struct ServiceViewResponse { + pub organization_ids: Vec, + pub service_group_ids: Vec, + pub search: String, + pub source: ServiceViewSource, + pub state: ServiceViewState, + pub service_type: ServiceViewType, + pub show_auto_connected: bool, +} + +impl From for ServiceViewResponse { + fn from(value: ServiceViewPreferences) -> Self { + Self { + organization_ids: value.organization_ids, + service_group_ids: value.service_group_ids, + search: value.search, + source: value.source, + state: value.state, + service_type: value.service_type, + show_auto_connected: value.show_auto_connected, + } + } +} + +/// PUT /api/v1/users/me/preferences/services +pub async fn save_services_view( + State(state): State, + auth_user: AuthUser, + Json(body): Json, +) -> AppResult> { + let preferences = ServiceViewPreferences { + organization_ids: body.organization_ids, + service_group_ids: body.service_group_ids, + search: body.search, + source: body.source, + state: body.state, + service_type: body.service_type, + show_auto_connected: body.show_auto_connected, + }; + let saved = crate::services::user_preferences_service::save_services_view( + &state.db, + &auth_user.user_id.to_string(), + preferences, + ) + .await?; + Ok(Json(saved.into())) +} + #[derive(Debug, Serialize)] pub struct UserCapabilitiesResponse { pub billing_available: bool, @@ -155,6 +230,7 @@ pub async fn get_me( updated_at: user_model.updated_at.to_rfc3339(), last_login_at: user_model.last_login_at.map(|t| t.to_rfc3339()), profile_config: ProfileConfigResponse { + services_view: user_model.profile_config.services_view.map(Into::into), onboarding: OnboardingStateResponse { ai_services_completed_at: user_model .profile_config @@ -382,6 +458,223 @@ mod tests { use crate::test_utils::{connect_test_database, test_app_state, test_auth_user, test_user}; use uuid::Uuid; + fn service_view_request() -> SaveServiceViewRequest { + SaveServiceViewRequest { + organization_ids: vec!["org-selection".into(), "org-second".into()], + service_group_ids: vec!["catalog:service-selection".into(), "catalog:second".into()], + search: "team".into(), + source: ServiceViewSource::Org, + state: ServiceViewState::Enabled, + service_type: ServiceViewType::Http, + show_auto_connected: false, + } + } + + #[tokio::test] + async fn services_view_round_trips_without_overwriting_other_settings_or_users() { + let db = connect_test_database("services_view_round_trip") + .await + .unwrap(); + let user_id = Uuid::new_v4().to_string(); + let other_id = Uuid::new_v4().to_string(); + let mut user = test_user(&user_id, UserType::Person); + user.profile_config.onboarding.ai_services_completed_at = + chrono::DateTime::from_timestamp_millis(Utc::now().timestamp_millis()); + user.profile_config + .release_integrity + .remote_credential_integrity_verification_opt_out = true; + db.collection::(USERS) + .insert_many([user.clone(), test_user(&other_id, UserType::Person)]) + .await + .unwrap(); + db.collection::(USERS) + .update_one( + doc! { "_id": &user_id }, + doc! { "$set": { "profile_config.future_setting": "preserve" } }, + ) + .await + .unwrap(); + role_service::seed_system_roles(&db).await.unwrap(); + let state = test_app_state(db.clone()); + let before = get_me(State(state.clone()), test_auth_user(&user_id)) + .await + .unwrap() + .0; + assert!(before.profile_config.services_view.is_none()); + let saved = save_services_view( + State(state.clone()), + test_auth_user(&user_id), + Json(service_view_request()), + ) + .await + .unwrap() + .0; + let loaded = get_me(State(state.clone()), test_auth_user(&user_id)) + .await + .unwrap() + .0; + assert_eq!( + serde_json::to_value(&saved).unwrap(), + serde_json::to_value(loaded.profile_config.services_view.unwrap()).unwrap() + ); + let persisted = db + .collection::(USERS) + .find_one(doc! { "_id": &user_id }) + .await + .unwrap() + .unwrap(); + assert_eq!( + persisted.profile_config.onboarding, + user.profile_config.onboarding + ); + assert_eq!( + persisted.profile_config.release_integrity, + user.profile_config.release_integrity + ); + let raw = db + .collection::(USERS) + .find_one(doc! { "_id": &user_id }) + .await + .unwrap() + .unwrap(); + assert_eq!( + raw.get_document("profile_config") + .unwrap() + .get_str("future_setting") + .unwrap(), + "preserve" + ); + let other = get_me(State(state.clone()), test_auth_user(&other_id)) + .await + .unwrap() + .0; + assert!(other.profile_config.services_view.is_none()); + + let mut clear = service_view_request(); + clear.search.clear(); + clear.organization_ids.clear(); + clear.service_group_ids.clear(); + clear.source = ServiceViewSource::All; + clear.state = ServiceViewState::All; + clear.service_type = ServiceViewType::All; + clear.show_auto_connected = true; + let _ = save_services_view(State(state.clone()), test_auth_user(&user_id), Json(clear)) + .await + .unwrap(); + let reset = get_me(State(state), test_auth_user(&user_id)) + .await + .unwrap() + .0; + assert_eq!( + reset.profile_config.services_view.unwrap().source, + ServiceViewSource::All + ); + } + + #[tokio::test] + async fn services_view_validates_and_supports_legacy_profiles() { + let db = connect_test_database("services_view_legacy").await.unwrap(); + let user_id = Uuid::new_v4().to_string(); + let mut raw = bson::to_document(&test_user(&user_id, UserType::Person)).unwrap(); + raw.remove("profile_config"); + db.collection::(USERS) + .insert_one(raw) + .await + .unwrap(); + let state = test_app_state(db.clone()); + let mut invalid = service_view_request(); + invalid.search = "x".repeat(201); + assert!(matches!( + save_services_view( + State(state.clone()), + test_auth_user(&user_id), + Json(invalid) + ) + .await, + Err(AppError::ValidationError(_)) + )); + for (organizations, services) in [ + (vec!["org".to_string(); 101], vec![]), + (vec![], vec![" ".to_string()]), + (vec![], vec!["x".repeat(129)]), + ] { + let mut invalid = service_view_request(); + invalid.organization_ids = organizations; + invalid.service_group_ids = services; + assert!(matches!( + save_services_view( + State(state.clone()), + test_auth_user(&user_id), + Json(invalid) + ) + .await, + Err(AppError::ValidationError(_)) + )); + } + let unchanged = db + .collection::(USERS) + .find_one(doc! { "_id": &user_id }) + .await + .unwrap() + .unwrap(); + assert!(!unchanged.contains_key("profile_config")); + let _ = save_services_view( + State(state.clone()), + test_auth_user(&user_id), + Json(service_view_request()), + ) + .await + .unwrap(); + let loaded = db + .collection::(USERS) + .find_one(doc! { "_id": &user_id }) + .await + .unwrap() + .unwrap(); + assert_eq!(loaded.profile_config.services_view.unwrap().search, "team"); + assert!(matches!( + save_services_view( + State(state), + test_auth_user(&Uuid::new_v4().to_string()), + Json(service_view_request()) + ) + .await, + Err(AppError::NotFound(_)) + )); + } + + #[test] + fn services_view_reads_legacy_selections_and_writes_arrays() { + let legacy = serde_json::json!({ "search": "", "organization_id": "org-1", "service_group_id": null, + "source": "all", "state": "all", "service_type": "all", "show_auto_connected": true }); + let persisted: ServiceViewPreferences = serde_json::from_value(legacy.clone()).unwrap(); + assert_eq!(persisted.organization_ids, vec!["org-1"]); + assert!(persisted.service_group_ids.is_empty()); + let request: SaveServiceViewRequest = serde_json::from_value(legacy).unwrap(); + assert_eq!(request.organization_ids, vec!["org-1"]); + let response = serde_json::to_value(ServiceViewResponse::from(persisted)).unwrap(); + assert_eq!(response["organization_ids"], serde_json::json!(["org-1"])); + assert_eq!(response["service_group_ids"], serde_json::json!([])); + assert!(response.get("organization_id").is_none()); + } + + #[test] + fn services_view_rejects_unknown_filter_values_and_owner_injection() { + let body = serde_json::json!({ "search": "", "source": "all", "state": "all", + "service_type": "all", "show_auto_connected": true }); + for (field, value) in [ + ("source", "admin"), + ("state", "healthy"), + ("service_type", "ftp"), + ("user_id", "someone-else"), + ] { + let mut invalid = body.clone(); + invalid[field] = value.into(); + assert!(serde_json::from_value::(invalid).is_err()); + } + assert!(serde_json::from_value::(body).is_ok()); + } + #[tokio::test] async fn get_me_derives_platform_role_fields_from_rbac_membership() { let Some(db) = connect_test_database("users_me_platform_role").await else { @@ -596,6 +889,7 @@ mod tests { updated_at: "2025-01-01T00:00:00+00:00".to_string(), last_login_at: Some("2025-06-01T12:00:00+00:00".to_string()), profile_config: ProfileConfigResponse { + services_view: None, onboarding: OnboardingStateResponse { ai_services_completed_at: Some("2025-03-15T10:00:00+00:00".to_string()), }, @@ -645,6 +939,7 @@ mod tests { updated_at: "2025-06-01T00:00:00+00:00".to_string(), last_login_at: None, profile_config: ProfileConfigResponse { + services_view: None, onboarding: OnboardingStateResponse { ai_services_completed_at: None, }, @@ -685,6 +980,7 @@ mod tests { updated_at: "2026-01-01T00:00:01Z".to_string(), last_login_at: None, profile_config: ProfileConfigResponse { + services_view: None, onboarding: OnboardingStateResponse { ai_services_completed_at: None, }, @@ -728,6 +1024,7 @@ mod tests { #[test] fn profile_config_response_serialization() { let resp = ProfileConfigResponse { + services_view: None, onboarding: OnboardingStateResponse { ai_services_completed_at: Some("2025-01-01T00:00:00+00:00".to_string()), }, @@ -863,6 +1160,7 @@ mod tests { updated_at: "2025-01-01T00:00:00+00:00".to_string(), last_login_at: None, profile_config: ProfileConfigResponse { + services_view: None, onboarding: OnboardingStateResponse { ai_services_completed_at: None, }, diff --git a/backend/src/models/user.rs b/backend/src/models/user.rs index beb996871..3d3308344 100644 --- a/backend/src/models/user.rs +++ b/backend/src/models/user.rs @@ -81,6 +81,71 @@ pub struct UserProfileConfig { pub onboarding: OnboardingState, #[serde(default)] pub release_integrity: ReleaseIntegrityProfileConfig, + #[serde(default)] + pub services_view: Option, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ServiceViewSource { + All, + Personal, + Org, + Platform, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ServiceViewState { + All, + Enabled, + Disabled, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ServiceViewType { + All, + Http, + Ssh, +} + +pub(crate) fn deserialize_service_view_ids<'de, D>(deserializer: D) -> Result, D::Error> +where + D: serde::Deserializer<'de>, +{ + #[derive(Deserialize)] + #[serde(untagged)] + enum Selection { + One(String), + Many(Vec), + } + Ok(match Option::::deserialize(deserializer)? { + Some(Selection::One(id)) => vec![id], + Some(Selection::Many(ids)) => ids, + None => Vec::new(), + }) +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct ServiceViewPreferences { + #[serde( + default, + alias = "organization_id", + deserialize_with = "crate::models::user::deserialize_service_view_ids" + )] + pub organization_ids: Vec, + #[serde( + default, + alias = "service_group_id", + deserialize_with = "crate::models::user::deserialize_service_view_ids" + )] + pub service_group_ids: Vec, + pub search: String, + pub source: ServiceViewSource, + pub state: ServiceViewState, + pub service_type: ServiceViewType, + pub show_auto_connected: bool, } #[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] diff --git a/backend/src/routes.rs b/backend/src/routes.rs index 281f07616..855a43302 100644 --- a/backend/src/routes.rs +++ b/backend/src/routes.rs @@ -517,6 +517,10 @@ fn build_router_internal(router_state: Option) -> (Router, R .route("/me", get(handlers::users::get_me)) .route("/me", put(handlers::users::update_me)) .route("/me", delete(handlers::users::delete_me)) + .route( + "/me/preferences/services", + put(handlers::users::save_services_view), + ) // Assistant postcondition evidence. These MUST be mounted on the // production router: a browser journey that proves success by a 404 // cannot distinguish "resource absent" from "route absent", so an diff --git a/backend/src/services/mod.rs b/backend/src/services/mod.rs index 22053afd5..b332e0628 100644 --- a/backend/src/services/mod.rs +++ b/backend/src/services/mod.rs @@ -168,6 +168,7 @@ pub mod url_validation; pub mod user_api_key_service; pub mod user_credentials_service; pub mod user_endpoint_service; +pub mod user_preferences_service; pub mod user_service_service; pub mod user_token_service; pub mod webhook_delivery_service; diff --git a/backend/src/services/service_history/read.rs b/backend/src/services/service_history/read.rs index 672576511..c268b02e8 100644 --- a/backend/src/services/service_history/read.rs +++ b/backend/src/services/service_history/read.rs @@ -1,7 +1,7 @@ use crate::{ errors::{AppError, AppResult}, models::{ - org_membership::{OrgMembership, OrgRole}, + org_membership::OrgMembership, service_change_event::{COLLECTION_NAME, ServiceChangeEvent}, user::{COLLECTION_NAME as USERS, User}, user_service::UserService, @@ -44,7 +44,7 @@ pub async fn can_read( return Ok(false); } let access = org_service::resolve_owner_access(db, reader.actor_id, owner).await?; - Ok(access.can_write() && access.allows_resource(id)) + Ok(access.can_read() && access.allows_resource(id)) } pub async fn summaries( @@ -75,7 +75,6 @@ pub async fn summaries( m.org_user_id == owner.id && m.member_user_id == reader.actor_id && m.revoked_at.is_none() - && m.role == OrgRole::Admin }) { let scope = crate::services::org_role_scope_service::effective_scope_for_membership( @@ -279,7 +278,7 @@ pub async fn archived( } let memberships: Vec = db .collection(MEMBERSHIPS) - .find(doc! { "member_user_id": reader.actor_id, "role": "admin", "revoked_at": null }) + .find(doc! { "member_user_id": reader.actor_id, "revoked_at": null }) .await? .try_collect() .await?; @@ -295,13 +294,17 @@ pub async fn archived( let mut permitted = Vec::new(); for owner in active { let access = org_service::resolve_owner_access(db, reader.actor_id, &owner.id).await?; - if !access.can_write() { + if !access.can_read() { continue; } let mut filter = doc! { "owner_id": &owner.id }; if let org_service::OwnerAccess::AsOrgAdmin { allowed_service_ids: Some(ids), .. + } + | org_service::OwnerAccess::AsOrgMember { + allowed_service_ids: Some(ids), + .. } = access { let ids: Vec<_> = ids.into_iter().filter(|id| reader.allows(id)).collect(); diff --git a/backend/src/services/service_history/tests.rs b/backend/src/services/service_history/tests.rs index 4f28ae45e..ce2d8af1f 100644 --- a/backend/src/services/service_history/tests.rs +++ b/backend/src/services/service_history/tests.rs @@ -344,7 +344,7 @@ async fn archive_scope_roles_deleted_owner_and_group_pagination() { ) .await .unwrap(); - assert!(!read::can_read(&db, &reader, &org, &s.id).await.unwrap()); + assert!(read::can_read(&db, &reader, &org, &s.id).await.unwrap()); db.collection::("users") .delete_one(doc! { "_id": &org }) .await @@ -770,8 +770,21 @@ async fn archived_discovery_respects_current_roles_scopes_and_uuid_identity() { ) .await .unwrap(); + let visible = read::archived(&db, &reader, None, 20).await.unwrap(); + assert_eq!(visible.services.len(), 1); + assert_eq!(visible.services[0].service_id, first.id); + assert!( + read::list(&db, &reader, &first.id, None, &[], 20) + .await + .is_ok() + ); + assert!( + read::list(&db, &reader, &second.id, None, &[], 20) + .await + .is_err() + ); assert!( - read::archived(&db, &reader, None, 20) + read::archived(&db, &restricted, None, 20) .await .unwrap() .services @@ -1079,3 +1092,58 @@ async fn new_and_rebound_references_are_included_in_retried_backing_fanout() { } } } + +#[tokio::test] +async fn reader_authorship_summaries_preserve_membership_and_key_scopes() { + use crate::models::org_membership::OrgRole; + let (db, mut service) = fixture().await; + let actor_id = service.user_id.clone(); + let org = uuid::Uuid::new_v4().to_string(); + service.user_id = org.clone(); + db.collection("users") + .insert_one(test_user(&org, UserType::Org)) + .await + .unwrap(); + collection::(&db, "user_services") + .insert_one(&service) + .await + .unwrap(); + let mut other = service.clone(); + other.id = uuid::Uuid::new_v4().to_string(); + collection::(&db, "user_services") + .insert_one(&other) + .await + .unwrap(); + let ids = vec![service.id.clone(), other.id.clone()]; + let reader = read::Reader { + actor_id: &actor_id, + allowed_service_ids: None, + }; + for role in [OrgRole::Admin, OrgRole::Member, OrgRole::Viewer] { + let membership = test_membership(&org, &actor_id, role, Some(vec![service.id.clone()])); + let summaries = read::summaries(&db, &reader, &ids, std::slice::from_ref(&membership)) + .await + .unwrap(); + assert_eq!(summaries.len(), 1); + assert!(summaries.contains_key(&service.id)); + let excluded = vec![other.id.clone()]; + let restricted = read::Reader { + actor_id: &actor_id, + allowed_service_ids: Some(&excluded), + }; + assert!( + read::summaries(&db, &restricted, &ids, std::slice::from_ref(&membership)) + .await + .unwrap() + .is_empty() + ); + let mut revoked = membership; + revoked.revoked_at = Some(chrono::Utc::now()); + assert!( + read::summaries(&db, &reader, &ids, &[revoked]) + .await + .unwrap() + .is_empty() + ); + } +} diff --git a/backend/src/services/user_preferences_service.rs b/backend/src/services/user_preferences_service.rs new file mode 100644 index 000000000..19616d41a --- /dev/null +++ b/backend/src/services/user_preferences_service.rs @@ -0,0 +1,51 @@ +use chrono::Utc; +use mongodb::{ + Database, + bson::{self, doc}, +}; + +use crate::errors::{AppError, AppResult}; +use crate::models::user::{COLLECTION_NAME, ServiceViewPreferences, User}; + +pub async fn save_services_view( + db: &Database, + user_id: &str, + mut preferences: ServiceViewPreferences, +) -> AppResult { + for (name, values) in [ + ("Organization", &mut preferences.organization_ids), + ("Service", &mut preferences.service_group_ids), + ] { + if values.len() > 100 { + return Err(AppError::ValidationError(format!( + "{name} filter is limited to 100 selections" + ))); + } + if values + .iter() + .any(|value| value.trim().is_empty() || value.chars().count() > 128) + { + return Err(AppError::ValidationError(format!( + "{name} filter must contain 1 to 128 characters per selection" + ))); + } + values.sort(); + values.dedup(); + } + if preferences.search.chars().count() > 200 { + return Err(AppError::ValidationError( + "Service search must be 200 characters or less".to_string(), + )); + } + let result = db.collection::(COLLECTION_NAME) + .update_one(doc! { "_id": user_id }, doc! { "$set": { + "profile_config.services_view": bson::to_bson(&preferences) + .map_err(|_| AppError::Internal("Could not encode service view preferences".to_string()))?, + "updated_at": bson::DateTime::from_chrono(Utc::now()), + } }) + .await?; + if result.matched_count == 0 { + return Err(AppError::NotFound("User not found".to_string())); + } + Ok(preferences) +} diff --git a/docs/AI_SERVICES_ARCHITECTURE.md b/docs/AI_SERVICES_ARCHITECTURE.md index 27900e2aa..452a76599 100644 --- a/docs/AI_SERVICES_ARCHITECTURE.md +++ b/docs/AI_SERVICES_ARCHITECTURE.md @@ -8,6 +8,87 @@ NyxID's AI Services system lets users manage external API credentials, SSH servi --- +## Service cards and saved filter defaults + +The External Services grid groups catalog-backed connections by `catalog_service_id`. +Custom connections remain separate by ID. Each group starts collapsed with a +256px minimum summary height; expanding a group keeps its connection comparison +table inside the parent. Connection details and history remain on their existing detail pages. + +The expanded table shows Classification (personal, named organization with role, +or an actual platform connection), Status, Activity (latest configuration change +with actor and date, or creation), and the exact Slug. Each row links by connection +ID to its detail page, so identical slugs in different organizations remain +distinguishable. Repeated descriptions, endpoints and configuration belong on +the full service page. **Service details** opens +`/keys/services/{groupId}` for the complete catalog group (or one custom service). +This page has Connections, Service information and History tabs. History is +selected by connection ID, and each connection links to its existing full +configuration page. Last-caller attribution remains explicitly unreported when +absent from the API; credential preparation and provisioning-app metadata are +never presented as the last service use. + +Standalone **Organization** and **Service** multi-select menus precede search. +Each supports searching and immediate checkbox selection. Active values appear +as individual removable pills (`Org: ChronoAI`, `Service: Aevatar`, +`Service: Codex`). Values within one field match with OR; the two fields combine +with AND. Empty selections mean all. Options come +from actual connection owners and grouped services, keyed by organization ID and +group ID. Clearing either selector leaves other criteria intact. Missing saved +selections remain visible as unavailable until cleared. These selections are +included in account defaults. Organization ownership uses a small circular org +avatar with the existing initials fallback on cards, table rows, filter choices +and pills. Platform sources use the NyxID icon. + +The search and Filters controls use the same `DataTableControls`, +`DataTableFilterPopover` and `DataTableFilterChips` as the audit log. The filter +panel uses Apply/Cancel; applied chips can be edited, removed individually, or +cleared together. Search applies on submit or blur. These controls apply to both +the grid and table. Filters include +source (`all`, `personal`, `org`, `platform`), service state (`all`, `enabled`, +`disabled`), type (`all`, `http`, `ssh`), and whether to include auto-connected +services. All sources/states/types and auto-connected services are included by +default. Source options come from the current connection list. Enabled/disabled +uses `UserService.is_active`; it does not assert credential health or readiness. +All criteria must match the same connection. Expanded tables show only matching +rows and the parent displays the matching count against the group total. With no +filters all siblings are visible. The full service page always contains every +accessible connection in the group, independent of list filters. + +**Save as default** writes the current filters to the authenticated user's +`users.profile_config.services_view` embedded blob. Search text is included. +**Restore default** discards draft filters. **Clear filters** shows everything for +this visit; saving afterward makes that the account default. Card expansion is +session presentation state and is never persisted to the account. Draft filters +survive detail-page navigation, but reset on sign-out/account change or reload. + +`GET /api/v1/users/me` includes `profile_config.services_view` (null until saved). +`PUT /api/v1/users/me/preferences/services` replaces that one preference group: + +```json +{ + "search": "", + "organization_ids": [], + "service_group_ids": [], + "source": "org", + "state": "enabled", + "service_type": "all", + "show_auto_connected": true +} +``` + +The PUT returns the saved filter object. It derives ownership from `AuthUser`, +rejects unknown fields/enum values, limits search to 200 Unicode characters and +each identifier to 128 characters and each selection list to 100 entries, and uses a dotted MongoDB update to preserve other settings. Existing users need no +migration: legacy singular organization/service fields are read as one-item +arrays (null as empty). Writes use the plural array fields and deduplicate IDs. +Selection order does not change default-view equality. This endpoint changes display preferences only; it does not change +routing, service access, or connection priority. + +Older backends omit `services_view`; filtering remains available, but account +saving is disabled until the supporting backend is deployed. The UI never falls +back to browser storage while claiming the preference was saved to the account. + ## System Components ```mermaid @@ -587,4 +668,35 @@ membership, owner activity, provider eligibility and catalog configuration. ## Service authorship and history -Service cards and tables include authorized creator/latest-editor summaries. Instance detail pages, including platform-managed instances, have a History tab. Deleted UUID histories remain discoverable from Services → Deleted service history under current personal-owner/org-admin/resource-scope checks. The transactional journal covers service, endpoint and credential writers; ordinary timestamps, usage and routine refresh do not count as configuration edits. See [SERVICE_HISTORY.md](SERVICE_HISTORY.md) for capture, safe values, writer inventory, audit publication and required MongoDB replica-set migration. +Service cards and tables include authorized creator/latest-editor summaries. Instance detail pages, including platform-managed instances, have a History tab. Deleted UUID histories remain discoverable from Services → Deleted service history under current personal-owner/org-membership/resource-scope checks. The transactional journal covers service, endpoint and credential writers; ordinary timestamps, usage and routine refresh do not count as configuration edits. See [SERVICE_HISTORY.md](SERVICE_HISTORY.md) for capture, safe values, writer inventory, audit publication and required MongoDB replica-set migration. + +### Connection comparison and configuration visibility + +The grid starts with one collapsed card per catalog service. Expanding a card keeps +its connection table inside the parent, replaces the fixed-height summary with a +compact header, and animates the card and neighboring grid positions using native +view transitions where supported. Reduced-motion users receive immediate updates. +The standalone table and service overview use the same comparison component. + +Rows combine connection name/slug/type, ownership/avatar/role, service and credential +state, configuration summary, and latest recorded change. Row disclosure exposes +additional metadata without nested connection cards. History has a separate action +for every visible connection; it is not conditional on a recorded creator. + +`GET /keys` and `GET /keys/{id}` include `can_edit_configuration`. Personal owners +and scoped org admins can inspect connection configuration; auto-connected rows +remain platform managed. Read-only projections omit upstream URLs, private spec +URLs, SSH targets, custom header values, WebSocket templates, custom User-Agent, +OAuth app identifiers, and configuration error text. `/user-services` also omits +private custom settings from inherited read-only rows; endpoint listing enforces +admin service scope, and operation discovery hides its private source-spec URL +from non-editors while retaining usable operation descriptions. Execution and +minimal authorization-evidence representations keep their existing contracts. + +The frontend uses the explicit denial when available and known personal/org-admin +ownership on older servers. It hides configuration on both the listing and direct +connection pages. Owners, admins, members and viewers retain scoped history access, +including archived history, under the active-owner and current-membership checks +in [SERVICE_HISTORY.md](SERVICE_HISTORY.md). This is independent of proxy-use +permission. Last editor and credential-preparation time are never labeled last +caller or successful upstream execution. diff --git a/docs/SERVICE_HISTORY.md b/docs/SERVICE_HISTORY.md index 5a7e38f67..c5c83bb4d 100644 --- a/docs/SERVICE_HISTORY.md +++ b/docs/SERVICE_HISTORY.md @@ -2,7 +2,7 @@ AI Services records the creator and latest configuration editor of a service instance. Authorized cards and table rows show both summaries; the detail page has a History tab, including platform-managed instances. The Services page's **Deleted service history** entry discovers retained histories after deletion or automatic physical cleanup. UUIDs identify histories across slug changes; creating a new service with an old slug starts a separate history. -Personal owners and current organization admins may read history only within their current service scope. The owner must remain active, and API-key service restrictions apply in addition to owner permissions. Members, viewers, revoked admins, and admins outside the service scope receive no authorship metadata. Ordinary service visibility does not imply history access. The history timeline, archived-history discovery, and management writes retain their human-only route gates. Existing inventory GET routes admit scoped API keys under the current owner/membership ACLs; their optional authorship summaries additionally require the history owner/admin permission. API-key inventory reads never provision or reconcile service rows. Handler-level service/node restrictions remain additional defense in depth. Archived discovery uses the same gates before returning any UUID or display snapshot. +Personal owners and current organization admins, members, and viewers may read history within their current service scope. History access follows service visibility, independently of edit or proxy-use permission. The owner must remain active; revoked memberships, out-of-scope services, and API-key service restrictions remain enforced. The timeline, authorship summaries, and archived discovery use the same read permission before returning any UUID or display snapshot. Timeline and archived-history routes retain their human-only gates. API-key inventory reads never provision or reconcile service rows. Configuration values such as upstream URLs remain editor-only in key inventory/detail responses; safe history projections never include URL values or secrets. Legacy rows have no invented creator or editor. Their footer says `Creator not recorded` / `Earlier edits not recorded`; the timeline reports when tracking began. History is read-only. Credential values, ciphertext, tokens, private keys, URL values, header values, frame templates, arbitrary free-form configuration and unreviewed OAuth scopes are never journaled. @@ -55,7 +55,7 @@ Events and sequence heads have no TTL and are not cascaded when services/endpoin - `GET /api/v1/keys/history/archived?limit=20&cursor=...` discovers retained deleted instance UUIDs within current permissions. This multi-segment route preserves arbitrary existing `/keys/{id_or_slug}` slugs. - `GET /api/v1/options/service-history-action` and `service-history-field` expose static definitions from `services/service_history/definitions.rs`, using existing route authentication. They accept search/offset/limit, and reject owner/principal/account context even if supplied empty. `/options/service-scope` keeps its owner ACL, custom-input and wire contracts. See [OPTIONS_API.md](OPTIONS_API.md). -Mutation capture and timeline rendering do not fetch options. Existing codes should remain in the registry if retired; unknown older/future codes still have a generic fallback. Registry labels never authorize persistence of a value. Responses are private/no-store, and frontend caches include the authenticated identity. List summaries share the request's membership snapshot with provisioning and listing; membership snapshots are never cached across requests. Summary authorization still checks active owners, admin roles, effective service scopes, and API-key scope restrictions. +Mutation capture and timeline rendering do not fetch options. Existing codes should remain in the registry if retired; unknown older/future codes still have a generic fallback. Registry labels never authorize persistence of a value. Responses are private/no-store, and frontend caches include the authenticated identity. List summaries share the request's membership snapshot with provisioning and listing; membership snapshots are never cached across requests. Summary authorization still checks active owners, current memberships, effective service scopes, and API-key scope restrictions. Service detail editors retain open drafts when a background read fails with a network or server error, and offer a read-only retry. Rejected reads discard cached details so a subsequent network failure cannot restore previously accessible data. Switching the service route or signed-in identity ends the previous editor and confirmation lifetime. The admin edit-form contracts, including sparse updates, reviewed payloads, and custom scope entry, remain described in [ADMIN_FORM_SAFETY.md](ADMIN_FORM_SAFETY.md). diff --git a/docs/plans/ai-service-connection-user-flow.md b/docs/plans/ai-service-connection-user-flow.md new file mode 100644 index 000000000..cd3c61b22 --- /dev/null +++ b/docs/plans/ai-service-connection-user-flow.md @@ -0,0 +1,150 @@ +# NyxID: service connections and pool ordering + +The current product recommendation is [Consolidated Services](consolidated-services-flow.md) +(revised 27 September 2026). This document records the earlier preview design; its view +names and readiness wording are superseded by that recommendation. + +**Revised 17 September 2026.** Proposal; the local frontend previews production +metadata. Health-based selection and Priority pool routing still require backend +implementation. This revision replaces the earlier global card-sorting and +placeholder-platform design. + +Local preview: + +## 1. Keep the service cards + +Open **External Services** and see the individual cards, including service name, +endpoint, proxy slug, credential state, owner and node routing. Expand **Details** +for description, permissions, dates and the existing full detail page. + +A compact **Individual cards / By service** switch changes presentation. By +service groups cards with the same catalog identity. It does not turn those +connections into a pool. Neither view has drag handles. + +Each service gets one short routing status once the server can supply it: + +| Status | Meaning | +| --- | --- | +| Ready via You | A verified personal connection is selected. | +| Ready via Acme | A verified, permitted Acme connection is selected. | +| Ready via NyxID | A real, verified platform connection is selected. | +| Not verified | The required checks have not completed. | +| Unavailable | No permitted working connection remains. | + +Credential status and routing readiness are separate. An active saved key does +not establish that the provider accepts it. A usage timestamp is not a successful +health check. Ready is a current, operation-scoped result with a verification +time; it cannot guarantee every future request will succeed. + +## 2. Show only actual connections + +Open **Connections** from a card. Show the actual connections, owner, and one +short status per row. Known failures stay visible here so the user can repair +them, but never enter the usable routing order. Unverified connections wait for +a check and do not count as working. + +If there is no platform execution service, show no platform row, option, fallback +slot, or platform label in the order. The catalog and shared OAuth application +credentials are not evidence that a platform execution service exists. + +For example, with a working personal connection, a working organization +connection, and no platform service: + +```text +OpenAI connections +Connection choice [Automatic ▾] + +1 Personal account You Selected +2 Team account Acme Available +``` + +If the personal connection fails verification, Acme becomes first. The personal +card remains accessible with **Reconnect needed**. If nothing can be verified, +show an empty usable order with **Not verified** or **Unavailable**, according +to whether checks are pending or conclusively failed. + +## 3. Automatic selection and explicit choice + +For an eligible canonical service slug, Automatic selects the first working, +authorized connection in this order: + +**User → organization → platform.** Absent sources are omitted. If none works, +return an actionable error and do not call the provider. + +**Connection choice** also offers actual verified connections as explicit +choices. Selecting a real platform connection is permitted even when a personal +connection works. This is an exact choice: if that connection is unavailable, +return its error. Do not silently switch back to the user's account. Missing +platform services never produce an option; existing but unverified/unavailable +connections show a disabled option and their reason. + +The proposed per-request override remains `X-NyxID-Connection-Source: platform` +(or `auto`) on the same canonical slug URL. Request choice takes precedence over +a saved choice, which takes precedence over the Automatic default. The header +and saved preferences are proposed additions, not currently deployed behavior. +Exact service IDs, custom slugs, pool slugs, agent credential bindings, narrower +scopes and approvals retain their contracts; they cannot be silently overridden. +Multiple equally eligible accounts require an explicit choice or an established +server policy rather than an arbitrary database order. + +## 4. Drag only members of a real pool + +Open **Service Pools**. Each pool shows its real name, proxy slug, current +selection strategy, description and member cards. Keep the same service +information and detail links found in External Services. + +The user decides whether they want ordered selection: + +| Selection | Behavior | +| --- | --- | +| Round robin | The current round-robin strategy. | +| Weighted | The current weighted strategy, retaining member weights. | +| Priority order | Proposed: use the first working member in the preferred order. | + +Choosing **Priority order** reveals drag handles on the members of that pool. +Dragging changes only that pool's preferred member order. It cannot reorder the +whole page, add another service to a pool, move a member between pools, or change +the user/organization/platform source hierarchy. Keyboard arrows and move buttons +provide equivalent controls. + +A preferred member order is not proof of health. Before dispatch, the server +checks members in that order, skips known unusable ones and resolves pending +checks. Disabled or excluded members cannot execute. If none works, the pool +returns an actionable error. The member cards retain their details and individual +states throughout. + +## 5. Server checks define “working” + +The UI and API must use the same server decision, bound to the actual caller, +requested operation and selected connection. At minimum it must verify: + +- Service/member enabled state, caller access and scope, organization permissions, + exact credential bindings and operation compatibility. +- Credential existence and validity, supported OAuth refresh, route/node + availability and provider authentication evidence from a safe supported check. +- Applicable approval and funding requirements without bypassing either through + fallback. The selected route supplies its actual payer and pricing. + +Pending or failed checks must not be represented as Ready. Providers without a +safe validation method need an explicit unverified state; never send arbitrary +production calls merely to manufacture a green badge. Verification freshness +must be bounded. Execution revalidates before dispatch and records the selected +service, source and reasons for skipped candidates in request history. + +Fallback is a decision made before the provider effect. Do not replay writes or +streams with another credential after a request has been sent. This avoids +changing identity, billing ownership or approval authority mid-request. + +## What is available in the local preview + +The local preview reads actual connection and personal-pool metadata, preserves +cards/details, omits absent platform choices, and lets users arrange real pool +members after opting into Priority. View and pool preferences are saved only in +this browser, scoped by account and pool. Production writes and execution are +blocked by the local gateway. + +There is currently no live per-connection readiness endpoint. The preview labels +saved connections **Not verified** and disables explicit connection choices; +it does not invent successful checks. Pool Priority is also a local proposal: +the production resolver currently selects enabled, active members using +round-robin or weighted selection, without a provider-health gate at that step. diff --git a/docs/plans/consolidated-services-flow.md b/docs/plans/consolidated-services-flow.md new file mode 100644 index 000000000..979a8776d --- /dev/null +++ b/docs/plans/consolidated-services-flow.md @@ -0,0 +1,650 @@ +# NyxID Services: consolidated flow + +Revised 27 September 2026 · One collapsed card per service, with its connections inside. +Execution and data contracts reviewed with Fable on 25 September; card layout +revised below to reflect the user’s subsequent direction. +Grounded in main `1b031c77`. This describes the target experience and required +server guarantees. The grouped collapsed-card UI is now implemented in the local +React frontend and uses production service data. Backend fallback, complete caller +attribution and the remaining target behavior below are not claimed implemented. +It supersedes the earlier UI proposal and its terminology. + +## The experience in one minute + +**One collapsed card per service by default. Its duplicate/configured connections +live inside that card. Expand to compare them; click into one to see everything.** + +1. See **OpenAI · 3 connections** once in the service grid. Personal, work and + sandbox configurations do not occupy three separate top-level cards. +2. Click **3 connections** or Expand. The same card grows to reveal three named + connection cards within its border, each with owner, address, credential state, + latest caller and an Open action. +3. Click the service title or **Open service** for its complete grouped view. + Click **Work account → Open** for that concrete connection’s Route, Activity + and Details. Its history and identity remain distinct from the others. +4. When an actual canonical address exists, its routing summary is labelled with + that address. Change its source in this card’s route section: Automatic keeps + personal → organization → platform; a specific-source choice is explicit. +5. Pools retain their own cards. Only a real pool’s Priority strategy enables + dragging its member services. A group of same-service connections is not a pool. + +The page stays collapsed on initial load. Expansion is local to each service and +is restored when returning from details. All connection data remains inside the +parent card or that service’s full page; there is no data panel beneath the grid. + +## Display reference + +**[Open the live frontend](http://127.0.0.1:4317/keys?view=routing)**. +This is the real React application, reading service/catalog metadata from +`https://nyx-api.chrono-ai.fun`. If the local session has expired, use the +[production sign-in flow](http://127.0.0.1:4317/__routing-preview/login). + +The implementation groups actual service records into collapsed cards. Expanding +a card contains the original connection cards and metadata within the parent; +clicking a connection opens its existing full detail/history page. Search matches +nested connections without losing siblings. The normal `/keys` grid uses the same +component. It does not invent routing decisions or last-caller evidence absent +from the backend. + +The earlier `services-card-reference.html` attachment is a historical design +reference, not the review surface. Use the live frontend link above. The updated +application passed 98 focused tests, TypeScript checking, lint and the production +build. The local server is serving the changed application modules. Signed-in +visual verification remains unavailable because no browser is connected. + +The pattern is **a grouped disclosure card**: a collapsed service summary, nested +individual connection cards, and a full detail page. The two states below are +views of the same card: + +```text +COLLAPSED EXPANDED +┌────────────────────────────┐ ┌─────────────────────────────────────┐ +│ OpenAI │ │ OpenAI │ +│ 3 connections │ │ 3 connections │ +│ │ │ │ +│ llm-openai · Automatic │ │ llm-openai · Automatic │ +│ Would use Work account │ │ Would use Work account │ +│ Checked 20s · for you │ │ Checked 20s · for you │ +│ │ │ │ +│ Latest: Codex dev · 2m │ │ ▾ Hide connections │ +│ │ │ ┌─────────────────────────────────┐ │ +│ ▸ 3 connections Open ↗ │ │ │ Personal production · You │ │ +└────────────────────────────┘ │ │ Needs reconnect Open ↗ │ │ + │ └─────────────────────────────────┘ │ + │ ┌─────────────────────────────────┐ │ + │ │ Work account · Acme │ │ + │ │ Selected for llm-openai Open ↗ │ │ + │ └─────────────────────────────────┘ │ + │ ┌─────────────────────────────────┐ │ + │ │ Sandbox · You │ │ + │ │ Exact calls only Open ↗ │ │ + │ └─────────────────────────────────┘ │ + │ Open service ↗ │ + └─────────────────────────────────────┘ +``` + +The diagram illustrates the full target design. The live implementation includes +actual connection slugs, saved state, metadata and detail links; evaluated routing +and per-service last-caller summaries still require the backend contracts below. + +## 1. A small vocabulary + +| Term | What the user means | +| --- | --- | +| **Service** | A configured service with an identity, address and full details. Existing UserService records keep this name. | +| **Address** | The slug/API address a caller requests. It may resolve automatically, target an exact service, or select a pool member. | +| **Connection** | A particular configured instance inside a service card: for example Personal production, Work account or Sandbox. It retains its existing UserService ID and full details. This UI label does not create or merge OAuth grants. | +| **Source** | Whose credential supplies a request: Personal, a named organization, or NyxID platform. This is separate from the service's owner. | +| **Pool** | An existing collection of member services with one callable address and a selection strategy. | +| **Origin** | How this service was created. | +| **Variant** | A separate service explicitly created from another service. Its origin records that relationship. | + +A catalog service is a template and catalog identity; some catalog addresses also +have execution behavior. Grouping cards by a catalog does not create a new callable +address, fallback policy, pool, shared history or readiness status. + +## 2. Services home: collapsed service groups + +Cards are the primary and default presentation. One top-level card represents one +service identity and contains its visible configured connections. The initial +view is collapsed; the count makes the contents discoverable. Use **Connections** +in the UI rather than **Duplicates**: these may be intentional accounts, endpoints +or variants, not redundant records to delete. + +Search, Owner, Needs attention, Kind (Services/Pools) and Add act on this card +collection. Search includes nested connection names and addresses. A connection +match keeps the parent visible and marks that there is a match inside; it does not +create a detached result row. Apply permissions before grouping, counts and search. + +### Grouping and identity + +- Group catalog-backed configurations by their explicit shared catalog service + identity. Do not group every OpenAI product together solely by provider name. +- Custom services stay separate unless they have an explicit common service/group + identity. A copied name, slug suffix, matching URL or shared credential is not + enough. Explicit variant provenance remains a relation even when a variant no + longer belongs to the same service identity. +- Personal and permitted org connections for the same service appear inside the + same card; each nested card labels its own owner. A parent group has no single + credential owner. Disabled connections remain nested with their state; the + count means configured visible connections, not working connections. +- Pools remain separate objects/cards and can contain members from several + service groups. Grouping connections never creates membership or enables drag. +- Preserve immutable IDs, addresses, approvals, scopes and histories for every + nested connection. No merging, deletion, automatic source preference or + inheritance happens merely because the cards are grouped. + +### Three levels of disclosure + +| Level | Content | Interaction | +| --- | --- | --- | +| **Collapsed service card** | Name/icon, description, visible connection count; an explicitly labelled canonical-address summary when one exists; a scoped latest request | Click the count/chevron to expand, or Open service | +| **Expanded service card** | Same header; named individual connection cards with owner, exact address, credential/route state and latest caller; canonical source controls scoped to that address | Compare or expand individual connection details inside the card; open a connection | +| **Full service / connection** | Complete Route, Activity and Details; full configuration, requests, changes, origin and related services | Service title opens the group; a nested Open action opens that exact connection | + +The service card expands vertically or takes more columns where space allows. +Its border encloses all nested cards and controls. Neighbors reflow in stable +reading order. Mobile uses the available width with a single column of nested +connections. There is no full-row detail area outside the parent border and no +separate inspector below the grid. + +Expansion is independent: users can compare several expanded service cards. +Keep expansion for the current browsing session and restore it on return from +full details; a fresh visit starts collapsed. This is presentation state, never a +routing preference. Show a bounded first set for unusually large groups, with +**Show all N connections** expanding inside that card or opening its full service +view. Never silently omit connections or make a hidden subset look like the total. + +### Resolution and last use belong to named targets + +A group is a browsing container. When a real canonical address exists, display +it explicitly, for example **llm-openai · Automatic**. Any **Would use Work account** +label belongs to that address and includes caller, operation and evidence age. +An alternate configured connection is not automatically a fallback candidate. +Distinguish **Selected for llm-openai**, **Preferred personal source**, **Exact calls +only**, **Disabled**, and pending verification using server facts. + +Without a real canonical address, the group shows its connection count and +**Open a connection**; it does not acquire an invented Automatic mode or API URL. +Its latest visible request may summarize the authorized group feed only when it +names the actual connection. A canonical latest-request summary labels that +address. Each nested connection shows its own latest request. Missing evidence +says Not recorded; a group never receives a blanket Ready status. + +**Change source** edits only the named canonical address. Each connection keeps +an Open/Copy exact address action. Platform offerings appear only when real and +permitted; catalog branding never creates a fake platform connection in the count. +Auto-provisioned platform instances retain their mutation restrictions. Disabled +duplicates remain distinct by ID; deleted services move to authorized archives. + +## 3. Click into the service: Route / Activity / Details + +The parent title and **Open service** open the complete service group. Route shows +its actual canonical behavior, when present, and all its connections. Activity +can aggregate authorized requests/changes only with the target connection named +on every event. It does not create a merged history or fabricate group authorship. +Details includes catalog metadata and links to each concrete configuration. + +A nested connection’s title/Open action opens its own full page, scoped by its +immutable service ID: Route, Activity and Details belong only to that connection. +Keep a breadcrumb **Services → OpenAI → Work account**. The group and concrete +views visibly state their scope. **View all activity** retains the scope of the +card it was clicked from. Returning restores filters, scroll and expanded cards. +All permitted data is available by clicking in; the collapsed state loses no +configuration or history. + +### Route: what will a call do? + +Show the requested address first. Then one short resolution summary, source +selection, and a **Why this source** disclosure. + +```text +OpenAI llm-openai +Route Activity Details + +Selection Automatic Change +For You · POST /v1/responses +Would use Acme · Work OpenAI +Checked 20 seconds ago +Paid by Acme · org wallet; provider charges to Acme’s key + +Why this source + Personal Needs reconnect + Acme Selected · credential and route checks passed + +Last completed Codex dev · Personal · 2 minutes ago +``` + +The platform row is absent in this example because no actual permitted platform +source exists. An existing broken personal service stays visible with its repair +reason; it is excluded from the usable set. A configured platform binding that +later loses availability remains a repairable record in that service's details, +but is not offered as a usable fallback or selectable source. + +**Do not use a generic “Ready via [source]” badge.** Distinguish these statements: + +| Label | Evidence it requires | +| --- | --- | +| **Saved · Not verified** | Configuration exists; upstream validity has not been established. | +| **Would use Acme · checked 20s ago** | Server evaluation for this caller, operation, policy version and current route/credential state. It predicts selection, not upstream success. | +| **Verified 2m ago** | A supported credential/operation check with version-bound evidence and a stated scope. | +| **Last completed 2m ago · Codex dev** | An actual completed execution record; not credential preparation or response headers alone. | +| **Needs reconnect / Node offline / Approval required** | A specific actionable state. | +| **Unavailable** | Evaluation found no permitted usable source; the action explains the repair needed. | + +“Working” cannot mean a permanent guarantee about a third party. The server must +validate authority, configuration and credential preparation at request time, +perform supported refresh, check transport and payment requirements, and exclude +known unusable sources. It must show verification evidence separately. Unknown +upstream health stays unknown; no generic active flag becomes proof of validity. +A read-only check never dispatches a business operation, advances a pool counter, +refreshes a credential, reserves money or grants approval. If such work is needed, +it reports **Refresh required**, **Verification needed**, or another pending gate. +A dedicated supported Verify action is separate and discloses any metered probe. + +The target's **working-only rule** is explicit: Automatic and pool selection do +not select a credential merely because it can be decrypted. They require +provider/operation-appropriate validity evidence: a completed OAuth authorization, +successful refresh, supported validation, or a qualifying recorded success bound +to the same credential, endpoint and configuration version. No arbitrary past 200 +proves every operation will work. Legitimate no-auth services use route/operation +checks appropriate to them. + +**Missing evidence is not failure.** Execution performs a supported safe check +when possible. If a higher-priority candidate still cannot be assessed, return +**Verification required**; do not replace its identity with an org/platform +account merely because evidence is absent. Known unusable candidates can be +skipped under the saved policy. A pending gate blocks a definite preview. +Round robin/Weighted select only within the working set explicitly approved for +that pool; their editor discloses excluded/unverified members before activation. +For Priority, an unassessed higher member requires verification before proceeding +past it, unless the user explicitly excludes it from the pool. + +Evidence includes its age and provider-defined validity. Credential/configuration +changes, known revocation and expiry require revalidation. An adapter may require +a freshness check, but elapsed time alone never marks a credential broken or +silently changes the selected identity. Show **Re-check required** and perform a +supported safe check; if unavailable, stop with an actionable state. Verification +still cannot guarantee that the third party accepts the next business operation. +An adapter may impose an age-based re-check only when it supplies a safe check. +For unsupported providers, version-bound evidence does not expire by age alone; +actual token expiry, configuration changes and classified failures still matter. + +For providers without a safe check, offer **Use exact service** with a generated +exact URL/agent configuration, or an explicit user-requested first real operation. +This authenticates and executes once through that chosen service, labelled +unverified, and can produce qualifying evidence. It is never a hidden business +probe or an automatic exception to working-only selection. Exact requests still +enforce authorization, known credential failures, route, approval and billing. +Setup must show this path rather than leaving a newly added key stuck at Saved. +While unverified, Copy request and generated agent configuration must use the +server-provided exact target/selector, not the unresolved Automatic slug URL. + +Default context is **You**. An inspectable agent/application context is available +only when the backend can evaluate its real scope and bindings and the viewer is +authorized to inspect it. Otherwise show recorded caller facts in Activity. +Changing this context does not impersonate the caller, execute a request or save +a preference. Advanced operation checks belong on the full page; a generic page +must not silently assume that permission for one operation grants all operations. +An unresolved higher-priority candidate prevents a definite selection preview: +show the pending check instead of claiming a lower-priority source will be used. + +### Choosing the source + +**Change** opens a focused editor with two choices: + +- **Automatic**: personal → named eligible organization(s) → actual platform + offering. Show only real authorized candidates. Users may exclude optional org + or platform fallback; the remaining source order is fixed and visible. +- **Use a specific source**: select a permitted concrete personal/org service or + the actual platform offering. This is strict: if it fails, return its error. + Selecting Platform works even while a personal service is usable. + +Platform joins Automatic only when an actual offering is available, its use is +permitted, and the payer already has explicit billing authorization covering this +route. Otherwise the editor requires opt-in with payer/pricing shown. Catalog +visibility or an existing wallet does not supply that consent. Migration never +enables new platform charges implicitly. Excluding a source removes it without +reordering the remaining tiers. + +The editor shows **Applies to: your calls to this address**, the actual payer and +any changed pricing before Save. A caller's explicit exact target or agent +binding remains stronger than a saved preference. Org-owned settings require +org write permission; their editor names the affected org scope. Do not silently +save a personal choice as an org-wide setting. A per-call exact selection is +supported; Copy request uses the existing UUID/exact-call form or explicitly +includes `_nyxid_via` in the slug URL. +Hard constraints intersect: an exact request and an agent binding that conflict +produce an error, rather than one silently overriding the other. + +Within a source tier, a user chooses a preferred concrete service when several +accounts are possible. Preserve an existing exact same-catalog match as the +migration default; select a sole compatible account when unambiguous. Do not pick +an arbitrary database record or move between unchosen accounts. An unresolved +account choice returns **Choose a source** instead of silently charging platform. +For multiple orgs, show the configured org order (initially primary org, then a +stable saved order); edit that order with explicit move controls. Dragging service +cards never changes source order. Intentional member failover belongs to a pool. + +### What the API must enforce + +Connect/Reconnect returns to the same service and starts supported safe validation. +The card progresses from Saved/Checking to Verified or a specific repair state. +A metered verification needs its own disclosed action. A repaired personal source +is preferred on the next Automatic call. Adding it does not change pool membership. +When no source qualifies, the Route panel shows Connect, Reconnect or Check as +appropriate; execution remains unavailable until the server requirements pass. + +Canonical Automatic is a versioned, explicitly enabled address contract, not a +new interpretation applied to every catalog slug during a UI rollout. A viewer +gets its entry when it is enabled and has at least one visible personal/org +candidate or an included, authorized platform offering. A saved policy or prior +activity keeps its entry visible when candidates disappear, as Unavailable. +Discoverable catalog entries with no such configured route stay in the catalog. +New calls to an enabled canonical contract with no usable candidate return its +structured error; they never fall through to a hidden legacy platform source. +Legacy personal connections are explicit migration candidates, not a second +resolver consulted after the new one fails. + +Existing exact/custom/pool address behavior is preserved until an explicit +migration. The migration preview names the affected addresses, agents, source +policy, evidence gaps and payer; it cannot enable Automatic while required +verification is unresolved. New setup likewise offers Automatic only after its +candidates and fallback choices are reviewed. Unsupported verification keeps a +usable exact address/configuration available; it does not silently move calls to +another account. Newly enabled tracking does not make existing traffic eligible +for new charges or force all old keys into a first-day fallback. + +Inventory legacy implicit platform traffic separately, including callers with no +service row. Use final credential class plus legacy route markers to identify it; +a missing concrete service ID alone does not prove platform use. Moving these +callers onto the new contract requires explicit platform billing authorization. +Retiring implicit access without it is a deliberate, announced migration cutoff, +not covered by the preservation promise for existing exact/custom/pool contracts. + +For an enabled canonical Automatic address, every call performs these steps: + +1. Resolve the address contract and authenticated caller. Apply caller-wide scope, + consent, operation and exact approval/binding restrictions. +2. Evaluate the selected personal candidate, then permitted organization + candidates, then an actual platform offering. Each must independently pass + account/operation compatibility, credential, transport and billing checks. +3. Skip only classified candidate failures that the saved policy permits, such + as a missing credential, terminal refresh rejection, or no permitted online + node route. Keep a safe reason. Missing/stale evidence requires checking or an + explicit choice, not fallback. Caller-wide denials, unresolved explicit account + choices, database/KMS failures and integrity errors are terminal. +4. Revalidate the selected identity and effective authority before dispatch. + If an exact approval is needed, obtain it for that identity. An existing + approval or agent binding for another identity never transfers to the fallback. +5. Dispatch once. Record the decision, target, verified caller and outcome. If no + usable source exists, return a structured error before any provider call. + +No legacy fallthrough may select a source excluded by the terminal decision. +An upstream timeout, error or interrupted stream after dispatch is that request's +outcome. It does not trigger replay under another account. Subsequent independent +calls evaluate again; a repaired personal source regains precedence in Automatic. +Do not globally invalidate credentials from an arbitrary resource 403, 429 or +provider outage. Candidate evidence is bound to identity, version and scope. + +Canonical catalog addresses are explicit server-reported contracts. Exact UUID/ +`_nyxid_via` selection, custom addresses and pool addresses retain their semantics. +Custom/pool collisions with catalog slugs require explicit migration; the UI must +not relabel existing traffic Automatic. A matching canonical personal record can +be the preferred candidate, but keeps its UUID, full card, exact-call link and +own history. There is no newly invented API address for a visual provider group. + +## 4. Pools: ordering lives here + +A pool has a self-contained card beside the service cards. Its expanded card +contains its strategy and member services; the full pool view’s Route tab +contains the same controls with complete member details. Current ownership rules +remain: all members must belong to the pool's owner. Provider similarity alone does not prove operation, +protocol, account or approval compatibility. + +- **Round robin**: selects among eligible members per request. +- **Weighted**: selects among eligible members with the configured weights. +- **Priority**: selects the first eligible, usable member in saved order. + +All three strategies use the same working set; their choice within it differs. +Preserve existing strategy/weights while adding the shared eligibility checks. + +Only Priority displays drag handles on member services inside that pool’s +expanded card or full view. The card itself never becomes draggable. The user +moves member services, reviews the new order and saves. Keyboard Move up/down +controls do the same thing. Changes name the pool, actor and version and appear in its change history. Unsaved edits +do not affect traffic; concurrent edits cannot silently overwrite one another. + +```text +Production AI · owned by You +Strategy: Priority + +1 OpenAI primary You Needs reconnect · skipped +2 OpenAI backup You Credential and route checks passed +3 OpenAI spare You Disabled · skipped + +One eligible member for the checked operation. +``` + +The saved order includes broken/disabled members so users can repair them. The +effective eligible set excludes them. Preview never consumes the round-robin +counter. No pool leaves its member set for a global org/platform fallback. A pool +member may already have an explicitly configured platform credential binding; +that remains the member's binding, subject to its own authorization and payer. +Priority is backend work: “first active row” is insufficient. + +## 5. Activity: who used it, and what happened? + +**Activity** has Requests and Changes filters, with Requests selected initially. +They share context and visual structure but retain separate records and clocks. + +Scope is named at the top: **Through this address** for a canonical address/pool, +**Across visible connections** for the service group, or **Handled by this +connection** for a concrete service. Grouped events retain their concrete target +labels and current disclosure checks; the group is not an execution identity. Canonical Changes records +that routing policy's edits; concrete service Changes records its own edits. +Selecting a source opens that concrete connection’s full view with a back breadcrumb. Do not merge a canonical address's policy, a candidate's history and +other same-catalog services into one unnamed timeline. + +A request entry shows **time · verified caller · operation · outcome**. Expanding +it shows: + +- Requested address and mode at that time. +- Actual selected service and source, credential override if applicable, and node + or pool member used. Selected-but-not-dispatched is distinct from executed. +- Safe skip reasons, actual payer, start/end time and request ID. +- The evidence captured for that request, not today's routing preview. + +Example: **Codex dev · Agent key → production-ai → OpenAI backup · Personal → +Completed**. Opening the pool or the member reaches the same execution record. +One request is counted once, regardless of routing or billing event count. + +Caller labels come from verified auth: **Alice · Session**, **Codex dev · Agent +key**, or **Release dashboard · Application · on behalf of Alice** when those +identities were authenticated. A user-assigned key name does not prove a particular +executable was running. User-Agent, service creator, provisioning app and key +owner are never substitutes for caller attribution. Generic keys show their +recorded name/identity with **Application not identified** when appropriate. + +**Latest request**, **Last completed request** and **Last change** are separate. +A failed latest request must not erase an earlier completed one. Outcomes include +in progress, completed, failed, denied, disconnected and unknown. HTTP completion, +stream termination and WebSocket closure need protocol-specific rules; a 200 +header or successful upgrade alone is not completed usage. “Completed” describes +the recorded transport/operation outcome, not proof of a provider's business +result. If a protocol cannot establish completion, keep unknown explicitly. + +No data reads **Not recorded**, with **Recorded since [date]** when coverage is +partial. Neither a shared credential's `last_used_at` nor missing retained events +justifies “Never used.” Projections expose an observation window and update lag. + +Visibility is enforced by the server before pagination, counts or summaries: + +| Viewer | Request activity | Change history | +| --- | --- | --- | +| Personal service owner | Authorized activity for that service | Existing owner access | +| Scoped org admin | Activity within permitted org resources | Existing write + resource scope | +| Org member | Their own authorized requests, labelled **Your latest request** | Restricted under current history rules | +| Org viewer | No execution activity through this feature | Restricted under current history rules | + +An org member's summary must be computed for that member; filtering a global +latest row is insufficient. Visible relationships, actors, targets and historical +snapshots need current disclosure checks too. Do not leak hidden caller names, +slugs or counts through provider headings, search, exports or related items. + +## 6. Details: metadata, origin and variants + +Preserve existing endpoint, protocol, auth method, safe headers, identity +propagation, node routing, catalog docs/capabilities/limitations, ownership, +credential binding, pricing and scoped-agent information. Show a compact summary +first, with technical sections expandable. Secrets retain existing protected +flows and never enter activity/history payloads. + +**Origin** states who created the service, when and through which recorded +channel/application. Older data says **Origin not recorded**. + +**Related** uses specific labels: + +| Relationship | What it means | +| --- | --- | +| Based on OpenAI | Shared catalog template/configuration origin. | +| Created from Production OpenAI | A recorded derivation from that specific service ID. | +| Used by Production AI | Actual pool membership. | +| Shares credential with Work OpenAI | Shared credential reference, subject to disclosure rights. | +| Bound to Codex dev | Explicit agent service/credential binding. | +| Created through Release dashboard | Provisioning provenance, not last caller. | + +**Create variant** is the explicit future action for a spin-off. It opens a form +prefilled from permitted configuration, shows what is copied, requires a new name/ +address and explicit credential choice, and creates a new immutable service ID +with `derived_from_service_id`. It does not inherit approvals, grants or agent +scope, and edits to the parent do not propagate. Record the creation and origin +in authorized histories; opening a parent/child preserves a breadcrumb to the +originating service and the card overview’s state. + +Credential replacement, rename and Enable/Disable are changes to the same service. +Similar slugs, shared keys, timestamps and `rotation_predecessor_id` are not +service lineage. Delete archives that ID's history; recreating the slug creates +a new identity and history. Authorized archived views retain safe snapshots. +Shared-credential edits already fan out through the existing journal, so there +is no extra “include related history” toggle. + +## 7. Backend delivery: the point at which this is solved + +The target flow requires backend work. A new layout alone cannot deliver it. + +| Capability | Present in checked main | Required addition | +| --- | --- | --- | +| Rich service cards/details, ownership and lifecycle | Yes | Collapsed service groups, nested connection cards and complete scoped views | +| Explicit platform credential binding | Yes; availability is metadata | Preserve this; add strict source preference to the canonical resolver | +| Service authorship and change journal | Yes, with restricted readers | Reuse it; add routing-policy/pool-order/variant events where not recorded | +| Working personal → org → platform fallback | No common resolver; an unusable personal match can return early | Shared resolver, versioned policy, consistent preview/discovery/execution and typed terminal decision | +| General upstream verification | No; Codex has a specific flow | Truthful check states; supported evidence bound to credential/configuration version | +| Pool Priority | No; RoundRobin and Weighted only | Authorized operation-compatible eligibility, Priority strategy and versioned order edits | +| Who last used this exact service | Partial audit fields, no complete correlated view | Complete execution attribution and authorized summaries/feed | +| Service spin-off lineage | No general derivation edge | Explicit variant creation and immutable origin relationship | + +Deliver in this order: + +1. **Contracts and resolver.** Record address identity, policy ownership/version, + request context, saved org order, candidate eligibility, typed skip/stop reasons, + approval and billing gates. Evaluation is read-only; execution revalidates and prepares. + All proxy paths, approvals, MCP/discovery and UI consume compatible decisions. +2. **Execution activity.** Mint a request ID at ingress. Thread it through + selection, audit and metering. Record requested target/slug snapshot, concrete + service/catalog/pool IDs, source, actor user, API key, authenticated application/ + delegation/child credential IDs when present, credential class, node, payer, + dispatch state and terminal outcome. Separate protocol completion from headers. + Pre-dispatch errors retain an execution record with no executed target. +3. **Reliable read models.** Build indexed, authorized recent-request queries and + rebuildable latest/latest-completed projections, including per-actor org views. + No N+1 audit scans. Persist terminal evidence with bounded retry/reconciliation; + a dropped fire-and-forget update must leave an explicit gap, not a false success. +4. **Pool and provenance writes.** Add real Priority semantics, compatibility + checks, concurrency-safe order saves and explicit variants/origin. Reuse safe + history writers and their authorization rules. +5. **The consolidated UI.** Bind collapsed/expanded service groups, nested + connection cards and their full scoped views to those contracts. Metadata/history improvements may ship earlier with accurate + no-data states; Automatic/source preview and Priority controls ship only with + their execution guarantees. + +The execution contract can extend existing audit events and projections; this +proposal does not require a second competing audit log. New audit rows must use +the existing tamper-evident append path. Changes stay in the service journal. +Platform absence, allowed fallback, final payer and caller visibility come from +the server. `platform_key_available`, row ownership or a last-used timestamp is +never enough for the frontend to reconstruct these decisions. + +## 8. Acceptance scenarios + +| Scenario | Required result | +| --- | --- | +| First visit with three OpenAI configurations | One collapsed OpenAI card says 3 connections; no three duplicate top-level cards. | +| Expand one service card | Its own border encloses all visible connections, their resolution/last caller and metadata; no detached data area. | +| Open Work account inside OpenAI | Exact connection identity, configuration and history; breadcrumb returns to the expanded group. | +| Different service IDs happen to share a provider name | Separate groups unless an explicit common service identity exists. | +| Hidden org connection shares this service | It contributes no count, search result, caller or history to an unauthorized viewer. | +| Keep several cards expanded | Each retains its own identity and controls; presentation changes do not change routing. | +| Click into a card or View all activity | Open that service’s full view; returning restores filters, scroll and expanded cards. | +| Expand a pool and choose Priority | Its member ordering stays inside the pool card/full view, with no global card drag. | +| Personal source works; org and platform also exist | Automatic uses personal. | +| A personal key is merely saved and org has current valid evidence | Check personal safely when supported; otherwise Verification required. Missing evidence alone cannot switch the caller to org. Offer an explicit exact/source choice. | +| Every configured credential is unverified and cannot be safely checked | Verification required for Automatic/Priority; setup provides an exact-call configuration and explicit first-request path. No hidden bootstrap or account substitution. | +| Existing keys lack the new activity history on rollout | Existing address contracts continue; Automatic migration requires a reviewed policy and qualifying evidence. No first-day outage or paid fallback. | +| Previously valid evidence needs a freshness check | Re-check or return a pending gate; age alone never marks failure or switches identity. | +| Personal refresh is terminally rejected; permitted org source works | A new Automatic request uses org and records why personal was skipped. | +| Personal and org unusable; actual permitted platform source works | Automatic uses platform only if allowed; payer/pricing are explicit. | +| No source qualifies | Structured error before provider dispatch; UI shows an actionable unavailable state. | +| No platform offering exists | No platform row, selector option or phantom fallback slot. | +| User explicitly selects platform while personal works | Platform is used; strict failure does not silently switch to personal. | +| Multiple unchosen accounts in one tier | Ask for a preferred account; no arbitrary database order or paid escape. | +| Agent binding or exact approval names a failing target | Honor its constraint; no cross-identity fallback. | +| A custom/pool slug collides with a catalog slug | Preserve current semantics pending explicit migration. | +| User enables Priority and reorders a pool | Only that pool's saved member order changes; broken members are skipped by real eligibility. | +| Preview a pool during concurrent traffic | No counter mutation and no guaranteed-next-member claim. | +| Upstream write timed out after dispatch | Record failure/unknown delivery as appropriate; do not replay under another identity. | +| Stream returned 200 then disconnected | Latest request shows disconnected; last completed does not advance. | +| Same credential is used by two services | Attribute use to the concrete executed service, not both. | +| One member is called directly and through a pool | Preserve requested entry point; deduplicate each execution by request ID. | +| Org member opens service activity | Their own latest request; no other actors or restricted change history leaks. | +| Rename, rotate key, delete and recreate slug | Rename/rotation keep history; recreation has a new immutable identity. | +| Create a variant | Explicit origin edge; no inferred lineage or inherited execution authorization. | +| Only legacy credential last-used data exists | Not recorded; no invented actor or success. | + +## 9. Adversarial review decisions + +Fable challenged the first draft against the actual resolver, pool and audit code. +The 25 September review settled the address identity, source resolution, activity, +privacy and origin contracts. The user’s 27 September direction supersedes the +review’s list-first/side-inspector presentation: use self-contained service cards, +one collapsed group per service with duplicate/configured connections inside, +individual expansion and a scoped full service/connection view on click. +Route / Activity / Details remain the full view’s structure. This grouping/card +revision was not separately reviewed by Fable; the user’s latest direction supersedes the earlier flat-list grouping +recommendation. It retains the reviewed execution and disclosure constraints. + +The review also established that last-caller needs correlated concrete execution +records and that org use rights do not grant org history rights. The final contract +adds per-viewer summaries and protocol completion rules. Priority must check usable +members, not merely active rows. Platform fallback is never promised from catalog +availability alone. The full target includes automatic resolution; a metadata-only +interim release is explicitly insufficient to call the original routing problem +solved. + +Fable's closing review found a first-call/migration trap in strict verification. +The final decision keeps working-only Automatic while treating missing evidence +as a pending gate, never as permission to switch accounts. Supported providers +validate during setup; unsupported ones get explicit exact-call setup and a +visible first-request path. Existing address contracts remain until a reviewed +migration. OAuth authorization counts as validity evidence. Freshness rechecks +never silently change identity. This preserves the user's requirement without +adding an invisible unverified exception to Automatic. + +The review also fixed platform billing opt-in, canonical entry/no-row behavior, +activity scope, and the org payer example. Working notes and the alternatives +challenged in review remain in `services-consolidated-fable-review.md`. + +**Final Fable disposition: review closed; G1 and G2 resolved.** Its three +nonblocking follow-ups are incorporated: inventory and disclose the legacy +platform consent cutoff, copy exact targets while unverified, and require a safe +adapter check before age alone can trigger mandatory revalidation. diff --git a/docs/plans/local-routing-preview.md b/docs/plans/local-routing-preview.md new file mode 100644 index 000000000..04d53556b --- /dev/null +++ b/docs/plans/local-routing-preview.md @@ -0,0 +1,246 @@ +# Local routing preview with production metadata + +Refreshed 25 September 2026 from main `1b031c77` (frontend v0.30.2). +The prior preview is preserved in stash `e71429e8d5d19a1668df4f2e2925abba37625f61`. +Updated 27 September: the actual React frontend now renders one collapsed card +per catalog service, with its real configured connections inside. The same +`GroupedServiceCards` component is used by the normal `/keys` grid and the local +production-data view at `/keys?view=routing`. This is application code, not the +standalone HTML reference. Current main's full detail pages, authorship/history, +org permissions and reconnect flows are retained. + +Run from `frontend/`: + +```sh +NYXID_ROUTING_PREVIEW=1 \ +BACKEND_URL=https://nyx-api.chrono-ai.fun \ +FRONTEND_URL=https://nyx.chrono-ai.fun \ +npm run dev -- --host 127.0.0.1 --port 4317 --strictPort +``` + +Open . If sign-in is required, open +. The production site's existing +CLI Authentication flow sends a fresh session to the local preview. The access +token stays in server memory for at most 15 minutes; refresh tokens are discarded. +The browser receives an opaque HttpOnly session. Restarting Vite or session expiry +requires signing in again. Do not use `?mock`; that is an unrelated fixture mode. + +The gateway accepts allowlisted metadata GETs, including `/service-pools`, +service history and Codex connection metadata from current main. Key and node +detail reads require UUID paths. Pool mutation requests, execution, credential +reveal and other mutation endpoints are blocked, except for the authenticated, +same-origin `PUT /users/me/preferences/services` with a validated filter payload. The existing backend's human +`GET /keys` still performs its normal platform auto-provisioning/reconciliation; +the gateway does not change that server behavior. Logout clears the local session. +The gateway and routing-specific diagnostics are development-only. Grouped service +cards and filter controls are also used by the normal production frontend. + +## Live frontend walkthrough — 27 September 2026 + +1. Open **External Services**. Each shared catalog service appears once with the + real connection count. Custom services remain separate by immutable identity. + All groups start collapsed on a fresh page load. +2. Click the service title or **View N connections**. The parent card grows to + contain a compact comparison table: Connection/Slug, Classification, Status, + Configuration and Activity. Editors have a Configure action; every visible + connection has a History action. +3. Status reflects known restrictions or missing verification. Activity shows + the latest recorded configuration change; no successful route or last caller + is invented. +4. Click a connection to open its existing full detail page and History tab. + Returning to services preserves group expansion and search in this browser + session, scoped to the account. Refreshing starts collapsed again. +5. Organization and Service have separate named selectors. Search matches names, + slugs and owners. Only matching rows appear in an expanded card; the full + group count and Service details link preserve context. +6. Pools keep the existing separate tab. Priority ordering there remains a local + preference preview; no production strategy change is implied by this UI edit. + +The service-grid changes passed 98 focused frontend tests, TypeScript checking, +targeted lint and the production build. The running Vite server serves the updated +modules and reports the production API URL in runtime config. No browser was +connected for signed-in visual inspection. + +## Card sizing and account filter defaults — 28 September 2026 + +Collapsed summaries now share a 256px minimum height with reserved description +space and aligned footers. Filters cover search, source, enabled/disabled state, +HTTP/SSH type and auto-connected inclusion. They apply to the normal grid/table +and the production-data preview. A partial group match shows only matching rows and +shows their count against the complete group. + +The backend implementation stores **Save as default** in +`users.profile_config.services_view`; see +[AI Services Architecture](../AI_SERVICES_ARCHITECTURE.md#service-cards-and-saved-filter-defaults). +The local production-backed preview can filter immediately. Account saving is +available only when its connected backend exposes this field on `/users/me`. +The backend addition in this worktree has not been deployed to production. +There is no local-storage substitute for an account save. + +Validation: 159 frontend/gateway/authentication tests and 21 backend profile +tests pass, including real MongoDB preference persistence and sibling-setting +preservation. TypeScript, targeted ESLint, Rust formatting and the frontend +production build pass. The updated live modules return HTTP 200. Signed-in visual +inspection remains unavailable because no browser is connected. + +## Readiness and execution boundary + +The current backend does not expose per-connection live readiness. `GET /keys` +sets `connected: true` during response construction; `status: active`, a recent +`last_used_at`, online node presence, and shared OAuth application credentials +are not evidence of a working upstream credential. Even an expired OAuth token +may be refreshable. The preview never upgrades these metadata facts to Ready. + +Production pools currently use round-robin or weighted selection. Their resolver +filters enabled members and active services, but does not verify credential or +provider health at member selection. The proposed Priority strategy and the +shared readiness resolver described in `ai-service-connection-user-flow.md` are +backend follow-up work. The local preference order is not an effective routing +order until those checks exist. + +No live provider probes or execution requests are made by this preview. The +current user session may also differ from an agent key's access. Final selection +must check the execution caller, exact bindings, policy, approvals and funding, +then report the actual selected source. It must not replay an already-dispatched +request through a second identity. + +## Connection tables and standalone selectors — 28 September 2026 + +Organization and Service are standalone searchable multi-select menus populated +from actual records. Each selected value has its own removable pill. Selections +within a menu match with OR; the two menus combine with AND. Organization ownership +uses circular avatars; platform sources use the NyxID icon. +Additional criteria reuse the audit log's filter panel, Apply/Cancel actions and +editable/removable chips. The saved-default blob now includes +`organization_ids` and `service_group_ids` arrays; older singular selections +migrate to one-item arrays and empty/null selections to empty arrays. +Expanded cards contain a comparison table, with the exact owner, status, latest +change and slug of each matching connection. **Service details** opens +`/keys/services/{groupId}`: Connections (the same table with row disclosures) and +per-connection History. This full page retains all accessible siblings regardless +of list filters. Each connection links to its original configuration page. + +Multi-select validation: 81 focused frontend/gateway tests and 22 backend profile +tests passed, including real MongoDB persistence, legacy single-selection reads, +individual pill removal, combined selections and empty-result recovery. The +frontend production build, TypeScript and targeted lint passed. No browser was +connected for signed-in visual inspection. + +## Dense connection tables and reader history — 28 September 2026 + +Expanded cards now have a compact header and take the grid width for the connection +table; collapsed cards retain their 256px minimum height. Native view transitions +animate card resizing and the surrounding grid, with a reduced-motion fallback. +The standalone table uses the same component. Extra dates, permissions, provisioning +source, header names and other metadata open inside a table row. The separate +per-connection information cards have been removed from the service overview. + +Editors see targets, auth/routing summaries and configuration links. Members and +viewers see connection identity, ownership, state and activity, with History always +available for authorized connections. Direct detail navigation enforces the same UI +boundary. A failed history request never restores cached history. + +The matching backend changes (private configuration projection and scoped reader +history) are local to this worktree and **not deployed**. The production-backed +preview shows the frontend changes, but production still enforces its deployed +history policy until the backend change is released. The preview does not claim +health-based fallback routing or last-caller data. + +Validation for this revision: 131 focused frontend tests, including filters, +connection/navigation permissions and history caching; 127 backend tests covering +history (21), keys (74), user-services (14) and endpoints (18). Backend integration +tests used an isolated MongoDB 8 replica set, removed after the run. Production +frontend build and TypeScript checks pass. Targeted ESLint has no findings; full +ESLint has no errors and 27 existing warnings. Signed-in visual animation review +remains unavailable because no browser is connected to this session. + +Only one service card can be expanded at a time. Opening a new card closes the +previous card in the same transition. Expanding a service scrolls its header into the main content viewport after the +card animation completes. Collapse and restored expansion state do not trigger +scrolling; reduced-motion users get an immediate reveal. Connection names now +link to their full details page for every authorized reader, including org +members and viewers. A separate chevron opens the inline summary; Configure +remains editor-only. + +The filter card is sticky within the dashboard content viewport and contains the +Organization/Service selectors, search, additional filters, selected pills, +Personal/All services view switch, save/default controls, result count, collapse +and refresh actions. The card's measured height sets the expanded service's scroll +margin, including after pills wrap. Personal is the initial view without a saved +account default and excludes organization and platform sources. Selecting an +organization switches to All services; returning to Personal clears organization +selections. Saved account defaults retain the user's chosen view. Clearing other +filters preserves Personal versus All services. + +Validation for the Personal default, sticky filters, exclusive expansion and reader +navigation: 104 focused frontend tests passed. The 17 routing-preview tests also +passed after the final test typing correction. Production build, TypeScript, +targeted ESLint and diff whitespace checks passed. The live preview returns HTTP +200; signed-in visual review still requires a connected browser. + +Saved views now has a dedicated header control in the sticky filter card, with a +count, a preview of the saved account default and a click to restore it. Save as +default / Update default is visible alongside it; matching the saved default +shows a checked status. This retains the existing single account-default model. +Personal / All services is one pill showing the active source icon and label; +clicking it switches to the other view. No saved default or persistence behavior +was migrated. These changes use the supplied Billing screenshot as a visual +reference for AI Services; the separate Billing checkout is unchanged. + +Validation: 47 focused frontend tests passed across routing preview, keys and +service-view state; production build, TypeScript, targeted ESLint and whitespace +checks passed. No browser was connected for signed-in visual inspection. + +The Organization and Service triggers now align label, selection and chevron in +fixed columns, with the selected value right-aligned beside the chevron; dropdown +rows reserve consistent checkbox/avatar space. The sticky +filters have an opaque background above the card and a scroll-dependent shadow +in both themes. The background extends to the full dashboard scrollport width, +using measured gutters to hide borders and shadows from scrolled cards. When +stuck, the card keeps its rounded corners and hides Saved views and the entire +results/action footer. Controls stay on one line, with selected filter pills +below, capped at two full rows (three rows total). Extra selections scroll within +that area and snap to complete rows. Pill heights are 44px on mobile and 36px on +desktop; long labels truncate with their full text available on hover. No empty +pill row is rendered. Narrow viewports scroll the controls +horizontally. The Personal / All services pill remains alongside the filters. +Returning to the normal position restores the saved view controls and footer. +There is no fixed padding or opaque band below the sticky card. +The cover now uses an 8px backdrop blur and fades out over 28px below the card; +its mask softens passing connection borders alongside the background instead of +cutting them off at a horizontal edge. A layered shadow keeps the rounded filter +card visually above that cover. This is an overlay only and adds no layout space. +The Vite production build passed after this styling adjustment. +An expanded service's name/count and actions remain sticky below the filters +while its connection rows scroll. Its offset uses the measured filter height +plus the existing 32px reveal gap. It starts moving up when the third-last +connection reaches the header's lower edge, keeping the final three connections +clear as the card scrolls away. The release threshold uses actual connection-row +positions, excluding expanded metadata/history rows. Scrolling back restores the +normal pinned position. Only the table content clips to the card corners, so the expanded section +does not introduce a scroll container that prevents the header from sticking. +When the service header pins, an opaque cover fills its top gap and rounded +corner cutouts across the full scrollport, hiding connection text and borders +that have scrolled above the header. The +cover is absent before pinning and after collapse. Scroll/resize tracking is +limited to the expanded card and cleaned up on collapse. Validation: 54 focused +tests passed, including pin/return/collapse behavior and releasing the header at +the final three connection rows; production build and targeted lint passed. +The search field keeps the same border color and thickness on focus in both +themes. The full service overview now shows the matching catalog icon beside its +title, with the same globe fallback as custom-service cards. The 14 shared filter +control tests and 6 service-overview tests passed after these changes, along with +the production build, TypeScript and targeted lint. +Collapsed service cards no longer list connection names beneath Sources; the +names remain in the expanded connection table. Active search retains its labelled +Matches summary so users can see why a connection was included. +The service grid uses 24px gaps and expanded headers have 20px padding. Expansion +scrolls the dashboard viewport using the live toolbar height and viewport padding, +with an additional 32px reveal offset. This spacing belongs to the scroll position, +so it moves away during manual scrolling. Browser scroll anchoring is disabled +within the changing service grid. Reduced motion remains immediate. +Validation: 60 focused tests passed, including changing-toolbar-height scrolling, +hiding/restoring Saved views and footer, and selecting organization pills while +stuck. Shared data-table controls retain their normal wrapping layout. Production +build, TypeScript, targeted ESLint and whitespace checks passed. The preview is +serving the changes; no browser is connected for visual verification. diff --git a/docs/plans/references/services-card-reference.html b/docs/plans/references/services-card-reference.html new file mode 100644 index 000000000..cbd8df0ac --- /dev/null +++ b/docs/plans/references/services-card-reference.html @@ -0,0 +1,63 @@ + + + + + +NyxID · Grouped service cards reference + + + +
Design reference · Example dataCollapsed service → connections inside → full detail
+
NyxIDServicesPersonal workspace
+
+
+
Your workspace

Services

Your connections, together by service.

3 services · 6 connections
+
+
+

Try 3 connections on OpenAI, then open Work account. Its route, activity and details belong to that exact connection. All connections start collapsed inside their service card.

+
+ +
+ + + diff --git a/docs/plans/services-consolidated-fable-review.md b/docs/plans/services-consolidated-fable-review.md new file mode 100644 index 000000000..d4f107c60 --- /dev/null +++ b/docs/plans/services-consolidated-fable-review.md @@ -0,0 +1,657 @@ +# Consolidated Services: Fable adversarial review (pass 1) + +Status: read-only design review of the seed design and of +`consolidated-services-flow.md` (25 Sep 2026). Checked against main `1b031c77`. +No code changed. Line references are to that commit. + +## 1. Verdict in one paragraph + +Ship **one row per callable address** with stateless provider dividers, not a +grouped "service block" hierarchy. A row is something an agent can actually +call today: a configured service slug or a pool slug. The catalog identity is a +sort key and a divider label, never a container with its own status, counts of +"connections", or a family-level "uses now". The inspector is a Sheet with three +fixed tabs, **Route / Activity / Details**, and it is the same component as the +full detail page. Before any of that is truthful, two backend facts must exist +that do not exist today: a per-request record that names the concrete service, +the verified caller and the outcome in one row, and a compact `last_request` / +`last_success` projection on `UserService`. Everything in the draft that reads +"Would use", "checked N seconds ago", "Check: POST /responses", "Use a specific +connection" or "As: application" depends on a resolver and policy store that +are still proposals; those controls must not appear in the first release. + +## 2. What the data can and cannot say (evidence) + +| Question the UI wants to answer | What exists | Consequence | +| --- | --- | --- | +| Which concrete service handled a request? | `proxy_request` audit stores `service_id` = **catalog id** for catalog-backed rows (`proxy_service.rs:3658`), plus method, path, `response_status`, `acting_client_id`, `connection_id` (`handlers/proxy.rs:4300`). The concrete `user_service_id`, `routed_via` and pool `chosen_user_service_id` are in a **separate** `proxy_routed_via_personal/org` row (`handlers/proxy.rs:599-650, 660-680`). No shared request id between the two rows. | "Who last used *this* service" cannot be computed for catalog-backed rows without a new field. The Agent Keys usage dashboard already keys `top_services` by that catalog id (`handlers/api_keys.rs:867-900`) and so cannot distinguish two OpenAI services. | +| Who was the caller? | `AuditLog.user_id`, `api_key_id`, `api_key_name` (`models/audit_log.rs`), `acting_client_id` in event data. `AuthUser.oauth_client_id` and `api_key_credential_id` exist (`mw/auth.rs:60,84`) but are **not** written to the proxy audit. | Person, agent key and delegated app are attributable from verified auth. Ordinary app tokens and Agent Key child credentials are not. | +| Last use? | `last_used_at` lives on `UserApiKey`, touched fire-and-forget at credential materialization **before dispatch** (`proxy_service.rs:2993-3000`) and also for agent override credentials (`3355-3362`). One credential can back several services (journal fan-out, `SERVICE_HISTORY.md`). | It is "credential last prepared", not "service last used" and not "success". Must be relabelled or hidden. | +| Last success? | Only derivable by scanning audit rows with `response_status < 400` (`handlers/api_keys.rs:862-865`), window clamped to 30 days. `usage_meter` rows carry `actor_user_id`, `api_key_id`, `credential_class`, `billing_owner_id`, `service_id` but only when `BILLING_ENABLED`. | No per-service projection. A list page cannot scan audit per row. | +| Who last changed it, and what? | `service_change_events` journal with per-service sequence, `service_slug` snapshot per event, actor kinds Person/ApiKey/ServiceAccount/App/System, safe field diffs; `created_by` / `last_change` summaries on the row; archived deleted histories (`handlers/service_history.rs:343-379`). | Solid. Readable only when `access.can_write() && access.allows_resource(id)` (`service_history/read.rs:47`), so personal owner or scoped org admin; members and viewers get nothing. | +| Lineage / spin-off? | `UserService.rotation_predecessor_id` points to the previous **UserApiKey**, not a parent service (`handlers/keys.rs:621-629`). `source` values: `user_created`, `auto_provision`, `codex_import`, `channel_onboarding`, `connection`, `telegram_*`; `source_id`, `source_app_id` + resolved `source_app_name`. No service-to-service edge. | There is no spin-off relation to render. Only origin facts and computed relations (same credential, same catalog, pool membership, agent binding). | +| What will the catalog slug do? | Resolver: exact personal slug, then personal pool, then legacy personal guard, then org walk in `primary_org_id` order (`proxy_service.rs:1766-1770`, mirrored by the approval-owner lookup at `2285-2330`); an unusable personal row errors with 400 instead of continuing (`2981-2986`); a missing row falls to the legacy platform master-credential path silently. `find_by_catalog_service_id` is an unordered `find_one` (`user_service_service.rs:788-802`). | The only deterministic statements today: an exact slug row executes itself; a pool executes one enabled active member chosen at request time; a catalog slug with no active row and `platform_key_available` falls to the platform key. | +| Is the credential working? | `status` (`active/expired/revoked/failed/refresh_failed/pending_auth`), OAuth `connection_status` derived from expiry (`unified_key_service.rs:4548`), `credential_missing`, `node_status`. Codex only: `metadata.verification_status` `saved/usable/reconnect_required` with `verified_at`, bound to token version, epoch, service version and endpoint (`codex_connection_service.rs:291-306`, `CODEX_CONNECTION.md`). AWS SigV4 probe at creation is not persisted. | Vocabulary is fixed by data: **Saved**, **Expired/Revoked/Failed**, **Missing**, **Verified ** (Codex only), **Not verified** (everything else). Never "Ready". | +| Payer? | `credential_class` and `billing_owner_id` on the meter; `BillingOwnerResolver::resolve_for_execution`; platform-key usage is billed to the acting person even on org-owned rows (CLAUDE.md Rule 5). | Payer must come from the server. Inferring it from the row owner is wrong for platform-bound org services. | +| Pools? | `ServicePool { user_id, slug, strategy: RoundRobin|Weighted, members[{user_service_id, weight, enabled}], rr_counter }`; `resolve_member` increments `rr_counter` on every resolution (`service_pool_service.rs:363-410`). `list_pools` is by owner id only. | A preview must never call `resolve_member`. Member array order already persists, so a `Priority` strategy is a small backend addition. | +| Agent pinning? | `AgentServiceBinding (api_key_id, user_service_id) -> user_api_key_id`; `ApiKey.allowed_service_ids`, `allow_all_services`, `allow_auto_connected_services` (server-expanded at auth time). | Scope membership and credential override are exact client-side facts. The auto-connected expansion is not. | +| Same slug twice? | `/keys` can return a disabled row and an active row with the same slug (`AI_SERVICES_ARCHITECTURE.md:279-282`); known gap 1 (E11000 on re-enable) and gap 2 (tombstone revival zombie). | Rows must be keyed by UUID and the disabled duplicate labelled. | + +## 3. The grouping decision, resolved + +**Choose: one default row per callable address, sorted by catalog identity, +with stateless provider dividers. "Flat" removes the dividers. No nested +"service block" and no cards-versus-grouped toggle.** + +Why the draft's grouped-by-catalog default loses: + +1. **Pools break the hierarchy.** A pool can mix members from different + catalog services. The draft says "real pool routes appear once" but never + says under which service block. Any answer is either wrong or an "Other" + bucket, which proves the container is not a real grouping. +2. **Custom services become one-row families.** Every custom service would + carry a header with "1 route, 1 connection". That is chrome, not + consolidation. +3. **The block invites the exact claim the user forbade.** "Latest visible + request" and "resolution for the visible caller" at the family level are + aggregates over routes with different owners, payers and privacy rules. An + org member would see Acme's latest caller; the draft's own privacy section + forbids that. +4. **"Automatic" mode does not exist.** The draft's Route has modes Automatic, + Direct, Pool. Only Direct and Pool are objects today. A family header is + the natural place for the Automatic verdict, so the hierarchy will grow one + before the resolver ships. +5. **The user's actual scan target is the address.** An agent config, a CLI + call and an MCP tool all name a slug. The list should be scannable by the + thing that appears in those configs. + +What the row-first list keeps from the draft: OpenAI still appears once as a +divider, its three addresses are adjacent, and each row expands to the +existing card. Consolidation is achieved by sort order, not by a container. + +## 4. Vocabulary (fewer words than the draft) + +The draft renames the catalog capability to "Service" and the `UserService` +row to "Connection". That inverts existing product language: Rule 8 lifecycle +verbs act on a *service*, `/keys/{service_id}/history`, "Deleted service +history", `nyxid service list`, and "connection" already means the OAuth +connection (`connection_id`, `connection_status`, Reconnect, Codex +connection). Users and the CLI would need two dictionaries. + +Use instead: + +| Term | Meaning | Backed by | +| --- | --- | --- | +| **Service** | A configured row you enable, disable, delete and call by its slug. | `UserService` | +| **Catalog service** | The template a service was created from. Not callable by itself. | `DownstreamService` | +| **Address** | The slug a caller uses. A service has one; a pool has one. | `UserService.slug`, `ServicePool.slug` | +| **Credential** | The stored key or OAuth grant a service executes with. | `UserApiKey` | +| **Pool** | An address that picks one member service per request. | `ServicePool` | +| **Origin** | How a service came to exist. Write-once. | `source`, `source_id`, `source_app_id`, `created_by` | + +Drop "Route" as a noun, "Connection" as a noun for services, and "spin-off" +entirely. "Route" survives only as the inspector tab title, meaning "what a +call to this address does". + +## 5. Final flow + +### 5.1 Services home + +``` +Services [Search] Owner ▾ Attention ▾ Kind ▾ Grouped|Flat [+ Add] + +OPENAI · catalog llm-openai ─────────────────────────────────────────── 3 addresses + Address Owner Credential Transport Last request Last change +▸ llm-openai You OpenAI key · Saved Direct 2m · coding-agent · 200 3d · Alice +▸ llm-openai-2 Acme Acme key · Saved Direct 1h · you · 200 12 Sep · Ben +▸ llm-openai You NyxID platform · Disabled Direct — 5 Sep · you + (slug reserved by the active row above) +ANTHROPIC · catalog llm-anthropic ──────────────────────────────────── 1 address +▸ llm-anthropic You OAuth · Expired Direct Reconnect needed … +POOLS ────────────────────────────────────────────────────────────────── 1 address +▸ llm-pool You 2 members · round robin per request 5m · coding-agent · 200 … +CUSTOM ───────────────────────────────────────────────────────────────── 1 address +▸ internal-api You Bearer · Saved node lab-1 (online) — Creator not recorded +``` + +Rules: + +- Divider = catalog name, catalog slug, count. Nothing else. Not clickable. +- **Owner** is who owns the row (You / org name). **Credential** is whose key + executes (your key / Acme key / NyxID platform). These differ for + platform-bound rows and must be separate columns. +- **Last request** is the row's `last_request` projection (see 7.1), shown as + age, verified caller, outcome class. For org rows a member sees only their + own ("your last request"); an admin sees all. Absent: "No requests since + " or "Not recorded". +- **Last change** is `last_change` when the reader may see it; otherwise the + column is blank for that row, not "never". Legacy: "Creator not recorded". +- Expanding a row reveals the existing card content unchanged. The card/table + ViewToggle stays for the mobile-card split; there is no third view. +- A disabled row that shares its slug with an active row is labelled as such. + Rows are keyed by UUID. +- The `CodexConnectionSection` banner moves into the Codex-linked row's Route + tab as a "Verified " line. It is per-credential evidence, not page + state. +- Search covers address, label, owner, catalog name. Caller search comes only + after the projection exists. +- **Attention** filter is defined exactly: `credential_missing`, `status` in + expired/revoked/failed/refresh_failed/pending_auth, `connection_status = + expired`, `node_id` set and `node_status != online`, or `is_active = false`. + +### 5.2 Inspector (Sheet on desktop, page on mobile, same component) + +``` +llm-openai You · Direct [Open full page] +[Route] [Activity] [Details] + +ROUTE + Calls to /api/v1/proxy/s/llm-openai execute this service (exact slug). + Credential OpenAI key · Saved 12 Sep · Not verified + Transport Direct + Payer You · your key (server-computed) + Evidence Last successful request 2m ago · coding-agent · 200 + Note This is the catalog slug. If this service is disabled or deleted, + calls to llm-openai use the NyxID platform key and platform pricing. + Viewing as [You ▾] coding-agent: in allowlist · overrides credential "work key" + +ACTIVITY (Requests | Changes) + 2m coding-agent · agent key POST /v1/responses 200 this service + 1h you · session GET /v1/models 200 this service + 3d Alice changed label "Prod" → "Production" [expand] + ─ Recorded since 20 Sep 2026 ─ + +DETAILS + Endpoint, auth method, headers, identity propagation, node routing (existing) + Origin Added by you · CLI · 12 Sep 2026 + Related Same credential: none + Same catalog: llm-openai-2 (Acme), llm-openai (NyxID platform, disabled) + Pools: llm-pool (member 1 of 2) + Agents bound: coding-agent → "work key" +``` + +Pool inspector Route tab: + +``` +ROUTE + Calls to /api/v1/proxy/s/llm-pool pick one enabled member per request. + Strategy Round robin · 2 of 3 members eligible now (excluded: llm-openai-2, disabled) + Members 1 llm-openai You OpenAI key · Saved weight 2 + 2 llm-openai-2 Acme Acme key · Disabled weight 1 (skipped) + 3 llm-anthropic You OAuth · Expired weight 1 (skipped) + Evidence Last request 5m ago ran member llm-openai (coding-agent, 200) + [Strategy ▾ Round robin | Weighted | Priority] drag handles appear only under Priority +``` + +Rules: + +- The three tabs are fixed. The full page is the same three tabs; today's + Overview/Advanced/History become Route/Details/Activity. No fourth tab, no + "Connections" tab, no "Related" tab. +- **Route** shows facts and evidence, never a verdict, until the evaluator + ships. "Would use", "checked N seconds ago", "Check: POST /responses", + "Ready via" and an operation picker are out of the first release. +- **Viewing as** lists You and the reader's agent keys. For an agent it prints + only exact facts: in allowlist / not in allowlist / all services, credential + override label, rate limit. No route verdict "as agent" until the evaluator + accepts an actor. "Application context" is dropped; nothing can simulate it. +- **Payer** is a server field. The client never derives it from Owner. +- The platform fallback **Note** appears only when the row's slug equals its + catalog slug and `platform_key_available` is true. It is the one place the + legacy fallthrough is disclosed, and it is the "paid fallback" answer. +- **Activity** is one chronological feed with two filter chips. Requests come + from the per-request record (7.1); Changes from the journal. Members of an + org see "Changes: available to org admins" instead of an empty list. +- **Details → Related** is computed from `api_key_id`, `catalog_service_id`, + pool membership and bindings. Each relation is labelled with what it is; none + implies inheritance. The journal already fans out shared-credential edits to + every referencing service, so no "include related events" option is needed. +- Pool member drag exists only when strategy is Priority, only inside that + pool, with keyboard equivalents. The preview never calls `resolve_member`. + +## 6. Critique of the draft, itemised + +### 6.1 Accepted as written + +- One workspace; pools in it; Agent Keys stays a separate destination. +- No family-wide selection claim; no invented canonical route; omit absent + platform sources; unavailable records shown below with a repair reason. +- Deep-link by immutable id; preserve list filters and scroll. +- Three tabs. +- Pin is strict; multiple accounts in a tier need an explicit choice; org order + is not database order. +- No fallback after dispatch; a resolver/database failure is not permission to + try another identity. +- Last attempt and last success tracked separately; transport completion is + not business success; "Not recorded" and "Recorded since" wording. +- Do not infer caller from User-Agent, provisioning app, creator or owner. +- Relationship labels instead of "spin-off"; rename and credential rotation are + history on the same service; slug reuse never merges histories. +- Create variant as a future write-once origin link. +- Round robin / Weighted say "selects per request"; previews never advance the + counter; Priority is a real strategy before it has drag handles. + +### 6.2 Blocking (the draft cannot be built truthfully until fixed) + +| # | Draft claim | Problem | Required change | +| --- | --- | --- | --- | +| B1 | "Expanded Requests feed shows the requested route, actual connection, source/owner and result"; "same execution ID" for pool and direct calls | No single record has actor + concrete service + outcome; catalog-backed audit stores the catalog id; the two audit rows share no id; ordinary app `oauth_client_id` and child credential id are not audited. | Add `user_service_id`, `route_kind` (exact / pool_member / org / legacy_platform), `pool_id`, `credential_class`, `oauth_client_id`, `api_key_credential_id` and an outcome class to the `proxy_request`, `llm_proxy_request` and `proxy_request_denied` events, plus one request id shared with the routing row and the meter's `billing_request_id`. `DestinationAudit` (`destination_routing.rs:375-400`) carries service/target/origin but no request id, so one must be minted at the proxy entry and threaded through all three writers. | +| B2 | "Latest request … on the row" | Reading audit per row is a 30-day scan per service; the existing agent-key dashboard already pays this. | Add `last_request {at, actor, outcome_class, route_kind, request_id}` and `last_success {…}` projections on `UserService`, updated fire-and-forget after the response is written, same pattern as `last_change`. Outcome classes: `dispatched_ok` (upstream 2xx/3xx), `dispatched_error` (upstream 4xx/5xx), `not_dispatched` (denied or failed before upstream). `last_success` advances only on `dispatched_ok`. | +| B3 | `last_used_at` shown as usage | It is credential preparation time on a possibly shared credential, including agent override use. | Relabel to "Credential last prepared" inside Details, or hide. Never feed Last request or Last success from it. | +| B4 | "Would use … checked 24 seconds ago", "Ready via", "Check: POST /responses", "Use a specific connection", "As: application" | No resolver projection, no preflight endpoint, no per-service route preference, no actor-parameterised evaluation. The example's fallback to Acme after an expired personal key is not current behaviour: today that request returns 400. | Remove from release 1. Route tab shows facts and evidence. Label the §3 example as future-state. | +| B5 | Service / Route / Connection vocabulary | Inverts existing product terms and collides with OAuth "connection". | Adopt section 4 terms. | +| B6 | "Latest visible request" on the service block; caller names in the feed | Org privacy is stated in §5 but violated by the block aggregate; the rule is not concrete. | Concrete rule: personal rows show everything to the owner; org rows show all callers to org admins, only the reader's own requests to members, nothing to viewers. The list column obeys the same rule. | +| B7 | "Actual payer/pricing" before saving a policy; payer in resolution | Client cannot derive payer; platform-bound org rows bill the acting person. | Payer is a server-computed field on the key view; UI renders it verbatim or omits it. | +| B8 | History for "the visible caller context" | `read.rs` requires `can_write()` plus resource scope; members and viewers get no authorship or journal. | Activity → Changes must render an explicit "available to org admins" state for members; do not promise members edit history. | +| B9 | One row per address | `/keys` can return two rows with one slug (disabled + active); tombstones can be revived into zombies (known gaps 1 and 2). | Key rows by UUID; label the disabled duplicate; fix gap 1 (409 at create) before shipping the grouped sort, or the sort will show an impossible pair without explanation. | +| B10 | Agent "As:" simulation | `allow_auto_connected_services` expansion is server-side; org role scopes apply per membership. | Show only allowlist membership, binding override and rate limit as facts. No verdict. | + +### 6.3 Challenged as control-heavy or unusable (drop or defer) + +- **Cards toggle for individual connections** as a second presentation of the + same list. Row expand shows the card. Keep only the existing card/table + ViewToggle for the responsive split. +- **Search by authenticated caller** before B1/B2 exist. +- **"Changes can optionally include related-service events"**: the journal + already writes shared-credential edits into every referencing service's own + history. The option adds a control and a scope-leak risk for no new data. +- **Operation picker ("Check: POST /responses") in the Sheet**. If an + operation-scoped check ever ships, put it on the full page and the CLI, not + in the list inspector. +- **Saving a route/pin policy from the inspector** ("show who owns the setting, + who it affects"). No policy store exists. The only writes in release 1 are + the existing ones: Enable/Disable, node routing, pool strategy and members, + credential replacement. +- **"Release dashboard · Application · on behalf of Alice"** cannot be produced + for ordinary app tokens today (not audited). Show it only when + `acting_client_id` (delegated) or the new `oauth_client_id` field is present. +- **"API key ending …7K2"**: the audit row has `api_key_id` and name, not the + prefix; a lookup is needed, and Agent Key child credentials are invisible. + Render the key name; add the child credential id in B1 if per-login + attribution matters. + +### 6.4 Where I disagree with the seed + +- "Service family" as the hierarchy: no server object, misleading at the + family level, breaks on pools and custom rows. Divider only. +- Four inspector tabs (Overview, Connections, Activity, Related): "Connections" + duplicates the list, "Related" is a section of Details, "Overview + caller" + is a Route tab with a Viewing-as control. +- "Uses now": unsafe for pools (counter), unavailable for catalog slugs (no + resolver), and dishonest for anything requiring a probe. Replace with + "executes this service" (exact), "picks per request" (pool), and evidence + with age. +- "Spin-off": no data. Origin (write-once) plus computed relations. +- Actor from creator or User-Agent: rejected; the audit row's verified + `user_id` / `api_key_id` / `acting_client_id` is the only source. + +## 7. Backend additions the flow depends on (smallest set) + +1. **Per-request record fields** (B1) on the existing proxy audit events, plus + a shared request id. No new collection. +2. **`last_request` / `last_success` projections** (B2) on `UserService` and + on `ServicePool` (the pool's last request also names the member that ran). +3. **`payer` on the key view**, computed by the existing billing owner resolver + without reserving anything. +4. **`PoolStrategy::Priority`**: first enabled active member in array order, + error if none; member reorder endpoint already implied by member edits. +5. **Gap 1 fix**: 409 when creating a service on a disabled row's slug. +6. **Read endpoint for a row's recent requests** scoped by the privacy rule in + B6, cursor-paginated, 30-day cap like the agent-key dashboard. + +Release 2 (only after the evaluator from `slug-connection-resolution-proposal.md` +exists): canonical catalog-slug rows with mode Automatic, the Route tab +candidate order, a read-only "Check route" button, Viewing-as verdicts, and +per-service source preference. Release 3: Create variant with an immutable +`origin.derived_from_service_id`. + +## 8. Edge cases the final flow must render + +| Case | Rendering | +| --- | --- | +| No requests ever, no journal | Last request "Not recorded"; Last change "Creator not recorded"; Activity shows "Recorded since ". | +| Legacy row with edits before tracking | Footer "Earlier edits not recorded"; timeline starts at first journaled event. | +| Deleted service | Hidden from the list; "Deleted services" filter lists archived UUIDs with slug and last change; inspector opens read-only with Activity only. | +| Slug recreated after delete | New UUID, new history. Details → Related: "This slug previously belonged to a deleted service (last changed )", labelled as slug reuse, never as lineage. | +| Disabled row and active row share a slug | Both listed; disabled one labelled "slug reserved by the active row"; Enable on it is expected to fail until gap 1 is fixed, so the button explains why. | +| Multiple orgs | Each org row shows org name and the reader's role. No ordinal until the evaluator exposes membership order; the Route tab of a catalog-slug row says "org order: primary org first" only when the resolver reports it. | +| Two personal accounts for one catalog service | Two rows, two addresses (`llm-openai`, `llm-openai-2`); the catalog-slug row carries the fallback Note; the other carries none. No "default" control until the preference store exists. | +| Agent pinned to a credential | Viewing-as shows "overrides credential X"; Activity rows from that agent show the override in the route column once B1 lands. | +| Viewer role on org row | Row visible, reduced opacity, no Last request, no Last change, inspector Route tab facts only. | +| Member role on org row | Row visible; Last request = member's own; Changes tab "available to org admins". | +| Platform absent | No platform row, no Note, no option anywhere. | +| Platform configured but `platform_key_available = false` | Explicit platform-bound rows show Credential "NyxID platform · Unavailable" with the catalog reason; no fallback Note on the catalog-slug row. | +| Paid fallback live (catalog slug, platform key available, user row exists) | The Note in the Route tab, worded with "platform pricing". Disable confirmation repeats it: "Calls to llm-openai will use the NyxID platform key while this service is disabled." | +| Pool with mixed credentials or providers | Members listed with their own owner and credential; pool sits under the POOLS divider, never under a catalog divider. | +| Node-routed service, node offline | Transport "node lab-1 (offline, 2 fallbacks)"; Attention filter catches it; no request is claimed possible. | +| Codex-linked service | Route tab Credential line "Verified · usable" or "reconnect_required"; the only service kind allowed to say Verified. | + +## 9. Recommendation + +Adopt the row-per-address list with stateless dividers, the three-tab +Route/Activity/Details Sheet shared with the full page, and the vocabulary in +section 4. Treat B1 and B2 as the definition of done for "who last used this +service"; without them the Last request column must not ship. Keep every +resolver-dependent phrase out of release 1 and label the draft's §3 example +as future behaviour. The draft's sequence (contracts, server, UI) is right; the +contracts are smaller than the draft implies, and the first UI release is a +truthful metadata view, not a routing product. + +--- + +# Pass 2: closing disposition on the rewritten draft + +Reviewed `consolidated-services-flow.md` at its 25 September rewrite (412 +lines). Line numbers below refer to that file. Verdict: **converged once the +six wording fixes in P2.2 are applied.** They change sentences, not the design. + +## P2.1 Accepted, and pass-1 positions withdrawn + +The draft's structure stands: one entry per real callable address, stateless +catalog dividers, one inspector with Route / Activity / Details shared with the +full page, Service vocabulary, correlated execution activity, origin rather +than inferred lineage, explicit List/Cards with no third mode. + +Withdrawn from pass 1 after the user's corrections and re-checking code: + +- "Priority = first enabled active member in array order." Wrong; a pool + member must pass the same eligibility as an Automatic candidate. Draft + lines 212 and 235 are right. +- "Metadata-only release 1, resolver later." The target includes Automatic + resolution; the draft's delivery order (contracts and resolver first, UI + last) replaces my release split. +- The platform fallback Note derived from slug plus `platform_key_available`. + Draft line 366-368 is right: fallback is a server decision, never + reconstructed from availability metadata. +- "Member's own latest by filtering the row's latest." Needs an actor-scoped + projection; draft line 283-284 is right. +- Pools must stay same-owner (`resolve_member` filters `user_id: owner_id`, + `service_pool_service.rs:380-384`); draft line 206-207 is right. + +Checked and found consistent with code, no change needed: + +- No replay after dispatch (lines 189-192, 385). A node that accepted the + request and then failed returns `DurableOperationOutcomeUncertain` with no + retry (`handlers/proxy.rs:3282-3287`); `fallback_node_ids` is consulted + only before a node accepts. Node failover therefore already sits below the + source boundary. +- Streaming and Codex-transport audits record `response_status` at header + time (`handlers/proxy.rs:3697-3712`, `3290-3300`), which is exactly why step + 2 (line 349) must separate protocol completion from headers. Evidence, not + a blocker. +- Exact-call identity for the same-slug candidate exists in two forms today: + the UUID address (`KeyResponse.proxy_url`, resolved by + `resolve_proxy_target_by_user_service_id`, `handlers/proxy.rs:1059`) and the + pin `?_nyxid_via=` on the slug form + (`handlers/proxy.rs:902`). Line 75 can name them. +- Auto-provisioned and platform-bound rows reject user mutation + (`user_service_service.rs:76-83`), so the opt-out lives in the source editor + as the draft says (line 148). + +## P2.2 Blockers: six line fixes + +**F1. Lines 115-133 and 172-182: state the eligibility rule.** The draft +never says whether a fresh, unverified credential may enter Automatic. Code +answers it: the read-only snapshot counts an expired OAuth token with a refresh +token as materializable without refreshing (`credential_is_materializable`, +`proxy_service.rs:3536-3545`; `read_only_snapshot`, `1650-1657`), and +execution refreshes during preparation. The only alternative, requiring +verification first, would exclude every newly added key from its own address +and there is no safe generic probe (Codex and the at-creation AWS probe are +the only supported checks). Insert after line 133: + +> Eligibility is preparation, not verification. A candidate enters the +> usable order when authority, configuration and credential preparation +> succeed at evaluation time; a refreshable credential is eligible with a +> pending refresh gate. Verification evidence changes the label, never the +> order. A candidate leaves the usable order only on a classified terminal +> failure (missing, revoked, failed, terminal refresh rejection, disabled, +> no permitted node route) or an explicit user action (Disable, pin). An +> upstream 4xx, 429 or outage is recorded as that request's outcome and +> never demotes a candidate; repair is a human action. The first completed +> execution through a candidate is its verification evidence; no business +> request is sent to manufacture one. + +This also closes the "actual valid and working" question: "working" is a +dated outcome shown in Activity, "eligible" is a preparation result, and the +UI never conflates them with a badge. + +**F2. Lines 120 and 133: a pending gate above blocks "Would use" below.** +With F1, a personal candidate in "Refresh required" is still ahead of Acme. +The example at line 98 is fine because "Needs reconnect" is terminal, but the +rule is missing. Replace the "Would use" table row evidence with: + +> Server evaluation for this caller, operation and policy version in which +> every higher-priority candidate is in a classified terminal state. If a +> higher candidate is in a pending gate (refresh, verification, approval), +> show **Would use Personal after refresh, otherwise Acme**; never name the +> lower tier alone. + +**F3. Line 148 and line 376: state the default for platform inclusion.** +"Users may exclude optional org or platform fallback" and "only if allowed" +leave the default undefined, and the default decides whether migration opts +BYOK users into charges. Insert after line 149: + +> Platform is included in Automatic by default only where the offering is +> available to this caller and the payer already holds billing authorization +> for that route; otherwise it is excluded until the user opts in from this +> editor with payer and pricing shown. Migration never enables platform +> charges for an existing user without that opt-in. Excluding a tier removes +> it; it never reorders the remaining tiers. + +**F4. Line 172 and line 189: define when a canonical entry exists, and +close the no-row path.** "An enabled canonical Automatic address" is never +defined, so the list could show every catalog entry with a public platform +key, or hide a live path. Insert before line 172: + +> A canonical entry exists for a viewer when the evaluated candidate set is +> non-empty: at least one personal service for that catalog identity, one +> org service reachable through an active membership with proxy rights, or +> a platform offering that is included under F3. With no candidate, the +> catalog page is the entry point and calls to the address return the +> structured unavailable error; the legacy catalog-slug path is not consulted +> for a canonical address unless a legacy pre-migration connection exists. + +The last clause is the guard from the earlier reconciliation; without it +"no rows plus platform excluded" leaks through `resolve_service_by_slug`. + +**F5. Lines 55-57 and 73-79: Owner and Latest request on canonical +entries.** A canonical entry has no record owner; "You" there is the policy +scope, while "Acme" on the row below is a record owner. Same column, two +meanings. Fix the value, not the column name: on canonical entries Owner +reads **Your calls** (or **Acme's calls** for an org-scoped policy that an +org admin opens). Add to line 77: + +> A canonical entry's Latest request covers requests **to this address** by +> the viewer's scope; the nested candidate card's Latest request covers +> executions **through this service** by any permitted entry point. Both +> labels are shown. When the same-slug candidate is an auto-provisioned +> platform row, the nested card offers no Disable; exclusion lives in the +> source editor. + +**F6. Line 100: the payer in the example is wrong.** With "Would use Acme · +Work OpenAI" on a BYOK org credential, the billing owner is the resource +owner, not the acting person: `resolve_for_execution` charges the acting +person only for `NyxidManagedMaster` and otherwise resolves the resource +owner (`billing/owner_resolver.rs:53-67`). Change the line to **Paid by Acme +(org wallet) · provider charges to Acme's key** and add one sentence under +the label table: + +> Paid by always follows the selected candidate: org credential, org wallet; +> platform key, the acting person; personal key, you. While selection is +> pending, show **Payer depends on source** rather than a guess. + +## P2.3 Recommended, not blocking + +- Line 57: the list's source cell ("Automatic · Personal") needs a batched + read-only evaluation for the page. Say so, or show only **Automatic** in + the list and evaluate on open. The footnote's age is invisible in a + four-column row anyway. +- Line 75: name the two exact-call forms (UUID address, `_nyxid_via` pin). +- Lines 210-212: say that Round robin and Weighted also select among + *eligible* members under F1, replacing today's `is_active`-only filter + (`service_pool_service.rs:374-390`), and that a member failing preparation + is skipped before dispatch within the member set. +- Line 166-167: "a stable saved order" for multiple orgs is a new per-user + preference; list it in section 7 under versioned policy. +- Line 264: add "denied before dispatch" as an outcome with no executed + target, matching line 350. + +## P2.4 Status of F1-F6 after the user's edits + +F2 (pending higher candidate blocks a definite preview), F5 (Owner on +canonical entries, "Through this address" versus "Handled by this service", +policy journal versus concrete journal, back breadcrumb) and F6 are addressed +in the final text. F3 and F4 remain as written above and are still required. +F1 is superseded by the user's explicit working-only choice; the closing +assessment of that choice follows. + +--- + +# Pass 2 closing: the final text (460 lines) + +Assessed as final. Two blockers remain, both inside the new working-only +paragraph (lines 151-163) and its acceptance rows (422-423). Everything else +is converged. + +## G1. The working-only selection rule contradicts the draft's own skip rule and deadlocks the common case + +The paragraph says unverified candidates "do not enter the working set" and +row 423 says "do not ... dispatch an Automatic/Priority request". Three +consequences, each traced to the final text: + +1. **Internal contradiction.** Step 3 (line 219-221) permits skipping "only + classified candidate failures". Row 422 skips a saved personal key because + no evidence exists. Absence of a record is not a classified failure. Line + 197 forbids moving "between unchosen accounts"; row 422 moves the caller + from their own configured account to Acme's on that same absence. +2. **Deadlock for slug callers.** A user with one personal key for a provider + without a supported check (every provider except Codex and the at-creation + AWS probe), no org and no platform, whose agent is configured with the + canonical slug: the slug is Automatic (line 195-197), the candidate is + unverified, no safe check exists, so every call returns **Verification + required**. The only bootstrap is an exact UUID or `_nyxid_via` call (line + 161-162), which the agent never makes. The Connect flow (line 204-205) + cannot progress past Saved for that provider. Pools of fresh keys under + Priority deadlock the same way. +3. **Day-one migration.** The execution record that produces "qualifying + recorded success" does not exist yet; today's audit rows cannot be + backfilled per service (catalog id, no join). At rollout every existing + BYOK candidate is unverified. Under row 422 their traffic shifts to org or + platform accounts on the first call; under row 423 it stops. Either is a + silent identity or availability change for every current user. + +Keep working-only for **claims and preview**; change **selection** so +missing evidence never substitutes an identity. Replace lines 157-160 with: + +> Execution may perform a supported safe check before selection, at most +> once per credential and configuration version, and records the result as +> evidence. When the top-ranked candidate has no evidence and no supported +> check, Automatic and Priority execute that candidate as its **verification +> attempt**: the caller's own request, on the requested operation, with no +> other identity substituted because evidence is missing. Preview shows +> **Would try Personal · unverified**, never Would use or Ready. The outcome +> becomes that candidate's version-bound evidence; a classified failure then +> removes it from the working set on the next call. A policy may opt into +> **Require verified sources**, in which case an unverified top candidate +> returns **Verification required** naming the available actions (Verify, +> Use exactly once, Switch source) and no other account is used. + +Rewrite rows 422-423 accordingly: + +| Scenario | Required result | +| --- | --- | +| A personal key is merely saved and org has current valid evidence | Check personal safely when supported; otherwise Automatic executes personal as its verification attempt and records the outcome. Org is used only after a classified personal failure, or when the policy requires verified sources and the user chose Switch source. | +| Every configured credential is unverified and cannot be safely checked | Automatic and Priority execute the top candidate as a verification attempt; nothing is marked ready. Under Require verified sources: Verification required, no dispatch. | + +If the user keeps strict exclusion instead, the minimum to make it operable +is: (a) the single-candidate bootstrap (a canonical call whose candidate set +has exactly one unverified member executes it as an exact request), (b) a +migration rule that pre-rollout candidates receive one verification attempt, +(c) Connect and the row both stating **Unverified · Automatic will use Acme +until verified** before any traffic shifts. Without all three the rule ships +either an outage or a silent account switch. + +## G2. Evidence definition omits OAuth authorization and lets time expire eligibility + +Line 153-156: "supported validation/refresh, or a qualifying recorded +success ... Freshness limits ... defined by the provider adapter." + +- A just-completed OAuth authorization produces a valid token with no + refresh performed and no request yet made. Under the text it is + unverified. Most catalog services are OAuth. Add: **a completed OAuth + authorization or successful refresh is validity evidence for that token + version** (`last_authorized_at`, `write_oauth_tokens_to_key`). +- Adapter-defined freshness means an idle service with a success eight days + ago can drop out of the working set with no change to anything, flipping + Automatic to another account and filling Needs attention with healthy + rows. Replace with: **evidence is invalidated by a credential epoch change, + an endpoint or configuration version change, or a classified failure; not + by elapsed time. Age is always displayed.** If an adapter needs a + time-bound re-check, it downgrades the label to **Re-check recommended** + and never changes selection. + +## Recommended, not blocking + +- Line 55-63: Owner **—** on the canonical row shares a column with blank + cells on restricted rows and "Not recorded" elsewhere, so a dash reads as + unknown. **Your policy** (or **Acme policy** for org scope) scans cleanly + and matches footnote ². + +## Verdict + +Converged on structure, vocabulary, inspector, activity scope, lineage, +pools and privacy. Not converged on G1 and G2: apply the replacement +wording, or keep strict exclusion with the three operability conditions. +No further investigation is needed either way. + +--- + +# Final disposition on G1 and G2 (primary draft at 524 lines) + +**Closed.** The rewrite keeps working-only selection and removes the three +risks by making absence a pending gate rather than a terminal or substitutable +state. Checked against the final text: + +- **Deadlock.** Lines 159-163 return **Verification required** instead of + skipping; lines 177-183 give an explicit **Use exact service** path with a + generated exact URL/agent configuration and a user-requested first real + operation that produces version-bound evidence; line 183 requires setup to + surface that path. Lines 250-256 offer Automatic only after candidates are + reviewed. A slug-configured agent with one unsupported key is no longer + trapped; it is redirected. Opportunistic first-use verification was my + preference; explicit setup is a legitimate tradeoff, not an open gap. +- **Migration.** Lines 250-252 preserve existing exact/custom/pool contracts + until an explicit migration whose preview names addresses, agents, policy, + evidence gaps and payer, and cannot enable Automatic while verification is + unresolved. Row 476 rules out a first-day outage or paid fallback. Closed. +- **Identity.** Lines 161-163 and row 474 forbid substituting an org or + platform account for missing evidence; lines 171-173 forbid time alone from + marking a credential broken or changing identity; Priority stops at an + unassessed higher member unless explicitly excluded (167-168); Round + robin/Weighted use an explicitly approved working set disclosed before + activation (165-166). Closed. +- **G2.** OAuth authorization and refresh count as evidence (154-155); + freshness is a re-check, never a demotion by age. Closed. + +Three one-line residuals, none blocking: + +1. **Implicit no-row platform path.** Lines 246-249 retire the hidden legacy + platform source for canonical contracts with no usable candidate. Callers + who rely on it today (no rows, never opened `/keys`) are not covered by + row 476. Name this as a deliberate consent cutoff in the migration + section and measure it first: audit rows with `routed_via: "personal"` + and `user_service_id: null` are exactly that path + (`handlers/proxy.rs:632-640`). +2. **Copy target.** While a service is unverified and its canonical address + is Automatic, the card's copy action and agent config must hand out the + exact URL (`KeyResponse.proxy_url`), not `proxy_url_slug`, or the trap + returns through the existing copy button. +3. **Freshness loophole.** Line 171-174 lets an adapter require a re-check + and, if unavailable, "stop with an actionable state". Add: an adapter may + require a freshness re-check only when it supplies a safe check; otherwise + version-bound evidence does not expire. Without this, an idle key for an + unsupported provider can move from working to stopped by time alone, + which line 172 says must not happen. + +Review closed. No further pass requested. diff --git a/docs/plans/slug-connection-resolution-proposal.md b/docs/plans/slug-connection-resolution-proposal.md new file mode 100644 index 000000000..5850503b5 --- /dev/null +++ b/docs/plans/slug-connection-resolution-proposal.md @@ -0,0 +1,483 @@ +# Slug routing and connection availability + +Status: proposal for review; application behavior is unchanged. + +The current product flow is [Consolidated Services](consolidated-services-flow.md) +(revised 27 September 2026). This document retains the detailed execution rationale; +the newer flow supersedes its UI terminology and presentation. + +The current UI flow is in [service connections and pool ordering](ai-service-connection-user-flow.md), +revised 17 September: omit absent platform sources, require verified readiness, +retain individual cards, and drag only actual members within a ServicePool after +opting into Priority. Catalog grouping is a view, not a pool. This document +retains the execution and security rationale behind that flow. + +Date: 2026-09-16. Prepared by Codex with the independent +[Fable review](slug-routing-fable-review.md) incorporated. This document is the +reconciled recommendation; the review retains its original alternatives. + +User clarification: the required execution order is **user → organization → +platform**. Organization fallback after an unusable user connection is part of +the core delivery. This supersedes the earlier review's recommendation to defer it. + +Source choice: users can explicitly select **NyxID platform only** even when their +personal connection is usable. The three-tier order is the **Automatic** mode, +not a restriction on an authorized explicit source choice. + +## Product contract + +In Automatic mode, calling a service's canonical slug selects the first usable, authorized +connection in this order: the user's connection, an authorized organization +connection, then a platform connection explicitly offered for that service. +Missing or unusable user credentials advance to the organization tier; missing +or unusable organization credentials advance to the platform tier. +If none qualifies, NyxID rejects the request before +forwarding it and explains what the caller can do next. + +The user should see the same decision before calling: which connection the slug +would use, why an earlier connection cannot be used, and which account pays. + +This selection happens on the server for every canonical slug API request. The +client keeps one URL and does not need to choose a source or implement fallback. +In Automatic mode, repairing a user connection makes the next new request prefer +it again. In platform-only mode, the platform remains selected. An +in-flight request or established stream remains on its selected connection. + +“Usable” means the credential and route can be prepared for this operation and +pass its access and billing checks. It cannot promise that a third-party service +will accept the next request. Show verification evidence and its age separately +from connection configuration, and recheck at execution time. + +## What exists and where the behavior falls short + +These observations were checked against the current code, rather than inferred +from older design documents: + +| Current behavior | Consequence | Evidence | +| --- | --- | --- | +| Personal `UserService` resolution immediately calls `finish_resolution()` and propagates its error. Personal pools, legacy personal records and org resolution follow separate branches. | A matching but unusable personal row can stop resolution before another source is considered. | `backend/src/services/proxy_service.rs`, `resolve_proxy_target_from_user_service`, `finish_resolution` | +| An inactive personal row is skipped by active-only lookup; for an eligible master-credential catalog service, the legacy path can then select the platform credential, subject to its authorization and scope gates. | Expiry can stop access while Disable/Delete can permit platform fallthrough. The new resolver must own the terminal decision, including explicit platform opt-out. | `user_service_service.rs`, `find_by_slug`; `handlers/proxy.rs`, slug legacy fallback; `proxy_service.rs`, `resolve_proxy_target` | +| Auto-provisioned platform services are stored under the user's ID. | Ownership alone cannot identify a user-supplied credential. | `proxy_service.rs`, `AUTO_PROVISION_SOURCE` handling; `unified_key_service.rs`, `auto_provision_no_auth_services` | +| Auto-provisioning skips a catalog service if any matching user row exists, including inactive rows, and is invoked from the key listing. | A platform candidate must be derived from authorized catalog configuration without requiring an auto-provision row or a visit to `/keys`. | `unified_key_service.rs`, `auto_provision_no_auth_services`, `list_keys` | +| Concrete connection slugs can receive suffixes; catalog-ID lookup uses `find_one` without a preference order. | Calling the catalog slug may miss a healthy user connection named `llm-openai-2`; selecting by catalog ID is not a defined multiple-account policy. | `unified_key_service.rs`, `resolve_unique_slug`; `user_service_service.rs`, `find_by_catalog_service_id`; `frontend/src/types/keys.ts`, `catalog_service_slug` | +| Master credentials already require a qualified catalog service and authorization. They are not generic provider OAuth app credentials. | A configured OAuth client does not make a platform execution connection available. Existing platform authorization must be retained. | `proxy_service.rs`, `authorize_master_credential`, `is_valid_master_credential_service` | +| `/keys` combines credential status, connection expiry, node status and provenance in the frontend. Proxy discovery uses connection presence; MCP has its own credential classifier. | Surfaces can disagree about whether the same service is callable. | `frontend/src/pages/keys.tsx`; `proxy_discovery_service.rs`; `mcp_service.rs`, `classify_credential` | +| Approval-owner and approval-hint lookups mirror routing independently. Pools already exist; selection increments a database counter. | New precedence must cover approvals and previews, and merely opening a page must not rotate a pool. | `proxy_service.rs`, `find_effective_service_owner`, approval hint resolvers; `service_pool_service.rs`, `resolve_member` | +| Billing already distinguishes the final credential class; resale requires `NyxidManagedMaster`. | Charge attribution should consume the final selected route. User credentials do not imply that every NyxID proxy fee is zero. | `backend/src/services/billing/route_context.rs` | + +## Resolve service identity before choosing a credential + +A catalog slug names a logical service. A connection UUID or custom/instance slug +names a particular connection. Preserve that distinction: + +- **Canonical catalog slug:** enables the user-first selection rule. Find user + candidates by the catalog ID, including connections whose concrete slug has a + suffix. Never match credentials by a slug prefix or provider name alone. +- **Concrete connection UUID, the existing `_nyxid_via` selector, custom slug or pool + slug:** retain their declared target. An explicit account choice must not turn + into another account or platform identity when it fails. Existing pool selection + remains inside its member set. +- **Existing custom/pool slug colliding with a catalog slug:** preserve its current + meaning until an explicit migration resolves the conflict. Never silently + retarget that traffic. Block new ambiguous assignments for enabled canonical + routes. +- **An existing same-slug connection linked to the same catalog:** can remain the + preferred member of that canonical route. The detail page exposes a UUID-based + exact URL for callers that require that particular connection. +- **Agent credential overrides and exact approvals:** are execution constraints, + not optional preferences. A missing bound credential fails that bound request. + An approval for one concrete identity never authorizes another by implication. + +For multiple user connections, persist a preferred connection. Preserve the +existing same-catalog exact personal slug match as the initial default. When +there is no such match or stored preference, automatically use the only configured +compatible personal connection. With several unchosen accounts, ask the user to +choose a default; do not choose an arbitrary MongoDB row or silently change +accounts because one has failed. A default is an ID preference, never a slug +rename. Existing pools cover intentional balancing; v1 adds no ordered list of +alternative user accounts. + +## Selection policy + +```mermaid +flowchart TD + A[Call canonical service slug] --> B{Caller and request permitted?} + B -->|No| X[Reject with reason and next action] + B -->|Yes| C{Usable user connection?} + C -->|Yes| U[Use user connection] + C -->|No| D{Usable authorized org connection?} + D -->|Yes| O[Use organization connection] + D -->|No| E{Platform fallback offered and permitted?} + E -->|No| X + E -->|Yes| F{Platform credential, operation and funding ready?} + F -->|Yes| P[Use platform connection] + F -->|No| X +``` + +User-first includes legacy personal credentials during migration. A user +connection that fails a typed eligibility/preparation check advances to eligible +organization connections, then platform. Label the organization with its actual +name and billing owner. For multiple organizations, retain primary-organization +priority and a defined stable order for remaining eligible memberships; expose +that order in the expanded routing view. Apply role, service/node scope, consent, +operation/account compatibility, approval and payment gates to each candidate. +An unauthorized organization is not a usable candidate. Caller-wide denials and +exact execution constraints still stop the request. Public catalog visibility +does not confer execution permission. + +Eligibility has four parts: + +1. **Authority:** authenticated caller, owner ACL, API-key service/node scope, + consent, operation policy, resource restrictions and approval constraints. +2. **Configuration:** enabled connection, existing endpoint and credential, + correct provider/operation/account compatibility, and an eligible catalog + service. A deliberately no-auth service needs no credential; a missing + credential row does not mean no-auth. +3. **Preparation:** materialize an authorized credential; perform the existing + bounded OAuth refresh when needed; confirm the required node route can be + dispatched. An offline primary node may use existing permitted node failover. +4. **Payment:** resolve the actual billing owner and final credential class, + enforce entitlements and reserve any required funding before provider effects. + +Classify failures rather than catch every `AppError` and continue: + +| Condition | Action | +| --- | --- | +| No configured personal connection | Consider the next permitted source. | +| Missing/expired personal credential, terminal refresh rejection, or unavailable permitted node routes | Consider the next source only for a canonical route whose policy allows that substitution; retain a repair reason. | +| Credential refresh is pending or its result is unknown | Bound the preparation attempt; report uncertainty or a permitted alternative without declaring the credential revoked. | +| Explicit connection/agent binding, node-only execution constraint, platform opt-out, or an approval bound to another target | Honor the constraint; do not reinterpret it as a health failure. A disabled pinned connection remains unavailable. | +| Caller-wide permission, consent, operation policy or rate-limit denial | Return the applicable denial. Fallback must not bypass it. | +| Candidate-specific org ACL does not permit use | Exclude that candidate without revealing private details; any alternative requires its own complete authorization. Preserve existing denial semantics unless explicitly migrated. | +| Database/KMS/decrypt failure or an integrity violation such as a dangling credential/endpoint reference or inconsistent ownership | Return the server error; do not mask it as “no connection” or silently introduce paid fallback. Typed incomplete credential setup is distinct from corrupt references. | +| Business request already sent, response timed out, stream interrupted, or upstream returned an error | Return that outcome. Do not replay the request through a different source in v1. | + +Use existing typed credential/refresh outcomes to inform subsequent calls. Do not +globally disable a credential based on an arbitrary resource-specific 403, a 429, +or a provider outage. Defer a new persisted health/circuit-breaker subsystem; +v1 reports known configuration and credential states. If health observations are +added later, bind them to the credential version and operation/account scope. +Existing refresh behavior must not increment `credential_epoch`. + +## Platform fallback is an explicit product offering + +Start with an inventory of the actual catalog configuration. Production catalog +data has not been inspected for this proposal. For a catalog row that already +supports BYOK and a master credential, reuse the existing validated platform +predicate and authorization path, including `provider_config_id.is_none()`. +Use the existing `proxy_operation_policy` for its operation allowlist, with +additional compatibility checks where the request references account resources. +Do not relax `requires_user_credential` or infer an execution credential from an +OAuth client ID/secret. Server-only `platform-*` vendor templates remain outside +user-addressed slug fallback. + +Only if the inventory shows that the logical user service and its platform +offering are distinct catalog rows, add an explicit administrator mapping by +catalog ID. Follow at most that configured target, with no recursive alias chain +and no slug-prefix matching. The platform candidate is virtual: derive it from +authorized catalog configuration even if no auto-provisioned `UserService` row +exists and the user has never opened `/keys`. + +Examples: + +- A platform OpenAI offering may support stateless inference with its own billing + and model limits. A request referring to user-owned files, assistants, batches + or other provider resources requires the original account. Unknown compatibility + fails closed; a matching HTTP method/path alone may be insufficient. +- A platform GitHub OAuth app helps users authorize their own accounts. It is not + a fallback GitHub account for reading repositories or creating issues. +- A valid public no-auth route can be available without either credential source; + its ordinary execution and node rules still apply. + +The normal mode is **Automatic: You → Organization → NyxID**. An **Allow NyxID +fallback** setting controls the last tier; turning it off leaves user → +organization → error. Enable that last tier where fallback is offered and the +payer already has any required billing authorization for the route. Otherwise +keep it off until the user opts in with payer and pricing disclosed. This setting +never moves platform ahead of an eligible organization. Do not silently opt +existing BYOK-only users into new charges during migration. + +Once the evaluator handles a request, its decision is terminal. A rejected or +automatically opted-out platform candidate must not be retried through the old catalog +fallthrough. Existing legitimate legacy personal connections remain candidates. + +## Explicit platform choice + +Expose a **Connection choice** control on the logical service: + +| Choice | Execution behavior | +| --- | --- | +| Automatic — You → Organization → NyxID | Select the first usable authorized tier, respecting the automatic platform-fallback setting. | +| NyxID platform only | Evaluate the platform candidate directly, even if personal and org connections are ready. Return its error if unavailable or denied; never silently use personal/org instead. | + +Allow the user to save this preference per service and override it for an +individual canonical API request. A proposed request header is +`X-NyxID-Connection-Source: platform` (use `auto` to explicitly request Automatic). +This is a proposed interface, not a currently implemented header. Keep the same +canonical slug URL. Validate the header, reject invalid/ambiguous values and +consume it inside NyxID; do not forward it downstream. CLI/MCP controls should +map to the same resolver input where those transports expose source choice. + +The effective choice is the request's explicit mode, otherwise the saved +per-service mode, otherwise Automatic. This precedence applies only to source +selection; exact connection pins, agent bindings, approval authority, caller +permissions and administrator constraints remain mandatory. Reject conflicting +explicit choices rather than silently dropping a pin or source request. + +Selecting platform is a choice of an already configured, authorized offering, +not permission to access an arbitrary internal platform service. Apply its +operation compatibility, rate-limit, consent, funding and approval gates even +when a working user credential is available. Show the actual payer and pricing +when saving the preference. A per-request header never grants new billing or +execution authority. + +**Allow NyxID fallback** only controls Automatic mode. An explicit platform-only +choice can use the platform when authorized even if automatic fallback is off; +any hard platform-use restriction continues to deny it. Hide the fallback toggle +while platform-only is selected to avoid presenting it as a contradictory setting. + +For example, the card becomes **Ready via NyxID · Platform selected**. Show personal +and org connections as **Ready · Not selected** when healthy, rather than implying +they failed. If platform is unavailable, show **Platform unavailable · Platform +selected**, its repair reason, and **Switch to Automatic**. The user's own +connection stays enabled and immediately becomes eligible again in Automatic. + +## One decision shared by execution and display + +Extend the existing proxy resolver boundary with a typed resolution result; +avoid a second registry of credentials or endpoints. Its input includes the +verified caller, logical or exact target, operation descriptor and execution +constraints. Candidate provenance is explicit: `personal`, `organization`, +`platform`, or `no_auth`, independently of the row's owner. + +Conceptual result (field names are proposed, not an existing API): + +```text +ServiceResolution + requested_slug / catalog_service_id / target_kind + requested_mode / effective_mode / selection_origin: request | saved | default + availability: ready | conditional | unavailable | blocked + selected_source / selected_connection_id / display_label + reason / recovery_action + candidates[]: source, allowed display label, eligibility, reason + verification: not_verified | known_credential_failure + evaluated_at / last_used_at + billing: payer, applicable charge layers, pricing reference + constraints: exact binding, required node, approval requirement +``` + +Execution enumerates permitted candidates and evaluates the selected concrete +route through the existing ordered gates. In particular, exact approvals retain +their claim and live policy/authority checks before credential materialization, +and their second authority comparison after materialization and before provider +effects. Bind approval/execution-authority digests and audit attribution to that +concrete route. A candidate change restarts the applicable route gates; never +reuse a grant obtained for the failed one. Once an approval or execution has +pinned the target, changes require revalidation or a new approval as appropriate. +Billing reservation occurs only for the final authorized route before dispatch. + +Build each candidate as a complete `ProxyTarget`: endpoint, credential, default +headers, identity propagation and node constraints travel together. Switching +sources must never send a personal credential or private connection header to a +platform endpoint, or transplant platform authority onto a user-controlled URL. + +The read-only projection uses the same candidate rules without decrypting, +refreshing OAuth tokens, probing providers, incrementing pool counters, consuming +rate-limit allowances or reserving funds. It reports `conditional` when refresh, +operation details, pool selection, billing reservation or verification must still +be resolved. It is an explanation of current facts, not a reusable execution +grant. Reevaluate live state on every call. + +Expose this projection on the service listing/detail and an actor-authorized +availability read. HTTP proxy, MCP discovery/execution, LLM ingress and CLI should +consume the same rule set for canonical calls. Preserve exact instance endpoints +and instance-specific MCP operation schemas. Do not publish a canonical MCP tool +whose schema or account semantics differ across its permitted candidates. + +Batch projection inputs for a page and avoid one credential/node/provider lookup +per card. Keep any preview cache short and keyed by caller/agent, owner, operation +and policy/credential version. Execution never trusts a cached preview. + +## What the user sees + +Keep the existing AI Services page and individual connection management. Add a +service-level row or header for each canonical service that answers **“What will +this slug use?”** Its connection details remain expandable/manageable underneath. + +Show one top-level service card per canonical catalog identity, with source +connections underneath. The service header always shows its canonical slug/API +URL, **Ready via [source]** (or a blocking state), and either the priority caption +**Automatic · You → Organization → NyxID** or **Platform selected**. Use actual organization names when +known. This makes clear that one service address can use several connections. +Associate personal and organization rows with that service by catalog ID; derive +the platform candidate from its validated catalog configuration/mapping. Custom +services remain independently addressed. + +| Primary status | Supporting text | Primary action | +| --- | --- | --- | +| Ready via your connection | `Personal OpenAI · last used 2 min ago` | View routing | +| Ready via Acme | `Organization connection · billed to Acme` | View routing | +| Ready via NyxID | `Your connection needs reconnection; no usable org connection · platform pricing applies` | Reconnect | +| Ready via NyxID | `No usable personal or org connection · platform pricing applies` | Connect your account | +| Connection check needed | `Your connection will be checked on use` | View routing | +| Unavailable | `Your connection needs reconnection; no usable org or platform connection` | Reconnect | +| Unavailable | `No usable personal, org or platform connection` | Connect | +| Payment required | `NyxID fallback requires credits` | Add credits | +| Access denied | Specific caller-safe reason | Request access, when applicable | + +These are illustrative states, not live service claims. “Ready” describes route +readiness. For an unverified API key, say **Not yet verified**. Existing +`last_used_at` means last use/attempt, not success. A future last-success indicator +must be backed by actual outcome evidence. Do not synthesize a green health +indicator from `status: active` or `auto_connected: true`. + +An expanded example: + +```text +OpenAI Ready via Acme +Slug: llm-openai +Automatic · You → Acme → NyxID + +Connection order +1 Your connection Skipped: needs reconnection Reconnect +2 Acme Selected for the next request +3 NyxID Available as fallback + +Billing: Acme · View pricing +Last request: Acme · 2 minutes ago + +View connections Copy service URL +``` + +Label every visible candidate **Selected**, **Available as fallback**, +**Not selected**, or **Skipped: [reason]**, according to the effective mode. +Give missing/unusable connections an appropriate repair +action. Explain why the selected candidate wins: “Your connection needs +reconnection, so new requests use Acme.” In Automatic mode, higher-priority recovery +changes the preview and the next request's source automatically; refresh the projection +after connection edits/reconnection and on page focus. Record the actual source +per request so users can inspect when fallback occurred. + +The default card is scoped to the signed-in user. If an API key or agent has +narrower scope or a pinned credential, its result can differ. An expanded **Access +as: You / [authorized agent key]** preview should make that distinction visible; +evaluate the selected caller's real constraints server-side. The group status +is general readiness, while an operation-specific preview can explain additional +request-specific restrictions. + +Use text plus an icon for status; color is supplementary. The actual final route +also appears in request history/audit and response metadata, because the next call +can differ from the preview. CLI output should show `SOURCE`, `AVAILABILITY` and +`REASON`. MCP discovery should return repair information for unavailable services +while executable tool publication follows the shared eligibility result. + +**Disable remains a connection action.** It turns off that connection and states +the resulting source before the change: for example, “This service will use Acme +while your connection is disabled.” Exact calls to a disabled connection still +fail. Turning off **Allow NyxID fallback** excludes only the platform tier; a +usable authorized org connection still precedes the terminal error. Disabling a +connection must not be presented as disabling every source of the logical service. +Auto-provisioned platform rows remain platform-managed; the opt-out belongs in +the routing preference because those rows reject user mutation. Conservatively +migrate existing disabled/disconnected intent instead of interpreting an old +Disable action as new consent to paid fallback. Delete retains its existing +credential/endpoint removal behavior. + +## Failure contract + +Return a stable machine-readable reason, a safe human message, and an authorized +recovery action. Preserve the existing error envelope and numeric code registry +in `backend/src/errors/mod.rs`; allocate any new variant there during +implementation rather than inventing a competing code table in this proposal. + +Add structured reasons while retaining existing applicable HTTP status/code +contracts: credential setup failures are generally 400, access denial 403, and +funding failures 402. Preserve existing node/pool error mappings. Unknown or +invisible services remain 404. Define a new aggregate failure only where no +existing variant accurately represents it, with allocation in the error registry. +No failure path forwards without a qualified route. + +Illustrative new reason: `no_usable_connection`, message: “OpenAI is unavailable. +Your connection needs reconnection, and no usable organization or NyxID connection +is available.” The +response can include caller-visible candidate reasons and a reconnect action; +never include platform secrets, private endpoint URLs or unauthorized org/account +details. Real database/decryption errors retain their sanitized server-fault +contract rather than being rewritten as connection setup failures. + +## Implementation sequence and proof of completion + +1. **Explain existing decisions.** Extract candidate provenance and read-only + availability from the current resolver. Share the decision rules with proxy, + MCP, approval ownership and display. Add the service status/header, routing + explanation, repair actions, billing/approval disclosure and response/audit + metadata. This stage preserves routing choices; it must not silently replace + the unordered catalog-ID selection with a different identity. +2. **Deliver user → organization → platform fallback.** Introduce catalog-identity + selection and typed pre-dispatch fallback across all three tiers behind a + rollout switch. Organization fallback is required in this stage, including + when the user's configured connection is unusable. Check each organization's + permissions, compatibility, approval requirements and billing ownership. + Handle a single unambiguous personal connection even with a suffixed slug, + preserve exact matches/pins, and return a choice action for ambiguous accounts. + Ship the automatic platform opt-out and explicit platform-only choice, both + saved per service and overridable per canonical request, in the same stage. Inventory the + actual catalog, preserve disabled intent and billing authorization, and close + the legacy fallthrough around the new decision. Cover HTTP/WS, MCP and LLM + call sites for the same logical target; LLM ingress already shares the core + resolver. Verify consistency rather than adding a separate LLM resolver. +3. **Extend account selection separately.** Add richer default-selection UI and + organization preference controls. Keep alternative-account balancing in + existing pools. This stage is not required to ship the core three-tier order. + +Use the existing service models for credentials and routes. If a separate +per-owner `ServiceRoutePreference` document is needed, keep it small: UUID ID, +`user_id`, catalog ID, optional preferred connection, +`routing_mode: automatic | platform_only`, and `allow_platform_fallback` for +Automatic mode. +Apply the repository's MongoDB +datetime/collection conventions and owner ACLs. It records routing intent, not a +duplicate connection inventory. A unique owner/catalog key prevents competing +defaults. Review concurrent changes and rollout migration with execution checks. + +Acceptance cases must demonstrate: + +- In Automatic mode, a working personal connection beats platform even when its concrete slug is + suffixed and a platform auto-provision row exists. +- In Automatic mode, a working personal connection beats an available organization connection; an + unusable personal connection selects a usable authorized organization before + platform. Missing/unusable user and org candidates select eligible platform; + no usable tier returns an error without business dispatch. +- In Automatic mode, reconnecting the personal connection restores its priority on the next request + with the same API slug. Multiple organizations follow the displayed order. +- A recoverably expired OAuth credential refreshes and stays selected; a terminal + refresh failure permits an authorized fallback before business dispatch. +- No usable route produces an actionable error with zero business dispatches. +- Disabled connections stay disabled; turning off platform fallback excludes it + through every path while retaining eligible org routing. Existing disconnected + intent survives migration. +- Explicit connection IDs, agent bindings, custom/pool slugs, node-only contracts, + org ACLs and exact approvals retain their constraints. +- Platform substitution happens only for the configured compatible operation; + a GitHub account, OpenAI file ID or instance-only MCP operation cannot drift to + another account. +- Multiple accounts without a default are deterministic and actionable; no + unordered `find_one` decides identity. +- Preview performs no credential refresh, decryption, pool advancement, provider + request, billing reservation or grant issuance/redemption, and labels unknowns. +- Fallback billing uses the final route and payer; a rejected or abandoned + candidate does not create duplicate usage or leak a funding reservation. +- Platform-only selects an eligible platform while personal and org connections + are healthy; an unavailable/denied platform errors without fallback. Repairing + a personal connection does not override the saved platform choice. +- Per-request source choice overrides the saved mode without changing that + preference. Conflicting exact pins/agent bindings are rejected. Invalid source + values fail validation and the source selector never reaches the provider. +- UI preview and actual request attribution distinguish Automatic fallback from + deliberate platform selection, including payer and approval requirements. +- A timed-out write or interrupted stream is never sent through a second + connection. A stale health observation cannot disable a replaced credential. +- Proxy, MCP, LLM and CLI agree for the same caller, operation and current state; + a platform database row alone is insufficient to report availability. + +No application implementation or deployment is part of this proposal. diff --git a/docs/plans/slug-routing-fable-review.md b/docs/plans/slug-routing-fable-review.md new file mode 100644 index 000000000..011d233ef --- /dev/null +++ b/docs/plans/slug-routing-fable-review.md @@ -0,0 +1,494 @@ +# Slug routing: Fable review + +Status: design review for the "stable slug prefers a working user connection, +then platform, else explicit error" proposal. Read-only; no code changed. +Companion to the primary's combined proposal. Line refs are against `main` +at 9bb33bcf. + +## 1. Recommendation in one paragraph + +Ship this as three releases, not one. Release 0 adds a single route +evaluator that both execution and previews consume, replaces the resolver's +scattered 400/404 exits with one structured "no usable connection" error, and +makes every surface (`/keys`, detail page, CLI, MCP) render the evaluator's +verdict. It changes no routing decision except making the one silent +fallthrough that already exists visible. Release 1 turns on the only new +routing behaviour the user asked for: a present-but-unusable personal +connection falls to the platform candidate, gated by the catalog predicate +that already defines "platform credential", with a user opt-out and explicit +billing disclosure. Release 2 handles the cases the primary flagged as open +(unusable personal to org, multiple personal accounts, "make default"). Do not +ship any release that switches between two user-owned accounts automatically. + +## 2. What the code does today (evidence) + +### 2.1 The resolver short-circuits and errors instead of trying the next candidate + +`resolve_proxy_target_from_user_service` (`backend/src/services/proxy_service.rs:1394-1562`) +does: personal exact slug row -> `finish_resolution`; else personal pool; else +legacy personal guard -> `Ok(None)`; else org walk. Any failure inside +`finish_resolution` propagates: + +| Condition | Where | Result today | +|---|---|---| +| `UserApiKey.status != "active"` | `proxy_service.rs:2650-2655` | 400 `API key is expired` (or revoked/failed/...) | +| credential not materializable | `proxy_service.rs:2660, 2672` | 400 `OAuth connection is not complete...` / `No credential stored...` | +| auto-provisioned row no longer eligible | `proxy_service.rs:2359-2397, 2497` | 404 `Service is no longer available` | +| org role/scope blocked | `proxy_service.rs:1556-1560` | 403 `OrgRoleInsufficient` | +| pool has no active member | `service_pool_service.rs:395`, `errors/mod.rs:725` | 502 code 11403 | + +None of these carries "which candidates exist and why each was skipped". + +### 2.2 There already is a silent platform fallthrough, and it is inconsistent + +When no `UserService` matches (including when the personal row is +**disabled or deleted**, since `find_by_slug` filters `is_active: true`, +`user_service_service.rs:580-589`), the handler falls to the legacy path +(`handlers/proxy.rs:1342-1348`) and `resolve_proxy_target` +(`proxy_service.rs:986-1119`) injects the catalog master credential whenever +`!requires_user_credential && auth_method != "none"` (`proxy_service.rs:1102-1112`). + +So for a master-credential catalog slug today: + +- personal key **expired** -> 400, no platform +- personal row **disabled or deleted** -> platform credential, silently, with + platform billing class and platform rate limit, no header saying so + +That asymmetry is the strongest argument for an explicit evaluator. It also +means "Disable" already has slug-level consequences nobody designed. + +### 2.3 "Platform connection" is two different things + +1. **Catalog master credential**: `DownstreamService.credential_encrypted` + non-empty with `requires_user_credential=false`. Reached via the legacy + path above, or via an auto-provisioned personal `UserService` + (`source="auto_provision"`, `api_key_id=None`, `master_credential=true`, + `proxy_service.rs:2492-2555`). The auto-provision predicate + `is_public_internal_master_credential_service` (`proxy_service.rs:2269-2279`) + requires `visibility=public`, `service_category=internal`, + `service_type=http`, and **`provider_config_id.is_none()`**. +2. **Platform vendor templates**: internal `platform-*` slugs + (`catalog_spec_sync.rs:216-218`), hidden from catalog reads and from + `create_key` (`unified_key_service.rs:825-829`), used by server-chosen + assistant operations (`platform_operation_service.rs`). Not addressable by + user slug. Out of scope for slug routing. + +Only (1) is a fallback candidate. Note the `provider_config_id.is_none()` +clause: every OAuth-provider-backed catalog row (GitHub, Google, Lark, X) is +already **structurally ineligible** as a platform candidate. That is the +right answer to "account-bound credentials cannot substitute platform +identity", and the proposal should cite it rather than add a new rule. The +legacy path (`resolve_proxy_target`) does not check this clause itself; it +relies on `authorize_master_credential -> validate_master_credential_service` +(`proxy_service.rs:150-152`). Confirm that helper excludes provider-backed +rows before relying on it. + +### 2.4 Same slug, both a user key and a platform key: possible but data-dependent + +One catalog slug is one `DownstreamService` row. `create_key` on a catalog +slug does not reject a user-supplied credential when the row also carries a +master credential (`unified_key_service.rs:812-945`; the only rejection is +the `platform-*` vendor guard). So the user's scenario exists only for rows +that are `internal` + `public` + master credential + BYO allowed. Whether any +production catalog row is shaped like that must be checked against data, not +seeds. If none is, the feature is really "canonical slug maps to a +*different* platform slug", which needs an explicit catalog-identity mapping +(see 3.2), not slug-text matching. + +### 2.5 Identity is `catalog_service_id`, not slug text + +- `resolve_unique_slug` auto-suffixes (`llm-openai-2`), and `PreserveExact` + lets a user pick any slug for a catalog service + (`unified_key_service.rs:100-153`). `UserServiceResolution.catalog_service_slug` + is loaded separately (`proxy_service.rs:2844-2870`) and the tests assert it + can differ from `UserService.slug` (`proxy_service.rs:4378-4382`). +- Consequence today: a user with a working key under custom slug `oai` who + calls `/proxy/s/llm-openai` (the catalog slug) gets the legacy platform + path, not their own key. Canonical selection must key on + `catalog_service_id`. +- `find_by_catalog_service_id` is an unordered `find_one` + (`user_service_service.rs:644-656`). With two active personal rows for the + same catalog (multi-connection is explicitly supported for OAuth, + `unified_key_service.rs:860-869`), catalog-id resolution is nondeterministic. + The primary's evidence is correct. +- `auto_provision_no_auth_services` skips creating the platform row when + **any** row (active or inactive) exists for the catalog id + (`unified_key_service.rs:1728-1745`). So a user who ever added their own key + never gets a platform row, and after deleting it they get the legacy path + instead. Also it runs lazily from `list_keys`; agent-only users who never + open `/keys` never get platform rows at all. + +### 2.6 Four partial "is it usable" evaluators, none of them the resolver + +| Surface | Evaluator | What it checks | +|---|---|---| +| `/keys` | `build_key_view` (`unified_key_service.rs:3993-4002`, `oauth_connection_status` 4059) | `status`, `connection_status`, `credential_missing`, `node_status`, `is_active` | +| frontend card/table | `keys.tsx:152-156` precedence in the component | `connection_status` > node status > credential status | +| CLI | `commands/service.rs:25-47 display_status` | same rules, re-implemented | +| MCP tools | `mcp_service.rs classify_credential` (~1762) | active-key map + node online | +| `/proxy/services` | `proxy_discovery_service.rs` | connection presence only | +| assistant readiness | `assistant_readiness_service.rs:219-330` | fixed capability registry, mirrors personal > legacy > org | + +None of these knows about pools, approvals, API-key scope, org role, or the +auto-provision eligibility recheck. Each can disagree with what the proxy +will actually do. The user's "clear way to visualize" is unachievable until +they all read one decision. + +### 2.7 Things a fallback would silently change + +- **Billing class**: `final_credential_class` (`handlers/proxy.rs:4131-4160`) + maps `master_credential` to `NyxidManagedMaster`; resale pricing applies + only in that class (`billing/route_context.rs:55-60`); platform per-user + rate limiting applies only on master-credential paths + (`proxy_service.rs:2505-2511, 1104-1110`). A user-to-platform switch changes + what the request costs and who pays. +- **Approvals**: execution authority binds `user_service_id` + (`execution_authority.rs:65,104,160`); approval identity compares + `user_service_id` (`approval_service.rs:701`). A grant for the personal row + does not cover the platform row. Also auto-connected rows suppress the + implicit global default approval (`is_auto_connected`, + `proxy_service.rs:1292-1296`), so the platform candidate may need *less* + approval than the personal one. Both directions must be visible in preview. +- **Agent scope and bindings**: `ApiKey.allowed_service_ids` are `UserService` + ids; `AgentServiceBinding` is keyed by `(api_key_id, user_service_id)`. The + platform row is a different id. +- **Legacy guard counts unusable tokens as present**: + `legacy_personal_provider_token_filter` accepts `expired` and + `refresh_failed` (`proxy_service.rs:1638-1647`), so a dead legacy token + blocks org fallback today. "Present" and "working" are already conflated. + +### 2.8 Pins already exist + +`?_nyxid_via=` resolves one exact row and refuses a +cross-slug id (`proxy_service.rs:1894-1911`). Exact slugs are unique per +owner among active rows. The pin primitive does not need inventing; only the +canonical-slug selection is new. + +## 3. Proposal + +### 3.1 One evaluator, two modes + +Add `slug_route_service::evaluate(actor, requested_slug, mode)` returning: + +``` +RouteDecision { + requested_slug, + catalog_identity: Option<{ catalog_service_id, catalog_slug }>, + candidates: [ RouteCandidate ], // ordered as they will be tried + selected: Option, + unavailable_reason: Option, + evaluated_at, health_scope: "pre_dispatch" // never claims downstream health +} +RouteCandidate { + kind: exact | personal | legacy | org | platform, + user_service_id: Option, slug, owner: personal | org{org_user_id} | platform, + eligibility: eligible | eligible_needs_refresh | ineligible(ReasonCode) | unknown, + billing: user_owned | platform_resale | no_auth | node_managed, + approval: none | required | granted, + actions: [ enable | reconnect | continue_auth | rebind_node | ask_org_admin | update_key_scope ] +} +``` + +Execution mode wraps the existing resolver: evaluate eligibility read-only in +order (`ProxyCredentialResolution::read_only_snapshot()` already exists, +`proxy_service.rs:1367-1374`), materialize only the first eligible candidate +via `finish_resolution`, and if materialization itself fails (refresh +failure, decrypt failure) move to the next candidate. The boundary is "before +the first byte is sent downstream". Downstream responses never trigger +re-selection in any release; they may only update credential status through +the existing refresh-failure paths. Preview mode never refreshes, never +touches `last_used_at`, never rate-limits, and reports +`eligible_needs_refresh` honestly for OAuth keys with a refresh token and an +expired access token. + +Fast path preserved: when the exact personal row is eligible, the cost is the +same single query as today. + +### 3.2 Selection rules + +1. **Exact wins.** If the requested slug matches an active `UserService` + (or pool) for the actor, that is the exact candidate. A `_nyxid_via` id or + an `AgentServiceBinding` is a pin: no fallback, ever. An exact custom slug + with no `catalog_service_id` has no other candidates. +2. **Canonical identity from the exact row or the catalog.** If the exact row + is unusable and carries `catalog_service_id`, or the slug is a catalog + slug with no exact row, candidates are collected by `catalog_service_id`: + other personal rows (deterministic order: slug equal to catalog slug + first, then `created_at` asc), legacy personal, org rows in membership + order with role/scope checks, then the platform candidate. +3. **Platform candidate eligibility** = `is_public_internal_master_credential_service` + + actor-addressed operation policy + consent (all existing checks) + + the user has not disabled the platform row (3.4). No new admin flag in + release 1; the predicate is the policy. If admins later want per-row + opt-in, add `DownstreamService.platform_fallback` with a backfill equal to + the predicate so nothing changes on deploy. +4. **User-owned accounts never substitute for each other automatically.** + If the canonical row is unusable and another personal row for the same + catalog is usable, the decision is `unavailable` with reason + `other_personal_connection_available` and the other slug named in the + action. Same for org-owned rows in release 1 (keep the NyxID#209 order: + personal presence still outranks org). Release 2 may add unusable-personal + to org behind the same evaluator, with the org billing owner disclosed. +5. **API-key scope filters candidates**, it does not error. A candidate + outside `allowed_service_ids` is `ineligible(out_of_scope)`. + +### 3.3 One error + +New variant `SlugRouteUnavailable { slug, decision }`, HTTP 400 for client +compatibility (today's failures are already 400), numeric code in a new +block 11800-11809, `details.candidates[]` with reason and action per +candidate. Replace the exits in 2.1 for slug-addressed requests. Keep 403 +`OrgRoleInsufficient` when the only candidates are org rows the actor may +not use, since that is a permission statement, not an availability one. + +Reason codes (one per existing state, no new states): `disabled`, +`credential_missing`, `pending_auth`, `expired`, `revoked`, `failed`, +`refresh_failed`, `node_offline`, `node_draining`, `node_deleted`, +`out_of_scope`, `org_role_insufficient`, `auto_provision_ineligible`, +`platform_policy_blocked`, `other_personal_connection_available`. + +### 3.4 Disable intent (answering the primary's open question) + +Keep Disable as a connection control; do not introduce a second control +also called Disable at the slug level (Rule 8 forbids lifecycle synonyms, and +"disable the route" and "disable the connection" would collide in the UI). + +My first instinct was to express the opt-out by disabling the auto-provisioned +platform row itself. Verified against code, that does not work without a +guard change: `ensure_user_managed_service` (`user_service_service.rs:74-81`) +rejects every user mutation on `source = "auto_provision"` rows, including +`is_active`, and the `/user-services` and `/keys` routes document that 403. +So the opt-out needs its own small authoritative record (FI-004): a per-owner, +per-catalog preference document with `platform_mode: first_then_platform | +your_connection_only` and an optional `preferred_user_service_id`, unique on +`(user_id, catalog_service_id)`. The combined draft proposes the same shape +(`ServiceRoutePreference`); I agree with it, with two limits below in 4.2. + +The platform candidate is then virtual: synthesized from the catalog row the +way the legacy path already does, never dependent on `list_keys` having run. +That also fixes the agent-only-user gap in 2.5. + +Whichever is chosen: once the evaluator covers a slug, the handler must not +fall into the legacy `resolve_service_by_slug` path for it unless a legacy +connection exists. Otherwise `your_connection_only` is silently bypassed by +the fallthrough from 2.2. + +Whichever is chosen: once the evaluator covers a slug, the handler must not +fall into the legacy `resolve_service_by_slug` path for it unless a legacy +connection exists. Otherwise disabling the platform row re-enables the +silent fallthrough from 2.2. + +### 3.5 Visualisation + +- **`/keys` list**: group cards by catalog identity. Each group header shows + the verdict for the canonical slug: `Available via your connection`, + `Available via platform (wallet-billed)`, or `Unavailable: ` with + the action button. Child cards keep today's per-connection badges. The + `Disabled` and `Credential Missing` badges stay; they are candidate state, + not slug state. +- **Detail page**: extend the existing Routing section + (`components/dashboard/routing-section.tsx`) with the ordered candidate + list and a "Check route" button that calls the preview endpoint. Show + billing class and approval requirement per candidate. +- **API**: `GET /api/v1/keys/route/{slug}` returns `RouteDecision`. It is + read-only and must live in the delegated-read allowlist, not the deny + classes. +- **CLI**: `nyxid service route ` prints the table; `nyxid service list` + gains a `route` column derived from the same call, replacing the local + `display_status` re-implementation. +- **MCP**: `nyx__list_connected_services` and `discover_services` include + `available_via` and `unavailable_reason`; tool visibility follows the + evaluator so MCP never hides a tool the proxy would serve via platform, and + never advertises one it would refuse. +- **Response and audit**: every proxy response carries + `X-NyxID-Route: personal|org|platform|legacy` and the selected + `user_service_id`; the routing audit event records the requested slug, the + selected candidate, and the skipped candidates with reason codes. No + credential material, ever. + +### 3.6 Smallest safe first release (challenge to the primary's bundle) + +The primary's leaning bundles evaluator, fallback, preview, and UI. I would +cut it: + +**Release 0, no routing change**: evaluator + structured error + preview +endpoint + all surfaces reading it + one determinism fix (ordered +`find_by_catalog_service_id`). Leave the legacy guard's "expired counts as +present" rule alone until Release 2; Release 0 only reports it as a candidate +with reason `expired`. Make the existing legacy platform fallthrough visible +via header and audit. This is shippable behind no flag because every request +resolves to the same row it does today. + +**Release 1, one new behaviour**: present-but-ineligible personal row falls +to the platform candidate, with the platform-row opt-out from 3.4 shipping in +the same release. Do not ship fallback without the opt-out: fallback spends +wallet credits. + +**Release 2**: unusable personal to org; "Make default for ``" +as an atomic slug swap between two personal rows; per-agent preferences. + +### 3.7 Objections and risks + +1. **The premise may not match production data** (2.4). If no catalog row is + both BYO-able and master-credentialed, "same slug, user then platform" + has no instances and the real need is a catalog-identity alias between a + user slug and a platform slug. Check before building Release 1. +2. **Fallback changes price and identity per request.** Acceptable only with + the header, the audit record, the preview, and the opt-out. Without all + four it is a billing surprise. +3. **Materializing after a failed candidate** can mark keys `failed` and + fire expiry notifications for a credential the request did not end up + using. That is correct (the credential is dead) but the notification copy + should not imply the request failed. +4. **Node failover stays below the boundary.** `fallback_node_ids` is + node-level retry for one selected `UserService`; do not fold it into the + evaluator, and do not let a node dispatch failure trigger candidate + re-selection (the body may already be streaming). +5. **Do not route on downstream 401/403** in any release. The reply may + carry side effects, bodies are consumed, and provider errors are not + reliably auth errors. +6. **Pools** already balance identical members but filter only `is_active` + (`service_pool_service.rs:374-390`). If the evaluator gains credential + eligibility, pools should use the same predicate for member viability or + a pool will keep selecting an expired member while the evaluator would + have skipped it. + +## 4. Review of the combined draft (`slug-connection-resolution-proposal.md`) + +The draft's evidence table is accurate; I re-checked every cited symbol +(`is_valid_master_credential_service` at `proxy_service.rs:308`, +`find_effective_service_owner` at 1992, `classify_credential`, +`resolve_member`). The product contract, the exact-vs-canonical split, the +pins, the no-replay rule, the preview/prepare/execute separation, and the +"a platform database row alone is insufficient" acceptance case are all +right and match my findings. What follows is only what I would change. + +### 4.1 Contradictions + +1. **Org fallback after a failed personal connection is both allowed and + forbidden.** The flowchart (`Usable user connection? No -> Usable + authorized org connection?`) falls to org unconditionally. The prose two + paragraphs later says a failed personal account does not authorize a new + account substitution, and the failure table says "only for a canonical + route whose policy allows that substitution". Pick one and fix the + diagram. My recommendation: Release 1 keeps today's rule (org only when + the actor has no personal connection at all); unusable-personal-to-org is + Release 2 and needs the org billing owner disclosed. +2. **"Disabled connections stay disabled; do not revive access through a new + fallback" versus what the code does now.** Disabling or deleting the + personal row on a master-credential catalog slug already revives platform + access through the legacy path (section 2.2). The draft's connection-level + Disable copy ("This service will use NyxID while this connection is + disabled") describes that existing behaviour, so the acceptance case + "existing disconnected intent survives migration" is ambiguous about + which intent. State explicitly: the legacy fallthrough is preserved but + made visible, and `your_connection_only` closes it. +3. **Default mode versus migration promise.** "The normal mode is Your + connection first, then NyxID" and "do not silently opt existing BYOK-only + users into new charges" conflict unless the default is conditional. Make + the rule crisp: the fallback mode is the default only where the platform + target is configured **and** the payer already holds applicable billing + authorization for that route; everyone else starts in + `your_connection_only` until they opt in from the routing panel. +4. **Failure contract HTTP code.** "No configured connection (proposed + 409)" changes the status every existing client sees on this path. Today + these are 400s (`API key is expired`, `No credential stored...`, + `proxy_service.rs:2650-2672`) and CLI, MCP, and SDK callers already handle + 400. Keep 400 and add the numeric code; 409 also reads as "conflict" to an + agent. Likewise "temporary route unavailability (503)" must not remap the + existing pool 502 (11403) or `NodeOffline` (8001). +5. **Canonical default for multiple personal rows.** The draft says + "several exist without a preference: ask the user to choose". On the slug + path the row whose slug equals the catalog slug is already an exact, + deterministic match; forcing a chosen default there regresses every + multi-connection user on day one with a `choose_default` error. The + unordered `find_one` only affects the catalog-id path + (`/proxy/{service_id}`). Rule: exact slug row is the default when usable; + a stored preference is required only when that row is unusable and other + personal rows exist. + +### 4.2 Unjustified scope for the first releases + +1. **Service-level Disable at the canonical header.** It is a third + lifecycle verb next to the two Rule 8 allows, it needs the preference + document to carry `is_active`, and nothing in the user's ask needs it: the + mode selector plus connection-level Disable already answers "stop using my + key" and "never use platform". Drop it, or rename and defer. +2. **Persisted credential-health observations** (verification state, + `last_success_at`, epoch-bound invalidation, "last succeeded 2 min ago"). + That is a new subsystem with its own invalidation rules. The ask is + pre-dispatch eligibility plus a clear error. Ship `Not yet verified` and + the existing `last_used_at` (`touch_last_used`, `proxy_service.rs:2662-2667`) + and defer health evidence. +3. **Operation allowlist on the platform target.** `DownstreamService` + already carries `proxy_operation_policy` with the + `PLATFORM_REQUIRE_OPERATION_POLICY` fail-closed switch (`docs/ENV.md:222`) + for exactly the "platform credential must not reach account-bound + operations" concern. Reuse it; do not add a parallel allowlist. +4. **Alias to a separate platform catalog row.** Only needed if production + has no catalog row that is both BYO-able and master-credentialed (section + 2.4). Check the data first; same-row fallback is the smaller feature. +5. **"Explicitly allowed alternative IDs" on the preference document.** An + ordered failover list of user connections is a second selection engine + beside pools, which the draft says it will not add. Keep the document to + `preferred_user_service_id` and `platform_mode` in v1. +6. **LLM ingress as separate rollout work.** `llm_proxy_request` already goes + through `resolve_proxy_target_from_user_service` (see the call-site note at + `proxy_service.rs:1671-1672`), so placing the evaluator inside the resolver + covers it. No separate LLM step; just say so. + +### 4.3 Gaps the draft should add + +- The legacy guard treats `expired` and `refresh_failed` provider tokens as + present (`proxy_service.rs:1638-1647`); under the draft's own "usable" + definition they are not. Decide when that changes (I say Release 2). +- Auto-provisioned rows reject all user mutation + (`user_service_service.rs:74-81`), so the opt-out cannot be "disable the + platform row"; the preference document is required for it. +- `X-NyxID-Route` (or equivalent) on every proxy response, and the skipped + candidates with reason codes in the routing audit event, so preview and + actual can be compared during rollout as step 3 promises. +- The `provider_config_id.is_none()` clause of + `is_public_internal_master_credential_service` is the existing structural + proof that OAuth-provider services (GitHub, Google) are never platform + candidates. Cite it; it is stronger than a policy statement. + +## 5. Reconciliation with the primary (final dispositions) + +Agreed by both parties on 2026-09-16. Where this section conflicts with +sections 3 and 4 above, this section wins. + +1. **No fallback on server faults.** KMS/decrypt failures, database errors, + and integrity violations (missing endpoint, dangling `api_key_id`) stay + sanitized 5xx and never trigger candidate re-selection. Only typed + pre-dispatch outcomes do: `credential_missing`, `pending_auth`, + `expired`/`revoked`/`failed`/`refresh_failed`, terminal refresh rejection, + `disabled`, `auto_provision_ineligible`, and unavailable permitted node + routes. This narrows my section 3.1 "if materialization fails, move on": + only a *typed* materialization failure moves on. +2. **Ordered catalog-id lookup is a routing change.** Replacing the + unordered `find_by_catalog_service_id` changes which row a catalog-id + call selects, so it is not part of the no-routing-change Release 0. + Release 0 only reports candidates. Canonical selection by catalog + identity lands with the fallback release, selects only when exactly one + usable personal row exists, and otherwise stops with an advisory + `choose_default` reason. This supersedes the "one determinism fix" + wording in section 3.6. +3. **No numeric codes allocated here.** The 11800-11809 suggestion in + section 3.3 is withdrawn. `backend/src/errors/mod.rs` is the registry and + allocation happens at implementation. +4. **Platform mapping.** Same-row fallback reuses the existing validated + master-credential predicate and authorization path. An admin-configured + mapping from a logical user catalog row to a distinct platform catalog row + is added only if production inventory confirms the two are distinct rows. + In both cases operation compatibility stays strict via the existing + operation policy; unknown compatibility fails closed. +5. **No atomic slug swap.** "Make default" is `preferred_user_service_id` on + the preference document; existing slugs and proxy URLs are never renamed. + Section 3.6 Release 2 "atomic slug swap" is withdrawn. +6. **Adopted from this review:** staged rollout; the existing silent + master-credential fallthrough is preserved but made visible and closed by + `your_connection_only`; the platform candidate is virtual and independent + of `list_keys`; `_nyxid_via` and agent bindings are the pins; Disable + stays connection-only with an explicit platform-mode opt-out; + failed-personal-to-org and multiple-account fallback are deferred. diff --git a/frontend/dev/routing-preview.ts b/frontend/dev/routing-preview.ts new file mode 100644 index 000000000..97686128f --- /dev/null +++ b/frontend/dev/routing-preview.ts @@ -0,0 +1,150 @@ +import { serviceViewSchema } from "../src/schemas/service-view"; +import { randomBytes } from "node:crypto"; +import type { IncomingMessage, ServerResponse } from "node:http"; +import type { Plugin } from "vite"; + +const READ_PATHS = new Set([ + "/api/v1/users/me", + "/api/v1/keys", + "/api/v1/api-keys", + "/api/v1/user-services", + "/api/v1/service-pools", + "/api/v1/catalog", + "/api/v1/orgs", + "/api/v1/nodes", + "/api/v1/runtime-config", + "/api/v1/public/config", + "/api/v1/providers/codex-connection", + "/api/v1/keys/history/archived", + "/api/v1/options/service-history-action", +]); + +const UUID_SEGMENT = "[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}"; +const DETAIL_PATH = new RegExp(`^/api/v1/(?:keys|nodes)/${UUID_SEGMENT}$`); +const HISTORY_PATH = new RegExp(`^/api/v1/keys/${UUID_SEGMENT}/history$`); + +function isMetadataPath(path: string) { + return READ_PATHS.has(path) || DETAIL_PATH.test(path) || HISTORY_PATH.test(path) || /^\/api\/v1\/catalog\/[a-z0-9][a-z0-9-]*$/.test(path); +} + +function cookie(req: IncomingMessage, name: string): string | undefined { + return req.headers.cookie?.split(";").map((part) => part.trim()) + .find((part) => part.startsWith(`${name}=`))?.slice(name.length + 1); +} + +function redirect(res: ServerResponse, location: string) { + res.writeHead(302, { Location: location, "Cache-Control": "no-store", "Referrer-Policy": "no-referrer" }); + res.end(); +} + +function json(res: ServerResponse, status: number, message: string) { + res.writeHead(status, { "Content-Type": "application/json", "Cache-Control": "no-store" }); + res.end(JSON.stringify({ message, error_code: status })); +} + +/** Opt-in, loopback-only access to production metadata for the local proposal. */ +export function routingPreview(backendUrl: string, frontendUrl: string): Plugin { + const states = new Map(); + const sessions = new Map(); + const sessionCookie = "nyxid_routing_preview"; + const stateCookie = "nyxid_routing_preview_state"; + + return { + name: "nyxid-local-routing-preview", + apply: "serve", + configureServer(server) { + server.middlewares.use(async (req, res, next) => { + const address = server.httpServer?.address(); + if (!address || typeof address === "string") return next(); + const origin = `http://127.0.0.1:${address.port}`; + if (req.headers.host !== `127.0.0.1:${address.port}`) { + return json(res, 403, "Open this preview on 127.0.0.1."); + } + if (req.headers.origin && req.headers.origin !== origin) { + return json(res, 403, "Cross-origin requests are not allowed."); + } + const url = new URL(req.url ?? "/", origin); + const now = Date.now(); + for (const [id, expires] of states) if (expires < now) states.delete(id); + for (const [id, session] of sessions) if (session.expires < now) sessions.delete(id); + + if (url.pathname === "/__routing-preview/login" || url.pathname === "/login") { + if (req.method !== "GET") return json(res, 405, "Use GET to sign in."); + const state = randomBytes(32).toString("hex"); + states.set(state, now + 600_000); + res.setHeader("Set-Cookie", `${stateCookie}=${state}; HttpOnly; SameSite=Lax; Path=/; Max-Age=600`); + const login = new URL("/cli-auth", frontendUrl); + login.searchParams.set("port", String(address.port)); + login.searchParams.set("state", state); + login.searchParams.set("client_ua", "NyxID local routing preview (metadata only)"); + return redirect(res, login.toString()); + } + + if (url.pathname === "/callback") { + const state = url.searchParams.get("state"); + const token = url.searchParams.get("access_token"); + if (req.method !== "GET" || !state || !states.has(state) || cookie(req, stateCookie) !== state + || !token || token.length > 16_384 || !/^[A-Za-z0-9_.-]+$/.test(token)) { + return json(res, 400, "Login could not be verified. Open /__routing-preview/login to try again."); + } + states.delete(state); + const id = randomBytes(32).toString("hex"); + // Only the short-lived access token is retained, in memory. Refresh + // tokens from the existing CLI callback are deliberately discarded. + sessions.set(id, { token, expires: now + 900_000 }); + res.setHeader("Set-Cookie", [ + `${sessionCookie}=${id}; HttpOnly; SameSite=Strict; Path=/; Max-Age=900`, + `${stateCookie}=; HttpOnly; SameSite=Lax; Path=/; Max-Age=0`, + ]); + return redirect(res, "/keys?view=routing"); + } + + if (url.pathname === "/api/v1/auth/logout" && req.method === "POST") { + sessions.delete(cookie(req, sessionCookie) ?? ""); + res.setHeader("Set-Cookie", `${sessionCookie}=; HttpOnly; SameSite=Strict; Path=/; Max-Age=0`); + return json(res, 200, "Local preview signed out."); + } + + if (!/^\/(api|oauth|mcp)(\/|$)/.test(url.pathname) && !url.pathname.startsWith("/.well-known")) return next(); + const savingView = req.method === "PUT" && url.pathname === "/api/v1/users/me/preferences/services" && !url.search; + if (!savingView && (req.method !== "GET" || !isMetadataPath(url.pathname))) { + return json(res, 403, "This preview permits metadata reads and saving your service view. Service changes and execution are disabled."); + } + if (savingView && req.headers.origin !== origin) return json(res, 403, "Save preferences from this preview only."); + const session = sessions.get(cookie(req, sessionCookie) ?? ""); + const isPublic = url.pathname === "/api/v1/public/config" || url.pathname === "/api/v1/runtime-config"; + if (!session && !isPublic) return json(res, 401, "Sign in to view your production connections."); + let preferenceBody: string | undefined; + if (savingView) { + try { + const chunks: Buffer[] = []; + let size = 0; + for await (const chunk of req) { + const buffer = Buffer.from(chunk); + size += buffer.length; + if (size > 131072) return json(res, 413, "Preferences are too large."); + chunks.push(buffer); + } + preferenceBody = JSON.stringify(serviceViewSchema.parse(JSON.parse(Buffer.concat(chunks).toString("utf8")))); + } catch { + return json(res, 400, "Invalid service view preferences."); + } + } + try { + const response = await fetch(new URL(url.pathname + url.search, backendUrl), { + method: savingView ? "PUT" : "GET", + headers: { ...(session ? { Authorization: `Bearer ${session.token}` } : {}), ...(savingView ? { "Content-Type": "application/json" } : {}) }, + body: preferenceBody, + redirect: "error", + signal: AbortSignal.timeout(20_000), + }); + const body = await response.text(); + res.writeHead(response.status, { "Content-Type": "application/json", "Cache-Control": "no-store" }); + res.end(body); + } catch { + json(res, 502, savingView ? "Your default view could not be saved. Try again." : "Production data could not be loaded. Try refreshing."); + } + }); + }, + }; +} diff --git a/frontend/src/app.css b/frontend/src/app.css index 07c5ca2c6..12bb8cf0a 100644 --- a/frontend/src/app.css +++ b/frontend/src/app.css @@ -787,3 +787,85 @@ html.theme-dark .assistant-halo--visible { border-color: rgba(255, 255, 255, 0.15); box-shadow: 0 0 24px rgba(255, 255, 255, 0.03); } + +.service-filter-pills { + --service-filter-pill-height: 2.75rem; + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px; + max-height: calc(2 * var(--service-filter-pill-height) + 6px); + overflow-y: auto; + overscroll-behavior-y: contain; + scroll-snap-type: y mandatory; +} + +.service-filter-extra-pills { + display: contents; +} + +.service-filter-pills > *, +.service-filter-extra-pills > * { + height: var(--service-filter-pill-height); + min-height: var(--service-filter-pill-height); + flex-shrink: 0; + scroll-snap-align: start; +} + +.service-filter-extra-pills > div > button:first-child { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +@media (min-width: 768px) { + .service-filter-pills { + --service-filter-pill-height: 2.25rem; + } +} + +/* Blur passing card outlines and fade the cover together at the lower edge. */ +.service-filter-toolbar[data-stuck="true"]::before { + content: ""; + position: absolute; + pointer-events: none; + top: -100vh; + bottom: -28px; + left: calc(-1 * var(--service-filter-gutter-left, 0px)); + right: calc(-1 * var(--service-filter-gutter-right, 0px)); + background: var(--color-background); + backdrop-filter: blur(8px); + mask-image: linear-gradient( + to bottom, + #000 calc(100% - 28px), + rgb(0 0 0 / 0.95) calc(100% - 20px), + rgb(0 0 0 / 0.6) calc(100% - 10px), + transparent + ); +} + +.service-card-header[data-stuck="true"]::before { + content: ""; + position: absolute; + pointer-events: none; + top: -100vh; + bottom: 0; + left: calc(-1 * var(--service-filter-gutter-left, 0px) - 1px); + right: calc(-1 * var(--service-filter-gutter-right, 0px) - 1px); + background: var(--color-background); +} + +/* Keep the expanding service and its neighbors attached to their grid positions. */ +html.service-card-transition::view-transition-group(*) { + animation-duration: 240ms; + animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1); +} +html.service-card-transition::view-transition-old(root), +html.service-card-transition::view-transition-new(root) { + animation: none; +} +@media (prefers-reduced-motion: reduce) { + html.service-card-transition::view-transition-group(*) { + animation-duration: 0s; + } +} diff --git a/frontend/src/components/dashboard/grouped-service-cards.tsx b/frontend/src/components/dashboard/grouped-service-cards.tsx new file mode 100644 index 000000000..fd7520f87 --- /dev/null +++ b/frontend/src/components/dashboard/grouped-service-cards.tsx @@ -0,0 +1,405 @@ +import { Link } from "@tanstack/react-router"; +import { + useId, + useLayoutEffect, + useRef, + useState, + type ReactNode, + type RefObject, +} from "react"; +import { ChevronRight, Globe } from "lucide-react"; +import { useServiceView } from "@/hooks/use-service-view"; +import { useServiceCardTransition } from "@/hooks/use-service-card-transition"; +import { ServiceViewToolbar } from "./service-view-toolbar"; +import { ServiceConnectionTable } from "./service-connection-table"; +import { ServiceOwnerAvatar } from "./service-owner-avatar"; +import { + connectionSourceLabel as sourceLabel, + connectionSource, + matchingConnections, +} from "@/lib/service-view"; +import { ServiceIcon } from "@/components/service-icon"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { cn } from "@/lib/utils"; +import { + groupServiceConnections, + type ServiceConnectionGroup, +} from "@/lib/service-groups"; +import type { CatalogEntry, KeyInfo } from "@/types/keys"; + +function GroupCard({ + group, + expanded, + onToggle, + search, + connections, + renderConnectionActions, + filtersRef, +}: { + readonly group: ServiceConnectionGroup; + readonly expanded: boolean; + readonly onToggle: (card: HTMLElement | null) => void; + readonly search: string; + readonly connections: readonly KeyInfo[]; + readonly renderConnectionActions?: (key: KeyInfo) => ReactNode; + readonly filtersRef: RefObject; +}) { + const contentId = useId(); + const headingId = useId(); + const cardRef = useRef(null); + const headerRef = useRef(null); + const headerOffset = useRef(0); + const connectionIds = connections + .map((connection) => connection.id) + .join(","); + const [headerStuck, setHeaderStuck] = useState(false); + useLayoutEffect(() => { + if (!expanded) return; + const card = cardRef.current; + const header = headerRef.current; + const scroller = card?.closest("main"); + if (!card || !header || !scroller) return; + const rows = card.querySelectorAll( + "[data-service-connection-row]", + ); + const finalRowsStart = rows[Math.max(0, rows.length - 3)]; + const update = () => { + const bounds = header.getBoundingClientRect(); + const nativeTop = bounds.top - headerOffset.current; + const offset = finalRowsStart + ? Math.min( + 0, + finalRowsStart.getBoundingClientRect().top - + nativeTop - + bounds.height, + ) + : 0; + headerOffset.current = offset; + header.style.translate = offset ? `0 ${offset}px` : ""; + setHeaderStuck( + nativeTop > card.getBoundingClientRect().top + card.clientTop + 1, + ); + }; + update(); + const observer = new ResizeObserver(update); + observer.observe(card); + observer.observe(header); + observer.observe(scroller); + if (finalRowsStart) observer.observe(finalRowsStart); + if (filtersRef.current) observer.observe(filtersRef.current); + scroller.addEventListener("scroll", update, { passive: true }); + return () => { + observer.disconnect(); + scroller.removeEventListener("scroll", update); + headerOffset.current = 0; + header.style.translate = ""; + }; + }, [expanded, filtersRef, connectionIds]); + const count = group.connections.length; + const matchingCount = connections.length; + const sources = [ + ...new Map( + connections.map((key) => { + const type = connectionSource(key); + const org = + key.credential_source?.type === "org" ? key.credential_source : null; + return [ + type === "org" ? org!.org_id : type, + { type, name: sourceLabel(key), avatarUrl: org?.avatar_url }, + ] as const; + }), + ).values(), + ]; + const disabled = connections.filter((key) => !key.is_active).length; + const matches = search + ? connections.filter((key) => + [key.label, key.slug, sourceLabel(key)].some((value) => + value.toLowerCase().includes(search), + ), + ) + : []; + + return ( +
+
+
+
+
+
+ {group.slug ? ( + + ) : ( + + )} +
+
+

+ +

+

+ {matchingCount < count + ? `${matchingCount} of ${count}` + : count}{" "} + {count === 1 ? "connection" : "connections"} +

+
+ {disabled > 0 && ( + {disabled} disabled + )} +
+ {!expanded && ( +

+ {group.description} +

+ )} + {!expanded && ( +
+

source.name).join(" · ")} + > + Sources + {sources.map((source, index) => ( + + + {source.name} + + ))} +

+ {matches.length > 0 && ( +

key.label).join(", ")} + > + Matches: {matches.map((key) => key.label).join(", ")} +

+ )} +
+ )} +
+
+ +
+ {matchingCount < count && ( + + {matchingCount} of {count} match + + )} + + Service details + +
+
+
+
+ +
+ ); +} + +export function GroupedServiceCards({ + keys, + catalog, + renderConnectionActions, + actions, + renderTable, +}: { + readonly keys: readonly KeyInfo[]; + readonly catalog?: readonly CatalogEntry[]; + readonly renderConnectionActions?: (key: KeyInfo) => ReactNode; + readonly actions?: ReactNode; + readonly renderTable?: (keys: readonly KeyInfo[]) => ReactNode; +}) { + const view = useServiceView(); + const animateCards = useServiceCardTransition(); + const containerRef = useRef(null); + const filtersRef = useRef(null); + const [filtersStuck, setFiltersStuck] = useState(false); + useLayoutEffect(() => { + const container = containerRef.current; + const toolbar = filtersRef.current; + if (!container || !toolbar) return; + const scroller = toolbar.closest("main"); + const updateShadow = () => { + const toolbarTop = toolbar.getBoundingClientRect().top; + setFiltersStuck(container.getBoundingClientRect().top < toolbarTop - 1); + }; + const measure = () => { + const bounds = toolbar.getBoundingClientRect(); + container.style.setProperty( + "--service-filters-height", + `${bounds.height}px`, + ); + if (scroller) { + const viewportLeft = + scroller.getBoundingClientRect().left + scroller.clientLeft; + container.style.setProperty( + "--service-filter-gutter-left", + `${Math.max(0, bounds.left - viewportLeft)}px`, + ); + container.style.setProperty( + "--service-filter-gutter-right", + `${Math.max(0, viewportLeft + scroller.clientWidth - bounds.right)}px`, + ); + } + updateShadow(); + }; + measure(); + const observer = new ResizeObserver(measure); + observer.observe(toolbar); + if (scroller) observer.observe(scroller); + scroller?.addEventListener("scroll", updateShadow, { passive: true }); + return () => { + observer.disconnect(); + scroller?.removeEventListener("scroll", updateShadow); + }; + }, [view.accountId]); + const { filters, expanded } = view; + const needle = filters.search.trim().toLowerCase(); + const groups = groupServiceConnections(keys, catalog); + const visible = groups + .map((group) => ({ group, matches: matchingConnections(group, filters) })) + .filter(({ matches }) => matches.length > 0); + const matchingKeys = visible.flatMap(({ matches }) => matches); + + return ( +
+ + + {visible.length} {visible.length === 1 ? "service" : "services"} ·{" "} + {matchingKeys.length} matching{" "} + {matchingKeys.length === 1 ? "connection" : "connections"} + + {expanded.length > 0 && ( + + )} +
{actions}
+
+ {visible.length ? ( + renderTable ? ( + renderTable(matchingKeys) + ) : ( +
+ {visible.map(({ group, matches }) => ( + + animateCards( + () => + view.setExpanded( + expanded.includes(group.id) ? [] : [group.id], + ), + expanded.includes(group.id) ? undefined : card, + filtersRef.current, + ) + } + renderConnectionActions={renderConnectionActions} + filtersRef={filtersRef} + /> + ))} +
+ ) + ) : ( +

+ {keys.length + ? filters.source === "personal" + ? "No personal services match this view. Choose All services to include organization and platform connections." + : "No services match these filters. Clear filters to see all services." + : "No connected services."} +

+ )} +
+ ); +} diff --git a/frontend/src/components/dashboard/service-connection-table.tsx b/frontend/src/components/dashboard/service-connection-table.tsx new file mode 100644 index 000000000..01216c4a2 --- /dev/null +++ b/frontend/src/components/dashboard/service-connection-table.tsx @@ -0,0 +1,373 @@ +import { Fragment, useState, type ReactNode } from "react"; +import { Link } from "@tanstack/react-router"; +import { + ArrowUpRight, + ChevronDown, + History, + LockKeyhole, + Settings2, +} from "lucide-react"; +import { Badge } from "@/components/ui/badge"; +import { ServiceOwnerAvatar } from "./service-owner-avatar"; +import { ServiceHistory } from "./service-history"; +import { + Table, + TableHeader, + TableHead, + TableBody, + TableRow, + TableCell, +} from "@/components/ui/table"; +import { connectionSource } from "@/lib/service-view"; +import { canEditConnection } from "@/lib/connection-access"; +import { classifyConnection } from "@/lib/service-routing-preview"; +import { cn, formatDate, formatDateTime } from "@/lib/utils"; +import type { KeyInfo } from "@/types/keys"; + +const authNames: Record = { + bearer: "Bearer", + api_key: "API key", + oauth2: "OAuth 2.0", + basic: "Basic", + none: "No auth", + node_managed: "Node credential", +}; + +function target(key: KeyInfo): string { + if (key.credential_binding === "platform") return "Platform credential"; + if (key.service_type === "ssh") + return key.ssh_host + ? `${key.ssh_host}:${key.ssh_port ?? 22}` + : "Target not reported"; + return key.endpoint_url || "Target not reported"; +} + +function ConnectionMetadata({ + connection: key, +}: { + readonly connection: KeyInfo; +}) { + const editable = canEditConnection(key); + const rows = [ + ["Created", formatDateTime(key.created_at)], + ["Added via", key.source_app_name || "Not recorded"], + ["Last caller", "Not reported"], + [ + "Credential prepared", + key.last_used_at ? formatDateTime(key.last_used_at) : "Not reported", + ], + ["Connection ID", key.id], + [ + "Credential expires", + key.expires_at ? formatDateTime(key.expires_at) : "Not reported", + ], + ...(editable + ? [ + ["API target", target(key)], + ["Node", key.node_id || "Direct"], + ["Permissions", key.granted_scopes?.join(", ") || "Not reported"], + [ + "Header names", + key.default_request_headers + ?.map((header) => header.name) + .join(", ") || "None", + ], + ["WebSocket rules", String(key.ws_frame_injections?.length ?? 0)], + ["User-Agent", key.custom_user_agent || "Client default"], + ["OpenAPI", key.openapi_spec_url || "Not configured"], + ["Recommended skills", key.recommended_skills?.join(", ") || "None"], + ] + : []), + ]; + return ( +
+ {rows.map(([label, value]) => ( +
+
{label}
+
+ {value} +
+
+ ))} +
+ ); +} + +export function ServiceConnectionTable({ + connections, + serviceName, + renderActions, + onViewHistory, +}: { + readonly connections: readonly KeyInfo[]; + readonly serviceName: string; + readonly renderActions?: (connection: KeyInfo) => ReactNode; + readonly onViewHistory?: (connection: KeyInfo) => void; +}) { + const [open, setOpen] = useState<{ + id: string; + view: "details" | "history"; + } | null>(null); + const [observedAt] = useState(Date.now); + const toggle = (id: string, view: "details" | "history") => + setOpen((current) => + current?.id === id && current.view === view ? null : { id, view }, + ); + + return ( + + + + + Connection / Slug + + + Classification + + + Status + + + Configuration + + + Activity + + + + + {connections.map((key) => { + const source = connectionSource(key); + const org = + key.credential_source?.type === "org" + ? key.credential_source + : null; + const owner = + org?.org_name ?? + (source === "platform" ? "NyxID platform" : "Personal"); + const editable = canEditConnection(key); + const readiness = classifyConnection(key, [], observedAt); + const change = + key.authorship?.last_change ?? key.authorship?.created_by; + const changedAt = change?.at ?? key.created_at; + const activity = key.authorship?.last_change ? "Changed" : "Created"; + const expanded = open?.id === key.id; + const panelId = `connection-${key.id}-detail`; + const route = + key.node_id || key.has_node_binding + ? `Node · ${key.node_status ?? "Unknown"}` + : "Direct"; + const configCounts = [ + key.granted_scopes?.length + ? `${key.granted_scopes.length} permissions` + : null, + key.default_request_headers?.length + ? `${key.default_request_headers.length} headers` + : null, + key.ws_frame_injections?.length + ? `${key.ws_frame_injections.length} WS rules` + : null, + ] + .filter(Boolean) + .join(" · "); + return ( + + td]:align-top [&>td]:py-3", + expanded && "bg-muted/20", + )} + > + +
+ + + + {key.label} + +
+ + {key.slug} + + + {key.service_type} + {key.streaming_supported ? " · Streaming" : ""} + {key.websocket_supported ? " · WebSocket" : ""} + +
+ +
+ + + {owner} + +
+

+ {org + ? `Organization · ${org.role}` + : source === "platform" + ? "Platform managed" + : "Personal owner"} +

+ {org && source === "platform" && ( +

+ Platform credential +

+ )} +
+ + + {readiness.reason} + +

+ {key.is_active ? "Enabled" : "Disabled"} ·{" "} + {key.status.replaceAll("_", " ")} +

+ {key.expires_at && ( +

+ Expires {formatDate(key.expires_at)} +

+ )} + {editable && renderActions?.(key)} +
+ + {editable ? ( + <> +

+ {target(key)} +

+

+ {authNames[key.auth_method] ?? key.auth_method} ·{" "} + {route} +

+ {configCounts && ( +

+ {configCounts} +

+ )} + +
+ +

+ {change ? `${activity} by ${change.actor.name}` : activity} +

+ {changedAt && ( + + )} + +
+
+ {expanded && ( + + + {open.view === "history" ? ( + + ) : ( + + )} + + + )} +
+ ); + })} +
+
+ ); +} diff --git a/frontend/src/components/dashboard/service-filter-multiselect.tsx b/frontend/src/components/dashboard/service-filter-multiselect.tsx new file mode 100644 index 000000000..973aba276 --- /dev/null +++ b/frontend/src/components/dashboard/service-filter-multiselect.tsx @@ -0,0 +1,155 @@ +import { useState, type ReactNode } from "react"; +import { ChevronDown } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Input } from "@/components/ui/input"; +import { cn } from "@/lib/utils"; +import { + Popover, + PopoverTrigger, + PopoverContent, +} from "@/components/ui/popover"; + +export interface ServiceFilterOption { + readonly id: string; + readonly label: string; + readonly icon?: ReactNode; +} + +export function ServiceFilterMultiselect({ + label, + plural, + options, + selected, + onChange, + className, +}: { + readonly label: string; + readonly plural: string; + readonly options: readonly ServiceFilterOption[]; + readonly selected: readonly string[]; + readonly onChange: (ids: string[]) => void; + readonly className?: string; +}) { + const [open, setOpen] = useState(false); + const [search, setSearch] = useState(""); + const hasIcons = options.some((option) => option.icon); + const visible = options.filter((option) => + option.label.toLowerCase().includes(search.trim().toLowerCase()), + ); + const summary = + selected.length === 0 + ? "All" + : selected.length === 1 + ? (options.find((option) => option.id === selected[0])?.label ?? + "Unavailable selection") + : `${selected.length} selected`; + return ( + { + setOpen(value); + if (!value) setSearch(""); + }} + > + + + + + setSearch(event.target.value)} + /> +
+ {visible.map((option) => { + const checked = selected.includes(option.id); + return ( + + ); + })} + {!visible.length && ( +

+ No matching {plural}. +

+ )} +
+
+ + +
+
+
+ ); +} diff --git a/frontend/src/components/dashboard/service-history.test.tsx b/frontend/src/components/dashboard/service-history.test.tsx index 5969f85af..b1d12cb55 100644 --- a/frontend/src/components/dashboard/service-history.test.tsx +++ b/frontend/src/components/dashboard/service-history.test.tsx @@ -169,7 +169,7 @@ describe("service authorship and history", () => { client.invalidateQueries({ queryKey: ["service-history"] }), ); expect(await screen.findByRole("alert")).toHaveTextContent( - "currently permitted organization admins", + "current access to this connection", ); expect( screen.queryByText(/Other service settings updated/), diff --git a/frontend/src/components/dashboard/service-history.tsx b/frontend/src/components/dashboard/service-history.tsx index ccfe7dfa4..c6f2d2f94 100644 --- a/frontend/src/components/dashboard/service-history.tsx +++ b/frontend/src/components/dashboard/service-history.tsx @@ -169,7 +169,7 @@ export function ServiceHistory({ serviceId }: { readonly serviceId: string }) {

{query.error instanceof ApiError && [403, 404].includes(query.error.status) - ? "Service history is unavailable. Personal owners and currently permitted organization admins can view it." + ? "Service history is unavailable. You need current access to this connection to view its history." : "Service history could not be loaded."}

+ + + + )} + + + {candidate ? ( + + ) : ( +
+ Service unavailable + {id} +
+ )} + + ); + })} + + {ids.length === 0 && ( +

+ No members in this pool. +

+ )} + + ); +} + +function PoolRoutingContent({ + userId, + renderConnection, +}: RoutingPreviewProps & { readonly userId: string | undefined }) { + const pools = useServicePools(); + const keys = useKeys(); + const services = useUserServices(); + const [preferences, setPreferences] = useState(() => readPreferences(userId)); + const [mountedAt] = useState(Date.now); + const candidates = new Map( + (keys.data ?? []).map((key) => [ + key.id, + classifyConnection( + key, + services.data ?? [], + keys.dataUpdatedAt || mountedAt, + ), + ]), + ); + + if (pools.isLoading || keys.isLoading) + return ; + if (pools.error || keys.error) + return ( +
+ Pool data could not be loaded.{" "} + +
+ ); + return ( +
+
+ Your pools · Local preview · Changes stay in this browser + +
+ {(pools.data ?? []).map((pool) => ( + { + const stored = readPreferences(userId); + const next = { + ...stored, + pools: { ...stored.pools, [pool.id]: value }, + }; + setPreferences(next); + savePreferences(userId, next); + }} + /> + ))} + {pools.data?.length === 0 && ( +

+ No service pools in your account. +

+ )} +
+ ); +} + +export default function ServicePoolRoutingPreview(props: RoutingPreviewProps) { + const userId = useAuthStore((state) => state.user?.id); + return ( + + ); +} diff --git a/frontend/src/components/dashboard/service-routing-preview.test.tsx b/frontend/src/components/dashboard/service-routing-preview.test.tsx new file mode 100644 index 000000000..68f8763db --- /dev/null +++ b/frontend/src/components/dashboard/service-routing-preview.test.tsx @@ -0,0 +1,999 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { + render as renderDom, + screen, + cleanup, + fireEvent, + within, +} from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { ReactNode } from "react"; +import type { RoutingCandidate } from "@/lib/service-routing-preview"; +import type { KeyInfo } from "@/types/keys"; +import type { ServicePool } from "@/schemas/pools"; + +function render(ui: ReactNode) { + const client = new QueryClient({ + defaultOptions: { mutations: { retry: false }, queries: { retry: false } }, + }); + return renderDom(ui, { + wrapper: ({ children }) => ( + {children} + ), + }); +} + +const { records, account, poolState } = vi.hoisted(() => ({ + account: { id: "user-a" }, + poolState: { data: [] as ServicePool[], error: null as unknown }, + records: [ + { + id: "mine", + label: "Personal account", + slug: "openai-personal", + endpoint_url: "https://api.openai.com", + description: "My development connection", + granted_scopes: ["models:read"], + catalog_service_id: "openai-id", + catalog_service_slug: "openai", + catalog_service_name: "OpenAI", + service_type: "http", + is_active: true, + status: "active", + credential_type: "api_key", + auth_method: "bearer", + api_key_id: "credential-a", + node_id: null, + auto_connected: false, + credential_source: { type: "personal" }, + }, + { + id: "team", + label: "Team account", + slug: "openai-team", + catalog_service_id: "openai-id", + catalog_service_slug: "openai", + catalog_service_name: "OpenAI", + service_type: "http", + is_active: true, + status: "active", + credential_type: "api_key", + auth_method: "bearer", + api_key_id: "credential-b", + node_id: null, + auto_connected: false, + credential_source: { + type: "org", + org_id: "team-id", + org_name: "Chrono", + allowed: true, + role: "member", + }, + }, + ] as KeyInfo[], +})); + +vi.mock("@tanstack/react-router", () => ({ + Link: ({ + children, + params, + ...props + }: { + children: ReactNode; + params: { keyId?: string; groupId?: string }; + "aria-label"?: string; + }) => ( + + {children} + + ), +})); +vi.mock("@/hooks/use-keys", () => ({ + useKeys: () => ({ data: records, refetch: vi.fn() }), + useCatalog: () => ({ data: [], refetch: vi.fn() }), +})); +vi.mock("@/hooks/use-user-services", () => ({ + useUserServices: () => ({ data: [], refetch: vi.fn() }), +})); +vi.mock("@/hooks/use-pools", () => ({ + useServicePools: () => ({ ...poolState, refetch: vi.fn() }), +})); +vi.mock("@/stores/auth-store", () => ({ + useAuthStore: (selector: (state: { user: { id: string } }) => unknown) => + selector({ user: account }), +})); + +import ServiceRoutingPreview from "./service-routing-preview"; +import { useServiceCardView } from "@/stores/service-card-view-store"; +import ServicePoolRoutingPreview from "./service-pool-routing-preview"; + +vi.mock("@/hooks/use-nodes", () => ({ useNodes: () => ({ data: [] }) })); +function renderConnection(candidate: RoutingCandidate) { + return {candidate.key.label}; +} +function preview() { + return ; +} +function poolPreview() { + return ; +} +function pool(id: string, members = ["mine", "team"]): ServicePool { + return { + id, + name: id, + slug: id.toLowerCase(), + user_id: "user-a", + strategy: "round_robin", + members: members.map((user_service_id) => ({ + user_service_id, + weight: 1, + enabled: true, + })), + rr_counter: 0, + is_active: true, + created_at: "2026-01-01", + updated_at: "2026-01-01", + }; +} + +beforeEach(() => { + useServiceCardView.setState({ + accountId: undefined, + expanded: [], + filters: undefined, + }); + localStorage.clear(); + account.id = "user-a"; + poolState.data = []; + poolState.error = null; +}); +afterEach(() => { + cleanup(); + records.splice(2); +}); + +describe("live grouped services", () => { + it("covers the gap above a pinned service header and clears it on return or collapse", async () => { + const user = userEvent.setup(); + render(
{preview()}
); + const main = screen.getByRole("main"); + main.scrollTo = vi.fn(); + const card = screen.getByRole("region", { name: "OpenAI" }); + const header = within(card) + .getByRole("heading", { name: "OpenAI" }) + .closest(".service-card-header")!; + card.getBoundingClientRect = () => + new DOMRect(0, 300 - main.scrollTop, 800, 1000); + header.getBoundingClientRect = () => + new DOMRect( + 0, + Math.max(132, 300 - main.scrollTop) + + (Number.parseFloat( + (header.style.translate ?? "").split(" ")[1] ?? "0", + ) || 0), + 800, + 120, + ); + await user.click( + screen.getByRole("button", { name: "Expand OpenAI connections" }), + ); + card.querySelector( + "[data-service-connection-row]", + )!.getBoundingClientRect = () => + new DOMRect(0, 452 - main.scrollTop, 800, 180); + fireEvent.scroll(main); + expect(header).toHaveAttribute("data-stuck", "false"); + main.scrollTop = 300; + fireEvent.scroll(main); + expect(header).toHaveAttribute("data-stuck", "true"); + main.scrollTop = 0; + fireEvent.scroll(main); + expect(header).toHaveAttribute("data-stuck", "false"); + main.scrollTop = 300; + fireEvent.scroll(main); + await user.click( + screen.getByRole("button", { name: "Collapse OpenAI connections" }), + ); + expect(header).toHaveAttribute("data-stuck", "false"); + }); + + it("releases the pinned header before the final three connections and restores it when scrolling back", async () => { + for (let index = 2; index <= 5; index++) { + records.push({ + ...records[0]!, + id: `personal-${index}`, + slug: `openai-${index}`, + }); + } + const user = userEvent.setup(); + render(
{preview()}
); + const main = screen.getByRole("main"); + main.scrollTo = vi.fn(); + const card = screen.getByRole("region", { name: "OpenAI" }); + const header = within(card) + .getByRole("heading", { name: "OpenAI" }) + .closest(".service-card-header")!; + card.getBoundingClientRect = () => + new DOMRect(0, 300 - main.scrollTop, 800, 900); + header.getBoundingClientRect = () => + new DOMRect( + 0, + Math.max(132, 300 - main.scrollTop) + + (Number.parseFloat( + (header.style.translate ?? "").split(" ")[1] ?? "0", + ) || 0), + 800, + 120, + ); + await user.click( + screen.getByRole("button", { name: "Expand OpenAI connections" }), + ); + const rows = [ + ...card.querySelectorAll("[data-service-connection-row]"), + ]; + expect(rows).toHaveLength(5); + const rowTops = [452, 572, 752, 842, 992]; + rows.forEach((row, index) => { + row.getBoundingClientRect = () => + new DOMRect(0, rowTops[index]! - main.scrollTop, 800, 100); + }); + main.scrollTop = 400; + fireEvent.scroll(main); + expect(header.style.translate).toBe(""); + main.scrollTop = 550; + fireEvent.scroll(main); + expect(header.style.translate).toBe("0 -50px"); + expect(header.getBoundingClientRect().bottom).toBe( + rows[2]!.getBoundingClientRect().top, + ); + main.scrollTop = 600; + fireEvent.scroll(main); + expect(header.style.translate).toBe("0 -100px"); + main.scrollTop = 400; + fireEvent.scroll(main); + expect(header.style.translate).toBe(""); + await user.click( + screen.getByRole("button", { name: "Collapse OpenAI connections" }), + ); + expect(header.style.translate).toBe(""); + expect(header).toHaveAttribute("data-stuck", "false"); + }); + + it("shows only filters and active pills while stuck, then restores saved views and the footer", async () => { + const user = userEvent.setup(); + render(
{preview()}
); + const main = screen.getByRole("main"); + const filters = screen.getByRole("region", { name: "Service filters" }); + const container = filters.parentElement!; + container.getBoundingClientRect = () => + new DOMRect(0, 80 - main.scrollTop, 900, 1200); + filters.getBoundingClientRect = () => + new DOMRect(0, Math.max(0, 80 - main.scrollTop), 900, 200); + await user.click( + screen.getByRole("button", { name: "Expand OpenAI connections" }), + ); + expect(screen.getByRole("button", { name: "Collapse" })).toBeVisible(); + await user.click(screen.getByRole("button", { name: "Saved views" })); + expect(screen.getByText(/No saved view yet/)).toBeVisible(); + main.scrollTop = 120; + fireEvent.scroll(main); + expect( + screen.queryByRole("button", { name: "Saved views" }), + ).not.toBeInTheDocument(); + expect(screen.queryByText(/No saved view yet/)).not.toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: "Save as default" }), + ).not.toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: "Refresh metadata" }), + ).not.toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: "Collapse" }), + ).not.toBeInTheDocument(); + expect(screen.queryByText(/matching connection/)).not.toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Service view: Personal" }), + ).toBeVisible(); + await user.click( + screen.getByRole("button", { name: "Service view: Personal" }), + ); + expect( + screen.getByRole("button", { name: "Service view: All services" }), + ).toBeVisible(); + await user.click(screen.getByRole("button", { name: "Organization" })); + await user.click(screen.getByRole("checkbox", { name: "Chrono" })); + await user.click(screen.getByRole("button", { name: "Done" })); + expect( + within(filters).getByRole("button", { name: "Remove Org: Chrono" }), + ).toBeVisible(); + main.scrollTop = 0; + fireEvent.scroll(main); + expect(screen.getByRole("button", { name: "Saved views" })).toBeVisible(); + expect( + screen.getByRole("button", { name: "Save as default" }), + ).toBeVisible(); + expect( + screen.getByRole("button", { name: "Refresh metadata" }), + ).toBeVisible(); + expect(screen.getByRole("button", { name: "Collapse" })).toBeVisible(); + expect(screen.getByText(/matching connection/)).toBeVisible(); + expect( + screen.getAllByRole("button", { name: /^Service view:/ }), + ).toHaveLength(1); + }); + + it("starts in Personal view and keeps all view controls in the filter card", async () => { + records.push({ + ...records[0]!, + id: "platform", + auto_connected: true, + label: "Platform connection", + }); + const user = userEvent.setup(); + render(preview()); + const filters = screen.getByRole("region", { name: "Service filters" }); + expect( + within(filters).getByRole("button", { name: "Service view: Personal" }), + ).toBeVisible(); + expect( + within(filters).getByText("1 service · 1 matching connection"), + ).toBeVisible(); + expect( + within(filters).getByRole("button", { name: "Refresh metadata" }), + ).toBeVisible(); + expect( + within(filters).getByRole("button", { name: "Save as default" }), + ).toBeVisible(); + expect( + within(filters).getAllByRole("button", { name: /^Service view:/ }), + ).toHaveLength(1); + await user.click( + within(filters).getByRole("button", { name: "Saved views" }), + ); + expect(screen.getByText(/No saved view yet/)).toBeVisible(); + expect( + screen.getByRole("button", { name: "Save current filters as default" }), + ).toBeDisabled(); + await user.keyboard("{Escape}"); + await user.click( + screen.getByRole("button", { name: "Expand OpenAI connections" }), + ); + expect(screen.queryByText("openai-team")).not.toBeInTheDocument(); + expect(screen.queryByText("Platform connection")).not.toBeInTheDocument(); + await user.click( + within(filters).getByRole("button", { name: "Service view: Personal" }), + ); + expect(screen.getByText("openai-team")).toBeVisible(); + expect(screen.getByText("Platform connection")).toBeVisible(); + expect( + within(filters).getByText("1 service · 3 matching connections"), + ).toBeVisible(); + await user.click( + within(filters).getByRole("button", { + name: "Service view: All services", + }), + ); + await user.click( + within(filters).getByRole("button", { name: "Organization" }), + ); + await user.click(screen.getByRole("checkbox", { name: "Chrono" })); + await user.click(screen.getByRole("button", { name: "Done" })); + expect( + within(filters).getByRole("button", { + name: "Service view: All services", + }), + ).toBeVisible(); + expect( + within(filters).getByRole("button", { name: "Remove Org: Chrono" }), + ).toBeVisible(); + expect(screen.getByText("openai-team")).toBeVisible(); + await user.click( + within(filters).getByRole("button", { + name: "Service view: All services", + }), + ); + expect( + screen.queryByRole("button", { name: "Remove Org: Chrono" }), + ).not.toBeInTheDocument(); + expect(screen.getByText("openai-personal")).toBeVisible(); + }); + + it("starts collapsed and compares connections in a table inside one service", async () => { + const user = userEvent.setup(); + render(preview()); + fireEvent.click( + screen.getByRole("button", { name: "Service view: Personal" }), + ); + const group = screen.getByRole("region", { name: "OpenAI" }); + const scroll = vi.fn(); + group.scrollIntoView = scroll; + expect(within(group).getByText("2 connections")).toBeVisible(); + expect( + screen.queryByText("https://api.openai.com"), + ).not.toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Expand OpenAI connections" }), + ).toHaveAttribute("aria-expanded", "false"); + await user.click( + screen.getByRole("button", { name: "Expand OpenAI connections" }), + ); + expect(within(group).getByText("https://api.openai.com")).toBeVisible(); + const table = within(group).getByRole("table", { + name: "OpenAI connections", + }); + expect(within(table).getAllByRole("row")).toHaveLength(3); + expect(scroll).toHaveBeenCalledWith({ + behavior: "smooth", + block: "start", + inline: "nearest", + }); + for (const name of [ + "Classification", + "Status", + "Activity", + "Connection / Slug", + "Configuration", + ]) { + expect(within(table).getByRole("columnheader", { name })).toBeVisible(); + } + expect(within(group).getByText("openai-team")).toBeVisible(); + expect(within(group).queryByText("1 granted")).not.toBeInTheDocument(); + expect( + within(group).getByRole("link", { + name: "Configure Personal account (Personal)", + }), + ).toHaveAttribute("href", "/keys/mine"); + expect( + within(group).getByRole("link", { + name: "View all OpenAI service details", + }), + ).toHaveAttribute("href", "/keys/services/catalog:openai-id"); + expect( + within(group).queryByText("Details", { selector: "summary" }), + ).not.toBeInTheDocument(); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + await user.click( + screen.getByRole("button", { name: "Collapse OpenAI connections" }), + ); + expect(scroll).toHaveBeenCalledTimes(1); + expect( + screen.queryByRole("button", { + name: /Drag|By service|Individual cards/, + }), + ).not.toBeInTheDocument(); + }); + + it("closes the previous service and scrolls the most recently opened card", async () => { + records.push({ + ...records[0]!, + id: "twilio", + label: "Twilio account", + slug: "twilio", + catalog_service_id: "twilio-id", + catalog_service_name: "Twilio", + catalog_service_slug: "twilio", + }); + const user = userEvent.setup(); + render(preview()); + fireEvent.click( + screen.getByRole("button", { name: "Service view: Personal" }), + ); + const openai = screen.getByRole("region", { name: "OpenAI" }); + const twilio = screen.getByRole("region", { name: "Twilio" }); + const scrollOpenai = vi.fn(); + const scrollTwilio = vi.fn(); + openai.scrollIntoView = scrollOpenai; + twilio.scrollIntoView = scrollTwilio; + await user.click( + screen.getByRole("button", { name: "Expand OpenAI connections" }), + ); + expect( + screen.getByRole("table", { name: "OpenAI connections" }), + ).toBeVisible(); + await user.click(within(twilio).getByRole("button", { name: "Twilio" })); + expect( + screen.queryByRole("table", { name: "OpenAI connections" }), + ).not.toBeInTheDocument(); + expect( + screen.getByRole("table", { name: "Twilio connections" }), + ).toBeVisible(); + expect( + screen.getByRole("button", { name: "Expand OpenAI connections" }), + ).toHaveAttribute("aria-expanded", "false"); + expect(scrollOpenai).toHaveBeenCalledOnce(); + expect(scrollTwilio).toHaveBeenCalledOnce(); + await user.click( + screen.getByRole("button", { name: "Expand OpenAI connections" }), + ); + expect( + screen.queryByRole("table", { name: "Twilio connections" }), + ).not.toBeInTheDocument(); + expect( + screen.getByRole("table", { name: "OpenAI connections" }), + ).toBeVisible(); + expect(scrollOpenai).toHaveBeenCalledTimes(2); + expect(scrollTwilio).toHaveBeenCalledOnce(); + }); + + it("searches a nested connection and shows only matching rows with the group total", async () => { + const user = userEvent.setup(); + render(preview()); + fireEvent.click( + screen.getByRole("button", { name: "Service view: Personal" }), + ); + await user.type( + screen.getByRole("textbox", { name: "Search services and connections" }), + "Team account{Enter}", + ); + expect(screen.getByText("Matches: Team account")).toBeVisible(); + expect(screen.getByText("1 of 2 connections")).toBeVisible(); + await user.click( + screen.getByRole("button", { name: "Expand OpenAI connections" }), + ); + expect(screen.queryByText("openai-personal")).not.toBeInTheDocument(); + expect(screen.getByText("openai-team")).toBeVisible(); + }); + + it("filters by actual sources and hides nonmatching connection rows", async () => { + const user = userEvent.setup(); + render(preview()); + fireEvent.click( + screen.getByRole("button", { name: "Service view: Personal" }), + ); + await user.click(screen.getByRole("button", { name: "Filters" })); + expect( + screen.queryByRole("button", { name: "NyxID platform" }), + ).not.toBeInTheDocument(); + await user.click( + within(screen.getByRole("dialog")).getByRole("button", { + name: "Organization", + }), + ); + await user.click(screen.getByRole("button", { name: "Apply filters" })); + expect(screen.getByText("1 of 2 match")).toBeVisible(); + await user.click( + screen.getByRole("button", { name: "Expand OpenAI connections" }), + ); + expect(screen.queryByText("openai-personal")).not.toBeInTheDocument(); + expect(screen.getByText("openai-team")).toBeVisible(); + expect( + screen.getByRole("button", { name: "Save as default" }), + ).toBeDisabled(); + }); + + it("uses the audit log Apply, Cancel and editable filter chip flow", async () => { + const user = userEvent.setup(); + render(preview()); + fireEvent.click( + screen.getByRole("button", { name: "Service view: Personal" }), + ); + await user.click(screen.getByRole("button", { name: "Filters" })); + await user.click(screen.getByRole("button", { name: "Service state" })); + await user.click(screen.getByRole("button", { name: "Disabled" })); + expect(screen.getByRole("region", { name: "OpenAI" })).toBeVisible(); + await user.click(screen.getByRole("button", { name: "Cancel" })); + expect(screen.getByRole("button", { name: "Filters" })).toBeVisible(); + await user.click(screen.getByRole("button", { name: "Filters" })); + await user.click(screen.getByRole("button", { name: "Disabled" })); + await user.click(screen.getByRole("button", { name: "Apply filters" })); + await user.click( + screen.getByRole("button", { name: /Edit Service state/ }), + ); + expect(screen.getByRole("button", { name: "Disabled" })).toHaveAttribute( + "aria-pressed", + "true", + ); + await user.click(screen.getByRole("button", { name: "Cancel" })); + await user.click( + screen.getByRole("button", { name: "Remove Service state filter" }), + ); + expect(screen.getByRole("region", { name: "OpenAI" })).toBeVisible(); + }); + + it("supports multiple organizations and services with removable selection pills", async () => { + records.push( + { + ...records[1]!, + id: "elf", + label: "Elf account", + credential_source: { + type: "org", + org_id: "elf-id", + org_name: "Elf", + allowed: true, + role: "admin", + avatar_url: null, + }, + }, + { + ...records[1]!, + id: "twilio", + label: "Twilio", + slug: "twilio", + catalog_service_id: "twilio-id", + catalog_service_name: "Twilio", + catalog_service_slug: "twilio", + }, + ); + const user = userEvent.setup(); + render(preview()); + fireEvent.click( + screen.getByRole("button", { name: "Service view: Personal" }), + ); + await user.click(screen.getByRole("button", { name: "Organization" })); + await user.click(screen.getByRole("checkbox", { name: "Chrono" })); + await user.click(screen.getByRole("checkbox", { name: "Elf" })); + expect(screen.getByRole("checkbox", { name: "Chrono" })).toBeChecked(); + expect(screen.getByRole("checkbox", { name: "Elf" })).toBeChecked(); + await user.click(screen.getByRole("button", { name: "Done" })); + expect( + screen.getByRole("button", { name: "Organization" }), + ).toHaveTextContent("2 selected"); + expect( + screen.getByRole("button", { name: "Remove Org: Chrono" }), + ).toBeVisible(); + expect( + screen.getByRole("button", { name: "Remove Org: Elf" }), + ).toBeVisible(); + await user.click(screen.getByRole("button", { name: "Service" })); + await user.type( + screen.getByRole("textbox", { name: "Search services" }), + "OpenAI", + ); + expect( + screen.queryByRole("checkbox", { name: "Twilio" }), + ).not.toBeInTheDocument(); + await user.click(screen.getByRole("checkbox", { name: "OpenAI" })); + await user.clear(screen.getByRole("textbox", { name: "Search services" })); + await user.click(screen.getByRole("checkbox", { name: "Twilio" })); + await user.click(screen.getByRole("button", { name: "Done" })); + expect( + screen.getByRole("button", { name: "Remove Service: OpenAI" }), + ).toBeVisible(); + expect( + screen.getByRole("button", { name: "Remove Service: Twilio" }), + ).toBeVisible(); + expect(screen.getByRole("region", { name: "OpenAI" })).toBeVisible(); + expect(screen.getByRole("region", { name: "Twilio" })).toBeVisible(); + await user.click( + screen.getByRole("button", { name: "Expand OpenAI connections" }), + ); + expect( + within( + screen.getByRole("table", { name: "OpenAI connections" }), + ).getAllByRole("row"), + ).toHaveLength(3); + expect(screen.queryByText("openai-personal")).not.toBeInTheDocument(); + await user.click( + screen.getByRole("button", { name: "Remove Org: Chrono" }), + ); + expect( + screen.queryByRole("region", { name: "Twilio" }), + ).not.toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Remove Service: Twilio" }), + ).toBeVisible(); + expect( + screen.getByRole("button", { name: "Remove Org: Elf" }), + ).toBeVisible(); + expect( + within( + screen.getByRole("table", { name: "OpenAI connections" }), + ).getAllByRole("row"), + ).toHaveLength(2); + await user.click( + screen.getByRole("button", { name: "Remove Service: OpenAI" }), + ); + expect(screen.getByText(/No services match these filters/)).toBeVisible(); + await user.click(screen.getByRole("button", { name: "Organization" })); + expect(screen.getByRole("checkbox", { name: "Elf" })).toBeChecked(); + expect(screen.getByRole("checkbox", { name: "Chrono" })).not.toBeChecked(); + await user.click( + screen.getByRole("button", { name: "Clear organizations" }), + ); + await user.click(screen.getByRole("button", { name: "Done" })); + expect(screen.getByRole("region", { name: "Twilio" })).toBeVisible(); + expect( + screen.getByRole("button", { name: "Remove Service: Twilio" }), + ).toBeVisible(); + await user.click(screen.getByRole("button", { name: "Clear filters" })); + expect( + screen.queryByRole("button", { name: /Remove (Org|Service):/ }), + ).not.toBeInTheDocument(); + expect(screen.getByRole("region", { name: "OpenAI" })).toBeVisible(); + }); + + it("distinguishes disabled, inaccessible and missing credentials and reports changes without inventing usage", async () => { + records.push( + { + ...records[0]!, + id: "disabled", + label: "Disabled", + slug: "disabled", + is_active: false, + }, + { + ...records[1]!, + id: "denied", + label: "Denied", + slug: "denied", + credential_source: { + ...records[1]!.credential_source!, + type: "org", + org_id: "denied-org", + org_name: "Restricted", + allowed: false, + role: "viewer", + avatar_url: null, + }, + }, + { + ...records[0]!, + id: "missing", + label: "Missing", + slug: "missing", + credential_missing: true, + last_used_at: "2026-09-27", + source_app_name: "Provisioning app", + authorship: { + created_by: null, + last_change: { + actor: { + kind: "agent", + id: "agent-id", + name: "Build agent", + api_key_id: "agent-id", + app_id: null, + person_id: null, + }, + at: "2026-09-26T10:00:00Z", + action: "updated", + change_group_id: "change-id", + }, + }, + }, + ); + const user = userEvent.setup(); + render(preview()); + fireEvent.click( + screen.getByRole("button", { name: "Service view: Personal" }), + ); + await user.click( + screen.getByRole("button", { name: "Expand OpenAI connections" }), + ); + const table = screen.getByRole("table", { name: "OpenAI connections" }); + expect( + within(table).getByText("Disabled", { selector: "div" }), + ).toBeVisible(); + expect(within(table).getByText("No access")).toBeVisible(); + expect(within(table).getByText("Credential missing")).toBeVisible(); + expect(within(table).getByText("Changed by Build agent")).toBeVisible(); + expect( + within(table).queryByText(/Last used|Ready|Provisioning app/), + ).not.toBeInTheDocument(); + }); + + it("lets the user clear an empty filter result", async () => { + const user = userEvent.setup(); + render(preview()); + fireEvent.click( + screen.getByRole("button", { name: "Service view: Personal" }), + ); + await user.click(screen.getByRole("button", { name: "Filters" })); + await user.click(screen.getByRole("button", { name: "Service state" })); + await user.click(screen.getByRole("button", { name: "Disabled" })); + await user.click(screen.getByRole("button", { name: "Apply filters" })); + expect(screen.getByText(/No services match these filters/)).toBeVisible(); + await user.click(screen.getByRole("button", { name: "Clear filters" })); + expect(screen.getByRole("region", { name: "OpenAI" })).toBeVisible(); + }); + + it("omits nonexistent platform sources and never invents a routing decision", async () => { + const user = userEvent.setup(); + render(preview()); + fireEvent.click( + screen.getByRole("button", { name: "Service view: Personal" }), + ); + await user.click( + screen.getByRole("button", { name: "Expand OpenAI connections" }), + ); + expect(screen.queryByText(/NyxID platform/)).not.toBeInTheDocument(); + expect(screen.getAllByText("Not verified")).toHaveLength(2); + expect( + screen.queryByText(/Ready via|Would use|Automatic/), + ).not.toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Organization" })).toBeVisible(); + expect(screen.getByRole("button", { name: "Service" })).toBeVisible(); + }); + + it("shows a real platform connection inside its service without claiming health", async () => { + records.push({ + ...records[0]!, + id: "platform", + label: "Platform account", + auto_connected: true, + }); + const user = userEvent.setup(); + render(preview()); + fireEvent.click( + screen.getByRole("button", { name: "Service view: Personal" }), + ); + expect(screen.getByText("3 connections")).toBeVisible(); + await user.click( + screen.getByRole("button", { name: "Expand OpenAI connections" }), + ); + expect(screen.getByText("Platform account")).toBeVisible(); + expect(screen.getAllByText("Not verified")).toHaveLength(3); + expect( + within( + screen.getByRole("table", { name: "OpenAI connections" }), + ).getByRole("img", { name: "NyxID platform" }), + ).toHaveAttribute("src", "/nyxid-coloured-icon.svg"); + expect(screen.queryByText(/Ready via/)).not.toBeInTheDocument(); + }); + + it("keeps custom services separate even when labels and addresses look alike", async () => { + records.push({ + ...records[0]!, + id: "custom", + catalog_service_id: null, + catalog_service_slug: null, + label: "OpenAI custom", + }); + const user = userEvent.setup(); + render(preview()); + fireEvent.click( + screen.getByRole("button", { name: "Service view: Personal" }), + ); + expect(screen.getByRole("region", { name: "OpenAI" })).toBeVisible(); + expect(screen.getByRole("region", { name: "OpenAI custom" })).toBeVisible(); + await user.click( + screen.getByRole("button", { name: "Expand OpenAI connections" }), + ); + expect( + screen.getByRole("button", { name: "Expand OpenAI custom connections" }), + ).toHaveAttribute("aria-expanded", "false"); + }); + + it("restores expansion after detail navigation and isolates it when accounts change", async () => { + const user = userEvent.setup(); + const mounted = render(preview()); + fireEvent.click( + screen.getByRole("button", { name: "Service view: Personal" }), + ); + await user.click( + screen.getByRole("button", { name: "Expand OpenAI connections" }), + ); + mounted.unmount(); + const next = render(preview()); + expect( + screen.getByRole("button", { name: "Service view: All services" }), + ).toBeVisible(); + expect( + screen.getByRole("button", { name: "Collapse OpenAI connections" }), + ).toHaveAttribute("aria-expanded", "true"); + account.id = "user-b"; + next.rerender(preview()); + fireEvent.click( + screen.getByRole("button", { name: "Service view: Personal" }), + ); + expect( + screen.getByRole("button", { name: "Expand OpenAI connections" }), + ).toHaveAttribute("aria-expanded", "false"); + }); +}); + +describe("pool member ordering", () => { + it("never infers a pool from connections that share a catalog service", () => { + render(poolPreview()); + expect(screen.getByText("No service pools in your account.")).toBeVisible(); + expect( + screen.queryByRole("button", { name: /Drag/ }), + ).not.toBeInTheDocument(); + }); + + it("requires opting into priority, scopes drag to one actual pool, and preserves live records", async () => { + const user = userEvent.setup(); + poolState.data = [pool("First"), pool("Second")]; + const before = JSON.stringify(poolState.data); + render(poolPreview()); + expect( + screen.queryByRole("button", { name: /Drag/ }), + ).not.toBeInTheDocument(); + await user.selectOptions( + screen.getByRole("combobox", { name: "Selection for First" }), + "priority", + ); + await user.selectOptions( + screen.getByRole("combobox", { name: "Selection for Second" }), + "priority", + ); + const transferData = new Map(); + const transfer = { + setData: (type: string, value: string) => transferData.set(type, value), + getData: (type: string) => transferData.get(type), + effectAllowed: "", + dropEffect: "", + }; + fireEvent.dragStart( + screen.getByRole("button", { name: "Drag Team account in First" }), + { dataTransfer: transfer }, + ); + const foreign = screen + .getByRole("button", { name: "Drag Personal account in Second" }) + .closest("li")!; + fireEvent.dragOver(foreign, { dataTransfer: transfer }); + fireEvent.drop(foreign, { dataTransfer: transfer }); + expect( + within( + within( + screen.getByRole("list", { name: "Members of Second" }), + ).getAllByRole("listitem")[0]!, + ).getByText("Personal account"), + ).toBeVisible(); + const target = screen + .getByRole("button", { name: "Drag Personal account in First" }) + .closest("li")!; + fireEvent.dragOver(target, { dataTransfer: transfer }); + fireEvent.drop(target, { dataTransfer: transfer }); + expect( + within( + within( + screen.getByRole("list", { name: "Members of First" }), + ).getAllByRole("listitem")[0]!, + ).getByText("Team account"), + ).toBeVisible(); + expect(JSON.stringify(poolState.data)).toBe(before); + expect( + screen.queryByText(/Ready via|Selected connection/), + ).not.toBeInTheDocument(); + }); + + it("supports keyboard ordering and persists independently for each pool and account", async () => { + const user = userEvent.setup(); + poolState.data = [pool("First"), pool("Second")]; + const mounted = render(poolPreview()); + await user.selectOptions( + screen.getByRole("combobox", { name: "Selection for First" }), + "priority", + ); + fireEvent.keyDown( + screen.getByRole("button", { name: "Drag Team account in First" }), + { key: "ArrowUp" }, + ); + mounted.unmount(); + const next = render(poolPreview()); + expect( + within( + within( + screen.getByRole("list", { name: "Members of First" }), + ).getAllByRole("listitem")[0]!, + ).getByText("Team account"), + ).toBeVisible(); + expect( + screen.getByRole("combobox", { name: "Selection for Second" }), + ).toHaveValue("current"); + account.id = "user-b"; + next.rerender(poolPreview()); + expect( + screen.getByRole("combobox", { name: "Selection for First" }), + ).toHaveValue("current"); + }); + + it("shows excluded and missing members without selecting them or fabricating cards", async () => { + poolState.data = [pool("First", ["mine", "missing"])]; + poolState.data[0]!.members[0]!.enabled = false; + render(poolPreview()); + expect(screen.getByText("Excluded")).toBeVisible(); + expect(screen.getByText("Service unavailable")).toBeVisible(); + expect(screen.queryByText("Team account")).not.toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/dashboard/service-routing-preview.tsx b/frontend/src/components/dashboard/service-routing-preview.tsx new file mode 100644 index 000000000..1eee4cd26 --- /dev/null +++ b/frontend/src/components/dashboard/service-routing-preview.tsx @@ -0,0 +1,124 @@ +import { canEditConnection } from "@/lib/connection-access"; +import { useState, type ReactNode } from "react"; +import { Link } from "@tanstack/react-router"; +import { ChevronRight, RefreshCw } from "lucide-react"; +import { useKeys, useCatalog } from "@/hooks/use-keys"; +import { useUserServices } from "@/hooks/use-user-services"; +import { Button } from "@/components/ui/button"; +import { Skeleton } from "@/components/ui/skeleton"; +import { GroupedServiceCards } from "./grouped-service-cards"; +import type { KeyInfo } from "@/types/keys"; +import { + classifyConnection, + type RoutingCandidate, +} from "@/lib/service-routing-preview"; + +export interface RoutingPreviewProps { + readonly renderConnection: (candidate: RoutingCandidate) => ReactNode; +} + +export function ConnectionCard({ + candidate, + renderConnection, +}: RoutingPreviewProps & { readonly candidate: RoutingCandidate }) { + const key = candidate.key; + return ( +
+ {renderConnection(candidate)} +
+ {candidate.owner} + + {candidate.reason} + +
+
+ + Details + +
+ {key.description &&

{key.description}

} + {canEditConnection(key) &&

Auth: {key.auth_method}

} + {key.last_used_at && ( +

+ Credential last prepared:{" "} + {new Date(key.last_used_at).toLocaleDateString()} +

+ )} + {key.expires_at && ( +

Expires: {new Date(key.expires_at).toLocaleDateString()}

+ )} + {canEditConnection(key) && !!key.granted_scopes?.length && ( +

+ Permissions: {key.granted_scopes.join(", ")} +

+ )} + + Open full service details + + +
+
+
+ ); +} + +export default function ServiceRoutingPreview({ + renderConnectionActions, +}: { + readonly renderConnectionActions?: (key: KeyInfo) => ReactNode; +}) { + const keys = useKeys(); + const catalog = useCatalog(); + const services = useUserServices(); + const [mountedAt] = useState(Date.now); + const candidates = (keys.data ?? []).map((key) => + classifyConnection( + key, + services.data ?? [], + keys.dataUpdatedAt || mountedAt, + ), + ); + + if (keys.isLoading) return ; + if (keys.error) + return ( +
+ Connections could not be loaded.{" "} + +
+ ); + + return ( + ({ + ...candidate.key, + credential_source: candidate.source, + }))} + catalog={catalog.data} + renderConnectionActions={renderConnectionActions} + actions={ + + } + /> + ); +} diff --git a/frontend/src/components/dashboard/service-saved-views.tsx b/frontend/src/components/dashboard/service-saved-views.tsx new file mode 100644 index 000000000..3f2e01c4e --- /dev/null +++ b/frontend/src/components/dashboard/service-saved-views.tsx @@ -0,0 +1,145 @@ +import { useState } from "react"; +import { Bookmark, Check, ChevronDown, Save, Star } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import type { useServiceView } from "@/hooks/use-service-view"; + +export function ServiceSavedViews({ + view, + onRestore, +}: { + readonly view: ReturnType; + readonly onRestore: () => void; +}) { + const [open, setOpen] = useState(false); + const source = { + personal: "Personal", + all: "All services", + org: "Organization", + platform: "NyxID platform", + }[view.savedFilters.source]; + const savedDescription = [ + source, + view.savedFilters.organization_ids.length + ? `${view.savedFilters.organization_ids.length} organizations` + : null, + view.savedFilters.service_group_ids.length + ? `${view.savedFilters.service_group_ids.length} services` + : null, + view.savedFilters.search ? `“${view.savedFilters.search}”` : null, + ] + .filter(Boolean) + .join(" · "); + + return ( +
+ + + + + +
+

Saved views

+

+ Your default opens when you return to AI Services. +

+
+ {view.hasDefault ? ( + + ) : ( +

+ No saved view yet. Set your filters, then save them as your + default. +

+ )} + {!view.canSave && ( +

+ Saving account defaults requires the updated server. +

+ )} + +
+
+ {view.isDefault ? ( + + + ) : ( + + )} +
+ ); +} diff --git a/frontend/src/components/dashboard/service-view-toolbar.tsx b/frontend/src/components/dashboard/service-view-toolbar.tsx new file mode 100644 index 000000000..47c65d4dc --- /dev/null +++ b/frontend/src/components/dashboard/service-view-toolbar.tsx @@ -0,0 +1,414 @@ +import { useRef, useState, type ReactNode, type Ref } from "react"; +import { ArrowLeftRight, Layers, UserRound, X } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { + ServiceFilterMultiselect, + type ServiceFilterOption, +} from "./service-filter-multiselect"; +import { ServiceOwnerAvatar } from "./service-owner-avatar"; +import { ServiceSavedViews } from "./service-saved-views"; +import { + DataTableControls, + DataTableSearch, + DataTableFilterPopover, + DataTableFilterChips, +} from "@/components/data-table/data-table-controls"; +import { + DEFAULT_SERVICE_FILTERS, + serviceViewSchema, +} from "@/schemas/service-view"; +import { connectionSource } from "@/lib/service-view"; +import { cn } from "@/lib/utils"; +import type { ServiceConnectionGroup } from "@/lib/service-groups"; +import type { useServiceView } from "@/hooks/use-service-view"; +import type { KeyInfo } from "@/types/keys"; +import type { + DataTableFilterField, + DataTableFilterSelections, +} from "@/types/data-table"; + +type FilterKey = "source" | "state" | "service_type" | "show_auto_connected"; +const SOURCE_LABELS = { + personal: "Personal", + org: "Organization", + platform: "NyxID platform", +}; + +export function ServiceViewToolbar({ + view, + keys, + groups, + children, + ref, + stuck = false, +}: { + readonly view: ReturnType; + readonly keys: readonly KeyInfo[]; + readonly groups: readonly ServiceConnectionGroup[]; + readonly children?: ReactNode; + readonly ref?: Ref; + readonly stuck?: boolean; +}) { + const { filters, setFilters } = view; + const inputRef = useRef(null); + const [draft, setDraft] = useState(null); + const [open, setOpen] = useState(false); + const [selectedKey, setSelectedKey] = useState("source"); + const sources = new Set(keys.map(connectionSource)); + const organizations: ServiceFilterOption[] = [ + ...new Map( + keys.flatMap((key) => { + const source = key.credential_source; + return source?.type === "org" ? [[source.org_id, source] as const] : []; + }), + ).values(), + ] + .sort((a, b) => a.org_name.localeCompare(b.org_name)) + .map((source) => ({ + id: source.org_id, + label: source.org_name, + icon: ( + + ), + })); + const services: ServiceFilterOption[] = groups.map((group) => ({ + id: group.id, + label: group.name, + })); + const selections = [ + ...filters.organization_ids.map((id) => ({ + id, + field: "organization_ids" as const, + prefix: "Org", + label: + organizations.find((option) => option.id === id)?.label ?? + "Unavailable organization", + icon: organizations.find((option) => option.id === id)?.icon, + })), + ...filters.service_group_ids.map((id) => ({ + id, + field: "service_group_ids" as const, + prefix: "Service", + label: + services.find((option) => option.id === id)?.label ?? + "Unavailable service", + icon: undefined, + })), + ]; + const hasStandaloneFilters = selections.length > 0; + const fields: readonly DataTableFilterField[] = [ + { + key: "source", + label: "Source", + value_type: "enum", + operator: "is", + options: (Object.keys(SOURCE_LABELS) as (keyof typeof SOURCE_LABELS)[]) + .filter((source) => sources.has(source)) + .map((value) => ({ value, label: SOURCE_LABELS[value] })), + }, + { + key: "state", + label: "Service state", + value_type: "enum", + operator: "is", + options: [ + { value: "enabled", label: "Enabled" }, + { value: "disabled", label: "Disabled" }, + ], + }, + { + key: "service_type", + label: "Type", + value_type: "enum", + operator: "is", + options: [ + { value: "http", label: "HTTP" }, + { value: "ssh", label: "SSH" }, + ], + }, + { + key: "show_auto_connected", + label: "Auto-connected", + value_type: "boolean", + operator: "is", + options: [ + { value: "false", label: "Hidden" }, + { value: "true", label: "Included" }, + ], + }, + ]; + const values: DataTableFilterSelections = { + source: filters.source === "all" ? [] : [filters.source], + state: filters.state === "all" ? [] : [filters.state], + service_type: filters.service_type === "all" ? [] : [filters.service_type], + show_auto_connected: filters.show_auto_connected ? [] : ["false"], + }; + const applied = fields.flatMap((field) => { + const selected = values[field.key] ?? []; + return selected.length && + !(field.key === "source" && filters.source === "personal") + ? [ + { + field, + values: selected, + valueLabels: selected.map( + (value) => + field.options.find((option) => option.value === value)?.label ?? + (field.key === "source" + ? SOURCE_LABELS[value as keyof typeof SOURCE_LABELS] + : value), + ), + }, + ] + : []; + }); + const editSearch = () => { + setDraft(filters.search); + inputRef.current?.focus(); + }; + const clear = () => { + setDraft(null); + setFilters({ + ...DEFAULT_SERVICE_FILTERS, + source: filters.source === "personal" ? "personal" : "all", + }); + }; + const sourceToggle = ( + + ); + + return ( +
+
+ {!stuck && ( +
+ { + setDraft(null); + view.restoreDefault(); + }} + /> + {sourceToggle} +
+ )} + +
+ + setFilters({ + ...filters, + organization_ids, + source: + organization_ids.length && filters.source === "personal" + ? "all" + : filters.source, + }) + } + /> + + setFilters({ ...filters, service_group_ids }) + } + /> +
+ undefined} + onValueChange={setDraft} + onApply={() => { + setFilters({ + ...filters, + search: (draft ?? filters.search).trim(), + }); + setDraft(null); + }} + onCancel={() => setDraft("")} + /> + + } + filter={ + + setFilters( + serviceViewSchema.parse({ + ...filters, + source: selections.source?.[0] ?? "all", + state: selections.state?.[0] ?? "all", + service_type: selections.service_type?.[0] ?? "all", + show_auto_connected: + selections.show_auto_connected?.[0] !== "false", + }), + ) + } + /> + } + chips={ + Boolean(selections.length || filters.search || applied.length) && ( +
+ {selections.map((selection) => ( +
+ {selection.icon} + + + {selection.prefix}: + {" "} + {selection.label} + + +
+ ))} + { + setDraft(null); + setFilters({ ...filters, search: "" }); + }} + onEditSearchValue={editSearch} + onRemoveSearchValue={() => undefined} + onEdit={(key) => { + setSelectedKey(key); + setOpen(true); + }} + onRemove={(key) => + setFilters({ + ...filters, + [key]: + key === "source" ? "all" : DEFAULT_SERVICE_FILTERS[key], + }) + } + onClear={clear} + /> + {hasStandaloneFilters && + !filters.search && + applied.length === 0 && ( + + )} +
+ ) + } + /> + + {!stuck && ( +
+ {children} +
+ )} + + {!stuck && view.saveError && ( +

+ {view.saveError} +

+ )} +
+
+ ); +} diff --git a/frontend/src/components/data-table/data-table-controls.tsx b/frontend/src/components/data-table/data-table-controls.tsx index 2e669463a..f8546f5cd 100644 --- a/frontend/src/components/data-table/data-table-controls.tsx +++ b/frontend/src/components/data-table/data-table-controls.tsx @@ -123,7 +123,7 @@ export function DataTableSearch({ role="search" className="w-full min-w-0 flex-1 sm:min-w-[320px]" > -
+
@@ -154,31 +154,33 @@ export function DataTableSearch({ >
); @@ -929,6 +931,7 @@ export interface DataTableFilterChipsProps< readonly filters: readonly AppliedDataTableFilter[]; readonly allFieldsLabel?: string; readonly ariaLabel?: string; + readonly className?: string; readonly onEditSearch: () => void; readonly onRemoveSearch: () => void; readonly onEditSearchValue: (field: SearchKey, value: string) => void; @@ -949,6 +952,7 @@ export function DataTableFilterChips< filters, allFieldsLabel = "All fields", ariaLabel = "Applied filters", + className, onEditSearch, onRemoveSearch, onEditSearchValue, @@ -967,7 +971,7 @@ export function DataTableFilterChips< ); return ( -
+
{search && (
-
+
+
form]:min-w-48 [&>form]:w-auto" + : "flex-wrap", + )} + > {search}
{filter}
{status} diff --git a/frontend/src/components/layout/dashboard-layout.tsx b/frontend/src/components/layout/dashboard-layout.tsx index f6905f1ad..e8f3d358b 100644 --- a/frontend/src/components/layout/dashboard-layout.tsx +++ b/frontend/src/components/layout/dashboard-layout.tsx @@ -95,7 +95,7 @@ export function DashboardLayout() { // Shared channel onboarding must stay reachable through setup and bot routing. const isChannelBotRoute = pathname === "/channel-bots" || pathname.startsWith("/channel-bots/"); if (onboarding.status === "loading") return null; - if (onboarding.status === "show" && !isChannelBotRoute) return ; + if (onboarding.status === "show" && !isChannelBotRoute && !(import.meta.env.DEV && import.meta.env.VITE_ROUTING_PREVIEW === "1")) return ; return ( @@ -247,7 +247,8 @@ function TopBarBreadcrumbs() { for (const [i, segment] of segments.entries()) { const segPath = accPaths[i]!; const isLast = i === segments.length - 1; - if (UUID_RE.test(segment)) { + if (segPath === "/keys/services") continue; + if (UUID_RE.test(segment) || (isLast && pathname.startsWith("/keys/services/"))) { if (isLast && detailLabel) { crumbs.push({ label: detailLabel }); } diff --git a/frontend/src/hooks/use-service-card-transition.test.ts b/frontend/src/hooks/use-service-card-transition.test.ts new file mode 100644 index 000000000..41a2c1f5c --- /dev/null +++ b/frontend/src/hooks/use-service-card-transition.test.ts @@ -0,0 +1,134 @@ +import { act, cleanup, renderHook } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { useServiceCardTransition } from "./use-service-card-transition"; + +const originalTransition = Object.getOwnPropertyDescriptor( + document, + "startViewTransition", +); +let card: HTMLElement; +let scroller: HTMLElement | undefined; +let scroll = vi.fn(); + +function installTransition() { + let finish!: () => void; + const finished = new Promise((resolve) => { + finish = resolve; + }); + const transition = { finished, skipTransition: vi.fn() }; + const start = vi.fn((update: () => void) => { + update(); + return transition; + }); + Object.defineProperty(document, "startViewTransition", { + configurable: true, + value: start, + }); + return { finish, transition, start }; +} + +beforeEach(() => { + card = document.createElement("section"); + scroll = vi.fn(); + card.scrollIntoView = scroll; + document.body.append(card); +}); +afterEach(() => { + cleanup(); + card.remove(); + scroller?.remove(); + scroller = undefined; + vi.restoreAllMocks(); + if (originalTransition) + Object.defineProperty(document, "startViewTransition", originalTransition); + else Reflect.deleteProperty(document, "startViewTransition"); +}); + +describe("service card scrolling", () => { + it("leaves space below the live toolbar height and the padded scroll viewport", async () => { + const { finish } = installTransition(); + scroller = document.createElement("main"); + scroller.style.paddingTop = "16px"; + scroller.scrollTop = 40; + Object.defineProperty(scroller, "clientTop", { value: 2 }); + scroller.getBoundingClientRect = () => new DOMRect(0, 100, 800, 900); + scroller.scrollTo = vi.fn(); + const toolbar = document.createElement("div"); + toolbar.style.top = "0px"; + toolbar.getBoundingClientRect = () => new DOMRect(0, 116, 800, 200); + card.getBoundingClientRect = () => new DOMRect(0, 650, 800, 300); + document.body.append(scroller); + scroller.append(toolbar, card); + const { result } = renderHook(useServiceCardTransition); + act(() => result.current(() => {}, card, toolbar)); + expect(scroller.scrollTo).not.toHaveBeenCalled(); + toolbar.getBoundingClientRect = () => new DOMRect(0, 116, 800, 280); + await act(async () => { + finish(); + }); + expect(scroller.scrollTo).toHaveBeenCalledWith({ + top: 260, + behavior: "smooth", + }); + expect(scroll).not.toHaveBeenCalled(); + }); + + it("waits until the card transition finishes before scrolling", async () => { + const { finish } = installTransition(); + const { result } = renderHook(useServiceCardTransition); + const update = vi.fn(); + act(() => result.current(update, card)); + expect(update).toHaveBeenCalledOnce(); + expect(scroll).not.toHaveBeenCalled(); + await act(async () => { + finish(); + }); + expect(scroll).toHaveBeenCalledWith({ + behavior: "smooth", + block: "start", + inline: "nearest", + }); + }); + + it("cancels a pending scroll when the card is collapsed during expansion", async () => { + const first = installTransition(); + const { result } = renderHook(useServiceCardTransition); + act(() => result.current(() => {}, card)); + const second = installTransition(); + act(() => result.current(() => {})); + expect(first.transition.skipTransition).toHaveBeenCalledOnce(); + await act(async () => { + first.finish(); + second.finish(); + }); + expect(scroll).not.toHaveBeenCalled(); + }); + + it("scrolls immediately without animation when reduced motion is preferred", () => { + const { start } = installTransition(); + vi.spyOn(window, "matchMedia").mockReturnValue({ + matches: true, + } as MediaQueryList); + const { result } = renderHook(useServiceCardTransition); + act(() => result.current(() => {}, card)); + expect(start).not.toHaveBeenCalled(); + expect(scroll).toHaveBeenCalledWith({ + behavior: "instant", + block: "start", + inline: "nearest", + }); + }); + + it("does not scroll after leaving the service view", async () => { + const { finish, transition } = installTransition(); + const { result, unmount } = renderHook(useServiceCardTransition); + act(() => result.current(() => {}, card)); + unmount(); + await act(async () => { + finish(); + }); + expect(transition.skipTransition).toHaveBeenCalledOnce(); + expect(scroll).not.toHaveBeenCalled(); + expect(document.documentElement).not.toHaveClass("service-card-transition"); + }); +}); diff --git a/frontend/src/hooks/use-service-card-transition.ts b/frontend/src/hooks/use-service-card-transition.ts new file mode 100644 index 000000000..c8149c9ef --- /dev/null +++ b/frontend/src/hooks/use-service-card-transition.ts @@ -0,0 +1,81 @@ +import { useCallback, useEffect, useRef } from "react"; +import { flushSync } from "react-dom"; + +export function useServiceCardTransition() { + const active = useRef(null); + useEffect( + () => () => { + active.current?.skipTransition(); + active.current = null; + document.documentElement.classList.remove("service-card-transition"); + }, + [], + ); + + return useCallback( + ( + change: () => void, + expandedCard?: HTMLElement | null, + stickyToolbar?: HTMLElement | null, + ) => { + active.current?.skipTransition(); + active.current = null; + const reducedMotion = window.matchMedia( + "(prefers-reduced-motion: reduce)", + ).matches; + const revealCard = () => { + if (expandedCard?.isConnected) { + const scroller = expandedCard.closest("main"); + if (scroller && stickyToolbar?.isConnected) { + const inset = + Number.parseFloat(getComputedStyle(stickyToolbar).top) || 0; + const padding = + Number.parseFloat(getComputedStyle(scroller).paddingTop) || 0; + scroller.scrollTo({ + top: Math.max( + 0, + scroller.scrollTop + + expandedCard.getBoundingClientRect().top - + scroller.getBoundingClientRect().top - + scroller.clientTop - + padding - + stickyToolbar.getBoundingClientRect().height - + inset - + 32, + ), + behavior: reducedMotion ? "instant" : "smooth", + }); + return; + } + expandedCard.scrollIntoView({ + behavior: reducedMotion ? "instant" : "smooth", + block: "start", + inline: "nearest", + }); + } + }; + if (!document.startViewTransition || reducedMotion) { + document.documentElement.classList.remove("service-card-transition"); + flushSync(change); + revealCard(); + return; + } + document.documentElement.classList.add("service-card-transition"); + const transition = document.startViewTransition(() => flushSync(change)); + active.current = transition; + // A superseded transition must not remove the next transition's styles. + void transition.finished + .catch(() => undefined) + .then(() => { + if (active.current === transition) { + active.current = null; + document.documentElement.classList.remove( + "service-card-transition", + ); + revealCard(); + } + }); + }, + [], + ); +} diff --git a/frontend/src/hooks/use-service-view.test.tsx b/frontend/src/hooks/use-service-view.test.tsx new file mode 100644 index 000000000..759ccf35d --- /dev/null +++ b/frontend/src/hooks/use-service-view.test.tsx @@ -0,0 +1,208 @@ +import { act, cleanup, renderHook, waitFor } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { ReactNode } from "react"; +import type { User } from "@/types/api"; +import { + DEFAULT_SERVICE_FILTERS, + type ServiceViewFilters, +} from "@/schemas/service-view"; + +const { put } = vi.hoisted(() => ({ put: vi.fn() })); +vi.mock("@/lib/api-client", () => ({ api: { put } })); +vi.mock("@/lib/telemetry", () => ({ identify: vi.fn(), reset: vi.fn() })); + +import { useAuthStore } from "@/stores/auth-store"; +import { useServiceCardView } from "@/stores/service-card-view-store"; +import { useServiceView } from "./use-service-view"; + +function user(id = "user-a", saved: ServiceViewFilters | null = null): User { + return { + id, + email: `${id}@example.com`, + display_name: null, + avatar_url: null, + email_verified: true, + mfa_enabled: false, + is_admin: false, + is_active: true, + created_at: "2026-01-01", + profile_config: { + onboarding: { ai_services_completed_at: "2026-01-01" }, + services_view: saved, + }, + }; +} +function mount() { + const client = new QueryClient({ + defaultOptions: { mutations: { retry: false }, queries: { retry: false } }, + }); + return { + client, + ...renderHook(useServiceView, { + wrapper: ({ children }: { children: ReactNode }) => ( + {children} + ), + }), + }; +} +const saved: ServiceViewFilters = { + ...DEFAULT_SERVICE_FILTERS, + source: "org", + search: "team", +}; + +beforeEach(() => { + vi.resetAllMocks(); + useAuthStore.setState({ user: user(), isAuthenticated: true }); + useServiceCardView.setState({ + accountId: undefined, + expanded: [], + filters: undefined, + }); +}); +afterEach(cleanup); + +describe("account service view preferences", () => { + it("starts personal, honors saved All services, and restores only the newest expanded card", () => { + const { result } = mount(); + expect(result.current.filters.source).toBe("personal"); + act(() => + useAuthStore.setState({ + user: user("user-a", { ...DEFAULT_SERVICE_FILTERS, source: "all" }), + }), + ); + expect(result.current.filters.source).toBe("all"); + act(() => result.current.setExpanded(["first", "latest"])); + expect(result.current.expanded).toEqual(["latest"]); + }); + + it("loads defaults, saves only on request, and restores them on a fresh visit", async () => { + useAuthStore.setState({ user: user("user-a", saved) }); + put.mockImplementation(async (_path, body) => body); + const mounted = mount(); + expect(mounted.result.current.filters).toEqual(saved); + expect(mounted.result.current.expanded).toEqual([]); + const next = { + ...saved, + state: "disabled" as const, + organization_ids: ["org-1", "org-2"], + service_group_ids: ["catalog:openai", "catalog:codex"], + }; + act(() => mounted.result.current.setFilters(next)); + expect(put).not.toHaveBeenCalled(); + act(() => mounted.result.current.saveDefault()); + await waitFor(() => expect(mounted.result.current.isDefault).toBe(true)); + expect(put).toHaveBeenCalledWith("/users/me/preferences/services", next); + expect( + mounted.client.getQueryData(["user", "me"])?.profile_config + ?.services_view, + ).toEqual(next); + expect( + useAuthStore.getState().user?.profile_config?.onboarding + .ai_services_completed_at, + ).toBe("2026-01-01"); + mounted.unmount(); + useServiceCardView.setState({ + accountId: undefined, + filters: undefined, + expanded: [], + }); + const fresh = mount(); + expect(fresh.result.current.filters).toEqual(next); + act(() => fresh.result.current.setFilters(DEFAULT_SERVICE_FILTERS)); + expect(fresh.result.current.differsFromDefault).toBe(true); + act(() => fresh.result.current.restoreDefault()); + expect(fresh.result.current.filters).toEqual(next); + }); + + it("keeps the previous default when saving fails and allows a retry", async () => { + useAuthStore.setState({ user: user("user-a", saved) }); + put + .mockRejectedValueOnce(new Error("offline")) + .mockImplementation(async (_path, body) => body); + const { result } = mount(); + act(() => result.current.setFilters(DEFAULT_SERVICE_FILTERS)); + act(() => result.current.saveDefault()); + await waitFor(() => + expect(result.current.saveError).toMatch(/Could not save/), + ); + expect(result.current.isDefault).toBe(false); + expect(useAuthStore.getState().user?.profile_config?.services_view).toEqual( + saved, + ); + act(() => result.current.saveDefault()); + await waitFor(() => expect(result.current.isDefault).toBe(true)); + expect(result.current.saveError).toBeNull(); + }); + + it("does not overwrite draft filters when profile data arrives late", () => { + const oldUser = user(); + useAuthStore.setState({ user: { ...oldUser, profile_config: undefined } }); + const { result } = mount(); + expect(result.current.canSave).toBe(false); + act(() => + result.current.setFilters({ + ...DEFAULT_SERVICE_FILTERS, + search: "my draft", + }), + ); + act(() => useAuthStore.setState({ user: user("user-a", saved) })); + expect(result.current.filters.search).toBe("my draft"); + expect(result.current.canSave).toBe(true); + }); + + it("ignores an old account's save response after switching accounts", async () => { + let resolveSave!: (value: ServiceViewFilters) => void; + put.mockImplementation( + () => + new Promise((resolve) => { + resolveSave = resolve; + }), + ); + const { result, client } = mount(); + act(() => result.current.setFilters(saved)); + act(() => result.current.setExpanded(["catalog:openai"])); + act(() => result.current.saveDefault()); + await waitFor(() => expect(put).toHaveBeenCalledOnce()); + act(() => useAuthStore.getState().setUser(user("user-b"))); + expect(result.current.filters).toEqual(DEFAULT_SERVICE_FILTERS); + expect(result.current.expanded).toEqual([]); + await act(async () => resolveSave(saved)); + expect(useAuthStore.getState().user?.id).toBe("user-b"); + expect( + useAuthStore.getState().user?.profile_config?.services_view, + ).toBeNull(); + expect(client.getQueryData(["user", "me"])).toBeUndefined(); + expect(result.current.saveError).toBeNull(); + }); + + it("keeps edits made while saving and clears drafts on sign-out", async () => { + let resolveSave!: (value: ServiceViewFilters) => void; + put.mockImplementation( + () => + new Promise((resolve) => { + resolveSave = resolve; + }), + ); + const { result } = mount(); + act(() => result.current.setFilters(saved)); + act(() => result.current.saveDefault()); + await waitFor(() => expect(put).toHaveBeenCalledOnce()); + act(() => result.current.setFilters({ ...saved, search: "new search" })); + await act(async () => resolveSave(saved)); + expect(result.current.filters.search).toBe("new search"); + expect(result.current.isDefault).toBe(false); + act(() => useAuthStore.getState().setUser(null)); + act(() => useAuthStore.getState().setUser(user("user-a", saved))); + expect(result.current.filters).toEqual(saved); + }); + + it("does not attempt account saves against older servers", () => { + useAuthStore.setState({ user: { ...user(), profile_config: undefined } }); + const { result } = mount(); + expect(result.current.canSave).toBe(false); + act(() => result.current.saveDefault()); + expect(put).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/hooks/use-service-view.ts b/frontend/src/hooks/use-service-view.ts new file mode 100644 index 000000000..0ede7ceff --- /dev/null +++ b/frontend/src/hooks/use-service-view.ts @@ -0,0 +1,98 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { api } from "@/lib/api-client"; +import { useAuthStore } from "@/stores/auth-store"; +import { useServiceCardView } from "@/stores/service-card-view-store"; +import { + DEFAULT_SERVICE_FILTERS, + sameServiceFilters, + serviceViewSchema, + type ServiceViewFilters, +} from "@/schemas/service-view"; +import type { User } from "@/types/api"; + +export function useServiceView() { + const user = useAuthStore((state) => state.user); + const queryClient = useQueryClient(); + const view = useServiceCardView(); + const parsed = serviceViewSchema.safeParse( + user?.profile_config?.services_view, + ); + const saved = parsed.success ? parsed.data : DEFAULT_SERVICE_FILTERS; + const sameAccount = view.accountId === user?.id; + const draftFilters = serviceViewSchema.safeParse( + sameAccount ? view.filters : undefined, + ); + const filters = draftFilters.success ? draftFilters.data : saved; + const expanded = sameAccount ? view.expanded.slice(-1) : []; + const canSave = user?.profile_config?.services_view !== undefined; + const hasDefault = parsed.success; + + const mutation = useMutation({ + mutationFn: async ({ + filters, + accountId, + }: { + filters: ServiceViewFilters; + accountId: string; + }) => { + await queryClient.cancelQueries({ queryKey: ["user", "me"] }); + if (useAuthStore.getState().user?.id !== accountId) + throw new Error("Account changed. Please try again."); + return serviceViewSchema.parse( + await api.put( + "/users/me/preferences/services", + serviceViewSchema.parse(filters), + ), + ); + }, + onSuccess: (savedFilters, { accountId }) => { + const current = useAuthStore.getState().user; + if (current?.id !== accountId || !current.profile_config) return; + const updated: User = { + ...current, + profile_config: { + ...current.profile_config, + services_view: savedFilters, + }, + }; + useAuthStore.getState().setUser(updated); + queryClient.setQueryData(["user", "me"], updated); + }, + }); + + const setView = (update: { + filters?: ServiceViewFilters; + expanded?: readonly string[]; + }) => { + useServiceCardView.setState({ + accountId: user?.id, + filters, + expanded, + ...update, + }); + }; + const mutationForAccount = mutation.variables?.accountId === user?.id; + + return { + accountId: user?.id, + filters, + expanded, + canSave, + hasDefault, + savedFilters: saved, + isDefault: hasDefault && sameServiceFilters(filters, saved), + differsFromDefault: !sameServiceFilters(filters, saved), + setFilters: (filters: ServiceViewFilters) => setView({ filters }), + setExpanded: (expanded: readonly string[]) => + setView({ expanded: expanded.slice(-1) }), + restoreDefault: () => setView({ filters: saved }), + saveDefault: () => { + if (user && canSave) mutation.mutate({ accountId: user.id, filters }); + }, + isSaving: mutationForAccount && mutation.isPending, + saveError: + mutationForAccount && mutation.error + ? "Could not save your default view. Please try again." + : null, + }; +} diff --git a/frontend/src/lib/connection-access.ts b/frontend/src/lib/connection-access.ts new file mode 100644 index 000000000..1bb942acc --- /dev/null +++ b/frontend/src/lib/connection-access.ts @@ -0,0 +1,11 @@ +import type { KeyInfo } from "@/types/keys"; + +export function canEditConnection(key: KeyInfo): boolean { + if (key.auto_connected || key.can_edit_configuration === false) return false; + // Older servers omit the explicit permission; require known ownership. + const source = key.credential_source; + return ( + source?.type === "personal" || + (source?.type === "org" && source.role === "admin" && source.allowed) + ); +} diff --git a/frontend/src/lib/service-groups.ts b/frontend/src/lib/service-groups.ts new file mode 100644 index 000000000..421f5d3a7 --- /dev/null +++ b/frontend/src/lib/service-groups.ts @@ -0,0 +1,43 @@ +import type { CatalogEntry, KeyInfo } from "@/types/keys"; + +export interface ServiceConnectionGroup { + readonly id: string; + readonly name: string; + readonly slug: string | null; + readonly description: string | null; + readonly connections: readonly KeyInfo[]; +} + +export function groupServiceConnections( + keys: readonly KeyInfo[], + catalog: readonly CatalogEntry[] = [], +): ServiceConnectionGroup[] { + const groups = new Map(); + for (const key of keys) { + const id = key.catalog_service_id + ? `catalog:${key.catalog_service_id}` + : `connection:${key.id}`; + const connections = groups.get(id) ?? []; + connections.push(key); + groups.set(id, connections); + } + return [...groups] + .map(([id, connections]) => { + const first = connections[0]!; + const slug = first.catalog_service_id ? first.catalog_service_slug : null; + const entry = catalog.find((item) => item.slug === slug); + return { + id, + name: first.catalog_service_id + ? (entry?.name ?? first.catalog_service_name ?? first.label) + : first.label, + slug, + description: + entry?.description ?? + (connections.length === 1 ? first.description : null) ?? + null, + connections, + }; + }) + .sort((a, b) => a.name.localeCompare(b.name)); +} diff --git a/frontend/src/lib/service-routing-preview.test.ts b/frontend/src/lib/service-routing-preview.test.ts new file mode 100644 index 000000000..d40c5d9f7 --- /dev/null +++ b/frontend/src/lib/service-routing-preview.test.ts @@ -0,0 +1,225 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import type { KeyInfo, CatalogEntry } from "@/types/keys"; +import { + buildRoutingGroups, + moveItem, + orderedIds, + readPreferences, + savePreferences, +} from "./service-routing-preview"; + +function key(overrides: Partial = {}): KeyInfo { + return { + id: "personal", + label: "My OpenAI", + slug: "openai-my-account", + endpoint_id: "endpoint", + api_key_id: "credential", + catalog_service_id: "openai-id", + catalog_service_slug: "openai", + catalog_service_name: "OpenAI", + is_active: true, + status: "active", + credential_type: "api_key", + auth_method: "bearer", + auth_key_name: "Authorization", + auto_connected: false, + node_id: null, + node_priority: 0, + expires_at: null, + last_used_at: null, + error_message: null, + created_at: "2026-01-01", + service_type: "http", + ssh_host: null, + ssh_port: null, + ssh_ca_public_key: null, + ssh_allowed_principals: null, + ssh_certificate_ttl_minutes: null, + ws_frame_injections: [], + credential_source: { type: "personal" }, + ...overrides, + }; +} + +const org = key({ + id: "org", + label: "Team OpenAI", + credential_source: { + type: "org", + org_id: "team", + org_name: "Chrono", + role: "member", + allowed: true, + }, +}); +const platform = key({ + id: "platform", + label: "Shared OpenAI", + auto_connected: true, +}); +function group(keys: KeyInfo[]) { + return buildRoutingGroups(keys, [], [], Date.parse("2026-09-17"))[0]!; +} + +beforeEach(() => localStorage.clear()); + +describe("routing from actual connections", () => { + it("retains all real records and their provenance without inventing a platform source", () => { + const result = group([org, key()]); + expect(result.slug).toBe("openai"); + expect(result.candidates.map((candidate) => candidate.key.id)).toEqual([ + "personal", + "org", + ]); + expect( + result.candidates.some((candidate) => candidate.tier === "platform"), + ).toBe(false); + }); + + it("includes a platform source only when a platform service record exists", () => { + const result = group([key(), platform]); + expect( + result.candidates.find((candidate) => candidate.tier === "platform")?.key + .id, + ).toBe("platform"); + }); + + it("recognizes explicit platform bindings from current main", () => { + const candidate = group([ + key({ + credential_binding: "platform", + auto_connected: false, + api_key_id: null, + platform_key_available: true, + }), + ]).candidates[0]!; + expect(candidate.tier).toBe("platform"); + expect(candidate.reason).toBe("Not verified"); + }); + + it("keeps a configured platform record unavailable when the backend withdraws availability", () => { + const candidate = group([ + key({ credential_binding: "platform", platform_key_available: false }), + ]).candidates[0]!; + expect(candidate.state).toBe("unavailable"); + expect(candidate.reason).toBe("Platform unavailable"); + }); + + it("does not mistake an offered platform key for this connection's selected credential", () => { + const candidate = group([ + key({ + credential_binding: "user", + auto_connected: true, + platform_key_available: true, + }), + ]).candidates[0]!; + expect(candidate.tier).toBe("personal"); + }); + + it("does not turn catalog entries or shared OAuth apps into connected services", () => { + const catalog = [ + { + slug: "openai", + name: "OpenAI", + service_type: "http", + has_platform_oauth_credentials: true, + }, + ] as CatalogEntry[]; + expect(buildRoutingGroups([], catalog, [], 0)).toEqual([]); + }); + + it("keeps unrelated custom connections separate", () => { + const result = buildRoutingGroups( + [ + key(), + key({ + id: "custom", + catalog_service_id: null, + catalog_service_slug: null, + }), + ], + [], + [], + 0, + ); + expect(result).toHaveLength(2); + expect( + result.find((item) => item.id === "connection:custom")?.canonical, + ).toBe(false); + }); + + it.each([ + { is_active: false }, + { credential_missing: true }, + { status: "revoked" }, + { status: "failed" }, + { connection_status: "expired" as const }, + { expires_at: "2026-01-01" }, + { + credential_source: { + type: "org" as const, + org_id: "team", + org_name: "Chrono", + role: "viewer" as const, + allowed: false, + }, + }, + ])( + "recognizes a known blocker without hiding its repairable card: %j", + (overrides) => { + const result = group([key(overrides)]); + expect(result.candidates).toHaveLength(1); + expect(result.candidates[0]?.state).toBe("unavailable"); + }, + ); + + it.each([ + { connected: true, status: "active" }, + { auto_connected: true }, + { node_id: "node", node_status: "online" }, + { node_id: "node", node_status: "offline" }, + { credential_source: undefined }, + { api_key_id: null }, + { auth_method: "none", api_key_id: null }, + { + credential_type: "oauth2", + expires_at: "2026-01-01", + connection_status: "active" as const, + }, + ])( + "does not claim working status from incomplete evidence: %j", + (overrides) => { + expect(group([key(overrides)]).candidates[0]?.state).toBe("unverified"); + }, + ); + + it("only reorders members of the given pool, removing stale saved IDs", () => { + const order = orderedIds(["a", "b", "new"], ["b", "foreign", "a", "b"]); + expect(order).toEqual(["b", "a", "new"]); + expect(moveItem(order, "foreign", "a")).toEqual(order); + expect(moveItem(order, "new", "b")).toEqual(["new", "b", "a"]); + }); + + it("scopes pool preferences by account and pool without inheriting old global sorts", () => { + localStorage.setItem( + "nyxid-routing-preview-v2:alice", + JSON.stringify({ + serviceOrder: ["b"], + connectionOrder: ["b"], + view: "services", + }), + ); + expect(readPreferences("alice")).toEqual({ + view: "connections", + pools: {}, + }); + savePreferences("alice", { + view: "services", + pools: { first: { priority: true, order: ["b", "a"] } }, + }); + expect(readPreferences("alice").pools.first?.order).toEqual(["b", "a"]); + expect(readPreferences("alice").pools.second).toBeUndefined(); + expect(readPreferences("bob")).toEqual({ view: "connections", pools: {} }); + }); +}); diff --git a/frontend/src/lib/service-routing-preview.ts b/frontend/src/lib/service-routing-preview.ts new file mode 100644 index 000000000..a63e70b31 --- /dev/null +++ b/frontend/src/lib/service-routing-preview.ts @@ -0,0 +1,189 @@ +import { z } from "zod"; +import type { KeyInfo, CatalogEntry } from "@/types/keys"; +import type { UserServiceResponse } from "@/schemas/keys"; +import type { CredentialSource } from "@/schemas/orgs"; + +export type RoutingTier = "personal" | "org" | "platform" | "unknown"; + +export interface RoutingCandidate { + key: KeyInfo; + tier: RoutingTier; + owner: string; + state: "unavailable" | "unverified"; + reason: string; + source?: CredentialSource; +} + +export interface RoutingGroup { + id: string; + name: string; + slug: string; + canonical: boolean; + candidates: RoutingCandidate[]; +} + +export function classifyConnection( + key: KeyInfo, + services: readonly UserServiceResponse[], + now: number, +): RoutingCandidate { + const source = + key.credential_source ?? + services.find((service) => service.id === key.id)?.credential_source; + const isPlatform = + key.credential_binding === "platform" || + (key.credential_binding === undefined && key.auto_connected); + const tier: RoutingTier = isPlatform + ? "platform" + : (source?.type ?? "unknown"); + const owner = isPlatform + ? "NyxID platform" + : source?.type === "org" + ? source.org_name + : source + ? "You" + : "Unknown owner"; + const result = ( + state: RoutingCandidate["state"], + reason: string, + ): RoutingCandidate => ({ key, tier, owner, state, reason, source }); + if (!key.is_active) return result("unavailable", "Disabled"); + if (source?.type === "org" && !source.allowed) + return result("unavailable", "No access"); + if (isPlatform && key.platform_key_available === false) + return result("unavailable", "Platform unavailable"); + if (key.credential_missing) + return result("unavailable", "Credential missing"); + if (["revoked", "failed", "refresh_failed"].includes(key.status)) + return result("unavailable", "Reconnect needed"); + if ( + key.status === "pending_auth" || + (key.requires_connection && key.connected === false) + ) + return result("unavailable", "Finish connecting"); + if (key.node_id && key.node_status !== "online") + return result("unverified", "Node check needed"); + if (key.connection_status === "expired" || key.status === "expired") + return result("unavailable", "Reconnect needed"); + if (key.expires_at && Date.parse(key.expires_at) <= now) { + return key.credential_type === "oauth2" + ? result("unverified", "Refresh needed") + : result("unavailable", "Credential expired"); + } + if (tier === "unknown") return result("unverified", "Owner not reported"); + if ( + !isPlatform && + !key.api_key_id && + !key.node_id && + key.auth_method !== "none" + ) + return result("unverified", "Credential check needed"); + // GET /keys reports configuration and credential state, not a successful + // provider check. Explicit platform bindings also need live verification. + return result("unverified", "Not verified"); +} + +export function buildRoutingGroups( + keys: readonly KeyInfo[], + catalog: readonly CatalogEntry[], + services: readonly UserServiceResponse[], + now: number, +): RoutingGroup[] { + const groups = new Map(); + for (const key of keys) { + const id = key.catalog_service_id + ? `catalog:${key.catalog_service_id}` + : `connection:${key.id}`; + let group = groups.get(id); + if (!group) { + const entry = catalog.find( + (item) => item.slug === key.catalog_service_slug, + ); + group = { + id, + name: entry?.name ?? key.catalog_service_name ?? key.label, + slug: key.catalog_service_slug ?? key.slug, + canonical: Boolean( + key.catalog_service_id && + key.catalog_service_slug && + key.service_type === "http", + ), + candidates: [], + }; + groups.set(id, group); + } + group.candidates.push(classifyConnection(key, services, now)); + } + const rank = { personal: 0, org: 1, platform: 2, unknown: 3 }; + for (const group of groups.values()) { + group.candidates.sort( + (a, b) => + rank[a.tier] - rank[b.tier] || a.key.label.localeCompare(b.key.label), + ); + } + // A catalog entry (including shared OAuth app credentials) is not a + // connection. Only records returned by /keys create groups or options. + return [...groups.values()].sort((a, b) => a.name.localeCompare(b.name)); +} + +export function moveItem( + order: readonly string[], + from: string, + to: string, +): string[] { + if (from === to || !order.includes(from) || !order.includes(to)) + return [...order]; + const next = order.filter((id) => id !== from); + next.splice(order.indexOf(to), 0, from); + return next; +} + +export function orderedIds( + current: readonly string[], + saved: readonly string[], +): string[] { + return [ + ...new Set([...saved.filter((id) => current.includes(id)), ...current]), + ]; +} + +const preferencesSchema = z.object({ + view: z.enum(["services", "connections"]), + pools: z.record( + z.string(), + z.object({ + priority: z.boolean(), + order: z.array(z.string()), + }), + ), +}); +export type PreviewPreferences = z.infer; + +const storageKey = (userId: string) => `nyxid-routing-preview-v3:${userId}`; + +export function readPreferences( + userId: string | undefined, +): PreviewPreferences { + const defaults: PreviewPreferences = { view: "connections", pools: {} }; + if (!userId) return defaults; + try { + const parsed = preferencesSchema.safeParse( + JSON.parse(localStorage.getItem(storageKey(userId)) ?? "null"), + ); + return parsed.success ? parsed.data : defaults; + } catch { + return defaults; + } +} + +export function savePreferences( + userId: string | undefined, + value: PreviewPreferences, +) { + if (!userId) return; + try { + localStorage.setItem(storageKey(userId), JSON.stringify(value)); + } catch { + // The preview remains usable when browser storage is unavailable. + } +} diff --git a/frontend/src/lib/service-view.test.ts b/frontend/src/lib/service-view.test.ts new file mode 100644 index 000000000..87bc9b719 --- /dev/null +++ b/frontend/src/lib/service-view.test.ts @@ -0,0 +1,185 @@ +import { describe, expect, it } from "vitest"; +import { matchingConnections } from "./service-view"; +import { + DEFAULT_SERVICE_FILTERS, + serviceViewSchema, + sameServiceFilters, +} from "@/schemas/service-view"; +import type { ServiceConnectionGroup } from "./service-groups"; +import type { KeyInfo } from "@/types/keys"; + +const group: ServiceConnectionGroup = { + id: "catalog:openai", + name: "OpenAI", + slug: "openai", + description: null, + connections: [ + { + id: "personal", + label: "Personal development", + slug: "openai-personal", + credential_source: { type: "personal" }, + is_active: true, + status: "expired", + service_type: "http", + auto_connected: false, + }, + { + id: "org", + label: "Team", + slug: "openai-team", + credential_source: { type: "org", org_id: "org-1", org_name: "Chrono" }, + is_active: false, + status: "active", + service_type: "http", + auto_connected: false, + }, + { + id: "platform", + label: "Platform", + slug: "openai", + credential_binding: "platform", + is_active: true, + service_type: "http", + auto_connected: true, + }, + ] as KeyInfo[], +}; + +describe("service view matching", () => { + it("shows only personal connections by default", () => { + expect( + matchingConnections(group, DEFAULT_SERVICE_FILTERS).map((key) => key.id), + ).toEqual(["personal"]); + }); + it("combines filters on the same connection and uses service state, not credential status", () => { + expect( + matchingConnections(group, { + ...DEFAULT_SERVICE_FILTERS, + source: "org", + state: "enabled", + }), + ).toEqual([]); + expect( + matchingConnections(group, { + ...DEFAULT_SERVICE_FILTERS, + source: "all", + state: "disabled", + }).map((key) => key.id), + ).toEqual(["org"]); + expect( + matchingConnections(group, { + ...DEFAULT_SERVICE_FILTERS, + source: "personal", + state: "enabled", + }).map((key) => key.id), + ).toEqual(["personal"]); + }); + it("searches both service and connection identity without modifying the group", () => { + expect( + matchingConnections(group, { + ...DEFAULT_SERVICE_FILTERS, + source: "all", + search: " Chrono ", + }).map((key) => key.id), + ).toEqual(["org"]); + expect( + matchingConnections(group, { + ...DEFAULT_SERVICE_FILTERS, + source: "all", + search: "OPENAI", + }), + ).toHaveLength(3); + expect( + matchingConnections(group, { + ...DEFAULT_SERVICE_FILTERS, + source: "all", + service_type: "ssh", + }), + ).toEqual([]); + expect( + matchingConnections(group, { + ...DEFAULT_SERVICE_FILTERS, + source: "all", + show_auto_connected: false, + }), + ).toHaveLength(2); + expect(group.connections).toHaveLength(3); + }); +}); + +describe("saved service selections", () => { + it("migrates legacy single selections and compares unordered sets", () => { + const legacy = { + search: "", + source: "all", + state: "all", + service_type: "all", + show_auto_connected: true, + }; + expect( + serviceViewSchema.parse({ + ...legacy, + organization_id: "org-1", + service_group_id: "catalog:openai", + }), + ).toEqual({ + ...DEFAULT_SERVICE_FILTERS, + source: "all", + organization_ids: ["org-1"], + service_group_ids: ["catalog:openai"], + }); + expect(serviceViewSchema.parse(legacy)).toEqual({ + ...DEFAULT_SERVICE_FILTERS, + source: "all", + }); + expect( + serviceViewSchema.parse({ + ...legacy, + organization_id: null, + service_group_id: null, + }), + ).toEqual({ ...DEFAULT_SERVICE_FILTERS, source: "all" }); + expect( + sameServiceFilters( + { + ...DEFAULT_SERVICE_FILTERS, + source: "all", + organization_ids: ["one", "two"], + }, + { + ...DEFAULT_SERVICE_FILTERS, + source: "all", + organization_ids: ["two", "one"], + }, + ), + ).toBe(true); + expect( + serviceViewSchema.safeParse({ + ...DEFAULT_SERVICE_FILTERS, + source: "all", + organization_ids: Array(101).fill("org"), + }).success, + ).toBe(false); + }); + it("matches any selected organization and service, requiring both filter groups", () => { + const filters = { + ...DEFAULT_SERVICE_FILTERS, + source: "all" as const, + organization_ids: ["org-1", "org-2"], + service_group_ids: ["catalog:openai", "catalog:codex"], + }; + expect(matchingConnections(group, filters).map((key) => key.id)).toEqual([ + "org", + ]); + expect( + matchingConnections(group, { + ...filters, + service_group_ids: ["catalog:codex"], + }), + ).toEqual([]); + expect( + matchingConnections(group, { ...filters, organization_ids: ["org-2"] }), + ).toEqual([]); + }); +}); diff --git a/frontend/src/lib/service-view.ts b/frontend/src/lib/service-view.ts new file mode 100644 index 000000000..6c1485832 --- /dev/null +++ b/frontend/src/lib/service-view.ts @@ -0,0 +1,54 @@ +import type { KeyInfo } from "@/types/keys"; +import type { ServiceViewFilters } from "@/schemas/service-view"; +import type { ServiceConnectionGroup } from "@/lib/service-groups"; + +export function connectionSource( + key: KeyInfo, +): "personal" | "org" | "platform" { + if ( + key.credential_binding === "platform" || + (key.credential_binding === undefined && key.auto_connected) + ) + return "platform"; + return key.credential_source?.type === "org" ? "org" : "personal"; +} + +export function connectionSourceLabel(key: KeyInfo): string { + if (connectionSource(key) === "platform") { + return key.auth_method === "none" ? "Platform managed" : "NyxID platform"; + } + return key.credential_source?.type === "org" + ? key.credential_source.org_name + : "Personal"; +} + +export function matchingConnections( + group: ServiceConnectionGroup, + filters: ServiceViewFilters, +): readonly KeyInfo[] { + if ( + filters.service_group_ids.length && + !filters.service_group_ids.includes(group.id) + ) + return []; + const needle = filters.search.trim().toLowerCase(); + const groupMatches = [group.name, group.slug ?? ""].some((value) => + value.toLowerCase().includes(needle), + ); + return group.connections.filter( + (key) => + (!filters.organization_ids.length || + (key.credential_source?.type === "org" && + filters.organization_ids.includes(key.credential_source.org_id))) && + (filters.source === "all" || connectionSource(key) === filters.source) && + (filters.state === "all" || + (filters.state === "enabled" ? key.is_active : !key.is_active)) && + (filters.service_type === "all" || + key.service_type === filters.service_type) && + (filters.show_auto_connected || !key.auto_connected) && + (groupMatches || + [key.label, key.slug, connectionSourceLabel(key)].some((value) => + value.toLowerCase().includes(needle), + )), + ); +} diff --git a/frontend/src/pages/key-detail.test.tsx b/frontend/src/pages/key-detail.test.tsx index 488a00ae6..7732ec4f2 100644 --- a/frontend/src/pages/key-detail.test.tsx +++ b/frontend/src/pages/key-detail.test.tsx @@ -454,7 +454,7 @@ describe("KeyDetailPage — core rendering", () => { expect( screen.queryByRole("button", { name: /reconnect/i }), ).not.toBeInTheDocument(); - expect(screen.getByText("Shared from Acme Org")).toBeInTheDocument(); + expect(screen.getAllByText("Acme Org").length).toBeGreaterThan(0); }); }); @@ -824,7 +824,7 @@ describe("KeyDetailPage — org read-only branch", () => { render(); - expect(screen.getByText("Shared from Acme Org")).toBeInTheDocument(); + expect(screen.getAllByText("Acme Org").length).toBeGreaterThan(0); // Read-only: no Delete or Deactivate buttons, routing widget read-only. expect( screen.queryByRole("button", { name: /^Delete$/i }), @@ -832,13 +832,12 @@ describe("KeyDetailPage — org read-only branch", () => { expect( screen.queryByRole("button", { name: /Disable/i }), ).not.toBeInTheDocument(); - await user.click(screen.getByRole("tab", { name: "Advanced" })); - expect(screen.getByTestId("routing-section")).toHaveAttribute( - "data-readonly", - "true", - ); - // Label heading still shown but with no inline edit pencil. - const heading = screen.getByText("My OpenAI"); + expect(screen.queryByRole("tab", { name: "Advanced" })).not.toBeInTheDocument(); + await user.click(screen.getByRole("button", { name: "Details for My OpenAI (Acme Org)" })); + expect(document.body.innerHTML).not.toContain("https://api.openai.com"); + await user.click(screen.getByRole("button", { name: "History for My OpenAI (Acme Org)" })); + expect(screen.getByTestId("history")).toHaveTextContent("key-1"); + const heading = screen.getByRole("heading", { name: "My OpenAI" }); expect(heading.parentElement?.querySelector("button")).toBeNull(); expect(screen.queryByRole("button", { name: "Change service icon" })).not.toBeInTheDocument(); }); diff --git a/frontend/src/pages/key-detail.tsx b/frontend/src/pages/key-detail.tsx index 4265f7254..8ba001ee5 100644 --- a/frontend/src/pages/key-detail.tsx +++ b/frontend/src/pages/key-detail.tsx @@ -1,3 +1,5 @@ +import { canEditConnection } from "@/lib/connection-access"; +import { ServiceConnectionTable } from "@/components/dashboard/service-connection-table"; import { OwnershipTransferCard } from "@/components/shared/ownership-transfer-card"; import { useOwnershipTransferAuthorization } from "@/hooks/use-ownership-transfers"; import { ServiceHistory, ServiceAuthorshipFooter } from "@/components/dashboard/service-history"; @@ -2393,7 +2395,7 @@ function KeyDetailView({ keyId }: { readonly keyId: string }) { // a confusing toast error after every attempt. const source = keyInfo.credential_source; const isOrgSource = source?.type === "org"; - const readOnly = isOrgSource && source.role !== "admin"; + const readOnly = !canEditConnection(keyInfo); const canReconnect = !readOnly && !keyInfo.auto_connected && @@ -2442,6 +2444,49 @@ function KeyDetailView({ keyId }: { readonly keyId: string }) { } : null; + if (readOnly && !keyInfo.auto_connected) { + return ( +
+ + All service connections + + + + + Overview + History + + +
+ setPlatformTab("history")} + /> +
+ {!isSsh && (!isOrgSource || source.allowed) && ( + + )} +
+ +
+
+ ); + } + return (
{error && ( @@ -2450,6 +2495,15 @@ function KeyDetailView({ keyId }: { readonly keyId: string }) { onRetry={refetch} /> )} + {import.meta.env.DEV && import.meta.env.VITE_ROUTING_PREVIEW === "1" && ( +
+

Local preview · full details from production. Account changes and execution are disabled.

+ Back to services +
+ )} + + ← All {keyInfo.catalog_service_name ?? keyInfo.label} service details +
@@ -2574,7 +2628,7 @@ function KeyDetailView({ keyId }: { readonly keyId: string }) { )}
- {keyInfo.catalog_service_id && } + {!readOnly && keyInfo.catalog_service_id && } {(keyInfo.auto_connected || keyInfo.credential_binding === "platform") ? ( @@ -2586,7 +2640,7 @@ function KeyDetailView({ keyId }: { readonly keyId: string }) { Overview {catalogTransferResource && Advanced} - {keyInfo.authorship && History} + History @@ -2692,7 +2746,7 @@ function KeyDetailView({ keyId }: { readonly keyId: string }) { users can see why those headers reach the downstream. Keep this behind disclosure so verification stays before inherited HTTP mechanics. */} - {keyInfo.auto_connected && !isSsh && catalogHeaders && catalogHeaders.length > 0 && ( + {!readOnly && keyInfo.auto_connected && !isSsh && catalogHeaders && catalogHeaders.length > 0 && (
Advanced: inherited request headers @@ -2713,14 +2767,14 @@ function KeyDetailView({ keyId }: { readonly keyId: string }) { )} - {keyInfo.authorship && } + ) : ( Overview Advanced - {keyInfo.authorship && History} + History @@ -2876,7 +2930,7 @@ function KeyDetailView({ keyId }: { readonly keyId: string }) { )} - {keyInfo.authorship && } + )} diff --git a/frontend/src/pages/keys.test.tsx b/frontend/src/pages/keys.test.tsx index d2d494e4f..0c6356652 100644 --- a/frontend/src/pages/keys.test.tsx +++ b/frontend/src/pages/keys.test.tsx @@ -1,14 +1,20 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import type { ReactNode } from "react"; -import { render, screen, waitFor } from "@testing-library/react"; +import { render as renderDom, screen, waitFor, fireEvent, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { beforeEach, describe, expect, it, vi } from "vitest"; import type { KeyInfo } from "@/types/keys"; +function render(ui: ReactNode) { + const client = new QueryClient({ defaultOptions: { mutations: { retry: false }, queries: { retry: false } } }); + return renderDom(ui, { wrapper: ({ children }) => {children} }); +} + const { mockNavigate, state } = vi.hoisted(() => ({ mockNavigate: vi.fn(), // Mutable containers populated per-test before render. state: { - search: {} as { tab?: string; slug?: string; action?: string }, + search: {} as { tab?: string; slug?: string; action?: string; view?: string }, keys: [] as KeyInfo[], keysLoading: false, keysError: null as unknown, @@ -22,12 +28,14 @@ vi.mock("@tanstack/react-router", () => ({ children, to, params, + ...props }: { readonly children: ReactNode; readonly to: string; readonly params?: Record; + readonly "aria-label"?: string; }) => ( - + {children} ), @@ -36,6 +44,7 @@ vi.mock("@tanstack/react-router", () => ({ })); vi.mock("@/hooks/use-keys", () => ({ + useCatalog: () => ({ data: [], refetch: vi.fn() }), useKeys: () => ({ data: state.keys, isLoading: state.keysLoading, @@ -52,6 +61,14 @@ vi.mock("@/hooks/use-nodes", () => ({ useNodes: () => ({ data: state.nodes }), })); +vi.mock("@/hooks/use-pools", () => ({ + useServicePools: () => ({ data: [{ + id: "pool-one", name: "My pool", slug: "my-pool", strategy: "round_robin", + members: [{ user_service_id: "key-1", enabled: true, weight: 1 }], + is_active: true, + }] }), +})); + // Heavy children — stubbed to assert wiring (open state, presence), not driven. vi.mock("@/components/providers/codex-connection", () => ({ CodexConnectionSection: () =>
, @@ -109,10 +126,16 @@ vi.mock("@/components/orgs/org-avatar", () => ({ })); import { KeysPage } from "./keys"; +import { useServiceCardView } from "@/stores/service-card-view-store"; + +function expandConnections() { + for (const button of screen.queryAllByRole("button", { name: /^Expand .+ connections$/ })) fireEvent.click(button); +} function makeKey(overrides: Partial = {}): KeyInfo { return { id: "key-1", + credential_source: { type: "personal" }, label: "My OpenAI", slug: "openai", endpoint_url: "https://api.openai.com", @@ -146,6 +169,7 @@ function makeKey(overrides: Partial = {}): KeyInfo { describe("KeysPage", () => { beforeEach(() => { vi.clearAllMocks(); + useServiceCardView.setState({ accountId: undefined, expanded: [], filters: undefined }); state.search = {}; state.keys = []; state.keysLoading = false; @@ -154,21 +178,45 @@ describe("KeysPage", () => { state.nodes = []; }); - it("defaults to the External Services tab and lists personal services as a flat grid", () => { + it("defaults to one collapsed card per service with duplicates inside", async () => { state.keys = [ - makeKey({ id: "key-1", label: "My OpenAI", slug: "openai" }), - makeKey({ id: "key-2", label: "My GitHub", slug: "github" }), + makeKey({ id: "key-1", label: "Personal OpenAI", slug: "openai" }), + makeKey({ id: "key-2", label: "Work OpenAI", slug: "openai-work" }), ]; + render(); + const group = screen.getByRole("region", { name: "OpenAI" }); + expect(within(group).getByText("2 connections")).toBeVisible(); + expect(screen.queryByText("openai")).not.toBeInTheDocument(); + expect(screen.queryByTestId("api-key-table")).not.toBeInTheDocument(); + expandConnections(); + expect(within(group).getByText("openai")).toBeVisible(); + expect(within(group).getByText("openai-work")).toBeVisible(); + expect(within(group).getByRole("link", { name: "View Personal OpenAI connection details (Personal)" })).toHaveAttribute("href", "/keys/$keyId:key-1"); + expect(within(group).getByRole("link", { name: "View Work OpenAI connection details (Personal)" })).toHaveAttribute("href", "/keys/$keyId:key-2"); + }); + it("uses the connection table and full detail navigation in the routing view", async () => { + state.search = { view: "routing" }; + state.keys = [makeKey({ label: "My preserved connection", slug: "my-openai", credential_source: { type: "personal" } })]; render(); + await screen.findByRole("button", { name: "Expand OpenAI connections" }); + expandConnections(); + expect(screen.getByText("https://api.openai.com")).toBeVisible(); + expect(screen.getByText("my-openai")).toBeVisible(); + expect(screen.queryByText("Details", { selector: "summary" })).not.toBeInTheDocument(); + expect(screen.getByRole("link", { name: "Configure My preserved connection (Personal)" })).toHaveAttribute("href", "/keys/$keyId:key-1"); + expect(screen.getByRole("link", { name: "View all OpenAI service details" })).toHaveAttribute("href", "/keys/services/$groupId:catalog:cat-1"); + expect(screen.queryByRole("button", { name: "Individual cards" })).not.toBeInTheDocument(); + }); - // The personal-only path renders cards directly with no section header. - expect(screen.getByText("My OpenAI")).toBeInTheDocument(); - expect(screen.getByText("My GitHub")).toBeInTheDocument(); - // Default tab is "services", so the Agent Keys table is not mounted. - expect(screen.queryByTestId("api-key-table")).not.toBeInTheDocument(); - // Proxy slug for an HTTP service is rendered as /proxy/s/{slug}. - expect(screen.getByText("/proxy/s/openai")).toBeInTheDocument(); + it("opens actual pool members with the original cards in the pool preview tab", async () => { + state.search = { view: "routing", tab: "pools" }; + state.keys = [makeKey({ credential_source: { type: "personal" } })]; + render(); + expect(await screen.findByText("My pool")).toBeInTheDocument(); + expect(screen.getByText("https://api.openai.com")).toBeInTheDocument(); + expect(screen.getByRole("combobox", { name: "Selection for My pool" })).toHaveValue("current"); + expect(screen.queryByRole("button", { name: "Create Pool" })).not.toBeInTheDocument(); }); it("omits oauth2 and api_key credential pills from service cards", () => { @@ -194,11 +242,12 @@ describe("KeysPage", () => { ]; render(); + expandConnections(); expect(screen.queryByText("oauth2")).not.toBeInTheDocument(); expect(screen.queryByText("api_key")).not.toBeInTheDocument(); - expect(screen.getByText("bearer")).toBeInTheDocument(); - expect(screen.getAllByText("Direct")).toHaveLength(3); + expect(screen.queryByText("bearer")).not.toBeInTheDocument(); + expect(screen.queryByText("Direct")).not.toBeInTheDocument(); }); it("shows the empty state with an Add Your First Service CTA when there are no services", async () => { @@ -241,48 +290,32 @@ describe("KeysPage", () => { expect(screen.getByRole("button", { name: /retry/i })).toBeInTheDocument(); }); - it("hides auto-connected services until the toggle is enabled", async () => { + it("filters auto-connected services and their rows inside a matching card", async () => { const user = userEvent.setup(); state.keys = [ - makeKey({ - id: "user-1", - label: "Manual Key", - endpoint_url: "https://manual.example/v1", - auto_connected: false, - }), - makeKey({ - id: "auto-1", - label: "Auto Key", - endpoint_url: "https://platform.internal.example/v1", - auto_connected: true, - source_app_name: "Claude Code", - }), + makeKey({ id: "manual", label: "Manual Key", endpoint_url: "https://manual.example/v1" }), + makeKey({ id: "auto", label: "Auto Key", auto_connected: true, endpoint_url: "https://platform.internal/v1" }), + makeKey({ id: "auto-only", catalog_service_id: "auto-only", catalog_service_name: "Platform Only", auto_connected: true }), ]; - render(); - - // Auto-connected hidden by default. - expect(screen.getByText("Manual Key")).toBeInTheDocument(); - expect(screen.getByText("https://manual.example/v1")).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "Service view: Personal" })); + expect(screen.getByRole("region", { name: "Platform Only" })).toBeInTheDocument(); + await user.click(screen.getByRole("button", { name: "Filters" })); + await user.click(screen.getByRole("button", { name: "Auto-connected" })); + await user.click(screen.getByRole("button", { name: "Hidden" })); + await user.click(screen.getByRole("button", { name: "Apply filters" })); + expect(screen.queryByRole("region", { name: "Platform Only" })).not.toBeInTheDocument(); + expect(screen.getByText("1 of 2 match")).toBeVisible(); + expandConnections(); + expect(screen.getByText("Manual Key")).toBeVisible(); expect(screen.queryByText("Auto Key")).not.toBeInTheDocument(); - expect( - screen.queryByText("https://platform.internal.example/v1"), - ).not.toBeInTheDocument(); - // The toggle label reflects the auto-connected count. - expect(screen.getByText("Show auto-connected (1)")).toBeInTheDocument(); - - await user.click(screen.getByRole("switch")); - - expect(screen.getByText("Auto Key")).toBeInTheDocument(); - expect( - screen.queryByText("https://platform.internal.example/v1"), - ).not.toBeInTheDocument(); - expect(screen.getAllByText("Platform managed").length).toBeGreaterThan(0); + expect(screen.queryByText("https://platform.internal/v1")).not.toBeInTheDocument(); + await user.click(screen.getByRole("button", { name: "Clear filters" })); + expect(screen.getByRole("region", { name: "Platform Only" })).toBeInTheDocument(); }); it("hides auto-connected endpoint URLs in table rows without changing normal rows", async () => { localStorage.setItem("nyxid-view-mode:keys-services", "table"); - const user = userEvent.setup(); state.keys = [ makeKey({ id: "manual-table", @@ -299,7 +332,7 @@ describe("KeysPage", () => { try { render(); - await user.click(screen.getByRole("switch")); + fireEvent.click(screen.getByRole("button", { name: "Service view: Personal" })); expect(screen.getByText("https://manual-table.example/v1")).toBeInTheDocument(); expect(screen.queryByText("https://platform-table.internal/v1")).not.toBeInTheDocument(); @@ -309,7 +342,7 @@ describe("KeysPage", () => { } }); - it("groups org-inherited services into a labelled section with a role badge", () => { + it("keeps organization identity and role in the connection table", () => { state.keys = [ makeKey({ id: "org-key", @@ -326,16 +359,12 @@ describe("KeysPage", () => { ]; render(); + fireEvent.click(screen.getByRole("button", { name: "Service view: Personal" })); + expandConnections(); + + expect(screen.getAllByText("Acme Org").length).toBeGreaterThan(0); + expect(screen.getByText("Organization · member")).toBeVisible(); - // Org section header (h3) + role badge come from the org credential source. - // ("Acme Org" also appears inside the stubbed OrgAvatar, so scope to the heading.) - expect( - screen.getByRole("heading", { name: "Acme Org" }), - ).toBeInTheDocument(); - expect(screen.getByTestId("role-badge")).toHaveTextContent("member"); - expect(screen.getByText("Shared from organization")).toBeInTheDocument(); - // Member (non-admin) org cards are flagged View-Only. - expect(screen.getByText("View-Only")).toBeInTheDocument(); }); it("opens the Add Key dialog when the toolbar Connect Service button is clicked", async () => { @@ -365,6 +394,7 @@ describe("KeysPage", () => { ]; render(); + expandConnections(); await user.click(screen.getByRole("button", { name: /reconnect/i })); @@ -394,8 +424,9 @@ describe("KeysPage", () => { ]; render(); + expandConnections(); - expect(screen.getByText("Credential Missing")).toBeInTheDocument(); + expect(screen.getByText("Disabled")).toBeInTheDocument(); await user.click(screen.getByRole("button", { name: /reconnect/i })); expect(screen.getByTestId("add-key-dialog")).toHaveAttribute( "data-reconnect", @@ -416,8 +447,9 @@ describe("KeysPage", () => { ]; render(); + expandConnections(); - expect(screen.getByText("Expired")).toBeInTheDocument(); + expect(screen.getByText("Reconnect needed")).toBeInTheDocument(); expect(screen.getByRole("button", { name: /reconnect/i })).toBeInTheDocument(); }); @@ -434,6 +466,7 @@ describe("KeysPage", () => { ]; render(); + expandConnections(); await user.click( screen.getByRole("button", { name: /continue authentication/i }), @@ -465,11 +498,25 @@ describe("KeysPage", () => { ]; render(); + fireEvent.click(screen.getByRole("button", { name: "Service view: Personal" })); + expandConnections(); expect( screen.queryByRole("button", { name: /reconnect/i }), ).not.toBeInTheDocument(); - expect(screen.getByText("View-Only")).toBeInTheDocument(); + expect(screen.getByText("Organization · member")).toBeVisible(); + }); + + it("does not offer reconnect when organization access is denied despite an admin role", () => { + state.keys = [makeKey({ + credential_type: "oauth2", auth_method: "oauth2", status: "failed", + credential_source: { type: "org", org_id: "denied-org", org_name: "Restricted", role: "admin", allowed: false, avatar_url: null }, + })]; + render(); + fireEvent.click(screen.getByRole("button", { name: "Service view: Personal" })); + expandConnections(); + expect(screen.getByText("No access")).toBeVisible(); + expect(screen.queryByRole("button", { name: /Reconnect/ })).not.toBeInTheDocument(); }); it("switches to the Agent Keys tab and mounts the API key table + usage dashboard", async () => { @@ -522,7 +569,7 @@ describe("KeysPage", () => { }); }); - it("renders services as a table and navigates to the key detail when a row is clicked", async () => { + it("shows connection metadata within table rows with explicit configuration navigation", async () => { // useViewMode reads localStorage to default the services tab into table mode. localStorage.setItem("nyxid-view-mode:keys-services", "table"); const user = userEvent.setup(); @@ -533,18 +580,16 @@ describe("KeysPage", () => { // Table view renders column headers instead of cards. expect( - screen.getByRole("columnheader", { name: "Endpoint" }), + screen.getByRole("columnheader", { name: "Configuration" }), ).toBeInTheDocument(); expect( - screen.getByRole("columnheader", { name: "Proxy Slug" }), + screen.getByRole("columnheader", { name: "Connection / Slug" }), ).toBeInTheDocument(); - await user.click(screen.getByText("My OpenAI")); + await user.click(screen.getByRole("button", { name: "Details for My OpenAI (Personal)" })); - expect(mockNavigate).toHaveBeenCalledWith({ - to: "/keys/$keyId", - params: { keyId: "key-1" }, - }); + expect(screen.getByRole("button", { name: "Details for My OpenAI (Personal)" })).toHaveAttribute("aria-expanded", "true"); + expect(screen.getByRole("link", { name: "Configure My OpenAI (Personal)" })).toHaveAttribute("href", "/keys/$keyId:key-1"); } finally { localStorage.removeItem("nyxid-view-mode:keys-services"); } diff --git a/frontend/src/pages/keys.tsx b/frontend/src/pages/keys.tsx index 3e9acebac..1c934ebc3 100644 --- a/frontend/src/pages/keys.tsx +++ b/frontend/src/pages/keys.tsx @@ -1,7 +1,10 @@ +import { ServiceConnectionTable } from "@/components/dashboard/service-connection-table"; +import { canEditConnection } from "@/lib/connection-access"; import { ServiceAuthorshipFooter, ArchivedServiceHistory } from "@/components/dashboard/service-history"; -import { useEffect, useMemo, useRef, useState } from "react"; +import { lazy, Suspense, useEffect, useMemo, useRef, useState } from "react"; import { Link, useSearch, useNavigate } from "@tanstack/react-router"; -import { useKeys } from "@/hooks/use-keys"; +import { useKeys, useCatalog } from "@/hooks/use-keys"; +import { GroupedServiceCards } from "@/components/dashboard/grouped-service-cards"; import { useUserServices } from "@/hooks/use-user-services"; import { PageHeader } from "@/components/shared/page-header"; import { CodexConnectionSection } from "@/components/providers/codex-connection"; @@ -13,14 +16,6 @@ import { Button, ButtonIcon } from "@/components/ui/button"; import { ErrorBanner } from "@/components/shared/error-banner"; import { Card, CardContent } from "@/components/ui/card"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import { - Table, - TableHeader, - TableBody, - TableRow, - TableHead, - TableCell, -} from "@/components/ui/table"; import { Globe, KeySquare, @@ -30,8 +25,6 @@ import { Shield, } from "lucide-react"; import { MagicKeyIcon } from "@/components/icons/empty-state"; -import { Switch } from "@/components/ui/switch"; -import { Label } from "@/components/ui/label"; import { useNodes } from "@/hooks/use-nodes"; import { ViewToggle, useViewMode, type ViewMode } from "@/components/shared/view-toggle"; import { ServiceIcon } from "@/components/service-icon"; @@ -41,7 +34,6 @@ import { ApiKeyCreateDialog } from "@/components/dashboard/api-key-create-dialog import { ApiKeyUsageDashboard } from "@/components/dashboard/api-key-usage-dashboard"; import { ServicePoolsTab } from "@/components/dashboard/service-pools-tab"; import { RoleBadge } from "@/components/orgs/role-badge"; -import { OrgAvatar } from "@/components/orgs/org-avatar"; import type { KeyInfo } from "@/types/keys"; import type { CredentialSource } from "@/schemas/orgs"; import { @@ -79,7 +71,7 @@ function statusVariant( interface KeyCardProps { readonly keyInfo: KeyInfo; - /** Credential provenance; undefined is treated as personal. */ + /** Credential provenance; missing ownership hides configuration. */ readonly source: CredentialSource | undefined; } @@ -98,7 +90,11 @@ function isReconnectableKey( keyInfo: KeyInfo, source: CredentialSource | undefined, ): boolean { - if (keyInfo.auto_connected || isNonAdminOrgSource(source)) return false; + if ( + keyInfo.auto_connected || + isNonAdminOrgSource(source) || + (source?.type === "org" && !source.allowed) + ) return false; const effectiveStatus = keyInfo.connection_status ?? keyInfo.status; if (!keyInfo.credential_missing && !RECONNECTABLE_STATUSES.has(effectiveStatus)) { return false; @@ -116,6 +112,19 @@ function reconnectLabel(status: string): string { : "Reconnect"; } +function ConnectionReconnect({ connection, onReconnect }: { + readonly connection: KeyInfo; + readonly onReconnect?: (key: KeyInfo) => void; +}) { + if (!onReconnect || !isReconnectableKey(connection, connection.credential_source)) return null; + return ( + + ); +} + function KeyCardContent({ keyInfo, source, @@ -134,8 +143,8 @@ function KeyCardContent({ keyInfo.node_id.slice(0, 8)) : null; const endpointUrl = keyInfo.endpoint_url ?? ""; - const displayUrl = keyInfo.auto_connected - ? "Platform managed" + const displayUrl = !canEditConnection({ ...keyInfo, credential_source: source }) + ? (keyInfo.auto_connected ? "Platform managed" : "Editors only") : isSsh ? `${keyInfo.ssh_host ?? "unknown"}:${keyInfo.ssh_port ?? 22}` : endpointUrl.length > 50 @@ -146,8 +155,6 @@ function KeyCardContent({ // Viewers and out-of-scope members see the card with reduced opacity. const isBlocked = source?.type === "org" && !source.allowed; // Members can USE the credential (allowed=true) but cannot MODIFY it. - // Non-admin org cards are non-clickable on the listing (see KeyCard) - // and flagged as read-only so the user knows why. const isReadOnly = source?.type === "org" && source.allowed && source.role !== "admin"; @@ -196,7 +203,10 @@ function KeyCardContent({
{isOrgInherited && ( - Org + {source.org_name} + )} + {isOrgInherited && ( + )} {isBlocked && ( Read-Only @@ -207,8 +217,8 @@ function KeyCardContent({ {keyInfo.admin_only && ( Admin-only )} - - {displayStatusLabel} + + {keyInfo.is_active ? displayStatusLabel : "Disabled"} {keyInfo.credential_missing && ( Credential Missing @@ -253,7 +263,7 @@ function KeyCardContent({ : "Auto-connected"} )} - {!keyInfo.is_active && Disabled} +
{showReconnect && ( @@ -305,17 +315,7 @@ function KeyCard({ }: KeyCardProps & { readonly onReconnect?: (keyInfo: KeyInfo) => void; }) { - // Navigation gating: - // - // - Personal credentials and admin-role org credentials: fully clickable - // and the detail page renders all mutation controls. - // - Member / viewer org credentials: clickable, but the detail page - // renders in read-only mode (see KeyDetailPage's `readOnly` branch). - // Members can still see endpoint / auth metadata and a usage snippet - // for credentials they're entitled to proxy through. - // - Out-of-scope org items (source.allowed === false due to scope, not - // role) don't even appear in the listing because - // `list_user_services_with_sources` drops them. + // Connection metadata and history stay navigable; the detail page gates configuration. return ( void; -}) { - const navigate = useNavigate(); - const isSsh = keyInfo.service_type === "ssh"; - const hasSshCertificateAuth = isSsh && keyInfo.ssh_ca_public_key !== null; - const { data: nodes } = useNodes(); - const nodeName = keyInfo.node_id - ? (nodes?.find((n) => n.id === keyInfo.node_id)?.name ?? - keyInfo.node_id.slice(0, 8)) - : null; - - const isOrgInherited = source?.type === "org"; - const isBlocked = source?.type === "org" && !source.allowed; - const isReadOnly = - source?.type === "org" && source.allowed && source.role !== "admin"; - - const displayStatus = keyInfo.connection_status === "expired" - ? "expired" - : keyInfo.node_id && keyInfo.node_status - ? (keyInfo.node_status === "unknown" ? "node_deleted" : keyInfo.node_status) - : keyInfo.status; - - const displayStatusLabel = - displayStatus === "node_deleted" - ? "Node Deleted" - : displayStatus.charAt(0).toUpperCase() + displayStatus.slice(1); - - const displayUrl = keyInfo.auto_connected - ? "Platform managed" - : isSsh - ? `${keyInfo.ssh_host ?? "unknown"}:${keyInfo.ssh_port ?? 22}` - : (keyInfo.endpoint_url ?? ""); - - const authLabel = keyInfo.auto_connected - ? keyInfo.auth_method === "none" - ? "No auth" - : "Platform managed" - : isSsh - ? hasSshCertificateAuth - ? "certificate" - : "ssh tunnel" - : keyInfo.credential_type; - const showReconnect = onReconnect && isReconnectableKey(keyInfo, source); - - return ( - void navigate({ to: "/keys/$keyId", params: { keyId: keyInfo.id } })} - > - -
- -
-

{keyInfo.label}

-

- {keyInfo.catalog_service_name ?? " "} -

-
-
-
- - - - {displayUrl} - - - - {authLabel} - - - - {isSsh ? keyInfo.slug : `/proxy/s/${keyInfo.slug}`} - - - - - {nodeName && keyInfo.node_id ? ( - e.stopPropagation()} - className="text-foreground hover:underline" - > - → {nodeName} - - ) : ( - "Direct" - )} - - - -
-
- {isOrgInherited && Org} - {isBlocked && Read-Only} - {isReadOnly && !isBlocked && View-Only} - {keyInfo.admin_only && Admin-only} - {/* Disabled services are listed so they can be re-enabled, so the - table has to say so — the credential status badge beside this - one reports the credential, which stays healthy while paused. */} - {!keyInfo.is_active && Disabled} - - {displayStatusLabel} - - {keyInfo.credential_missing && ( - Credential Missing - )} - {isSsh && SSH} -
- {showReconnect && ( - - )} -
-
- -
- ); -} - -function ServiceTableView({ - groups, - onReconnect, -}: { - readonly groups: readonly ServiceGroup[]; - readonly onReconnect: (keyInfo: KeyInfo) => void; -}) { - return ( -
- {groups.map((group) => ( -
-
-
- {group.icon === "org" ? ( - - ) : ( - - )} -

- {group.title} -

-
- {group.role && } - {group.subtitle && ( - - {group.subtitle} - - )} -
-
- - - - Name - Endpoint - Auth - Proxy Slug - Routing - Status - Authorship - - - - {group.keys.map(({ keyInfo, source }) => ( - - ))} - -
-
-
- ))} -
- ); -} - -interface ServiceGroup { - readonly key: string; - readonly title: string; - readonly subtitle: string | null; - readonly role: "owner" | "admin" | "member" | "viewer" | null; - readonly icon: "personal" | "org"; - /** - * Org avatar URL when `icon === "org"`. Surfaced via `credential_source` - * on the API response so we can render the same avatar as the - * Organizations page (#545). `null` when the org has no avatar configured - * — falls back to initials / building icon inside `OrgAvatar`. - */ - readonly avatarUrl: string | null; - readonly keys: readonly { - readonly keyInfo: KeyInfo; - readonly source: CredentialSource; - }[]; -} - -/** - * Group visible keys by credential source. Personal items first, then one - * section per org (ordered by first-seen in the incoming list). - * - * Keys without an explicit `credential_source` default to `personal` so the - * UI keeps working against older backends that have not yet been augmented. - */ -function groupKeysBySource( - keys: readonly KeyInfo[], - sourceById: ReadonlyMap, -): readonly ServiceGroup[] { - const personal: ServiceGroup = { - key: "personal", - title: "My Services", - subtitle: null, - role: null, - icon: "personal", - avatarUrl: null, - keys: [], - }; - - const orgGroups = new Map(); - const personalMut: { keyInfo: KeyInfo; source: CredentialSource }[] = []; - - for (const keyInfo of keys) { - const source: CredentialSource = keyInfo.credential_source ?? - sourceById.get(keyInfo.id) ?? { type: "personal" }; - - if (source.type === "personal") { - personalMut.push({ keyInfo, source }); - continue; - } - - const existing = orgGroups.get(source.org_id); - if (existing) { - orgGroups.set(source.org_id, { - ...existing, - // Prefer the first non-null avatar we see for this org. The backend - // returns the same avatar on every row, but when `/keys` loads - // before /user-services has finished hydrating the source map, the - // earliest entry may lack it — keep whichever value we've already - // captured. - avatarUrl: existing.avatarUrl ?? source.avatar_url ?? null, - keys: [...existing.keys, { keyInfo, source }], - }); - } else { - orgGroups.set(source.org_id, { - key: `org-${source.org_id}`, - title: source.org_name, - subtitle: "Shared from organization", - role: source.role, - icon: "org", - avatarUrl: source.avatar_url ?? null, - keys: [{ keyInfo, source }], - }); - } - } - - const groups: ServiceGroup[] = []; - if (personalMut.length > 0) { - groups.push({ ...personal, keys: personalMut }); - } - for (const g of orgGroups.values()) { - groups.push(g); - } - return groups; -} - function ServicesEmptyState({ onAdd }: { readonly onAdd: () => void }) { return ( void; readonly onReconnect: (keyInfo: KeyInfo) => void; - readonly showAutoConnected: boolean; readonly viewMode: ViewMode; }) { const { data: keys, isLoading, error, refetch } = useKeys(); @@ -653,6 +363,7 @@ function ExternalServicesTab({ // future change, the `credential_source` field on KeyInfo will take // precedence and this call becomes a no-op. const { data: userServices } = useUserServices(); + const { data: catalog } = useCatalog(); const sourceById = useMemo(() => { const map = new Map(); @@ -670,82 +381,24 @@ function ExternalServicesTab({ ); } - const userKeys = (keys ?? []).filter((k) => !k.auto_connected); - const autoKeys = (keys ?? []).filter((k) => k.auto_connected); - const visibleKeys = showAutoConnected ? (keys ?? []) : userKeys; - - if (visibleKeys.length === 0 && autoKeys.length === 0) { - return ; - } - - if (visibleKeys.length === 0) { - return ; - } - - const groups = groupKeysBySource(visibleKeys, sourceById); - - if (viewMode === "table") { - return ; - } - - // If only personal services exist, skip section headers to preserve the - // current flat-grid look-and-feel. - const [firstGroup] = groups; - if (groups.length === 1 && firstGroup && firstGroup.icon === "personal") { - return ( -
- {firstGroup.keys.map(({ keyInfo, source }) => ( - - ))} + if (!keys?.length) return ; + + return ({ + ...keyInfo, + credential_source: keyInfo.credential_source ?? sourceById.get(keyInfo.id), + }))} + catalog={catalog} + renderTable={viewMode === "table" ? (filteredKeys) => ( +
+ } />
- ); - } - - return ( -
- {groups.map((group) => ( -
-
-
- {group.icon === "org" ? ( - - ) : ( - - )} -

- {group.title} -

-
- {group.role && } - {group.subtitle && ( - - {group.subtitle} - - )} -
-
- {group.keys.map(({ keyInfo, source }) => ( - - ))} -
-
- ))} -
- ); + ) : undefined} + renderConnectionActions={(keyInfo) => } + />; } function NyxIdApiKeysTab({ @@ -827,44 +480,24 @@ function AddButton({ return ; } -function AutoConnectedToggle({ - checked, - onCheckedChange, - count, -}: { - readonly checked: boolean; - readonly onCheckedChange: (checked: boolean) => void; - readonly count: number; -}) { - return ( -
- - -
- ); -} +const RoutingPreview = import.meta.env.DEV + ? lazy(() => import("@/components/dashboard/service-routing-preview")) + : null; +const PoolRoutingPreview = import.meta.env.DEV + ? lazy(() => import("@/components/dashboard/service-pool-routing-preview")) + : null; export function KeysPage() { - const search: { tab?: string; slug?: string; action?: string; service?: string } = useSearch({ strict: false }); + const search: { tab?: string; slug?: string; action?: string; service?: string; view?: string } = useSearch({ strict: false }); const navigate = useNavigate(); const tab = parseTab(search.tab, KEYS_TABS, KEYS_TAB_DEFAULT); + const previewActive = Boolean(RoutingPreview && (search.view === "routing" || import.meta.env.VITE_ROUTING_PREVIEW === "1")); const [addServiceOpen, setAddServiceOpen] = useState(false); const [createPoolOpen, setCreatePoolOpen] = useState(false); const [createKeyOpen, setCreateKeyOpen] = useState(false); const [createKeySetupMode, setCreateKeySetupMode] = useState(false); const [initialSetupServiceId, setInitialSetupServiceId] = useState(null); - const [showAutoConnected, setShowAutoConnected] = useState(false); const [servicesViewMode, setServicesViewMode] = useViewMode("keys-services"); const [agentKeysViewMode, setAgentKeysViewMode] = useViewMode("keys-agent"); const [pendingPrefillSlug, setPendingPrefillSlug] = useState(null); @@ -939,11 +572,8 @@ export function KeysPage() { } } - const { data: keys } = useKeys(); - const autoCount = (keys ?? []).filter((k) => k.auto_connected).length; - function setTab(value: string) { - void navigate({ to: "/keys", search: { tab: value }, replace: true }); + void navigate({ to: "/keys", search: { tab: value, ...(previewActive ? { view: "routing" } : {}) }, replace: true }); } return ( @@ -951,6 +581,7 @@ export function KeysPage() { Routing preview : undefined} /> @@ -961,47 +592,54 @@ export function KeysPage() { Agent Keys
- {tab === "services" && ( - - )} - {tab !== "pools" && ( + {tab !== "pools" && !(tab === "services" && previewActive) && ( )} - setAddServiceOpen(true)} onCreatePool={() => setCreatePoolOpen(true)} onCreateKey={() => setCreateKeyOpen(true)} - /> + />}
- }> + ( + { + setReconnectKey(keyInfo); + setAddServiceOpen(true); + }} /> + )} /> + + ) : setAddServiceOpen(true)} onReconnect={(keyInfo) => { setReconnectKey(keyInfo); setAddServiceOpen(true); }} - showAutoConnected={showAutoConnected} viewMode={servicesViewMode} - /> + />} - }> + ( + + )} /> + + ) : + />} diff --git a/frontend/src/pages/lazy.ts b/frontend/src/pages/lazy.ts index ae16dd0c2..bb69e4470 100644 --- a/frontend/src/pages/lazy.ts +++ b/frontend/src/pages/lazy.ts @@ -324,7 +324,10 @@ export const DocsPage = lazy(() => import("@/features/docs/docs-page").then((m) => ({ default: m.DocsPage })), ); - export const AdminUsagePage = lazy(() => import("@/pages/admin-usage").then((m) => ({ default: m.AdminUsagePage })), ); + +export const ServiceOverviewPage = lazy(() => + import("@/pages/service-overview").then((module) => ({ default: module.ServiceOverviewPage })), +); diff --git a/frontend/src/pages/login.tsx b/frontend/src/pages/login.tsx index bd0302fc2..e4d2e02db 100644 --- a/frontend/src/pages/login.tsx +++ b/frontend/src/pages/login.tsx @@ -1,6 +1,7 @@ import { AuthFlow } from "@/components/auth/auth-flow"; import { MfaVerifyForm } from "@/components/auth/mfa-verify-form"; import { useAuthStore } from "@/stores/auth-store"; +import { Button } from "@/components/ui/button"; export function LoginPage() { const mfaRequired = useAuthStore((s) => s.mfaRequired); @@ -10,6 +11,10 @@ export function LoginPage() { const socialError = params.get("error") ?? undefined; const inviteCode = params.get("code") ?? undefined; + if (import.meta.env.DEV && import.meta.env.VITE_ROUTING_PREVIEW === "1") { + return

View your real connections

Sign in on NyxID, then return here to explore the local routing proposal with your production account data.

; + } + if (mfaRequired) { return ; } diff --git a/frontend/src/pages/service-overview.test.tsx b/frontend/src/pages/service-overview.test.tsx new file mode 100644 index 000000000..4ffce250d --- /dev/null +++ b/frontend/src/pages/service-overview.test.tsx @@ -0,0 +1,224 @@ +import { cleanup, render, screen, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { ReactNode } from "react"; +import type { KeyInfo } from "@/types/keys"; + +const { state } = vi.hoisted(() => ({ + state: { + groupId: "catalog:openai", + keys: [] as KeyInfo[], + error: null as unknown, + }, +})); +vi.mock("@tanstack/react-router", () => ({ + useParams: () => ({ groupId: state.groupId }), + Link: ({ + to, + params, + children, + ...props + }: { + to: string; + params?: { keyId?: string }; + "aria-label"?: string; + children: ReactNode; + }) => ( + + {children} + + ), +})); +vi.mock("@/components/layout/dashboard-layout", () => ({ + useBreadcrumbLabel: vi.fn(), +})); +vi.mock("@/hooks/use-keys", () => ({ + useKeys: () => ({ data: state.keys, error: state.error, refetch: vi.fn() }), + useCatalog: () => ({ + data: [ + { + slug: "openai", + name: "OpenAI", + description: "Service description", + documentation_url: "https://openai.example/docs", + }, + ], + refetch: vi.fn(), + }), +})); +vi.mock("@/hooks/use-user-services", () => ({ + useUserServices: () => ({ data: [] }), +})); +vi.mock("@/hooks/use-nodes", () => ({ useNodes: () => ({ data: [] }) })); +vi.mock("@/components/dashboard/service-history", () => ({ + ServiceHistory: ({ serviceId }: { serviceId: string }) => ( +
{serviceId}
+ ), + ServiceAuthorshipFooter: () => null, +})); +import { ServiceOverviewPage } from "./service-overview"; + +function key(id: string, overrides: Partial = {}): KeyInfo { + return { + id, + label: id, + slug: id, + catalog_service_id: "openai", + catalog_service_slug: "openai", + catalog_service_name: "OpenAI", + credential_source: { type: "personal" }, + service_type: "http", + is_active: true, + status: "active", + auth_method: "bearer", + credential_type: "api_key", + api_key_id: id, + node_id: null, + auto_connected: false, + created_at: "2026-01-01", + granted_scopes: ["models:read", "chat:write"], + ws_frame_injections: [], + ...overrides, + } as KeyInfo; +} +beforeEach(() => { + state.groupId = "catalog:openai"; + state.error = null; + state.keys = [ + key("Development"), + key("Team", { + source_app_name: "Provisioning app", + last_used_at: "2026-09-01", + }), + key("Custom", { catalog_service_id: null }), + ]; +}); +afterEach(cleanup); + +describe("full service page", () => { + it("contains all connections for the service with full information and configuration links", async () => { + const user = userEvent.setup(); + render(); + expect(screen.getByRole("heading", { name: "OpenAI" })).toBeVisible(); + expect(screen.getByText("2 connections")).toBeVisible(); + expect( + screen.getByRole("link", { + name: "Configure Development (Personal)", + }), + ).toHaveAttribute("href", "/keys/Development"); + expect( + screen.queryByRole("link", { name: /Configure Custom/ }), + ).not.toBeInTheDocument(); + expect(screen.queryByTestId("service-history")).not.toBeInTheDocument(); + await user.click( + screen.getByRole("button", { name: "Details for Team (Personal)" }), + ); + const info = screen.getByRole("table", { name: "OpenAI connections" }); + expect(within(info).getByText("models:read, chat:write")).toBeVisible(); + expect(within(info).getByText("Provisioning app")).toBeVisible(); + expect(within(info).getByText("Last caller")).toBeVisible(); + expect( + within(info).getByRole("link", { name: "Configure Team (Personal)" }), + ).toHaveAttribute("href", "/keys/Team"); + expect( + screen.getByRole("link", { name: "Service documentation" }), + ).toHaveAttribute("href", "https://openai.example/docs"); + }); + + it.each(["member", "viewer"] as const)( + "lets an org %s inspect metadata and history without showing private configuration", + async (role) => { + const user = userEvent.setup(); + state.keys = [ + key("Shared", { + endpoint_url: "https://private.internal/v1", + openapi_spec_url: "https://private.internal/spec", + custom_user_agent: "private-client", + credential_source: { + type: "org", + org_id: "org", + org_name: "ChronoAI", + role, + allowed: role === "member", + }, + }), + ]; + render(); + expect(screen.getByText("Editors only")).toBeVisible(); + expect(screen.getByRole("link", { name: "View Shared connection details (ChronoAI)" })).toHaveAttribute("href", "/keys/Shared"); + expect( + screen.queryByRole("link", { name: /Configure/ }), + ).not.toBeInTheDocument(); + await user.click( + screen.getByRole("button", { name: "Details for Shared (ChronoAI)" }), + ); + expect(document.body.innerHTML).not.toContain("private.internal"); + expect(document.body.innerHTML).not.toContain("private-client"); + expect(document.body.innerHTML).not.toContain("models:read"); + expect(screen.getByText("Connection ID")).toBeVisible(); + await user.click( + screen.getByRole("button", { name: "History for Shared (ChronoAI)" }), + ); + expect(screen.getByRole("tab", { name: "History" })).toHaveAttribute( + "aria-selected", + "true", + ); + expect(screen.getByTestId("service-history")).toHaveTextContent("Shared"); + }, + ); + + it("honors an explicit server denial even when cached provenance says admin", async () => { + state.keys = [ + key("Restricted", { + can_edit_configuration: false, + endpoint_url: "https://private.internal", + }), + ]; + render(); + expect( + screen.queryByRole("link", { name: /Configure/ }), + ).not.toBeInTheDocument(); + expect(document.body.innerHTML).not.toContain("private.internal"); + expect( + screen.getByRole("button", { name: "History for Restricted (Personal)" }), + ).toBeVisible(); + }); + + it("loads history for the selected connection only", async () => { + const user = userEvent.setup(); + render(); + await user.click(screen.getByRole("tab", { name: "History" })); + expect(screen.getByTestId("service-history")).toHaveTextContent( + "Development", + ); + await user.click( + screen.getByRole("combobox", { name: "Connection history" }), + ); + await user.click(screen.getByRole("option", { name: "Team · Personal" })); + expect(screen.getByTestId("service-history")).toHaveTextContent("Team"); + await user.click(screen.getByRole("tab", { name: "Connections" })); + expect(screen.queryByTestId("service-history")).not.toBeInTheDocument(); + }); + + it("does not manufacture a group from the catalog or expose stale connections after an access failure", () => { + state.groupId = "catalog:missing"; + const mounted = render(); + expect( + screen.getByRole("heading", { name: "Service unavailable" }), + ).toBeVisible(); + state.groupId = "catalog:openai"; + state.error = new Error("No access"); + mounted.rerender(); + expect( + screen.getByText("Service information could not be loaded."), + ).toBeVisible(); + expect( + screen.queryByRole("link", { + name: "Configure Development (Personal)", + }), + ).not.toBeInTheDocument(); + }); +}); diff --git a/frontend/src/pages/service-overview.tsx b/frontend/src/pages/service-overview.tsx new file mode 100644 index 000000000..9338561c8 --- /dev/null +++ b/frontend/src/pages/service-overview.tsx @@ -0,0 +1,180 @@ +import { useBreadcrumbLabel } from "@/components/layout/dashboard-layout"; +import { useState } from "react"; +import { Link, useParams } from "@tanstack/react-router"; +import { ArrowLeft, Globe } from "lucide-react"; +import { useKeys, useCatalog } from "@/hooks/use-keys"; +import { useUserServices } from "@/hooks/use-user-services"; +import { PageHeader } from "@/components/shared/page-header"; +import { ServiceIcon } from "@/components/service-icon"; +import { ErrorBanner } from "@/components/shared/error-banner"; +import { ServiceConnectionTable } from "@/components/dashboard/service-connection-table"; +import { ServiceHistory } from "@/components/dashboard/service-history"; +import { Skeleton } from "@/components/ui/skeleton"; +import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { groupServiceConnections } from "@/lib/service-groups"; +import { connectionSourceLabel } from "@/lib/service-view"; + +export function ServiceOverviewPage() { + const { groupId } = useParams({ strict: false }) as { groupId: string }; + const keys = useKeys(); + const catalog = useCatalog(); + const services = useUserServices(); + const [historyId, setHistoryId] = useState(null); + const [tab, setTab] = useState("connections"); + const connections = (keys.data ?? []).map((key) => ({ + ...key, + credential_source: + key.credential_source ?? + services.data?.find((service) => service.id === key.id) + ?.credential_source, + })); + const group = groupServiceConnections(connections, catalog.data).find( + (group) => group.id === groupId, + ); + useBreadcrumbLabel(group?.name); + const entry = catalog.data?.find((entry) => entry.slug === group?.slug); + const historyConnection = + group?.connections.find((connection) => connection.id === historyId) ?? + group?.connections[0]; + const back = ( + + + All services + + ); + + if (keys.isLoading) return ; + if (keys.error) + return ( +
+ {back} + +
+ ); + if (!group) + return ( +
+ {back} + +
+ ); + + return ( +
+ {back} +
+ } + description={ + group.description ?? + "Connections, configuration and history for this service." + } + /> +
+ + {group.connections.length}{" "} + {group.connections.length === 1 ? "connection" : "connections"} + + + {group.connections.filter((key) => key.is_active).length} enabled + + + {[...new Set(group.connections.map(connectionSourceLabel))].join( + " · ", + )} + + {entry?.documentation_url && + /^https?:\/\//i.test(entry.documentation_url) && ( + + Service documentation + + )} +
+ {catalog.error && ( + + )} + + + Connections + History + + + { + setHistoryId(connection.id); + setTab("history"); + }} + /> + + + + {tab === "history" && historyConnection && ( + + )} + + +
+ ); +} diff --git a/frontend/src/router.tsx b/frontend/src/router.tsx index c5e6bfa43..f38e5d57d 100644 --- a/frontend/src/router.tsx +++ b/frontend/src/router.tsx @@ -99,6 +99,7 @@ import { KeysPage, BillingPage, KeyDetailPage, + ServiceOverviewPage, ChannelBotsPage, ChannelBotSetupPage, ChannelBotSetupLinksPage, @@ -710,7 +711,8 @@ const keysRoute = createRoute({ // service scope in the Agent Key create dialog. validateSearch: ( search: Record, - ): { tab?: string; slug?: string; action?: string; service?: string } => ({ + ): { tab?: string; slug?: string; action?: string; service?: string; view?: string } => ({ + ...(import.meta.env.DEV && search.view === "routing" ? { view: "routing" } : {}), ...(typeof search.tab === "string" ? { tab: search.tab } : {}), ...(typeof search.slug === "string" && search.slug.length > 0 ? { slug: search.slug } @@ -740,6 +742,12 @@ const billingRoute = createRoute({ ), }); +const serviceOverviewRoute = createRoute({ + path: "/keys/services/$groupId", + getParentRoute: () => dashboardLayout, + component: ServiceOverviewPage, +}); + const keyDetailRoute = createRoute({ path: "/keys/$keyId", getParentRoute: () => dashboardLayout, @@ -1068,6 +1076,7 @@ const routeTree = rootRoute.addChildren([ approvalGrantsRoute, keysRoute, billingRoute, + serviceOverviewRoute, keyDetailRoute, apiKeyDetailRoute, nodesRoute, diff --git a/frontend/src/schemas/service-view.ts b/frontend/src/schemas/service-view.ts new file mode 100644 index 000000000..3a1a0971b --- /dev/null +++ b/frontend/src/schemas/service-view.ts @@ -0,0 +1,91 @@ +import { z } from "zod"; + +const selectionId = z + .string() + .refine( + (value) => value.trim().length > 0 && Array.from(value).length <= 128, + "Selections must contain 1 to 128 characters", + ); +const selections = z + .array(selectionId) + .max(100) + .transform((ids) => [...new Set(ids)]); + +export const serviceViewSchema = z + .object({ + search: z + .string() + .refine( + (value) => Array.from(value).length <= 200, + "Search is limited to 200 characters", + ), + organization_ids: selections.optional(), + service_group_ids: selections.optional(), + organization_id: selectionId.nullish(), + service_group_id: selectionId.nullish(), + source: z.enum(["all", "personal", "org", "platform"]), + state: z.enum(["all", "enabled", "disabled"]), + service_type: z.enum(["all", "http", "ssh"]), + show_auto_connected: z.boolean(), + }) + .strict() + .refine( + (value) => + !( + value.organization_ids !== undefined && + value.organization_id !== undefined + ), + "Use organization_ids", + ) + .refine( + (value) => + !( + value.service_group_ids !== undefined && + value.service_group_id !== undefined + ), + "Use service_group_ids", + ) + .transform( + ({ + organization_id, + service_group_id, + organization_ids, + service_group_ids, + ...filters + }) => ({ + ...filters, + organization_ids: + organization_ids ?? (organization_id ? [organization_id] : []), + service_group_ids: + service_group_ids ?? (service_group_id ? [service_group_id] : []), + }), + ); + +export type ServiceViewFilters = z.infer; + +export const DEFAULT_SERVICE_FILTERS: ServiceViewFilters = { + search: "", + organization_ids: [], + service_group_ids: [], + source: "personal", + state: "all", + service_type: "all", + show_auto_connected: true, +}; + +export function sameServiceFilters( + a: ServiceViewFilters, + b: ServiceViewFilters, +): boolean { + return ( + a.search === b.search && + a.organization_ids.length === b.organization_ids.length && + a.organization_ids.every((id) => b.organization_ids.includes(id)) && + a.service_group_ids.length === b.service_group_ids.length && + a.service_group_ids.every((id) => b.service_group_ids.includes(id)) && + a.source === b.source && + a.state === b.state && + a.service_type === b.service_type && + a.show_auto_connected === b.show_auto_connected + ); +} diff --git a/frontend/src/stores/auth-store.ts b/frontend/src/stores/auth-store.ts index 125de1cb4..a3a5ce3a0 100644 --- a/frontend/src/stores/auth-store.ts +++ b/frontend/src/stores/auth-store.ts @@ -1,3 +1,4 @@ +import { useServiceCardView } from "@/stores/service-card-view-store"; import { create } from "zustand"; import type { User, LoginResponse } from "@/types/api"; import { api, apiClient, ApiError } from "@/lib/api-client"; @@ -12,7 +13,8 @@ import { transitionAssistantIdentity } from "@/lib/assistant/identity"; const MFA_REQUIRED_ERROR_CODE = 2002; -function clearAssistantLocalState(): void { +function clearAccountLocalState(): void { + useServiceCardView.setState({ accountId: undefined, filters: undefined, expanded: [] }); useAssistantContextStore.getState().clear(); useAssistantDraftStore.getState().clear(); useAssistantWireLogStore.getState().reset(); @@ -23,7 +25,8 @@ function applyIdentityTransition( nextUser: User | null, ): void { if (previousUser?.id === nextUser?.id) return; - if (previousUser !== null) clearAssistantLocalState(); + useServiceCardView.setState({ accountId: nextUser?.id, filters: undefined, expanded: [] }); + if (previousUser !== null) clearAccountLocalState(); transitionAssistantIdentity(nextUser?.id ?? null); } @@ -98,7 +101,7 @@ export const useAuthStore = create((set, get) => ({ // Clear telemetry identity BEFORE state wipe so the next event // the app emits already carries a fresh anon distinct_id. telemetryReset(); - clearAssistantLocalState(); + clearAccountLocalState(); transitionAssistantIdentity(null); set({ user: null, @@ -129,7 +132,7 @@ export const useAuthStore = create((set, get) => ({ // ex-user. Parity with the explicit `logout()` branch above. if (!ephemeral) { telemetryReset(); - clearAssistantLocalState(); + clearAccountLocalState(); } transitionAssistantIdentity(null); set({ user: null, isAuthenticated: false, isLoading: false }); @@ -142,7 +145,7 @@ export const useAuthStore = create((set, get) => ({ setUser: (user: User | null): void => { const previousUser = get().user; if (user === null) { - clearAssistantLocalState(); + clearAccountLocalState(); transitionAssistantIdentity(null); } else { applyIdentityTransition(previousUser, user); diff --git a/frontend/src/stores/service-card-view-store.ts b/frontend/src/stores/service-card-view-store.ts new file mode 100644 index 000000000..8fab5134a --- /dev/null +++ b/frontend/src/stores/service-card-view-store.ts @@ -0,0 +1,13 @@ +import { create } from "zustand"; +import type { ServiceViewFilters } from "@/schemas/service-view"; + +interface ServiceCardView { + accountId?: string; + expanded: readonly string[]; + filters?: ServiceViewFilters; +} + +// Unsaved changes survive navigation only. Account defaults come from /users/me. +export const useServiceCardView = create(() => ({ + expanded: [], +})); diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts index aee1d9efe..108ef3c9a 100644 --- a/frontend/src/types/api.ts +++ b/frontend/src/types/api.ts @@ -1,3 +1,4 @@ +import type { ServiceViewFilters } from "@/schemas/service-view"; import type { ProxyOperationPolicy } from "@/schemas/services"; import type { InferenceMetadata, PlatformKeyConfig, LanePricingView } from "@/schemas/platform-keys"; import type { BillingMetric } from "@/schemas/billing"; @@ -42,6 +43,7 @@ export interface OnboardingState { /// User-scoped configuration surfaced on `GET /users/me`. export interface ProfileConfig { + readonly services_view?: ServiceViewFilters | null; readonly onboarding: OnboardingState; } diff --git a/frontend/src/types/keys.ts b/frontend/src/types/keys.ts index 64a3e283d..e0a5d58d3 100644 --- a/frontend/src/types/keys.ts +++ b/frontend/src/types/keys.ts @@ -6,6 +6,7 @@ import type { WsFrameInjection } from "@/schemas/services"; export type { DefaultRequestHeader } from "@/schemas/default-request-headers"; export interface KeyInfo { + readonly can_edit_configuration?: boolean; readonly authorship?: import("@/schemas/service-history").ServiceAuthorship; readonly credential_binding?: "platform" | "user"; readonly platform_key_available?: boolean; diff --git a/frontend/test/routing-preview-gateway.test.ts b/frontend/test/routing-preview-gateway.test.ts new file mode 100644 index 000000000..556b8d94c --- /dev/null +++ b/frontend/test/routing-preview-gateway.test.ts @@ -0,0 +1,168 @@ +// @vitest-environment node +import { createServer, type Server } from "node:http"; +import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"; +import type { Connect, ViteDevServer } from "vite"; +import { routingPreview } from "../dev/routing-preview"; + +function listen(server: Server): Promise { + return new Promise((resolve) => server.listen(0, "127.0.0.1", () => { + const address = server.address(); + if (address && typeof address !== "string") resolve(`http://127.0.0.1:${address.port}`); + })); +} + +function close(server: Server): Promise { + return new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve())); +} + +describe("local preview metadata gateway", () => { + let local: Server; + let upstream: Server; + let origin: string; + const calls: { path?: string; authorization?: string; cookie?: string; method?: string; body?: string }[] = []; + + beforeAll(async () => { + upstream = createServer(async (req, res) => { + const chunks: Buffer[] = []; + for await (const chunk of req) chunks.push(Buffer.from(chunk)); + calls.push({ path: req.url, authorization: req.headers.authorization, cookie: req.headers.cookie, method: req.method, body: Buffer.concat(chunks).toString("utf8") }); + res.setHeader("Content-Type", "application/json"); + res.end(JSON.stringify({ keys: [] })); + }); + const backend = await listen(upstream); + let middleware: Connect.NextHandleFunction; + local = createServer((req, res) => middleware(req, res, () => { res.writeHead(404); res.end(); })); + const plugin = routingPreview(backend, "https://nyx.example"); + const configure = plugin.configureServer as (server: ViteDevServer) => void; + configure({ httpServer: local, middlewares: { use: (handler: Connect.NextHandleFunction) => { middleware = handler; } } } as unknown as ViteDevServer); + origin = await listen(local); + }); + + beforeEach(() => calls.splice(0)); + afterAll(async () => { await Promise.all([close(local), close(upstream)]); }); + + async function startLogin() { + const response = await fetch(`${origin}/__routing-preview/login`, { redirect: "manual" }); + const target = new URL(response.headers.get("location")!); + expect(target.origin + target.pathname).toBe("https://nyx.example/cli-auth"); + expect(target.searchParams.get("port")).toBe(new URL(origin).port); + return { state: target.searchParams.get("state")!, cookie: response.headers.get("set-cookie")!.split(";")[0]! }; + } + + async function signIn() { + const login = await startLogin(); + const params = new URLSearchParams({ state: login.state, access_token: "test.access.token", refresh_token: "ignored.refresh.token" }); + const response = await fetch(`${origin}/callback?${params}`, { headers: { Cookie: login.cookie }, redirect: "manual" }); + expect(response.status).toBe(302); + expect(response.headers.get("location")).toBe("/keys?view=routing"); + const sessionCookie = response.headers.getSetCookie()[0]!; + expect(sessionCookie).toContain("HttpOnly"); + expect(sessionCookie).not.toContain("test.access.token"); + return sessionCookie.split(";")[0]!; + } + + it("requires a session for account metadata", async () => { + expect((await fetch(`${origin}/api/v1/keys`)).status).toBe(401); + expect(calls).toHaveLength(0); + }); + + it("accepts a bound login callback and forwards the access token only to allowed metadata reads", async () => { + const cookie = await signIn(); + const response = await fetch(`${origin}/api/v1/keys`, { headers: { Cookie: cookie } }); + expect(response.status).toBe(200); + expect(calls).toEqual([{ path: "/api/v1/keys", authorization: "Bearer test.access.token", cookie: undefined, method: "GET", body: "" }]); + }); + + it.each([ + "/api/v1/keys/13ae3c40-5ec0-4eee-9e20-25c60209dd12", + "/api/v1/catalog/llm-openai", + "/api/v1/nodes/13ae3c40-5ec0-4eee-9e20-25c60209dd12", + "/api/v1/api-keys", + "/api/v1/service-pools", + "/api/v1/providers/codex-connection", + "/api/v1/keys/history/archived", + "/api/v1/options/service-history-action", + "/api/v1/keys/13ae3c40-5ec0-4eee-9e20-25c60209dd12/history", + ])("preserves metadata access for the original detail pages: %s", async (path) => { + const cookie = await signIn(); + expect((await fetch(`${origin}${path}`, { headers: { Cookie: cookie } })).status).toBe(200); + expect(calls[0]?.path).toBe(path); + }); + + it("rejects an unbound callback even with a valid state", async () => { + const login = await startLogin(); + expect((await fetch(`${origin}/callback?state=${login.state}&access_token=test.token`, { redirect: "manual" })).status).toBe(400); + }); + + it("rejects replay of a consumed callback", async () => { + const login = await startLogin(); + const url = `${origin}/callback?state=${login.state}&access_token=test.token`; + const options = { headers: { Cookie: login.cookie }, redirect: "manual" as const }; + expect((await fetch(url, options)).status).toBe(302); + expect((await fetch(url, options)).status).toBe(400); + }); + + it.each([ + ["POST", "/api/v1/keys"], + ["DELETE", "/api/v1/keys/id"], + ["POST", "/api/v1/service-pools"], + ["POST", "/api/v1/providers/codex-connection/verify"], + ["PUT", "/api/v1/service-pools/id/members"], + ["DELETE", "/api/v1/service-pools/id"], + ["GET", "/api/v1/proxy/s/openai/models"], + ["GET", "/api/v1/keys/id/reveal"], + ["GET", "/api/v1/keys/13ae3c40-5ec0-4eee-9e20-25c60209dd12/reveal"], + ["GET", "/api/v1/nodes/13ae3c40-5ec0-4eee-9e20-25c60209dd12/pending-credentials"], + ["GET", "/api/v1/catalog/llm-openai/endpoints"], + ["GET", "/oauth/authorize"], + ["GET", "/mcp"], + ])("blocks %s %s before it reaches production", async (method, path) => { + const cookie = await signIn(); + expect((await fetch(`${origin}${path}`, { method, headers: { Cookie: cookie } })).status).toBe(403); + expect(calls).toHaveLength(0); + }); + + it("rejects cross-origin reads even with a local session", async () => { + const cookie = await signIn(); + const response = await fetch(`${origin}/api/v1/keys`, { headers: { Cookie: cookie, Origin: "https://other.example" } }); + expect(response.status).toBe(403); + expect(calls).toHaveLength(0); + }); + + const preferences = { search: "team", organization_ids: ["org-1", "org-2"], service_group_ids: ["catalog:openai", "catalog:codex"], source: "org", state: "enabled", service_type: "http", show_auto_connected: false }; + + it("forwards only validated service preferences under the preview user's identity", async () => { + const cookie = await signIn(); + const response = await fetch(`${origin}/api/v1/users/me/preferences/services`, { + method: "PUT", headers: { Cookie: cookie, Origin: origin, "Content-Type": "application/json" }, body: JSON.stringify(preferences), + }); + expect(response.status).toBe(200); + expect(calls).toHaveLength(1); + expect(calls[0]).toMatchObject({ path: "/api/v1/users/me/preferences/services", method: "PUT", authorization: "Bearer test.access.token" }); + expect(JSON.parse(calls[0]!.body)).toEqual(preferences); + expect(calls[0]?.cookie).toBeUndefined(); + }); + + it("rejects unauthenticated, cross-origin, malformed and oversized preference writes", async () => { + const cookie = await signIn(); + for (const [headers, body, status] of [ + [{ Origin: origin }, JSON.stringify(preferences), 401], + [{ Cookie: cookie }, JSON.stringify(preferences), 403], + [{ Cookie: cookie, Origin: "https://other.example" }, JSON.stringify(preferences), 403], + [{ Cookie: cookie, Origin: origin }, JSON.stringify({ ...preferences, user_id: "other" }), 400], + [{ Cookie: cookie, Origin: origin }, JSON.stringify({ ...preferences, state: "healthy" }), 400], + [{ Cookie: cookie, Origin: origin }, "x".repeat(131073), 413], + ] as const) { + const response = await fetch(`${origin}/api/v1/users/me/preferences/services`, { method: "PUT", headers, body }); + expect(response.status).toBe(status); + } + expect(calls).toHaveLength(0); + }); + + it("clears only the local session on logout", async () => { + const cookie = await signIn(); + expect((await fetch(`${origin}/api/v1/auth/logout`, { method: "POST", headers: { Cookie: cookie, Origin: origin } })).status).toBe(200); + expect((await fetch(`${origin}/api/v1/keys`, { headers: { Cookie: cookie } })).status).toBe(401); + expect(calls).toHaveLength(0); + }); +}); diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 29219b3e3..281a71f27 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -6,6 +6,7 @@ import path from "path" import fs from "node:fs" import https from "node:https" import { allDocPages } from "./src/features/docs/manifest" +import { routingPreview } from "./dev/routing-preview" const backendUrl = process.env.BACKEND_URL || "http://localhost:3001" @@ -240,9 +241,13 @@ function docsSync(): Plugin { const BUILD_ID = process.env.SOURCE_COMMIT || Date.now().toString(36) export default defineConfig({ - plugins: [telegramClaimReferrer(), react(), tailwindcss(), docsSync()], + plugins: [ + ...(process.env.NYXID_ROUTING_PREVIEW === "1" ? [routingPreview(backendUrl, expectedOrigin)] : []), + telegramClaimReferrer(), react(), tailwindcss(), docsSync(), + ], define: { __BUILD_ID__: JSON.stringify(BUILD_ID), + "import.meta.env.VITE_ROUTING_PREVIEW": JSON.stringify(process.env.NYXID_ROUTING_PREVIEW === "1" ? "1" : "0"), }, resolve: { alias: { From cc1f203a64099c21386ccff22277b6de80c16088 Mon Sep 17 00:00:00 2001 From: ctkm-aelf <230958228+ctkm-aelf@users.noreply.github.com> Date: Mon, 28 Sep 2026 22:20:03 +0800 Subject: [PATCH 02/36] fix(services): carry latest service icons through refreshed views --- cli/src/wizard/assets/index.html | 54 +++++++++---------- cli/src/wizard/bundle-meta/index.hash | 2 +- cli/src/wizard/bundle-meta/index.manifest | 1 + docs/plans/local-routing-preview.md | 7 ++- .../dashboard/grouped-service-cards.tsx | 12 ++--- .../dashboard/service-connection-table.tsx | 6 +++ frontend/src/lib/service-groups.ts | 4 ++ frontend/src/lib/service-view.test.ts | 2 + frontend/src/pages/key-detail.tsx | 18 ++++++- frontend/src/pages/service-overview.tsx | 12 ++--- 10 files changed, 75 insertions(+), 43 deletions(-) diff --git a/cli/src/wizard/assets/index.html b/cli/src/wizard/assets/index.html index e470e5060..ed99af83c 100644 --- a/cli/src/wizard/assets/index.html +++ b/cli/src/wizard/assets/index.html @@ -13,23 +13,23 @@ NyxID CLI Wizard - +`);let l=b(n,r,o),d=new i(t.getSymbolSize(n));return m(d,n),h(d),g(d,n),v(d,r,0),n>=7&&_(d,n),y(d,l),isNaN(a)&&(a=s.getBestMask(d,v.bind(null,d,r))),s.applyMask(a,d),v(d,r,a),{modules:d,version:n,errorCorrectionLevel:r,maskPattern:a,segments:o}}e.create=function(e,r){if(e===void 0||e===``)throw Error(`No input text`);let i=n.M,a,o;return r!==void 0&&(i=n.from(r.errorCorrectionLevel,n.M),a=u.from(r.version),o=s.from(r.maskPattern),r.toSJISFunc&&t.setToSJISFunction(r.toSJISFunc)),S(e,a,i,o)}})),Lk=s((e=>{function t(e){if(typeof e==`number`&&(e=e.toString()),typeof e!=`string`)throw Error(`Color should be defined as hex string`);let t=e.slice().replace(`#`,``).split(``);if(t.length<3||t.length===5||t.length>8)throw Error(`Invalid hex color: `+e);(t.length===3||t.length===4)&&(t=Array.prototype.concat.apply([],t.map(function(e){return[e,e]}))),t.length===6&&t.push(`F`,`F`);let n=parseInt(t.join(``),16);return{r:n>>24&255,g:n>>16&255,b:n>>8&255,a:n&255,hex:`#`+t.slice(0,6).join(``)}}e.getOptions=function(e){e||={},e.color||={};let n=e.margin===void 0||e.margin===null||e.margin<0?4:e.margin,r=e.width&&e.width>=21?e.width:void 0,i=e.scale||4;return{width:r,scale:r?4:i,margin:n,color:{dark:t(e.color.dark||`#000000ff`),light:t(e.color.light||`#ffffffff`)},type:e.type,rendererOpts:e.rendererOpts||{}}},e.getScale=function(e,t){return t.width&&t.width>=e+t.margin*2?t.width/(e+t.margin*2):t.scale},e.getImageWidth=function(t,n){let r=e.getScale(t,n);return Math.floor((t+n.margin*2)*r)},e.qrToImageData=function(t,n,r){let i=n.modules.size,a=n.modules.data,o=e.getScale(i,r),s=Math.floor((i+r.margin*2)*o),c=r.margin*o,l=[r.color.light,r.color.dark];for(let e=0;e=c&&n>=c&&e{var t=Lk();function n(e,t,n){e.clearRect(0,0,t.width,t.height),t.style||={},t.height=n,t.width=n,t.style.height=n+`px`,t.style.width=n+`px`}function r(){try{return document.createElement(`canvas`)}catch{throw Error(`You need to specify a canvas element`)}}e.render=function(e,i,a){let o=a,s=i;o===void 0&&(!i||!i.getContext)&&(o=i,i=void 0),i||(s=r()),o=t.getOptions(o);let c=t.getImageWidth(e.modules.size,o),l=s.getContext(`2d`),u=l.createImageData(c,c);return t.qrToImageData(u.data,e,o),n(l,s,c),l.putImageData(u,0,0),s},e.renderToDataURL=function(t,n,r){let i=r;i===void 0&&(!n||!n.getContext)&&(i=n,n=void 0),i||={};let a=e.render(t,n,i),o=i.type||`image/png`,s=i.rendererOpts||{};return a.toDataURL(o,s.quality)}})),zk=s((e=>{var t=Lk();function n(e,t){let n=e.a/255,r=t+`="`+e.hex+`"`;return n<1?r+` `+t+`-opacity="`+n.toFixed(2).slice(1)+`"`:r}function r(e,t,n){let r=e+t;return n!==void 0&&(r+=` `+n),r}function i(e,t,n){let i=``,a=0,o=!1,s=0;for(let c=0;c0&&l>0&&e[c-1]||(i+=o?r(`M`,l+n,.5+u+n):r(`m`,a,0),a=0,o=!1),l+1`:``,d=``,f=`viewBox="0 0 `+l+` `+l+`"`,p=``+u+d+` +`;return typeof a==`function`&&a(null,p),p}})),Bk=u(s((e=>{var t=pk(),n=Ik(),r=Rk(),i=zk();function a(e,r,i,a,o){let s=[].slice.call(arguments,1),c=s.length,l=typeof s[c-1]==`function`;if(!l&&!t())throw Error(`Callback required as last argument`);if(l){if(c<2)throw Error(`Too few arguments provided`);c===2?(o=i,i=r,r=a=void 0):c===3&&(r.getContext&&o===void 0?(o=a,a=void 0):(o=a,a=i,i=r,r=void 0))}else{if(c<1)throw Error(`Too few arguments provided`);return c===1?(i=r,r=a=void 0):c===2&&!r.getContext&&(a=i,i=r,r=void 0),new Promise(function(t,o){try{t(e(n.create(i,a),r,a))}catch(e){o(e)}})}try{let t=n.create(i,a);o(null,e(t,r,a))}catch(e){o(e)}}e.create=n.create,e.toCanvas=a.bind(null,r.render),e.toDataURL=a.bind(null,r.renderToDataURL),e.toString=a.bind(null,function(e,t,n){return i.render(e,n)})}))(),1);function Vk(e){return(0,L.jsx)(`svg`,{viewBox:`0 0 24 24`,fill:`currentColor`,"aria-hidden":`true`,...e,children:(0,L.jsx)(`path`,{d:`M22.2819 9.8211a5.9847 5.9847 0 0 0-.5157-4.9108 6.0462 6.0462 0 0 0-6.5098-2.9A6.0651 6.0651 0 0 0 4.9807 4.1818a5.9847 5.9847 0 0 0-3.9977 2.9 6.0462 6.0462 0 0 0 .7427 7.0966 5.98 5.98 0 0 0 .511 4.9107 6.051 6.051 0 0 0 6.5146 2.9001A5.9847 5.9847 0 0 0 13.2599 24a6.0557 6.0557 0 0 0 5.7718-4.2058 5.9894 5.9894 0 0 0 3.9977-2.9001 6.0557 6.0557 0 0 0-.7475-7.0729zm-9.022 12.6081a4.4755 4.4755 0 0 1-2.8764-1.0408l.1419-.0804 4.7783-2.7582a.7948.7948 0 0 0 .3927-.6813v-6.7369l2.02 1.1686a.071.071 0 0 1 .038.052v5.5826a4.504 4.504 0 0 1-4.4945 4.4944zm-9.6607-4.1254a4.4708 4.4708 0 0 1-.5346-3.0137l.142.0852 4.783 2.7582a.7712.7712 0 0 0 .7806 0l5.8428-3.3685v2.3324a.0804.0804 0 0 1-.0332.0615L9.74 19.9502a4.4992 4.4992 0 0 1-6.1408-1.6464zM2.3408 7.8956a4.485 4.485 0 0 1 2.3655-1.9728V11.6a.7664.7664 0 0 0 .3879.6765l5.8144 3.3543-2.0201 1.1685a.0757.0757 0 0 1-.071 0l-4.8303-2.7865A4.504 4.504 0 0 1 2.3408 7.872zm16.5963 3.8558L13.1038 8.364 15.1192 7.2a.0757.0757 0 0 1 .071 0l4.8303 2.7913a4.4944 4.4944 0 0 1-.6765 8.1042v-5.6772a.79.79 0 0 0-.407-.667zm2.0107-3.0231l-.142-.0852-4.7735-2.7818a.7759.7759 0 0 0-.7854 0L9.409 9.2297V6.8974a.0662.0662 0 0 1 .0284-.0615l4.8303-2.7866a4.4992 4.4992 0 0 1 6.6802 4.66zM8.3065 12.863l-2.02-1.1638a.0804.0804 0 0 1-.038-.0567V6.0742a4.4992 4.4992 0 0 1 7.3757-3.4537l-.142.0805L8.704 5.459a.7948.7948 0 0 0-.3927.6813zm1.0976-2.3654l2.602-1.4998 2.6069 1.4998v2.9994l-2.5974 1.4997-2.6067-1.4997Z`})})}function Hk(e){return(0,L.jsx)(`svg`,{viewBox:`0 0 24 24`,fill:`currentColor`,"aria-hidden":`true`,...e,children:(0,L.jsx)(`path`,{d:`M17.3041 3.541h-3.6718l6.696 16.918H24Zm-10.6082 0L0 20.459h3.7442l1.3693-3.5527h7.0052l1.3693 3.5528h3.7442L10.5363 3.5409Zm-.3712 10.2232 2.2914-5.9456 2.2914 5.9456Z`})})}function Uk(e){return(0,L.jsx)(`svg`,{viewBox:`0 0 24 24`,fill:`currentColor`,"aria-hidden":`true`,...e,children:(0,L.jsx)(`path`,{d:`M17.143 3.429v3.428h-3.429v3.429h-3.428V6.857H6.857V3.43H3.43v13.714H0v3.428h10.286v-3.428H6.857v-3.429h3.429v3.429h3.429v-3.429h3.428v3.429h-3.428v3.428H24v-3.428h-3.43V3.429z`})})}function Wk(e){return(0,L.jsxs)(`svg`,{viewBox:`0 0 600 600`,fill:`currentColor`,"aria-hidden":`true`,...e,children:[(0,L.jsx)(`path`,{d:`M407.5 31c-72.3 0-144.7-0.1-217 0.2-10.9 0-22 1.2-32.7 3.6C76.3 52.7 22.4 128 32.3 210.8c6.7 56.4 36.1 97.5 86.9 122.9 28.6 14.3 59.4 19.2 91.1 18.7 28.6-0.4 55.7-7.3 81.9-18.4 54.8-23.1 109.8-45.5 164.2-69.4 20.8-9.1 41.1-20.1 60-32.7 28.9-19.2 44-47.1 46.1-82.2C566.3 85.7 515.3 31 451 31c-14.2 0-28.3 0-43.5 0z`}),(0,L.jsx)(`path`,{d:`M315.3 562.3c13.2 5.6 26.6 8.7 40.6 8.8 25.2 0.1 50.3 0.1 75.5 0 9.6 0 19.3 0 28.9-1 27-2.7 50.8-13.5 70.5-32 30.2-28.5 43.8-63.7 39.8-105.2-3.3-33.2-17.5-61-43.2-82.2-38.2-31.5-81.1-39-127.5-21.1-27.6 10.6-54.8 22.5-82.1 33.9-46.7 19.5-73.4 64.9-66.2 115.1 5.6 39.2 27.6 66.9 63.7 83.7z`}),(0,L.jsx)(`path`,{d:`M97 565.6c38.4 11 77.5-2 101-33.2 18.3-24.3 21.1-52.1 16.9-80.9-5.9-40.4-37.2-71.1-79-77-42-5.9-77.7 16.4-95 49.8-10.8 20.9-12.5 43.2-8.9 65.7 6 37.7 27.8 62.8 64.8 75.6z`})]})}function Gk(e){return(0,L.jsx)(`svg`,{viewBox:`0 0 50 72`,fill:`currentColor`,"aria-hidden":`true`,...e,children:(0,L.jsx)(`path`,{d:`M41.7154 23.1929C38.9531 24.0129 36.8707 25.8677 35.3457 27.8826C35.0182 28.3151 34.3358 27.9901 34.4658 27.4601C37.3856 15.4534 33.5283 5.47401 21.5039 0.561817C20.894 0.311833 20.259 0.859299 20.419 1.49926C25.8887 23.4604 2.88236 21.608 5.78971 46.504C5.83971 46.9314 5.35973 47.2239 5.00975 46.9739C3.9198 46.1915 2.70237 44.5591 1.86741 43.4116C1.62242 43.0742 1.09245 43.1692 0.979951 43.5716C0.314984 45.9765 0 48.2413 0 50.4912C0 59.2407 4.49727 66.9427 11.3044 71.4074C11.6944 71.6624 12.1944 71.2974 12.0619 70.8499C11.7119 69.675 11.5144 68.4351 11.4994 67.1527C11.4994 66.3652 11.5494 65.5603 11.6719 64.8103C11.9569 62.9254 12.6119 61.1306 13.7118 59.4957C17.4841 53.8335 25.0462 48.3638 23.8388 40.9368C23.7613 40.4668 24.3163 40.1569 24.6663 40.4793C29.9935 45.3465 31.0485 51.8936 30.1735 57.7658C30.0985 58.2757 30.7385 58.5482 31.061 58.1482C31.8759 57.1283 32.8709 56.2334 33.9533 55.5609C34.2233 55.3934 34.5833 55.5209 34.6858 55.8209C35.2882 57.5733 36.1832 59.2182 37.0281 60.8631C38.0381 62.8404 38.5756 65.0978 38.4906 67.4877C38.4481 68.6501 38.2556 69.775 37.9331 70.8449C37.7956 71.2974 38.2906 71.6749 38.6881 71.4149C45.5002 66.9502 50 59.2482 50 50.4937C50 47.4514 49.4675 44.4691 48.4601 41.6743C46.3477 35.8121 40.988 31.4099 42.3429 23.7704C42.4079 23.4054 42.0704 23.0879 41.7154 23.1929Z`})})}function Kk(e){return(0,L.jsx)(`svg`,{viewBox:`0 0 24 24`,fill:`currentColor`,"aria-hidden":`true`,...e,children:(0,L.jsx)(`path`,{d:`M23.748 4.651c-.254-.124-.364.113-.512.233-.051.04-.094.09-.137.137-.372.397-.806.657-1.373.626-.829-.046-1.537.214-2.163.848-.133-.782-.575-1.248-1.247-1.548-.352-.155-.708-.311-.955-.65-.172-.24-.219-.509-.305-.774-.055-.16-.11-.323-.293-.35-.2-.031-.278.136-.356.276-.313.572-.434 1.202-.422 1.84.027 1.436.633 2.58 1.838 3.393.137.094.172.187.129.323-.082.28-.18.553-.266.833-.055.179-.137.218-.328.14a5.5 5.5 0 0 1-1.737-1.179c-.857-.828-1.631-1.743-2.597-2.46a12 12 0 0 0-.689-.47c-.985-.957.13-1.743.387-1.836.27-.098.094-.433-.778-.428-.872.003-1.67.295-2.687.685a3 3 0 0 1-.465.136 9.6 9.6 0 0 0-2.883-.101c-1.885.21-3.39 1.1-4.497 2.622C.082 8.776-.231 10.854.152 13.02c.403 2.284 1.568 4.175 3.36 5.653 1.857 1.533 3.997 2.284 6.438 2.14 1.482-.085 3.132-.284 4.994-1.86.47.234.962.328 1.78.398.629.058 1.235-.031 1.705-.129.735-.155.684-.836.418-.961-2.155-1.004-1.682-.595-2.112-.926 1.095-1.295 2.768-3.598 3.284-6.733.05-.346.115-.834.108-1.114-.004-.171.035-.238.23-.257a4.2 4.2 0 0 0 1.545-.475c1.397-.763 1.96-2.016 2.093-3.517.02-.23-.004-.467-.247-.588M11.58 18.168c-2.088-1.642-3.101-2.183-3.52-2.16-.39.024-.32.472-.234.763.09.288.207.487.371.74.114.167.192.416-.113.603-.673.416-1.842-.14-1.897-.168-1.361-.801-2.5-1.86-3.301-3.306-.775-1.393-1.225-2.888-1.299-4.482-.02-.385.094-.522.477-.592a4.7 4.7 0 0 1 1.53-.038c2.131.311 3.946 1.264 5.467 2.774.868.86 1.525 1.887 2.202 2.89.72 1.066 1.494 2.082 2.48 2.915.348.291.626.513.892.677-.802.09-2.14.109-3.055-.615zm1.001-6.44a.306.306 0 0 1 .415-.287.3.3 0 0 1 .113.074.3.3 0 0 1 .086.214c0 .17-.136.307-.308.307a.303.303 0 0 1-.306-.307m3.11 1.596c-.2.081-.4.151-.591.16a1.25 1.25 0 0 1-.798-.254c-.274-.23-.47-.358-.551-.758a1.7 1.7 0 0 1 .015-.588c.07-.327-.007-.537-.238-.727-.188-.156-.426-.199-.689-.199a.6.6 0 0 1-.254-.078.253.253 0 0 1-.114-.358 1 1 0 0 1 .192-.21c.356-.202.767-.136 1.146.016.352.144.618.408 1.001.782.392.451.462.576.685.915.176.264.336.536.446.848.066.194-.02.353-.25.45`})})}function qk(e){return(0,L.jsxs)(`svg`,{viewBox:`0 0 512 512`,fill:`currentColor`,stroke:`currentColor`,"aria-hidden":`true`,...e,children:[(0,L.jsx)(`path`,{d:`M3 248.945C18 248.945 76 236 106 219C136 202 136 202 198 158C276.497 102.293 332 120.945 423 120.945`,fill:`none`,strokeWidth:`90`}),(0,L.jsx)(`path`,{d:`M511 121.5L357.25 210.268L357.25 32.7324L511 121.5Z`}),(0,L.jsx)(`path`,{d:`M0 249C15 249 73 261.945 103 278.945C133 295.945 133 295.945 195 339.945C273.497 395.652 329 377 420 377`,fill:`none`,strokeWidth:`90`}),(0,L.jsx)(`path`,{d:`M508 376.445L354.25 287.678L354.25 465.213L508 376.445Z`})]})}function Jk(e){return(0,L.jsx)(`svg`,{viewBox:`0 0 24 24`,fill:`currentColor`,"aria-hidden":`true`,...e,children:(0,L.jsx)(`path`,{d:`M14.234 10.162 22.977 0h-2.072l-7.591 8.824L7.251 0H.258l9.168 13.343L.258 24H2.33l8.016-9.318L16.749 24h6.993zm-2.837 3.299-.929-1.329L3.076 1.56h3.182l5.965 8.532.929 1.329 7.754 11.09h-3.182z`})})}function Yk(e){return(0,L.jsx)(`svg`,{viewBox:`0 0 24 24`,fill:`currentColor`,"aria-hidden":`true`,...e,children:(0,L.jsx)(`path`,{d:`M12.48 10.92v3.28h7.84c-.24 1.84-.853 3.187-1.787 4.133-1.147 1.147-2.933 2.4-6.053 2.4-4.827 0-8.6-3.893-8.6-8.72s3.773-8.72 8.6-8.72c2.6 0 4.507 1.027 5.907 2.347l2.307-2.307C18.747 1.44 16.133 0 12.48 0 5.867 0 .307 5.387.307 12s5.56 12 12.173 12c3.573 0 6.267-1.173 8.373-3.36 2.16-2.16 2.84-5.213 2.84-7.667 0-.76-.053-1.467-.173-2.053H12.48z`})})}function Xk(e){return(0,L.jsx)(`svg`,{viewBox:`0 0 24 24`,fill:`currentColor`,"aria-hidden":`true`,...e,children:(0,L.jsx)(`path`,{d:`M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12`})})}function Zk(e){return(0,L.jsx)(`svg`,{viewBox:`0 0 24 24`,fill:`currentColor`,"aria-hidden":`true`,...e,children:(0,L.jsx)(`path`,{d:`M9.101 23.691v-7.98H6.627v-3.667h2.474v-1.58c0-4.085 1.848-5.978 5.858-5.978.401 0 .955.042 1.468.103a8.68 8.68 0 0 1 1.141.195v3.325a8.623 8.623 0 0 0-.653-.036 26.805 26.805 0 0 0-.733-.009c-.707 0-1.259.096-1.675.309a1.686 1.686 0 0 0-.679.622c-.258.42-.374.995-.374 1.752v1.297h3.919l-.386 2.103-.287 1.564h-3.246v8.245C19.396 23.238 24 18.179 24 12.044c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.628 3.874 10.35 9.101 11.647Z`})})}function Qk(e){return(0,L.jsx)(`svg`,{viewBox:`0 0 24 24`,fill:`currentColor`,"aria-hidden":`true`,...e,children:(0,L.jsx)(`path`,{d:`M20.317 4.3698a19.7913 19.7913 0 00-4.8851-1.5152.0741.0741 0 00-.0785.0371c-.211.3753-.4447.8648-.6083 1.2495-1.8447-.2762-3.68-.2762-5.4868 0-.1636-.3933-.4058-.8742-.6177-1.2495a.077.077 0 00-.0785-.037 19.7363 19.7363 0 00-4.8852 1.515.0699.0699 0 00-.0321.0277C.5334 9.0458-.319 13.5799.0992 18.0578a.0824.0824 0 00.0312.0561c2.0528 1.5076 4.0413 2.4228 5.9929 3.0294a.0777.0777 0 00.0842-.0276c.4616-.6304.8731-1.2952 1.226-1.9942a.076.076 0 00-.0416-.1057c-.6528-.2476-1.2743-.5495-1.8722-.8923a.077.077 0 01-.0076-.1277c.1258-.0943.2517-.1923.3718-.2914a.0743.0743 0 01.0776-.0105c3.9278 1.7933 8.18 1.7933 12.0614 0a.0739.0739 0 01.0785.0095c.1202.099.246.1981.3728.2924a.077.077 0 01-.0066.1276 12.2986 12.2986 0 01-1.873.8914.0766.0766 0 00-.0407.1067c.3604.698.7719 1.3628 1.225 1.9932a.076.076 0 00.0842.0286c1.961-.6067 3.9495-1.5219 6.0023-3.0294a.077.077 0 00.0313-.0552c.5004-5.177-.8382-9.6739-3.5485-13.6604a.061.061 0 00-.0312-.0286zM8.02 15.3312c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9555-2.4189 2.157-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.9555 2.4189-2.1569 2.4189zm7.9748 0c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9554-2.4189 2.1569-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.946 2.4189-2.1568 2.4189Z`})})}function $k(e){return(0,L.jsx)(`svg`,{viewBox:`0 0 24 24`,fill:`currentColor`,"aria-hidden":`true`,...e,children:(0,L.jsx)(`path`,{d:`M12 0C5.4 0 0 5.4 0 12s5.4 12 12 12 12-5.4 12-12S18.66 0 12 0zm5.521 17.34c-.24.359-.66.48-1.021.24-2.82-1.74-6.36-2.101-10.561-1.141-.418.122-.779-.179-.899-.539-.12-.421.18-.78.54-.9 4.56-1.021 8.52-.6 11.64 1.32.42.18.479.659.301 1.02zm1.44-3.3c-.301.42-.841.6-1.262.3-3.239-1.98-8.159-2.58-11.939-1.38-.479.12-1.02-.12-1.14-.6-.12-.48.12-1.021.6-1.141C9.6 9.9 15 10.561 18.72 12.84c.361.181.54.78.241 1.2zm.12-3.36C15.24 8.4 8.82 8.16 5.16 9.301c-.6.179-1.2-.181-1.38-.721-.18-.601.18-1.2.72-1.381 4.26-1.26 11.28-1.02 15.721 1.621.539.3.719 1.02.419 1.56-.299.421-1.02.599-1.559.3z`})})}function eA(e){return(0,L.jsx)(`svg`,{viewBox:`0 0 24 24`,fill:`currentColor`,"aria-hidden":`true`,...e,children:(0,L.jsx)(`path`,{d:`M5.042 15.165a2.528 2.528 0 0 1-2.52 2.523A2.528 2.528 0 0 1 0 15.165a2.527 2.527 0 0 1 2.522-2.52h2.52v2.52zM6.313 15.165a2.527 2.527 0 0 1 2.521-2.52 2.527 2.527 0 0 1 2.521 2.52v6.313A2.528 2.528 0 0 1 8.834 24a2.528 2.528 0 0 1-2.521-2.522v-6.313zM8.834 5.042a2.528 2.528 0 0 1-2.521-2.52A2.528 2.528 0 0 1 8.834 0a2.528 2.528 0 0 1 2.521 2.522v2.52H8.834zM8.834 6.313a2.528 2.528 0 0 1 2.521 2.521 2.528 2.528 0 0 1-2.521 2.521H2.522A2.528 2.528 0 0 1 0 8.834a2.528 2.528 0 0 1 2.522-2.521h6.312zM18.956 8.834a2.528 2.528 0 0 1 2.522-2.521A2.528 2.528 0 0 1 24 8.834a2.528 2.528 0 0 1-2.522 2.521h-2.522V8.834zM17.688 8.834a2.528 2.528 0 0 1-2.523 2.521 2.527 2.527 0 0 1-2.52-2.521V2.522A2.527 2.527 0 0 1 15.165 0a2.528 2.528 0 0 1 2.523 2.522v6.312zM15.165 18.956a2.528 2.528 0 0 1 2.523 2.522A2.528 2.528 0 0 1 15.165 24a2.527 2.527 0 0 1-2.52-2.522v-2.522h2.52zM15.165 17.688a2.527 2.527 0 0 1-2.52-2.523 2.526 2.526 0 0 1 2.52-2.52h6.313A2.527 2.527 0 0 1 24 15.165a2.528 2.528 0 0 1-2.522 2.523h-6.313z`})})}function tA(e){return(0,L.jsx)(`svg`,{viewBox:`0 0 24 24`,fill:`currentColor`,"aria-hidden":`true`,...e,children:(0,L.jsx)(`path`,{d:`M0 0v11.408h11.408V0zm12.594 0v11.408H24V0zM0 12.594V24h11.408V12.594zm12.594 0V24H24V12.594z`})})}function nA(e){return(0,L.jsx)(`svg`,{viewBox:`0 0 24 24`,fill:`currentColor`,"aria-hidden":`true`,...e,children:(0,L.jsx)(`path`,{d:`M12.525.02c1.31-.02 2.61-.01 3.91-.02.08 1.53.63 3.09 1.75 4.17 1.12 1.11 2.7 1.62 4.24 1.79v4.03c-1.44-.05-2.89-.35-4.2-.97-.57-.26-1.1-.59-1.62-.93-.01 2.92.01 5.84-.02 8.75-.08 1.4-.54 2.79-1.35 3.94-1.31 1.92-3.58 3.17-5.91 3.21-1.43.08-2.86-.31-4.08-1.03-2.02-1.19-3.44-3.37-3.65-5.71-.02-.5-.03-1-.01-1.49.18-1.9 1.12-3.72 2.58-4.96 1.66-1.44 3.98-2.13 6.15-1.72.02 1.48-.04 2.96-.04 4.44-.99-.32-2.15-.23-3.02.37-.63.41-1.11 1.04-1.36 1.75-.21.51-.15 1.07-.14 1.61.24 1.64 1.82 3.02 3.5 2.87 1.12-.01 2.19-.66 2.77-1.61.19-.33.4-.67.41-1.06.1-1.79.06-3.57.07-5.36.01-4.03-.01-8.05.02-12.07z`})})}function rA(e){return(0,L.jsx)(`svg`,{viewBox:`0 0 24 24`,fill:`currentColor`,"aria-hidden":`true`,...e,children:(0,L.jsx)(`path`,{d:`M11.571 4.714h1.715v5.143H11.57zm4.715 0H18v5.143h-1.714zM6 0L1.714 4.286v15.428h5.143V24l4.286-4.286h3.428L22.286 12V0zm14.571 11.143l-3.428 3.428h-3.429l-3 3v-3H6.857V1.714h13.714Z`})})}function iA(e){return(0,L.jsx)(`svg`,{viewBox:`0 0 24 24`,fill:`currentColor`,"aria-hidden":`true`,...e,children:(0,L.jsx)(`path`,{d:`M12 0C5.373 0 0 5.373 0 12c0 3.314 1.343 6.314 3.515 8.485l-2.286 2.286C.775 23.225 1.097 24 1.738 24H12c6.627 0 12-5.373 12-12S18.627 0 12 0Zm4.388 3.199c1.104 0 1.999.895 1.999 1.999 0 1.105-.895 2-1.999 2-.946 0-1.739-.657-1.947-1.539v.002c-1.147.162-2.032 1.15-2.032 2.341v.007c1.776.067 3.4.567 4.686 1.363.473-.363 1.064-.58 1.707-.58 1.547 0 2.802 1.254 2.802 2.802 0 1.117-.655 2.081-1.601 2.531-.088 3.256-3.637 5.876-7.997 5.876-4.361 0-7.905-2.617-7.998-5.87-.954-.447-1.614-1.415-1.614-2.538 0-1.548 1.255-2.802 2.803-2.802.645 0 1.239.218 1.712.585 1.275-.79 2.881-1.291 4.64-1.365v-.01c0-1.663 1.263-3.034 2.88-3.207.188-.911.993-1.595 1.959-1.595Zm-8.085 8.376c-.784 0-1.459.78-1.506 1.797-.047 1.016.64 1.429 1.426 1.429.786 0 1.371-.369 1.418-1.385.047-1.017-.553-1.841-1.338-1.841Zm7.406 0c-.786 0-1.385.824-1.338 1.841.047 1.017.634 1.385 1.418 1.385.785 0 1.473-.413 1.426-1.429-.046-1.017-.721-1.797-1.506-1.797Zm-3.703 4.013c-.974 0-1.907.048-2.77.135-.147.015-.241.168-.183.305.483 1.154 1.622 1.964 2.953 1.964 1.33 0 2.47-.81 2.953-1.964.057-.137-.037-.29-.184-.305-.863-.087-1.795-.135-2.769-.135Z`})})}function aA({variant:e=`solid`,...t}){return(0,L.jsxs)(`svg`,{viewBox:`80 130 660 540`,fill:e===`outline`?`none`:`currentColor`,stroke:e===`outline`?`currentColor`:`none`,strokeWidth:e===`outline`?30:0,strokeLinejoin:`round`,"aria-hidden":`true`,...t,children:[(0,L.jsx)(`path`,{d:`M423.075867,410.677734 C415.744812,398.010834 408.757996,385.129547 401.024048,372.713654 C364.628082,314.284393 321.334015,261.391510 270.727295,214.787643 C248.109116,193.958496 223.566727,175.217773 199.869446,155.563187 C197.579849,153.664200 194.587845,152.249771 195.716263,148.465775 C196.920410,144.427872 200.280441,144.764893 203.488174,144.765289 C292.963928,144.776016 382.439758,144.824509 471.915405,144.718201 C484.221069,144.703568 493.629883,149.569427 501.132721,159.136398 C533.640015,200.586929 556.618713,246.732040 569.629150,298.312439 C533.666138,310.514313 503.289673,330.491913 477.096375,357.345367 C459.431030,375.455902 441.105896,392.922882 423.075867,410.677734 Z`}),(0,L.jsx)(`path`,{d:`M422.830688,410.940979 C441.105896,392.922882 459.431030,375.455902 477.096375,357.345367 C503.289673,330.491913 533.666138,310.514313 569.702515,298.715515 C580.198547,296.204865 590.355896,293.571594 600.645630,291.646088 C607.830872,290.301514 615.179077,289.508179 622.486084,289.187988 C658.613342,287.604736 693.312744,293.690247 726.160522,309.210541 C727.006897,309.610504 727.779114,310.167572 728.486572,310.591797 C721.357727,319.227142 714.146179,327.480194 707.472229,336.147247 C693.437988,354.372803 684.670654,375.585999 674.255493,395.854858 C664.208557,415.407074 654.319397,435.040466 644.235168,454.573242 C642.501099,457.931915 640.138306,460.965942 637.753174,464.651062 C637.353271,465.501129 637.266113,465.850037 637.178955,466.198975 C637.144836,466.101532 636.873535,466.197357 636.418274,466.883179 C636.234314,467.473175 636.050354,468.063171 636.095703,468.046875 C634.338562,469.671326 632.340759,471.098724 630.823547,472.925354 C606.515503,502.192047 575.273743,518.862732 537.765747,523.886047 C515.541626,526.862549 493.767212,524.121155 472.241058,518.067810 C427.652893,505.529175 384.314484,489.700134 341.877197,469.465759 C371.294128,453.300354 398.227325,434.038086 422.830688,410.940979 Z`}),(0,L.jsx)(`path`,{d:`M636.112793,468.058197 C633.715088,472.205719 631.564819,476.520996 628.883423,480.476196 C566.403625,572.638000 480.475006,628.906128 370.287048,645.529480 C276.822723,659.629700 189.911423,639.837524 110.412666,588.346252 C100.677971,582.041016 95.101463,573.316772 94.325127,561.051025 C94.781334,559.023499 94.988503,557.707825 94.988693,556.392212 C95.001305,471.848450 95.001999,387.304718 94.956757,302.760986 C94.955917,301.187042 94.391716,299.613464 94.090363,298.039703 C96.664818,292.874542 99.409256,292.618652 103.908760,297.033661 C118.990723,311.832458 133.654266,327.092285 149.266510,341.309784 C205.874664,392.860687 268.921021,434.931610 338.013672,467.929260 C339.056030,468.427094 340.173340,468.768005 341.877197,469.465759 C384.314484,489.700134 427.652893,505.529175 472.241058,518.067810 C493.767212,524.121155 515.541626,526.862549 537.765747,523.886047 C575.273743,518.862732 606.515503,502.192047 630.823547,472.925354 C632.340759,471.098724 634.338562,469.671326 636.112793,468.058197 Z`})]})}function oA(e){return(0,L.jsx)(`svg`,{viewBox:`0 0 24 24`,fill:`currentColor`,"aria-hidden":`true`,...e,children:(0,L.jsx)(`path`,{d:`M11.944 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0a12 12 0 0 0-.056 0zm4.962 7.224c.1-.002.321.023.465.14a.506.506 0 0 1 .171.325c.016.093.036.306.02.472-.18 1.898-.962 6.502-1.36 8.627-.168.9-.499 1.201-.82 1.23-.696.065-1.225-.46-1.9-.902-1.056-.693-1.653-1.124-2.678-1.8-1.185-.78-.417-1.21.258-1.91.177-.184 3.247-2.977 3.307-3.23.007-.032.014-.15-.056-.212s-.174-.041-.249-.024c-.106.024-1.793 1.14-5.061 3.345-.48.33-.913.49-1.302.48-.428-.008-1.252-.241-1.865-.44-.752-.245-1.349-.374-1.297-.789.027-.216.325-.437.893-.663 3.498-1.524 5.83-2.529 6.998-3.014 3.332-1.386 4.025-1.627 4.476-1.635z`})})}function sA(e){return(0,L.jsx)(`svg`,{viewBox:`0 0 24 24`,fill:`currentColor`,"aria-hidden":`true`,...e,children:(0,L.jsx)(`path`,{d:`M4.6035 0v24h4.9317V0zm9.8613 0v24h4.9317V0z`})})}function cA(e){return(0,L.jsx)(`svg`,{viewBox:`0 0 24 24`,fill:`currentColor`,"aria-hidden":`true`,...e,children:(0,L.jsx)(`path`,{d:`M12 0C5.381-.008.008 5.352 0 11.971V12c0 6.64 5.359 12 12 12 6.64 0 12-5.36 12-12 0-6.641-5.36-12-12-12zm0 20.801c-4.846.015-8.786-3.904-8.801-8.75V12c-.014-4.846 3.904-8.786 8.75-8.801H12c4.847-.014 8.786 3.904 8.801 8.75V12c.015 4.847-3.904 8.786-8.75 8.801H12zm5.44-11.76c0 1.359-1.12 2.479-2.481 2.479-1.366-.007-2.472-1.113-2.479-2.479 0-1.361 1.12-2.481 2.479-2.481 1.361 0 2.481 1.12 2.481 2.481zm0 5.919c0 1.36-1.12 2.48-2.481 2.48-1.367-.008-2.473-1.114-2.479-2.48 0-1.359 1.12-2.479 2.479-2.479 1.361-.001 2.481 1.12 2.481 2.479zm-5.919 0c0 1.36-1.12 2.48-2.479 2.48-1.368-.007-2.475-1.113-2.481-2.48 0-1.359 1.12-2.479 2.481-2.479 1.358-.001 2.479 1.12 2.479 2.479zm0-5.919c0 1.359-1.12 2.479-2.479 2.479-1.367-.007-2.475-1.112-2.481-2.479 0-1.361 1.12-2.481 2.481-2.481 1.358 0 2.479 1.12 2.479 2.481z`})})}function lA(e){return(0,L.jsx)(`svg`,{viewBox:`0 0 24 24`,fill:`currentColor`,"aria-hidden":`true`,...e,children:(0,L.jsx)(`path`,{d:`M6.763 10.036c0 .296.032.535.088.71.064.176.144.368.256.576.04.063.056.127.056.183 0 .08-.048.16-.152.24l-.503.335a.383.383 0 0 1-.208.072c-.08 0-.16-.04-.239-.112a2.47 2.47 0 0 1-.287-.375 6.18 6.18 0 0 1-.248-.471c-.622.734-1.405 1.101-2.347 1.101-.67 0-1.205-.191-1.596-.574-.391-.384-.59-.894-.59-1.533 0-.678.239-1.23.726-1.644.487-.415 1.133-.623 1.955-.623.272 0 .551.024.846.064.296.04.6.104.918.176v-.583c0-.607-.127-1.03-.375-1.277-.255-.248-.686-.367-1.3-.367-.28 0-.568.031-.863.103-.295.072-.583.16-.862.272a2.287 2.287 0 0 1-.28.104.488.488 0 0 1-.127.023c-.112 0-.168-.08-.168-.247v-.391c0-.128.016-.224.056-.28a.597.597 0 0 1 .224-.167c.279-.144.614-.264 1.005-.36a4.84 4.84 0 0 1 1.246-.151c.95 0 1.644.216 2.091.647.439.43.662 1.085.662 1.963v2.586zm-3.24 1.214c.263 0 .534-.048.822-.144.287-.096.543-.271.758-.51.128-.152.224-.32.272-.512.047-.191.08-.423.08-.694v-.335a6.66 6.66 0 0 0-.735-.136 6.02 6.02 0 0 0-.75-.048c-.535 0-.926.104-1.19.32-.263.215-.39.518-.39.917 0 .375.095.655.295.846.191.2.47.296.838.296zm6.41.862c-.144 0-.24-.024-.304-.08-.064-.048-.12-.16-.168-.311L7.586 5.55a1.398 1.398 0 0 1-.072-.32c0-.128.064-.2.191-.2h.783c.151 0 .255.025.31.08.065.048.113.16.16.312l1.342 5.284 1.245-5.284c.04-.16.088-.264.151-.312a.549.549 0 0 1 .32-.08h.638c.152 0 .256.025.32.08.063.048.12.16.151.312l1.261 5.348 1.381-5.348c.048-.16.104-.264.16-.312a.52.52 0 0 1 .311-.08h.743c.127 0 .2.065.2.2 0 .04-.009.08-.017.128a1.137 1.137 0 0 1-.056.2l-1.923 6.17c-.048.16-.104.263-.168.311a.51.51 0 0 1-.303.08h-.687c-.151 0-.255-.024-.32-.08-.063-.056-.119-.16-.15-.32l-1.238-5.148-1.23 5.14c-.04.16-.087.264-.15.32-.065.056-.177.08-.32.08zm10.256.215c-.415 0-.83-.048-1.229-.143-.399-.096-.71-.2-.918-.32-.128-.071-.215-.151-.247-.223a.563.563 0 0 1-.048-.224v-.407c0-.167.064-.247.183-.247.048 0 .096.008.144.024.048.016.12.048.2.08.271.12.566.215.878.279.319.064.63.096.95.096.502 0 .894-.088 1.165-.264a.86.86 0 0 0 .415-.758.777.777 0 0 0-.215-.559c-.144-.151-.416-.287-.807-.415l-1.157-.36c-.583-.183-1.014-.454-1.277-.813a1.902 1.902 0 0 1-.4-1.158c0-.335.073-.63.216-.886.144-.255.335-.479.575-.654.24-.184.51-.32.83-.415.32-.096.655-.136 1.006-.136.175 0 .359.008.535.032.183.024.35.056.518.088.16.04.312.08.455.127.144.048.256.096.336.144a.69.69 0 0 1 .24.2.43.43 0 0 1 .071.263v.375c0 .168-.064.256-.184.256a.83.83 0 0 1-.303-.096 3.652 3.652 0 0 0-1.532-.311c-.455 0-.815.071-1.062.223-.248.152-.375.383-.375.71 0 .224.08.416.24.567.159.152.454.304.877.44l1.134.358c.574.184.99.44 1.237.767.247.327.367.702.367 1.117 0 .343-.072.655-.207.926-.144.272-.336.511-.583.703-.248.2-.543.343-.886.447-.36.111-.734.167-1.142.167zM21.698 16.207c-2.626 1.94-6.442 2.969-9.722 2.969-4.598 0-8.74-1.7-11.87-4.526-.247-.223-.024-.527.272-.351 3.384 1.963 7.559 3.153 11.877 3.153 2.914 0 6.114-.607 9.06-1.852.439-.2.814.287.383.607zM22.792 14.961c-.336-.43-2.22-.207-3.074-.103-.255.032-.295-.192-.063-.36 1.5-1.053 3.967-.75 4.254-.399.287.36-.08 2.826-1.485 4.007-.215.184-.423.088-.327-.151.32-.79 1.03-2.57.695-2.994z`})})}function uA(e){return(0,L.jsxs)(`svg`,{viewBox:`0 0 24 24`,fill:`currentColor`,fillRule:`evenodd`,"aria-hidden":`true`,...e,children:[(0,L.jsx)(`path`,{d:`M9.046 7.104a.527.527 0 110 1.055.527.527 0 010-1.055z`}),(0,L.jsx)(`path`,{d:`M15.376 7.104a.528.528 0 110 1.056.528.528 0 010-1.056z`}),(0,L.jsx)(`path`,{clipRule:`evenodd`,d:`M16.877 1.912c.58-.27 1.14-.323 1.616-.037a.317.317 0 01-.326.542c-.227-.136-.547-.153-1.022.068-.352.165-.765.45-1.234.866 2.683 1.17 4.4 3.5 5.148 5.921a6.421 6.421 0 00-.704.184c-.578.016-1.174.204-1.502.735-.338.55-.268 1.276.072 2.069l.005.012.007.014c.523 1.045 1.318 1.91 2.2 2.284-.912 3.274-3.44 6.144-5.972 6.988v2.109h-2.11v-2.11c-1.043.417-2.086.01-2.11 0v2.11h-2.11v-2.11c-2.531-.843-5.061-3.713-5.973-6.987.882-.373 1.678-1.238 2.2-2.284l.007-.014.006-.012c.34-.793.41-1.518.071-2.069-.327-.531-.923-.719-1.503-.735a6.409 6.409 0 00-.704-.183c.749-2.421 2.466-4.751 5.149-5.922-.47-.416-.88-.701-1.234-.866-.474-.221-.794-.204-1.021-.068a.318.318 0 01-.435-.109.317.317 0 01.109-.433c.476-.286 1.036-.233 1.615.037.49.229 1.031.628 1.621 1.182A9.924 9.924 0 0112 2.568c1.199 0 2.284.19 3.256.526.59-.554 1.13-.953 1.62-1.182zM8.835 6.577a1.266 1.266 0 100 2.532 1.266 1.266 0 000-2.532zm6.33 0a1.267 1.267 0 100 2.533 1.267 1.267 0 000-2.533z`}),(0,L.jsx)(`path`,{d:`M.395 13.118c-.966-1.932-.163-3.863 2.41-3.365v-.001l.05.01c.084.018.17.038.26.06.033.009.067.017.1.027.084.022.168.048.255.076l.09.027c.528 0 .95.158 1.16.501.212.343.212.87-.105 1.61-.085.17-.178.333-.276.489l-.01.017a4.967 4.967 0 01-.62.791l-.019.02c-1.092 1.117-2.496 1.336-3.295-.262z`}),(0,L.jsx)(`path`,{d:`M21.193 9.753c2.574-.5 3.378 1.433 2.411 3.365-.58 1.159-1.476 1.361-2.342.96l-.011-.005a2.419 2.419 0 01-.114-.056l-.019-.01a2.751 2.751 0 01-.115-.067l-.023-.014c-.035-.022-.071-.044-.106-.068l-.05-.035c-.55-.388-1.062-1.007-1.44-1.76-.276-.647-.311-1.132-.174-1.472.176-.439.636-.639 1.23-.639.032-.011.066-.02.099-.03.08-.026.16-.05.238-.072l.117-.03a5.502 5.502 0 01.3-.067z`})]})}function dA({children:e,badge:t,className:n}){return(0,L.jsxs)(`span`,{className:`relative inline-flex shrink-0 items-center justify-center [&>svg]:!h-full [&>svg]:!w-full ${n??`h-5 w-5`}`,children:[e,(0,L.jsx)(`span`,{"aria-hidden":`true`,className:`absolute -bottom-[30%] -right-[30%] inline-flex h-[70%] w-[70%] items-center justify-center rounded-md bg-muted ring-2 ring-background [&>svg]:!h-full [&>svg]:!w-full`,children:t})]})}function fA({children:e,badge:t,className:n}){return(0,L.jsxs)(`span`,{className:`relative inline-flex shrink-0 items-center justify-center [&>svg]:!h-full [&>svg]:!w-full ${n??`h-5 w-5`}`,children:[e,(0,L.jsx)(`span`,{"aria-hidden":`true`,className:`absolute bottom-[2%] right-[3%] inline-flex h-[clamp(8px,30%,16px)] w-[clamp(8px,30%,16px)] items-center justify-center rounded-[2px] bg-muted p-px [&>svg]:!h-full [&>svg]:!w-full`,children:t})]})}function pA({className:e}){return(0,L.jsx)(Vk,{"data-slug":`llm-openai`,className:e})}function mA({className:e}){return(0,L.jsx)(dA,{className:e,badge:(0,L.jsx)(Kr,{className:`h-3.5 w-3.5`,strokeWidth:2.5}),children:(0,L.jsx)(Vk,{"data-slug":`llm-openai-codex`,className:`h-5 w-5`})})}function hA({className:e}){return(0,L.jsx)(Hk,{"data-slug":`llm-anthropic`,className:e})}function gA({className:e}){return(0,L.jsx)(dA,{className:e,badge:(0,L.jsx)(ai,{className:`h-3.5 w-3.5`,strokeWidth:2.5}),children:(0,L.jsx)(Yk,{"data-slug":`llm-google-ai`,className:`h-5 w-5`})})}function _A({className:e}){return(0,L.jsx)(Uk,{"data-slug":`llm-mistral`,className:e})}function vA({className:e}){return(0,L.jsx)(Wk,{"data-slug":`llm-cohere`,className:e})}function yA({className:e}){return(0,L.jsx)(Kk,{"data-slug":`llm-deepseek`,className:e})}function bA({className:e}){return(0,L.jsx)(uA,{"data-slug":`llm-openclaw`,className:e})}function xA({className:e}){return(0,L.jsx)(qk,{"data-slug":`llm-openrouter`,className:e})}function SA({className:e}){return(0,L.jsx)(Gk,{"data-slug":`api-firecrawl`,className:e})}function CA({className:e}){return(0,L.jsx)(Jk,{"data-slug":`api-twitter`,className:e})}function wA({className:e}){return(0,L.jsx)(Yk,{"data-slug":`api-google`,className:e})}function TA({className:e}){return(0,L.jsx)(dA,{className:e,badge:(0,L.jsx)(Lr,{strokeWidth:2.5}),children:(0,L.jsx)(Yk,{"data-slug":`api-google-workspace`,className:`h-full w-full`})})}function EA({className:e}){return(0,L.jsx)(`svg`,{viewBox:`0 0 24 24`,fill:`currentColor`,"aria-hidden":`true`,"data-slug":`api-google-calendar`,className:e,children:(0,L.jsx)(`path`,{d:`M18.316 5.684H24v12.632h-5.684V5.684zM5.684 24h12.632v-5.684H5.684V24zM18.316 5.684V0H1.895A1.894 1.894 0 0 0 0 1.895v16.421h5.684V5.684h12.632zm-7.207 6.25v-.065c.272-.144.5-.349.687-.617s.279-.595.279-.982c0-.379-.099-.72-.3-1.025a2.05 2.05 0 0 0-.832-.714 2.703 2.703 0 0 0-1.197-.257c-.6 0-1.094.156-1.481.467-.386.311-.65.671-.793 1.078l1.085.452c.086-.249.224-.461.413-.633.189-.172.445-.257.767-.257.33 0 .602.088.816.264a.86.86 0 0 1 .322.703c0 .33-.12.589-.36.778-.24.19-.535.284-.886.284h-.567v1.085h.633c.407 0 .748.109 1.02.327.272.218.407.499.407.843 0 .336-.129.614-.387.832s-.565.327-.924.327c-.351 0-.651-.103-.897-.311-.248-.208-.422-.502-.521-.881l-1.096.452c.178.616.505 1.082.977 1.401.472.319.984.478 1.538.477a2.84 2.84 0 0 0 1.293-.291c.382-.193.684-.458.902-.794.218-.336.327-.72.327-1.149 0-.429-.115-.797-.344-1.105a2.067 2.067 0 0 0-.881-.689zm2.093-1.931l.602.913L15 10.045v5.744h1.187V8.446h-.827l-2.158 1.557zM22.105 0h-3.289v5.184H24V1.895A1.894 1.894 0 0 0 22.105 0zm-3.289 23.5l4.684-4.684h-4.684V23.5zM0 22.105C0 23.152.848 24 1.895 24h3.289v-5.184H0v3.289z`})})}function DA({className:e}){return(0,L.jsx)(`svg`,{viewBox:`0 0 24 24`,fill:`currentColor`,"aria-hidden":`true`,"data-slug":`api-google-drive`,className:e,children:(0,L.jsx)(`path`,{d:`M12.01 1.485c-2.082 0-3.754.02-3.743.047.01.02 1.708 3.001 3.774 6.62l3.76 6.574h3.76c2.081 0 3.753-.02 3.742-.047-.005-.02-1.708-3.001-3.775-6.62l-3.76-6.574zm-4.76 1.73a789.828 789.861 0 0 0-3.63 6.319L0 15.868l1.89 3.298 1.885 3.297 3.62-6.335 3.618-6.33-1.88-3.287C8.1 4.704 7.255 3.22 7.25 3.214zm2.259 12.653-.203.348c-.114.198-.96 1.672-1.88 3.287a423.93 423.948 0 0 1-1.698 2.97c-.01.026 3.24.042 7.222.042h7.244l1.796-3.157c.992-1.734 1.85-3.23 1.906-3.323l.104-.167h-7.249z`})})}function OA({className:e}){return(0,L.jsx)(dA,{className:e,badge:(0,L.jsx)(ri,{strokeWidth:2.5}),children:(0,L.jsx)(Yk,{"data-slug":`api-google-gmail`,className:`h-full w-full`})})}function kA({className:e}){return(0,L.jsx)(`svg`,{viewBox:`0 0 24 24`,fill:`currentColor`,"aria-hidden":`true`,"data-slug":`api-google-docs`,className:e,children:(0,L.jsx)(`path`,{d:`M14.727 6.727H14V0H4.91c-.905 0-1.637.732-1.637 1.636v20.728c0 .904.732 1.636 1.636 1.636h14.182c.904 0 1.636-.732 1.636-1.636V6.727h-6zm-.545 10.455H7.09v-1.364h7.09v1.364zm2.727-3.273H7.091v-1.364h9.818v1.364zm0-3.273H7.091V9.273h9.818v1.363zM14.727 6h6l-6-6v6z`})})}function AA({className:e}){return(0,L.jsx)(`svg`,{viewBox:`0 0 24 24`,fill:`currentColor`,"aria-hidden":`true`,"data-slug":`api-google-sheets`,className:e,children:(0,L.jsx)(`path`,{d:`M11.318 12.545H7.91v-1.909h3.41v1.91zM14.728 0v6h6l-6-6zm1.363 10.636h-3.41v1.91h3.41v-1.91zm0 3.273h-3.41v1.91h3.41v-1.91zM20.727 6.5v15.864c0 .904-.732 1.636-1.636 1.636H4.909a1.636 1.636 0 0 1-1.636-1.636V1.636C3.273.732 4.005 0 4.909 0h9.318v6.5h6.5zm-3.273 2.773H6.545v7.909h10.91v-7.91zm-6.136 4.636H7.91v1.91h3.41v-1.91z`})})}function jA({className:e}){return(0,L.jsx)(`svg`,{viewBox:`0 0 24 24`,fill:`currentColor`,"aria-hidden":`true`,"data-slug":`api-google-slides`,className:e,children:(0,L.jsx)(`path`,{d:`M16.09 15.273H7.91v-4.637h8.18v4.637zm1.728-8.523h2.91v15.614c0 .904-.733 1.636-1.637 1.636H4.909a1.636 1.636 0 0 1-1.636-1.636V1.636C3.273.732 4.005 0 4.909 0h9.068v6.75h3.841zm-.363 2.523H6.545v7.363h10.91V9.273zm-2.728-5.979V6h6.001l-6-6v3.294z`})})}function MA({className:e}){return(0,L.jsx)(`svg`,{viewBox:`0 0 122.88 128.1`,fill:`currentColor`,"aria-hidden":`true`,"data-slug":`api-notion`,className:e,children:(0,L.jsx)(`path`,{fillRule:`evenodd`,d:`M21.19,22.46c4,3.23,5.48,3,13,2.49l70.53-4.24c1.5,0,.25-1.49-.25-1.74L92.72,10.5a14.08,14.08,0,0,0-11-3.23l-68.29,5c-2.49.24-3,1.49-2,2.49l9.73,7.72ZM25.42,38.9v74.21c0,4,2,5.48,6.48,5.23l77.52-4.48c4.49-.25,5-3,5-6.23V33.91c0-3.23-1.25-5-4-4.73l-81,4.73c-3,.25-4,1.75-4,5Zm76.53,4c.49,2.24,0,4.48-2.25,4.73L96,48.36v54.79c-3.24,1.74-6.23,2.73-8.72,2.73-4,0-5-1.24-8-5L54.83,62.55V99.66l7.73,1.74s0,4.48-6.23,4.48l-17.2,1c-.5-1,0-3.48,1.75-4l4.48-1.25V52.59l-6.23-.5a4.66,4.66,0,0,1,4.24-5.73l18.44-1.24L87.24,84V49.6l-6.48-.74a4.21,4.21,0,0,1,4-5l17.21-1ZM7.72,5.52l71-5.23C87.49-.46,89.73.05,95.21,4L117.89,20c3.74,2.74,5,3.48,5,6.47v87.42c0,5.47-2,8.71-9,9.21l-82.5,5c-5.24.25-7.73-.5-10.47-4L4.24,102.4c-3-4-4.24-7-4.24-10.46V14.24C0,9.76,2,6,7.72,5.52Z`})})}function NA({className:e}){return(0,L.jsx)(dA,{className:e,badge:(0,L.jsx)(Gr,{className:`h-2.5 w-2.5`,strokeWidth:2.5}),children:(0,L.jsx)(Yk,{"data-slug":`api-google-cloud`,className:`h-full w-full`})})}function PA({className:e}){return(0,L.jsx)(Xk,{"data-slug":`api-github`,className:e})}function FA({className:e}){return(0,L.jsx)(dA,{className:e,badge:(0,L.jsx)(ei,{className:`h-3.5 w-3.5`,strokeWidth:2.5}),children:(0,L.jsx)(Xk,{"data-slug":`api-github-pat`,className:`h-5 w-5`})})}function IA({className:e}){return(0,L.jsx)(Zk,{"data-slug":`api-facebook`,className:e})}function LA({className:e}){return(0,L.jsx)(Qk,{"data-slug":`api-discord`,className:e})}function RA({className:e}){return(0,L.jsx)(dA,{className:e,badge:(0,L.jsx)(Fr,{className:`h-3.5 w-3.5`,strokeWidth:2.5}),children:(0,L.jsx)(Qk,{"data-slug":`api-discord-bot`,className:`h-5 w-5`})})}function zA({className:e}){return(0,L.jsx)($k,{"data-slug":`api-spotify`,className:e})}function BA({className:e}){return(0,L.jsx)(eA,{"data-slug":`api-slack`,className:e})}function VA({className:e}){return(0,L.jsx)(dA,{className:e,badge:(0,L.jsx)(Fr,{className:`h-3.5 w-3.5`,strokeWidth:2.5}),children:(0,L.jsx)(eA,{"data-slug":`api-slack-bot`,className:`h-5 w-5`})})}function HA({className:e}){return(0,L.jsx)(tA,{"data-slug":`api-microsoft`,className:e})}function UA({className:e}){return(0,L.jsx)(nA,{"data-slug":`api-tiktok`,className:e})}function WA({className:e}){return(0,L.jsx)(rA,{"data-slug":`api-twitch`,className:e})}function GA({className:e}){return(0,L.jsx)(iA,{"data-slug":`api-reddit`,className:e})}function KA({className:e}){return(0,L.jsx)(aA,{variant:`solid`,"data-slug":`api-lark`,className:e})}function qA({className:e}){return(0,L.jsx)(dA,{className:e,badge:(0,L.jsx)(Fr,{className:`h-3.5 w-3.5`,strokeWidth:2.5}),children:(0,L.jsx)(aA,{variant:`solid`,"data-slug":`api-lark-bot`,className:`h-5 w-5`})})}function JA({className:e}){return(0,L.jsx)(aA,{variant:`outline`,"data-slug":`api-feishu`,className:e})}function YA({className:e}){return(0,L.jsx)(dA,{className:e,badge:(0,L.jsx)(Fr,{className:`h-3.5 w-3.5`,strokeWidth:2.5}),children:(0,L.jsx)(aA,{variant:`outline`,"data-slug":`api-feishu-bot`,className:`h-5 w-5`})})}function XA({className:e}){return(0,L.jsx)(dA,{className:e,badge:(0,L.jsx)(Fr,{className:`h-3.5 w-3.5`,strokeWidth:2.5}),children:(0,L.jsx)(oA,{"data-slug":`api-telegram-bot`,className:`h-5 w-5`})})}function ZA({className:e}){return(0,L.jsxs)(`svg`,{viewBox:`0 0 512 512`,fill:`currentColor`,"aria-hidden":`true`,"data-slug":`api-supabase`,className:e,children:[(0,L.jsx)(`path`,{d:`M297.6 501c-12.9 16.3-39.2 7.4-39.5-13.4L253.6 183h204.8c37.1 0 57.8 42.8 34.7 71.9z`}),(0,L.jsx)(`path`,{d:`M214.4 11c12.9-16.3 39.2-7.4 39.5 13.4l2 304.5H53.7c-37.1 0-57.8-42.8-34.7-71.9z`})]})}function QA({className:e}){return(0,L.jsx)(sA,{"data-slug":`api-elevenlabs`,className:e})}function $A({className:e}){return(0,L.jsxs)(`svg`,{viewBox:`0 -14 384 384`,fill:`currentColor`,"aria-hidden":`true`,"data-slug":`api-telnyx`,className:e,children:[(0,L.jsx)(`path`,{d:`M376.033 322.631C382.445 311.064 384.269 297.54 381.15 284.708C380.256 281.162 379.02 277.708 377.459 274.396C376.966 273.309 376.406 272.254 375.782 271.236L324.947 176.013H272.939L326.122 275.81C327.791 278.71 328.67 281.992 328.67 285.332C328.67 288.672 327.791 291.954 326.122 294.854C324.495 297.564 322.187 299.809 319.422 301.369C316.658 302.929 313.532 303.75 310.351 303.753H249.702C249.031 313.044 246.121 322.039 241.217 329.984C236.312 337.928 229.557 344.59 221.517 349.41H333C340.443 348.919 347.687 346.819 354.223 343.256C363.448 338.751 371.059 331.553 376.033 322.631Z`}),(0,L.jsx)(`path`,{d:`M90.9084 113.732H142.917L168.838 65.9127C171.031 61.7857 174.319 58.3308 178.347 55.9206C182.375 53.5105 186.991 52.2367 191.696 52.2367C196.401 52.2367 201.017 53.5105 205.046 55.9206C209.074 58.3308 212.362 61.7857 214.555 65.9127L239.721 113.732H291.729L254.82 44.5395C248.7 33.1074 239.556 23.5431 228.369 16.873C217.181 10.2029 204.373 6.67871 191.319 6.67871C178.264 6.67871 165.456 10.2029 154.269 16.873C143.082 23.5431 133.938 33.1074 127.818 44.5395L90.9084 113.732Z`}),(0,L.jsx)(`path`,{d:`M132.851 167.707C133.396 158.454 136.191 149.47 140.996 141.52C145.802 133.569 152.476 126.89 160.449 122.05H59.7869V167.707H132.851Z`}),(0,L.jsx)(`path`,{d:`M141.153 171.034V295.78H164.809C169.742 295.725 174.522 294.069 178.415 291.065C182.309 288.061 185.102 283.875 186.367 279.147C186.876 277.333 187.131 275.458 187.122 273.575V198.727C187.144 190.602 190.398 182.813 196.178 177.052C201.958 171.29 209.796 168.022 217.992 167.956H323.268V122.05H190.478C177.388 122.072 164.843 127.242 155.595 136.426C146.347 145.61 141.153 158.057 141.153 171.034Z`}),(0,L.jsx)(`path`,{d:`M5.17648 274.402C3.61573 277.714 2.37973 281.168 1.48554 284.714C-1.63066 297.573 0.193473 311.122 6.60253 322.72C11.5903 331.612 19.2003 338.779 28.4126 343.262C34.9489 346.825 42.1929 348.925 49.6356 349.416H191.317C204.666 349.416 217.468 344.159 226.907 334.801C236.346 325.443 241.648 312.751 241.648 299.518V175.936H217.657C211.759 176.086 206.152 178.505 202.019 182.679C197.887 186.854 195.553 192.458 195.512 198.307V273.154C195.489 281.279 192.218 289.063 186.416 294.8C180.613 300.537 172.753 303.759 164.558 303.759H72.7039C69.5232 303.756 66.3976 302.935 63.6332 301.375C60.8688 299.815 58.5603 297.57 56.9336 294.86C55.2645 291.944 54.387 288.649 54.387 285.296C54.387 281.944 55.2645 278.648 56.9336 275.733L110.117 175.936H57.6885L6.85418 271.159C6.26699 272.323 5.67979 273.321 5.17648 274.402Z`})]})}function ej({className:e}){return(0,L.jsx)(cA,{"data-slug":`api-twilio`,className:e})}function tj({className:e}){return(0,L.jsx)(`svg`,{viewBox:`0 0 390 388`,fill:`currentColor`,"aria-hidden":`true`,"data-slug":`api-aurinko`,className:e,children:(0,L.jsx)(`path`,{d:`M162.077 85.2174C163.314 84.8304 164.088 83.979 164.474 83.205L180.715 46.2078L203.452 79.5672C204.225 80.3412 205.076 81.1926 206.313 81.1926C207.55 81.1926 208.324 80.8056 209.561 79.9542L238.717 51.858L246.837 91.6416C247.224 92.88 247.61 93.654 248.848 94.041C249.621 94.428 250.859 94.815 252.096 94.428L290.223 79.5672L282.489 119.351C282.489 120.589 282.489 121.75 283.262 122.602C284.036 123.376 284.886 123.84 286.124 124.227L326.648 124.614L303.911 158.36C303.138 159.134 303.138 160.373 303.524 161.611C303.911 162.85 304.762 163.624 305.535 164.011L342.811 180.11L308.783 202.633C308.01 203.407 307.159 204.259 307.159 205.42C307.159 206.581 307.546 207.432 308.397 208.206L336.779 237.154L296.642 245.203C295.404 245.59 294.631 245.977 294.244 247.216C293.857 247.99 293.857 249.228 294.244 250.466L309.247 288.238L269.11 280.575C267.873 280.575 266.712 280.575 265.862 281.349C265.088 282.123 264.624 282.974 264.238 284.135L263.464 324.77L230.21 302.247C229.437 301.473 228.199 301.473 226.962 301.86C225.724 302.247 224.951 303.098 224.564 303.872L208.324 340.87L185.587 307.123C184.814 306.349 183.963 305.498 182.726 305.498C181.488 305.498 180.715 305.885 179.477 306.736L150.322 334.832L142.202 295.049C141.815 293.81 141.428 293.036 140.191 292.649C139.417 292.262 138.18 292.262 136.943 292.649L98.8161 307.51L106.55 267.727C106.936 266.488 106.55 265.327 105.776 264.476C105.003 263.702 104.152 263.237 102.915 263.237L62.0041 262.463L84.7409 228.717C85.1276 227.943 85.5143 226.705 85.1276 225.466C84.7409 224.228 83.8902 223.454 83.1168 223.067L45.8409 206.968L79.8687 184.444C80.6421 183.67 81.4928 182.819 81.4928 181.658C81.4928 180.419 81.1061 179.645 80.2554 178.407L51.8731 149.459L92.0105 141.41C93.2479 141.023 94.0212 140.636 94.4079 139.397C94.7946 138.623 95.1813 137.385 94.4079 136.147L79.4047 98.3754L119.542 106.038C120.779 106.038 121.94 106.038 122.79 105.264C123.564 104.49 124.028 103.639 124.028 102.478L124.801 62.307L158.829 84.8304C159.602 85.2174 160.066 85.6044 160.84 85.6044C161.304 85.6044 161.69 85.2174 162.077 85.2174V85.2174ZM193.707 0C192.47 0.387 191.697 1.2384 191.31 2.0124L179.168 30.186L161.69 4.7988C160.453 3.1734 158.055 2.7864 156.431 4.0248C154.807 5.2632 154.421 7.6626 155.194 9.288L175.069 38.5452L158.829 75.9294L124.801 53.406L125.188 18.0342C125.188 16.0218 123.564 14.3964 121.553 14.0094C120.779 14.0094 119.929 14.3964 119.155 14.7834C118.382 15.5574 117.918 16.4088 117.918 17.5698L117.531 48.1428L91.5465 31.347C89.9224 30.1086 87.525 30.573 86.2876 32.5854C85.0502 34.2108 85.5143 36.6102 87.525 37.8486L117.531 57.5082L116.758 98.5302L76.2339 90.8676L63.1642 57.8952C62.3908 55.8828 60.3027 55.1088 58.292 55.8828C57.5186 56.2698 56.6679 56.6568 56.2813 57.5082C55.8946 58.2822 55.5079 59.5206 56.2813 60.759L67.6497 89.3196L37.2566 83.2824C35.2459 82.8954 33.2351 84.0564 32.7711 86.0688C32.3844 88.0812 33.5445 90.0936 35.6326 90.4806L70.8978 97.2918L85.9009 135.063L45.7636 143.113L21.0161 117.803C19.392 116.177 16.9946 116.177 15.7572 117.803C14.9838 118.577 14.5198 119.428 14.5198 120.202C14.5198 121.441 14.9065 122.215 15.7572 123.453L37.2566 145.512L6.86357 151.549C4.85283 151.936 3.61546 153.949 4.00214 155.961C4.38882 157.973 6.39955 159.212 8.48762 158.747L43.3662 151.549L72.1351 180.884L38.1073 203.794L5.16218 189.707C3.15144 188.933 1.14071 189.707 0.29001 191.72C-0.09667 192.494 -0.09667 193.345 0.29001 194.119C0.67669 195.358 1.52739 196.132 2.30075 196.519L30.6831 208.593L5.16218 225.853C2.30075 227.092 1.91407 229.104 3.15144 231.116C4.38882 232.742 6.78623 233.129 8.41029 232.355L38.03 212.695L75.7699 228.794L53.0331 262.928L17.3813 262.541C15.3705 262.541 13.7465 264.166 13.3598 266.179C13.3598 266.953 13.7465 267.804 14.1332 268.578C14.9065 268.965 16.1439 269.352 17.3813 269.352L48.161 269.739L31.1471 295.436C29.9097 297.061 30.3737 299.461 32.3844 300.699C34.0085 301.937 36.4059 301.473 37.6433 299.461L57.5186 269.739L98.8934 270.513L91.1598 310.684L57.9053 323.532C55.8946 324.306 55.0439 326.318 55.8946 328.331C56.6679 330.343 58.756 331.117 60.7667 330.343L89.5357 319.12L83.0395 349.693C82.6528 351.706 83.8129 353.718 85.9009 354.105C87.9117 354.492 89.9224 353.331 90.3864 351.319L97.2693 316.334L135.396 301.473L143.516 341.257L117.995 365.792C116.371 367.418 116.371 369.817 117.995 371.056C119.619 372.681 122.017 372.681 123.254 371.056L145.527 349.771L151.637 379.957C152.023 381.969 154.034 383.207 156.122 382.743C158.133 382.356 159.37 380.344 158.983 378.331L151.714 343.346L181.334 314.786L204.457 348.532L190.691 381.892C189.918 383.904 190.691 385.916 192.702 386.69C194.713 387.464 196.723 386.69 197.574 384.678L209.716 356.504L227.116 381.814C228.354 383.44 230.751 383.827 232.375 383.053C233.999 381.814 234.386 379.415 233.613 377.789L213.737 348.455L229.978 311.071L264.47 333.594L264.083 368.966C264.083 370.978 265.707 372.604 267.718 372.991C269.729 372.991 271.353 371.365 271.739 369.353L272.126 338.78L298.034 355.653C299.658 356.891 302.055 356.427 303.292 354.415C304.53 352.789 304.066 350.39 302.055 349.151L271.662 329.492L272.435 288.47L312.959 296.132L325.952 329.105C326.725 331.117 328.813 331.891 330.824 331.117C332.835 330.343 333.685 328.331 332.835 326.318L321.466 297.758L351.859 303.795C353.87 304.182 355.881 303.021 356.345 301.009C356.732 298.996 355.572 296.984 353.483 296.597L318.218 289.786L303.215 252.014L343.352 243.965L368.1 269.275C369.724 270.9 372.121 270.9 373.359 269.275C374.983 267.649 374.983 265.25 373.359 264.011L351.859 241.875L382.252 235.838C384.263 235.451 385.501 233.438 385.114 231.426C384.727 229.414 382.716 228.175 380.628 228.64L345.75 235.838L316.981 206.503L351.009 183.593L383.876 197.68C385.887 198.454 387.898 197.68 388.749 195.667C389.522 193.655 388.749 191.642 386.738 190.868L358.356 178.794L383.876 161.534C385.501 160.295 385.887 157.896 385.114 156.271C383.876 154.645 381.479 154.258 379.855 155.032L350.235 174.692L312.495 158.593L335.619 124.459L371.271 124.846C373.281 124.846 374.906 123.221 375.292 121.208C375.292 119.196 373.668 117.571 371.657 117.184L340.878 116.797L357.892 91.0998C359.129 89.4744 358.665 87.075 356.654 85.8366C355.03 84.5982 352.633 85.0626 351.395 87.075L331.52 116.797L290.145 116.023L297.879 75.852L331.133 63.0036C333.144 62.2296 333.995 60.2172 333.144 58.2048C332.757 57.4308 332.371 56.5794 331.52 56.1924C330.747 55.8054 329.509 55.4184 328.272 56.1924L299.503 67.4154L305.612 37.2294C305.999 35.217 304.839 33.2046 302.751 32.8176C300.74 32.4306 298.73 33.5916 298.266 35.604L291.383 70.5888L253.256 85.4496L245.136 45.279L270.657 21.1302C272.281 19.5048 272.281 17.1054 270.657 15.867C269.883 15.093 269.033 14.6286 268.259 14.6286C267.022 14.6286 266.248 15.0156 265.398 15.867L243.125 37.3842L237.015 7.1982C236.629 5.1858 234.618 3.9474 232.53 4.4118C230.442 4.8762 229.282 6.8112 229.669 8.8236L236.938 43.4214L207.318 71.982L184.35 38.1582L198.502 5.5728C199.275 3.5604 198.502 1.548 196.491 0.774C196.105 0 195.254 0 194.867 0H193.707V0Z`})})}function nj({className:e}){return(0,L.jsxs)(`svg`,{width:`122`,height:`37`,viewBox:`0 0 203 52`,fill:`none`,xmlns:`http://www.w3.org/2000/svg`,"aria-hidden":`true`,"data-slug":`api-ifttt`,className:e,children:[(0,L.jsx)(`title`,{children:`IFTTT`}),(0,L.jsx)(`desc`,{children:`IFTTT`}),(0,L.jsx)(`path`,{d:`M109.374-.25H68.0791V15.3654H80.3558V52.1734H97.0968V15.3654H109.374V-.25ZM156.249-.25H114.954V15.3654H127.231V52.1734H143.972V15.3654H156.249V-.25ZM203.123-.25H161.829V15.3654H174.105V52.1734H190.846V15.3654H203.123V-.25ZM16.741-.25H0V52.1734H16.741V-.25ZM62.4997-.25H24.5535V52.1734H41.2945V37.6734H55.8033V20.9425H41.2945V15.3654H62.4997V-.25Z`,fill:`currentColor`})]})}function rj({className:e}){return(0,L.jsxs)(`svg`,{width:`122`,height:`37`,viewBox:`0 0 203 52`,fill:`none`,xmlns:`http://www.w3.org/2000/svg`,"aria-hidden":`true`,"data-slug":`api-ifttt-mcp`,className:e,children:[(0,L.jsx)(`title`,{children:`IFTTT`}),(0,L.jsx)(`desc`,{children:`IFTTT`}),(0,L.jsx)(`path`,{d:`M109.374-.25H68.0791V15.3654H80.3558V52.1734H97.0968V15.3654H109.374V-.25ZM156.249-.25H114.954V15.3654H127.231V52.1734H143.972V15.3654H156.249V-.25ZM203.123-.25H161.829V15.3654H174.105V52.1734H190.846V15.3654H203.123V-.25ZM16.741-.25H0V52.1734H16.741V-.25ZM62.4997-.25H24.5535V52.1734H41.2945V37.6734H55.8033V20.9425H41.2945V15.3654H62.4997V-.25Z`,fill:`currentColor`})]})}function ij({className:e}){return(0,L.jsx)(dA,{className:e,badge:(0,L.jsx)(zr,{className:`h-3.5 w-3.5`,strokeWidth:2.5}),children:(0,L.jsx)(lA,{"data-slug":`aws-cost-explorer`,className:`h-5 w-5`})})}function aj({className:e}){return(0,L.jsxs)(`svg`,{viewBox:`0 0 155.343 151`,className:e,fill:`currentColor`,"data-slug":`aevatar`,"aria-hidden":`true`,children:[(0,L.jsx)(`path`,{d:`M69.2512 59.3131H57.5239V13.1698H41.5711V45.3592H11.8454V59.3291H0V32.6979H28.6214V0.23637C28.9318 0.212513 29.2172 0.185583 29.4858 0.160233C30.0282 0.109045 30.5024 0.0642984 30.9766 0.0642984C34.8603 0.0578252 38.7436 0.0559593 42.6267 0.0540935C50.7461 0.0501922 58.8649 0.0462912 66.9856 0.000271626C68.7622 -0.0117334 69.4182 0.368426 69.4101 2.29323C69.3484 16.1531 69.3521 30.0111 69.3557 43.8699C69.3569 48.3252 69.3581 52.7806 69.3571 57.2362C69.3571 57.6665 69.3284 58.0973 69.2971 58.5651C69.2813 58.8018 69.2648 59.0483 69.2512 59.3091V59.3131Z`}),(0,L.jsx)(`path`,{d:`M93.9689 83.7439V83.7479L93.9648 83.7439H93.9689Z`}),(0,L.jsx)(`path`,{d:`M93.9689 83.7439V72.159H143.294V45.4719H113.565V13.1383H97.844V59.3056H85.9334V0.204906H126.368V32.5424H155.343V119.371H126.364V151H85.9742V93.9121H97.6851V138.259H113.479V107.01H143.249V83.7439H93.9689Z`}),(0,L.jsx)(`path`,{d:`M0.0570124 72.1751H60.6452V83.676H11.988V106.89H41.404V138.223H57.3894V93.8642H69.1859V150.944H28.8373V119.483H0.0570124V72.1751Z`}),(0,L.jsx)(`path`,{d:`M69.4222 83.6277H85.1917V68.3493H69.4222V83.6277Z`})]})}function oj({className:e}){return(0,L.jsx)(sj,{className:e,"data-slug":`cma`})}function sj(e){return(0,L.jsxs)(`svg`,{viewBox:`0 0 100 100`,"aria-hidden":`true`,...e,children:[(0,L.jsx)(`path`,{d:`M71 71 A30 30 0 1 0 29 71`,fill:`none`,stroke:`currentColor`,strokeWidth:`11`,strokeLinecap:`round`}),(0,L.jsx)(`path`,{d:`M71 71 L61 59 L50 71 L39 59 L29 71`,fill:`none`,stroke:`currentColor`,strokeWidth:`10`,strokeLinecap:`round`,strokeLinejoin:`round`}),(0,L.jsx)(`rect`,{x:`30`,y:`38`,width:`17`,height:`12`,rx:`4`,fill:`currentColor`}),(0,L.jsx)(`rect`,{x:`53`,y:`38`,width:`17`,height:`12`,rx:`4`,fill:`currentColor`}),(0,L.jsx)(`path`,{d:`M46 44 L54 44`,fill:`none`,stroke:`currentColor`,strokeWidth:`4`})]})}function cj({className:e}){return(0,L.jsxs)(`svg`,{viewBox:`0 0 64 64`,className:e,fill:`currentColor`,"data-slug":`cmaeg`,"aria-hidden":`true`,children:[(0,L.jsx)(`path`,{d:`M6 60V27L19 9L30 3.9V19L19 31V60Z`}),(0,L.jsx)(`path`,{d:`M58 60V27L45 9L34 3.9V19L45 31V60Z`}),(0,L.jsx)(`path`,{d:`M32 29L39 38V49L32 58L25 49V38Z`})]})}function lj({className:e}){return(0,L.jsx)(fA,{className:e,badge:(0,L.jsx)(Zr,{strokeWidth:2.5}),children:(0,L.jsx)(sj,{"data-slug":`cma-trigger-github-observer-staging`,className:`h-full w-full`})})}function uj(e){return(0,L.jsx)(`svg`,{viewBox:`0 0 747 444`,fill:`currentColor`,"aria-hidden":`true`,...e,children:(0,L.jsx)(`path`,{pathLength:1,d:`M216.2,411.1 C229.3,406.1 244.4,393.4 256.2,377.5 C272.6,355.6 284.6,329.3 304.5,271.5 C306.6,265.4 308.6,260.1 309.0,259.7 C309.4,259.3 313.0,264.4 317.0,271.2 C340.1,310.0 355.7,323.1 377.4,321.8 C394.8,320.7 406.0,311.3 425.4,281.8 C431.8,272.0 437.2,264.0 437.4,264.0 C437.6,264.0 439.1,267.7 440.5,272.2 C457.3,323.1 472.1,355.1 490.1,378.6 C513.2,409.1 541.4,420.8 565.7,410.0 L570.1,408.1 L560.1,398.3 C535.3,373.8 520.7,340.3 495.0,249.0 C490.1,231.7 484.5,212.3 482.6,206.0 L479.1,194.5 L481.1,190.5 C486.7,179.6 503.7,161.4 515.4,153.6 C525.3,147.1 530.1,145.6 542.0,145.6 C554.6,145.5 563.6,148.3 575.5,156.1 C588.4,164.5 612.0,189.2 612.0,194.3 C612.0,195.4 609.5,200.4 606.4,205.6 C595.9,223.3 590.4,238.8 578.8,283.5 C566.8,330.3 560.7,347.8 551.0,363.6 C548.2,368.1 546.0,372.4 546.0,373.1 C546.0,376.8 560.0,393.8 567.4,399.2 C569.5,400.7 572.4,402.0 573.7,402.0 C581.3,402.0 598.4,373.4 608.5,344.1 C614.8,325.6 619.6,307.0 628.0,269.7 C635.5,236.3 637.2,229.5 638.2,229.5 C638.5,229.5 644.4,236.1 651.3,244.1 C670.0,265.8 679.6,273.9 691.6,278.1 C698.0,280.4 707.7,280.6 712.5,278.6 C719.4,275.7 718.4,270.4 706.5,246.0 C681.7,195.5 664.5,178.0 639.5,178.0 L631.0,178.0 L626.2,171.3 C606.4,143.4 580.8,119.2 563.6,112.1 C555.5,108.8 542.7,108.1 534.4,110.5 C516.6,115.5 494.8,136.6 478.7,164.2 C475.8,169.0 473.2,172.6 472.9,172.2 C472.5,171.8 468.5,161.6 464.0,149.5 C447.3,104.0 438.3,84.1 426.8,66.9 C420.3,57.2 410.7,45.6 406.7,42.6 C404.0,40.6 403.9,40.6 400.4,42.5 C393.3,46.3 374.0,69.2 374.0,73.7 C374.0,74.8 376.4,80.8 379.4,87.1 C391.3,112.3 402.5,144.6 420.1,204.5 L431.2,242.5 L429.6,247.7 C426.1,258.8 410.8,277.3 398.7,285.1 C378.1,298.4 356.3,293.8 335.2,271.6 C327.3,263.2 314.0,245.1 314.0,242.5 C314.0,241.6 316.7,230.8 320.1,218.7 C340.3,145.0 347.4,122.6 359.1,95.6 C369.1,72.3 373.4,65.7 387.8,51.0 C394.5,44.2 399.8,38.0 399.6,37.3 C398.5,34.5 382.9,30.7 372.5,30.7 C336.4,30.6 308.6,65.6 282.0,144.6 C275.1,165.0 273.5,168.9 272.7,168.0 C272.5,167.7 268.7,162.1 264.3,155.5 C242.8,123.4 223.6,109.0 202.1,109.0 C177.1,109.0 155.8,124.7 126.3,164.7 L115.8,179.0 L107.1,179.0 C92.9,179.0 83.6,182.7 74.8,191.7 C58.3,208.7 29.1,261.9 30.2,273.1 C30.6,277.9 36.0,280.5 45.2,280.4 C60.6,280.1 79.2,266.8 97.9,242.6 C109.5,227.6 108.1,227.2 112.9,246.7 C115.1,255.9 119.5,274.9 122.5,288.8 C132.7,335.0 141.0,359.7 153.9,381.2 C161.2,393.5 169.2,403.0 172.2,403.0 C177.3,403.0 185.3,395.4 195.0,381.3 L200.0,374.2 L194.1,362.8 C183.5,342.6 178.2,327.3 166.5,282.0 C156.1,242.1 149.3,223.0 138.9,204.6 C136.1,199.8 134.1,195.3 134.3,194.6 C135.3,192.2 153.2,172.2 158.7,167.4 C176.7,151.7 191.8,145.2 208.0,146.2 C225.4,147.3 238.8,155.9 256.7,177.4 C268.0,190.9 267.9,190.4 263.7,204.1 C261.7,210.4 255.2,232.6 249.1,253.5 C222.6,345.2 205.5,383.4 183.4,400.4 C176.2,405.9 175.7,407.3 180.0,409.5 C189.6,414.4 205.5,415.1 216.2,411.1 Z M70.0,238.5 C70.0,235.0 86.4,209.0 93.2,201.9 C98.4,196.4 99.6,198.5 95.4,205.8 C89.7,215.8 70.1,241.0 70.0,238.5 Z M671.8,232.8 C662.9,222.2 648.0,201.5 648.0,199.8 C648.0,198.3 650.7,199.0 653.4,201.1 C657.8,204.6 678.9,238.0 676.7,238.0 C676.4,238.0 674.2,235.6 671.8,232.8 Z`})})}function dj({slug:e,badge:t,className:n}){return(0,L.jsx)(fA,{className:n,badge:t,children:(0,L.jsx)(uj,{"data-slug":e,className:`h-full w-full`})})}function fj({className:e}){return(0,L.jsx)(dj,{className:e,badge:(0,L.jsx)(Fr,{strokeWidth:2.5}),slug:`chrono-llm-public`})}function pj({className:e}){return(0,L.jsx)(dj,{className:e,badge:(0,L.jsx)(Ir,{strokeWidth:2.5}),slug:`chrono-sandbox`})}function mj({className:e}){return(0,L.jsx)(dj,{className:e,badge:(0,L.jsx)(Jr,{strokeWidth:2.5}),slug:`chrono-storage-service`})}function hj({className:e}){return(0,L.jsxs)(`span`,{className:`relative inline-flex shrink-0 ${e??`h-5 w-5`}`,children:[(0,L.jsx)(`svg`,{viewBox:`0 0 424 424`,className:`h-full w-full`,fill:`currentColor`,"data-slug":`llm-nyx`,"aria-hidden":`true`,children:(0,L.jsx)(`path`,{d:`M422.875 88.0461V335.824C422.875 383.898 383.903 422.87 335.829 422.87H214.328C213.008 422.87 211.938 421.799 211.938 420.48V191.899C211.938 189.461 208.72 188.587 207.487 190.69L72.0088 421.69C71.5786 422.421 70.7947 422.87 69.9486 422.87H3.39006C2.07075 422.87 1 421.799 1 420.48V3.39006C1 2.07075 2.07075 1 3.39006 1H139.237C140.556 1 141.627 2.07075 141.627 3.39006V231.971C141.627 234.409 144.844 235.284 146.077 233.18L281.56 2.18069C281.99 1.44933 282.774 1 283.62 1H335.824C383.898 1 422.87 39.9724 422.87 88.0461H422.875Z`})}),(0,L.jsx)(Fr,{"aria-hidden":`true`,className:`absolute bottom-[12%] right-[9%] !h-[35%] !w-[35%] text-background`,strokeWidth:2.5})]})}function gj({className:e}){return(0,L.jsx)(`svg`,{viewBox:`0 0 64 64`,className:e,fill:`currentColor`,"data-slug":`ornn-api`,"aria-hidden":`true`,children:(0,L.jsx)(`path`,{fillRule:`evenodd`,d:`M63.39,38.24 L59.46,37.46 A28,28 0 0,1 55.28,47.56 L58.61,49.78 A32,32 0 0,1 49.78,58.61 L47.56,55.28 A28,28 0 0,1 37.46,59.46 L38.24,63.39 A32,32 0 0,1 25.76,63.39 L26.54,59.46 A28,28 0 0,1 16.44,55.28 L14.22,58.61 A32,32 0 0,1 5.39,49.78 L8.72,47.56 A28,28 0 0,1 4.54,37.46 L0.61,38.24 A32,32 0 0,1 0.61,25.76 L4.54,26.54 A28,28 0 0,1 8.72,16.44 L5.39,14.22 A32,32 0 0,1 14.22,5.39 L16.44,8.72 A28,28 0 0,1 26.54,4.54 L25.76,0.61 A32,32 0 0,1 38.24,0.61 L37.46,4.54 A28,28 0 0,1 47.56,8.72 L49.78,5.39 A32,32 0 0,1 58.61,14.22 L55.28,16.44 A28,28 0 0,1 59.46,26.54 L63.39,25.76 A32,32 0 0,1 63.39,38.24 Z M46,32 A14,14 0 1,0 18,32 A14,14 0 1,0 46,32 Z`})})}function _j({className:e}){return(0,L.jsxs)(`svg`,{viewBox:`0 0 424 424`,className:e,fill:`currentColor`,"data-slug":`talos`,"aria-hidden":`true`,children:[(0,L.jsx)(`path`,{d:`M1 1H336C384 1 423 40 423 88V141H71C32 141 1 110 1 71Z`}),(0,L.jsx)(`path`,{d:`M142 165H282V353C282 392 251 423 212 423H142Z`})]})}var vj={"llm-openai":pA,"llm-openai-codex":mA,"llm-anthropic":hA,"llm-google-ai":gA,"llm-mistral":_A,"llm-cohere":vA,"llm-deepseek":yA,"llm-openclaw":bA,"llm-openrouter":xA,"api-firecrawl":SA,"api-twitter":CA,"api-google":wA,"api-google-workspace":TA,"api-google-calendar":EA,"api-google-drive":DA,"api-google-gmail":OA,"api-google-docs":kA,"api-google-sheets":AA,"api-google-slides":jA,"api-google-cloud":NA,"api-notion":MA,"api-github":PA,"api-github-pat":FA,"api-facebook":IA,"api-discord":LA,"api-discord-bot":RA,"api-spotify":zA,"api-slack":BA,"api-slack-bot":VA,"api-microsoft":HA,"api-tiktok":UA,"api-twitch":WA,"api-reddit":GA,"api-lark":KA,"api-lark-bot":qA,"api-feishu":JA,"api-feishu-bot":YA,"api-telegram-bot":XA,"api-supabase":ZA,"api-elevenlabs":QA,"api-telnyx":$A,telnyx:$A,"platform-telnyx":$A,"api-twilio":ej,"api-aurinko":tj,aurinko:tj,"api-ifttt":nj,"api-ifttt-mcp":rj,"aws-cost-explorer":ij,aevatar:aj,cma:oj,cmaeg:cj,"cma-trigger-github-observer-staging":lj,"chrono-llm-public":fj,"chrono-sandbox":pj,"chrono-storage-service":mj,"llm-nyx":hj,"ornn-api":gj,talos:_j};function yj({className:e}){return(0,L.jsx)(Qr,{className:e,"aria-hidden":`true`,"data-fallback":`true`})}var bj={"2xs":`!h-3.5 !w-3.5`,xs:`!h-4 !w-4`,sm:`!h-5 !w-5`,md:`!h-6 !w-6`,lg:`!h-8 !w-8`,xl:`!h-9 !w-9`};function xj(e){if(e.length>2048||e.includes(`#`))return null;try{let t=new URL(e);return t.protocol!==`http:`&&t.protocol!==`https:`||!t.hostname||t.username||t.password?null:t.href}catch{return null}}function Sj({slug:e,iconUrl:t,size:n=`sm`,className:r}){let i=t?xj(t):null;return i?(0,L.jsx)(Cj,{slug:e,iconUrl:i,size:n,className:r},i):!e&&!t?null:(0,L.jsx)(vj[e??`custom`]??yj,{className:Tr(bj[n],`shrink-0 text-muted-foreground`,r)})}function Cj({slug:e,iconUrl:t,size:n,className:r}){let[i,a]=(0,I.useState)(!1);return i?(0,L.jsx)(Sj,{slug:e??`custom`,size:n,className:r}):(0,L.jsx)(`img`,{src:t,alt:``,"aria-hidden":`true`,referrerPolicy:`no-referrer`,onError:()=>a(!0),className:Tr(bj[n],`shrink-0 object-contain`,r)})}var wj={"claude-code":`llm-anthropic`,codex:`llm-openai-codex`,openclaw:`llm-openclaw`},Tj={"2xs":`!h-3.5 !w-3.5`,xs:`!h-4 !w-4`,sm:`!h-5 !w-5`,md:`!h-6 !w-6`,lg:`!h-8 !w-8`,xl:`!h-9 !w-9`};function Ej(e){return(0,L.jsx)(`svg`,{viewBox:`0 0 24 24`,fill:`currentColor`,fillRule:`evenodd`,"aria-hidden":`true`,...e,children:(0,L.jsx)(`path`,{d:`M22.106 5.68L12.5.135a.998.998 0 00-.998 0L1.893 5.68a.84.84 0 00-.419.726v11.186c0 .3.16.577.42.727l9.607 5.547a.999.999 0 00.998 0l9.608-5.547a.84.84 0 00.42-.727V6.407a.84.84 0 00-.42-.726zm-.603 1.176L12.228 22.92c-.063.108-.228.064-.228-.061V12.34a.59.59 0 00-.295-.51l-9.11-5.26c-.107-.062-.063-.228.062-.228h18.55c.264 0 .428.286.296.514z`})})}function Dj({platform:e,size:t=`xs`,className:n}){if(!e||e===`__none__`)return null;let r=wj[e];if(r)return(0,L.jsx)(Sj,{slug:r,size:t,className:n});let i=Tr(Tj[t],`shrink-0 text-muted-foreground`,n);return e===`cursor`?(0,L.jsx)(Ej,{className:i}):(0,L.jsx)(Fr,{className:i,"aria-hidden":`true`})}var Oj=I.forwardRef(({className:e,type:t,...n},r)=>(0,L.jsx)(`input`,{type:t,className:Tr(`flex h-8 w-full rounded-lg border border-input bg-transparent px-3 py-1.5 text-[12px] text-foreground transition-colors duration-200 file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-text-tertiary focus-visible:outline-none focus-visible:border-white/[0.15] aria-invalid:border-destructive aria-invalid:focus-visible:border-destructive disabled:cursor-not-allowed disabled:opacity-50`,e),ref:r,...n}));Oj.displayName=`Input`;function kj(e,t=[]){let n=[];function r(t,r){let i=I.createContext(r),a=n.length;n=[...n,r];let o=t=>{let{scope:n,children:r,...o}=t,s=n?.[e]?.[a]||i,c=I.useMemo(()=>o,Object.values(o));return(0,L.jsx)(s.Provider,{value:c,children:r})};o.displayName=t+`Provider`;function s(n,o){let s=o?.[e]?.[a]||i,c=I.useContext(s);if(c)return c;if(r!==void 0)return r;throw Error(`\`${n}\` must be used within \`${t}\``)}return[o,s]}let i=()=>{let t=n.map(e=>I.createContext(e));return function(n){let r=n?.[e]||t;return I.useMemo(()=>({[`__scope${e}`]:{...n,[e]:r}}),[n,r])}};return i.scopeName=e,[r,Aj(i,...t)]}function Aj(...e){let t=e[0];if(e.length===1)return t;let n=()=>{let n=e.map(e=>({useScope:e(),scopeName:e.scopeName}));return function(e){let r=n.reduce((t,{useScope:n,scopeName:r})=>{let i=n(e)[`__scope${r}`];return{...t,...i}},{});return I.useMemo(()=>({[`__scope${t.scopeName}`]:r}),[r])}};return n.scopeName=t.scopeName,n}function jj(e){let t=I.useRef({value:e,previous:e});return I.useMemo(()=>(t.current.value!==e&&(t.current.previous=t.current.value,t.current.value=e),t.current.previous),[e])}function Mj(e){let t=Nj(e),n=I.forwardRef((e,n)=>{let{children:r,...i}=e,a=I.Children.toArray(r),o=a.find(Fj);if(o){let e=o.props.children,r=a.map(t=>t===o?I.Children.count(e)>1?I.Children.only(null):I.isValidElement(e)?e.props.children:null:t);return(0,L.jsx)(t,{...i,ref:n,children:I.isValidElement(e)?I.cloneElement(e,void 0,r):null})}return(0,L.jsx)(t,{...i,ref:n,children:r})});return n.displayName=`${e}.Slot`,n}function Nj(e){let t=I.forwardRef((e,t)=>{let{children:n,...r}=e;if(I.isValidElement(n)){let e=Lj(n),i=Ij(r,n.props);return n.type!==I.Fragment&&(i.ref=t?It(t,e):e),I.cloneElement(n,i)}return I.Children.count(n)>1?I.Children.only(null):null});return t.displayName=`${e}.SlotClone`,t}var Pj=Symbol(`radix.slottable`);function Fj(e){return I.isValidElement(e)&&typeof e.type==`function`&&`__radixId`in e.type&&e.type.__radixId===Pj}function Ij(e,t){let n={...t};for(let r in t){let i=e[r],a=t[r];/^on[A-Z]/.test(r)?i&&a?n[r]=(...e)=>{let t=a(...e);return i(...e),t}:i&&(n[r]=i):r===`style`?n[r]={...i,...a}:r===`className`&&(n[r]=[i,a].filter(Boolean).join(` `))}return{...e,...n}}function Lj(e){let t=Object.getOwnPropertyDescriptor(e.props,`ref`)?.get,n=t&&`isReactWarning`in t&&t.isReactWarning;return n?e.ref:(t=Object.getOwnPropertyDescriptor(e,`ref`)?.get,n=t&&`isReactWarning`in t&&t.isReactWarning,n?e.props.ref:e.props.ref||e.ref)}var Rj=[`a`,`button`,`div`,`form`,`h2`,`h3`,`img`,`input`,`label`,`li`,`nav`,`ol`,`p`,`select`,`span`,`svg`,`ul`].reduce((e,t)=>{let n=Mj(`Primitive.${t}`),r=I.forwardRef((e,r)=>{let{asChild:i,...a}=e,o=i?n:t;return typeof window<`u`&&(window[Symbol.for(`radix-ui`)]=!0),(0,L.jsx)(o,{...a,ref:r})});return r.displayName=`Primitive.${t}`,{...e,[t]:r}},{}),zj=`Switch`,[Bj,Vj]=kj(zj),[Hj,Uj]=Bj(zj),Wj=I.forwardRef((e,t)=>{let{__scopeSwitch:n,name:r,checked:i,defaultChecked:a,required:o,disabled:s,value:c=`on`,onCheckedChange:l,form:u,...d}=e,[f,p]=I.useState(null),m=Lt(t,e=>p(e)),h=I.useRef(!1),g=f?u||!!f.closest(`form`):!0,[_,v]=$E({prop:i,defaultProp:a??!1,onChange:l,caller:zj});return(0,L.jsxs)(Hj,{scope:n,checked:_,disabled:s,children:[(0,L.jsx)(Rj.button,{type:`button`,role:`switch`,"aria-checked":_,"aria-required":o,"data-state":Yj(_),"data-disabled":s?``:void 0,disabled:s,value:c,...d,ref:m,onClick:kS(e.onClick,e=>{v(e=>!e),g&&(h.current=e.isPropagationStopped(),h.current||e.stopPropagation())})}),g&&(0,L.jsx)(Jj,{control:f,bubbles:!h.current,name:r,value:c,checked:_,required:o,disabled:s,form:u,style:{transform:`translateX(-100%)`}})]})});Wj.displayName=zj;var Gj=`SwitchThumb`,Kj=I.forwardRef((e,t)=>{let{__scopeSwitch:n,...r}=e,i=Uj(Gj,n);return(0,L.jsx)(Rj.span,{"data-state":Yj(i.checked),"data-disabled":i.disabled?``:void 0,...r,ref:t})});Kj.displayName=Gj;var qj=`SwitchBubbleInput`,Jj=I.forwardRef(({__scopeSwitch:e,control:t,checked:n,bubbles:r=!0,...i},a)=>{let o=I.useRef(null),s=Lt(o,a),c=jj(n),l=cE(t);return I.useEffect(()=>{let e=o.current;if(!e)return;let t=window.HTMLInputElement.prototype,i=Object.getOwnPropertyDescriptor(t,`checked`).set;if(c!==n&&i){let t=new Event(`click`,{bubbles:r});i.call(e,n),e.dispatchEvent(t)}},[c,n,r]),(0,L.jsx)(`input`,{type:`checkbox`,"aria-hidden":!0,defaultChecked:n,...i,tabIndex:-1,ref:s,style:{...i.style,...l,position:`absolute`,pointerEvents:`none`,opacity:0,margin:0}})});Jj.displayName=qj;function Yj(e){return e?`checked`:`unchecked`}var Xj=Wj,Zj=Kj,Qj=I.forwardRef(({className:e,...t},n)=>(0,L.jsx)(Xj,{className:Tr(`peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors duration-300 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-muted`,e),...t,ref:n,children:(0,L.jsx)(Zj,{className:Tr(`pointer-events-none block h-4 w-4 rounded-full shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=checked]:bg-white data-[state=unchecked]:translate-x-0 data-[state=unchecked]:bg-muted-foreground`)})}));Qj.displayName=Xj.displayName;function $j(e,t=[]){let n=[];function r(t,r){let i=I.createContext(r),a=n.length;n=[...n,r];let o=t=>{let{scope:n,children:r,...o}=t,s=n?.[e]?.[a]||i,c=I.useMemo(()=>o,Object.values(o));return(0,L.jsx)(s.Provider,{value:c,children:r})};o.displayName=t+`Provider`;function s(n,o){let s=o?.[e]?.[a]||i,c=I.useContext(s);if(c)return c;if(r!==void 0)return r;throw Error(`\`${n}\` must be used within \`${t}\``)}return[o,s]}let i=()=>{let t=n.map(e=>I.createContext(e));return function(n){let r=n?.[e]||t;return I.useMemo(()=>({[`__scope${e}`]:{...n,[e]:r}}),[n,r])}};return i.scopeName=e,[r,eM(i,...t)]}function eM(...e){let t=e[0];if(e.length===1)return t;let n=()=>{let n=e.map(e=>({useScope:e(),scopeName:e.scopeName}));return function(e){let r=n.reduce((t,{useScope:n,scopeName:r})=>{let i=n(e)[`__scope${r}`];return{...t,...i}},{});return I.useMemo(()=>({[`__scope${t.scopeName}`]:r}),[r])}};return n.scopeName=t.scopeName,n}function tM(e){let t=nM(e),n=I.forwardRef((e,n)=>{let{children:r,...i}=e,a=I.Children.toArray(r),o=a.find(aM);if(o){let e=o.props.children,r=a.map(t=>t===o?I.Children.count(e)>1?I.Children.only(null):I.isValidElement(e)?e.props.children:null:t);return(0,L.jsx)(t,{...i,ref:n,children:I.isValidElement(e)?I.cloneElement(e,void 0,r):null})}return(0,L.jsx)(t,{...i,ref:n,children:r})});return n.displayName=`${e}.Slot`,n}function nM(e){let t=I.forwardRef((e,t)=>{let{children:n,...r}=e;if(I.isValidElement(n)){let e=sM(n),i=oM(r,n.props);return n.type!==I.Fragment&&(i.ref=t?It(t,e):e),I.cloneElement(n,i)}return I.Children.count(n)>1?I.Children.only(null):null});return t.displayName=`${e}.SlotClone`,t}var rM=Symbol(`radix.slottable`);function iM(e){let t=({children:e})=>(0,L.jsx)(L.Fragment,{children:e});return t.displayName=`${e}.Slottable`,t.__radixId=rM,t}function aM(e){return I.isValidElement(e)&&typeof e.type==`function`&&`__radixId`in e.type&&e.type.__radixId===rM}function oM(e,t){let n={...t};for(let r in t){let i=e[r],a=t[r];/^on[A-Z]/.test(r)?i&&a?n[r]=(...e)=>{let t=a(...e);return i(...e),t}:i&&(n[r]=i):r===`style`?n[r]={...i,...a}:r===`className`&&(n[r]=[i,a].filter(Boolean).join(` `))}return{...e,...n}}function sM(e){let t=Object.getOwnPropertyDescriptor(e.props,`ref`)?.get,n=t&&`isReactWarning`in t&&t.isReactWarning;return n?e.ref:(t=Object.getOwnPropertyDescriptor(e,`ref`)?.get,n=t&&`isReactWarning`in t&&t.isReactWarning,n?e.props.ref:e.props.ref||e.ref)}var cM=[`a`,`button`,`div`,`form`,`h2`,`h3`,`img`,`input`,`label`,`li`,`nav`,`ol`,`p`,`select`,`span`,`svg`,`ul`].reduce((e,t)=>{let n=tM(`Primitive.${t}`),r=I.forwardRef((e,r)=>{let{asChild:i,...a}=e,o=i?n:t;return typeof window<`u`&&(window[Symbol.for(`radix-ui`)]=!0),(0,L.jsx)(o,{...a,ref:r})});return r.displayName=`Primitive.${t}`,{...e,[t]:r}},{});function lM(e){let t=uM(e),n=I.forwardRef((e,n)=>{let{children:r,...i}=e,a=I.Children.toArray(r),o=a.find(fM);if(o){let e=o.props.children,r=a.map(t=>t===o?I.Children.count(e)>1?I.Children.only(null):I.isValidElement(e)?e.props.children:null:t);return(0,L.jsx)(t,{...i,ref:n,children:I.isValidElement(e)?I.cloneElement(e,void 0,r):null})}return(0,L.jsx)(t,{...i,ref:n,children:r})});return n.displayName=`${e}.Slot`,n}function uM(e){let t=I.forwardRef((e,t)=>{let{children:n,...r}=e;if(I.isValidElement(n)){let e=mM(n),i=pM(r,n.props);return n.type!==I.Fragment&&(i.ref=t?It(t,e):e),I.cloneElement(n,i)}return I.Children.count(n)>1?I.Children.only(null):null});return t.displayName=`${e}.SlotClone`,t}var dM=Symbol(`radix.slottable`);function fM(e){return I.isValidElement(e)&&typeof e.type==`function`&&`__radixId`in e.type&&e.type.__radixId===dM}function pM(e,t){let n={...t};for(let r in t){let i=e[r],a=t[r];/^on[A-Z]/.test(r)?i&&a?n[r]=(...e)=>{let t=a(...e);return i(...e),t}:i&&(n[r]=i):r===`style`?n[r]={...i,...a}:r===`className`&&(n[r]=[i,a].filter(Boolean).join(` `))}return{...e,...n}}function mM(e){let t=Object.getOwnPropertyDescriptor(e.props,`ref`)?.get,n=t&&`isReactWarning`in t&&t.isReactWarning;return n?e.ref:(t=Object.getOwnPropertyDescriptor(e,`ref`)?.get,n=t&&`isReactWarning`in t&&t.isReactWarning,n?e.props.ref:e.props.ref||e.ref)}var hM=[`a`,`button`,`div`,`form`,`h2`,`h3`,`img`,`input`,`label`,`li`,`nav`,`ol`,`p`,`select`,`span`,`svg`,`ul`].reduce((e,t)=>{let n=lM(`Primitive.${t}`),r=I.forwardRef((e,r)=>{let{asChild:i,...a}=e,o=i?n:t;return typeof window<`u`&&(window[Symbol.for(`radix-ui`)]=!0),(0,L.jsx)(o,{...a,ref:r})});return r.displayName=`Primitive.${t}`,{...e,[t]:r}},{}),gM=Object.freeze({position:`absolute`,border:0,width:1,height:1,padding:0,margin:-1,overflow:`hidden`,clip:`rect(0, 0, 0, 0)`,whiteSpace:`nowrap`,wordWrap:`normal`}),_M=`VisuallyHidden`,vM=I.forwardRef((e,t)=>(0,L.jsx)(hM.span,{...e,ref:t,style:{...gM,...e.style}}));vM.displayName=_M;var yM=vM,[bM,xM]=$j(`Tooltip`,[dE]),SM=dE(),CM=`TooltipProvider`,wM=700,TM=`tooltip.open`,[EM,DM]=bM(CM),OM=e=>{let{__scopeTooltip:t,delayDuration:n=wM,skipDelayDuration:r=300,disableHoverableContent:i=!1,children:a}=e,o=I.useRef(!0),s=I.useRef(!1),c=I.useRef(0);return I.useEffect(()=>{let e=c.current;return()=>window.clearTimeout(e)},[]),(0,L.jsx)(EM,{scope:t,isOpenDelayedRef:o,delayDuration:n,onOpen:I.useCallback(()=>{window.clearTimeout(c.current),o.current=!1},[]),onClose:I.useCallback(()=>{window.clearTimeout(c.current),c.current=window.setTimeout(()=>o.current=!0,r)},[r]),isPointerInTransitRef:s,onPointerInTransitChange:I.useCallback(e=>{s.current=e},[]),disableHoverableContent:i,children:a})};OM.displayName=CM;var kM=`Tooltip`,[AM,jM]=bM(kM),MM=e=>{let{__scopeTooltip:t,children:n,open:r,defaultOpen:i,onOpenChange:a,disableHoverableContent:o,delayDuration:s}=e,c=DM(kM,e.__scopeTooltip),l=SM(t),[u,d]=I.useState(null),f=AC(),p=I.useRef(0),m=o??c.disableHoverableContent,h=s??c.delayDuration,g=I.useRef(!1),[_,v]=$E({prop:r,defaultProp:i??!1,onChange:e=>{e?(c.onOpen(),document.dispatchEvent(new CustomEvent(TM))):c.onClose(),a?.(e)},caller:kM}),y=I.useMemo(()=>_?g.current?`delayed-open`:`instant-open`:`closed`,[_]),b=I.useCallback(()=>{window.clearTimeout(p.current),p.current=0,g.current=!1,v(!0)},[v]),x=I.useCallback(()=>{window.clearTimeout(p.current),p.current=0,v(!1)},[v]),S=I.useCallback(()=>{window.clearTimeout(p.current),p.current=window.setTimeout(()=>{g.current=!0,v(!0),p.current=0},h)},[h,v]);return I.useEffect(()=>()=>{p.current&&=(window.clearTimeout(p.current),0)},[]),(0,L.jsx)(DE,{...l,children:(0,L.jsx)(AM,{scope:t,contentId:f,open:_,stateAttribute:y,trigger:u,onTriggerChange:d,onTriggerEnter:I.useCallback(()=>{c.isOpenDelayedRef.current?S():b()},[c.isOpenDelayedRef,S,b]),onTriggerLeave:I.useCallback(()=>{m?x():(window.clearTimeout(p.current),p.current=0)},[x,m]),onOpen:b,onClose:x,disableHoverableContent:m,children:n})})};MM.displayName=kM;var NM=`TooltipTrigger`,PM=I.forwardRef((e,t)=>{let{__scopeTooltip:n,...r}=e,i=jM(NM,n),a=DM(NM,n),o=SM(n),s=Lt(t,I.useRef(null),i.onTriggerChange),c=I.useRef(!1),l=I.useRef(!1),u=I.useCallback(()=>c.current=!1,[]);return I.useEffect(()=>()=>document.removeEventListener(`pointerup`,u),[u]),(0,L.jsx)(OE,{asChild:!0,...o,children:(0,L.jsx)(cM.button,{"aria-describedby":i.open?i.contentId:void 0,"data-state":i.stateAttribute,...r,ref:s,onPointerMove:kS(e.onPointerMove,e=>{e.pointerType!==`touch`&&!l.current&&!a.isPointerInTransitRef.current&&(i.onTriggerEnter(),l.current=!0)}),onPointerLeave:kS(e.onPointerLeave,()=>{i.onTriggerLeave(),l.current=!1}),onPointerDown:kS(e.onPointerDown,()=>{i.open&&i.onClose(),c.current=!0,document.addEventListener(`pointerup`,u,{once:!0})}),onFocus:kS(e.onFocus,()=>{c.current||i.onOpen()}),onBlur:kS(e.onBlur,i.onClose),onClick:kS(e.onClick,i.onClose)})})});PM.displayName=NM;var FM=`TooltipPortal`,[IM,LM]=bM(FM,{forceMount:void 0}),RM=e=>{let{__scopeTooltip:t,forceMount:n,children:r,container:i}=e,a=jM(FM,t);return(0,L.jsx)(IM,{scope:t,forceMount:n,children:(0,L.jsx)(VE,{present:n||a.open,children:(0,L.jsx)(zE,{asChild:!0,container:i,children:r})})})};RM.displayName=FM;var zM=`TooltipContent`,BM=I.forwardRef((e,t)=>{let n=LM(zM,e.__scopeTooltip),{forceMount:r=n.forceMount,side:i=`top`,...a}=e,o=jM(zM,e.__scopeTooltip);return(0,L.jsx)(VE,{present:r||o.open,children:o.disableHoverableContent?(0,L.jsx)(GM,{side:i,...a,ref:t}):(0,L.jsx)(VM,{side:i,...a,ref:t})})}),VM=I.forwardRef((e,t)=>{let n=jM(zM,e.__scopeTooltip),r=DM(zM,e.__scopeTooltip),i=I.useRef(null),a=Lt(t,i),[o,s]=I.useState(null),{trigger:c,onClose:l}=n,u=i.current,{onPointerInTransitChange:d}=r,f=I.useCallback(()=>{s(null),d(!1)},[d]),p=I.useCallback((e,t)=>{let n=e.currentTarget,r={x:e.clientX,y:e.clientY},i=YM(r,JM(r,n.getBoundingClientRect())),a=XM(t.getBoundingClientRect());s(QM([...i,...a])),d(!0)},[d]);return I.useEffect(()=>()=>f(),[f]),I.useEffect(()=>{if(c&&u){let e=e=>p(e,u),t=e=>p(e,c);return c.addEventListener(`pointerleave`,e),u.addEventListener(`pointerleave`,t),()=>{c.removeEventListener(`pointerleave`,e),u.removeEventListener(`pointerleave`,t)}}},[c,u,p,f]),I.useEffect(()=>{if(o){let e=e=>{let t=e.target,n={x:e.clientX,y:e.clientY},r=c?.contains(t)||u?.contains(t),i=!ZM(n,o);r?f():i&&(f(),l())};return document.addEventListener(`pointermove`,e),()=>document.removeEventListener(`pointermove`,e)}},[c,u,o,l,f]),(0,L.jsx)(GM,{...e,ref:a})}),[HM,UM]=bM(kM,{isInside:!1}),WM=iM(`TooltipContent`),GM=I.forwardRef((e,t)=>{let{__scopeTooltip:n,children:r,"aria-label":i,onEscapeKeyDown:a,onPointerDownOutside:o,...s}=e,c=jM(zM,n),l=SM(n),{onClose:u}=c;return I.useEffect(()=>(document.addEventListener(TM,u),()=>document.removeEventListener(TM,u)),[u]),I.useEffect(()=>{if(c.trigger){let e=e=>{e.target?.contains(c.trigger)&&u()};return window.addEventListener(`scroll`,e,{capture:!0}),()=>window.removeEventListener(`scroll`,e,{capture:!0})}},[c.trigger,u]),(0,L.jsx)(JS,{asChild:!0,disableOutsidePointerEvents:!1,onEscapeKeyDown:a,onPointerDownOutside:o,onFocusOutside:e=>e.preventDefault(),onDismiss:u,children:(0,L.jsxs)(kE,{"data-state":c.stateAttribute,...l,...s,ref:t,style:{...s.style,"--radix-tooltip-content-transform-origin":`var(--radix-popper-transform-origin)`,"--radix-tooltip-content-available-width":`var(--radix-popper-available-width)`,"--radix-tooltip-content-available-height":`var(--radix-popper-available-height)`,"--radix-tooltip-trigger-width":`var(--radix-popper-anchor-width)`,"--radix-tooltip-trigger-height":`var(--radix-popper-anchor-height)`},children:[(0,L.jsx)(WM,{children:r}),(0,L.jsx)(HM,{scope:n,isInside:!0,children:(0,L.jsx)(yM,{id:c.contentId,role:`tooltip`,children:i||r})})]})})});BM.displayName=zM;var KM=`TooltipArrow`,qM=I.forwardRef((e,t)=>{let{__scopeTooltip:n,...r}=e,i=SM(n);return UM(KM,n).isInside?null:(0,L.jsx)(AE,{...i,...r,ref:t})});qM.displayName=KM;function JM(e,t){let n=Math.abs(t.top-e.y),r=Math.abs(t.bottom-e.y),i=Math.abs(t.right-e.x),a=Math.abs(t.left-e.x);switch(Math.min(n,r,i,a)){case a:return`left`;case i:return`right`;case n:return`top`;case r:return`bottom`;default:throw Error(`unreachable`)}}function YM(e,t,n=5){let r=[];switch(t){case`top`:r.push({x:e.x-n,y:e.y+n},{x:e.x+n,y:e.y+n});break;case`bottom`:r.push({x:e.x-n,y:e.y-n},{x:e.x+n,y:e.y-n});break;case`left`:r.push({x:e.x+n,y:e.y-n},{x:e.x+n,y:e.y+n});break;case`right`:r.push({x:e.x-n,y:e.y-n},{x:e.x-n,y:e.y+n});break}return r}function XM(e){let{top:t,right:n,bottom:r,left:i}=e;return[{x:i,y:t},{x:n,y:t},{x:n,y:r},{x:i,y:r}]}function ZM(e,t){let{x:n,y:r}=e,i=!1;for(let e=0,a=t.length-1;er!=d>r&&n<(u-c)*(r-l)/(d-l)+c&&(i=!i)}return i}function QM(e){let t=e.slice();return t.sort((e,t)=>e.xt.x?1:e.yt.y)),$M(t)}function $M(e){if(e.length<=1)return e.slice();let t=[];for(let n=0;n=2;){let e=t[t.length-1],n=t[t.length-2];if((e.x-n.x)*(r.y-n.y)>=(e.y-n.y)*(r.x-n.x))t.pop();else break}t.push(r)}t.pop();let n=[];for(let t=e.length-1;t>=0;t--){let r=e[t];for(;n.length>=2;){let e=n[n.length-1],t=n[n.length-2];if((e.x-t.x)*(r.y-t.y)>=(e.y-t.y)*(r.x-t.x))n.pop();else break}n.push(r)}return n.pop(),t.length===1&&n.length===1&&t[0].x===n[0].x&&t[0].y===n[0].y?t:t.concat(n)}var eN=OM,tN=MM,nN=PM,rN=RM,iN=BM,aN=eN,oN=tN,sN=nN,cN=I.forwardRef(({className:e,sideOffset:t=4,...n},r)=>(0,L.jsx)(rN,{children:(0,L.jsx)(iN,{ref:r,sideOffset:t,className:Tr(`z-[100] overflow-hidden rounded-[6px] bg-muted px-3 py-1.5 text-xs text-foreground shadow-lg shadow-primary/5`,`data-[state=delayed-open]:animate-in data-[state=instant-open]:animate-in`,`data-[state=closed]:animate-out`,`data-[state=delayed-open]:fade-in-0 data-[state=instant-open]:fade-in-0`,`data-[state=closed]:fade-out-0`,`data-[state=delayed-open]:zoom-in-95 data-[state=instant-open]:zoom-in-95`,`data-[state=closed]:zoom-out-95`,`data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2`,e),...n})}));cN.displayName=iN.displayName;async function lN(e){try{await Wy.post(`/cli-pairings/${encodeURIComponent(e)}/reserve-action`,{})}catch(e){if(e instanceof Ly&&(e.status===409||e.status===404))throw Error(`This pairing was already completed or started in another tab. Close this tab and check your CLI — if the CLI didn't finish the flow, run the command again for a fresh pairing.`);let t=e instanceof Error?e.message:String(e);throw Error(`Couldn't reserve this pairing with NyxID (${t}). Try again; if the problem persists, cancel and re-run the CLI command.`)}}async function uN(e){try{await Wy.post(`/cli-pairings/${encodeURIComponent(e)}/rewind-action`,{})}catch{}}async function dN(e,t){try{return await t()}catch(t){throw t instanceof Ly&&t.status>=400&&t.status<500&&await uN(e),t}}var fN=/^[a-z0-9-]+$/,pN=$().min(1,`Node name is required`).max(64,`Node name must be 64 characters or fewer`).regex(fN,`Lowercase letters, digits, and hyphens only`);$().min(1,`Slug is required`).max(64,`Slug must be 64 characters or fewer`).regex(fN,`Lowercase letters, digits, and hyphens only`).refine(e=>!e.startsWith(`-`)&&!e.endsWith(`-`),{message:`Slug must not start or end with a hyphen`}).refine(e=>!e.includes(`--`),{message:`Slug must not contain consecutive hyphens`});var mN=$().min(1,`Name is required`).max(200,`Name must be 200 characters or fewer`);$().min(1,`Label is required`).max(200,`Label must be 200 characters or fewer`);var hN=[`claude-code`,`cursor`,`codex`,`openclaw`,`generic`];sx([_x(``),hx(hN)]);var gN={slug:$().optional(),label:$().optional(),via_node:$().optional(),org_id:$().uuid().optional(),endpoint_url:$().optional(),custom:Zb().optional(),custom_slug:$().optional(),auth_method:$().optional(),auth_key_name:$().optional(),reconnect_key_id:$().optional(),scope_override:rx($()).optional()};ax(gN);function _N(e){if(!e||typeof e!=`object`||Array.isArray(e))return{};let t=e,n={};for(let e of Object.keys(gN)){if(!Object.prototype.hasOwnProperty.call(t,e))continue;let r=gN[e].safeParse(t[e]);r.success&&r.data!==void 0&&(n[e]=r.data)}return n}function vN(e,t){let n=e.safeParse(t);return n.success?null:n.error.issues[0]?.message??`Invalid value`}function yN({label:e,schema:t,value:n,onChange:r,onValidityChange:i,hint:a,placeholder:o,optional:s=!1,autoFocus:c,autoComplete:l=`off`,id:u}){let d=(0,I.useId)(),f=u??d,p=s&&n.length===0?null:vN(t,n);(0,I.useEffect)(()=>{i&&i(p===null)},[p,i]);let m=`${f}-hint`,h=`${f}-error`;return(0,L.jsxs)(`div`,{className:`flex flex-col gap-1.5`,children:[(0,L.jsx)(vi,{htmlFor:f,children:e}),(0,L.jsx)(Oj,{id:f,value:n,onChange:e=>{r(e.target.value)},placeholder:o,autoFocus:c,autoComplete:l,"aria-invalid":p!=null,"aria-describedby":p?h:a?m:void 0,className:p==null?void 0:`border-destructive focus-visible:border-destructive`}),p?(0,L.jsx)(`p`,{id:h,className:`text-xs text-destructive`,children:p}):a?(0,L.jsx)(`p`,{id:m,className:`text-xs text-muted-foreground`,children:a}):null]})}var bN=new Set([`http:`,`https:`]),xN=/^\d{1,3}(\.\d{1,3}){3}$/,SN=/^([a-z0-9]([a-z0-9-]*[a-z0-9])?\.)+[a-z]{2,}$/i;function CN(e){let t;try{t=new URL(e)}catch{return!1}if(!bN.has(t.protocol))return!1;if(t.hostname.startsWith(`[`))return!0;let n=t.hostname.replace(/\.$/,``).toLowerCase();return n.length===0?!1:n===`localhost`||xN.test(n)?!0:SN.test(n)}var wN=[`read`,`write`,`admin`,`openid`,`profile`,`email`,`services:read`,`services:write`,`proxy`];ax({name:$().min(1,`Name is required`).max(64,`Name must be at most 64 characters`).refine(e=>e.trim().length>0,`Name must not be blank`),scopes:rx(hx(wN)).min(1,`At least one scope is required`),expires_at:$().nullable().optional().refine(e=>{if(e==null||e===``)return!0;let t=/^\d{4}-\d{2}-\d{2}$/.test(e)?new Date(`${e}T23:59:59Z`):new Date(e);return Number.isNaN(t.getTime())?!1:t.getTime()>Date.now()},{message:`Expiry date must be in the future`}),description:$().nullable().optional(),allow_all_services:Zb().optional(),allow_auto_connected_services:Zb().optional(),allow_all_nodes:Zb().optional(),allowed_service_ids:rx($()).optional(),allowed_node_ids:rx($()).optional(),callback_url:$().refine(CN,`Must be a valid URL`).nullable().optional(),platform:$().nullable().optional(),rate_limit_per_second:qb().int().positive().max(4294967295).optional(),rate_limit_burst:qb().int().positive().max(4294967295).optional(),target_org_id:$().optional()});function TN({value:e,onChange:t,label:n=`Scopes`,hint:r=`Must match the backend's allowed scope set. Pick at least one.`}){function i(n){let r=new Set(e);r.has(n)?r.delete(n):r.add(n),t(r)}let a=e.size===0;return(0,L.jsxs)(`div`,{className:`flex flex-col gap-1.5`,children:[(0,L.jsx)(vi,{children:n}),(0,L.jsx)(`div`,{role:`group`,"aria-label":`Scopes`,"aria-invalid":a,className:`flex flex-wrap gap-2 rounded-lg p-2 transition-colors duration-300 `+(a?`border border-destructive`:`border border-transparent`),children:wN.map(t=>(0,L.jsx)(EN,{scope:t,checked:e.has(t),onToggle:()=>{i(t)}},t))}),(0,L.jsx)(`p`,{className:a?`text-xs text-destructive`:`text-xs text-muted-foreground`,children:a?`At least one scope is required.`:r})]})}function EN({scope:e,checked:t,onToggle:n}){return(0,L.jsxs)(`label`,{className:`inline-flex cursor-pointer select-none items-center gap-1.5 rounded-full border px-3 py-1.5 text-[12px] transition-colors duration-300 `+(t?`border-primary bg-primary/15 text-foreground`:`border-border bg-transparent text-muted-foreground hover:border-border hover:bg-muted/40`),children:[(0,L.jsx)(`input`,{type:`checkbox`,className:`peer sr-only`,checked:t,onChange:n,value:e}),(0,L.jsx)(`span`,{className:`text-xs`,children:e})]})}function DN(e,t=[]){let n=[];function r(t,r){let i=I.createContext(r),a=n.length;n=[...n,r];let o=t=>{let{scope:n,children:r,...o}=t,s=n?.[e]?.[a]||i,c=I.useMemo(()=>o,Object.values(o));return(0,L.jsx)(s.Provider,{value:c,children:r})};o.displayName=t+`Provider`;function s(n,o){let s=o?.[e]?.[a]||i,c=I.useContext(s);if(c)return c;if(r!==void 0)return r;throw Error(`\`${n}\` must be used within \`${t}\``)}return[o,s]}let i=()=>{let t=n.map(e=>I.createContext(e));return function(n){let r=n?.[e]||t;return I.useMemo(()=>({[`__scope${e}`]:{...n,[e]:r}}),[n,r])}};return i.scopeName=e,[r,ON(i,...t)]}function ON(...e){let t=e[0];if(e.length===1)return t;let n=()=>{let n=e.map(e=>({useScope:e(),scopeName:e.scopeName}));return function(e){let r=n.reduce((t,{useScope:n,scopeName:r})=>{let i=n(e)[`__scope${r}`];return{...t,...i}},{});return I.useMemo(()=>({[`__scope${t.scopeName}`]:r}),[r])}};return n.scopeName=t.scopeName,n}function kN(e){let t=AN(e),n=I.forwardRef((e,n)=>{let{children:r,...i}=e,a=I.Children.toArray(r),o=a.find(MN);if(o){let e=o.props.children,r=a.map(t=>t===o?I.Children.count(e)>1?I.Children.only(null):I.isValidElement(e)?e.props.children:null:t);return(0,L.jsx)(t,{...i,ref:n,children:I.isValidElement(e)?I.cloneElement(e,void 0,r):null})}return(0,L.jsx)(t,{...i,ref:n,children:r})});return n.displayName=`${e}.Slot`,n}function AN(e){let t=I.forwardRef((e,t)=>{let{children:n,...r}=e;if(I.isValidElement(n)){let e=PN(n),i=NN(r,n.props);return n.type!==I.Fragment&&(i.ref=t?It(t,e):e),I.cloneElement(n,i)}return I.Children.count(n)>1?I.Children.only(null):null});return t.displayName=`${e}.SlotClone`,t}var jN=Symbol(`radix.slottable`);function MN(e){return I.isValidElement(e)&&typeof e.type==`function`&&`__radixId`in e.type&&e.type.__radixId===jN}function NN(e,t){let n={...t};for(let r in t){let i=e[r],a=t[r];/^on[A-Z]/.test(r)?i&&a?n[r]=(...e)=>{let t=a(...e);return i(...e),t}:i&&(n[r]=i):r===`style`?n[r]={...i,...a}:r===`className`&&(n[r]=[i,a].filter(Boolean).join(` `))}return{...e,...n}}function PN(e){let t=Object.getOwnPropertyDescriptor(e.props,`ref`)?.get,n=t&&`isReactWarning`in t&&t.isReactWarning;return n?e.ref:(t=Object.getOwnPropertyDescriptor(e,`ref`)?.get,n=t&&`isReactWarning`in t&&t.isReactWarning,n?e.props.ref:e.props.ref||e.ref)}var FN=[`a`,`button`,`div`,`form`,`h2`,`h3`,`img`,`input`,`label`,`li`,`nav`,`ol`,`p`,`select`,`span`,`svg`,`ul`].reduce((e,t)=>{let n=kN(`Primitive.${t}`),r=I.forwardRef((e,r)=>{let{asChild:i,...a}=e,o=i?n:t;return typeof window<`u`&&(window[Symbol.for(`radix-ui`)]=!0),(0,L.jsx)(o,{...a,ref:r})});return r.displayName=`Primitive.${t}`,{...e,[t]:r}},{}),IN=`Checkbox`,[LN,RN]=DN(IN),[zN,BN]=LN(IN);function VN(e){let{__scopeCheckbox:t,checked:n,children:r,defaultChecked:i,disabled:a,form:o,name:s,onCheckedChange:c,required:l,value:u=`on`,internal_do_not_use_render:d}=e,[f,p]=$E({prop:n,defaultProp:i??!1,onChange:c,caller:IN}),[m,h]=I.useState(null),[g,_]=I.useState(null),v=I.useRef(!1),y=m?!!o||!!m.closest(`form`):!0,b={checked:f,disabled:a,setChecked:p,control:m,setControl:h,name:s,form:o,value:u,hasConsumerStoppedPropagationRef:v,required:l,defaultChecked:XN(i)?!1:i,isFormControl:y,bubbleInput:g,setBubbleInput:_};return(0,L.jsx)(zN,{scope:t,...b,children:YN(d)?d(b):r})}var HN=`CheckboxTrigger`,UN=I.forwardRef(({__scopeCheckbox:e,onKeyDown:t,onClick:n,...r},i)=>{let{control:a,value:o,disabled:s,checked:c,required:l,setControl:u,setChecked:d,hasConsumerStoppedPropagationRef:f,isFormControl:p,bubbleInput:m}=BN(HN,e),h=Lt(i,u),g=I.useRef(c);return I.useEffect(()=>{let e=a?.form;if(e){let t=()=>d(g.current);return e.addEventListener(`reset`,t),()=>e.removeEventListener(`reset`,t)}},[a,d]),(0,L.jsx)(FN.button,{type:`button`,role:`checkbox`,"aria-checked":XN(c)?`mixed`:c,"aria-required":l,"data-state":ZN(c),"data-disabled":s?``:void 0,disabled:s,value:o,...r,ref:h,onKeyDown:kS(t,e=>{e.key===`Enter`&&e.preventDefault()}),onClick:kS(n,e=>{d(e=>XN(e)?!0:!e),m&&p&&(f.current=e.isPropagationStopped(),f.current||e.stopPropagation())})})});UN.displayName=HN;var WN=I.forwardRef((e,t)=>{let{__scopeCheckbox:n,name:r,checked:i,defaultChecked:a,required:o,disabled:s,value:c,onCheckedChange:l,form:u,...d}=e;return(0,L.jsx)(VN,{__scopeCheckbox:n,checked:i,defaultChecked:a,disabled:s,required:o,onCheckedChange:l,name:r,form:u,value:c,internal_do_not_use_render:({isFormControl:e})=>(0,L.jsxs)(L.Fragment,{children:[(0,L.jsx)(UN,{...d,ref:t,__scopeCheckbox:n}),e&&(0,L.jsx)(JN,{__scopeCheckbox:n})]})})});WN.displayName=IN;var GN=`CheckboxIndicator`,KN=I.forwardRef((e,t)=>{let{__scopeCheckbox:n,forceMount:r,...i}=e,a=BN(GN,n);return(0,L.jsx)(VE,{present:r||XN(a.checked)||a.checked===!0,children:(0,L.jsx)(FN.span,{"data-state":ZN(a.checked),"data-disabled":a.disabled?``:void 0,...i,ref:t,style:{pointerEvents:`none`,...e.style}})})});KN.displayName=GN;var qN=`CheckboxBubbleInput`,JN=I.forwardRef(({__scopeCheckbox:e,...t},n)=>{let{control:r,hasConsumerStoppedPropagationRef:i,checked:a,defaultChecked:o,required:s,disabled:c,name:l,value:u,form:d,bubbleInput:f,setBubbleInput:p}=BN(qN,e),m=Lt(n,p),h=jj(a),g=cE(r);I.useEffect(()=>{let e=f;if(!e)return;let t=window.HTMLInputElement.prototype,n=Object.getOwnPropertyDescriptor(t,`checked`).set,r=!i.current;if(h!==a&&n){let t=new Event(`click`,{bubbles:r});e.indeterminate=XN(a),n.call(e,XN(a)?!1:a),e.dispatchEvent(t)}},[f,h,a,i]);let _=I.useRef(XN(a)?!1:a);return(0,L.jsx)(FN.input,{type:`checkbox`,"aria-hidden":!0,defaultChecked:o??_.current,required:s,disabled:c,name:l,value:u,form:d,...t,tabIndex:-1,ref:m,style:{...t.style,...g,position:`absolute`,pointerEvents:`none`,opacity:0,margin:0,transform:`translateX(-100%)`}})});JN.displayName=qN;function YN(e){return typeof e==`function`}function XN(e){return e===`indeterminate`}function ZN(e){return XN(e)?`indeterminate`:e?`checked`:`unchecked`}var QN=I.forwardRef(({className:e,...t},n)=>(0,L.jsx)(WN,{ref:n,className:Tr(`peer h-4 w-4 shrink-0 rounded-[4px] border border-muted-foreground/40 bg-transparent focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:border-primary data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground`,e),...t,children:(0,L.jsx)(KN,{className:Tr(`flex items-center justify-center text-current`),children:(0,L.jsx)(Br,{className:`h-3 w-3`})})}));QN.displayName=WN.displayName;function $N({services:e,selectedIds:t,allowAll:n=!1,onAllowAllChange:r,onToggle:i,orgOwned:a=!1,disabled:o=!1}){let s=(0,I.useId)();return a&&!e.some(e=>e.auto_connected)?(0,L.jsx)(`p`,{className:`text-[12px] text-muted-foreground`,children:`This org-owned key cannot use platform services from your personal account.`}):(0,L.jsxs)(`section`,{"aria-label":`Auto-connected platform services`,className:`space-y-2 border-t border-border/50 pt-3`,children:[(0,L.jsx)(`p`,{className:`text-[12px] font-medium`,children:`Auto-connected platform services`}),(0,L.jsxs)(vi,{className:`flex items-start gap-2 text-[12px]`,children:[(0,L.jsx)(QN,{checked:n,disabled:o,onCheckedChange:e=>r(e===!0)}),`Allow all auto-connected platform services (includes ones added later)`]}),e.filter(e=>e.auto_connected).map(e=>{let r=n&&e.platform_grant_eligible!==!1;return(0,L.jsxs)(vi,{htmlFor:`${s}-${e.id}`,className:`flex items-center gap-2 text-[12px] ${r?`text-muted-foreground`:``}`,children:[(0,L.jsx)(QN,{id:`${s}-${e.id}`,checked:r||t.includes(e.id),disabled:o||r,onCheckedChange:()=>i(e.id)}),e.label||e.name||e.slug||e.id,e.platform_grant_eligible===!1&&` (Organization; select individually)`]},e.id)})]})}function eP(){let e=ft({queryKey:[`keys`,`list`,OS(e=>e.user?.id)],queryFn:async()=>(await Wy.get(`/keys`)).keys,staleTime:0,refetchOnMount:`always`});return{...e,data:e.isError?void 0:e.data}}function tP(e={}){return ft({queryKey:[`nodes`],queryFn:async()=>(await Wy.get(`/nodes`)).nodes,refetchInterval:e.pollIntervalMs&&e.pollIntervalMs>0?e.pollIntervalMs:void 0})}function nP({value:e,onChange:t,ownerId:n}){let r=eP(),i=tP();function a(n){let r=new Set(e.selectedServiceIds);r.has(n)?r.delete(n):r.add(n),t({...e,selectedServiceIds:r})}function o(n){let r=new Set(e.selectedNodeIds);r.has(n)?r.delete(n):r.add(n),t({...e,selectedNodeIds:r})}return(0,L.jsxs)(`section`,{"aria-labelledby":`access-scope-title`,className:`flex flex-col gap-4 rounded-lg border border-border bg-muted/30 p-4`,children:[(0,L.jsxs)(`div`,{className:`flex flex-col gap-1`,children:[(0,L.jsx)(`h3`,{id:`access-scope-title`,className:`text-[13px] font-semibold`,children:`Access Scope`}),(0,L.jsx)(`p`,{className:`text-xs text-muted-foreground`,children:`Restrict which services and nodes this key can access via proxy.`})]}),(0,L.jsx)(rP,{label:`Services`,icon:(0,L.jsx)(iP,{}),allowAll:e.allowAllServices,onAllowAllChange:n=>{t({...e,allowAllServices:n})},listLabel:`Select allowed services:`,loading:r.isLoading,items:r.data?.filter(e=>e.is_active&&!e.auto_connected&&(!n||e.credential_source?.type===`org`&&e.credential_source.org_id===n)&&(e.credential_source?.type!==`org`||e.credential_source.allowed)).map(e=>({id:e.id,primary:e.label,secondary:e.slug,iconSlug:e.catalog_service_slug}))??[],selectedIds:e.selectedServiceIds,onToggle:a}),!e.allowAllServices&&(0,L.jsx)($N,{services:(r.data??[]).filter(e=>e.is_active&&(n?e.credential_source?.type===`org`&&e.credential_source.org_id===n:e.credential_source?.type!==`org`)),selectedIds:[...e.selectedServiceIds],allowAll:e.allowAutoConnectedServices,onAllowAllChange:n=>t({...e,allowAutoConnectedServices:n}),onToggle:a,orgOwned:!!n}),(0,L.jsx)(rP,{label:`Nodes`,icon:(0,L.jsx)(aP,{}),allowAll:e.allowAllNodes,onAllowAllChange:n=>{t({...e,allowAllNodes:n})},listLabel:`Select allowed nodes:`,loading:i.isLoading,items:i.data?.filter(e=>!n||e.owner.id===n).map(e=>({id:e.id,primary:e.name,secondary:e.status}))??[],selectedIds:e.selectedNodeIds,onToggle:o})]})}function rP({label:e,icon:t,allowAll:n,onAllowAllChange:r,listLabel:i,loading:a,items:o,selectedIds:s,onToggle:c}){return(0,L.jsxs)(`div`,{className:`flex flex-col gap-2`,children:[(0,L.jsxs)(`div`,{className:`flex items-center gap-1.5 text-[12px] font-medium`,children:[(0,L.jsx)(`span`,{className:`text-muted-foreground`,children:t}),(0,L.jsx)(`span`,{children:e})]}),(0,L.jsxs)(vi,{className:`flex cursor-pointer items-center gap-2 text-[12px]`,children:[(0,L.jsx)(QN,{checked:n,onCheckedChange:e=>{r(e===!0)}}),(0,L.jsxs)(`span`,{children:[`Allow all `,e.toLowerCase()]})]}),n?null:(0,L.jsxs)(`div`,{className:`flex flex-col gap-1.5 rounded-lg border border-border bg-background/40 p-3`,children:[(0,L.jsx)(`p`,{className:`text-xs text-muted-foreground`,children:i}),a?(0,L.jsx)(`p`,{className:`text-xs text-muted-foreground`,children:`Loading…`}):o.length===0?(0,L.jsx)(`p`,{className:`text-xs text-muted-foreground`,children:`None available. Add one first, then come back.`}):(0,L.jsx)(`div`,{className:`flex flex-col gap-1`,role:`list`,children:o.map(e=>(0,L.jsxs)(vi,{className:`flex cursor-pointer items-center gap-2 text-[12px]`,children:[(0,L.jsx)(QN,{checked:s.has(e.id),onCheckedChange:()=>{c(e.id)}}),(0,L.jsx)(Sj,{slug:e.iconSlug,size:`2xs`}),(0,L.jsxs)(`span`,{className:`truncate`,children:[e.primary,e.secondary?(0,L.jsxs)(`span`,{className:`ml-1.5 text-xs text-muted-foreground`,children:[`(`,e.secondary,`)`]}):null]})]},e.id))})]})]})}function iP(){return(0,L.jsx)(`svg`,{viewBox:`0 0 24 24`,width:`16`,height:`16`,fill:`none`,stroke:`currentColor`,strokeWidth:`2`,strokeLinecap:`round`,strokeLinejoin:`round`,"aria-hidden":`true`,children:(0,L.jsx)(`path`,{d:`M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z`})})}function aP(){return(0,L.jsxs)(`svg`,{viewBox:`0 0 24 24`,width:`16`,height:`16`,fill:`none`,stroke:`currentColor`,strokeWidth:`2`,strokeLinecap:`round`,strokeLinejoin:`round`,"aria-hidden":`true`,children:[(0,L.jsx)(`rect`,{x:`2`,y:`3`,width:`20`,height:`7`,rx:`1.5`}),(0,L.jsx)(`rect`,{x:`2`,y:`14`,width:`20`,height:`7`,rx:`1.5`}),(0,L.jsx)(`line`,{x1:`6`,y1:`6.5`,x2:`6.01`,y2:`6.5`}),(0,L.jsx)(`line`,{x1:`6`,y1:`17.5`,x2:`6.01`,y2:`17.5`})]})}var oP=[`orgs`],sP={all:oP,list:()=>[...oP,`list`],detail:e=>[...oP,`detail`,e]};function cP(){return ft({queryKey:sP.list(),queryFn:async()=>(await Wy.get(`/orgs`)).orgs})}function lP(e,[t,n]){return Math.min(n,Math.max(t,e))}function uP(e,t=[]){let n=[];function r(t,r){let i=I.createContext(r),a=n.length;n=[...n,r];let o=t=>{let{scope:n,children:r,...o}=t,s=n?.[e]?.[a]||i,c=I.useMemo(()=>o,Object.values(o));return(0,L.jsx)(s.Provider,{value:c,children:r})};o.displayName=t+`Provider`;function s(n,o){let s=o?.[e]?.[a]||i,c=I.useContext(s);if(c)return c;if(r!==void 0)return r;throw Error(`\`${n}\` must be used within \`${t}\``)}return[o,s]}let i=()=>{let t=n.map(e=>I.createContext(e));return function(n){let r=n?.[e]||t;return I.useMemo(()=>({[`__scope${e}`]:{...n,[e]:r}}),[n,r])}};return i.scopeName=e,[r,dP(i,...t)]}function dP(...e){let t=e[0];if(e.length===1)return t;let n=()=>{let n=e.map(e=>({useScope:e(),scopeName:e.scopeName}));return function(e){let r=n.reduce((t,{useScope:n,scopeName:r})=>{let i=n(e)[`__scope${r}`];return{...t,...i}},{});return I.useMemo(()=>({[`__scope${t.scopeName}`]:r}),[r])}};return n.scopeName=t.scopeName,n}function fP(e){let t=pP(e),n=I.forwardRef((e,n)=>{let{children:r,...i}=e,a=I.Children.toArray(r),o=a.find(hP);if(o){let e=o.props.children,r=a.map(t=>t===o?I.Children.count(e)>1?I.Children.only(null):I.isValidElement(e)?e.props.children:null:t);return(0,L.jsx)(t,{...i,ref:n,children:I.isValidElement(e)?I.cloneElement(e,void 0,r):null})}return(0,L.jsx)(t,{...i,ref:n,children:r})});return n.displayName=`${e}.Slot`,n}function pP(e){let t=I.forwardRef((e,t)=>{let{children:n,...r}=e;if(I.isValidElement(n)){let e=_P(n),i=gP(r,n.props);return n.type!==I.Fragment&&(i.ref=t?It(t,e):e),I.cloneElement(n,i)}return I.Children.count(n)>1?I.Children.only(null):null});return t.displayName=`${e}.SlotClone`,t}var mP=Symbol(`radix.slottable`);function hP(e){return I.isValidElement(e)&&typeof e.type==`function`&&`__radixId`in e.type&&e.type.__radixId===mP}function gP(e,t){let n={...t};for(let r in t){let i=e[r],a=t[r];/^on[A-Z]/.test(r)?i&&a?n[r]=(...e)=>{let t=a(...e);return i(...e),t}:i&&(n[r]=i):r===`style`?n[r]={...i,...a}:r===`className`&&(n[r]=[i,a].filter(Boolean).join(` `))}return{...e,...n}}function _P(e){let t=Object.getOwnPropertyDescriptor(e.props,`ref`)?.get,n=t&&`isReactWarning`in t&&t.isReactWarning;return n?e.ref:(t=Object.getOwnPropertyDescriptor(e,`ref`)?.get,n=t&&`isReactWarning`in t&&t.isReactWarning,n?e.props.ref:e.props.ref||e.ref)}function vP(e){let t=e+`CollectionProvider`,[n,r]=uP(t),[i,a]=n(t,{collectionRef:{current:null},itemMap:new Map}),o=e=>{let{scope:t,children:n}=e,r=I.useRef(null),a=I.useRef(new Map).current;return(0,L.jsx)(i,{scope:t,itemMap:a,collectionRef:r,children:n})};o.displayName=t;let s=e+`CollectionSlot`,c=fP(s),l=I.forwardRef((e,t)=>{let{scope:n,children:r}=e;return(0,L.jsx)(c,{ref:Lt(t,a(s,n).collectionRef),children:r})});l.displayName=s;let u=e+`CollectionItemSlot`,d=`data-radix-collection-item`,f=fP(u),p=I.forwardRef((e,t)=>{let{scope:n,children:r,...i}=e,o=I.useRef(null),s=Lt(t,o),c=a(u,n);return I.useEffect(()=>(c.itemMap.set(o,{ref:o,...i}),()=>void c.itemMap.delete(o))),(0,L.jsx)(f,{[d]:``,ref:s,children:r})});p.displayName=u;function m(t){let n=a(e+`CollectionConsumer`,t);return I.useCallback(()=>{let e=n.collectionRef.current;if(!e)return[];let t=Array.from(e.querySelectorAll(`[${d}]`));return Array.from(n.itemMap.values()).sort((e,n)=>t.indexOf(e.ref.current)-t.indexOf(n.ref.current))},[n.collectionRef,n.itemMap])}return[{Provider:o,Slot:l,ItemSlot:p},m,r]}function yP(e,t=[]){let n=[];function r(t,r){let i=I.createContext(r),a=n.length;n=[...n,r];let o=t=>{let{scope:n,children:r,...o}=t,s=n?.[e]?.[a]||i,c=I.useMemo(()=>o,Object.values(o));return(0,L.jsx)(s.Provider,{value:c,children:r})};o.displayName=t+`Provider`;function s(n,o){let s=o?.[e]?.[a]||i,c=I.useContext(s);if(c)return c;if(r!==void 0)return r;throw Error(`\`${n}\` must be used within \`${t}\``)}return[o,s]}let i=()=>{let t=n.map(e=>I.createContext(e));return function(n){let r=n?.[e]||t;return I.useMemo(()=>({[`__scope${e}`]:{...n,[e]:r}}),[n,r])}};return i.scopeName=e,[r,bP(i,...t)]}function bP(...e){let t=e[0];if(e.length===1)return t;let n=()=>{let n=e.map(e=>({useScope:e(),scopeName:e.scopeName}));return function(e){let r=n.reduce((t,{useScope:n,scopeName:r})=>{let i=n(e)[`__scope${r}`];return{...t,...i}},{});return I.useMemo(()=>({[`__scope${t.scopeName}`]:r}),[r])}};return n.scopeName=t.scopeName,n}var xP=I.createContext(void 0);function SP(e){let t=I.useContext(xP);return e||t||`ltr`}function CP(e){let t=wP(e),n=I.forwardRef((e,n)=>{let{children:r,...i}=e,a=I.Children.toArray(r),o=a.find(EP);if(o){let e=o.props.children,r=a.map(t=>t===o?I.Children.count(e)>1?I.Children.only(null):I.isValidElement(e)?e.props.children:null:t);return(0,L.jsx)(t,{...i,ref:n,children:I.isValidElement(e)?I.cloneElement(e,void 0,r):null})}return(0,L.jsx)(t,{...i,ref:n,children:r})});return n.displayName=`${e}.Slot`,n}function wP(e){let t=I.forwardRef((e,t)=>{let{children:n,...r}=e;if(I.isValidElement(n)){let e=OP(n),i=DP(r,n.props);return n.type!==I.Fragment&&(i.ref=t?It(t,e):e),I.cloneElement(n,i)}return I.Children.count(n)>1?I.Children.only(null):null});return t.displayName=`${e}.SlotClone`,t}var TP=Symbol(`radix.slottable`);function EP(e){return I.isValidElement(e)&&typeof e.type==`function`&&`__radixId`in e.type&&e.type.__radixId===TP}function DP(e,t){let n={...t};for(let r in t){let i=e[r],a=t[r];/^on[A-Z]/.test(r)?i&&a?n[r]=(...e)=>{let t=a(...e);return i(...e),t}:i&&(n[r]=i):r===`style`?n[r]={...i,...a}:r===`className`&&(n[r]=[i,a].filter(Boolean).join(` `))}return{...e,...n}}function OP(e){let t=Object.getOwnPropertyDescriptor(e.props,`ref`)?.get,n=t&&`isReactWarning`in t&&t.isReactWarning;return n?e.ref:(t=Object.getOwnPropertyDescriptor(e,`ref`)?.get,n=t&&`isReactWarning`in t&&t.isReactWarning,n?e.props.ref:e.props.ref||e.ref)}var kP=[`a`,`button`,`div`,`form`,`h2`,`h3`,`img`,`input`,`label`,`li`,`nav`,`ol`,`p`,`select`,`span`,`svg`,`ul`].reduce((e,t)=>{let n=CP(`Primitive.${t}`),r=I.forwardRef((e,r)=>{let{asChild:i,...a}=e,o=i?n:t;return typeof window<`u`&&(window[Symbol.for(`radix-ui`)]=!0),(0,L.jsx)(o,{...a,ref:r})});return r.displayName=`Primitive.${t}`,{...e,[t]:r}},{}),AP=[` `,`Enter`,`ArrowUp`,`ArrowDown`],jP=[` `,`Enter`],MP=`Select`,[NP,PP,FP]=vP(MP),[IP,LP]=yP(MP,[FP,dE]),RP=dE(),[zP,BP]=IP(MP),[VP,HP]=IP(MP),UP=e=>{let{__scopeSelect:t,children:n,open:r,defaultOpen:i,onOpenChange:a,value:o,defaultValue:s,onValueChange:c,dir:l,name:u,autoComplete:d,disabled:f,required:p,form:m}=e,h=RP(t),[g,_]=I.useState(null),[v,y]=I.useState(null),[b,x]=I.useState(!1),S=SP(l),[C,w]=$E({prop:r,defaultProp:i??!1,onChange:a,caller:MP}),[T,E]=$E({prop:o,defaultProp:s,onChange:c,caller:MP}),ee=I.useRef(null),D=g?m||!!g.closest(`form`):!0,[O,k]=I.useState(new Set),A=Array.from(O).map(e=>e.props.value).join(`;`);return(0,L.jsx)(DE,{...h,children:(0,L.jsxs)(zP,{required:p,scope:t,trigger:g,onTriggerChange:_,valueNode:v,onValueNodeChange:y,valueNodeHasChildren:b,onValueNodeHasChildrenChange:x,contentId:AC(),value:T,onValueChange:E,open:C,onOpenChange:w,dir:S,triggerPointerDownPosRef:ee,disabled:f,children:[(0,L.jsx)(NP.Provider,{scope:t,children:(0,L.jsx)(VP,{scope:e.__scopeSelect,onNativeOptionAdd:I.useCallback(e=>{k(t=>new Set(t).add(e))},[]),onNativeOptionRemove:I.useCallback(e=>{k(t=>{let n=new Set(t);return n.delete(e),n})},[]),children:n})}),D?(0,L.jsxs)(RF,{"aria-hidden":!0,required:p,tabIndex:-1,name:u,autoComplete:d,value:T,onChange:e=>E(e.target.value),disabled:f,form:m,children:[T===void 0?(0,L.jsx)(`option`,{value:``}):null,Array.from(O)]},A):null]})})};UP.displayName=MP;var WP=`SelectTrigger`,GP=I.forwardRef((e,t)=>{let{__scopeSelect:n,disabled:r=!1,...i}=e,a=RP(n),o=BP(WP,n),s=o.disabled||r,c=Lt(t,o.onTriggerChange),l=PP(n),u=I.useRef(`touch`),[d,f,p]=BF(e=>{let t=l().filter(e=>!e.disabled),n=VF(t,e,t.find(e=>e.value===o.value));n!==void 0&&o.onValueChange(n.value)}),m=e=>{s||(o.onOpenChange(!0),p()),e&&(o.triggerPointerDownPosRef.current={x:Math.round(e.pageX),y:Math.round(e.pageY)})};return(0,L.jsx)(OE,{asChild:!0,...a,children:(0,L.jsx)(kP.button,{type:`button`,role:`combobox`,"aria-controls":o.contentId,"aria-expanded":o.open,"aria-required":o.required,"aria-autocomplete":`none`,dir:o.dir,"data-state":o.open?`open`:`closed`,disabled:s,"data-disabled":s?``:void 0,"data-placeholder":zF(o.value)?``:void 0,...i,ref:c,onClick:kS(i.onClick,e=>{e.currentTarget.focus(),u.current!==`mouse`&&m(e)}),onPointerDown:kS(i.onPointerDown,e=>{u.current=e.pointerType;let t=e.target;t.hasPointerCapture(e.pointerId)&&t.releasePointerCapture(e.pointerId),e.button===0&&e.ctrlKey===!1&&e.pointerType===`mouse`&&(m(e),e.preventDefault())}),onKeyDown:kS(i.onKeyDown,e=>{let t=d.current!==``;!(e.ctrlKey||e.altKey||e.metaKey)&&e.key.length===1&&f(e.key),!(t&&e.key===` `)&&AP.includes(e.key)&&(m(),e.preventDefault())})})})});GP.displayName=WP;var KP=`SelectValue`,qP=I.forwardRef((e,t)=>{let{__scopeSelect:n,className:r,style:i,children:a,placeholder:o=``,...s}=e,c=BP(KP,n),{onValueNodeHasChildrenChange:l}=c,u=a!==void 0,d=Lt(t,c.onValueNodeChange);return DC(()=>{l(u)},[l,u]),(0,L.jsx)(kP.span,{...s,ref:d,style:{pointerEvents:`none`},children:zF(c.value)?(0,L.jsx)(L.Fragment,{children:o}):a})});qP.displayName=KP;var JP=`SelectIcon`,YP=I.forwardRef((e,t)=>{let{__scopeSelect:n,children:r,...i}=e;return(0,L.jsx)(kP.span,{"aria-hidden":!0,...i,ref:t,children:r||`▼`})});YP.displayName=JP;var XP=`SelectPortal`,ZP=e=>(0,L.jsx)(zE,{asChild:!0,...e});ZP.displayName=XP;var QP=`SelectContent`,$P=I.forwardRef((e,t)=>{let n=BP(QP,e.__scopeSelect),[r,i]=I.useState();if(DC(()=>{i(new DocumentFragment)},[]),!n.open){let t=r;return t?fi.createPortal((0,L.jsx)(tF,{scope:e.__scopeSelect,children:(0,L.jsx)(NP.Slot,{scope:e.__scopeSelect,children:(0,L.jsx)(`div`,{children:e.children})})}),t):null}return(0,L.jsx)(aF,{...e,ref:t})});$P.displayName=QP;var eF=10,[tF,nF]=IP(QP),rF=`SelectContentImpl`,iF=CP(`SelectContent.RemoveScroll`),aF=I.forwardRef((e,t)=>{let{__scopeSelect:n,position:r=`item-aligned`,onCloseAutoFocus:i,onEscapeKeyDown:a,onPointerDownOutside:o,side:s,sideOffset:c,align:l,alignOffset:u,arrowPadding:d,collisionBoundary:f,collisionPadding:p,sticky:m,hideWhenDetached:h,avoidCollisions:g,..._}=e,v=BP(QP,n),[y,b]=I.useState(null),[x,S]=I.useState(null),C=Lt(t,e=>b(e)),[w,T]=I.useState(null),[E,ee]=I.useState(null),D=PP(n),[O,k]=I.useState(!1),A=I.useRef(!1);I.useEffect(()=>{if(y)return uD(y)},[y]),nC();let j=I.useCallback(e=>{let[t,...n]=D().map(e=>e.ref.current),[r]=n.slice(-1),i=document.activeElement;for(let n of e)if(n===i||(n?.scrollIntoView({block:`nearest`}),n===t&&x&&(x.scrollTop=0),n===r&&x&&(x.scrollTop=x.scrollHeight),n?.focus(),document.activeElement!==i))return},[D,x]),M=I.useCallback(()=>j([w,y]),[j,w,y]);I.useEffect(()=>{O&&M()},[O,M]);let{onOpenChange:N,triggerPointerDownPosRef:te}=v;I.useEffect(()=>{if(y){let e={x:0,y:0},t=t=>{e={x:Math.abs(Math.round(t.pageX)-(te.current?.x??0)),y:Math.abs(Math.round(t.pageY)-(te.current?.y??0))}},n=n=>{e.x<=10&&e.y<=10?n.preventDefault():y.contains(n.target)||N(!1),document.removeEventListener(`pointermove`,t),te.current=null};return te.current!==null&&(document.addEventListener(`pointermove`,t),document.addEventListener(`pointerup`,n,{capture:!0,once:!0})),()=>{document.removeEventListener(`pointermove`,t),document.removeEventListener(`pointerup`,n,{capture:!0})}}},[y,N,te]),I.useEffect(()=>{let e=()=>N(!1);return window.addEventListener(`blur`,e),window.addEventListener(`resize`,e),()=>{window.removeEventListener(`blur`,e),window.removeEventListener(`resize`,e)}},[N]);let[P,F]=BF(e=>{let t=D().filter(e=>!e.disabled),n=VF(t,e,t.find(e=>e.ref.current===document.activeElement));n&&setTimeout(()=>n.ref.current.focus())}),ne=I.useCallback((e,t,n)=>{let r=!A.current&&!n;(v.value!==void 0&&v.value===t||r)&&(T(e),r&&(A.current=!0))},[v.value]),re=I.useCallback(()=>y?.focus(),[y]),ie=I.useCallback((e,t,n)=>{let r=!A.current&&!n;(v.value!==void 0&&v.value===t||r)&&ee(e)},[v.value]),ae=r===`popper`?lF:sF,oe=ae===lF?{side:s,sideOffset:c,align:l,alignOffset:u,arrowPadding:d,collisionBoundary:f,collisionPadding:p,sticky:m,hideWhenDetached:h,avoidCollisions:g}:{};return(0,L.jsx)(tF,{scope:n,content:y,viewport:x,onViewportChange:S,itemRefCallback:ne,selectedItem:w,onItemLeave:re,itemTextRefCallback:ie,focusSelectedItem:M,selectedItemText:E,position:r,isPositioned:O,searchRef:P,children:(0,L.jsx)(yO,{as:iF,allowPinchZoom:!0,children:(0,L.jsx)(hC,{asChild:!0,trapped:v.open,onMountAutoFocus:e=>{e.preventDefault()},onUnmountAutoFocus:kS(i,e=>{v.trigger?.focus({preventScroll:!0}),e.preventDefault()}),children:(0,L.jsx)(JS,{asChild:!0,disableOutsidePointerEvents:!0,onEscapeKeyDown:a,onPointerDownOutside:o,onFocusOutside:e=>e.preventDefault(),onDismiss:()=>v.onOpenChange(!1),children:(0,L.jsx)(ae,{role:`listbox`,id:v.contentId,"data-state":v.open?`open`:`closed`,dir:v.dir,onContextMenu:e=>e.preventDefault(),..._,...oe,onPlaced:()=>k(!0),ref:C,style:{display:`flex`,flexDirection:`column`,outline:`none`,..._.style},onKeyDown:kS(_.onKeyDown,e=>{let t=e.ctrlKey||e.altKey||e.metaKey;if(e.key===`Tab`&&e.preventDefault(),!t&&e.key.length===1&&F(e.key),[`ArrowUp`,`ArrowDown`,`Home`,`End`].includes(e.key)){let t=D().filter(e=>!e.disabled).map(e=>e.ref.current);if([`ArrowUp`,`End`].includes(e.key)&&(t=t.slice().reverse()),[`ArrowUp`,`ArrowDown`].includes(e.key)){let n=e.target,r=t.indexOf(n);t=t.slice(r+1)}setTimeout(()=>j(t)),e.preventDefault()}})})})})})})});aF.displayName=rF;var oF=`SelectItemAlignedPosition`,sF=I.forwardRef((e,t)=>{let{__scopeSelect:n,onPlaced:r,...i}=e,a=BP(QP,n),o=nF(QP,n),[s,c]=I.useState(null),[l,u]=I.useState(null),d=Lt(t,e=>u(e)),f=PP(n),p=I.useRef(!1),m=I.useRef(!0),{viewport:h,selectedItem:g,selectedItemText:_,focusSelectedItem:v}=o,y=I.useCallback(()=>{if(a.trigger&&a.valueNode&&s&&l&&h&&g&&_){let e=a.trigger.getBoundingClientRect(),t=l.getBoundingClientRect(),n=a.valueNode.getBoundingClientRect(),i=_.getBoundingClientRect();if(a.dir!==`rtl`){let r=i.left-t.left,a=n.left-r,o=e.left-a,c=e.width+o,l=Math.max(c,t.width),u=window.innerWidth-eF,d=lP(a,[eF,Math.max(eF,u-l)]);s.style.minWidth=c+`px`,s.style.left=d+`px`}else{let r=t.right-i.right,a=window.innerWidth-n.right-r,o=window.innerWidth-e.right-a,c=e.width+o,l=Math.max(c,t.width),u=window.innerWidth-eF,d=lP(a,[eF,Math.max(eF,u-l)]);s.style.minWidth=c+`px`,s.style.right=d+`px`}let o=f(),c=window.innerHeight-eF*2,u=h.scrollHeight,d=window.getComputedStyle(l),m=parseInt(d.borderTopWidth,10),v=parseInt(d.paddingTop,10),y=parseInt(d.borderBottomWidth,10),b=parseInt(d.paddingBottom,10),x=m+v+u+b+y,S=Math.min(g.offsetHeight*5,x),C=window.getComputedStyle(h),w=parseInt(C.paddingTop,10),T=parseInt(C.paddingBottom,10),E=e.top+e.height/2-eF,ee=c-E,D=g.offsetHeight/2,O=g.offsetTop+D,k=m+v+O,A=x-k;if(k<=E){let e=o.length>0&&g===o[o.length-1].ref.current;s.style.bottom=`0px`;let t=l.clientHeight-h.offsetTop-h.offsetHeight,n=k+Math.max(ee,D+(e?T:0)+t+y);s.style.height=n+`px`}else{let e=o.length>0&&g===o[0].ref.current;s.style.top=`0px`;let t=Math.max(E,m+h.offsetTop+(e?w:0)+D)+A;s.style.height=t+`px`,h.scrollTop=k-E+h.offsetTop}s.style.margin=`${eF}px 0`,s.style.minHeight=S+`px`,s.style.maxHeight=c+`px`,r?.(),requestAnimationFrame(()=>p.current=!0)}},[f,a.trigger,a.valueNode,s,l,h,g,_,a.dir,r]);DC(()=>y(),[y]);let[b,x]=I.useState();return DC(()=>{l&&x(window.getComputedStyle(l).zIndex)},[l]),(0,L.jsx)(uF,{scope:n,contentWrapper:s,shouldExpandOnScrollRef:p,onScrollButtonChange:I.useCallback(e=>{e&&m.current===!0&&(y(),v?.(),m.current=!1)},[y,v]),children:(0,L.jsx)(`div`,{ref:c,style:{display:`flex`,flexDirection:`column`,position:`fixed`,zIndex:b},children:(0,L.jsx)(kP.div,{...i,ref:d,style:{boxSizing:`border-box`,maxHeight:`100%`,...i.style}})})})});sF.displayName=oF;var cF=`SelectPopperPosition`,lF=I.forwardRef((e,t)=>{let{__scopeSelect:n,align:r=`start`,collisionPadding:i=eF,...a}=e,o=RP(n);return(0,L.jsx)(kE,{...o,...a,ref:t,align:r,collisionPadding:i,style:{boxSizing:`border-box`,...a.style,"--radix-select-content-transform-origin":`var(--radix-popper-transform-origin)`,"--radix-select-content-available-width":`var(--radix-popper-available-width)`,"--radix-select-content-available-height":`var(--radix-popper-available-height)`,"--radix-select-trigger-width":`var(--radix-popper-anchor-width)`,"--radix-select-trigger-height":`var(--radix-popper-anchor-height)`}})});lF.displayName=cF;var[uF,dF]=IP(QP,{}),fF=`SelectViewport`,pF=I.forwardRef((e,t)=>{let{__scopeSelect:n,nonce:r,...i}=e,a=nF(fF,n),o=dF(fF,n),s=Lt(t,a.onViewportChange),c=I.useRef(0);return(0,L.jsxs)(L.Fragment,{children:[(0,L.jsx)(`style`,{dangerouslySetInnerHTML:{__html:`[data-radix-select-viewport]{scrollbar-width:none;-ms-overflow-style:none;-webkit-overflow-scrolling:touch;}[data-radix-select-viewport]::-webkit-scrollbar{display:none}`},nonce:r}),(0,L.jsx)(NP.Slot,{scope:n,children:(0,L.jsx)(kP.div,{"data-radix-select-viewport":``,role:`presentation`,...i,ref:s,style:{position:`relative`,flex:1,overflow:`hidden auto`,...i.style},onScroll:kS(i.onScroll,e=>{let t=e.currentTarget,{contentWrapper:n,shouldExpandOnScrollRef:r}=o;if(r?.current&&n){let e=Math.abs(c.current-t.scrollTop);if(e>0){let r=window.innerHeight-eF*2,i=parseFloat(n.style.minHeight),a=parseFloat(n.style.height),o=Math.max(i,a);if(o0?s:0,n.style.justifyContent=`flex-end`)}}}c.current=t.scrollTop})})})]})});pF.displayName=fF;var mF=`SelectGroup`,[hF,gF]=IP(mF),_F=I.forwardRef((e,t)=>{let{__scopeSelect:n,...r}=e,i=AC();return(0,L.jsx)(hF,{scope:n,id:i,children:(0,L.jsx)(kP.div,{role:`group`,"aria-labelledby":i,...r,ref:t})})});_F.displayName=mF;var vF=`SelectLabel`,yF=I.forwardRef((e,t)=>{let{__scopeSelect:n,...r}=e,i=gF(vF,n);return(0,L.jsx)(kP.div,{id:i.id,...r,ref:t})});yF.displayName=vF;var bF=`SelectItem`,[xF,SF]=IP(bF),CF=I.forwardRef((e,t)=>{let{__scopeSelect:n,value:r,disabled:i=!1,textValue:a,...o}=e,s=BP(bF,n),c=nF(bF,n),l=s.value===r,[u,d]=I.useState(a??``),[f,p]=I.useState(!1),m=Lt(t,e=>c.itemRefCallback?.(e,r,i)),h=AC(),g=I.useRef(`touch`),_=()=>{i||(s.onValueChange(r),s.onOpenChange(!1))};if(r===``)throw Error(`A must have a value prop that is not an empty string. This is because the Select value can be set to an empty string to clear the selection and show the placeholder.`);return(0,L.jsx)(xF,{scope:n,value:r,disabled:i,textId:h,isSelected:l,onItemTextChange:I.useCallback(e=>{d(t=>t||(e?.textContent??``).trim())},[]),children:(0,L.jsx)(NP.ItemSlot,{scope:n,value:r,disabled:i,textValue:u,children:(0,L.jsx)(kP.div,{role:`option`,"aria-labelledby":h,"data-highlighted":f?``:void 0,"aria-selected":l&&f,"data-state":l?`checked`:`unchecked`,"aria-disabled":i||void 0,"data-disabled":i?``:void 0,tabIndex:i?void 0:-1,...o,ref:m,onFocus:kS(o.onFocus,()=>p(!0)),onBlur:kS(o.onBlur,()=>p(!1)),onClick:kS(o.onClick,()=>{g.current!==`mouse`&&_()}),onPointerUp:kS(o.onPointerUp,()=>{g.current===`mouse`&&_()}),onPointerDown:kS(o.onPointerDown,e=>{g.current=e.pointerType}),onPointerMove:kS(o.onPointerMove,e=>{g.current=e.pointerType,i?c.onItemLeave?.():g.current===`mouse`&&e.currentTarget.focus({preventScroll:!0})}),onPointerLeave:kS(o.onPointerLeave,e=>{e.currentTarget===document.activeElement&&c.onItemLeave?.()}),onKeyDown:kS(o.onKeyDown,e=>{c.searchRef?.current!==``&&e.key===` `||(jP.includes(e.key)&&_(),e.key===` `&&e.preventDefault())})})})})});CF.displayName=bF;var wF=`SelectItemText`,TF=I.forwardRef((e,t)=>{let{__scopeSelect:n,className:r,style:i,...a}=e,o=BP(wF,n),s=nF(wF,n),c=SF(wF,n),l=HP(wF,n),[u,d]=I.useState(null),f=Lt(t,e=>d(e),c.onItemTextChange,e=>s.itemTextRefCallback?.(e,c.value,c.disabled)),p=u?.textContent,m=I.useMemo(()=>(0,L.jsx)(`option`,{value:c.value,disabled:c.disabled,children:p},c.value),[c.disabled,c.value,p]),{onNativeOptionAdd:h,onNativeOptionRemove:g}=l;return DC(()=>(h(m),()=>g(m)),[h,g,m]),(0,L.jsxs)(L.Fragment,{children:[(0,L.jsx)(kP.span,{id:c.textId,...a,ref:f}),c.isSelected&&o.valueNode&&!o.valueNodeHasChildren?fi.createPortal(a.children,o.valueNode):null]})});TF.displayName=wF;var EF=`SelectItemIndicator`,DF=I.forwardRef((e,t)=>{let{__scopeSelect:n,...r}=e;return SF(EF,n).isSelected?(0,L.jsx)(kP.span,{"aria-hidden":!0,...r,ref:t}):null});DF.displayName=EF;var OF=`SelectScrollUpButton`,kF=I.forwardRef((e,t)=>{let n=nF(OF,e.__scopeSelect),r=dF(OF,e.__scopeSelect),[i,a]=I.useState(!1),o=Lt(t,r.onScrollButtonChange);return DC(()=>{if(n.viewport&&n.isPositioned){let e=function(){a(t.scrollTop>0)},t=n.viewport;return e(),t.addEventListener(`scroll`,e),()=>t.removeEventListener(`scroll`,e)}},[n.viewport,n.isPositioned]),i?(0,L.jsx)(MF,{...e,ref:o,onAutoScroll:()=>{let{viewport:e,selectedItem:t}=n;e&&t&&(e.scrollTop-=t.offsetHeight)}}):null});kF.displayName=OF;var AF=`SelectScrollDownButton`,jF=I.forwardRef((e,t)=>{let n=nF(AF,e.__scopeSelect),r=dF(AF,e.__scopeSelect),[i,a]=I.useState(!1),o=Lt(t,r.onScrollButtonChange);return DC(()=>{if(n.viewport&&n.isPositioned){let e=function(){let e=t.scrollHeight-t.clientHeight;a(Math.ceil(t.scrollTop)t.removeEventListener(`scroll`,e)}},[n.viewport,n.isPositioned]),i?(0,L.jsx)(MF,{...e,ref:o,onAutoScroll:()=>{let{viewport:e,selectedItem:t}=n;e&&t&&(e.scrollTop+=t.offsetHeight)}}):null});jF.displayName=AF;var MF=I.forwardRef((e,t)=>{let{__scopeSelect:n,onAutoScroll:r,...i}=e,a=nF(`SelectScrollButton`,n),o=I.useRef(null),s=PP(n),c=I.useCallback(()=>{o.current!==null&&(window.clearInterval(o.current),o.current=null)},[]);return I.useEffect(()=>()=>c(),[c]),DC(()=>{s().find(e=>e.ref.current===document.activeElement)?.ref.current?.scrollIntoView({block:`nearest`})},[s]),(0,L.jsx)(kP.div,{"aria-hidden":!0,...i,ref:t,style:{flexShrink:0,...i.style},onPointerDown:kS(i.onPointerDown,()=>{o.current===null&&(o.current=window.setInterval(r,50))}),onPointerMove:kS(i.onPointerMove,()=>{a.onItemLeave?.(),o.current===null&&(o.current=window.setInterval(r,50))}),onPointerLeave:kS(i.onPointerLeave,()=>{c()})})}),NF=`SelectSeparator`,PF=I.forwardRef((e,t)=>{let{__scopeSelect:n,...r}=e;return(0,L.jsx)(kP.div,{"aria-hidden":!0,...r,ref:t})});PF.displayName=NF;var FF=`SelectArrow`,IF=I.forwardRef((e,t)=>{let{__scopeSelect:n,...r}=e,i=RP(n),a=BP(FF,n),o=nF(FF,n);return a.open&&o.position===`popper`?(0,L.jsx)(AE,{...i,...r,ref:t}):null});IF.displayName=FF;var LF=`SelectBubbleInput`,RF=I.forwardRef(({__scopeSelect:e,value:t,...n},r)=>{let i=I.useRef(null),a=Lt(r,i),o=jj(t);return I.useEffect(()=>{let e=i.current;if(!e)return;let n=window.HTMLSelectElement.prototype,r=Object.getOwnPropertyDescriptor(n,`value`).set;if(o!==t&&r){let n=new Event(`change`,{bubbles:!0});r.call(e,t),e.dispatchEvent(n)}},[o,t]),(0,L.jsx)(kP.select,{...n,style:{...gM,...n.style},ref:a,defaultValue:t})});RF.displayName=LF;function zF(e){return e===``||e===void 0}function BF(e){let t=BS(e),n=I.useRef(``),r=I.useRef(0),i=I.useCallback(e=>{let i=n.current+e;t(i),(function e(t){n.current=t,window.clearTimeout(r.current),t!==``&&(r.current=window.setTimeout(()=>e(``),1e3))})(i)},[t]),a=I.useCallback(()=>{n.current=``,window.clearTimeout(r.current)},[]);return I.useEffect(()=>()=>window.clearTimeout(r.current),[]),[n,i,a]}function VF(e,t,n){let r=t.length>1&&Array.from(t).every(e=>e===t[0])?t[0]:t,i=n?e.indexOf(n):-1,a=HF(e,Math.max(i,0));r.length===1&&(a=a.filter(e=>e!==n));let o=a.find(e=>e.textValue.toLowerCase().startsWith(r.toLowerCase()));return o===n?void 0:o}function HF(e,t){return e.map((n,r)=>e[(t+r)%e.length])}var UF=UP,WF=GP,GF=qP,KF=YP,qF=ZP,JF=$P,YF=pF,XF=yF,ZF=CF,QF=TF,$F=DF,eI=kF,tI=jF,nI=PF,rI=UF,iI=GF,aI=I.forwardRef(({className:e,children:t,...n},r)=>(0,L.jsxs)(WF,{ref:r,className:Tr(`flex h-8 w-full items-center justify-between gap-2 rounded-lg border border-input bg-transparent px-3 py-1.5 text-left text-[12px] text-foreground transition-colors duration-200 placeholder:text-text-tertiary focus-visible:outline-none focus-visible:border-white/[0.15] aria-invalid:border-destructive aria-invalid:focus-visible:border-destructive disabled:cursor-not-allowed disabled:opacity-50 [&>span]:min-w-0 [&>span]:line-clamp-1`,e),...n,children:[t,(0,L.jsx)(KF,{asChild:!0,children:(0,L.jsx)(Vr,{className:`h-3.5 w-3.5 shrink-0 text-text-tertiary`})})]}));aI.displayName=WF.displayName;var oI=I.forwardRef(({className:e,...t},n)=>(0,L.jsx)(eI,{ref:n,className:Tr(`flex cursor-default items-center justify-center py-1`,e),...t,children:(0,L.jsx)(Ur,{className:`h-3.5 w-3.5 text-text-tertiary`})}));oI.displayName=eI.displayName;var sI=I.forwardRef(({className:e,...t},n)=>(0,L.jsx)(tI,{ref:n,className:Tr(`flex cursor-default items-center justify-center py-1`,e),...t,children:(0,L.jsx)(Vr,{className:`h-3.5 w-3.5 text-text-tertiary`})}));sI.displayName=tI.displayName;var cI=I.forwardRef(({className:e,children:t,position:n=`popper`,...r},i)=>(0,L.jsx)(qF,{children:(0,L.jsxs)(JF,{ref:i,className:Tr(`relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-xl border border-border bg-popover text-popover-foreground shadow-lg shadow-primary/5 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2`,n===`popper`&&`w-[var(--radix-select-trigger-width)] data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1`,e),position:n,...r,children:[(0,L.jsx)(oI,{}),(0,L.jsx)(YF,{className:Tr(`p-1.5`,n===`popper`&&`h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]`),children:t}),(0,L.jsx)(sI,{})]})}));cI.displayName=JF.displayName;var lI=I.forwardRef(({className:e,...t},n)=>(0,L.jsx)(XF,{ref:n,className:Tr(`px-3.5 py-2 text-[13px] font-medium text-muted-foreground`,e),...t}));lI.displayName=XF.displayName;var uI=I.forwardRef(({className:e,children:t,...n},r)=>(0,L.jsxs)(ZF,{ref:r,className:Tr(`relative flex w-full cursor-pointer select-none items-center rounded-md py-1.5 pl-3 pr-8 text-[12px] outline-none transition-colors duration-200 focus:bg-white/[0.06] focus:text-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50`,e),...n,children:[(0,L.jsx)(`span`,{className:`absolute right-3 flex h-3.5 w-3.5 items-center justify-center`,children:(0,L.jsx)($F,{children:(0,L.jsx)(Br,{className:`h-3.5 w-3.5 text-primary`})})}),(0,L.jsx)(QF,{children:t})]}));uI.displayName=ZF.displayName;var dI=I.forwardRef(({className:e,...t},n)=>(0,L.jsx)(nI,{ref:n,className:Tr(`-mx-1 my-1 h-px bg-border`,e),...t}));dI.displayName=nI.displayName;function fI(e){let t=(e??`read write`).split(/\s+/).map(e=>e.trim()).filter(Boolean),n=new Set(wN);return new Set(t.filter(e=>n.has(e)))}function pI(e){let t=e.allowed_services_csv,n=e.allowed_nodes_csv,r=new Set((t??``).split(`,`).map(e=>e.trim()).filter(Boolean)),i=new Set((n??``).split(`,`).map(e=>e.trim()).filter(Boolean)),a=e.allow_all_services||!t&&!e.allow_auto_connected_services,o=!n;return{allowAllServices:a,allowAutoConnectedServices:e.allow_auto_connected_services??!1,allowAllNodes:o,selectedServiceIds:r,selectedNodeIds:i}}function mI({prefill:e,pairingId:t,onSuccess:n}){let[r,i]=(0,I.useState)(e.name??``),[a,o]=(0,I.useState)(e.platform??``),[s,c]=(0,I.useState)(!1),[l,u]=(0,I.useState)(()=>fI(e.scopes)),[d,f]=(0,I.useState)(()=>pI(e)),[p,m]=(0,I.useState)(e.org_id??``),h=cP(),[g,_]=(0,I.useState)(!1),[v,y]=(0,I.useState)(null);async function b(){_(!0),y(null);try{let i={name:r,scopes:Array.from(l).join(` `),allow_all_services:d.allowAllServices,allow_auto_connected_services:d.allowAutoConnectedServices??!1,allow_all_nodes:d.allowAllNodes};if(a&&(i.platform=a),e.callback_url&&(i.callback_url=e.callback_url),p&&(i.target_org_id=p),d.allowAllServices||(i.allowed_service_ids=Array.from(d.selectedServiceIds)),d.allowAllNodes||(i.allowed_node_ids=Array.from(d.selectedNodeIds)),e.expires_in_days!=null&&e.expires_in_days>0){let t=new Date;t.setDate(t.getDate()+e.expires_in_days),i.expires_at=t.toISOString()}await lN(t);let o=await dN(t,()=>Wy.post(`/api-keys`,i));n({kind:`api-key-create`,api_key_id:o.id,full_key:o.full_key})}catch(e){y(EI(e))}finally{_(!1)}}let x=g||!s||l.size===0;return(0,L.jsxs)(`div`,{className:`flex flex-col gap-4`,children:[(0,L.jsxs)(`div`,{className:`flex flex-col gap-1`,children:[(0,L.jsx)(`h2`,{className:`font-serif text-[28px] font-normal`,children:`Create an API key`}),(0,L.jsx)(`p`,{className:`text-[12px] text-muted-foreground`,children:`Review the details your CLI sent and confirm to mint the key.`})]}),(0,L.jsxs)(`div`,{className:`flex flex-col gap-4`,children:[(0,L.jsx)(yN,{id:`pair-api-key-name`,label:`Name`,schema:mN,value:r,onChange:i,onValidityChange:c,placeholder:`e.g. coding-agent`,hint:"A short label so you can find this key in `nyxid api-key list`.",autoFocus:!0}),(0,L.jsxs)(`div`,{className:`flex flex-col gap-1.5`,children:[(0,L.jsxs)(`div`,{className:`flex items-center gap-1.5`,children:[(0,L.jsx)(vi,{htmlFor:`pair-api-key-platform`,children:`Platform`}),(0,L.jsx)(aN,{delayDuration:150,children:(0,L.jsxs)(oN,{children:[(0,L.jsx)(sN,{asChild:!0,children:(0,L.jsx)(`button`,{type:`button`,"aria-label":`About platform tags`,className:`text-muted-foreground transition-colors duration-300 hover:text-foreground`,children:(0,L.jsx)($r,{className:`h-3.5 w-3.5`})})}),(0,L.jsx)(cN,{side:`right`,align:`start`,sideOffset:8,className:`max-w-[340px] whitespace-normal px-5 py-4 text-[13px] leading-[1.55]`,children:(0,L.jsxs)(`div`,{className:`flex flex-col gap-4`,children:[(0,L.jsxs)(`p`,{children:[(0,L.jsx)(`span`,{className:`font-medium text-foreground`,children:`Platform`}),` `,`tags the key with the AI agent that will use it.`]}),(0,L.jsx)(`p`,{className:`text-muted-foreground`,children:`It controls three things: audit attribution (logs show which agent made each proxy request), per- agent rate-limit buckets, and dashboard filtering on the API Keys page.`}),(0,L.jsxs)(`p`,{className:`text-muted-foreground`,children:[`Values are a fixed allowlist —`,` `,(0,L.jsx)(`code`,{children:`claude-code`}),`, `,(0,L.jsx)(`code`,{children:`cursor`}),`,`,` `,(0,L.jsx)(`code`,{children:`codex`}),`, `,(0,L.jsx)(`code`,{children:`openclaw`}),`,`,` `,(0,L.jsx)(`code`,{children:`generic`}),`. Custom strings are rejected by the backend.`]}),(0,L.jsxs)(`p`,{className:`text-muted-foreground`,children:[`Leave as `,(0,L.jsx)(`code`,{children:`— none —`}),` if you don't want the tag.`]})]})})]})})]}),(0,L.jsxs)(rI,{value:a===``?`__none__`:a,onValueChange:e=>{o(e===`__none__`?``:e)},children:[(0,L.jsx)(aI,{id:`pair-api-key-platform`,children:(0,L.jsx)(iI,{placeholder:`— none —`})}),(0,L.jsxs)(cI,{children:[(0,L.jsx)(uI,{value:`__none__`,children:`— none —`}),hN.map(e=>(0,L.jsx)(uI,{value:e,children:(0,L.jsxs)(`span`,{className:`inline-flex items-center gap-2`,children:[(0,L.jsx)(Dj,{platform:e,size:`2xs`}),(0,L.jsx)(`span`,{children:e})]})},e))]})]}),(0,L.jsx)(`p`,{className:`text-xs text-muted-foreground`,children:`Tags the key for audit attribution + per-agent rate limits.`})]}),(h.data?.length??0)>0?(0,L.jsxs)(wI,{label:`Owner`,htmlFor:`pair-api-key-owner`,children:[(0,L.jsxs)(`select`,{id:`pair-api-key-owner`,value:p,onChange:e=>{m(e.target.value),f(e=>({...e,selectedServiceIds:new Set,selectedNodeIds:new Set,allowAutoConnectedServices:!1}))},className:`flex h-10 w-full rounded-xl border border-input bg-transparent px-[14px] py-2 text-[13px] text-foreground transition-colors duration-300 focus-visible:outline-none`,children:[(0,L.jsx)(`option`,{value:``,children:`Personal (your account)`}),h.data?.filter(e=>e.your_role===`admin`).map(e=>(0,L.jsxs)(`option`,{value:e.id,children:[`Org · `,e.display_name??e.id]},e.id))]}),(0,L.jsx)(`p`,{className:`mt-1 text-xs text-muted-foreground`,children:`Org-owned keys authenticate as the org; every admin of the selected org can rotate or delete them.`})]}):null,(0,L.jsx)(TN,{value:l,onChange:u}),(0,L.jsx)(nP,{value:d,onChange:f,ownerId:p})]}),v?(0,L.jsx)(TI,{message:v}):null,(0,L.jsx)(di,{variant:`primary`,onClick:()=>void b(),disabled:x,children:g?`Creating...`:`Create Key`})]})}function hI({prefill:e,pairingId:t,onSuccess:n}){let[r,i]=(0,I.useState)(!1),[a,o]=(0,I.useState)(null);async function s(){i(!0),o(null);try{await lN(t);let r=await dN(t,()=>Wy.post(`/api-keys/${encodeURIComponent(e.resource_id)}/rotate`));n({kind:`api-key-rotate`,resource_id:r.id,full_key:r.full_key,platform:r.platform})}catch(e){o(EI(e))}finally{i(!1)}}return(0,L.jsxs)(`div`,{className:`flex flex-col gap-4`,children:[(0,L.jsxs)(`div`,{className:`flex flex-col gap-1`,children:[(0,L.jsx)(`h2`,{className:`font-serif text-[28px] font-normal`,children:`Rotate API key`}),(0,L.jsxs)(`p`,{className:`text-[12px] text-muted-foreground`,children:[`Rotating `,(0,L.jsx)(`strong`,{children:e.display_name}),` will issue a new key and immediately revoke the previous one.`]})]}),a?(0,L.jsx)(TI,{message:a}):null,(0,L.jsx)(di,{variant:`primary`,onClick:()=>void s(),disabled:r,children:r?`Rotating...`:`Rotate key`})]})}var gI=`my-node`;function _I({prefill:e,pairingId:t,onSuccess:n}){let[r,i]=(0,I.useState)(e.name??``),[a,o]=(0,I.useState)(!0),[s,c]=(0,I.useState)(!1),[l,u]=(0,I.useState)(null);async function d(){c(!0),u(null);try{let e=r.trim(),i=e.length>0?e:gI;await lN(t);let a=await dN(t,()=>Wy.post(`/nodes/register-token`,{name:i}));n({kind:`node-register-token`,token_id:a.token_id,token:a.token})}catch(e){u(EI(e))}finally{c(!1)}}return(0,L.jsxs)(`div`,{className:`flex flex-col gap-4`,children:[(0,L.jsxs)(`div`,{className:`flex flex-col gap-1`,children:[(0,L.jsx)(`h2`,{className:`font-serif text-[28px] font-normal`,children:`Generate node registration token`}),(0,L.jsxs)(`p`,{className:`text-[12px] text-muted-foreground`,children:[`Use this token with `,(0,L.jsx)(`code`,{children:`nyxid node register`}),` to connect a new node.`]})]}),(0,L.jsx)(yN,{id:`pair-node-name`,label:`Node name (optional)`,schema:pN,value:r,onChange:i,onValidityChange:o,placeholder:gI,hint:`Lowercase letters, digits, hyphens only (max 64). Leave blank for \`${gI}\`.`,optional:!0,autoFocus:!0}),l?(0,L.jsx)(TI,{message:l}):null,(0,L.jsx)(di,{variant:`primary`,onClick:()=>void d(),disabled:s||!a,children:s?`Generating...`:`Generate token`})]})}function vI({prefill:e,pairingId:t,onSuccess:n}){let[r,i]=(0,I.useState)(!1),[a,o]=(0,I.useState)(null);async function s(){i(!0),o(null);try{await lN(t);let r=await dN(t,()=>Wy.post(`/nodes/${encodeURIComponent(e.resource_id)}/rotate-token`));n({kind:`node-rotate-token`,resource_id:e.resource_id,auth_token:r.auth_token,signing_secret:r.signing_secret})}catch(e){o(EI(e))}finally{i(!1)}}return(0,L.jsxs)(`div`,{className:`flex flex-col gap-4`,children:[(0,L.jsxs)(`div`,{className:`flex flex-col gap-1`,children:[(0,L.jsx)(`h2`,{className:`font-serif text-[28px] font-normal`,children:`Rotate node token`}),(0,L.jsxs)(`p`,{className:`text-[12px] text-muted-foreground`,children:[`Rotating `,(0,L.jsx)(`strong`,{children:e.display_name}),` issues a new auth token + signing secret and revokes the previous pair.`]})]}),a?(0,L.jsx)(TI,{message:a}):null,(0,L.jsx)(di,{variant:`primary`,onClick:()=>void s(),disabled:r,children:r?`Rotating...`:`Rotate token`})]})}function yI({prefill:e,pairingId:t,onSuccess:n}){let r=Xu({resolver:Yu(dk),defaultValues:{name:e.name??``,allowed_scopes:e.scopes??`openid profile`,description:e.description??``,role_ids:e.role_ids_csv??``}}),i=r.watch(`name`),a=r.watch(`allowed_scopes`),o=r.watch(`description`)??``,s=r.watch(`role_ids`)??``,[c,l]=(0,I.useState)(e.org_id??``),u=OS(e=>e.user),d=ft({queryKey:[`wizard-current-user`,t],enabled:!u,queryFn:async()=>{await OS.getState().checkAuth({ephemeral:!0});let e=OS.getState().user;if(!e)throw Error(`Unable to load your account. Check your CLI login and retry.`);return e},retry:!1}),f=cP(),[p,m]=(0,I.useState)(!1),[h,g]=(0,I.useState)(null);async function _(){if(await r.trigger()){m(!0),g(null);try{let r={name:i.trim(),allowed_scopes:a.trim()};o.trim()&&(r.description=o.trim()),e.rate_limit_override!=null&&(r.rate_limit_override=e.rate_limit_override);let l=s.split(`,`).map(e=>e.trim()).filter(Boolean);l.length>0&&(r.role_ids=l),c&&(r.target_org_id=c),await lN(t);let u=await dN(t,()=>Wy.post(`/admin/service-accounts`,r));n({kind:`service-account-create`,service_account_id:u.id,client_id:u.client_id,client_secret:u.client_secret})}catch(e){g(EI(e))}finally{m(!1)}}}let v=p||i.trim().length===0||a.trim().length===0;return(0,L.jsxs)(`div`,{className:`flex flex-col gap-4`,children:[(0,L.jsxs)(`div`,{className:`flex flex-col gap-1`,children:[(0,L.jsx)(`h2`,{className:`font-serif text-[28px] font-normal`,children:`Create a service account`}),(0,L.jsx)(`p`,{className:`text-sm text-muted-foreground`,children:`Service accounts authenticate via the OAuth client_credentials flow. The client_secret is shown once, on the next screen.`})]}),(0,L.jsxs)(`div`,{className:`flex flex-col gap-4`,children:[(0,L.jsx)(wI,{label:`Name`,htmlFor:`pair-sa-name`,children:(0,L.jsx)(Oj,{id:`pair-sa-name`,value:i,onChange:e=>{r.setValue(`name`,e.target.value)},placeholder:`e.g. ci-deploys`,autoFocus:!0})}),(0,L.jsx)(wI,{label:`Allowed scopes`,htmlFor:`pair-sa-scopes`,children:(0,L.jsx)(lk,{id:`pair-sa-scopes`,value:a,onChange:e=>r.setValue(`allowed_scopes`,e),ownerId:c||u?.id||``})}),(0,L.jsx)(wI,{label:`Description (optional)`,htmlFor:`pair-sa-desc`,children:(0,L.jsx)(Oj,{id:`pair-sa-desc`,value:o,onChange:e=>{r.setValue(`description`,e.target.value)},placeholder:`What this account is for`})}),(0,L.jsx)(wI,{label:`Role IDs (optional, comma-separated)`,htmlFor:`pair-sa-roles`,children:(0,L.jsx)(Oj,{id:`pair-sa-roles`,value:s,onChange:e=>{r.setValue(`role_ids`,e.target.value)},placeholder:`role-id-1,role-id-2`})}),(f.data?.length??0)>0?(0,L.jsx)(wI,{label:`Owner`,htmlFor:`pair-sa-owner`,children:(0,L.jsxs)(`select`,{id:`pair-sa-owner`,value:c,onChange:e=>{l(e.target.value)},className:`flex h-10 w-full rounded-[10px] border border-input bg-transparent px-[14px] py-2 text-[13px] text-foreground ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2`,children:[(0,L.jsx)(`option`,{value:``,children:`Personal (your admin account)`}),f.data?.map(e=>(0,L.jsxs)(`option`,{value:e.id,children:[`Org · `,e.display_name??e.id]},e.id))]})}):null]}),!u&&d.isPending&&(0,L.jsx)(`p`,{role:`status`,children:`Loading your account…`}),!u&&d.isError&&(0,L.jsxs)(`div`,{role:`alert`,children:[(0,L.jsx)(TI,{message:d.error.message}),(0,L.jsx)(di,{variant:`outline`,onClick:()=>void d.refetch(),children:`Retry account`})]}),Object.entries(r.formState.errors).map(([e,t])=>(0,L.jsx)(TI,{message:t.message??`Invalid value`},e)),h?(0,L.jsx)(TI,{message:h}):null,(0,L.jsx)(di,{onClick:()=>void _(),disabled:v,children:p?`Creating...`:`Create Service Account`})]})}function bI({prefill:e,pairingId:t,onSuccess:n}){let[r,i]=(0,I.useState)(!1),[a,o]=(0,I.useState)(null);async function s(){i(!0),o(null);try{await lN(t);let r=await dN(t,()=>Wy.post(`/admin/service-accounts/${encodeURIComponent(e.resource_id)}/rotate-secret`));n({kind:`service-account-rotate-secret`,resource_id:e.resource_id,client_id:r.client_id,client_secret:r.client_secret})}catch(e){o(EI(e))}finally{i(!1)}}return(0,L.jsxs)(`div`,{className:`flex flex-col gap-4`,children:[(0,L.jsxs)(`div`,{className:`flex flex-col gap-1`,children:[(0,L.jsx)(`h2`,{className:`font-serif text-[28px] font-normal`,children:`Rotate service account secret`}),(0,L.jsxs)(`p`,{className:`text-sm text-muted-foreground`,children:[`Rotating `,(0,L.jsx)(`strong`,{children:e.display_name}),` immediately revokes all existing access tokens issued under this service account and mints a new client_secret.`]})]}),a?(0,L.jsx)(TI,{message:a}):null,(0,L.jsx)(di,{onClick:()=>void s(),disabled:r,children:r?`Rotating...`:`Rotate secret`})]})}function xI({prefill:e,pairingId:t,onSuccess:n}){let[r,i]=(0,I.useState)(e.name??``),a=(e.redirect_uris??[]).filter(e=>typeof e==`string`&&e.length>0),[o,s]=(0,I.useState)(a.length>0?[...a]:[``]),[c,l]=(0,I.useState)(e.allowed_scopes??`openid profile email`),[u,d]=(0,I.useState)(e.delegation_scopes??``),[f,p]=(0,I.useState)(e.broker_capability??!1),[m,h]=(0,I.useState)(e.org_id??``),g=cP(),[_,v]=(0,I.useState)(!1),[y,b]=(0,I.useState)(null);function x(e,t){s(n=>n.map((n,r)=>r===e?t:n))}function S(){s(e=>[...e,``])}function C(e){s(t=>t.length===1?[``]:t.filter((t,n)=>n!==e))}async function w(){v(!0),b(null);try{let i=o.map(e=>e.trim()).filter(Boolean);if(i.length===0){b(`At least one redirect URI is required.`),v(!1);return}let a={name:r.trim(),redirect_uris:i,client_type:`confidential`};c.trim()&&(a.allowed_scopes=c.split(/\s+/).map(e=>e.trim()).filter(Boolean)),u.trim()&&(a.delegation_scopes=u.trim()),f&&(a.broker_capability_enabled=!0);let s=(e.default_service_catalog_slugs??[]).map(e=>e.trim()).filter(Boolean);s.length>0&&(a.default_service_catalog_slugs=s),m&&(a.target_org_id=m),await lN(t);let l=await dN(t,()=>Wy.post(`/developer/oauth-clients`,a));if(!l.client_secret)throw Error(`Server didn't return a client_secret — was the client_type 'public'?`);n({kind:`developer-app-create`,developer_app_id:l.id,client_secret:l.client_secret})}catch(e){b(EI(e))}finally{v(!1)}}let T=_||r.trim().length===0;return(0,L.jsxs)(`div`,{className:`flex flex-col gap-4`,children:[(0,L.jsxs)(`div`,{className:`flex flex-col gap-1`,children:[(0,L.jsx)(`h2`,{className:`font-serif text-[28px] font-normal`,children:`Create a developer OAuth app`}),(0,L.jsx)(`p`,{className:`text-sm text-muted-foreground`,children:`Confidential client — the client_secret is shown once on the next screen. Use it to sign Sign-in-with-NyxID requests from your downstream product.`})]}),(0,L.jsxs)(`div`,{className:`flex flex-col gap-4`,children:[(0,L.jsx)(wI,{label:`App name`,htmlFor:`pair-app-name`,children:(0,L.jsx)(Oj,{id:`pair-app-name`,value:r,onChange:e=>{i(e.target.value)},placeholder:`e.g. Acme Web`,autoFocus:!0})}),(0,L.jsxs)(`div`,{className:`flex flex-col gap-1.5`,children:[(0,L.jsx)(vi,{children:`Redirect URIs`}),(0,L.jsxs)(`div`,{className:`flex flex-col gap-2`,children:[o.map((e,t)=>(0,L.jsxs)(`div`,{className:`flex items-center gap-2`,children:[(0,L.jsx)(Oj,{value:e,onChange:e=>{x(t,e.target.value)},placeholder:`https://app.example.com/callback`,className:`flex-1`}),(0,L.jsx)(di,{type:`button`,variant:`outline`,size:`icon`,onClick:()=>{C(t)},"aria-label":`Remove redirect URI`,disabled:o.length===1&&e.trim().length===0,children:(0,L.jsx)(oi,{className:`h-4 w-4`})})]},t)),(0,L.jsxs)(di,{type:`button`,variant:`outline`,size:`sm`,onClick:S,className:`self-start`,children:[(0,L.jsx)(ii,{className:`mr-1 h-3 w-3`}),` Add redirect URI`]})]})]}),(0,L.jsxs)(wI,{label:`Allowed scopes`,htmlFor:`pair-app-scopes`,children:[(0,L.jsx)(Oj,{id:`pair-app-scopes`,value:c,onChange:e=>{l(e.target.value)},placeholder:`openid profile email`}),(0,L.jsx)(`p`,{className:`text-xs text-muted-foreground`,children:`Space-separated.`})]}),(0,L.jsx)(wI,{label:`Delegation scopes (optional)`,htmlFor:`pair-app-delegation`,children:(0,L.jsx)(Oj,{id:`pair-app-delegation`,value:u,onChange:e=>{d(e.target.value)},placeholder:`(blank disables token exchange)`})}),(0,L.jsxs)(`div`,{className:`flex items-center justify-between rounded-md border border-border bg-muted/20 px-3 py-2`,children:[(0,L.jsxs)(`div`,{className:`flex flex-col gap-0.5`,children:[(0,L.jsx)(vi,{htmlFor:`pair-app-broker`,children:`Broker capability`}),(0,L.jsx)(`p`,{className:`text-xs text-muted-foreground`,children:`Allow this app to broker downstream credentials.`})]}),(0,L.jsx)(Qj,{id:`pair-app-broker`,checked:f,onCheckedChange:e=>{p(e)}})]}),(g.data?.length??0)>0?(0,L.jsx)(wI,{label:`Owner`,htmlFor:`pair-app-owner`,children:(0,L.jsxs)(`select`,{id:`pair-app-owner`,value:m,onChange:e=>{h(e.target.value)},className:`flex h-10 w-full rounded-[10px] border border-input bg-transparent px-[14px] py-2 text-[13px] text-foreground ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2`,children:[(0,L.jsx)(`option`,{value:``,children:`Personal`}),g.data?.map(e=>(0,L.jsxs)(`option`,{value:e.id,children:[`Org · `,e.display_name??e.id]},e.id))]})}):null]}),y?(0,L.jsx)(TI,{message:y}):null,(0,L.jsx)(di,{onClick:()=>void w(),disabled:T,children:_?`Creating...`:`Create app`})]})}function SI({prefill:e,pairingId:t,onSuccess:n}){let[r,i]=(0,I.useState)(!1),[a,o]=(0,I.useState)(null);async function s(){i(!0),o(null);try{await lN(t);let r=await dN(t,()=>Wy.post(`/developer/oauth-clients/${encodeURIComponent(e.resource_id)}/rotate-secret`));n({kind:`developer-app-rotate-secret`,resource_id:r.id,client_secret:r.client_secret})}catch(e){o(EI(e))}finally{i(!1)}}return(0,L.jsxs)(`div`,{className:`flex flex-col gap-4`,children:[(0,L.jsxs)(`div`,{className:`flex flex-col gap-1`,children:[(0,L.jsx)(`h2`,{className:`font-serif text-[28px] font-normal`,children:`Rotate developer app secret`}),(0,L.jsxs)(`p`,{className:`text-sm text-muted-foreground`,children:[`Rotating `,(0,L.jsx)(`strong`,{children:e.display_name}),` mints a new client_secret. Update any deployments using the previous value immediately.`]})]}),a?(0,L.jsx)(TI,{message:a}):null,(0,L.jsx)(di,{onClick:()=>void s(),disabled:r,children:r?`Rotating...`:`Rotate secret`})]})}function CI({pairingId:e,onSuccess:t}){let[n,r]=(0,I.useState)(`init`),[i,a]=(0,I.useState)(null),[o,s]=(0,I.useState)(null),[c,l]=(0,I.useState)(null),[u,d]=(0,I.useState)(null),[f,p]=(0,I.useState)(``),[m,h]=(0,I.useState)(null),[g,_]=(0,I.useState)(!1),v=(0,I.useRef)(!1);(0,I.useEffect)(()=>{v.current||(v.current=!0,(async()=>{try{await lN(e);let t=await dN(e,()=>Wy.post(`/auth/mfa/setup`,{}));a(t.factor_id),s(t.secret),l(t.qr_code_url);try{d(await Bk.toDataURL(t.qr_code_url,{width:240,margin:1}))}catch{}r(`ready`)}catch(e){h(EI(e)),v.current=!1}})())},[e]);async function y(){if(!(!i||n!==`ready`)){h(null),r(`confirming`);try{let e=f.trim();if(e.length===0){h(`Enter the 6-digit code from your authenticator.`),r(`ready`);return}t({kind:`mfa-setup`,factor_id:i,recovery_codes:(await Wy.post(`/auth/mfa/confirm`,{code:e})).recovery_codes})}catch(e){h(EI(e)),r(`ready`)}}}return n===`init`?(0,L.jsxs)(`div`,{className:`flex flex-col gap-4`,children:[(0,L.jsxs)(`div`,{className:`flex flex-col gap-1`,children:[(0,L.jsx)(`h2`,{className:`font-serif text-[28px] font-normal`,children:`Setting up MFA`}),(0,L.jsx)(`p`,{className:`text-sm text-muted-foreground`,children:`Generating a TOTP secret on the server…`})]}),m?(0,L.jsx)(TI,{message:m}):null]}):(0,L.jsxs)(`div`,{className:`flex flex-col gap-4`,children:[(0,L.jsxs)(`div`,{className:`flex flex-col gap-1`,children:[(0,L.jsx)(`h2`,{className:`font-serif text-[28px] font-normal`,children:`Add MFA to your account`}),(0,L.jsx)(`p`,{className:`text-sm text-muted-foreground`,children:`Scan this QR with your authenticator app (1Password, Authy, Google Authenticator). Then enter the 6-digit code it shows to verify and finish enrollment.`})]}),u?(0,L.jsx)(`div`,{className:`flex justify-center rounded-md border border-border bg-white p-4 dark:bg-muted/30`,children:(0,L.jsx)(`img`,{src:u,alt:`MFA enrollment QR code`,className:`h-60 w-60`})}):(0,L.jsx)(`p`,{className:`text-xs text-muted-foreground`,children:`Couldn't render the QR code. Use the otpauth URL below instead.`}),(0,L.jsxs)(`div`,{className:`flex flex-col gap-1.5`,children:[(0,L.jsx)(vi,{children:`Or enter the secret manually`}),(0,L.jsxs)(`div`,{className:`flex items-center gap-2`,children:[(0,L.jsx)(`code`,{className:`flex-1 overflow-x-auto rounded-md border bg-muted/40 px-3 py-2 font-mono text-sm`,children:g?o:`•`.repeat(Math.max(o?.length??12,12))}),(0,L.jsx)(di,{type:`button`,variant:`outline`,size:`sm`,onClick:()=>{_(e=>!e)},children:g?`Hide`:`Reveal`})]}),c?(0,L.jsxs)(`p`,{className:`break-all text-[11px] text-muted-foreground`,children:[`otpauth URL: `,(0,L.jsx)(`code`,{className:`font-mono`,children:c})]}):null]}),(0,L.jsx)(wI,{label:`6-digit code from your authenticator`,htmlFor:`pair-mfa-code`,children:(0,L.jsx)(Oj,{id:`pair-mfa-code`,value:f,onChange:e=>{p(e.target.value)},placeholder:`123456`,inputMode:`numeric`,autoFocus:!0,maxLength:10,className:`font-mono tracking-widest`})}),m?(0,L.jsx)(TI,{message:m}):null,(0,L.jsx)(di,{onClick:()=>void y(),disabled:n===`confirming`||f.trim().length===0,children:n===`confirming`?`Verifying...`:`Verify and enable MFA`})]})}function wI({label:e,htmlFor:t,children:n}){return(0,L.jsxs)(`div`,{className:`flex flex-col gap-1.5`,children:[(0,L.jsx)(vi,{htmlFor:t,children:e}),n]})}function TI({message:e}){return(0,L.jsx)(`p`,{className:`rounded-lg border border-destructive/40 bg-destructive/10 px-3 py-2 text-[12px] text-destructive`,children:e})}function EI(e){return e instanceof Error?e.message:`Something went wrong. Please try again.`}var DI=[`tokens`,`requests`,`bytes`,`input_tokens`,`output_tokens`,`cache_read_tokens`,`cache_write_tokens`,`images`],OI={tokens:{label:`tokens`,singular:`token`,tokenFamily:!0},requests:{label:`requests`,singular:`request`,tokenFamily:!1},bytes:{label:`bytes`,singular:`byte`,tokenFamily:!1},input_tokens:{label:`input tokens`,singular:`input token`,tokenFamily:!0},output_tokens:{label:`output tokens`,singular:`output token`,tokenFamily:!0},cache_read_tokens:{label:`cache-read tokens`,singular:`cache-read token`,tokenFamily:!0},cache_write_tokens:{label:`cache-write tokens`,singular:`cache-write token`,tokenFamily:!0},images:{label:`images`,singular:`image`,tokenFamily:!1}};function kI(e,t){let n=OI[e];return n?t===1?n.singular:n.label:e}var AI=RegExp(`^\\d+(?:\\.\\d{1,12})?$`);function jI(e){if(!AI.test(e))return!1;let[t,n=``]=e.split(`.`);return BigInt(t)*10n**12n+BigInt(n.padEnd(12,`0`))<=1000000n*10n**12n}var MI=$().trim().regex(AI,`Use a non-negative decimal with at most 12 decimal places`).refine(jI,`Price must not exceed 1,000,000 credits per unit`),NI=ax({wire_protocol:hx([`anthropic_messages`,`openai_responses`,`openai_completions`]),model_list:Zb(),realtime:Zb().optional()}).extend({binding:hx([`platform`,`user`]),status_slug:$().optional()}),PI=ax({metric:$(),credits_per_unit:MI,sync_status:hx([`pending`,`synced`,`failed`]).optional()}),FI=PI.extend({components:rx(PI).nullish()}),II=PI.extend({metric:hx(DI)});II.extend({components:rx(II).max(DI.length-1).nullish()}).superRefine((e,t)=>{let n=new Set([e.metric]);e.components?.forEach((e,r)=>{n.has(e.metric)&&t.addIssue({code:`custom`,path:[`components`,r,`metric`],message:`Each unit may appear only once per lane`}),n.add(e.metric)})}),ax({enabled:Zb(),audience:hx([`public`,`restricted`]),allowed_owner_ids:rx($().uuid()).max(1e3)}),ax({inference:NI.nullish(),platform_key:ax({available:Zb(),pricing:FI.nullish()}).optional(),byok_pricing:FI.nullish()});function LI(e){return e?[e,...e.components??[]].map(e=>`${e.credits_per_unit} credits / ${kI(e.metric,1)}${e.sync_status&&e.sync_status!==`synced`?` (price pending; current billing applies)`:``}`).join(` + `):`free`}function RI({value:e,onChange:t,platformPrice:n,byokPrice:r,legacyBillable:i=!1,resaleBillable:a=!1,disabled:o=!1}){return(0,L.jsxs)(`fieldset`,{className:`space-y-2`,disabled:o,children:[(0,L.jsx)(`legend`,{className:`mb-2 text-xs font-medium`,children:`Choose a key`}),[{platform:!0,title:`Use NyxID's key`,price:n},{platform:!1,title:`Use your own key`,price:r}].map(a=>(0,L.jsxs)(`label`,{className:Tr(`flex cursor-pointer items-start gap-3 rounded-lg border p-3 text-xs`,e===a.platform?`border-primary/50`:`border-border/50`),children:[(0,L.jsx)(`input`,{type:`radio`,name:`credential-binding`,checked:e===a.platform,onChange:()=>t(a.platform),className:`mt-0.5 accent-primary`}),(0,L.jsxs)(`span`,{children:[(0,L.jsx)(`span`,{className:`block font-medium`,children:a.title}),(0,L.jsx)(`span`,{className:`text-muted-foreground`,children:!n&&!r&&i?`Current service/plan pricing applies`:LI(a.price)})]})]},String(a.platform))),a&&(0,L.jsx)(`p`,{className:`text-[11px] text-muted-foreground`,children:`Platform-key use may also incur the separate resale fee.`})]})}function zI({className:e,...t}){return(0,L.jsx)(`div`,{className:Tr(`animate-pulse rounded-md bg-muted`,e),...t})}var BI=`__personal__`;function VI({id:e,"aria-describedby":t,value:n,onChange:r,disabled:i,label:a=`Scope`,adminOnly:o=!0,allowAll:s=!1,personalLabel:c=`Personal`}){let{data:l,isLoading:u}=cP(),d=(l??[]).filter(e=>!o||e.your_role===`admin`);return(0,L.jsxs)(rI,{value:n??BI,onValueChange:e=>r(e===BI?null:e),disabled:i||u,children:[(0,L.jsx)(aI,{id:e,"aria-label":a,"aria-describedby":t,children:(0,L.jsx)(iI,{placeholder:c})}),(0,L.jsxs)(cI,{children:[s&&(0,L.jsxs)(L.Fragment,{children:[(0,L.jsx)(uI,{value:`all`,children:`View all`}),(0,L.jsx)(dI,{asChild:!0,className:`border-0`,children:(0,L.jsx)(`hr`,{})})]}),(0,L.jsx)(uI,{value:BI,children:c}),d.map(e=>(0,L.jsx)(uI,{value:e.id,children:e.display_name||e.id},e.id))]})]})}function HI(e,t=[]){return[...new Set([...e,...t.filter(e=>e.required).map(e=>e.scope)])]}function UI(e){let t=new Set,n=[];for(let r of e.split(/[,\s]+/)){let e=r.trim();e&&!t.has(e)&&(t.add(e),n.push(e))}return n}function WI(e,t,n,r){let i=new Set,a=new Set(t),o=new Set(r),s=[];for(let t of e)i.has(t.scope)||(i.add(t.scope),s.push({scope:t.scope,label:t.label||t.scope,description:t.description||null,sensitive:!!t.sensitive,isDefault:a.has(t.scope),locked:o.has(t.scope)}));for(let e of r)i.has(e)||(i.add(e),s.push({scope:e,label:e,description:null,sensitive:!1,isDefault:!1,locked:!0}));for(let e of t)i.has(e)||(i.add(e),s.push({scope:e,label:e,description:null,sensitive:!1,isDefault:!0,locked:!1}));for(let e of n)i.has(e)||(i.add(e),s.push({scope:e,label:e,description:null,sensitive:!1,isDefault:!1,locked:!1}));return s}function GI({catalog:e,defaultScopes:t,value:n,onChange:r,customPlaceholder:i=`e.g. custom.scope`,idPrefix:a=`scope`,lockedScopes:o=[],grantedScopes:s,providerName:c,platformAllowlist:l}){let[u,d]=(0,I.useState)(``),f=HI(n,e),p=new Set(e.filter(e=>e.required).map(e=>e.scope)),m=new Set(f),h=new Set(o),g=WI(e,t,n,o),_=l?new Set(l):null,v=(e,t)=>_!==null&&!t&&!_.has(e),y=e=>g.find(t=>t.scope===e)?.label??e,b=s?new Set(s):null,x=b?f.filter(e=>!b.has(e)):[],S=s?s.filter(e=>!m.has(e)):[],C=x.length>0||S.length>0;function w(e){if(h.has(e)||p.has(e))return;let t=new Set(m);t.has(e)?t.delete(e):t.add(e),r(g.map(e=>e.scope).filter(e=>t.has(e)))}let[T,E]=(0,I.useState)(null);function ee(){let e=UI(u);if(e.length===0)return;if(_!==null){let t=e.filter(e=>!_.has(e));if(t.length>0){E(`${t.join(`, `)} — not available on NyxID's shared app. Use your own OAuth app to request ${t.length>1?`these`:`it`}.`);return}}E(null);let t=[...f];for(let n of e)t.includes(n)||t.push(n);d(``),r(t)}return(0,L.jsxs)(`div`,{className:`flex flex-col gap-2`,children:[(0,L.jsx)(vi,{className:`text-xs`,children:`Scopes`}),g.length>0?(0,L.jsx)(`div`,{role:`group`,"aria-label":`Scopes`,className:`flex flex-wrap gap-1.5`,children:g.map(e=>{let t=p.has(e.scope),n=v(e.scope,e.locked),r=(m.has(e.scope)||e.locked)&&!n;return(0,L.jsxs)(`button`,{type:`button`,"aria-pressed":r,disabled:e.locked||t||n,title:n?`${e.description??e.scope} — available only with your own OAuth app`:t?`${e.description??e.scope} — required for this service`:e.locked?`${e.description??e.scope} — already granted; can't be removed here`:e.description??e.scope,onClick:()=>{w(e.scope)},className:`group inline-flex max-w-full items-center gap-1.5 rounded-full border px-3 py-1.5 text-left text-[12px] transition-colors `+(n?`cursor-not-allowed border-dashed border-border/60 bg-transparent text-muted-foreground/50`:e.locked||t?`cursor-default border-primary/60 bg-primary/10 text-foreground`:r?`border-primary bg-primary/15 text-foreground`:`border-border bg-transparent text-muted-foreground hover:border-primary/50 hover:bg-muted/40`),children:[e.sensitive?(0,L.jsxs)(L.Fragment,{children:[(0,L.jsx)(`span`,{"aria-hidden":`true`,className:`h-1.5 w-1.5 shrink-0 rounded-full bg-warning`}),(0,L.jsx)(`span`,{className:`sr-only`,children:`(write or admin access) `})]}):null,(0,L.jsx)(`span`,{className:`truncate`,children:e.label}),n?(0,L.jsx)(`span`,{className:`shrink-0 text-[11px] italic text-muted-foreground/70`,children:`own app`}):t?(0,L.jsx)(`span`,{className:`shrink-0 text-[11px] text-muted-foreground`,children:`required`}):e.locked?(0,L.jsx)(`span`,{className:`shrink-0 text-[11px] text-muted-foreground`,children:`granted`}):e.isDefault?(0,L.jsx)(`span`,{className:`shrink-0 text-[11px] text-muted-foreground`,children:`default`}):null,r&&!e.locked&&!t?(0,L.jsx)(ci,{className:`h-3 w-3 shrink-0 opacity-50 group-hover:opacity-100`}):null]},e.scope)})}):null,g.some(e=>e.sensitive)?(0,L.jsxs)(`p`,{className:`flex items-center gap-1.5 text-[11px] text-muted-foreground`,children:[(0,L.jsx)(`span`,{"aria-hidden":`true`,className:`h-1.5 w-1.5 shrink-0 rounded-full bg-warning`}),`Dot marks a write or admin-level scope.`]}):null,_!==null&&g.some(e=>v(e.scope,e.locked))?(0,L.jsxs)(`p`,{className:`text-[11px] text-muted-foreground`,children:[`Scopes marked “own app” aren’t offered on NyxID’s shared`,` `,c??`provider`,` app. Connect with your own OAuth app to request them.`]}):null,(0,L.jsxs)(`div`,{className:`flex items-center gap-1.5`,children:[(0,L.jsx)(Oj,{id:`${a}-custom`,value:u,onChange:e=>{d(e.target.value)},onKeyDown:e=>{e.key===`Enter`&&(e.preventDefault(),ee())},placeholder:i,autoComplete:`off`,spellCheck:!1,className:`h-9 text-[12px]`}),(0,L.jsxs)(di,{type:`button`,variant:`outline`,onClick:ee,disabled:u.trim().length===0,className:`h-9 shrink-0 px-3`,children:[(0,L.jsx)(ii,{className:`h-3.5 w-3.5`}),`Add`]})]}),T?(0,L.jsx)(`p`,{className:`text-[11px] text-destructive`,children:T}):null,b&&C?(0,L.jsxs)(`div`,{className:`flex flex-col gap-1 rounded-lg border border-border bg-muted/40 px-3 py-2 text-[12px]`,children:[(0,L.jsx)(`span`,{className:`text-[11px] font-medium uppercase tracking-wide text-muted-foreground`,children:`Changes`}),x.length>0?(0,L.jsxs)(`p`,{className:`text-foreground`,children:[(0,L.jsx)(`span`,{className:`text-success`,children:`+ Adding:`}),` `,x.map(y).join(`, `)]}):null,S.length>0?(0,L.jsxs)(L.Fragment,{children:[(0,L.jsxs)(`p`,{className:`text-foreground`,children:[(0,L.jsx)(`span`,{className:`text-destructive`,children:`− Removing:`}),` `,S.map(y).join(`, `)]}),(0,L.jsxs)(`p`,{className:`text-[11px] text-warning`,children:[`Removing a permission re-authorizes this connection and will stop any app that relies on it. NyxID will use only the remaining permissions; the old access at`,` `,c??`the provider`,` stays until you revoke it there.`]})]}):null]}):null,(0,L.jsx)(`p`,{className:`text-xs text-muted-foreground`,children:h.size>0?`Scopes marked “granted” are already authorized and locked — this provider can’t narrow them by re-authorizing, so they can’t be removed here. Add anything missing above.`:b?`Tick to add a permission, untick to remove one, then update. Changes re-authorize this connection at the provider.`:g.length>0?`Selected scopes are requested at sign-in. Defaults are pre-selected — deselect to drop one. Add anything missing above; the upstream provider decides whether to grant them.`:`Comma- or space-separated. The upstream provider decides whether to grant them.`})]})}function KI(e){return e===`revoked`||e===`failed`||e===`expired`}var qI=5;async function JI({keyId:e,getKey:t,completeWithKey:n,isCancelled:r,onTerminalFailure:i,onTimeout:a,sleepMs:o=YI,nowMs:s=Date.now,timeoutMs:c=300*1e3,intervalMs:l=2e3,maxConsecutiveErrors:u=qI,isComplete:d=e=>e.status===`active`}){let f=s()+c,p=0;for(;s()=u){r()||i({status:`failed`,error_message:"Lost contact with the wizard. Authorization may have completed — run `nyxid status` to verify, then cancel and re-run the wizard if the service is missing."});return}}}r()||a()}function YI(e){return new Promise(t=>{window.setTimeout(t,e)})}var XI=ax({api_base_url:$().trim().url(`API base URL must be a valid URL`).transform(e=>e.replace(/\/+$/,``)),release_integrity:ax({enabled:Zb(),manifest_url:$().trim().url(`Release integrity manifest URL must be a valid URL`).nullable(),verification_ttl_secs:qb().int().positive()})});function ZI(){return ft({queryKey:[`runtime-config`],queryFn:async()=>{let e=await Wy.get(`/runtime-config`);return XI.parse(e)},staleTime:1/0})}function QI(e){if(!e||typeof document>`u`)return;let t=document.head||document.getElementsByTagName(`head`)[0],n=document.createElement(`style`);n.type=`text/css`,t.appendChild(n),n.styleSheet?n.styleSheet.cssText=e:n.appendChild(document.createTextNode(e))}Array(12).fill(0);var $I=1,eL=new class{constructor(){this.subscribe=e=>(this.subscribers.push(e),()=>{let t=this.subscribers.indexOf(e);this.subscribers.splice(t,1)}),this.publish=e=>{this.subscribers.forEach(t=>t(e))},this.addToast=e=>{this.publish(e),this.toasts=[...this.toasts,e]},this.create=e=>{let{message:t,...n}=e,r=typeof e?.id==`number`||e.id?.length>0?e.id:$I++,i=this.toasts.find(e=>e.id===r),a=e.dismissible===void 0?!0:e.dismissible;return this.dismissedToasts.has(r)&&this.dismissedToasts.delete(r),i?this.toasts=this.toasts.map(n=>n.id===r?(this.publish({...n,...e,id:r,title:t}),{...n,...e,id:r,dismissible:a,title:t}):n):this.addToast({title:t,...n,dismissible:a,id:r}),r},this.dismiss=e=>(e?(this.dismissedToasts.add(e),requestAnimationFrame(()=>this.subscribers.forEach(t=>t({id:e,dismiss:!0})))):this.toasts.forEach(e=>{this.subscribers.forEach(t=>t({id:e.id,dismiss:!0}))}),e),this.message=(e,t)=>this.create({...t,message:e}),this.error=(e,t)=>this.create({...t,message:e,type:`error`}),this.success=(e,t)=>this.create({...t,type:`success`,message:e}),this.info=(e,t)=>this.create({...t,type:`info`,message:e}),this.warning=(e,t)=>this.create({...t,type:`warning`,message:e}),this.loading=(e,t)=>this.create({...t,type:`loading`,message:e}),this.promise=(e,t)=>{if(!t)return;let n;t.loading!==void 0&&(n=this.create({...t,promise:e,type:`loading`,message:t.loading,description:typeof t.description==`function`?void 0:t.description}));let r=Promise.resolve(e instanceof Function?e():e),i=n!==void 0,a,o=r.then(async e=>{if(a=[`resolve`,e],I.isValidElement(e))i=!1,this.create({id:n,type:`default`,message:e});else if(nL(e)&&!e.ok){i=!1;let r=typeof t.error==`function`?await t.error(`HTTP error! status: ${e.status}`):t.error,a=typeof t.description==`function`?await t.description(`HTTP error! status: ${e.status}`):t.description,o=typeof r==`object`&&!I.isValidElement(r)?r:{message:r};this.create({id:n,type:`error`,description:a,...o})}else if(e instanceof Error){i=!1;let r=typeof t.error==`function`?await t.error(e):t.error,a=typeof t.description==`function`?await t.description(e):t.description,o=typeof r==`object`&&!I.isValidElement(r)?r:{message:r};this.create({id:n,type:`error`,description:a,...o})}else if(t.success!==void 0){i=!1;let r=typeof t.success==`function`?await t.success(e):t.success,a=typeof t.description==`function`?await t.description(e):t.description,o=typeof r==`object`&&!I.isValidElement(r)?r:{message:r};this.create({id:n,type:`success`,description:a,...o})}}).catch(async e=>{if(a=[`reject`,e],t.error!==void 0){i=!1;let r=typeof t.error==`function`?await t.error(e):t.error,a=typeof t.description==`function`?await t.description(e):t.description,o=typeof r==`object`&&!I.isValidElement(r)?r:{message:r};this.create({id:n,type:`error`,description:a,...o})}}).finally(()=>{i&&(this.dismiss(n),n=void 0),t.finally==null||t.finally.call(t)}),s=()=>new Promise((e,t)=>o.then(()=>a[0]===`reject`?t(a[1]):e(a[1])).catch(t));return typeof n!=`string`&&typeof n!=`number`?{unwrap:s}:Object.assign(n,{unwrap:s})},this.custom=(e,t)=>{let n=t?.id||$I++;return this.create({jsx:e(n),id:n,...t}),n},this.getActiveToasts=()=>this.toasts.filter(e=>!this.dismissedToasts.has(e.id)),this.subscribers=[],this.toasts=[],this.dismissedToasts=new Set}},tL=(e,t)=>{let n=t?.id||$I++;return eL.addToast({title:e,...t,id:n}),n},nL=e=>e&&typeof e==`object`&&`ok`in e&&typeof e.ok==`boolean`&&`status`in e&&typeof e.status==`number`,rL=Object.assign(tL,{success:eL.success,info:eL.info,warning:eL.warning,error:eL.error,custom:eL.custom,message:eL.message,promise:eL.promise,dismiss:eL.dismiss,loading:eL.loading},{getHistory:()=>eL.toasts,getToasts:()=>eL.getActiveToasts()});QI(`[data-sonner-toaster][dir=ltr],html[dir=ltr]{--toast-icon-margin-start:-3px;--toast-icon-margin-end:4px;--toast-svg-margin-start:-1px;--toast-svg-margin-end:0px;--toast-button-margin-start:auto;--toast-button-margin-end:0;--toast-close-button-start:0;--toast-close-button-end:unset;--toast-close-button-transform:translate(-35%, -35%)}[data-sonner-toaster][dir=rtl],html[dir=rtl]{--toast-icon-margin-start:4px;--toast-icon-margin-end:-3px;--toast-svg-margin-start:0px;--toast-svg-margin-end:-1px;--toast-button-margin-start:0;--toast-button-margin-end:auto;--toast-close-button-start:unset;--toast-close-button-end:0;--toast-close-button-transform:translate(35%, -35%)}[data-sonner-toaster]{position:fixed;width:var(--width);font-family:ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica Neue,Arial,Noto Sans,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;--gray1:hsl(0, 0%, 99%);--gray2:hsl(0, 0%, 97.3%);--gray3:hsl(0, 0%, 95.1%);--gray4:hsl(0, 0%, 93%);--gray5:hsl(0, 0%, 90.9%);--gray6:hsl(0, 0%, 88.7%);--gray7:hsl(0, 0%, 85.8%);--gray8:hsl(0, 0%, 78%);--gray9:hsl(0, 0%, 56.1%);--gray10:hsl(0, 0%, 52.3%);--gray11:hsl(0, 0%, 43.5%);--gray12:hsl(0, 0%, 9%);--border-radius:8px;box-sizing:border-box;padding:0;margin:0;list-style:none;outline:0;z-index:999999999;transition:transform .4s ease}@media (hover:none) and (pointer:coarse){[data-sonner-toaster][data-lifted=true]{transform:none}}[data-sonner-toaster][data-x-position=right]{right:var(--offset-right)}[data-sonner-toaster][data-x-position=left]{left:var(--offset-left)}[data-sonner-toaster][data-x-position=center]{left:50%;transform:translateX(-50%)}[data-sonner-toaster][data-y-position=top]{top:var(--offset-top)}[data-sonner-toaster][data-y-position=bottom]{bottom:var(--offset-bottom)}[data-sonner-toast]{--y:translateY(100%);--lift-amount:calc(var(--lift) * var(--gap));z-index:var(--z-index);position:absolute;opacity:0;transform:var(--y);touch-action:none;transition:transform .4s,opacity .4s,height .4s,box-shadow .2s;box-sizing:border-box;outline:0;overflow-wrap:anywhere}[data-sonner-toast][data-styled=true]{padding:16px;background:var(--normal-bg);border:1px solid var(--normal-border);color:var(--normal-text);border-radius:var(--border-radius);box-shadow:0 4px 12px rgba(0,0,0,.1);width:var(--width);font-size:13px;display:flex;align-items:center;gap:6px}[data-sonner-toast]:focus-visible{box-shadow:0 4px 12px rgba(0,0,0,.1),0 0 0 2px rgba(0,0,0,.2)}[data-sonner-toast][data-y-position=top]{top:0;--y:translateY(-100%);--lift:1;--lift-amount:calc(1 * var(--gap))}[data-sonner-toast][data-y-position=bottom]{bottom:0;--y:translateY(100%);--lift:-1;--lift-amount:calc(var(--lift) * var(--gap))}[data-sonner-toast][data-styled=true] [data-description]{font-weight:400;line-height:1.4;color:#3f3f3f}[data-rich-colors=true][data-sonner-toast][data-styled=true] [data-description]{color:inherit}[data-sonner-toaster][data-sonner-theme=dark] [data-description]{color:#e8e8e8}[data-sonner-toast][data-styled=true] [data-title]{font-weight:500;line-height:1.5;color:inherit}[data-sonner-toast][data-styled=true] [data-icon]{display:flex;height:16px;width:16px;position:relative;justify-content:flex-start;align-items:center;flex-shrink:0;margin-left:var(--toast-icon-margin-start);margin-right:var(--toast-icon-margin-end)}[data-sonner-toast][data-promise=true] [data-icon]>svg{opacity:0;transform:scale(.8);transform-origin:center;animation:sonner-fade-in .3s ease forwards}[data-sonner-toast][data-styled=true] [data-icon]>*{flex-shrink:0}[data-sonner-toast][data-styled=true] [data-icon] svg{margin-left:var(--toast-svg-margin-start);margin-right:var(--toast-svg-margin-end)}[data-sonner-toast][data-styled=true] [data-content]{display:flex;flex-direction:column;gap:2px}[data-sonner-toast][data-styled=true] [data-button]{border-radius:4px;padding-left:8px;padding-right:8px;height:24px;font-size:12px;color:var(--normal-bg);background:var(--normal-text);margin-left:var(--toast-button-margin-start);margin-right:var(--toast-button-margin-end);border:none;font-weight:500;cursor:pointer;outline:0;display:flex;align-items:center;flex-shrink:0;transition:opacity .4s,box-shadow .2s}[data-sonner-toast][data-styled=true] [data-button]:focus-visible{box-shadow:0 0 0 2px rgba(0,0,0,.4)}[data-sonner-toast][data-styled=true] [data-button]:first-of-type{margin-left:var(--toast-button-margin-start);margin-right:var(--toast-button-margin-end)}[data-sonner-toast][data-styled=true] [data-cancel]{color:var(--normal-text);background:rgba(0,0,0,.08)}[data-sonner-toaster][data-sonner-theme=dark] [data-sonner-toast][data-styled=true] [data-cancel]{background:rgba(255,255,255,.3)}[data-sonner-toast][data-styled=true] [data-close-button]{position:absolute;left:var(--toast-close-button-start);right:var(--toast-close-button-end);top:0;height:20px;width:20px;display:flex;justify-content:center;align-items:center;padding:0;color:var(--gray12);background:var(--normal-bg);border:1px solid var(--gray4);transform:var(--toast-close-button-transform);border-radius:50%;cursor:pointer;z-index:1;transition:opacity .1s,background .2s,border-color .2s}[data-sonner-toast][data-styled=true] [data-close-button]:focus-visible{box-shadow:0 4px 12px rgba(0,0,0,.1),0 0 0 2px rgba(0,0,0,.2)}[data-sonner-toast][data-styled=true] [data-disabled=true]{cursor:not-allowed}[data-sonner-toast][data-styled=true]:hover [data-close-button]:hover{background:var(--gray2);border-color:var(--gray5)}[data-sonner-toast][data-swiping=true]::before{content:'';position:absolute;left:-100%;right:-100%;height:100%;z-index:-1}[data-sonner-toast][data-y-position=top][data-swiping=true]::before{bottom:50%;transform:scaleY(3) translateY(50%)}[data-sonner-toast][data-y-position=bottom][data-swiping=true]::before{top:50%;transform:scaleY(3) translateY(-50%)}[data-sonner-toast][data-swiping=false][data-removed=true]::before{content:'';position:absolute;inset:0;transform:scaleY(2)}[data-sonner-toast][data-expanded=true]::after{content:'';position:absolute;left:0;height:calc(var(--gap) + 1px);bottom:100%;width:100%}[data-sonner-toast][data-mounted=true]{--y:translateY(0);opacity:1}[data-sonner-toast][data-expanded=false][data-front=false]{--scale:var(--toasts-before) * 0.05 + 1;--y:translateY(calc(var(--lift-amount) * var(--toasts-before))) scale(calc(-1 * var(--scale)));height:var(--front-toast-height)}[data-sonner-toast]>*{transition:opacity .4s}[data-sonner-toast][data-x-position=right]{right:0}[data-sonner-toast][data-x-position=left]{left:0}[data-sonner-toast][data-expanded=false][data-front=false][data-styled=true]>*{opacity:0}[data-sonner-toast][data-visible=false]{opacity:0;pointer-events:none}[data-sonner-toast][data-mounted=true][data-expanded=true]{--y:translateY(calc(var(--lift) * var(--offset)));height:var(--initial-height)}[data-sonner-toast][data-removed=true][data-front=true][data-swipe-out=false]{--y:translateY(calc(var(--lift) * -100%));opacity:0}[data-sonner-toast][data-removed=true][data-front=false][data-swipe-out=false][data-expanded=true]{--y:translateY(calc(var(--lift) * var(--offset) + var(--lift) * -100%));opacity:0}[data-sonner-toast][data-removed=true][data-front=false][data-swipe-out=false][data-expanded=false]{--y:translateY(40%);opacity:0;transition:transform .5s,opacity .2s}[data-sonner-toast][data-removed=true][data-front=false]::before{height:calc(var(--initial-height) + 20%)}[data-sonner-toast][data-swiping=true]{transform:var(--y) translateY(var(--swipe-amount-y,0)) translateX(var(--swipe-amount-x,0));transition:none}[data-sonner-toast][data-swiped=true]{user-select:none}[data-sonner-toast][data-swipe-out=true][data-y-position=bottom],[data-sonner-toast][data-swipe-out=true][data-y-position=top]{animation-duration:.2s;animation-timing-function:ease-out;animation-fill-mode:forwards}[data-sonner-toast][data-swipe-out=true][data-swipe-direction=left]{animation-name:swipe-out-left}[data-sonner-toast][data-swipe-out=true][data-swipe-direction=right]{animation-name:swipe-out-right}[data-sonner-toast][data-swipe-out=true][data-swipe-direction=up]{animation-name:swipe-out-up}[data-sonner-toast][data-swipe-out=true][data-swipe-direction=down]{animation-name:swipe-out-down}@keyframes swipe-out-left{from{transform:var(--y) translateX(var(--swipe-amount-x));opacity:1}to{transform:var(--y) translateX(calc(var(--swipe-amount-x) - 100%));opacity:0}}@keyframes swipe-out-right{from{transform:var(--y) translateX(var(--swipe-amount-x));opacity:1}to{transform:var(--y) translateX(calc(var(--swipe-amount-x) + 100%));opacity:0}}@keyframes swipe-out-up{from{transform:var(--y) translateY(var(--swipe-amount-y));opacity:1}to{transform:var(--y) translateY(calc(var(--swipe-amount-y) - 100%));opacity:0}}@keyframes swipe-out-down{from{transform:var(--y) translateY(var(--swipe-amount-y));opacity:1}to{transform:var(--y) translateY(calc(var(--swipe-amount-y) + 100%));opacity:0}}@media (max-width:600px){[data-sonner-toaster]{position:fixed;right:var(--mobile-offset-right);left:var(--mobile-offset-left);width:100%}[data-sonner-toaster][dir=rtl]{left:calc(var(--mobile-offset-left) * -1)}[data-sonner-toaster] [data-sonner-toast]{left:0;right:0;width:calc(100% - var(--mobile-offset-left) * 2)}[data-sonner-toaster][data-x-position=left]{left:var(--mobile-offset-left)}[data-sonner-toaster][data-y-position=bottom]{bottom:var(--mobile-offset-bottom)}[data-sonner-toaster][data-y-position=top]{top:var(--mobile-offset-top)}[data-sonner-toaster][data-x-position=center]{left:var(--mobile-offset-left);right:var(--mobile-offset-right);transform:none}}[data-sonner-toaster][data-sonner-theme=light]{--normal-bg:#fff;--normal-border:var(--gray4);--normal-text:var(--gray12);--success-bg:hsl(143, 85%, 96%);--success-border:hsl(145, 92%, 87%);--success-text:hsl(140, 100%, 27%);--info-bg:hsl(208, 100%, 97%);--info-border:hsl(221, 91%, 93%);--info-text:hsl(210, 92%, 45%);--warning-bg:hsl(49, 100%, 97%);--warning-border:hsl(49, 91%, 84%);--warning-text:hsl(31, 92%, 45%);--error-bg:hsl(359, 100%, 97%);--error-border:hsl(359, 100%, 94%);--error-text:hsl(360, 100%, 45%)}[data-sonner-toaster][data-sonner-theme=light] [data-sonner-toast][data-invert=true]{--normal-bg:#000;--normal-border:hsl(0, 0%, 20%);--normal-text:var(--gray1)}[data-sonner-toaster][data-sonner-theme=dark] [data-sonner-toast][data-invert=true]{--normal-bg:#fff;--normal-border:var(--gray3);--normal-text:var(--gray12)}[data-sonner-toaster][data-sonner-theme=dark]{--normal-bg:#000;--normal-bg-hover:hsl(0, 0%, 12%);--normal-border:hsl(0, 0%, 20%);--normal-border-hover:hsl(0, 0%, 25%);--normal-text:var(--gray1);--success-bg:hsl(150, 100%, 6%);--success-border:hsl(147, 100%, 12%);--success-text:hsl(150, 86%, 65%);--info-bg:hsl(215, 100%, 6%);--info-border:hsl(223, 43%, 17%);--info-text:hsl(216, 87%, 65%);--warning-bg:hsl(64, 100%, 6%);--warning-border:hsl(60, 100%, 9%);--warning-text:hsl(46, 87%, 65%);--error-bg:hsl(358, 76%, 10%);--error-border:hsl(357, 89%, 16%);--error-text:hsl(358, 100%, 81%)}[data-sonner-toaster][data-sonner-theme=dark] [data-sonner-toast] [data-close-button]{background:var(--normal-bg);border-color:var(--normal-border);color:var(--normal-text)}[data-sonner-toaster][data-sonner-theme=dark] [data-sonner-toast] [data-close-button]:hover{background:var(--normal-bg-hover);border-color:var(--normal-border-hover)}[data-rich-colors=true][data-sonner-toast][data-type=success]{background:var(--success-bg);border-color:var(--success-border);color:var(--success-text)}[data-rich-colors=true][data-sonner-toast][data-type=success] [data-close-button]{background:var(--success-bg);border-color:var(--success-border);color:var(--success-text)}[data-rich-colors=true][data-sonner-toast][data-type=info]{background:var(--info-bg);border-color:var(--info-border);color:var(--info-text)}[data-rich-colors=true][data-sonner-toast][data-type=info] [data-close-button]{background:var(--info-bg);border-color:var(--info-border);color:var(--info-text)}[data-rich-colors=true][data-sonner-toast][data-type=warning]{background:var(--warning-bg);border-color:var(--warning-border);color:var(--warning-text)}[data-rich-colors=true][data-sonner-toast][data-type=warning] [data-close-button]{background:var(--warning-bg);border-color:var(--warning-border);color:var(--warning-text)}[data-rich-colors=true][data-sonner-toast][data-type=error]{background:var(--error-bg);border-color:var(--error-border);color:var(--error-text)}[data-rich-colors=true][data-sonner-toast][data-type=error] [data-close-button]{background:var(--error-bg);border-color:var(--error-border);color:var(--error-text)}.sonner-loading-wrapper{--size:16px;height:var(--size);width:var(--size);position:absolute;inset:0;z-index:10}.sonner-loading-wrapper[data-visible=false]{transform-origin:center;animation:sonner-fade-out .2s ease forwards}.sonner-spinner{position:relative;top:50%;left:50%;height:var(--size);width:var(--size)}.sonner-loading-bar{animation:sonner-spin 1.2s linear infinite;background:var(--gray11);border-radius:6px;height:8%;left:-10%;position:absolute;top:-3.9%;width:24%}.sonner-loading-bar:first-child{animation-delay:-1.2s;transform:rotate(.0001deg) translate(146%)}.sonner-loading-bar:nth-child(2){animation-delay:-1.1s;transform:rotate(30deg) translate(146%)}.sonner-loading-bar:nth-child(3){animation-delay:-1s;transform:rotate(60deg) translate(146%)}.sonner-loading-bar:nth-child(4){animation-delay:-.9s;transform:rotate(90deg) translate(146%)}.sonner-loading-bar:nth-child(5){animation-delay:-.8s;transform:rotate(120deg) translate(146%)}.sonner-loading-bar:nth-child(6){animation-delay:-.7s;transform:rotate(150deg) translate(146%)}.sonner-loading-bar:nth-child(7){animation-delay:-.6s;transform:rotate(180deg) translate(146%)}.sonner-loading-bar:nth-child(8){animation-delay:-.5s;transform:rotate(210deg) translate(146%)}.sonner-loading-bar:nth-child(9){animation-delay:-.4s;transform:rotate(240deg) translate(146%)}.sonner-loading-bar:nth-child(10){animation-delay:-.3s;transform:rotate(270deg) translate(146%)}.sonner-loading-bar:nth-child(11){animation-delay:-.2s;transform:rotate(300deg) translate(146%)}.sonner-loading-bar:nth-child(12){animation-delay:-.1s;transform:rotate(330deg) translate(146%)}@keyframes sonner-fade-in{0%{opacity:0;transform:scale(.8)}100%{opacity:1;transform:scale(1)}}@keyframes sonner-fade-out{0%{opacity:1;transform:scale(1)}100%{opacity:0;transform:scale(.8)}}@keyframes sonner-spin{0%{opacity:1}100%{opacity:.15}}@media (prefers-reduced-motion){.sonner-loading-bar,[data-sonner-toast],[data-sonner-toast]>*{transition:none!important;animation:none!important}}.sonner-loader{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);transform-origin:center;transition:opacity .2s,transform .2s}.sonner-loader[data-visible=false]{opacity:0;transform:scale(.8) translate(-50%,-50%)}`);function iL({label:e,url:t,description:n,docsHref:r,className:i}){let[a,o]=(0,I.useState)(!1);async function s(){try{await Er(t),o(!0),rL.success(`${e} copied`),setTimeout(()=>o(!1),2e3)}catch{rL.error(`Failed to copy`)}}return(0,L.jsxs)(`div`,{className:Tr(`space-y-2 rounded-xl border border-border bg-muted/40 p-3`,i),children:[(0,L.jsx)(`p`,{className:`text-xs font-medium text-foreground`,children:e}),(0,L.jsxs)(`div`,{className:`relative`,children:[(0,L.jsx)(`code`,{className:`flex min-h-[40px] items-center break-all rounded-lg border border-border bg-background px-3 py-2 pr-11 font-mono text-[12px] leading-relaxed text-foreground`,children:t}),(0,L.jsxs)(di,{type:`button`,variant:`ghost`,size:`icon`,className:`absolute right-1.5 top-1.5 h-8 w-8 shrink-0`,onClick:()=>void s(),"aria-label":`Copy ${e}`,children:[a?(0,L.jsx)(Br,{className:`h-3.5 w-3.5 text-success`}):(0,L.jsx)(qr,{className:`h-3.5 w-3.5`}),(0,L.jsxs)(`span`,{className:`sr-only`,children:[`Copy `,e]})]})]}),n?(0,L.jsx)(`p`,{className:`text-xs text-muted-foreground`,children:n}):null,r?(0,L.jsxs)(`a`,{href:r,target:`_blank`,rel:`noopener noreferrer`,className:`inline-flex items-center gap-1 text-xs text-primary hover:underline`,children:[`Learn more →`,(0,L.jsx)(Yr,{className:`h-3 w-3`,"aria-hidden":`true`})]}):null]})}function aL(e){return e===`twitter`||e===`api-twitter`}function oL(e){return e?`${e}/api/v1/providers/callback`:null}function sL({slug:e}){let{data:t,isError:n,isLoading:r}=ZI(),i=oL(t?.api_base_url),a=aL(e);return i?(0,L.jsxs)(`div`,{className:`space-y-2`,children:[(0,L.jsx)(iL,{label:a?`Twitter / X OAuth setup`:`NyxID callback URL`,url:i,description:a?`This integration requires an X app with OAuth 2.0 enabled in User authentication settings in X Developer Console. Configure the callback URL below as one of your app's redirect URIs.`:`Add this URL as an authorized redirect URI in your OAuth app's settings on the provider's developer console, or authorization will fail.`}),a?(0,L.jsxs)(`a`,{href:`https://developer.x.com/en/portal/dashboard`,target:`_blank`,rel:`noopener noreferrer`,className:`inline-flex items-center gap-1 text-xs text-primary hover:underline`,children:[`Where do I get Client ID and Client Secret? Open Keys & Tokens in X Developer Console`,(0,L.jsx)(Yr,{className:`h-3 w-3`})]}):null]}):r?(0,L.jsx)(`p`,{className:`rounded-md border border-border bg-background/60 p-2 text-xs text-muted-foreground`,children:`Loading callback URL...`}):(0,L.jsx)(`p`,{className:`rounded-md border border-warning/30 bg-warning/10 p-2 text-xs text-warning`,children:n?`Couldn't load callback URL. Please retry. If this persists, contact support.`:`Callback URL not yet available. Please retry. If this persists, contact support.`})}function cL(e){let t=(e??`system`).toLowerCase();return t===`user`||t===`both`}async function lL(e,t,n,r,i,a,o,s,c){let l={service_slug:e,label:t};r&&(l.node_id=r),a&&(l.target_org_id=a);let u=i?.trim();if(u&&(l.endpoint_url=u),c)l.copy_oauth_client_from=c;else{let e=o?.trim(),t=s?.trim();e&&t&&(l.oauth_client_id=e,l.oauth_client_secret=t)}try{return await Wy.post(`/keys`,l)}catch(e){throw e instanceof Ly&&e.status>=400&&e.status<500&&(n.current=!1),e}}function uL(e){if(!(typeof window>`u`))try{fetch(`/api/v1/keys/${encodeURIComponent(e)}?only_if_pending=true`,{method:`DELETE`,credentials:`include`,keepalive:!0})}catch{}}function dL(e){if(!(typeof window>`u`))try{fetch(`/api/v1/cli-pairings/${encodeURIComponent(e)}/cancel`,{method:`POST`,credentials:`include`,keepalive:!0,headers:{"Content-Type":`application/json`},body:`{}`})}catch{}}function fL(e,t,n,r){if(!(typeof window>`u`))try{fetch(`/api/v1/cli-pairings/${encodeURIComponent(e)}/complete`,{method:`POST`,credentials:`include`,keepalive:!0,headers:{"Content-Type":`application/json`},body:JSON.stringify({ack:{acknowledged:!0,service_id:t,slug:n,label:r}})})}catch{}}async function pL(e){if(!e)return{kind:`unknown`};try{if((await Wy.delete(`/keys/${encodeURIComponent(e)}?only_if_pending=true`)).deleted===!0)return{kind:`deleted`};try{let t=await Wy.get(`/keys/${encodeURIComponent(e)}`);return t.status===`active`?{kind:`active`,key:t}:{kind:`unknown`}}catch{return{kind:`unknown`}}}catch{return{kind:`unknown`}}}async function mL(e,t,n,r,i){let a=i.current;if(a)try{await a}catch{}let o=t.current,s=n.current,c=r.current;t.current=null,n.current=!1;let l=s&&!c;if(o)try{let t=await Wy.delete(`/keys/${encodeURIComponent(o)}?only_if_pending=true`);if(t.deleted===!0&&s)l=!0;else if(t.deleted===!1){try{let t=await Wy.get(`/keys/${encodeURIComponent(o)}`);await Wy.post(`/cli-pairings/${encodeURIComponent(e)}/complete`,{ack:{acknowledged:!0,service_id:t.id,slug:t.slug,label:t.label}})}catch{}return}else l=!1}catch{}if(l)try{await uN(e)}catch{}}function hL({providerId:e,slug:t,label:n,nodeId:r,targetOrgId:i,endpointUrl:a,pairingId:o,credentialMode:s,documentationUrl:c,scopeOverride:l,reconnectKeyId:u,baselineAuthorizedAt:d,onSuccess:f,onCancel:p}){let m=!!u,[h,g]=(0,I.useState)(!m&&cL(s)?`checking-credentials`:`starting`),[_,v]=(0,I.useState)(null),[y,b]=(0,I.useState)(null),[x,S]=(0,I.useState)(``),[C,w]=(0,I.useState)(``),[T,E]=(0,I.useState)(null),[ee,D]=(0,I.useState)([]),O=(0,I.useRef)(u??null),k=(0,I.useRef)(!1),A=(0,I.useRef)(!1),j=(0,I.useRef)(!1),M=(0,I.useRef)(null),N=(0,I.useRef)(!1);(0,I.useEffect)(()=>{function e(){if(N.current||m)return;let e=O.current;if(e){uL(e),fL(o,e,t,n);return}j.current&&dL(o)}return window.addEventListener(`beforeunload`,e),()=>{window.removeEventListener(`beforeunload`,e),!N.current&&(m||mL(o,O,A,j,M))}},[o,t,n,m]);async function te(){if(m)return`uncertain`;let e=M.current;if(e)try{await e}catch{}let t=O.current;O.current=null;let n=await pL(t);if(n.kind===`active`)return A.current=!1,N.current=!0,g(`done`),f({kind:`ai-key`,service_id:n.key.id,slug:n.key.slug,label:n.key.label}),`active`;let r=!j.current,i=n.kind===`deleted`;return A.current&&(i||r)?(A.current=!1,await uN(o),`released`):(A.current=!1,`uncertain`)}async function P(){k.current=!0,await te()!==`active`&&p()}(0,I.useEffect)(()=>{m||cL(s)&&(g(`needs-credentials`),(async()=>{try{D(((await Wy.get(`/keys`)).keys??[]).filter(e=>e.status===`active`&&e.oauth_client_id&&e.api_key_id&&e.catalog_service_slug===t).map(e=>({id:e.api_key_id,slug:e.slug,oauthClientId:e.oauth_client_id})))}catch{}})())},[s,t]);function F(){!T&&(!x.trim()||!C.trim())||(v(null),g(`starting`))}(0,I.useEffect)(()=>{if(h!==`starting`)return;let s=!1;return k.current=!1,(async()=>{try{O.current||(await lN(o),A.current=!0);let c;if(O.current)c={id:O.current,status:`pending_auth`};else{j.current=!0;let e=lL(t,n,j,r,a,i,x,C,T??void 0);M.current=e;try{c=await e}finally{M.current===e&&(M.current=null)}O.current=c.id}if(s)return;if(c.status===`active`){await re(c.id);return}let u=new URLSearchParams({redirect_path:`/keys/${c.id}`,key_id:c.id});l!==void 0&&u.set(`scope_override`,l.join(`,`));let d=await Wy.get(`/providers/${encodeURIComponent(e)}/connect/oauth?${u.toString()}`);if(s)return;if(!d.authorization_url)throw Error(`provider did not return an authorization_url`);if(b(d.authorization_url),!window.open(d.authorization_url,`_blank`,`noopener,noreferrer`)){g(`waiting`),v(`Browser blocked the popup. Use the button below to open the provider sign-in.`),await ne(c.id);return}g(`waiting`),await ne(c.id)}catch(e){if(s)return;g(`error`),v(vL(e)),te()}})(),()=>{s=!0}},[h]),(0,I.useEffect)(()=>()=>{k.current=!0},[]);async function ne(e){let t=d??null;await JI({keyId:e,getKey:e=>Wy.get(`/keys/${encodeURIComponent(e)}`),completeWithKey:re,isCancelled:()=>k.current,...m?{isComplete:e=>e.status===`active`&&!!e.last_authorized_at&&e.last_authorized_at!==t}:{},onTerminalFailure:()=>{g(`error`),v(`Authorization didn't complete (it may have been canceled or denied on the provider page). Cancel and re-run to try again.`)},onTimeout:()=>{g(`error`),v(`We didn't see authorization complete within 5 minutes. If you canceled on the provider page or it's taking longer than expected, cancel and re-run.`)}})}async function re(e){let t=await Wy.get(`/keys/${encodeURIComponent(e)}`);k.current||(N.current=!0,g(`done`),f({kind:`ai-key`,service_id:t.id,slug:t.slug,label:t.label}))}if(h===`needs-credentials`){let e=ee.length>0,n=!!T;return(0,L.jsxs)(`div`,{className:`flex flex-col gap-4`,children:[(0,L.jsxs)(`div`,{className:`flex flex-col gap-1`,children:[(0,L.jsx)(`h3`,{className:`font-medium`,children:e?`OAuth app credentials`:`Paste your OAuth app credentials`}),(0,L.jsx)(`p`,{className:`text-[12px] text-muted-foreground`,children:`This provider expects you to register your own OAuth app and supply the resulting Client ID and Client Secret.`}),c?(0,L.jsxs)(`a`,{href:c,target:`_blank`,rel:`noopener noreferrer`,className:`inline-flex items-center gap-1 text-xs text-muted-foreground underline-offset-2 hover:underline`,children:[`How to create an OAuth app`,(0,L.jsx)(Yr,{className:`h-3 w-3`})]}):null]}),(0,L.jsx)(sL,{slug:t}),e?(0,L.jsxs)(`div`,{className:`flex flex-col gap-2`,children:[(0,L.jsx)(vi,{className:`text-[12px] font-medium`,children:`Use credentials from an existing connection`}),(0,L.jsxs)(`div`,{className:`flex flex-col gap-1.5`,children:[ee.map(e=>(0,L.jsxs)(`button`,{type:`button`,onClick:()=>{E(e.id),S(``),w(``)},className:`flex items-center gap-2 rounded-md border px-3 py-2 text-left text-sm transition-colors ${T===e.id?`border-primary bg-primary/5`:`border-border hover:border-primary/50`}`,children:[(0,L.jsx)(`span`,{className:`flex-1 truncate`,children:e.slug}),(0,L.jsx)(`span`,{className:`shrink-0 text-xs text-muted-foreground`,children:e.oauthClientId})]},e.id)),(0,L.jsx)(`button`,{type:`button`,onClick:()=>{E(null)},className:`flex items-center gap-2 rounded-md border px-3 py-2 text-left text-sm transition-colors ${T?`border-border hover:border-primary/50`:`border-primary bg-primary/5`}`,children:`Enter new credentials`})]})]}):null,n?null:(0,L.jsxs)(`div`,{className:`flex flex-col gap-3`,children:[(0,L.jsxs)(`div`,{className:`flex flex-col gap-1.5`,children:[(0,L.jsx)(vi,{htmlFor:`pair-aikey-oauth-client-id`,children:`Client ID`}),(0,L.jsx)(Oj,{id:`pair-aikey-oauth-client-id`,value:x,onChange:e=>{S(e.target.value)},autoFocus:!0,autoComplete:`off`})]}),(0,L.jsxs)(`div`,{className:`flex flex-col gap-1.5`,children:[(0,L.jsx)(vi,{htmlFor:`pair-aikey-oauth-client-secret`,children:`Client Secret`}),(0,L.jsx)(Oj,{id:`pair-aikey-oauth-client-secret`,type:`password`,value:C,onChange:e=>{w(e.target.value)},autoComplete:`off`})]})]}),_?(0,L.jsx)(_L,{message:_}):null,(0,L.jsx)(di,{variant:`primary`,onClick:F,disabled:!n&&(!x.trim()||!C.trim()),children:n?`Continue with existing credentials`:`Save and continue`}),(0,L.jsx)(di,{variant:`outline`,onClick:()=>void P(),children:`Cancel`})]})}return(0,L.jsxs)(`div`,{className:`flex flex-col gap-4`,children:[(0,L.jsxs)(`div`,{className:`flex flex-col gap-1`,children:[(0,L.jsx)(`h3`,{className:`font-medium`,children:`Complete sign-in on the provider`}),(0,L.jsx)(`p`,{className:`text-[12px] text-muted-foreground`,children:`We opened a new tab where you'll authorize NyxID. When it completes, come back — this page will finish automatically.`})]}),h===`checking-credentials`?(0,L.jsxs)(`div`,{className:`flex items-center gap-2 text-[12px] text-muted-foreground`,children:[(0,L.jsx)(ti,{className:`h-4 w-4 animate-spin`}),`Checking provider credentials...`]}):h===`starting`?(0,L.jsxs)(`div`,{className:`flex items-center gap-2 text-[12px] text-muted-foreground`,children:[(0,L.jsx)(ti,{className:`h-4 w-4 animate-spin`}),`Creating placeholder service...`]}):h===`waiting`?(0,L.jsxs)(`div`,{className:`flex items-center gap-2 text-[12px] text-muted-foreground`,children:[(0,L.jsx)(ti,{className:`h-4 w-4 animate-spin`}),`Waiting for provider authorization...`]}):null,y&&h===`waiting`?(0,L.jsxs)(`a`,{href:y,target:`_blank`,rel:`noopener noreferrer`,className:`inline-flex items-center justify-center gap-2 rounded-lg border bg-muted/40 px-3 py-2 text-[12px] hover:bg-muted`,children:[`Reopen provider sign-in`,(0,L.jsx)(Yr,{className:`h-4 w-4`})]}):null,_?(0,L.jsx)(_L,{message:_}):null,h===`done`?null:(0,L.jsx)(di,{variant:`outline`,onClick:()=>void P(),children:`Cancel`})]})}function gL({providerId:e,slug:t,label:n,nodeId:r,targetOrgId:i,endpointUrl:a,pairingId:o,scopeOverride:s,onSuccess:c,onCancel:l}){let[u,d]=(0,I.useState)(null),[f,p]=(0,I.useState)(null),[m,h]=(0,I.useState)(`starting`),[g,_]=(0,I.useState)(null),[v,y]=(0,I.useState)(!1),[b,x]=(0,I.useState)(0),S=(0,I.useRef)(0),C=(0,I.useRef)(null),w=(0,I.useRef)(!1),T=(0,I.useRef)(!1),E=(0,I.useRef)(!1),ee=(0,I.useRef)(null),D=(0,I.useRef)(!1);async function O(){let e=ee.current;if(e)try{await e}catch{}let t=C.current;C.current=null;let n=await pL(t);if(n.kind===`active`)return T.current=!1,D.current=!0,h(`done`),c({kind:`ai-key`,service_id:n.key.id,slug:n.key.slug,label:n.key.label}),`active`;let r=!E.current,i=n.kind===`deleted`;return T.current&&(i||r)?(T.current=!1,await uN(o),`released`):(T.current=!1,`uncertain`)}async function k(){w.current=!0,S.current+=1,await O()!==`active`&&l()}(0,I.useEffect)(()=>{function e(){if(D.current)return;let e=C.current;if(e){uL(e),fL(o,e,t,n);return}E.current&&dL(o)}return window.addEventListener(`beforeunload`,e),()=>{window.removeEventListener(`beforeunload`,e),!D.current&&mL(o,C,T,E,ee)}},[o,t,n]),(0,I.useEffect)(()=>(w.current=!1,j(),()=>{w.current=!0,S.current+=1}),[]),(0,I.useEffect)(()=>{if(m!==`waiting`)return;let e=window.setInterval(()=>{x(e=>e>0?e-1:0)},1e3);return()=>{window.clearInterval(e)}},[m]);function A(e){let t=Math.floor(e/60),n=e%60;return`${String(t)}:${String(n).padStart(2,`0`)}`}async function j(){let l=++S.current;h(`starting`),_(null);try{let u=C.current;if(!u){await lN(o),T.current=!0,E.current=!0;let e=lL(t,n,E,r,a,i);ee.current=e;let s;try{s=await e}finally{ee.current===e&&(ee.current=null)}if(u=s.id,C.current=u,l!==S.current)return;if(s.status===`active`){let e=await Wy.get(`/keys/${encodeURIComponent(u)}`);if(l!==S.current)return;D.current=!0,h(`done`),c({kind:`ai-key`,service_id:e.id,slug:e.slug,label:e.label});return}}let f=new URLSearchParams;u&&f.set(`key_id`,u),s!==void 0&&f.set(`scope_override`,s.join(`,`));let m=f.toString(),g=await Wy.post(`/providers/${encodeURIComponent(e)}/connect/device-code/initiate${m?`?${m}`:``}`,{});if(l!==S.current)return;d(g.user_code),p(g.verification_uri);let v=typeof window<`u`?Number(new URLSearchParams(window.location.search).get(`expires_in_override`)):NaN,y=Number.isFinite(v)&&v>0?v:Number(g.expires_in)>0?Number(g.expires_in):900;x(y),h(`waiting`);let b=Number(g.interval)||5,k=`/providers/${encodeURIComponent(e)}/connect/device-code/poll`,A=Date.now()+y*1e3;for(;Date.now(){y(!1)},2e3)}catch{}}return(0,L.jsxs)(`div`,{className:`flex flex-col gap-4`,children:[(0,L.jsxs)(`div`,{className:`flex flex-col gap-1`,children:[(0,L.jsx)(`h3`,{className:`font-medium`,children:`Authorize via device code`}),(0,L.jsx)(`p`,{className:`text-[12px] text-muted-foreground`,children:`Open the verification URL, enter the code, and complete sign-in on the provider. This page will finish automatically.`})]}),m===`starting`?(0,L.jsxs)(`div`,{className:`flex items-center gap-2 text-[12px] text-muted-foreground`,children:[(0,L.jsx)(ti,{className:`h-4 w-4 animate-spin`}),`Requesting device code...`]}):m===`waiting`&&u&&f?(0,L.jsxs)(`div`,{className:`flex flex-col gap-3 rounded-lg border bg-muted/30 p-4`,children:[(0,L.jsxs)(`div`,{className:`flex flex-col gap-1`,children:[(0,L.jsxs)(`div`,{className:`flex items-center justify-between gap-2`,children:[(0,L.jsx)(`span`,{className:`text-xs uppercase tracking-wide text-muted-foreground`,children:`Code`}),b>0?(0,L.jsxs)(`span`,{className:`text-xs tabular-nums text-muted-foreground`,children:[`Expires in `,A(b)]}):null]}),(0,L.jsxs)(`div`,{className:`flex items-center gap-2`,children:[(0,L.jsx)(`code`,{className:`rounded bg-background px-3 py-1.5 font-mono text-lg`,children:u}),(0,L.jsxs)(di,{variant:`outline`,onClick:()=>void M(),children:[(0,L.jsx)(ui,{children:(0,L.jsx)(qr,{className:`h-3.5 w-3.5`})}),v?`Copied`:`Copy`]})]})]}),(0,L.jsxs)(`div`,{className:`flex flex-col gap-1`,children:[(0,L.jsx)(`span`,{className:`text-xs uppercase tracking-wide text-muted-foreground`,children:`Visit`}),(0,L.jsxs)(`a`,{href:f,target:`_blank`,rel:`noopener noreferrer`,className:`inline-flex items-center gap-1.5 text-[12px] underline-offset-2 hover:underline`,children:[f,(0,L.jsx)(Yr,{className:`h-3.5 w-3.5`})]})]}),(0,L.jsxs)(`div`,{className:`flex items-center gap-2 text-xs text-muted-foreground`,children:[(0,L.jsx)(ti,{className:`h-3 w-3 animate-spin`}),`Waiting for authorization...`]})]}):m===`expired`?(0,L.jsxs)(`div`,{className:`flex flex-col gap-2`,children:[(0,L.jsx)(`p`,{className:`rounded-lg border border-amber-500/40 bg-amber-500/10 px-3 py-2 text-[12px]`,children:`The device code expired before authorization completed.`}),(0,L.jsx)(di,{variant:`primary`,onClick:()=>void j(),children:`Request a new code`})]}):null,g?(0,L.jsx)(_L,{message:g}):null,m===`done`?null:(0,L.jsx)(di,{variant:`outline`,onClick:()=>void k(),children:`Cancel`})]})}function _L({message:e}){return(0,L.jsx)(`p`,{className:`rounded-lg border border-destructive/40 bg-destructive/10 px-3 py-2 text-[12px] text-destructive`,children:e})}function vL(e){return e instanceof Ly||e instanceof Error?e.message:`Something went wrong. Please try again.`}function yL(e){return new Promise(t=>{window.setTimeout(t,e)})}function bL(e){let t=(e.provider_type??``).toLowerCase();return(e.service_type??`http`)===`ssh`?`ssh`:t===`oauth2`?`oauth`:t===`device_code`?`device-code`:e.requires_credential===!1?`no-auth`:Array.isArray(e.token_exchange_credential_fields)&&e.token_exchange_credential_fields.length>0?`token-exchange`:e.requires_gateway_url?`gateway-url`:`paste-key`}function xL(e,t){switch(e){case`no-auth`:return`1-click connect`;case`gateway-url`:return`URL + API key`;case`token-exchange`:return`${(t.token_exchange_credential_fields??[]).length} fields`;case`oauth`:return`OAuth sign-in`;case`device-code`:return`device code`;case`ssh`:return`SSH cert`;case`paste-key`:return`paste API key`}}var SL={oauth:`OAuth`,"device-code":`Device code`,ssh:`SSH`};function CL(e,t){if(!t)return 0;let n=e.toLowerCase(),r=t.toLowerCase(),i=n.indexOf(r);if(i>=0)return i;let a=0,o=0,s=100,c=0;for(;a{let e=await Wy.get(`/catalog?include_all=true`);return e.entries??e.services??[]}}),o=r??[],s=t.trim(),c=s?o.map(e=>{let t=CL(e.slug,s),n=CL(e.name??``,s),r=t===null?n:n===null?t:Math.min(t,n);return r===null?null:{entry:e,score:r}}).filter(e=>e!==null).sort((e,t)=>e.score-t.score).map(e=>e.entry):o;return(0,L.jsxs)(`div`,{className:`flex flex-col gap-4`,children:[(0,L.jsxs)(`div`,{className:`flex flex-col gap-1.5`,children:[(0,L.jsx)(`label`,{htmlFor:`catalog-search`,className:`text-xs font-medium uppercase tracking-wide text-muted-foreground`,children:`Search`}),(0,L.jsx)(Oj,{id:`catalog-search`,type:`search`,placeholder:`search services…`,autoComplete:`off`,spellCheck:!1,value:t,onChange:e=>{n(e.target.value)}})]}),(0,L.jsx)(TL,{children:`Simple setup`}),i?(0,L.jsx)(`p`,{className:`text-[12px] text-muted-foreground`,children:`Loading catalog…`}):a?(0,L.jsx)(`p`,{className:`text-[12px] text-destructive`,children:a instanceof Ly?`Couldn't load the catalog: ${a.message} (${String(a.status)})`:`Couldn't load the catalog. Check the CLI logs for details.`}):c.length===0?(0,L.jsx)(`p`,{className:`text-[12px] text-muted-foreground`,children:s?`No services match your search.`:`Catalog is empty.`}):(0,L.jsx)(`div`,{className:`max-h-[420px] overflow-y-auto overscroll-contain pr-1`,role:`list`,children:(0,L.jsx)(`div`,{className:`grid grid-cols-1 gap-3 sm:grid-cols-2`,children:c.map(t=>(0,L.jsx)(EL,{entry:t,onClick:()=>{e(t.slug)}},t.slug))})}),(0,L.jsx)(TL,{children:`Advanced`}),(0,L.jsx)(`div`,{className:`grid grid-cols-1 gap-3 sm:grid-cols-2`,children:(0,L.jsx)(DL,{onClick:()=>{e(`__custom__`)}})})]})}function TL({children:e}){return(0,L.jsx)(`div`,{className:`text-xs font-medium uppercase tracking-wide text-muted-foreground`,children:e})}function EL({entry:e,onClick:t}){let n=bL(e),r=SL[n];return(0,L.jsxs)(`button`,{type:`button`,onClick:t,role:`listitem`,className:`group relative flex min-h-[132px] flex-col items-start gap-1 rounded-xl border border-border/50 bg-card/60 p-4 text-left transition-colors duration-300 hover:border-white/[0.15] hover:bg-card focus-visible:outline-none`,children:[r?(0,L.jsx)(`span`,{className:`absolute right-3 top-3 rounded-full border border-border bg-muted/60 px-2 py-0.5 text-[10px] uppercase tracking-wide text-muted-foreground`,children:r}):null,(0,L.jsxs)(`div`,{className:`flex w-full items-center gap-2`,children:[(0,L.jsx)(Sj,{slug:e.slug,size:`sm`}),(0,L.jsx)(`span`,{className:`text-[13px] font-semibold text-foreground`,children:e.name||e.slug})]}),e.description?(0,L.jsx)(`span`,{className:`line-clamp-2 text-xs text-muted-foreground`,children:e.description}):null,(0,L.jsx)(`span`,{className:`mt-auto text-[11px] text-text-tertiary`,children:xL(n,e)})]})}function DL({onClick:e}){return(0,L.jsxs)(`button`,{type:`button`,onClick:e,className:`flex min-h-[132px] flex-col items-start gap-1 rounded-xl border border-dashed border-border/50 bg-transparent p-4 text-left transition-colors duration-300 hover:border-white/[0.15] hover:bg-card/40 focus-visible:outline-none`,children:[(0,L.jsx)(`span`,{className:`text-[13px] font-semibold text-foreground`,children:`Custom / self-hosted…`}),(0,L.jsx)(`span`,{className:`text-xs text-muted-foreground`,children:`For anything that isn't in the catalog above — paste your own endpoint URL + credential.`})]})}function OL(e){let t=(e.provider_type??``).toLowerCase();return t===`oauth2`?`oauth`:t===`device_code`?`device-code`:e.requires_credential===!1?`no-auth`:Array.isArray(e.token_exchange_credential_fields)&&e.token_exchange_credential_fields.length>0?`token-exchange`:e.requires_credential?`api-key`:`other`}function kL({prefill:e,pairingId:t,onSuccess:n,onSlugPicked:r}){let[i,a]=(0,I.useState)(e.custom?`__custom__`:e.slug??``),[o,s]=(0,I.useState)(e.org_id??null),c=i.trim(),l=(0,I.useRef)(c?null:``);(0,I.useEffect)(()=>{l.current!==c&&(l.current=c,r?.(c))},[c,r]);let{data:u,isLoading:d,error:f}=ft({queryKey:[`cli-pair`,`catalog`,c],queryFn:async()=>Wy.get(`/catalog/${encodeURIComponent(c)}`),enabled:!!c&&c!==`__custom__`}),p=f?f instanceof Ly?f.message:`Couldn't load catalog entry "${c}".`:null;if(e.reconnect_key_id)return(0,L.jsx)(AL,{keyId:e.reconnect_key_id,initialScopeOverride:e.scope_override??null,pairingId:t,onSuccess:n});let m=!c&&!p,h=c===`__custom__`,g=m?`Add an AI service`:h?`Custom / self-hosted service`:`Connect service`,_=m?`Pick a service to connect. Simple-bearer APIs (OpenAI, Anthropic, Gemini) land in the guided form. Anything else — self-hosted, OAuth, device code, custom endpoint — goes to the power-user form.`:h?`For services not in the catalog — paste your own endpoint URL and credential.`:`Your CLI wants to add ${c||`a service`} to NyxID. Confirm the details here.`;return(0,L.jsxs)(`div`,{className:`flex flex-col gap-4`,children:[(0,L.jsxs)(`div`,{className:`flex flex-col gap-1`,children:[(0,L.jsx)(`h2`,{className:`font-serif text-[28px] font-normal`,children:g}),(0,L.jsx)(`p`,{className:`text-[12px] text-muted-foreground`,children:_})]}),(0,L.jsx)(jL,{value:o,onChange:s}),m?(0,L.jsx)(wL,{onSelect:a}):h?(0,L.jsx)(IL,{prefill:e,targetOrgId:o,pairingId:t,onSuccess:n,onBack:()=>{a(``)}}):d?(0,L.jsx)(zI,{className:`h-24 w-full`}):p?(0,L.jsxs)(`div`,{className:`flex flex-col gap-3`,children:[(0,L.jsx)(zL,{message:p}),(0,L.jsx)(wL,{onSelect:a})]}):u?(0,L.jsx)(LL,{entry:u,prefill:e,targetOrgId:o,pairingId:t,onSuccess:n}):null]})}function AL({keyId:e,initialScopeOverride:t,pairingId:n,onSuccess:r}){let{data:i,isLoading:a,error:o}=ft({queryKey:[`cli-pair`,`manage-scopes`,`key`,e],queryFn:()=>Wy.get(`/keys/${encodeURIComponent(e)}`)}),s=i?.catalog_service_slug??i?.slug??``,{data:c,isLoading:l,error:u}=ft({queryKey:[`cli-pair`,`manage-scopes`,`catalog`,s],queryFn:()=>Wy.get(`/catalog/${encodeURIComponent(s)}`),enabled:!!s}),d=i?.granted_scopes??[],f=c?.default_scopes??[],p=t&&t.length>0?t:null,m=p??(d.length>0?d:f),[h,g]=(0,I.useState)(null),_=HI(h??m,c?.scope_catalog??[]),v=h!==null||p!==null||d.length>0||c?.scope_catalog?.some(e=>e.required)?_:void 0,y=g,[b,x]=(0,I.useState)(!1),S=(()=>{let e=o??u;return e?e instanceof Ly?e.message:`Couldn't load this connection.`:null})();if(a||s&&l)return(0,L.jsx)(zI,{className:`h-24 w-full`});if(S)return(0,L.jsx)(zL,{message:S});if(!i||!c)return(0,L.jsx)(zL,{message:`Connection not found.`});if((c.provider_type??``).toLowerCase()!==`oauth2`||!c.provider_config_id||c.supports_oauth_scopes===!1)return(0,L.jsxs)(`div`,{className:`flex flex-col gap-1`,children:[(0,L.jsx)(`h2`,{className:`font-serif text-[28px] font-normal`,children:`Manage permissions`}),(0,L.jsxs)(`p`,{className:`rounded-lg border border-amber-500/40 bg-amber-500/10 px-3 py-2 text-[12px]`,children:[c.name,` doesn't support managing scopes here — its permissions are fixed by the provider.`]})]});let C=c.scope_removal===`unsupported`?d:[];return b?(0,L.jsx)(hL,{providerId:c.provider_config_id,slug:i.slug,label:i.label,pairingId:n,credentialMode:c.credential_mode,documentationUrl:c.documentation_url,scopeOverride:v,reconnectKeyId:e,baselineAuthorizedAt:i.last_authorized_at??null,onSuccess:r,onCancel:()=>{x(!1)}}):(0,L.jsxs)(`div`,{className:`flex flex-col gap-4`,children:[(0,L.jsxs)(`div`,{className:`flex flex-col gap-1`,children:[(0,L.jsx)(`h2`,{className:`font-serif text-[28px] font-normal`,children:`Manage permissions`}),(0,L.jsxs)(`p`,{className:`text-[12px] text-muted-foreground`,children:[`Adjust what `,i.label,` can do, then re-authorize at the provider. Your CLI is waiting for you to finish here.`]})]}),(0,L.jsxs)(`div`,{className:`flex items-start gap-3 rounded-lg border bg-muted/30 p-3`,children:[c.icon_url?(0,L.jsx)(`img`,{src:c.icon_url,alt:``,className:`h-8 w-8 rounded`,loading:`lazy`}):null,(0,L.jsxs)(`div`,{className:`flex flex-col gap-0.5`,children:[(0,L.jsx)(`h3`,{className:`font-medium`,children:c.name}),(0,L.jsx)(`p`,{className:`text-xs text-muted-foreground`,children:i.slug})]})]}),(0,L.jsx)(GI,{catalog:c.scope_catalog??[],defaultScopes:c.default_scopes??[],value:_,onChange:y,lockedScopes:C,grantedScopes:d,providerName:c.name,idPrefix:`pair-manage-scope`}),(0,L.jsx)(di,{variant:`primary`,onClick:()=>x(!0),children:`Re-authorize with these permissions`})]})}function jL({value:e,onChange:t}){let{data:n}=cP();return(n??[]).some(e=>e.your_role===`admin`)?(0,L.jsxs)(`div`,{className:`rounded-lg border border-border bg-muted/30 px-3 py-2`,children:[(0,L.jsxs)(`div`,{className:`flex items-center justify-between gap-3`,children:[(0,L.jsxs)(`div`,{className:`flex items-center gap-2 text-xs font-medium text-muted-foreground`,children:[(0,L.jsx)(Rr,{className:`h-3.5 w-3.5`}),`Owner`]}),(0,L.jsx)(`div`,{className:`w-[220px]`,children:(0,L.jsx)(VI,{value:e,onChange:t,label:`Owner`})})]}),(0,L.jsx)(`p`,{className:`mt-1 text-[11px] text-muted-foreground`,children:`Org-owned services are shared with every admin of that organization and can be proxied by its members.`})]}):null}function ML(e){switch(e){case`ifttt_webhook`:return``;case`header`:return`X-API-Key`;case`query`:return`key`;case`path`:return`bot`;case`body`:return`app_secret`;default:return`Authorization`}}function NL(e){switch(e){case`bearer`:case`header`:case`query`:case`path`:case`basic`:case`body`:case`ifttt_webhook`:case`bot_bearer`:case`none`:return e;default:return`bearer`}}function PL(e){return e===`header`||e===`query`||e===`path`||e===`body`}function FL(){return(0,L.jsx)(`span`,{"aria-hidden":`true`,className:`text-destructive ml-0.5`,children:`*`})}function IL({prefill:e,targetOrgId:t,pairingId:n,onSuccess:r,onBack:i}){let[a,o]=(0,I.useState)(e.label??``),[s,c]=(0,I.useState)(e.endpoint_url??``),[l,u]=(0,I.useState)(``),[d,f]=(0,I.useState)(NL(e.auth_method)),[p,m]=(0,I.useState)(e.auth_key_name??ML(NL(e.auth_method))),[h,g]=(0,I.useState)(e.custom_slug??``),[_,v]=(0,I.useState)(!1),[y,b]=(0,I.useState)(null),x=e.via_node?.trim()??``,S=a.trim(),C=s.trim(),w=l.trim(),T=d!==`none`,E=PL(d),ee=_||!S||!C||T&&!w,D=d===`bot_bearer`?`Bot token`:d===`basic`?`user:pass`:d===`body`?`${p.trim()||ML(d)} value`:`API key / credential`;async function O(){v(!0),b(null);try{let e={label:S,endpoint_url:C,auth_method:d};T&&(e.credential=w),E&&(e.auth_key_name=p.trim()||ML(d));let i=h.trim();i&&(e.slug=i),x&&(e.node_id=x),t&&(e.target_org_id=t),await lN(n);let a=await dN(n,()=>Wy.post(`/keys`,e));r({kind:`ai-key`,service_id:a.id,slug:a.slug,label:a.label})}catch(e){let t=(e instanceof Ly?e.message:null)??e?.message;b(t&&t.length>0?t:`Couldn't connect this service. Please try again.`)}finally{v(!1)}}return(0,L.jsxs)(`div`,{className:`flex flex-col gap-4`,children:[(0,L.jsxs)(`div`,{className:`flex flex-col gap-3`,children:[(0,L.jsxs)(`div`,{className:`flex flex-col gap-1.5`,children:[(0,L.jsxs)(`div`,{className:`flex items-center`,children:[(0,L.jsx)(vi,{htmlFor:`pair-custom-label`,children:`Label`}),(0,L.jsx)(FL,{})]}),(0,L.jsx)(Oj,{id:`pair-custom-label`,value:a,onChange:e=>{o(e.target.value)},placeholder:`e.g. My Self-hosted OpenAI Proxy`,autoFocus:!0,"aria-required":`true`}),(0,L.jsx)(`p`,{className:`text-xs text-muted-foreground`,children:`Shown everywhere in the CLI and web UI.`})]}),(0,L.jsxs)(`div`,{className:`flex flex-col gap-1.5`,children:[(0,L.jsxs)(`div`,{className:`flex items-center`,children:[(0,L.jsx)(vi,{htmlFor:`pair-custom-endpoint`,children:`Endpoint URL`}),(0,L.jsx)(FL,{})]}),(0,L.jsx)(Oj,{id:`pair-custom-endpoint`,value:s,onChange:e=>{c(e.target.value)},placeholder:`https://api.example.com`,"aria-required":`true`}),(0,L.jsx)(`p`,{className:`text-xs text-muted-foreground`,children:`The base URL NyxID proxies requests to.`})]}),(0,L.jsxs)(`div`,{className:`flex flex-col gap-1.5`,children:[(0,L.jsxs)(`div`,{className:`flex items-center`,children:[(0,L.jsx)(vi,{htmlFor:`pair-custom-auth-method`,children:`Auth method`}),(0,L.jsx)(FL,{})]}),(0,L.jsxs)(`select`,{id:`pair-custom-auth-method`,value:d,onChange:e=>{let t=e.target.value;f(t),(!p.trim()||[`Authorization`,`X-API-Key`,`key`,`bot`,`app_secret`].includes(p.trim()))&&m(ML(t))},className:`flex h-10 w-full rounded-lg border border-input bg-transparent px-[14px] py-2 text-[13px] text-foreground focus-visible:outline-none`,"aria-required":`true`,children:[(0,L.jsx)(`option`,{value:`bearer`,children:`bearer (Authorization: Bearer …)`}),(0,L.jsx)(`option`,{value:`bot_bearer`,children:`bot_bearer (Authorization: Bot …)`}),(0,L.jsx)(`option`,{value:`header`,children:`header (custom header)`}),(0,L.jsx)(`option`,{value:`query`,children:`query (?key=…)`}),(0,L.jsx)(`option`,{value:`path`,children:`path (path-prefix injection)`}),(0,L.jsx)(`option`,{value:`ifttt_webhook`,children:`IFTTT Webhooks (raw key)`}),(0,L.jsx)(`option`,{value:`basic`,children:`basic (Authorization: Basic …)`}),(0,L.jsx)(`option`,{value:`body`,children:`body (JSON-body field injection)`}),(0,L.jsx)(`option`,{value:`none`,children:`none (no auth injection)`})]}),(0,L.jsx)(`p`,{className:`text-xs text-muted-foreground`,children:`How NyxID attaches the credential to outgoing requests.`})]}),T?(0,L.jsxs)(`div`,{className:`flex flex-col gap-1.5`,children:[(0,L.jsxs)(`div`,{className:`flex items-center`,children:[(0,L.jsx)(vi,{htmlFor:`pair-custom-credential`,children:D}),(0,L.jsx)(FL,{})]}),(0,L.jsx)(Oj,{id:`pair-custom-credential`,type:`password`,value:l,onChange:e=>{u(e.target.value)},placeholder:d===`basic`?`user:pass`:`sk-...`,autoFocus:!!e.custom,"aria-required":`true`}),(0,L.jsxs)(`p`,{className:`text-xs text-muted-foreground`,children:[`Pasted once, encrypted at rest.`,d===`basic`?` Format: user:pass.`:``]})]}):null,E?(0,L.jsxs)(`div`,{className:`flex flex-col gap-1.5`,children:[(0,L.jsx)(vi,{htmlFor:`pair-custom-auth-key-name`,children:d===`header`?`Header name`:d===`query`?`Query parameter name`:d===`path`?`Path prefix segment`:`Body field name`}),(0,L.jsx)(Oj,{id:`pair-custom-auth-key-name`,value:p,onChange:e=>{m(e.target.value)},placeholder:ML(d)})]}):null,x?(0,L.jsxs)(`div`,{className:`rounded-lg border border-border bg-muted/40 px-3 py-2`,children:[(0,L.jsx)(`p`,{className:`text-xs font-medium text-foreground`,children:`Routed via node`}),(0,L.jsx)(`code`,{className:`font-mono text-[11px] text-muted-foreground`,children:x}),(0,L.jsx)(`p`,{className:`text-[11px] text-muted-foreground mt-1`,children:`Credential will be encrypted and pushed to this node over the existing WebSocket channel. NyxID never logs it.`})]}):null,(0,L.jsxs)(`div`,{className:`flex flex-col gap-1.5`,children:[(0,L.jsx)(vi,{htmlFor:`pair-custom-slug`,children:`Custom slug (optional)`}),(0,L.jsx)(Oj,{id:`pair-custom-slug`,value:h,onChange:e=>{g(e.target.value)},placeholder:`auto-generated from label`}),(0,L.jsxs)(`p`,{className:`text-xs text-muted-foreground`,children:[`URL segment at `,(0,L.jsx)(`code`,{children:`/proxy/s//…`}),`. Leave blank to let NyxID derive it from the label.`]})]})]}),y?(0,L.jsx)(zL,{message:y}):null,(0,L.jsxs)(`div`,{className:`flex items-center justify-between gap-2`,children:[(0,L.jsx)(di,{variant:`outline`,onClick:i,disabled:_,children:`← Back`}),(0,L.jsx)(di,{variant:`primary`,onClick:()=>void O(),disabled:ee,children:_?`Connecting…`:`Connect service`})]})]})}function LL({entry:e,prefill:t,targetOrgId:n,pairingId:r,onSuccess:i}){let[a,o]=(0,I.useState)(!0),s=!!(e.platform_key?.available&&!t.via_node&&a),c=s?`no-auth`:OL(e),[l,u]=(0,I.useState)(t.label??e.name),[d,f]=(0,I.useState)(``),[p,m]=(0,I.useState)(t.endpoint_url??``),[h,g]=(0,I.useState)({}),[_,v]=(0,I.useState)(e.default_scopes??[]),y=HI(_,e.scope_catalog??[]),[b,x]=(0,I.useState)(!1),[S,C]=(0,I.useState)(null),w=t.via_node?.trim()??``,[T,E]=(0,I.useState)(!1);async function ee(){x(!0),C(null);try{let t={service_slug:e.slug,label:l};if(s&&(t.use_platform_key=!0),c===`token-exchange`&&!w){let n=e.token_exchange_credential_fields??[],r={};for(let e of n){let t=h[e.name]?.trim();if(!t){C(`${e.label||e.name} is required.`),x(!1);return}r[e.name]=t}t.credential=JSON.stringify(r)}else c===`api-key`&&e.requires_credential&&!w&&(t.credential=d);!s&&(e.requires_gateway_url||p)&&(t.endpoint_url=p),w&&(t.node_id=w),n&&(t.target_org_id=n),await lN(r);let a=await dN(r,()=>Wy.post(`/keys`,t));i({kind:`ai-key`,service_id:a.id,slug:a.slug,label:a.label})}catch(e){let t=(e instanceof Ly?e.message:null)??e?.message;C(t&&t.length>0?t:`Couldn't create the service. Please try again.`)}finally{x(!1)}}async function D(e){try{await Wy.post(`/cli-pairings/${encodeURIComponent(r)}/cancel`,{})}catch{}if(e){if(window.__WIZARD_BOOTSTRAP__?.context===`local`){alert(`This auth shape isn't supported in the CLI wizard. Open your NyxID dashboard and complete setup on the Keys page (tab: External Services). You can close this tab now.`);return}window.location.assign(e)}else window.history.back()}if(e.service_type===`ssh`)return(0,L.jsxs)(`div`,{className:`flex flex-col gap-3`,children:[(0,L.jsxs)(`p`,{className:`rounded-lg border border-amber-500/40 bg-amber-500/10 px-3 py-2 text-[12px]`,children:[e.name,` is an SSH service. Use`,` `,(0,L.jsx)(`code`,{children:`nyxid service add-ssh`}),` from your CLI instead (certificate-based auth, not a credential binding).`]}),(0,L.jsx)(di,{variant:`outline`,onClick:()=>void D(null),children:`Go Back`})]});if(c===`other`||(c===`oauth`||c===`device-code`)&&!e.provider_config_id){let t=`/keys?tab=services&slug=${encodeURIComponent(e.slug)}`;return(0,L.jsxs)(`div`,{className:`flex flex-col gap-3`,children:[(0,L.jsxs)(`p`,{className:`rounded-lg border border-amber-500/40 bg-amber-500/10 px-3 py-2 text-[12px]`,children:[e.name,` uses `,(0,L.jsx)(`code`,{children:e.auth_method}),` auth, which isn't supported via remote pairing. Complete setup on the main Keys page. Your CLI will receive a cancel and print a "finish in browser" hint.`]}),(0,L.jsxs)(di,{variant:`primary`,onClick:()=>void D(t),className:`justify-center gap-2`,children:[`Open Keys page`,(0,L.jsx)(Yr,{className:`h-4 w-4`})]})]})}let O=p.trim()||t.endpoint_url,k=e.supports_oauth_scopes!==!1&&(c===`oauth`||c===`device-code`&&e.device_code_format!==`openai`),A=k?y:void 0;if(T&&c===`oauth`&&e.provider_config_id)return(0,L.jsx)(hL,{providerId:e.provider_config_id,slug:e.slug,label:l,nodeId:t.via_node,targetOrgId:n,endpointUrl:O,pairingId:r,credentialMode:e.credential_mode,documentationUrl:e.documentation_url,scopeOverride:A,onSuccess:i,onCancel:()=>{E(!1)}});if(T&&c===`device-code`&&e.provider_config_id)return(0,L.jsx)(gL,{providerId:e.provider_config_id,slug:e.slug,label:l,nodeId:t.via_node,targetOrgId:n,endpointUrl:O,pairingId:r,documentationUrl:e.documentation_url,scopeOverride:A,onSuccess:i,onCancel:()=>{E(!1)}});let j=c===`api-key`&&e.requires_credential,M=e.slug===`api-supabase`,N=b?`Creating...`:w?`Connect via node`:c===`oauth`?`Continue with provider sign-in`:c===`device-code`?`Get device code`:c===`no-auth`?`Connect`:`Create Service`,te=c===`token-exchange`?(e.token_exchange_credential_fields??[]).every(e=>(h[e.name]??``).trim().length>0):!0,P=b||!l.trim()||j&&!w&&!d.trim()||!s&&e.requires_gateway_url&&!p.trim()||!w&&!te;function F(){c===`oauth`||c===`device-code`?E(!0):ee()}return(0,L.jsxs)(`div`,{className:`flex flex-col gap-4`,children:[(0,L.jsxs)(`div`,{className:`flex items-start gap-3 rounded-lg border bg-muted/30 p-3`,children:[e.icon_url?(0,L.jsx)(`img`,{src:e.icon_url,alt:``,className:`h-8 w-8 rounded`,loading:`lazy`}):null,(0,L.jsxs)(`div`,{className:`flex flex-col gap-0.5`,children:[(0,L.jsx)(`h3`,{className:`font-medium`,children:e.name}),e.description?(0,L.jsx)(`p`,{className:`text-xs text-muted-foreground`,children:e.description}):null,(0,L.jsxs)(`p`,{className:`text-xs text-muted-foreground`,children:[`Auth: `,(0,L.jsx)(`code`,{children:e.auth_method})]})]})]}),e.platform_key?.available&&!w&&(0,L.jsx)(RI,{value:s,onChange:o,platformPrice:e.platform_key.pricing,byokPrice:e.byok_pricing,legacyBillable:e.billing?.platform_billable,resaleBillable:e.billing?.resale_billable,disabled:b}),(0,L.jsxs)(`div`,{className:`flex flex-col gap-3`,children:[(0,L.jsx)(RL,{label:`Label`,htmlFor:`pair-aikey-label`,children:(0,L.jsx)(Oj,{id:`pair-aikey-label`,value:l,onChange:e=>{u(e.target.value)},autoFocus:!0})}),!s&&e.requires_gateway_url?(0,L.jsx)(RL,{label:M?`Supabase Project URL`:`Instance URL`,htmlFor:`pair-aikey-url`,children:(0,L.jsx)(Oj,{id:`pair-aikey-url`,value:p,onChange:e=>{m(e.target.value)},placeholder:M?`https://project-ref.supabase.co`:`https://your-instance.example.com`})}):null,j&&!w?(0,L.jsxs)(RL,{label:M?`Supabase API key`:`API key`,htmlFor:`pair-aikey-credential`,children:[(0,L.jsx)(Oj,{id:`pair-aikey-credential`,type:`password`,autoComplete:`off`,value:d,onChange:e=>{f(e.target.value)},placeholder:M?`sb_secret_... or sb_publishable_...`:`sk-...`}),e.api_key_url?(0,L.jsxs)(`a`,{href:e.api_key_url,target:`_blank`,rel:`noopener noreferrer`,className:`mt-1 inline-flex items-center gap-1 text-xs text-muted-foreground underline-offset-2 hover:underline`,children:[`Get an API key`,(0,L.jsx)(Yr,{className:`h-3 w-3`})]}):null]}):null,c===`token-exchange`&&!w?(e.token_exchange_credential_fields??[]).map(e=>(0,L.jsx)(RL,{label:e.label||e.name,htmlFor:`pair-aikey-tx-${e.name}`,children:(0,L.jsx)(Oj,{id:`pair-aikey-tx-${e.name}`,type:e.secret?`password`:`text`,autoComplete:`off`,value:h[e.name]??``,onChange:t=>{let n=t.target.value;g(t=>({...t,[e.name]:n}))},placeholder:e.placeholder??``})},e.name)):null,k?(0,L.jsx)(GI,{catalog:e.scope_catalog??[],defaultScopes:e.default_scopes??[],value:y,onChange:v,customPlaceholder:c===`oauth`?`e.g. media.write`:`e.g. repo,read:org`,idPrefix:`pair-aikey-scope`}):c===`device-code`?(0,L.jsx)(`p`,{className:`text-xs text-muted-foreground`,children:`This provider does not accept additional scopes — they are fixed by the upstream client registration.`}):null,w?(0,L.jsxs)(`div`,{className:`rounded-lg border border-border bg-muted/40 px-3 py-2`,children:[(0,L.jsx)(`p`,{className:`text-xs font-medium text-foreground`,children:`Routed via node`}),(0,L.jsx)(`code`,{className:`font-mono text-[11px] text-muted-foreground`,children:w}),(0,L.jsx)(`p`,{className:`text-[11px] text-muted-foreground mt-1`,children:`Credential will be configured on the node agent. NyxID never sees or stores it.`})]}):null,c===`no-auth`?(0,L.jsx)(`p`,{className:`text-xs text-muted-foreground`,children:`This service doesn't need a credential. Click Connect to add it to your services.`}):null]}),S?(0,L.jsx)(zL,{message:S}):null,(0,L.jsx)(di,{variant:`primary`,onClick:F,disabled:P,children:N})]})}function RL({label:e,htmlFor:t,children:n}){return(0,L.jsxs)(`div`,{className:`flex flex-col gap-1.5`,children:[(0,L.jsx)(vi,{htmlFor:t,children:e}),n]})}function zL({message:e}){return(0,L.jsx)(`p`,{className:`rounded-lg border border-destructive/40 bg-destructive/10 px-3 py-2 text-[12px] text-destructive`,children:e})}var BL=!1,VL=null;function HL(e){if(VL=e,BL)return;BL=!0;let t=window.fetch.bind(window);window.fetch=async(e,n)=>{let r=ZL(e,n),i=new URL(r.url,window.location.origin);if(i.origin!==window.location.origin)return t(r);if(i.pathname.startsWith(`/api/v1/cli-pairings/`))return i.pathname.endsWith(`/cancel`)?t(`/api/proxy/cancel-unload`,{method:`POST`,headers:$L({"content-type":`application/json`}),body:`{}`,keepalive:n?.keepalive??!1}):new Response(JSON.stringify({ok:!0}),{status:200,headers:{"content-type":`application/json`}});if(r.method===`DELETE`&&/^\/api\/v1\/keys\/[^/]+$/.test(i.pathname)&&i.searchParams.get(`only_if_pending`)===`true`){let e=i.pathname.split(`/`).pop()??``;return t(`/api/proxy/abandon-placeholder`,{method:`POST`,headers:$L({"content-type":`application/json`}),body:JSON.stringify({key_id:e})})}if(i.pathname.startsWith(`/api/v1/`)){let e=new URL(i.toString());e.pathname=`/api/proxy${i.pathname}`;let a=$L(QL(r,n)),o=await t(e.toString(),{method:r.method,headers:a,body:await eR(r),credentials:r.credentials,signal:r.signal});return GL(o),o}return t(r)}}var UL=`nyxid-wizard-upstream-error`;function WL(e){let t=t=>{if(!(t instanceof CustomEvent))return;let n=t.detail?.kind;(n===`timeout`||n===`unreachable`)&&e(n)};return window.addEventListener(UL,t),()=>{window.removeEventListener(UL,t)}}function GL(e){if(e.ok)return;let t=e.headers.get(`content-type`);!t||!t.toLowerCase().includes(`application/json`)||e.clone().json().then(e=>{if(!e||typeof e!=`object`)return;let t=e.error;t===`upstream_timeout`?window.dispatchEvent(new CustomEvent(UL,{detail:{kind:`timeout`}})):t===`upstream_unreachable`&&window.dispatchEvent(new CustomEvent(UL,{detail:{kind:`unreachable`}}))}).catch(()=>{})}async function KL(e){let t=await fetch(`/api/proxy/complete`,{method:`POST`,headers:$L({"content-type":`application/json`}),body:JSON.stringify(e)});if(!t.ok)throw Error(`/api/proxy/complete failed: ${String(t.status)} ${t.statusText}`)}async function qL(){try{await fetch(`/api/proxy/cancel`,{method:`POST`,headers:$L({"content-type":`application/json`}),body:`{}`})}catch{}}var JL=1200,YL=3;function XL(e){let t=0,n=!1,r=window.setInterval(()=>{fetch(`/api/proxy/heartbeat`,{method:`POST`,headers:$L({"content-type":`application/json`}),body:`{}`}).then(r=>{if(!r.ok)throw Error(`heartbeat ${String(r.status)}`);t=0,n&&(n=!1,e?.onReconnect?.())}).catch(()=>{t+=1,t>=YL&&!n&&(n=!0,e?.onDisconnect?.())})},JL);return()=>{window.clearInterval(r)}}function ZL(e,t){return e instanceof Request?e:new Request(e,t)}function QL(e,t){let n={};return e.headers.forEach((e,t)=>{n[t]=e}),t?.headers&&new Headers(t.headers).forEach((e,t)=>{n[t]=e}),n}function $L(e){return VL?{...e,"x-wizard-csrf":VL.csrf}:e}async function eR(e){if(e.method===`GET`||e.method===`HEAD`)return null;try{let t=await e.clone().text();return t.length>0?t:null}catch{return null}}function tR({state:e,context:t,pairingStatus:n}){return(0,L.jsxs)(`div`,{role:`alert`,"aria-live":`polite`,className:`mb-4 flex items-start gap-3 rounded-lg border border-destructive/50 bg-destructive/10 px-4 py-3 text-[12px] text-foreground`,children:[(0,L.jsx)(e===`reconnecting`?ti:si,{className:`mt-0.5 h-4 w-4 shrink-0 text-destructive `+(e===`reconnecting`?`animate-spin`:``),"aria-hidden":!0}),(0,L.jsxs)(`div`,{className:`flex flex-col gap-1`,children:[(0,L.jsx)(`p`,{className:`font-medium`,children:e===`reconnecting`?`Reconnecting…`:t===`local`?`Connection to CLI interrupted`:n===`cancelled`?`CLI cancelled this pairing`:n===`expired`?`Pairing expired`:`Pairing went stale`}),(0,L.jsx)(`p`,{className:`text-muted-foreground`,children:e===`reconnecting`?`Retrying the last check…`:t===`local`?`The nyxid CLI missed several heartbeat checks. Keep this tab open; the wizard will continue if the connection recovers. If this message persists, re-run the command in your terminal.`:n===`cancelled`?`The CLI sent a cancel — nothing was created on the server. You can close this tab.`:n===`expired`?`This pairing passed its 15-minute TTL. Re-run the command in your terminal to start a new one.`:`The pairing record is no longer reachable. Re-run the CLI command to start a fresh one.`})]})]})}function nR({kind:e,onDismiss:t}){return(0,L.jsxs)(`div`,{role:`alert`,"aria-live":`polite`,className:`mb-4 flex items-start gap-3 rounded-lg border border-destructive/50 bg-destructive/10 px-4 py-3 text-[12px] text-foreground`,children:[(0,L.jsx)(e===`timeout`?Wr:si,{className:`mt-0.5 h-4 w-4 shrink-0 text-destructive`,"aria-hidden":!0}),(0,L.jsxs)(`div`,{className:`flex flex-1 flex-col gap-1`,children:[(0,L.jsx)(`p`,{className:`font-medium`,children:e===`timeout`?`Request to NyxID timed out`:`NyxID backend unreachable`}),(0,L.jsx)(`p`,{className:`text-muted-foreground`,children:e===`timeout`?`The page took too long to reach the NyxID backend. No changes were made. Try again from the form below — or close this tab and re-run the command in your terminal.`:`Couldn't reach the NyxID backend on the last attempt. No changes were made. Check your network, then try again from the form below — or close this tab and re-run the command in your terminal.`})]}),(0,L.jsx)(`button`,{type:`button`,onClick:t,"aria-label":`Dismiss`,className:`rounded p-1 text-muted-foreground hover:bg-destructive/10 hover:text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-destructive`,children:(0,L.jsx)(ci,{className:`h-3.5 w-3.5`,"aria-hidden":!0})})]})}function rR(e){return(0,L.jsx)(`svg`,{xmlns:`http://www.w3.org/2000/svg`,viewBox:`-5.0 -10.0 110.0 135.0`,fill:`currentColor`,...e,children:(0,L.jsx)(`path`,{d:`m74.719 41.191c0.011719-0.007812 0.023438-0.011718 0.03125-0.019531l3.0312-1.75c1.1172-0.64453 1.9922-2.1523 1.9883-3.4297l-0.039062-18.926c-0.003907-0.69141-0.25781-1.2227-0.72266-1.5 0 0 0-0.003906-0.003906-0.003906 0 0 0.003906 0.003906-0.015625-0.011719-0.003906 0-0.007812-0.003906-0.011719-0.003906-0.17187-0.097656-3.8789-2.2578-3.7031-2.1602-0.47656-0.27344-1.0781-0.23047-1.6992 0.12109l-1.25 0.72266c0-0.24219 0.015625-3.7422 0.015625-3.5273v-0.003906c0-0.011719-0.011719-0.015625-0.011719-0.027344-0.015625-0.70312-0.46094-1.3477-1.2656-1.8125-2.4922-1.4375-6.7812-0.44141-6.7812 1.8398 0.003906 2.3828-0.011719 8.3906-0.011719 8.1797l-7.0625 4.0781c-0.48828 0.28125-0.070312 1.0312 0.42969 0.74609 17.559-10.082 16.707-9.8555 17.203-9.5742l2.5547 1.4883c-12.867 7.4258-32.582 18.809-51.461 29.707-1.1133 0.64062-1.9805 2.1523-1.9766 3.4375 0.058594 27.855-0.03125 15.969 0.042969 19.02l-2.5547-1.4883c-0.49609-0.30469-0.26172 0.49609-0.35937-19.703 0-0.96094 0.71875-2.2109 1.5469-2.6758l28.973-16.727c0.20703-0.11719 0.27734-0.38281 0.15625-0.58984-0.12109-0.20703-0.38281-0.27734-0.58984-0.15625l-15.32 8.8438v-3.5156c0-0.015625-0.011718-0.023437-0.015624-0.035156-0.015626-0.70312-0.46094-1.3438-1.2617-1.8086-2.4688-1.4258-6.7852-0.46094-6.7812 1.8398 0.003906 3.2031-0.007813 8.3906-0.007813 8.1797l-5.5781 3.2188c-1.0938 0.61719-1.9805 2.1523-1.9805 3.4258l0.054688 18.926c0 0.69141 0.25391 1.2305 0.72656 1.5195 3.5742 2.0586 3.7812 2.2734 3.957 2.2227 0.14453 0.050782 0.28516 0.11719 0.44922 0.11719 0.66797 0 1.1758-0.39453 2.2891-1.0391l-0.023437 9.4297c-0.61328 0.19922-1.2109 0.44922-1.7773 0.77344-1.6172 0.93359-2.5117 2.2148-2.5195 3.6055-0.003906 1.6992-0.007812 4.7891-0.007812 4.6133-0.003906 1.4023 0.91016 2.7031 2.5703 3.6602 1.6055 0.92578 3.707 1.3867 5.8125 1.3867 2.1211 0 4.2461-0.46875 5.8594-1.4062 1.625-0.94531 2.5234-2.2266 2.5273-3.6094v-4.5977c0-0.011718-0.011719-0.019531-0.011719-0.03125-0.011718-1.3828-0.91016-2.6641-2.5391-3.6055-0.5625-0.32813-1.1953-0.58984-1.8633-0.79688l0.007812-2.5898c0-0.23828-0.19141-0.42969-0.42969-0.42969-0.23828 0-0.42969 0.19141-0.42969 0.42969l-0.015625 7.2227c0 1.3594-3.3945 2.3125-5.4883 1.1094-0.53906-0.31641-0.83984-0.71094-0.83984-1.1094l0.035156-14.559 6.3438-3.6602-0.015624 5.1836c0 0.23828 0.19141 0.42969 0.42969 0.43359 0.23828 0 0.42969-0.19141 0.42969-0.42969l0.015626-5.6836 28.43-16.414-0.035156 9.4492c-2.1211 0.69141-4.293 2.1523-4.3047 4.375-0.007812 1.168-0.003906 4.7812-0.003906 4.6055-0.015625 4.6992 9.1953 6.5742 14.242 3.6445 1.625-0.94531 2.5234-2.2266 2.5273-3.6094 0-0.18359 0.007813-4.4062 0.007813-4.5898 0-2.3008-2.2422-3.7305-4.418-4.4453l0.039062-14.078zm-8.7383-31.586c1.2539-0.73047 3.3828-0.73047 4.6562 0.003906 3.2852 1.8906-3.2969 4.0273-5.2227 1.7227-0.16406-0.20312-0.26172-0.41406-0.26172-0.63281 0-0.003907-0.003906-0.003907-0.003906-0.007813 0.003906-0.39062 0.29687-0.77344 0.83203-1.0859zm-0.83203 2.6758c1.5781 1.2578 4.7695 1.2148 6.3242-0.007812l-0.011718 2.4609-6.3281 3.6523zm-35.652 18.391c1.25-0.73047 3.3867-0.72656 4.6562 0.003906 1.9414 1.1211 0.25391 2.6562-2.168 2.7266-2.5859 0.070312-4.4766-1.5781-2.4883-2.7305zm-0.83203 2.6797c1.6094 1.2734 4.8477 1.168 6.332-0.011718v2.4492l-6.3438 3.6641zm10.617 53.434c-0.011719 3.6289-8.1406 5.6406-12.91 2.8867-1.3828-0.79688-2.1406-1.832-2.1367-2.9141v-2.332c3.1289 3.8086 12.098 3.6172 15.047 0.011719zm-10.344-2.5352c2.5078 1.4375 6.7852 0.40625 6.7852-1.8555l0.007813-3.7266c4.2617 1.4883 4.5586 4.6445 1.4453 6.3867-4.1406 2.4102-11.523 1.2734-12.777-2.043-0.003907-0.015625-0.015626-0.027344-0.019532-0.039063-0.011718-0.03125-0.007812-0.0625-0.015625-0.09375-0.47656-1.5352 0.58594-2.793 1.9688-3.5938 0.43359-0.24609 0.88672-0.44141 1.3477-0.60938l-0.007813 3.7148c-0.003906 0.72266 0.44531 1.3789 1.2656 1.8594zm35.391-38.055s-0.003906 0-0.007813 0.003907l-2.5117 1.4492 10.109-27.867 5.5664-3.2148-10.102 27.867zm-16.055 9.2695 10.105-27.863 5.6172-3.2461-10.105 27.863zm-13.496 7.793 10.105-27.859 5.6055-3.2383-10.105 27.859zm-6.7227 3.8789 10.086-27.848 5.5859-3.2266-10.105 27.859zm-3.2266-18.375c0-0.96094 0.71875-2.2109 1.5469-2.6758l3.8594-2.2305-5.3867 14.824zm26.82-17.273 5.5703-3.2188-10.105 27.863-5.5703 3.2188zm13.508-7.8008 5.6055-3.2383-10.109 27.867-5.6055 3.2344zm13.348-7.4023c0.62109 0.25 0.25781 0.64844 0.38672 11.164l-4.7422 13.074-5.6055 3.2383zm-1.1523 22.391-2.043 1.1797 3.5859-9.8906 0.011719 6.0312c0.003906 0.96484-0.70703 2.1914-1.5547 2.6797zm-52.188 29.777c-0.47656-0.27734-0.26953-1.0508-0.3125-7.2617l6.543-18.004 5.5859-3.2266-10.086 27.844c-0.58984 0.29297-1.2734 0.90625-1.7305 0.64844zm50.602-2.7305c-0.015625 5.5508-15.062 5.3945-15.047-0.03125v-2.332c3.1094 3.7969 12.074 3.6289 15.047 0.011719zm-2.1055-1.7344c-4.2344 2.4727-11.234 1.2031-12.672-1.8477-0.98438-2.1055 1.0117-3.7305 3.1758-4.5156l-0.011718 3.6992c0 3.3242 8.0508 3.3359 8.0508 0.019531l0.011719-3.7383c4.4492 1.5742 4.4688 4.6211 1.4453 6.3828zm-2.3203-2.6484c0 1.3672-3.4062 2.293-5.4766 1.0898-0.55078-0.3125-0.85156-0.70703-0.85156-1.1055l0.050781-14.562 2.9102-1.6797h0.003906l3.4102-1.9688z`})})}function iR({code:e=`404`,title:t=`Page not found`,description:n=`The page you're looking for doesn't exist or may have moved.`,action:r}){return(0,L.jsxs)(`div`,{className:`flex min-h-[60vh] w-full flex-col items-center justify-center gap-1 px-6 py-12 text-center`,children:[(0,L.jsx)(rR,{className:`h-48 w-48 text-muted-foreground/30`}),(0,L.jsx)(`p`,{className:`font-mono text-xs uppercase tracking-widest text-text-tertiary`,children:e}),(0,L.jsx)(`h1`,{className:`mt-2 font-serif text-[28px] font-normal text-foreground`,children:t}),(0,L.jsx)(`p`,{className:`mt-1 max-w-sm text-sm text-muted-foreground`,children:n}),r?(0,L.jsx)(`div`,{className:`mt-6`,children:r}):null]})}var aR=2e4,oR=window.__WIZARD_BOOTSTRAP__;oR&&HL(oR);var sR=new qe({defaultOptions:{queries:{retry:1,staleTime:3e4}}});function cR(e,t){return t?e!==`done`&&e!==`cancelled`&&e!==`wizard-lost`:!1}function lR(e,t){return t?e===`claimed`:!1}function uR(){let[e,t]=(0,I.useState)({phase:`claimed`}),[n,r]=(0,I.useState)(null),[i,a]=(0,I.useState)(!!oR?.prefill?.slug),[o,s]=(0,I.useState)(!1),[c,l]=(0,I.useState)(null);if((0,I.useEffect)(()=>{if(!oR)return;let e=XL({onDisconnect:()=>{s(!0)},onReconnect:()=>{s(!1)}});return()=>{e()}},[]),(0,I.useEffect)(()=>{if(oR)return WL(e=>{l(e)})},[]),(0,I.useEffect)(()=>{if(!o||e.phase===`done`||e.phase===`cancelled`||e.phase===`wizard-lost`)return;let n=window.setTimeout(()=>{t(e=>e.phase===`done`||e.phase===`cancelled`||e.phase===`wizard-lost`?e:{phase:`wizard-lost`})},aR);return()=>{window.clearTimeout(n)}},[o,e.phase]),!oR)return(0,L.jsx)(xR,{});let u=_t(pR(e.phase),oR.flow,{slugPicked:i});async function d(e){if(e.kind===`ai-key`){t({phase:`acking`,result:e}),await dR(e,r,t);return}t({phase:`secret`,result:e})}async function f(){e.phase===`secret`&&await dR(e.result,r,t)}return(0,L.jsxs)(Pt,{context:`local`,step:u,children:[lR(e.phase,c)?(0,L.jsx)(nR,{kind:c,onDismiss:()=>{l(null)}}):null,cR(e.phase,o)?(0,L.jsx)(tR,{state:`disconnected`,context:`local`}):null,e.phase===`claimed`?(0,L.jsx)(mR,{flow:oR.flow,prefill:oR.prefill??{},onSuccess:e=>void d(e),onCancel:()=>{t({phase:`cancelled`}),qL()},onSlugPicked:e=>{a(!!e)}}):e.phase===`secret`?(0,L.jsx)(hR,{result:e.result,completeError:n,onAck:()=>void f()}):e.phase===`acking`?(0,L.jsx)(gR,{result:e.result,completeError:n,onRetry:()=>{d(e.result)}}):e.phase===`cancelled`?(0,L.jsx)(vR,{}):e.phase===`wizard-lost`?(0,L.jsx)(yR,{}):(0,L.jsx)(_R,{})]})}async function dR(e,t,n){try{await KL(fR(e)),t(null),n({phase:`done`})}catch(e){t(e instanceof Error?e.message:String(e))}}function fR(e){switch(e.kind){case`ai-key`:return{acknowledged:!0,service_id:e.service_id,slug:e.slug,label:e.label};case`api-key-create`:return{acknowledged:!0,api_key_id:e.api_key_id};case`api-key-rotate`:return{acknowledged:!0,resource_id:e.resource_id};case`node-register-token`:return{acknowledged:!0,token_id:e.token_id};case`node-rotate-token`:return{acknowledged:!0,resource_id:e.resource_id};case`service-account-create`:return{acknowledged:!0,service_account_id:e.service_account_id};case`service-account-rotate-secret`:return{acknowledged:!0,resource_id:e.resource_id};case`developer-app-create`:return{acknowledged:!0,developer_app_id:e.developer_app_id};case`developer-app-rotate-secret`:return{acknowledged:!0,resource_id:e.resource_id};case`mfa-setup`:return{acknowledged:!0,factor_id:e.factor_id}}}function pR(e){return e===`claimed`?`claimed`:e===`secret`?`secret`:e===`acking`?`acking`:`done`}function mR({flow:e,prefill:t,onSuccess:n,onCancel:r,onSlugPicked:i}){let a=`local`;switch(e){case`api-key-create`:return(0,L.jsxs)(`div`,{className:`flex flex-col gap-4`,children:[(0,L.jsx)(mI,{prefill:t,pairingId:a,onSuccess:n}),(0,L.jsx)(bR,{onCancel:r})]});case`api-key-rotate`:return(0,L.jsxs)(`div`,{className:`flex flex-col gap-4`,children:[(0,L.jsx)(hI,{prefill:t,pairingId:a,onSuccess:n}),(0,L.jsx)(bR,{onCancel:r})]});case`node-register-token`:return(0,L.jsxs)(`div`,{className:`flex flex-col gap-4`,children:[(0,L.jsx)(_I,{prefill:t,pairingId:a,onSuccess:n}),(0,L.jsx)(bR,{onCancel:r})]});case`node-rotate-token`:return(0,L.jsxs)(`div`,{className:`flex flex-col gap-4`,children:[(0,L.jsx)(vI,{prefill:t,pairingId:a,onSuccess:n}),(0,L.jsx)(bR,{onCancel:r})]});case`ai-key`:return(0,L.jsxs)(`div`,{className:`flex flex-col gap-4`,children:[(0,L.jsx)(kL,{prefill:_N(t),pairingId:a,onSuccess:n,onSlugPicked:i}),(0,L.jsx)(bR,{onCancel:r})]});case`service-account-create`:return(0,L.jsxs)(`div`,{className:`flex flex-col gap-4`,children:[(0,L.jsx)(yI,{prefill:t,pairingId:a,onSuccess:n}),(0,L.jsx)(bR,{onCancel:r})]});case`service-account-rotate-secret`:return(0,L.jsxs)(`div`,{className:`flex flex-col gap-4`,children:[(0,L.jsx)(bI,{prefill:t,pairingId:a,onSuccess:n}),(0,L.jsx)(bR,{onCancel:r})]});case`developer-app-create`:return(0,L.jsxs)(`div`,{className:`flex flex-col gap-4`,children:[(0,L.jsx)(xI,{prefill:t,pairingId:a,onSuccess:n}),(0,L.jsx)(bR,{onCancel:r})]});case`developer-app-rotate-secret`:return(0,L.jsxs)(`div`,{className:`flex flex-col gap-4`,children:[(0,L.jsx)(SI,{prefill:t,pairingId:a,onSuccess:n}),(0,L.jsx)(bR,{onCancel:r})]});case`mfa-setup`:return(0,L.jsxs)(`div`,{className:`flex flex-col gap-4`,children:[(0,L.jsx)(CI,{pairingId:a,onSuccess:n}),(0,L.jsx)(bR,{onCancel:r})]})}}function hR({result:e,completeError:t,onAck:n}){let r=t===null;return e.kind===`api-key-create`?(0,L.jsx)(yi,{title:`API key created`,description:`Save this key now — it won't be shown again.`,secret:e.full_key,ackButtonLabel:`I have saved this — close`,onAcknowledge:n,isAcknowledging:r&&t===null&&!1}):e.kind===`api-key-rotate`?(0,L.jsx)(Si,{result:e,description:`The previous key is revoked. Save this new value now — it won't be shown again.`,ackButtonLabel:`I have saved this — close`,onAcknowledge:n}):e.kind===`node-register-token`?(0,L.jsx)(yi,{title:`Registration token generated`,description:"Use this with `nyxid node register`. Save it now — it won't be shown again.",secret:e.token,ackButtonLabel:`I have saved this — close`,onAcknowledge:n,isAcknowledging:!1}):e.kind===`node-rotate-token`?(0,L.jsx)(yi,{title:`Node tokens rotated`,description:"Update the node with `nyxid node rekey`. Save both values now — they won't be shown again.",secret:e.auth_token,secondarySecret:{label:`Signing secret`,value:e.signing_secret},ackButtonLabel:`I have saved this — close`,onAcknowledge:n,isAcknowledging:!1}):e.kind===`service-account-create`?(0,L.jsx)(yi,{title:`Service account created`,description:`Save the client_secret — it isn't shown again. Use it with the OAuth client_credentials flow.`,secret:e.client_secret,secondarySecret:{label:`Client ID`,value:e.client_id},ackButtonLabel:`I have saved this — close`,onAcknowledge:n,isAcknowledging:!1}):e.kind===`service-account-rotate-secret`?(0,L.jsx)(yi,{title:`Service account secret rotated`,description:`All previously-issued tokens have been revoked. Save this new client_secret — it isn't shown again.`,secret:e.client_secret,secondarySecret:{label:`Client ID`,value:e.client_id},ackButtonLabel:`I have saved this — close`,onAcknowledge:n,isAcknowledging:!1}):e.kind===`developer-app-create`?(0,L.jsx)(yi,{title:`Developer app created`,description:`Save the client_secret — it isn't shown again. Use it to sign Sign-in-with-NyxID requests.`,secret:e.client_secret,ackButtonLabel:`I have saved this — close`,onAcknowledge:n,isAcknowledging:!1}):e.kind===`developer-app-rotate-secret`?(0,L.jsx)(yi,{title:`Developer app secret rotated`,description:`The previous client_secret no longer authenticates. Update any deployments using it.`,secret:e.client_secret,ackButtonLabel:`I have saved this — close`,onAcknowledge:n,isAcknowledging:!1}):e.kind===`mfa-setup`?(0,L.jsx)(bi,{codes:e.recovery_codes,onAcknowledged:n}):(0,L.jsx)(`p`,{className:`text-sm text-destructive`,children:`Unknown result kind.`})}function gR({result:e,completeError:t,onRetry:n}){return(0,L.jsxs)(`div`,{className:`flex flex-col gap-4`,children:[(0,L.jsx)(`h2`,{className:`font-serif text-[28px] font-normal`,children:`Service added`}),(0,L.jsxs)(`p`,{className:`text-sm text-muted-foreground`,children:[(0,L.jsx)(`code`,{className:`font-mono text-xs`,children:e.slug}),` is now connected. Check your terminal for the final summary.`]}),t?(0,L.jsxs)(`div`,{className:`flex flex-col gap-2`,children:[(0,L.jsxs)(`p`,{className:`text-sm text-destructive`,children:[`Couldn't notify CLI: `,t]}),(0,L.jsx)(di,{variant:`outline`,onClick:n,children:`Retry`})]}):null]})}function _R(){return(0,L.jsx)(`div`,{className:`flex flex-col gap-4`,children:(0,L.jsxs)(`div`,{className:`flex flex-col gap-1`,children:[(0,L.jsx)(`h2`,{className:`font-serif text-[28px] font-normal`,children:`Done`}),(0,L.jsx)(`p`,{className:`text-sm text-muted-foreground`,children:`You can close this tab and return to your terminal.`})]})})}function vR(){return(0,L.jsx)(`div`,{className:`flex flex-col gap-4`,children:(0,L.jsxs)(`div`,{className:`flex flex-col gap-1`,children:[(0,L.jsx)(`h2`,{className:`font-serif text-[28px] font-normal`,children:`Cancelled`}),(0,L.jsx)(`p`,{className:`text-sm text-muted-foreground`,children:`Nothing was created. You can close this tab — your CLI should already be back at the prompt.`})]})})}function yR(){return(0,L.jsx)(`div`,{className:`flex flex-col gap-4`,children:(0,L.jsxs)(`div`,{className:`flex flex-col gap-1`,children:[(0,L.jsx)(`h2`,{className:`font-serif text-[28px] font-normal`,children:`Wizard interrupted`}),(0,L.jsxs)(`p`,{className:`text-sm text-muted-foreground`,children:[`Lost contact with the `,(0,L.jsx)(`code`,{children:`nyxid`}),` CLI. The CLI may have finished and exited successfully, or the connection was interrupted before the result reached this page.`]}),(0,L.jsxs)(`p`,{className:`mt-2 text-sm text-muted-foreground`,children:[`Run `,(0,L.jsx)(`code`,{children:`nyxid status`}),` in your terminal to see whether the service was created. If it’s missing, re-run the wizard command.`]})]})})}function bR({onCancel:e}){return(0,L.jsx)(`button`,{type:`button`,onClick:e,className:`self-start text-xs text-muted-foreground underline underline-offset-2 hover:text-foreground`,children:`Cancel and return to terminal`})}function xR(){return(0,L.jsx)(iR,{title:`Wizard not available here`,description:(0,L.jsxs)(L.Fragment,{children:[`This page is served by the `,(0,L.jsx)(`code`,{children:`nyxid`}),` CLI’s local wizard server, which injects its config on request. Open the URL printed by the CLI instead.`]})})}var SR=document.getElementById(`wizard-root`);SR&&(0,mt.createRoot)(SR).render((0,L.jsx)(I.StrictMode,{children:(0,L.jsx)(Qe,{client:sR,children:(0,L.jsx)(uR,{})})})); diff --git a/cli/src/wizard/bundle-meta/index.hash b/cli/src/wizard/bundle-meta/index.hash index 418672dcd..34d5a18f6 100644 --- a/cli/src/wizard/bundle-meta/index.hash +++ b/cli/src/wizard/bundle-meta/index.hash @@ -1 +1 @@ -ee66ea666acc4441ce616d6c9c88c9bf99182a22e32fe13fe4d35b9ee92668dd +bd9ee22f9620b031536d24a7af098e134b710044fb4062f12f66d9eccb8c1cb3 diff --git a/cli/src/wizard/bundle-meta/index.manifest b/cli/src/wizard/bundle-meta/index.manifest index 08f00ad41..85127bf05 100644 --- a/cli/src/wizard/bundle-meta/index.manifest +++ b/cli/src/wizard/bundle-meta/index.manifest @@ -128,6 +128,7 @@ frontend/src/stores/assistant-draft-store.ts frontend/src/stores/assistant-wire-log-store.ts frontend/src/stores/auth-store.ts frontend/src/stores/credits-denial-store.ts +frontend/src/stores/service-card-view-store.ts frontend/src/stores/theme-store.ts frontend/src/types/options.ts frontend/src/wizard-entry.tsx diff --git a/docs/plans/local-routing-preview.md b/docs/plans/local-routing-preview.md index 04d53556b..bceeec4b4 100644 --- a/docs/plans/local-routing-preview.md +++ b/docs/plans/local-routing-preview.md @@ -1,6 +1,11 @@ # Local routing preview with production metadata -Refreshed 25 September 2026 from main `1b031c77` (frontend v0.30.2). +Refreshed 28 September 2026 from main `bef3511b` (frontend v0.30.2), including +the service icon registry and per-connection icon overrides from #1681. +Grouped cards and service overview headers show the catalog icon; a group with +one connection also uses its custom icon. Each connection row shows its own icon +override, with the service glyph as fallback. Auto-connected and custom services +can resolve their glyph by service slug without a catalog identifier. The prior preview is preserved in stash `e71429e8d5d19a1668df4f2e2925abba37625f61`. Updated 27 September: the actual React frontend now renders one collapsed card per catalog service, with its real configured connections inside. The same diff --git a/frontend/src/components/dashboard/grouped-service-cards.tsx b/frontend/src/components/dashboard/grouped-service-cards.tsx index fd7520f87..fa357d0b2 100644 --- a/frontend/src/components/dashboard/grouped-service-cards.tsx +++ b/frontend/src/components/dashboard/grouped-service-cards.tsx @@ -7,7 +7,7 @@ import { type ReactNode, type RefObject, } from "react"; -import { ChevronRight, Globe } from "lucide-react"; +import { ChevronRight } from "lucide-react"; import { useServiceView } from "@/hooks/use-service-view"; import { useServiceCardTransition } from "@/hooks/use-service-card-transition"; import { ServiceViewToolbar } from "./service-view-toolbar"; @@ -151,11 +151,11 @@ function GroupCard({
- {group.slug ? ( - - ) : ( - - )} +

+ {key.label} diff --git a/frontend/src/lib/service-groups.ts b/frontend/src/lib/service-groups.ts index 421f5d3a7..103d17b96 100644 --- a/frontend/src/lib/service-groups.ts +++ b/frontend/src/lib/service-groups.ts @@ -4,6 +4,8 @@ export interface ServiceConnectionGroup { readonly id: string; readonly name: string; readonly slug: string | null; + readonly iconSlug: string; + readonly iconUrl: string | null; readonly description: string | null; readonly connections: readonly KeyInfo[]; } @@ -32,6 +34,8 @@ export function groupServiceConnections( ? (entry?.name ?? first.catalog_service_name ?? first.label) : first.label, slug, + iconSlug: first.catalog_service_slug ?? first.slug, + iconUrl: connections.length === 1 ? (first.icon_url ?? null) : null, description: entry?.description ?? (connections.length === 1 ? first.description : null) ?? diff --git a/frontend/src/lib/service-view.test.ts b/frontend/src/lib/service-view.test.ts index 87bc9b719..6cfded5aa 100644 --- a/frontend/src/lib/service-view.test.ts +++ b/frontend/src/lib/service-view.test.ts @@ -12,6 +12,8 @@ const group: ServiceConnectionGroup = { id: "catalog:openai", name: "OpenAI", slug: "openai", + iconSlug: "openai", + iconUrl: null, description: null, connections: [ { diff --git a/frontend/src/pages/key-detail.tsx b/frontend/src/pages/key-detail.tsx index 8ba001ee5..69a679940 100644 --- a/frontend/src/pages/key-detail.tsx +++ b/frontend/src/pages/key-detail.tsx @@ -2396,6 +2396,11 @@ function KeyDetailView({ keyId }: { readonly keyId: string }) { const source = keyInfo.credential_source; const isOrgSource = source?.type === "org"; const readOnly = !canEditConnection(keyInfo); + const canEditIcon = + !readOnly || + (keyInfo.auto_connected && + (source?.type === "personal" || + (source?.type === "org" && source.role === "admin" && source.allowed))); const canReconnect = !readOnly && !keyInfo.auto_connected && @@ -2454,7 +2459,16 @@ function KeyDetailView({ keyId }: { readonly keyId: string }) { > All service connections - + + } + /> Overview @@ -2511,7 +2525,7 @@ function KeyDetailView({ keyId }: { readonly keyId: string }) { keyId={keyInfo.id} slug={keyInfo.catalog_service_slug ?? keyInfo.slug} iconUrl={keyInfo.icon_url} - readOnly={readOnly} + readOnly={!canEditIcon} />
{keyInfo.auto_connected ? ( diff --git a/frontend/src/pages/service-overview.tsx b/frontend/src/pages/service-overview.tsx index 9338561c8..1cb518a9c 100644 --- a/frontend/src/pages/service-overview.tsx +++ b/frontend/src/pages/service-overview.tsx @@ -1,7 +1,7 @@ import { useBreadcrumbLabel } from "@/components/layout/dashboard-layout"; import { useState } from "react"; import { Link, useParams } from "@tanstack/react-router"; -import { ArrowLeft, Globe } from "lucide-react"; +import { ArrowLeft } from "lucide-react"; import { useKeys, useCatalog } from "@/hooks/use-keys"; import { useUserServices } from "@/hooks/use-user-services"; import { PageHeader } from "@/components/shared/page-header"; @@ -90,11 +90,11 @@ export function ServiceOverviewPage() { aria-hidden="true" className="flex size-12 items-center justify-center rounded-xl border border-border bg-card" > - {group.slug ? ( - - ) : ( - - )} +
} description={ From fff23ca508627f4ef59d0908e86ab5291dd5e509 Mon Sep 17 00:00:00 2001 From: ctkm-aelf <230958228+ctkm-aelf@users.noreply.github.com> Date: Tue, 29 Sep 2026 05:13:13 +0800 Subject: [PATCH 03/36] feat(services): surface billing and caller insights in service cards --- backend/src/api_docs.rs | 1 + .../src/billing_integration_tests/usage.rs | 1 + backend/src/db.rs | 8 + backend/src/handlers/llm_gateway.rs | 52 +- backend/src/handlers/mcp_transport.rs | 59 +- backend/src/handlers/mod.rs | 3 + backend/src/handlers/proxy.rs | 34 +- backend/src/handlers/service_insights.rs | 178 ++ .../src/handlers/service_insights_tests.rs | 32 + backend/src/models/usage_meter.rs | 3 + backend/src/routes.rs | 4 + backend/src/services/billing/funding.rs | 1 + .../services/billing/funding/target_tests.rs | 1 + backend/src/services/billing/lago_client.rs | 1 + backend/src/services/billing/meter.rs | 5 + backend/src/services/billing/reconcile.rs | 1 + backend/src/services/billing/usage_rollup.rs | 12 +- .../services/billing/usage_rollup/tests.rs | 49 + backend/src/services/billing/webhook.rs | 1 + .../src/services/destination_routing_tests.rs | 1 + .../exact_service_approval_service.rs | 17 + backend/src/services/mcp_service.rs | 40 +- backend/src/services/mod.rs | 2 + .../services/platform_key_service/tests.rs | 1 + .../src/services/service_insights_activity.rs | 896 ++++++++++ .../src/services/service_insights_billing.rs | 1585 +++++++++++++++++ docs/AI_SERVICES_ARCHITECTURE.md | 49 + docs/plans/local-routing-preview.md | 28 + frontend/dev/routing-preview.ts | 4 +- .../dashboard/grouped-service-cards.tsx | 103 +- .../dashboard/service-connection-table.tsx | 213 ++- .../dashboard/service-insight-panels.tsx | 406 +++++ .../dashboard/service-insights.test.tsx | 366 ++++ .../service-routing-preview.test.tsx | 15 +- frontend/src/hooks/use-agent-bindings.ts | 2 + frontend/src/hooks/use-api-keys.ts | 4 + frontend/src/hooks/use-service-insights.ts | 72 + frontend/src/lib/service-insights.ts | 139 ++ frontend/src/pages/service-overview.test.tsx | 13 +- frontend/src/schemas/service-insights.ts | 95 + frontend/test/routing-preview-gateway.test.ts | 3 + 41 files changed, 4419 insertions(+), 81 deletions(-) create mode 100644 backend/src/handlers/service_insights.rs create mode 100644 backend/src/handlers/service_insights_tests.rs create mode 100644 backend/src/services/service_insights_activity.rs create mode 100644 backend/src/services/service_insights_billing.rs create mode 100644 frontend/src/components/dashboard/service-insight-panels.tsx create mode 100644 frontend/src/components/dashboard/service-insights.test.tsx create mode 100644 frontend/src/hooks/use-service-insights.ts create mode 100644 frontend/src/lib/service-insights.ts create mode 100644 frontend/src/schemas/service-insights.ts diff --git a/backend/src/api_docs.rs b/backend/src/api_docs.rs index cd30bc533..124f573dc 100644 --- a/backend/src/api_docs.rs +++ b/backend/src/api_docs.rs @@ -39,6 +39,7 @@ // AI Services (unified key management) crate::handlers::keys::create_key, crate::handlers::keys::list_keys, + crate::handlers::service_insights::get_insights, crate::handlers::keys::get_key, crate::handlers::keys::get_key_authorization, crate::handlers::keys::update_key, diff --git a/backend/src/billing_integration_tests/usage.rs b/backend/src/billing_integration_tests/usage.rs index 742d0bcf5..3e020fc61 100644 --- a/backend/src/billing_integration_tests/usage.rs +++ b/backend/src/billing_integration_tests/usage.rs @@ -18,6 +18,7 @@ fn meter(owner: &str, quantity: i64) -> UsageMeterRow { wallet_id: Some("wallet".into()), actor_user_id: owner.to_string(), api_key_id: None, + user_service_id: None, service_id: Some("service".into()), service_slug: Some("llm-test".into()), metric: BillingMetric::Tokens, diff --git a/backend/src/db.rs b/backend/src/db.rs index f55b49ad4..f4f590709 100644 --- a/backend/src/db.rs +++ b/backend/src/db.rs @@ -443,6 +443,14 @@ pub async fn ensure_indexes(db: &Database) -> Result<(), mongodb::error::Error> // ── audit_log ── let audit = db.collection::("audit_log"); + audit + .create_index( + IndexModel::builder() + .keys(doc! { "event_type": 1, "event_data.user_service_id": 1, "created_at": -1, "_id": -1 }) + .options(IndexOptions::builder().name("audit_service_requests".to_string()).build()) + .build(), + ) + .await?; audit .create_index( IndexModel::builder() diff --git a/backend/src/handlers/llm_gateway.rs b/backend/src/handlers/llm_gateway.rs index 13c736446..7b06f94ce 100644 --- a/backend/src/handlers/llm_gateway.rs +++ b/backend/src/handlers/llm_gateway.rs @@ -290,6 +290,7 @@ pub async fn llm_proxy_request( // user has a perfectly valid UserService linked by catalog_service_id. let mut is_auto_connected_for_approval = false; let mut credential_source = None; + let mut resolved_user_service_id = None; let (target, resolved_via_user_service, master_credential, owner_for_approval) = match proxy_service::resolve_proxy_target_from_user_service( &state.db, @@ -306,6 +307,7 @@ pub async fn llm_proxy_request( .await? { Some(resolution) => { + resolved_user_service_id = Some(resolution.user_service_id.clone()); credential_source = resolution.credential_source; is_auto_connected_for_approval = resolution.is_auto_connected; let effective_owner = resolution @@ -372,6 +374,16 @@ pub async fn llm_proxy_request( credential_source.as_deref(), &target, ); + let billing_request_id = uuid::Uuid::new_v4().to_string(); + let mut request_audit = crate::services::service_insights_activity::RequestAudit::new( + &state.db, + &auth_user, + resolved_user_service_id.as_deref(), + &service_id, + billing_resource_owner_id, + &billing_request_id, + credential_class, + ); let billing_owner = state .billing .owner_resolver() @@ -380,14 +392,15 @@ pub async fn llm_proxy_request( billing_resource_owner_id, credential_class, ) - .await?; + .await + .inspect_err(|error| request_audit.admission_error(error))?; let billing_ctx = crate::services::billing::BillingRouteContext::new( crate::services::billing::BillingIngress::LlmProvider, - uuid::Uuid::new_v4().to_string(), + billing_request_id, billing_owner.owner_id, user_id_str.clone(), auth_user.api_key_id.clone(), - None, + resolved_user_service_id, Some(service_id.clone()), Some(service.slug.clone()), crate::services::billing::NodeIntent::Direct, @@ -398,7 +411,11 @@ pub async fn llm_proxy_request( state.billing.resale_enabled(), ); let billing_ctx = billing_ctx.with_request_body(Some(&body_bytes)); - let metered = state.billing.open(&billing_ctx).await?; + let metered = state + .billing + .open(&billing_ctx) + .await + .inspect_err(|error| request_audit.admission_error(error))?; // Resolve credentials for injection. The new UserService path bakes the // credential into `target` (via auth_method / credential), so we only need @@ -564,6 +581,7 @@ pub async fn llm_proxy_request( })), ); + request_audit.response(response.status().as_u16()); Ok(response) } @@ -674,6 +692,7 @@ pub async fn gateway_request( // instead of `provider_slug` -- the URL's provider slug does not // match UserService.slug, which is user-chosen at provision time. let mut credential_source = None; + let mut resolved_user_service_id = None; let (target, resolved_via_user_service, master_credential) = match proxy_service::resolve_proxy_target_from_user_service( &state.db, @@ -690,6 +709,7 @@ pub async fn gateway_request( .await? { Some(resolution) => { + resolved_user_service_id = Some(resolution.user_service_id.clone()); credential_source = resolution.credential_source; is_auto_connected_for_approval = resolution.is_auto_connected; effective_owner_for_approval = Some( @@ -809,6 +829,16 @@ pub async fn gateway_request( credential_source.as_deref(), &target, ); + let billing_request_id = uuid::Uuid::new_v4().to_string(); + let mut request_audit = crate::services::service_insights_activity::RequestAudit::new( + &state.db, + &auth_user, + resolved_user_service_id.as_deref(), + &service_id, + billing_resource_owner_id, + &billing_request_id, + credential_class, + ); let billing_owner = state .billing .owner_resolver() @@ -817,14 +847,15 @@ pub async fn gateway_request( billing_resource_owner_id, credential_class, ) - .await?; + .await + .inspect_err(|error| request_audit.admission_error(error))?; let billing_ctx = crate::services::billing::BillingRouteContext::new( crate::services::billing::BillingIngress::LlmGateway, - uuid::Uuid::new_v4().to_string(), + billing_request_id, billing_owner.owner_id, user_id_str.clone(), auth_user.api_key_id.clone(), - None, + resolved_user_service_id, Some(service_id.clone()), Some(service.slug.clone()), crate::services::billing::NodeIntent::Direct, @@ -835,7 +866,11 @@ pub async fn gateway_request( state.billing.resale_enabled(), ); let billing_ctx = billing_ctx.with_request_body(Some(&body_bytes)); - let metered = state.billing.open(&billing_ctx).await?; + let metered = state + .billing + .open(&billing_ctx) + .await + .inspect_err(|error| request_audit.admission_error(error))?; // Resolve delegated credentials. When the target came from the new // UserService path, the credential is already baked into `target`; we only @@ -1058,6 +1093,7 @@ pub async fn gateway_request( })), ); + request_audit.response(response.status().as_u16()); Ok(response) } diff --git a/backend/src/handlers/mcp_transport.rs b/backend/src/handlers/mcp_transport.rs index ff1c35b75..c22da551d 100644 --- a/backend/src/handlers/mcp_transport.rs +++ b/backend/src/handlers/mcp_transport.rs @@ -358,6 +358,8 @@ struct McpAuthContext { user_id: String, auth_method: AuthMethod, acting_client_id: Option, + oauth_client_id: Option, + api_key_credential_id: Option, approval_owner_user_id: Option, /// True when auth was via `x-api-key`. API-key requests are stateless: each /// request authenticates independently, no MCP session is created or required. @@ -388,6 +390,8 @@ impl McpAuthContext { user_id, auth_method, acting_client_id: None, + oauth_client_id: None, + api_key_credential_id: None, approval_owner_user_id: None, is_api_key: false, api_key_id: None, @@ -516,7 +520,7 @@ async fn authenticate_mcp( .map_err(|_| mcp_401(&state.config.base_url))?; match crate::services::key_service::validate_api_key(&state.db, raw_key).await { - Ok((user_id, api_key, _credential_id)) => { + Ok((user_id, api_key, credential_id)) => { if !auth::scope_allows_rest_proxy(&api_key.scopes) { return Err(mcp_403_api_key_insufficient_scope()); } @@ -543,6 +547,8 @@ async fn authenticate_mcp( user_id, auth_method: AuthMethod::ApiKey, acting_client_id: None, + oauth_client_id: None, + api_key_credential_id: credential_id, approval_owner_user_id: None, is_api_key: true, api_key_id: Some(api_key.id.clone()), @@ -660,6 +666,7 @@ async fn authenticate_mcp( ctx.api_key_id = api_key_id; ctx.api_key_name = api_key_name; } + ctx.oauth_client_id = claims.client_id.clone(); ctx.acting_client_id = claims.act.map(|a| a.sub); ctx.approval_owner_user_id = approval_owner_user_id; ctx.ip_address = request_ip.clone(); @@ -1808,6 +1815,29 @@ async fn dispatch_tools_call( /// the authenticated MCP caller -- API key identity + node scope. fn mcp_exec_context<'a>(auth: &'a McpAuthContext) -> mcp_service::McpExecContext<'a> { mcp_service::McpExecContext { + attribution: Some( + crate::services::service_insights_activity::RequestAttribution { + actor: crate::services::audit_service::AuditActor { + user_id: auth.user_id.clone(), + api_key_id: auth.api_key_id.clone(), + api_key_name: auth.api_key_name.clone(), + ip_address: auth.ip_address.clone(), + user_agent: auth.user_agent.clone(), + }, + auth_kind: match auth.auth_method { + AuthMethod::Session => "session", + AuthMethod::AccessToken => "access_token", + AuthMethod::ApiKey => "api_key", + AuthMethod::ServiceAccount => "service_account", + AuthMethod::Delegated => "delegated", + AuthMethod::Relay => "relay", + } + .into(), + oauth_client_id: auth.oauth_client_id.clone(), + acting_client_id: auth.acting_client_id.clone(), + api_key_credential_id: auth.api_key_credential_id.clone(), + }, + ), api_key_id: auth.api_key_id.as_deref(), allow_all_nodes: auth.allow_all_nodes, allowed_node_ids: &auth.allowed_node_ids, @@ -3745,6 +3775,8 @@ mod tests { user_id: "user-1".into(), auth_method: AuthMethod::ApiKey, acting_client_id: None, + oauth_client_id: None, + api_key_credential_id: None, approval_owner_user_id: None, is_api_key: true, api_key_id: Some("key-1".into()), @@ -4908,11 +4940,19 @@ mod tests { #[test] fn mcp_exec_context_from_api_key_auth() { - let auth = api_key_auth(vec!["svc-1".into()]); + let mut auth = api_key_auth(vec!["svc-1".into()]); + auth.api_key_credential_id = Some("login-credential".into()); let ctx = mcp_exec_context(&auth); assert_eq!(ctx.api_key_id, Some("key-1")); assert!(!ctx.allow_all_nodes); assert!(ctx.allowed_node_ids.is_empty()); + let attribution = ctx.attribution.unwrap(); + assert_eq!(attribution.actor.api_key_id.as_deref(), Some("key-1")); + assert_eq!(attribution.actor.api_key_name.as_deref(), Some("agent")); + assert_eq!( + attribution.api_key_credential_id.as_deref(), + Some("login-credential") + ); } #[test] @@ -4921,6 +4961,21 @@ mod tests { let ctx = mcp_exec_context(&auth); assert!(ctx.api_key_id.is_none()); assert!(ctx.allow_all_nodes); + assert_eq!(ctx.attribution.unwrap().auth_kind, "session"); + } + + #[test] + fn mcp_exec_context_preserves_verified_application_identity() { + let mut auth = McpAuthContext::user("user-1".into(), AuthMethod::AccessToken); + auth.oauth_client_id = Some("registered-client".into()); + let ctx = mcp_exec_context(&auth); + let attribution = ctx.attribution.unwrap(); + assert_eq!( + attribution.oauth_client_id.as_deref(), + Some("registered-client") + ); + assert_eq!(attribution.auth_kind, "access_token"); + assert!(attribution.actor.api_key_id.is_none()); } // ----------------------------------------------------------------------- diff --git a/backend/src/handlers/mod.rs b/backend/src/handlers/mod.rs index 94ddae9b9..f7b930f11 100644 --- a/backend/src/handlers/mod.rs +++ b/backend/src/handlers/mod.rs @@ -121,5 +121,8 @@ mod curation_tests; pub mod options; pub mod service_history; +pub mod service_insights; +#[cfg(test)] +mod service_insights_tests; pub mod channel_activities; diff --git a/backend/src/handlers/proxy.rs b/backend/src/handlers/proxy.rs index 12b8bb616..8c4c0ce2c 100644 --- a/backend/src/handlers/proxy.rs +++ b/backend/src/handlers/proxy.rs @@ -2287,6 +2287,16 @@ async fn execute_proxy_inner( credential_source.as_deref(), &target, ); + let billing_request_id = uuid::Uuid::new_v4().to_string(); + let mut request_audit = crate::services::service_insights_activity::RequestAudit::new( + &state.db, + auth_user, + resolved_user_service_id.as_deref(), + &target.service.id, + billing_resource_owner_id, + &billing_request_id, + credential_class, + ); let billing_owner = state .billing .owner_resolver() @@ -2295,8 +2305,8 @@ async fn execute_proxy_inner( billing_resource_owner_id, credential_class, ) - .await?; - let billing_request_id = uuid::Uuid::new_v4().to_string(); + .await + .inspect_err(|error| request_audit.admission_error(error))?; let is_ws_candidate = is_ws_upgrade_request(&request); let platform_metric = platform_metric_for_target(&target, is_ws_candidate); let node_intent = match &node_route { @@ -2470,6 +2480,7 @@ async fn execute_proxy_inner( match approval_outcome { approval_service::ApprovalOutcome::Allowed { .. } => {} approval_service::ApprovalOutcome::Denied => { + request_audit.denied(403); if let Some(api_key_id) = scheduled_api_key_id { audit_service::log_for_user( state.db.clone(), @@ -2739,7 +2750,11 @@ async fn execute_proxy_inner( } let billing_ctx = billing_ctx.with_request_body(body.as_deref()); - let metered = state.billing.open(&billing_ctx).await?; + let metered = state + .billing + .open(&billing_ctx) + .await + .inspect_err(|error| request_audit.admission_error(error))?; let durable_reservation = if let Some(api_key_id) = scheduled_api_key_id { let grant_id = match durable_grant_id.as_deref() { @@ -2901,7 +2916,9 @@ async fn execute_proxy_inner( let ws_upgrade = match WebSocketUpgrade::from_request_parts(&mut parts, &()).await { Ok(ws) => ws, Err(rejection) => { - return Ok(rejection.into_response()); + let response = rejection.into_response(); + request_audit.denied(response.status().as_u16()); + return Ok(response); } }; @@ -2926,7 +2943,8 @@ async fn execute_proxy_inner( metered.clone(), billing_egress_permit, ) - .await; + .await + .inspect(|response| request_audit.response(response.status().as_u16())); } // Direct WS passthrough: connect to downstream directly. @@ -2946,7 +2964,8 @@ async fn execute_proxy_inner( metered.clone(), billing_egress_permit, ) - .await; + .await + .inspect(|response| request_audit.response(response.status().as_u16())); } // === Node Proxy Routing (v2: failover + streaming + metrics + HMAC signing) === @@ -3387,6 +3406,7 @@ async fn execute_proxy_inner( } destination_audit.complete(response.status().as_u16()); + request_audit.response(response.status().as_u16()); return Ok(response); } Err(NodeProxyFailure { @@ -3792,6 +3812,7 @@ async fn execute_proxy_inner( } destination_audit.complete(response.status().as_u16()); + request_audit.response(response.status().as_u16()); return Ok(response); } @@ -4372,6 +4393,7 @@ async fn execute_proxy_inner( ); destination_audit.complete(response.status().as_u16()); + request_audit.response(response.status().as_u16()); Ok(response) } diff --git a/backend/src/handlers/service_insights.rs b/backend/src/handlers/service_insights.rs new file mode 100644 index 000000000..5ba225c0f --- /dev/null +++ b/backend/src/handlers/service_insights.rs @@ -0,0 +1,178 @@ +use std::collections::HashSet; + +use axum::{ + Json, + extract::{Query, State}, + http::header, +}; +use serde::{Deserialize, Serialize}; +use utoipa::{IntoParams, ToSchema}; +use uuid::Uuid; + +use crate::{ + AppState, + errors::{AppError, AppResult}, + mw::auth::{AuthMethod, AuthUser}, + services::{ + org_service, service_insights_activity, service_insights_billing, user_service_service, + }, +}; + +#[derive(Deserialize, IntoParams)] +#[serde(deny_unknown_fields)] +pub struct InsightsQuery { + /// Comma-separated exact connection UUIDs, at most 100. + pub ids: String, + /// Optional managed agent key whose execution context should be explained. + pub api_key_id: Option, +} + +#[derive(Serialize, ToSchema)] +pub struct ConnectionInsightResponse { + pub service_id: String, + pub billing: Option, + pub usage: Option, +} + +#[derive(Serialize, ToSchema)] +pub struct InsightsResponse { + pub connections: Vec, +} + +fn connection_ids(raw: &str) -> AppResult> { + let parts: Vec<_> = raw.split(',').collect(); + if parts.is_empty() || parts.len() > 100 || raw.len() > 3_699 { + return Err(AppError::ValidationError( + "Choose between 1 and 100 connection IDs".into(), + )); + } + parts + .into_iter() + .map(|id| { + Uuid::parse_str(id) + .map(|id| id.to_string()) + .map_err(|_| AppError::ValidationError("Connection IDs must be UUIDs".into())) + }) + .collect() +} + +#[utoipa::path( + get, + path = "/api/v1/service-insights", + params(InsightsQuery), + responses((status = 200, body = InsightsResponse), (status = 400, description = "Invalid connection IDs"), (status = 403, description = "Account management session required")), + security(("bearer_auth" = [])), + tag = "AI Services" +)] +pub async fn get_insights( + State(state): State, + auth: AuthUser, + Query(query): Query, +) -> AppResult<( + [(header::HeaderName, &'static str); 1], + Json, +)> { + if !matches!( + auth.auth_method, + AuthMethod::Session | AuthMethod::AccessToken | AuthMethod::Delegated + ) { + return Err(AppError::Forbidden( + "Connection insights require an account management session".into(), + )); + } + let requested = connection_ids(&query.ids)?; + let agent_key_id = query + .api_key_id + .as_deref() + .map(|id| { + Uuid::parse_str(id) + .map(|id| id.to_string()) + .map_err(|_| AppError::ValidationError("Agent key ID must be a UUID".into())) + }) + .transpose()?; + let actor = auth.user_id.to_string(); + let memberships = org_service::list_memberships_for_member(&state.db, &actor, false).await?; + let services = user_service_service::list_user_services_with_sources_including_disabled( + &state.db, + &actor, + &memberships, + ) + .await? + .into_iter() + .filter(|row| requested.contains(&row.service.id)) + .filter(|row| auth.allow_all_services || auth.allowed_service_ids.contains(&row.service.id)) + .map(|row| row.service) + .collect::>(); + // Each optional projection can fail without suppressing the other. No cached + // privilege-bearing result is substituted after an authorization failure. + let billing_principal = auth.proxy_resolution_user_id(); + let (billing, activity) = tokio::join!( + async { + match agent_key_id.as_deref() { + Some(key_id) => { + service_insights_billing::explain_for_agent_key( + &state.db, + &state.billing, + &actor, + &services, + key_id, + ) + .await + } + None => { + service_insights_billing::explain_connections( + &state.db, + &state.billing, + &billing_principal, + &actor, + &services, + ) + .await + } + } + }, + service_insights_activity::insights(&state.db, &actor, &services), + ); + if let Err(error) = &billing { + tracing::warn!(%error, "Service billing insights unavailable"); + } + if let Err(error) = &activity { + tracing::warn!(%error, "Service caller insights unavailable"); + } + let mut billing = billing.unwrap_or_default(); + let mut activity = activity.unwrap_or_default(); + let connections = services + .into_iter() + .map(|service| ConnectionInsightResponse { + billing: billing.remove(&service.id), + usage: activity.remove(&service.id), + service_id: service.id, + }) + .collect(); + Ok(( + [(header::CACHE_CONTROL, "private, no-store")], + Json(InsightsResponse { connections }), + )) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn connection_insights_rejects_invalid_or_unbounded_ids() { + for ids in [ + "".to_string(), + "slug".into(), + format!("{},", Uuid::new_v4()), + vec![Uuid::new_v4().to_string(); 101].join(","), + ] { + assert!(connection_ids(&ids).is_err()); + } + let id = Uuid::new_v4().to_string(); + assert_eq!( + connection_ids(&format!("{id},{id}")).unwrap(), + HashSet::from([id]) + ); + } +} diff --git a/backend/src/handlers/service_insights_tests.rs b/backend/src/handlers/service_insights_tests.rs new file mode 100644 index 000000000..dd6800591 --- /dev/null +++ b/backend/src/handlers/service_insights_tests.rs @@ -0,0 +1,32 @@ +use axum::extract::{Query, State}; + +use crate::{ + errors::AppError, + handlers::service_insights::{InsightsQuery, get_insights}, + mw::auth::AuthMethod, + test_utils::{test_app_state_no_db, test_auth_user}, +}; + +#[tokio::test] +async fn agent_and_service_account_tokens_cannot_enumerate_key_inventory_through_insights() { + let state = test_app_state_no_db().await; + let actor_id = uuid::Uuid::new_v4().to_string(); + for method in [ + AuthMethod::ApiKey, + AuthMethod::ServiceAccount, + AuthMethod::Relay, + ] { + let mut auth = test_auth_user(&actor_id); + auth.auth_method = method; + let result = get_insights( + State(state.clone()), + auth, + Query(InsightsQuery { + ids: uuid::Uuid::new_v4().to_string(), + api_key_id: None, + }), + ) + .await; + assert!(matches!(result, Err(AppError::Forbidden(_)))); + } +} diff --git a/backend/src/models/usage_meter.rs b/backend/src/models/usage_meter.rs index 37b3a1a6e..ee0293ad4 100644 --- a/backend/src/models/usage_meter.rs +++ b/backend/src/models/usage_meter.rs @@ -139,6 +139,9 @@ pub struct UsageMeterRow { pub api_key_id: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub service_id: Option, + /// Exact connection selected at execution; legacy catalog-only rows stay unknown. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub user_service_id: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub service_slug: Option, pub metric: BillingMetric, diff --git a/backend/src/routes.rs b/backend/src/routes.rs index 855a43302..3af08e8a5 100644 --- a/backend/src/routes.rs +++ b/backend/src/routes.rs @@ -1836,6 +1836,10 @@ fn build_router_internal(router_state: Option) -> (Router, R // Shared management routes; individual groups retain service-account gates. // Delegated reads require account:read and the existing route/method policy. let api_v1_shared = Router::new() + .route( + "/service-insights", + get(handlers::service_insights::get_insights), + ) .route("/keys", get(handlers::service_account_key_reads::list_keys)) .route( "/keys/{key_id}", diff --git a/backend/src/services/billing/funding.rs b/backend/src/services/billing/funding.rs index d04361ed7..5a04f095a 100644 --- a/backend/src/services/billing/funding.rs +++ b/backend/src/services/billing/funding.rs @@ -1355,6 +1355,7 @@ mod tests { wallet_id: Some("wallet-1".to_string()), actor_user_id: owner_id.to_string(), api_key_id: None, + user_service_id: None, service_id: Some(service_id.to_string()), service_slug: Some(service_slug.to_string()), metric: BillingMetric::Requests, diff --git a/backend/src/services/billing/funding/target_tests.rs b/backend/src/services/billing/funding/target_tests.rs index 223eead20..91767c143 100644 --- a/backend/src/services/billing/funding/target_tests.rs +++ b/backend/src/services/billing/funding/target_tests.rs @@ -115,6 +115,7 @@ async fn member_removal_blocks_new_funding_but_preserves_admitted_reservations() wallet_id: Some("wallet".into()), actor_user_id: "person".into(), api_key_id: None, + user_service_id: None, service_id: Some("service".into()), service_slug: Some("service".into()), metric: BillingMetric::Requests, diff --git a/backend/src/services/billing/lago_client.rs b/backend/src/services/billing/lago_client.rs index 5782bd013..1e3958e94 100644 --- a/backend/src/services/billing/lago_client.rs +++ b/backend/src/services/billing/lago_client.rs @@ -3031,6 +3031,7 @@ mod tests { wallet_id: Some("wallet-1".to_string()), actor_user_id: "owner-1".to_string(), api_key_id: None, + user_service_id: None, service_id: Some("service-1".to_string()), service_slug: Some("service-one".to_string()), metric: crate::models::service_billing::BillingMetric::Requests, diff --git a/backend/src/services/billing/meter.rs b/backend/src/services/billing/meter.rs index 9c92e6fc1..ece6a1d30 100644 --- a/backend/src/services/billing/meter.rs +++ b/backend/src/services/billing/meter.rs @@ -515,6 +515,7 @@ async fn insert_reserved_row( wallet_id, actor_user_id: ctx.actor_user_id.clone(), api_key_id: ctx.api_key_id.clone(), + user_service_id: ctx.user_service_id.clone(), service_id: ctx .catalog_service_id .clone() @@ -958,6 +959,10 @@ mod tests { .expect("collect rows"); assert_eq!(rows.len(), 2); + assert!(rows.iter().all( + |row| row.user_service_id.as_deref() == Some("user-service-1") + && row.service_id.as_deref() == Some("catalog-1") + )); assert!(rows.iter().any(|row| { row.layer == BillingLayer::Platform && row.transaction_id == "billing-request-1:platform" diff --git a/backend/src/services/billing/reconcile.rs b/backend/src/services/billing/reconcile.rs index 07daf1d39..746c4010b 100644 --- a/backend/src/services/billing/reconcile.rs +++ b/backend/src/services/billing/reconcile.rs @@ -677,6 +677,7 @@ mod tests { wallet_id: Some("wallet-1".to_string()), actor_user_id: "actor-1".to_string(), api_key_id: None, + user_service_id: None, service_id: Some("service-1".to_string()), service_slug: Some("service-one".to_string()), metric: BillingMetric::Requests, diff --git a/backend/src/services/billing/usage_rollup.rs b/backend/src/services/billing/usage_rollup.rs index 7b44bb74d..2d80a1a2b 100644 --- a/backend/src/services/billing/usage_rollup.rs +++ b/backend/src/services/billing/usage_rollup.rs @@ -416,6 +416,10 @@ async fn raw_increments( doc! { "$dateTrunc": { "date": "$created_at", "unit": "hour", "timezone": "UTC" } }, ); key.insert("exact", "$exact"); + key.insert( + "user_service_id", + doc! { "$ifNull": ["$user_service_id", null] }, + ); group.insert("rows_folded", doc! { "$sum": 1_i64 }); let mut groups: Vec = db .collection::(METERS) @@ -433,8 +437,12 @@ async fn raw_increments( return Err(AppError::Internal("Missing usage group key".into())); }; let mut partition_key = Document::new(); - for field in ["api_key", "acked"] { - if let Some(value) = key.remove(field) { + // Keep replay identities stable across replicas. Exact connection is + // additive partition metadata, like the API key, not a new bucket key. + for field in ["api_key", "acked", "user_service_id"] { + if let Some(value) = key.remove(field) + && (field != "user_service_id" || !matches!(value, Bson::Null)) + { partition_key.insert(field, value); } } diff --git a/backend/src/services/billing/usage_rollup/tests.rs b/backend/src/services/billing/usage_rollup/tests.rs index beab1eace..dd692f67e 100644 --- a/backend/src/services/billing/usage_rollup/tests.rs +++ b/backend/src/services/billing/usage_rollup/tests.rs @@ -52,6 +52,55 @@ fn bootstrap_increment(at: DateTime, partitions: usize) -> UsageRollupHourl bson::from_document(document).unwrap() } +#[tokio::test] +async fn exact_connections_remain_distinct_through_rollup_and_legacy_stays_unknown() { + let db = connect_test_database("rollup_exact_connection") + .await + .unwrap(); + let at = hour(Utc::now()); + let mut rows = vec![row(at), row(at), row(at)]; + rows[0].insert("user_service_id", "connection-a"); + rows[1].insert("user_service_id", "connection-b"); + for row in &mut rows { + row.insert("wallet_id", "wallet"); + } + let ids: Vec<_> = rows + .iter() + .map(|r| r.get_str("_id").unwrap().to_owned()) + .collect(); + db.collection::(METERS) + .insert_many(rows) + .await + .unwrap(); + let increments = raw_increments(&db, &ids, 1).await.unwrap(); + assert_eq!(increments.len(), 1); + let connections: std::collections::HashSet<_> = increments[0] + .cost_partitions + .values() + .map(|p| p.key.get_str("user_service_id").ok()) + .collect(); + assert_eq!( + connections, + std::collections::HashSet::from([Some("connection-a"), Some("connection-b"), None]) + ); + let daily = daily_increments(&increments).unwrap(); + assert_eq!(daily.len(), 1); + let daily_connections: std::collections::HashSet<_> = daily[0] + .cost_partitions + .values() + .map(|p| p.key.get_str("user_service_id").ok()) + .collect(); + assert_eq!(daily_connections, connections); + // Both replay bucket identities stay identical to legacy-shaped input. + db.collection::(METERS) + .update_many(doc! {}, doc! { "$unset": { "user_service_id": "" } }) + .await + .unwrap(); + let legacy = raw_increments(&db, &ids, 1).await.unwrap(); + assert_eq!(legacy[0].id, increments[0].id); + assert_eq!(daily_increments(&legacy).unwrap()[0].id, daily[0].id); +} + #[test] fn bootstrap_byte_budget_splits_oversized_increments_and_counts_exact_bson_size() { let increment = bootstrap_increment(hour(Utc::now()), 64); diff --git a/backend/src/services/billing/webhook.rs b/backend/src/services/billing/webhook.rs index b11606fef..d8a4d8b93 100644 --- a/backend/src/services/billing/webhook.rs +++ b/backend/src/services/billing/webhook.rs @@ -440,6 +440,7 @@ mod tests { wallet_id: Some(wallet_id.to_string()), actor_user_id: owner_id.to_string(), api_key_id: None, + user_service_id: None, service_id: Some("svc-1".to_string()), service_slug: Some("svc".to_string()), metric: BillingMetric::Requests, diff --git a/backend/src/services/destination_routing_tests.rs b/backend/src/services/destination_routing_tests.rs index becf84307..bf7a3a811 100644 --- a/backend/src/services/destination_routing_tests.rs +++ b/backend/src/services/destination_routing_tests.rs @@ -519,6 +519,7 @@ pub(crate) async fn mcp_call( &state.token_exchange_cache, &state.cloud_response_cache, &mcp_service::McpExecContext { + attribution: None, api_key_id: None, allow_all_nodes: true, allowed_node_ids: &[], diff --git a/backend/src/services/exact_service_approval_service.rs b/backend/src/services/exact_service_approval_service.rs index 5eb3ab3da..2c00dbcf7 100644 --- a/backend/src/services/exact_service_approval_service.rs +++ b/backend/src/services/exact_service_approval_service.rs @@ -468,6 +468,23 @@ pub async fn redeem_request( } }; let exec_ctx = mcp_service::McpExecContext { + attribution: Some(super::service_insights_activity::RequestAttribution { + actor: super::audit_service::AuditActor { + user_id: caller.actor_user_id.clone(), + api_key_id: caller.api_key_id.clone(), + api_key_name: caller + .api_key_id + .as_ref() + .and(caller.requester_label.clone()), + ip_address: None, + user_agent: None, + }, + auth_kind: caller.requester_type.clone(), + acting_client_id: (caller.requester_type == "delegated") + .then(|| caller.requester_id.clone()), + oauth_client_id: None, + api_key_credential_id: None, + }), api_key_id: caller.api_key_id.as_deref(), allow_all_nodes: caller.allow_all_nodes, allowed_node_ids: &caller.allowed_node_ids, diff --git a/backend/src/services/mcp_service.rs b/backend/src/services/mcp_service.rs index 48db73344..00e893d0e 100644 --- a/backend/src/services/mcp_service.rs +++ b/backend/src/services/mcp_service.rs @@ -165,6 +165,7 @@ impl McpBillingRouteContextBuilder { /// node allow-list enforcement. OAuth and session callers pass `api_key_id: /// None` and `allow_all_nodes: true`, preserving their existing behavior. pub struct McpExecContext<'a> { + pub attribution: Option, /// API key ID that is acting on behalf of the user. Enables per-agent /// credential override via [`proxy_service::resolve_agent_credential_override`]. pub api_key_id: Option<&'a str>, @@ -4441,6 +4442,7 @@ pub async fn execute_tool_resolved( } else { build_downstream_request_headers(endpoint, body.is_some())? }; + let resource_owner_id = billing_context_builder.effective_owner_id.clone(); let billing_ctx = billing_context_builder .build( billing.as_ref(), @@ -4453,7 +4455,39 @@ pub async fn execute_tool_resolved( ) .await?; let billing_ctx = billing_ctx.with_request_body(body.as_deref()); - let metered = billing.open(&billing_ctx).await?; + let attribution = exec_ctx.attribution.clone().unwrap_or_else(|| { + super::service_insights_activity::RequestAttribution { + actor: super::audit_service::AuditActor { + user_id: user_id.into(), + api_key_id: exec_ctx.api_key_id.map(str::to_owned), + api_key_name: None, + ip_address: None, + user_agent: None, + }, + auth_kind: if exec_ctx.api_key_id.is_some() { + "api_key" + } else { + "unknown" + } + .into(), + oauth_client_id: None, + acting_client_id: None, + api_key_credential_id: None, + } + }); + let mut request_audit = super::service_insights_activity::RequestAudit::from_attribution( + db, + attribution, + billing_ctx.user_service_id.as_deref(), + &target.service.id, + &resource_owner_id, + &billing_ctx.billing_request_id, + billing_ctx.credential_class, + ); + let metered = billing + .open(&billing_ctx) + .await + .inspect_err(|error| request_audit.admission_error(error))?; let request_len = body.as_ref().map(|body| body.len() as i64).unwrap_or(0); // ------------------------------------------------------------------- @@ -4571,6 +4605,7 @@ pub async fn execute_tool_resolved( ) .await?; destination_audit.complete(resp.status); + request_audit.response(resp.status); return Ok(McpToolExecutionOutcome::Response(tool_response( resp.status, header_value(&resp.headers, "content-type"), @@ -4599,6 +4634,7 @@ pub async fn execute_tool_resolved( ) .await?; destination_audit.complete(status); + request_audit.response(status); return Ok(McpToolExecutionOutcome::Response(tool_response( status, header_value(&headers, "content-type"), @@ -4737,6 +4773,7 @@ pub async fn execute_tool_resolved( .await?; destination_audit.complete(status); + request_audit.response(status); Ok(McpToolExecutionOutcome::Response(ToolResponse { status, text: body_text, @@ -5571,6 +5608,7 @@ mod tests { &state.token_exchange_cache, &state.cloud_response_cache, &McpExecContext { + attribution: None, api_key_id: None, allow_all_nodes: true, allowed_node_ids: &[], diff --git a/backend/src/services/mod.rs b/backend/src/services/mod.rs index b332e0628..4ec3cfb17 100644 --- a/backend/src/services/mod.rs +++ b/backend/src/services/mod.rs @@ -192,6 +192,8 @@ pub mod channel_retry_ingress; pub mod provider_link_service; pub mod retired_service_service; pub mod service_history; +pub mod service_insights_activity; +pub mod service_insights_billing; #[cfg(test)] pub(crate) mod assistant_authority_tests; diff --git a/backend/src/services/platform_key_service/tests.rs b/backend/src/services/platform_key_service/tests.rs index 5858ffe5a..02654a80b 100644 --- a/backend/src/services/platform_key_service/tests.rs +++ b/backend/src/services/platform_key_service/tests.rs @@ -624,6 +624,7 @@ async fn platform_key_http_llm_gateway_and_mcp_use_server_credential_and_live_ac ) .unwrap(); let ctx = mcp_service::McpExecContext { + attribution: None, api_key_id: None, allow_all_nodes: true, allowed_node_ids: &[], diff --git a/backend/src/services/service_insights_activity.rs b/backend/src/services/service_insights_activity.rs new file mode 100644 index 000000000..736ba3515 --- /dev/null +++ b/backend/src/services/service_insights_activity.rs @@ -0,0 +1,896 @@ +//! Metadata-only projections of current scope grants and exact recorded requests. +use std::{ + collections::{HashMap, HashSet}, + time::Duration, +}; + +use bson::{Document, doc}; +use chrono::{DateTime, Utc}; +use futures::TryStreamExt; +use mongodb::Database; +use serde::Serialize; +use utoipa::ToSchema; + +use crate::{ + errors::{AppError, AppResult}, + models::{ + agent_service_binding::AgentServiceBinding, + api_key::{ApiKey, ApiKeyPurpose}, + audit_log::AuditLog, + user_service::UserService, + }, + mw::auth::{AuthMethod, AuthUser}, + services::{ + audit_service, key_service, + org_service::{self, OwnerAccess}, + }, +}; + +const KEY_LIMIT: usize = 500; +const REQUEST_LIMIT: i64 = 3; + +#[derive(Clone, Serialize, ToSchema)] +pub struct ConnectionActivity { + pub access: AccessSummary, + pub activity: ActivitySummary, +} + +#[derive(Clone, Serialize, ToSchema)] +pub struct AccessSummary { + /// Counts cover the visible key inventory, never other members' personal keys. + pub visibility: String, + pub keys: Vec, + pub total: usize, + pub truncated: bool, +} + +#[derive(Clone, Serialize, ToSchema)] +pub struct AccessKey { + pub id: String, + pub name: String, + pub platform: Option, + pub owner_id: String, + pub permission: String, + pub credential_override: bool, +} + +#[derive(Clone, Serialize, ToSchema)] +pub struct ActivitySummary { + pub visibility: String, + pub period_days: u32, + /// Historical events and uninstrumented ingress cannot prove a complete period. + pub tracking: String, + pub request_count: u64, + pub requests: Vec, + pub truncated: bool, +} + +#[derive(Clone, Serialize, ToSchema)] +pub struct RecentRequest { + pub id: String, + pub execution_id: Option, + pub caller: RequestCaller, + pub occurred_at: DateTime, + pub outcome: String, + pub response_status: Option, +} + +#[derive(Clone, Serialize, ToSchema)] +pub struct RequestCaller { + pub id: Option, + pub kind: String, + pub name: String, + pub app_id: Option, + pub app_name: Option, +} + +fn can_execute(access: &OwnerAccess, service: &UserService) -> bool { + access.allows_resource(&service.id) + && match access { + OwnerAccess::Direct | OwnerAccess::AsOrgAdmin { .. } => true, + OwnerAccess::AsOrgMember { role, .. } => { + super::user_service_service::role_can_proxy_service(*role, service) + } + OwnerAccess::Forbidden => false, + } +} + +fn permission(key: &ApiKey, effective_ids: &[String], service_id: &str) -> Option<&'static str> { + if key.purpose != ApiKeyPurpose::General + || !key.is_active + || key.expires_at.is_some_and(|expiry| expiry <= Utc::now()) + || !crate::mw::auth::scope_allows_llm_proxy(&key.scopes) + { + return None; + } + if key.allow_all_services { + Some("all_services") + } else if key.allowed_service_ids.iter().any(|id| id == service_id) { + Some("selected_service") + } else if effective_ids.iter().any(|id| id == service_id) { + Some("platform_services") + } else { + None + } +} + +/// The handler must apply its token's service allowlist before passing rows here. +/// This layer independently checks owner/member visibility and scopes key inventory. +pub async fn insights( + db: &Database, + actor_id: &str, + services: &[UserService], +) -> AppResult> { + if services.len() > 100 { + return Err(AppError::ValidationError( + "At most 100 connections may be requested".into(), + )); + } + let mut owner_access = HashMap::new(); + for service in services { + if !owner_access.contains_key(&service.user_id) { + owner_access.insert( + service.user_id.clone(), + org_service::resolve_owner_access(db, actor_id, &service.user_id).await?, + ); + } + } + let visible: Vec<_> = services + .iter() + .filter(|service| { + owner_access + .get(&service.user_id) + .is_some_and(|access| access.can_read() && access.allows_resource(&service.id)) + }) + .collect(); + if visible.is_empty() { + return Ok(HashMap::new()); + } + let mut key_owners = HashSet::from([actor_id.to_owned()]); + for service in &visible { + if owner_access[&service.user_id].can_write() { + key_owners.insert(service.user_id.clone()); + } + } + let mut keys: Vec = db.collection("api_keys") + .find(doc! { "user_id": { "$in": key_owners.into_iter().collect::>() }, "is_active": true, "$or": [{ "expires_at": null }, { "expires_at": { "$gt": bson::DateTime::now() } }] }) + .sort(doc! { "name": 1, "_id": 1 }) + .limit((KEY_LIMIT + 1) as i64) + .max_time(Duration::from_secs(3)) + .await?.try_collect().await?; + let keys_truncated = keys.len() > KEY_LIMIT; + keys.truncate(KEY_LIMIT); + let ids: Vec<_> = visible.iter().map(|s| s.id.as_str()).collect(); + let key_ids: Vec<_> = keys.iter().map(|k| k.id.as_str()).collect(); + let bindings: Vec = db + .collection("agent_service_bindings") + .find(doc! { "api_key_id": { "$in": key_ids }, "user_service_id": { "$in": &ids } }) + .max_time(Duration::from_secs(3)) + .await? + .try_collect() + .await?; + let overrides: HashSet<_> = bindings + .iter() + .map(|b| { + ( + b.api_key_id.as_str(), + b.user_service_id.as_str(), + b.user_id.as_str(), + ) + }) + .collect(); + // Reuse the runtime expansion, once per owner, for dynamic platform grants. + let mut platform_grants: HashMap> = HashMap::new(); + for key in &keys { + if key.allow_auto_connected_services + && !key.allow_all_services + && !platform_grants.contains_key(&key.user_id) + { + let mut grant_key = key.clone(); + grant_key.allowed_service_ids.clear(); + platform_grants.insert( + key.user_id.clone(), + key_service::effective_allowed_service_ids(db, &grant_key).await?, + ); + } + } + let mut result = HashMap::new(); + let mut managed_ids = Vec::new(); + let mut own_ids = Vec::new(); + for service in &visible { + let access = &owner_access[&service.user_id]; + let managed = access.can_write(); + if managed { + managed_ids.push(service.id.as_str()); + } else { + own_ids.push(service.id.as_str()); + } + let mut allowed = Vec::new(); + for key in &keys { + let key_access = if key.user_id == service.user_id { + Some(&OwnerAccess::Direct) + } else if key.user_id == actor_id { + Some(access) + } else { + None + }; + if !key_access.is_some_and(|a| can_execute(a, service)) { + continue; + } + let effective = platform_grants + .get(&key.user_id) + .map(Vec::as_slice) + .unwrap_or_default(); + let effective = if key.allow_auto_connected_services { + effective + } else { + &[] + }; + if let Some(reason) = permission(key, effective, &service.id) { + allowed.push(AccessKey { + id: key.id.clone(), + name: key.name.clone(), + platform: key.platform.clone(), + owner_id: key.user_id.clone(), + permission: reason.into(), + credential_override: key.user_id == service.user_id + && overrides.contains(&( + key.id.as_str(), + service.id.as_str(), + service.user_id.as_str(), + )), + }); + } + } + result.insert( + service.id.clone(), + ConnectionActivity { + access: AccessSummary { + visibility: if managed { "managed_keys" } else { "own_keys" }.into(), + total: allowed.len(), + keys: allowed, + truncated: keys_truncated, + }, + activity: ActivitySummary { + visibility: if managed { + "all_requests" + } else { + "own_requests" + } + .into(), + period_days: 30, + tracking: "partial".into(), + request_count: 0, + requests: Vec::new(), + truncated: false, + }, + }, + ); + } + // Apply privacy before aggregation: service visibility does not reveal other + // org members' callers. No legacy catalog-only event is allocated to a row. + let filter = request_filter(actor_id, &managed_ids, &own_ids); + let groups: Vec = db.collection::("audit_log").aggregate(vec![ + doc! { "$match": filter }, + doc! { "$sort": { "created_at": -1, "_id": -1 } }, + doc! { "$group": { "_id": "$event_data.user_service_id", "count": { "$sum": 1 }, "requests": { "$firstN": { "input": "$$ROOT", "n": REQUEST_LIMIT } } } }, + ]).max_time(Duration::from_secs(3)).await?.try_collect().await?; + let mut events = Vec::new(); + for group in groups { + let Some(summary) = group.get_str("_id").ok().and_then(|id| result.get_mut(id)) else { + continue; + }; + summary.activity.request_count = group + .get_i64("count") + .or_else(|_| group.get_i32("count").map(i64::from)) + .unwrap_or_default() + .max(0) as u64; + if let Ok(rows) = group.get_array("requests") { + for row in rows { + if let Ok(event) = bson::from_bson::(row.clone()) { + events.push(event); + } + } + } + summary.activity.truncated = summary.activity.request_count > REQUEST_LIMIT as u64; + } + let mut app_ids = HashSet::new(); + let mut subject_ids = HashSet::new(); + for event in &events { + if let Some(app) = event_string(event, "oauth_client_id") + .or_else(|| event_string(event, "acting_client_id")) + { + app_ids.insert(app); + } + if let Some(subject) = &event.user_id { + subject_ids.insert(subject.as_str()); + } + } + let apps = names( + db, + "oauth_clients", + "client_name", + app_ids.into_iter().collect(), + ) + .await?; + let subjects: Vec<_> = subject_ids.into_iter().collect(); + let users = names(db, "users", "display_name", subjects.clone()).await?; + let accounts = names(db, "service_accounts", "name", subjects).await?; + for event in events { + let Some(summary) = + event_string(&event, "user_service_id").and_then(|id| result.get_mut(id)) + else { + continue; + }; + summary + .activity + .requests + .push(recent_request(&event, actor_id, &apps, &users, &accounts)); + } + Ok(result) +} + +fn request_filter(actor_id: &str, managed: &[&str], own: &[&str]) -> Document { + doc! { + "event_type": "service_request", + "created_at": { "$gte": bson::DateTime::from_chrono(Utc::now() - chrono::Duration::days(30)) }, + "$or": [ + { "event_data.user_service_id": { "$in": managed } }, + { "event_data.user_service_id": { "$in": own }, "user_id": actor_id }, + ], + } +} + +async fn names( + db: &Database, + collection: &str, + field: &str, + ids: Vec<&str>, +) -> AppResult> { + if ids.is_empty() { + return Ok(HashMap::new()); + } + let rows: Vec = db + .collection::(collection) + .find(doc! { "_id": { "$in": ids } }) + .projection(doc! { "_id": 1, field: 1 }) + .max_time(Duration::from_secs(3)) + .await? + .try_collect() + .await?; + Ok(rows + .into_iter() + .filter_map(|row| { + Some(( + row.get_str("_id").ok()?.into(), + row.get_str(field).ok()?.into(), + )) + }) + .collect()) +} + +fn event_string<'a>(event: &'a AuditLog, key: &str) -> Option<&'a str> { + event.event_data.as_ref()?.get(key)?.as_str() +} + +fn recent_request( + event: &AuditLog, + viewer: &str, + apps: &HashMap, + users: &HashMap, + accounts: &HashMap, +) -> RecentRequest { + let app_id = event_string(event, "oauth_client_id") + .or_else(|| event_string(event, "acting_client_id")) + .map(str::to_owned); + let app_name = app_id.as_ref().and_then(|id| apps.get(id)).cloned(); + let subject = event.user_id.clone(); + let (kind, id, name) = if let Some(key_id) = &event.api_key_id { + ( + "agent_key", + Some(key_id.clone()), + event + .api_key_name + .clone() + .unwrap_or_else(|| "Agent key".into()), + ) + } else if event_string(event, "auth_kind") == Some("service_account") { + ( + "service_account", + subject.clone(), + subject + .as_ref() + .and_then(|id| accounts.get(id)) + .cloned() + .unwrap_or_else(|| "Service account".into()), + ) + } else if app_id.is_some() { + ( + "oauth_app", + app_id.clone(), + app_name.clone().unwrap_or_else(|| "OAuth app".into()), + ) + } else if let Some(kind @ ("session" | "access_token" | "relay" | "delegated")) = + event_string(event, "auth_kind") + { + ( + kind, + subject.clone(), + if subject.as_deref() == Some(viewer) { + "You".into() + } else { + subject + .as_ref() + .and_then(|id| users.get(id)) + .cloned() + .unwrap_or_else(|| { + if kind == "session" { + "User session" + } else { + "Authenticated user" + } + .into() + }) + }, + ) + } else { + ("unknown", subject, "Client not recorded".into()) + }; + RecentRequest { + id: event.id.clone(), + execution_id: event_string(event, "execution_id").map(str::to_owned), + caller: RequestCaller { + id, + kind: kind.into(), + name, + app_id, + app_name, + }, + occurred_at: event.created_at, + outcome: event_string(event, "outcome").unwrap_or("unknown").into(), + response_status: event + .event_data + .as_ref() + .and_then(|v| v.get("response_status")) + .and_then(|v| v.as_u64()) + .and_then(|v| u16::try_from(v).ok()), + } +} + +/// One exact connection event per resolved execution, including early returns. +/// A response header is not stream completion, so successful HTTP responses keep +/// the explicit `response_received` state and WS upgrades use `connection_opened`. +pub struct RequestAudit { + db: Database, + actor: audit_service::AuditActor, + event: serde_json::Value, +} + +#[derive(Clone)] +pub struct RequestAttribution { + pub actor: audit_service::AuditActor, + pub auth_kind: String, + pub oauth_client_id: Option, + pub acting_client_id: Option, + pub api_key_credential_id: Option, +} + +fn admission_denial_status(error: &AppError) -> Option { + match error { + AppError::InsufficientCredits + | AppError::WalletSuspended + | AppError::PlanEntitlementRequired(_) => Some(402), + AppError::Forbidden(_) + | AppError::ApiKeyScopeForbidden(_) + | AppError::OrgRoleInsufficient(_) => Some(403), + _ => None, + } +} + +impl RequestAudit { + #[allow(clippy::too_many_arguments)] + pub fn new( + db: &Database, + auth: &AuthUser, + service_id: Option<&str>, + catalog_id: &str, + owner_id: &str, + execution_id: &str, + credential_class: crate::models::usage_meter::CredentialClass, + ) -> Self { + Self::from_attribution( + db, + RequestAttribution { + actor: audit_service::AuditActor::from_auth_user(auth), + auth_kind: match auth.auth_method { + AuthMethod::Session => "session", + AuthMethod::AccessToken => "access_token", + AuthMethod::ApiKey => "api_key", + AuthMethod::ServiceAccount => "service_account", + AuthMethod::Delegated => "delegated", + AuthMethod::Relay => "relay", + } + .into(), + oauth_client_id: auth.oauth_client_id.clone(), + acting_client_id: auth.acting_client_id.clone(), + api_key_credential_id: auth.api_key_credential_id.clone(), + }, + service_id, + catalog_id, + owner_id, + execution_id, + credential_class, + ) + } + + #[allow(clippy::too_many_arguments)] + pub fn from_attribution( + db: &Database, + attribution: RequestAttribution, + service_id: Option<&str>, + catalog_id: &str, + owner_id: &str, + execution_id: &str, + credential_class: crate::models::usage_meter::CredentialClass, + ) -> Self { + Self { + db: db.clone(), + actor: attribution.actor, + event: serde_json::json!({ + "user_service_id": service_id, "service_id": catalog_id, "owner_user_id": owner_id, + "execution_id": execution_id, "billing_request_id": execution_id, + "credential_class": credential_class, "oauth_client_id": attribution.oauth_client_id, + "acting_client_id": attribution.acting_client_id, "api_key_credential_id": attribution.api_key_credential_id, + "auth_kind": attribution.auth_kind, + "outcome": "unknown", + }), + } + } + + pub fn response(&mut self, status: u16) { + self.event["response_status"] = status.into(); + self.event["outcome"] = match status { + 101 => "connection_opened", + 400..=599 => "failed", + _ => "response_received", + } + .into(); + } + + pub fn denied(&mut self, status: u16) { + self.event["response_status"] = status.into(); + self.event["outcome"] = "denied".into(); + self.event["dispatch_state"] = "not_dispatched".into(); + } + + /// Call only at admission gates before dispatch. Transient database/billing + /// failures remain unknown and must not be turned into provider failures. + pub fn admission_error(&mut self, error: &AppError) { + if let Some(status) = admission_denial_status(error) { + self.denied(status); + } + } +} + +impl Drop for RequestAudit { + fn drop(&mut self) { + if self.event["user_service_id"].is_null() { + return; + } + audit_service::log_async( + self.db.clone(), + Some(self.actor.user_id.clone()), + "service_request".into(), + Some(self.event.clone()), + self.actor.ip_address.clone(), + self.actor.user_agent.clone(), + self.actor.api_key_id.clone(), + self.actor.api_key_name.clone(), + ); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn key() -> ApiKey { + bson::from_document(doc! { + "_id": "key", "user_id": "owner", "name": "CI agent", "key_prefix": "prefix", + "key_hash": "hash", "scopes": "proxy", "is_active": true, + "created_at": bson::DateTime::now(), "allow_all_services": false, + "allowed_service_ids": ["selected"], "allow_auto_connected_services": true, + }) + .unwrap() + } + + fn event() -> AuditLog { + bson::from_document(doc! { + "_id": "event", "user_id": "owner", "event_type": "service_request", + "event_data": { "user_service_id": "connection", "execution_id": "execution", "auth_kind": "session", "outcome": "response_received", "response_status": 200 }, + "created_at": bson::DateTime::now(), + }).unwrap() + } + + #[test] + fn scope_grants_distinguish_explicit_dynamic_and_broad_permission() { + let mut key = key(); + let effective = vec!["dynamic".into()]; + assert_eq!( + permission(&key, &effective, "selected"), + Some("selected_service") + ); + assert_eq!( + permission(&key, &effective, "dynamic"), + Some("platform_services") + ); + assert_eq!(permission(&key, &effective, "other"), None); + key.allow_all_services = true; + assert_eq!(permission(&key, &effective, "other"), Some("all_services")); + key.is_active = false; + assert_eq!(permission(&key, &effective, "other"), None); + key.is_active = true; + key.expires_at = Some(Utc::now() - chrono::Duration::seconds(1)); + assert_eq!(permission(&key, &effective, "other"), None); + key.expires_at = None; + key.purpose = ApiKeyPurpose::ScheduledInvocation; + assert_eq!(permission(&key, &effective, "other"), None); + key.purpose = ApiKeyPurpose::General; + key.scopes = "read write".into(); + assert_eq!(permission(&key, &effective, "other"), None); + } + + #[test] + fn member_activity_is_actor_scoped_before_counting() { + let filter = request_filter("viewer", &["personal"], &["org"]); + let branches = filter.get_array("$or").unwrap(); + assert!(!branches[0].as_document().unwrap().contains_key("user_id")); + assert_eq!( + branches[1] + .as_document() + .unwrap() + .get_str("user_id") + .unwrap(), + "viewer" + ); + assert_eq!(filter.get_str("event_type").unwrap(), "service_request"); + assert!(!filter.to_string().contains("service_slug")); + } + + #[test] + fn caller_identity_does_not_infer_apps_or_success_from_provisioning() { + let mut event = event(); + let empty = HashMap::new(); + let own = recent_request(&event, "owner", &empty, &empty, &empty); + assert_eq!(own.caller.kind, "session"); + assert_eq!(own.caller.name, "You"); + assert_eq!(own.outcome, "response_received"); + let other = recent_request(&event, "viewer", &empty, &empty, &empty); + assert_eq!(other.caller.name, "User session"); + for kind in ["access_token", "relay", "delegated"] { + event.event_data = + Some(serde_json::json!({ "source_app_name": "Heca", "auth_kind": kind })); + let own = recent_request(&event, "owner", &empty, &empty, &empty); + assert_eq!(own.caller.kind, kind); + assert_eq!(own.caller.name, "You"); + assert_eq!(own.caller.id.as_deref(), Some("owner")); + assert!(own.caller.app_id.is_none()); + assert!(own.caller.app_name.is_none()); + assert_eq!(own.outcome, "unknown"); + let names = HashMap::from([("owner".into(), "Alicia".into())]); + let shared = recent_request(&event, "viewer", &empty, &names, &empty); + assert_eq!(shared.caller.kind, kind); + assert_eq!(shared.caller.name, "Alicia"); + } + event.event_data = Some(serde_json::json!({ "source_app_name": "Heca" })); + let unknown = recent_request(&event, "owner", &empty, &empty, &empty); + assert_eq!(unknown.caller.kind, "unknown"); + assert_eq!(unknown.caller.name, "Client not recorded"); + event.api_key_id = Some("agent".into()); + event.api_key_name = Some("Codex CI".into()); + event.event_data = + Some(serde_json::json!({ "oauth_client_id": "app", "auth_kind": "relay" })); + let apps = HashMap::from([("app".into(), "Release app".into())]); + let agent = recent_request(&event, "owner", &apps, &empty, &empty); + assert_eq!(agent.caller.kind, "agent_key"); + assert_eq!(agent.caller.name, "Codex CI"); + assert_eq!(agent.caller.app_name.as_deref(), Some("Release app")); + } + + #[test] + fn admission_failures_only_claim_denial_when_the_gate_confirms_it() { + assert_eq!( + admission_denial_status(&AppError::InsufficientCredits), + Some(402) + ); + assert_eq!( + admission_denial_status(&AppError::WalletSuspended), + Some(402) + ); + assert_eq!( + admission_denial_status(&AppError::Forbidden("policy".into())), + Some(403) + ); + assert_eq!( + admission_denial_status(&AppError::BillingProviderUnavailable("temporary".into())), + None + ); + assert_eq!( + admission_denial_status(&AppError::Internal("database".into())), + None + ); + } + + #[tokio::test] + async fn exact_connection_activity_and_key_inventory_apply_independent_org_visibility() { + use crate::{ + models::{org_membership::OrgRole, user::UserType}, + test_utils::{connect_test_database, test_membership, test_user, test_user_service}, + }; + let db = connect_test_database("service_insights_acl").await.unwrap(); + audit_service::init_audit_chain_hmac_key(zeroize::Zeroizing::new([7; 32])); + db.collection("users") + .insert_many([ + test_user("viewer", UserType::Person), + test_user("other", UserType::Person), + test_user("org", UserType::Org), + ]) + .await + .unwrap(); + let membership = test_membership("org", "viewer", OrgRole::Member, Some(vec!["a".into()])); + db.collection::("org_memberships") + .insert_one(&membership) + .await + .unwrap(); + let a = test_user_service("a", "org", "same-catalog-a", "ep", Some("catalog"), None); + let b = test_user_service("b", "org", "same-catalog-b", "ep", Some("catalog"), None); + let mut personal = key(); + personal.id = "personal-key".into(); + personal.user_id = "viewer".into(); + personal.allow_all_services = true; + let mut org = personal.clone(); + org.id = "org-key".into(); + org.user_id = "org".into(); + let mut hidden = personal.clone(); + hidden.id = "other-private-key".into(); + hidden.user_id = "other".into(); + db.collection("api_keys") + .insert_many([personal, org, hidden]) + .await + .unwrap(); + db.collection::("agent_service_bindings").insert_one(doc! { + "_id": "binding", "api_key_id": "org-key", "user_service_id": "a", "user_api_key_id": "external-secret", + "user_id": "org", "created_at": bson::DateTime::now(), "updated_at": bson::DateTime::now(), + }).await.unwrap(); + for (actor_id, connection, event_type) in [ + ("viewer", Some("a"), "service_request"), + ("other", Some("a"), "service_request"), + ("viewer", Some("b"), "service_request"), + ("viewer", None, "proxy_request"), + ] { + audit_service::log_actor_event(db.clone(), &audit_service::AuditActor { + user_id: actor_id.into(), ip_address: None, user_agent: None, + api_key_id: None, api_key_name: None, + }, event_type, Some(serde_json::json!({ + "user_service_id": connection, "service_id": "catalog", "auth_kind": "session", "outcome": "response_received", + }))).await.unwrap(); + } + let member = insights(&db, "viewer", &[a.clone(), b.clone()]) + .await + .unwrap(); + assert!(!member.contains_key("b")); + assert_eq!(member["a"].access.visibility, "own_keys"); + assert_eq!( + member["a"] + .access + .keys + .iter() + .map(|k| k.id.as_str()) + .collect::>(), + ["personal-key"] + ); + assert_eq!(member["a"].activity.visibility, "own_requests"); + assert_eq!(member["a"].activity.request_count, 1); + assert_eq!(member["a"].activity.requests[0].caller.name, "You"); + assert_eq!(member["a"].activity.tracking, "partial"); + + db.collection::("org_memberships") + .update_one( + doc! { "_id": &membership.id }, + doc! { "$set": { "role": "admin" } }, + ) + .await + .unwrap(); + let admin = insights(&db, "viewer", &[a, b]).await.unwrap(); + assert!(!admin.contains_key("b")); + assert_eq!(admin["a"].activity.request_count, 2); + assert_eq!(admin["a"].access.total, 2); + assert!( + admin["a"] + .access + .keys + .iter() + .any(|k| k.id == "org-key" && k.credential_override) + ); + assert!( + !admin["a"] + .access + .keys + .iter() + .any(|k| k.id == "other-private-key") + ); + assert!( + admin["a"] + .activity + .requests + .iter() + .any(|r| r.caller.name == "Test User") + ); + let serialized = serde_json::to_string(&admin).unwrap(); + for secret_field in ["external-secret", "key_hash", "key_prefix"] { + assert!(!serialized.contains(secret_field)); + } + } + + #[tokio::test] + async fn request_capture_keeps_exact_identity_and_billing_link_without_claiming_stream_completion() + { + use crate::{ + models::usage_meter::CredentialClass, + test_utils::{connect_test_database, test_auth_user}, + }; + let db = connect_test_database("service_request_capture") + .await + .unwrap(); + audit_service::init_audit_chain_hmac_key(zeroize::Zeroizing::new([7; 32])); + let mut auth = test_auth_user(&uuid::Uuid::new_v4().to_string()); + auth.auth_method = AuthMethod::ApiKey; + auth.api_key_id = Some("agent-key".into()); + auth.api_key_name = Some("Codex CI".into()); + auth.api_key_credential_id = Some("login-credential".into()); + auth.oauth_client_id = Some("verified-app".into()); + let mut audit = RequestAudit::new( + &db, + &auth, + Some("exact-connection"), + "catalog", + "owner", + "execution", + CredentialClass::AgentOverrideUserOwned, + ); + audit.response(200); + drop(audit); + let event = tokio::time::timeout(Duration::from_secs(3), async { + loop { + if let Some(event) = db + .collection::("audit_log") + .find_one(doc! { "event_type": "service_request" }) + .await + .unwrap() + { + break event; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .unwrap(); + let data = event.event_data.unwrap(); + assert_eq!(event.api_key_id.as_deref(), Some("agent-key")); + assert!(event.seq.is_some()); + assert_eq!(data["user_service_id"], "exact-connection"); + assert_eq!(data["service_id"], "catalog"); + assert_eq!(data["execution_id"], data["billing_request_id"]); + assert_eq!(data["oauth_client_id"], "verified-app"); + assert_eq!(data["api_key_credential_id"], "login-credential"); + assert_eq!(data["credential_class"], "agent_override_user_owned"); + assert_eq!(data["outcome"], "response_received"); + assert_eq!( + db.collection::("audit_log") + .count_documents(doc! { "event_type": "service_request" }) + .await + .unwrap(), + 1 + ); + } +} diff --git a/backend/src/services/service_insights_billing.rs b/backend/src/services/service_insights_billing.rs new file mode 100644 index 000000000..8556846ea --- /dev/null +++ b/backend/src/services/service_insights_billing.rs @@ -0,0 +1,1585 @@ +use std::collections::HashMap; + +use futures::TryStreamExt; +use mongodb::{Database, bson::doc}; +use serde::Serialize; +use utoipa::ToSchema; + +use crate::errors::{AppError, AppResult}; +use crate::models::agent_service_binding::{AgentServiceBinding, COLLECTION_NAME as BINDINGS}; +use crate::models::api_key::{ApiKey, ApiKeyPurpose, COLLECTION_NAME as AGENT_KEYS}; +use crate::models::downstream_service::{COLLECTION_NAME as CATALOG, DownstreamService}; +use crate::models::service_billing::{BillingMetric, PricingSyncStatus, ServiceBilling}; +use crate::models::usage_meter::CredentialClass; +use crate::models::user::{COLLECTION_NAME as USERS, User}; +use crate::models::user_api_key::{COLLECTION_NAME as CREDENTIALS, UserApiKey}; +use crate::models::user_endpoint::{COLLECTION_NAME as ENDPOINTS, UserEndpoint}; +use crate::models::user_service::UserService; +use crate::services::billing::{BillingIngress, BillingRouteContext, BillingService, NodeIntent}; +use crate::services::{feature_flag_service, org_service, platform_key_service, proxy_service}; + +#[derive(Clone, Copy, Debug, Serialize, ToSchema, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum BillingExplanationStatus { + Resolved, + Conditional, + Restricted, + Unavailable, +} + +#[derive(Clone, Copy, Debug, Serialize, ToSchema, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum ConnectionChargeStatus { + UsageBased, + NotCharged, + Conditional, + Restricted, + Unavailable, +} + +#[derive(Clone, Copy, Debug, Serialize, ToSchema, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum BillingAccountKind { + Personal, + Organization, +} + +#[derive(Clone, Debug, Serialize, ToSchema)] +pub struct ServiceBillingAccount { + pub id: String, + pub kind: BillingAccountKind, + pub name: String, +} + +#[derive(Clone, Debug, Serialize, ToSchema)] +pub struct ServiceBillingRate { + /// "platform" or "resale"; both are charges collected by NyxID. + pub layer: String, + pub metric: BillingMetric, + /// Exact decimal credits per one unit; absent when the plan rate is unknown. + pub credits_per_unit: Option, + pub currency: String, + /// "credential_lane", "service_price", or "legacy_plan". + pub source: String, +} + +#[derive(Clone, Copy, Debug, Serialize, ToSchema, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum ProviderBillingDisclosure { + SeparateProviderAccount, + NyxidCredential, + NoCredential, + Unknown, +} + +#[derive(Clone, Debug, Serialize, ToSchema)] +pub struct ServiceBillingExplanation { + pub status: BillingExplanationStatus, + pub credential_class: Option, + pub credential_label: String, + pub account: Option, + pub charge_status: ConnectionChargeStatus, + pub rates: Vec, + pub provider_billing: ProviderBillingDisclosure, + /// "for_you" uses the viewer's default; "agent_key" includes that key's override. + pub context: String, + pub notes: Vec, +} + +/// Metadata-only preview for rows already selected by the inventory ACL. +/// Distinct catalog, credential and account rows are loaded once per request. +/// No credential resolution, wallet provisioning, reservation or provider call +/// may be added here: opening AI Services must not execute a service. +pub async fn explain_connections( + db: &Database, + billing: &BillingService, + billing_principal_id: &str, + actor_user_id: &str, + services: &[UserService], +) -> AppResult> { + explain_connections_in_context( + db, + billing, + billing_principal_id, + actor_user_id, + services, + None, + ) + .await +} + +/// Inspect a managed agent identity without authenticating as that key or +/// granting its permissions to the viewer. The handler's service ACL remains +/// in force in addition to the key's live execution authority. +pub async fn explain_for_agent_key( + db: &Database, + billing: &BillingService, + actor_id: &str, + services: &[UserService], + api_key_id: &str, +) -> AppResult> { + if services.len() > 100 { + return Err(AppError::ValidationError( + "At most 100 connections may be requested".into(), + )); + } + let key = db + .collection::(AGENT_KEYS) + .find_one(doc! { "_id": api_key_id }) + .await? + .ok_or_else(|| AppError::NotFound("Agent key not found".into()))?; + let management_access = org_service::resolve_owner_access(db, actor_id, &key.user_id).await?; + if !management_access.can_write() { + return Err(AppError::NotFound("Agent key not found".into())); + } + let active = key.is_active + && key.purpose == ApiKeyPurpose::General + && key + .expires_at + .is_none_or(|expires| expires > chrono::Utc::now()) + && crate::mw::auth::scope_allows_llm_proxy(&key.scopes); + let effective_ids = if active { + crate::services::key_service::effective_allowed_service_ids(db, &key).await? + } else { + Vec::new() + }; + let mut selected = Vec::new(); + let mut result = HashMap::new(); + let mut viewer_accesses = HashMap::new(); + for service in services { + if !viewer_accesses.contains_key(&service.user_id) { + viewer_accesses.insert( + service.user_id.clone(), + org_service::resolve_owner_access(db, actor_id, &service.user_id).await?, + ); + } + let access = &viewer_accesses[&service.user_id]; + let explanation = if !access.can_read() || !access.allows_resource(&service.id) { + Some(restricted()) + } else if !active { + Some(unavailable( + "This agent key is inactive, expired, or lacks proxy permission.", + )) + } else if !key.allow_all_services && !effective_ids.contains(&service.id) { + Some(unavailable( + "This agent key does not have access to this connection.", + )) + } else if !key.allow_all_nodes + && service + .node_id + .as_ref() + .is_some_and(|id| !key.allowed_node_ids.contains(id)) + { + Some(unavailable( + "This agent key does not have access to the connection's node.", + )) + } else { + None + }; + if let Some(explanation) = explanation { + result.insert(service.id.clone(), explanation); + } else { + selected.push(service.clone()); + } + } + result.extend( + explain_connections_in_context( + db, + billing, + &key.user_id, + &key.user_id, + &selected, + Some(&key), + ) + .await?, + ); + for explanation in result.values_mut() { + explanation.context = "agent_key".into(); + } + Ok(result) +} + +async fn explain_connections_in_context( + db: &Database, + billing: &BillingService, + billing_principal_id: &str, + actor_user_id: &str, + services: &[UserService], + agent_key: Option<&ApiKey>, +) -> AppResult> { + if services.is_empty() { + return Ok(HashMap::new()); + } + let bindings: Vec = if let Some(key) = agent_key { + let service_ids: Vec<_> = services.iter().map(|s| s.id.as_str()).collect(); + db.collection::(BINDINGS) + .find(doc! { + "api_key_id": &key.id, "user_id": &key.user_id, + "user_service_id": { "$in": service_ids }, + }) + .await? + .try_collect() + .await? + } else { + Vec::new() + }; + let overrides: HashMap<_, _> = bindings + .iter() + .map(|b| (b.user_service_id.as_str(), b)) + .collect(); + let catalog_ids: Vec<_> = services + .iter() + .filter_map(|s| s.catalog_service_id.as_deref()) + .collect(); + let mut credential_ids: Vec<_> = services + .iter() + .filter_map(|s| s.api_key_id.as_deref()) + .collect(); + credential_ids.extend( + bindings + .iter() + .map(|binding| binding.user_api_key_id.as_str()), + ); + let endpoints: HashMap = if bindings.is_empty() { + HashMap::new() + } else { + let ids: Vec<_> = services + .iter() + .filter(|s| overrides.contains_key(s.id.as_str())) + .map(|s| s.endpoint_id.as_str()) + .collect(); + db.collection::(ENDPOINTS) + .find(doc! { "_id": { "$in": ids } }) + .await? + .try_collect::>() + .await? + .into_iter() + .map(|e| (e.id.clone(), e)) + .collect() + }; + let mut owner_ids: Vec<_> = services.iter().map(|s| s.user_id.as_str()).collect(); + owner_ids.push(billing_principal_id); + let (catalog, credentials, owners): (Vec, Vec, Vec) = tokio::try_join!( + async { + db.collection::(CATALOG) + .find(doc! { "_id": { "$in": catalog_ids } }) + .await? + .try_collect() + .await + }, + async { + db.collection::(CREDENTIALS) + .find(doc! { "_id": { "$in": credential_ids } }) + .await? + .try_collect() + .await + }, + async { + db.collection::(USERS) + .find(doc! { "_id": { "$in": owner_ids } }) + .await? + .try_collect() + .await + }, + )?; + let catalog: HashMap<_, _> = catalog.into_iter().map(|s| (s.id.clone(), s)).collect(); + let credentials: HashMap<_, _> = credentials.into_iter().map(|k| (k.id.clone(), k)).collect(); + let owners: HashMap<_, _> = owners.into_iter().map(|u| (u.id.clone(), u)).collect(); + let needs_platform_grants = services.iter().any(uses_platform_binding); + let grants = if needs_platform_grants { + Some(platform_key_service::OwnerGrants::load(db, billing_principal_id).await?) + } else { + None + }; + let providers = if needs_platform_grants { + platform_key_service::load_providers(db).await? + } else { + HashMap::new() + }; + let mut accesses = HashMap::new(); + let mut materializable = HashMap::new(); + let mut rollout = HashMap::new(); + let mut resolved_owners = HashMap::new(); + let mut explanations = HashMap::new(); + + for service in services { + if !accesses.contains_key(&service.user_id) { + accesses.insert( + service.user_id.clone(), + org_service::resolve_owner_access(db, billing_principal_id, &service.user_id) + .await?, + ); + } + let access = &accesses[&service.user_id]; + if !access.can_read() || !access.allows_resource(&service.id) { + explanations.insert(service.id.clone(), restricted()); + continue; + } + if matches!(access, org_service::OwnerAccess::AsOrgMember { role, .. } if !role.can_proxy()) + || (service.admin_only + && matches!(access, org_service::OwnerAccess::AsOrgMember { .. })) + { + explanations.insert( + service.id.clone(), + unavailable("You do not have execution access to this connection."), + ); + continue; + } + if !service.is_active || service.deleted_at.is_some() { + explanations.insert( + service.id.clone(), + unavailable("This connection is disabled."), + ); + continue; + } + let catalog_service = service + .catalog_service_id + .as_ref() + .and_then(|id| catalog.get(id)); + if service.catalog_service_id.is_some() && catalog_service.is_none() { + explanations.insert( + service.id.clone(), + unavailable("The service billing configuration is unavailable."), + ); + continue; + } + let credential = service + .api_key_id + .as_ref() + .and_then(|id| credentials.get(id)); + let mut effective_auth_method = service.auth_method.clone(); + let default_class = if uses_platform_binding(service) { + let available = catalog_service.is_some_and(|catalog| { + grants.as_ref().is_some_and(|grants| { + platform_key_service::available_with_grants( + catalog, + catalog + .provider_config_id + .as_ref() + .and_then(|id| providers.get(id)), + &service.user_id, + grants, + ) + }) + }); + if !available || service.node_id.is_some() { + explanations.insert( + service.id.clone(), + unavailable("The NyxID credential is unavailable for this connection."), + ); + continue; + } + // Explicit platform bindings can override the stored connection auth. + let Some(catalog_service) = catalog_service else { + explanations.insert( + service.id.clone(), + unavailable("The service billing configuration is unavailable."), + ); + continue; + }; + match platform_key_service::effective_auth(db, catalog_service).await { + Ok((method, _)) => { + effective_auth_method = method; + CredentialClass::NyxidManagedMaster + } + Err(AppError::ValidationError(_)) => { + explanations.insert( + service.id.clone(), + unavailable("The NyxID credential configuration is incomplete."), + ); + continue; + } + Err(error) => return Err(error), + } + } else if service.auth_method == "none" { + if service.node_id.is_some() { + CredentialClass::NodeManaged + } else { + CredentialClass::NoAuth + } + } else { + let Some(credential) = credential.filter(|key| key.user_id == service.user_id) else { + explanations.insert( + service.id.clone(), + unavailable("No connection credential is available."), + ); + continue; + }; + if !materializable.contains_key(&credential.id) { + materializable.insert( + credential.id.clone(), + proxy_service::credential_is_materializable(db, credential).await?, + ); + } + let has_server_credential = materializable[&credential.id]; + if service.node_id.is_none() + && (credential.status != "active" || !has_server_credential) + { + explanations.insert( + service.id.clone(), + unavailable( + "Reconnect or replace the connection credential before using this service.", + ), + ); + continue; + } + default_credential_class(service, credential, has_server_credential) + }; + let credential_class = if let Some(binding) = overrides.get(service.id.as_str()) { + let override_key = credentials.get(&binding.user_api_key_id).filter(|key| { + key.user_id == billing_principal_id && key.user_id == service.user_id + }); + let Some(override_key) = override_key else { + explanations.insert( + service.id.clone(), + unavailable("The agent's credential override is unavailable."), + ); + continue; + }; + if !materializable.contains_key(&override_key.id) { + materializable.insert( + override_key.id.clone(), + proxy_service::credential_is_materializable(db, override_key).await?, + ); + } + if override_key.status != "active" || !materializable[&override_key.id] { + explanations.insert( + service.id.clone(), + unavailable("Reconnect or replace this agent's credential override."), + ); + continue; + } + let target_url = if default_class == CredentialClass::NyxidManagedMaster { + catalog_service.map(|c| c.base_url.as_str()) + } else { + endpoints + .get(&service.endpoint_id) + .filter(|e| e.user_id == service.user_id) + .map(|e| e.url.as_str()) + }; + let Some(target_url) = target_url else { + explanations.insert( + service.id.clone(), + unavailable("The override's destination is unavailable."), + ); + continue; + }; + match crate::services::ifttt_oauth_service::validate_credential_route( + db, + override_key.provider_config_id.as_deref(), + &effective_auth_method, + target_url, + None, + ) + .await + { + Ok(()) => {} + Err(AppError::ValidationError(_)) => { + explanations.insert(service.id.clone(), unavailable("This credential override cannot be used with the connection's destination.")); + continue; + } + Err(error) => return Err(error), + } + // The proxy classifies a node without a default server credential + // as node-managed before considering an agent override. + if default_class == CredentialClass::NodeManaged { + default_class + } else { + CredentialClass::AgentOverrideUserOwned + } + } else { + default_class + }; + let owner_key = ( + service.user_id.clone(), + credential_class == CredentialClass::NyxidManagedMaster, + ); + if !resolved_owners.contains_key(&owner_key) { + let resolved = billing + .owner_resolver() + .resolve_for_execution(billing_principal_id, &service.user_id, credential_class) + .await?; + resolved_owners.insert(owner_key.clone(), resolved.owner_id); + } + let payer_id = &resolved_owners[&owner_key]; + let Some(payer) = owners.get(payer_id).filter(|u| u.is_active) else { + explanations.insert( + service.id.clone(), + unavailable("The billing account is unavailable."), + ); + continue; + }; + if !rollout.contains_key(payer_id) { + let enabled = billing.billing_enabled() + && feature_flag_service::billing_rollout_enabled(db, payer_id, actor_user_id) + .await?; + rollout.insert(payer_id.clone(), enabled); + } + let account = ServiceBillingAccount { + id: payer_id.clone(), + kind: if payer.user_type.is_org() { + BillingAccountKind::Organization + } else { + BillingAccountKind::Personal + }, + name: if payer.user_type.is_org() { + payer + .display_name + .clone() + .unwrap_or_else(|| "Organization".into()) + } else { + "Your personal account".into() + }, + }; + let mut explanation = project_billing( + service, + catalog_service.and_then(|s| s.billing.as_ref()), + credential_class, + account, + rollout[payer_id], + billing.resale_enabled(), + billing.lago_configured(), + ); + annotate_transport_pricing(&mut explanation, catalog_service); + if credential_class == CredentialClass::NodeManaged { + explanation.status = BillingExplanationStatus::Conditional; + explanation + .notes + .push("Uses the node credential if the node is available.".into()); + } + explanations.insert(service.id.clone(), explanation); + } + Ok(explanations) +} + +fn annotate_transport_pricing( + explanation: &mut ServiceBillingExplanation, + catalog: Option<&DownstreamService>, +) { + let Some(catalog) = catalog else { + return; + }; + if catalog + .capabilities + .as_ref() + .is_some_and(|capabilities| capabilities.supports_websocket) + && catalog + .billing + .as_ref() + .is_none_or(|billing| billing.platform_metric.is_none()) + && explanation.rates.iter().any(|rate| { + rate.layer == "platform" + && rate.source != "credential_lane" + && rate.metric != BillingMetric::Bytes + }) + { + explanation.status = BillingExplanationStatus::Conditional; + explanation.notes.push( + "Rates shown are for HTTP requests. WebSocket connections use bytes and may have a different rate.".into(), + ); + } +} + +fn uses_platform_binding(service: &UserService) -> bool { + platform_key_service::binding(service) == "platform" + && (service.auth_method != "none" + || service.credential_binding.as_deref() == Some("platform")) +} + +fn default_credential_class( + service: &UserService, + credential: &UserApiKey, + has_server_credential: bool, +) -> CredentialClass { + if service.node_id.is_some() && !has_server_credential { + CredentialClass::NodeManaged + } else if credential.credential_source.as_deref() == Some("platform") { + CredentialClass::NyxidPlatformOauthApp + } else { + CredentialClass::UserOwned + } +} + +fn restricted() -> ServiceBillingExplanation { + ServiceBillingExplanation { + status: BillingExplanationStatus::Restricted, + credential_class: None, + credential_label: "Credential restricted".into(), + account: None, + charge_status: ConnectionChargeStatus::Restricted, + rates: Vec::new(), + provider_billing: ProviderBillingDisclosure::Unknown, + context: "for_you".into(), + notes: Vec::new(), + } +} + +fn unavailable(reason: &str) -> ServiceBillingExplanation { + ServiceBillingExplanation { + status: BillingExplanationStatus::Unavailable, + credential_label: "Credential unavailable".into(), + charge_status: ConnectionChargeStatus::Unavailable, + notes: vec![reason.into()], + ..restricted() + } +} + +#[allow(clippy::too_many_arguments)] +fn project_billing( + service: &UserService, + configuration: Option<&ServiceBilling>, + credential_class: CredentialClass, + account: ServiceBillingAccount, + charging_enabled: bool, + resale_enabled: bool, + provider_configured: bool, +) -> ServiceBillingExplanation { + // UserService proxy targets use the connection slug with the catalog's + // billing block. The catalog slug is not the metering heuristic here. + let metric = configuration + .and_then(|b| b.platform_metric) + .unwrap_or_else(|| { + if service.service_type == "ssh" { + BillingMetric::Bytes + } else if service.slug.starts_with("llm-") { + BillingMetric::Tokens + } else { + BillingMetric::Requests + } + }); + let ctx = BillingRouteContext::new( + BillingIngress::Proxy, + String::new(), + account.id.clone(), + String::new(), + None, + Some(service.id.clone()), + service.catalog_service_id.clone(), + Some(service.slug.clone()), + NodeIntent::Direct, + service.auth_method.clone(), + credential_class, + metric, + configuration, + resale_enabled, + ); + let (credential_label, provider_billing) = match credential_class { + CredentialClass::NyxidManagedMaster => ( + "NyxID credential", + ProviderBillingDisclosure::NyxidCredential, + ), + CredentialClass::NyxidPlatformOauthApp => ( + "NyxID OAuth app", + ProviderBillingDisclosure::SeparateProviderAccount, + ), + CredentialClass::NodeManaged => ( + "Node credential", + ProviderBillingDisclosure::SeparateProviderAccount, + ), + CredentialClass::NoAuth => ("No credential", ProviderBillingDisclosure::NoCredential), + CredentialClass::AgentOverrideUserOwned => ( + "Credential override", + ProviderBillingDisclosure::SeparateProviderAccount, + ), + CredentialClass::UserOwned if account.kind == BillingAccountKind::Organization => ( + "Organization credential", + ProviderBillingDisclosure::SeparateProviderAccount, + ), + CredentialClass::UserOwned => ( + "Your credential", + ProviderBillingDisclosure::SeparateProviderAccount, + ), + }; + let mut result = ServiceBillingExplanation { + status: BillingExplanationStatus::Resolved, + credential_class: Some(credential_class), + credential_label: credential_label.into(), + account: Some(account), + charge_status: ConnectionChargeStatus::NotCharged, + rates: Vec::new(), + provider_billing, + context: "for_you".into(), + notes: Vec::new(), + }; + if !charging_enabled || (!ctx.service_platform_billable && ctx.resale.is_none()) { + result + .notes + .push("No NyxID usage charge applies to this caller and default credential.".into()); + return result; + } + result.charge_status = ConnectionChargeStatus::UsageBased; + if ctx.service_platform_billable { + for (metric, code) in ctx.platform_specs() { + result + .rates + .push(platform_rate(configuration, credential_class, metric, code)); + } + } + if let Some(resale) = &ctx.resale { + result.rates.push(ServiceBillingRate { + layer: "resale".into(), + metric: resale.metric, + credits_per_unit: None, + currency: "credits".into(), + source: "legacy_plan".into(), + }); + } + if result + .rates + .iter() + .any(|rate| rate.credits_per_unit.is_none()) + { + result.status = BillingExplanationStatus::Conditional; + result.notes.push( + "A legacy plan rate applies; the exact amount is determined at execution.".into(), + ); + } + if !provider_configured { + result.status = BillingExplanationStatus::Conditional; + result.charge_status = ConnectionChargeStatus::Conditional; + result.notes.push( + "Billing setup is unavailable; execution depends on the server's billing policy." + .into(), + ); + } + result.notes.push("Allowances and grants are applied before wallet credits. Actual debit is recorded after settlement.".into()); + result +} + +fn platform_rate( + configuration: Option<&ServiceBilling>, + class: CredentialClass, + metric: BillingMetric, + code: &str, +) -> ServiceBillingRate { + let lane = configuration + .and_then(|b| match class { + CredentialClass::NyxidManagedMaster => b.platform_key_pricing.as_ref(), + CredentialClass::NoAuth => None, + _ => b.byok_pricing.as_ref(), + }) + .filter(|l| l.sync_status == PricingSyncStatus::Synced); + let lane_price = lane.and_then(|l| { + if l.lago_metric_code == code { + Some(l.credits_per_unit.clone()) + } else { + l.components + .iter() + .find(|c| c.sync_status == PricingSyncStatus::Synced && c.lago_metric_code == code) + .map(|c| c.credits_per_unit.clone()) + } + }); + let service_price = configuration + .and_then(|b| b.platform_pricing.as_ref()) + .filter(|p| p.sync_status == PricingSyncStatus::Synced && p.lago_metric_code == code) + .map(|p| p.credits_per_unit.clone()); + let source = if lane_price.is_some() { + "credential_lane" + } else if service_price.is_some() { + "service_price" + } else { + "legacy_plan" + }; + ServiceBillingRate { + layer: "platform".into(), + metric, + credits_per_unit: lane_price.or(service_price), + currency: "credits".into(), + source: source.into(), + } +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use super::*; + use crate::models::org_membership::{COLLECTION_NAME as MEMBERSHIPS, OrgMembership, OrgRole}; + use crate::models::service_billing::{LanePriceComponent, LanePricing, ServicePlatformPricing}; + use crate::models::user::UserType; + use crate::test_utils::{ + connect_test_database, test_app_config, test_membership, test_user, test_user_service, + }; + + fn connection() -> UserService { + bson::from_document(doc! { + "_id": "connection", "user_id": "owner", "slug": "openai-work", + "endpoint_id": "endpoint", "api_key_id": "credential", + "auth_method": "bearer", "auth_key_name": "Authorization", + "is_active": true, "created_at": bson::DateTime::now(), + "updated_at": bson::DateTime::now(), + }) + .unwrap() + } + + fn account(kind: BillingAccountKind) -> ServiceBillingAccount { + ServiceBillingAccount { + id: "owner".into(), + kind, + name: "Account".into(), + } + } + + fn lane(metric: BillingMetric, price: &str, code: &str) -> LanePricing { + LanePricing { + metric, + credits_per_unit: price.into(), + lago_metric_code: code.into(), + sync_status: PricingSyncStatus::Synced, + sync_error: None, + components: Vec::new(), + } + } + + fn explain(config: &ServiceBilling, class: CredentialClass) -> ServiceBillingExplanation { + project_billing( + &connection(), + Some(config), + class, + account(BillingAccountKind::Personal), + true, + true, + true, + ) + } + + #[test] + fn platform_credential_and_personal_payer_are_independent() { + let config = ServiceBilling { + platform_key_pricing: Some(lane(BillingMetric::InputTokens, "0.000000125", "pk")), + ..Default::default() + }; + let result = explain(&config, CredentialClass::NyxidManagedMaster); + assert_eq!(result.credential_label, "NyxID credential"); + assert_eq!(result.account.unwrap().kind, BillingAccountKind::Personal); + assert_eq!(result.charge_status, ConnectionChargeStatus::UsageBased); + assert_eq!( + result.rates[0].credits_per_unit.as_deref(), + Some("0.000000125") + ); + assert_eq!(result.rates[0].metric, BillingMetric::InputTokens); + assert_eq!( + result.provider_billing, + ProviderBillingDisclosure::NyxidCredential + ); + } + + #[test] + fn shared_oauth_and_credential_override_use_own_key_prices() { + let config = ServiceBilling { + byok_pricing: Some(lane(BillingMetric::Requests, "2", "byok")), + platform_key_pricing: Some(lane(BillingMetric::Tokens, "3", "pk")), + ..Default::default() + }; + for class in [ + CredentialClass::UserOwned, + CredentialClass::AgentOverrideUserOwned, + CredentialClass::NyxidPlatformOauthApp, + CredentialClass::NodeManaged, + ] { + let result = explain(&config, class); + assert_eq!(result.rates[0].credits_per_unit.as_deref(), Some("2")); + assert_eq!( + result.provider_billing, + ProviderBillingDisclosure::SeparateProviderAccount + ); + } + } + + #[test] + fn missing_selected_lane_is_only_free_when_lane_configuration_confirms_it() { + let config = ServiceBilling { + platform_billable: true, + platform_key_pricing: Some(lane(BillingMetric::Tokens, "3", "pk")), + ..Default::default() + }; + assert_eq!( + explain(&config, CredentialClass::UserOwned).charge_status, + ConnectionChargeStatus::NotCharged + ); + let legacy = ServiceBilling { + platform_billable: true, + ..Default::default() + }; + let result = explain(&legacy, CredentialClass::UserOwned); + assert_eq!(result.charge_status, ConnectionChargeStatus::UsageBased); + assert_eq!(result.status, BillingExplanationStatus::Conditional); + assert_eq!(result.rates.len(), 1); + assert_eq!(result.rates[0].credits_per_unit, None); + } + + #[test] + fn unsynced_primary_uses_legacy_rate_and_excludes_components() { + let mut unsynced = lane(BillingMetric::InputTokens, "2", "byok"); + unsynced.sync_status = PricingSyncStatus::Pending; + unsynced.components.push(LanePriceComponent { + metric: BillingMetric::OutputTokens, + credits_per_unit: "4".into(), + lago_metric_code: "byok_output".into(), + sync_status: PricingSyncStatus::Synced, + sync_error: None, + }); + let config = ServiceBilling { + platform_billable: true, + platform_pricing: Some(ServicePlatformPricing { + credits_per_unit: "7".into(), + lago_metric_code: "legacy".into(), + sync_status: PricingSyncStatus::Synced, + sync_error: None, + }), + byok_pricing: Some(unsynced), + ..Default::default() + }; + let result = explain(&config, CredentialClass::UserOwned); + assert_eq!(result.rates.len(), 1); + assert_eq!(result.rates[0].credits_per_unit.as_deref(), Some("7")); + assert_eq!(result.rates[0].source, "service_price"); + assert_eq!(result.rates[0].metric, BillingMetric::Requests); + } + + #[test] + fn only_synced_components_are_exposed_as_effective_rates() { + let mut pricing = lane(BillingMetric::InputTokens, "1", "primary"); + for (metric, status) in [ + (BillingMetric::OutputTokens, PricingSyncStatus::Synced), + (BillingMetric::CacheReadTokens, PricingSyncStatus::Pending), + (BillingMetric::CacheWriteTokens, PricingSyncStatus::Failed), + ] { + pricing.components.push(LanePriceComponent { + metric, + credits_per_unit: "2".into(), + lago_metric_code: metric.as_str().into(), + sync_status: status, + sync_error: None, + }); + } + let config = ServiceBilling { + byok_pricing: Some(pricing), + ..Default::default() + }; + let result = explain(&config, CredentialClass::UserOwned); + assert_eq!(result.rates.len(), 2); + assert_eq!(result.rates[1].metric, BillingMetric::OutputTokens); + assert_eq!(result.rates[1].credits_per_unit.as_deref(), Some("2")); + } + + #[test] + fn charge_restriction_overrides_a_synced_own_key_lane() { + let config = ServiceBilling { + platform_charge_nyxid_credentials_only: true, + byok_pricing: Some(lane(BillingMetric::Requests, "1", "byok")), + ..Default::default() + }; + for class in [ + CredentialClass::UserOwned, + CredentialClass::AgentOverrideUserOwned, + CredentialClass::NodeManaged, + ] { + assert_eq!( + explain(&config, class).charge_status, + ConnectionChargeStatus::NotCharged + ); + } + assert_eq!( + explain(&config, CredentialClass::NyxidPlatformOauthApp).charge_status, + ConnectionChargeStatus::UsageBased + ); + } + + #[test] + fn rollout_or_global_disable_suppresses_both_charge_layers() { + let config = ServiceBilling { + platform_billable: true, + resale_billable: true, + lago_resale_metric_code: Some("resale".into()), + ..Default::default() + }; + let result = project_billing( + &connection(), + Some(&config), + CredentialClass::NyxidManagedMaster, + account(BillingAccountKind::Personal), + false, + true, + true, + ); + assert_eq!(result.charge_status, ConnectionChargeStatus::NotCharged); + assert!(result.rates.is_empty()); + } + + #[test] + fn resale_is_independent_of_the_platform_price_lane() { + let config = ServiceBilling { + resale_billable: true, + lago_resale_metric_code: Some("resale".into()), + ..Default::default() + }; + let master = explain(&config, CredentialClass::NyxidManagedMaster); + assert_eq!(master.charge_status, ConnectionChargeStatus::UsageBased); + assert_eq!(master.rates[0].layer, "resale"); + assert_eq!(master.rates[0].credits_per_unit, None); + assert_eq!( + explain(&config, CredentialClass::UserOwned).charge_status, + ConnectionChargeStatus::NotCharged + ); + } + + #[test] + fn billing_provider_missing_is_conditional_not_free() { + let config = ServiceBilling { + byok_pricing: Some(lane(BillingMetric::Requests, "1", "byok")), + ..Default::default() + }; + let result = project_billing( + &connection(), + Some(&config), + CredentialClass::UserOwned, + account(BillingAccountKind::Organization), + true, + true, + false, + ); + assert_eq!( + result.account.unwrap().kind, + BillingAccountKind::Organization + ); + assert_eq!(result.charge_status, ConnectionChargeStatus::Conditional); + assert_eq!(result.credential_label, "Organization credential"); + assert_eq!(result.rates[0].credits_per_unit.as_deref(), Some("1")); + } + + #[test] + fn no_auth_does_not_erase_legacy_opt_in_billing() { + let legacy = ServiceBilling { + platform_billable: true, + ..Default::default() + }; + assert_eq!( + explain(&legacy, CredentialClass::NoAuth).charge_status, + ConnectionChargeStatus::UsageBased + ); + let lanes = ServiceBilling { + platform_billable: true, + byok_pricing: Some(lane(BillingMetric::Requests, "1", "byok")), + ..Default::default() + }; + assert_eq!( + explain(&lanes, CredentialClass::NoAuth).charge_status, + ConnectionChargeStatus::NotCharged + ); + } + + #[test] + fn restricted_and_unavailable_never_masquerade_as_free_or_publish_an_account() { + for result in [restricted(), unavailable("Credential unavailable")] { + assert!(result.account.is_none()); + assert!(result.credential_class.is_none()); + assert!(result.rates.is_empty()); + assert_ne!(result.charge_status, ConnectionChargeStatus::NotCharged); + } + } + + #[test] + fn websocket_fallback_pricing_is_explicitly_conditional_but_fixed_lanes_are_not() { + let mut catalog = crate::models::downstream_service::test_helpers::dummy_service(); + catalog.capabilities = Some(crate::models::downstream_service::ServiceCapabilities { + supports_websocket: true, + ..Default::default() + }); + let mut config = ServiceBilling { + platform_billable: true, + platform_pricing: Some(ServicePlatformPricing { + credits_per_unit: "2".into(), + lago_metric_code: "service".into(), + sync_status: PricingSyncStatus::Synced, + sync_error: None, + }), + ..Default::default() + }; + catalog.billing = Some(config.clone()); + let mut fallback = explain(&config, CredentialClass::UserOwned); + assert_eq!(fallback.status, BillingExplanationStatus::Resolved); + annotate_transport_pricing(&mut fallback, Some(&catalog)); + assert_eq!(fallback.status, BillingExplanationStatus::Conditional); + assert!(fallback.notes.iter().any(|note| note.contains("WebSocket"))); + assert_eq!(fallback.rates[0].metric, BillingMetric::Requests); + + config.platform_metric = Some(BillingMetric::Requests); + catalog.billing = Some(config.clone()); + let mut fixed_metric = explain(&config, CredentialClass::UserOwned); + annotate_transport_pricing(&mut fixed_metric, Some(&catalog)); + assert_eq!(fixed_metric.status, BillingExplanationStatus::Resolved); + + config.platform_metric = None; + config.byok_pricing = Some(lane(BillingMetric::InputTokens, "1", "byok")); + catalog.billing = Some(config.clone()); + let mut fixed_lane = explain(&config, CredentialClass::UserOwned); + annotate_transport_pricing(&mut fixed_lane, Some(&catalog)); + assert_eq!(fixed_lane.status, BillingExplanationStatus::Resolved); + assert!( + !fixed_lane + .notes + .iter() + .any(|note| note.contains("WebSocket")) + ); + } + + fn stored_credential(id: &str, owner: &str) -> UserApiKey { + bson::from_document(doc! { + "_id": id, "user_id": owner, "label": "External credential", + "credential_type": "api_key", "status": "active", + // Intentionally not decryptable: this projection must only inspect metadata. + "credential_encrypted": bson::Binary { + subtype: bson::spec::BinarySubtype::Generic, bytes: vec![1, 2, 3], + }, + "created_at": bson::DateTime::now(), "updated_at": bson::DateTime::now(), + }) + .unwrap() + } + + async fn seed_accounts(db: &Database, membership: OrgMembership) { + db.collection::(USERS) + .insert_many([ + test_user("person", UserType::Person), + test_user("org", UserType::Org), + ]) + .await + .unwrap(); + db.collection::(MEMBERSHIPS) + .insert_one(membership) + .await + .unwrap(); + } + + #[tokio::test] + async fn database_preview_resolves_default_personal_org_and_platform_payers_without_writes() { + let db = connect_test_database("insights_billing_payers") + .await + .expect("database"); + seed_accounts(&db, test_membership("org", "person", OrgRole::Member, None)).await; + let mut catalog = crate::models::downstream_service::test_helpers::dummy_service(); + catalog.auth_method = "bearer".into(); + catalog.auth_key_name = "Authorization".into(); + catalog.service_category = "internal".into(); + catalog.credential_encrypted = vec![1, 2, 3]; + db.collection::(CATALOG) + .insert_one(&catalog) + .await + .unwrap(); + db.collection::(CREDENTIALS) + .insert_many([ + stored_credential("personal-key", "person"), + stored_credential("org-key", "org"), + ]) + .await + .unwrap(); + let mut personal = test_user_service( + "personal", + "person", + "personal", + "endpoint", + Some(&catalog.id), + None, + ); + personal.auth_method = "bearer".into(); + personal.api_key_id = Some("personal-key".into()); + let mut organization = test_user_service( + "organization", + "org", + "org-service", + "endpoint", + Some(&catalog.id), + None, + ); + organization.auth_method = "bearer".into(); + organization.api_key_id = Some("org-key".into()); + let mut platform = organization.clone(); + platform.id = "platform".into(); + platform.api_key_id = None; + platform.credential_binding = Some("platform".into()); + let billing = BillingService::new(db.clone(), Arc::new(test_app_config())); + let result = explain_connections( + &db, + &billing, + "person", + "person", + &[personal, organization, platform], + ) + .await + .unwrap(); + + let personal = &result["personal"]; + assert_eq!(personal.credential_class, Some(CredentialClass::UserOwned)); + assert_eq!(personal.account.as_ref().unwrap().id, "person"); + assert_eq!( + personal.account.as_ref().unwrap().kind, + BillingAccountKind::Personal + ); + let organization = &result["organization"]; + assert_eq!( + organization.credential_class, + Some(CredentialClass::UserOwned) + ); + assert_eq!(organization.account.as_ref().unwrap().id, "org"); + assert_eq!( + organization.account.as_ref().unwrap().kind, + BillingAccountKind::Organization + ); + let platform = &result["platform"]; + assert_eq!( + platform.credential_class, + Some(CredentialClass::NyxidManagedMaster) + ); + assert_eq!( + platform.account.as_ref().unwrap().id, + "person", + "org-visible platform credential bills the caller" + ); + assert_eq!( + platform.account.as_ref().unwrap().kind, + BillingAccountKind::Personal + ); + assert!( + result + .values() + .all(|r| r.charge_status == ConnectionChargeStatus::NotCharged) + ); + for collection in [ + crate::models::billing_wallet::COLLECTION_NAME, + crate::models::usage_meter::COLLECTION_NAME, + ] { + assert_eq!( + db.collection::(collection) + .count_documents(doc! {}) + .await + .unwrap(), + 0 + ); + } + let keys: Vec = db + .collection::(CREDENTIALS) + .find(doc! {}) + .await + .unwrap() + .try_collect() + .await + .unwrap(); + assert!(keys.iter().all(|key| key.last_used_at.is_none())); + db.drop().await.unwrap(); + } + + #[tokio::test] + async fn database_missing_or_invalid_credentials_cannot_publish_a_payer_or_free_state() { + let db = connect_test_database("insights_billing_missing") + .await + .expect("database"); + db.collection::(USERS) + .insert_one(test_user("person", UserType::Person)) + .await + .unwrap(); + let mut empty = stored_credential("empty", "person"); + empty.credential_encrypted = None; + let mut revoked = stored_credential("revoked", "person"); + revoked.status = "revoked".into(); + db.collection::(CREDENTIALS) + .insert_many([empty, revoked, stored_credential("foreign", "someone-else")]) + .await + .unwrap(); + let services: Vec<_> = ["missing", "empty", "revoked", "foreign"] + .into_iter() + .map(|id| { + let mut service = test_user_service(id, "person", id, "endpoint", None, None); + service.auth_method = "bearer".into(); + service.api_key_id = Some(id.into()); + service + }) + .collect(); + let billing = BillingService::new(db.clone(), Arc::new(test_app_config())); + let result = explain_connections(&db, &billing, "person", "person", &services) + .await + .unwrap(); + assert_eq!(result.len(), 4); + for explanation in result.values() { + assert_eq!(explanation.status, BillingExplanationStatus::Unavailable); + assert_eq!( + explanation.charge_status, + ConnectionChargeStatus::Unavailable + ); + assert!(explanation.account.is_none()); + assert!(explanation.rates.is_empty()); + } + db.drop().await.unwrap(); + } + + #[tokio::test] + async fn database_scope_and_viewer_restrictions_hide_billing_accounts() { + let db = connect_test_database("insights_billing_scope") + .await + .expect("database"); + seed_accounts( + &db, + test_membership( + "org", + "person", + OrgRole::Viewer, + Some(vec!["visible".into()]), + ), + ) + .await; + let services = [ + test_user_service("visible", "org", "visible", "endpoint", None, None), + test_user_service("hidden", "org", "hidden", "endpoint", None, None), + ]; + let billing = BillingService::new(db.clone(), Arc::new(test_app_config())); + let result = explain_connections(&db, &billing, "person", "person", &services) + .await + .unwrap(); + assert_eq!( + result["visible"].status, + BillingExplanationStatus::Unavailable + ); + assert_eq!( + result["hidden"].status, + BillingExplanationStatus::Restricted + ); + assert!( + result + .values() + .all(|r| r.account.is_none() && r.rates.is_empty()) + ); + db.drop().await.unwrap(); + } + + fn stored_agent_key(owner: &str) -> ApiKey { + bson::from_document(doc! { + "_id": "agent", "user_id": owner, "name": "Build agent", + "key_prefix": "nyxid_ag_test", "key_hash": "test-hash", + "scopes": "proxy", "is_active": true, "created_at": bson::DateTime::now(), + "allow_all_services": false, "allowed_service_ids": ["service"], + }) + .unwrap() + } + + #[tokio::test] + async fn agent_preview_uses_selected_principal_and_final_override_price_lane() { + let db = connect_test_database("insights_billing_agent") + .await + .expect("database"); + seed_accounts(&db, test_membership("org", "person", OrgRole::Admin, None)).await; + let mut catalog = crate::models::downstream_service::test_helpers::dummy_service(); + catalog.auth_method = "bearer".into(); + catalog.auth_key_name = "Authorization".into(); + catalog.service_category = "internal".into(); + catalog.credential_encrypted = vec![1, 2, 3]; + catalog.billing = Some(ServiceBilling { + byok_pricing: Some(lane(BillingMetric::Requests, "2", "byok")), + platform_key_pricing: Some(lane(BillingMetric::Requests, "5", "platform")), + ..Default::default() + }); + db.collection::(CATALOG) + .insert_one(&catalog) + .await + .unwrap(); + let mut service = test_user_service( + "service", + "org", + "platform", + "endpoint", + Some(&catalog.id), + None, + ); + service.auth_method = "bearer".into(); + service.credential_binding = Some("platform".into()); + db.collection::(crate::models::user_service::COLLECTION_NAME) + .insert_one(&service) + .await + .unwrap(); + db.collection::(AGENT_KEYS) + .insert_one(stored_agent_key("org")) + .await + .unwrap(); + let services = [service]; + let mut config = test_app_config(); + config.billing_enabled = true; + let billing = BillingService::new(db.clone(), Arc::new(config)); + let default = explain_connections(&db, &billing, "person", "person", &services) + .await + .unwrap(); + assert_eq!(default["service"].account.as_ref().unwrap().id, "person"); + assert_eq!( + default["service"].rates[0].credits_per_unit.as_deref(), + Some("5") + ); + let selected = explain_for_agent_key(&db, &billing, "person", &services, "agent") + .await + .unwrap(); + assert_eq!(selected["service"].account.as_ref().unwrap().id, "org"); + assert_eq!( + selected["service"].credential_class, + Some(CredentialClass::NyxidManagedMaster) + ); + assert_eq!(selected["service"].context, "agent_key"); + + db.collection::(CREDENTIALS) + .insert_one(stored_credential("override", "org")) + .await + .unwrap(); + let now = chrono::Utc::now(); + db.collection::(BINDINGS) + .insert_one(AgentServiceBinding { + id: "binding".into(), + api_key_id: "agent".into(), + user_service_id: "service".into(), + user_api_key_id: "override".into(), + user_id: "org".into(), + created_at: now, + updated_at: now, + }) + .await + .unwrap(); + let selected = explain_for_agent_key(&db, &billing, "person", &services, "agent") + .await + .unwrap(); + let selected = &selected["service"]; + assert_eq!(selected.account.as_ref().unwrap().id, "org"); + assert_eq!( + selected.account.as_ref().unwrap().kind, + BillingAccountKind::Organization + ); + assert_eq!( + selected.credential_class, + Some(CredentialClass::AgentOverrideUserOwned) + ); + assert_eq!(selected.credential_label, "Credential override"); + assert_eq!(selected.rates[0].credits_per_unit.as_deref(), Some("2")); + assert!( + db.collection::(CREDENTIALS) + .find_one(doc! { "_id": "override" }) + .await + .unwrap() + .unwrap() + .last_used_at + .is_none() + ); + + // A configured override never grants access by itself. + db.collection::(AGENT_KEYS) + .update_one( + doc! { "_id": "agent" }, + doc! { "$set": { "allowed_service_ids": [] } }, + ) + .await + .unwrap(); + let denied = explain_for_agent_key(&db, &billing, "person", &services, "agent") + .await + .unwrap(); + assert_eq!( + denied["service"].status, + BillingExplanationStatus::Unavailable + ); + assert!(denied["service"].account.is_none()); + + // The same runtime auto-connected expansion admits an explicit platform binding. + db.collection::(AGENT_KEYS) + .update_one( + doc! { "_id": "agent" }, + doc! { "$set": { "allow_auto_connected_services": true } }, + ) + .await + .unwrap(); + let permitted = explain_for_agent_key(&db, &billing, "person", &services, "agent") + .await + .unwrap(); + assert_eq!( + permitted["service"].credential_class, + Some(CredentialClass::AgentOverrideUserOwned) + ); + + // Invalid overrides fail closed instead of pretending the default key was selected. + db.collection::(CREDENTIALS) + .update_one( + doc! { "_id": "override" }, + doc! { "$set": { "status": "revoked" } }, + ) + .await + .unwrap(); + let invalid = explain_for_agent_key(&db, &billing, "person", &services, "agent") + .await + .unwrap(); + assert_eq!( + invalid["service"].status, + BillingExplanationStatus::Unavailable + ); + assert!(invalid["service"].account.is_none()); + db.drop().await.unwrap(); + } + + #[tokio::test] + async fn agent_preview_preserves_key_management_lifecycle_and_service_scope_acl() { + let db = connect_test_database("insights_billing_agent_acl") + .await + .expect("database"); + seed_accounts(&db, test_membership("org", "person", OrgRole::Member, None)).await; + db.collection::(AGENT_KEYS) + .insert_one(stored_agent_key("org")) + .await + .unwrap(); + let services = [test_user_service( + "service", "org", "service", "endpoint", None, None, + )]; + let billing = BillingService::new(db.clone(), Arc::new(test_app_config())); + assert!(matches!( + explain_for_agent_key(&db, &billing, "person", &services, "agent").await, + Err(AppError::NotFound(_)) + )); + db.collection::(MEMBERSHIPS) + .update_one( + doc! { "org_user_id": "org", "member_user_id": "person" }, + doc! { "$set": { "role": "admin" } }, + ) + .await + .unwrap(); + for changes in [ + doc! { "is_active": false }, + doc! { "is_active": true, "expires_at": bson::DateTime::from_chrono(chrono::Utc::now() - chrono::Duration::minutes(1)) }, + doc! { "expires_at": null, "purpose": "scheduled_invocation" }, + doc! { "purpose": "general", "scopes": "read" }, + ] { + db.collection::(AGENT_KEYS) + .update_one(doc! { "_id": "agent" }, doc! { "$set": changes }) + .await + .unwrap(); + let result = explain_for_agent_key(&db, &billing, "person", &services, "agent") + .await + .unwrap(); + assert_eq!( + result["service"].status, + BillingExplanationStatus::Unavailable + ); + assert!(result["service"].account.is_none()); + } + db.collection::(AGENT_KEYS) + .update_one( + doc! { "_id": "agent" }, + doc! { "$set": { "scopes": "proxy", "allow_all_services": true } }, + ) + .await + .unwrap(); + db.collection::(MEMBERSHIPS) + .update_one( + doc! { "org_user_id": "org", "member_user_id": "person" }, + doc! { "$set": { "allowed_service_ids": [] } }, + ) + .await + .unwrap(); + let result = explain_for_agent_key(&db, &billing, "person", &services, "agent") + .await + .unwrap(); + assert_eq!( + result["service"].status, + BillingExplanationStatus::Restricted + ); + assert!(result["service"].account.is_none()); + db.drop().await.unwrap(); + } +} diff --git a/docs/AI_SERVICES_ARCHITECTURE.md b/docs/AI_SERVICES_ARCHITECTURE.md index 452a76599..240777d88 100644 --- a/docs/AI_SERVICES_ARCHITECTURE.md +++ b/docs/AI_SERVICES_ARCHITECTURE.md @@ -700,3 +700,52 @@ including archived history, under the active-owner and current-membership checks in [SERVICE_HISTORY.md](SERVICE_HISTORY.md). This is independent of proxy-use permission. Last editor and credential-preparation time are never labeled last caller or successful upstream execution. + +### Billing and caller information inside service cards + +Collapsed cards show credential sources, the latest recorded request, and a billing +summary for their active connections. The expanded comparison table exposes +**Owner / credential**, **Access & requests**, and **Billing** alongside identity, +state, and configuration. Each connection can open one inline panel: permitted +agent keys and credential overrides, its latest three recorded requests, or payer +and rates. Users do not need to open an individual connection page to inspect +these fields. Billing's **For** selector previews the viewer or a managed agent +key, including an applicable credential override. + +`GET /api/v1/service-insights?ids=` returns up to 100 exact +connection projections; optional `api_key_id` selects a managed agent's billing +context. It deliberately lives outside `/keys/{id_or_slug}` so no user slug is +reserved. Sessions, access tokens, and delegated account reads use existing live +owner/membership/resource checks. API keys, service accounts, and relay tokens +cannot enumerate the management projection. Responses are private and not cached +by HTTP intermediaries; a failed frontend refresh drops privileged summaries. + +Billing is a read-only explanation built from the execution owner resolver, +credential class, rollout flags, and pricing configuration. A NyxID credential +does not imply NyxID pays: the resolved account is displayed separately. Synced +rates retain exact decimal values. Unknown legacy prices remain conditional; +missing data is never represented as free. Provider charges may be separate, and +the preview is not a settled debit or proof that a provider credential works. +Inspecting the page does not decrypt credentials, reserve credits, or call providers. + +Agent access describes current scope grants, not use. Members see their own key +inventory and requests; scoped organization admins can also inspect organization +keys and permitted connection activity. Other members' private keys are excluded. +Request counts and the latest three events cover 30 days, with the privacy filter +applied before aggregation. Requests use verified auth identities; an agent's +platform label or the service's provisioning application cannot establish which +application executed a request. + +The `service_request` event is appended through the existing audit hash chain for +resolved HTTP proxy, node, streaming, WebSocket, LLM, and MCP requests. It records +the exact `user_service_id` and a server-generated execution ID shared with the +billing request, plus verified caller and application identities when available. +Response received and connection opened are distinct from completed streams; +explicit admission denials are recorded as denied, and unclassified early exits +remain unknown. Historical and uninstrumented events keep coverage partial. +Catalog IDs and timestamps are never used to allocate old activity to duplicates. + +Raw usage meters also retain the exact connection ID. Charged hourly and daily +rollups retain it in existing cost-partition metadata, preserving established +bucket/replay identities during mixed-version deployment. These metadata additions +do not change pricing, settlement, or the routing policy. diff --git a/docs/plans/local-routing-preview.md b/docs/plans/local-routing-preview.md index bceeec4b4..956667d32 100644 --- a/docs/plans/local-routing-preview.md +++ b/docs/plans/local-routing-preview.md @@ -249,3 +249,31 @@ hiding/restoring Saved views and footer, and selecting organization pills while stuck. Shared data-table controls retain their normal wrapping layout. Production build, TypeScript, targeted ESLint and whitespace checks passed. The preview is serving the changes; no browser is connected for visual verification. + +## Inline billing and caller insights — 29 September 2026 + +Cards now show Sources, Latest request (or Your latest), and Billing. Expanding a +card shows a connection table with separate Access & requests and Billing columns. +The inline panels list permitted agent keys and overrides, the latest three exact +requests in 30 days, and the billing account with applicable rates. Billing's For +selector compares the viewer's default with a managed agent key. Configuration +and permitted change history retain their existing access boundaries. + +The implementation includes `GET /api/v1/service-insights` and exact request +attribution in the HTTP proxy, both LLM routes, and MCP. The endpoint is allowed +through the local preview's read-only gateway. Its production backend must deploy +this endpoint before these fields populate: an older server shows Not reported, +with an explanatory inline message. It never fabricates payer or caller data. +Older request history remains partial after deployment. Rates are current billing +previews; settled transaction history is not added by this revision. + +Open `http://127.0.0.1:4317/keys?view=routing` to review the running frontend, or +`http://127.0.0.1:4317/__routing-preview/login` for a fresh preview login. Browser +automation was unavailable in this session, so signed-in visual review remains +outstanding. + +Validation: 103 focused frontend tests and 30 backend tests passed, including +real MongoDB privacy, payer/override, exact request capture, metering, and rollup +checks. TypeScript, the production frontend build, Rust formatting, targeted ESLint, +and diff whitespace checks passed. Full ESLint has zero errors and 27 existing +warnings. The committed CLI wizard source-closure hash remains current. diff --git a/frontend/dev/routing-preview.ts b/frontend/dev/routing-preview.ts index 97686128f..836039303 100644 --- a/frontend/dev/routing-preview.ts +++ b/frontend/dev/routing-preview.ts @@ -6,6 +6,7 @@ import type { Plugin } from "vite"; const READ_PATHS = new Set([ "/api/v1/users/me", "/api/v1/keys", + "/api/v1/service-insights", "/api/v1/api-keys", "/api/v1/user-services", "/api/v1/service-pools", @@ -21,10 +22,11 @@ const READ_PATHS = new Set([ const UUID_SEGMENT = "[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}"; const DETAIL_PATH = new RegExp(`^/api/v1/(?:keys|nodes)/${UUID_SEGMENT}$`); +const AGENT_METADATA_PATH = new RegExp(`^/api/v1/api-keys/${UUID_SEGMENT}(?:/(?:bindings|usage))?$`); const HISTORY_PATH = new RegExp(`^/api/v1/keys/${UUID_SEGMENT}/history$`); function isMetadataPath(path: string) { - return READ_PATHS.has(path) || DETAIL_PATH.test(path) || HISTORY_PATH.test(path) || /^\/api\/v1\/catalog\/[a-z0-9][a-z0-9-]*$/.test(path); + return READ_PATHS.has(path) || AGENT_METADATA_PATH.test(path) || DETAIL_PATH.test(path) || HISTORY_PATH.test(path) || /^\/api\/v1\/catalog\/[a-z0-9][a-z0-9-]*$/.test(path); } function cookie(req: IncomingMessage, name: string): string | undefined { diff --git a/frontend/src/components/dashboard/grouped-service-cards.tsx b/frontend/src/components/dashboard/grouped-service-cards.tsx index fa357d0b2..1fa27d7f0 100644 --- a/frontend/src/components/dashboard/grouped-service-cards.tsx +++ b/frontend/src/components/dashboard/grouped-service-cards.tsx @@ -7,7 +7,7 @@ import { type ReactNode, type RefObject, } from "react"; -import { ChevronRight } from "lucide-react"; +import { ChevronRight, CreditCard, UsersRound } from "lucide-react"; import { useServiceView } from "@/hooks/use-service-view"; import { useServiceCardTransition } from "@/hooks/use-service-card-transition"; import { ServiceViewToolbar } from "./service-view-toolbar"; @@ -26,22 +26,34 @@ import { groupServiceConnections, type ServiceConnectionGroup, } from "@/lib/service-groups"; +import { + useServiceInsights, + type ServiceInsightsState, +} from "@/hooks/use-service-insights"; +import { + callerLabel, + summarizeBilling, + insightStatusLabel, +} from "@/lib/service-insights"; +import { formatRelativeTime } from "@/lib/utils"; import type { CatalogEntry, KeyInfo } from "@/types/keys"; function GroupCard({ group, expanded, onToggle, - search, + insights, connections, + search, renderConnectionActions, filtersRef, }: { readonly group: ServiceConnectionGroup; readonly expanded: boolean; readonly onToggle: (card: HTMLElement | null) => void; - readonly search: string; + readonly insights: ServiceInsightsState; readonly connections: readonly KeyInfo[]; + readonly search: string; readonly renderConnectionActions?: (key: KeyInfo) => ReactNode; readonly filtersRef: RefObject; }) { @@ -112,13 +124,31 @@ function GroupCard({ ).values(), ]; const disabled = connections.filter((key) => !key.is_active).length; - const matches = search - ? connections.filter((key) => - [key.label, key.slug, sourceLabel(key)].some((value) => - value.toLowerCase().includes(search), - ), - ) - : []; + const connectionInsights = connections.map((key) => + insights.connections.get(key.id), + ); + const recent = connectionInsights + .flatMap((item) => item?.usage?.activity.requests ?? []) + .sort((a, b) => b.occurred_at.localeCompare(a.occurred_at))[0]; + const billingSummary = + insights.status !== "ready" + ? insightStatusLabel(insights.status, "Billing") + : summarizeBilling( + connections + .filter((key) => key.is_active) + .map((key) => insights.connections.get(key.id)), + ); + const ownRequests = connectionInsights.every( + (item) => item?.usage?.activity.visibility === "own_requests", + ); + const callerSummary = + insights.status !== "ready" + ? insightStatusLabel(insights.status, "Activity") + : recent + ? `${callerLabel(recent.caller)} · ${formatRelativeTime(recent.occurred_at)}` + : connectionInsights.every((item) => item?.usage) + ? "No recorded requests · 30d" + : "Activity not reported"; return (
source.name).join(" · ")} > - Sources + Sources {sources.map((source, index) => ( ))}

- {matches.length > 0 && ( + + + {search.trim() && (

key.label).join(", ")} + className="truncate text-[11px] text-muted-foreground" + title={connections.map((key) => key.label).join(" · ")} > - Matches: {matches.map((key) => key.label).join(", ")} + Matches: {connections.map((key) => key.label).join(" · ")}

)}

@@ -262,6 +329,7 @@ function GroupCard({
@@ -330,12 +398,12 @@ export function GroupedServiceCards({ }; }, [view.accountId]); const { filters, expanded } = view; - const needle = filters.search.trim().toLowerCase(); const groups = groupServiceConnections(keys, catalog); const visible = groups .map((group) => ({ group, matches: matchingConnections(group, filters) })) .filter(({ matches }) => matches.length > 0); const matchingKeys = visible.flatMap(({ matches }) => matches); + const insights = useServiceInsights(renderTable ? [] : keys); return (
@@ -373,8 +441,9 @@ export function GroupedServiceCards({ key={group.id} group={group} expanded={expanded.includes(group.id)} - search={needle} + insights={insights} connections={matches} + search={filters.search} onToggle={(card) => animateCards( () => diff --git a/frontend/src/components/dashboard/service-connection-table.tsx b/frontend/src/components/dashboard/service-connection-table.tsx index 3a7e35dda..2bf2053c4 100644 --- a/frontend/src/components/dashboard/service-connection-table.tsx +++ b/frontend/src/components/dashboard/service-connection-table.tsx @@ -6,6 +6,8 @@ import { History, LockKeyhole, Settings2, + UsersRound, + CreditCard, } from "lucide-react"; import { Badge } from "@/components/ui/badge"; import { ServiceIcon } from "@/components/service-icon"; @@ -24,6 +26,25 @@ import { canEditConnection } from "@/lib/connection-access"; import { classifyConnection } from "@/lib/service-routing-preview"; import { cn, formatDate, formatDateTime } from "@/lib/utils"; import type { KeyInfo } from "@/types/keys"; +import type { ServiceInsight } from "@/schemas/service-insights"; +import { + useServiceInsights, + type ServiceInsightsState, +} from "@/hooks/use-service-insights"; +import { + ConnectionInsightPanel, + type InsightPanel, +} from "./service-insight-panels"; +import { + billingAccountLabel, + credentialLabel, + rateLabel, + callerLabel, + callerKindLabel, + outcomeLabel, + insightStatusLabel, +} from "@/lib/service-insights"; +import { formatRelativeTime } from "@/lib/utils"; const authNames: Record = { bearer: "Bearer", @@ -45,14 +66,19 @@ function target(key: KeyInfo): string { function ConnectionMetadata({ connection: key, + insight, }: { readonly connection: KeyInfo; + readonly insight?: ServiceInsight; }) { const editable = canEditConnection(key); const rows = [ ["Created", formatDateTime(key.created_at)], ["Added via", key.source_app_name || "Not recorded"], - ["Last caller", "Not reported"], + [ + "Last caller", + insight?.usage?.activity.requests[0]?.caller.name || "Not recorded", + ], [ "Credential prepared", key.last_used_at ? formatDateTime(key.last_used_at) : "Not reported", @@ -99,18 +125,21 @@ export function ServiceConnectionTable({ serviceName, renderActions, onViewHistory, + insights: suppliedInsights, }: { readonly connections: readonly KeyInfo[]; readonly serviceName: string; readonly renderActions?: (connection: KeyInfo) => ReactNode; readonly onViewHistory?: (connection: KeyInfo) => void; + readonly insights?: ServiceInsightsState; }) { const [open, setOpen] = useState<{ id: string; - view: "details" | "history"; + view: "details" | "history" | InsightPanel; } | null>(null); const [observedAt] = useState(Date.now); - const toggle = (id: string, view: "details" | "history") => + const insights = useServiceInsights(connections, suppliedInsights); + const toggle = (id: string, view: "details" | "history" | InsightPanel) => setOpen((current) => current?.id === id && current.view === view ? null : { id, view }, ); @@ -123,25 +152,29 @@ export function ServiceConnectionTable({ > - + Connection / Slug - Classification + Owner / Credential - - Status + + Access & requests - - Configuration + + Billing - - Activity + + Configuration {connections.map((key) => { + const insight = insights.connections.get(key.id); + const billing = insight?.billing; + const usage = insight?.usage; + const latest = usage?.activity.requests[0]; const source = connectionSource(key); const org = key.credential_source?.type === "org" @@ -234,6 +267,30 @@ export function ServiceConnectionTable({ {key.streaming_supported ? " · Streaming" : ""} {key.websocket_supported ? " · WebSocket" : ""} +
+ + {readiness.reason} + +

+ {key.is_active ? "Enabled" : "Disabled"} ·{" "} + {key.status.replaceAll("_", " ")} +

+ {key.expires_at && ( +

+ Expires {formatDate(key.expires_at)} +

+ )} + {editable && renderActions?.(key)} +
@@ -253,35 +310,107 @@ export function ServiceConnectionTable({ ? "Platform managed" : "Personal owner"}

- {org && source === "platform" && ( -

- Platform credential -

- )} +

+ {credentialLabel(key, billing)} +

- toggle(key.id, "access")} + aria-expanded={expanded && open.view === "access"} + aria-controls={panelId} + aria-label={`Agent key access for ${key.label}`} + className="inline-flex items-center gap-1.5 rounded-sm text-xs font-medium text-primary hover:underline focus-visible:outline-2 focus-visible:outline-ring" > - {readiness.reason} - -

- {key.is_active ? "Enabled" : "Disabled"} ·{" "} - {key.status.replaceAll("_", " ")} -

- {key.expires_at && ( -

- Expires {formatDate(key.expires_at)} + + {usage + ? `${usage.access.keys.length}${usage.access.truncated ? "+" : ""} agent ${usage.access.keys.length === 1 ? "key" : "keys"}` + : insightStatusLabel(insights.status, "Access")} + + {usage && ( +

+ {usage.access.visibility === "own_keys" + ? "Your keys with access" + : "Managed keys with access"} + {usage.access.keys.some( + (agent) => agent.credential_override, + ) && + ` · ${usage.access.keys.filter((agent) => agent.credential_override).length} overrides`}

)} - {editable && renderActions?.(key)} + +
+ + {editable ? ( @@ -323,10 +452,8 @@ export function ServiceConnectionTable({ {key.auto_connected ? "Platform managed" : "Editors only"}

)} -
-

{open.view === "history" ? ( + ) : open.view === "details" ? ( + ) : ( - + )} diff --git a/frontend/src/components/dashboard/service-insight-panels.tsx b/frontend/src/components/dashboard/service-insight-panels.tsx new file mode 100644 index 000000000..d76e1f426 --- /dev/null +++ b/frontend/src/components/dashboard/service-insight-panels.tsx @@ -0,0 +1,406 @@ +import { useState } from "react"; +import { Link } from "@tanstack/react-router"; +import { ArrowUpRight, Bot, CreditCard, UsersRound } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { + Select, + SelectTrigger, + SelectValue, + SelectContent, + SelectItem, +} from "@/components/ui/select"; +import { metricLabel } from "@/schemas/billing-metrics"; +import type { ServiceInsight } from "@/schemas/service-insights"; +import { + useServiceInsights, + type ServiceInsightsState, +} from "@/hooks/use-service-insights"; +import { + accessReasonLabel, + billingAccountLabel, + callerKindLabel, + callerLabel, + credentialLabel, + outcomeLabel, +} from "@/lib/service-insights"; +import { formatDateTime } from "@/lib/utils"; +import type { KeyInfo } from "@/types/keys"; + +export type InsightPanel = "access" | "requests" | "billing"; + +export function InsightsUnavailable({ + state, +}: { + readonly state: ServiceInsightsState; +}) { + return ( +

+

+ {state.status === "loading" + ? "Loading billing and caller information…" + : state.status === "restricted" + ? "You do not have permission to view these connection insights." + : state.status === "unavailable" + ? "This server does not provide connection billing and caller insights yet." + : "Connection insights could not be loaded."} +

+ {state.status !== "loading" && ( + + )} +
+ ); +} + +function ConnectionBillingPanel({ + connection, + insight, + state, +}: { + readonly connection: KeyInfo; + readonly insight: ServiceInsight; + readonly state: ServiceInsightsState; +}) { + const [caller, setCaller] = useState("you"); + const selectedState = useServiceInsights( + [connection], + caller === "you" ? state : undefined, + caller === "you" ? undefined : caller, + ); + const bill = + caller === "you" + ? insight.billing + : selectedState.connections.get(connection.id)?.billing; + return ( +
+
+

+ Billing resolution +

+
+ For + +
+
+ {bill ? ( + <> +
+
+
Credential
+
+ {credentialLabel(connection, bill)} +
+
+
+
Billing account
+
{billingAccountLabel(bill)}
+
+
+
NyxID charges
+
+ {bill.charge_status === "not_charged" + ? "Not charged by NyxID" + : bill.charge_status === "usage_based" + ? "Based on metered usage" + : bill.charge_status === "restricted" + ? "Restricted" + : "Determined at execution"} +
+
+
+ {!!bill.rates.length && ( +
+ + + + + + + + + + {bill.rates.map((rate, i) => ( + + + + + + ))} + +
ChargeUnit + Credits per unit +
+ {rate.layer === "resale" + ? "Provider usage through NyxID" + : "NyxID usage"} + + {metricLabel(rate.metric, 1)} + + {rate.credits_per_unit ?? "Plan rate not reported"} +
+
+ )} + {bill.provider_billing === "separate_provider_account" && ( +

+ Your provider may charge its own account separately. Provider + charges are not included in these NyxID rates. +

+ )} + {bill.provider_billing === "nyxid_credential" && ( +

+ NyxID supplies this credential. The billing account above pays the + applicable NyxID charges. +

+ )} + {bill.notes.map((note) => ( +

+ {note} +

+ ))} + + ) : ( + + )} +
+ ); +} + +export function ConnectionInsightPanel({ + connection, + insight, + view, + state, +}: { + readonly connection: KeyInfo; + readonly insight?: ServiceInsight; + readonly view: InsightPanel; + readonly state: ServiceInsightsState; +}) { + const [showAllKeys, setShowAllKeys] = useState(false); + if (!insight || (view === "billing" ? !insight.billing : !insight.usage)) + return ; + const usage = insight.usage; + if (view === "billing") + return ( + + ); + if (!usage) return ; + if (view === "access") + return ( +
+
+

+ Agent keys with + access +

+ + {usage.access.visibility === "own_keys" + ? "Your keys only" + : "Keys you manage"}{" "} + · current scope + +
+ {usage.access.keys.length ? ( +
+ + + + + + + + + + {(showAllKeys + ? usage.access.keys + : usage.access.keys.slice(0, 3) + ).map((key) => ( + + + + + + ))} + +
Agent keyAccess throughCredential
+ + + {key.name} + + + {key.platform && ( + + {key.platform} + + )} + + {accessReasonLabel(key.permission)} + + {key.credential_override ? ( + Credential override + ) : ( + "Connection default" + )} +
+
+ ) : ( +

+ No agent keys in your permitted inventory currently have scope + access to this connection. +

+ )} + {usage.access.keys.length > 3 && ( + + )} + {usage.access.truncated && ( +

+ Showing a limited set of keys. Open Agent keys to review the full + inventory. +

+ )} +

+ Scope access does not prove a working connection or previous use. Open + a key to manage its service scope or credential override. +

+
+ ); + return ( +
+
+

Recent requests

+ + {usage.activity.visibility === "own_requests" + ? "Your requests" + : "Visible requests"}{" "} + · {usage.activity.period_days} days + +
+ {usage.activity.requests.length ? ( +
+ + + + + + + + + + + {usage.activity.requests.map((request) => ( + + + + + + + ))} + +
CallerType / applicationTimeOutcome
+ {callerLabel(request.caller)} + + {callerKindLabel(request.caller.kind)} + {request.caller.kind !== "session" && ( + + {request.caller.app_name ?? + (request.caller.app_id + ? "Application name not recorded" + : "Application not recorded")} + + )} + + + + {outcomeLabel(request.outcome)} + {request.response_status != null && ( + + · {request.response_status} + + )} +
+
+ ) : ( +

+ No requests with exact connection attribution were recorded in this + period. +

+ )} + {usage.activity.request_count > 0 && ( +

+ {usage.activity.request_count.toLocaleString()} recorded + {usage.activity.request_count === 1 ? " request" : " requests"} + {usage.activity.truncated + ? ` · showing the latest ${usage.activity.requests.length}` + : " in this period"} +

+ )} +

+ {usage.activity.tracking === "partial" + ? "Tracking is partial. Older requests and unsupported request paths may not identify this connection. " + : ""} + A shared agent key identifies the key, not every application using it. A + received response does not confirm stream completion or a settled + charge. +

+
+ ); +} diff --git a/frontend/src/components/dashboard/service-insights.test.tsx b/frontend/src/components/dashboard/service-insights.test.tsx new file mode 100644 index 000000000..1c1574e98 --- /dev/null +++ b/frontend/src/components/dashboard/service-insights.test.tsx @@ -0,0 +1,366 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { + render, + screen, + within, + cleanup, + waitFor, +} from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { ReactNode } from "react"; +import type { KeyInfo } from "@/types/keys"; +import type { ServiceInsight } from "@/schemas/service-insights"; +import type { ServiceInsightsState } from "@/hooks/use-service-insights"; +import { billingAccountLabel, summarizeBilling } from "@/lib/service-insights"; +import { api } from "@/lib/api-client"; +import { ServiceConnectionTable } from "./service-connection-table"; + +vi.mock("@tanstack/react-router", () => ({ + Link: ({ + children, + to, + params, + ...props + }: { + children: ReactNode; + to: string; + params?: { keyId: string }; + "aria-label"?: string; + }) => ( + + {children} + + ), +})); +vi.mock("@/stores/auth-store", () => ({ + useAuthStore: (selector: (state: { user: { id: string } }) => unknown) => + selector({ user: { id: "person" } }), +})); +vi.mock("./service-history", () => ({ + ServiceHistory: () =>
Permitted service history
, +})); + +const connection = { + id: "connection-a", + label: "Team OpenAI", + slug: "openai-team", + catalog_service_id: "openai", + catalog_service_slug: "openai", + catalog_service_name: "OpenAI", + service_type: "http", + is_active: true, + status: "active", + auth_method: "bearer", + credential_type: "api_key", + node_id: null, + auto_connected: false, + can_edit_configuration: false, + credential_binding: "platform", + credential_source: { + type: "org", + org_id: "org", + org_name: "ChronoAI", + role: "member", + allowed: true, + }, + endpoint_url: "https://private.example.test", + endpoint_id: "endpoint", + auth_key_name: "Authorization", + node_priority: 0, + expires_at: null, + last_used_at: null, + error_message: null, + ssh_host: null, + ssh_port: null, + ssh_ca_public_key: null, + ssh_allowed_principals: null, + ssh_certificate_ttl_minutes: null, + created_at: "2026-09-01T00:00:00Z", + source_app_name: "Provisioning app", + ws_frame_injections: [], +} as KeyInfo; +const insight: ServiceInsight = { + service_id: connection.id, + billing: { + status: "resolved", + credential_class: "nyxid_managed_master", + credential_label: "NyxID credential", + account: { id: "person", kind: "personal", name: "Your personal account" }, + charge_status: "usage_based", + rates: [ + { + layer: "platform", + metric: "requests", + credits_per_unit: "0.25", + currency: "credits", + source: "credential_lane", + }, + ], + provider_billing: "nyxid_credential", + context: "for_you", + notes: [], + }, + usage: { + access: { + visibility: "own_keys", + keys: [ + { + id: "agent-1", + name: "Codex CI", + platform: "codex", + owner_id: "person", + permission: "selected_service", + credential_override: false, + }, + { + id: "agent-2", + name: "Unused worker", + platform: null, + owner_id: "person", + permission: "all_services", + credential_override: true, + }, + ], + truncated: false, + }, + activity: { + visibility: "own_requests", + period_days: 30, + tracking: "partial", + request_count: 1, + requests: [ + { + id: "event-1", + execution_id: "execution-1", + caller: { + id: "agent-1", + kind: "agent_key", + name: "Codex CI", + app_id: "app", + app_name: "Release app", + }, + occurred_at: "2026-09-29T00:00:00Z", + outcome: "response_received", + response_status: 200, + }, + ], + truncated: false, + }, + }, +}; +function mount( + value: ServiceInsight | undefined = insight, + status: ServiceInsightsState["status"] = "ready", +) { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + return render( + + + , + ); +} +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); +}); + +describe("service card billing and caller details", () => { + it("exposes payer, credential and actual last caller directly in the expanded table", () => { + mount(); + expect(screen.getByRole("columnheader", { name: "Billing" })).toBeVisible(); + expect(screen.getByText("Personal account")).toBeVisible(); + expect(screen.getByText("NyxID credential")).toBeVisible(); + expect(screen.getByText("Codex CI")).toBeVisible(); + expect(screen.getByText("0.25 credits / request")).toBeVisible(); + expect( + screen.getByText(/Your keys with access · 1 overrides/), + ).toBeVisible(); + expect(screen.queryByText("Provisioning app")).not.toBeInTheDocument(); + expect( + screen.queryByText("https://private.example.test"), + ).not.toBeInTheDocument(); + }); + it("opens rates inside the table and replaces them with access and request attribution", async () => { + const user = userEvent.setup(); + mount(); + await user.click( + screen.getByRole("button", { name: "Billing for Team OpenAI" }), + ); + expect( + screen.getByRole("table", { name: "Applicable NyxID rates" }), + ).toBeVisible(); + expect(screen.getByText(/NyxID supplies this credential/)).toBeVisible(); + await user.click( + screen.getByRole("button", { name: "Agent key access for Team OpenAI" }), + ); + expect( + screen.queryByRole("table", { name: "Applicable NyxID rates" }), + ).not.toBeInTheDocument(); + expect(screen.getByRole("link", { name: "Codex CI" })).toHaveAttribute( + "href", + "/keys/api-key/agent-1", + ); + expect(screen.getByText("Unused worker")).toBeVisible(); + expect(screen.getByText("Credential override")).toBeVisible(); + await user.click( + screen.getByRole("button", { name: "Recent requests for Team OpenAI" }), + ); + const requests = screen.getByRole("table", { + name: "Recent connection requests", + }); + expect(within(requests).getByText("Release app")).toBeVisible(); + expect(within(requests).getByText("Agent key")).toBeVisible(); + expect(screen.queryByText("Unused worker")).not.toBeInTheDocument(); + expect(screen.getByText(/Tracking is partial/)).toBeVisible(); + }); + it("keeps history available to a reader while private configuration stays hidden", async () => { + mount(); + await userEvent.click( + screen.getByRole("button", { + name: "History for Team OpenAI (ChronoAI)", + }), + ); + expect(screen.getByText("Permitted service history")).toBeVisible(); + expect( + screen.queryByRole("link", { name: /Configure/ }), + ).not.toBeInTheDocument(); + }); + it("resolves the selected agent key's payer in place without retaining the default payer while loading", async () => { + const user = userEvent.setup(); + let resolvePreview!: (value: unknown) => void; + const request = vi.spyOn(api, "get").mockImplementation( + () => + new Promise((resolve) => { + resolvePreview = resolve; + }), + ); + mount(); + await user.click( + screen.getByRole("button", { name: "Billing for Team OpenAI" }), + ); + const panel = screen.getByRole("region", { + name: "Billing for Team OpenAI", + }); + await user.click( + within(panel).getByRole("combobox", { name: "Preview billing for" }), + ); + await user.click( + screen.getByRole("option", { name: "Codex CI · agent key" }), + ); + await waitFor(() => + expect(request).toHaveBeenCalledWith( + "/service-insights?ids=connection-a&api_key_id=agent-1", + ), + ); + expect( + within(panel).queryByText("Personal account"), + ).not.toBeInTheDocument(); + expect(within(panel).getByText(/Loading billing/)).toBeVisible(); + resolvePreview({ + connections: [ + { + ...insight, + billing: { + ...insight.billing!, + account: { id: "org", name: "ChronoAI", kind: "organization" }, + context: "agent_key", + }, + }, + ], + }); + expect( + await within(panel).findByText("ChronoAI · organization"), + ).toBeVisible(); + await user.click( + within(panel).getByRole("combobox", { name: "Preview billing for" }), + ); + await user.click( + screen.getByRole("option", { name: "You · connection default" }), + ); + expect(within(panel).getByText("Personal account")).toBeVisible(); + expect( + within(panel).queryByText("ChronoAI · organization"), + ).not.toBeInTheDocument(); + }); + it("shows server compatibility failures instead of claiming free service or no usage", async () => { + mount({ ...insight, billing: null, usage: null }, "unavailable"); + expect(screen.getByText("Billing not reported")).toBeVisible(); + expect(screen.getByText("Activity not reported")).toBeVisible(); + expect(screen.queryByText(/free|never used/i)).not.toBeInTheDocument(); + await userEvent.click( + screen.getByRole("button", { name: "Billing for Team OpenAI" }), + ); + expect(screen.getByText(/This server does not provide/)).toBeVisible(); + }); + it.each([ + ["restricted", "Activity restricted", "Billing restricted"], + ["error", "Activity couldn't load", "Billing couldn't load"], + ["loading", "Loading activity…", "Loading billing…"], + ] as const)( + "distinguishes %s insights from an empty history", + (status, activity, billing) => { + mount({ ...insight, billing: null, usage: null }, status); + expect(screen.getByText(activity)).toBeVisible(); + expect(screen.getByText(billing)).toBeVisible(); + expect( + screen.queryByText(/No recorded requests/), + ).not.toBeInTheDocument(); + }, + ); + it("labels an empty captured period without claiming that the service was never used", () => { + mount({ + ...insight, + usage: { + ...insight.usage!, + activity: { + ...insight.usage!.activity, + requests: [], + request_count: 0, + }, + }, + }); + expect(screen.getByText("No recorded requests · 30d")).toBeVisible(); + expect(screen.queryByText("Activity not reported")).not.toBeInTheDocument(); + }); + it("does not merge different payer accounts or hide a restricted connection in the group summary", () => { + const org = { + ...insight, + billing: { + ...insight.billing!, + account: { id: "org", name: "ChronoAI", kind: "organization" as const }, + }, + }; + expect(summarizeBilling([insight, org])).toBe("Varies by connection"); + expect( + summarizeBilling([ + insight, + { ...org, billing: { ...org.billing, status: "unavailable" } }, + ]), + ).toBe("Varies by connection"); + expect(summarizeBilling([insight, undefined])).toBe("Billing not reported"); + expect( + billingAccountLabel({ + ...insight.billing!, + status: "restricted", + charge_status: "restricted", + }), + ).toBe("Billing restricted"); + expect( + billingAccountLabel({ ...insight.billing!, status: "unavailable" }), + ).toBe("Billing unavailable"); + }); +}); diff --git a/frontend/src/components/dashboard/service-routing-preview.test.tsx b/frontend/src/components/dashboard/service-routing-preview.test.tsx index 68f8763db..702f91a6e 100644 --- a/frontend/src/components/dashboard/service-routing-preview.test.tsx +++ b/frontend/src/components/dashboard/service-routing-preview.test.tsx @@ -115,6 +115,13 @@ import ServiceRoutingPreview from "./service-routing-preview"; import { useServiceCardView } from "@/stores/service-card-view-store"; import ServicePoolRoutingPreview from "./service-pool-routing-preview"; +vi.mock("@/hooks/use-service-insights", () => ({ + useServiceInsights: () => ({ + connections: new Map(), + status: "unavailable", + refresh: vi.fn(), + }), +})); vi.mock("@/hooks/use-nodes", () => ({ useNodes: () => ({ data: [] }) })); function renderConnection(candidate: RoutingCandidate) { return {candidate.key.label}; @@ -436,9 +443,9 @@ describe("live grouped services", () => { inline: "nearest", }); for (const name of [ - "Classification", - "Status", - "Activity", + "Owner / Credential", + "Access & requests", + "Billing", "Connection / Slug", "Configuration", ]) { @@ -533,7 +540,7 @@ describe("live grouped services", () => { screen.getByRole("textbox", { name: "Search services and connections" }), "Team account{Enter}", ); - expect(screen.getByText("Matches: Team account")).toBeVisible(); + expect(screen.getByText("1 of 2 match")).toBeVisible(); expect(screen.getByText("1 of 2 connections")).toBeVisible(); await user.click( screen.getByRole("button", { name: "Expand OpenAI connections" }), diff --git a/frontend/src/hooks/use-agent-bindings.ts b/frontend/src/hooks/use-agent-bindings.ts index cc9c457ef..7b8c22f1c 100644 --- a/frontend/src/hooks/use-agent-bindings.ts +++ b/frontend/src/hooks/use-agent-bindings.ts @@ -38,6 +38,7 @@ export function useCreateBinding() { ); }, onSuccess: (_data, variables) => { + void queryClient.invalidateQueries({ queryKey: ["keys", "insights"] }); void queryClient.invalidateQueries({ queryKey: ["agent-bindings", variables.keyId], }); @@ -63,6 +64,7 @@ export function useDeleteBinding() { return api.delete(`/api-keys/${keyId}/bindings/${bindingId}`); }, onSuccess: (_data, variables) => { + void queryClient.invalidateQueries({ queryKey: ["keys", "insights"] }); void queryClient.invalidateQueries({ queryKey: ["agent-bindings", variables.keyId], }); diff --git a/frontend/src/hooks/use-api-keys.ts b/frontend/src/hooks/use-api-keys.ts index 0bf80c4dc..21837a873 100644 --- a/frontend/src/hooks/use-api-keys.ts +++ b/frontend/src/hooks/use-api-keys.ts @@ -239,6 +239,7 @@ export function useCreateApiKey() { return api.post("/api-keys", payload); }, onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: ["keys", "insights"] }); void queryClient.invalidateQueries({ predicate: (q) => Array.isArray(q.queryKey) && q.queryKey[0] === "api-keys", @@ -272,6 +273,7 @@ export function useUpdateApiKey() { return api.put(`/api-keys/${keyId}`, body); }, onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: ["keys", "insights"] }); void queryClient.invalidateQueries({ predicate: (q) => Array.isArray(q.queryKey) && q.queryKey[0] === "api-keys", @@ -288,6 +290,7 @@ export function useDeleteApiKey() { return api.delete(`/api-keys/${id}`); }, onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: ["keys", "insights"] }); // Invalidate both the personal scope and every org-scope cache. // `predicate` catches keys like `["api-keys", "org", ]` that // `useAllAdminedApiKeys` populates lazily. @@ -307,6 +310,7 @@ export function useRotateApiKey() { return api.post(`/api-keys/${id}/rotate`); }, onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: ["keys", "insights"] }); void queryClient.invalidateQueries({ predicate: (q) => Array.isArray(q.queryKey) && q.queryKey[0] === "api-keys", diff --git a/frontend/src/hooks/use-service-insights.ts b/frontend/src/hooks/use-service-insights.ts new file mode 100644 index 000000000..f84033ac1 --- /dev/null +++ b/frontend/src/hooks/use-service-insights.ts @@ -0,0 +1,72 @@ +import { useQuery } from "@tanstack/react-query"; +import { api, ApiError } from "@/lib/api-client"; +import { useAuthStore } from "@/stores/auth-store"; +import { + serviceInsightsResponseSchema, + type ServiceInsight, +} from "@/schemas/service-insights"; +import type { KeyInfo } from "@/types/keys"; + +export interface ServiceInsightsState { + readonly connections: ReadonlyMap; + readonly status: "loading" | "ready" | "unavailable" | "restricted" | "error"; + readonly refresh: () => void; +} + +export function useServiceInsights( + connections: readonly KeyInfo[], + supplied?: ServiceInsightsState, + apiKeyId?: string, +): ServiceInsightsState { + const identity = useAuthStore((state) => state.user?.id); + const ids = [ + ...new Set(connections.map((connection) => connection.id)), + ].sort(); + const query = useQuery({ + queryKey: ["keys", "insights", identity, ids, apiKeyId ?? null], + enabled: !supplied && !!identity && ids.length > 0, + queryFn: async () => { + const batches = Array.from( + { length: Math.ceil(ids.length / 100) }, + (_, i) => ids.slice(i * 100, (i + 1) * 100), + ); + const results = await Promise.all( + batches.map(async (batch) => { + const response = await api.get( + `/service-insights?ids=${encodeURIComponent(batch.join(","))}${apiKeyId ? `&api_key_id=${encodeURIComponent(apiKeyId)}` : ""}`, + ); + return serviceInsightsResponseSchema.parse(response).connections; + }), + ); + return results.flat(); + }, + retry: false, + staleTime: 30_000, + refetchOnWindowFocus: true, + }); + if (supplied) return supplied; + const unavailable = + query.error instanceof ApiError && + [404, 405, 501].includes(query.error.status); + return { + // Never retain privileged summaries after an authorization/network failure. + connections: new Map( + query.isError + ? [] + : (query.data ?? []).map((item) => [item.service_id, item]), + ), + status: + query.error instanceof ApiError && query.error.status === 403 + ? "restricted" + : query.isError + ? unavailable + ? "unavailable" + : "error" + : query.isPending && ids.length + ? "loading" + : "ready", + refresh: () => { + void query.refetch(); + }, + }; +} diff --git a/frontend/src/lib/service-insights.ts b/frontend/src/lib/service-insights.ts new file mode 100644 index 000000000..fac8fc352 --- /dev/null +++ b/frontend/src/lib/service-insights.ts @@ -0,0 +1,139 @@ +import { metricLabel } from "@/schemas/billing-metrics"; +import type { + ServiceBillingExplanation, + ServiceCaller, + ServiceInsight, +} from "@/schemas/service-insights"; +import type { KeyInfo } from "@/types/keys"; +import type { ServiceInsightsState } from "@/hooks/use-service-insights"; + +export function insightStatusLabel( + state: ServiceInsightsState["status"], + field: "Access" | "Activity" | "Billing", +): string { + if (state === "loading") return `Loading ${field.toLowerCase()}…`; + if (state === "restricted") return `${field} restricted`; + if (state === "error") return `${field} couldn't load`; + return `${field} not reported`; +} + +export function credentialLabel( + connection: KeyInfo, + billing?: ServiceBillingExplanation | null, +): string { + if (billing) return billing.credential_label; + if (connection.credential_binding === "platform") return "NyxID credential"; + if (connection.node_id || connection.has_node_binding) + return "Node credential"; + if (connection.auth_method === "none") return "No credential"; + if (connection.credential_source?.type === "org") + return "Organization credential"; + return "Your credential"; +} + +export function billingAccountLabel( + billing?: ServiceBillingExplanation | null, +): string { + if (!billing) return "Billing not reported"; + if (billing.status === "restricted") return "Billing restricted"; + if (billing.charge_status === "not_charged") return "Not charged by NyxID"; + if (billing.status === "unavailable") return "Billing unavailable"; + if (billing.account) + return billing.account.kind === "personal" + ? "Personal account" + : `${billing.account.name} · organization`; + return billing.status === "conditional" + ? "Depends on execution" + : "Billing unavailable"; +} + +export function rateLabel(billing: ServiceBillingExplanation): string { + if (billing.charge_status === "not_charged") return "No NyxID charge"; + if (billing.charge_status === "restricted") return "Rates restricted"; + if (!billing.rates.length) return "Rate not reported"; + if (billing.rates.length > 1) return `${billing.rates.length} metered rates`; + const rate = billing.rates[0]!; + return rate.credits_per_unit == null + ? "Rate set at execution" + : `${rate.credits_per_unit} credits / ${metricLabel(rate.metric, 1)}`; +} + +export function callerKindLabel(kind: string): string { + return ( + ( + { + api_key: "Agent key", + agent_key: "Agent key", + app: "Application", + oauth_app: "OAuth app", + session: "Session", + access_token: "Access token", + service_account: "Service account", + delegated: "Delegated token", + relay: "Relay", + unknown: "Caller not recorded", + } as Record + )[kind] ?? kind.replaceAll("_", " ") + ); +} +export function callerLabel(caller: ServiceCaller): string { + return caller.name || callerKindLabel(caller.kind); +} +export function outcomeLabel(outcome: string): string { + return ( + ( + { + completed: "Completed", + failed: "Failed", + denied: "Denied", + disconnected: "Disconnected", + response_received: "Response received", + connection_opened: "Connection opened", + outcome_unknown: "Outcome unknown", + unknown: "Outcome unknown", + } as Record + )[outcome] ?? "Outcome unknown" + ); +} +export function accessReasonLabel(reason: string): string { + return ( + ( + { + selected: "Selected service", + explicit: "Selected service", + selected_service: "Selected service", + all_services: "All services", + platform_services: "Platform services", + auto_connected: "Platform services", + } as Record + )[reason] ?? reason.replaceAll("_", " ") + ); +} + +export function summarizeBilling( + insights: readonly (ServiceInsight | undefined)[], +): string { + if (!insights.length || insights.some((item) => !item?.billing)) + return "Billing not reported"; + const bills = insights.map((item) => item!.billing!); + if (bills.some((bill) => bill.status === "restricted")) + return "Billing restricted"; + if (bills.every((bill) => bill.charge_status === "not_charged")) + return "Not charged by NyxID"; + const first = bills[0]!; + if (bills.every((bill) => bill.status === "unavailable")) + return "Billing unavailable"; + if (bills.some((bill) => bill.status === "unavailable")) + return "Varies by connection"; + if ( + bills.some( + (bill) => + bill.account?.id !== first.account?.id || + bill.charge_status !== first.charge_status, + ) + ) + return "Varies by connection"; + return first.account + ? `For you: ${billingAccountLabel(first)}` + : "Depends on execution"; +} diff --git a/frontend/src/pages/service-overview.test.tsx b/frontend/src/pages/service-overview.test.tsx index 4ffce250d..cd438ec05 100644 --- a/frontend/src/pages/service-overview.test.tsx +++ b/frontend/src/pages/service-overview.test.tsx @@ -53,6 +53,13 @@ vi.mock("@/hooks/use-user-services", () => ({ useUserServices: () => ({ data: [] }), })); vi.mock("@/hooks/use-nodes", () => ({ useNodes: () => ({ data: [] }) })); +vi.mock("@/hooks/use-service-insights", () => ({ + useServiceInsights: () => ({ + connections: new Map(), + status: "unavailable", + refresh: vi.fn(), + }), +})); vi.mock("@/components/dashboard/service-history", () => ({ ServiceHistory: ({ serviceId }: { serviceId: string }) => (
{serviceId}
@@ -148,7 +155,11 @@ describe("full service page", () => { ]; render(); expect(screen.getByText("Editors only")).toBeVisible(); - expect(screen.getByRole("link", { name: "View Shared connection details (ChronoAI)" })).toHaveAttribute("href", "/keys/Shared"); + expect( + screen.getByRole("link", { + name: "View Shared connection details (ChronoAI)", + }), + ).toHaveAttribute("href", "/keys/Shared"); expect( screen.queryByRole("link", { name: /Configure/ }), ).not.toBeInTheDocument(); diff --git a/frontend/src/schemas/service-insights.ts b/frontend/src/schemas/service-insights.ts new file mode 100644 index 000000000..6b92ab2cb --- /dev/null +++ b/frontend/src/schemas/service-insights.ts @@ -0,0 +1,95 @@ +import { z } from "zod"; + +export const serviceBillingExplanationSchema = z.object({ + status: z.enum(["resolved", "conditional", "restricted", "unavailable"]), + credential_class: z.string().nullable(), + credential_label: z.string(), + account: z + .object({ + id: z.string(), + kind: z.enum(["personal", "organization"]), + name: z.string(), + }) + .nullable(), + charge_status: z.enum([ + "usage_based", + "not_charged", + "conditional", + "restricted", + "unavailable", + ]), + rates: z.array( + z.object({ + layer: z.string(), + metric: z.string(), + credits_per_unit: z.string().nullable(), + currency: z.string(), + source: z.string(), + }), + ), + provider_billing: z.enum([ + "separate_provider_account", + "nyxid_credential", + "no_credential", + "unknown", + ]), + context: z.string(), + notes: z.array(z.string()), +}); + +export const serviceCallerSchema = z.object({ + id: z.string().nullable(), + kind: z.string(), + name: z.string(), + app_id: z.string().nullable(), + app_name: z.string().nullable(), +}); + +export const connectionActivitySchema = z.object({ + access: z.object({ + visibility: z.string(), + keys: z.array( + z.object({ + id: z.string(), + name: z.string(), + platform: z.string().nullable(), + owner_id: z.string(), + permission: z.string(), + credential_override: z.boolean(), + }), + ), + truncated: z.boolean(), + }), + activity: z.object({ + visibility: z.string(), + period_days: z.number(), + tracking: z.string(), + request_count: z.number(), + requests: z.array( + z.object({ + id: z.string(), + execution_id: z.string().nullable(), + caller: serviceCallerSchema, + occurred_at: z.string(), + outcome: z.string(), + response_status: z.number().nullable(), + }), + ), + truncated: z.boolean(), + }), +}); + +export const serviceInsightSchema = z.object({ + service_id: z.string(), + billing: serviceBillingExplanationSchema.nullable(), + usage: connectionActivitySchema.nullable(), +}); +export const serviceInsightsResponseSchema = z.object({ + connections: z.array(serviceInsightSchema), +}); +export type ServiceInsight = z.infer; +export type ConnectionActivity = z.infer; +export type ServiceBillingExplanation = z.infer< + typeof serviceBillingExplanationSchema +>; +export type ServiceCaller = z.infer; diff --git a/frontend/test/routing-preview-gateway.test.ts b/frontend/test/routing-preview-gateway.test.ts index 556b8d94c..fefae508e 100644 --- a/frontend/test/routing-preview-gateway.test.ts +++ b/frontend/test/routing-preview-gateway.test.ts @@ -78,6 +78,9 @@ describe("local preview metadata gateway", () => { "/api/v1/catalog/llm-openai", "/api/v1/nodes/13ae3c40-5ec0-4eee-9e20-25c60209dd12", "/api/v1/api-keys", + "/api/v1/service-insights?ids=13ae3c40-5ec0-4eee-9e20-25c60209dd12", + "/api/v1/api-keys/13ae3c40-5ec0-4eee-9e20-25c60209dd12", + "/api/v1/api-keys/13ae3c40-5ec0-4eee-9e20-25c60209dd12/bindings", "/api/v1/service-pools", "/api/v1/providers/codex-connection", "/api/v1/keys/history/archived", From f1f01392cf6e9559d73501e80ea708a16a0f1016 Mon Sep 17 00:00:00 2001 From: ctkm-aelf <230958228+ctkm-aelf@users.noreply.github.com> Date: Tue, 29 Sep 2026 17:53:35 +0800 Subject: [PATCH 04/36] fix(services): stabilize sticky filters and show configured access and billing --- docs/AI_SERVICES_ARCHITECTURE.md | 11 + docs/plans/local-routing-preview.md | 24 +- docs/plans/service-route-resolution-flow.md | 117 ++++++++ frontend/src/app.css | 2 +- .../dashboard/grouped-service-cards.tsx | 63 +++- .../dashboard/service-connection-table.tsx | 49 +++- .../dashboard/service-insight-panels.tsx | 142 ++++++--- .../dashboard/service-insights.test.tsx | 57 ++++ .../service-routing-preview.test.tsx | 113 +++++++- .../dashboard/service-view-toolbar.tsx | 4 +- .../hooks/use-service-card-transition.test.ts | 5 +- .../src/hooks/use-service-card-transition.ts | 4 +- frontend/src/hooks/use-service-insights.ts | 43 ++- .../src/lib/service-insights-compat.test.tsx | 271 ++++++++++++++++++ frontend/src/lib/service-insights-compat.ts | 269 +++++++++++++++++ frontend/src/lib/service-insights.ts | 14 + frontend/src/schemas/service-insights.ts | 56 +++- 17 files changed, 1165 insertions(+), 79 deletions(-) create mode 100644 docs/plans/service-route-resolution-flow.md create mode 100644 frontend/src/lib/service-insights-compat.test.tsx create mode 100644 frontend/src/lib/service-insights-compat.ts diff --git a/docs/AI_SERVICES_ARCHITECTURE.md b/docs/AI_SERVICES_ARCHITECTURE.md index 240777d88..9d8ffe1eb 100644 --- a/docs/AI_SERVICES_ARCHITECTURE.md +++ b/docs/AI_SERVICES_ARCHITECTURE.md @@ -728,6 +728,17 @@ missing data is never represented as free. Provider charges may be separate, and the preview is not a settled debit or proof that a provider credential works. Inspecting the page does not decrypt credentials, reserve credits, or call providers. +On servers without the insights route (404/405/501 only), the frontend reads the +existing managed-key inventories, binding metadata and catalog credential prices. +This compatibility projection is labelled **configured scope** and **expected +payer**, with a credential → payer → charges flow inside the connection row. +It does not assert live execution permission, credential health, a resolved payer, +or an effective price; synchronization status accompanies configured prices. +Restricted/network errors do not fall back. Incomplete inventories and unknown +overrides remain explicit, and exact caller history remains unavailable rather +than appearing as zero requests. Agent-specific billing remains on the new +server resolver; the compatibility view only describes the connection default. + Agent access describes current scope grants, not use. Members see their own key inventory and requests; scoped organization admins can also inspect organization keys and permitted connection activity. Other members' private keys are excluded. diff --git a/docs/plans/local-routing-preview.md b/docs/plans/local-routing-preview.md index 956667d32..72531bcaa 100644 --- a/docs/plans/local-routing-preview.md +++ b/docs/plans/local-routing-preview.md @@ -261,9 +261,16 @@ and permitted change history retain their existing access boundaries. The implementation includes `GET /api/v1/service-insights` and exact request attribution in the HTTP proxy, both LLM routes, and MCP. The endpoint is allowed -through the local preview's read-only gateway. Its production backend must deploy -this endpoint before these fields populate: an older server shows Not reported, -with an explanatory inline message. It never fabricates payer or caller data. +through the local preview's read-only gateway. When an older server returns +404, 405 or 501, the frontend reads existing agent-key inventories, credential +binding metadata and catalog prices. The comparison table shows key names and +configured scope, plus expected payer and configured rates. Its inline billing +flow separates credential supply, expected payer and NyxID charges. Pending or +failed price synchronization stays visible. The compatibility view does not +resolve per-agent billing or claim recorded use; exact caller history and the +managed-key billing selector require the new endpoint. Authorization and network +failures never trigger this fallback. Partial key inventories and unknown +credential overrides are labelled explicitly. Older request history remains partial after deployment. Rates are current billing previews; settled transaction history is not added by this revision. @@ -277,3 +284,14 @@ real MongoDB privacy, payer/override, exact request capture, metering, and rollu checks. TypeScript, the production frontend build, Rust formatting, targeted ESLint, and diff whitespace checks passed. Full ESLint has zero errors and 27 existing warnings. The committed CLI wizard source-closure hash remains current. + +The compact sticky filter now preserves its expanded height in normal page flow. +This prevents shrinking scroll height from clamping the scroll position back +across the sticky threshold. Its visible surface, cover, service-header offset and +card reveal still use the actual compact height; the reserved flow space is +transparent and does not intercept clicks. A regression test models repeated +resize/scroll frames near the bottom of a short filtered list. +Configured agent-key names also appear on collapsed cards when exact request +history is unavailable. Validation for this revision: 124 frontend tests, +TypeScript, production build, and changed-file ESLint passed. The running preview +serves the updated modules; signed-in visual verification remains outstanding. diff --git a/docs/plans/service-route-resolution-flow.md b/docs/plans/service-route-resolution-flow.md new file mode 100644 index 000000000..6d61f1196 --- /dev/null +++ b/docs/plans/service-route-resolution-flow.md @@ -0,0 +1,117 @@ +# Service routing, billing and dependency presentation + +Status: proposed flow. The current refresh implements connection metadata and +insight panels; it does not implement ordered cross-owner fallback. + +## One service, one explained route + +Keep the collapsed service card and its expandable connection table. A group of +connections is not automatically an executable route. Show a service call slug +only after the backend has an explicit route configuration for it; preserve +individual connection identity and direct addressing. + +Use three distinct terms: + +- **Connection order** selects the connection to execute. Default to personal, + organization, then platform when available. A saved custom order can put + platform first. Personal A and Personal B remain distinct connections. +- **Billing flow** explains the payer and rates after caller context and final + credential selection. Moving to another connection may change the payer. +- **Derived from** describes catalog/copy lineage. It has no routing or billing + meaning by itself. + +## Expanded card + +The card header contains a caller selector: **For: You / managed agent key**. +Verified application identity appears with a recorded request; an arbitrary app +name is not a credential or execution context. + +Directly below, show the expected result in one compact line: + +`Next: ChronoAI OpenAI · Bills: ChronoAI · NyxID rate: [effective unit price]` + +The table stays inside this card and makes the effective order visible: + +| Order | Connection / source | Route state | Credential | NyxID payer | Rate | +| --- | --- | --- | --- | --- | --- | +| 1 | NyxID OpenAI | Skipped · reason | NyxID key | Acting person | Applicable platform-key rate | +| 2 | ChronoAI OpenAI | Selected now | Organization key | ChronoAI | Applicable own-key rate | +| 3 | Personal A | Fallback | Your key | You | Applicable own-key rate | +| 4 | Personal B | Fallback | Your key | You | Applicable own-key rate | + +This is an illustrative layout, not a claim about live availability or prices. +Render only real, authorized sources; an absent platform connection has no row +or empty placeholder. If an existing visible connection becomes unusable, retain +it with a concrete skip reason. Do not expose inaccessible connection metadata. + +Source-priority controls are explicit. Drag handles reorder connections within +their pool; card-grid ordering does not configure routing. Pinning a connection +is a separate mode with no fallback, and does not masquerade as an agent +credential override. A credential override can change the credential class and +payer without changing the selected connection. + +Billing and dependency information expands in the existing single row panel: + +`Caller → selected connection → effective credential → payer → rates` + +Show ownership, source catalog, direct predecessor when recorded, and change +history under **Details / Derived from**. A common catalog association does not +prove that one connection was copied from another. Keep private configuration +editor-only; authorized readers retain permitted history. + +## Execution contract + +Persist the chosen source policy and pool member priorities as execution +configuration, independently of saved list filters. Add an ordered strategy and +caller-aware cross-owner eligibility; the existing pools only select enabled, +active same-owner members using round-robin or weights. + +The real execution path and read-only explanation must use the same candidate +eligibility rules. For the authenticated caller, check live service scopes, +organization access, platform grants, enabled state, credential availability, +applicable overrides, node availability and request capabilities. Use credential +validation evidence with timestamps; missing or stale evidence is unverified, +not proof of a working connection. Execution still validates/materializes the +credential and rechecks authorization before provider effects. + +Select the first eligible candidate in the saved order. If none is usable, +return a structured no-usable-connection error and safe reasons. Do not silently +use an unauthorized platform key. Exact connection calls retain exact semantics. + +After final credential selection, use the existing billing owner and pricing +resolvers. NyxID master keys charge the acting person's account; organization +credentials normally charge the service owner organization; personal credentials +charge the person. Shared OAuth applications and agent credential overrides +require the actual credential-class resolver, not a source badge heuristic. + +Funding is a separate order inside the resolved account: matching allowance +units, grant credits, then wallet credits. A platform → organization → personal +connection order is never an implicit permission to cascade charges through +unrelated wallets. Show unit rates before a request and actual coverage/debit +after settlement. Own provider charges can be separate from NyxID charges. + +Automatic failover must be bounded and replay-safe. Skip known unusable +candidates before dispatch; do not replay an ambiguous request after provider +effects or streaming have begun. Any real attempted upstream work can incur +cost; record attempts independently rather than promising only successful calls +are charged. Any permitted automatic switch to a different payer must be part +of the saved policy and visible in the table. + +## Preview versus recorded result + +The explanation API reports **Expected route now**, ordered candidates and skip +reasons for the selected caller. Reading it does not call providers, refresh +credentials, reserve credits or charge anything. Reuse execution policy logic +without stateful execution effects. The preview can change before a call. + +The request record is authoritative for what happened. Capture the route/policy +version, candidate attempts and reasons, selected exact connection, verified +caller/app identity, final credential class, billing owner, rate basis and +settlement reference. Show the latest three requests inside the card; full +history remains on the service page. Old events without these fields remain +explicitly incomplete. + +The current `/api/v1/service-insights` endpoint in draft PR #1685 explains exact +connections and recorded callers. It must be extended to explain a configured +route once the shared ordered resolver exists. Deploying that endpoint alone +does not create cross-owner fallback. diff --git a/frontend/src/app.css b/frontend/src/app.css index 12bb8cf0a..d52377e20 100644 --- a/frontend/src/app.css +++ b/frontend/src/app.css @@ -830,7 +830,7 @@ html.theme-dark .assistant-halo--visible { position: absolute; pointer-events: none; top: -100vh; - bottom: -28px; + height: calc(100vh + var(--service-filters-height, 0px) + 28px); left: calc(-1 * var(--service-filter-gutter-left, 0px)); right: calc(-1 * var(--service-filter-gutter-right, 0px)); background: var(--color-background); diff --git a/frontend/src/components/dashboard/grouped-service-cards.tsx b/frontend/src/components/dashboard/grouped-service-cards.tsx index 1fa27d7f0..ba45e5184 100644 --- a/frontend/src/components/dashboard/grouped-service-cards.tsx +++ b/frontend/src/components/dashboard/grouped-service-cards.tsx @@ -99,7 +99,8 @@ function GroupCard({ observer.observe(header); observer.observe(scroller); if (finalRowsStart) observer.observe(finalRowsStart); - if (filtersRef.current) observer.observe(filtersRef.current); + const filterSurface = filtersRef.current?.firstElementChild; + if (filterSurface) observer.observe(filterSurface); scroller.addEventListener("scroll", update, { passive: true }); return () => { observer.disconnect(); @@ -141,14 +142,37 @@ function GroupCard({ const ownRequests = connectionInsights.every( (item) => item?.usage?.activity.visibility === "own_requests", ); + const configuredAccess = connectionInsights.every( + (item) => + item?.usage?.access.basis === "configuration" && + item.usage.activity.tracking === "unavailable", + ); + const configuredKeys = [ + ...new Map( + connectionInsights.flatMap((item) => + (item?.usage?.access.keys ?? []).map( + (key) => [key.id, key.name] as const, + ), + ), + ).values(), + ]; const callerSummary = insights.status !== "ready" ? insightStatusLabel(insights.status, "Activity") : recent ? `${callerLabel(recent.caller)} · ${formatRelativeTime(recent.occurred_at)}` - : connectionInsights.every((item) => item?.usage) - ? "No recorded requests · 30d" - : "Activity not reported"; + : configuredAccess + ? configuredKeys.length + ? `${configuredKeys.slice(0, 2).join(" · ")}${configuredKeys.length > 2 ? ` +${configuredKeys.length - 2}` : ""}` + : connectionInsights.some((item) => item?.usage?.access.incomplete) + ? "Key inventory incomplete" + : "No matching keys" + : connectionInsights.every( + (item) => + item?.usage && item.usage.activity.tracking !== "unavailable", + ) + ? "No recorded requests · 30d" + : "Activity not reported"; return (
onToggle(cardRef.current)} aria-expanded={expanded} aria-controls={contentId} - aria-label={`Expand ${group.name} to compare caller activity`} + aria-label={`Expand ${group.name} to compare ${configuredAccess ? "agent key scope" : "caller activity"}`} className="grid w-full grid-cols-[6rem_1fr] items-center gap-2 rounded-sm text-left focus-visible:outline-2 focus-visible:outline-ring" > - {ownRequests ? "Your latest" : "Latest request"} + {configuredAccess + ? "Agent keys" + : ownRequests + ? "Your latest" + : "Latest request"} {callerSummary} @@ -362,13 +390,28 @@ export function GroupedServiceCards({ const container = containerRef.current; const toolbar = filtersRef.current; if (!container || !toolbar) return; + const surface = toolbar.firstElementChild as HTMLElement; const scroller = toolbar.closest("main"); + let pinned = false; + let expandedHeight = surface.getBoundingClientRect().height; const updateShadow = () => { const toolbarTop = toolbar.getBoundingClientRect().top; - setFiltersStuck(container.getBoundingClientRect().top < toolbarTop - 1); + const next = container.getBoundingClientRect().top < toolbarTop - 1; + if (!pinned) + expandedHeight = Math.max( + surface.getBoundingClientRect().height, + Number.parseFloat(toolbar.style.minHeight) || 0, + ); + if (next !== pinned) { + // Keep the original flow height: shrinking scrollHeight can clamp + // scrollTop back across the pin threshold and repeatedly unpin it. + toolbar.style.minHeight = next ? `${expandedHeight}px` : ""; + pinned = next; + } + setFiltersStuck(next); }; const measure = () => { - const bounds = toolbar.getBoundingClientRect(); + const bounds = surface.getBoundingClientRect(); container.style.setProperty( "--service-filters-height", `${bounds.height}px`, @@ -389,7 +432,7 @@ export function GroupedServiceCards({ }; measure(); const observer = new ResizeObserver(measure); - observer.observe(toolbar); + observer.observe(surface); if (scroller) observer.observe(scroller); scroller?.addEventListener("scroll", updateShadow, { passive: true }); return () => { diff --git a/frontend/src/components/dashboard/service-connection-table.tsx b/frontend/src/components/dashboard/service-connection-table.tsx index 2bf2053c4..43c270c20 100644 --- a/frontend/src/components/dashboard/service-connection-table.tsx +++ b/frontend/src/components/dashboard/service-connection-table.tsx @@ -324,21 +324,42 @@ export function ServiceConnectionTable({ className="inline-flex items-center gap-1.5 rounded-sm text-xs font-medium text-primary hover:underline focus-visible:outline-2 focus-visible:outline-ring" > - {usage + {usage && usage.access.visibility !== "unavailable" ? `${usage.access.keys.length}${usage.access.truncated ? "+" : ""} agent ${usage.access.keys.length === 1 ? "key" : "keys"}` - : insightStatusLabel(insights.status, "Access")} + : insightStatusLabel( + usage ? "error" : insights.status, + "Access", + )} {usage && (

- {usage.access.visibility === "own_keys" - ? "Your keys with access" - : "Managed keys with access"} + {usage.access.basis === "configuration" + ? `Configured scope${usage.access.incomplete ? " · partial inventory" : ""}` + : usage.access.visibility === "own_keys" + ? "Your keys with access" + : "Managed keys with access"} {usage.access.keys.some( (agent) => agent.credential_override, ) && ` · ${usage.access.keys.filter((agent) => agent.credential_override).length} overrides`}

)} + {!!usage?.access.keys.length && ( +

agent.name) + .join(" · ")} + > + {usage.access.keys + .slice(0, 2) + .map((agent) => agent.name) + .join(" · ")} + {usage.access.keys.length > 2 + ? ` +${usage.access.keys.length - 2}` + : ""} +

+ )} - {typeof actions === "function" ? actions(compact) : actions} - - )} + actions={actions} /> ); } diff --git a/frontend/src/components/dashboard/service-view-toolbar.tsx b/frontend/src/components/dashboard/service-view-toolbar.tsx index 740ac8a9c..e24cf8c11 100644 --- a/frontend/src/components/dashboard/service-view-toolbar.tsx +++ b/frontend/src/components/dashboard/service-view-toolbar.tsx @@ -1,6 +1,12 @@ import { useRef, useState, type ReactNode, type Ref } from "react"; import { ArrowLeftRight, Layers, UserRound, X } from "lucide-react"; import { Button } from "@/components/ui/button"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; import { ServiceFilterMultiselect, type ServiceFilterOption, @@ -135,40 +141,46 @@ export function ServiceViewToolbar({ }); }; const sourceToggle = ( - + + + + + + + {filters.source === "personal" + ? "Personal view includes your services and their organization/platform counterparts. Click to show all services." + : "All services. Click to show services with a personal connection."} + + + ); return ( diff --git a/frontend/src/components/shared/add-cta-button.tsx b/frontend/src/components/shared/add-cta-button.tsx index 63df9c20f..c6270f127 100644 --- a/frontend/src/components/shared/add-cta-button.tsx +++ b/frontend/src/components/shared/add-cta-button.tsx @@ -1,11 +1,18 @@ import { Plus } from "lucide-react"; import { Button, ButtonIcon } from "@/components/ui/button"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; interface AddCtaButtonProps { readonly label: string; readonly onClick: () => void; readonly disabled?: boolean; readonly compact?: boolean; + readonly compactLabel?: string; readonly icon?: React.ComponentType<{ className?: string }>; /** * "primary" (default) → the goal-completing CTA on this page. Renders @@ -26,21 +33,24 @@ export function AddCtaButton({ onClick, disabled = false, compact = false, + compactLabel, icon: Icon = Plus, variant = "primary", }: AddCtaButtonProps) { if (variant === "primary") { - return ( + const button = ( ); + return compact ? ( + + + {button} + + {label} + + + + ) : ( + button + ); } return ( diff --git a/frontend/src/lib/service-insights-compat.test.tsx b/frontend/src/lib/service-insights-compat.test.tsx index 3dd7c48c6..0966e4560 100644 --- a/frontend/src/lib/service-insights-compat.test.tsx +++ b/frontend/src/lib/service-insights-compat.test.tsx @@ -29,6 +29,7 @@ const personal = { credential_source: { type: "personal" }, credential_binding: "user", auth_method: "bearer", + credential_type: "api_key", is_active: true, } as KeyInfo; const org = { @@ -104,6 +105,88 @@ function mount(connections: KeyInfo[] = [personal]) { } describe("deployed service insight compatibility", () => { + it("does not classify an OAuth login as a supplied developer app", () => { + const bill = configuredBilling({ + ...personal, + credential_type: "oauth2", + api_key_id: "oauth-token", + }); + expect(bill.provider_billing).toBe("unknown"); + expect(bill.credential_label).toBe("Connected account · app unverified"); + expect(bill.credential_label).not.toContain("BYOK"); + }); + + it("reads legacy catalog credit billing without claiming the caller has been charged", async () => { + responses.set("/catalog", { + entries: [ + { + slug: "openai", + billing: { + platform_billable: true, + platform_metric: "requests", + platform_pricing: { + credits_per_unit: "0.2", + sync_status: "synced", + }, + }, + }, + ], + }); + const [item] = await loadConfiguredServiceInsights([personal], "person"); + expect(item!.billing).toMatchObject({ + credit_billing_configured: true, + charge_status: "conditional", + rates: [{ credits_per_unit: "0.2", metric: "requests" }], + }); + }); + + it("does not apply legacy pricing over a different credential lane", () => { + const bill = configuredBilling(personal, { + slug: "openai", + billing: { + platform_billable: true, + platform_metric: "requests", + platform_key_pricing: { + metric: "requests", + credits_per_unit: "0.5", + sync_status: "synced", + }, + }, + }); + expect(bill.credit_billing_configured).toBe(false); + expect(bill.rates).toEqual([]); + expect(bill.charge_status).toBe("conditional"); + }); + + it("does not advertise platform-only charges on a known user-supplied API key", () => { + const bill = configuredBilling(personal, { + slug: "openai", + billing: { + platform_billable: true, + platform_charge_nyxid_credentials_only: true, + byok_pricing: { + metric: "requests", + credits_per_unit: "0.5", + sync_status: "synced", + }, + }, + }); + expect(bill.credit_billing_configured).toBe(false); + expect(bill.rates).toEqual([]); + }); + + it("preserves unreported plan rates without inventing a numeric price", () => { + const bill = configuredBilling(personal, { + slug: "openai", + billing: { + platform_billable: true, + platform_metric: "requests", + }, + }); + expect(bill.credit_billing_configured).toBe(true); + expect(bill.rates[0]?.credits_per_unit).toBeNull(); + }); + it("accepts the deployed key list's omitted expiry and zero binding count", async () => { const { expires_at: _expiry, diff --git a/frontend/src/lib/service-insights-compat.ts b/frontend/src/lib/service-insights-compat.ts index ce344ff12..784ca0031 100644 --- a/frontend/src/lib/service-insights-compat.ts +++ b/frontend/src/lib/service-insights-compat.ts @@ -1,5 +1,4 @@ import { api } from "@/lib/api-client"; -import { credentialLabel } from "@/lib/service-insights"; import { configuredAgentKeyListSchema, configuredBindingsSchema, @@ -24,8 +23,10 @@ export function configuredBilling( catalog?: ConfiguredCatalogEntry, ): ServiceBillingExplanation { const platform = connection.credential_binding === "platform"; + const node = Boolean(connection.node_id || connection.has_node_binding); const userCredential = !platform && + (connection.auth_method !== "none" || node) && Boolean( connection.credential_binding === "user" || connection.api_key_id || @@ -36,18 +37,55 @@ export function configuredBilling( connection.credential_source?.type === "org" ? connection.credential_source : null; - const lane = platform - ? (connection.platform_key_pricing ?? catalog?.platform_key?.pricing) + const oauth = ["oauth2", "device_code"].includes(connection.credential_type); + const billing = catalog?.billing; + const configuredLane = platform + ? (connection.platform_key_pricing ?? + catalog?.platform_key?.pricing ?? + billing?.platform_key_pricing) : userCredential - ? (connection.byok_pricing ?? catalog?.byok_pricing) + ? (connection.byok_pricing ?? + catalog?.byok_pricing ?? + billing?.byok_pricing) : undefined; + const hasLanes = Boolean( + configuredLane || + connection.byok_pricing || + connection.platform_key_pricing || + catalog?.byok_pricing || + catalog?.platform_key?.pricing || + billing?.byok_pricing || + billing?.platform_key_pricing, + ); + const ownApiKey = + userCredential && + !node && + !oauth && + connection.credential_type === "api_key"; + const excludedFromPlatformCharge = + billing?.platform_charge_nyxid_credentials_only === true && + !platform && + (ownApiKey || node || connection.auth_method === "none"); + const lane = excludedFromPlatformCharge ? undefined : configuredLane; + const legacyConfigured = + !hasLanes && + !excludedFromPlatformCharge && + billing?.platform_billable === true; + const credentialLabel = platform + ? "NyxID key" + : node + ? "Node credential · supplier unverified" + : connection.auth_method === "none" + ? "No credential" + : oauth + ? "Connected account · app unverified" + : ownApiKey + ? `${org ? "Organization" : "Your"} API key (BYOK)` + : "Credential supplier unverified"; return { status: "conditional", credential_class: null, - credential_label: - platform || userCredential || connection.auth_method === "none" - ? credentialLabel(connection) - : "Connection default · unverified", + credential_label: credentialLabel, account: null, payer_rule: platform ? "Acting user's personal account" @@ -57,6 +95,9 @@ export function configuredBilling( ? `${org.org_name} · organization` : "Your personal account", charge_status: "conditional", + credit_billing_configured: Boolean( + lane || legacyConfigured || (platform && billing?.resale_billable), + ), rates: lane ? [lane, ...(lane.components ?? [])].map((rate) => ({ layer: "platform", @@ -66,17 +107,49 @@ export function configuredBilling( source: "configuration", sync_status: rate.sync_status ?? "unknown", })) - : [], + : legacyConfigured && billing?.platform_metric + ? [ + { + layer: "platform", + metric: billing.platform_metric, + credits_per_unit: + billing.platform_pricing?.credits_per_unit ?? null, + currency: "credits", + source: "configuration", + sync_status: billing.platform_pricing?.sync_status ?? "unknown", + }, + ] + : [], provider_billing: platform ? "nyxid_credential" : connection.auth_method === "none" ? "no_credential" - : userCredential + : ownApiKey ? "separate_provider_account" : "unknown", context: "configuration", notes: [ "Configured billing for the connection default. The payer and applicable charges are verified at execution; agent credential overrides can change them.", + ...(oauth + ? [ + "Signing in does not identify the developer app's owner. This server does not report whether this connection uses your app or NyxID's app.", + ] + : []), + ...(billing?.platform_charge_nyxid_credentials_only + ? [ + "Configured NyxID charges are limited to NyxID-supplied credentials or OAuth apps; eligibility must be verified at execution.", + ] + : []), + ...(legacyConfigured + ? [ + "The catalog configures NyxID credit billing for this service. Caller eligibility and the active plan rate are verified at execution.", + ] + : []), + ...(platform && billing?.resale_billable + ? [ + "Provider usage through NyxID is configured for credit billing; its rate is not reported here.", + ] + : []), ...(lane && [lane, ...(lane.components ?? [])].some( (rate) => rate.sync_status !== "synced", @@ -85,7 +158,7 @@ export function configuredBilling( "Unsynced prices are not confirmed as active. Current billing rules apply until price synchronization completes.", ] : []), - ...(lane + ...(lane || legacyConfigured ? [] : [ "This server does not report a credential-specific rate here. This does not mean usage is free.", diff --git a/frontend/src/lib/service-insights.ts b/frontend/src/lib/service-insights.ts index 1c9efa0a9..12d274be9 100644 --- a/frontend/src/lib/service-insights.ts +++ b/frontend/src/lib/service-insights.ts @@ -22,7 +22,22 @@ export function credentialLabel( connection: KeyInfo, billing?: ServiceBillingExplanation | null, ): string { - if (billing) return billing.credential_label; + if (billing) { + if (billing.status === "restricted" || billing.status === "unavailable") + return billing.credential_label; + if (billing.context === "configuration") return billing.credential_label; + if (billing.credential_class === "nyxid_platform_oauth_app") + return "NyxID developer app"; + if (billing.credential_class === "nyxid_managed_master") return "NyxID key"; + if ( + ["user_owned", "agent_override_user_owned"].includes( + billing.credential_class ?? "", + ) && + connection.credential_type === "api_key" + ) + return `${connection.credential_source?.type === "org" ? "Organization" : "Your"} API key (BYOK)`; + return billing.credential_label; + } if (connection.credential_binding === "platform") return "NyxID credential"; if (connection.node_id || connection.has_node_binding) return "Node credential"; @@ -59,7 +74,9 @@ export function rateLabel(billing: ServiceBillingExplanation): string { const rate = billing.rates[0]!; const label = billing.rates.length === 1 - ? `${rate.credits_per_unit} credits / ${metricLabel(rate.metric, 1)} · configured` + ? rate.credits_per_unit == null + ? "Plan rate not reported" + : `${rate.credits_per_unit} credits / ${metricLabel(rate.metric, 1)} · configured` : `${billing.rates.length} configured rates`; return `${label}${pending ? " · sync unconfirmed" : ""}`; } @@ -164,6 +181,8 @@ export function providerBillingLabel( ): string { if (billing?.status === "restricted") return "Provider billing restricted"; if (billing?.status === "unavailable") return "Provider billing unavailable"; + if (billing?.credential_class === "nyxid_platform_oauth_app") + return "NyxID supplies the developer app; signing in connects your provider account"; if (billing?.provider_billing === "separate_provider_account") return "Provider billed separately"; if (billing?.provider_billing === "nyxid_credential") @@ -179,15 +198,20 @@ export function billingModelLabel( if (!billing) return "Billing not reported"; if (billing.status === "restricted") return "Billing restricted"; if (billing.status === "unavailable") return "Billing unavailable"; - switch (billing.provider_billing) { - case "separate_provider_account": - return "BYOK"; - case "nyxid_credential": - return "NyxID-managed"; - case "no_credential": - return "No provider account"; + switch (billing.charge_status) { + case "usage_based": + return "NyxID credits"; + case "not_charged": + return "No NyxID charge"; + case "restricted": + return "Billing restricted"; + case "unavailable": + return "Billing unavailable"; default: - return "Billing model unknown"; + return billing.context === "configuration" && + (billing.credit_billing_configured || billing.rates.length > 0) + ? "NyxID credits · configured" + : "Credit billing unverified"; } } @@ -201,18 +225,46 @@ export function summarizeBillingModel( ); if (!models.size) return "Billing not reported"; if (models.size === 1) return [...models][0]!; - if ([...models].some((model) => model.startsWith("Billing"))) + if ( + [...models].some( + (model) => + model.startsWith("Billing") || model === "Credit billing unverified", + ) + ) return "Billing partly reported"; - if (models.size === 2 && models.has("BYOK") && models.has("NyxID-managed")) - return "BYOK + NyxID"; - return "Multiple billing models"; + return "Charges vary by connection"; } export function nyxidChargeLabel(billing: ServiceBillingExplanation): string { if (billing.status === "restricted") return "NyxID fees restricted"; if (billing.status === "unavailable") return "NyxID fees unavailable"; if (billing.charge_status === "not_charged") return "No NyxID charge"; - return `NyxID fee: ${rateLabel(billing)}`; + if (!billing.rates.length) return "Rate not reported"; + return `Rate: ${rateLabel(billing)}`; +} + +export function billingExplanation(billing: ServiceBillingExplanation): string { + if (billing.status === "restricted" || billing.status === "unavailable") + return billingModelLabel(billing); + const supply = + billing.credential_class === "nyxid_platform_oauth_app" + ? "NyxID supplies the developer app. Signing into your provider account is not BYOK." + : billing.provider_billing === "nyxid_credential" + ? "NyxID supplies the provider key." + : billing.provider_billing === "separate_provider_account" + ? "Your supplied credential uses a separate provider account. Any NyxID fees are additional to the provider's charges." + : billing.provider_billing === "no_credential" + ? "No provider credential is required." + : "The supplier of this connection's key or developer app is unverified."; + const charges = + billing.charge_status === "not_charged" + ? "NyxID does not charge this caller for this connection." + : billing.charge_status === "usage_based" + ? "NyxID meters usage against the billing account shown." + : billing.credit_billing_configured || billing.rates.length + ? "NyxID credit billing is configured; caller eligibility and the active rate are verified at execution." + : "NyxID credit charges have not been verified. A missing rate does not mean usage is free."; + return `${supply} ${charges}`; } export function summarizeBillingDetail( diff --git a/frontend/src/pages/keys.tsx b/frontend/src/pages/keys.tsx index 9e76b2a1b..c03a47a92 100644 --- a/frontend/src/pages/keys.tsx +++ b/frontend/src/pages/keys.tsx @@ -389,7 +389,7 @@ function ExternalServicesTab({ credential_source: keyInfo.credential_source ?? sourceById.get(keyInfo.id), }))} catalog={catalog} - actions={(compact) => } + actions={(compact) => } renderTable={viewMode === "table" ? (filteredKeys) => (
} /> @@ -617,7 +617,7 @@ export function KeysPage() { {previewActive && RoutingPreview ? ( }> setAddServiceOpen(true)} compact={compact} />} + actions={(compact) => setAddServiceOpen(true)} compact={compact} compactLabel="Connect" />} renderConnectionActions={(connection) => ( { setReconnectKey(keyInfo); diff --git a/frontend/src/schemas/service-insights.ts b/frontend/src/schemas/service-insights.ts index a4ac6e68c..f50208cb5 100644 --- a/frontend/src/schemas/service-insights.ts +++ b/frontend/src/schemas/service-insights.ts @@ -36,6 +36,7 @@ export const serviceBillingExplanationSchema = z.object({ "unknown", ]), context: z.string(), + credit_billing_configured: z.boolean().optional(), payer_rule: z.string().optional(), notes: z.array(z.string()), }); @@ -143,6 +144,22 @@ export const configuredCatalogSchema = z.object({ z.object({ slug: z.string(), byok_pricing: lanePricingViewSchema.nullish(), + billing: z + .object({ + byok_pricing: lanePricingViewSchema.nullish(), + platform_key_pricing: lanePricingViewSchema.nullish(), + platform_billable: z.boolean().optional(), + platform_charge_nyxid_credentials_only: z.boolean().optional(), + platform_metric: z.string().nullish(), + platform_pricing: z + .object({ + credits_per_unit: z.string(), + sync_status: z.string().optional(), + }) + .nullish(), + resale_billable: z.boolean().optional(), + }) + .nullish(), platform_key: z .object({ pricing: lanePricingViewSchema.nullish() }) .nullish(), From d312434371d46859da3bf8944277535dff232bc2 Mon Sep 17 00:00:00 2001 From: ctkm-aelf <230958228+ctkm-aelf@users.noreply.github.com> Date: Wed, 30 Sep 2026 23:34:45 +0800 Subject: [PATCH 09/36] Fix collapsed service card corner overflow --- frontend/src/components/dashboard/grouped-service-cards.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/dashboard/grouped-service-cards.tsx b/frontend/src/components/dashboard/grouped-service-cards.tsx index d6a368b76..9f6cd1741 100644 --- a/frontend/src/components/dashboard/grouped-service-cards.tsx +++ b/frontend/src/components/dashboard/grouped-service-cards.tsx @@ -261,8 +261,8 @@ function GroupCard({ >
From a7c2a27110daf90f088923c4e35d41977ffd4bff Mon Sep 17 00:00:00 2001 From: ctkm-aelf <230958228+ctkm-aelf@users.noreply.github.com> Date: Thu, 1 Oct 2026 10:11:00 +0800 Subject: [PATCH 10/36] Integrate saved pool routing and failover into service cards --- docs/AI_SERVICES_ARCHITECTURE.md | 54 +- docs/plans/local-routing-preview.md | 46 +- frontend/dev/routing-preview.ts | 4 +- .../dashboard/grouped-service-cards.tsx | 190 +++++-- .../dashboard/service-connection-table.tsx | 4 +- .../dashboard/service-insight-panels.tsx | 5 +- .../dashboard/service-pool-cards.tsx | 128 +++++ .../service-pool-routing-panel.test.tsx | 122 +++++ .../dashboard/service-pool-routing-panel.tsx | 356 +++++++++++++ .../service-pool-routing-preview.tsx | 333 ------------- .../dashboard/service-pools-tab.test.tsx | 64 +++ .../dashboard/service-pools-tab.tsx | 254 +++++++--- .../service-routing-preview.test.tsx | 173 +++---- .../dashboard/service-routing-preview.tsx | 64 +-- .../hooks/use-service-routing-pools.test.tsx | 80 +++ .../src/hooks/use-service-routing-pools.ts | 64 +++ .../src/lib/routing-preview-gateway.test.ts | 50 ++ frontend/src/lib/service-pool-display.test.ts | 93 ++++ frontend/src/lib/service-pool-display.ts | 69 +++ .../src/lib/service-routing-preview.test.ts | 37 +- frontend/src/lib/service-routing-preview.ts | 63 --- frontend/src/pages/keys.test.tsx | 239 ++++++--- frontend/src/pages/keys.tsx | 468 ++++++------------ 23 files changed, 1851 insertions(+), 1109 deletions(-) create mode 100644 frontend/src/components/dashboard/service-pool-cards.tsx create mode 100644 frontend/src/components/dashboard/service-pool-routing-panel.test.tsx create mode 100644 frontend/src/components/dashboard/service-pool-routing-panel.tsx delete mode 100644 frontend/src/components/dashboard/service-pool-routing-preview.tsx create mode 100644 frontend/src/hooks/use-service-routing-pools.test.tsx create mode 100644 frontend/src/hooks/use-service-routing-pools.ts create mode 100644 frontend/src/lib/routing-preview-gateway.test.ts create mode 100644 frontend/src/lib/service-pool-display.test.ts create mode 100644 frontend/src/lib/service-pool-display.ts diff --git a/docs/AI_SERVICES_ARCHITECTURE.md b/docs/AI_SERVICES_ARCHITECTURE.md index 14bdeb61b..7a6d96eba 100644 --- a/docs/AI_SERVICES_ARCHITECTURE.md +++ b/docs/AI_SERVICES_ARCHITECTURE.md @@ -90,11 +90,34 @@ Card billing summaries keep each source separate even when the expected payer is the same. Personal credentials normally use the personal account, organization credentials use the owning organization's account, and a platform key uses the acting person's personal account. Each source opens its own inline billing panel. -These groups do not configure execution order or credential-failure fallback; -billing panels explain that a failed request does not retry another connection. Within a selected -billing account, eligible allowances fund usage before credit grants and then wallet -credits. That funding priority is shown in the billing panel and does not select -another connection or payer. +The service card's **Routing** row shows saved pools containing its connections. +Grouping by catalog does not create a pool or change an individual connection slug. +Open Routing to inspect the pool slug, strategy, priority/weight, credential supplier, +billing account/rates, and operation-scoped eligibility/cooldown in a table inside +the card. All members of the selected pool are shown, including members of other +catalog services. Pool management inventory is currently personal or organization +admin only; incomplete access is labeled instead of asserting there are no pools. + +Priority pools support bounded failover; round-robin and weighted pools select a +single member per request. Omitted/null priority policies use server defaults, not +"failover off". Disabled pools, disabled members and failed inspection remain +explicit. Eligibility is metadata inspection, not proof of a successful upstream +request. The Same API inspector applies the entered method/path on Inspect; AI +chat inspection uses POST chat/completions. Reads refresh every 15 seconds while +open. A failed read discards cached eligibility. + +The pool editor saves settings and membership in one revision-checked PUT. Dragging +or using reorder arrows creates a strict priority sequence, preserving weights, +models, enabled state and compatibility declarations. Equal numeric priorities +configure rotation within a tier. The old local-storage-only priority preview is +removed. The Service Pools routing view uses equal-height collapsed cards with +one open route at a time and the same inline routing/billing table. + +Each pool attempt retains its own billing identity, credential class and payer. +Known consumption can charge more than one attempt. Within a selected billing +account, eligible allowances fund usage before credit grants and then wallet +credits. This funding order does not select another connection or payer; local +NyxID billing failures remain terminal. See [Service pools](SERVICE_POOLS.md). **Save as default** writes the current filters to the authenticated user's `users.profile_config.services_view` embedded blob. Search text is included. @@ -184,16 +207,17 @@ graph TB ## Service-Pool Routing Boundary -NyxID#974 was narrowed to a routing proof before adding a user-facing pool -surface. The proof is recorded in -[SERVICE_POOL_ROUTING_PROOF.md](SERVICE_POOL_ROUTING_PROOF.md). - -The important boundary is that `UserService` remains the concrete proxy target -member, while any future `ServicePool` must be selected inside -`proxy_service::resolve_proxy_target_from_user_service()`. The existing -`node_routing_service::resolve_node_route()` / `fallback_node_ids` layer remains -node failover below a selected `UserService`; it is not sufficient by itself to -balance multiple endpoint/credential instances behind one stable slug. +A `ServicePool` owns a stable slug and a set of concrete `UserService` members. +Priority ingress captures the pool revision, plans eligible candidates without +materializing credentials, and uses the exact selected member for authorization, +approval, dispatch and per-attempt billing. Round-robin and weighted strategies +retain single-member selection. See [Service pools](SERVICE_POOLS.md) and the +[architecture proof](SERVICE_POOL_ROUTING_PROOF.md). + +Node routing selects transport below that concrete member. Node failover does +not replace pool selection, change connection ownership, or combine billing +accounts. The card presents these saved pool policies without changing the normal +resolution behavior of individual connection slugs. ## Data Model Relationships diff --git a/docs/plans/local-routing-preview.md b/docs/plans/local-routing-preview.md index 53b8bb7c3..63c421873 100644 --- a/docs/plans/local-routing-preview.md +++ b/docs/plans/local-routing-preview.md @@ -1,7 +1,8 @@ # Local routing preview with production metadata -Refreshed 28 September 2026 from main `bef3511b` (frontend v0.30.2), including -the service icon registry and per-connection icon overrides from #1681. +Refreshed 1 October 2026 from main `be1883bd` (frontend v0.39.0), including +priority failover, round-robin/weighted rotation, operation-scoped pool health, +and the latest service icon registry. Grouped cards and service overview headers show the catalog icon; a group with one connection also uses its custom icon. Each connection row shows its own icon override, with the service glyph as fallback. Auto-connected and custom services @@ -31,6 +32,7 @@ The browser receives an opaque HttpOnly session. Restarting Vite or session expi requires signing in again. Do not use `?mock`; that is an unrelated fixture mode. The gateway accepts allowlisted metadata GETs, including `/service-pools`, +UUID-addressed pool details, candidates and health, candidate discovery, service history and Codex connection metadata from current main. Key and node detail reads require UUID paths. Pool mutation requests, execution, credential reveal and other mutation endpoints are blocked, except for the authenticated, @@ -58,8 +60,11 @@ cards and filter controls are also used by the normal production frontend. 5. Organization and Service have separate named selectors. Search matches names, slugs and owners. Only matching rows appear in an expanded card; the full group count and Service details link preserve context. -6. Pools keep the existing separate tab. Priority ordering there remains a local - preference preview; no production strategy change is implied by this UI edit. +6. Each service card has a Routing row linked to its real saved pools. Opening it + shows priority/rotation, the pool slug, eligibility/cooldown and billing per + member inside the card. The Service Pools tab uses the same route table in + expandable cards. Configure opens the real revision-checked pool editor; this + production-data preview allows drafting but disables Save and mutations. The service-grid changes passed 98 focused frontend tests, TypeScript checking, targeted lint and the production build. The running Vite server serves the updated @@ -90,24 +95,21 @@ inspection remains unavailable because no browser is connected. ## Readiness and execution boundary -The current backend does not expose per-connection live readiness. `GET /keys` -sets `connected: true` during response construction; `status: active`, a recent -`last_used_at`, online node presence, and shared OAuth application credentials -are not evidence of a working upstream credential. Even an expired OAuth token -may be refreshable. The preview never upgrades these metadata facts to Ready. - -Production pools currently use round-robin or weighted selection. Their resolver -filters enabled members and active services, but does not verify credential or -provider health at member selection. The proposed Priority strategy and the -shared readiness resolver described in `ai-service-connection-user-flow.md` are -backend follow-up work. The local preference order is not an effective routing -order until those checks exist. - -No live provider probes or execution requests are made by this preview. The -current user session may also differ from an agent key's access. Final selection -must check the execution caller, exact bindings, policy, approvals and funding, -then report the actual selected source. It must not replay an already-dispatched -request through a second identity. +Pool health comes from the saved configuration and the selected operation. Eligible +means the member passed metadata/admission inspection; it is not an upstream probe. +Do not infer working credentials from `status: active`, recent credential preparation +or node presence. Failed inspection shows unverified health, including when cached +results previously said eligible. + +Priority pools use the saved failover policy and durable cooldown; round-robin and +weighted pools select once. A null policy uses priority defaults. Direct connection +slugs keep their normal semantics. The UI does not create a routing policy merely +because multiple connections share a service card. See [Service pools](../SERVICE_POOLS.md) +for retry safety, per-attempt billing and supported entrances. + +The production metadata gateway remains read-only for pools. Saving settings and +resetting cooldowns use the normal backend endpoints outside this preview. The +preview does not synthesize routes or use local storage as execution configuration. ## Connection tables and standalone selectors — 28 September 2026 diff --git a/frontend/dev/routing-preview.ts b/frontend/dev/routing-preview.ts index 836039303..8f974d5d6 100644 --- a/frontend/dev/routing-preview.ts +++ b/frontend/dev/routing-preview.ts @@ -10,6 +10,7 @@ const READ_PATHS = new Set([ "/api/v1/api-keys", "/api/v1/user-services", "/api/v1/service-pools", + "/api/v1/service-pools/candidates", "/api/v1/catalog", "/api/v1/orgs", "/api/v1/nodes", @@ -23,10 +24,11 @@ const READ_PATHS = new Set([ const UUID_SEGMENT = "[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}"; const DETAIL_PATH = new RegExp(`^/api/v1/(?:keys|nodes)/${UUID_SEGMENT}$`); const AGENT_METADATA_PATH = new RegExp(`^/api/v1/api-keys/${UUID_SEGMENT}(?:/(?:bindings|usage))?$`); +const POOL_METADATA_PATH = new RegExp(`^/api/v1/service-pools/${UUID_SEGMENT}(?:/(?:candidates|health))?$`); const HISTORY_PATH = new RegExp(`^/api/v1/keys/${UUID_SEGMENT}/history$`); function isMetadataPath(path: string) { - return READ_PATHS.has(path) || AGENT_METADATA_PATH.test(path) || DETAIL_PATH.test(path) || HISTORY_PATH.test(path) || /^\/api\/v1\/catalog\/[a-z0-9][a-z0-9-]*$/.test(path); + return READ_PATHS.has(path) || POOL_METADATA_PATH.test(path) || AGENT_METADATA_PATH.test(path) || DETAIL_PATH.test(path) || HISTORY_PATH.test(path) || /^\/api\/v1\/catalog\/[a-z0-9][a-z0-9-]*$/.test(path); } function cookie(req: IncomingMessage, name: string): string | undefined { diff --git a/frontend/src/components/dashboard/grouped-service-cards.tsx b/frontend/src/components/dashboard/grouped-service-cards.tsx index 9f6cd1741..92f267802 100644 --- a/frontend/src/components/dashboard/grouped-service-cards.tsx +++ b/frontend/src/components/dashboard/grouped-service-cards.tsx @@ -14,6 +14,16 @@ import { ServiceViewToolbar } from "./service-view-toolbar"; import { ServiceConnectionTable } from "./service-connection-table"; import { ServiceAvatarStack } from "./service-avatar-stack"; import { ServiceBillingSummary } from "./service-billing-summary"; +import { GitBranch } from "lucide-react"; +import { + useServiceRoutingPools, + type ServiceRoutingPools, +} from "@/hooks/use-service-routing-pools"; +import { poolStrategyLabel } from "@/lib/service-pool-display"; +import type { ServicePool } from "@/schemas/pools"; +import { ServicePoolRoutingPanel } from "./service-pool-routing-panel"; +import { PoolEditor } from "./service-pools-tab"; +import { useAuthStore } from "@/stores/auth-store"; import { connectionSourceLabel as sourceLabel, connectionSource, @@ -47,7 +57,11 @@ function GroupCard({ search, renderConnectionActions, filtersRef, + routing, + allConnections, }: { + readonly routing: ServiceRoutingPools; + readonly allConnections: readonly KeyInfo[]; readonly group: ServiceConnectionGroup; readonly expanded: boolean; readonly onToggle: (card: HTMLElement | null) => void; @@ -57,6 +71,30 @@ function GroupCard({ readonly renderConnectionActions?: (key: KeyInfo) => ReactNode; readonly filtersRef: RefObject; }) { + const identity = useAuthStore((state) => state.user?.id); + const [routingOpen, setRoutingOpen] = useState(false); + const [requestedPanel, setRequestedPanel] = useState<{ + id: string; + view: "billing" | "requests" | "access"; + version: number; + } | null>(null); + const [routeId, setRouteId] = useState(null); + const [editingPool, setEditingPool] = useState(null); + const pools = routing.pools.filter((pool) => + pool.members.some((member) => + connections.some((key) => key.id === member.user_service_id), + ), + ); + const selectedPool = pools.find((pool) => pool.id === routeId) ?? pools[0]; + const routingLabel = routing.loading + ? "Loading routing…" + : pools.length === 1 + ? `${poolStrategyLabel(pools[0]!)}${pools[0]!.is_active ? "" : " · disabled"}` + : pools.length + ? `${pools.length} pools` + : routing.incomplete + ? "Pool access incomplete" + : "Individual slugs"; const contentId = useId(); const headingId = useId(); const cardRef = useRef(null); @@ -108,7 +146,14 @@ function GroupCard({ headerOffset.current = 0; header.style.translate = ""; }; - }, [expanded, filtersRef, connectionIds]); + }, [ + expanded, + filtersRef, + connectionIds, + routingOpen, + selectedPool?.id, + requestedPanel?.version, + ]); const count = group.connections.length; const matchingCount = connections.length; const sources = [ @@ -203,10 +248,6 @@ function GroupCard({ view: "billing" | "requests" | "access", connectionId?: string, ) => { - if (!expanded) { - onToggle(cardRef.current); - return; - } const lastConnection = view === "requests" ? connections @@ -221,18 +262,15 @@ function GroupCard({ b.request!.occurred_at.localeCompare(a.request!.occurred_at), )[0]?.id : undefined; - const buttons = [ - ...(cardRef.current?.querySelectorAll( - `[data-insight-view="${view}"]`, - ) ?? []), - ]; - const button = - buttons.find( - (item) => - item.dataset.connectionId === (connectionId ?? lastConnection), - ) ?? buttons[0]; - button?.click(); - button?.focus({ preventScroll: true }); + const id = connectionId ?? lastConnection ?? connections[0]?.id; + if (!id) return; + setRequestedPanel((current) => ({ + id, + view, + version: (current?.version ?? 0) + 1, + })); + setRoutingOpen(false); + if (!expanded) onToggle(cardRef.current); }; return ( @@ -265,7 +303,7 @@ function GroupCard({ expanded ? "rounded-t-xl shadow-sm" : "h-64 rounded-xl", )} > -
+
{!expanded && ( -

+

{search.trim() ? `Matches: ${connections.map((key) => key.label).join(" · ")}` : group.description} @@ -310,9 +348,7 @@ function GroupCard({

{!expanded && ( @@ -347,6 +383,28 @@ function GroupCard({ {agents.text} + onToggle(cardRef.current)} - aria-expanded={expanded} + onClick={() => { + if (expanded && routingOpen) setRoutingOpen(false); + else { + setRoutingOpen(false); + onToggle(cardRef.current); + } + }} + aria-expanded={expanded && !routingOpen} aria-controls={contentId} - aria-label={`${expanded ? "Collapse" : "Expand"} ${group.name} connections`} + aria-label={`${expanded && !routingOpen ? "Collapse" : "Expand"} ${group.name} connections`} > - {expanded + {expanded && !routingOpen ? "Hide connections" : `View ${matchingCount} ${matchingCount === 1 ? "connection" : "connections"}`} @@ -399,15 +463,74 @@ function GroupCard({ > {expanded && (
- + {routingOpen ? ( + <> +
+ {pools.map((pool) => ( + + ))} + + Manage pools + +
+ {selectedPool ? ( + setEditingPool(selectedPool)} + /> + ) : ( +

+ {routing.loading + ? "Loading saved pools…" + : routing.incomplete + ? "Some pools could not be inspected. Organization pool settings require admin access." + : "These connections use their individual slugs. Create a pool to give compatible connections one route with rotation or priority failover."} +

+ )} + {selectedPool && routing.incomplete && ( +

+ Additional organization pools may require admin access. +

+ )} + + ) : ( + + )}
)}
+ {editingPool && ( + setEditingPool(null)} + /> + )}
); } @@ -491,6 +614,7 @@ export function GroupedServiceCards({ .filter(({ matches }) => matches.length > 0); const matchingKeys = visible.flatMap(({ matches }) => matches); const insights = useServiceInsights(renderTable ? [] : keys); + const routing = useServiceRoutingPools(keys, !renderTable); return (
@@ -529,6 +653,8 @@ export function GroupedServiceCards({ group={group} expanded={expanded.includes(group.id)} insights={insights} + routing={routing} + allConnections={keys} connections={matches} search={filters.search} onToggle={(card) => diff --git a/frontend/src/components/dashboard/service-connection-table.tsx b/frontend/src/components/dashboard/service-connection-table.tsx index 4e06a8862..f10d37023 100644 --- a/frontend/src/components/dashboard/service-connection-table.tsx +++ b/frontend/src/components/dashboard/service-connection-table.tsx @@ -141,17 +141,19 @@ export function ServiceConnectionTable({ renderActions, onViewHistory, insights: suppliedInsights, + initialPanel = null, }: { readonly connections: readonly KeyInfo[]; readonly serviceName: string; readonly renderActions?: (connection: KeyInfo) => ReactNode; readonly onViewHistory?: (connection: KeyInfo) => void; readonly insights?: ServiceInsightsState; + readonly initialPanel?: { id: string; view: InsightPanel } | null; }) { const [open, setOpen] = useState<{ id: string; view: "details" | "history" | InsightPanel; - } | null>(null); + } | null>(initialPanel); const [observedAt] = useState(Date.now); const insights = useServiceInsights(connections, suppliedInsights); const toggle = (id: string, view: "details" | "history" | InsightPanel) => diff --git a/frontend/src/components/dashboard/service-insight-panels.tsx b/frontend/src/components/dashboard/service-insight-panels.tsx index 27bf1925f..1a9086153 100644 --- a/frontend/src/components/dashboard/service-insight-panels.tsx +++ b/frontend/src/components/dashboard/service-insight-panels.tsx @@ -181,8 +181,9 @@ function ConnectionBillingPanel({ per request; the grant used is not reported in this preview.

- If a request fails, NyxID does not retry the other connections - in this service. + A direct connection slug targets this connection. A priority + pool slug can fail over according to its saved policy, with + each attempted connection using its own billing rules.

)} diff --git a/frontend/src/components/dashboard/service-pool-cards.tsx b/frontend/src/components/dashboard/service-pool-cards.tsx new file mode 100644 index 000000000..ab34b56aa --- /dev/null +++ b/frontend/src/components/dashboard/service-pool-cards.tsx @@ -0,0 +1,128 @@ +import { useState, type ReactNode } from "react"; +import { ChevronRight, GitBranch } from "lucide-react"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { useKeys } from "@/hooks/use-keys"; +import { useServiceInsights } from "@/hooks/use-service-insights"; +import { useServiceCardTransition } from "@/hooks/use-service-card-transition"; +import { + poolFailoverLabel, + poolStrategyLabel, +} from "@/lib/service-pool-display"; +import { cn } from "@/lib/utils"; +import type { ServicePool } from "@/schemas/pools"; +import { ServicePoolRoutingPanel } from "./service-pool-routing-panel"; + +export function ServicePoolCards({ + pools, + actions, + onEdit, +}: { + readonly pools: readonly ServicePool[]; + readonly actions: (pool: ServicePool) => ReactNode; + readonly onEdit: (pool: ServicePool) => void; +}) { + const [open, setOpen] = useState(null); + const transition = useServiceCardTransition(); + const keys = useKeys(); + const connections = keys.isError ? [] : (keys.data ?? []); + const insights = useServiceInsights(connections); + return ( +
+ {pools.map((pool) => { + const expanded = open === pool.id; + return ( +
+
+
+
+
+ +
+
+

+ {pool.name} +

+ + {pool.slug} + +
+ {actions(pool)} +
+

+ {pool.description || + "One route across compatible connections"} +

+
+
+ {poolStrategyLabel(pool)} + + {pool.is_active ? "Enabled" : "Disabled"} + +
+

{poolFailoverLabel(pool)}

+

+ {pool.members.filter((member) => member.enabled).length} /{" "} + {pool.members.length} members enabled ·{" "} + {pool.member_contract === "ai_chat" + ? "AI chat" + : "Same API"} +

+

+ Billing follows each attempted connection +

+
+
+
+ + +
+
+ {expanded && ( +
+ onEdit(pool)} + /> +
+ )} +
+ ); + })} +
+ ); +} diff --git a/frontend/src/components/dashboard/service-pool-routing-panel.test.tsx b/frontend/src/components/dashboard/service-pool-routing-panel.test.tsx new file mode 100644 index 000000000..787ca7628 --- /dev/null +++ b/frontend/src/components/dashboard/service-pool-routing-panel.test.tsx @@ -0,0 +1,122 @@ +import { render, screen, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { ServicePoolRoutingPanel } from "./service-pool-routing-panel"; +import type { PoolCandidate, ServicePool } from "@/schemas/pools"; +const state = vi.hoisted(() => ({ + health: vi.fn(), + result: { + data: { candidates: [] as PoolCandidate[] }, + isError: false, + isLoading: false, + }, +})); +vi.mock("@/hooks/use-pools", () => ({ + usePoolHealth: (options: unknown) => { + state.health(options); + return state.result; + }, +})); +const pool: ServicePool = { + id: "pool", + user_id: "me", + name: "Twitter route", + slug: "twitter-route", + strategy: "priority", + member_contract: "same_api", + config_revision: 4, + tier_balance: "round_robin", + failover: null, + members: [ + { user_service_id: "backup", enabled: true, priority: 10, weight: 1 }, + { user_service_id: "platform", enabled: true, priority: 0, weight: 1 }, + ], + rr_counter: 0, + is_active: true, + created_at: "2026-01-01", + updated_at: "2026-01-01", +}; +function view(overrides: Partial = {}) { + return ( + + ); +} +beforeEach(() => { + state.result = { data: { candidates: [] }, isError: false, isLoading: false }; + state.health.mockClear(); +}); +describe("inline saved pool route", () => { + it("shows actual priorities and unknown health without inventing free billing or readiness", () => { + render(view()); + const rows = within(screen.getByRole("table")).getAllByRole("row"); + expect(within(rows[1]!).getByText("Priority 0")).toBeVisible(); + expect(within(rows[2]!).getByText("Priority 10")).toBeVisible(); + expect(screen.getAllByText("Not inspected")).toHaveLength(2); + expect(screen.queryByText("No NyxID charge")).not.toBeInTheDocument(); + expect( + screen.getByText(/Platform-key usage bills the acting person/), + ).toBeVisible(); + }); + it("inspects the submitted operation without issuing service calls while editing the path", async () => { + const user = userEvent.setup(); + render(view()); + await user.selectOptions( + screen.getByLabelText("Method for Twitter route"), + "GET", + ); + await user.clear(screen.getByLabelText("Operation path for Twitter route")); + await user.type( + screen.getByLabelText("Operation path for Twitter route"), + "/2/users/me", + ); + expect(state.health).toHaveBeenLastCalledWith( + expect.objectContaining({ method: "POST", path: "/" }), + ); + await user.click(screen.getByRole("button", { name: "Inspect" })); + expect(state.health).toHaveBeenLastCalledWith( + expect.objectContaining({ method: "GET", path: "/2/users/me" }), + ); + }); + it("does not retain eligibility after an inspection error", () => { + state.result = { + data: { + candidates: [ + { + user_service_id: "platform", + eligible: true, + reason: null, + consecutive_failures: 0, + } as PoolCandidate, + ], + }, + isError: true, + isLoading: false, + }; + render(view()); + expect( + screen.queryByText("Eligible", { exact: true }), + ).not.toBeInTheDocument(); + expect(screen.getByText(/connection health is unverified/)).toBeVisible(); + }); + it("shows AI model mapping and the gateway alias", () => { + render( + view({ + member_contract: "ai_chat", + members: [{ ...pool.members[0]!, model: "model-b" }], + }), + ); + expect(screen.getByText("pool:twitter-route")).toBeVisible(); + expect(screen.getByText("Model: model-b")).toBeVisible(); + expect(state.health).toHaveBeenLastCalledWith( + expect.objectContaining({ method: "POST", path: "chat/completions" }), + ); + }); +}); diff --git a/frontend/src/components/dashboard/service-pool-routing-panel.tsx b/frontend/src/components/dashboard/service-pool-routing-panel.tsx new file mode 100644 index 000000000..21718a147 --- /dev/null +++ b/frontend/src/components/dashboard/service-pool-routing-panel.tsx @@ -0,0 +1,356 @@ +import { useState } from "react"; +import { GitBranch, Settings2 } from "lucide-react"; +import { ServiceIcon } from "@/components/service-icon"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { Input } from "@/components/ui/input"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { usePoolHealth } from "@/hooks/use-pools"; +import type { ServiceInsightsState } from "@/hooks/use-service-insights"; +import { + billingAccountLabel, + billingModelLabel, + credentialLabel, + nyxidChargeLabel, +} from "@/lib/service-insights"; +import { + orderedPoolMembers, + poolFailoverLabel, + poolMemberStatus, + poolStrategyLabel, +} from "@/lib/service-pool-display"; +import { connectionSource, connectionSourceLabel } from "@/lib/service-view"; +import { defaultFailoverPolicy, type ServicePool } from "@/schemas/pools"; +import type { KeyInfo } from "@/types/keys"; +import { ServiceOwnerAvatar } from "./service-owner-avatar"; + +export function ServicePoolRoutingPanel({ + pool, + connections, + insights, + onEdit, +}: { + readonly pool: ServicePool; + readonly connections: readonly KeyInfo[]; + readonly insights: ServiceInsightsState; + readonly onEdit?: () => void; +}) { + const ai = pool.strategy === "priority" && pool.member_contract === "ai_chat"; + const [method, setMethod] = useState("POST"); + const [path, setPath] = useState("/"); + const [operation, setOperation] = useState({ method: "POST", path: "/" }); + const health = usePoolHealth({ + poolId: pool.id, + contract: pool.member_contract, + method: ai ? "POST" : operation.method, + path: ai ? "chat/completions" : operation.path, + }); + const inspected = new Map( + (health.isError ? [] : (health.data?.candidates ?? [])).map((candidate) => [ + candidate.user_service_id, + candidate, + ]), + ); + const keys = new Map(connections.map((key) => [key.id, key])); + const members = orderedPoolMembers(pool); + const policy = pool.failover ?? defaultFailoverPolicy; + const priority = pool.strategy === "priority"; + + return ( +
+
+
+
+
+ + /api/v1/proxy/s/{pool.slug} + + {ai && ( +

+ AI chat · Gateway model pool:{pool.slug} +

+ )} +
+ {onEdit && ( + + )} +
+
+ {poolFailoverLabel(pool)} + {priority && ( + <> + + {pool.tier_balance === "weighted" ? "Weighted" : "Round-robin"}{" "} + within each priority + + + {policy.per_attempt_timeout_ms / 1000}s per attempt ·{" "} + {policy.overall_deadline_ms / 1000}s total + + + )} +
+

+ {priority + ? "Lower priority runs first; ineligible and cooling connections are skipped. Use this pool slug to apply its routing policy." + : "Each request selects one eligible connection. Rotation applies when you call this pool slug."} +

+ {ai ? ( +

+ Eligibility for POST chat/completions · refreshed every 15s +

+ ) : ( +
{ + event.preventDefault(); + setOperation({ method, path: path.trim() || "/" }); + }} + > + + + +

+ Showing {operation.method} {operation.path} · metadata only, no + service call · refreshed every 15s +

+
+ )} + {health.isError && ( +

+ Eligibility could not be loaded. Saved routing is shown; connection + health is unverified. +

+ )} +
+ + + + + {priority ? "Priority / Weight" : "Rotation"} + + Connection / Credential + Billing / Payer + Eligibility / Cooldown + + + + {members.map((member, index) => { + const key = keys.get(member.user_service_id); + const billing = insights.connections.get( + member.user_service_id, + )?.billing; + const candidate = inspected.get(member.user_service_id); + const org = + key?.credential_source?.type === "org" + ? key.credential_source + : null; + const status = poolMemberStatus(member, candidate); + return ( + + +

+ {priority + ? `Priority ${member.priority ?? 0}` + : pool.strategy === "weighted" + ? `Weight ${member.weight}` + : `Member ${index + 1}`} +

+ {priority && pool.tier_balance === "weighted" && ( +

+ Weight {member.weight} +

+ )} +
+ +
+ {key && ( + + )} + + {key?.label ?? + candidate?.slug ?? + "Unavailable connection"} + +
+ {(key?.slug || candidate?.slug) && ( + + {key?.slug ?? candidate?.slug} + + )} + {key && ( +
+ + {connectionSourceLabel(key)} +
+ )} +

+ {key + ? credentialLabel(key, billing) + : "Credential not reported"} +

+ {ai && ( +

+ Model: {member.model ?? "Not configured"} +

+ )} +
+ +

{billingModelLabel(billing)}

+

+ {billing?.context === "configuration" + ? "Expected payer" + : "Payer"} + : {billingAccountLabel(billing)} +

+

+ {billing + ? nyxidChargeLabel(billing) + : "Rate not reported"} +

+
+ + + {health.isLoading && member.enabled + ? "Inspecting…" + : status} + + {candidate && ( +

+ {candidate.consecutive_failures} failures + {candidate.last_status + ? ` · Last HTTP ${candidate.last_status}` + : ""} +

+ )} + {candidate?.cooldown_until && ( +

+ Cooldown until{" "} + {new Date(candidate.cooldown_until).toLocaleString()} +

+ )} + {!pool.is_active && ( +

+ Pool disabled · no execution +

+ )} +
+
+ ); + })} +
+
+ {!members.length && ( +

+ No connections in this pool. +

+ )} +
+

+ Each attempted connection uses its own rates and billing account. + Reported usage can charge more than one attempt. Within that account: + eligible allowance → credit grants → wallet credits. Platform-key usage + bills the acting person. +

+ {priority && ( +
+ + Failover conditions + +
+

+ Retry causes:{" "} + {policy.retry_on.length + ? policy.retry_on + .map((trigger) => trigger.replaceAll("_", " ")) + .join(", ") + : "None"} + . +

+

+ Cooldown: {policy.cooldown.base_ms / 1000}– + {policy.cooldown.max_ms / 1000}s after{" "} + {policy.cooldown.failures_to_open} failure(s) + {policy.cooldown.honor_retry_after + ? "; honors provider Retry-After" + : ""} + . +

+

+ {policy.retry_ambiguous_dispatch + ? "Ambiguous replay enabled: a timeout or server error can retry completed work and incur extra charges." + : "Ambiguous replay off: a POST timeout or server error does not automatically retry work that may already have run."}{" "} + Failover stops once response data reaches the caller. NyxID + access, approval and billing errors stop the request. +

+
+
+ )} +
+ ); +} diff --git a/frontend/src/components/dashboard/service-pool-routing-preview.tsx b/frontend/src/components/dashboard/service-pool-routing-preview.tsx deleted file mode 100644 index 5a5af39e8..000000000 --- a/frontend/src/components/dashboard/service-pool-routing-preview.tsx +++ /dev/null @@ -1,333 +0,0 @@ -import { useState } from "react"; -import { ArrowDown, ArrowUp, GripVertical, RefreshCw } from "lucide-react"; -import { useServicePools } from "@/hooks/use-pools"; -import { useKeys } from "@/hooks/use-keys"; -import { useUserServices } from "@/hooks/use-user-services"; -import { useAuthStore } from "@/stores/auth-store"; -import { Button } from "@/components/ui/button"; -import { Badge } from "@/components/ui/badge"; -import { Skeleton } from "@/components/ui/skeleton"; -import { cn } from "@/lib/utils"; -import type { ServicePool } from "@/schemas/pools"; -import { - classifyConnection, - moveItem, - orderedIds, - readPreferences, - savePreferences, - type PreviewPreferences, - type RoutingCandidate, -} from "@/lib/service-routing-preview"; -import { - ConnectionCard, - type RoutingPreviewProps, -} from "./service-routing-preview"; - -function PoolCard({ - pool, - candidates, - preference, - onChange, - renderConnection, -}: RoutingPreviewProps & { - readonly pool: ServicePool; - readonly candidates: ReadonlyMap; - readonly preference: PreviewPreferences["pools"][string] | undefined; - readonly onChange: (value: PreviewPreferences["pools"][string]) => void; -}) { - const [dragged, setDragged] = useState(null); - const [over, setOver] = useState(null); - const [announcement, setAnnouncement] = useState(""); - const priority = preference?.priority ?? false; - const ids = orderedIds( - pool.members.map((member) => member.user_service_id), - priority ? (preference?.order ?? []) : [], - ); - const strategy = pool.strategy === "weighted" ? "Weighted" : "Round robin"; - const label = (id: string) => - candidates.get(id)?.key.label ?? "Missing service"; - - function reorder(from: string, to: string) { - if ( - !priority || - !pool.is_active || - !ids.includes(from) || - !ids.includes(to) - ) - return; - const next = moveItem(ids, from, to); - onChange({ priority, order: next }); - setAnnouncement( - `${label(from)} moved to position ${next.indexOf(from) + 1} in ${pool.name}.`, - ); - } - - return ( -
-
-
-
-

{pool.name}

- {pool.members.length} members - {!pool.is_active && Disabled} -
- - /proxy/s/{pool.slug} - -
- -
- {pool.description && ( -

{pool.description}

- )} - {priority && ( -

- Drag members to set a preference. Working connections still need - verification. -

- )} - - {announcement} - -
    - {ids.map((id, index) => { - const member = pool.members.find( - (item) => item.user_service_id === id, - )!; - const candidate = candidates.get(id); - const reorderable = priority && pool.is_active; - return ( -
  1. { - if (!reorderable || !dragged) return; - event.preventDefault(); - event.dataTransfer.dropEffect = "move"; - setOver(id); - }} - onDrop={(event) => { - if (!reorderable || !dragged) return; - event.preventDefault(); - if ( - event.dataTransfer.getData("application/x-nyxid-pool") === - pool.id - ) - reorder(dragged, id); - setDragged(null); - setOver(null); - }} - > -
    - - {priority - ? `Preference ${index + 1}` - : pool.strategy === "weighted" - ? `Weight ${member.weight}` - : `Member ${index + 1}`} - -
    - {!member.enabled && ( - Excluded - )} - {priority && ( - <> - - - - - )} -
    -
    - {candidate ? ( - - ) : ( -
    - Service unavailable - {id} -
    - )} -
  2. - ); - })} -
- {ids.length === 0 && ( -

- No members in this pool. -

- )} -
- ); -} - -function PoolRoutingContent({ - userId, - renderConnection, -}: RoutingPreviewProps & { readonly userId: string | undefined }) { - const pools = useServicePools(); - const keys = useKeys(); - const services = useUserServices(); - const [preferences, setPreferences] = useState(() => readPreferences(userId)); - const [mountedAt] = useState(Date.now); - const candidates = new Map( - (keys.data ?? []).map((key) => [ - key.id, - classifyConnection( - key, - services.data ?? [], - keys.dataUpdatedAt || mountedAt, - ), - ]), - ); - - if (pools.isLoading || keys.isLoading) - return ; - if (pools.error || keys.error) - return ( -
- Pool data could not be loaded.{" "} - -
- ); - return ( -
-
- Your pools · Local preview · Changes stay in this browser - -
- {(pools.data ?? []).map((pool) => ( - { - const stored = readPreferences(userId); - const next = { - ...stored, - pools: { ...stored.pools, [pool.id]: value }, - }; - setPreferences(next); - savePreferences(userId, next); - }} - /> - ))} - {pools.data?.length === 0 && ( -

- No service pools in your account. -

- )} -
- ); -} - -export default function ServicePoolRoutingPreview(props: RoutingPreviewProps) { - const userId = useAuthStore((state) => state.user?.id); - return ( - - ); -} diff --git a/frontend/src/components/dashboard/service-pools-tab.test.tsx b/frontend/src/components/dashboard/service-pools-tab.test.tsx index f13d7949c..d3f69641d 100644 --- a/frontend/src/components/dashboard/service-pools-tab.test.tsx +++ b/frontend/src/components/dashboard/service-pools-tab.test.tsx @@ -383,3 +383,67 @@ it("keeps labels from page two and saved IDs when a new candidate query resets p expect(screen.getByText("Saved off-page member")).toBeVisible(); expect(screen.queryByText(/Saved member;/)).not.toBeInTheDocument(); }); + +describe("pool member order in the routing editor", () => { + it("saves a reordered route atomically with its original revision and preserves member settings", async () => { + const user = userEvent.setup(); + render( + , + ); + await user.click(screen.getByRole("button", { name: "Move member 2 up" })); + await waitFor(() => + expect(screen.getByRole("button", { name: "Save" })).toBeEnabled(), + ); + expect(mocks.update).not.toHaveBeenCalled(); + await user.click(screen.getByRole("button", { name: "Save" })); + await waitFor(() => expect(mocks.update).toHaveBeenCalledTimes(1)); + expect(mocks.update).toHaveBeenCalledWith( + expect.objectContaining({ + poolId: "pool-id", + expected_revision: 17, + strategy: "priority", + members: [ + { + user_service_id: "second", + priority: 0, + weight: 5, + enabled: false, + model: null, + same_api_compatible: true, + }, + { + user_service_id: "first", + priority: 1, + weight: 2, + enabled: true, + model: null, + same_api_compatible: true, + }, + ], + }), + ); + }); +}); diff --git a/frontend/src/components/dashboard/service-pools-tab.tsx b/frontend/src/components/dashboard/service-pools-tab.tsx index dde8009c9..048020750 100644 --- a/frontend/src/components/dashboard/service-pools-tab.tsx +++ b/frontend/src/components/dashboard/service-pools-tab.tsx @@ -1,7 +1,9 @@ import { useState } from "react"; import { zodResolver } from "@hookform/resolvers/zod"; import { useWatch } from "react-hook-form"; -import { MoreVertical } from "lucide-react"; +import { ArrowDown, ArrowUp, GripVertical, MoreVertical } from "lucide-react"; +import { ServicePoolCards } from "./service-pool-cards"; +import { reorderPoolMembers } from "@/lib/service-pool-display"; import { toast } from "sonner"; import { ApiError } from "@/lib/api-client"; import { firstNestedErrorMessage } from "@/lib/form-errors"; @@ -73,9 +75,12 @@ import { } from "@/schemas/pools"; interface ServicePoolsTabProps { + readonly layout?: "cards" | "table"; readonly createOpen: boolean; readonly onCreateOpenChange: (open: boolean) => void; } +const readOnlyPreview = + import.meta.env.DEV && import.meta.env.VITE_ROUTING_PREVIEW === "1"; const strategyLabels = { priority: "Priority", round_robin: "Round Robin", @@ -329,6 +334,18 @@ export function PoolEditor({ const members = values.members ?? []; const priority = values.strategy === "priority"; const [search, setSearch] = useState(""); + const [dragged, setDragged] = useState(null); + const [orderAnnouncement, setOrderAnnouncement] = useState(""); + function moveMember(from: string, to: string) { + if (from === to || form.getValues("strategy") !== "priority") return; + const next = reorderPoolMembers( + { strategy: "priority", members: form.getValues("members") }, + from, + to, + ); + form.setValue("members", next); + setOrderAnnouncement("Priority order updated. Save to apply this route."); + } const [candidateMethod, setCandidateMethod] = useState("POST"); const [candidatePath, setCandidatePath] = useState("/"); const candidates = usePoolCandidates({ @@ -388,6 +405,7 @@ export function PoolEditor({ } } async function save(input: CreateServicePoolInput) { + if (readOnlyPreview) return; try { const normalized = { ...input, @@ -524,6 +542,15 @@ export function PoolEditor({ />

Members

+ {priority && ( +

+ Drag or use arrows to set a strict priority order. To rotate + within a tier, give those members the same priority number. +

+ )} + + {orderAnnouncement} + {members.map((member, index) => { const id = member.user_service_id!; const candidate = selectedRows.get(id); @@ -531,9 +558,91 @@ export function PoolEditor({
{ + if (priority && dragged) event.preventDefault(); + }} + onDrop={(event) => { + event.preventDefault(); + if ( + dragged && + event.dataTransfer.getData( + "application/x-nyxid-pool", + ) === (pool?.id ?? "new") + ) + moveMember(dragged, id); + setDragged(null); + }} >
-
+ {priority && ( +
+ + + +
+ )} +

{candidate?.slug ?? selectedLabels[id] ?? @@ -735,6 +844,12 @@ export function PoolEditor({ )}

)} + {readOnlyPreview && ( +

+ Production preview: inspect or draft settings here. Saving pool + changes is disabled. +

+ )} {rootError && } @@ -842,7 +961,7 @@ export function PoolHealthDialog({ onClick={() => { void clear(row.user_service_id); }} - disabled={reset.isPending} + disabled={readOnlyPreview || reset.isPending} > Reset @@ -858,6 +977,7 @@ export function PoolHealthDialog({ void clear(); }} isLoading={reset.isPending} + disabled={readOnlyPreview} > Reset all cooldowns @@ -868,6 +988,7 @@ export function PoolHealthDialog({ } export function ServicePoolsTab({ + layout = "table", createOpen, onCreateOpenChange, }: ServicePoolsTabProps) { @@ -911,6 +1032,7 @@ export function ServicePoolsTab({ Health { void toggle(pool); }} @@ -919,6 +1041,7 @@ export function ServicePoolsTab({ setDeleting(pool)} > Delete @@ -968,65 +1091,74 @@ export function ServicePoolsTab({ connections.
)} - {(pools.data?.length ?? 0) > 0 && ( - <> -
- - - - Name - Route - Strategy / contract - Members - Status - Actions - - - - {pools.data?.map((pool) => ( - - {pool.name} - {pool.slug} - - {strategyLabels[pool.strategy]} /{" "} - {pool.member_contract === "ai_chat" - ? "AI chat" - : "Same API"} - - - {pool.members.filter((m) => m.enabled).length} /{" "} - {pool.members.length} enabled - - - - {pool.is_active ? "Enabled" : "Disabled"} - - - {actions(pool)} + {(pools.data?.length ?? 0) > 0 && + (layout === "cards" ? ( + + ) : ( + <> +
+
+ + + Name + Route + Strategy / contract + Members + Status + Actions - ))} - -
-
-
- {pools.data?.map((pool) => ( -
-
-

{pool.name}

-

- {pool.slug} · {strategyLabels[pool.strategy]} ·{" "} - {pool.is_active ? "Enabled" : "Disabled"} -

+ + + {pools.data?.map((pool) => ( + + {pool.name} + {pool.slug} + + {strategyLabels[pool.strategy]} /{" "} + {pool.member_contract === "ai_chat" + ? "AI chat" + : "Same API"} + + + {pool.members.filter((m) => m.enabled).length} /{" "} + {pool.members.length} enabled + + + + {pool.is_active ? "Enabled" : "Disabled"} + + + {actions(pool)} + + ))} + + +
+
+ {pools.data?.map((pool) => ( +
+
+

{pool.name}

+

+ {pool.slug} · {strategyLabels[pool.strategy]} ·{" "} + {pool.is_active ? "Enabled" : "Disabled"} +

+
+ {actions(pool)}
- {actions(pool)} -
- ))} -
- - )} + ))} +
+ + ))} {createOpen && ( onCreateOpenChange(false)} /> )} diff --git a/frontend/src/components/dashboard/service-routing-preview.test.tsx b/frontend/src/components/dashboard/service-routing-preview.test.tsx index 1cc98cd5b..f0a0d07fb 100644 --- a/frontend/src/components/dashboard/service-routing-preview.test.tsx +++ b/frontend/src/components/dashboard/service-routing-preview.test.tsx @@ -10,7 +10,6 @@ import { import userEvent from "@testing-library/user-event"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { ReactNode } from "react"; -import type { RoutingCandidate } from "@/lib/service-routing-preview"; import type { KeyInfo } from "@/types/keys"; import type { ServicePool } from "@/schemas/pools"; import type { ServiceInsight } from "@/schemas/service-insights"; @@ -83,7 +82,7 @@ const { records, account, poolState, insightConnections } = vi.hoisted(() => ({ vi.mock("@tanstack/react-router", () => ({ Link: ({ children, - params, + params = {}, ...props }: { children: ReactNode; @@ -111,6 +110,18 @@ vi.mock("@/hooks/use-user-services", () => ({ })); vi.mock("@/hooks/use-pools", () => ({ useServicePools: () => ({ ...poolState, refetch: vi.fn() }), + usePoolHealth: () => ({ + data: { candidates: [] }, + isError: false, + isLoading: false, + }), +})); +vi.mock("@/hooks/use-service-routing-pools", () => ({ + useServiceRoutingPools: () => ({ + pools: poolState.error ? [] : poolState.data, + loading: false, + incomplete: !!poolState.error, + }), })); vi.mock("@/stores/auth-store", () => ({ useAuthStore: (selector: (state: { user: { id: string } }) => unknown) => @@ -119,7 +130,6 @@ vi.mock("@/stores/auth-store", () => ({ import ServiceRoutingPreview from "./service-routing-preview"; import { useServiceCardView } from "@/stores/service-card-view-store"; -import ServicePoolRoutingPreview from "./service-pool-routing-preview"; vi.mock("@/hooks/use-service-insights", () => ({ useServiceInsights: () => ({ @@ -129,15 +139,9 @@ vi.mock("@/hooks/use-service-insights", () => ({ }), })); vi.mock("@/hooks/use-nodes", () => ({ useNodes: () => ({ data: [] }) })); -function renderConnection(candidate: RoutingCandidate) { - return {candidate.key.label}; -} function preview() { return ; } -function poolPreview() { - return ; -} function pool(id: string, members = ["mine", "team"]): ServicePool { return { id, @@ -262,7 +266,7 @@ describe("live grouped services", () => { }), ).toBeVisible(); expect( - within(card).getByText(/If a request fails, NyxID does not retry/), + within(card).getByText(/A priority pool slug can fail over/), ).toBeVisible(); expect( within(card).getByText( @@ -1204,109 +1208,74 @@ describe("live grouped services", () => { }); }); -describe("pool member ordering", () => { - it("never infers a pool from connections that share a catalog service", () => { - render(poolPreview()); - expect(screen.getByText("No service pools in your account.")).toBeVisible(); - expect( - screen.queryByRole("button", { name: /Drag/ }), - ).not.toBeInTheDocument(); - }); - - it("requires opting into priority, scopes drag to one actual pool, and preserves live records", async () => { - const user = userEvent.setup(); - poolState.data = [pool("First"), pool("Second")]; - const before = JSON.stringify(poolState.data); - render(poolPreview()); - expect( - screen.queryByRole("button", { name: /Drag/ }), - ).not.toBeInTheDocument(); - await user.selectOptions( - screen.getByRole("combobox", { name: "Selection for First" }), - "priority", - ); - await user.selectOptions( - screen.getByRole("combobox", { name: "Selection for Second" }), - "priority", - ); - const transferData = new Map(); - const transfer = { - setData: (type: string, value: string) => transferData.set(type, value), - getData: (type: string) => transferData.get(type), - effectAllowed: "", - dropEffect: "", - }; - fireEvent.dragStart( - screen.getByRole("button", { name: "Drag Team account in First" }), - { dataTransfer: transfer }, +describe("saved routing in service cards", () => { + it("does not infer a pool from grouped connections", async () => { + render(preview()); + expect(screen.getByText("Individual slugs")).toBeVisible(); + await userEvent.click( + screen.getByRole("button", { name: "Show routing for OpenAI" }), ); - const foreign = screen - .getByRole("button", { name: "Drag Personal account in Second" }) - .closest("li")!; - fireEvent.dragOver(foreign, { dataTransfer: transfer }); - fireEvent.drop(foreign, { dataTransfer: transfer }); expect( - within( - within( - screen.getByRole("list", { name: "Members of Second" }), - ).getAllByRole("listitem")[0]!, - ).getByText("Personal account"), + screen.getByText(/Create a pool to give compatible connections/), ).toBeVisible(); - const target = screen - .getByRole("button", { name: "Drag Personal account in First" }) - .closest("li")!; - fireEvent.dragOver(target, { dataTransfer: transfer }); - fireEvent.drop(target, { dataTransfer: transfer }); + }); + it("opens actual priority failover and the full pool member table inside the card", async () => { + poolState.data = [ + { ...pool("Reliable"), strategy: "priority", failover: null }, + ]; + render(preview()); + await userEvent.click( + screen.getByRole("button", { name: "Show routing for OpenAI" }), + ); + const card = screen.getByRole("region", { name: "OpenAI" }); + expect(within(card).getByText("/api/v1/proxy/s/reliable")).toBeVisible(); + expect(within(card).getByText("Failover · up to 3 attempts")).toBeVisible(); expect( - within( - within( - screen.getByRole("list", { name: "Members of First" }), - ).getAllByRole("listitem")[0]!, - ).getByText("Team account"), + within(card).getByRole("table", { name: "Reliable route members" }), ).toBeVisible(); - expect(JSON.stringify(poolState.data)).toBe(before); + expect(within(card).getByText("Personal account")).toBeVisible(); + expect(within(card).getByText("Team account")).toBeVisible(); expect( - screen.queryByText(/Ready via|Selected connection/), + within(card).queryByRole("table", { name: "OpenAI connections" }), ).not.toBeInTheDocument(); - }); - - it("supports keyboard ordering and persists independently for each pool and account", async () => { - const user = userEvent.setup(); - poolState.data = [pool("First"), pool("Second")]; - const mounted = render(poolPreview()); - await user.selectOptions( - screen.getByRole("combobox", { name: "Selection for First" }), - "priority", - ); - fireEvent.keyDown( - screen.getByRole("button", { name: "Drag Team account in First" }), - { key: "ArrowUp" }, + await userEvent.click( + within(card).getByRole("button", { name: "Expand OpenAI connections" }), ); - mounted.unmount(); - const next = render(poolPreview()); expect( - within( - within( - screen.getByRole("list", { name: "Members of First" }), - ).getAllByRole("listitem")[0]!, - ).getByText("Team account"), + within(card).getByRole("table", { name: "OpenAI connections" }), ).toBeVisible(); + }); + it("opens connection billing in one click when switching from the route table", async () => { + poolState.data = [{ ...pool("Reliable"), strategy: "priority" }]; + for (const connection of records) + insightConnections.set(connection.id, { + service_id: connection.id, + billing: configuredBilling(connection), + usage: null, + }); + render(preview()); + await userEvent.click( + screen.getByRole("button", { name: "Show routing for OpenAI" }), + ); + await userEvent.click( + screen.getByRole("button", { name: "Show Personal billing for OpenAI" }), + ); + expect(screen.getByText("Billing flow")).toBeVisible(); expect( - screen.getByRole("combobox", { name: "Selection for Second" }), - ).toHaveValue("current"); - account.id = "user-b"; - next.rerender(poolPreview()); - expect( - screen.getByRole("combobox", { name: "Selection for First" }), - ).toHaveValue("current"); + screen.queryByRole("table", { name: "Reliable route members" }), + ).not.toBeInTheDocument(); }); - - it("shows excluded and missing members without selecting them or fabricating cards", async () => { - poolState.data = [pool("First", ["mine", "missing"])]; - poolState.data[0]!.members[0]!.enabled = false; - render(poolPreview()); - expect(screen.getByText("Excluded")).toBeVisible(); - expect(screen.getByText("Service unavailable")).toBeVisible(); - expect(screen.queryByText("Team account")).not.toBeInTheDocument(); + it("does not describe weighted rotation as failover", async () => { + poolState.data = [{ ...pool("Rotate"), strategy: "weighted" }]; + render(preview()); + await userEvent.click( + screen.getByRole("button", { name: "Show routing for OpenAI" }), + ); + expect(screen.getByText("Single attempt · no failover")).toBeVisible(); + }); + it("reports pool read failures instead of claiming individual routes only", async () => { + poolState.error = new Error("Unavailable"); + render(preview()); + expect(screen.getByText("Pool access incomplete")).toBeVisible(); }); }); diff --git a/frontend/src/components/dashboard/service-routing-preview.tsx b/frontend/src/components/dashboard/service-routing-preview.tsx index dbac50fca..1ef025f0c 100644 --- a/frontend/src/components/dashboard/service-routing-preview.tsx +++ b/frontend/src/components/dashboard/service-routing-preview.tsx @@ -1,73 +1,11 @@ -import { canEditConnection } from "@/lib/connection-access"; import { useState, type ReactNode } from "react"; -import { Link } from "@tanstack/react-router"; -import { ChevronRight } from "lucide-react"; import { useKeys, useCatalog } from "@/hooks/use-keys"; import { useUserServices } from "@/hooks/use-user-services"; import { Button } from "@/components/ui/button"; import { Skeleton } from "@/components/ui/skeleton"; import { GroupedServiceCards } from "./grouped-service-cards"; import type { KeyInfo } from "@/types/keys"; -import { - classifyConnection, - type RoutingCandidate, -} from "@/lib/service-routing-preview"; - -export interface RoutingPreviewProps { - readonly renderConnection: (candidate: RoutingCandidate) => ReactNode; -} - -export function ConnectionCard({ - candidate, - renderConnection, -}: RoutingPreviewProps & { readonly candidate: RoutingCandidate }) { - const key = candidate.key; - return ( -
- {renderConnection(candidate)} -
- {candidate.owner} - - {candidate.reason} - -
-
- - Details - -
- {key.description &&

{key.description}

} - {canEditConnection(key) &&

Auth: {key.auth_method}

} - {key.last_used_at && ( -

- Credential last prepared:{" "} - {new Date(key.last_used_at).toLocaleDateString()} -

- )} - {key.expires_at && ( -

Expires: {new Date(key.expires_at).toLocaleDateString()}

- )} - {canEditConnection(key) && !!key.granted_scopes?.length && ( -

- Permissions: {key.granted_scopes.join(", ")} -

- )} - - Open full service details - - -
-
-
- ); -} +import { classifyConnection } from "@/lib/service-routing-preview"; export default function ServiceRoutingPreview({ renderConnectionActions, diff --git a/frontend/src/hooks/use-service-routing-pools.test.tsx b/frontend/src/hooks/use-service-routing-pools.test.tsx new file mode 100644 index 000000000..41dc6c028 --- /dev/null +++ b/frontend/src/hooks/use-service-routing-pools.test.tsx @@ -0,0 +1,80 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { act, renderHook, waitFor } from "@testing-library/react"; +import type { PropsWithChildren } from "react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { useServiceRoutingPools } from "./use-service-routing-pools"; +import type { KeyInfo } from "@/types/keys"; +const mock = vi.hoisted(() => ({ get: vi.fn() })); +vi.mock("@/lib/api-client", () => ({ api: mock })); +vi.mock("@/stores/auth-store", () => ({ + useAuthStore: (selector: (state: { user: { id: string } }) => unknown) => + selector({ user: { id: "me" } }), +})); +const pool = { + id: "pool", + user_id: "me", + name: "Route", + slug: "route", + strategy: "priority", + members: [], + rr_counter: 0, + is_active: true, + created_at: "2026-01-01", + updated_at: "2026-01-01", +}; +const keys = [ + { id: "personal", credential_source: { type: "personal" } }, + { + id: "admin", + credential_source: { + type: "org", + org_id: "team", + role: "admin", + allowed: true, + }, + }, + { + id: "viewer", + credential_source: { + type: "org", + org_id: "read-only", + role: "viewer", + allowed: false, + }, + }, +] as KeyInfo[]; +function setup() { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false, gcTime: 0 } }, + }); + const wrapper = ({ children }: PropsWithChildren) => ( + {children} + ); + return { client, wrapper }; +} +beforeEach(() => { + mock.get.mockReset(); + mock.get.mockResolvedValue({ pools: [pool] }); +}); +describe("service card pool inventory", () => { + it("reads personal and manageable org pools without requesting restricted org inventory", async () => { + const { result } = renderHook(() => useServiceRoutingPools(keys), setup()); + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(mock.get.mock.calls.map(([path]) => path).sort()).toEqual([ + "/service-pools", + "/service-pools?org_id=team", + ]); + expect(result.current.incomplete).toBe(true); + }); + it("clears stale routing after a pool read loses access", async () => { + const { client, wrapper } = setup(); + const { result } = renderHook(() => useServiceRoutingPools([keys[0]!]), { + wrapper, + }); + await waitFor(() => expect(result.current.pools).toHaveLength(1)); + mock.get.mockRejectedValue(new Error("Access removed")); + await act(() => client.invalidateQueries({ queryKey: ["service-pools"] })); + await waitFor(() => expect(result.current.incomplete).toBe(true)); + expect(result.current.pools).toEqual([]); + }); +}); diff --git a/frontend/src/hooks/use-service-routing-pools.ts b/frontend/src/hooks/use-service-routing-pools.ts new file mode 100644 index 000000000..08ae6bf26 --- /dev/null +++ b/frontend/src/hooks/use-service-routing-pools.ts @@ -0,0 +1,64 @@ +import { useQueries } from "@tanstack/react-query"; +import { api } from "@/lib/api-client"; +import { useAuthStore } from "@/stores/auth-store"; +import { + servicePoolListResponseSchema, + type ServicePool, +} from "@/schemas/pools"; +import type { KeyInfo } from "@/types/keys"; + +export interface ServiceRoutingPools { + pools: readonly ServicePool[]; + loading: boolean; + incomplete: boolean; +} + +export function useServiceRoutingPools( + keys: readonly KeyInfo[], + enabled = true, +): ServiceRoutingPools { + const identity = useAuthStore((state) => state.user?.id); + // Pool management reads currently require organization admin access. + const orgIds = [ + ...new Set( + keys.flatMap((key) => { + const source = key.credential_source; + return source?.type === "org" && + source.allowed && + source.role === "admin" + ? [source.org_id] + : []; + }), + ), + ].sort(); + const queries = useQueries({ + queries: [undefined, ...orgIds].map((orgId) => ({ + queryKey: ["service-pools", "routing", identity, orgId], + enabled: enabled && !!identity && keys.length > 0, + queryFn: async () => + servicePoolListResponseSchema.parse( + await api.get( + orgId + ? `/service-pools?org_id=${encodeURIComponent(orgId)}` + : "/service-pools", + ), + ).pools, + retry: false, + staleTime: 30_000, + })), + }); + return { + pools: queries.flatMap((query) => + query.isError ? [] : (query.data ?? []), + ), + loading: queries.some((query) => query.isLoading), + incomplete: + queries.some((query) => query.isError) || + keys.some( + (key) => + key.credential_source?.type === "org" && + (key.credential_source.role !== "admin" || + !key.credential_source.allowed), + ), + }; +} diff --git a/frontend/src/lib/routing-preview-gateway.test.ts b/frontend/src/lib/routing-preview-gateway.test.ts new file mode 100644 index 000000000..5eb82fd44 --- /dev/null +++ b/frontend/src/lib/routing-preview-gateway.test.ts @@ -0,0 +1,50 @@ +import type { IncomingMessage, ServerResponse } from "node:http"; +import type { ViteDevServer } from "vite"; +import { describe, expect, it, vi } from "vitest"; +import { routingPreview } from "../../dev/routing-preview"; + +async function request(method: string, url: string) { + const use = vi.fn(); + const plugin = routingPreview( + "https://backend.example", + "https://frontend.example", + ); + if (typeof plugin.configureServer !== "function") + throw new Error("Missing preview gateway"); + plugin.configureServer.call( + {} as ThisParameterType, + { + httpServer: { address: () => ({ port: 4317 }) }, + middlewares: { use }, + } as unknown as ViteDevServer, + ); + const response = { writeHead: vi.fn(), end: vi.fn() }; + await use.mock.calls[0]![0]( + { method, url, headers: { host: "127.0.0.1:4317" } } as IncomingMessage, + response as unknown as ServerResponse, + vi.fn(), + ); + return response.writeHead.mock.calls[0]?.[0]; +} +const id = "00000000-0000-4000-8000-000000000001"; +describe("pool metadata preview boundary", () => { + it.each([ + "/service-pools", + "/service-pools/candidates", + `/service-pools/${id}`, + `/service-pools/${id}/candidates`, + `/service-pools/${id}/health?method=GET&path=/2/users/me`, + ])("allows authenticated metadata reads of %s", async (path) => { + // No session: admitted metadata requests reach the authentication gate. + expect(await request("GET", `/api/v1${path}`)).toBe(401); + }); + it.each([ + ["PUT", `/service-pools/${id}`], + ["POST", "/service-pools"], + ["POST", `/service-pools/${id}/health/reset`], + ["GET", `/service-pools/${id}/health/reset`], + ["GET", "/proxy/s/twitter-route"], + ])("blocks %s %s", async (method, path) => { + expect(await request(method!, `/api/v1${path}`)).toBe(403); + }); +}); diff --git a/frontend/src/lib/service-pool-display.test.ts b/frontend/src/lib/service-pool-display.test.ts new file mode 100644 index 000000000..aed757ea2 --- /dev/null +++ b/frontend/src/lib/service-pool-display.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, it } from "vitest"; +import { defaultFailoverPolicy, type ServicePool } from "@/schemas/pools"; +import { + orderedPoolMembers, + poolFailoverLabel, + poolMemberStatus, + reorderPoolMembers, +} from "./service-pool-display"; +const members = [ + { + user_service_id: "backup", + enabled: true, + priority: 20, + weight: 4, + model: "model-b", + same_api_compatible: true, + }, + { + user_service_id: "primary", + enabled: true, + priority: 0, + weight: 2, + model: "model-a", + }, + { + user_service_id: "disabled", + enabled: false, + priority: 10, + weight: 1, + model: null, + }, +]; +const pool = { strategy: "priority", members, failover: null } as ServicePool; +describe("saved pool presentation", () => { + it("uses default failover for null policy and honors disabled retry policies", () => { + expect(poolFailoverLabel(pool)).toBe("Failover · up to 3 attempts"); + expect( + poolFailoverLabel({ + ...pool, + failover: { ...defaultFailoverPolicy, max_attempts: 1 }, + }), + ).toBe("Failover off"); + expect( + poolFailoverLabel({ + ...pool, + failover: { ...defaultFailoverPolicy, retry_on: [] }, + }), + ).toBe("Failover off"); + for (const strategy of ["round_robin", "weighted"] as const) + expect(poolFailoverLabel({ ...pool, strategy })).toBe( + "Single attempt · no failover", + ); + }); + it("orders priority tiers numerically, preserves ties and leaves rotation order unchanged", () => { + expect(orderedPoolMembers(pool).map((m) => m.user_service_id)).toEqual([ + "primary", + "disabled", + "backup", + ]); + expect(orderedPoolMembers({ ...pool, strategy: "round_robin" })).toEqual( + members, + ); + expect( + orderedPoolMembers({ + strategy: "priority", + members: members.map((member) => ({ ...member, priority: 0 })), + }).map((m) => m.user_service_id), + ).toEqual(["backup", "primary", "disabled"]); + }); + it("reorders only real members and preserves model, weight, exclusion and compatibility fields", () => { + const result = reorderPoolMembers(pool, "backup", "primary"); + expect(result).toEqual([ + { ...members[0], priority: 0 }, + { ...members[1], priority: 1 }, + { ...members[2], priority: 2 }, + ]); + expect(members[0]!.priority).toBe(20); + expect(reorderPoolMembers(pool, "foreign", "primary")).toEqual( + orderedPoolMembers(pool), + ); + expect( + reorderPoolMembers( + { ...pool, strategy: "weighted" }, + "backup", + "primary", + ), + ).toEqual(members); + }); + it("does not infer health from an enabled member", () => { + expect(poolMemberStatus(members[0]!)).toBe("Not inspected"); + expect(poolMemberStatus(members[2]!)).toBe("Member disabled"); + }); +}); diff --git a/frontend/src/lib/service-pool-display.ts b/frontend/src/lib/service-pool-display.ts new file mode 100644 index 000000000..918cb0103 --- /dev/null +++ b/frontend/src/lib/service-pool-display.ts @@ -0,0 +1,69 @@ +import { + defaultFailoverPolicy, + type PoolCandidate, + type ServicePool, + type ServicePoolMember, +} from "@/schemas/pools"; + +export function poolStrategyLabel(pool: ServicePool): string { + return pool.strategy === "priority" + ? "Priority routing" + : pool.strategy === "weighted" + ? "Weighted rotation" + : "Round-robin rotation"; +} + +export function poolFailoverLabel(pool: ServicePool): string { + if (pool.strategy !== "priority") return "Single attempt · no failover"; + const policy = pool.failover ?? defaultFailoverPolicy; + return policy.max_attempts === 1 || !policy.retry_on.length + ? "Failover off" + : `Failover · up to ${policy.max_attempts} attempts`; +} + +export function orderedPoolMembers( + pool: Pick, +): ServicePoolMember[] { + return pool.strategy === "priority" + ? [...pool.members].sort((a, b) => (a.priority ?? 0) - (b.priority ?? 0)) + : [...pool.members]; +} + +/** Reordering explicitly creates a strict priority sequence; equal tiers are edited numerically. */ +export function reorderPoolMembers( + pool: Pick, + from: string, + to: string, +): ServicePoolMember[] { + const members = orderedPoolMembers(pool); + const start = members.findIndex((m) => m.user_service_id === from); + const end = members.findIndex((m) => m.user_service_id === to); + if (pool.strategy !== "priority" || start < 0 || end < 0 || start === end) + return members; + members.splice(end, 0, members.splice(start, 1)[0]!); + return members.map((member, index) => ({ ...member, priority: index })); +} + +const reasons: Record = { + unavailable: "Connection unavailable", + inactive: "Service disabled", + disabled: "Member disabled", + cooldown: "Cooling down", + incompatible_protocol: "Incompatible protocol", + compatibility_declaration_required: "Compatibility confirmation needed", + inference_protocol_required: "Inference metadata required", + operation_unsupported: "Operation not permitted", + node_upgrade_required: "Node upgrade required", + node_offline: "Node offline", + unsupported_transport: "Unsupported transport", +}; +export function poolMemberStatus( + member: ServicePoolMember, + candidate?: PoolCandidate, +): string { + if (!member.enabled) return "Member disabled"; + if (!candidate) return "Not inspected"; + if (candidate.reason) + return reasons[candidate.reason] ?? candidate.reason.replaceAll("_", " "); + return candidate.eligible ? "Eligible" : "Not eligible"; +} diff --git a/frontend/src/lib/service-routing-preview.test.ts b/frontend/src/lib/service-routing-preview.test.ts index d40c5d9f7..53bc40229 100644 --- a/frontend/src/lib/service-routing-preview.test.ts +++ b/frontend/src/lib/service-routing-preview.test.ts @@ -1,12 +1,6 @@ import { beforeEach, describe, expect, it } from "vitest"; import type { KeyInfo, CatalogEntry } from "@/types/keys"; -import { - buildRoutingGroups, - moveItem, - orderedIds, - readPreferences, - savePreferences, -} from "./service-routing-preview"; +import { buildRoutingGroups } from "./service-routing-preview"; function key(overrides: Partial = {}): KeyInfo { return { @@ -193,33 +187,4 @@ describe("routing from actual connections", () => { expect(group([key(overrides)]).candidates[0]?.state).toBe("unverified"); }, ); - - it("only reorders members of the given pool, removing stale saved IDs", () => { - const order = orderedIds(["a", "b", "new"], ["b", "foreign", "a", "b"]); - expect(order).toEqual(["b", "a", "new"]); - expect(moveItem(order, "foreign", "a")).toEqual(order); - expect(moveItem(order, "new", "b")).toEqual(["new", "b", "a"]); - }); - - it("scopes pool preferences by account and pool without inheriting old global sorts", () => { - localStorage.setItem( - "nyxid-routing-preview-v2:alice", - JSON.stringify({ - serviceOrder: ["b"], - connectionOrder: ["b"], - view: "services", - }), - ); - expect(readPreferences("alice")).toEqual({ - view: "connections", - pools: {}, - }); - savePreferences("alice", { - view: "services", - pools: { first: { priority: true, order: ["b", "a"] } }, - }); - expect(readPreferences("alice").pools.first?.order).toEqual(["b", "a"]); - expect(readPreferences("alice").pools.second).toBeUndefined(); - expect(readPreferences("bob")).toEqual({ view: "connections", pools: {} }); - }); }); diff --git a/frontend/src/lib/service-routing-preview.ts b/frontend/src/lib/service-routing-preview.ts index a63e70b31..0c8e0c505 100644 --- a/frontend/src/lib/service-routing-preview.ts +++ b/frontend/src/lib/service-routing-preview.ts @@ -1,4 +1,3 @@ -import { z } from "zod"; import type { KeyInfo, CatalogEntry } from "@/types/keys"; import type { UserServiceResponse } from "@/schemas/keys"; import type { CredentialSource } from "@/schemas/orgs"; @@ -125,65 +124,3 @@ export function buildRoutingGroups( // connection. Only records returned by /keys create groups or options. return [...groups.values()].sort((a, b) => a.name.localeCompare(b.name)); } - -export function moveItem( - order: readonly string[], - from: string, - to: string, -): string[] { - if (from === to || !order.includes(from) || !order.includes(to)) - return [...order]; - const next = order.filter((id) => id !== from); - next.splice(order.indexOf(to), 0, from); - return next; -} - -export function orderedIds( - current: readonly string[], - saved: readonly string[], -): string[] { - return [ - ...new Set([...saved.filter((id) => current.includes(id)), ...current]), - ]; -} - -const preferencesSchema = z.object({ - view: z.enum(["services", "connections"]), - pools: z.record( - z.string(), - z.object({ - priority: z.boolean(), - order: z.array(z.string()), - }), - ), -}); -export type PreviewPreferences = z.infer; - -const storageKey = (userId: string) => `nyxid-routing-preview-v3:${userId}`; - -export function readPreferences( - userId: string | undefined, -): PreviewPreferences { - const defaults: PreviewPreferences = { view: "connections", pools: {} }; - if (!userId) return defaults; - try { - const parsed = preferencesSchema.safeParse( - JSON.parse(localStorage.getItem(storageKey(userId)) ?? "null"), - ); - return parsed.success ? parsed.data : defaults; - } catch { - return defaults; - } -} - -export function savePreferences( - userId: string | undefined, - value: PreviewPreferences, -) { - if (!userId) return; - try { - localStorage.setItem(storageKey(userId), JSON.stringify(value)); - } catch { - // The preview remains usable when browser storage is unavailable. - } -} diff --git a/frontend/src/pages/keys.test.tsx b/frontend/src/pages/keys.test.tsx index 0c6356652..0aab9c251 100644 --- a/frontend/src/pages/keys.test.tsx +++ b/frontend/src/pages/keys.test.tsx @@ -1,20 +1,37 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import type { ReactNode } from "react"; -import { render as renderDom, screen, waitFor, fireEvent, within } from "@testing-library/react"; +import { + render as renderDom, + screen, + waitFor, + fireEvent, + within, +} from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { beforeEach, describe, expect, it, vi } from "vitest"; import type { KeyInfo } from "@/types/keys"; function render(ui: ReactNode) { - const client = new QueryClient({ defaultOptions: { mutations: { retry: false }, queries: { retry: false } } }); - return renderDom(ui, { wrapper: ({ children }) => {children} }); + const client = new QueryClient({ + defaultOptions: { mutations: { retry: false }, queries: { retry: false } }, + }); + return renderDom(ui, { + wrapper: ({ children }) => ( + {children} + ), + }); } const { mockNavigate, state } = vi.hoisted(() => ({ mockNavigate: vi.fn(), // Mutable containers populated per-test before render. state: { - search: {} as { tab?: string; slug?: string; action?: string; view?: string }, + search: {} as { + tab?: string; + slug?: string; + action?: string; + view?: string; + }, keys: [] as KeyInfo[], keysLoading: false, keysError: null as unknown, @@ -35,7 +52,10 @@ vi.mock("@tanstack/react-router", () => ({ readonly params?: Record; readonly "aria-label"?: string; }) => ( - + {children} ), @@ -62,12 +82,35 @@ vi.mock("@/hooks/use-nodes", () => ({ })); vi.mock("@/hooks/use-pools", () => ({ - useServicePools: () => ({ data: [{ - id: "pool-one", name: "My pool", slug: "my-pool", strategy: "round_robin", - members: [{ user_service_id: "key-1", enabled: true, weight: 1 }], - is_active: true, - }] }), + useUpdateServicePool: () => ({ mutateAsync: vi.fn(), isPending: false }), + useDeleteServicePool: () => ({ mutateAsync: vi.fn(), isPending: false }), + usePoolHealth: () => ({ + data: { candidates: [] }, + isError: false, + isLoading: false, + }), + useServicePools: () => ({ + data: [ + { + id: "pool-one", + name: "My pool", + slug: "my-pool", + strategy: "round_robin", + members: [{ user_service_id: "key-1", enabled: true, weight: 1 }], + is_active: true, + }, + ], + }), +})); + +vi.mock("@/hooks/use-service-routing-pools", () => ({ + useServiceRoutingPools: () => ({ + pools: [], + loading: false, + incomplete: false, + }), })); +vi.mock("@/hooks/use-orgs", () => ({ useOrgs: () => ({ data: [] }) })); // Heavy children — stubbed to assert wiring (open state, presence), not driven. vi.mock("@/components/providers/codex-connection", () => ({ @@ -129,7 +172,10 @@ import { KeysPage } from "./keys"; import { useServiceCardView } from "@/stores/service-card-view-store"; function expandConnections() { - for (const button of screen.queryAllByRole("button", { name: /^Expand .+ connections$/ })) fireEvent.click(button); + for (const button of screen.queryAllByRole("button", { + name: /^Expand .+ connections$/, + })) + fireEvent.click(button); } function makeKey(overrides: Partial = {}): KeyInfo { @@ -169,7 +215,11 @@ function makeKey(overrides: Partial = {}): KeyInfo { describe("KeysPage", () => { beforeEach(() => { vi.clearAllMocks(); - useServiceCardView.setState({ accountId: undefined, expanded: [], filters: undefined }); + useServiceCardView.setState({ + accountId: undefined, + expanded: [], + filters: undefined, + }); state.search = {}; state.keys = []; state.keysLoading = false; @@ -191,32 +241,60 @@ describe("KeysPage", () => { expandConnections(); expect(within(group).getByText("openai")).toBeVisible(); expect(within(group).getByText("openai-work")).toBeVisible(); - expect(within(group).getByRole("link", { name: "View Personal OpenAI connection details (Personal)" })).toHaveAttribute("href", "/keys/$keyId:key-1"); - expect(within(group).getByRole("link", { name: "View Work OpenAI connection details (Personal)" })).toHaveAttribute("href", "/keys/$keyId:key-2"); + expect( + within(group).getByRole("link", { + name: "View Personal OpenAI connection details (Personal)", + }), + ).toHaveAttribute("href", "/keys/$keyId:key-1"); + expect( + within(group).getByRole("link", { + name: "View Work OpenAI connection details (Personal)", + }), + ).toHaveAttribute("href", "/keys/$keyId:key-2"); }); it("uses the connection table and full detail navigation in the routing view", async () => { state.search = { view: "routing" }; - state.keys = [makeKey({ label: "My preserved connection", slug: "my-openai", credential_source: { type: "personal" } })]; + state.keys = [ + makeKey({ + label: "My preserved connection", + slug: "my-openai", + credential_source: { type: "personal" }, + }), + ]; render(); await screen.findByRole("button", { name: "Expand OpenAI connections" }); expandConnections(); expect(screen.getByText("https://api.openai.com")).toBeVisible(); expect(screen.getByText("my-openai")).toBeVisible(); - expect(screen.queryByText("Details", { selector: "summary" })).not.toBeInTheDocument(); - expect(screen.getByRole("link", { name: "Configure My preserved connection (Personal)" })).toHaveAttribute("href", "/keys/$keyId:key-1"); - expect(screen.getByRole("link", { name: "View all OpenAI service details" })).toHaveAttribute("href", "/keys/services/$groupId:catalog:cat-1"); - expect(screen.queryByRole("button", { name: "Individual cards" })).not.toBeInTheDocument(); + expect( + screen.queryByText("Details", { selector: "summary" }), + ).not.toBeInTheDocument(); + expect( + screen.getByRole("link", { + name: "Configure My preserved connection (Personal)", + }), + ).toHaveAttribute("href", "/keys/$keyId:key-1"); + expect( + screen.getByRole("link", { name: "View all OpenAI service details" }), + ).toHaveAttribute("href", "/keys/services/$groupId:catalog:cat-1"); + expect( + screen.queryByRole("button", { name: "Individual cards" }), + ).not.toBeInTheDocument(); }); - it("opens actual pool members with the original cards in the pool preview tab", async () => { + it("opens saved pool routing and members inside the expanded pool card", async () => { state.search = { view: "routing", tab: "pools" }; state.keys = [makeKey({ credential_source: { type: "personal" } })]; render(); expect(await screen.findByText("My pool")).toBeInTheDocument(); - expect(screen.getByText("https://api.openai.com")).toBeInTheDocument(); - expect(screen.getByRole("combobox", { name: "Selection for My pool" })).toHaveValue("current"); - expect(screen.queryByRole("button", { name: "Create Pool" })).not.toBeInTheDocument(); + await userEvent.click(screen.getByRole("button", { name: "View route" })); + expect( + screen.getByRole("table", { name: "My pool route members" }), + ).toBeVisible(); + expect(screen.getByText("My OpenAI")).toBeVisible(); + expect(screen.getByText("/api/v1/proxy/s/my-pool")).toBeVisible(); + expect(screen.getByRole("button", { name: "Create pool" })).toBeVisible(); }); it("omits oauth2 and api_key credential pills from service cards", () => { @@ -284,34 +362,28 @@ describe("KeysPage", () => { render(); - expect( - screen.getByText(/failed to load services/i), - ).toBeInTheDocument(); + expect(screen.getByText(/failed to load services/i)).toBeInTheDocument(); expect(screen.getByRole("button", { name: /retry/i })).toBeInTheDocument(); }); - it("filters auto-connected services and their rows inside a matching card", async () => { - const user = userEvent.setup(); + it("keeps personal and platform counterparts visible together without the removed Filters menu", async () => { state.keys = [ - makeKey({ id: "manual", label: "Manual Key", endpoint_url: "https://manual.example/v1" }), - makeKey({ id: "auto", label: "Auto Key", auto_connected: true, endpoint_url: "https://platform.internal/v1" }), - makeKey({ id: "auto-only", catalog_service_id: "auto-only", catalog_service_name: "Platform Only", auto_connected: true }), + makeKey(), + makeKey({ + id: "platform", + label: "Platform counterpart", + slug: "platform-openai", + auto_connected: true, + credential_binding: "platform", + }), ]; render(); - fireEvent.click(screen.getByRole("button", { name: "Service view: Personal" })); - expect(screen.getByRole("region", { name: "Platform Only" })).toBeInTheDocument(); - await user.click(screen.getByRole("button", { name: "Filters" })); - await user.click(screen.getByRole("button", { name: "Auto-connected" })); - await user.click(screen.getByRole("button", { name: "Hidden" })); - await user.click(screen.getByRole("button", { name: "Apply filters" })); - expect(screen.queryByRole("region", { name: "Platform Only" })).not.toBeInTheDocument(); - expect(screen.getByText("1 of 2 match")).toBeVisible(); + expect( + screen.queryByRole("button", { name: "Filters" }), + ).not.toBeInTheDocument(); expandConnections(); - expect(screen.getByText("Manual Key")).toBeVisible(); - expect(screen.queryByText("Auto Key")).not.toBeInTheDocument(); - expect(screen.queryByText("https://platform.internal/v1")).not.toBeInTheDocument(); - await user.click(screen.getByRole("button", { name: "Clear filters" })); - expect(screen.getByRole("region", { name: "Platform Only" })).toBeInTheDocument(); + expect(screen.getByText("My OpenAI")).toBeVisible(); + expect(screen.getByText("Platform counterpart")).toBeVisible(); }); it("hides auto-connected endpoint URLs in table rows without changing normal rows", async () => { @@ -332,10 +404,16 @@ describe("KeysPage", () => { try { render(); - fireEvent.click(screen.getByRole("button", { name: "Service view: Personal" })); + fireEvent.click( + screen.getByRole("button", { name: "Service view: Personal" }), + ); - expect(screen.getByText("https://manual-table.example/v1")).toBeInTheDocument(); - expect(screen.queryByText("https://platform-table.internal/v1")).not.toBeInTheDocument(); + expect( + screen.getByText("https://manual-table.example/v1"), + ).toBeInTheDocument(); + expect( + screen.queryByText("https://platform-table.internal/v1"), + ).not.toBeInTheDocument(); expect(screen.getAllByText("Platform managed").length).toBeGreaterThan(0); } finally { localStorage.removeItem("nyxid-view-mode:keys-services"); @@ -359,12 +437,13 @@ describe("KeysPage", () => { ]; render(); - fireEvent.click(screen.getByRole("button", { name: "Service view: Personal" })); + fireEvent.click( + screen.getByRole("button", { name: "Service view: Personal" }), + ); expandConnections(); expect(screen.getAllByText("Acme Org").length).toBeGreaterThan(0); - expect(screen.getByText("Organization · member")).toBeVisible(); - + expect(screen.getByTitle("Acme Org · Organization · member")).toBeVisible(); }); it("opens the Add Key dialog when the toolbar Connect Service button is clicked", async () => { @@ -450,7 +529,9 @@ describe("KeysPage", () => { expandConnections(); expect(screen.getByText("Reconnect needed")).toBeInTheDocument(); - expect(screen.getByRole("button", { name: /reconnect/i })).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: /reconnect/i }), + ).toBeInTheDocument(); }); it("labels pending OAuth service cards as continue authentication", async () => { @@ -498,25 +579,42 @@ describe("KeysPage", () => { ]; render(); - fireEvent.click(screen.getByRole("button", { name: "Service view: Personal" })); + fireEvent.click( + screen.getByRole("button", { name: "Service view: Personal" }), + ); expandConnections(); expect( screen.queryByRole("button", { name: /reconnect/i }), ).not.toBeInTheDocument(); - expect(screen.getByText("Organization · member")).toBeVisible(); + expect(screen.getByTitle("Acme Org · Organization · member")).toBeVisible(); }); it("does not offer reconnect when organization access is denied despite an admin role", () => { - state.keys = [makeKey({ - credential_type: "oauth2", auth_method: "oauth2", status: "failed", - credential_source: { type: "org", org_id: "denied-org", org_name: "Restricted", role: "admin", allowed: false, avatar_url: null }, - })]; + state.keys = [ + makeKey({ + credential_type: "oauth2", + auth_method: "oauth2", + status: "failed", + credential_source: { + type: "org", + org_id: "denied-org", + org_name: "Restricted", + role: "admin", + allowed: false, + avatar_url: null, + }, + }), + ]; render(); - fireEvent.click(screen.getByRole("button", { name: "Service view: Personal" })); + fireEvent.click( + screen.getByRole("button", { name: "Service view: Personal" }), + ); expandConnections(); expect(screen.getByText("No access")).toBeVisible(); - expect(screen.queryByRole("button", { name: /Reconnect/ })).not.toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: /Reconnect/ }), + ).not.toBeInTheDocument(); }); it("switches to the Agent Keys tab and mounts the API key table + usage dashboard", async () => { @@ -586,10 +684,20 @@ describe("KeysPage", () => { screen.getByRole("columnheader", { name: "Connection / Slug" }), ).toBeInTheDocument(); - await user.click(screen.getByRole("button", { name: "Details for My OpenAI (Personal)" })); + await user.click( + screen.getByRole("button", { + name: "Details for My OpenAI (Personal)", + }), + ); - expect(screen.getByRole("button", { name: "Details for My OpenAI (Personal)" })).toHaveAttribute("aria-expanded", "true"); - expect(screen.getByRole("link", { name: "Configure My OpenAI (Personal)" })).toHaveAttribute("href", "/keys/$keyId:key-1"); + expect( + screen.getByRole("button", { + name: "Details for My OpenAI (Personal)", + }), + ).toHaveAttribute("aria-expanded", "true"); + expect( + screen.getByRole("link", { name: "Configure My OpenAI (Personal)" }), + ).toHaveAttribute("href", "/keys/$keyId:key-1"); } finally { localStorage.removeItem("nyxid-view-mode:keys-services"); } @@ -632,9 +740,10 @@ describe("KeysPage", () => { render(); await waitFor(() => { - expect( - screen.getByTestId("api-key-create-dialog"), - ).toHaveAttribute("data-open", "true"); + expect(screen.getByTestId("api-key-create-dialog")).toHaveAttribute( + "data-open", + "true", + ); }); }); }); diff --git a/frontend/src/pages/keys.tsx b/frontend/src/pages/keys.tsx index c03a47a92..baf06c06e 100644 --- a/frontend/src/pages/keys.tsx +++ b/frontend/src/pages/keys.tsx @@ -1,6 +1,5 @@ import { ServiceConnectionTable } from "@/components/dashboard/service-connection-table"; -import { canEditConnection } from "@/lib/connection-access"; -import { ServiceAuthorshipFooter, ArchivedServiceHistory } from "@/components/dashboard/service-history"; +import { ArchivedServiceHistory } from "@/components/dashboard/service-history"; import { lazy, Suspense, useEffect, useMemo, useRef, useState } from "react"; import { Link, useSearch, useNavigate } from "@tanstack/react-router"; import { useKeys, useCatalog } from "@/hooks/use-keys"; @@ -11,29 +10,22 @@ import { CodexConnectionSection } from "@/components/providers/codex-connection" import { AddCtaButton } from "@/components/shared/add-cta-button"; import { TeachingEmptyState } from "@/components/shared/teaching-empty-state"; import { Skeleton } from "@/components/ui/skeleton"; -import { Badge } from "@/components/ui/badge"; import { Button, ButtonIcon } from "@/components/ui/button"; import { ErrorBanner } from "@/components/shared/error-banner"; import { Card, CardContent } from "@/components/ui/card"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import { - Globe, - KeySquare, - Server, - Terminal, - RefreshCw, - Shield, -} from "lucide-react"; +import { KeySquare, Terminal, RefreshCw, Shield } from "lucide-react"; import { MagicKeyIcon } from "@/components/icons/empty-state"; -import { useNodes } from "@/hooks/use-nodes"; -import { ViewToggle, useViewMode, type ViewMode } from "@/components/shared/view-toggle"; -import { ServiceIcon } from "@/components/service-icon"; +import { + ViewToggle, + useViewMode, + type ViewMode, +} from "@/components/shared/view-toggle"; import { AddKeyDialog } from "@/components/dashboard/add-key-dialog"; import { ApiKeyTable } from "@/components/dashboard/api-key-table"; import { ApiKeyCreateDialog } from "@/components/dashboard/api-key-create-dialog"; import { ApiKeyUsageDashboard } from "@/components/dashboard/api-key-usage-dashboard"; import { ServicePoolsTab } from "@/components/dashboard/service-pools-tab"; -import { RoleBadge } from "@/components/orgs/role-badge"; import type { KeyInfo } from "@/types/keys"; import type { CredentialSource } from "@/schemas/orgs"; import { @@ -46,35 +38,6 @@ import { parseTab, } from "@/lib/url-tabs"; -function statusVariant( - status: string, -): "success" | "secondary" | "destructive" { - switch (status) { - case "active": - case "online": - return "success"; - case "expired": - case "inaccessible": - case "draining": - return "secondary"; - case "revoked": - case "failed": - case "refresh_failed": - case "offline": - case "node_deleted": - case "unknown": - return "destructive"; - default: - return "secondary"; - } -} - -interface KeyCardProps { - readonly keyInfo: KeyInfo; - /** Credential provenance; missing ownership hides configuration. */ - readonly source: CredentialSource | undefined; -} - const RECONNECTABLE_STATUSES = new Set([ "pending_auth", "refresh_failed", @@ -94,9 +57,13 @@ function isReconnectableKey( keyInfo.auto_connected || isNonAdminOrgSource(source) || (source?.type === "org" && !source.allowed) - ) return false; + ) + return false; const effectiveStatus = keyInfo.connection_status ?? keyInfo.status; - if (!keyInfo.credential_missing && !RECONNECTABLE_STATUSES.has(effectiveStatus)) { + if ( + !keyInfo.credential_missing && + !RECONNECTABLE_STATUSES.has(effectiveStatus) + ) { return false; } return ( @@ -107,226 +74,34 @@ function isReconnectableKey( } function reconnectLabel(status: string): string { - return status === "pending_auth" - ? "Continue authentication" - : "Reconnect"; + return status === "pending_auth" ? "Continue authentication" : "Reconnect"; } -function ConnectionReconnect({ connection, onReconnect }: { +function ConnectionReconnect({ + connection, + onReconnect, +}: { readonly connection: KeyInfo; readonly onReconnect?: (key: KeyInfo) => void; }) { - if (!onReconnect || !isReconnectableKey(connection, connection.credential_source)) return null; + if ( + !onReconnect || + !isReconnectableKey(connection, connection.credential_source) + ) + return null; return ( - ); } -function KeyCardContent({ - keyInfo, - source, - onReconnect, -}: KeyCardProps & { - readonly onReconnect?: (keyInfo: KeyInfo) => void; -}) { - const isSsh = keyInfo.service_type === "ssh"; - const hasSshCertificateAuth = isSsh && keyInfo.ssh_ca_public_key !== null; - // Issue #416: resolve the bound node's name so the list card shows - // "Via my-node" instead of bare "Via node". TanStack Query dedupes - // the request across all rendered cards. - const { data: nodes } = useNodes(); - const nodeName = keyInfo.node_id - ? (nodes?.find((n) => n.id === keyInfo.node_id)?.name ?? - keyInfo.node_id.slice(0, 8)) - : null; - const endpointUrl = keyInfo.endpoint_url ?? ""; - const displayUrl = !canEditConnection({ ...keyInfo, credential_source: source }) - ? (keyInfo.auto_connected ? "Platform managed" : "Editors only") - : isSsh - ? `${keyInfo.ssh_host ?? "unknown"}:${keyInfo.ssh_port ?? 22}` - : endpointUrl.length > 50 - ? `${endpointUrl.slice(0, 50)}...` - : endpointUrl; - - const isOrgInherited = source?.type === "org"; - // Viewers and out-of-scope members see the card with reduced opacity. - const isBlocked = source?.type === "org" && !source.allowed; - // Members can USE the credential (allowed=true) but cannot MODIFY it. - const isReadOnly = - source?.type === "org" && source.allowed && source.role !== "admin"; - - const displayStatus = keyInfo.connection_status === "expired" - ? "expired" - : keyInfo.node_id && keyInfo.node_status - ? (keyInfo.node_status === "unknown" ? "node_deleted" : keyInfo.node_status) - : keyInfo.status; - - const displayStatusLabel = - displayStatus === "node_deleted" - ? "Node Deleted" - : displayStatus.charAt(0).toUpperCase() + displayStatus.slice(1); - const showReconnect = onReconnect && isReconnectableKey(keyInfo, source); - const autoAuthLabel = keyInfo.auth_method === "none" - ? "No auth required" - : "Platform managed"; - - return ( - - -
- -
-

- {keyInfo.label} -

- {keyInfo.catalog_service_name && ( -

- {keyInfo.catalog_service_name} -

- )} -
-
-
- {isOrgInherited && ( - {source.org_name} - )} - {isOrgInherited && ( - - )} - {isBlocked && ( - Read-Only - )} - {isReadOnly && !isBlocked && ( - View-Only - )} - {keyInfo.admin_only && ( - Admin-only - )} - - {keyInfo.is_active ? displayStatusLabel : "Disabled"} - - {keyInfo.credential_missing && ( - Credential Missing - )} - {isSsh && SSH} - {(keyInfo.auto_connected || - isSsh || - (keyInfo.credential_type !== "oauth2" && - keyInfo.credential_type !== "api_key")) && ( - - {keyInfo.auto_connected - ? autoAuthLabel - : isSsh - ? hasSshCertificateAuth - ? "certificate" - : "ssh tunnel" - : keyInfo.credential_type} - - )} - {/* Routing pill — moved to top so it aligns across cards. - When routed via a node, the badge becomes a real Link so the - user can jump straight to the node detail page (deferred Wave B - cleanup, ships with C.1 canon sweep). */} - {nodeName && keyInfo.node_id ? ( - e.stopPropagation()} - className="inline-flex" - > - - → {nodeName} - - - ) : ( - Direct - )} - {keyInfo.auto_connected && ( - - {keyInfo.source_app_name - ? `Via ${keyInfo.source_app_name}` - : "Auto-connected"} - - )} - -
- - {showReconnect && ( - - )} - -
-
-
- {isSsh ? ( - - ) : ( - - )} - {displayUrl} -
-
- - - {isSsh ? keyInfo.slug : `/proxy/s/${keyInfo.slug}`} - -
-
- -
-
-
- ); -} - -function KeyCard({ - keyInfo, - source, - onReconnect, -}: KeyCardProps & { - readonly onReconnect?: (keyInfo: KeyInfo) => void; -}) { - // Connection metadata and history stay navigable; the detail page gates configuration. - return ( - - - - ); -} - function ServicesEmptyState({ onAdd }: { readonly onAdd: () => void }) { return ( + ); } if (!keys?.length) return ; - return ({ - ...keyInfo, - credential_source: keyInfo.credential_source ?? sourceById.get(keyInfo.id), - }))} - catalog={catalog} - actions={(compact) => } - renderTable={viewMode === "table" ? (filteredKeys) => ( -
- } /> -
- ) : undefined} - renderConnectionActions={(keyInfo) => } - />; + return ( + ({ + ...keyInfo, + credential_source: + keyInfo.credential_source ?? sourceById.get(keyInfo.id), + }))} + catalog={catalog} + actions={(compact) => ( + + )} + renderTable={ + viewMode === "table" + ? (filteredKeys) => ( +
+ ( + + )} + /> +
+ ) + : undefined + } + renderConnectionActions={(keyInfo) => ( + + )} + /> + ); } function NyxIdApiKeysTab({ @@ -437,7 +237,9 @@ function NyxIdApiKeysTab({
@@ -445,7 +247,9 @@ function NyxIdApiKeysTab({
-

Agent Keys

+

+ Agent Keys +

@@ -484,26 +288,36 @@ function AddButton({ const RoutingPreview = import.meta.env.DEV ? lazy(() => import("@/components/dashboard/service-routing-preview")) : null; -const PoolRoutingPreview = import.meta.env.DEV - ? lazy(() => import("@/components/dashboard/service-pool-routing-preview")) - : null; export function KeysPage() { - const search: { tab?: string; slug?: string; action?: string; service?: string; view?: string } = useSearch({ strict: false }); + const search: { + tab?: string; + slug?: string; + action?: string; + service?: string; + view?: string; + } = useSearch({ strict: false }); const navigate = useNavigate(); const tab = parseTab(search.tab, KEYS_TABS, KEYS_TAB_DEFAULT); - const previewActive = Boolean(RoutingPreview && (search.view === "routing" || import.meta.env.VITE_ROUTING_PREVIEW === "1")); + const previewActive = Boolean( + RoutingPreview && + (search.view === "routing" || import.meta.env.VITE_ROUTING_PREVIEW === "1"), + ); const [addServiceOpen, setAddServiceOpen] = useState(false); const [createPoolOpen, setCreatePoolOpen] = useState(false); const [createKeyOpen, setCreateKeyOpen] = useState(false); const [createKeySetupMode, setCreateKeySetupMode] = useState(false); - const [initialSetupServiceId, setInitialSetupServiceId] = useState(null); + const [initialSetupServiceId, setInitialSetupServiceId] = useState< + string | null + >(null); const [servicesViewMode, setServicesViewMode] = useViewMode("keys-services"); const [agentKeysViewMode, setAgentKeysViewMode] = useViewMode("keys-agent"); // Shared query with ExternalServicesTab; only decides header CTA placement. const { data: pageKeys } = useKeys(); - const [pendingPrefillSlug, setPendingPrefillSlug] = useState(null); + const [pendingPrefillSlug, setPendingPrefillSlug] = useState( + null, + ); const [reconnectKey, setReconnectKey] = useState(null); const appliedSlugRef = useRef(null); const appliedActionRef = useRef(null); @@ -576,7 +390,11 @@ export function KeysPage() { } function setTab(value: string) { - void navigate({ to: "/keys", search: { tab: value, ...(previewActive ? { view: "routing" } : {}) }, replace: true }); + void navigate({ + to: "/keys", + search: { tab: value, ...(previewActive ? { view: "routing" } : {}) }, + replace: true, + }); } return ( @@ -584,7 +402,15 @@ export function KeysPage() { Routing preview : undefined} + actions={ + import.meta.env.DEV && !previewActive ? ( + + ) : undefined + } /> @@ -597,18 +423,26 @@ export function KeysPage() {
{tab !== "pools" && !(tab === "services" && previewActive) && ( )} {/* Services keep Connect Service inside the sticky filter toolbar; the empty state has no toolbar, so the header button stays. */} - {(tab !== "services" || !pageKeys?.length) && !(previewActive && tab === "pools") && setAddServiceOpen(true)} - onCreatePool={() => setCreatePoolOpen(true)} - onCreateKey={() => setCreateKeyOpen(true)} - />} + {(tab === "nyxid" || (tab === "services" && !pageKeys?.length)) && ( + setAddServiceOpen(true)} + onCreatePool={() => setCreatePoolOpen(true)} + onCreateKey={() => setCreateKeyOpen(true)} + /> + )}
@@ -617,36 +451,44 @@ export function KeysPage() { {previewActive && RoutingPreview ? ( }> setAddServiceOpen(true)} compact={compact} compactLabel="Connect" />} + actions={(compact) => ( + setAddServiceOpen(true)} + compact={compact} + compactLabel="Connect" + /> + )} renderConnectionActions={(connection) => ( - { - setReconnectKey(keyInfo); - setAddServiceOpen(true); - }} /> - )} /> + { + setReconnectKey(keyInfo); + setAddServiceOpen(true); + }} + /> + )} + /> - ) : setAddServiceOpen(true)} - onReconnect={(keyInfo) => { - setReconnectKey(keyInfo); - setAddServiceOpen(true); - }} - viewMode={servicesViewMode} - />} + ) : ( + setAddServiceOpen(true)} + onReconnect={(keyInfo) => { + setReconnectKey(keyInfo); + setAddServiceOpen(true); + }} + viewMode={servicesViewMode} + /> + )} - {previewActive && PoolRoutingPreview ? ( - }> - ( - - )} /> - - ) : } + /> From 047e5d8d3300dc1cd16b2fd17b6fbafb041f2096 Mon Sep 17 00:00:00 2001 From: ctkm-aelf <230958228+ctkm-aelf@users.noreply.github.com> Date: Thu, 1 Oct 2026 10:17:17 +0800 Subject: [PATCH 11/36] Link service attribution to the actual pool attempt billing identity --- backend/src/handlers/proxy.rs | 9 ++-- .../handlers/service_pool_billing_tests.rs | 42 ++++++++++++++++++- docs/plans/local-routing-preview.md | 6 +++ 3 files changed, 51 insertions(+), 6 deletions(-) diff --git a/backend/src/handlers/proxy.rs b/backend/src/handlers/proxy.rs index 1ad3e765d..702c4a503 100644 --- a/backend/src/handlers/proxy.rs +++ b/backend/src/handlers/proxy.rs @@ -3605,7 +3605,10 @@ async fn execute_proxy_inner( credential_source.as_deref(), &target, ); - let billing_request_id = uuid::Uuid::new_v4().to_string(); + let billing_request_id = pool_accounting + .as_ref() + .map(|ctx| ctx.request_id.clone()) + .unwrap_or_else(|| uuid::Uuid::new_v4().to_string()); let mut request_audit = crate::services::service_insights_activity::RequestAudit::new( &state.db, auth_user, @@ -3625,10 +3628,6 @@ async fn execute_proxy_inner( ) .await .inspect_err(|error| request_audit.admission_error(error))?; - let billing_request_id = pool_accounting - .as_ref() - .map(|ctx| ctx.request_id.clone()) - .unwrap_or_else(|| uuid::Uuid::new_v4().to_string()); let is_ws_candidate = is_ws_upgrade_request(&request); let platform_metric = platform_metric_for_target(&target, is_ws_candidate); let node_intent = match &node_route { diff --git a/backend/src/handlers/service_pool_billing_tests.rs b/backend/src/handlers/service_pool_billing_tests.rs index dcd844495..976d4bea0 100644 --- a/backend/src/handlers/service_pool_billing_tests.rs +++ b/backend/src/handlers/service_pool_billing_tests.rs @@ -364,7 +364,7 @@ async fn pool_proxy_billing_preserves_reported_consumption_from_both_attempts() to_bytes(response.into_body(), 8192).await.unwrap(); let rows = settled_rows(&fixture, 2).await; assert_ne!(rows[0].billing_request_id, rows[1].billing_request_id); - for row in rows { + for row in &rows { assert_eq!( row.pool_attempt.as_ref().and_then(|a| a.outcome), Some(crate::models::usage_meter::PoolAttemptOutcome::Reported) @@ -427,6 +427,46 @@ async fn pool_proxy_billing_preserves_reported_consumption_from_both_attempts() assert!(!format!("{event:?}").contains("review-platform-secret")); assert!(!format!("{event:?}").contains("review-byok-secret")); } + let request_events: Vec = + tokio::time::timeout(std::time::Duration::from_secs(5), async { + loop { + let events: Vec = fixture + .proxy + .state + .db + .collection::("audit_log") + .find(doc! { "event_type": "service_request" }) + .await + .unwrap() + .try_collect() + .await + .unwrap(); + if events.len() >= 2 { + break events; + } + tokio::time::sleep(std::time::Duration::from_millis(20)).await; + } + }) + .await + .expect("both pool attempts retain service attribution"); + assert_eq!(request_events.len(), 2); + for row in &rows { + let event = request_events + .iter() + .filter_map(|event| event.get_document("event_data").ok()) + .find(|data| { + data.get_str("billing_request_id").ok() == Some(row.billing_request_id.as_str()) + }) + .expect("service history must use the actual attempt billing identity"); + assert_eq!( + event.get_str("execution_id").unwrap(), + row.billing_request_id + ); + assert_eq!( + event.get_str("user_service_id").ok(), + row.user_service_id.as_deref() + ); + } fixture.proxy.state.db.drop().await.unwrap(); } diff --git a/docs/plans/local-routing-preview.md b/docs/plans/local-routing-preview.md index 63c421873..03aee740c 100644 --- a/docs/plans/local-routing-preview.md +++ b/docs/plans/local-routing-preview.md @@ -15,6 +15,12 @@ production-data view at `/keys?view=routing`. This is application code, not the standalone HTML reference. Current main's full detail pages, authorship/history, org permissions and reconnect flows are retained. +Latest pool integration validation: 102 focused frontend tests and all 6 backend +pool billing tests passed. The backend tests used an isolated MongoDB 8.0.16 +replica set, including a regression joining each attempt's service history to its +billing request ID. TypeScript, the production frontend build, changed-file lint +and whitespace checks passed. Signed-in visual review is still outstanding. + Run from `frontend/`: ```sh From 3b51a3089c681e9854f00a8a4a89270f4e93888a Mon Sep 17 00:00:00 2001 From: ctkm-aelf <230958228+ctkm-aelf@users.noreply.github.com> Date: Thu, 1 Oct 2026 11:19:47 +0800 Subject: [PATCH 12/36] Show pool and failover summaries on AI service cards --- docs/AI_SERVICES_ARCHITECTURE.md | 11 +- docs/plans/local-routing-preview.md | 23 +++- .../dashboard/grouped-service-cards.tsx | 69 ++++------ .../dashboard/service-pool-cards.tsx | 15 ++- .../dashboard/service-pool-summary.tsx | 127 ++++++++++++++++++ .../dashboard/service-pools-tab.tsx | 8 +- .../service-routing-preview.test.tsx | 47 ++++++- frontend/src/lib/service-pool-display.test.ts | 36 ++++- frontend/src/lib/service-pool-display.ts | 25 ++++ frontend/src/pages/keys.test.tsx | 65 +++++++-- frontend/src/pages/keys.tsx | 5 + frontend/src/router.tsx | 6 +- 12 files changed, 363 insertions(+), 74 deletions(-) create mode 100644 frontend/src/components/dashboard/service-pool-summary.tsx diff --git a/docs/AI_SERVICES_ARCHITECTURE.md b/docs/AI_SERVICES_ARCHITECTURE.md index 7a6d96eba..7c3b13b9c 100644 --- a/docs/AI_SERVICES_ARCHITECTURE.md +++ b/docs/AI_SERVICES_ARCHITECTURE.md @@ -90,13 +90,20 @@ Card billing summaries keep each source separate even when the expected payer is the same. Personal credentials normally use the personal account, organization credentials use the owning organization's account, and a platform key uses the acting person's personal account. Each source opens its own inline billing panel. -The service card's **Routing** row shows saved pools containing its connections. +Each collapsed service card shows **Pool** (name, strategy, additional pool count) +and **Failover** (configured attempt limit or off/disabled state). Multiple pools +show how many have failover enabled; hover/focus lists their individual policies. +These two compact lines keep the common 256px collapsed card height. Grouping by catalog does not create a pool or change an individual connection slug. -Open Routing to inspect the pool slug, strategy, priority/weight, credential supplier, +Click the pool summary to inspect the pool slug, strategy, priority/weight, credential supplier, billing account/rates, and operation-scoped eligibility/cooldown in a table inside the card. All members of the selected pool are shown, including members of other catalog services. Pool management inventory is currently personal or organization admin only; incomplete access is labeled instead of asserting there are no pools. +AI service routing details are read-only. **Manage in Service Pools** opens the +selected pool under its personal or organization owner and scrolls its expanded +card into view. Pool configuration, member ordering and policy edits live in +**Service Pools** only. Priority pools support bounded failover; round-robin and weighted pools select a single member per request. Omitted/null priority policies use server defaults, not diff --git a/docs/plans/local-routing-preview.md b/docs/plans/local-routing-preview.md index 03aee740c..085012da0 100644 --- a/docs/plans/local-routing-preview.md +++ b/docs/plans/local-routing-preview.md @@ -15,12 +15,17 @@ production-data view at `/keys?view=routing`. This is application code, not the standalone HTML reference. Current main's full detail pages, authorship/history, org permissions and reconnect flows are retained. -Latest pool integration validation: 102 focused frontend tests and all 6 backend +Pool integration validation: 102 focused frontend tests and all 6 backend pool billing tests passed. The backend tests used an isolated MongoDB 8.0.16 replica set, including a regression joining each attempt's service history to its billing request ID. TypeScript, the production frontend build, changed-file lint and whitespace checks passed. Signed-in visual review is still outstanding. +The latest at-a-glance pool/failover summaries and owner-specific management links +passed 70 focused frontend tests, the production/credential-accept builds and +changed-file lint. AI service routing inspection has no pool editor; editing is +confined to Service Pools. + Run from `frontend/`: ```sh @@ -66,11 +71,17 @@ cards and filter controls are also used by the normal production frontend. 5. Organization and Service have separate named selectors. Search matches names, slugs and owners. Only matching rows appear in an expanded card; the full group count and Service details link preserve context. -6. Each service card has a Routing row linked to its real saved pools. Opening it - shows priority/rotation, the pool slug, eligibility/cooldown and billing per - member inside the card. The Service Pools tab uses the same route table in - expandable cards. Configure opens the real revision-checked pool editor; this - production-data preview allows drafting but disables Save and mutations. +6. Each collapsed service card shows its saved **Pool** name and strategy, plus + **Failover**: for example, `Up to 3 attempts`, `Off · single attempt`, or + `Pool disabled`. Multiple pools show the additional count and how many have + failover enabled. Hover/focus shows each policy and its pool slug. These + summaries describe configured policies, not successful health probes. +7. Click the pool summary to see priority/rotation, the pool slug, + eligibility/cooldown and billing per member inside the card. This inspection + is read-only. **Manage in Service Pools** opens the selected pool, selects its + owner and scrolls its expanded card into view. **Configure** and member/policy + editing live only in Service Pools. The real revision-checked editor allows + drafting in this production-data preview but disables Save and mutations. The service-grid changes passed 98 focused frontend tests, TypeScript checking, targeted lint and the production build. The running Vite server serves the updated diff --git a/frontend/src/components/dashboard/grouped-service-cards.tsx b/frontend/src/components/dashboard/grouped-service-cards.tsx index 92f267802..123dd3fad 100644 --- a/frontend/src/components/dashboard/grouped-service-cards.tsx +++ b/frontend/src/components/dashboard/grouped-service-cards.tsx @@ -14,15 +14,12 @@ import { ServiceViewToolbar } from "./service-view-toolbar"; import { ServiceConnectionTable } from "./service-connection-table"; import { ServiceAvatarStack } from "./service-avatar-stack"; import { ServiceBillingSummary } from "./service-billing-summary"; -import { GitBranch } from "lucide-react"; import { useServiceRoutingPools, type ServiceRoutingPools, } from "@/hooks/use-service-routing-pools"; -import { poolStrategyLabel } from "@/lib/service-pool-display"; -import type { ServicePool } from "@/schemas/pools"; import { ServicePoolRoutingPanel } from "./service-pool-routing-panel"; -import { PoolEditor } from "./service-pools-tab"; +import { ServicePoolSummary } from "./service-pool-summary"; import { useAuthStore } from "@/stores/auth-store"; import { connectionSourceLabel as sourceLabel, @@ -79,22 +76,12 @@ function GroupCard({ version: number; } | null>(null); const [routeId, setRouteId] = useState(null); - const [editingPool, setEditingPool] = useState(null); const pools = routing.pools.filter((pool) => pool.members.some((member) => connections.some((key) => key.id === member.user_service_id), ), ); const selectedPool = pools.find((pool) => pool.id === routeId) ?? pools[0]; - const routingLabel = routing.loading - ? "Loading routing…" - : pools.length === 1 - ? `${poolStrategyLabel(pools[0]!)}${pools[0]!.is_active ? "" : " · disabled"}` - : pools.length - ? `${pools.length} pools` - : routing.incomplete - ? "Pool access incomplete" - : "Individual slugs"; const contentId = useId(); const headingId = useId(); const cardRef = useRef(null); @@ -348,7 +335,9 @@ function GroupCard({
{!expanded && ( @@ -383,28 +372,18 @@ function GroupCard({ {agents.text} - + /> - Manage pools + Manage in Service Pools
{selectedPool ? ( @@ -492,7 +479,6 @@ function GroupCard({ pool={selectedPool} connections={allConnections} insights={insights} - onEdit={() => setEditingPool(selectedPool)} /> ) : (

@@ -522,15 +508,6 @@ function GroupCard({

)}
- {editingPool && ( - setEditingPool(null)} - /> - )} ); } diff --git a/frontend/src/components/dashboard/service-pool-cards.tsx b/frontend/src/components/dashboard/service-pool-cards.tsx index ab34b56aa..f4a783b33 100644 --- a/frontend/src/components/dashboard/service-pool-cards.tsx +++ b/frontend/src/components/dashboard/service-pool-cards.tsx @@ -1,4 +1,4 @@ -import { useState, type ReactNode } from "react"; +import { useEffect, useRef, useState, type ReactNode } from "react"; import { ChevronRight, GitBranch } from "lucide-react"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; @@ -17,12 +17,22 @@ export function ServicePoolCards({ pools, actions, onEdit, + initialOpenId, }: { readonly pools: readonly ServicePool[]; readonly actions: (pool: ServicePool) => ReactNode; readonly onEdit: (pool: ServicePool) => void; + readonly initialOpenId?: string; }) { - const [open, setOpen] = useState(null); + const [open, setOpen] = useState(initialOpenId ?? null); + const linkedCard = useRef(null); + useEffect(() => { + if (!initialOpenId) return; + const frame = requestAnimationFrame(() => { + linkedCard.current?.scrollIntoView({ block: "start", inline: "nearest" }); + }); + return () => cancelAnimationFrame(frame); + }, [initialOpenId]); const transition = useServiceCardTransition(); const keys = useKeys(); const connections = keys.isError ? [] : (keys.data ?? []); @@ -34,6 +44,7 @@ export function ServicePoolCards({ return (
void; +}) { + const first = pools[0]; + const name = + first?.name ?? + (loading + ? "Loading pools…" + : incomplete + ? "Pool access incomplete" + : "Individual slugs"); + const failover = first + ? poolFailoverSummary(pools) + : loading + ? "Loading…" + : incomplete + ? "Not confirmed" + : "No pool"; + const strategy = + first && pools.length === 1 + ? { + priority: "Priority", + weighted: "Weighted", + round_robin: "Round-robin", + }[first.strategy] + : undefined; + + return ( + + + + + + + {pools.map((pool) => ( +
+

+ {pool.name} · {poolStrategyLabel(pool)} +

+

{poolFailoverLabel(pool)}

+ /api/v1/proxy/s/{pool.slug} +
+ ))} +

+ {first + ? "Configured policy applies when calling the pool slug. Actual attempts depend on eligible members and the request. Individual connection slugs run directly." + : loading + ? "Loading saved pool membership." + : incomplete + ? "Pool membership could not be fully checked." + : "No saved pool contains these connections. Individual connection slugs run directly."} +

+ {first && incomplete && ( +

+ Showing known pools only; additional organization pools may + require admin access. +

+ )} +
+
+
+ ); +} diff --git a/frontend/src/components/dashboard/service-pools-tab.tsx b/frontend/src/components/dashboard/service-pools-tab.tsx index 048020750..9c5ab438b 100644 --- a/frontend/src/components/dashboard/service-pools-tab.tsx +++ b/frontend/src/components/dashboard/service-pools-tab.tsx @@ -76,6 +76,8 @@ import { interface ServicePoolsTabProps { readonly layout?: "cards" | "table"; + readonly initialOrgId?: string; + readonly initialPoolId?: string; readonly createOpen: boolean; readonly onCreateOpenChange: (open: boolean) => void; } @@ -989,10 +991,12 @@ export function PoolHealthDialog({ export function ServicePoolsTab({ layout = "table", + initialOrgId, + initialPoolId, createOpen, onCreateOpenChange, }: ServicePoolsTabProps) { - const [owner, setOwner] = useState("personal"); + const [owner, setOwner] = useState(initialOrgId ?? "personal"); const orgId = owner === "personal" ? undefined : owner; const { data: orgs } = useOrgs(); const pools = useServicePools(orgId); @@ -1094,7 +1098,9 @@ export function ServicePoolsTab({ {(pools.data?.length ?? 0) > 0 && (layout === "cards" ? ( diff --git a/frontend/src/components/dashboard/service-routing-preview.test.tsx b/frontend/src/components/dashboard/service-routing-preview.test.tsx index f0a0d07fb..987fef007 100644 --- a/frontend/src/components/dashboard/service-routing-preview.test.tsx +++ b/frontend/src/components/dashboard/service-routing-preview.test.tsx @@ -83,10 +83,14 @@ vi.mock("@tanstack/react-router", () => ({ Link: ({ children, params = {}, + to, + search, ...props }: { children: ReactNode; params: { keyId?: string; groupId?: string }; + to: string; + search?: Record; "aria-label"?: string; }) => ( ({ href={ params.groupId ? `/keys/services/${params.groupId}` - : `/keys/${params.keyId}` + : params.keyId + ? `/keys/${params.keyId}` + : `${to}${search ? `?${new URLSearchParams(Object.entries(search).filter((entry): entry is [string, string] => entry[1] !== undefined))}` : ""}` } > {children} @@ -1224,6 +1230,11 @@ describe("saved routing in service cards", () => { { ...pool("Reliable"), strategy: "priority", failover: null }, ]; render(preview()); + expect(screen.getByText("Reliable")).toBeVisible(); + expect(screen.getByText("Up to 3 attempts")).toBeVisible(); + expect( + screen.getByRole("button", { name: "Show routing for OpenAI" }), + ).toHaveAttribute("aria-expanded", "false"); await userEvent.click( screen.getByRole("button", { name: "Show routing for OpenAI" }), ); @@ -1235,6 +1246,12 @@ describe("saved routing in service cards", () => { ).toBeVisible(); expect(within(card).getByText("Personal account")).toBeVisible(); expect(within(card).getByText("Team account")).toBeVisible(); + expect( + within(card).queryByRole("button", { name: "Configure pool" }), + ).not.toBeInTheDocument(); + expect( + within(card).getByRole("link", { name: "Manage in Service Pools" }), + ).toHaveAttribute("href", "/keys?tab=pools&view=routing&pool=Reliable"); expect( within(card).queryByRole("table", { name: "OpenAI connections" }), ).not.toBeInTheDocument(); @@ -1268,11 +1285,39 @@ describe("saved routing in service cards", () => { it("does not describe weighted rotation as failover", async () => { poolState.data = [{ ...pool("Rotate"), strategy: "weighted" }]; render(preview()); + expect(screen.getByText("Rotate")).toBeVisible(); + expect(screen.getByText("Weighted")).toBeVisible(); + expect(screen.getByText("Off · single attempt")).toBeVisible(); await userEvent.click( screen.getByRole("button", { name: "Show routing for OpenAI" }), ); expect(screen.getByText("Single attempt · no failover")).toBeVisible(); }); + it("shows mixed failover at a glance and links the selected org pool to its owner", async () => { + poolState.data = [ + { ...pool("Personal"), strategy: "weighted" }, + { ...pool("Team"), user_id: "team-id", strategy: "priority" }, + ]; + render(preview()); + const summary = screen.getByRole("button", { + name: "Show routing for OpenAI", + }); + expect(within(summary).getByText("+1")).toBeVisible(); + expect(within(summary).getByText("On in 1 of 2 pools")).toBeVisible(); + await userEvent.click(summary); + await userEvent.click( + screen.getByRole("button", { name: "Team" }), + ); + expect( + screen.getByRole("link", { name: "Manage in Service Pools" }), + ).toHaveAttribute( + "href", + "/keys?tab=pools&view=routing&pool=Team&org=team-id", + ); + expect( + screen.queryByRole("button", { name: "Configure pool" }), + ).not.toBeInTheDocument(); + }); it("reports pool read failures instead of claiming individual routes only", async () => { poolState.error = new Error("Unavailable"); render(preview()); diff --git a/frontend/src/lib/service-pool-display.test.ts b/frontend/src/lib/service-pool-display.test.ts index aed757ea2..ae3d1bc37 100644 --- a/frontend/src/lib/service-pool-display.test.ts +++ b/frontend/src/lib/service-pool-display.test.ts @@ -3,6 +3,7 @@ import { defaultFailoverPolicy, type ServicePool } from "@/schemas/pools"; import { orderedPoolMembers, poolFailoverLabel, + poolFailoverSummary, poolMemberStatus, reorderPoolMembers, } from "./service-pool-display"; @@ -30,8 +31,41 @@ const members = [ model: null, }, ]; -const pool = { strategy: "priority", members, failover: null } as ServicePool; +const pool = { + strategy: "priority", + members, + failover: null, + is_active: true, +} as ServicePool; describe("saved pool presentation", () => { + it("summarizes configured failover without treating rotation or disabled pools as backups", () => { + expect(poolFailoverSummary([pool])).toBe("Up to 3 attempts"); + const rotation = { ...pool, strategy: "weighted" as const }; + const disabled = { ...pool, is_active: false }; + expect(poolFailoverSummary([rotation])).toBe("Off · single attempt"); + expect(poolFailoverSummary([disabled])).toBe("Pool disabled"); + expect(poolFailoverLabel(disabled)).toBe("Pool disabled · no failover"); + expect(poolFailoverSummary([pool, rotation, disabled])).toBe( + "On in 1 of 3 pools", + ); + expect(poolFailoverSummary([rotation, disabled])).toBe("Off in all pools"); + expect(poolFailoverSummary([disabled, disabled])).toBe("Pools disabled"); + expect( + poolFailoverSummary([ + { + ...pool, + members: members.map((member) => ({ ...member, enabled: false })), + }, + ]), + ).toBe("No enabled members"); + for (const failover of [ + { ...defaultFailoverPolicy, max_attempts: 1 }, + { ...defaultFailoverPolicy, retry_on: [] }, + ]) + expect(poolFailoverSummary([{ ...pool, failover }])).toBe( + "Off · single attempt", + ); + }); it("uses default failover for null policy and honors disabled retry policies", () => { expect(poolFailoverLabel(pool)).toBe("Failover · up to 3 attempts"); expect( diff --git a/frontend/src/lib/service-pool-display.ts b/frontend/src/lib/service-pool-display.ts index 918cb0103..4c01ddd68 100644 --- a/frontend/src/lib/service-pool-display.ts +++ b/frontend/src/lib/service-pool-display.ts @@ -14,6 +14,7 @@ export function poolStrategyLabel(pool: ServicePool): string { } export function poolFailoverLabel(pool: ServicePool): string { + if (!pool.is_active) return "Pool disabled · no failover"; if (pool.strategy !== "priority") return "Single attempt · no failover"; const policy = pool.failover ?? defaultFailoverPolicy; return policy.max_attempts === 1 || !policy.retry_on.length @@ -21,6 +22,30 @@ export function poolFailoverLabel(pool: ServicePool): string { : `Failover · up to ${policy.max_attempts} attempts`; } +function configuredAttempts(pool: ServicePool): number { + if (!pool.is_active || !pool.members.some((member) => member.enabled)) + return 0; + if (pool.strategy !== "priority") return 1; + const policy = pool.failover ?? defaultFailoverPolicy; + return policy.retry_on.length ? policy.max_attempts : 1; +} + +export function poolFailoverSummary(pools: readonly ServicePool[]): string { + if (!pools.length) return "No pool"; + if (pools.length === 1) { + const pool = pools[0]!; + if (!pool.is_active) return "Pool disabled"; + const attempts = configuredAttempts(pool); + if (!attempts) return "No enabled members"; + return attempts > 1 ? `Up to ${attempts} attempts` : "Off · single attempt"; + } + if (pools.every((pool) => !pool.is_active)) return "Pools disabled"; + const enabled = pools.filter((pool) => configuredAttempts(pool) > 1).length; + return enabled + ? `On in ${enabled} of ${pools.length} pools` + : "Off in all pools"; +} + export function orderedPoolMembers( pool: Pick, ): ServicePoolMember[] { diff --git a/frontend/src/pages/keys.test.tsx b/frontend/src/pages/keys.test.tsx index 0aab9c251..6bece092c 100644 --- a/frontend/src/pages/keys.test.tsx +++ b/frontend/src/pages/keys.test.tsx @@ -22,8 +22,9 @@ function render(ui: ReactNode) { }); } -const { mockNavigate, state } = vi.hoisted(() => ({ +const { mockNavigate, mockPoolOwner, state } = vi.hoisted(() => ({ mockNavigate: vi.fn(), + mockPoolOwner: vi.fn(), // Mutable containers populated per-test before render. state: { search: {} as { @@ -31,6 +32,8 @@ const { mockNavigate, state } = vi.hoisted(() => ({ slug?: string; action?: string; view?: string; + pool?: string; + org?: string; }, keys: [] as KeyInfo[], keysLoading: false, @@ -89,18 +92,21 @@ vi.mock("@/hooks/use-pools", () => ({ isError: false, isLoading: false, }), - useServicePools: () => ({ - data: [ - { - id: "pool-one", - name: "My pool", - slug: "my-pool", - strategy: "round_robin", - members: [{ user_service_id: "key-1", enabled: true, weight: 1 }], - is_active: true, - }, - ], - }), + useServicePools: (orgId?: string) => { + mockPoolOwner(orgId); + return { + data: [ + { + id: "pool-one", + name: "My pool", + slug: "my-pool", + strategy: "round_robin", + members: [{ user_service_id: "key-1", enabled: true, weight: 1 }], + is_active: true, + }, + ], + }; + }, })); vi.mock("@/hooks/use-service-routing-pools", () => ({ @@ -110,7 +116,18 @@ vi.mock("@/hooks/use-service-routing-pools", () => ({ incomplete: false, }), })); -vi.mock("@/hooks/use-orgs", () => ({ useOrgs: () => ({ data: [] }) })); +vi.mock("@/hooks/use-orgs", () => ({ + useOrgs: () => ({ + data: [ + { + id: "team-id", + display_name: "Research", + slug: "research", + your_role: "admin", + }, + ], + }), +})); // Heavy children — stubbed to assert wiring (open state, presence), not driven. vi.mock("@/components/providers/codex-connection", () => ({ @@ -295,8 +312,28 @@ describe("KeysPage", () => { expect(screen.getByText("My OpenAI")).toBeVisible(); expect(screen.getByText("/api/v1/proxy/s/my-pool")).toBeVisible(); expect(screen.getByRole("button", { name: "Create pool" })).toBeVisible(); + expect( + screen.getByRole("button", { name: "Configure pool" }), + ).toBeVisible(); }); + it.each([undefined, "team-id"])( + "opens a linked pool under its owner (%s) without opening an editor", + async (org) => { + state.search = { tab: "pools", pool: "pool-one", org }; + state.keys = [makeKey({ credential_source: { type: "personal" } })]; + render(); + expect( + await screen.findByRole("table", { name: "My pool route members" }), + ).toBeVisible(); + expect(mockPoolOwner).toHaveBeenLastCalledWith(org); + expect( + screen.getByRole("button", { name: "Configure pool" }), + ).toBeVisible(); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + }, + ); + it("omits oauth2 and api_key credential pills from service cards", () => { state.keys = [ makeKey({ diff --git a/frontend/src/pages/keys.tsx b/frontend/src/pages/keys.tsx index baf06c06e..95a764992 100644 --- a/frontend/src/pages/keys.tsx +++ b/frontend/src/pages/keys.tsx @@ -296,6 +296,8 @@ export function KeysPage() { action?: string; service?: string; view?: string; + pool?: string; + org?: string; } = useSearch({ strict: false }); const navigate = useNavigate(); const tab = parseTab(search.tab, KEYS_TABS, KEYS_TAB_DEFAULT); @@ -485,7 +487,10 @@ export function KeysPage() { diff --git a/frontend/src/router.tsx b/frontend/src/router.tsx index f6c1bedb9..df268ee68 100644 --- a/frontend/src/router.tsx +++ b/frontend/src/router.tsx @@ -737,7 +737,7 @@ const keysRoute = createRoute({ // service scope in the Agent Key create dialog. validateSearch: ( search: Record, - ): { tab?: string; slug?: string; action?: string; service?: string; view?: string } => ({ + ): { tab?: string; slug?: string; action?: string; service?: string; view?: string; pool?: string; org?: string } => ({ ...(import.meta.env.DEV && search.view === "routing" ? { view: "routing" } : {}), ...(typeof search.tab === "string" ? { tab: search.tab } : {}), ...(typeof search.slug === "string" && search.slug.length > 0 @@ -747,6 +747,10 @@ const keysRoute = createRoute({ ...(typeof search.service === "string" && search.service.length > 0 ? { service: search.service } : {}), + ...(typeof search.pool === "string" && search.pool.length > 0 && search.pool.length <= 128 + ? { pool: search.pool } : {}), + ...(typeof search.org === "string" && search.org.length > 0 && search.org.length <= 128 + ? { org: search.org } : {}), }), component: KeysPage, }); From 8dd785939310368bf51a05806d93ed7cdbaae2ea Mon Sep 17 00:00:00 2001 From: ctkm-aelf <230958228+ctkm-aelf@users.noreply.github.com> Date: Fri, 2 Oct 2026 15:25:22 +0800 Subject: [PATCH 13/36] Clarify service card billing, pools and activity summaries --- .../src/services/service_insights_billing.rs | 221 ++++++++++++++++++ docs/AI_SERVICES_ARCHITECTURE.md | 46 ++-- docs/plans/local-routing-preview.md | 19 +- .../dashboard/grouped-service-cards.tsx | 136 +++++++---- .../dashboard/service-billing-summary.tsx | 152 ++++++------ .../dashboard/service-connection-table.tsx | 64 ++++- .../dashboard/service-insight-panels.tsx | 8 + .../dashboard/service-insights.test.tsx | 4 +- .../dashboard/service-pool-summary.tsx | 63 +++-- .../service-routing-preview.test.tsx | 175 +++++++++----- frontend/src/lib/service-billing-config.ts | 62 +++++ frontend/src/lib/service-card-summary.test.ts | 196 ++++++++++++++++ frontend/src/lib/service-card-summary.ts | 46 ++++ frontend/src/lib/service-insights-compat.ts | 5 +- frontend/src/schemas/service-insights.ts | 2 +- 15 files changed, 954 insertions(+), 245 deletions(-) create mode 100644 frontend/src/lib/service-billing-config.ts create mode 100644 frontend/src/lib/service-card-summary.test.ts create mode 100644 frontend/src/lib/service-card-summary.ts diff --git a/backend/src/services/service_insights_billing.rs b/backend/src/services/service_insights_billing.rs index 8556846ea..7c2326a79 100644 --- a/backend/src/services/service_insights_billing.rs +++ b/backend/src/services/service_insights_billing.rs @@ -79,6 +79,8 @@ pub struct ServiceBillingExplanation { pub credential_label: String, pub account: Option, pub charge_status: ConnectionChargeStatus, + /// Saved usage-charge configuration, independent of availability and caller rollout. + pub credit_billing_configured: Option, pub rates: Vec, pub provider_billing: ProviderBillingDisclosure, /// "for_you" uses the viewer's default; "agent_key" includes that key's override. @@ -549,9 +551,108 @@ async fn explain_connections_in_context( } explanations.insert(service.id.clone(), explanation); } + // Keep configured billability visible for disabled or unavailable connections. + // This reads the already-loaded metadata, without resolving credentials or a payer. + for service in services { + let Some(explanation) = explanations.get_mut(&service.id) else { + continue; + }; + annotate_configured_charge( + explanation, + service, + service + .catalog_service_id + .as_ref() + .and_then(|id| catalog.get(id)), + service + .api_key_id + .as_ref() + .and_then(|id| credentials.get(id)), + agent_key.is_some(), + ); + } Ok(explanations) } +fn annotate_configured_charge( + explanation: &mut ServiceBillingExplanation, + service: &UserService, + catalog: Option<&DownstreamService>, + credential: Option<&UserApiKey>, + agent_context: bool, +) { + if explanation.status == BillingExplanationStatus::Restricted + || (service.catalog_service_id.is_some() && catalog.is_none()) + || (agent_context && explanation.credential_class.is_none()) + { + return; + } + let stored = credential.filter(|key| key.user_id == service.user_id); + let class = explanation.credential_class.or_else(|| { + if uses_platform_binding(service) { + Some(CredentialClass::NyxidManagedMaster) + } else if service.node_id.is_some() { + // Without materializing a disabled credential, shared OAuth versus node + // supply is ambiguous and can change a credential-restricted price. + if stored.is_some_and(|key| key.credential_source.as_deref() == Some("platform")) { + None + } else { + Some(CredentialClass::NodeManaged) + } + } else if service.auth_method == "none" { + Some(CredentialClass::NoAuth) + } else { + stored.map(|key| default_credential_class(service, key, true)) + } + }); + let configuration = catalog.and_then(|service| service.billing.as_ref()); + explanation.credit_billing_configured = if configuration.is_none() { + Some(false) + } else { + class.map(|class| configured_usage_charge(configuration, class)) + }; +} + +fn configured_usage_charge(configuration: Option<&ServiceBilling>, class: CredentialClass) -> bool { + let Some(billing) = configuration else { + return false; + }; + if class == CredentialClass::NyxidManagedMaster && billing.resale_billable { + return true; + } + if billing.platform_charge_nyxid_credentials_only + && !matches!( + class, + CredentialClass::NyxidManagedMaster | CredentialClass::NyxidPlatformOauthApp + ) + { + return false; + } + let positive = |rate: &str| { + crate::services::billing::amounts::decimal_to_pico(rate).is_some_and(|rate| rate > 0) + }; + let legacy = billing.platform_billable + && billing.platform_pricing.as_ref().is_none_or(|price| { + price.sync_status != PricingSyncStatus::Synced || positive(&price.credits_per_unit) + }); + if billing.byok_pricing.is_none() && billing.platform_key_pricing.is_none() { + return legacy; + } + let lane = match class { + CredentialClass::NyxidManagedMaster => billing.platform_key_pricing.as_ref(), + CredentialClass::NoAuth => None, + _ => billing.byok_pricing.as_ref(), + }; + lane.is_some_and(|lane| { + positive(&lane.credits_per_unit) + || lane + .components + .iter() + .any(|rate| positive(&rate.credits_per_unit)) + || (lane.sync_status != PricingSyncStatus::Synced && legacy) + }) +} + fn annotate_transport_pricing( explanation: &mut ServiceBillingExplanation, catalog: Option<&DownstreamService>, @@ -607,6 +708,7 @@ fn restricted() -> ServiceBillingExplanation { credential_label: "Credential restricted".into(), account: None, charge_status: ConnectionChargeStatus::Restricted, + credit_billing_configured: None, rates: Vec::new(), provider_billing: ProviderBillingDisclosure::Unknown, context: "for_you".into(), @@ -696,6 +798,7 @@ fn project_billing( credential_label: credential_label.into(), account: Some(account), charge_status: ConnectionChargeStatus::NotCharged, + credit_billing_configured: Some(configured_usage_charge(configuration, credential_class)), rates: Vec::new(), provider_billing, context: "for_you".into(), @@ -831,6 +934,124 @@ mod tests { } } + #[test] + fn configured_billability_for_disabled_connections_preserves_access_and_unknown_states() { + let mut catalog = crate::models::downstream_service::test_helpers::dummy_service(); + catalog.billing = Some(ServiceBilling { + platform_key_pricing: Some(lane(BillingMetric::Requests, "1", "pk")), + ..Default::default() + }); + let mut service = connection(); + service.catalog_service_id = Some(catalog.id.clone()); + service.is_active = false; + service.credential_binding = Some("platform".into()); + let mut disabled = unavailable("This connection is disabled."); + annotate_configured_charge(&mut disabled, &service, Some(&catalog), None, false); + assert_eq!(disabled.credit_billing_configured, Some(true)); + assert_eq!(disabled.status, BillingExplanationStatus::Unavailable); + assert!(disabled.account.is_none()); + let mut hidden = restricted(); + annotate_configured_charge(&mut hidden, &service, Some(&catalog), None, false); + assert_eq!(hidden.credit_billing_configured, None); + let mut missing_catalog = unavailable("Missing configuration"); + annotate_configured_charge(&mut missing_catalog, &service, None, None, false); + assert_eq!(missing_catalog.credit_billing_configured, None); + let mut override_unavailable = unavailable("Agent override unavailable"); + annotate_configured_charge( + &mut override_unavailable, + &service, + Some(&catalog), + None, + true, + ); + assert_eq!(override_unavailable.credit_billing_configured, None); + } + + #[test] + fn configured_billability_counts_the_credential_lane_independent_of_rollout() { + let mut config = ServiceBilling { + platform_key_pricing: Some(lane(BillingMetric::Requests, "1", "pk")), + ..Default::default() + }; + assert!(configured_usage_charge( + Some(&config), + CredentialClass::NyxidManagedMaster + )); + assert!(!configured_usage_charge( + Some(&config), + CredentialClass::UserOwned + )); + config.byok_pricing = Some(lane(BillingMetric::Requests, "0", "byok")); + assert!(!configured_usage_charge( + Some(&config), + CredentialClass::UserOwned + )); + config + .byok_pricing + .as_mut() + .unwrap() + .components + .push(LanePriceComponent { + metric: BillingMetric::Images, + credits_per_unit: "0.000000000001".into(), + lago_metric_code: "images".into(), + sync_status: PricingSyncStatus::Pending, + sync_error: None, + }); + assert!(configured_usage_charge( + Some(&config), + CredentialClass::UserOwned + )); + let mut service = connection(); + service.is_active = false; + let explanation = project_billing( + &service, + Some(&config), + CredentialClass::UserOwned, + ServiceBillingAccount { + id: "person".into(), + kind: BillingAccountKind::Personal, + name: "Personal".into(), + }, + false, + false, + false, + ); + assert_eq!(explanation.credit_billing_configured, Some(true)); + assert_eq!( + explanation.charge_status, + ConnectionChargeStatus::NotCharged + ); + config.platform_charge_nyxid_credentials_only = true; + assert!(!configured_usage_charge( + Some(&config), + CredentialClass::UserOwned + )); + assert!(configured_usage_charge( + Some(&config), + CredentialClass::NyxidPlatformOauthApp + )); + let mut pending = ServiceBilling { + platform_billable: true, + platform_pricing: Some(ServicePlatformPricing { + credits_per_unit: "0".into(), + lago_metric_code: "legacy".into(), + sync_status: PricingSyncStatus::Pending, + sync_error: None, + }), + ..Default::default() + }; + assert!(configured_usage_charge( + Some(&pending), + CredentialClass::UserOwned + )); + pending.platform_pricing.as_mut().unwrap().sync_status = PricingSyncStatus::Synced; + assert!(!configured_usage_charge( + Some(&pending), + CredentialClass::UserOwned + )); + } + fn explain(config: &ServiceBilling, class: CredentialClass) -> ServiceBillingExplanation { project_billing( &connection(), diff --git a/docs/AI_SERVICES_ARCHITECTURE.md b/docs/AI_SERVICES_ARCHITECTURE.md index 7c3b13b9c..e4b06dcca 100644 --- a/docs/AI_SERVICES_ARCHITECTURE.md +++ b/docs/AI_SERVICES_ARCHITECTURE.md @@ -12,7 +12,7 @@ NyxID's AI Services system lets users manage external API credentials, SSH servi The External Services grid groups catalog-backed connections by `catalog_service_id`. Custom connections remain separate by ID. Each group starts collapsed with a -256px fixed collapsed height; expanding a group keeps its connection comparison +288px fixed collapsed height and two reserved description lines; expanding a group keeps its connection comparison table inside the parent. Connection details and history remain on their existing detail pages. The expanded table shows Classification (personal, named organization with role, @@ -62,15 +62,26 @@ rows and the parent displays the matching count against the group total. With no filters all siblings are visible. The full service page always contains every accessible connection in the group, independent of list filters. -Sources and Billing use stationary overlapping avatar circles. Hovering or focusing -a circle brings it to the front and shows its full details in a tooltip. Circles do -not move or resize, and tooltips do not animate. Extra sources remain reachable -through the overflow count. -Billing shows **NyxID credits**, **No NyxID charge**, or **Credit billing unverified**, -independently of credential supply. Configuration-only data is labeled -**NyxID credits · configured**; mixed charge states say they vary by connection. -Each row shows the credential supplier under ownership, and the charge model, -expected/resolved payer, and rate in Billing. Hover reveals the explanation. +Source avatars sit at the bottom right of the card body without a visible label. +Hovering or focusing a stationary circle brings it forward and shows its source +name. Extra sources remain reachable through the overflow count. + +The body starts with the number of connections with configured NyxID usage +charges, e.g. **1 of 6 connections billable**. Disabled connections count; grants, +allowances, and caller rollout do not change configured billability. The additive +`billing.credit_billing_configured` insight field is true/false when known and null +when unverifiable or restricted. It uses the selected credential class, positive +primary/component prices, legacy charge configuration, and independent resale +configuration. Older servers use permitted catalog/connection metadata where +available; omitted pricing is unverified, never assumed free. External provider +invoices are separate. Clicking the summary opens the first billable connection's +billing panel. Every table row exposes its own classification, payer and rates. + +Agent keys are counted once across the group's connections; partial inventories +show a `+`. Last use is on its own line with a relative time and recorded caller. +The lower-left **Edited** summary uses the latest `authorship.last_change` event, +including its actor, and opens that connection's history. Creation and credential +preparation timestamps are not substituted for edits or usage. BYOK means a supplied API key or developer app. A user's OAuth token is not proof of their own app: the internal BYOK price lane also includes NyxID's shared OAuth @@ -86,14 +97,13 @@ it does not change the provider credential's owner. Conversely, signing into an account through NyxID's app is not BYOK. This preview shows the configured funding order; identifying the actual allowance, grant, or wallet used requires per-request settlement data and is not claimed here. -Card billing summaries keep each source separate even when the expected payer is -the same. Personal credentials normally use the personal account, organization -credentials use the owning organization's account, and a platform key uses the -acting person's personal account. Each source opens its own inline billing panel. -Each collapsed service card shows **Pool** (name, strategy, additional pool count) -and **Failover** (configured attempt limit or off/disabled state). Multiple pools -show how many have failover enabled; hover/focus lists their individual policies. -These two compact lines keep the common 256px collapsed card height. +Personal credentials normally use the personal account, organization credentials +use the owning organization's account, and a platform key uses the acting person's +personal account. These details remain separate for every connection in the table. +The pool summary shows the number of member connections and selection strategy; +the next line shows the pool name and configured failover. Multiple pools show +their count and how many have failover enabled. Hover/focus lists each pool's +members, strategy, policy and slug. Expanded connection rows link to their pools. Grouping by catalog does not create a pool or change an individual connection slug. Click the pool summary to inspect the pool slug, strategy, priority/weight, credential supplier, billing account/rates, and operation-scoped eligibility/cooldown in a table inside diff --git a/docs/plans/local-routing-preview.md b/docs/plans/local-routing-preview.md index 085012da0..dcbc95392 100644 --- a/docs/plans/local-routing-preview.md +++ b/docs/plans/local-routing-preview.md @@ -21,10 +21,21 @@ replica set, including a regression joining each attempt's service history to it billing request ID. TypeScript, the production frontend build, changed-file lint and whitespace checks passed. Signed-in visual review is still outstanding. -The latest at-a-glance pool/failover summaries and owner-specific management links -passed 70 focused frontend tests, the production/credential-accept builds and -changed-file lint. AI service routing inspection has no pool editor; editing is -confined to Service Pools. +Updated 2 October: collapsed service cards are 288px tall with two description +lines and the existing icon/name/count and footer navigation. The body shows a +configured billable-connection count (including disabled connections), pool +member count/strategy and failover, deduplicated agent-key count, last-use time, +and latest recorded edit. Source avatars sit at the body's bottom right. The +expanded table exposes per-connection billing, pool membership, access/use, and +edit history. Clicking billing selects the first billable connection; clicking +last edit opens the affected connection's history. No pool editor is mounted in +AI service cards. + +102 focused frontend tests, 14 backend billing projection tests, production/credential-accept +builds and changed-file lint passed for this revision. Signed-in visual review remains unavailable. +The backend now reports configured billing separately from execution availability; +the production-data preview uses published inventory metadata until that backend +change is deployed. Missing data remains unverified. Run from `frontend/`: diff --git a/frontend/src/components/dashboard/grouped-service-cards.tsx b/frontend/src/components/dashboard/grouped-service-cards.tsx index 123dd3fad..420170905 100644 --- a/frontend/src/components/dashboard/grouped-service-cards.tsx +++ b/frontend/src/components/dashboard/grouped-service-cards.tsx @@ -7,13 +7,14 @@ import { type ReactNode, type RefObject, } from "react"; -import { ChevronRight, UsersRound } from "lucide-react"; +import { ChevronRight, UsersRound, History } from "lucide-react"; import { useServiceView } from "@/hooks/use-service-view"; import { useServiceCardTransition } from "@/hooks/use-service-card-transition"; import { ServiceViewToolbar } from "./service-view-toolbar"; import { ServiceConnectionTable } from "./service-connection-table"; import { ServiceAvatarStack } from "./service-avatar-stack"; import { ServiceBillingSummary } from "./service-billing-summary"; +import { latestServiceEdit } from "@/lib/service-card-summary"; import { useServiceRoutingPools, type ServiceRoutingPools, @@ -56,7 +57,9 @@ function GroupCard({ filtersRef, routing, allConnections, + catalog, }: { + readonly catalog?: CatalogEntry; readonly routing: ServiceRoutingPools; readonly allConnections: readonly KeyInfo[]; readonly group: ServiceConnectionGroup; @@ -72,7 +75,7 @@ function GroupCard({ const [routingOpen, setRoutingOpen] = useState(false); const [requestedPanel, setRequestedPanel] = useState<{ id: string; - view: "billing" | "requests" | "access"; + view: "billing" | "requests" | "access" | "history"; version: number; } | null>(null); const [routeId, setRouteId] = useState(null); @@ -161,6 +164,7 @@ function GroupCard({ }), ).values(), ]; + const lastEdit = latestServiceEdit(connections); const disabled = connections.filter((key) => !key.is_active).length; const connectionInsights = connections.map((key) => insights.connections.get(key.id), @@ -196,13 +200,15 @@ function GroupCard({ (item) => item?.usage?.activity.visibility === "own_requests", ); const keysText = configuredKeys.length - ? `${configuredKeys.length} ${configuredKeys.length === 1 ? "key" : "keys"}` + ? `${configuredKeys.length}${accessIncomplete ? "+" : ""} agent ${configuredKeys.length === 1 ? "key" : "keys"}` : accessIncomplete - ? "keys —" - : "no keys"; + ? "Agent keys unverified" + : "0 agent keys"; const useText = latestUse - ? `${callerLabel(latestUse.caller)} · ${formatRelativeTime(latestUse.occurred_at)}` - : "use not recorded"; + ? `${ownUseOnly ? "Your last use" : "Last used"} ${formatRelativeTime(latestUse.occurred_at)} · ${callerLabel(latestUse.caller)}` + : useTracked + ? "No recorded use in 30 days" + : "Last use not reported"; const agents = insights.status === "loading" ? { text: "Loading…", title: "Loading agent keys and use" } @@ -217,7 +223,7 @@ function GroupCard({ : "Agent keys and use couldn't load", } : { - text: `${keysText} · ${useText}`, + text: keysText, title: [ configuredKeys.length ? `Keys with access: ${configuredKeys.join(", ")}` @@ -232,7 +238,7 @@ function GroupCard({ ].join("\n"), }; const openSummary = ( - view: "billing" | "requests" | "access", + view: "billing" | "requests" | "access" | "history", connectionId?: string, ) => { const lastConnection = @@ -287,10 +293,10 @@ function GroupCard({
-
+
{!expanded && ( -

+

{search.trim() ? `Matches: ${connections.map((key) => key.label).join(" · ")}` : group.description} @@ -340,38 +346,13 @@ function GroupCard({ : "space-y-0.5", )} > - {!expanded && ( -

- - Sources - - -
- )} - + openSummary("billing", id)} + /> - openSummary("billing", id)} - /> + +
+ + +
@@ -503,6 +532,12 @@ function GroupCard({ insights={insights} serviceName={group.name} renderActions={renderConnectionActions} + catalog={catalog} + pools={pools} + onViewPool={(id) => { + setRouteId(id); + setRoutingOpen(true); + }} /> )}
@@ -632,6 +667,7 @@ export function GroupedServiceCards({ insights={insights} routing={routing} allConnections={keys} + catalog={catalog?.find((entry) => entry.slug === group.slug)} connections={matches} search={filters.search} onToggle={(card) => diff --git a/frontend/src/components/dashboard/service-billing-summary.tsx b/frontend/src/components/dashboard/service-billing-summary.tsx index 1a64cca16..a5e3d2b20 100644 --- a/frontend/src/components/dashboard/service-billing-summary.tsx +++ b/frontend/src/components/dashboard/service-billing-summary.tsx @@ -1,90 +1,98 @@ import { CreditCard } from "lucide-react"; import type { ServiceInsightsState } from "@/hooks/use-service-insights"; +import { connectionBillability } from "@/lib/service-card-summary"; +import { insightStatusLabel } from "@/lib/service-insights"; +import type { CatalogEntry, KeyInfo } from "@/types/keys"; import { - summarizeBilling, - summarizeBillingDetail, - summarizeBillingModel, - credentialLabel, -} from "@/lib/service-insights"; -import { connectionSource, connectionSourceLabel } from "@/lib/service-view"; -import type { KeyInfo } from "@/types/keys"; -import { ServiceAvatarStack } from "./service-avatar-stack"; + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; export function ServiceBillingSummary({ connections, insights, + catalog, serviceName, onOpen, }: { readonly connections: readonly KeyInfo[]; readonly insights: ServiceInsightsState; + readonly catalog?: CatalogEntry; readonly serviceName: string; readonly onOpen: (connectionId: string) => void; }) { - const sources = new Map(); - for (const connection of connections) { - if (!connection.is_active) continue; - const type = connectionSource(connection); - const id = - type === "org" && connection.credential_source?.type === "org" - ? `org:${connection.credential_source.org_id}` - : type; - const rows = sources.get(id) ?? []; - rows.push(connection); - sources.set(id, rows); - } - const model = summarizeBillingModel( - insights.status, - [...sources.values()].flat().map((row) => insights.connections.get(row.id)), - ); + const rows = connections.map((connection) => ({ + connection, + billable: + insights.status === "ready" + ? connectionBillability( + connection, + insights.connections.get(connection.id)?.billing, + catalog, + ) + : undefined, + })); + const billed = rows.filter((row) => row.billable === true); + const unknown = rows.filter((row) => row.billable === undefined).length; + const label = + insights.status !== "ready" + ? insightStatusLabel(insights.status, "Billing") + : billed.length + ? `${billed.length}${unknown ? "+" : ""} of ${rows.length} ${rows.length === 1 ? "connection" : "connections"} billable` + : unknown + ? `Billing unverified · ${unknown} ${unknown === 1 ? "connection" : "connections"}` + : "No NyxID usage charges"; return ( -
- Billing - {sources.size ? ( - <> - { - const first = rows[0]!; - const type = connectionSource(first); - const source = - type === "platform" ? "Platform" : connectionSourceLabel(first); - const sourceInsights = rows.map((connection) => - insights.connections.get(connection.id), - ); - const sourceModel = summarizeBillingModel( - insights.status, - sourceInsights, - ); - const org = - type === "org" && first.credential_source?.type === "org" - ? first.credential_source - : undefined; - return { - id, - type, - name: source, - avatarUrl: org?.avatar_url, - detail: sourceModel, - description: - insights.status === "ready" - ? `${[...new Set(rows.map((row) => credentialLabel(row, insights.connections.get(row.id)?.billing)))].join(" / ")} · NyxID payer: ${summarizeBilling(sourceInsights)} · ${summarizeBillingDetail(sourceInsights)}` - : undefined, - actionLabel: `Show ${source} billing for ${serviceName}`, - onSelect: () => onOpen(first.id), - }; - })} - /> - - {model} - - - ) : ( - - - )} -
+ + + + + + +

NyxID usage billing

+ {unknown > 0 && ( +

+ {unknown} {unknown === 1 ? "connection has" : "connections have"}{" "} + unverified billing; the billable count may be higher. +

+ )} + {rows.map(({ connection, billable }) => ( +

+ {connection.label}:{" "} + {billable === true + ? "Billable" + : billable === false + ? "No NyxID usage charge" + : "Unverified"} + {!connection.is_active ? " · disabled" : ""} +

+ ))} +

+ Counts configured charges, including disabled connections. + Allowances and grants can cover charges. Provider invoices are + separate. +

+
+
+
); } diff --git a/frontend/src/components/dashboard/service-connection-table.tsx b/frontend/src/components/dashboard/service-connection-table.tsx index f10d37023..a08d46c29 100644 --- a/frontend/src/components/dashboard/service-connection-table.tsx +++ b/frontend/src/components/dashboard/service-connection-table.tsx @@ -9,6 +9,7 @@ import { Settings2, UsersRound, CreditCard, + GitBranch, } from "lucide-react"; import { Badge } from "@/components/ui/badge"; import { @@ -37,7 +38,10 @@ import { formatDateTime, formatRelativeTime, } from "@/lib/utils"; -import type { KeyInfo } from "@/types/keys"; +import type { CatalogEntry, KeyInfo } from "@/types/keys"; +import type { ServicePool } from "@/schemas/pools"; +import { poolStrategyLabel } from "@/lib/service-pool-display"; +import { connectionBillability } from "@/lib/service-card-summary"; import type { ServiceInsight } from "@/schemas/service-insights"; import { useServiceInsights, @@ -142,13 +146,19 @@ export function ServiceConnectionTable({ onViewHistory, insights: suppliedInsights, initialPanel = null, + catalog, + pools = [], + onViewPool, }: { readonly connections: readonly KeyInfo[]; readonly serviceName: string; readonly renderActions?: (connection: KeyInfo) => ReactNode; readonly onViewHistory?: (connection: KeyInfo) => void; readonly insights?: ServiceInsightsState; - readonly initialPanel?: { id: string; view: InsightPanel } | null; + readonly initialPanel?: { id: string; view: InsightPanel | "history" } | null; + readonly catalog?: CatalogEntry; + readonly pools?: readonly ServicePool[]; + readonly onViewPool?: (poolId: string) => void; }) { const [open, setOpen] = useState<{ id: string; @@ -191,6 +201,13 @@ export function ServiceConnectionTable({ {connections.map((key) => { const insight = insights.connections.get(key.id); const billing = insight?.billing; + const billable = + insights.status === "ready" + ? connectionBillability(key, billing, catalog) + : undefined; + const memberships = pools.filter((pool) => + pool.members.some((member) => member.user_service_id === key.id), + ); const usage = insight?.usage; const latest = latestRecordedUse(usage); const useTracked = @@ -319,6 +336,31 @@ export function ServiceConnectionTable({ {editable && renderActions?.(key)}

+ {memberships.map((pool) => { + const member = pool.members.find( + (member) => member.user_service_id === key.id, + )!; + return ( + + ); + })}
@@ -442,12 +484,16 @@ export function ServiceConnectionTable({ - {billing - ? billingModelLabel(billing) - : insightStatusLabel( - insights.status, - "Billing", - )} + {billable === true + ? "Billable · NyxID usage" + : billable === false + ? "No NyxID usage charge" + : billing + ? billingModelLabel(billing) + : insightStatusLabel( + insights.status, + "Billing", + )} {billing && ( @@ -550,6 +596,8 @@ export function ServiceConnectionTable({ : toggle(key.id, "history") } aria-label={`History for ${key.label} (${owner})`} + aria-expanded={expanded && open.view === "history"} + aria-controls={panelId} className="inline-flex shrink-0 items-center gap-1 rounded-sm text-primary hover:underline focus-visible:outline-2 focus-visible:outline-ring" >
); } diff --git a/frontend/src/components/dashboard/service-insights.test.tsx b/frontend/src/components/dashboard/service-insights.test.tsx index b996dc11a..a9118f58e 100644 --- a/frontend/src/components/dashboard/service-insights.test.tsx +++ b/frontend/src/components/dashboard/service-insights.test.tsx @@ -318,7 +318,9 @@ describe("service card billing and caller details", () => { expect(within(panel).getByText("Billing flow")).toBeVisible(); expect(within(panel).getByText("Expected payer")).toBeVisible(); expect(within(panel).getByText("Pending")).toBeVisible(); - expect(within(panel).queryByRole("combobox")).not.toBeInTheDocument(); + expect( + within(panel).queryByRole("combobox", { name: "Preview billing for" }), + ).not.toBeInTheDocument(); expect( within(panel).getByRole("table", { name: "Configured NyxID rates" }), ).toBeVisible(); diff --git a/frontend/src/components/dashboard/service-view-toolbar.tsx b/frontend/src/components/dashboard/service-view-toolbar.tsx index 7278180b0..d33e755d2 100644 --- a/frontend/src/components/dashboard/service-view-toolbar.tsx +++ b/frontend/src/components/dashboard/service-view-toolbar.tsx @@ -266,7 +266,7 @@ export function ServiceViewToolbar({ role="region" aria-label="Service filters" data-stuck={stuck} - className="service-filter-toolbar pointer-events-none sticky top-0 z-20" + className="service-filter-toolbar pointer-events-none sticky -top-2 z-20 sm:-top-3" >
): BillingUsageRow => ({ + service_slug: "llm-deepseek", + metric: "tokens", + lago_metric_code: "platform_svc_llm-deepseek_pk", + layer: "platform", + quantity: 0, + requests: 0, + bytes: 0, + events: 0, + lago_acked: true, + billable: true, + ...overrides, +}); + +describe("serviceUsageSummary", () => { + it("counts token-metered calls from events and splits funding", () => { + const summary = serviceUsageSummary( + [ + row({ + quantity: 300, + events: 2, + api_key_id: "k1", + api_key_name: "heca", + estimated_credits: "0.0003", + grant_credits: "0.0003", + }), + row({ + quantity: 210, + events: 1, + estimated_credits: "0.00021", + wallet_credits: "0.00021", + }), + row({ service_slug: "other", quantity: 99, events: 9 }), + ], + "llm-deepseek", + ); + expect(summary).toMatchObject({ + calls: 3, + quantities: [{ metric: "tokens", quantity: 510 }], + charged: "0.00051", + grant: "0.0003", + wallet: "0.00021", + allowance: "0", + billable: true, + agents: [ + { name: "heca", calls: 2 }, + { name: null, calls: 1 }, + ], + }); + }); + + it("reports metering-only usage without a charge and unsettled charges as unknown", () => { + expect( + serviceUsageSummary( + [row({ billable: false, metric: "requests", quantity: 4, events: 4 })], + "llm-deepseek", + ), + ).toMatchObject({ calls: 4, billable: false, charged: "0" }); + expect( + serviceUsageSummary([row({ events: 1 })], "llm-deepseek")?.charged, + ).toBeNull(); + expect(serviceUsageSummary([row({})], "api-twitter")).toBeNull(); + }); +}); diff --git a/frontend/src/lib/service-usage.ts b/frontend/src/lib/service-usage.ts new file mode 100644 index 000000000..5d4108850 --- /dev/null +++ b/frontend/src/lib/service-usage.ts @@ -0,0 +1,78 @@ +import type { BillingUsageRow } from "@/schemas/billing"; +import { decimalCredits, exactCredits, parseCredits } from "./credits"; + +export interface ServiceUsageSummary { + /** Recorded calls; token-metered rows report `requests: 0`, so count events. */ + readonly calls: number; + /** Metered quantity per unit, largest first. */ + readonly quantities: readonly { metric: string; quantity: number }[]; + /** Total NyxID credits charged; null when a charged row has no settled amount. */ + readonly charged: string | null; + readonly wallet: string; + readonly grant: string; + readonly allowance: string; + /** Any row was chargeable; false means metering only. */ + readonly billable: boolean; + readonly agents: readonly { name: string | null; calls: number }[]; +} + +function sum(values: readonly (string | null)[]): string { + return decimalCredits( + values.reduce((total, value) => total + parseCredits(value ?? "0"), 0n), + ); +} + +/** + * `/billing/usage` records usage by the proxy slug the caller used, for the + * caller's own billing account. Connections sharing a slug share these rows. + */ +export function serviceUsageSummary( + rows: readonly BillingUsageRow[], + slug: string, +): ServiceUsageSummary | null { + const matching = rows.filter((row) => row.service_slug === slug); + if (!matching.length) return null; + const quantities = new Map(); + const agents = new Map(); + for (const row of matching) { + quantities.set( + row.metric, + (quantities.get(row.metric) ?? 0) + row.quantity, + ); + const name = row.api_key_id + ? (row.api_key_name ?? "Unnamed agent key") + : null; + agents.set(name, (agents.get(name) ?? 0) + row.events); + } + const estimates = matching.map((row) => + row.billable + ? exactCredits(row.estimated_credits, row.estimated_credits_micros) + : "0", + ); + return { + calls: matching.reduce((total, row) => total + row.events, 0), + quantities: [...quantities] + .map(([metric, quantity]) => ({ metric, quantity })) + .sort((a, b) => b.quantity - a.quantity), + charged: estimates.includes(null) ? null : sum(estimates), + wallet: sum( + matching.map((row) => + exactCredits(row.wallet_credits, row.wallet_credits_micros), + ), + ), + grant: sum( + matching.map((row) => + exactCredits(row.grant_credits, row.grant_credits_micros), + ), + ), + allowance: sum( + matching.map((row) => + exactCredits(row.allowance_credits, row.allowance_credits_micros), + ), + ), + billable: matching.some((row) => row.billable), + agents: [...agents] + .map(([name, calls]) => ({ name, calls })) + .sort((a, b) => b.calls - a.calls), + }; +} From 2a805ba3916525ef133092d812e86f6cd6bfe757 Mon Sep 17 00:00:00 2001 From: ctkm-aelf <230958228+ctkm-aelf@users.noreply.github.com> Date: Tue, 6 Oct 2026 15:09:09 +0800 Subject: [PATCH 29/36] feat: daily usage chart in the service billing panel GET /billing/usage accepts an optional bucket=day that splits the same rows by UTC day (additive `day` field; responses without it are unchanged). The billing panel's usage box requests daily rows for 7/30/90 day periods and draws calls per day with a per-day tooltip. On servers without day buckets it explains that the chart needs the updated backend. Sample mode serves daily usage and answers /service-insights with 404, like current production. Co-Authored-By: Claude Opus 5.5 --- .../src/billing_integration_tests/usage.rs | 65 +- backend/src/handlers/billing.rs | 27 +- .../src/services/admin_usage_service/tests.rs | 5 +- .../dashboard/service-insight-panels.tsx | 117 +- frontend/src/hooks/use-billing.ts | 22 +- frontend/src/lib/mock-data.ts | 1894 +++++++++++++---- frontend/src/lib/service-usage.test.ts | 52 +- frontend/src/lib/service-usage.ts | 49 + frontend/src/schemas/billing.ts | 2 + 9 files changed, 1837 insertions(+), 396 deletions(-) diff --git a/backend/src/billing_integration_tests/usage.rs b/backend/src/billing_integration_tests/usage.rs index cfec341c3..ca1414ae3 100644 --- a/backend/src/billing_integration_tests/usage.rs +++ b/backend/src/billing_integration_tests/usage.rs @@ -52,7 +52,10 @@ async fn read_usage(state: &crate::AppState, actor: &str) -> BillingUsageRespons billing::get_usage( State(state.clone()), test_auth_user(actor), - Query(UsageQuery { period: None }), + Query(UsageQuery { + period: None, + bucket: None, + }), ) .await .expect("usage read") @@ -968,3 +971,63 @@ async fn execution_owner_preserves_org_acl_for_non_master_credentials() { )); db.drop().await.unwrap(); } + +#[tokio::test] +async fn day_buckets_split_usage_by_utc_day_without_changing_totals() { + let Some(db) = connect_test_database("billing_usage_day_buckets").await else { + return; + }; + let owner = insert_owner(&db).await; + let state = billing_route_state(db.clone(), Arc::new(FakeLago::default()), 0); + let today = Utc::now(); + let yesterday = today - chrono::Duration::days(1); + for (at, quantity) in [(yesterday, 4), (today, 5), (today, 6)] { + let mut row = meter(&owner, quantity); + row.wallet_id = None; + row.created_at = at; + db.collection::(USAGE_METER) + .insert_one(row) + .await + .unwrap(); + } + let flat = read_usage(&state, &owner).await; + assert_eq!(flat.rows.len(), 1); + assert!(flat.rows[0].day.is_none()); + let daily = billing::get_usage( + State(state.clone()), + test_auth_user(&owner), + Query(UsageQuery { + period: Some("7d".into()), + bucket: Some("day".into()), + }), + ) + .await + .expect("daily usage read") + .0; + let days: Vec<_> = daily + .rows + .iter() + .map(|row| { + ( + row.day.expect("bucketed day").date_naive(), + row.quantity, + row.events, + ) + }) + .collect(); + assert_eq!( + days, + vec![(yesterday.date_naive(), 4, 1), (today.date_naive(), 11, 2),] + ); + assert_eq!(daily.totals.quantity, flat.totals.quantity); + let invalid = billing::get_usage( + State(state), + test_auth_user(&owner), + Query(UsageQuery { + period: None, + bucket: Some("hour".into()), + }), + ) + .await; + assert!(matches!(invalid, Err(AppError::ValidationError(_)))); +} diff --git a/backend/src/handlers/billing.rs b/backend/src/handlers/billing.rs index 79f1f14d4..5e4a97205 100644 --- a/backend/src/handlers/billing.rs +++ b/backend/src/handlers/billing.rs @@ -35,6 +35,8 @@ const LAGO_HMAC_SHA256_ALGORITHM: &str = "hmac"; #[derive(Debug, Deserialize)] pub struct UsageQuery { pub period: Option, + /// `day` additionally splits rows by UTC calendar day for usage charts. + pub bucket: Option, } #[derive(Debug, Serialize, ToSchema)] @@ -81,6 +83,9 @@ pub struct BillingUsageRow { /// only). None when no row in the group carried a breakdown. #[serde(skip_serializing_if = "Option::is_none")] pub token_breakdown: Option, + /// UTC day start; present only when requested with `bucket=day`. + #[serde(skip_serializing_if = "Option::is_none")] + pub day: Option>, } #[derive(Debug, Serialize, ToSchema)] @@ -215,7 +220,8 @@ pub struct InvoiceDownloadResponse { path = "/api/v1/billing/usage", tag = "Billing", params( - ("period" = Option, Query, description = "Usage period: 24h, 7d, 30d, 90d, or all") + ("period" = Option, Query, description = "Usage period: 24h, 7d, 30d, 90d, or all"), + ("bucket" = Option, Query, description = "`day` splits rows by UTC day") ), responses( (status = 200, description = "Billing usage summary", body = BillingUsageResponse) @@ -230,6 +236,15 @@ pub async fn get_usage( let owner_id = auth_user.user_id.to_string(); let period = query.period.unwrap_or_else(|| "30d".to_string()); let since = period_start(&period); + let by_day = match query.bucket.as_deref() { + None => false, + Some("day") => true, + Some(_) => { + return Err(AppError::ValidationError( + "bucket must be `day` when supplied".to_string(), + )); + } + }; let mut match_doc = doc! { "billing_owner_id": &owner_id, "quantity": { "$ne": null }, @@ -296,6 +311,13 @@ pub async fn get_usage( // only (service not platform_billable); they carry no // cost and are never pushed to Lago. "billable": { "$ne": [{ "$ifNull": ["$wallet_id", null] }, null] }, + "day": if by_day { + bson::Bson::Document(doc! { "$dateTrunc": { + "date": "$created_at", "unit": "day", "timezone": "UTC", + } }) + } else { + bson::Bson::Null + }, }, "quantity": { "$sum": "$quantity" }, // Keep exact settlements separate from historical estimates. @@ -338,7 +360,7 @@ pub async fn get_usage( "cache_creation_tokens": { "$sum": { "$ifNull": ["$token_breakdown.cache_creation_tokens", 0] } }, } }, - doc! { "$sort": { "_id.service_slug": 1, "_id.layer": 1, "_id.metric": 1 } }, + doc! { "$sort": { "_id.service_slug": 1, "_id.layer": 1, "_id.metric": 1, "_id.day": 1 } }, ]; let mut cursor = state @@ -404,6 +426,7 @@ pub async fn get_usage( 0 }, token_breakdown: usage_row_breakdown(&doc), + day: id_doc.get_datetime("day").ok().map(|day| day.to_chrono()), }); } diff --git a/backend/src/services/admin_usage_service/tests.rs b/backend/src/services/admin_usage_service/tests.rs index 68ab19422..1bba55c07 100644 --- a/backend/src/services/admin_usage_service/tests.rs +++ b/backend/src/services/admin_usage_service/tests.rs @@ -2269,7 +2269,10 @@ async fn pico_costs_retain_472_micros_across_raw_rollup_api_and_analytics() { let response = crate::handlers::billing::get_usage( axum::extract::State(test_app_state(db.clone())), test_auth_user(&actor), - axum::extract::Query(crate::handlers::billing::UsageQuery { period: None }), + axum::extract::Query(crate::handlers::billing::UsageQuery { + period: None, + bucket: None, + }), ) .await .unwrap() diff --git a/frontend/src/components/dashboard/service-insight-panels.tsx b/frontend/src/components/dashboard/service-insight-panels.tsx index 340e0f1ac..e9a2b7fc4 100644 --- a/frontend/src/components/dashboard/service-insight-panels.tsx +++ b/frontend/src/components/dashboard/service-insight-panels.tsx @@ -19,7 +19,17 @@ import { import { metricLabel } from "@/schemas/billing-metrics"; import type { BillingUsagePeriod } from "@/schemas/billing"; import { useBillingUsage } from "@/hooks/use-billing"; -import { serviceUsageSummary } from "@/lib/service-usage"; +import { Bar, BarChart, XAxis, YAxis } from "recharts"; +import { + ChartContainer, + ChartTooltip, + type ChartConfig, +} from "@/components/ui/chart"; +import { + serviceUsageDaily, + serviceUsageSummary, + type ServiceUsageDay, +} from "@/lib/service-usage"; import { formatExactCredits, hasCredits } from "@/lib/credits"; import type { ServiceInsight } from "@/schemas/service-insights"; import { @@ -77,12 +87,106 @@ const USAGE_PERIODS: readonly [BillingUsagePeriod, string][] = [ ["90d", "Last 90 days"], ]; +const usageChartConfig = { + calls: { label: "Calls", color: "var(--color-primary)" }, +} satisfies ChartConfig; + +const shortDay = (day: string) => + new Date(`${day}T00:00:00Z`).toLocaleDateString(undefined, { + month: "short", + day: "numeric", + timeZone: "UTC", + }); + +function UsageDayTooltip({ + active, + payload, +}: { + readonly active?: boolean; + readonly payload?: readonly { payload: ServiceUsageDay }[]; +}) { + const day = active ? payload?.[0]?.payload : undefined; + if (!day) return null; + return ( +
+

{shortDay(day.day)} (UTC)

+

+ {day.calls.toLocaleString()} {day.calls === 1 ? "call" : "calls"} +

+ {day.quantities.map(({ metric, quantity }) => ( +

+ {quantity.toLocaleString()} {metricLabel(metric, quantity)} +

+ ))} + {hasCredits(day.charged) && ( +

+ {formatExactCredits(day.charged!)} credits +

+ )} +
+ ); +} + +function UsageDailyChart({ series }: { readonly series: ServiceUsageDay[] }) { + return ( +
+ + + + + } + /> + + + +
+ {series + .filter((day) => day.calls > 0) + .map((day) => `${shortDay(day.day)}: ${day.calls} calls`) + .join(", ") || "No calls in this period"} +
+
+ ); +} + function ConnectionUsage({ connection }: { readonly connection: KeyInfo }) { const [period, setPeriod] = useState("30d"); - const usage = useBillingUsage(period); + // One daily request feeds both the totals and the chart. + const usage = useBillingUsage(period, period === "24h" ? undefined : "day"); const summary = usage.data ? serviceUsageSummary(usage.data.rows, connection.slug) : null; + const daily = usage.data + ? serviceUsageDaily(usage.data.rows, connection.slug, period) + : null; const org = connection.credential_source?.type === "org" ? connection.credential_source.org_name @@ -171,6 +275,15 @@ function ConnectionUsage({ connection }: { readonly connection: KeyInfo }) { )}
+ {daily ? ( + + ) : ( + period !== "24h" && ( +

+ The daily chart needs the updated NyxID backend. +

+ ) + )} {summary.agents.length > 1 || summary.agents[0]?.name ? (

By caller:{" "} diff --git a/frontend/src/hooks/use-billing.ts b/frontend/src/hooks/use-billing.ts index f99e53dec..9e0386b82 100644 --- a/frontend/src/hooks/use-billing.ts +++ b/frontend/src/hooks/use-billing.ts @@ -18,11 +18,15 @@ import { const BILLING_WALLET_KEY = ["billing", "wallet"] as const; const BILLING_USAGE_KEY = ["billing", "usage"] as const; -export function billingUsagePath(period?: BillingUsagePeriod): string { - if (!period) { - return "/billing/usage"; - } - return `/billing/usage?period=${encodeURIComponent(period)}`; +export function billingUsagePath( + period?: BillingUsagePeriod, + bucket?: "day", +): string { + const params = new URLSearchParams(); + if (period) params.set("period", period); + if (bucket) params.set("bucket", bucket); + const query = params.toString(); + return query ? `/billing/usage?${query}` : "/billing/usage"; } export function useBillingWallet() { @@ -68,11 +72,13 @@ export function useTopUpBilling() { }); } -export function useBillingUsage(period?: BillingUsagePeriod) { +export function useBillingUsage(period?: BillingUsagePeriod, bucket?: "day") { return useQuery({ - queryKey: period ? [...BILLING_USAGE_KEY, period] : BILLING_USAGE_KEY, + queryKey: period + ? [...BILLING_USAGE_KEY, period, ...(bucket ? [bucket] : [])] + : BILLING_USAGE_KEY, queryFn: async (): Promise => { - const response = await api.get(billingUsagePath(period)); + const response = await api.get(billingUsagePath(period, bucket)); return billingUsageResponseSchema.parse(response); }, }); diff --git a/frontend/src/lib/mock-data.ts b/frontend/src/lib/mock-data.ts index c680e51b2..d60f2d5da 100644 --- a/frontend/src/lib/mock-data.ts +++ b/frontend/src/lib/mock-data.ts @@ -1,4 +1,5 @@ import { mockSetupResponse } from "@/lib/assistant/mock-setup-journeys"; +import { ApiError } from "@/lib/api-client"; // Keep the manually selected demo engine across chat navigation and reloads. const MOCK_NYXBOT_STORAGE = "nyxid.mock-nyxbot"; @@ -8,8 +9,10 @@ if (import.meta.env.DEV && typeof window !== "undefined") { sessionStorage.setItem(MOCK_NYXBOT_STORAGE, selection); } } -const mockNyxbotEnabled = globalThis.__nyxidAssistantHttpFaults?.nyxagentEnabled ?? - (import.meta.env.DEV && typeof window !== "undefined" && +const mockNyxbotEnabled = + globalThis.__nyxidAssistantHttpFaults?.nyxagentEnabled ?? + (import.meta.env.DEV && + typeof window !== "undefined" && sessionStorage.getItem(MOCK_NYXBOT_STORAGE) === "1"); // ── Mock User ── @@ -25,7 +28,10 @@ const MOCK_USER = { created_at: "2025-11-20T08:00:00Z", capabilities: { billing_available: true, - enabled_features: ["experimental:ai-assistant", ...(mockNyxbotEnabled ? ["assistant:nyxagent-engine"] : [])], + enabled_features: [ + "experimental:ai-assistant", + ...(mockNyxbotEnabled ? ["assistant:nyxagent-engine"] : []), + ], }, }; @@ -200,8 +206,11 @@ const MOCK_KEYS = [ error_message: null, created_at: "2026-01-15T09:00:00Z", service_type: "http", - ssh_host: null, ssh_port: null, ssh_ca_public_key: null, - ssh_allowed_principals: null, ssh_certificate_ttl_minutes: null, + ssh_host: null, + ssh_port: null, + ssh_ca_public_key: null, + ssh_allowed_principals: null, + ssh_certificate_ttl_minutes: null, openapi_spec_url: null, credential_source: { type: "personal" as const }, }, @@ -233,8 +242,11 @@ const MOCK_KEYS = [ error_message: null, created_at: "2026-01-20T10:00:00Z", service_type: "http", - ssh_host: null, ssh_port: null, ssh_ca_public_key: null, - ssh_allowed_principals: null, ssh_certificate_ttl_minutes: null, + ssh_host: null, + ssh_port: null, + ssh_ca_public_key: null, + ssh_allowed_principals: null, + ssh_certificate_ttl_minutes: null, openapi_spec_url: null, credential_source: { type: "personal" as const }, }, @@ -266,8 +278,11 @@ const MOCK_KEYS = [ error_message: null, created_at: "2026-02-01T11:00:00Z", service_type: "http", - ssh_host: null, ssh_port: null, ssh_ca_public_key: null, - ssh_allowed_principals: null, ssh_certificate_ttl_minutes: null, + ssh_host: null, + ssh_port: null, + ssh_ca_public_key: null, + ssh_allowed_principals: null, + ssh_certificate_ttl_minutes: null, openapi_spec_url: null, credential_source: { type: "personal" as const }, }, @@ -299,8 +314,11 @@ const MOCK_KEYS = [ error_message: null, created_at: "2026-03-10T14:00:00Z", service_type: "http", - ssh_host: null, ssh_port: null, ssh_ca_public_key: null, - ssh_allowed_principals: null, ssh_certificate_ttl_minutes: null, + ssh_host: null, + ssh_port: null, + ssh_ca_public_key: null, + ssh_allowed_principals: null, + ssh_certificate_ttl_minutes: null, openapi_spec_url: null, credential_source: { type: "personal" as const }, }, @@ -332,8 +350,11 @@ const MOCK_KEYS = [ error_message: null, created_at: "2026-03-25T09:00:00Z", service_type: "http", - ssh_host: null, ssh_port: null, ssh_ca_public_key: null, - ssh_allowed_principals: null, ssh_certificate_ttl_minutes: null, + ssh_host: null, + ssh_port: null, + ssh_ca_public_key: null, + ssh_allowed_principals: null, + ssh_certificate_ttl_minutes: null, openapi_spec_url: null, credential_source: { type: "personal" as const }, }, @@ -365,8 +386,11 @@ const MOCK_KEYS = [ error_message: null, created_at: "2026-04-10T10:00:00Z", service_type: "http", - ssh_host: null, ssh_port: null, ssh_ca_public_key: null, - ssh_allowed_principals: null, ssh_certificate_ttl_minutes: null, + ssh_host: null, + ssh_port: null, + ssh_ca_public_key: null, + ssh_allowed_principals: null, + ssh_certificate_ttl_minutes: null, openapi_spec_url: null, credential_source: { type: "personal" as const }, }, @@ -434,25 +458,89 @@ const MOCK_KEYS = [ error_message: null, created_at: "2026-02-15T09:00:00Z", service_type: "http", - ssh_host: null, ssh_port: null, ssh_ca_public_key: null, - ssh_allowed_principals: null, ssh_certificate_ttl_minutes: null, + ssh_host: null, + ssh_port: null, + ssh_ca_public_key: null, + ssh_allowed_principals: null, + ssh_certificate_ttl_minutes: null, openapi_spec_url: null, - credential_source: { type: "org" as const, org_name: "ChronoAI", role: "member", allowed: true }, + credential_source: { + type: "org" as const, + org_name: "ChronoAI", + role: "member", + allowed: true, + }, }, ]; // ── External API Keys (credentials) ── const MOCK_EXTERNAL_API_KEYS = [ - { id: "eak-0001", label: "OpenAI Production Key", credential_type: "api_key", auth_method: "bearer", auth_key_name: "Authorization", created_at: "2026-01-15T09:00:00Z", last_used_at: "2026-05-06T14:22:00Z", service_count: 1 }, - { id: "eak-0002", label: "Claude API Key", credential_type: "api_key", auth_method: "header", auth_key_name: "x-api-key", created_at: "2026-01-20T10:00:00Z", last_used_at: "2026-05-05T10:15:00Z", service_count: 1 }, - { id: "eak-0003", label: "GitHub Token", credential_type: "api_key", auth_method: "bearer", auth_key_name: "Authorization", created_at: "2026-02-01T11:00:00Z", last_used_at: "2026-05-04T16:30:00Z", service_count: 1 }, - { id: "eak-0004", label: "Stripe Secret Key", credential_type: "api_key", auth_method: "bearer", auth_key_name: "Authorization", created_at: "2026-03-10T14:00:00Z", last_used_at: null, service_count: 1 }, - { id: "eak-0005", label: "Supabase API Key", credential_type: "api_key", auth_method: "header", auth_key_name: "apikey", created_at: "2026-03-25T09:00:00Z", last_used_at: "2026-05-06T08:00:00Z", service_count: 1 }, - { id: "eak-0006", label: "Vercel Token", credential_type: "api_key", auth_method: "bearer", auth_key_name: "Authorization", created_at: "2026-04-10T10:00:00Z", last_used_at: null, service_count: 1 }, + { + id: "eak-0001", + label: "OpenAI Production Key", + credential_type: "api_key", + auth_method: "bearer", + auth_key_name: "Authorization", + created_at: "2026-01-15T09:00:00Z", + last_used_at: "2026-05-06T14:22:00Z", + service_count: 1, + }, + { + id: "eak-0002", + label: "Claude API Key", + credential_type: "api_key", + auth_method: "header", + auth_key_name: "x-api-key", + created_at: "2026-01-20T10:00:00Z", + last_used_at: "2026-05-05T10:15:00Z", + service_count: 1, + }, + { + id: "eak-0003", + label: "GitHub Token", + credential_type: "api_key", + auth_method: "bearer", + auth_key_name: "Authorization", + created_at: "2026-02-01T11:00:00Z", + last_used_at: "2026-05-04T16:30:00Z", + service_count: 1, + }, + { + id: "eak-0004", + label: "Stripe Secret Key", + credential_type: "api_key", + auth_method: "bearer", + auth_key_name: "Authorization", + created_at: "2026-03-10T14:00:00Z", + last_used_at: null, + service_count: 1, + }, + { + id: "eak-0005", + label: "Supabase API Key", + credential_type: "api_key", + auth_method: "header", + auth_key_name: "apikey", + created_at: "2026-03-25T09:00:00Z", + last_used_at: "2026-05-06T08:00:00Z", + service_count: 1, + }, + { + id: "eak-0006", + label: "Vercel Token", + credential_type: "api_key", + auth_method: "bearer", + auth_key_name: "Authorization", + created_at: "2026-04-10T10:00:00Z", + last_used_at: null, + service_count: 1, + }, ]; // ── User Endpoints ── -const MOCK_USER_ENDPOINTS = MOCK_KEYS.filter((k) => k.service_type === "http").map((k) => ({ +const MOCK_USER_ENDPOINTS = MOCK_KEYS.filter( + (k) => k.service_type === "http", +).map((k) => ({ id: k.endpoint_id, label: k.label, url: k.endpoint_url, @@ -460,7 +548,9 @@ const MOCK_USER_ENDPOINTS = MOCK_KEYS.filter((k) => k.service_type === "http").m })); // ── User Services (proxy routing) ── -const MOCK_USER_SERVICES = MOCK_KEYS.filter((k) => k.service_type === "http").map((k) => ({ +const MOCK_USER_SERVICES = MOCK_KEYS.filter( + (k) => k.service_type === "http", +).map((k) => ({ id: k.id, slug: k.slug, label: k.label, @@ -483,9 +573,33 @@ const MOCK_USER_SERVICES = MOCK_KEYS.filter((k) => k.service_type === "http").ma // ── Connections (legacy) ── const MOCK_CONNECTIONS = [ - { service_id: "svc-openai", service_name: "OpenAI", service_category: "ai", auth_type: "api_key", has_credential: true, credential_label: "Production Key", connected_at: "2026-01-15T09:00:00Z" }, - { service_id: "svc-anthropic", service_name: "Anthropic", service_category: "ai", auth_type: "api_key", has_credential: true, credential_label: "Claude Key", connected_at: "2026-01-20T10:00:00Z" }, - { service_id: "svc-github", service_name: "GitHub", service_category: "developer", auth_type: "oauth2", has_credential: true, credential_label: null, connected_at: "2026-02-01T11:00:00Z" }, + { + service_id: "svc-openai", + service_name: "OpenAI", + service_category: "ai", + auth_type: "api_key", + has_credential: true, + credential_label: "Production Key", + connected_at: "2026-01-15T09:00:00Z", + }, + { + service_id: "svc-anthropic", + service_name: "Anthropic", + service_category: "ai", + auth_type: "api_key", + has_credential: true, + credential_label: "Claude Key", + connected_at: "2026-01-20T10:00:00Z", + }, + { + service_id: "svc-github", + service_name: "GitHub", + service_category: "developer", + auth_type: "oauth2", + has_credential: true, + credential_label: null, + connected_at: "2026-02-01T11:00:00Z", + }, ]; // ── Nodes ── @@ -493,26 +607,62 @@ const MOCK_NODES = [ { id: "node-0001", name: "prod-us-east", - owner: { kind: "user" as const, id: MOCK_USER.id, display_name: "Dannick Young" }, + owner: { + kind: "user" as const, + id: MOCK_USER.id, + display_name: "Dannick Young", + }, status: "Online", is_connected: true, last_heartbeat_at: "2026-05-06T14:30:00Z", connected_at: "2026-05-01T08:00:00Z", - metadata: { agent_version: "0.9.2", os: "linux", arch: "x86_64", ip_address: "10.0.1.50" }, - metrics: { total_requests: 12450, success_count: 12380, error_count: 70, success_rate: 99.4, avg_latency_ms: 42, last_error: null, last_error_at: null, last_success_at: "2026-05-06T14:29:00Z" }, + metadata: { + agent_version: "0.9.2", + os: "linux", + arch: "x86_64", + ip_address: "10.0.1.50", + }, + metrics: { + total_requests: 12450, + success_count: 12380, + error_count: 70, + success_rate: 99.4, + avg_latency_ms: 42, + last_error: null, + last_error_at: null, + last_success_at: "2026-05-06T14:29:00Z", + }, binding_count: 3, created_at: "2026-02-10T09:00:00Z", }, { id: "node-0002", name: "staging-eu", - owner: { kind: "user" as const, id: MOCK_USER.id, display_name: "Dannick Young" }, + owner: { + kind: "user" as const, + id: MOCK_USER.id, + display_name: "Dannick Young", + }, status: "Online", is_connected: true, last_heartbeat_at: "2026-05-06T14:28:00Z", connected_at: "2026-05-03T10:00:00Z", - metadata: { agent_version: "0.9.2", os: "darwin", arch: "arm64", ip_address: "192.168.1.100" }, - metrics: { total_requests: 3200, success_count: 3180, error_count: 20, success_rate: 99.4, avg_latency_ms: 85, last_error: null, last_error_at: null, last_success_at: "2026-05-06T14:25:00Z" }, + metadata: { + agent_version: "0.9.2", + os: "darwin", + arch: "arm64", + ip_address: "192.168.1.100", + }, + metrics: { + total_requests: 3200, + success_count: 3180, + error_count: 20, + success_rate: 99.4, + avg_latency_ms: 85, + last_error: null, + last_error_at: null, + last_success_at: "2026-05-06T14:25:00Z", + }, binding_count: 2, created_at: "2026-03-15T14:00:00Z", }, @@ -539,12 +689,17 @@ const MOCK_APPROVAL_REQUESTS = { requests: [ { id: "ar-0000", - service_name: "Lark", service_slug: "lark-bot", - requester_type: "api_key", requester_label: "claude-code-agent", + service_name: "Lark", + service_slug: "lark-bot", + requester_type: "api_key", + requester_label: "claude-code-agent", operation_summary: "POST /im/v1/messages", action_description: "Post the drafted summary to #payments-oncall", - tool_name: null, tool_call_id: null, tool_arguments: null, - is_destructive: false, approval_mode: "per_request" as const, + tool_name: null, + tool_call_id: null, + tool_arguments: null, + is_destructive: false, + approval_mode: "per_request" as const, status: "pending" as const, created_at: new Date(Date.now() - 60_000).toISOString(), decided_at: null, @@ -553,64 +708,94 @@ const MOCK_APPROVAL_REQUESTS = { }, { id: "ar-0001", - service_name: "OpenAI", service_slug: "openai", - requester_type: "api_key", requester_label: "claude-code-agent", + service_name: "OpenAI", + service_slug: "openai", + requester_type: "api_key", + requester_label: "claude-code-agent", operation_summary: "POST /v1/chat/completions", action_description: "Generate chat completion with gpt-4o", - tool_name: null, tool_call_id: null, tool_arguments: null, - is_destructive: false, approval_mode: "per_request" as const, + tool_name: null, + tool_call_id: null, + tool_arguments: null, + is_destructive: false, + approval_mode: "per_request" as const, status: "approved" as const, - created_at: "2026-05-06T14:20:00Z", decided_at: "2026-05-06T14:20:05Z", + created_at: "2026-05-06T14:20:00Z", + decided_at: "2026-05-06T14:20:05Z", expires_at: "2026-05-06T14:25:00Z", decision_channel: "telegram", }, { id: "ar-0002", - service_name: "GitHub", service_slug: "github", - requester_type: "api_key", requester_label: "cursor-agent", + service_name: "GitHub", + service_slug: "github", + requester_type: "api_key", + requester_label: "cursor-agent", operation_summary: "DELETE /repos/nyxid/branch", action_description: "Delete branch feature/old-auth", - tool_name: null, tool_call_id: null, tool_arguments: null, - is_destructive: true, approval_mode: "per_request" as const, + tool_name: null, + tool_call_id: null, + tool_arguments: null, + is_destructive: true, + approval_mode: "per_request" as const, status: "rejected" as const, - created_at: "2026-05-05T18:00:00Z", decided_at: "2026-05-05T18:01:30Z", + created_at: "2026-05-05T18:00:00Z", + decided_at: "2026-05-05T18:01:30Z", expires_at: "2026-05-05T18:05:00Z", decision_channel: "push", }, { id: "ar-0003", - service_name: "Stripe", service_slug: "stripe", - requester_type: "api_key", requester_label: "ci-pipeline", + service_name: "Stripe", + service_slug: "stripe", + requester_type: "api_key", + requester_label: "ci-pipeline", operation_summary: "GET /v1/charges", action_description: "List recent charges", - tool_name: null, tool_call_id: null, tool_arguments: null, - is_destructive: false, approval_mode: "grant" as const, + tool_name: null, + tool_call_id: null, + tool_arguments: null, + is_destructive: false, + approval_mode: "grant" as const, status: "approved" as const, - created_at: "2026-05-04T10:00:00Z", decided_at: "2026-05-04T10:00:12Z", + created_at: "2026-05-04T10:00:00Z", + decided_at: "2026-05-04T10:00:12Z", expires_at: "2026-05-04T10:05:00Z", decision_channel: "telegram", }, ], - total: 4, page: 1, per_page: 20, + total: 4, + page: 1, + per_page: 20, }; // ── Approval Grants ── const MOCK_APPROVAL_GRANTS = { grants: [ { - id: "ag-0001", service_id: "svc-openai", service_name: "OpenAI", - requester_type: "api_key", requester_id: "k1-0001-0001-0001-000000000001", + id: "ag-0001", + service_id: "svc-openai", + service_name: "OpenAI", + requester_type: "api_key", + requester_id: "k1-0001-0001-0001-000000000001", requester_label: "claude-code-agent", - granted_at: "2026-05-01T08:00:00Z", expires_at: "2026-05-31T08:00:00Z", + granted_at: "2026-05-01T08:00:00Z", + expires_at: "2026-05-31T08:00:00Z", }, { - id: "ag-0002", service_id: "svc-github", service_name: "GitHub", - requester_type: "api_key", requester_id: "k1-0001-0001-0001-000000000002", + id: "ag-0002", + service_id: "svc-github", + service_name: "GitHub", + requester_type: "api_key", + requester_id: "k1-0001-0001-0001-000000000002", requester_label: "cursor-agent", - granted_at: "2026-04-28T12:00:00Z", expires_at: "2026-05-28T12:00:00Z", + granted_at: "2026-04-28T12:00:00Z", + expires_at: "2026-05-28T12:00:00Z", }, ], - total: 2, page: 1, per_page: 20, + total: 2, + page: 1, + per_page: 20, }; // ── Developer Apps ── @@ -652,7 +837,8 @@ const MOCK_CHANNEL_BOTS_DATA = [ platform_bot_id: "bot123456", platform_bot_username: "nyxid_approvals_bot", webhook_registered: true, - webhook_url: "https://auth.nyxid.dev/api/v1/webhooks/channel/telegram/bot-0001", + webhook_url: + "https://auth.nyxid.dev/api/v1/webhooks/channel/telegram/bot-0001", status: "active" as const, is_active: true, created_at: "2026-03-01T09:00:00Z", @@ -670,7 +856,8 @@ const MOCK_CHANNEL_BOTS_DATA = [ platform_bot_id: "bot789012", platform_bot_username: "NyxID Dev", webhook_registered: true, - webhook_url: "https://auth.nyxid.dev/api/v1/webhooks/channel/discord/bot-0002", + webhook_url: + "https://auth.nyxid.dev/api/v1/webhooks/channel/discord/bot-0002", status: "active" as const, is_active: true, created_at: "2026-04-10T14:00:00Z", @@ -724,7 +911,8 @@ const MOCK_CHANNEL_MESSAGES = { platform_message_id: "tg-msg-002", platform_sender_id: null, content_type: "text", - content: "The deployment to production completed successfully at 13:50 UTC. All health checks are passing.", + content: + "The deployment to production completed successfully at 13:50 UTC. All health checks are passing.", created_at: "2026-05-06T13:56:00Z", }, { @@ -739,7 +927,9 @@ const MOCK_CHANNEL_MESSAGES = { created_at: "2026-05-06T14:00:00Z", }, ], - total: 3, page: 1, per_page: 20, + total: 3, + page: 1, + per_page: 20, }; // ── Organizations ── @@ -758,11 +948,46 @@ const MOCK_ORGS = [ const MOCK_ORG_MEMBERS = { members: [ - { user_id: MOCK_USER.id, email: MOCK_USER.email, display_name: MOCK_USER.display_name, avatar_url: null, role: "owner", joined_at: "2025-12-01T08:00:00Z" }, - { user_id: "u-0002", email: "alex@chronoai.dev", display_name: "Alex Chen", avatar_url: null, role: "admin", joined_at: "2025-12-15T10:00:00Z" }, - { user_id: "u-0003", email: "sarah@chronoai.dev", display_name: "Sarah Park", avatar_url: null, role: "member", joined_at: "2026-01-05T09:00:00Z" }, - { user_id: "u-0004", email: "mike@chronoai.dev", display_name: "Mike Torres", avatar_url: null, role: "member", joined_at: "2026-02-10T14:00:00Z" }, - { user_id: "u-0005", email: "lin@chronoai.dev", display_name: "Lin Wei", avatar_url: null, role: "viewer", joined_at: "2026-03-20T11:00:00Z" }, + { + user_id: MOCK_USER.id, + email: MOCK_USER.email, + display_name: MOCK_USER.display_name, + avatar_url: null, + role: "owner", + joined_at: "2025-12-01T08:00:00Z", + }, + { + user_id: "u-0002", + email: "alex@chronoai.dev", + display_name: "Alex Chen", + avatar_url: null, + role: "admin", + joined_at: "2025-12-15T10:00:00Z", + }, + { + user_id: "u-0003", + email: "sarah@chronoai.dev", + display_name: "Sarah Park", + avatar_url: null, + role: "member", + joined_at: "2026-01-05T09:00:00Z", + }, + { + user_id: "u-0004", + email: "mike@chronoai.dev", + display_name: "Mike Torres", + avatar_url: null, + role: "member", + joined_at: "2026-02-10T14:00:00Z", + }, + { + user_id: "u-0005", + email: "lin@chronoai.dev", + display_name: "Lin Wei", + avatar_url: null, + role: "viewer", + joined_at: "2026-03-20T11:00:00Z", + }, ], total: 5, }; @@ -796,169 +1021,442 @@ const MOCK_BROKER_BINDINGS: readonly unknown[] = []; // ── Sessions ── const MOCK_SESSIONS = [ - { id: "sess-0001", ip_address: "192.168.1.10", user_agent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)", created_at: "2026-05-06T08:00:00Z", last_active_at: "2026-05-06T14:30:00Z", is_current: true }, - { id: "sess-0002", ip_address: "10.0.0.5", user_agent: "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0)", created_at: "2026-05-05T20:00:00Z", last_active_at: "2026-05-06T12:00:00Z", is_current: false }, + { + id: "sess-0001", + ip_address: "192.168.1.10", + user_agent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)", + created_at: "2026-05-06T08:00:00Z", + last_active_at: "2026-05-06T14:30:00Z", + is_current: true, + }, + { + id: "sess-0002", + ip_address: "10.0.0.5", + user_agent: "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0)", + created_at: "2026-05-05T20:00:00Z", + last_active_at: "2026-05-06T12:00:00Z", + is_current: false, + }, ]; // ── Push Devices ── const MOCK_PUSH_DEVICES = { devices: [ - { id: "dev-0001", platform: "ios", device_name: "iPhone 15 Pro", registered_at: "2026-04-01T09:00:00Z", last_used_at: "2026-05-06T14:00:00Z" }, + { + id: "dev-0001", + platform: "ios", + device_name: "iPhone 15 Pro", + registered_at: "2026-04-01T09:00:00Z", + last_used_at: "2026-05-06T14:00:00Z", + }, ], }; // ── Catalog ── const MOCK_CATALOG = [ { - slug: "api-supabase", name: "Supabase Data API", description: "Read and write a Supabase project's tables through its Data API.", - base_url: "https://project-ref.supabase.co/rest/v1", auth_method: "header", auth_key_name: "apikey", - provider_config_id: "provider-supabase", provider_type: "api_key", requires_gateway_url: true, + slug: "api-supabase", + name: "Supabase Data API", + description: + "Read and write a Supabase project's tables through its Data API.", + base_url: "https://project-ref.supabase.co/rest/v1", + auth_method: "header", + auth_key_name: "apikey", + provider_config_id: "provider-supabase", + provider_type: "api_key", + requires_gateway_url: true, credential_mode: "admin", - api_key_instructions: "Enter your project URL and Supabase API key. Secret keys bypass Row Level Security; publishable keys use your anonymous role's policies.", + api_key_instructions: + "Enter your project URL and Supabase API key. Secret keys bypass Row Level Security; publishable keys use your anonymous role's policies.", api_key_url: "https://supabase.com/dashboard/project/_/settings/api-keys", - icon_url: null, documentation_url: "https://supabase.com/docs/guides/api", + icon_url: null, + documentation_url: "https://supabase.com/docs/guides/api", service_type: "http", - ssh_host: null, ssh_port: null, ssh_ca_public_key: null, ssh_allowed_principals: null, ssh_certificate_ttl_minutes: null, - authorization_url: null, token_url: null, device_code_url: null, - default_scopes: null, supports_pkce: null, device_code_format: null, - oauth_client_id: null, client_id_param_name: null, - requires_credential: true, token_exchange_credential_fields: null, default_request_headers: null, - homepage_url: "https://supabase.com", repository_url: null, issues_url: null, - capabilities: { supports_proxy_read: true, supports_proxy_write: true, supports_proxy_binary_upload: false, supports_direct_downstream_auth: true, supports_authoring_via_nyx: false, supports_websocket: false, supports_streaming: false }, - auth_notes: "NyxID sends the stored key in the apikey header.", known_limitations: "Data API only; no PostgreSQL sessions, Storage, Edge Functions, or Realtime.", required_permissions: [], - }, - { - slug: "openai", name: "OpenAI", description: "OpenAI API — GPT-4o, DALL-E, Whisper", - base_url: "https://api.openai.com/v1", auth_method: "bearer", auth_key_name: "Authorization", - provider_config_id: null, provider_type: null, requires_gateway_url: false, + ssh_host: null, + ssh_port: null, + ssh_ca_public_key: null, + ssh_allowed_principals: null, + ssh_certificate_ttl_minutes: null, + authorization_url: null, + token_url: null, + device_code_url: null, + default_scopes: null, + supports_pkce: null, + device_code_format: null, + oauth_client_id: null, + client_id_param_name: null, + requires_credential: true, + token_exchange_credential_fields: null, + default_request_headers: null, + homepage_url: "https://supabase.com", + repository_url: null, + issues_url: null, + capabilities: { + supports_proxy_read: true, + supports_proxy_write: true, + supports_proxy_binary_upload: false, + supports_direct_downstream_auth: true, + supports_authoring_via_nyx: false, + supports_websocket: false, + supports_streaming: false, + }, + auth_notes: "NyxID sends the stored key in the apikey header.", + known_limitations: + "Data API only; no PostgreSQL sessions, Storage, Edge Functions, or Realtime.", + required_permissions: [], + }, + { + slug: "openai", + name: "OpenAI", + description: "OpenAI API — GPT-4o, DALL-E, Whisper", + base_url: "https://api.openai.com/v1", + auth_method: "bearer", + auth_key_name: "Authorization", + provider_config_id: null, + provider_type: null, + requires_gateway_url: false, credential_mode: "api_key", api_key_instructions: "Get your API key from platform.openai.com", api_key_url: "https://platform.openai.com/api-keys", - icon_url: null, documentation_url: "https://platform.openai.com/docs", + icon_url: null, + documentation_url: "https://platform.openai.com/docs", service_type: "http", - ssh_host: null, ssh_port: null, ssh_ca_public_key: null, ssh_allowed_principals: null, ssh_certificate_ttl_minutes: null, - authorization_url: null, token_url: null, device_code_url: null, - default_scopes: null, supports_pkce: null, device_code_format: null, - oauth_client_id: null, client_id_param_name: null, - requires_credential: true, token_exchange_credential_fields: null, default_request_headers: null, - homepage_url: "https://openai.com", repository_url: null, issues_url: null, - capabilities: { supports_proxy_read: true, supports_proxy_write: true, supports_proxy_binary_upload: false, supports_direct_downstream_auth: false, supports_authoring_via_nyx: false, supports_websocket: false, supports_streaming: true }, - auth_notes: "Use your API key from the OpenAI dashboard.", known_limitations: null, required_permissions: [], - }, - { - slug: "anthropic", name: "Anthropic", description: "Anthropic Claude API", - base_url: "https://api.anthropic.com/v1", auth_method: "header", auth_key_name: "x-api-key", - provider_config_id: null, provider_type: null, requires_gateway_url: false, + ssh_host: null, + ssh_port: null, + ssh_ca_public_key: null, + ssh_allowed_principals: null, + ssh_certificate_ttl_minutes: null, + authorization_url: null, + token_url: null, + device_code_url: null, + default_scopes: null, + supports_pkce: null, + device_code_format: null, + oauth_client_id: null, + client_id_param_name: null, + requires_credential: true, + token_exchange_credential_fields: null, + default_request_headers: null, + homepage_url: "https://openai.com", + repository_url: null, + issues_url: null, + capabilities: { + supports_proxy_read: true, + supports_proxy_write: true, + supports_proxy_binary_upload: false, + supports_direct_downstream_auth: false, + supports_authoring_via_nyx: false, + supports_websocket: false, + supports_streaming: true, + }, + auth_notes: "Use your API key from the OpenAI dashboard.", + known_limitations: null, + required_permissions: [], + }, + { + slug: "anthropic", + name: "Anthropic", + description: "Anthropic Claude API", + base_url: "https://api.anthropic.com/v1", + auth_method: "header", + auth_key_name: "x-api-key", + provider_config_id: null, + provider_type: null, + requires_gateway_url: false, credential_mode: "api_key", api_key_instructions: "Get your API key from console.anthropic.com", api_key_url: "https://console.anthropic.com/settings/keys", - icon_url: null, documentation_url: "https://docs.anthropic.com", + icon_url: null, + documentation_url: "https://docs.anthropic.com", service_type: "http", - ssh_host: null, ssh_port: null, ssh_ca_public_key: null, ssh_allowed_principals: null, ssh_certificate_ttl_minutes: null, - authorization_url: null, token_url: null, device_code_url: null, - default_scopes: null, supports_pkce: null, device_code_format: null, - oauth_client_id: null, client_id_param_name: null, - requires_credential: true, token_exchange_credential_fields: null, default_request_headers: null, - homepage_url: "https://anthropic.com", repository_url: null, issues_url: null, - capabilities: { supports_proxy_read: true, supports_proxy_write: true, supports_proxy_binary_upload: false, supports_direct_downstream_auth: false, supports_authoring_via_nyx: false, supports_websocket: false, supports_streaming: true }, - auth_notes: "Requires x-api-key header.", known_limitations: null, required_permissions: [], - }, - { - slug: "github", name: "GitHub", description: "GitHub REST & GraphQL API", - base_url: "https://api.github.com", auth_method: "oauth2", auth_key_name: "Authorization", - provider_config_id: "provider-github", provider_type: "oauth2", requires_gateway_url: false, + ssh_host: null, + ssh_port: null, + ssh_ca_public_key: null, + ssh_allowed_principals: null, + ssh_certificate_ttl_minutes: null, + authorization_url: null, + token_url: null, + device_code_url: null, + default_scopes: null, + supports_pkce: null, + device_code_format: null, + oauth_client_id: null, + client_id_param_name: null, + requires_credential: true, + token_exchange_credential_fields: null, + default_request_headers: null, + homepage_url: "https://anthropic.com", + repository_url: null, + issues_url: null, + capabilities: { + supports_proxy_read: true, + supports_proxy_write: true, + supports_proxy_binary_upload: false, + supports_direct_downstream_auth: false, + supports_authoring_via_nyx: false, + supports_websocket: false, + supports_streaming: true, + }, + auth_notes: "Requires x-api-key header.", + known_limitations: null, + required_permissions: [], + }, + { + slug: "github", + name: "GitHub", + description: "GitHub REST & GraphQL API", + base_url: "https://api.github.com", + auth_method: "oauth2", + auth_key_name: "Authorization", + provider_config_id: "provider-github", + provider_type: "oauth2", + requires_gateway_url: false, credential_mode: "both", api_key_instructions: null, api_key_url: null, - icon_url: null, documentation_url: "https://docs.github.com/en/rest", + icon_url: null, + documentation_url: "https://docs.github.com/en/rest", service_type: "http", - ssh_host: null, ssh_port: null, ssh_ca_public_key: null, ssh_allowed_principals: null, ssh_certificate_ttl_minutes: null, - authorization_url: "https://github.com/login/oauth/authorize", token_url: "https://github.com/login/oauth/access_token", device_code_url: null, + ssh_host: null, + ssh_port: null, + ssh_ca_public_key: null, + ssh_allowed_principals: null, + ssh_certificate_ttl_minutes: null, + authorization_url: "https://github.com/login/oauth/authorize", + token_url: "https://github.com/login/oauth/access_token", + device_code_url: null, default_scopes: ["repo"], - scope_catalog: [{ scope: "repo", label: "Repositories", description: "Read and write repository data." }], + scope_catalog: [ + { + scope: "repo", + label: "Repositories", + description: "Read and write repository data.", + }, + ], scope_removal: "unsupported", - supports_pkce: false, device_code_format: null, - oauth_client_id: null, client_id_param_name: null, + supports_pkce: false, + device_code_format: null, + oauth_client_id: null, + client_id_param_name: null, has_platform_oauth_credentials: true, platform_scope_allowlist: ["repo"], - requires_credential: true, token_exchange_credential_fields: null, default_request_headers: null, - homepage_url: "https://github.com", repository_url: null, issues_url: null, - capabilities: { supports_proxy_read: true, supports_proxy_write: true, supports_proxy_binary_upload: false, supports_direct_downstream_auth: false, supports_authoring_via_nyx: false, supports_websocket: false, supports_streaming: false }, - auth_notes: null, known_limitations: null, required_permissions: [], + requires_credential: true, + token_exchange_credential_fields: null, + default_request_headers: null, + homepage_url: "https://github.com", + repository_url: null, + issues_url: null, + capabilities: { + supports_proxy_read: true, + supports_proxy_write: true, + supports_proxy_binary_upload: false, + supports_direct_downstream_auth: false, + supports_authoring_via_nyx: false, + supports_websocket: false, + supports_streaming: false, + }, + auth_notes: null, + known_limitations: null, + required_permissions: [], }, { - slug: "stripe", name: "Stripe", description: "Stripe Payments API", - base_url: "https://api.stripe.com/v1", auth_method: "bearer", auth_key_name: "Authorization", - provider_config_id: null, provider_type: null, requires_gateway_url: false, + slug: "stripe", + name: "Stripe", + description: "Stripe Payments API", + base_url: "https://api.stripe.com/v1", + auth_method: "bearer", + auth_key_name: "Authorization", + provider_config_id: null, + provider_type: null, + requires_gateway_url: false, credential_mode: "api_key", - api_key_instructions: "Find your secret key in the Stripe Dashboard under Developers > API keys", + api_key_instructions: + "Find your secret key in the Stripe Dashboard under Developers > API keys", api_key_url: "https://dashboard.stripe.com/apikeys", - icon_url: null, documentation_url: "https://stripe.com/docs/api", + icon_url: null, + documentation_url: "https://stripe.com/docs/api", service_type: "http", - ssh_host: null, ssh_port: null, ssh_ca_public_key: null, ssh_allowed_principals: null, ssh_certificate_ttl_minutes: null, - authorization_url: null, token_url: null, device_code_url: null, - default_scopes: null, supports_pkce: null, device_code_format: null, - oauth_client_id: null, client_id_param_name: null, - requires_credential: true, token_exchange_credential_fields: null, default_request_headers: null, - homepage_url: "https://stripe.com", repository_url: null, issues_url: null, - capabilities: { supports_proxy_read: true, supports_proxy_write: true, supports_proxy_binary_upload: false, supports_direct_downstream_auth: false, supports_authoring_via_nyx: false, supports_websocket: false, supports_streaming: false }, - auth_notes: null, known_limitations: null, required_permissions: [], + ssh_host: null, + ssh_port: null, + ssh_ca_public_key: null, + ssh_allowed_principals: null, + ssh_certificate_ttl_minutes: null, + authorization_url: null, + token_url: null, + device_code_url: null, + default_scopes: null, + supports_pkce: null, + device_code_format: null, + oauth_client_id: null, + client_id_param_name: null, + requires_credential: true, + token_exchange_credential_fields: null, + default_request_headers: null, + homepage_url: "https://stripe.com", + repository_url: null, + issues_url: null, + capabilities: { + supports_proxy_read: true, + supports_proxy_write: true, + supports_proxy_binary_upload: false, + supports_direct_downstream_auth: false, + supports_authoring_via_nyx: false, + supports_websocket: false, + supports_streaming: false, + }, + auth_notes: null, + known_limitations: null, + required_permissions: [], }, // Unconnected entries so the assistant Plugins marketplace shows an // "Available to add" section in mock mode (names mirror the mockup). { - slug: "lark-bot", name: "Lark Bot", description: "Send and receive messages in Lark/Feishu channels through your bot.", - base_url: "https://open.larksuite.com/open-apis", auth_method: "bearer", auth_key_name: "Authorization", - provider_config_id: null, provider_type: null, requires_gateway_url: false, + slug: "lark-bot", + name: "Lark Bot", + description: + "Send and receive messages in Lark/Feishu channels through your bot.", + base_url: "https://open.larksuite.com/open-apis", + auth_method: "bearer", + auth_key_name: "Authorization", + provider_config_id: null, + provider_type: null, + requires_gateway_url: false, credential_mode: "api_key", - api_key_instructions: "Create a bot in the Lark developer console and paste its token.", + api_key_instructions: + "Create a bot in the Lark developer console and paste its token.", api_key_url: "https://open.larksuite.com/app", - icon_url: null, documentation_url: "https://open.larksuite.com/document", + icon_url: null, + documentation_url: "https://open.larksuite.com/document", service_type: "http", - ssh_host: null, ssh_port: null, ssh_ca_public_key: null, ssh_allowed_principals: null, ssh_certificate_ttl_minutes: null, - authorization_url: null, token_url: null, device_code_url: null, - default_scopes: null, supports_pkce: null, device_code_format: null, - oauth_client_id: null, client_id_param_name: null, - requires_credential: true, token_exchange_credential_fields: null, default_request_headers: null, - homepage_url: "https://larksuite.com", repository_url: null, issues_url: null, - capabilities: { supports_proxy_read: true, supports_proxy_write: true, supports_proxy_binary_upload: false, supports_direct_downstream_auth: false, supports_authoring_via_nyx: false, supports_websocket: false, supports_streaming: false }, - auth_notes: null, known_limitations: null, required_permissions: [], - }, - { - slug: "postgres", name: "Postgres", description: "Read-only SQL over your database, executed on your own node.", - base_url: "http://localhost:5432", auth_method: "header", auth_key_name: "x-api-key", - provider_config_id: null, provider_type: null, requires_gateway_url: false, + ssh_host: null, + ssh_port: null, + ssh_ca_public_key: null, + ssh_allowed_principals: null, + ssh_certificate_ttl_minutes: null, + authorization_url: null, + token_url: null, + device_code_url: null, + default_scopes: null, + supports_pkce: null, + device_code_format: null, + oauth_client_id: null, + client_id_param_name: null, + requires_credential: true, + token_exchange_credential_fields: null, + default_request_headers: null, + homepage_url: "https://larksuite.com", + repository_url: null, + issues_url: null, + capabilities: { + supports_proxy_read: true, + supports_proxy_write: true, + supports_proxy_binary_upload: false, + supports_direct_downstream_auth: false, + supports_authoring_via_nyx: false, + supports_websocket: false, + supports_streaming: false, + }, + auth_notes: null, + known_limitations: null, + required_permissions: [], + }, + { + slug: "postgres", + name: "Postgres", + description: "Read-only SQL over your database, executed on your own node.", + base_url: "http://localhost:5432", + auth_method: "header", + auth_key_name: "x-api-key", + provider_config_id: null, + provider_type: null, + requires_gateway_url: false, credential_mode: "api_key", - api_key_instructions: "Provide a read-only connection string for your database.", + api_key_instructions: + "Provide a read-only connection string for your database.", api_key_url: null, - icon_url: null, documentation_url: "https://www.postgresql.org/docs/", + icon_url: null, + documentation_url: "https://www.postgresql.org/docs/", service_type: "http", - ssh_host: null, ssh_port: null, ssh_ca_public_key: null, ssh_allowed_principals: null, ssh_certificate_ttl_minutes: null, - authorization_url: null, token_url: null, device_code_url: null, - default_scopes: null, supports_pkce: null, device_code_format: null, - oauth_client_id: null, client_id_param_name: null, - requires_credential: true, token_exchange_credential_fields: null, default_request_headers: null, - homepage_url: "https://www.postgresql.org", repository_url: null, issues_url: null, - capabilities: { supports_proxy_read: true, supports_proxy_write: false, supports_proxy_binary_upload: false, supports_direct_downstream_auth: false, supports_authoring_via_nyx: false, supports_websocket: false, supports_streaming: false }, - auth_notes: null, known_limitations: null, required_permissions: [], - }, - { - slug: "openclaw", name: "OpenClaw Gateway", description: "Route requests to your self-hosted OpenClaw instance via a local node.", - base_url: "http://localhost:18789", auth_method: "bearer", auth_key_name: "Authorization", - provider_config_id: null, provider_type: null, requires_gateway_url: true, + ssh_host: null, + ssh_port: null, + ssh_ca_public_key: null, + ssh_allowed_principals: null, + ssh_certificate_ttl_minutes: null, + authorization_url: null, + token_url: null, + device_code_url: null, + default_scopes: null, + supports_pkce: null, + device_code_format: null, + oauth_client_id: null, + client_id_param_name: null, + requires_credential: true, + token_exchange_credential_fields: null, + default_request_headers: null, + homepage_url: "https://www.postgresql.org", + repository_url: null, + issues_url: null, + capabilities: { + supports_proxy_read: true, + supports_proxy_write: false, + supports_proxy_binary_upload: false, + supports_direct_downstream_auth: false, + supports_authoring_via_nyx: false, + supports_websocket: false, + supports_streaming: false, + }, + auth_notes: null, + known_limitations: null, + required_permissions: [], + }, + { + slug: "openclaw", + name: "OpenClaw Gateway", + description: + "Route requests to your self-hosted OpenClaw instance via a local node.", + base_url: "http://localhost:18789", + auth_method: "bearer", + auth_key_name: "Authorization", + provider_config_id: null, + provider_type: null, + requires_gateway_url: true, credential_mode: "api_key", api_key_instructions: "Paste your OpenClaw gateway URL and bearer token.", api_key_url: null, - icon_url: null, documentation_url: "https://docs.openclaw.dev", + icon_url: null, + documentation_url: "https://docs.openclaw.dev", service_type: "http", - ssh_host: null, ssh_port: null, ssh_ca_public_key: null, ssh_allowed_principals: null, ssh_certificate_ttl_minutes: null, - authorization_url: null, token_url: null, device_code_url: null, - default_scopes: null, supports_pkce: null, device_code_format: null, - oauth_client_id: null, client_id_param_name: null, - requires_credential: true, token_exchange_credential_fields: null, default_request_headers: null, - homepage_url: "https://openclaw.dev", repository_url: null, issues_url: null, - capabilities: { supports_proxy_read: true, supports_proxy_write: true, supports_proxy_binary_upload: false, supports_direct_downstream_auth: false, supports_authoring_via_nyx: false, supports_websocket: false, supports_streaming: true }, - auth_notes: null, known_limitations: null, required_permissions: [], + ssh_host: null, + ssh_port: null, + ssh_ca_public_key: null, + ssh_allowed_principals: null, + ssh_certificate_ttl_minutes: null, + authorization_url: null, + token_url: null, + device_code_url: null, + default_scopes: null, + supports_pkce: null, + device_code_format: null, + oauth_client_id: null, + client_id_param_name: null, + requires_credential: true, + token_exchange_credential_fields: null, + default_request_headers: null, + homepage_url: "https://openclaw.dev", + repository_url: null, + issues_url: null, + capabilities: { + supports_proxy_read: true, + supports_proxy_write: true, + supports_proxy_binary_upload: false, + supports_direct_downstream_auth: false, + supports_authoring_via_nyx: false, + supports_websocket: false, + supports_streaming: true, + }, + auth_notes: null, + known_limitations: null, + required_permissions: [], }, ]; @@ -983,9 +1481,27 @@ const MOCK_API_KEY_USAGE_LIST = MOCK_API_KEYS.map((k, i) => { total_tokens: total * 1160, reported_cost: total * 0.0032, top_services: [ - { service_id: "s1", service_slug: "openai", service_label: "OpenAI", request_count: Math.floor(total * 0.6), error_count: 0 }, - { service_id: "s2", service_slug: "anthropic", service_label: "Anthropic", request_count: Math.floor(total * 0.3), error_count: 0 }, - { service_id: "s3", service_slug: "github-copilot", service_label: "GitHub Copilot", request_count: Math.floor(total * 0.1), error_count: 0 }, + { + service_id: "s1", + service_slug: "openai", + service_label: "OpenAI", + request_count: Math.floor(total * 0.6), + error_count: 0, + }, + { + service_id: "s2", + service_slug: "anthropic", + service_label: "Anthropic", + request_count: Math.floor(total * 0.3), + error_count: 0, + }, + { + service_id: "s3", + service_slug: "github-copilot", + service_label: "GitHub Copilot", + request_count: Math.floor(total * 0.1), + error_count: 0, + }, ], daily_buckets: Array.from({ length: 7 }, (_, d) => { const date = new Date(baseDate); @@ -1003,8 +1519,30 @@ const MOCK_API_KEY_USAGE_LIST = MOCK_API_KEYS.map((k, i) => { // ── Approval Service Configs ── const MOCK_SERVICE_APPROVAL_CONFIGS = { configs: [ - { service_id: "svc-openai", service_name: "OpenAI", approval_required: true, approval_mode: "grant" as const, rules: [], default_effect: null, created_at: "2026-03-01T00:00:00Z", updated_at: "2026-03-01T00:00:00Z", user_service_id: "key-openai-1", user_service_slug: "openai" }, - { service_id: "svc-github", service_name: "GitHub", approval_required: true, approval_mode: "per_request" as const, rules: [], default_effect: null, created_at: "2026-03-01T00:00:00Z", updated_at: "2026-03-01T00:00:00Z", user_service_id: "key-github-1", user_service_slug: "github" }, + { + service_id: "svc-openai", + service_name: "OpenAI", + approval_required: true, + approval_mode: "grant" as const, + rules: [], + default_effect: null, + created_at: "2026-03-01T00:00:00Z", + updated_at: "2026-03-01T00:00:00Z", + user_service_id: "key-openai-1", + user_service_slug: "openai", + }, + { + service_id: "svc-github", + service_name: "GitHub", + approval_required: true, + approval_mode: "per_request" as const, + rules: [], + default_effect: null, + created_at: "2026-03-01T00:00:00Z", + updated_at: "2026-03-01T00:00:00Z", + user_service_id: "key-github-1", + user_service_slug: "github", + }, ], dominant_org_policies: [], }; @@ -1136,30 +1674,142 @@ const MOCK_ADMIN_USERS = [ // ── Admin Audit Log ── const MOCK_AUDIT_LOG = [ - { id: "aud-001", user_id: MOCK_ADMIN_USERS[0]!.id, api_key_id: null, api_key_name: null, event_type: "user.login", event_data: { method: "password", ip: "192.168.1.10" }, ip_address: "192.168.1.10", user_agent: "Mozilla/5.0 (Macintosh)", created_at: "2026-05-14T09:30:00Z" }, - { id: "aud-002", user_id: MOCK_ADMIN_USERS[0]!.id, api_key_id: "k1-0001-0001-0001-000000000001", api_key_name: "claude-code-agent", event_type: "proxy.request", event_data: { service: "openai", method: "POST", path: "/v1/chat/completions" }, ip_address: "10.0.1.50", user_agent: "nyxid-agent/0.9.2", created_at: "2026-05-14T09:25:00Z" }, - { id: "aud-003", user_id: MOCK_ADMIN_USERS[1]!.id, api_key_id: null, api_key_name: null, event_type: "user.login", event_data: { method: "password" }, ip_address: "10.0.0.5", user_agent: "Mozilla/5.0 (Windows NT 10.0)", created_at: "2026-05-13T16:45:00Z" }, - { id: "aud-004", user_id: MOCK_ADMIN_USERS[0]!.id, api_key_id: null, api_key_name: null, event_type: "service_account.create", event_data: { name: "CI/CD Pipeline" }, ip_address: "192.168.1.10", user_agent: "Mozilla/5.0 (Macintosh)", created_at: "2026-05-13T14:00:00Z" }, - { id: "aud-005", user_id: MOCK_ADMIN_USERS[2]!.id, api_key_id: null, api_key_name: null, event_type: "mfa.setup", event_data: { method: "totp" }, ip_address: "172.16.0.20", user_agent: "Mozilla/5.0 (Linux)", created_at: "2026-05-13T10:00:00Z" }, - { id: "aud-006", user_id: MOCK_ADMIN_USERS[3]!.id, api_key_id: null, api_key_name: null, event_type: "user.login", event_data: { method: "password" }, ip_address: "192.168.1.42", user_agent: "Mozilla/5.0 (Macintosh)", created_at: "2026-05-14T08:10:00Z" }, - { id: "aud-007", user_id: MOCK_ADMIN_USERS[0]!.id, api_key_id: null, api_key_name: null, event_type: "invite_code.create", event_data: { max_uses: 5 }, ip_address: "192.168.1.10", user_agent: "Mozilla/5.0 (Macintosh)", created_at: "2026-05-12T15:00:00Z" }, - { id: "aud-008", user_id: MOCK_ADMIN_USERS[4]!.id, api_key_id: null, api_key_name: null, event_type: "user.register", event_data: { invite_code: "CHRONO-2026" }, ip_address: "203.0.113.50", user_agent: "Mozilla/5.0 (iPhone)", created_at: "2026-05-10T15:00:00Z" }, - { id: "aud-009", user_id: MOCK_ADMIN_USERS[0]!.id, api_key_id: null, api_key_name: null, event_type: "role.create", event_data: { name: "API Consumer" }, ip_address: "192.168.1.10", user_agent: "Mozilla/5.0 (Macintosh)", created_at: "2026-05-10T11:00:00Z" }, - { id: "aud-010", user_id: MOCK_ADMIN_USERS[0]!.id, api_key_id: null, api_key_name: null, event_type: "user.status_change", event_data: { target_user: "deactivated@example.com", is_active: false }, ip_address: "192.168.1.10", user_agent: "Mozilla/5.0 (Macintosh)", created_at: "2026-05-09T10:00:00Z" }, + { + id: "aud-001", + user_id: MOCK_ADMIN_USERS[0]!.id, + api_key_id: null, + api_key_name: null, + event_type: "user.login", + event_data: { method: "password", ip: "192.168.1.10" }, + ip_address: "192.168.1.10", + user_agent: "Mozilla/5.0 (Macintosh)", + created_at: "2026-05-14T09:30:00Z", + }, + { + id: "aud-002", + user_id: MOCK_ADMIN_USERS[0]!.id, + api_key_id: "k1-0001-0001-0001-000000000001", + api_key_name: "claude-code-agent", + event_type: "proxy.request", + event_data: { + service: "openai", + method: "POST", + path: "/v1/chat/completions", + }, + ip_address: "10.0.1.50", + user_agent: "nyxid-agent/0.9.2", + created_at: "2026-05-14T09:25:00Z", + }, + { + id: "aud-003", + user_id: MOCK_ADMIN_USERS[1]!.id, + api_key_id: null, + api_key_name: null, + event_type: "user.login", + event_data: { method: "password" }, + ip_address: "10.0.0.5", + user_agent: "Mozilla/5.0 (Windows NT 10.0)", + created_at: "2026-05-13T16:45:00Z", + }, + { + id: "aud-004", + user_id: MOCK_ADMIN_USERS[0]!.id, + api_key_id: null, + api_key_name: null, + event_type: "service_account.create", + event_data: { name: "CI/CD Pipeline" }, + ip_address: "192.168.1.10", + user_agent: "Mozilla/5.0 (Macintosh)", + created_at: "2026-05-13T14:00:00Z", + }, + { + id: "aud-005", + user_id: MOCK_ADMIN_USERS[2]!.id, + api_key_id: null, + api_key_name: null, + event_type: "mfa.setup", + event_data: { method: "totp" }, + ip_address: "172.16.0.20", + user_agent: "Mozilla/5.0 (Linux)", + created_at: "2026-05-13T10:00:00Z", + }, + { + id: "aud-006", + user_id: MOCK_ADMIN_USERS[3]!.id, + api_key_id: null, + api_key_name: null, + event_type: "user.login", + event_data: { method: "password" }, + ip_address: "192.168.1.42", + user_agent: "Mozilla/5.0 (Macintosh)", + created_at: "2026-05-14T08:10:00Z", + }, + { + id: "aud-007", + user_id: MOCK_ADMIN_USERS[0]!.id, + api_key_id: null, + api_key_name: null, + event_type: "invite_code.create", + event_data: { max_uses: 5 }, + ip_address: "192.168.1.10", + user_agent: "Mozilla/5.0 (Macintosh)", + created_at: "2026-05-12T15:00:00Z", + }, + { + id: "aud-008", + user_id: MOCK_ADMIN_USERS[4]!.id, + api_key_id: null, + api_key_name: null, + event_type: "user.register", + event_data: { invite_code: "CHRONO-2026" }, + ip_address: "203.0.113.50", + user_agent: "Mozilla/5.0 (iPhone)", + created_at: "2026-05-10T15:00:00Z", + }, + { + id: "aud-009", + user_id: MOCK_ADMIN_USERS[0]!.id, + api_key_id: null, + api_key_name: null, + event_type: "role.create", + event_data: { name: "API Consumer" }, + ip_address: "192.168.1.10", + user_agent: "Mozilla/5.0 (Macintosh)", + created_at: "2026-05-10T11:00:00Z", + }, + { + id: "aud-010", + user_id: MOCK_ADMIN_USERS[0]!.id, + api_key_id: null, + api_key_name: null, + event_type: "user.status_change", + event_data: { target_user: "deactivated@example.com", is_active: false }, + ip_address: "192.168.1.10", + user_agent: "Mozilla/5.0 (Macintosh)", + created_at: "2026-05-09T10:00:00Z", + }, ]; // Mirrors the shape the backend advertises so the mock table offers the same // sorts, scoped-search fields, and filters as a live one. const MOCK_AUDIT_LOG_FILTER_OPTIONS = { sorts: [ - "-created_at", "created_at", - "event_type", "-event_type", - "api_key_name", "-api_key_name", - "api_key_id", "-api_key_id", - "user_id", "-user_id", - "ip_address", "-ip_address", - "user_agent", "-user_agent", - "status", "-status", + "-created_at", + "created_at", + "event_type", + "-event_type", + "api_key_name", + "-api_key_name", + "api_key_id", + "-api_key_id", + "user_id", + "-user_id", + "ip_address", + "-ip_address", + "user_agent", + "-user_agent", + "status", + "-status", ], search_fields: [ { key: "event_type", label: "Event type" }, @@ -1239,33 +1889,68 @@ const MOCK_AUDIT_LOG_FILTER_OPTIONS = { // ── Admin Invite Codes ── const MOCK_INVITE_CODES = [ { - id: "inv-001", code: "CHRONO-2026", max_uses: 5, used_count: 3, is_active: true, + id: "inv-001", + code: "CHRONO-2026", + max_uses: 5, + used_count: 3, + is_active: true, created_by: MOCK_ADMIN_USERS[0]!.id, creator: { email: "dannick@nyxid.dev", display_name: "Donnick Young" }, note: "Team onboarding Q1 2026", - created_at: "2026-01-10T09:00:00Z", updated_at: "2026-03-20T11:00:00Z", + created_at: "2026-01-10T09:00:00Z", + updated_at: "2026-03-20T11:00:00Z", usages: [ - { user_id: MOCK_ADMIN_USERS[2]!.id, used_at: "2026-01-05T09:00:00Z", user_email: "sarah@chronoai.dev", user_display_name: "Sarah Park" }, - { user_id: MOCK_ADMIN_USERS[3]!.id, used_at: "2026-02-10T14:00:00Z", user_email: "mike@chronoai.dev", user_display_name: "Mike Torres" }, - { user_id: MOCK_ADMIN_USERS[4]!.id, used_at: "2026-03-20T11:00:00Z", user_email: "lin@chronoai.dev", user_display_name: "Lin Wei" }, + { + user_id: MOCK_ADMIN_USERS[2]!.id, + used_at: "2026-01-05T09:00:00Z", + user_email: "sarah@chronoai.dev", + user_display_name: "Sarah Park", + }, + { + user_id: MOCK_ADMIN_USERS[3]!.id, + used_at: "2026-02-10T14:00:00Z", + user_email: "mike@chronoai.dev", + user_display_name: "Mike Torres", + }, + { + user_id: MOCK_ADMIN_USERS[4]!.id, + used_at: "2026-03-20T11:00:00Z", + user_email: "lin@chronoai.dev", + user_display_name: "Lin Wei", + }, ], }, { - id: "inv-002", code: "PARTNER-VIP", max_uses: 10, used_count: 0, is_active: true, + id: "inv-002", + code: "PARTNER-VIP", + max_uses: 10, + used_count: 0, + is_active: true, created_by: MOCK_ADMIN_USERS[0]!.id, creator: { email: "dannick@nyxid.dev", display_name: "Dannick Young" }, note: "Partner program invites", - created_at: "2026-04-01T12:00:00Z", updated_at: "2026-04-01T12:00:00Z", + created_at: "2026-04-01T12:00:00Z", + updated_at: "2026-04-01T12:00:00Z", usages: [], }, { - id: "inv-003", code: "BETA-TEST-42", max_uses: 1, used_count: 1, is_active: false, + id: "inv-003", + code: "BETA-TEST-42", + max_uses: 1, + used_count: 1, + is_active: false, created_by: MOCK_ADMIN_USERS[1]!.id, creator: { email: "alex@chronoai.dev", display_name: "Alex Chen" }, note: null, - created_at: "2025-12-20T08:00:00Z", updated_at: "2026-01-05T09:00:00Z", + created_at: "2025-12-20T08:00:00Z", + updated_at: "2026-01-05T09:00:00Z", usages: [ - { user_id: MOCK_ADMIN_USERS[2]!.id, used_at: "2026-01-05T09:00:00Z", user_email: "sarah@chronoai.dev", user_display_name: "Sarah Park" }, + { + user_id: MOCK_ADMIN_USERS[2]!.id, + used_at: "2026-01-05T09:00:00Z", + user_email: "sarah@chronoai.dev", + user_display_name: "Sarah Park", + }, ], }, ]; @@ -1273,79 +1958,144 @@ const MOCK_INVITE_CODES = [ // ── Admin Roles ── const MOCK_ROLES = [ { - id: "role-001", name: "Platform Admin", slug: "platform-admin", + id: "role-001", + name: "Platform Admin", + slug: "platform-admin", description: "Full administrative access to all platform features", - permissions: ["admin:read", "admin:write", "users:manage", "roles:manage", "audit:read"], - is_default: false, is_system: true, client_id: null, - created_at: "2025-11-01T00:00:00Z", updated_at: "2025-11-01T00:00:00Z", + permissions: [ + "admin:read", + "admin:write", + "users:manage", + "roles:manage", + "audit:read", + ], + is_default: false, + is_system: true, + client_id: null, + created_at: "2025-11-01T00:00:00Z", + updated_at: "2025-11-01T00:00:00Z", }, { - id: "role-002", name: "API Consumer", slug: "api-consumer", + id: "role-002", + name: "API Consumer", + slug: "api-consumer", description: "Can connect services and use the proxy", permissions: ["proxy:read", "proxy:write", "services:read", "keys:manage"], - is_default: true, is_system: false, client_id: null, - created_at: "2026-01-15T10:00:00Z", updated_at: "2026-03-10T14:00:00Z", + is_default: true, + is_system: false, + client_id: null, + created_at: "2026-01-15T10:00:00Z", + updated_at: "2026-03-10T14:00:00Z", }, { - id: "role-003", name: "Node Operator", slug: "node-operator", + id: "role-003", + name: "Node Operator", + slug: "node-operator", description: "Can register and manage credential nodes", permissions: ["nodes:manage", "proxy:read", "proxy:write"], - is_default: false, is_system: false, client_id: null, - created_at: "2026-02-20T09:00:00Z", updated_at: "2026-02-20T09:00:00Z", + is_default: false, + is_system: false, + client_id: null, + created_at: "2026-02-20T09:00:00Z", + updated_at: "2026-02-20T09:00:00Z", }, { - id: "role-004", name: "Audit Viewer", slug: "audit-viewer", + id: "role-004", + name: "Audit Viewer", + slug: "audit-viewer", description: "Read-only access to audit logs", permissions: ["audit:read"], - is_default: false, is_system: false, client_id: null, - created_at: "2026-03-05T11:00:00Z", updated_at: "2026-03-05T11:00:00Z", + is_default: false, + is_system: false, + client_id: null, + created_at: "2026-03-05T11:00:00Z", + updated_at: "2026-03-05T11:00:00Z", }, ]; // ── Admin Groups ── const MOCK_GROUPS = [ { - id: "grp-001", name: "Engineering", slug: "engineering", + id: "grp-001", + name: "Engineering", + slug: "engineering", description: "Core engineering team with full proxy and node access", roles: [MOCK_ROLES[1]!, MOCK_ROLES[2]!], - parent_group_id: null, member_count: 3, - created_at: "2025-12-01T08:00:00Z", updated_at: "2026-04-10T14:00:00Z", + parent_group_id: null, + member_count: 3, + created_at: "2025-12-01T08:00:00Z", + updated_at: "2026-04-10T14:00:00Z", }, { - id: "grp-002", name: "Product", slug: "product", + id: "grp-002", + name: "Product", + slug: "product", description: "Product team with service access", roles: [MOCK_ROLES[1]!], - parent_group_id: null, member_count: 2, - created_at: "2026-01-10T09:00:00Z", updated_at: "2026-03-15T10:00:00Z", + parent_group_id: null, + member_count: 2, + created_at: "2026-01-10T09:00:00Z", + updated_at: "2026-03-15T10:00:00Z", }, { - id: "grp-003", name: "Security", slug: "security", + id: "grp-003", + name: "Security", + slug: "security", description: "Security team with audit access", roles: [MOCK_ROLES[3]!], - parent_group_id: null, member_count: 1, - created_at: "2026-02-15T11:00:00Z", updated_at: "2026-02-15T11:00:00Z", + parent_group_id: null, + member_count: 1, + created_at: "2026-02-15T11:00:00Z", + updated_at: "2026-02-15T11:00:00Z", }, ]; -const MOCK_GROUP_MEMBERS: Record = { +const MOCK_GROUP_MEMBERS: Record< + string, + { members: unknown[]; total: number } +> = { "grp-001": { members: [ - { id: MOCK_ADMIN_USERS[0]!.id, email: "dannick@nyxid.dev", display_name: "Dannick Young" }, - { id: MOCK_ADMIN_USERS[1]!.id, email: "alex@chronoai.dev", display_name: "Alex Chen" }, - { id: MOCK_ADMIN_USERS[3]!.id, email: "mike@chronoai.dev", display_name: "Mike Torres" }, + { + id: MOCK_ADMIN_USERS[0]!.id, + email: "dannick@nyxid.dev", + display_name: "Dannick Young", + }, + { + id: MOCK_ADMIN_USERS[1]!.id, + email: "alex@chronoai.dev", + display_name: "Alex Chen", + }, + { + id: MOCK_ADMIN_USERS[3]!.id, + email: "mike@chronoai.dev", + display_name: "Mike Torres", + }, ], total: 3, }, "grp-002": { members: [ - { id: MOCK_ADMIN_USERS[2]!.id, email: "sarah@chronoai.dev", display_name: "Sarah Park" }, - { id: MOCK_ADMIN_USERS[3]!.id, email: "mike@chronoai.dev", display_name: "Mike Torres" }, + { + id: MOCK_ADMIN_USERS[2]!.id, + email: "sarah@chronoai.dev", + display_name: "Sarah Park", + }, + { + id: MOCK_ADMIN_USERS[3]!.id, + email: "mike@chronoai.dev", + display_name: "Mike Torres", + }, ], total: 2, }, "grp-003": { members: [ - { id: MOCK_ADMIN_USERS[1]!.id, email: "alex@chronoai.dev", display_name: "Alex Chen" }, + { + id: MOCK_ADMIN_USERS[1]!.id, + email: "alex@chronoai.dev", + display_name: "Alex Chen", + }, ], total: 1, }, @@ -1354,30 +2104,48 @@ const MOCK_GROUP_MEMBERS: Record // ── Admin Service Accounts ── const MOCK_SERVICE_ACCOUNTS = [ { - id: "sa-001", name: "CI/CD Pipeline", description: "Automated deployment pipeline", - client_id: "nyx_sa_ci_cd_pipeline_8f3a", secret_prefix: "nyx_ss_8f3a", - allowed_scopes: "openid proxy:* llm:proxy", role_ids: ["role-002"], - is_active: true, rate_limit_override: 50, + id: "sa-001", + name: "CI/CD Pipeline", + description: "Automated deployment pipeline", + client_id: "nyx_sa_ci_cd_pipeline_8f3a", + secret_prefix: "nyx_ss_8f3a", + allowed_scopes: "openid proxy:* llm:proxy", + role_ids: ["role-002"], + is_active: true, + rate_limit_override: 50, created_by: MOCK_ADMIN_USERS[0]!.id, - created_at: "2026-03-01T09:00:00Z", updated_at: "2026-05-10T14:00:00Z", + created_at: "2026-03-01T09:00:00Z", + updated_at: "2026-05-10T14:00:00Z", last_authenticated_at: "2026-05-14T06:00:00Z", }, { - id: "sa-002", name: "Monitoring Agent", description: "Health check and monitoring service", - client_id: "nyx_sa_monitoring_agent_2b7c", secret_prefix: "nyx_ss_2b7c", - allowed_scopes: "openid proxy:read", role_ids: [], - is_active: true, rate_limit_override: null, + id: "sa-002", + name: "Monitoring Agent", + description: "Health check and monitoring service", + client_id: "nyx_sa_monitoring_agent_2b7c", + secret_prefix: "nyx_ss_2b7c", + allowed_scopes: "openid proxy:read", + role_ids: [], + is_active: true, + rate_limit_override: null, created_by: MOCK_ADMIN_USERS[0]!.id, - created_at: "2026-04-15T11:00:00Z", updated_at: "2026-04-15T11:00:00Z", + created_at: "2026-04-15T11:00:00Z", + updated_at: "2026-04-15T11:00:00Z", last_authenticated_at: "2026-05-14T09:28:00Z", }, { - id: "sa-003", name: "Data Sync Worker", description: null, - client_id: "nyx_sa_data_sync_worker_9d1e", secret_prefix: "nyx_ss_9d1e", - allowed_scopes: "openid proxy:read proxy:write", role_ids: ["role-002"], - is_active: false, rate_limit_override: 20, + id: "sa-003", + name: "Data Sync Worker", + description: null, + client_id: "nyx_sa_data_sync_worker_9d1e", + secret_prefix: "nyx_ss_9d1e", + allowed_scopes: "openid proxy:read proxy:write", + role_ids: ["role-002"], + is_active: false, + rate_limit_override: 20, created_by: MOCK_ADMIN_USERS[1]!.id, - created_at: "2026-02-20T15:00:00Z", updated_at: "2026-05-01T10:00:00Z", + created_at: "2026-02-20T15:00:00Z", + updated_at: "2026-05-01T10:00:00Z", last_authenticated_at: "2026-04-28T22:00:00Z", }, ]; @@ -1385,59 +2153,194 @@ const MOCK_SERVICE_ACCOUNTS = [ // ── Admin Nodes ── const MOCK_ADMIN_NODES = [ { - id: "node-0001", name: "prod-us-east", - user_id: MOCK_ADMIN_USERS[0]!.id, user_email: "dannick@nyxid.dev", - status: "Online", is_connected: true, - last_heartbeat_at: "2026-05-14T09:30:00Z", connected_at: "2026-05-12T08:00:00Z", - metadata: { agent_version: "0.9.2", os: "linux", arch: "x86_64", ip_address: "10.0.1.50" }, - metrics: { total_requests: 12450, success_count: 12380, error_count: 70, success_rate: 0.994, avg_latency_ms: 42, last_error: null, last_error_at: null, last_success_at: "2026-05-14T09:29:00Z" }, - binding_count: 3, created_at: "2026-02-10T09:00:00Z", - }, - { - id: "node-0002", name: "staging-eu", - user_id: MOCK_ADMIN_USERS[0]!.id, user_email: "dannick@nyxid.dev", - status: "Online", is_connected: true, - last_heartbeat_at: "2026-05-14T09:28:00Z", connected_at: "2026-05-10T10:00:00Z", - metadata: { agent_version: "0.9.2", os: "darwin", arch: "arm64", ip_address: "192.168.1.100" }, - metrics: { total_requests: 3200, success_count: 3180, error_count: 20, success_rate: 0.994, avg_latency_ms: 85, last_error: null, last_error_at: null, last_success_at: "2026-05-14T09:25:00Z" }, - binding_count: 2, created_at: "2026-03-15T14:00:00Z", - }, - { - id: "node-0003", name: "alex-dev-local", - user_id: MOCK_ADMIN_USERS[1]!.id, user_email: "alex@chronoai.dev", - status: "Offline", is_connected: false, - last_heartbeat_at: "2026-05-13T18:00:00Z", connected_at: null, - metadata: { agent_version: "0.9.1", os: "darwin", arch: "arm64", ip_address: "192.168.1.42" }, - metrics: { total_requests: 890, success_count: 875, error_count: 15, success_rate: 0.983, avg_latency_ms: 120, last_error: "connection timeout", last_error_at: "2026-05-13T17:55:00Z", last_success_at: "2026-05-13T17:50:00Z" }, - binding_count: 1, created_at: "2026-04-01T10:00:00Z", - }, - { - id: "node-0004", name: "prod-drain-test", - user_id: MOCK_ADMIN_USERS[0]!.id, user_email: "dannick@nyxid.dev", - status: "Draining", is_connected: true, - last_heartbeat_at: "2026-05-14T09:29:00Z", connected_at: "2026-05-14T06:00:00Z", - metadata: { agent_version: "0.9.2", os: "linux", arch: "x86_64", ip_address: "10.0.1.51" }, - metrics: { total_requests: 450, success_count: 448, error_count: 2, success_rate: 0.996, avg_latency_ms: 38, last_error: null, last_error_at: null, last_success_at: "2026-05-14T09:20:00Z" }, - binding_count: 1, created_at: "2026-05-01T12:00:00Z", + id: "node-0001", + name: "prod-us-east", + user_id: MOCK_ADMIN_USERS[0]!.id, + user_email: "dannick@nyxid.dev", + status: "Online", + is_connected: true, + last_heartbeat_at: "2026-05-14T09:30:00Z", + connected_at: "2026-05-12T08:00:00Z", + metadata: { + agent_version: "0.9.2", + os: "linux", + arch: "x86_64", + ip_address: "10.0.1.50", + }, + metrics: { + total_requests: 12450, + success_count: 12380, + error_count: 70, + success_rate: 0.994, + avg_latency_ms: 42, + last_error: null, + last_error_at: null, + last_success_at: "2026-05-14T09:29:00Z", + }, + binding_count: 3, + created_at: "2026-02-10T09:00:00Z", + }, + { + id: "node-0002", + name: "staging-eu", + user_id: MOCK_ADMIN_USERS[0]!.id, + user_email: "dannick@nyxid.dev", + status: "Online", + is_connected: true, + last_heartbeat_at: "2026-05-14T09:28:00Z", + connected_at: "2026-05-10T10:00:00Z", + metadata: { + agent_version: "0.9.2", + os: "darwin", + arch: "arm64", + ip_address: "192.168.1.100", + }, + metrics: { + total_requests: 3200, + success_count: 3180, + error_count: 20, + success_rate: 0.994, + avg_latency_ms: 85, + last_error: null, + last_error_at: null, + last_success_at: "2026-05-14T09:25:00Z", + }, + binding_count: 2, + created_at: "2026-03-15T14:00:00Z", + }, + { + id: "node-0003", + name: "alex-dev-local", + user_id: MOCK_ADMIN_USERS[1]!.id, + user_email: "alex@chronoai.dev", + status: "Offline", + is_connected: false, + last_heartbeat_at: "2026-05-13T18:00:00Z", + connected_at: null, + metadata: { + agent_version: "0.9.1", + os: "darwin", + arch: "arm64", + ip_address: "192.168.1.42", + }, + metrics: { + total_requests: 890, + success_count: 875, + error_count: 15, + success_rate: 0.983, + avg_latency_ms: 120, + last_error: "connection timeout", + last_error_at: "2026-05-13T17:55:00Z", + last_success_at: "2026-05-13T17:50:00Z", + }, + binding_count: 1, + created_at: "2026-04-01T10:00:00Z", + }, + { + id: "node-0004", + name: "prod-drain-test", + user_id: MOCK_ADMIN_USERS[0]!.id, + user_email: "dannick@nyxid.dev", + status: "Draining", + is_connected: true, + last_heartbeat_at: "2026-05-14T09:29:00Z", + connected_at: "2026-05-14T06:00:00Z", + metadata: { + agent_version: "0.9.2", + os: "linux", + arch: "x86_64", + ip_address: "10.0.1.51", + }, + metrics: { + total_requests: 450, + success_count: 448, + error_count: 2, + success_rate: 0.996, + avg_latency_ms: 38, + last_error: null, + last_error_at: null, + last_success_at: "2026-05-14T09:20:00Z", + }, + binding_count: 1, + created_at: "2026-05-01T12:00:00Z", }, ]; // ── Admin Sessions ── -const MOCK_ADMIN_SESSIONS: Record = { +const MOCK_ADMIN_SESSIONS: Record< + string, + { sessions: unknown[]; total: number } +> = { [MOCK_ADMIN_USERS[0]!.id]: { sessions: [ - { id: "sess-a01", ip_address: "192.168.1.10", user_agent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)", created_at: "2026-05-14T09:30:00Z", expires_at: "2026-05-21T09:30:00Z", last_active_at: "2026-05-14T09:30:00Z", revoked: false }, - { id: "sess-a02", ip_address: "10.0.0.5", user_agent: "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0)", created_at: "2026-05-13T20:00:00Z", expires_at: "2026-05-20T20:00:00Z", last_active_at: "2026-05-14T08:00:00Z", revoked: false }, + { + id: "sess-a01", + ip_address: "192.168.1.10", + user_agent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)", + created_at: "2026-05-14T09:30:00Z", + expires_at: "2026-05-21T09:30:00Z", + last_active_at: "2026-05-14T09:30:00Z", + revoked: false, + }, + { + id: "sess-a02", + ip_address: "10.0.0.5", + user_agent: "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0)", + created_at: "2026-05-13T20:00:00Z", + expires_at: "2026-05-20T20:00:00Z", + last_active_at: "2026-05-14T08:00:00Z", + revoked: false, + }, ], total: 2, }, }; // ── Admin User Roles / Groups ── -const MOCK_USER_ROLES: Record = { - [MOCK_ADMIN_USERS[0]!.id]: { direct_roles: [MOCK_ROLES[0]], inherited_roles: [MOCK_ROLES[1], MOCK_ROLES[2]], effective_permissions: ["admin:read", "admin:write", "users:manage", "roles:manage", "audit:read", "proxy:read", "proxy:write", "services:read", "keys:manage", "nodes:manage"] }, - [MOCK_ADMIN_USERS[1]!.id]: { direct_roles: [MOCK_ROLES[1]], inherited_roles: [], effective_permissions: ["proxy:read", "proxy:write", "services:read", "keys:manage"] }, - [MOCK_ADMIN_USERS[3]!.id]: { direct_roles: [MOCK_ROLES[1]], inherited_roles: [], effective_permissions: ["proxy:read", "proxy:write", "services:read", "keys:manage"] }, +const MOCK_USER_ROLES: Record< + string, + { + direct_roles: unknown[]; + inherited_roles: unknown[]; + effective_permissions: string[]; + } +> = { + [MOCK_ADMIN_USERS[0]!.id]: { + direct_roles: [MOCK_ROLES[0]], + inherited_roles: [MOCK_ROLES[1], MOCK_ROLES[2]], + effective_permissions: [ + "admin:read", + "admin:write", + "users:manage", + "roles:manage", + "audit:read", + "proxy:read", + "proxy:write", + "services:read", + "keys:manage", + "nodes:manage", + ], + }, + [MOCK_ADMIN_USERS[1]!.id]: { + direct_roles: [MOCK_ROLES[1]], + inherited_roles: [], + effective_permissions: [ + "proxy:read", + "proxy:write", + "services:read", + "keys:manage", + ], + }, + [MOCK_ADMIN_USERS[3]!.id]: { + direct_roles: [MOCK_ROLES[1]], + inherited_roles: [], + effective_permissions: [ + "proxy:read", + "proxy:write", + "services:read", + "keys:manage", + ], + }, }; const MOCK_USER_GROUPS_MAP: Record = { @@ -1448,11 +2351,17 @@ const MOCK_USER_GROUPS_MAP: Record = { }; // ── Helper: find by ID in an array ── -function findById(items: readonly T[], id: string): T | undefined { +function findById( + items: readonly T[], + id: string, +): T | undefined { return items.find((item) => item.id === id); } -function findBySlug(items: readonly T[], slug: string): T | undefined { +function findBySlug( + items: readonly T[], + slug: string, +): T | undefined { return items.find((item) => item.slug === slug); } @@ -1603,7 +2512,12 @@ const MOCK_BILLING_ALLOWANCES = { mockAllowance("openai", "bytes", 1_000_000_000, 150_000_000), mockAllowance("anthropic", "tokens", 2_000_000, 0), mockAllowance("anthropic", "requests", 500, 500), - mockAllowance("enterprise-knowledge-graph-retrieval-gateway", "requests", 10_000, 9_990), + mockAllowance( + "enterprise-knowledge-graph-retrieval-gateway", + "requests", + 10_000, + 9_990, + ), ], }; function mockUsageRow( @@ -1637,17 +2551,76 @@ function mockUsageRow( ...overrides, }; } -function mockBillingUsage(period: string) { - const rows = [ - mockUsageRow("openai", "input_tokens", 412_000, 0.0213, { model: "gpt-4o" }), - mockUsageRow("openai", "output_tokens", 61_000, 0.0142, { model: "gpt-4o" }), - mockUsageRow("anthropic", "tokens", 18_400, 0.0071, { model: "claude-sonnet-5" }), +const MOCK_DAY_PATTERN = [0, 2, 5, 3, 0, 6, 1, 4, 2, 0, 3, 7, 1, 2]; + +/** Spread each fixture row over UTC days for `bucket=day`, deterministically. */ +function mockDailyRows( + rows: ReturnType[], + period: string, +) { + const days = { "7d": 7, "30d": 30, "90d": 90 }[period] ?? 1; + const today = Date.UTC( + new Date().getUTCFullYear(), + new Date().getUTCMonth(), + new Date().getUTCDate(), + ); + return rows.flatMap((row) => + Array.from({ length: days }, (_, i) => i).flatMap((i) => { + const weight = + MOCK_DAY_PATTERN[ + (i + row.service_slug.length) % MOCK_DAY_PATTERN.length + ]!; + if (!weight) return []; + const share = weight / (days * 3); + const micros = Math.round(row.estimated_credits_micros * share); + return [ + { + ...row, + quantity: Math.max(1, Math.round(row.quantity * share)), + requests: + row.metric === "requests" + ? Math.max(1, Math.round(row.quantity * share)) + : 0, + events: weight, + estimated_credits_micros: micros, + grant_credits_micros: micros, + day: new Date(today - (days - 1 - i) * 86_400_000).toISOString(), + }, + ]; + }), + ); +} + +function mockBillingUsage(period: string, bucket: string | null = null) { + const flatRows = [ + mockUsageRow("openai", "input_tokens", 412_000, 0.0213, { + model: "gpt-4o", + }), + mockUsageRow("openai", "output_tokens", 61_000, 0.0142, { + model: "gpt-4o", + }), + mockUsageRow("anthropic", "tokens", 18_400, 0.0071, { + model: "claude-sonnet-5", + }), mockUsageRow("github", "requests", 340, 0), mockUsageRow("stripe", "requests", 42, 0), - mockUsageRow("enterprise-knowledge-graph-retrieval-gateway", "requests", 9_990, 0), + mockUsageRow( + "enterprise-knowledge-graph-retrieval-gateway", + "requests", + 9_990, + 0, + ), ]; - const sum = (field: "quantity" | "requests" | "bytes" | "events" | "estimated_credits_micros" | "grant_credits_micros") => - rows.reduce((total, row) => total + (row[field] as number), 0); + const rows = bucket === "day" ? mockDailyRows(flatRows, period) : flatRows; + const sum = ( + field: + | "quantity" + | "requests" + | "bytes" + | "events" + | "estimated_credits_micros" + | "grant_credits_micros", + ) => rows.reduce((total, row) => total + (row[field] as number), 0); return { owner_id: MOCK_BILLING_OWNER, period, @@ -1702,24 +2675,49 @@ type MockHandler = ( const MOCK_HANDLERS: MockHandler[] = [ // User - (p) => p === "/users/me" ? MOCK_USER : undefined, - (p) => p === "/users/me/primary-org" ? MOCK_ORGS[0] : undefined, + (p) => (p === "/users/me" ? MOCK_USER : undefined), + (p) => (p === "/users/me/primary-org" ? MOCK_ORGS[0] : undefined), // Billing - (p) => p === "/billing/wallet" ? MOCK_BILLING_WALLET : undefined, - (p, q) => p === "/billing/usage" ? mockBillingUsage(q.get("period") ?? "30d") : undefined, - (p) => p === "/billing/topups" ? MOCK_BILLING_TOPUPS : undefined, - (p) => p === "/billing/grants" ? MOCK_BILLING_GRANTS : undefined, - (p) => p === "/billing/allowances" ? MOCK_BILLING_ALLOWANCES : undefined, + (p) => (p === "/billing/wallet" ? MOCK_BILLING_WALLET : undefined), + (p, q) => + p === "/billing/usage" + ? mockBillingUsage(q.get("period") ?? "30d", q.get("bucket")) + : undefined, + // Like current production: no insights route, so the configured fallback runs. + (p) => { + if (p === "/service-insights") + throw new ApiError(404, { + message: "Not found", + error_code: 404, + } as never); + return undefined; + }, + (p) => (p === "/billing/topups" ? MOCK_BILLING_TOPUPS : undefined), + (p) => (p === "/billing/grants" ? MOCK_BILLING_GRANTS : undefined), + (p) => (p === "/billing/allowances" ? MOCK_BILLING_ALLOWANCES : undefined), // API keys usage (must be before generic /api-keys patterns) - (p) => p.match(/^\/api-keys\/usage/) ? { usage: MOCK_API_KEY_USAGE_LIST, since: "2026-05-01T00:00:00Z", days: 7 } : undefined, + (p) => + p.match(/^\/api-keys\/usage/) + ? { + usage: MOCK_API_KEY_USAGE_LIST, + since: "2026-05-01T00:00:00Z", + days: 7, + } + : undefined, // API keys external - (p) => p.match(/^\/api-keys\/external$/) ? { keys: MOCK_EXTERNAL_API_KEYS } : undefined, + (p) => + p.match(/^\/api-keys\/external$/) + ? { keys: MOCK_EXTERNAL_API_KEYS } + : undefined, (p) => { const m = p.match(/^\/api-keys\/external\/([\w-]+)$/); - return m ? findById(MOCK_EXTERNAL_API_KEYS, m[1] ?? "") ?? MOCK_EXTERNAL_API_KEYS[0] : undefined; + return m + ? (findById(MOCK_EXTERNAL_API_KEYS, m[1] ?? "") ?? + MOCK_EXTERNAL_API_KEYS[0]) + : undefined; }, // API key bindings @@ -1733,36 +2731,44 @@ const MOCK_HANDLERS: MockHandler[] = [ (p) => { const m = p.match(/^\/api-keys\/([\w-]+)\/usage/); if (!m) return undefined; - return MOCK_API_KEY_USAGE_LIST.find((u) => u.api_key_id === m[1]) ?? MOCK_API_KEY_USAGE_LIST[0]; + return ( + MOCK_API_KEY_USAGE_LIST.find((u) => u.api_key_id === m[1]) ?? + MOCK_API_KEY_USAGE_LIST[0] + ); }, // API key detail (p) => { const m = p.match(/^\/api-keys\/([\w-]+)$/); - return m ? findById(MOCK_API_KEYS, m[1] ?? "") ?? MOCK_API_KEYS[0] : undefined; + return m + ? (findById(MOCK_API_KEYS, m[1] ?? "") ?? MOCK_API_KEYS[0]) + : undefined; }, // API keys list - (p) => p.match(/^\/api-keys$/) ? { keys: MOCK_API_KEYS } : undefined, + (p) => (p.match(/^\/api-keys$/) ? { keys: MOCK_API_KEYS } : undefined), // Keys (external services) (p) => { const m = p.match(/^\/keys\/([\w-]+)$/); - return m ? findById(MOCK_KEYS, m[1] ?? "") ?? MOCK_KEYS[0] : undefined; + return m ? (findById(MOCK_KEYS, m[1] ?? "") ?? MOCK_KEYS[0]) : undefined; }, - (p) => p === "/keys" ? { keys: MOCK_KEYS } : undefined, + (p) => (p === "/keys" ? { keys: MOCK_KEYS } : undefined), // User endpoints - (p) => p.match(/^\/endpoints/) ? { endpoints: MOCK_USER_ENDPOINTS } : undefined, + (p) => + p.match(/^\/endpoints/) ? { endpoints: MOCK_USER_ENDPOINTS } : undefined, // User services - (p) => p.match(/^\/user-services/) ? { services: MOCK_USER_SERVICES } : undefined, + (p) => + p.match(/^\/user-services/) ? { services: MOCK_USER_SERVICES } : undefined, // Connections - (p) => p.match(/^\/connections$/) ? { connections: MOCK_CONNECTIONS } : undefined, + (p) => + p.match(/^\/connections$/) ? { connections: MOCK_CONNECTIONS } : undefined, // Nodes - (p) => p.match(/^\/nodes\/my-bindings/) ? { bindings: [] } : undefined, + (p) => (p.match(/^\/nodes\/my-bindings/) ? { bindings: [] } : undefined), (p) => { const m = p.match(/^\/nodes\/([\w-]+)\/admins/); return m ? { admins: [] } : undefined; @@ -1773,14 +2779,22 @@ const MOCK_HANDLERS: MockHandler[] = [ }, (p) => { const m = p.match(/^\/nodes\/([\w-]+)$/); - return m ? findById(MOCK_NODES, m[1] ?? "") ?? MOCK_NODES[0] : undefined; + return m ? (findById(MOCK_NODES, m[1] ?? "") ?? MOCK_NODES[0]) : undefined; }, - (p) => p === "/nodes" ? { nodes: MOCK_NODES } : undefined, + (p) => (p === "/nodes" ? { nodes: MOCK_NODES } : undefined), // Notifications - (p) => p === "/notifications/settings" ? MOCK_NOTIFICATION_SETTINGS : undefined, - (p) => p.match(/^\/notifications\/devices/) ? MOCK_PUSH_DEVICES : undefined, - (p) => p.match(/^\/notifications\/telegram/) ? { link_code: "MOCK-LINK-CODE", bot_username: "nyxid_approvals_bot", expires_in_secs: 600 } : undefined, + (p) => + p === "/notifications/settings" ? MOCK_NOTIFICATION_SETTINGS : undefined, + (p) => (p.match(/^\/notifications\/devices/) ? MOCK_PUSH_DEVICES : undefined), + (p) => + p.match(/^\/notifications\/telegram/) + ? { + link_code: "MOCK-LINK-CODE", + bot_username: "nyxid_approvals_bot", + expires_in_secs: 600, + } + : undefined, // Approvals // The real handler filters by status server-side; the assistant view relies @@ -1794,22 +2808,36 @@ const MOCK_HANDLERS: MockHandler[] = [ ); return { ...MOCK_APPROVAL_REQUESTS, requests, total: requests.length }; }, - (p) => p.match(/^\/approvals\/grants/) ? MOCK_APPROVAL_GRANTS : undefined, - (p) => p.match(/^\/approvals\/service-configs/) ? MOCK_SERVICE_APPROVAL_CONFIGS : undefined, + (p) => (p.match(/^\/approvals\/grants/) ? MOCK_APPROVAL_GRANTS : undefined), + (p) => + p.match(/^\/approvals\/service-configs/) + ? MOCK_SERVICE_APPROVAL_CONFIGS + : undefined, // Developer apps (p) => { const m = p.match(/^\/developer\/oauth-clients\/([\w-]+)$/); - return m ? findById(MOCK_DEVELOPER_APPS, m[1] ?? "") ?? MOCK_DEVELOPER_APPS[0] : undefined; + return m + ? (findById(MOCK_DEVELOPER_APPS, m[1] ?? "") ?? MOCK_DEVELOPER_APPS[0]) + : undefined; }, - (p) => p === "/developer/oauth-clients" ? { clients: MOCK_DEVELOPER_APPS } : undefined, + (p) => + p === "/developer/oauth-clients" + ? { clients: MOCK_DEVELOPER_APPS } + : undefined, // Channel bots (p) => { const m = p.match(/^\/channel-bots\/([\w-]+)$/); - return m ? findById(MOCK_CHANNEL_BOTS_DATA, m[1] ?? "") ?? MOCK_CHANNEL_BOTS_DATA[0] : undefined; + return m + ? (findById(MOCK_CHANNEL_BOTS_DATA, m[1] ?? "") ?? + MOCK_CHANNEL_BOTS_DATA[0]) + : undefined; }, - (p) => p.match(/^\/channel-bots$/) ? { bots: MOCK_CHANNEL_BOTS_DATA, total: MOCK_CHANNEL_BOTS_DATA.length } : undefined, + (p) => + p.match(/^\/channel-bots$/) + ? { bots: MOCK_CHANNEL_BOTS_DATA, total: MOCK_CHANNEL_BOTS_DATA.length } + : undefined, // Channel conversations (p) => { @@ -1818,9 +2846,18 @@ const MOCK_HANDLERS: MockHandler[] = [ }, (p) => { const m = p.match(/^\/channel-conversations\/([\w-]+)$/); - return m ? findById(MOCK_CONVERSATIONS_DATA, m[1] ?? "") ?? MOCK_CONVERSATIONS_DATA[0] : undefined; - }, - (p) => p.match(/^\/channel-conversations/) ? { conversations: MOCK_CONVERSATIONS_DATA, total: MOCK_CONVERSATIONS_DATA.length } : undefined, + return m + ? (findById(MOCK_CONVERSATIONS_DATA, m[1] ?? "") ?? + MOCK_CONVERSATIONS_DATA[0]) + : undefined; + }, + (p) => + p.match(/^\/channel-conversations/) + ? { + conversations: MOCK_CONVERSATIONS_DATA, + total: MOCK_CONVERSATIONS_DATA.length, + } + : undefined, // Organizations (p) => { @@ -1837,44 +2874,90 @@ const MOCK_HANDLERS: MockHandler[] = [ }, (p) => { const m = p.match(/^\/orgs\/([\w-]+)$/); - return m ? findById(MOCK_ORGS, m[1] ?? "") ?? { ...MOCK_ORGS[0], id: m[1] } : undefined; + return m + ? (findById(MOCK_ORGS, m[1] ?? "") ?? { ...MOCK_ORGS[0], id: m[1] }) + : undefined; }, - (p) => p === "/orgs" ? { orgs: MOCK_ORGS } : undefined, + (p) => (p === "/orgs" ? { orgs: MOCK_ORGS } : undefined), // Consents & broker - (p) => p.match(/^\/users\/me\/consents/) ? { consents: MOCK_CONSENTS } : undefined, - (p) => p.match(/^\/users\/me\/broker-bindings/) ? { bindings: MOCK_BROKER_BINDINGS } : undefined, - (p) => p === "/auth/consents/me" ? { consents: MOCK_CONSENTS } : undefined, - (p) => p === "/broker/bindings/me" ? { bindings: MOCK_BROKER_BINDINGS } : undefined, + (p) => + p.match(/^\/users\/me\/consents/) ? { consents: MOCK_CONSENTS } : undefined, + (p) => + p.match(/^\/users\/me\/broker-bindings/) + ? { bindings: MOCK_BROKER_BINDINGS } + : undefined, + (p) => (p === "/auth/consents/me" ? { consents: MOCK_CONSENTS } : undefined), + (p) => + p === "/broker/bindings/me" + ? { bindings: MOCK_BROKER_BINDINGS } + : undefined, // Sessions - (p) => p === "/sessions" ? MOCK_SESSIONS : undefined, + (p) => (p === "/sessions" ? MOCK_SESSIONS : undefined), // Catalog (p) => { const m = p.match(/^\/catalog\/([\w-]+)\/shape/); - return m ? { endpoints: [{ method: "POST", path: "/v1/chat/completions", summary: "Create chat completion" }, { method: "GET", path: "/v1/models", summary: "List models" }] } : undefined; + return m + ? { + endpoints: [ + { + method: "POST", + path: "/v1/chat/completions", + summary: "Create chat completion", + }, + { method: "GET", path: "/v1/models", summary: "List models" }, + ], + } + : undefined; }, (p) => { const m = p.match(/^\/catalog\/([\w-]+)\/endpoints/); - return m ? { endpoints: [{ method: "POST", path: "/v1/chat/completions", summary: "Create chat completion" }, { method: "GET", path: "/v1/models", summary: "List models" }, { method: "POST", path: "/v1/embeddings", summary: "Create embeddings" }] } : undefined; + return m + ? { + endpoints: [ + { + method: "POST", + path: "/v1/chat/completions", + summary: "Create chat completion", + }, + { method: "GET", path: "/v1/models", summary: "List models" }, + { + method: "POST", + path: "/v1/embeddings", + summary: "Create embeddings", + }, + ], + } + : undefined; }, (p) => { const m = p.match(/^\/catalog\/([\w-]+)$/); - return m ? findBySlug(MOCK_CATALOG, m[1] ?? "") ?? MOCK_CATALOG[0] : undefined; + return m + ? (findBySlug(MOCK_CATALOG, m[1] ?? "") ?? MOCK_CATALOG[0]) + : undefined; }, - (p) => p.match(/^\/catalog$/) ? { entries: MOCK_CATALOG } : undefined, + (p) => (p.match(/^\/catalog$/) ? { entries: MOCK_CATALOG } : undefined), // ── Admin endpoints ── // Admin users (p) => { const m = p.match(/^\/admin\/users\/([\w-]+)\/sessions$/); - return m ? (MOCK_ADMIN_SESSIONS[m[1] ?? ""] ?? { sessions: [], total: 0 }) : undefined; + return m + ? (MOCK_ADMIN_SESSIONS[m[1] ?? ""] ?? { sessions: [], total: 0 }) + : undefined; }, (p) => { const m = p.match(/^\/admin\/users\/([\w-]+)\/roles$/); - return m ? (MOCK_USER_ROLES[m[1] ?? ""] ?? { direct_roles: [], inherited_roles: [], effective_permissions: [] }) : undefined; + return m + ? (MOCK_USER_ROLES[m[1] ?? ""] ?? { + direct_roles: [], + inherited_roles: [], + effective_permissions: [], + }) + : undefined; }, (p) => { const m = p.match(/^\/admin\/users\/([\w-]+)\/groups$/); @@ -1882,15 +2965,37 @@ const MOCK_HANDLERS: MockHandler[] = [ }, (p) => { const m = p.match(/^\/admin\/users\/([\w-]+)$/); - return m ? findById(MOCK_ADMIN_USERS, m[1] ?? "") ?? MOCK_ADMIN_USERS[0] : undefined; - }, - (p) => p.match(/^\/admin\/users$/) ? { users: MOCK_ADMIN_USERS, total: MOCK_ADMIN_USERS.length, page: 1, per_page: 20 } : undefined, + return m + ? (findById(MOCK_ADMIN_USERS, m[1] ?? "") ?? MOCK_ADMIN_USERS[0]) + : undefined; + }, + (p) => + p.match(/^\/admin\/users$/) + ? { + users: MOCK_ADMIN_USERS, + total: MOCK_ADMIN_USERS.length, + page: 1, + per_page: 20, + } + : undefined, // Admin audit log - (p) => p.match(/^\/admin\/audit-log/) ? { entries: MOCK_AUDIT_LOG, total: MOCK_AUDIT_LOG.length, page: 1, per_page: 50, filter_options: MOCK_AUDIT_LOG_FILTER_OPTIONS } : undefined, + (p) => + p.match(/^\/admin\/audit-log/) + ? { + entries: MOCK_AUDIT_LOG, + total: MOCK_AUDIT_LOG.length, + page: 1, + per_page: 50, + filter_options: MOCK_AUDIT_LOG_FILTER_OPTIONS, + } + : undefined, // Admin invite codes - (p) => p.match(/^\/admin\/invite-codes$/) ? { invite_codes: MOCK_INVITE_CODES } : undefined, + (p) => + p.match(/^\/admin\/invite-codes$/) + ? { invite_codes: MOCK_INVITE_CODES } + : undefined, // Admin service accounts (p) => { @@ -1903,46 +3008,71 @@ const MOCK_HANDLERS: MockHandler[] = [ }, (p) => { const m = p.match(/^\/admin\/service-accounts\/([\w-]+)$/); - return m ? findById(MOCK_SERVICE_ACCOUNTS, m[1] ?? "") ?? MOCK_SERVICE_ACCOUNTS[0] : undefined; - }, - (p) => p.match(/^\/admin\/service-accounts$/) ? { service_accounts: MOCK_SERVICE_ACCOUNTS, total: MOCK_SERVICE_ACCOUNTS.length, page: 1, per_page: 20 } : undefined, + return m + ? (findById(MOCK_SERVICE_ACCOUNTS, m[1] ?? "") ?? + MOCK_SERVICE_ACCOUNTS[0]) + : undefined; + }, + (p) => + p.match(/^\/admin\/service-accounts$/) + ? { + service_accounts: MOCK_SERVICE_ACCOUNTS, + total: MOCK_SERVICE_ACCOUNTS.length, + page: 1, + per_page: 20, + } + : undefined, // Admin nodes (p) => { const m = p.match(/^\/admin\/nodes\/([\w-]+)$/); - return m ? findById(MOCK_ADMIN_NODES, m[1] ?? "") ?? MOCK_ADMIN_NODES[0] : undefined; - }, - (p) => p.match(/^\/admin\/nodes$/) ? { nodes: MOCK_ADMIN_NODES, total: MOCK_ADMIN_NODES.length, page: 1, per_page: 50 } : undefined, + return m + ? (findById(MOCK_ADMIN_NODES, m[1] ?? "") ?? MOCK_ADMIN_NODES[0]) + : undefined; + }, + (p) => + p.match(/^\/admin\/nodes$/) + ? { + nodes: MOCK_ADMIN_NODES, + total: MOCK_ADMIN_NODES.length, + page: 1, + per_page: 50, + } + : undefined, // Roles (p) => { const m = p.match(/^\/roles\/([\w-]+)$/); - return m ? findById(MOCK_ROLES, m[1] ?? "") ?? MOCK_ROLES[0] : undefined; + return m ? (findById(MOCK_ROLES, m[1] ?? "") ?? MOCK_ROLES[0]) : undefined; }, - (p) => p.match(/^\/roles$/) ? { roles: MOCK_ROLES } : undefined, + (p) => (p.match(/^\/roles$/) ? { roles: MOCK_ROLES } : undefined), // Groups (p) => { const m = p.match(/^\/groups\/([\w-]+)\/members$/); - return m ? (MOCK_GROUP_MEMBERS[m[1] ?? ""] ?? { members: [], total: 0 }) : undefined; + return m + ? (MOCK_GROUP_MEMBERS[m[1] ?? ""] ?? { members: [], total: 0 }) + : undefined; }, (p) => { const m = p.match(/^\/groups\/([\w-]+)$/); - return m ? findById(MOCK_GROUPS, m[1] ?? "") ?? MOCK_GROUPS[0] : undefined; + return m + ? (findById(MOCK_GROUPS, m[1] ?? "") ?? MOCK_GROUPS[0]) + : undefined; }, - (p) => p.match(/^\/groups$/) ? { groups: MOCK_GROUPS } : undefined, + (p) => (p.match(/^\/groups$/) ? { groups: MOCK_GROUPS } : undefined), // Services (admin/legacy) - (p) => p === "/services" ? { services: [] } : undefined, + (p) => (p === "/services" ? { services: [] } : undefined), // MCP - (p) => p.match(/^\/mcp/) ? MOCK_MCP_CONFIG : undefined, + (p) => (p.match(/^\/mcp/) ? MOCK_MCP_CONFIG : undefined), // LLM - (p) => p.match(/^\/llm\/status/) ? MOCK_LLM_STATUS : undefined, + (p) => (p.match(/^\/llm\/status/) ? MOCK_LLM_STATUS : undefined), // Public config - (p) => p === "/public/config" ? MOCK_PUBLIC_CONFIG : undefined, + (p) => (p === "/public/config" ? MOCK_PUBLIC_CONFIG : undefined), // Auth device-code login (p) => { @@ -1992,7 +3122,9 @@ let _mockLatched: boolean | null = null; export function isMockMode(): boolean { if (_mockLatched !== null) return _mockLatched; - _mockLatched = import.meta.env.DEV && new URLSearchParams(window.location.search).has("mock"); + _mockLatched = + import.meta.env.DEV && + new URLSearchParams(window.location.search).has("mock"); return _mockLatched; } diff --git a/frontend/src/lib/service-usage.test.ts b/frontend/src/lib/service-usage.test.ts index 7ab69fe5d..2dc66ef31 100644 --- a/frontend/src/lib/service-usage.test.ts +++ b/frontend/src/lib/service-usage.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; import type { BillingUsageRow } from "@/schemas/billing"; -import { serviceUsageSummary } from "./service-usage"; +import { serviceUsageDaily, serviceUsageSummary } from "./service-usage"; const row = (overrides: Partial): BillingUsageRow => ({ service_slug: "llm-deepseek", @@ -66,3 +66,53 @@ describe("serviceUsageSummary", () => { expect(serviceUsageSummary([row({})], "api-twitter")).toBeNull(); }); }); + +describe("serviceUsageDaily", () => { + const now = new Date("2026-10-06T12:00:00Z"); + it("zero-fills each UTC day of the period, oldest first", () => { + const series = serviceUsageDaily( + [ + row({ + day: "2026-10-04T00:00:00Z", + events: 2, + quantity: 20, + estimated_credits: "0.1", + }), + row({ + day: "2026-10-06T00:00:00Z", + events: 1, + quantity: 5, + estimated_credits: "0.05", + }), + ], + "llm-deepseek", + "7d", + now, + ); + expect(series?.map((d) => [d.day, d.calls, d.charged])).toEqual([ + ["2026-09-30", 0, "0"], + ["2026-10-01", 0, "0"], + ["2026-10-02", 0, "0"], + ["2026-10-03", 0, "0"], + ["2026-10-04", 2, "0.1"], + ["2026-10-05", 0, "0"], + ["2026-10-06", 1, "0.05"], + ]); + }); + + it("has no daily view for 24h or a server without day buckets", () => { + expect(serviceUsageDaily([], "llm-deepseek", "24h", now)).toBeNull(); + expect( + serviceUsageDaily([row({ events: 1 })], "llm-deepseek", "30d", now), + ).toBeNull(); + expect( + serviceUsageDaily( + [row({ service_slug: "other" })], + "llm-deepseek", + "30d", + now, + ), + ).toBeNull(); + expect(serviceUsageDaily([], "llm-deepseek", "30d", now)).toHaveLength(30); + }); +}); diff --git a/frontend/src/lib/service-usage.ts b/frontend/src/lib/service-usage.ts index 5d4108850..f50f6afb8 100644 --- a/frontend/src/lib/service-usage.ts +++ b/frontend/src/lib/service-usage.ts @@ -76,3 +76,52 @@ export function serviceUsageSummary( .sort((a, b) => b.calls - a.calls), }; } + +export interface ServiceUsageDay { + /** UTC calendar day, `YYYY-MM-DD`. */ + readonly day: string; + readonly calls: number; + readonly quantities: readonly { metric: string; quantity: number }[]; + /** NyxID credits charged that day; null when a charged row is unsettled. */ + readonly charged: string | null; +} + +const PERIOD_DAYS: Record = { "7d": 7, "30d": 30, "90d": 90 }; + +/** + * Zero-filled UTC daily series from `bucket=day` rows, oldest first. Null when + * the period has no daily view or the server did not split rows by day. + */ +export function serviceUsageDaily( + rows: readonly BillingUsageRow[], + slug: string, + period: string, + now = new Date(), +): ServiceUsageDay[] | null { + const days = PERIOD_DAYS[period]; + const matching = rows.filter((row) => row.service_slug === slug); + // Older servers ignore `bucket=day`; any undated row means no daily split. + if (!days || rows.some((row) => !row.day)) return null; + const byDay = new Map(); + for (const row of matching) { + const key = row.day!.slice(0, 10); + byDay.set(key, [...(byDay.get(key) ?? []), row]); + } + const today = Date.UTC( + now.getUTCFullYear(), + now.getUTCMonth(), + now.getUTCDate(), + ); + return Array.from({ length: days }, (_, i) => { + const day = new Date(today - (days - 1 - i) * 86_400_000) + .toISOString() + .slice(0, 10); + const summary = serviceUsageSummary(byDay.get(day) ?? [], slug); + return { + day, + calls: summary?.calls ?? 0, + quantities: summary?.quantities ?? [], + charged: summary ? summary.charged : "0", + }; + }); +} diff --git a/frontend/src/schemas/billing.ts b/frontend/src/schemas/billing.ts index 32fd8a7b4..d5d7369a4 100644 --- a/frontend/src/schemas/billing.ts +++ b/frontend/src/schemas/billing.ts @@ -119,6 +119,8 @@ export const billingUsageRowSchema = z.object({ billable: z.boolean().optional().default(true), estimated_credits_micros: z.number().int().nullable().optional(), token_breakdown: billingTokenBreakdownSchema.nullable().optional(), + /** UTC day start, only when requested with `bucket=day`. */ + day: z.string().nullable().optional(), }); export const billingUsageTotalsSchema = z.object({ From 3856c56f3b02362ec053f752c537f30bf48d2901 Mon Sep 17 00:00:00 2001 From: ctkm-aelf <230958228+ctkm-aelf@users.noreply.github.com> Date: Tue, 6 Oct 2026 15:27:38 +0800 Subject: [PATCH 30/36] feat: explain service billing in plain language MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The billing panel now leads with one answer (Free on NyxID, Uses NyxID credits, Cost not confirmed, or unavailable) and a sentence saying why, followed by whose key or app is used, who pays NyxID, and the price. Repeated badges, internal terms and footnotes are replaced by a short list of tips that only appear when they apply. Connection rows show one short line (e.g. "You pay · 0.05 credits/request" or "Free on NyxID"). Usage flags credits charged on a connection that is free today. Co-Authored-By: Claude Opus 5.5 --- .../dashboard/service-connection-table.tsx | 51 ++-- .../dashboard/service-insight-panels.tsx | 235 ++++++------------ .../dashboard/service-insights.test.tsx | 52 ++-- .../service-routing-preview.test.tsx | 29 +-- frontend/src/lib/billing-plain.test.ts | 146 +++++++++++ frontend/src/lib/billing-plain.ts | 217 ++++++++++++++++ 6 files changed, 495 insertions(+), 235 deletions(-) create mode 100644 frontend/src/lib/billing-plain.test.ts create mode 100644 frontend/src/lib/billing-plain.ts diff --git a/frontend/src/components/dashboard/service-connection-table.tsx b/frontend/src/components/dashboard/service-connection-table.tsx index e38f6d436..d7338e9ce 100644 --- a/frontend/src/components/dashboard/service-connection-table.tsx +++ b/frontend/src/components/dashboard/service-connection-table.tsx @@ -54,12 +54,10 @@ import { ConnectionInsightPanel, type InsightPanel, } from "./service-insight-panels"; +import { plainBilling } from "@/lib/billing-plain"; import { - billingAccountLabel, - billingExplanation, callerLabel, credentialLabel, - nyxidChargeLabel, accessCountLabel, latestRecordedUse, outcomeLabel, @@ -498,31 +496,19 @@ export function ServiceConnectionTable({ {insights.status === "ready" ? connectionBillingLabels[billingCategory] - : insightStatusLabel(insights.status, "Billing")} + : insightStatusLabel( + insights.status, + "Billing", + )} {billing && - (billingCategory === "platform" || billingCategory === "byok") && ( - <> - - - {billing.context === "configuration" - ? "Expected payer: " - : "Payer: "} - - {billingAccountLabel(billing)} + (billingCategory === "platform" || + billingCategory === "byok") && ( + + {plainBilling(key, billing).short} - - {nyxidChargeLabel(billing)} - - - )} + )}

- {credentialLabel(key, billing)} -

-

{billing - ? billingExplanation(billing) - : insightStatusLabel(insights.status, "Billing")} + ? plainBilling(key, billing).headline + : insightStatusLabel( + insights.status, + "Billing", + )}

+ {billing && ( +

+ {plainBilling(key, billing).detail} +

+ )}

- Click to see rates and the funding order. + Click for details and your usage.

)} diff --git a/frontend/src/components/dashboard/service-insight-panels.tsx b/frontend/src/components/dashboard/service-insight-panels.tsx index e9a2b7fc4..9f5b7b8e1 100644 --- a/frontend/src/components/dashboard/service-insight-panels.tsx +++ b/frontend/src/components/dashboard/service-insight-panels.tsx @@ -1,7 +1,6 @@ import { useState } from "react"; import { Link } from "@tanstack/react-router"; import { - ArrowRight, ArrowUpRight, Bot, CreditCard, @@ -31,6 +30,7 @@ import { type ServiceUsageDay, } from "@/lib/service-usage"; import { formatExactCredits, hasCredits } from "@/lib/credits"; +import { plainBilling } from "@/lib/billing-plain"; import type { ServiceInsight } from "@/schemas/service-insights"; import { useServiceInsights, @@ -38,12 +38,8 @@ import { } from "@/hooks/use-service-insights"; import { accessReasonLabel, - billingAccountLabel, - billingModelLabel, - billingExplanation, callerKindLabel, callerLabel, - credentialLabel, outcomeLabel, recordedSourceLabel, } from "@/lib/service-insights"; @@ -177,7 +173,14 @@ function UsageDailyChart({ series }: { readonly series: ServiceUsageDay[] }) { ); } -function ConnectionUsage({ connection }: { readonly connection: KeyInfo }) { +function ConnectionUsage({ + connection, + freeNow, +}: { + readonly connection: KeyInfo; + /** The connection is free on NyxID today, so charged usage needs a reason. */ + readonly freeNow: boolean; +}) { const [period, setPeriod] = useState("30d"); // One daily request feeds both the totals and the chart. const usage = useBillingUsage(period, period === "24h" ? undefined : "day"); @@ -234,7 +237,7 @@ function ConnectionUsage({ connection }: { readonly connection: KeyInfo }) {
-
Metered
+
Usage
{summary.quantities .map( @@ -245,25 +248,25 @@ function ConnectionUsage({ connection }: { readonly connection: KeyInfo }) {
-
NyxID credits charged
+
NyxID credits used
{!summary.billable - ? "None · metered only" + ? "None" : summary.charged == null - ? "Not settled yet" + ? "Still being calculated" : formatExactCredits(summary.charged)}
{summary.billable && [ - ["Wallet", summary.wallet], - ["Grants", summary.grant], - ["Allowances", summary.allowance], + ["From wallet", summary.wallet], + ["From grants", summary.grant], + ["From allowances", summary.allowance], ].some(([, value]) => hasCredits(value)) && (
{[ - ["Wallet", summary.wallet], - ["Grants", summary.grant], - ["Allowances", summary.allowance], + ["From wallet", summary.wallet], + ["From grants", summary.grant], + ["From allowances", summary.allowance], ] .filter(([, value]) => hasCredits(value)) .map( @@ -280,18 +283,28 @@ function ConnectionUsage({ connection }: { readonly connection: KeyInfo }) { ) : ( period !== "24h" && (

- The daily chart needs the updated NyxID backend. + A day-by-day chart will appear here after the next NyxID update.

) )} + {freeNow && + summary.charged != null && + hasCredits(summary.charged) && ( +

+ Credits were charged in this period even though this connection + is free on NyxID now. They may come from an earlier price, or + from another connection that shares{" "} + {connection.slug}. +

+ )} {summary.agents.length > 1 || summary.agents[0]?.name ? (

- By caller:{" "} + Made by:{" "} {summary.agents .slice(0, 5) .map( ({ name, calls }) => - `${name ?? "You (signed in)"} ${calls.toLocaleString()}`, + `${name ?? "you, signed in"} (${calls.toLocaleString()})`, ) .join(" · ")} {summary.agents.length > 5 && @@ -301,8 +314,9 @@ function ConnectionUsage({ connection }: { readonly connection: KeyInfo }) { )}

- Recorded by slug {connection.slug} for your personal - billing account, so connections sharing this slug are combined. + Counts calls made through {connection.slug} that were + billed to your personal account. Other connections using the same + address are counted together. {org && ` Usage billed to ${org} isn’t included.`}

@@ -328,6 +342,7 @@ function ConnectionBillingPanel({ caller === "you" ? insight.billing : selectedState.connections.get(connection.id)?.billing; + const plain = bill ? plainBilling(connection, bill) : null; return (

- Billing flow + Billing

-
- {insight.billing?.context === "configuration" ? ( - Connection default · configured - ) : ( - <> - For + {insight.billing?.context !== "configuration" && + !!insight.usage?.access.keys.length && ( +
+ Show for - +
)} -
- {bill ? ( + {plain ? ( <> -
- {bill.credit_billing_configured != null && - bill.status !== "restricted" && ( - - {bill.credit_billing_configured - ? "NyxID usage charges configured" - : "No NyxID usage charges configured"} - - )} - {billingModelLabel(bill)} - {bill.status !== "restricted" && bill.status !== "unavailable" && ( - - {billingExplanation(bill)} - - )} +
+

{plain.headline}

+

{plain.detail}

-
-
-
Credential
-
- {credentialLabel(connection, bill)} -
+
+
+
Whose key or app
+
{plain.key.title}
+ {plain.key.note && ( +
+ {plain.key.note} +
+ )}
-
- {bill.status !== "restricted" && - bill.status !== "unavailable" && - (bill.charge_status === "usage_based" || - bill.charge_status === "conditional") && ( -
-

- Funding order: - Eligible allowances → Credit grants → Wallet credits -

-

- Applied within the selected billing account, including - eligible platform-issued grants. Actual funding is determined - per request; the grant used is not reported in this preview. -

-

- A direct connection slug targets this connection. A priority - pool slug can fail over according to its saved policy, with - each attempted connection using its own billing rules. -

-
- )} - {!!bill.rates.length && ( -
- - - - - - - {bill.context === "configuration" && ( - - )} - - - - {bill.rates.map((rate, i) => ( - - - - - {bill.context === "configuration" && ( - - )} - - ))} - -
ChargeUnit - Credits per unit - Price sync
- {rate.layer === "resale" - ? "Provider usage through NyxID" - : "NyxID usage"} - - {metricLabel(rate.metric, 1)} - - {rate.credits_per_unit ?? "Plan rate not reported"} - - {rate.sync_status === "synced" - ? "Synced" - : rate.sync_status === "pending" - ? "Pending" - : rate.sync_status === "failed" - ? "Failed" - : "Not reported"} -
-
+ {!!plain.tips.length && ( +
    + {plain.tips.map((tip) => ( +
  • {tip}
  • + ))} +
)} - {bill.notes.map((note) => ( -

- {note} -

- ))} ) : ( )} - +
); } diff --git a/frontend/src/components/dashboard/service-insights.test.tsx b/frontend/src/components/dashboard/service-insights.test.tsx index a9118f58e..9805159b7 100644 --- a/frontend/src/components/dashboard/service-insights.test.tsx +++ b/frontend/src/components/dashboard/service-insights.test.tsx @@ -206,7 +206,7 @@ describe("service card billing and caller details", () => { }); expect(screen.getByTitle(/^Organization · ChronoAI · /)).toBeVisible(); expect(screen.getByText(/Codex CI · Release app/)).toBeVisible(); - expect(screen.getByText("Personal account")).toBeVisible(); + expect(screen.getByText("You pay · 0.25 credits/request")).toBeVisible(); }); it("does not present incomplete key inventory as zero keys", () => { mount({ @@ -307,7 +307,9 @@ describe("service card billing and caller details", () => { }, }); expect(screen.getByTitle(/Configured scope[\s\S]*Codex CI/)).toBeVisible(); - expect(screen.getByText("Acting user's personal account")).toBeVisible(); + expect( + screen.getByText("Caller pays · 0.05 credits/request"), + ).toBeVisible(); expect(screen.queryByText(/No recorded requests/)).not.toBeInTheDocument(); await user.click( screen.getByRole("button", { name: "Billing for Team OpenAI" }), @@ -315,15 +317,15 @@ describe("service card billing and caller details", () => { const panel = screen.getByRole("region", { name: "Billing for Team OpenAI", }); - expect(within(panel).getByText("Billing flow")).toBeVisible(); - expect(within(panel).getByText("Expected payer")).toBeVisible(); - expect(within(panel).getByText("Pending")).toBeVisible(); + expect(within(panel).getByText("Uses NyxID credits")).toBeVisible(); + expect(within(panel).getByText("Who pays NyxID")).toBeVisible(); + expect( + within(panel).getByText(/A new price is waiting to be activated/), + ).toBeVisible(); expect( within(panel).queryByRole("combobox", { name: "Preview billing for" }), ).not.toBeInTheDocument(); - expect( - within(panel).getByRole("table", { name: "Configured NyxID rates" }), - ).toBeVisible(); + expect(within(panel).getByText("0.05 credits per request")).toBeVisible(); await user.click( screen.getByRole("button", { name: "Recent requests for Team OpenAI" }), ); @@ -337,7 +339,7 @@ describe("service card billing and caller details", () => { it("exposes payer, credential and actual last caller directly in the expanded table", () => { mount(); expect(screen.getByRole("columnheader", { name: "Billing" })).toBeVisible(); - expect(screen.getByText("Personal account")).toBeVisible(); + expect(screen.getByText("You pay · 0.25 credits/request")).toBeVisible(); expect(screen.getByText(/NyxID key/)).toBeVisible(); expect( within( @@ -345,7 +347,6 @@ describe("service card billing and caller details", () => { ).getByText(/Codex CI/), ).toBeVisible(); expect(screen.getByText("NyxID")).toBeVisible(); - expect(screen.getByText("Rate: 0.25 credits / request")).toBeVisible(); expect(screen.getByText(/· 1 override$/)).toBeVisible(); expect(screen.getByTitle(/^Your keys with access/)).toBeVisible(); expect(screen.queryByText("Provisioning app")).not.toBeInTheDocument(); @@ -359,15 +360,17 @@ describe("service card billing and caller details", () => { await user.click( screen.getByRole("button", { name: "Billing for Team OpenAI" }), ); + expect(screen.getByText("0.25 credits per request")).toBeVisible(); expect( - screen.getByRole("table", { name: "Applicable NyxID rates" }), + screen.getByText( + "Each request costs 0.25 NyxID credits, paid by you, from your personal credits.", + ), ).toBeVisible(); - expect(screen.getByText(/NyxID supplies the provider key/)).toBeVisible(); await user.click( screen.getByRole("button", { name: "Agent key access for Team OpenAI" }), ); expect( - screen.queryByRole("table", { name: "Applicable NyxID rates" }), + screen.queryByText("0.25 credits per request"), ).not.toBeInTheDocument(); expect(screen.getByRole("link", { name: "Codex CI" })).toHaveAttribute( "href", @@ -418,7 +421,7 @@ describe("service card billing and caller details", () => { within(panel).getByRole("combobox", { name: "Preview billing for" }), ); await user.click( - screen.getByRole("option", { name: "Codex CI · agent key" }), + screen.getByRole("option", { name: "Agent key: Codex CI" }), ); await waitFor(() => expect(request).toHaveBeenCalledWith( @@ -426,7 +429,7 @@ describe("service card billing and caller details", () => { ), ); expect( - within(panel).queryByText("Personal account"), + within(panel).queryByText("You, from your personal credits"), ).not.toBeInTheDocument(); expect(within(panel).getByText(/Loading billing/)).toBeVisible(); resolvePreview({ @@ -442,17 +445,17 @@ describe("service card billing and caller details", () => { ], }); expect( - await within(panel).findByText("ChronoAI · organization"), + await within(panel).findByText("ChronoAI, from its organization credits"), ).toBeVisible(); await user.click( within(panel).getByRole("combobox", { name: "Preview billing for" }), ); - await user.click( - screen.getByRole("option", { name: "You · connection default" }), - ); - expect(within(panel).getByText("Personal account")).toBeVisible(); + await user.click(screen.getByRole("option", { name: "You" })); + expect( + within(panel).getByText("You, from your personal credits"), + ).toBeVisible(); expect( - within(panel).queryByText("ChronoAI · organization"), + within(panel).queryByText("ChronoAI, from its organization credits"), ).not.toBeInTheDocument(); }); it("shows server compatibility failures instead of claiming free service or no usage", async () => { @@ -540,15 +543,14 @@ describe("service card billing and caller details", () => { screen.getByRole("button", { name: "Billing for Team OpenAI" }), ); expect(cell.getByText("BYOK")).toBeVisible(); - expect(cell.getByText("Personal account")).toBeVisible(); - expect(cell.getByText("Rate: 0.25 credits / request")).toBeVisible(); + expect(cell.getByText("You pay · 0.25 credits/request")).toBeVisible(); await user.click( screen.getByRole("button", { name: "Billing for Team OpenAI" }), ); expect( within( screen.getByRole("region", { name: "Billing for Team OpenAI" }), - ).getByText(/Any NyxID fees are additional/), + ).getByText(/This is a NyxID fee on top of anything/), ).toBeVisible(); }); @@ -560,7 +562,7 @@ describe("service card billing and caller details", () => { const cell = within( screen.getByRole("button", { name: "Billing for Team OpenAI" }), ); - expect(cell.getAllByText("No NyxID charge")[0]).toBeVisible(); + expect(cell.getByText("Free on NyxID")).toBeVisible(); expect(screen.getByText(/NyxID key/)).toBeVisible(); expect(cell.queryByText("BYOK")).not.toBeInTheDocument(); }); diff --git a/frontend/src/components/dashboard/service-routing-preview.test.tsx b/frontend/src/components/dashboard/service-routing-preview.test.tsx index f630fcca5..01d843418 100644 --- a/frontend/src/components/dashboard/service-routing-preview.test.tsx +++ b/frontend/src/components/dashboard/service-routing-preview.test.tsx @@ -296,7 +296,7 @@ describe("live grouped services", () => { name: "Billing for Unpriced account 0", }); expect( - within(billing).getByText("No NyxID usage charges configured"), + within(billing).getByText("Free on NyxID", { selector: "p" }), ).toBeVisible(); expect( within(billing).queryByText("Credit billing unverified"), @@ -399,11 +399,11 @@ describe("live grouped services", () => { const panel = card.getByRole("region", { name: `Billing for ${name}` }); expect( within(panel).getByText( - type === "oauth2" ? "Your OAuth app (BYOK)" : "Your API key (BYOK)", + type === "oauth2" ? /^Your own .+ app$/ : "Your own API key", ), ).toBeVisible(); expect( - within(panel).getByText("No NyxID usage charges configured"), + within(panel).getByText("Free on NyxID", { selector: "p" }), ).toBeVisible(); expect(panel).not.toHaveTextContent("0.05"); expect( @@ -493,14 +493,8 @@ describe("live grouped services", () => { const platformPanel = card.getByRole("region", { name: "Billing for Personal Twitter", }); - const platformRates = within(platformPanel).getByRole("table", { - name: "Configured NyxID rates", - }); - expect( - within(platformRates).getByRole("cell", { name: "0.05" }), - ).toBeVisible(); expect( - within(platformRates).getByRole("cell", { name: "request" }), + within(platformPanel).getByText("0.05 credits per request"), ).toBeVisible(); await user.click( card.getByRole("button", { name: "Billing for ChronoAI Twitter" }), @@ -508,8 +502,10 @@ describe("live grouped services", () => { const byoPanel = card.getByRole("region", { name: "Billing for ChronoAI Twitter", }); - expect(byoPanel).toHaveTextContent("Organization OAuth app (BYOK)"); - expect(byoPanel).toHaveTextContent("No NyxID usage charges configured"); + expect(byoPanel).toHaveTextContent("Chrono's own Twitter app"); + expect(byoPanel).toHaveTextContent( + "so NyxID doesn't charge for it. Twitter may bill Chrono directly.", + ); expect(byoPanel).not.toHaveTextContent("0.05"); expect(byoPanel).not.toHaveTextContent("0.01"); }); @@ -543,7 +539,7 @@ describe("live grouped services", () => { expect( screen.getByRole("region", { name: "Billing for App 3" }), ).toBeVisible(); - expect(screen.getByText("NyxID usage charges configured")).toBeVisible(); + expect(screen.getByText("Uses NyxID credits")).toBeVisible(); expect(screen.getByText("NyxID")).toBeVisible(); }); it("identifies supplied API keys without requiring published prices", () => { @@ -674,12 +670,9 @@ describe("live grouped services", () => { name: "Billing for Platform account", }), ).toBeVisible(); - expect( - within(card).getByText(/A priority pool slug can fail over/), - ).toBeVisible(); expect( within(card).getByText( - /Eligible allowances → Credit grants → Wallet credits/, + /free allowances first, then credit grants, then your wallet balance/, ), ).toBeVisible(); expect( @@ -1731,7 +1724,7 @@ describe("saved routing in service cards", () => { await userEvent.click( screen.getByRole("button", { name: "Show billing for OpenAI" }), ); - expect(screen.getByText("Billing flow")).toBeVisible(); + expect(screen.getByText("Billing", { selector: "h4" })).toBeVisible(); expect( screen.queryByRole("table", { name: "Reliable route members" }), ).not.toBeInTheDocument(); diff --git a/frontend/src/lib/billing-plain.test.ts b/frontend/src/lib/billing-plain.test.ts new file mode 100644 index 000000000..4052b64b2 --- /dev/null +++ b/frontend/src/lib/billing-plain.test.ts @@ -0,0 +1,146 @@ +import { describe, expect, it } from "vitest"; +import type { ServiceBillingExplanation } from "@/schemas/service-insights"; +import type { KeyInfo } from "@/types/keys"; +import { plainBilling } from "./billing-plain"; + +const twitter = { + id: "t", + label: "Twitter / X API", + slug: "api-twitter", + catalog_service_name: "Twitter / X API", + credential_type: "oauth2", + credential_source: { type: "personal" }, +} as KeyInfo; +const deepseek = { + id: "d", + label: "DeepSeek API", + slug: "llm-deepseek", + catalog_service_name: "DeepSeek API", + credential_type: "api_key", + credential_source: { type: "personal" }, +} as KeyInfo; +const bill = ( + overrides: Partial, +): ServiceBillingExplanation => ({ + status: "conditional", + credential_class: null, + credential_label: "", + account: null, + charge_status: "conditional", + rates: [], + provider_billing: "unknown", + context: "configuration", + notes: [], + ...overrides, +}); +const rate = (credits_per_unit: string, metric = "requests") => ({ + layer: "platform", + metric, + credits_per_unit, + currency: "credits", + source: "configuration", + sync_status: "synced", +}); + +describe("plainBilling", () => { + it("says NyxID-app OAuth with no price is free, naming whose app it is", () => { + const plain = plainBilling( + twitter, + bill({ + credential_class: "nyxid_platform_oauth_app", + credential_supplier: "nyxid", + credit_billing_configured: false, + charge_status: "not_charged", + }), + ); + expect(plain).toMatchObject({ + verdict: "free", + headline: "Free on NyxID", + detail: + "This connection uses NyxID's Twitter / X app, and NyxID doesn't charge for it right now.", + key: { title: "NyxID's Twitter / X app" }, + short: "Free on NyxID", + }); + }); + + it("explains an organization's own app is free on NyxID but billed by the provider", () => { + const plain = plainBilling( + { + ...twitter, + credential_source: { + type: "org", + org_id: "o", + org_name: "ChronoAI", + }, + } as KeyInfo, + bill({ + credential_class: "user_owned", + credential_supplier: "own", + credit_billing_configured: false, + }), + ); + expect(plain.key.title).toBe("ChronoAI's own Twitter / X app"); + expect(plain.detail).toBe( + "This connection uses ChronoAI's own app, so NyxID doesn't charge for it. Twitter / X may bill ChronoAI directly.", + ); + }); + + it("states the price and payer for a charged NyxID key in one sentence", () => { + const plain = plainBilling( + deepseek, + bill({ + credential_class: "nyxid_managed_master", + credential_supplier: "nyxid", + credit_billing_configured: true, + rates: [rate("0.000001", "tokens")], + }), + ); + expect(plain).toMatchObject({ + verdict: "charged", + headline: "Uses NyxID credits", + detail: + "Each token costs 0.000001 NyxID credits, paid by whoever makes the call, from their personal credits.", + price: "0.000001 credits per token", + short: "Caller pays · 0.000001 credits/token", + }); + expect(plain.tips[0]).toMatch(/free allowances first/); + }); + + it("adds the provider caveat when your own key carries a NyxID fee", () => { + const plain = plainBilling( + deepseek, + bill({ + credential_class: "user_owned", + credential_supplier: "own", + credit_billing_configured: true, + rates: [rate("0.01")], + }), + ); + expect(plain.detail).toBe( + "Each request costs 0.01 NyxID credits, paid by you, from your personal credits. This is a NyxID fee on top of anything DeepSeek bills you directly.", + ); + expect(plain.short).toBe("You pay · 0.01 credits/request"); + }); + + it("does not guess when a paid service's app is unknown", () => { + const plain = plainBilling( + twitter, + bill({ credential_supplier: "unknown", credit_billing_configured: true }), + ); + expect(plain).toMatchObject({ + verdict: "unconfirmed", + headline: "Cost not confirmed", + key: { title: "Not confirmed" }, + }); + }); + + it("keeps restricted billing private", () => { + expect(plainBilling(twitter, bill({ status: "restricted" }))).toMatchObject( + { + verdict: "hidden", + headline: "You can't see billing for this connection", + short: "Billing unavailable", + }, + ); + }); +}); diff --git a/frontend/src/lib/billing-plain.ts b/frontend/src/lib/billing-plain.ts new file mode 100644 index 000000000..8a771208e --- /dev/null +++ b/frontend/src/lib/billing-plain.ts @@ -0,0 +1,217 @@ +import { metricLabel } from "@/schemas/billing-metrics"; +import type { ServiceBillingExplanation } from "@/schemas/service-insights"; +import type { KeyInfo } from "@/types/keys"; + +export type BillingVerdict = "charged" | "free" | "unconfirmed" | "hidden"; + +/** Billing in words a first-time user can act on: cost, whose key, who pays. */ +export interface PlainBilling { + readonly verdict: BillingVerdict; + readonly headline: string; + readonly detail: string; + readonly key: { readonly title: string; readonly note?: string }; + readonly payer: string; + readonly price: string; + /** Short form for table rows, e.g. "You pay · 0.05 credits/request". */ + readonly short: string; + readonly tips: readonly string[]; +} + +const OAUTH = ["oauth2", "device_code"]; + +function providerName(connection: KeyInfo): string { + const name = + connection.catalog_service_name ?? connection.name ?? connection.label; + return name.replace(/\s+API$/i, ""); +} + +function supplierOf( + bill: ServiceBillingExplanation, +): "nyxid" | "own" | "none" | "unknown" { + if (bill.credential_supplier) return bill.credential_supplier; + switch (bill.credential_class) { + case "nyxid_managed_master": + case "nyxid_platform_oauth_app": + return "nyxid"; + case "user_owned": + case "agent_override_user_owned": + case "node_managed": + return "own"; + case "no_auth": + return "none"; + default: + return "unknown"; + } +} + +export function plainBilling( + connection: KeyInfo, + bill: ServiceBillingExplanation, +): PlainBilling { + const provider = providerName(connection); + const org = + connection.credential_source?.type === "org" + ? connection.credential_source.org_name + : null; + const supplier = supplierOf(bill); + const oauth = OAUTH.includes(connection.credential_type); + const master = bill.credential_class === "nyxid_managed_master"; + const thing = oauth ? "app" : "key"; + const owner = org ? `${org}'s` : "Your"; + const you = org ?? "you"; + + const key = + supplier === "nyxid" + ? master + ? { title: `NyxID's ${provider} key`, note: "NyxID provides the key." } + : { + title: `NyxID's ${provider} app`, + note: `You sign in with your own ${provider} account; NyxID provides the app.`, + } + : supplier === "own" + ? { + title: + connection.credential_type === "node_managed" || + bill.credential_class === "node_managed" + ? "A key stored on your node" + : oauth + ? `${owner} own ${provider} app` + : `${owner} own API key`, + } + : supplier === "none" + ? { title: "No key needed" } + : { + title: "Not confirmed", + note: oauth + ? `We can't tell yet whether this sign-in uses your own ${provider} app or NyxID's.` + : "We can't tell who supplied this key.", + }; + + if (bill.status === "restricted" || bill.status === "unavailable") { + const restricted = bill.status === "restricted"; + return { + verdict: "hidden", + headline: restricted + ? "You can't see billing for this connection" + : "Billing couldn't load", + detail: restricted + ? "Ask the owner of this connection what it costs." + : "Try again later.", + key, + payer: "Not available", + price: "Not available", + short: "Billing unavailable", + tips: [], + }; + } + + const free = + bill.credit_billing_configured === false || + bill.charge_status === "not_charged"; + const rates = bill.rates; + const unit = (metric: string) => metricLabel(metric, 1); + const price = free + ? "Free on NyxID" + : rates.length + ? rates + .map((rate) => + rate.credits_per_unit == null + ? `Plan price per ${unit(rate.metric)}` + : `${rate.credits_per_unit} credits per ${unit(rate.metric)}`, + ) + .join(" + ") + : "Not confirmed"; + const shortPrice = + rates.length === 1 && rates[0]!.credits_per_unit != null + ? `${rates[0]!.credits_per_unit} credits/${unit(rates[0]!.metric)}` + : "NyxID credits"; + + if (free) { + const detail = + supplier === "nyxid" + ? `This connection uses NyxID's ${provider} ${thing}, and NyxID doesn't charge for it right now.` + : supplier === "own" + ? `This connection uses ${org ? owner : "your"} own ${thing}, so NyxID doesn't charge for it. ${provider} may bill ${you} directly.` + : supplier === "none" + ? "This service doesn't need a key, and NyxID doesn't charge for it." + : "NyxID doesn't charge for this connection."; + return { + verdict: "free", + headline: "Free on NyxID", + detail, + key, + payer: "No one. NyxID doesn't charge for this.", + price, + short: "Free on NyxID", + tips: [], + }; + } + + if (supplier === "unknown") { + return { + verdict: "unconfirmed", + headline: "Cost not confirmed", + detail: `NyxID charges for ${provider} when you use NyxID's ${thing}, but not when you use your own. We can't tell yet which one this connection uses.`, + key, + payer: "Depends on whose app is used", + price, + short: "Cost not confirmed", + tips: [], + }; + } + + const payer = bill.account + ? bill.account.kind === "personal" + ? "You, from your personal credits" + : `${bill.account.name}, from its organization credits` + : master + ? "Whoever makes the call, from their personal credits" + : org + ? `${org}, from its organization credits` + : "You, from your personal credits"; + const payerShort = bill.account + ? bill.account.kind === "personal" + ? "You pay" + : `${bill.account.name} pays` + : master + ? "Caller pays" + : org + ? `${org} pays` + : "You pay"; + const each = + rates.length === 1 && rates[0]!.credits_per_unit != null + ? `Each ${unit(rates[0]!.metric)} costs ${rates[0]!.credits_per_unit} NyxID credits` + : "Usage costs NyxID credits"; + const payerPhrase = + payer.startsWith("You") || payer.startsWith("Whoever") + ? payer.charAt(0).toLowerCase() + payer.slice(1) + : payer; + const tips = [ + "Credits are used in this order: free allowances first, then credit grants, then your wallet balance.", + ...(rates.some((rate) => rate.sync_status && rate.sync_status !== "synced") + ? [ + "A new price is waiting to be activated. Until then, the previous price applies.", + ] + : []), + ...(rates.some((rate) => rate.credits_per_unit == null) + ? ["The price comes from your billing plan and isn't shown here."] + : []), + ...(bill.context === "configuration" + ? ["Agent keys set to use a different key can be billed differently."] + : []), + ]; + return { + verdict: "charged", + headline: "Uses NyxID credits", + detail: `${each}, paid by ${payerPhrase}.${ + supplier === "own" + ? ` This is a NyxID fee on top of anything ${provider} bills ${you} directly.` + : "" + }`, + key, + payer, + price, + short: `${payerShort} · ${shortPrice}`, + tips, + }; +} From 4b70352fa8c1293e1a011b738b917d3103a85fb0 Mon Sep 17 00:00:00 2001 From: ctkm-aelf <230958228+ctkm-aelf@users.noreply.github.com> Date: Tue, 6 Oct 2026 15:34:32 +0800 Subject: [PATCH 31/36] fix: don't call priced services free; own keys are always BYOK - A NyxID-app connection on a service that only prices the platform key now reads "No NyxID price set for this connection" and names the price that does exist, instead of "Free on NyxID". - When credits were charged on the connection's slug in the last 7 days, the billing panel says so at the top instead of claiming it is free. - A confirmed own key or app shows BYOK even when NyxID does not price the service (e.g. Anthropic with your own API key). Co-Authored-By: Claude Opus 5.5 --- .../dashboard/service-insight-panels.tsx | 26 +++++++++++++------ .../service-routing-preview.test.tsx | 4 ++- frontend/src/lib/billing-plain.ts | 16 ++++++++++++ frontend/src/lib/service-card-summary.test.ts | 23 +++++++++++----- frontend/src/lib/service-card-summary.ts | 2 ++ 5 files changed, 56 insertions(+), 15 deletions(-) diff --git a/frontend/src/components/dashboard/service-insight-panels.tsx b/frontend/src/components/dashboard/service-insight-panels.tsx index 9f5b7b8e1..eb835fa82 100644 --- a/frontend/src/components/dashboard/service-insight-panels.tsx +++ b/frontend/src/components/dashboard/service-insight-panels.tsx @@ -1,11 +1,6 @@ import { useState } from "react"; import { Link } from "@tanstack/react-router"; -import { - ArrowUpRight, - Bot, - CreditCard, - UsersRound, -} from "lucide-react"; +import { ArrowUpRight, Bot, CreditCard, UsersRound } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; import { @@ -343,6 +338,15 @@ function ConnectionBillingPanel({ ? insight.billing : selectedState.connections.get(connection.id)?.billing; const plain = bill ? plainBilling(connection, bill) : null; + // Settings say what should be charged; recorded usage says what was. + const recent = useBillingUsage("7d"); + const recentCharged = recent.data + ? serviceUsageSummary(recent.data.rows, connection.slug)?.charged + : null; + const chargedAnyway = + plain?.verdict === "free" && + recentCharged != null && + hasCredits(recentCharged); return (
-

{plain.headline}

-

{plain.detail}

+

+ {chargedAnyway ? "Credits were charged recently" : plain.headline} +

+

+ {chargedAnyway + ? `${formatExactCredits(recentCharged!)} NyxID credits were charged in the last 7 days for calls through ${connection.slug}, so don't treat this connection as free. ${plain.detail}` + : plain.detail} +

diff --git a/frontend/src/components/dashboard/service-routing-preview.test.tsx b/frontend/src/components/dashboard/service-routing-preview.test.tsx index 01d843418..5a1f08c77 100644 --- a/frontend/src/components/dashboard/service-routing-preview.test.tsx +++ b/frontend/src/components/dashboard/service-routing-preview.test.tsx @@ -239,6 +239,8 @@ describe("live grouped services", () => { catalog_service_id: "chrono-llm", catalog_service_slug: "chrono-llm", catalog_service_name: "Chrono LLM", + api_key_id: null, + auth_method: "none", }; records.push(connection); internalCatalog.push({ @@ -312,7 +314,7 @@ describe("live grouped services", () => { }); it.each([ - { type: "api_key", auth: "bearer", label: "—" }, + { type: "api_key", auth: "bearer", label: "BYOK" }, { type: "api_key", auth: "none", label: "—" }, { type: "oauth2", auth: "bearer", label: "—" }, ])( diff --git a/frontend/src/lib/billing-plain.ts b/frontend/src/lib/billing-plain.ts index 8a771208e..04434fb10 100644 --- a/frontend/src/lib/billing-plain.ts +++ b/frontend/src/lib/billing-plain.ts @@ -126,6 +126,22 @@ export function plainBilling( ? `${rates[0]!.credits_per_unit} credits/${unit(rates[0]!.metric)}` : "NyxID credits"; + // The service has a NyxID price, but none applies to this credential. That + // is a pricing gap, not a free service, so say exactly that. + const otherPrice = connection.platform_key_pricing; + if (free && supplier === "nyxid" && !master && otherPrice) { + return { + verdict: "free", + headline: "No NyxID price set for this connection", + detail: `${provider} has a NyxID price of ${otherPrice.credits_per_unit} credits per ${unit(otherPrice.metric)}, but it only applies to calls made with NyxID's ${provider} key. No price is set for sign-ins through NyxID's ${provider} app, so these calls aren't charged under the current settings.`, + key, + payer: "No one under the current settings", + price: "No price set for this connection", + short: "No price set", + tips: [], + }; + } + if (free) { const detail = supplier === "nyxid" diff --git a/frontend/src/lib/service-card-summary.test.ts b/frontend/src/lib/service-card-summary.test.ts index 5753a782b..bb626d9e3 100644 --- a/frontend/src/lib/service-card-summary.test.ts +++ b/frontend/src/lib/service-card-summary.test.ts @@ -48,12 +48,20 @@ const healthy = { describe("billing gate before credential supply — reviewed acceptance cases", () => { it.each([ connection, + { ...connection, node_id: "node", credential_type: "node_managed" }, + { ...connection, is_active: false }, + ])("shows BYOK for your own key even on an unpriced service: %j", (row) => { + const catalog = { slug: "llm-anthropic", billing: null }; + expect( + connectionBillingCategory(row, configuredBilling(row, catalog), catalog), + ).toBe("byok"); + }); + + it.each([ { ...connection, credential_binding: "platform" as const }, oauth, { ...oauth, oauth_app_source: "platform" as const }, - { ...connection, node_id: "node", credential_type: "node_managed" }, - { ...connection, is_active: false }, - ])("shows a dash for every credential on an unpriced service: %j", (row) => { + ])("shows a dash for other credentials on an unpriced service: %j", (row) => { const catalog = { slug: "llm-anthropic", billing: null }; expect( connectionBillingCategory(row, configuredBilling(row, catalog), catalog), @@ -161,12 +169,15 @@ describe("billing gate before credential supply — reviewed acceptance cases", ...bill, service_billing_configured: false, }), - ).toBe("not_billable"); + ).toBe("byok"); }); it("does not label missing catalog data or restricted rows as free", () => { expect( - connectionBillingCategory(connection, configuredBilling(connection)), + connectionBillingCategory( + { ...connection, api_key_id: null }, + configuredBilling({ ...connection, api_key_id: null }), + ), ).toBe("unknown"); expect( connectionBillingCategory(connection, { @@ -181,7 +192,7 @@ describe("billing gate before credential supply — reviewed acceptance cases", node_id: "node", }; expect(connectionBillingCategory(custom, configuredBilling(custom))).toBe( - "not_billable", + "byok", ); }); diff --git a/frontend/src/lib/service-card-summary.ts b/frontend/src/lib/service-card-summary.ts index 8222a5a2d..74f437953 100644 --- a/frontend/src/lib/service-card-summary.ts +++ b/frontend/src/lib/service-card-summary.ts @@ -32,6 +32,8 @@ export function connectionBillingCategory( catalog?: ConfiguredCatalogEntry, ): ConnectionBillingCategory { if (billing?.status === "restricted") return "unknown"; + // A confirmed own key or app is BYOK whether or not NyxID prices the service. + if (credentialSupplier(connection, billing) === "own") return "byok"; const serviceConfigured = billing?.service_billing_configured ?? serviceBillingConfigured(connection, catalog) ?? From db9a08224d5c09204fc2ba5a08645c0fe49c2bc0 Mon Sep 17 00:00:00 2001 From: ctkm-aelf <230958228+ctkm-aelf@users.noreply.github.com> Date: Tue, 6 Oct 2026 16:24:43 +0800 Subject: [PATCH 32/36] fix: show the service's NyxID rate for NyxID-supplied connections Connections using NyxID's key or app now read like DeepSeek: "Uses NyxID credits" with the service's NyxID rate (e.g. 0.05 credits per request for Twitter / X), instead of explaining which price lane applies. Co-Authored-By: Claude Opus 5.5 --- frontend/src/lib/billing-plain.test.ts | 27 +++++++++++++++++ frontend/src/lib/billing-plain.ts | 40 ++++++++++++++------------ 2 files changed, 48 insertions(+), 19 deletions(-) diff --git a/frontend/src/lib/billing-plain.test.ts b/frontend/src/lib/billing-plain.test.ts index 4052b64b2..a4c68bb89 100644 --- a/frontend/src/lib/billing-plain.test.ts +++ b/frontend/src/lib/billing-plain.test.ts @@ -63,6 +63,33 @@ describe("plainBilling", () => { }); }); + it("shows the service's NyxID rate for a NyxID-app connection, like any NyxID service", () => { + const plain = plainBilling( + { + ...twitter, + platform_key_pricing: { + metric: "requests", + credits_per_unit: "0.05", + sync_status: "synced", + }, + } as KeyInfo, + bill({ + credential_class: "nyxid_platform_oauth_app", + credential_supplier: "nyxid", + credit_billing_configured: false, + charge_status: "not_charged", + }), + ); + expect(plain).toMatchObject({ + verdict: "charged", + headline: "Uses NyxID credits", + detail: + "Each request costs 0.05 NyxID credits, paid by you, from your personal credits.", + price: "0.05 credits per request", + short: "You pay · 0.05 credits/request", + }); + }); + it("explains an organization's own app is free on NyxID but billed by the provider", () => { const plain = plainBilling( { diff --git a/frontend/src/lib/billing-plain.ts b/frontend/src/lib/billing-plain.ts index 04434fb10..079299dd3 100644 --- a/frontend/src/lib/billing-plain.ts +++ b/frontend/src/lib/billing-plain.ts @@ -105,10 +105,28 @@ export function plainBilling( }; } + // A connection using NyxID's key or app is billed at the service's NyxID + // rate, shown the same way for every NyxID-supplied service. + const servicePrice = + supplier === "nyxid" && !bill.rates.length + ? connection.platform_key_pricing + : null; const free = - bill.credit_billing_configured === false || - bill.charge_status === "not_charged"; - const rates = bill.rates; + !servicePrice && + (bill.credit_billing_configured === false || + bill.charge_status === "not_charged"); + const rates = servicePrice + ? [ + { + layer: "platform", + metric: servicePrice.metric, + credits_per_unit: servicePrice.credits_per_unit, + currency: "credits", + source: "configuration", + sync_status: servicePrice.sync_status, + }, + ] + : bill.rates; const unit = (metric: string) => metricLabel(metric, 1); const price = free ? "Free on NyxID" @@ -126,22 +144,6 @@ export function plainBilling( ? `${rates[0]!.credits_per_unit} credits/${unit(rates[0]!.metric)}` : "NyxID credits"; - // The service has a NyxID price, but none applies to this credential. That - // is a pricing gap, not a free service, so say exactly that. - const otherPrice = connection.platform_key_pricing; - if (free && supplier === "nyxid" && !master && otherPrice) { - return { - verdict: "free", - headline: "No NyxID price set for this connection", - detail: `${provider} has a NyxID price of ${otherPrice.credits_per_unit} credits per ${unit(otherPrice.metric)}, but it only applies to calls made with NyxID's ${provider} key. No price is set for sign-ins through NyxID's ${provider} app, so these calls aren't charged under the current settings.`, - key, - payer: "No one under the current settings", - price: "No price set for this connection", - short: "No price set", - tips: [], - }; - } - if (free) { const detail = supplier === "nyxid" From ca5a3710317df6cac99e2f798ce9ef28afa8ff81 Mon Sep 17 00:00:00 2001 From: ctkm-aelf <230958228+ctkm-aelf@users.noreply.github.com> Date: Tue, 6 Oct 2026 16:29:26 +0800 Subject: [PATCH 33/36] fix: group the card billing tooltip and cap its height Identical billing entries collapse into one line with a count, up to three connection names and how many are disabled. The tooltip has a minimum width, a 90vh maximum height and scrolls beyond that. NyxID rates also come from the catalog entry when the connection row lacks them, so the row, tooltip and panel agree. Co-Authored-By: Claude Opus 5.5 --- .../dashboard/service-billing-summary.tsx | 88 +++++++++++++------ .../dashboard/service-connection-table.tsx | 7 +- .../dashboard/service-insight-panels.tsx | 12 ++- .../service-routing-preview.test.tsx | 18 ++-- frontend/src/lib/billing-plain.ts | 9 +- 5 files changed, 93 insertions(+), 41 deletions(-) diff --git a/frontend/src/components/dashboard/service-billing-summary.tsx b/frontend/src/components/dashboard/service-billing-summary.tsx index bb446c0c3..44ae6a835 100644 --- a/frontend/src/components/dashboard/service-billing-summary.tsx +++ b/frontend/src/components/dashboard/service-billing-summary.tsx @@ -5,7 +5,8 @@ import { connectionBillingLabels, type ConnectionBillingCategory, } from "@/lib/service-card-summary"; -import { insightStatusLabel, nyxidChargeLabel } from "@/lib/service-insights"; +import { insightStatusLabel } from "@/lib/service-insights"; +import { plainBilling } from "@/lib/billing-plain"; import type { CatalogEntry, KeyInfo } from "@/types/keys"; import { Tooltip, @@ -14,6 +15,20 @@ import { TooltipTrigger, } from "@/components/ui/tooltip"; +/** Up to three names, then a count; disabled connections are counted apart. */ +function groupNames(connections: readonly KeyInfo[]): string { + const active = connections.filter((connection) => connection.is_active); + const disabled = connections.length - active.length; + const shown = active.slice(0, 3).map((connection) => connection.label); + const more = active.length - shown.length; + return [ + shown.join(", ") + (more > 0 ? ` and ${more} more` : ""), + disabled ? `${disabled} disabled` : "", + ] + .filter(Boolean) + .join(" · "); +} + export function ServiceBillingSummary({ connections, insights, @@ -52,6 +67,37 @@ export function ServiceBillingSummary({ unknown: "unverified", }; const notBillable = rows.every((row) => row.category === "not_billable"); + // Identical entries collapse into one line; a list of 30 equal rows says + // nothing more than "30 connections". + const groups = [ + ...rows + .reduce((all, row) => { + const detail = + row.category === "not_billable" + ? "Not billable by NyxID" + : row.category === "unknown" + ? row.billing?.service_billing_configured === true + ? "Whose key or app is used isn't confirmed" + : "Billing details unavailable" + : row.billing + ? plainBilling(row.connection, row.billing, catalog).short + : "Billing details unavailable"; + const key = `${row.category}|${detail}`; + const group = all.get(key) ?? { + key, + category: row.category, + detail, + rows: [] as typeof rows, + }; + group.rows.push(row); + return all.set(key, group); + }, new Map()) + .values(), + ].sort( + (a, b) => + categories.indexOf(a.category) - categories.indexOf(b.category) || + b.rows.length - a.rows.length, + ); const label = insights.status !== "ready" ? insightStatusLabel(insights.status, "Billing") @@ -94,42 +140,30 @@ export function ServiceBillingSummary({ align={notBillable ? "center" : "start"} sideOffset={8} collisionPadding={12} - className="max-w-[min(22rem,calc(100vw-2rem))] space-y-1 break-words [overflow-wrap:anywhere]" + className="max-h-[90vh] min-w-56 max-w-[min(22rem,calc(100vw-2rem))] space-y-2 overflow-y-auto break-words [overflow-wrap:anywhere]" > {notBillable ? (

Not billable by NyxID

) : ( <>

Connection billing

- {rows.map(({ connection, category, billing }) => ( -
+ {groups.map((group) => ( +

- {connection.label}:{" "} - {category === "not_billable" - ? "Not billable by NyxID" - : connectionBillingLabels[category]} - {!connection.is_active ? " · disabled" : ""} + {connectionBillingLabels[group.category]} ·{" "} + {group.rows.length}{" "} + {group.rows.length === 1 ? "connection" : "connections"} +

+

{group.detail}

+

+ {groupNames(group.rows.map((row) => row.connection))}

- {category === "unknown" && ( -

- {billing?.service_billing_configured === true - ? "Credential supplier unverified" - : "Billing configuration unavailable"} -

- )} - {billing && category !== "not_billable" && ( -

- {nyxidChargeLabel(billing)} -

- )}
))} -

- NyxID means platform billing is configured. BYOK means the - selected key or developer app was supplied by you or your - organization. Free credits and grants do not change these - labels. A dash means not billable by NyxID; the provider may - charge separately. +

+ NyxID: uses NyxID's key or app and costs NyxID credits. + BYOK: uses your or your organization's own key or app. —: + NyxID doesn't charge for it.

)} diff --git a/frontend/src/components/dashboard/service-connection-table.tsx b/frontend/src/components/dashboard/service-connection-table.tsx index d7338e9ce..f9d4d4bd0 100644 --- a/frontend/src/components/dashboard/service-connection-table.tsx +++ b/frontend/src/components/dashboard/service-connection-table.tsx @@ -506,7 +506,7 @@ export function ServiceConnectionTable({ (billingCategory === "platform" || billingCategory === "byok") && ( - {plainBilling(key, billing).short} + {plainBilling(key, billing, catalog).short} )} @@ -532,7 +532,7 @@ export function ServiceConnectionTable({ <>

{billing - ? plainBilling(key, billing).headline + ? plainBilling(key, billing, catalog).headline : insightStatusLabel( insights.status, "Billing", @@ -540,7 +540,7 @@ export function ServiceConnectionTable({

{billing && (

- {plainBilling(key, billing).detail} + {plainBilling(key, billing, catalog).detail}

)}

@@ -639,6 +639,7 @@ export function ServiceConnectionTable({ insight={insight} view={open.view} state={insights} + catalog={catalog} /> )} diff --git a/frontend/src/components/dashboard/service-insight-panels.tsx b/frontend/src/components/dashboard/service-insight-panels.tsx index eb835fa82..1fa2dfa5c 100644 --- a/frontend/src/components/dashboard/service-insight-panels.tsx +++ b/frontend/src/components/dashboard/service-insight-panels.tsx @@ -26,7 +26,10 @@ import { } from "@/lib/service-usage"; import { formatExactCredits, hasCredits } from "@/lib/credits"; import { plainBilling } from "@/lib/billing-plain"; -import type { ServiceInsight } from "@/schemas/service-insights"; +import type { + ConfiguredCatalogEntry, + ServiceInsight, +} from "@/schemas/service-insights"; import { useServiceInsights, type ServiceInsightsState, @@ -322,10 +325,12 @@ function ConnectionBillingPanel({ connection, insight, state, + catalog, }: { readonly connection: KeyInfo; readonly insight: ServiceInsight; readonly state: ServiceInsightsState; + readonly catalog?: ConfiguredCatalogEntry; }) { const [caller, setCaller] = useState("you"); const selectedState = useServiceInsights( @@ -337,7 +342,7 @@ function ConnectionBillingPanel({ caller === "you" ? insight.billing : selectedState.connections.get(connection.id)?.billing; - const plain = bill ? plainBilling(connection, bill) : null; + const plain = bill ? plainBilling(connection, bill, catalog) : null; // Settings say what should be charged; recorded usage says what was. const recent = useBillingUsage("7d"); const recentCharged = recent.data @@ -434,11 +439,13 @@ export function ConnectionInsightPanel({ insight, view, state, + catalog, }: { readonly connection: KeyInfo; readonly insight?: ServiceInsight; readonly view: InsightPanel; readonly state: ServiceInsightsState; + readonly catalog?: ConfiguredCatalogEntry; }) { const [showAllKeys, setShowAllKeys] = useState(false); if (!insight || (view === "billing" ? !insight.billing : !insight.usage)) @@ -450,6 +457,7 @@ export function ConnectionInsightPanel({ connection={connection} insight={insight} state={state} + catalog={catalog} /> ); if (!usage) return ; diff --git a/frontend/src/components/dashboard/service-routing-preview.test.tsx b/frontend/src/components/dashboard/service-routing-preview.test.tsx index 5a1f08c77..e71608f7e 100644 --- a/frontend/src/components/dashboard/service-routing-preview.test.tsx +++ b/frontend/src/components/dashboard/service-routing-preview.test.tsx @@ -395,7 +395,8 @@ describe("live grouped services", () => { expect(summary).toHaveTextContent(/^BYOK$/); await user.hover(summary); const tooltip = await screen.findByRole("tooltip"); - expect(tooltip).toHaveTextContent(`${name}: BYOK`); + expect(tooltip).toHaveTextContent("BYOK · 1 connection"); + expect(tooltip).toHaveTextContent(name); expect(tooltip).not.toHaveTextContent("0.05"); await user.click(summary); const panel = card.getByRole("region", { name: `Billing for ${name}` }); @@ -484,11 +485,13 @@ describe("live grouped services", () => { await user.hover(summary); const tooltip = await screen.findByRole("tooltip"); for (const text of [ - "ChronoAI Twitter: BYOK", - "Personal Twitter: NyxID", - "Public Twitter: NyxID", - "Legacy Twitter: Unverified", - "0.05 credits / request", + "BYOK · 1 connection", + "ChronoAI Twitter", + "Personal Twitter", + "Public Twitter", + "Unverified · 1 connection", + "Legacy Twitter", + "0.05 credits/request", ]) expect(tooltip).toHaveTextContent(text); await user.click(summary); @@ -739,7 +742,8 @@ describe("live grouped services", () => { ); const tooltip = within(await screen.findByRole("tooltip")); expect(tooltip.getByText("Connection billing")).toBeInTheDocument(); - expect(tooltip.getByText("Personal account: BYOK")).toBeInTheDocument(); + expect(tooltip.getAllByText("BYOK · 1 connection")[0]).toBeInTheDocument(); + expect(tooltip.getByText("Personal account")).toBeInTheDocument(); expect(screen.queryByText("Latest request")).not.toBeInTheDocument(); expect(screen.queryByText(/No recorded requests/)).not.toBeInTheDocument(); }); diff --git a/frontend/src/lib/billing-plain.ts b/frontend/src/lib/billing-plain.ts index 079299dd3..9849ee7d7 100644 --- a/frontend/src/lib/billing-plain.ts +++ b/frontend/src/lib/billing-plain.ts @@ -1,5 +1,9 @@ import { metricLabel } from "@/schemas/billing-metrics"; -import type { ServiceBillingExplanation } from "@/schemas/service-insights"; +import type { + ConfiguredCatalogEntry, + ServiceBillingExplanation, +} from "@/schemas/service-insights"; +import { configuredPlatformPrice } from "./service-billing-config"; import type { KeyInfo } from "@/types/keys"; export type BillingVerdict = "charged" | "free" | "unconfirmed" | "hidden"; @@ -47,6 +51,7 @@ function supplierOf( export function plainBilling( connection: KeyInfo, bill: ServiceBillingExplanation, + catalog?: ConfiguredCatalogEntry, ): PlainBilling { const provider = providerName(connection); const org = @@ -109,7 +114,7 @@ export function plainBilling( // rate, shown the same way for every NyxID-supplied service. const servicePrice = supplier === "nyxid" && !bill.rates.length - ? connection.platform_key_pricing + ? configuredPlatformPrice(connection, catalog) : null; const free = !servicePrice && From 1563e45162bb41659d21bcdbe4f3eb33d832d589 Mon Sep 17 00:00:00 2001 From: ctkm-aelf <230958228+ctkm-aelf@users.noreply.github.com> Date: Tue, 6 Oct 2026 17:00:22 +0800 Subject: [PATCH 34/36] fix: show auto-connected-only services in Personal view when enabled Personal view kept only services with a personal connection, and auto-connected rows count as NyxID platform, so a service whose only connection was auto-connected never appeared even with the toggle on. Shown auto-connected rows now qualify a service for Personal view. Sample mode gains an auto-connected Tavily row to exercise the toggle. Co-Authored-By: Claude Opus 5.5 --- .../service-routing-preview.test.tsx | 8 +++++-- frontend/src/lib/mock-data.ts | 14 ++++++++++++ frontend/src/lib/service-view.test.ts | 22 +++++++++++++++---- frontend/src/lib/service-view.ts | 8 ++++++- 4 files changed, 45 insertions(+), 7 deletions(-) diff --git a/frontend/src/components/dashboard/service-routing-preview.test.tsx b/frontend/src/components/dashboard/service-routing-preview.test.tsx index e71608f7e..184e97c2f 100644 --- a/frontend/src/components/dashboard/service-routing-preview.test.tsx +++ b/frontend/src/components/dashboard/service-routing-preview.test.tsx @@ -629,12 +629,16 @@ describe("live grouped services", () => { } const user = userEvent.setup(); render(preview()); + expect( + screen.queryByRole("region", { name: "Platform-only service" }), + ).not.toBeInTheDocument(); fireEvent.click( screen.getByRole("button", { name: "Auto-connected services: hidden" }), ); + // Shown auto-connected services belong to your account, so they list too. expect( - screen.queryByRole("region", { name: "Platform-only service" }), - ).not.toBeInTheDocument(); + screen.getByRole("region", { name: "Platform-only service" }), + ).toBeInTheDocument(); const card = screen.getByRole("region", { name: "OpenAI" }); expect(within(card).getByText("3 connections")).toBeVisible(); expect(within(card).getByText("1 NyxID · 2 BYOK")).toBeVisible(); diff --git a/frontend/src/lib/mock-data.ts b/frontend/src/lib/mock-data.ts index d60f2d5da..2a00efbb7 100644 --- a/frontend/src/lib/mock-data.ts +++ b/frontend/src/lib/mock-data.ts @@ -472,6 +472,20 @@ const MOCK_KEYS = [ }, }, ]; +// Provisioned automatically by NyxID; hidden until "Auto-connected" is shown. +MOCK_KEYS.push({ + ...MOCK_KEYS[0]!, + id: "key-auto-0001", + label: "Tavily Search", + slug: "tavily-search", + endpoint_url: "https://api.tavily.com", + endpoint_id: "ep-auto-0001", + catalog_service_id: "cs-tavily", + catalog_service_slug: "tavily-search", + catalog_service_name: "Tavily Search", + auto_connected: true, + last_used_at: null, +} as (typeof MOCK_KEYS)[number]); // ── External API Keys (credentials) ── const MOCK_EXTERNAL_API_KEYS = [ diff --git a/frontend/src/lib/service-view.test.ts b/frontend/src/lib/service-view.test.ts index b9ed0bd88..0602c959c 100644 --- a/frontend/src/lib/service-view.test.ts +++ b/frontend/src/lib/service-view.test.ts @@ -52,6 +52,16 @@ const group: ServiceConnectionGroup = { }; describe("service view matching", () => { + it("lists a service whose only connection is auto-connected once shown, in Personal view", () => { + const autoOnly = { + ...group, + connections: group.connections.filter((key) => key.auto_connected), + }; + expect(matchingConnections(autoOnly, DEFAULT_SERVICE_FILTERS)).toEqual([]); + expect(matchingConnections(autoOnly, SHOWN).map((key) => key.id)).toEqual([ + "platform", + ]); + }); it("hides auto-connected connections by default", () => { expect( matchingConnections(group, DEFAULT_SERVICE_FILTERS).map((key) => key.id), @@ -63,12 +73,16 @@ describe("service view matching", () => { "org", "platform", ]); + const withoutPersonal = { + ...group, + connections: group.connections.slice(1), + }; expect( - matchingConnections( - { ...group, connections: group.connections.slice(1) }, - SHOWN, - ), + matchingConnections(withoutPersonal, DEFAULT_SERVICE_FILTERS), ).toEqual([]); + expect( + matchingConnections(withoutPersonal, SHOWN).map((key) => key.id), + ).toEqual(["org", "platform"]); }); it("applies explicit filters to counterparts without requiring them to match the personal row", () => { expect( diff --git a/frontend/src/lib/service-view.ts b/frontend/src/lib/service-view.ts index f4b8fc1af..58230b9d9 100644 --- a/frontend/src/lib/service-view.ts +++ b/frontend/src/lib/service-view.ts @@ -31,9 +31,15 @@ export function matchingConnections( !filters.service_group_ids.includes(group.id) ) return []; + // Auto-connected rows are provisioned onto your own account, so once shown + // they qualify a service for the Personal view like a personal connection. if ( filters.source === "personal" && - !group.connections.some((key) => connectionSource(key) === "personal") + !group.connections.some( + (key) => + connectionSource(key) === "personal" || + (filters.show_auto_connected && key.auto_connected), + ) ) return []; const needle = filters.search.trim().toLowerCase(); From ad448846d4aa11a494ae182294c9646b89f17da3 Mon Sep 17 00:00:00 2001 From: ctkm-aelf <230958228+ctkm-aelf@users.noreply.github.com> Date: Tue, 6 Oct 2026 17:54:08 +0800 Subject: [PATCH 35/36] feat: usage visuals in the connection billing panel The billing panel's usage history now draws a line graph per UTC day (calls, each recorded unit, or credits) when the server reports day buckets. On servers without them it shows a coarse trend instead: average calls per day over the last 24 hours and 1-7, 7-30 and 30-90 days ago, differenced from the period totals. It also shows where the credits came from (free allowance, credit grants, wallet), ranked bars for who made the calls and for models, and splits the panel into Details and Usage history sections. Opened connection panels sit in one card under their row with no divider between them, and Connection details, History and Recent requests get icon headers like Billing and Agent keys. Co-Authored-By: Claude Opus 5.5 --- .../dashboard/service-connection-table.tsx | 72 ++- .../dashboard/service-insight-panels.tsx | 532 ++++++++++++++---- frontend/src/lib/service-usage.test.ts | 55 +- frontend/src/lib/service-usage.ts | 60 ++ 4 files changed, 599 insertions(+), 120 deletions(-) diff --git a/frontend/src/components/dashboard/service-connection-table.tsx b/frontend/src/components/dashboard/service-connection-table.tsx index f9d4d4bd0..291c7b078 100644 --- a/frontend/src/components/dashboard/service-connection-table.tsx +++ b/frontend/src/components/dashboard/service-connection-table.tsx @@ -10,6 +10,8 @@ import { UsersRound, CreditCard, GitBranch, + Info, + type LucideIcon, } from "lucide-react"; import { Badge } from "@/components/ui/badge"; import { @@ -139,6 +141,26 @@ function ConnectionMetadata({ ); } +/** A titled panel inside an opened connection, matching Billing and Agent keys. */ +function PanelSection({ + icon: Icon, + title, + children, +}: { + readonly icon: LucideIcon; + readonly title: string; + readonly children: ReactNode; +}) { + return ( +

+

+

+ {children} +
+ ); +} + export function ServiceConnectionTable({ connections, serviceName, @@ -258,7 +280,8 @@ export function ServiceConnectionTable({ data-service-connection-row={key.id} className={cn( "[&>td]:align-top [&>td]:py-3", - expanded && "bg-muted/20", + // The opened panel continues this entry, so no rule between them. + expanded && "border-b-0 bg-muted/20 hover:bg-muted/20", )} > @@ -622,26 +645,35 @@ export function ServiceConnectionTable({ {expanded && ( - - {open.view === "history" ? ( - - ) : open.view === "details" ? ( - - ) : ( - - )} + +
+ {open.view === "history" ? ( + + + + ) : open.view === "details" ? ( + + + + ) : ( + + )} +
)} diff --git a/frontend/src/components/dashboard/service-insight-panels.tsx b/frontend/src/components/dashboard/service-insight-panels.tsx index 1fa2dfa5c..d12ef7a13 100644 --- a/frontend/src/components/dashboard/service-insight-panels.tsx +++ b/frontend/src/components/dashboard/service-insight-panels.tsx @@ -1,6 +1,12 @@ import { useState } from "react"; import { Link } from "@tanstack/react-router"; -import { ArrowUpRight, Bot, CreditCard, UsersRound } from "lucide-react"; +import { + Activity, + ArrowUpRight, + Bot, + CreditCard, + UsersRound, +} from "lucide-react"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; import { @@ -13,7 +19,15 @@ import { import { metricLabel } from "@/schemas/billing-metrics"; import type { BillingUsagePeriod } from "@/schemas/billing"; import { useBillingUsage } from "@/hooks/use-billing"; -import { Bar, BarChart, XAxis, YAxis } from "recharts"; +import { + Bar, + BarChart, + CartesianGrid, + Line, + LineChart, + XAxis, + YAxis, +} from "recharts"; import { ChartContainer, ChartTooltip, @@ -22,9 +36,12 @@ import { import { serviceUsageDaily, serviceUsageSummary, + serviceUsageTrend, type ServiceUsageDay, + type ServiceUsageSummary, + type ServiceUsageWindow, } from "@/lib/service-usage"; -import { formatExactCredits, hasCredits } from "@/lib/credits"; +import { formatExactCredits, hasCredits, parseCredits } from "@/lib/credits"; import { plainBilling } from "@/lib/billing-plain"; import type { ConfiguredCatalogEntry, @@ -41,7 +58,7 @@ import { outcomeLabel, recordedSourceLabel, } from "@/lib/service-insights"; -import { formatDateTime } from "@/lib/utils"; +import { cn, formatDateTime } from "@/lib/utils"; import type { KeyInfo } from "@/types/keys"; export type InsightPanel = "access" | "requests" | "billing"; @@ -82,9 +99,28 @@ const USAGE_PERIODS: readonly [BillingUsagePeriod, string][] = [ ]; const usageChartConfig = { - calls: { label: "Calls", color: "var(--color-primary)" }, + value: { label: "Usage", color: "var(--color-primary)" }, } satisfies ChartConfig; +/** What the trend line plots: calls, one recorded metric, or credits. */ +type TrendMeasure = "calls" | "credits" | `metric:${string}`; + +function trendValue(day: ServiceUsageDay, measure: TrendMeasure) { + if (measure === "calls") return day.calls; + // Unsettled days have no exact charge yet; leave a gap, not a fake zero. + if (measure === "credits") + return day.charged == null ? null : Number(day.charged); + const metric = measure.slice("metric:".length); + return day.quantities.find((q) => q.metric === metric)?.quantity ?? 0; +} + +function trendLabel(measure: TrendMeasure) { + if (measure === "calls") return "Calls"; + if (measure === "credits") return "Credits"; + const label = metricLabel(measure.slice("metric:".length)); + return label.charAt(0).toUpperCase() + label.slice(1); +} + const shortDay = (day: string) => new Date(`${day}T00:00:00Z`).toLocaleDateString(undefined, { month: "short", @@ -92,14 +128,29 @@ const shortDay = (day: string) => timeZone: "UTC", }); +/** Where charged credits came from, in the order NyxID spends them. */ +function fundingParts(source: { + readonly allowance: string; + readonly grant: string; + readonly wallet: string; +}) { + return ( + [ + ["Free allowance", source.allowance], + ["Free credit grants", source.grant], + ["Wallet", source.wallet], + ] as const + ).filter(([, value]) => hasCredits(value)); +} + function UsageDayTooltip({ active, payload, }: { readonly active?: boolean; - readonly payload?: readonly { payload: ServiceUsageDay }[]; + readonly payload?: readonly { payload: { source: ServiceUsageDay } }[]; }) { - const day = active ? payload?.[0]?.payload : undefined; + const day = active ? payload?.[0]?.payload.source : undefined; if (!day) return null; return (
@@ -112,27 +163,79 @@ function UsageDayTooltip({ {quantity.toLocaleString()} {metricLabel(metric, quantity)}

))} - {hasCredits(day.charged) && ( + {day.charged == null ? ( +

Credits still being calculated

+ ) : ( + hasCredits(day.charged) && ( +

+ {formatExactCredits(day.charged)} credits +

+ ) + )} + {fundingParts(day).length > 0 && (

- {formatExactCredits(day.charged!)} credits + Paid from{" "} + {fundingParts(day) + .map( + ([label, value]) => + `${label.toLowerCase()} ${formatExactCredits(value)}`, + ) + .join(" · ")}

)}
); } -function UsageDailyChart({ series }: { readonly series: ServiceUsageDay[] }) { +function UsageTrendChart({ + series, + measures, +}: { + readonly series: ServiceUsageDay[]; + readonly measures: readonly TrendMeasure[]; +}) { + const [picked, setPicked] = useState("calls"); + const measure = measures.includes(picked) ? picked : "calls"; + const data = series.map((day) => ({ + day: day.day, + value: trendValue(day, measure), + source: day, + })); + const label = trendLabel(measure); return ( -
+
+ {measures.length > 1 && ( +
+ {measures.map((option) => ( + + ))} +
+ )} - + + value.toLocaleString(undefined, { + notation: "compact", + maximumSignificantDigits: 3, + }) + } /> } /> + + + +
+ {data + .filter((point) => point.value) + .map((point) => `${shortDay(point.day)}: ${point.value} ${label}`) + .join(", ") || `No ${label.toLowerCase()} in this period`} +
+
+ ); +} + +const FUNDING_COLORS: Record = { + "Free allowance": "var(--color-success)", + "Free credit grants": "var(--chart-1)", + Wallet: "var(--color-primary)", +}; + +/** One bar split by where the period's credits came from. */ +function FundingBar({ summary }: { readonly summary: ServiceUsageSummary }) { + const parts = fundingParts(summary).map(([label, value]) => ({ + label, + value, + pico: parseCredits(value), + })); + const total = parts.reduce((sum, part) => sum + part.pico, 0n); + if (total === 0n) return null; + return ( +
+

Where the credits came from

+ + ); +} + +/** Ranked horizontal bars, capped at five rows. */ +function BreakdownBars({ + title, + items, +}: { + readonly title: string; + readonly items: readonly { label: string; value: number }[]; +}) { + const max = Math.max(1, ...items.map((item) => item.value)); + const shown = items.slice(0, 5); + return ( +
+

{title}

+
    + {shown.map((item) => ( +
  • + + {item.label} + +
  • + ))} +
+ {items.length > shown.length && ( +

+ +{items.length - shown.length} more +

+ )} +
+ ); +} + +const windowChartConfig = { + perDay: { label: "Calls per day", color: "var(--color-primary)" }, +} satisfies ChartConfig; + +/** + * Average calls per day over recent windows, built from period totals so it + * works before the server reports usage by day. + */ +function UsageWindows({ slug }: { readonly slug: string }) { + const day = useBillingUsage("24h"); + const week = useBillingUsage("7d"); + const month = useBillingUsage("30d"); + const quarter = useBillingUsage("90d"); + const queries = [day, week, month, quarter]; + if (queries.some((query) => query.isPending)) + return

Loading recent trend…

; + if (!day.data || !week.data || !month.data || !quarter.data) return null; + const windows = serviceUsageTrend( + { + "24h": day.data.rows, + "7d": week.data.rows, + "30d": month.data.rows, + "90d": quarter.data.rows, + }, + slug, + ); + return ( +
+

+ Recent trend · average calls per day +

+ + + + + + value.toLocaleString(undefined, { maximumFractionDigits: 1 }) + } + /> + } + /> -
- {series - .filter((day) => day.calls > 0) - .map((day) => `${shortDay(day.day)}: ${day.calls} calls`) - .join(", ") || "No calls in this period"} +
+ {windows + .map( + (slot) => + `${slot.label}: ${slot.calls.toLocaleString()} ${slot.calls === 1 ? "call" : "calls"}`, + ) + .join(" · ")} + . A day-by-day graph replaces this once NyxID reports usage by day.
); } +function WindowTooltip({ + active, + payload, +}: { + readonly active?: boolean; + readonly payload?: readonly { payload: ServiceUsageWindow }[]; +}) { + const slot = active ? payload?.[0]?.payload : undefined; + if (!slot) return null; + return ( +
+

{slot.label}

+

+ {slot.calls.toLocaleString()} {slot.calls === 1 ? "call" : "calls"} +

+

+ {slot.perDay.toLocaleString(undefined, { maximumFractionDigits: 1 })}{" "} + per day on average +

+
+ ); +} + function ConnectionUsage({ connection, freeNow, @@ -193,13 +508,17 @@ function ConnectionUsage({ ? connection.credential_source.org_name : null; return ( -
-

Your usage

+
+ Usage history +