From b6f162bb31234a7cc46dfcf1b9a3a04e4fb7d176 Mon Sep 17 00:00:00 2001 From: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@sprout-oss.stage.blox.sqprod.co> Date: Fri, 26 Jun 2026 11:47:25 -0400 Subject: [PATCH] feat(relay): bind TenantContext from connection host at WS upgrade Resolve the community from the connection host BEFORE the WebSocket upgrade (conformance row-zero) and carry it as ConnectionState.tenant for the whole connection lifetime. Handlers read &conn.tenant and pass it into scoped DB/pub-sub calls; nothing downstream can mint or change it. - RelayError::HostNotMapped: fail-closed, generic message (no host oracle). - normalize_host(&HeaderMap, fallback): request Host header, lowercased, port-stripped; falls back to the configured relay_url host (N=1 parity). - AppState::resolve_tenant: the ONLY mint site, via the buzz-db lookup seam. Integration seam: depends on buzz-db Db::lookup_community_by_host (Mari's lane). Until that lands on rebase, the crate has exactly that one unresolved symbol; everything else (normalize_host + tests, the field plumbing) compiles clean. Co-authored-by: Tyler Longwell Signed-off-by: Tyler Longwell --- crates/buzz-relay/src/connection.rs | 17 +++++- crates/buzz-relay/src/error.rs | 9 +++ crates/buzz-relay/src/router.rs | 92 ++++++++++++++++++++++++++++- crates/buzz-relay/src/state.rs | 26 ++++++++ 4 files changed, 140 insertions(+), 4 deletions(-) diff --git a/crates/buzz-relay/src/connection.rs b/crates/buzz-relay/src/connection.rs index d6d6ccf77..29e357a8a 100644 --- a/crates/buzz-relay/src/connection.rs +++ b/crates/buzz-relay/src/connection.rs @@ -14,6 +14,7 @@ use tracing::{debug, info, trace, warn}; use uuid::Uuid; use buzz_auth::{generate_challenge, AuthContext}; +use buzz_core::TenantContext; use nostr::Filter; use crate::handlers; @@ -44,6 +45,11 @@ pub enum AuthState { pub struct ConnectionState { /// Unique identifier for this connection. pub conn_id: Uuid, + /// The community this connection is bound to, resolved from the connection + /// host at WebSocket upgrade *before* the receive loop starts (conformance + /// row-zero). Handlers read `&conn.tenant` and pass it into every scoped + /// DB / pub-sub call; nothing downstream can mint or change it. + pub tenant: TenantContext, /// Remote socket address of the client. pub remote_addr: SocketAddr, /// Current NIP-42 authentication state. @@ -102,7 +108,15 @@ impl ConnectionState { /// /// Acquires a connection semaphore permit, sends the NIP-42 AUTH challenge, /// then drives the send, heartbeat, and receive loops until the connection closes. -pub async fn handle_connection(socket: WebSocket, state: Arc, addr: SocketAddr) { +/// +/// `tenant` is resolved from the connection host at upgrade time (see +/// `router::nip11_or_ws_handler`) and carried for the connection's whole lifetime. +pub async fn handle_connection( + socket: WebSocket, + state: Arc, + addr: SocketAddr, + tenant: TenantContext, +) { let permit = match state.conn_semaphore.clone().try_acquire_owned() { Ok(p) => p, Err(_) => { @@ -125,6 +139,7 @@ pub async fn handle_connection(socket: WebSocket, state: Arc, addr: So let conn = Arc::new(ConnectionState { conn_id, + tenant, remote_addr: addr, auth_state: RwLock::new(AuthState::Pending { challenge: challenge.clone(), diff --git a/crates/buzz-relay/src/error.rs b/crates/buzz-relay/src/error.rs index 53f2c0db5..ef35fa391 100644 --- a/crates/buzz-relay/src/error.rs +++ b/crates/buzz-relay/src/error.rs @@ -37,6 +37,15 @@ pub enum RelayError { #[error("Not authenticated")] NotAuthenticated, + /// The connection host does not map to any community. + /// + /// Fail-closed tenant resolution (conformance row-zero): an unmapped host is + /// rejected outright — there is no default-community fallthrough. The message + /// is deliberately generic so it cannot be used as a cross-tenant existence + /// oracle (which hosts are configured). + #[error("Host not mapped to a community")] + HostNotMapped, + /// The client sent a message that could not be parsed. #[error("Invalid message format: {0}")] InvalidMessage(String), diff --git a/crates/buzz-relay/src/router.rs b/crates/buzz-relay/src/router.rs index dc16acb35..3de5dc0e8 100644 --- a/crates/buzz-relay/src/router.rs +++ b/crates/buzz-relay/src/router.rs @@ -161,9 +161,25 @@ async fn nip11_or_ws_handler( } match WebSocketUpgrade::from_request(req, &state).await { - Ok(ws) => ws - .on_upgrade(move |socket| handle_connection(socket, state, addr)) - .into_response(), + Ok(ws) => { + // Conformance row-zero: resolve the tenant from the connection host + // BEFORE upgrading, so a connection that upgrades already carries a + // resolved `TenantContext`. An unmapped host is rejected fail-closed — + // there is no default-community fallthrough. + let host = normalize_host( + &headers, + &crate::api::nip05::extract_domain(&state.config.relay_url), + ); + let tenant = match state.resolve_tenant(&host).await { + Ok(tenant) => tenant, + Err(_) => { + // Generic rejection: do not leak which hosts are configured. + return StatusCode::NOT_FOUND.into_response(); + } + }; + ws.on_upgrade(move |socket| handle_connection(socket, state, addr, tenant)) + .into_response() + } Err(_) => { // Browser requesting HTML and web UI is configured → serve SPA. if let Some(ref dir) = state.config.web_dir { @@ -185,6 +201,27 @@ async fn nip11_or_ws_handler( } } +/// Normalize the connection host into the canonical form used to look up a +/// community (lowercase, no port). This is the *connection* host — the request's +/// `Host` header — because the tenant must be resolved from where the client +/// actually connected, not from static relay config (conformance row-zero). +/// +/// `fallback_host` is used only when the header is absent or empty (e.g. a direct +/// internal hit); the caller passes the configured `relay_url` host. In a +/// single-community (N=1) deployment the configured host and the request host +/// coincide, so behaviour matches today's relay; the DB lookup still has the +/// final say and an unmapped host is rejected. +fn normalize_host(headers: &HeaderMap, fallback_host: &str) -> String { + headers + .get(axum::http::header::HOST) + .and_then(|v| v.to_str().ok()) + // Strip an optional port and lowercase. Mirrors `nip05::extract_domain` + // but operates on the live request authority rather than a URL. + .map(|h| h.split(':').next().unwrap_or(h).to_lowercase()) + .filter(|h| !h.is_empty()) + .unwrap_or_else(|| fallback_host.to_lowercase()) +} + async fn health_handler() -> impl IntoResponse { (StatusCode::OK, "ok") } @@ -262,3 +299,52 @@ fn build_cors_layer(cors_origins: &[String]) -> CorsLayer { .allow_methods(tower_http::cors::Any) .allow_headers(tower_http::cors::Any) } + +#[cfg(test)] +mod tests { + use super::*; + use axum::http::header::HOST; + use axum::http::HeaderValue; + + fn headers_with_host(host: &str) -> HeaderMap { + let mut h = HeaderMap::new(); + h.insert(HOST, HeaderValue::from_str(host).unwrap()); + h + } + + #[test] + fn host_header_wins_over_fallback() { + let headers = headers_with_host("tenant.example"); + assert_eq!( + normalize_host(&headers, "configured.example"), + "tenant.example" + ); + } + + #[test] + fn host_header_is_lowercased_and_port_stripped() { + let headers = headers_with_host("Tenant.Example:8080"); + assert_eq!( + normalize_host(&headers, "configured.example"), + "tenant.example" + ); + } + + #[test] + fn falls_back_when_header_absent() { + let headers = HeaderMap::new(); + assert_eq!( + normalize_host(&headers, "Configured.Example"), + "configured.example" + ); + } + + #[test] + fn empty_host_header_falls_back() { + let headers = headers_with_host(""); + assert_eq!( + normalize_host(&headers, "configured.example"), + "configured.example" + ); + } +} diff --git a/crates/buzz-relay/src/state.rs b/crates/buzz-relay/src/state.rs index 1a45948d6..f5aa589e5 100644 --- a/crates/buzz-relay/src/state.rs +++ b/crates/buzz-relay/src/state.rs @@ -580,6 +580,28 @@ impl AppState { } Ok(visibility) } + + /// Resolve a normalized connection host to its [`TenantContext`]. + /// + /// This is the ONLY place a `TenantContext` is minted from a live request + /// (conformance row-zero): the host comes from the connection, the community + /// from the durable `communities` map, and the client never influences it. + /// An unmapped host fails closed via [`RelayError::HostNotMapped`] — there is + /// no default-community fallthrough. + pub async fn resolve_tenant( + &self, + normalized_host: &str, + ) -> Result { + let record = self + .db + .lookup_community_by_host(normalized_host) + .await? + .ok_or(crate::error::RelayError::HostNotMapped)?; + Ok(buzz_core::TenantContext::resolved( + record.id, + normalized_host, + )) + } } /// Handle for graceful audit worker shutdown. @@ -722,6 +744,10 @@ mod tests { let conn = ConnectionState { conn_id, + tenant: buzz_core::TenantContext::resolved( + buzz_core::CommunityId::from_uuid(Uuid::nil()), + "test.localhost", + ), remote_addr: "127.0.0.1:1234".parse().unwrap(), auth_state: RwLock::new(AuthState::Failed), subscriptions: Arc::new(Mutex::new(HashMap::new())),