From ce747ec759d56ee034c219efd1797ff9b14f5aca Mon Sep 17 00:00:00 2001 From: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@sprout-oss.stage.blox.sqprod.co> Date: Sat, 27 Jun 2026 13:34:51 -0400 Subject: [PATCH] fix(admin): derive admin tenant host via shared relay_url_authority MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue 3 reopen (Max): buzz-admin's resolve_admin_tenant derived its lookup host with Url::host_str(), which drops an explicit non-default port and IPv6 brackets. For the default RELAY_URL ws://localhost:3000 the admin would look up community host `localhost` while startup seeding (and live request resolution) bind `localhost:3000` — so the admin CLI's membership writes would miss, or hit, the wrong deployment community. Lift relay_url_authority into buzz-core::tenant as the single canonical helper so the relay's host-resolution seam (startup seeding, bind_deployment_community) and the buzz-admin CLI derive a byte-identical authority: host plus explicit non-default port, IPv6 brackets preserved, default ports collapsed — exactly as normalize_host shapes an inbound Host header. The relay tenant module now `pub use`-re-exports it (no behavior change at the relay seam); buzz-admin calls it directly. Adds 4 buzz-core unit tests pinning the authority shape: non-default-port retention (localhost:3000, relay.example:8443), default-port collapse (:443/:80), IPv6 brackets ([::1]:3000), and unparseable/empty -> empty (callers fail closed on empty). Co-authored-by: Tyler Longwell Signed-off-by: Tyler Longwell --- crates/buzz-admin/src/main.rs | 19 +++++---- crates/buzz-core/src/tenant.rs | 70 +++++++++++++++++++++++++++++++++ crates/buzz-relay/src/tenant.rs | 28 ++++--------- 3 files changed, 86 insertions(+), 31 deletions(-) diff --git a/crates/buzz-admin/src/main.rs b/crates/buzz-admin/src/main.rs index 4e3ef89b5..fd6075c25 100644 --- a/crates/buzz-admin/src/main.rs +++ b/crates/buzz-admin/src/main.rs @@ -24,7 +24,7 @@ use std::sync::Arc; use anyhow::Result; use buzz_core::kind::KIND_NIP43_MEMBERSHIP_LIST; -use buzz_core::tenant::{normalize_host, TenantContext}; +use buzz_core::tenant::{relay_url_authority, TenantContext}; use buzz_db::{Db, DbConfig}; use buzz_pubsub::{EventTopic, PubSubManager}; use clap::{Parser, Subcommand}; @@ -405,15 +405,14 @@ async fn connect_db() -> Result { async fn resolve_admin_tenant(db: &Db) -> Result { let relay_url = std::env::var("RELAY_URL").unwrap_or_else(|_| "ws://localhost:3000".to_string()); - let raw_host = url::Url::parse( - &relay_url - .replace("ws://", "http://") - .replace("wss://", "https://"), - ) - .ok() - .and_then(|u| u.host_str().map(|h| h.to_string())) - .unwrap_or_default(); - let host = normalize_host(&raw_host); + // Derive the authority the *same* way startup seeding and live request + // resolution do (`buzz_core::tenant::relay_url_authority`): host plus an + // explicit non-default port, IPv6 brackets preserved. A plain + // `Url::host_str()` drops the port/brackets, so for `ws://localhost:3000` + // the admin would look up `localhost` while startup seeded `localhost:3000` + // — and `wss://relay.example:8443` would resolve `relay.example`. Sharing + // the helper keeps buzz-admin byte-identical to the community startup seeds. + let host = relay_url_authority(&relay_url); let record = db.lookup_community_by_host(&host).await?.ok_or_else(|| { anyhow::anyhow!( "RELAY_URL host '{host}' is not mapped to a community.\n\ diff --git a/crates/buzz-core/src/tenant.rs b/crates/buzz-core/src/tenant.rs index e7dbb630a..81317a8bc 100644 --- a/crates/buzz-core/src/tenant.rs +++ b/crates/buzz-core/src/tenant.rs @@ -137,6 +137,41 @@ pub fn normalize_host(host: &str) -> String { host } +/// Extract the authority (host plus an explicit non-default port, if present) +/// from a relay URL in the same normalized shape as request `Host` headers and +/// `communities.host`. +/// +/// Shared by the relay's host-resolution seam (startup community seeding and +/// the deployment-community bind), the relay's `bind_deployment_community`, and +/// the `buzz-admin` CLI's tenant resolution. All of these must derive the +/// *byte-identical* authority that live request resolution +/// ([`crate::tenant::normalize_host`]) produces from an inbound `Host`, or a +/// bootstrapped/looked-up community lands under a host no request resolves to. +/// +/// In particular this preserves an explicit non-default port (`relay:8443` → +/// `relay:8443`) and IPv6 brackets (`[::1]:3000`) — both of which a naive +/// `Url::host_str()` drops. Returns the empty string when `relay_url` has no +/// parseable host (the caller fails closed on empty). +#[must_use] +pub fn relay_url_authority(relay_url: &str) -> String { + let Ok(url) = url::Url::parse(relay_url) else { + return String::new(); + }; + let Some(host) = url.host() else { + return String::new(); + }; + let host = match host { + url::Host::Domain(domain) => domain.to_string(), + url::Host::Ipv4(addr) => addr.to_string(), + url::Host::Ipv6(addr) => format!("[{addr}]"), + }; + let authority = match url.port() { + Some(port) => format!("{host}:{port}"), + None => host, + }; + normalize_host(&authority) +} + #[cfg(test)] mod tests { use super::*; @@ -196,4 +231,39 @@ mod tests { assert_eq!(normalize_host(""), ""); assert_eq!(normalize_host(" "), ""); } + + #[test] + fn relay_url_authority_keeps_explicit_nondefault_port() { + // The default dev seed: startup, bind_deployment_community, and + // buzz-admin must all derive `localhost:3000` (NOT bare `localhost`), + // or the admin lookup misses the community startup seeded. + assert_eq!(relay_url_authority("ws://localhost:3000"), "localhost:3000"); + assert_eq!( + relay_url_authority("wss://relay.example:8443"), + "relay.example:8443" + ); + } + + #[test] + fn relay_url_authority_collapses_default_ports() { + // Default ports collapse to the bare host, matching how an inbound + // `Host` header for the same deployment normalizes. + assert_eq!(relay_url_authority("wss://relay.example:443"), "relay.example"); + assert_eq!(relay_url_authority("ws://relay.example:80"), "relay.example"); + assert_eq!(relay_url_authority("wss://relay.example"), "relay.example"); + } + + #[test] + fn relay_url_authority_preserves_ipv6_brackets() { + // `host_str()` strips IPv6 brackets and the port; `relay_url_authority` + // must keep both so the authority matches `communities.host`. + assert_eq!(relay_url_authority("ws://[::1]:3000"), "[::1]:3000"); + } + + #[test] + fn relay_url_authority_unparseable_is_empty() { + // No parseable host → empty authority; callers fail closed. + assert_eq!(relay_url_authority("not a url"), ""); + assert_eq!(relay_url_authority(""), ""); + } } diff --git a/crates/buzz-relay/src/tenant.rs b/crates/buzz-relay/src/tenant.rs index 42a9461e7..88b75f7d6 100644 --- a/crates/buzz-relay/src/tenant.rs +++ b/crates/buzz-relay/src/tenant.rs @@ -105,7 +105,7 @@ pub async fn bind_deployment_community( resolver: &R, relay_url: &str, ) -> Result> { - bind_community(resolver, &relay_url_authority(relay_url)).await + bind_community(resolver, &buzz_core::tenant::relay_url_authority(relay_url)).await } /// Extract the relay URL authority in the same normalized shape as request @@ -115,26 +115,12 @@ pub async fn bind_deployment_community( /// `pub` so startup ([`crate::main`], a separate binary crate) can seed the /// deployment's own community under the *same* normalized host that live request /// resolution ([`bind_community`]) will derive — the two must agree or the -/// bootstrapped owner lands in a community no request ever resolves to. Returns -/// the empty string when `relay_url` has no parseable host. -pub fn relay_url_authority(relay_url: &str) -> String { - let Ok(url) = url::Url::parse(relay_url) else { - return String::new(); - }; - let Some(host) = url.host() else { - return String::new(); - }; - let host = match host { - url::Host::Domain(domain) => domain.to_string(), - url::Host::Ipv4(addr) => addr.to_string(), - url::Host::Ipv6(addr) => format!("[{addr}]"), - }; - let authority = match url.port() { - Some(port) => format!("{host}:{port}"), - None => host, - }; - normalize_host(&authority) -} +/// bootstrapped owner lands in a community no request ever resolves to. +/// +/// This is a thin re-export of [`buzz_core::tenant::relay_url_authority`]: the +/// canonical implementation lives in `buzz-core` so the relay seam *and* the +/// `buzz-admin` CLI derive a byte-identical authority (same port/IPv6 handling). +pub use buzz_core::tenant::relay_url_authority; /// Production [`HostResolver`]: the relay resolves hosts against the durable /// `communities` host map in Postgres.