Replace listen-addr and server-url with a single port
The server's bind host was always 0.0.0.0 inside the container, and the
loopback URL handed to quire-ci is derivable from the port — two keys
that had to agree was operator footgun. quire-ci runs as a subprocess
of quire-server today, so http://127.0.0.1:{port} is the only URL the
API transport ever needs.

Transport::for_new_run becomes infallible (no missing-server-url to
guard) and Error::ApiTransportMissingServerUrl goes away. When remote
CI lands the host config grows back; today the simpler shape wins.

Assisted-by: Claude Opus 4.7 via Claude Code
change ynpmrmzsswnolnywztnqupxloztxrxur
commit cecbce1dfe412ab0a9a3badb3946183147be1988
author Alpha Chen <alpha@kejadlen.dev>
date
parent nlkpnomu
diff --git a/docs/config.md b/docs/config.md
index 75639cf..a9ae633 100644
--- a/docs/config.md
+++ b/docs/config.md
@@ -11,6 +11,7 @@ Operator-created. Re-read on every call (no caching today).
 
 | Key            | Type           | Required | Purpose                                                  |
 |----------------|----------------|----------|----------------------------------------------------------|
+| `:port`        | integer        | no       | TCP port the HTTP server binds to (on `0.0.0.0`). Default: `3000`. |
 | `:sentry :dsn` | `SecretString` | no       | Sentry DSN for error reporting from both `quire` and `quire-ci`. Omit to disable. |
 | `:secrets`     | table          | no       | Named secrets exposed to `ci.fnl` jobs as `(secret :name)`. |
 
