diff --git a/crates/buzz-admin/src/main.rs b/crates/buzz-admin/src/main.rs index f45e57371..4e3ef89b5 100644 --- a/crates/buzz-admin/src/main.rs +++ b/crates/buzz-admin/src/main.rs @@ -145,7 +145,11 @@ async fn cmd_add_member(pubkey_arg: String, role: String) -> Result { let (db, pubsub, relay_keypair) = connect_member_services().await?; - match db.add_relay_member(&pubkey_hex, &role, None).await { + let tenant = resolve_admin_tenant(&db).await?; + match db + .add_relay_member(tenant.community(), &pubkey_hex, &role, None) + .await + { Ok(true) => println!("added {pubkey_hex} as {role}"), Ok(false) => println!("already a member: {pubkey_hex} (no change)"), Err(e) => { @@ -154,7 +158,6 @@ async fn cmd_add_member(pubkey_arg: String, role: String) -> Result { } } - let tenant = resolve_admin_tenant(&db).await?; if let Err(e) = publish_membership_list_with_bump(&db, &pubsub, &relay_keypair, &tenant).await { eprintln!("warning: member added to DB but list publish failed: {e}"); } @@ -180,11 +183,13 @@ async fn cmd_remove_member(pubkey_arg: String, role_filter: Option) -> R let (db, pubsub, relay_keypair) = connect_member_services().await?; + let tenant = resolve_admin_tenant(&db).await?; use buzz_db::relay_members::RemoveResult; let result = if let Some(ref role) = role_filter { - db.remove_relay_member_if_role(&pubkey_hex, role).await + db.remove_relay_member_if_role(tenant.community(), &pubkey_hex, role) + .await } else { - db.remove_relay_member(&pubkey_hex).await + db.remove_relay_member(tenant.community(), &pubkey_hex).await }; match result { @@ -211,7 +216,6 @@ async fn cmd_remove_member(pubkey_arg: String, role_filter: Option) -> R } } - let tenant = resolve_admin_tenant(&db).await?; if let Err(e) = publish_membership_list_with_bump(&db, &pubsub, &relay_keypair, &tenant).await { eprintln!("warning: member removed from DB but list publish failed: {e}"); } @@ -221,7 +225,8 @@ async fn cmd_remove_member(pubkey_arg: String, role_filter: Option) -> R async fn cmd_list_members() -> Result { let db = connect_db().await?; - let members = db.list_relay_members().await?; + let tenant = resolve_admin_tenant(&db).await?; + let members = db.list_relay_members(tenant.community()).await?; if members.is_empty() { println!("(no relay members)"); @@ -303,7 +308,7 @@ async fn publish_membership_list_with_bump( None => now, }; - let members = db.list_relay_members().await?; + let members = db.list_relay_members(tenant.community()).await?; let mut tags: Vec = Vec::with_capacity(members.len() + 1); // NIP-70 protected-event marker — prevents re-broadcasting by third parties. diff --git a/crates/buzz-db/src/lib.rs b/crates/buzz-db/src/lib.rs index 71474f5ce..c6fbbc35e 100644 --- a/crates/buzz-db/src/lib.rs +++ b/crates/buzz-db/src/lib.rs @@ -1799,71 +1799,86 @@ impl Db { Ok(out) } - /// Returns `true` if `pubkey` (64-char hex) is in the relay member list. - pub async fn is_relay_member(&self, pubkey: &str) -> Result { - relay_members::is_relay_member(&self.pool, pubkey).await + /// Returns `true` if `pubkey` (64-char hex) is a member of `community`. + pub async fn is_relay_member(&self, community: CommunityId, pubkey: &str) -> Result { + relay_members::is_relay_member(&self.pool, community, pubkey).await } - /// Returns the relay member record for `pubkey`, or `None` if not found. + /// Returns the relay member record for `pubkey` in `community`, or `None` if not found. pub async fn get_relay_member( &self, + community: CommunityId, pubkey: &str, ) -> Result> { - relay_members::get_relay_member(&self.pool, pubkey).await + relay_members::get_relay_member(&self.pool, community, pubkey).await } - /// Returns all relay members ordered by `created_at` ascending. - pub async fn list_relay_members(&self) -> Result> { - relay_members::list_relay_members(&self.pool).await + /// Returns all relay members of `community` ordered by `created_at` ascending. + pub async fn list_relay_members( + &self, + community: CommunityId, + ) -> Result> { + relay_members::list_relay_members(&self.pool, community).await } - /// Adds a new relay member. No-ops silently if the pubkey already exists (idempotent). - /// Adds a new relay member. + /// Adds a new relay member to `community`. /// /// Returns `true` if the row was actually inserted, `false` if the pubkey - /// already existed (idempotent — `ON CONFLICT DO NOTHING`). + /// already existed in `community` (idempotent — `ON CONFLICT DO NOTHING`). pub async fn add_relay_member( &self, + community: CommunityId, pubkey: &str, role: &str, added_by: Option<&str>, ) -> Result { - relay_members::add_relay_member(&self.pool, pubkey, role, added_by).await + relay_members::add_relay_member(&self.pool, community, pubkey, role, added_by).await } - /// Removes a relay member atomically, refusing to delete the owner. - pub async fn remove_relay_member(&self, pubkey: &str) -> Result { - relay_members::remove_relay_member(&self.pool, pubkey).await + /// Removes a relay member from `community` atomically, refusing to delete the owner. + pub async fn remove_relay_member( + &self, + community: CommunityId, + pubkey: &str, + ) -> Result { + relay_members::remove_relay_member(&self.pool, community, pubkey).await } - /// Removes a relay member only if their current role matches `expected_role`. + /// Removes a relay member from `community` only if their current role matches `expected_role`. /// /// Atomic conditional delete — eliminates the TOCTOU race between a /// prior role read and the delete. See [`relay_members::remove_relay_member_if_role`]. pub async fn remove_relay_member_if_role( &self, + community: CommunityId, pubkey: &str, expected_role: &str, ) -> Result { - relay_members::remove_relay_member_if_role(&self.pool, pubkey, expected_role).await + relay_members::remove_relay_member_if_role(&self.pool, community, pubkey, expected_role) + .await } - /// Updates the role of an existing relay member. Returns `true` if updated. - pub async fn update_relay_member_role(&self, pubkey: &str, new_role: &str) -> Result { - relay_members::update_relay_member_role(&self.pool, pubkey, new_role).await + /// Updates the role of an existing relay member in `community`. Returns `true` if updated. + pub async fn update_relay_member_role( + &self, + community: CommunityId, + pubkey: &str, + new_role: &str, + ) -> Result { + relay_members::update_relay_member_role(&self.pool, community, pubkey, new_role).await } - /// Ensures the owner pubkey exists with role `"owner"`. Called at startup. - pub async fn bootstrap_owner(&self, owner_pubkey: &str) -> Result<()> { - relay_members::bootstrap_owner(&self.pool, owner_pubkey).await + /// Ensures the owner pubkey exists with role `"owner"` in `community`. Called at startup. + pub async fn bootstrap_owner(&self, community: CommunityId, owner_pubkey: &str) -> Result<()> { + relay_members::bootstrap_owner(&self.pool, community, owner_pubkey).await } - /// Migrates existing `pubkey_allowlist` entries into `relay_members`. + /// Migrates existing `pubkey_allowlist` entries into `relay_members` for `community`. /// /// Idempotent — uses `ON CONFLICT DO NOTHING`. Returns the number of rows /// inserted, or 0 if the `pubkey_allowlist` table doesn't exist. - pub async fn backfill_from_allowlist(&self) -> Result { - relay_members::backfill_from_allowlist(&self.pool).await + pub async fn backfill_from_allowlist(&self, community: CommunityId) -> Result { + relay_members::backfill_from_allowlist(&self.pool, community).await } /// Returns `true` if `pubkey` (64-char hex) is archived in `community_id`. diff --git a/crates/buzz-db/src/relay_members.rs b/crates/buzz-db/src/relay_members.rs index 8a7dc6d82..ce363b3a6 100644 --- a/crates/buzz-db/src/relay_members.rs +++ b/crates/buzz-db/src/relay_members.rs @@ -1,12 +1,16 @@ //! Relay-level membership persistence (NIP-43). //! -//! The `relay_members` table stores pubkeys (hex), roles, and audit metadata. -//! All pubkey values are 64-char lowercase hex strings. +//! The `relay_members` table is community-scoped: its primary key is +//! `(community_id, pubkey)`. Every read, write, and list is bound to a single +//! `community_id` so that admitting a pubkey to community A never admits it to +//! community B (NIP-43 admission confinement). `pubkey` values are 64-char +//! lowercase hex strings. use chrono::{DateTime, Utc}; use sqlx::{PgPool, Row as _}; use crate::error::Result; +use crate::CommunityId; /// A single relay member record. #[derive(Debug, Clone)] @@ -23,21 +27,31 @@ pub struct RelayMember { pub updated_at: DateTime, } -/// Returns `true` if `pubkey` (64-char hex) is in the relay member list. -pub async fn is_relay_member(pool: &PgPool, pubkey: &str) -> Result { - let row = sqlx::query("SELECT 1 FROM relay_members WHERE pubkey = $1") +/// Returns `true` if `pubkey` (64-char hex) is a member of `community`. +pub async fn is_relay_member( + pool: &PgPool, + community: CommunityId, + pubkey: &str, +) -> Result { + let row = sqlx::query("SELECT 1 FROM relay_members WHERE community_id = $1 AND pubkey = $2") + .bind(community.as_uuid()) .bind(pubkey) .fetch_optional(pool) .await?; Ok(row.is_some()) } -/// Returns the relay member record for `pubkey`, or `None` if not found. -pub async fn get_relay_member(pool: &PgPool, pubkey: &str) -> Result> { +/// Returns the relay member record for `pubkey` in `community`, or `None`. +pub async fn get_relay_member( + pool: &PgPool, + community: CommunityId, + pubkey: &str, +) -> Result> { let row = sqlx::query( "SELECT pubkey, role, added_by, created_at, updated_at \ - FROM relay_members WHERE pubkey = $1", + FROM relay_members WHERE community_id = $1 AND pubkey = $2", ) + .bind(community.as_uuid()) .bind(pubkey) .fetch_optional(pool) .await?; @@ -55,12 +69,16 @@ pub async fn get_relay_member(pool: &PgPool, pubkey: &str) -> Result Result> { +/// Returns all relay members of `community` ordered by `created_at` ascending. +pub async fn list_relay_members( + pool: &PgPool, + community: CommunityId, +) -> Result> { let rows = sqlx::query( "SELECT pubkey, role, added_by, created_at, updated_at \ - FROM relay_members ORDER BY created_at ASC", + FROM relay_members WHERE community_id = $1 ORDER BY created_at ASC", ) + .bind(community.as_uuid()) .fetch_all(pool) .await?; @@ -78,20 +96,23 @@ pub async fn list_relay_members(pool: &PgPool) -> Result> { .map_err(crate::error::DbError::from) } -/// Adds a new relay member. +/// Adds a new relay member to `community`. /// /// Returns `true` if the row was actually inserted, `false` if the pubkey -/// already existed (idempotent — `ON CONFLICT DO NOTHING`). +/// already existed in this community (idempotent — `ON CONFLICT DO NOTHING` on +/// the `(community_id, pubkey)` primary key). pub async fn add_relay_member( pool: &PgPool, + community: CommunityId, pubkey: &str, role: &str, added_by: Option<&str>, ) -> Result { let result = sqlx::query( - "INSERT INTO relay_members (pubkey, role, added_by) \ - VALUES ($1, $2, $3) ON CONFLICT (pubkey) DO NOTHING", + "INSERT INTO relay_members (community_id, pubkey, role, added_by) \ + VALUES ($1, $2, $3, $4) ON CONFLICT (community_id, pubkey) DO NOTHING", ) + .bind(community.as_uuid()) .bind(pubkey) .bind(role) .bind(added_by) @@ -118,11 +139,19 @@ pub enum RemoveResult { /// Uses a single conditional `DELETE … WHERE role <> 'owner'` so the /// owner-protection check and the deletion are one atomic operation — /// no TOCTOU race between a separate read and delete. -pub async fn remove_relay_member(pool: &PgPool, pubkey: &str) -> Result { - let result = sqlx::query("DELETE FROM relay_members WHERE pubkey = $1 AND role <> 'owner'") - .bind(pubkey) - .execute(pool) - .await?; +pub async fn remove_relay_member( + pool: &PgPool, + community: CommunityId, + pubkey: &str, +) -> Result { + let result = sqlx::query( + "DELETE FROM relay_members \ + WHERE community_id = $1 AND pubkey = $2 AND role <> 'owner'", + ) + .bind(community.as_uuid()) + .bind(pubkey) + .execute(pool) + .await?; if result.rows_affected() > 0 { return Ok(RemoveResult::Removed); @@ -130,7 +159,8 @@ pub async fn remove_relay_member(pool: &PgPool, pubkey: &str) -> Result Result Result { - let result = sqlx::query("DELETE FROM relay_members WHERE pubkey = $1 AND role = $2") - .bind(pubkey) - .bind(expected_role) - .execute(pool) - .await?; + let result = sqlx::query( + "DELETE FROM relay_members WHERE community_id = $1 AND pubkey = $2 AND role = $3", + ) + .bind(community.as_uuid()) + .bind(pubkey) + .bind(expected_role) + .execute(pool) + .await?; if result.rows_affected() > 0 { return Ok(RemoveResult::Removed); @@ -172,7 +206,8 @@ pub async fn remove_relay_member_if_role( // rows_affected == 0: either not found or role changed. One cheap read to // distinguish the cases so callers can return the right error message. - let row = sqlx::query("SELECT role FROM relay_members WHERE pubkey = $1") + let row = sqlx::query("SELECT role FROM relay_members WHERE community_id = $1 AND pubkey = $2") + .bind(community.as_uuid()) .bind(pubkey) .fetch_optional(pool) .await?; @@ -193,42 +228,58 @@ pub async fn remove_relay_member_if_role( } } -/// Updates the role of an existing relay member. Returns `true` if updated. -pub async fn update_relay_member_role(pool: &PgPool, pubkey: &str, new_role: &str) -> Result { +/// Updates the role of an existing relay member in `community`. Returns `true` +/// if updated. +pub async fn update_relay_member_role( + pool: &PgPool, + community: CommunityId, + pubkey: &str, + new_role: &str, +) -> Result { let result = sqlx::query( - "UPDATE relay_members SET role = $1, updated_at = now() WHERE pubkey = $2 AND role <> 'owner'", + "UPDATE relay_members SET role = $1, updated_at = now() \ + WHERE community_id = $2 AND pubkey = $3 AND role <> 'owner'", ) .bind(new_role) + .bind(community.as_uuid()) .bind(pubkey) .execute(pool) .await?; Ok(result.rows_affected() > 0) } -/// Ensures the configured owner pubkey holds the `"owner"` role, and demotes -/// any other owners to `"admin"`. This handles owner rotation: if -/// `RELAY_OWNER_PUBKEY` changes, the old owner is automatically demoted. +/// Ensures the configured owner pubkey holds the `"owner"` role *in +/// `community`*, and demotes any other owners in that community to `"admin"`. +/// This handles owner rotation: if `RELAY_OWNER_PUBKEY` changes, the old owner +/// is automatically demoted. Scoped to one community — an owner of community A +/// is never bootstrapped into community B. /// /// Runs in a single transaction. Safe to call at every startup — idempotent. -pub async fn bootstrap_owner(pool: &PgPool, owner_pubkey: &str) -> Result<()> { +pub async fn bootstrap_owner( + pool: &PgPool, + community: CommunityId, + owner_pubkey: &str, +) -> Result<()> { let pubkey = owner_pubkey.to_ascii_lowercase(); let mut tx = pool.begin().await?; - // 1. Upsert the configured owner. + // 1. Upsert the configured owner for this community. sqlx::query( - "INSERT INTO relay_members (pubkey, role, added_by) \ - VALUES ($1, 'owner', NULL) \ - ON CONFLICT (pubkey) DO UPDATE SET role = 'owner', updated_at = now()", + "INSERT INTO relay_members (community_id, pubkey, role, added_by) \ + VALUES ($1, $2, 'owner', NULL) \ + ON CONFLICT (community_id, pubkey) DO UPDATE SET role = 'owner', updated_at = now()", ) + .bind(community.as_uuid()) .bind(&pubkey) .execute(&mut *tx) .await?; - // 2. Demote any other owners to admin. + // 2. Demote any other owners in this community to admin. sqlx::query( "UPDATE relay_members SET role = 'admin', updated_at = now() \ - WHERE role = 'owner' AND pubkey <> $1", + WHERE community_id = $1 AND role = 'owner' AND pubkey <> $2", ) + .bind(community.as_uuid()) .bind(&pubkey) .execute(&mut *tx) .await?; @@ -237,16 +288,18 @@ pub async fn bootstrap_owner(pool: &PgPool, owner_pubkey: &str) -> Result<()> { Ok(()) } -/// Migrates existing `pubkey_allowlist` entries into `relay_members`. +/// Migrates existing `pubkey_allowlist` entries into `relay_members` for +/// `community` (the deployment's default community). /// -/// Converts BYTEA pubkeys to lowercase hex text and inserts them as members. -/// Returns the number of rows inserted, or 0 if: +/// Converts BYTEA pubkeys to lowercase hex text and inserts them as members of +/// `community`. Returns the number of rows inserted, or 0 if: /// - the `pubkey_allowlist` table doesn't exist, or -/// - `relay_members` already has rows (migration ran in a prior startup). +/// - `relay_members` already has rows for this community (migration ran in a +/// prior startup). /// /// The empty-table guard prevents re-adding members that were intentionally /// removed by an admin after the initial backfill. -pub async fn backfill_from_allowlist(pool: &PgPool) -> Result { +pub async fn backfill_from_allowlist(pool: &PgPool, community: CommunityId) -> Result { // Check if pubkey_allowlist table exists. let exists: bool = sqlx::query_scalar( "SELECT EXISTS (SELECT 1 FROM information_schema.tables \ @@ -259,25 +312,155 @@ pub async fn backfill_from_allowlist(pool: &PgPool) -> Result { return Ok(0); } - // Only backfill if relay_members is empty — once the table has rows - // (from a previous backfill or manual admin commands), we must not + // Only backfill if this community's relay_members is empty — once it has + // rows (from a previous backfill or manual admin commands), we must not // re-add members that were intentionally removed. - let has_members: bool = sqlx::query_scalar("SELECT EXISTS (SELECT 1 FROM relay_members)") - .fetch_one(pool) - .await?; + let has_members: bool = sqlx::query_scalar( + "SELECT EXISTS (SELECT 1 FROM relay_members WHERE community_id = $1)", + ) + .bind(community.as_uuid()) + .fetch_one(pool) + .await?; if has_members { return Ok(0); } let result = sqlx::query( - "INSERT INTO relay_members (pubkey, role, added_by, created_at) \ - SELECT encode(pubkey, 'hex'), 'member', NULL, added_at \ + "INSERT INTO relay_members (community_id, pubkey, role, added_by, created_at) \ + SELECT $1, encode(pubkey, 'hex'), 'member', NULL, added_at \ FROM pubkey_allowlist \ - ON CONFLICT (pubkey) DO NOTHING", + ON CONFLICT (community_id, pubkey) DO NOTHING", ) + .bind(community.as_uuid()) .execute(pool) .await?; Ok(result.rows_affected()) } + +#[cfg(test)] +mod tests { + use super::*; + use uuid::Uuid; + + const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; + + async fn setup_pool() -> PgPool { + let database_url = std::env::var("BUZZ_TEST_DATABASE_URL") + .or_else(|_| std::env::var("DATABASE_URL")) + .unwrap_or_else(|_| TEST_DB_URL.to_owned()); + PgPool::connect(&database_url) + .await + .expect("connect to test DB") + } + + async fn make_test_community(pool: &PgPool) -> CommunityId { + let id = Uuid::new_v4(); + let host = format!("relay-members-test-{}.example", id.simple()); + sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)") + .bind(id) + .bind(host) + .execute(pool) + .await + .expect("insert test community"); + CommunityId::from_uuid(id) + } + + /// NIP-43 admission confinement: a pubkey admitted to community A is *not* + /// admitted to community B. This is the exact mutation #1285 targets — a + /// `WHERE pubkey = $1` membership check (no community predicate) would let an + /// A-member authenticate against B. We add the pubkey only to A and assert + /// every read path (`is_relay_member`, `get_relay_member`, `list_relay_members`) + /// confines it to A. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn membership_is_confined_to_its_community() { + let pool = setup_pool().await; + let community_a = make_test_community(&pool).await; + let community_b = make_test_community(&pool).await; + // 64-char lowercase hex, unique per run so reruns don't collide. + let pubkey = format!("{:064x}", Uuid::new_v4().as_u128()); + + let inserted = add_relay_member(&pool, community_a, &pubkey, "member", None) + .await + .expect("add member to community A"); + assert!(inserted, "first insert into A should report inserted"); + + // is_relay_member: member of A, NOT of B. + assert!( + is_relay_member(&pool, community_a, &pubkey) + .await + .expect("is_relay_member A"), + "pubkey must be a member of community A" + ); + assert!( + !is_relay_member(&pool, community_b, &pubkey) + .await + .expect("is_relay_member B"), + "pubkey admitted to A must NOT be a member of B (admission confinement)" + ); + + // get_relay_member (used by the NIP-OA owner check + admin role lookups): + // resolves in A, absent in B. + assert!( + get_relay_member(&pool, community_a, &pubkey) + .await + .expect("get_relay_member A") + .is_some(), + "get_relay_member must resolve in community A" + ); + assert!( + get_relay_member(&pool, community_b, &pubkey) + .await + .expect("get_relay_member B") + .is_none(), + "get_relay_member must not resolve the A pubkey in community B" + ); + + // list_relay_members: B's list never contains A's member. + let list_a = list_relay_members(&pool, community_a) + .await + .expect("list A"); + assert!( + list_a.iter().any(|m| m.pubkey == pubkey), + "community A list must contain the admitted pubkey" + ); + let list_b = list_relay_members(&pool, community_b) + .await + .expect("list B"); + assert!( + list_b.iter().all(|m| m.pubkey != pubkey), + "community B list must not contain A's member" + ); + } + + /// Owner bootstrap is community-scoped: bootstrapping the owner in A does not + /// make that pubkey an owner (or member) of B. Guards against a global + /// `INSERT ... (pubkey, role)` bootstrap leaking the owner across tenants. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn owner_bootstrap_is_confined_to_its_community() { + let pool = setup_pool().await; + let community_a = make_test_community(&pool).await; + let community_b = make_test_community(&pool).await; + let owner = format!("{:064x}", Uuid::new_v4().as_u128()); + + bootstrap_owner(&pool, community_a, &owner) + .await + .expect("bootstrap owner in A"); + + let in_a = get_relay_member(&pool, community_a, &owner) + .await + .expect("get owner A") + .expect("owner exists in A"); + assert_eq!(in_a.role, "owner", "bootstrapped pubkey must be owner in A"); + + assert!( + !is_relay_member(&pool, community_b, &owner) + .await + .expect("is_relay_member B"), + "owner bootstrapped in A must NOT be a member of B" + ); + } +} diff --git a/crates/buzz-relay/src/api/bridge.rs b/crates/buzz-relay/src/api/bridge.rs index dc03c29cd..9f5c0fff7 100644 --- a/crates/buzz-relay/src/api/bridge.rs +++ b/crates/buzz-relay/src/api/bridge.rs @@ -261,7 +261,13 @@ pub async fn submit_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(&state, &pubkey_bytes, auth_tag).await?; + super::relay_members::enforce_relay_membership( + &state, + tenant.community(), + &pubkey_bytes, + auth_tag, + ) + .await?; let event: nostr::Event = serde_json::from_slice(&body) .map_err(|e| api_error(StatusCode::BAD_REQUEST, &format!("invalid event JSON: {e}")))?; @@ -349,7 +355,13 @@ pub async fn query_events( let pubkey_bytes = pubkey.to_bytes().to_vec(); let auth_tag = headers.get("x-auth-tag").and_then(|v| v.to_str().ok()); - super::relay_members::enforce_relay_membership(&state, &pubkey_bytes, auth_tag).await?; + super::relay_members::enforce_relay_membership( + &state, + tenant.community(), + &pubkey_bytes, + auth_tag, + ) + .await?; // Two-pass parse: preserve raw JSON for custom extension fields (before_id, // depth_limit, feed_types) that nostr::Filter silently drops. @@ -624,7 +636,13 @@ pub async fn count_events( let pubkey_bytes = pubkey.to_bytes().to_vec(); let auth_tag = headers.get("x-auth-tag").and_then(|v| v.to_str().ok()); - super::relay_members::enforce_relay_membership(&state, &pubkey_bytes, auth_tag).await?; + super::relay_members::enforce_relay_membership( + &state, + tenant.community(), + &pubkey_bytes, + auth_tag, + ) + .await?; let filters: Vec = serde_json::from_slice(&body) .map_err(|e| api_error(StatusCode::BAD_REQUEST, &format!("invalid filters: {e}")))?; diff --git a/crates/buzz-relay/src/api/git/transport.rs b/crates/buzz-relay/src/api/git/transport.rs index 7d61569a9..6da80b8b6 100644 --- a/crates/buzz-relay/src/api/git/transport.rs +++ b/crates/buzz-relay/src/api/git/transport.rs @@ -182,9 +182,14 @@ impl axum::extract::FromRequestParts> for GitAuth { .headers .get("x-auth-tag") .and_then(|v| v.to_str().ok()); - if crate::api::relay_members::enforce_relay_membership(state, pubkey.as_bytes(), auth_tag) - .await - .is_err() + if crate::api::relay_members::enforce_relay_membership( + state, + tenant.community(), + pubkey.as_bytes(), + auth_tag, + ) + .await + .is_err() { warn!(pubkey = %pubkey.to_hex(), "git: relay membership denied"); return Err((StatusCode::FORBIDDEN, "restricted: not a relay member").into_response()); diff --git a/crates/buzz-relay/src/api/media.rs b/crates/buzz-relay/src/api/media.rs index c3e86b238..7c9ee79bd 100644 --- a/crates/buzz-relay/src/api/media.rs +++ b/crates/buzz-relay/src/api/media.rs @@ -112,6 +112,7 @@ impl FromRequestParts> for AuthenticatedUpload { let auth_tag = headers.get("x-auth-tag").and_then(|v| v.to_str().ok()); crate::api::relay_members::enforce_relay_membership( state, + tenant.community(), auth_event.pubkey.as_bytes(), auth_tag, ) diff --git a/crates/buzz-relay/src/api/mod.rs b/crates/buzz-relay/src/api/mod.rs index 30c56b987..856c16711 100644 --- a/crates/buzz-relay/src/api/mod.rs +++ b/crates/buzz-relay/src/api/mod.rs @@ -32,6 +32,7 @@ pub(crate) fn not_found(msg: &str) -> (StatusCode, Json) { /// `git/transport.rs`, and `audio/handler.rs`. pub mod relay_members { use axum::{http::StatusCode, response::Json}; + use buzz_core::tenant::CommunityId; use tracing::{debug, info}; use crate::state::AppState; @@ -50,8 +51,12 @@ pub mod relay_members { } /// Check relay membership without committing to an HTTP response shape. + /// + /// `community` is the server-resolved tenant of the request; membership is + /// scoped to it so admitting a pubkey to community A never admits it to B. pub async fn check_relay_membership( state: &AppState, + community: CommunityId, pubkey_bytes: &[u8], auth_tag_header: Option<&str>, ) -> Result { @@ -62,7 +67,7 @@ pub mod relay_members { let pubkey_hex = hex::encode(pubkey_bytes); let is_member = state .db - .is_relay_member(&pubkey_hex) + .is_relay_member(community, &pubkey_hex) .await .map_err(|e| format!("relay membership check failed: {e}"))?; if is_member { @@ -77,8 +82,11 @@ pub mod relay_members { match buzz_sdk::nip_oa::verify_auth_tag(tag_json, &agent_pubkey) { Ok(owner_pubkey) => { let owner_hex = owner_pubkey.to_hex(); - let owner_is_member = - state.db.is_relay_member(&owner_hex).await.map_err(|e| { + let owner_is_member = state + .db + .is_relay_member(community, &owner_hex) + .await + .map_err(|e| { format!("relay membership check (owner) failed: {e}") })?; if owner_is_member { @@ -113,10 +121,11 @@ pub mod relay_members { /// no NIP-OA tag is present/applicable (open relay without auth tag). pub async fn enforce_relay_membership( state: &AppState, + community: CommunityId, pubkey_bytes: &[u8], auth_tag_header: Option<&str>, ) -> Result, (StatusCode, Json)> { - match check_relay_membership(state, pubkey_bytes, auth_tag_header).await { + match check_relay_membership(state, community, pubkey_bytes, auth_tag_header).await { Ok(MembershipDecision::OpenRelay) | Ok(MembershipDecision::Member) => Ok(None), Ok(MembershipDecision::ViaOwner(owner)) => Ok(Some(owner)), Ok(MembershipDecision::Denied) => Err(( diff --git a/crates/buzz-relay/src/audio/handler.rs b/crates/buzz-relay/src/audio/handler.rs index 56ba0e6c1..115ca7d7a 100644 --- a/crates/buzz-relay/src/audio/handler.rs +++ b/crates/buzz-relay/src/audio/handler.rs @@ -182,6 +182,7 @@ async fn handle_audio_connection( if crate::api::relay_members::enforce_relay_membership( &state, + tenant.community(), pubkey.as_bytes(), auth_tag_json.as_deref(), ) diff --git a/crates/buzz-relay/src/handlers/auth.rs b/crates/buzz-relay/src/handlers/auth.rs index ef17426f6..48a8f0354 100644 --- a/crates/buzz-relay/src/handlers/auth.rs +++ b/crates/buzz-relay/src/handlers/auth.rs @@ -113,6 +113,7 @@ pub async fn handle_auth(event: nostr::Event, conn: Arc, state: // Relay membership gate — uses the shared helper with NIP-OA fallback. let nip_oa_owner = match crate::api::relay_members::enforce_relay_membership( &state, + conn.tenant.community(), pubkey.as_bytes(), auth_tag_json.as_deref(), ) diff --git a/crates/buzz-relay/src/handlers/identity_archive.rs b/crates/buzz-relay/src/handlers/identity_archive.rs index 2547e30af..9da920483 100644 --- a/crates/buzz-relay/src/handlers/identity_archive.rs +++ b/crates/buzz-relay/src/handlers/identity_archive.rs @@ -238,7 +238,7 @@ async fn determine_consent_path( let actor_member = state .db - .get_relay_member(actor_hex) + .get_relay_member(community_id, actor_hex) .await .map_err(|e| format!("database error: {e}"))?; let actor_role = actor_member.as_ref().map(|m| m.role.as_str()).unwrap_or(""); diff --git a/crates/buzz-relay/src/handlers/ingest.rs b/crates/buzz-relay/src/handlers/ingest.rs index f20065623..e11380faf 100644 --- a/crates/buzz-relay/src/handlers/ingest.rs +++ b/crates/buzz-relay/src/handlers/ingest.rs @@ -1478,7 +1478,7 @@ async fn ingest_event_inner( // remove_relay_member handles both the NotFound and IsOwner cases atomically. let remove_result = state .db - .remove_relay_member(&sender_hex) + .remove_relay_member(tenant.community(), &sender_hex) .await .map_err(|e| IngestError::Internal(format!("database error: {e}")))?; diff --git a/crates/buzz-relay/src/handlers/mesh_signaling.rs b/crates/buzz-relay/src/handlers/mesh_signaling.rs index 06754f48c..3135ae629 100644 --- a/crates/buzz-relay/src/handlers/mesh_signaling.rs +++ b/crates/buzz-relay/src/handlers/mesh_signaling.rs @@ -26,7 +26,7 @@ use buzz_core::event::StoredEvent; use buzz_core::kind::{ event_kind_u32, KIND_MESH_CALL_ME_NOW, KIND_MESH_CONNECT_REQUEST, KIND_MESH_STATUS_REPORT, }; -use buzz_core::tenant::TenantContext; +use buzz_core::tenant::{CommunityId, TenantContext}; use nostr::{EventBuilder, Kind, Tag}; use crate::api::relay_members::{check_relay_membership, MembershipDecision}; @@ -234,11 +234,11 @@ pub async fn handle_connect_request( // BUZZ_ALLOW_NIP_OA_AUTH is on; v1 mesh excludes delegated identities, so we // re-check the requester here with no auth tag (which makes ViaOwner // unreachable — only Member/OpenRelay/Denied) to match the target check. - require_mesh_member(state, requester_pubkey_hex) + require_mesh_member(state, tenant.community(), requester_pubkey_hex) .await .map_err(|_| "restricted: delegated identities cannot initiate mesh in v1".to_string())?; - require_mesh_member(state, &target_hex) + require_mesh_member(state, tenant.community(), &target_hex) .await .map_err(|_| "restricted: target is not a relay member".to_string())?; @@ -274,9 +274,13 @@ pub async fn handle_connect_request( /// `None` auth_tag → ViaOwner is unreachable, so only Member/OpenRelay admit; /// everything else (Denied, ViaOwner-if-it-somehow-appeared, or a check error) /// FAILS CLOSED. Used symmetrically for requester and target. -async fn require_mesh_member(state: &Arc, pubkey_hex: &str) -> Result<(), ()> { +async fn require_mesh_member( + state: &Arc, + community: CommunityId, + pubkey_hex: &str, +) -> Result<(), ()> { let bytes = hex::decode(pubkey_hex).map_err(|_| ())?; - match check_relay_membership(state, &bytes, None).await { + match check_relay_membership(state, community, &bytes, None).await { Ok(d) if membership_admits_mesh(&d) => Ok(()), Ok(_) => Err(()), Err(e) => { @@ -355,7 +359,7 @@ pub async fn handle_status_report( // pubkey that the connect path (which denies ViaOwner) then refuses — broken // discovery. So gate the reporter the same way: direct members only, fail // closed. Keeps all three desktop-facing mesh kinds consistent on delegation. - require_mesh_member(state, reporter_pubkey_hex) + require_mesh_member(state, tenant.community(), reporter_pubkey_hex) .await .map_err(|_| { "restricted: delegated identities cannot report mesh status in v1".to_string() diff --git a/crates/buzz-relay/src/handlers/relay_admin.rs b/crates/buzz-relay/src/handlers/relay_admin.rs index 64b869872..fe44e74c7 100644 --- a/crates/buzz-relay/src/handlers/relay_admin.rs +++ b/crates/buzz-relay/src/handlers/relay_admin.rs @@ -94,7 +94,7 @@ pub async fn handle_relay_admin_event( let sender_member = state .db - .get_relay_member(&sender_hex) + .get_relay_member(tenant.community(), &sender_hex) .await .map_err(|e| format!("database error: {e}"))?; @@ -130,7 +130,7 @@ pub async fn handle_relay_admin_event( // to change an existing member's role. let was_inserted = state .db - .add_relay_member(&target_hex, &role, Some(&sender_hex)) + .add_relay_member(tenant.community(), &target_hex, &role, Some(&sender_hex)) .await .map_err(|e| format!("database error: {e}"))?; @@ -174,14 +174,14 @@ pub async fn handle_relay_admin_event( let remove_result = if sender_role == "admin" { state .db - .remove_relay_member_if_role(&target_hex, "member") + .remove_relay_member_if_role(tenant.community(), &target_hex, "member") .await .map_err(|e| format!("database error: {e}"))? } else { // Owner path — atomic delete that refuses to remove other owners. state .db - .remove_relay_member(&target_hex) + .remove_relay_member(tenant.community(), &target_hex) .await .map_err(|e| format!("database error: {e}"))? }; @@ -240,7 +240,7 @@ pub async fn handle_relay_admin_event( let updated = state .db - .update_relay_member_role(&target_hex, &new_role) + .update_relay_member_role(tenant.community(), &target_hex, &new_role) .await .map_err(|e| format!("database error: {e}"))?; @@ -248,7 +248,7 @@ pub async fn handle_relay_admin_event( // Distinguish "owner (protected)" from "doesn't exist" let exists = state .db - .get_relay_member(&target_hex) + .get_relay_member(tenant.community(), &target_hex) .await .map_err(|e| format!("database error: {e}"))?; return Err(if exists.is_some() { diff --git a/crates/buzz-relay/src/handlers/side_effects.rs b/crates/buzz-relay/src/handlers/side_effects.rs index 44745d174..8e265cbd7 100644 --- a/crates/buzz-relay/src/handlers/side_effects.rs +++ b/crates/buzz-relay/src/handlers/side_effects.rs @@ -2336,7 +2336,7 @@ pub async fn publish_nip43_membership_list( tenant: &TenantContext, state: &Arc, ) -> anyhow::Result<()> { - let members = state.db.list_relay_members().await?; + let members = state.db.list_relay_members(tenant.community()).await?; let relay_pubkey_hex = state.relay_keypair.public_key().to_hex(); let mut tags: Vec = Vec::with_capacity(members.len() + 1); diff --git a/crates/buzz-relay/src/main.rs b/crates/buzz-relay/src/main.rs index 499d8245d..22399b411 100644 --- a/crates/buzz-relay/src/main.rs +++ b/crates/buzz-relay/src/main.rs @@ -104,30 +104,80 @@ async fn main() -> anyhow::Result<()> { )); } + // NIP-43 / multi-tenant: seed the deployment's *own* community before any + // membership backfill or owner bootstrap, so those writes are scoped to a + // real `(community_id, pubkey)` and not a global pubkey. The host is derived + // from `relay_url` with the *same* normalization request resolution uses + // (`relay_url_authority` → `normalize_host`), so the bootstrapped owner lands + // in exactly the community that live requests for this host will resolve to. + // + // `ensure_configured_community` is idempotent (`ON CONFLICT (host)`), so this + // is safe to run every startup. An empty authority (unparseable `relay_url`) + // is a misconfiguration — fail fast when membership is enforced rather than + // seeding an empty-host community that no request can ever resolve to. + let deployment_community = { + let host = buzz_relay::tenant::relay_url_authority(&config.relay_url); + if host.is_empty() { + if config.require_relay_membership { + return Err(anyhow::anyhow!( + "Cannot derive a community host from BUZZ_RELAY_URL ({:?}); a resolvable host is required when BUZZ_REQUIRE_RELAY_MEMBERSHIP=true", + config.relay_url + )); + } + error!( + relay_url = %config.relay_url, + "Could not derive a community host from relay_url; skipping membership backfill/bootstrap (non-fatal, membership not required)" + ); + None + } else { + match db.ensure_configured_community(&host).await { + Ok(record) => { + info!(host = %record.host, community = %record.id, "Deployment community ensured"); + Some(record.id) + } + Err(e) => { + if config.require_relay_membership { + error!("Fatal: failed to ensure deployment community with membership enforcement enabled: {e}"); + return Err(anyhow::anyhow!( + "Failed to ensure deployment community (required when BUZZ_REQUIRE_RELAY_MEMBERSHIP=true): {e}" + )); + } + error!("Failed to ensure deployment community (non-fatal, membership not required): {e}"); + None + } + } + } + }; + // NIP-43: migrate any existing pubkey_allowlist entries to relay_members. // Idempotent — safe to run every startup. Must run before bootstrap_owner // so that existing allowlist users become relay members before the owner // is promoted (otherwise enabling membership locks everyone out). - match db.backfill_from_allowlist().await { - Ok(0) => {} - Ok(n) => info!("Backfilled {n} pubkey_allowlist entries into relay_members"), - Err(e) => { - if config.require_relay_membership { - error!( - "Fatal: failed to backfill allowlist with membership enforcement enabled: {e}" - ); - return Err(anyhow::anyhow!( - "Failed to backfill pubkey_allowlist (required when BUZZ_REQUIRE_RELAY_MEMBERSHIP=true): {e}" - )); - } else { - error!("Failed to backfill pubkey_allowlist (non-fatal): {e}"); + if let Some(community) = deployment_community { + match db.backfill_from_allowlist(community).await { + Ok(0) => {} + Ok(n) => info!("Backfilled {n} pubkey_allowlist entries into relay_members"), + Err(e) => { + if config.require_relay_membership { + error!( + "Fatal: failed to backfill allowlist with membership enforcement enabled: {e}" + ); + return Err(anyhow::anyhow!( + "Failed to backfill pubkey_allowlist (required when BUZZ_REQUIRE_RELAY_MEMBERSHIP=true): {e}" + )); + } else { + error!("Failed to backfill pubkey_allowlist (non-fatal): {e}"); + } } } } - // NIP-43: ensure the configured relay owner always holds the owner role. - if let Some(ref owner_pubkey) = config.relay_owner_pubkey { - match db.bootstrap_owner(owner_pubkey).await { + // NIP-43: ensure the configured relay owner always holds the owner role + // within the deployment community. + if let (Some(community), Some(owner_pubkey)) = + (deployment_community, config.relay_owner_pubkey.as_ref()) + { + match db.bootstrap_owner(community, owner_pubkey).await { Ok(()) => info!(pubkey = %owner_pubkey, "Relay owner bootstrapped"), Err(e) => { if config.require_relay_membership { diff --git a/crates/buzz-relay/src/tenant.rs b/crates/buzz-relay/src/tenant.rs index a3c195685..42a9461e7 100644 --- a/crates/buzz-relay/src/tenant.rs +++ b/crates/buzz-relay/src/tenant.rs @@ -111,7 +111,13 @@ pub async fn bind_deployment_community( /// Extract the relay URL authority in the same normalized shape as request /// `Host` headers and `communities.host`: host plus an explicit non-default /// port, if present. -fn relay_url_authority(relay_url: &str) -> String { +/// +/// `pub` so startup ([`crate::main`], a separate binary crate) can seed the +/// deployment's own community under the *same* normalized host that live request +/// resolution ([`bind_community`]) will derive — the two must agree or the +/// bootstrapped owner lands in a community no request ever resolves to. Returns +/// the empty string when `relay_url` has no parseable host. +pub fn relay_url_authority(relay_url: &str) -> String { let Ok(url) = url::Url::parse(relay_url) else { return String::new(); };