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] 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 eb791e441..1c7202db7 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 affea6919..ed01b9580 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 10a88002b..dbdf94a5c 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 000000000..9dd867c56 --- /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 eaa67a052..7e7df2019 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 000000000..882c47d39 --- /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 4b348a520..2a47c7bb5 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/")