diff --git a/quire-server/src/bin/quire/server.rs b/quire-server/src/bin/quire/server.rs
index 1368146..0feee9d 100644
--- a/quire-server/src/bin/quire/server.rs
+++ b/quire-server/src/bin/quire/server.rs
@@ -17,7 +17,8 @@ async fn index() -> String {
 }
 
 pub async fn run(quire: &Quire, ci_routes: axum::Router) -> Result<()> {
-    let addr: SocketAddr = ([0, 0, 0, 0], 3000).into();
+    let config = quire.global_config()?;
+    let addr = SocketAddr::from(([0, 0, 0, 0], config.port));
 
     // Set up event socket.
     let socket_path = quire.socket_path();
diff --git a/quire-server/src/ci/error.rs b/quire-server/src/ci/error.rs
index bd93717..ca3238c 100644
--- a/quire-server/src/ci/error.rs
+++ b/quire-server/src/ci/error.rs
@@ -60,9 +60,6 @@ pub enum Error {
     #[error("quire-ci exited with status {exit:?}")]
     QuireCiExit { exit: Option<i32> },
 
-    #[error("ci.transport=api requires ci.server-url to be set in config.fnl")]
-    ApiTransportMissingServerUrl,
-
     #[error("failed to parse quire-ci event stream at {}: {source}", path.display())]
     EventStreamParse {
         path: std::path::PathBuf,
diff --git a/quire-server/src/ci/mod.rs b/quire-server/src/ci/mod.rs
index 3068146..668760a 100644
--- a/quire-server/src/ci/mod.rs
+++ b/quire-server/src/ci/mod.rs
@@ -166,19 +166,7 @@ pub fn trigger(quire: &crate::Quire, event: &PushEvent) {
                 );
             },
             || {
-                let transport =
-                    match Transport::for_new_run(config.ci.transport, config.server_url.as_deref())
-                    {
-                        Ok(t) => t,
-                        Err(e) => {
-                            tracing::error!(
-                                repo = %event.repo,
-                                error = &e as &(dyn std::error::Error + 'static),
-                                "CI transport misconfigured",
-                            );
-                            return;
-                        }
-                    };
+                let transport = Transport::for_new_run(config.ci.transport, config.port);
                 if let Err(e) = trigger_ref(
                     &repo,
                     &db_path,
diff --git a/quire-server/src/ci/run.rs b/quire-server/src/ci/run.rs
index 2765ed8..f9736c0 100644
--- a/quire-server/src/ci/run.rs
+++ b/quire-server/src/ci/run.rs
@@ -40,7 +40,7 @@ pub enum TransportMode {
 }
 
 /// Runtime transport for a single CI run. Built once per run from
-/// the config-shape [`TransportMode`] + the top-level `server_url`,
+/// the config-shape [`TransportMode`] + the server's listen port,
 /// then passed to `Runs::create` and `Run::execute_via_quire_ci`.
 /// The `Api` variant carries the shared [`ApiSession`] — quire-ci
 /// receives a structurally identical value via its CLI flags.
@@ -53,22 +53,16 @@ pub enum Transport {
 
 impl Transport {
     /// Build a runtime transport for a new run. For `Api`, mints a
-    /// fresh run ID and CSPRNG bearer token, pairs them with
-    /// `server_url`, and bundles them into an [`ApiSession`]. Errors
-    /// if `mode == Api` and `server_url` is missing.
-    pub fn for_new_run(mode: TransportMode, server_url: Option<&str>) -> Result<Self> {
+    /// fresh run ID and CSPRNG bearer token, derives the loopback
+    /// server URL from `port`, and bundles them into an [`ApiSession`].
+    pub fn for_new_run(mode: TransportMode, port: u16) -> Self {
         match mode {
-            TransportMode::Filesystem => Ok(Transport::Filesystem),
-            TransportMode::Api => {
-                let server_url = server_url
-                    .ok_or(Error::ApiTransportMissingServerUrl)?
-                    .to_string();
-                Ok(Transport::Api(ApiSession {
-                    run_id: uuid::Uuid::now_v7().to_string(),
-                    server_url,
-                    auth_token: mint_auth_token(),
-                }))
-            }
+            TransportMode::Filesystem => Transport::Filesystem,
+            TransportMode::Api => Transport::Api(ApiSession {
+                run_id: uuid::Uuid::now_v7().to_string(),
+                server_url: format!("http://127.0.0.1:{port}"),
+                auth_token: mint_auth_token(),
+            }),
         }
     }
 }
@@ -923,8 +917,7 @@ mod tests {
     fn create_with_api_persists_minted_auth_token() {
         let (_dir, quire) = tmp_quire();
         let runs = test_runs(&quire);
-        let transport = Transport::for_new_run(TransportMode::Api, Some("http://127.0.0.1:3000"))
-            .expect("for_new_run");
+        let transport = Transport::for_new_run(TransportMode::Api, 3000);
         let run = runs.create(&test_meta(), &transport).expect("create");
 
         let Transport::Api(api) = &transport else {
@@ -948,12 +941,11 @@ mod tests {
 
     #[test]
     fn for_new_run_mints_alphanumeric_token() {
-        let transport = Transport::for_new_run(TransportMode::Api, Some("http://127.0.0.1:3000"))
-            .expect("for_new_run");
+        let transport = Transport::for_new_run(TransportMode::Api, 4000);
         let Transport::Api(api) = transport else {
             panic!("expected Api transport");
         };
-        assert_eq!(api.server_url, "http://127.0.0.1:3000");
+        assert_eq!(api.server_url, "http://127.0.0.1:4000");
         assert_eq!(api.auth_token.len(), 32);
         assert!(
             api.auth_token.chars().all(|c| c.is_ascii_alphanumeric()),
@@ -967,15 +959,6 @@ mod tests {
         );
     }
 
-    #[test]
-    fn for_new_run_rejects_api_without_server_url() {
-        match Transport::for_new_run(TransportMode::Api, None) {
-            Ok(_) => panic!("for_new_run should fail without server_url"),
-            Err(Error::ApiTransportMissingServerUrl) => {}
-            Err(other) => panic!("unexpected error: {other}"),
-        }
-    }
-
     #[test]
     fn mint_auth_token_returns_unique_values() {
         let a = mint_auth_token();
diff --git a/quire-server/src/quire/mod.rs b/quire-server/src/quire/mod.rs
index 150017d..7a050c2 100644
--- a/quire-server/src/quire/mod.rs
+++ b/quire-server/src/quire/mod.rs
@@ -23,17 +23,20 @@ pub struct GlobalConfig {
     /// Each value is a `SecretString` (plain literal or `{:file "..."}`).
     #[serde(default)]
     pub secrets: HashMap<String, SecretString>,
-    /// Base URL the orchestrator advertises to quire-ci over the API
-    /// transport (e.g. `http://127.0.0.1:3000`). Top-level because it
-    /// describes the server itself, not CI; once the filesystem
-    /// transport is retired it stays put.
-    #[serde(default)]
-    pub server_url: Option<String>,
+    /// TCP port the HTTP server binds to on all interfaces (`0.0.0.0`).
+    /// The API transport derives `http://127.0.0.1:{port}` from this for
+    /// quire-ci's bootstrap URL.
+    #[serde(default = "default_port")]
+    pub port: u16,
     /// CI configuration.
     #[serde(default)]
     pub ci: CiConfig,
 }
 
+fn default_port() -> u16 {
+    3000
+}
+
 #[derive(serde::Deserialize, Debug, Default)]
 pub struct CiConfig {
     /// How the orchestrator dispatches CI runs. Defaults to shelling
@@ -418,23 +421,29 @@ mod tests {
         let config = q.global_config().expect("global_config should load");
         assert_eq!(config.ci.transport, TransportMode::Filesystem);
         assert_eq!(config.ci.executor, Executor::QuireCi);
-        assert!(config.server_url.is_none());
+        assert_eq!(config.port, 3000);
     }
 
     #[test]
-    fn global_config_parses_api_transport_and_server_url() {
+    fn global_config_parses_api_transport() {
         let dir = tempfile::tempdir().expect("tempdir");
         let config_path = dir.path().join("config.fnl");
-        fs_err::write(
-            &config_path,
-            r#"{:server-url "http://127.0.0.1:3000" :ci {:transport :api}}"#,
-        )
-        .expect("write");
+        fs_err::write(&config_path, r#"{:ci {:transport :api}}"#).expect("write");
 
         let q = Quire::new(dir.path().to_path_buf());
         let config = q.global_config().expect("global_config should load");
         assert_eq!(config.ci.transport, TransportMode::Api);
-        assert_eq!(config.server_url.as_deref(), Some("http://127.0.0.1:3000"));
+    }
+
+    #[test]
+    fn global_config_parses_custom_port() {
+        let dir = tempfile::tempdir().expect("tempdir");
+        let config_path = dir.path().join("config.fnl");
+        fs_err::write(&config_path, r#"{:port 4000}"#).expect("write");
+
+        let q = Quire::new(dir.path().to_path_buf());
+        let config = q.global_config().expect("global_config should load");
+        assert_eq!(config.port, 4000);
     }
 
     #[test]