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 <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
This commit is contained in:
npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d
2026-06-26 11:47:25 -04:00
co-authored by Tyler Longwell
parent ab4b518d5d
commit b6f162bb31
4 changed files with 140 additions and 4 deletions
+16 -1
View File
@@ -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<AppState>, 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<AppState>,
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<AppState>, addr: So
let conn = Arc::new(ConnectionState {
conn_id,
tenant,
remote_addr: addr,
auth_state: RwLock::new(AuthState::Pending {
challenge: challenge.clone(),
+9
View File
@@ -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),
+89 -3
View File
@@ -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"
);
}
}
+26
View File
@@ -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<buzz_core::TenantContext, crate::error::RelayError> {
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())),