Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 79 additions & 2 deletions crates/buzz-db/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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<Utc>,
}

/// Token summary returned by [`Db::list_active_tokens`].
#[derive(Debug, Clone)]
pub struct TokenSummary {
Expand Down Expand Up @@ -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<Vec<OwnedCommunityRecord>> {
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<Utc> = 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.
///
Expand Down Expand Up @@ -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<u8> = vec![0u8; 32];
sqlx::query(
Expand Down
18 changes: 11 additions & 7 deletions crates/buzz-relay/src/api/bridge.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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],
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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(
Expand All @@ -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
{
Expand Down
1 change: 1 addition & 0 deletions crates/buzz-relay/src/api/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down
210 changes: 210 additions & 0 deletions crates/buzz-relay/src/api/operator.rs
Original file line number Diff line number Diff line change
@@ -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<AppState>,
headers: &HeaderMap,
method: &str,
path: &str,
raw_query: Option<&str>,
body: Option<&[u8]>,
) -> Result<nostr::PublicKey, (StatusCode, Json<Value>)> {
// 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": "<hex>" }
/// ```
///
/// 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<Arc<AppState>>,
headers: HeaderMap,
body: axum::body::Bytes,
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
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<Arc<AppState>>,
headers: HeaderMap,
RawQuery(raw_query): RawQuery,
Query(query): Query<ListCommunitiesQuery>,
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
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::<Vec<_>>(),
})))
}

/// Check whether a community host is available, returning the relay-canonical
/// normalized authority used by create.
pub async fn community_availability(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
RawQuery(raw_query): RawQuery,
Query(query): Query<CommunityAvailabilityQuery>,
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
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()),
})))
}
Loading
Loading