Skip to content
Merged
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
124 changes: 118 additions & 6 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,28 @@ 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 {
/// 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 +316,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 @@ -374,13 +433,13 @@ impl Db {
pub async fn ensure_configured_community(
&self,
normalized_host: &str,
) -> Result<CommunityRecord> {
) -> Result<EnsuredCommunityRecord> {
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)
Expand All @@ -389,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,
})
}

Expand Down Expand Up @@ -2864,6 +2925,57 @@ 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() {
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
69 changes: 62 additions & 7 deletions crates/buzz-relay/src/api/bridge.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,12 +25,23 @@ 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,
body: Option<&[u8]>,
require_auth_token: bool,
) -> Result<(nostr::PublicKey, [u8; 32]), (StatusCode, Json<Value>)> {
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<Value>)> {
// Try NIP-98 first (Authorization: Nostr <base64>)
if let Some(auth_str) = headers
Expand All @@ -51,6 +62,18 @@ 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}")))?;

Expand All @@ -76,7 +99,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 +158,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 +587,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 +601,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 Expand Up @@ -2097,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
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
Loading
Loading