From 41df57b8a01098c53232d5bb36c16ff441be684e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?D=C3=A1vid=20Balatoni?= Date: Sat, 24 Oct 2026 23:09:04 +0200 Subject: [PATCH] feat(config): add GitHub host settings --- docs/public/api-reference/fabro-api.yaml | 10 + .../fabro-cli/src/commands/run/publish.rs | 12 +- lib/apps/fabro-server/src/diagnostics.rs | 4 +- lib/apps/fabro-server/src/serve.rs | 2 +- lib/apps/fabro-server/src/server.rs | 49 +-- .../src/server/handler/pull_requests.rs | 2 +- .../fabro-server/src/server/handler/system.rs | 2 +- lib/apps/fabro-server/src/server/tests.rs | 10 +- lib/apps/fabro-server/src/spawn_env.rs | 5 + lib/apps/fabro-server/src/test_support.rs | 2 +- lib/components/fabro-github/src/lib.rs | 59 ++- .../fabro-config/src/layers/server.rs | 4 + .../fabro-config/src/resolve/server.rs | 43 +- .../fabro-config/src/tests/resolve_server.rs | 70 +++- lib/foundation/fabro-static/src/env_vars.rs | 2 + .../fabro-static/src/secret_registry.rs | 1 + lib/foundation/fabro-types/src/github_host.rs | 368 ++++++++++++++++++ lib/foundation/fabro-types/src/lib.rs | 2 + .../fabro-types/src/settings/server.rs | 5 +- .../src/models/github-integration-settings.ts | 8 + 20 files changed, 605 insertions(+), 55 deletions(-) create mode 100644 lib/foundation/fabro-types/src/github_host.rs diff --git a/docs/public/api-reference/fabro-api.yaml b/docs/public/api-reference/fabro-api.yaml index a734c4ddc..669b683e0 100644 --- a/docs/public/api-reference/fabro-api.yaml +++ b/docs/public/api-reference/fabro-api.yaml @@ -15672,7 +15672,17 @@ components: - client_id - slug - webhooks + - web_url + - api_url properties: + web_url: + type: string + description: Web origin of the GitHub deployment, without a trailing slash. + example: https://github.com + api_url: + type: string + description: REST API base URL of the GitHub deployment, without a trailing slash. + example: https://api.github.com enabled: type: boolean strategy: diff --git a/lib/apps/fabro-cli/src/commands/run/publish.rs b/lib/apps/fabro-cli/src/commands/run/publish.rs index 8555f3161..b4850eec4 100644 --- a/lib/apps/fabro-cli/src/commands/run/publish.rs +++ b/lib/apps/fabro-cli/src/commands/run/publish.rs @@ -39,7 +39,7 @@ use fabro_static::EnvVars; use fabro_store::platform_records::{PlatformRecord, PullRequestCreatedRecord}; use fabro_types::settings::run::{PullRequestSettings, RunMode}; use fabro_types::settings::server::GithubIntegrationStrategy; -use fabro_types::{GitHubRepositorySlug, RunId, RunSpec, RunTarget}; +use fabro_types::{GitHubHost, GitHubRepositorySlug, RunId, RunSpec, RunTarget}; use fabro_vault::Vault; use fabro_workflow::pull_request::{self, AutoMergeOptions, OpenPullRequestRequest}; use tokio::time; @@ -52,6 +52,12 @@ const PUSH_TIMEOUT: Duration = Duration::from_mins(5); const PUSH_ATTEMPTS: u32 = 3; const PUSH_RETRY_DELAY: Duration = Duration::from_secs(2); +/// The GitHub deployment the server is configured for. +pub(super) fn github_host() -> Result { + let settings = ServerSettingsBuilder::load_default().context("loading the server settings")?; + fabro_github::github_host(&settings.server.integrations.github) +} + /// The server's GitHub credentials as the worker reaches them, or `None` /// when none are configured. pub(super) fn github_credentials(vault: &Vault) -> Result> { @@ -313,8 +319,8 @@ impl RunPublisher for GitHubPublisher { .credentials .as_ref() .ok_or_else(|| "GitHub credentials are unavailable".to_string())?; - let base_url = fabro_github::github_api_base_url(); - let context = GitHubContext::new(credentials, &base_url); + let host = github_host().map_err(|err| format!("{err:#}"))?; + let context = GitHubContext::new(credentials, host.api_url()); Box::pin(self.open_pull_request(context, settings, publication)).await } } diff --git a/lib/apps/fabro-server/src/diagnostics.rs b/lib/apps/fabro-server/src/diagnostics.rs index d9f5111a2..5d43949e0 100644 --- a/lib/apps/fabro-server/src/diagnostics.rs +++ b/lib/apps/fabro-server/src/diagnostics.rs @@ -399,7 +399,7 @@ async fn check_github_app(state: &AppState) -> CheckResult { }; let probe = timeout( EXTERNAL_SERVICE_PROBE_TIMEOUT, - http.get(format!("{}/user", fabro_github::github_api_base_url())) + http.get(format!("{}/user", state.github_host.api_url())) .header("Authorization", format!("Bearer {token}")) .header("Accept", "application/vnd.github+json") .header("User-Agent", "fabro-server") @@ -549,7 +549,7 @@ async fn check_github_app(state: &AppState) -> CheckResult { }; let auth_result = timeout( EXTERNAL_SERVICE_PROBE_TIMEOUT, - fabro_github::get_authenticated_app(&http, &jwt, &fabro_github::github_api_base_url()), + fabro_github::get_authenticated_app(&http, &jwt, state.github_host.api_url()), ) .await; match auth_result { diff --git a/lib/apps/fabro-server/src/serve.rs b/lib/apps/fabro-server/src/serve.rs index deedd584f..06e8cc56d 100644 --- a/lib/apps/fabro-server/src/serve.rs +++ b/lib/apps/fabro-server/src/serve.rs @@ -737,7 +737,7 @@ where preloaded_vault: startup_vault.into_vault(), server_secrets, env_lookup, - github_api_base_url: None, + github_host: None, active_config_path, http_client: None, sandbox_inventory: None, diff --git a/lib/apps/fabro-server/src/server.rs b/lib/apps/fabro-server/src/server.rs index 413c8c608..91f7bfa30 100644 --- a/lib/apps/fabro-server/src/server.rs +++ b/lib/apps/fabro-server/src/server.rs @@ -1152,28 +1152,28 @@ pub struct AppState { control_request_locks: KeyedMutex, parent_link_lock: AsyncMutex<()>, - pub(super) server_secrets: ServerSecrets, - pub(crate) llm_source: Arc, + pub(super) server_secrets: ServerSecrets, + pub(crate) llm_source: Arc, /// The database pool the stores share, for the Petri run store a /// server-process run writes its records through. - pub(crate) db_pool: DbPool, - manifest_run_defaults: RwLock>, - manifest_run_settings: RwLock>, + pub(crate) db_pool: DbPool, + manifest_run_defaults: RwLock>, + manifest_run_settings: RwLock>, pub(crate) server_settings: RwLock>, - effective_web_url: RwLock, - catalog: RwLock>, - pub(crate) env_lookup: EnvLookup, - pub(crate) github_api_base_url: String, - active_config_path: PathBuf, - http_client: Option, - sandbox_inventory: SandboxInventory, - shutdown: CancellationToken, - shutting_down: AtomicBool, + effective_web_url: RwLock, + catalog: RwLock>, + pub(crate) env_lookup: EnvLookup, + pub(crate) github_host: fabro_types::GitHubHost, + active_config_path: PathBuf, + http_client: Option, + sandbox_inventory: SandboxInventory, + shutdown: CancellationToken, + shutting_down: AtomicBool, /// Test switch: execute runs in this process instead of a worker. - execute_in_process: bool, - slack_service: Option>, - slack_started: AtomicBool, - github_webhook_secret: Option, + execute_in_process: bool, + slack_service: Option>, + slack_started: AtomicBool, + github_webhook_secret: Option, } pub(crate) struct AppStores { @@ -1277,7 +1277,7 @@ impl AppState { .map_err(|source| RunMaterializeError::LoadCredentials { source })?; ProductionAutomationRunMaterializer::new( credentials, - self.github_api_base_url.clone(), + self.github_host.api_url().to_string(), self.http_client.clone(), Arc::clone(&self.automation_repo_cache), fabro_workflow_version::WorkflowVersionStore::new(self.store_ref().blobs()), @@ -1352,7 +1352,7 @@ pub(crate) struct AppStateConfig { pub(crate) preloaded_vault: Vault, pub(crate) server_secrets: ServerSecrets, pub(crate) env_lookup: EnvLookup, - pub(crate) github_api_base_url: Option, + pub(crate) github_host: Option, pub(crate) active_config_path: PathBuf, pub(crate) http_client: Option, pub(crate) sandbox_inventory: Option, @@ -2519,7 +2519,7 @@ pub(crate) fn build_app_state(config: AppStateConfig) -> anyhow::Result anyhow::Result host, + None => fabro_github::github_host(¤t_server_settings.server.integrations.github)?, + }; let storage_root = PathBuf::from(¤t_server_settings.server.storage.root); let automation_repo_cache = Arc::new(GitRepoCache::new( Storage::new(&storage_root) @@ -2721,7 +2724,7 @@ pub(crate) fn build_app_state(config: AppStateConfig) -> anyhow::Result( })?; Ok(fabro_github::GitHubContext::with_http_client( creds, - state.github_api_base_url.as_str(), + state.github_host.api_url(), http_client, )) } diff --git a/lib/apps/fabro-server/src/server/handler/system.rs b/lib/apps/fabro-server/src/server/handler/system.rs index 34420eaa6..917fff590 100644 --- a/lib/apps/fabro-server/src/server/handler/system.rs +++ b/lib/apps/fabro-server/src/server/handler/system.rs @@ -455,7 +455,7 @@ async fn get_github_repo( } let settings = state.server_settings(); let github_settings = &settings.server.integrations.github; - let base_url = fabro_github::github_api_base_url(); + let base_url = state.github_host.api_url(); let (token, client) = match github_settings.strategy { GithubIntegrationStrategy::App => { if github_settings.app_id.is_none() { diff --git a/lib/apps/fabro-server/src/server/tests.rs b/lib/apps/fabro-server/src/server/tests.rs index 02b3d006c..f8a8e785c 100644 --- a/lib/apps/fabro-server/src/server/tests.rs +++ b/lib/apps/fabro-server/src/server/tests.rs @@ -2114,7 +2114,7 @@ fn slack_app_state_with_settings_and_secret_sources( preloaded_vault: vault, server_secrets: load_test_server_secrets(server_env_path, server_secret_env), env_lookup: default_env_lookup(), - github_api_base_url: None, + github_host: None, active_config_path: tempfile::tempdir().unwrap().path().join("settings.toml"), http_client: Some(fabro_http::test_http_client().expect("test HTTP client should build")), sandbox_inventory: None, @@ -5102,7 +5102,9 @@ fn create_github_token_app_state_with_env_lookup_and_llm_catalog_settings( preloaded_vault, server_secrets: load_test_server_secrets(server_env_path, HashMap::new()), env_lookup: Arc::new(env_lookup), - github_api_base_url, + github_host: github_api_base_url.map(|url| { + fabro_types::GitHubHost::new(&url, Some(&url)).expect("mock GitHub URL should be valid") + }), active_config_path, http_client: Some(fabro_http::test_http_client().expect("test HTTP client should build")), sandbox_inventory: None, @@ -10808,7 +10810,7 @@ methods = ["dev-token"] preloaded_vault, server_secrets: ServerSecrets::load(server_env_path, HashMap::new()).unwrap(), env_lookup: default_env_lookup(), - github_api_base_url: None, + github_host: None, active_config_path: tempfile::tempdir().unwrap().path().join("settings.toml"), http_client: Some(fabro_http::test_http_client().expect("test HTTP client should build")), sandbox_inventory: None, @@ -10866,7 +10868,7 @@ fn slack_service_respects_disabled_server_config_even_with_vault_tokens() { HashMap::new(), ), env_lookup: default_env_lookup(), - github_api_base_url: None, + github_host: None, active_config_path: tempfile::tempdir().unwrap().path().join("settings.toml"), http_client: Some(fabro_http::test_http_client().expect("test HTTP client should build")), sandbox_inventory: None, diff --git a/lib/apps/fabro-server/src/spawn_env.rs b/lib/apps/fabro-server/src/spawn_env.rs index 333a48723..02b433093 100644 --- a/lib/apps/fabro-server/src/spawn_env.rs +++ b/lib/apps/fabro-server/src/spawn_env.rs @@ -68,6 +68,11 @@ const WORKER_ENV_ALLOWLIST: &[&str] = &[ // the vault and supplies it explicitly to the in-process provider. EnvVars::DAYTONA_API_URL, EnvVars::DAYTONA_ORGANIZATION_ID, + // The GitHub deployment's non-secret selection. The worker's GitHub + // client reads the web origin and API base overrides; the settings + // reach it through FABRO_HOME. + EnvVars::GITHUB_WEB_URL, + EnvVars::GITHUB_BASE_URL, // A test's checkpoint gates: the worker's hooks hold at a named point // until the test releases them, so a crash can be placed there. EnvVars::FABRO_TEST_CHECKPOINT_GATES, diff --git a/lib/apps/fabro-server/src/test_support.rs b/lib/apps/fabro-server/src/test_support.rs index bd61d3009..93a14f6a2 100644 --- a/lib/apps/fabro-server/src/test_support.rs +++ b/lib/apps/fabro-server/src/test_support.rs @@ -302,7 +302,7 @@ impl TestAppStateBuilder { preloaded_vault, server_secrets: load_test_server_secrets(server_env_path, self.server_secret_env), env_lookup: self.env_lookup, - github_api_base_url: None, + github_host: None, active_config_path, http_client: Some( fabro_http::test_http_client().expect("test HTTP client should build"), diff --git a/lib/components/fabro-github/src/lib.rs b/lib/components/fabro-github/src/lib.rs index 9505b4e7c..c4a9f3fdb 100644 --- a/lib/components/fabro-github/src/lib.rs +++ b/lib/components/fabro-github/src/lib.rs @@ -4,8 +4,9 @@ use base64::engine::general_purpose::STANDARD; use chrono::{DateTime, Utc}; use fabro_redact::DisplaySafeUrl; use fabro_static::EnvVars; -use fabro_types::PullRequestGithubDetail; use fabro_types::settings::run::MergeStrategy; +use fabro_types::settings::server::GithubIntegrationSettings; +use fabro_types::{GitHubHost, PullRequestGithubDetail}; use serde::Deserialize; use tokio::process::Command; @@ -23,8 +24,6 @@ pub(crate) mod tests_mock; pub use access::GitHubRepositoryAccess; pub use identity::GitHubAccountIdentity; -pub const GITHUB_API_BASE_URL: &str = "https://api.github.com"; - /// Git config key that routes github.com HTTPS credentials through /// [`GITHUB_CREDENTIAL_HELPER`]. pub const GITHUB_CREDENTIAL_HELPER_KEY: &str = "credential.https://github.com.helper"; @@ -52,14 +51,44 @@ pub fn apply_probe_git_env(command: &mut Command, token: &str) { .env("GIT_CONFIG_VALUE_0", GITHUB_CREDENTIAL_HELPER); } -/// Returns the GitHub API base URL, allowing override via `GITHUB_BASE_URL` env -/// var. +/// REST API base from `GITHUB_BASE_URL` or github.com, for call sites that +/// have no settings to resolve a [`GitHubHost`] from. #[expect( clippy::disallowed_methods, reason = "GitHub API client exposes a documented process-env base URL override." )] pub fn github_api_base_url() -> String { - std::env::var(EnvVars::GITHUB_BASE_URL).unwrap_or_else(|_| GITHUB_API_BASE_URL.to_string()) + std::env::var(EnvVars::GITHUB_BASE_URL) + .unwrap_or_else(|_| GitHubHost::default().api_url().to_string()) +} + +/// Resolves the GitHub deployment from settings, letting `GITHUB_WEB_URL` +/// override the web origin and `GITHUB_BASE_URL` override the REST API base. +#[expect( + clippy::disallowed_methods, + reason = "GitHub API client exposes documented process-env web origin and API base overrides." +)] +pub fn github_host(settings: &GithubIntegrationSettings) -> anyhow::Result { + host_with_overrides( + &settings.host, + std::env::var(EnvVars::GITHUB_WEB_URL).ok().as_deref(), + std::env::var(EnvVars::GITHUB_BASE_URL).ok().as_deref(), + ) +} + +/// A `web_url` override re-derives the API URL, since the settings' API URL +/// belonged to the old origin; an `api_url` override always wins. +fn host_with_overrides( + configured: &GitHubHost, + web_url: Option<&str>, + api_url: Option<&str>, +) -> anyhow::Result { + let host = match (web_url, api_url) { + (None, None) => return Ok(configured.clone()), + (None, Some(api)) => GitHubHost::new(configured.web_url(), Some(api)), + (Some(web), api) => GitHubHost::new(web, api), + }; + host.context("invalid GITHUB_WEB_URL or GITHUB_BASE_URL override") } /// Bundle of GitHub credentials and the API base URL, threaded through every @@ -1642,6 +1671,24 @@ mod tests { use super::*; + #[test] + fn host_overrides_apply_over_settings() { + let configured = GitHubHost::new("https://ghe.example.com", None).unwrap(); + assert_eq!( + host_with_overrides(&configured, None, None).unwrap(), + configured + ); + + let api_only = host_with_overrides(&configured, None, Some("http://127.0.0.1:9")).unwrap(); + assert_eq!(api_only.web_url(), "https://ghe.example.com"); + assert_eq!(api_only.api_url(), "http://127.0.0.1:9"); + + let web = host_with_overrides(&configured, Some("https://octocorp.ghe.com"), None).unwrap(); + assert_eq!(web.api_url(), "https://api.octocorp.ghe.com"); + + assert!(host_with_overrides(&configured, Some("http://ghe.example.com"), None).is_err()); + } + #[test] fn decode_pem_env_accepts_raw_pem() { let pem = "-----BEGIN TEST KEY-----\nabc\n-----END TEST KEY-----"; diff --git a/lib/foundation/fabro-config/src/layers/server.rs b/lib/foundation/fabro-config/src/layers/server.rs index e15892707..8cba449db 100644 --- a/lib/foundation/fabro-config/src/layers/server.rs +++ b/lib/foundation/fabro-config/src/layers/server.rs @@ -233,6 +233,10 @@ pub struct GithubIntegrationLayer { pub slug: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub webhooks: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub web_url: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub api_url: Option, } /// `[server.integrations.slack]` — Slack workspace credentials and defaults. diff --git a/lib/foundation/fabro-config/src/resolve/server.rs b/lib/foundation/fabro-config/src/resolve/server.rs index 8ce62c516..66d75ad9b 100644 --- a/lib/foundation/fabro-config/src/resolve/server.rs +++ b/lib/foundation/fabro-config/src/resolve/server.rs @@ -1,7 +1,6 @@ use std::collections::BTreeMap; use std::path::Path; -use fabro_types::SandboxProviderKind; use fabro_types::settings::server::{ GithubIntegrationSettings, GithubIntegrationStrategy, IntegrationWebhooksSettings, ObjectStoreProvider, ObjectStoreSettings, SandboxPluginSettings, ServerApiSettings, @@ -11,6 +10,7 @@ use fabro_types::settings::server::{ ServerSchedulerSettings, ServerStorageSettings, ServerWebSettings, SlackIntegrationSettings, WebhookStrategy, }; +use fabro_types::{GitHubHost, SandboxProviderKind}; use fabro_util::Home; use super::{ @@ -19,9 +19,10 @@ use super::{ }; use crate::user::default_storage_dir; use crate::{ - IntegrationWebhooksLayer, ObjectStoreLocalLayer, ObjectStoreS3Layer, ServerApiLayer, - ServerArtifactsLayer, ServerAuthLayer, ServerIntegrationsLayer, ServerLayer, ServerListenLayer, - ServerSandboxLayer, ServerSandboxProviderLayer, ServerStorageLayer, ServerWebLayer, + GithubIntegrationLayer, IntegrationWebhooksLayer, ObjectStoreLocalLayer, ObjectStoreS3Layer, + ServerApiLayer, ServerArtifactsLayer, ServerAuthLayer, ServerIntegrationsLayer, ServerLayer, + ServerListenLayer, ServerSandboxLayer, ServerSandboxProviderLayer, ServerStorageLayer, + ServerWebLayer, }; pub fn resolve_server(layer: &ServerLayer, errors: &mut Vec) -> ServerNamespace { @@ -29,7 +30,7 @@ pub fn resolve_server(layer: &ServerLayer, errors: &mut Vec) -> Se let listen = resolve_listen(layer.listen.as_ref(), errors); let web = resolve_web(layer.web.as_ref()); let auth = resolve_auth(layer.auth.as_ref(), errors); - let integrations = resolve_integrations(layer.integrations.as_ref()); + let integrations = resolve_integrations(layer.integrations.as_ref(), errors); validate_github_webhook_strategy(&integrations, layer.api.as_ref(), errors); let api_url = layer.api.as_ref().and_then(|api| api.url.clone()); @@ -330,7 +331,10 @@ fn resolve_object_store( } } -fn resolve_integrations(layer: Option<&ServerIntegrationsLayer>) -> ServerIntegrationsSettings { +fn resolve_integrations( + layer: Option<&ServerIntegrationsLayer>, + errors: &mut Vec, +) -> ServerIntegrationsSettings { ServerIntegrationsSettings { github: layer .and_then(|integrations| integrations.github.as_ref()) @@ -344,13 +348,15 @@ fn resolve_integrations(layer: Option<&ServerIntegrationsLayer>) -> ServerIntegr github.client_id.as_deref(), ); warn_if_demoted_template("server.integrations.github.slug", github.slug.as_deref()); + let host = resolve_github_host(github, errors); GithubIntegrationSettings { - enabled: github.enabled.unwrap_or(true), - strategy: github.strategy.unwrap_or_default(), - app_id: github.app_id.clone(), + host, + enabled: github.enabled.unwrap_or(true), + strategy: github.strategy.unwrap_or_default(), + app_id: github.app_id.clone(), client_id: github.client_id.clone(), - slug: github.slug.clone(), - webhooks: github.webhooks.as_ref().map(resolve_github_webhooks), + slug: github.slug.clone(), + webhooks: github.webhooks.as_ref().map(resolve_github_webhooks), } }) .unwrap_or_default(), @@ -375,6 +381,21 @@ fn resolve_integrations(layer: Option<&ServerIntegrationsLayer>) -> ServerIntegr } } +fn resolve_github_host( + layer: &GithubIntegrationLayer, + errors: &mut Vec, +) -> GitHubHost { + let default = GitHubHost::default(); + let web_url = layer.web_url.as_deref().unwrap_or(default.web_url()); + GitHubHost::new(web_url, layer.api_url.as_deref()).unwrap_or_else(|err| { + errors.push(ResolveError::Invalid { + path: format!("server.integrations.github.{}", err.field()), + reason: err.to_string(), + }); + default + }) +} + fn resolve_github_webhooks(layer: &IntegrationWebhooksLayer) -> IntegrationWebhooksSettings { IntegrationWebhooksSettings { strategy: layer.strategy, diff --git a/lib/foundation/fabro-config/src/tests/resolve_server.rs b/lib/foundation/fabro-config/src/tests/resolve_server.rs index 33995be1f..6518afe94 100644 --- a/lib/foundation/fabro-config/src/tests/resolve_server.rs +++ b/lib/foundation/fabro-config/src/tests/resolve_server.rs @@ -3,11 +3,11 @@ reason = "sync test fixture setup and raw template source assertions; not on a Tokio path" )] -use fabro_types::SandboxProviderKind; use fabro_types::settings::server::{ GithubIntegrationStrategy, LogDestination, ObjectStoreSettings, ServerAuthMethod, ServerListenSettings, ServerNamespace, }; +use fabro_types::{GitHubHost, SandboxProviderKind}; use fabro_util::Home; use temp_env::with_var; @@ -108,6 +108,8 @@ fn resolved_server_integrations_disable_slack_when_config_is_absent() { "client_id": null, "slug": null, "webhooks": null, + "web_url": "https://github.com", + "api_url": "https://api.github.com", }, "slack": { "enabled": false, @@ -527,6 +529,72 @@ enabled = true ); } +#[test] +fn github_host_defaults_to_github_com() { + let settings = resolve_server(&parse("_version = 1\n")); + assert_eq!(settings.integrations.github.host, GitHubHost::default()); + + let settings = resolve_server(&parse( + "_version = 1\n\n[server.integrations.github]\nenabled = true\n", + )); + assert_eq!(settings.integrations.github.host, GitHubHost::default()); +} + +#[test] +fn github_host_derives_api_url_from_web_url() { + let settings = resolve_server(&parse( + r#" +_version = 1 + +[server.integrations.github] +web_url = "https://ghe.example.com" +"#, + )); + let host = &settings.integrations.github.host; + assert_eq!(host.web_url(), "https://ghe.example.com"); + assert_eq!(host.api_url(), "https://ghe.example.com/api/v3"); +} + +#[test] +fn github_host_honours_explicit_api_url() { + let settings = resolve_server(&parse( + r#" +_version = 1 + +[server.integrations.github] +web_url = "https://ghe.example.com" +api_url = "https://api.ghe.example.com" +"#, + )); + assert_eq!( + settings.integrations.github.host.api_url(), + "https://api.ghe.example.com" + ); +} + +#[test] +fn github_host_rejects_invalid_web_url() { + let file = parse( + r#" +_version = 1 + +[server.integrations.github] +web_url = "http://ghe.example.com" +"#, + ); + let error = + ServerSettingsBuilder::from_layer(&file).expect_err("plain http should be rejected"); + let errors = resolve_errors(error); + assert!( + errors.iter().any(|err| { + err.to_string() + .contains("server.integrations.github.web_url") + && err.to_string().contains("must use https") + }), + "unexpected errors: {errors:?}" + ); +} + #[test] fn parsing_rejects_removed_server_ip_allowlist() { let err = r#" diff --git a/lib/foundation/fabro-static/src/env_vars.rs b/lib/foundation/fabro-static/src/env_vars.rs index 53a45f158..6536f2e32 100644 --- a/lib/foundation/fabro-static/src/env_vars.rs +++ b/lib/foundation/fabro-static/src/env_vars.rs @@ -124,6 +124,7 @@ impl EnvVars { pub const GITHUB_APP_WEBHOOK_SECRET: &'static str = "GITHUB_APP_WEBHOOK_SECRET"; pub const GITHUB_BASE_URL: &'static str = "GITHUB_BASE_URL"; pub const GITHUB_TOKEN: &'static str = "GITHUB_TOKEN"; + pub const GITHUB_WEB_URL: &'static str = "GITHUB_WEB_URL"; pub const OAUTH_CALLBACK_PATH: &'static str = "OAUTH_CALLBACK_PATH"; pub const OAUTH_CLIENT_ID: &'static str = "OAUTH_CLIENT_ID"; pub const OAUTH_ISSUER: &'static str = "OAUTH_ISSUER"; @@ -285,6 +286,7 @@ mod tests { EnvVars::GITHUB_APP_WEBHOOK_SECRET, EnvVars::GITHUB_BASE_URL, EnvVars::GITHUB_TOKEN, + EnvVars::GITHUB_WEB_URL, EnvVars::OAUTH_CALLBACK_PATH, EnvVars::OAUTH_CLIENT_ID, EnvVars::OAUTH_ISSUER, diff --git a/lib/foundation/fabro-static/src/secret_registry.rs b/lib/foundation/fabro-static/src/secret_registry.rs index 4fb715b6f..c6759e2b0 100644 --- a/lib/foundation/fabro-static/src/secret_registry.rs +++ b/lib/foundation/fabro-static/src/secret_registry.rs @@ -123,6 +123,7 @@ mod tests { for name in [ EnvVars::GH_TOKEN, EnvVars::GITHUB_BASE_URL, + EnvVars::GITHUB_WEB_URL, EnvVars::SLACK_BASE_URL, EnvVars::DAYTONA_API_URL, EnvVars::DAYTONA_ORGANIZATION_ID, diff --git a/lib/foundation/fabro-types/src/github_host.rs b/lib/foundation/fabro-types/src/github_host.rs new file mode 100644 index 000000000..bc928e90c --- /dev/null +++ b/lib/foundation/fabro-types/src/github_host.rs @@ -0,0 +1,368 @@ +use serde::{Deserialize, Serialize}; + +const DEFAULT_WEB_URL: &str = "https://github.com"; +const DEFAULT_API_URL: &str = "https://api.github.com"; + +/// Rejected GitHub host setting, naming the offending field. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +#[error("{field} {problem}")] +pub struct GitHubHostError { + field: &'static str, + problem: String, +} + +impl GitHubHostError { + /// Name of the rejected setting: `web_url` or `api_url`. + #[must_use] + pub fn field(&self) -> &'static str { + self.field + } + + fn new(field: &'static str, problem: impl Into) -> Self { + Self { + field, + problem: problem.into(), + } + } +} + +/// The GitHub deployment Fabro talks to: github.com (the default), a GitHub +/// Enterprise Cloud tenant with data residency (`*.ghe.com`) or a GitHub +/// Enterprise Server instance. The web origin is the one source of truth; +/// every other URL is derived from it unless the API URL is given explicitly. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(try_from = "GitHubHostWire")] +pub struct GitHubHost { + web_url: String, + api_url: String, +} + +#[derive(Deserialize)] +struct GitHubHostWire { + #[serde(default)] + web_url: Option, + #[serde(default)] + api_url: Option, +} + +impl TryFrom for GitHubHost { + type Error = GitHubHostError; + + fn try_from(wire: GitHubHostWire) -> Result { + Self::new( + wire.web_url.as_deref().unwrap_or(DEFAULT_WEB_URL), + wire.api_url.as_deref(), + ) + } +} + +impl Default for GitHubHost { + fn default() -> Self { + Self { + web_url: DEFAULT_WEB_URL.to_string(), + api_url: DEFAULT_API_URL.to_string(), + } + } +} + +#[expect( + clippy::disallowed_types, + reason = "GitHub host settings are public service origins; validation errors never echo credentials." +)] +fn parse_origin(field: &'static str, raw: &str) -> Result { + let raw = raw.trim().trim_end_matches('/'); + let parsed = url::Url::parse(raw).map_err(|_| { + GitHubHostError::new( + field, + format!("must be an https URL such as https://github.com (got `{raw}`)"), + ) + })?; + let host = parsed + .host_str() + .ok_or_else(|| GitHubHostError::new(field, "must include a host"))?; + let loopback = matches!(host, "localhost" | "127.0.0.1" | "[::1]"); + match parsed.scheme() { + "https" => {} + "http" if loopback => {} + _ => { + return Err(GitHubHostError::new( + field, + format!("must use https (got `{raw}`); http is only allowed for localhost"), + )); + } + } + if !parsed.username().is_empty() || parsed.password().is_some() { + return Err(GitHubHostError::new(field, "must not contain credentials")); + } + if parsed.query().is_some() || parsed.fragment().is_some() { + return Err(GitHubHostError::new( + field, + "must not contain a query or fragment", + )); + } + Ok(parsed) +} + +impl GitHubHost { + /// Builds a host from the web origin and an optional explicit API URL. + /// Without `api_url`, the API URL is derived from the web origin. + pub fn new(web_url: &str, api_url: Option<&str>) -> Result { + let web = parse_origin("web_url", web_url)?; + if !matches!(web.path(), "" | "/") { + return Err(GitHubHostError::new( + "web_url", + "must not contain a path; GitHub is never served under a path prefix", + )); + } + let web_origin = web.origin().ascii_serialization(); + let api_url = match api_url { + Some(api) => { + parse_origin("api_url", api)?; + api.trim().trim_end_matches('/').to_string() + } + None => derive_api_url(&web_origin, web.host_str().unwrap_or_default()), + }; + Ok(Self { + web_url: web_origin, + api_url, + }) + } + + /// Web origin without a trailing slash, e.g. `https://ghe.example.com`. + #[must_use] + pub fn web_url(&self) -> &str { + &self.web_url + } + + /// REST API base without a trailing slash, e.g. + /// `https://ghe.example.com/api/v3`. + #[must_use] + pub fn api_url(&self) -> &str { + &self.api_url + } + + /// Authority of the web origin, e.g. `ghe.example.com` or + /// `ghe.example.com:8443`. + #[must_use] + pub fn host(&self) -> &str { + self.web_url + .split_once("://") + .map_or(self.web_url.as_str(), |(_, authority)| authority) + } + + #[must_use] + pub fn is_github_com(&self) -> bool { + self.host() == "github.com" + } + + /// GraphQL endpoint derived from the REST API URL. + #[must_use] + pub fn graphql_url(&self) -> String { + match self.api_url.strip_suffix("/api/v3") { + Some(base) => format!("{base}/api/graphql"), + None => format!("{}/graphql", self.api_url), + } + } + + #[must_use] + pub fn repository_url(&self, owner: &str, repo: &str) -> String { + format!("{}/{owner}/{repo}", self.web_url) + } + + #[must_use] + pub fn pull_request_url(&self, owner: &str, repo: &str, number: u64) -> String { + format!("{}/pull/{number}", self.repository_url(owner, repo)) + } + + /// Domain of GitHub's private noreply commit emails. + #[must_use] + pub fn noreply_email_domain(&self) -> String { + format!("users.noreply.{}", self.host()) + } + + /// Git config key that routes this host's HTTPS credentials to a helper. + #[must_use] + pub fn credential_helper_key(&self) -> String { + format!("credential.{}.helper", self.web_url) + } + + /// Whether `origin` is a git remote URL on this host (HTTPS, HTTP, scp-like + /// SSH or `ssh://`). + #[must_use] + pub fn is_origin(&self, origin: &str) -> bool { + let host = self.host(); + origin.starts_with(&format!("{}/", self.web_url)) + || origin.starts_with(&format!("http://{host}/")) + || origin.starts_with(&format!("git@{host}:")) + || origin.starts_with(&format!("ssh://git@{host}/")) + } +} + +fn derive_api_url(web_origin: &str, hostname: &str) -> String { + let under = |domain: &str| hostname == domain || hostname.ends_with(&format!(".{domain}")); + if under("github.com") { + DEFAULT_API_URL.to_string() + } else if under("ghe.com") { + web_origin.replacen("://", "://api.", 1) + } else { + format!("{web_origin}/api/v3") + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn host(web: &str) -> GitHubHost { + GitHubHost::new(web, None).unwrap() + } + + #[test] + fn default_matches_github_com_constants() { + let default = GitHubHost::default(); + assert_eq!(default.web_url(), "https://github.com"); + assert_eq!(default.api_url(), "https://api.github.com"); + assert_eq!(default, host("https://github.com")); + assert!(default.is_github_com()); + assert_eq!(default.graphql_url(), "https://api.github.com/graphql"); + assert_eq!(default.repository_url("o", "r"), "https://github.com/o/r"); + assert_eq!( + default.pull_request_url("o", "r", 7), + "https://github.com/o/r/pull/7" + ); + assert_eq!(default.noreply_email_domain(), "users.noreply.github.com"); + assert_eq!( + default.credential_helper_key(), + "credential.https://github.com.helper" + ); + assert!(default.is_origin("git@github.com:o/r.git")); + assert!(default.is_origin("https://github.com/o/r")); + assert!(default.is_origin("http://github.com/o/r")); + assert!(default.is_origin("ssh://git@github.com/o/r")); + assert!(!default.is_origin("https://gitlab.com/o/r")); + } + + #[test] + fn data_residency_host_derives_urls() { + let ghec = host("https://octocorp.ghe.com"); + assert_eq!(ghec.api_url(), "https://api.octocorp.ghe.com"); + assert_eq!(ghec.graphql_url(), "https://api.octocorp.ghe.com/graphql"); + assert_eq!(ghec.host(), "octocorp.ghe.com"); + assert!(!ghec.is_github_com()); + assert_eq!( + ghec.repository_url("o", "r"), + "https://octocorp.ghe.com/o/r" + ); + assert_eq!( + ghec.pull_request_url("o", "r", 7), + "https://octocorp.ghe.com/o/r/pull/7" + ); + assert_eq!( + ghec.noreply_email_domain(), + "users.noreply.octocorp.ghe.com" + ); + assert_eq!( + ghec.credential_helper_key(), + "credential.https://octocorp.ghe.com.helper" + ); + assert!(ghec.is_origin("git@octocorp.ghe.com:o/r.git")); + assert!(ghec.is_origin("ssh://git@octocorp.ghe.com/o/r")); + assert!(!ghec.is_origin("https://github.com/o/r")); + } + + #[test] + fn server_host_derives_urls() { + let ghes = host("https://ghe.example.com/"); + assert_eq!(ghes.web_url(), "https://ghe.example.com"); + assert_eq!(ghes.api_url(), "https://ghe.example.com/api/v3"); + assert_eq!(ghes.graphql_url(), "https://ghe.example.com/api/graphql"); + assert_eq!(ghes.host(), "ghe.example.com"); + assert_eq!(ghes.repository_url("o", "r"), "https://ghe.example.com/o/r"); + assert_eq!( + ghes.pull_request_url("o", "r", 7), + "https://ghe.example.com/o/r/pull/7" + ); + assert_eq!(ghes.noreply_email_domain(), "users.noreply.ghe.example.com"); + assert_eq!( + ghes.credential_helper_key(), + "credential.https://ghe.example.com.helper" + ); + assert!(ghes.is_origin("git@ghe.example.com:o/r.git")); + assert!(ghes.is_origin("https://ghe.example.com/o/r")); + } + + #[test] + fn host_keeps_port_and_lowercases() { + let ghes = host("https://GHE.Example.com:8443"); + assert_eq!(ghes.web_url(), "https://ghe.example.com:8443"); + assert_eq!(ghes.host(), "ghe.example.com:8443"); + } + + #[test] + fn explicit_api_url_wins() { + let ghes = GitHubHost::new( + "https://ghe.example.com", + Some("https://api.ghe.example.com/"), + ) + .unwrap(); + assert_eq!(ghes.api_url(), "https://api.ghe.example.com"); + assert_eq!(ghes.graphql_url(), "https://api.ghe.example.com/graphql"); + } + + #[test] + fn loopback_http_is_accepted() { + let local = host("http://127.0.0.1:1234"); + assert_eq!(local.web_url(), "http://127.0.0.1:1234"); + assert_eq!(local.api_url(), "http://127.0.0.1:1234/api/v3"); + let mock = GitHubHost::new("http://127.0.0.1:1234", Some("http://127.0.0.1:1234")).unwrap(); + assert_eq!(mock.graphql_url(), "http://127.0.0.1:1234/graphql"); + } + + #[test] + fn invalid_values_name_the_problem() { + let plain_http = GitHubHost::new("http://ghe.example.com", None).unwrap_err(); + assert!( + plain_http.to_string().starts_with("web_url must use https"), + "{plain_http}" + ); + let bare = GitHubHost::new("ghe.example.com", None).unwrap_err(); + assert!( + bare.to_string().starts_with("web_url must be an https URL"), + "{bare}" + ); + let path = GitHubHost::new("https://ghe.example.com/github", None).unwrap_err(); + assert!( + path.to_string().contains("must not contain a path"), + "{path}" + ); + let api = GitHubHost::new( + "https://ghe.example.com", + Some("http://ghe.example.com/api/v3"), + ) + .unwrap_err(); + assert!( + api.to_string().starts_with("api_url must use https"), + "{api}" + ); + } + + #[test] + fn serde_round_trips_and_validates() { + let ghes = host("https://ghe.example.com"); + let json = serde_json::to_value(&ghes).unwrap(); + assert_eq!( + json, + serde_json::json!({ + "web_url": "https://ghe.example.com", + "api_url": "https://ghe.example.com/api/v3", + }) + ); + assert_eq!(serde_json::from_value::(json).unwrap(), ghes); + assert!( + serde_json::from_value::( + serde_json::json!({"web_url": "http://x.example.com", "api_url": "https://x.example.com"}) + ) + .is_err() + ); + } +} diff --git a/lib/foundation/fabro-types/src/lib.rs b/lib/foundation/fabro-types/src/lib.rs index e08f61bfa..cc7684d6e 100644 --- a/lib/foundation/fabro-types/src/lib.rs +++ b/lib/foundation/fabro-types/src/lib.rs @@ -15,6 +15,7 @@ pub mod diff; pub mod engine; pub mod failure_signature; pub mod git_identity; +pub mod github_host; mod id; mod input_scalar; pub mod interview; @@ -86,6 +87,7 @@ pub use diff::{DiffStats, DiffSummary, RunDiff}; pub use engine::{PetriAdmission, PetriGraphRef}; pub use failure_signature::FailureSignature; pub use git_identity::{GitIdentity, GitIdentitySource}; +pub use github_host::{GitHubHost, GitHubHostError}; pub use input_scalar::{ JsonScalarToTomlError, TomlScalarToJsonError, json_scalar_to_toml_value, toml_scalar_to_json_value, diff --git a/lib/foundation/fabro-types/src/settings/server.rs b/lib/foundation/fabro-types/src/settings/server.rs index d66a83154..d121d208a 100644 --- a/lib/foundation/fabro-types/src/settings/server.rs +++ b/lib/foundation/fabro-types/src/settings/server.rs @@ -11,7 +11,7 @@ use std::net::SocketAddr; use serde::de::Error as _; use serde::{Deserialize, Deserializer, Serialize, Serializer}; -use crate::SandboxProviderKind; +use crate::{GitHubHost, SandboxProviderKind}; /// A structurally resolved `[server]` view for consumers. /// @@ -297,6 +297,9 @@ pub struct GithubIntegrationSettings { pub client_id: Option, pub slug: Option, pub webhooks: Option, + /// The GitHub deployment (`web_url`/`api_url` on the wire). + #[serde(flatten)] + pub host: GitHubHost, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] diff --git a/lib/packages/fabro-api-client/src/models/github-integration-settings.ts b/lib/packages/fabro-api-client/src/models/github-integration-settings.ts index fa6bdfcac..7263955b8 100644 --- a/lib/packages/fabro-api-client/src/models/github-integration-settings.ts +++ b/lib/packages/fabro-api-client/src/models/github-integration-settings.ts @@ -27,4 +27,12 @@ export interface GithubIntegrationSettings { 'client_id': string | null; 'slug': string | null; 'webhooks': IntegrationWebhooksSettings | null; + /** + * Web origin of the GitHub deployment, without a trailing slash. + */ + 'web_url': string; + /** + * REST API base URL of the GitHub deployment, without a trailing slash. + */ + 'api_url': string; }