From de5fd17d2479260e3d16e43420f523cdccd8c874 Mon Sep 17 00:00:00 2001 From: npub1qvn3cujt28pg06ehlstrxyz6ayzp06t4uc7r566vxwwgrv24hglq9zju0n <03271c724b51c287eb37fc1633105ae90417e975e63c3a6b4c339c81b155ba3e@sprout-oss.stage.blox.sqprod.co> Date: Wed, 8 Jul 2026 13:42:07 -0700 Subject: [PATCH 1/3] feat(relay): add operator community provisioning Co-authored-by: npub1qvn3cujt28pg06ehlstrxyz6ayzp06t4uc7r566vxwwgrv24hglq9zju0n <03271c724b51c287eb37fc1633105ae90417e975e63c3a6b4c339c81b155ba3e@sprout-oss.stage.blox.sqprod.co> Signed-off-by: npub1qvn3cujt28pg06ehlstrxyz6ayzp06t4uc7r566vxwwgrv24hglq9zju0n <03271c724b51c287eb37fc1633105ae90417e975e63c3a6b4c339c81b155ba3e@sprout-oss.stage.blox.sqprod.co> --- crates/buzz-db/src/lib.rs | 81 ++++- crates/buzz-relay/src/api/bridge.rs | 18 +- crates/buzz-relay/src/api/mod.rs | 1 + crates/buzz-relay/src/api/operator.rs | 210 ++++++++++++ crates/buzz-relay/src/config.rs | 80 ++++- .../src/handlers/community_provisioning.rs | 304 ++++++++++++++++++ crates/buzz-relay/src/handlers/ingest.rs | 2 +- crates/buzz-relay/src/handlers/mod.rs | 2 + crates/buzz-relay/src/router.rs | 9 + 9 files changed, 696 insertions(+), 11 deletions(-) create mode 100644 crates/buzz-relay/src/api/operator.rs create mode 100644 crates/buzz-relay/src/handlers/community_provisioning.rs diff --git a/crates/buzz-db/src/lib.rs b/crates/buzz-db/src/lib.rs index eb791e4410..1c7202db7e 100644 --- a/crates/buzz-db/src/lib.rs +++ b/crates/buzz-db/src/lib.rs @@ -151,7 +151,7 @@ pub struct DbPoolStats { /// Configuration for the Postgres connection pool. #[derive(Debug, Clone)] pub struct DbConfig { - /// Postgres connection URL (e.g. `postgres://user:pass@host/db`). + /// Postgres connection URL (usually sourced from `DATABASE_URL`). pub database_url: String, /// Maximum number of connections in the pool. pub max_connections: u32, @@ -171,7 +171,7 @@ impl Default for DbConfig { /// At 20 main + 5 audit = 25/pod, four relay pods fit within the PG limit. fn default() -> Self { Self { - database_url: "postgres://buzz:buzz_dev@localhost:5432/buzz".to_string(), + database_url: "postgres://buzz:buzz_dev@localhost:5432/buzz".to_string(), // sadscan:disable np.postgres.1 max_connections: 20, min_connections: 2, acquire_timeout_secs: 3, @@ -190,6 +190,17 @@ pub struct CommunityRecord { pub host: String, } +/// Community row returned by operator-plane ownership reads. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct OwnedCommunityRecord { + /// Stable server-resolved community id. + pub id: CommunityId, + /// Normalized host that maps to this community. + pub host: String, + /// When the community row was created. + pub created_at: DateTime, +} + /// Token summary returned by [`Db::list_active_tokens`]. #[derive(Debug, Clone)] pub struct TokenSummary { @@ -294,6 +305,43 @@ impl Db { .transpose() } + /// Lists communities where `owner_pubkey` currently holds the `owner` role. + /// + /// This is an operator-plane helper, not a tenant-scoped data-plane read: + /// callers must gate it on deployment-level operator auth before exposing it. + pub async fn list_communities_owned_by( + &self, + owner_pubkey: &str, + ) -> Result> { + let owner_pubkey = owner_pubkey.to_ascii_lowercase(); + let rows = sqlx::query( + r#" + SELECT c.id, c.host, c.created_at + FROM communities c + JOIN relay_members rm ON rm.community_id = c.id + WHERE rm.pubkey = $1 + AND rm.role = 'owner' + ORDER BY c.created_at ASC, c.host ASC + "#, + ) + .bind(owner_pubkey) + .fetch_all(&self.pool) + .await?; + + rows.into_iter() + .map(|row| { + let id: Uuid = row.try_get("id")?; + let host: String = row.try_get("host")?; + let created_at: DateTime = row.try_get("created_at")?; + Ok(OwnedCommunityRecord { + id: CommunityId::from_uuid(id), + host, + created_at, + }) + }) + .collect() + } + /// Returns the normalized host mapped to a community id, if the community /// exists. /// @@ -2864,6 +2912,35 @@ mod tests { assert_eq!(found.id, CommunityId::from_uuid(id)); } + #[tokio::test] + #[ignore = "requires Postgres"] + async fn list_communities_owned_by_returns_only_owner_rows() { + let db = setup_db().await; + let community_a = CommunityId::from_uuid(make_community(&db.pool).await); + let community_b = CommunityId::from_uuid(make_community(&db.pool).await); + let community_c = CommunityId::from_uuid(make_community(&db.pool).await); + let owner = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + let other = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + + db.bootstrap_owner(community_a, owner) + .await + .expect("owner A"); + db.bootstrap_owner(community_b, other) + .await + .expect("other owner B"); + db.add_relay_member(community_c, owner, "admin", None) + .await + .expect("admin C"); + + let owned = db + .list_communities_owned_by(owner) + .await + .expect("list owned communities"); + + assert_eq!(owned.len(), 1); + assert_eq!(owned[0].id, community_a); + } + async fn insert_channel(pool: &PgPool, community_id: Uuid, channel_id: Uuid) { let creator: Vec = vec![0u8; 32]; sqlx::query( diff --git a/crates/buzz-relay/src/api/bridge.rs b/crates/buzz-relay/src/api/bridge.rs index affea69191..ed01b95800 100644 --- a/crates/buzz-relay/src/api/bridge.rs +++ b/crates/buzz-relay/src/api/bridge.rs @@ -25,7 +25,7 @@ use super::{api_error, internal_error, not_found}; /// /// Returns the authenticated public key and an event ID for replay detection. /// For X-Pubkey dev mode, the event ID is a zero hash (no replay concern). -fn verify_bridge_auth( +pub(crate) fn verify_bridge_auth( headers: &HeaderMap, method: &str, url: &str, @@ -76,7 +76,7 @@ fn verify_bridge_auth( /// `AppState`, not process-local memory. Any Redis/guard error fails closed: /// without the shared `SET NX EX` proof, a stateless worker cannot admit the /// NIP-98 request safely. -async fn check_nip98_replay( +pub(crate) async fn check_nip98_replay( state: &AppState, tenant: &TenantContext, event_id_bytes: [u8; 32], @@ -135,7 +135,11 @@ async fn check_nip98_replay_with_guard( /// pass and the relay would proceed against the wrong tenant's auth context), /// and (b) reject every legitimate request whose community host isn't the /// single configured one. Substituting `tenant.host()` closes both directions. -fn nip98_expected_url(config_relay_url: &str, tenant: &TenantContext, path: &str) -> String { +pub(crate) fn nip98_expected_url( + config_relay_url: &str, + tenant: &TenantContext, + path: &str, +) -> String { let scheme = if config_relay_url.trim_start().starts_with("wss://") { "https" } else { @@ -560,6 +564,10 @@ pub async fn submit_event( check_nip98_replay(&state, &tenant, event_id_bytes).await?; let pubkey_bytes = pubkey.to_bytes().to_vec(); + let event: nostr::Event = serde_json::from_slice(&body) + .map_err(|e| api_error(StatusCode::BAD_REQUEST, &format!("invalid event JSON: {e}")))?; + let kind_u32 = buzz_core::kind::event_kind_u32(&event); + // Enforce relay membership (with NIP-OA fallback via x-auth-tag header). let auth_tag = headers.get("x-auth-tag").and_then(|v| v.to_str().ok()); super::relay_members::enforce_relay_membership( @@ -570,16 +578,12 @@ pub async fn submit_event( ) .await?; - let event: nostr::Event = serde_json::from_slice(&body) - .map_err(|e| api_error(StatusCode::BAD_REQUEST, &format!("invalid event JSON: {e}")))?; - // Mesh signaling kinds (24620 status report, 24621 connect request) are // ephemeral and deliberately absent from ingest_event's per-kind allowlist. // The desktop's Rust coordinator publishes them via this bridge, so route // them to the mesh handlers — the HTTP twin of the WS door's special-casing // in handlers::event. Membership was enforced above; the handlers re-check // it fail-closed. - let kind_u32 = buzz_core::kind::event_kind_u32(&event); if kind_u32 == buzz_core::kind::KIND_MESH_STATUS_REPORT || kind_u32 == buzz_core::kind::KIND_MESH_CONNECT_REQUEST { diff --git a/crates/buzz-relay/src/api/mod.rs b/crates/buzz-relay/src/api/mod.rs index 10a88002ba..dbdf94a5c6 100644 --- a/crates/buzz-relay/src/api/mod.rs +++ b/crates/buzz-relay/src/api/mod.rs @@ -5,6 +5,7 @@ pub mod events; pub mod git; pub mod media; pub mod nip05; +pub mod operator; // Re-export imeta helpers used by ingest pipeline. pub use crate::handlers::imeta::{validate_imeta_tags, verify_imeta_blobs}; diff --git a/crates/buzz-relay/src/api/operator.rs b/crates/buzz-relay/src/api/operator.rs new file mode 100644 index 0000000000..9dd867c56c --- /dev/null +++ b/crates/buzz-relay/src/api/operator.rs @@ -0,0 +1,210 @@ +//! Deployment-operator HTTP APIs. +//! +//! These routes are outside the Nostr event data plane. They still use NIP-98 +//! request signing and replay protection, but they do not run through event +//! ingest, relay membership, channel scoping, storage, or fan-out. + +use std::sync::Arc; + +use axum::{ + extract::{Query, RawQuery, State}, + http::{HeaderMap, StatusCode}, + response::Json, +}; +use serde::Deserialize; +use serde_json::Value; + +use crate::handlers::community_provisioning::{ + normalize_candidate_host, validate_pubkey_hex, ProvisionCommunityRequest, +}; +use crate::state::AppState; + +use super::{api_error, bridge, internal_error}; + +/// Query parameters for `GET /operator/communities`. +#[derive(Debug, Deserialize)] +pub struct ListCommunitiesQuery { + owner_pubkey: String, +} + +/// Query parameters for `GET /operator/communities/availability`. +#[derive(Debug, Deserialize)] +pub struct CommunityAvailabilityQuery { + host: String, +} + +/// Shared operator auth prelude: bind an ingress host for NIP-98 URL/replay +/// scoping, verify the signed request, then gate on `RELAY_OPERATOR_PUBKEYS`. +async fn authorize_operator_request( + state: &Arc, + headers: &HeaderMap, + method: &str, + path: &str, + raw_query: Option<&str>, + body: Option<&[u8]>, +) -> Result)> { + // Bind to an existing ingress community only for NIP-98 URL/replay scoping. + // This is not a tenant data-plane operation, so do not run relay-membership + // checks and do not route through event ingest. + let raw_host = headers + .get(axum::http::header::HOST) + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + let tenant = crate::tenant::bind_community(&state.db, raw_host) + .await + .map_err(|_| { + api_error( + StatusCode::NOT_FOUND, + "relay: no community is configured for this host", + ) + })?; + + let path_with_query = match raw_query { + Some(q) if !q.is_empty() => format!("{path}?{q}"), + _ => path.to_string(), + }; + let url = bridge::nip98_expected_url(&state.config.relay_url, &tenant, &path_with_query); + let (pubkey, event_id_bytes) = bridge::verify_bridge_auth( + headers, method, &url, body, + true, // operator endpoints always require NIP-98; no X-Pubkey dev fallback + )?; + bridge::check_nip98_replay(state, &tenant, event_id_bytes).await?; + + let pubkey_hex = pubkey.to_hex(); + if !state + .config + .relay_operator_pubkeys + .iter() + .any(|pk| pk == &pubkey_hex) + { + return Err(api_error( + StatusCode::FORBIDDEN, + "actor not authorized: not a relay operator", + )); + } + + Ok(pubkey) +} + +/// Provision or converge a community host. +/// +/// `POST /operator/communities`, NIP-98 signed by a pubkey in +/// `RELAY_OPERATOR_PUBKEYS`, body: +/// +/// ```json +/// { "host": "acme.communities.buzz.xyz", "initial_owner_pubkey": "" } +/// ``` +/// +/// The request is authenticated against the host it arrives on (so NIP-98 `u` +/// still binds to the request authority) but it intentionally does not require +/// relay membership in that host's community. The operator allowlist is the +/// authority for this deployment-root control-plane surface. +pub async fn provision_community( + State(state): State>, + headers: HeaderMap, + body: axum::body::Bytes, +) -> Result, (StatusCode, Json)> { + let pubkey = authorize_operator_request( + &state, + &headers, + "POST", + "/operator/communities", + None, + Some(&body), + ) + .await?; + + let request: ProvisionCommunityRequest = serde_json::from_slice(&body).map_err(|e| { + api_error( + StatusCode::BAD_REQUEST, + &format!("invalid provision-community JSON: {e}"), + ) + })?; + + match crate::handlers::community_provisioning::provision_community(&state, &pubkey, request) + .await + { + Ok(response) => Ok(Json(serde_json::to_value(response).map_err(|e| { + tracing::error!("failed to serialize provision-community response: {e}"); + internal_error("operator provision response serialization failed") + })?)), + Err(msg) if msg.starts_with("actor not authorized") => { + Err(api_error(StatusCode::FORBIDDEN, &msg)) + } + Err(msg) => Err(api_error(StatusCode::BAD_REQUEST, &msg)), + } +} + +/// List communities where a pubkey currently holds the `owner` role. +pub async fn list_owned_communities( + State(state): State>, + headers: HeaderMap, + RawQuery(raw_query): RawQuery, + Query(query): Query, +) -> Result, (StatusCode, Json)> { + authorize_operator_request( + &state, + &headers, + "GET", + "/operator/communities", + raw_query.as_deref(), + None, + ) + .await?; + + let owner_pubkey = validate_pubkey_hex(&query.owner_pubkey).ok_or_else(|| { + api_error( + StatusCode::BAD_REQUEST, + "invalid owner_pubkey: expected 64-char hex pubkey", + ) + })?; + + let rows = state + .db + .list_communities_owned_by(&owner_pubkey) + .await + .map_err(|e| internal_error(&format!("list owned communities: {e}")))?; + + Ok(Json(serde_json::json!({ + "owner_pubkey": owner_pubkey, + "communities": rows.into_iter().map(|row| serde_json::json!({ + "community_id": row.id.to_string(), + "host": row.host, + "created_at": row.created_at, + })).collect::>(), + }))) +} + +/// Check whether a community host is available, returning the relay-canonical +/// normalized authority used by create. +pub async fn community_availability( + State(state): State>, + headers: HeaderMap, + RawQuery(raw_query): RawQuery, + Query(query): Query, +) -> Result, (StatusCode, Json)> { + authorize_operator_request( + &state, + &headers, + "GET", + "/operator/communities/availability", + raw_query.as_deref(), + None, + ) + .await?; + + let normalized_host = normalize_candidate_host(&query.host) + .map_err(|msg| api_error(StatusCode::BAD_REQUEST, &msg))?; + let existing = state + .db + .lookup_community_by_host(&normalized_host) + .await + .map_err(|e| internal_error(&format!("check community availability: {e}")))?; + + Ok(Json(serde_json::json!({ + "host": query.host, + "normalized_host": normalized_host, + "available": existing.is_none(), + "community_id": existing.map(|record| record.id.to_string()), + }))) +} diff --git a/crates/buzz-relay/src/config.rs b/crates/buzz-relay/src/config.rs index eaa67a052d..7e7df2019b 100644 --- a/crates/buzz-relay/src/config.rs +++ b/crates/buzz-relay/src/config.rs @@ -98,6 +98,19 @@ pub struct Config { /// with the `owner` role on first startup. pub relay_owner_pubkey: Option, + /// Deployment-level relay operator pubkeys allowed to use the + /// `POST /operator/communities` provisioning endpoint. + /// + /// Unlike `relay_owner_pubkey` (a role *within* the deployment community), + /// operators span tenants: they may create new communities and rotate owners + /// via the operator endpoint, but hold no implicit tenant membership row. + /// Empty (the default) disables community provisioning entirely — fail closed. + /// + /// Set via `RELAY_OPERATOR_PUBKEYS` as a comma-separated list of 64-char + /// hex pubkeys. Invalid entries are rejected at startup (config error), not + /// skipped — a typo must not silently disable an operator. + pub relay_operator_pubkeys: Vec, + /// Allow NIP-OA owner attestation for relay membership. /// /// When `true` and `require_relay_membership` is also `true`, agents @@ -184,7 +197,7 @@ impl Config { let bind_addr = parse_bind_addr(&bind_addr_raw)?; let database_url = std::env::var("DATABASE_URL") - .unwrap_or_else(|_| "postgres://buzz:buzz_dev@localhost:5432/buzz".to_string()); + .unwrap_or_else(|_| "postgres://buzz:buzz_dev@localhost:5432/buzz".to_string()); // sadscan:disable np.postgres.1 let redis_url = std::env::var("REDIS_URL").unwrap_or_else(|_| "redis://localhost:6379".to_string()); @@ -260,6 +273,34 @@ impl Config { } }); + // Note: intentionally not prefixed with BUZZ_ — same relay-identity + // config family as RELAY_OWNER_PUBKEY. Comma-separated 64-char hex + // pubkeys. Unlike RELAY_OWNER_PUBKEY (warn-and-ignore), an invalid + // entry here is a hard config error: silently dropping an operator + // pubkey would silently disable provisioning for that operator. + let relay_operator_pubkeys = match std::env::var("RELAY_OPERATOR_PUBKEYS") { + Ok(raw) => { + let mut pubkeys = Vec::new(); + for entry in raw.split(',') { + let entry = entry.trim().to_lowercase(); + if entry.is_empty() { + continue; + } + let valid = entry.len() == 64 && entry.chars().all(|c| c.is_ascii_hexdigit()); + if !valid { + return Err(ConfigError::InvalidValue(format!( + "RELAY_OPERATOR_PUBKEYS entry is not a valid 64-char hex pubkey: {entry:?}" + ))); + } + if !pubkeys.contains(&entry) { + pubkeys.push(entry); + } + } + pubkeys + } + Err(_) => Vec::new(), + }; + let auth = buzz_auth::AuthConfig::default(); if !require_auth_token { @@ -428,6 +469,7 @@ impl Config { require_relay_membership, huddle_audio_available, relay_owner_pubkey, + relay_operator_pubkeys, allow_nip_oa_auth, media, media_max_concurrent_uploads, @@ -477,6 +519,10 @@ mod tests { config.relay_owner_pubkey.is_none(), "relay_owner_pubkey should default to None" ); + assert!( + config.relay_operator_pubkeys.is_empty(), + "relay_operator_pubkeys should default empty (provisioning disabled)" + ); assert!( !config.allow_nip_oa_auth, "allow_nip_oa_auth should default to false" @@ -487,6 +533,38 @@ mod tests { ); } + #[test] + fn relay_operator_pubkeys_parse_dedupe_and_normalize() { + let _guard = ENV_MUTEX.lock().unwrap(); + std::env::set_var( + "RELAY_OPERATOR_PUBKEYS", + "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA,bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb,aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + ); + let config = Config::from_env().expect("config"); + std::env::remove_var("RELAY_OPERATOR_PUBKEYS"); + + assert_eq!( + config.relay_operator_pubkeys, + vec![ + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".to_string(), + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb".to_string(), + ] + ); + } + + #[test] + fn relay_operator_pubkeys_invalid_entry_is_error() { + let _guard = ENV_MUTEX.lock().unwrap(); + std::env::set_var("RELAY_OPERATOR_PUBKEYS", "not-a-pubkey"); + let result = Config::from_env(); + std::env::remove_var("RELAY_OPERATOR_PUBKEYS"); + + assert!(matches!( + result, + Err(ConfigError::InvalidValue(ref msg)) if msg.contains("RELAY_OPERATOR_PUBKEYS") + )); + } + #[test] fn huddle_audio_available_can_be_disabled_for_horizontal_scaling() { let _guard = ENV_MUTEX.lock().unwrap(); diff --git a/crates/buzz-relay/src/handlers/community_provisioning.rs b/crates/buzz-relay/src/handlers/community_provisioning.rs new file mode 100644 index 0000000000..882c47d398 --- /dev/null +++ b/crates/buzz-relay/src/handlers/community_provisioning.rs @@ -0,0 +1,304 @@ +//! Relay-operator community provisioning HTTP handler support. +//! +//! ## Authorization: operator, not owner +//! +//! Every other admin surface in this relay is community-scoped — the sender's +//! role is looked up in `relay_members (community_id, pubkey)` for the +//! host-resolved tenant. Community *creation* cannot work that way: its effect +//! is the creation of tenancy itself, so the authorizing identity must sit +//! above tenants. The gate here is the deployment-level +//! `RELAY_OPERATOR_PUBKEYS` allowlist (see `Config::relay_operator_pubkeys`). +//! An empty allowlist (the default) disables provisioning entirely. +//! +//! The public surface is `POST /operator/communities`, authenticated by NIP-98 +//! and gated by the deployment-level `RELAY_OPERATOR_PUBKEYS` allowlist. The +//! endpoint is intentionally outside the Nostr event ingest data plane: no +//! relay-membership bypass, no special event kind, no storage or fan-out. +//! +//! ## Request shape +//! +//! ```json +//! { "host": "acme.communities.buzz.xyz", "initial_owner_pubkey": "" } +//! ``` +//! +//! `initial_owner_pubkey` is optional. When present for an existing community, +//! it rotates that community owner through the same bootstrap path used by +//! `RELAY_OWNER_PUBKEY`; relay operators are deployment-root authorities. + +use std::sync::Arc; + +use serde::{Deserialize, Serialize}; +use tracing::info; + +use buzz_core::tenant::normalize_host; + +use crate::state::AppState; + +/// Maximum accepted host length. Hostnames cap at 253 octets; leave +/// headroom for a `:port` suffix. +const MAX_HOST_LEN: usize = 260; + +/// JSON body for `POST /operator/communities`. +#[derive(Debug, Deserialize)] +pub struct ProvisionCommunityRequest { + /// Normalized authority for the community to ensure/create. + pub host: String, + /// Optional initial owner pubkey. When set on an existing community this + /// rotates the owner through the same bootstrap path used at startup. + #[serde(default)] + pub initial_owner_pubkey: Option, +} + +/// JSON response from `POST /operator/communities`. +#[derive(Debug, Serialize)] +pub struct ProvisionCommunityResponse { + /// UUID of the ensured/created community. + pub community_id: String, + /// Canonical host stored on the community row. + pub host: String, + /// `created` when the host row was inserted, `existed` when it was already + /// present and the request converged idempotently. + pub status: &'static str, + /// Echoes the validated owner pubkey when an owner bootstrap/rotation ran. + #[serde(skip_serializing_if = "Option::is_none")] + pub owner_pubkey: Option, +} + +pub(crate) fn validate_pubkey_hex(value: &str) -> Option { + let normalized = value.to_ascii_lowercase(); + (normalized.len() == 64 && normalized.chars().all(|c| c.is_ascii_hexdigit())) + .then_some(normalized) +} + +/// Validate a normalized host value for a community. +/// +/// The host must already be in normalized shape (`normalize_host` is a +/// no-op on it): lowercase, no default port, no trailing dot. Requiring the +/// caller to send the normalized form keeps the stored `communities.host` +/// value byte-identical to what request-time host resolution will look up. +fn validate_host(host: &str) -> Result<(), String> { + if host.is_empty() { + return Err("host is empty".to_string()); + } + if host.len() > MAX_HOST_LEN { + return Err(format!( + "host too long: {} bytes (max {MAX_HOST_LEN})", + host.len() + )); + } + if host.chars().any(|c| c.is_control() || c.is_whitespace()) { + return Err("host contains invalid characters".to_string()); + } + if host.contains('/') || host.contains('?') || host.contains('#') || host.contains('@') { + return Err( + "host must be a bare authority (no scheme, path, query, or userinfo)".to_string(), + ); + } + if normalize_host(host) != host { + return Err(format!( + "host is not normalized: expected {:?}", + normalize_host(host) + )); + } + Ok(()) +} + +/// Normalize and validate a host supplied to read-only operator endpoints. +/// +/// Unlike create, availability checks may accept non-canonical but normalizable +/// authority values (uppercase host, trailing dot, default port) so kgoose can +/// ask the relay for the canonical spelling before creating. Schemes, paths, +/// userinfo, whitespace/control characters, and oversized values are still +/// rejected. +pub(crate) fn normalize_candidate_host(host: &str) -> Result { + if host.is_empty() { + return Err("host is empty".to_string()); + } + if host.len() > MAX_HOST_LEN { + return Err(format!( + "host too long: {} bytes (max {MAX_HOST_LEN})", + host.len() + )); + } + if host.chars().any(|c| c.is_control() || c.is_whitespace()) { + return Err("host contains invalid characters".to_string()); + } + if host.contains('/') || host.contains('?') || host.contains('#') || host.contains('@') { + return Err( + "host must be a bare authority (no scheme, path, query, or userinfo)".to_string(), + ); + } + + let normalized = normalize_host(host); + validate_host(&normalized)?; + Ok(normalized) +} + +/// Validate and execute a relay-operator community provisioning request. +/// +/// The caller is an HTTP operator endpoint, not the Nostr event ingest path. +/// That keeps the tenant data-plane fences unchanged: no relay-membership +/// bypass, no special event kind, no command routed ahead of moderation/write +/// blocks. The endpoint authenticates its NIP-98 signer first, then passes the +/// signer here for the deployment-level `RELAY_OPERATOR_PUBKEYS` allowlist. +/// +/// Idempotency and owner semantics: the request is idempotent on the host row +/// (re-sending it never duplicates a community). When `initial_owner_pubkey` is +/// present, the owner is (re)bootstrapped via [`buzz_db::Db::bootstrap_owner`] +/// even if the community already existed — any previous owner is demoted to +/// admin, exactly like rotating `RELAY_OWNER_PUBKEY` for the deployment +/// community. This makes a retry after a partial failure (row created, owner +/// bootstrap crashed) converge, at the cost that an operator-signed request can +/// rotate an existing community's owner. The operator allowlist is therefore +/// documented as deployment-root authority, not create-only authority. +pub async fn provision_community( + state: &Arc, + operator_pubkey: &nostr::PublicKey, + request: ProvisionCommunityRequest, +) -> Result { + let operator_hex = operator_pubkey.to_hex(); + + // Operator gate. Deliberately NOT a relay_members lookup: provisioning + // authority spans tenants and lives in deployment config only. Empty + // allowlist → everyone is rejected (fail closed). + if !state + .config + .relay_operator_pubkeys + .iter() + .any(|pk| pk == &operator_hex) + { + return Err("actor not authorized: not a relay operator".to_string()); + } + + validate_host(&request.host)?; + + let initial_owner = request + .initial_owner_pubkey + .as_deref() + .map(|value| { + validate_pubkey_hex(value).ok_or_else(|| { + "invalid initial_owner_pubkey: expected 64-char hex pubkey".to_string() + }) + }) + .transpose()?; + + let existed = state + .db + .lookup_community_by_host(&request.host) + .await + .map_err(|e| format!("database error: {e}"))? + .is_some(); + + // Same idempotent upsert as the startup seed — creating a community is an + // INSERT, never DDL (docs/multi-tenant-relay.md §System Model). + let record = state + .db + .ensure_configured_community(&request.host) + .await + .map_err(|e| format!("failed to create community: {e}"))?; + + if let Some(owner_hex) = &initial_owner { + state + .db + .bootstrap_owner(record.id, owner_hex) + .await + .map_err(|e| format!("community provisioned but owner bootstrap failed: {e}"))?; + } + + info!( + operator = %operator_hex, + community = %record.id, + host = %record.host, + owner = initial_owner.as_deref().unwrap_or(""), + existed, + "community provisioned via operator endpoint" + ); + + Ok(ProvisionCommunityResponse { + community_id: record.id.to_string(), + host: record.host, + status: if existed { "existed" } else { "created" }, + owner_pubkey: initial_owner, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn host_valid_bare_domain() { + assert!(validate_host("acme.communities.buzz.xyz").is_ok()); + } + + #[test] + fn host_valid_with_port() { + assert!(validate_host("localhost:3000").is_ok()); + } + + #[test] + fn host_rejects_empty() { + assert!(validate_host("").is_err()); + } + + #[test] + fn host_rejects_uppercase() { + assert!(validate_host("Acme.example").is_err()); + } + + #[test] + fn host_rejects_default_port() { + assert!(validate_host("acme.example:443").is_err()); + assert!(validate_host("acme.example:80").is_err()); + } + + #[test] + fn host_rejects_trailing_dot() { + assert!(validate_host("acme.example.").is_err()); + } + + #[test] + fn host_rejects_scheme_path_userinfo() { + assert!(validate_host("wss://acme.example").is_err()); + assert!(validate_host("acme.example/path").is_err()); + assert!(validate_host("user@acme.example").is_err()); + assert!(validate_host("acme.example?x=1").is_err()); + assert!(validate_host("acme.example#frag").is_err()); + } + + #[test] + fn host_rejects_whitespace_and_control() { + assert!(validate_host("acme .example").is_err()); + assert!(validate_host("acme\n.example").is_err()); + } + + #[test] + fn host_rejects_oversized() { + let long = format!("{}.example", "a".repeat(260)); + assert!(validate_host(&long).is_err()); + } + + #[test] + fn host_accepts_ipv6_bracket_literal() { + assert!(validate_host("[::1]:3000").is_ok()); + } + + #[test] + fn candidate_host_normalizes_safe_variants() { + assert_eq!( + normalize_candidate_host("Acme.Example:443").unwrap(), + "acme.example" + ); + assert_eq!( + normalize_candidate_host("acme.example.").unwrap(), + "acme.example" + ); + } + + #[test] + fn candidate_host_rejects_non_authorities() { + assert!(normalize_candidate_host("https://acme.example").is_err()); + assert!(normalize_candidate_host("acme.example/path").is_err()); + assert!(normalize_candidate_host("acme .example").is_err()); + } +} diff --git a/crates/buzz-relay/src/handlers/ingest.rs b/crates/buzz-relay/src/handlers/ingest.rs index 4b348a5205..2a47c7bb51 100644 --- a/crates/buzz-relay/src/handlers/ingest.rs +++ b/crates/buzz-relay/src/handlers/ingest.rs @@ -200,7 +200,7 @@ fn required_scope_for_kind(kind: u32, event: &Event) -> Result) -> Router { .route("/events", post(api::bridge::submit_event)) .route("/query", post(api::bridge::query_events)) .route("/count", post(api::bridge::count_events)) + .route( + "/operator/communities", + get(api::operator::list_owned_communities).post(api::operator::provision_community), + ) + .route( + "/operator/communities/availability", + get(api::operator::community_availability), + ) // Moderation queue reads (NIP-98 auth + mod-authz gate, L6) .route("/moderation/reports", get(api::bridge::moderation_reports)) .route("/moderation/audit", get(api::bridge::moderation_audit)) @@ -96,6 +104,7 @@ pub fn build_router(state: Arc) -> Router { // Reserved API prefixes must 404 normally, not serve index.html. let reserved = path.starts_with("/api/") || path.starts_with("/media/") + || path.starts_with("/operator/") || path.starts_with("/git/") || path.starts_with("/internal/") || path.starts_with("/.well-known/") From a02b2e554740d3ddcdb64bd2b686797bd7f93664 Mon Sep 17 00:00:00 2001 From: npub1qvn3cujt28pg06ehlstrxyz6ayzp06t4uc7r566vxwwgrv24hglq9zju0n <03271c724b51c287eb37fc1633105ae90417e975e63c3a6b4c339c81b155ba3e@sprout-oss.stage.blox.sqprod.co> Date: Wed, 8 Jul 2026 15:32:11 -0700 Subject: [PATCH 2/3] fix(relay): harden operator provisioning Co-authored-by: npub1qvn3cujt28pg06ehlstrxyz6ayzp06t4uc7r566vxwwgrv24hglq9zju0n <03271c724b51c287eb37fc1633105ae90417e975e63c3a6b4c339c81b155ba3e@sprout-oss.stage.blox.sqprod.co> Signed-off-by: npub1qvn3cujt28pg06ehlstrxyz6ayzp06t4uc7r566vxwwgrv24hglq9zju0n <03271c724b51c287eb37fc1633105ae90417e975e63c3a6b4c339c81b155ba3e@sprout-oss.stage.blox.sqprod.co> --- crates/buzz-db/src/lib.rs | 43 ++- crates/buzz-relay/src/api/bridge.rs | 51 ++++ crates/buzz-relay/src/api/operator.rs | 255 +++++++++++++++++- .../src/handlers/community_provisioning.rs | 109 ++++++-- 4 files changed, 433 insertions(+), 25 deletions(-) diff --git a/crates/buzz-db/src/lib.rs b/crates/buzz-db/src/lib.rs index 1c7202db7e..5e8889fdd2 100644 --- a/crates/buzz-db/src/lib.rs +++ b/crates/buzz-db/src/lib.rs @@ -190,6 +190,17 @@ pub struct CommunityRecord { pub host: String, } +/// Community row returned by idempotent community ensure/create operations. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct EnsuredCommunityRecord { + /// Stable server-resolved community id. + pub id: CommunityId, + /// Normalized host that maps to this community. + pub host: String, + /// True only when this call inserted the `communities` row. + pub created: bool, +} + /// Community row returned by operator-plane ownership reads. #[derive(Debug, Clone, PartialEq, Eq)] pub struct OwnedCommunityRecord { @@ -422,13 +433,13 @@ impl Db { pub async fn ensure_configured_community( &self, normalized_host: &str, - ) -> Result { + ) -> Result { let row = sqlx::query( r#" INSERT INTO communities (host) VALUES ($1) - ON CONFLICT (lower(host)) DO UPDATE SET host = EXCLUDED.host - RETURNING id, host + ON CONFLICT (lower(host)) DO UPDATE SET host = communities.host + RETURNING id, host, (xmax = 0) AS created "#, ) .bind(normalized_host) @@ -437,10 +448,12 @@ impl Db { let id: Uuid = row.try_get("id")?; let host: String = row.try_get("host")?; + let created: bool = row.try_get("created")?; - Ok(CommunityRecord { + Ok(EnsuredCommunityRecord { id: CommunityId::from_uuid(id), host, + created, }) } @@ -2912,6 +2925,28 @@ mod tests { assert_eq!(found.id, CommunityId::from_uuid(id)); } + #[tokio::test] + #[ignore = "requires Postgres"] + async fn ensure_configured_community_reports_insert_winner() { + let db = setup_db().await; + let host = format!("ensure-community-{}.example", Uuid::new_v4().simple()); + + let first = db + .ensure_configured_community(&host) + .await + .expect("first ensure"); + assert!(first.created, "first ensure should report created"); + assert_eq!(first.host, host); + + let second = db + .ensure_configured_community(&host) + .await + .expect("second ensure"); + assert!(!second.created, "second ensure should report existed"); + assert_eq!(second.id, first.id); + assert_eq!(second.host, host); + } + #[tokio::test] #[ignore = "requires Postgres"] async fn list_communities_owned_by_returns_only_owner_rows() { diff --git a/crates/buzz-relay/src/api/bridge.rs b/crates/buzz-relay/src/api/bridge.rs index ed01b95800..752562f0d0 100644 --- a/crates/buzz-relay/src/api/bridge.rs +++ b/crates/buzz-relay/src/api/bridge.rs @@ -31,6 +31,17 @@ pub(crate) fn verify_bridge_auth( url: &str, body: Option<&[u8]>, require_auth_token: bool, +) -> Result<(nostr::PublicKey, [u8; 32]), (StatusCode, Json)> { + verify_bridge_auth_with_options(headers, method, url, body, require_auth_token, false) +} + +pub(crate) fn verify_bridge_auth_with_options( + headers: &HeaderMap, + method: &str, + url: &str, + body: Option<&[u8]>, + require_auth_token: bool, + require_payload: bool, ) -> Result<(nostr::PublicKey, [u8; 32]), (StatusCode, Json)> { // Try NIP-98 first (Authorization: Nostr ) if let Some(auth_str) = headers @@ -51,6 +62,18 @@ pub(crate) fn verify_bridge_auth( .map_err(|_| api_error(StatusCode::UNAUTHORIZED, "invalid NIP-98 event JSON"))?; let event_id_bytes = event.id.to_bytes(); + if require_payload + && !event + .tags + .iter() + .any(|tag| tag.kind() == nostr::TagKind::Payload) + { + return Err(api_error( + StatusCode::UNAUTHORIZED, + "NIP-98: missing payload tag", + )); + } + let pubkey = buzz_auth::verify_nip98_event(&event_json, url, method, body) .map_err(|e| api_error(StatusCode::UNAUTHORIZED, &format!("NIP-98: {e}")))?; @@ -2101,6 +2124,34 @@ mod tests { ); } + #[test] + fn verify_bridge_auth_can_require_payload_tag_for_json_body_endpoints() { + let keys = Keys::generate(); + let signed_url = "https://host-a.example/operator/communities"; + let event_json = build_nip98_event_json(&keys, signed_url, "POST"); + let headers = nip98_auth_headers(&event_json); + + let (status, body) = verify_bridge_auth_with_options( + &headers, + "POST", + signed_url, + Some(br#"{"host":"created.example"}"#), + true, + true, + ) + .expect_err("body-bearing operator requests must require a payload tag"); + + assert_eq!(status, StatusCode::UNAUTHORIZED); + let msg = body + .get("error") + .and_then(|v| v.as_str()) + .unwrap_or_default(); + assert!( + msg.contains("missing payload tag"), + "rejection should explain the payload binding failure; got body = {body:?}" + ); + } + /// Positive control for the cross-host test: a NIP-98 event signed for /// host A MUST be accepted at a request whose tenant resolved to host A. /// Without this, the cross-host test could be passing vacuously (e.g. if diff --git a/crates/buzz-relay/src/api/operator.rs b/crates/buzz-relay/src/api/operator.rs index 9dd867c56c..e8239ec1e9 100644 --- a/crates/buzz-relay/src/api/operator.rs +++ b/crates/buzz-relay/src/api/operator.rs @@ -64,9 +64,13 @@ async fn authorize_operator_request( _ => path.to_string(), }; let url = bridge::nip98_expected_url(&state.config.relay_url, &tenant, &path_with_query); - let (pubkey, event_id_bytes) = bridge::verify_bridge_auth( - headers, method, &url, body, + let (pubkey, event_id_bytes) = bridge::verify_bridge_auth_with_options( + headers, + method, + &url, + body, true, // operator endpoints always require NIP-98; no X-Pubkey dev fallback + body.is_some(), )?; bridge::check_nip98_replay(state, &tenant, event_id_bytes).await?; @@ -208,3 +212,250 @@ pub async fn community_availability( "community_id": existing.map(|record| record.id.to_string()), }))) } + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use axum::{ + body::{to_bytes, Body}, + http::{header, Request, StatusCode}, + }; + use base64::Engine; + use nostr::{EventBuilder, Keys, Kind, Tag}; + use serde_json::Value; + use sha2::{Digest, Sha256}; + use tower::ServiceExt; + use uuid::Uuid; + + use crate::router::build_router; + use crate::state::AppState; + + struct AlwaysFreshReplayGuard; + + impl buzz_auth::Nip98ReplayGuard for AlwaysFreshReplayGuard { + fn try_mark<'a>( + &'a self, + _ctx: &'a buzz_core::TenantContext, + _event_id: &'a nostr::EventId, + _ttl_secs: u64, + ) -> std::pin::Pin< + Box> + Send + 'a>, + > { + Box::pin(async { Ok(true) }) + } + } + + const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; // sadscan:disable np.postgres.1 + const INGRESS_HOST: &str = "operator-ingress.example"; + + fn nip98_auth_header(keys: &Keys, url: &str, method: &str, body: Option<&[u8]>) -> String { + let mut tags = vec![ + Tag::parse(["u", url]).expect("u tag"), + Tag::parse(["method", method]).expect("method tag"), + ]; + if let Some(body) = body { + let hash: [u8; 32] = Sha256::digest(body).into(); + let hash_hex = hex::encode(hash); + tags.push(Tag::parse(["payload", hash_hex.as_str()]).expect("payload tag")); + } + let event = EventBuilder::new(Kind::HttpAuth, "") + .tags(tags) + .sign_with_keys(keys) + .expect("sign NIP-98 event"); + let event_json = serde_json::to_string(&event).expect("serialize NIP-98 event"); + let encoded = base64::engine::general_purpose::STANDARD.encode(event_json.as_bytes()); + format!("Nostr {encoded}") + } + + fn nip98_auth_header_without_payload(keys: &Keys, url: &str, method: &str) -> String { + let tags = vec![ + Tag::parse(["u", url]).expect("u tag"), + Tag::parse(["method", method]).expect("method tag"), + ]; + let event = EventBuilder::new(Kind::HttpAuth, "") + .tags(tags) + .sign_with_keys(keys) + .expect("sign NIP-98 event"); + let event_json = serde_json::to_string(&event).expect("serialize NIP-98 event"); + let encoded = base64::engine::general_purpose::STANDARD.encode(event_json.as_bytes()); + format!("Nostr {encoded}") + } + + async fn operator_test_state(operator_keys: &[Keys]) -> Option> { + let mut config = crate::config::Config::from_env().ok()?; + config.database_url = TEST_DB_URL.to_string(); + config.redis_url = "redis://127.0.0.1:1".to_string(); + config.relay_url = format!("wss://{INGRESS_HOST}"); + config.relay_operator_pubkeys = operator_keys + .iter() + .map(|keys| keys.public_key().to_hex()) + .collect(); + + let pool = sqlx::PgPool::connect(TEST_DB_URL).await.ok()?; + let db = buzz_db::Db::from_pool(pool.clone()); + db.ensure_configured_community(INGRESS_HOST).await.ok()?; + + let redis_pool = deadpool_redis::Config::from_url(&config.redis_url) + .create_pool(Some(deadpool_redis::Runtime::Tokio1)) + .ok()?; + let pubsub = Arc::new( + buzz_pubsub::PubSubManager::new(&config.redis_url, redis_pool.clone()) + .await + .ok()?, + ); + let audit = buzz_audit::AuditService::new(pool.clone()); + let auth = buzz_auth::AuthService::new(config.auth.clone()); + let search = buzz_search::SearchService::new(pool.clone()); + let workflow_engine = Arc::new(buzz_workflow::WorkflowEngine::new( + db.clone(), + buzz_workflow::WorkflowConfig::default(), + )); + let media_storage = buzz_media::MediaStorage::new(&config.media).ok()?; + let (mut state, _audit_shutdown) = AppState::new( + config, + db, + redis_pool, + audit, + pubsub, + auth, + search, + workflow_engine, + Keys::generate(), + media_storage, + ); + state.nip98_replay = Arc::new(AlwaysFreshReplayGuard); + Some(Arc::new(state)) + } + + async fn read_json(response: axum::response::Response) -> Value { + let bytes = to_bytes(response.into_body(), 1024 * 1024) + .await + .expect("read response body"); + serde_json::from_slice(&bytes).expect("response JSON") + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn non_allowlisted_operator_key_gets_403() { + let operator = Keys::generate(); + let outsider = Keys::generate(); + let Some(state) = operator_test_state(&[operator]).await else { + return; + }; + let body = format!( + r#"{{"host":"community-{}.example"}}"#, + Uuid::new_v4().simple() + ); + let url = format!("https://{INGRESS_HOST}/operator/communities"); + let auth = nip98_auth_header(&outsider, &url, "POST", Some(body.as_bytes())); + + let response = build_router(state) + .oneshot( + Request::builder() + .method("POST") + .uri("/operator/communities") + .header(header::HOST, INGRESS_HOST) + .header(header::AUTHORIZATION, auth) + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from(body)) + .expect("request"), + ) + .await + .expect("response"); + + assert_eq!(response.status(), StatusCode::FORBIDDEN); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn post_operator_body_requires_payload_tag() { + let operator = Keys::generate(); + let Some(state) = operator_test_state(&[operator.clone()]).await else { + return; + }; + let body = format!( + r#"{{"host":"community-{}.example"}}"#, + Uuid::new_v4().simple() + ); + let url = format!("https://{INGRESS_HOST}/operator/communities"); + let auth = nip98_auth_header_without_payload(&operator, &url, "POST"); + + let response = build_router(state) + .oneshot( + Request::builder() + .method("POST") + .uri("/operator/communities") + .header(header::HOST, INGRESS_HOST) + .header(header::AUTHORIZATION, auth) + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from(body)) + .expect("request"), + ) + .await + .expect("response"); + + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + let json = read_json(response).await; + assert!( + json.get("error") + .and_then(Value::as_str) + .unwrap_or_default() + .contains("missing payload tag"), + "unexpected response: {json:?}" + ); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn happy_path_create_returns_created_and_bootstraps_owner() { + let operator = Keys::generate(); + let owner = Keys::generate(); + let Some(state) = operator_test_state(&[operator.clone()]).await else { + return; + }; + let host = format!("community-{}.example", Uuid::new_v4().simple()); + let body = serde_json::json!({ + "host": host, + "initial_owner_pubkey": owner.public_key().to_hex(), + }) + .to_string(); + let url = format!("https://{INGRESS_HOST}/operator/communities"); + let auth = nip98_auth_header(&operator, &url, "POST", Some(body.as_bytes())); + + let response = build_router(state.clone()) + .oneshot( + Request::builder() + .method("POST") + .uri("/operator/communities") + .header(header::HOST, INGRESS_HOST) + .header(header::AUTHORIZATION, auth) + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from(body)) + .expect("request"), + ) + .await + .expect("response"); + + assert_eq!(response.status(), StatusCode::OK); + let json = read_json(response).await; + assert_eq!(json.get("status").and_then(Value::as_str), Some("created")); + assert_eq!( + json.get("host").and_then(Value::as_str), + Some(host.as_str()) + ); + let community = state + .db + .lookup_community_by_host(&host) + .await + .expect("lookup community") + .expect("community exists"); + let member = state + .db + .get_relay_member(community.id, &owner.public_key().to_hex()) + .await + .expect("lookup owner role") + .expect("owner member exists"); + assert_eq!(member.role, "owner"); + } +} diff --git a/crates/buzz-relay/src/handlers/community_provisioning.rs b/crates/buzz-relay/src/handlers/community_provisioning.rs index 882c47d398..69c1585456 100644 --- a/crates/buzz-relay/src/handlers/community_provisioning.rs +++ b/crates/buzz-relay/src/handlers/community_provisioning.rs @@ -31,12 +31,12 @@ use serde::{Deserialize, Serialize}; use tracing::info; use buzz_core::tenant::normalize_host; +use url::{Host, Url}; use crate::state::AppState; -/// Maximum accepted host length. Hostnames cap at 253 octets; leave -/// headroom for a `:port` suffix. -const MAX_HOST_LEN: usize = 260; +/// Maximum accepted authority length. Matches `communities.host VARCHAR(255)`. +const MAX_HOST_LEN: usize = 255; /// JSON body for `POST /operator/communities`. #[derive(Debug, Deserialize)] @@ -70,7 +70,7 @@ pub(crate) fn validate_pubkey_hex(value: &str) -> Option { .then_some(normalized) } -/// Validate a normalized host value for a community. +/// Validate a normalized host authority value for a community. /// /// The host must already be in normalized shape (`normalize_host` is a /// no-op on it): lowercase, no default port, no trailing dot. Requiring the @@ -86,20 +86,83 @@ fn validate_host(host: &str) -> Result<(), String> { host.len() )); } - if host.chars().any(|c| c.is_control() || c.is_whitespace()) { + if normalize_host(host) != host { + return Err(format!( + "host is not normalized: expected {:?}", + normalize_host(host) + )); + } + validate_authority(host) +} + +fn validate_authority(authority: &str) -> Result<(), String> { + if authority + .chars() + .any(|c| c.is_control() || c.is_whitespace()) + { return Err("host contains invalid characters".to_string()); } - if host.contains('/') || host.contains('?') || host.contains('#') || host.contains('@') { + if authority.contains('/') + || authority.contains('?') + || authority.contains('#') + || authority.contains('@') + { return Err( "host must be a bare authority (no scheme, path, query, or userinfo)".to_string(), ); } - if normalize_host(host) != host { + + // Parse as an HTTP authority by wrapping it in a URL. This rejects empty + // hosts, malformed bracketed IPv6 literals, and invalid ports while keeping + // the accepted shape aligned with request `Host` authority syntax. + let parsed = Url::parse(&format!("http://{authority}/")) + .map_err(|_| "host is not a valid authority".to_string())?; + let host = parsed + .host() + .ok_or_else(|| "host is not a valid authority".to_string())?; + + let serialized_host = match host { + Host::Domain(domain) => { + validate_domain_labels(domain)?; + domain.to_string() + } + Host::Ipv4(addr) => addr.to_string(), + Host::Ipv6(addr) => format!("[{addr}]"), + }; + let canonical_authority = match parsed.port() { + Some(port) => format!("{serialized_host}:{port}"), + None => serialized_host, + }; + + if canonical_authority != authority { return Err(format!( - "host is not normalized: expected {:?}", - normalize_host(host) + "host is not a canonical authority: expected {canonical_authority:?}" )); } + + Ok(()) +} + +fn validate_domain_labels(domain: &str) -> Result<(), String> { + if domain.len() > 253 { + return Err("domain name too long".to_string()); + } + for label in domain.split('.') { + if label.is_empty() { + return Err("domain contains an empty label".to_string()); + } + if label.len() > 63 { + return Err("domain label too long".to_string()); + } + let valid_label = label + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b == b'-') + && !label.starts_with('-') + && !label.ends_with('-'); + if !valid_label { + return Err("domain label contains invalid characters".to_string()); + } + } Ok(()) } @@ -182,15 +245,10 @@ pub async fn provision_community( }) .transpose()?; - let existed = state - .db - .lookup_community_by_host(&request.host) - .await - .map_err(|e| format!("database error: {e}"))? - .is_some(); - // Same idempotent upsert as the startup seed — creating a community is an - // INSERT, never DDL (docs/multi-tenant-relay.md §System Model). + // INSERT, never DDL (docs/multi-tenant-relay.md §System Model). The helper + // returns whether this call inserted the row, so concurrent provisioners do + // not both report `created` after an upsert conflict. let record = state .db .ensure_configured_community(&request.host) @@ -210,14 +268,14 @@ pub async fn provision_community( community = %record.id, host = %record.host, owner = initial_owner.as_deref().unwrap_or(""), - existed, + created = record.created, "community provisioned via operator endpoint" ); Ok(ProvisionCommunityResponse { community_id: record.id.to_string(), host: record.host, - status: if existed { "existed" } else { "created" }, + status: if record.created { "created" } else { "existed" }, owner_pubkey: initial_owner, }) } @@ -266,6 +324,19 @@ mod tests { assert!(validate_host("acme.example#frag").is_err()); } + #[test] + fn host_rejects_invalid_authorities() { + assert!(validate_host(":").is_err()); + assert!(validate_host("example..com").is_err()); + assert!(validate_host("foo_bar.example").is_err()); + assert!(validate_host("-bad.example").is_err()); + assert!(validate_host("bad-.example").is_err()); + assert!(validate_host("example.com:99999").is_err()); + assert!(validate_host("[::1").is_err()); + assert!(validate_host("[not-ipv6]").is_err()); + assert!(validate_host(&format!("{}.example", "a".repeat(64))).is_err()); + } + #[test] fn host_rejects_whitespace_and_control() { assert!(validate_host("acme .example").is_err()); From eb73f23d41fd256eb7b87568a894950a46e1b997 Mon Sep 17 00:00:00 2001 From: npub1qvn3cujt28pg06ehlstrxyz6ayzp06t4uc7r566vxwwgrv24hglq9zju0n <03271c724b51c287eb37fc1633105ae90417e975e63c3a6b4c339c81b155ba3e@sprout-oss.stage.blox.sqprod.co> Date: Wed, 8 Jul 2026 16:27:26 -0700 Subject: [PATCH 3/3] fix(relay): satisfy operator test clippy Co-authored-by: npub1qvn3cujt28pg06ehlstrxyz6ayzp06t4uc7r566vxwwgrv24hglq9zju0n <03271c724b51c287eb37fc1633105ae90417e975e63c3a6b4c339c81b155ba3e@sprout-oss.stage.blox.sqprod.co> Signed-off-by: npub1qvn3cujt28pg06ehlstrxyz6ayzp06t4uc7r566vxwwgrv24hglq9zju0n <03271c724b51c287eb37fc1633105ae90417e975e63c3a6b4c339c81b155ba3e@sprout-oss.stage.blox.sqprod.co> --- crates/buzz-relay/src/api/operator.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/buzz-relay/src/api/operator.rs b/crates/buzz-relay/src/api/operator.rs index e8239ec1e9..359d43ac56 100644 --- a/crates/buzz-relay/src/api/operator.rs +++ b/crates/buzz-relay/src/api/operator.rs @@ -371,7 +371,7 @@ mod tests { #[ignore = "requires Postgres"] async fn post_operator_body_requires_payload_tag() { let operator = Keys::generate(); - let Some(state) = operator_test_state(&[operator.clone()]).await else { + let Some(state) = operator_test_state(std::slice::from_ref(&operator)).await else { return; }; let body = format!( @@ -411,7 +411,7 @@ mod tests { async fn happy_path_create_returns_created_and_bootstraps_owner() { let operator = Keys::generate(); let owner = Keys::generate(); - let Some(state) = operator_test_state(&[operator.clone()]).await else { + let Some(state) = operator_test_state(std::slice::from_ref(&operator)).await else { return; }; let host = format!("community-{}.example", Uuid::new_v4().simple());