fix(admin): derive admin tenant host via shared relay_url_authority

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 <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
This commit is contained in:
npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d
2026-06-27 14:43:51 -04:00
co-authored by Tyler Longwell
parent 2adae32bc7
commit ce747ec759
3 changed files with 86 additions and 31 deletions
+9 -10
View File
@@ -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<Db> {
async fn resolve_admin_tenant(db: &Db) -> Result<TenantContext> {
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\
+70
View File
@@ -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(""), "");
}
}
+7 -21
View File
@@ -105,7 +105,7 @@ pub async fn bind_deployment_community<R: HostResolver>(
resolver: &R,
relay_url: &str,
) -> Result<TenantContext, BindError<R::Error>> {
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<R: HostResolver>(
/// `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.