From 005c22456367ba3ded71bce097c2668882740f81 Mon Sep 17 00:00:00 2001 From: npub1c4alndp82zyt9veaklm5d965quss79vlhk9awv7qu5erwhmf42qqlvc25c Date: Mon, 13 Jul 2026 16:33:58 -0700 Subject: [PATCH 1/4] feat: add community transfer-ownership endpoint and db function Add POST /operator/communities/transfer operator endpoint that atomically transfers community ownership to a new pubkey. The previous owner is demoted to member (not admin), per product decision. - transfer_ownership() in relay_members.rs: single atomic txn that upserts new owner and demotes all other owners to member - TransferResult enum: Transferred/AlreadyOwner/NoOwner edge cases - Db::transfer_ownership() wrapper in lib.rs - TransferCommunityRequest/Response structs in community_provisioning.rs - transfer_community() handler in operator.rs with NIP-98 operator auth, UUID/hex validation, and best-effort NIP-43 snapshot publication - Route wired in router.rs - 5 DB-level tests + 3 HTTP-level tests (all #[ignore] requires Postgres) Co-authored-by: Kalvin Chau Signed-off-by: Kalvin Chau --- crates/buzz-db/src/lib.rs | 10 + crates/buzz-db/src/relay_members.rs | 269 +++++++++++++++ crates/buzz-relay/src/api/operator.rs | 318 ++++++++++++++++++ .../src/handlers/community_provisioning.rs | 23 ++ crates/buzz-relay/src/router.rs | 4 + 5 files changed, 624 insertions(+) diff --git a/crates/buzz-db/src/lib.rs b/crates/buzz-db/src/lib.rs index 9dd7b501a8..da6d037636 100644 --- a/crates/buzz-db/src/lib.rs +++ b/crates/buzz-db/src/lib.rs @@ -2367,6 +2367,16 @@ impl Db { relay_members::bootstrap_owner(&self.pool, community, owner_pubkey).await } + /// Atomically transfers ownership of `community` to `new_owner_pubkey`, + /// demoting the previous owner(s) to `member`. + pub async fn transfer_ownership( + &self, + community: CommunityId, + new_owner_pubkey: &str, + ) -> Result { + relay_members::transfer_ownership(&self.pool, community, new_owner_pubkey).await + } + /// Migrates existing `pubkey_allowlist` entries into `relay_members` for `community`. /// /// Idempotent — uses `ON CONFLICT DO NOTHING`. Returns the number of rows diff --git a/crates/buzz-db/src/relay_members.rs b/crates/buzz-db/src/relay_members.rs index 8a71d86095..11d8a00c17 100644 --- a/crates/buzz-db/src/relay_members.rs +++ b/crates/buzz-db/src/relay_members.rs @@ -281,6 +281,90 @@ pub async fn bootstrap_owner( Ok(()) } +/// The result of a transfer-ownership attempt. +#[derive(Debug, PartialEq)] +pub enum TransferResult { + /// Transfer completed: the new owner was upserted and the previous + /// owner(s) demoted to `member`. + Transferred { + /// Pubkey of the previous sole owner, if exactly one existed. + previous_owner: Option, + }, + /// The new owner pubkey is already the sole owner — nothing to do. + AlreadyOwner, + /// No owner row exists for this community (community may not exist). + NoOwner, +} + +/// Atomically transfers ownership of `community` to `new_owner_pubkey`. +/// +/// Runs in a single transaction: +/// 1. Reads existing owner rows to detect no-op and error conditions. +/// 2. Upserts `new_owner_pubkey` as `owner` (insert or promote). +/// 3. Demotes every other owner in this community to `member` — **not** +/// `admin`, per product decision: the former owner retains no management +/// capabilities. +/// +/// Scoped to one community — an ownership transfer in A never touches B. +pub async fn transfer_ownership( + pool: &PgPool, + community: CommunityId, + new_owner_pubkey: &str, +) -> Result { + let pubkey = new_owner_pubkey.to_ascii_lowercase(); + let mut tx = pool.begin().await?; + + // 1. Read existing owners within the transaction so the check and the + // mutation are atomic — no TOCTOU window between reading and writing. + let existing_owners: Vec = sqlx::query_scalar( + "SELECT pubkey FROM relay_members WHERE community_id = $1 AND role = 'owner'", + ) + .bind(community.as_uuid()) + .fetch_all(&mut *tx) + .await?; + + if existing_owners.is_empty() { + tx.rollback().await?; + return Ok(TransferResult::NoOwner); + } + + // Already the sole owner — no transfer needed. + if existing_owners.len() == 1 && existing_owners[0] == pubkey { + tx.rollback().await?; + return Ok(TransferResult::AlreadyOwner); + } + + let previous_owner = if existing_owners.len() == 1 { + Some(existing_owners[0].clone()) + } else { + existing_owners.iter().find(|p| **p != pubkey).cloned() + }; + + // 2. Upsert the new owner. + sqlx::query( + "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?; + + // 3. Demote all other owners to member (not admin). + sqlx::query( + "UPDATE relay_members SET role = 'member', updated_at = now() \ + WHERE community_id = $1 AND role = 'owner' AND pubkey <> $2", + ) + .bind(community.as_uuid()) + .bind(&pubkey) + .execute(&mut *tx) + .await?; + + tx.commit().await?; + Ok(TransferResult::Transferred { previous_owner }) +} + /// Migrates existing `pubkey_allowlist` entries into `relay_members` for /// `community` (the deployment's default community). /// @@ -456,4 +540,189 @@ mod tests { "owner bootstrapped in A must NOT be a member of B" ); } + + /// Transfer ownership: upserts new owner, demotes previous owner to + /// `member` (not `admin`), and returns the previous owner's pubkey. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn transfer_ownership_demotes_old_owner_to_member() { + let pool = setup_pool().await; + let community = make_test_community(&pool).await; + let old_owner = format!("{:064x}", Uuid::new_v4().as_u128()); + let new_owner = format!("{:064x}", Uuid::new_v4().as_u128()); + + bootstrap_owner(&pool, community, &old_owner) + .await + .expect("bootstrap initial owner"); + + let result = transfer_ownership(&pool, community, &new_owner) + .await + .expect("transfer ownership"); + + assert_eq!( + result, + TransferResult::Transferred { + previous_owner: Some(old_owner.clone()), + } + ); + + // New owner is owner. + let new_role = get_relay_member(&pool, community, &new_owner) + .await + .expect("get new owner") + .expect("new owner exists") + .role; + assert_eq!(new_role, "owner"); + + // Old owner is member, not admin, not owner. + let old_role = get_relay_member(&pool, community, &old_owner) + .await + .expect("get old owner") + .expect("old owner still exists") + .role; + assert_eq!(old_role, "member"); + } + + /// Transferring to the current sole owner is a no-op (`AlreadyOwner`). + #[tokio::test] + #[ignore = "requires Postgres"] + async fn transfer_ownership_already_owner_is_noop() { + let pool = setup_pool().await; + let community = make_test_community(&pool).await; + let owner = format!("{:064x}", Uuid::new_v4().as_u128()); + + bootstrap_owner(&pool, community, &owner) + .await + .expect("bootstrap owner"); + + let result = transfer_ownership(&pool, community, &owner) + .await + .expect("transfer ownership to self"); + + assert_eq!(result, TransferResult::AlreadyOwner); + + // Still owner. + let role = get_relay_member(&pool, community, &owner) + .await + .expect("get owner") + .expect("owner exists") + .role; + assert_eq!(role, "owner"); + } + + /// Transferring a community with no owner row returns `NoOwner`. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn transfer_ownership_no_owner_returns_no_owner() { + let pool = setup_pool().await; + let community = make_test_community(&pool).await; + let new_owner = format!("{:064x}", Uuid::new_v4().as_u128()); + + // No bootstrap — community exists but has no owner row. + + let result = transfer_ownership(&pool, community, &new_owner) + .await + .expect("transfer ownership on empty community"); + + assert_eq!(result, TransferResult::NoOwner); + } + + /// Transfer ownership is community-scoped: transferring in A does not + /// affect ownership in B. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn transfer_ownership_is_community_scoped() { + let pool = setup_pool().await; + let community_a = make_test_community(&pool).await; + let community_b = make_test_community(&pool).await; + let owner_a = format!("{:064x}", Uuid::new_v4().as_u128()); + let owner_b = format!("{:064x}", Uuid::new_v4().as_u128()); + let new_owner = format!("{:064x}", Uuid::new_v4().as_u128()); + + bootstrap_owner(&pool, community_a, &owner_a) + .await + .expect("bootstrap owner A"); + bootstrap_owner(&pool, community_b, &owner_b) + .await + .expect("bootstrap owner B"); + + transfer_ownership(&pool, community_a, &new_owner) + .await + .expect("transfer A"); + + // A: new owner is owner, old owner is member. + assert_eq!( + get_relay_member(&pool, community_a, &new_owner) + .await + .expect("get new owner A") + .expect("exists") + .role, + "owner" + ); + assert_eq!( + get_relay_member(&pool, community_a, &owner_a) + .await + .expect("get old owner A") + .expect("exists") + .role, + "member" + ); + + // B: untouched — owner_b is still owner, new_owner is not a member. + assert_eq!( + get_relay_member(&pool, community_b, &owner_b) + .await + .expect("get owner B") + .expect("exists") + .role, + "owner" + ); + assert!( + !is_relay_member(&pool, community_b, &new_owner) + .await + .expect("is_relay_member B"), + "new owner of A must NOT be a member of B" + ); + } + + /// Transfer ownership to someone who is already a member promotes them to + /// owner and demotes the old owner to member. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn transfer_ownership_promotes_existing_member() { + let pool = setup_pool().await; + let community = make_test_community(&pool).await; + let old_owner = format!("{:064x}", Uuid::new_v4().as_u128()); + let existing_member = format!("{:064x}", Uuid::new_v4().as_u128()); + + bootstrap_owner(&pool, community, &old_owner) + .await + .expect("bootstrap owner"); + add_relay_member(&pool, community, &existing_member, "member", None) + .await + .expect("add member"); + + let result = transfer_ownership(&pool, community, &existing_member) + .await + .expect("transfer to existing member"); + + assert!(matches!(result, TransferResult::Transferred { .. })); + + assert_eq!( + get_relay_member(&pool, community, &existing_member) + .await + .expect("get new owner") + .expect("exists") + .role, + "owner" + ); + assert_eq!( + get_relay_member(&pool, community, &old_owner) + .await + .expect("get old owner") + .expect("exists") + .role, + "member" + ); + } } diff --git a/crates/buzz-relay/src/api/operator.rs b/crates/buzz-relay/src/api/operator.rs index 0a84283299..5dc1e92708 100644 --- a/crates/buzz-relay/src/api/operator.rs +++ b/crates/buzz-relay/src/api/operator.rs @@ -13,9 +13,13 @@ use axum::{ }; use serde::Deserialize; use serde_json::Value; +use uuid::Uuid; + +use buzz_core::{CommunityId, TenantContext}; use crate::handlers::community_provisioning::{ normalize_candidate_host, validate_pubkey_hex, ProvisionCommunityRequest, + TransferCommunityRequest, TransferCommunityResponse, }; use crate::state::AppState; @@ -211,6 +215,110 @@ pub async fn list_owned_communities( }))) } +/// Transfer ownership of a community to a new owner pubkey. +/// +/// `POST /operator/communities/transfer`, NIP-98 signed by a pubkey in +/// `RELAY_OPERATOR_PUBKEYS`, body: +/// +/// ```json +/// { "community_id": "", "new_owner_pubkey": "" } +/// ``` +/// +/// The previous owner is demoted to `member` (not `admin`). The transfer is +/// instant and atomic at the database layer. Publication of the updated +/// NIP-43 membership list is best-effort, matching the provision path. +pub async fn transfer_community( + State(state): State>, + headers: HeaderMap, + body: axum::body::Bytes, +) -> Result, (StatusCode, Json)> { + let _pubkey = authorize_operator_request( + &state, + &headers, + "POST", + "/operator/communities/transfer", + None, + Some(&body), + ) + .await?; + + let request: TransferCommunityRequest = serde_json::from_slice(&body).map_err(|e| { + api_error( + StatusCode::BAD_REQUEST, + &format!("invalid transfer-community JSON: {e}"), + ) + })?; + + let community_uuid = Uuid::parse_str(&request.community_id).map_err(|e| { + api_error( + StatusCode::BAD_REQUEST, + &format!("invalid community_id: {e}"), + ) + })?; + + let new_owner_pubkey = validate_pubkey_hex(&request.new_owner_pubkey).ok_or_else(|| { + api_error( + StatusCode::BAD_REQUEST, + "invalid new_owner_pubkey: expected 64-char hex pubkey", + ) + })?; + + let community = CommunityId::from_uuid(community_uuid); + + let result = state + .db + .transfer_ownership(community, &new_owner_pubkey) + .await + .map_err(|e| internal_error(&format!("transfer ownership: {e}")))?; + + let (status, previous_owner) = match result { + buzz_db::relay_members::TransferResult::Transferred { previous_owner } => { + ("transferred", previous_owner) + } + buzz_db::relay_members::TransferResult::AlreadyOwner => ("already_owner", None), + buzz_db::relay_members::TransferResult::NoOwner => { + return Err(api_error( + StatusCode::NOT_FOUND, + "community has no owner to transfer from", + )); + } + }; + + // Best-effort NIP-43 membership snapshot publication — same pattern as + // provision_community. The DB mutation is already committed; a publication + // failure must not turn a success into an HTTP error. + if state.config.require_relay_membership { + if let Some(host) = state + .db + .lookup_community_host(community) + .await + .map_err(|e| internal_error(&format!("lookup community host: {e}")))? + { + let tenant = TenantContext::resolved(community, host); + if let Err(error) = + crate::handlers::side_effects::publish_nip43_membership_list(&tenant, &state).await + { + tracing::warn!( + community = %community, + error = %error, + "ownership transferred but NIP-43 membership snapshot publication failed" + ); + } + } + } + + let response = TransferCommunityResponse { + community_id: request.community_id, + new_owner_pubkey, + status, + previous_owner, + }; + + Ok(Json(serde_json::to_value(response).map_err(|e| { + tracing::error!("failed to serialize transfer-community response: {e}"); + internal_error("operator transfer response serialization failed") + })?)) +} /// Check whether a community host is available, returning the relay-canonical /// normalized authority used by create. pub async fn community_availability( @@ -582,4 +690,214 @@ mod tests { && tag.as_slice().get(2).is_some_and(|value| value == "owner") })); } + + /// Happy path: POST /operator/communities/transfer swaps ownership, demotes + /// the old owner to `member`, and publishes a NIP-43 snapshot reflecting the + /// new roles. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn happy_path_transfer_swaps_owner_and_demotes_old_to_member() { + let operator = Keys::generate(); + let initial_owner = Keys::generate(); + let new_owner = Keys::generate(); + let Some(state) = operator_test_state(std::slice::from_ref(&operator)).await else { + return; + }; + + // Create a community with initial_owner. + let host = format!("community-{}.example", Uuid::new_v4().simple()); + let create_body = serde_json::json!({ + "host": host, + "initial_owner_pubkey": initial_owner.public_key().to_hex(), + "create_only": true, + }) + .to_string(); + let create_url = format!("http://{INGRESS_HOST}/operator/communities"); + let create_auth = + nip98_auth_header(&operator, &create_url, "POST", Some(create_body.as_bytes())); + build_router(state.clone()) + .oneshot( + Request::builder() + .method("POST") + .uri("/operator/communities") + .header(header::HOST, INGRESS_HOST) + .header(header::AUTHORIZATION, create_auth) + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from(create_body)) + .expect("create request"), + ) + .await + .expect("create response"); + + let community = state + .db + .lookup_community_by_host(&host) + .await + .expect("lookup community") + .expect("community exists"); + let community_id = community.id.to_string(); + let initial_owner_hex = initial_owner.public_key().to_hex(); + let new_owner_hex = new_owner.public_key().to_hex(); + + // Transfer ownership. + let transfer_body = serde_json::json!({ + "community_id": community_id, + "new_owner_pubkey": new_owner_hex, + }) + .to_string(); + let transfer_url = format!("http://{INGRESS_HOST}/operator/communities/transfer"); + let transfer_auth = nip98_auth_header( + &operator, + &transfer_url, + "POST", + Some(transfer_body.as_bytes()), + ); + + let response = build_router(state.clone()) + .oneshot( + Request::builder() + .method("POST") + .uri("/operator/communities/transfer") + .header(header::HOST, INGRESS_HOST) + .header(header::AUTHORIZATION, transfer_auth) + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from(transfer_body)) + .expect("transfer request"), + ) + .await + .expect("transfer response"); + + assert_eq!(response.status(), StatusCode::OK); + let json = read_json(response).await; + assert_eq!( + json.get("status").and_then(Value::as_str), + Some("transferred") + ); + assert_eq!( + json.get("new_owner_pubkey").and_then(Value::as_str), + Some(new_owner_hex.as_str()) + ); + assert_eq!( + json.get("previous_owner").and_then(Value::as_str), + Some(initial_owner_hex.as_str()) + ); + + // New owner is owner. + assert_eq!( + state + .db + .get_relay_member(community.id, &new_owner_hex) + .await + .expect("get new owner") + .expect("new owner exists") + .role, + "owner" + ); + // Old owner is member (not admin). + assert_eq!( + state + .db + .get_relay_member(community.id, &initial_owner_hex) + .await + .expect("get old owner") + .expect("old owner exists") + .role, + "member" + ); + + // NIP-43 snapshot reflects the new roles. + let membership_events = state + .db + .query_events(&EventQuery { + kinds: Some(vec![KIND_NIP43_MEMBERSHIP_LIST as i32]), + global_only: true, + limit: Some(1), + ..EventQuery::for_community(community.id) + }) + .await + .expect("query membership snapshot"); + let snapshot = membership_events + .first() + .expect("membership snapshot published after transfer"); + assert!(snapshot.event.tags.iter().any(|tag| { + tag.as_slice().first().is_some_and(|v| v == "member") + && tag.as_slice().get(1).is_some_and(|v| v == &new_owner_hex) + && tag.as_slice().get(2).is_some_and(|v| v == "owner") + })); + assert!(snapshot.event.tags.iter().any(|tag| { + tag.as_slice().first().is_some_and(|v| v == "member") + && tag + .as_slice() + .get(1) + .is_some_and(|v| v == &initial_owner_hex) + && tag.as_slice().get(2).is_some_and(|v| v == "member") + })); + } + + /// Transfer with an invalid community_id returns 400. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn transfer_with_invalid_community_id_returns_400() { + let operator = Keys::generate(); + let new_owner = Keys::generate(); + let Some(state) = operator_test_state(std::slice::from_ref(&operator)).await else { + return; + }; + let body = serde_json::json!({ + "community_id": "not-a-uuid", + "new_owner_pubkey": new_owner.public_key().to_hex(), + }) + .to_string(); + let url = format!("http://{INGRESS_HOST}/operator/communities/transfer"); + let auth = nip98_auth_header(&operator, &url, "POST", Some(body.as_bytes())); + + let response = build_router(state) + .oneshot( + Request::builder() + .method("POST") + .uri("/operator/communities/transfer") + .header(header::HOST, INGRESS_HOST) + .header(header::AUTHORIZATION, auth) + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from(body)) + .expect("request"), + ) + .await + .expect("response"); + + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + } + + /// Transfer with an invalid new_owner_pubkey returns 400. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn transfer_with_invalid_pubkey_returns_400() { + let operator = Keys::generate(); + let Some(state) = operator_test_state(std::slice::from_ref(&operator)).await else { + return; + }; + let body = serde_json::json!({ + "community_id": Uuid::new_v4().to_string(), + "new_owner_pubkey": "not-a-pubkey", + }) + .to_string(); + let url = format!("http://{INGRESS_HOST}/operator/communities/transfer"); + let auth = nip98_auth_header(&operator, &url, "POST", Some(body.as_bytes())); + + let response = build_router(state) + .oneshot( + Request::builder() + .method("POST") + .uri("/operator/communities/transfer") + .header(header::HOST, INGRESS_HOST) + .header(header::AUTHORIZATION, auth) + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from(body)) + .expect("request"), + ) + .await + .expect("response"); + + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + } } diff --git a/crates/buzz-relay/src/handlers/community_provisioning.rs b/crates/buzz-relay/src/handlers/community_provisioning.rs index 5411bd4258..c8768755fa 100644 --- a/crates/buzz-relay/src/handlers/community_provisioning.rs +++ b/crates/buzz-relay/src/handlers/community_provisioning.rs @@ -170,6 +170,29 @@ fn validate_domain_labels(domain: &str) -> Result<(), String> { Ok(()) } +/// JSON body for `POST /operator/communities/transfer`. +#[derive(Debug, Deserialize)] +pub struct TransferCommunityRequest { + /// UUID of the community to transfer. + pub community_id: String, + /// 64-char hex pubkey of the new owner. + pub new_owner_pubkey: String, +} + +/// JSON response from `POST /operator/communities/transfer`. +#[derive(Debug, Serialize)] +pub struct TransferCommunityResponse { + /// UUID of the transferred community. + pub community_id: String, + /// 64-char hex pubkey of the new owner. + pub new_owner_pubkey: String, + /// `"transferred"` or `"already_owner"`. + pub status: &'static str, + /// Hex pubkey of the previous owner, if one existed and was demoted. + #[serde(skip_serializing_if = "Option::is_none")] + pub previous_owner: Option, +} + /// Normalize and validate a host supplied to read-only operator endpoints. /// /// Unlike create, availability checks may accept non-canonical but normalizable diff --git a/crates/buzz-relay/src/router.rs b/crates/buzz-relay/src/router.rs index 5f4bbee7e2..902a0ac1b4 100644 --- a/crates/buzz-relay/src/router.rs +++ b/crates/buzz-relay/src/router.rs @@ -68,6 +68,10 @@ pub fn build_router(state: Arc) -> Router { "/operator/communities/availability", get(api::operator::community_availability), ) + .route( + "/operator/communities/transfer", + post(api::operator::transfer_community), + ) // Relay invites: mint (owner/admin) + claim (membership-gate exempt) .route("/api/invites", post(api::invites::mint_invite)) .route("/api/invites/claim", post(api::invites::claim_invite)) From ee628e61a50eb009e348e215736604c7c69ceeaf Mon Sep 17 00:00:00 2001 From: npub1c4alndp82zyt9veaklm5d965quss79vlhk9awv7qu5erwhmf42qqlvc25c Date: Mon, 13 Jul 2026 17:24:07 -0700 Subject: [PATCH 2/4] fix: address review findings for community transfer ownership MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit High 1 — Stale owner race: transfer_ownership() now accepts expected_owner_pubkey. The current owner row is locked FOR UPDATE and verified against the expected value inside the same transaction. Returns OwnerConflict (HTTP 409) when the current owner no longer matches — a delayed/retried request can no longer overwrite a completed transfer. High 2 — Racy 3-community limit: the per-recipient ownership count is now enforced at the relay layer inside the transfer transaction, protected by a transaction-scoped advisory lock on the transferee pubkey. The same lock key is used by create_community_with_owner, so transfer-vs-create races to the same recipient are serialized. The kgoose preflight check remains as a fast fail for user experience but is no longer the authoritative enforcement. High 3 — NIP-43 snapshot staleness: added replace_addressable_event_forced() which unconditionally soft-deletes prior events for the same (kind, pubkey, channel_id) without created_at/event-id ordering. The relay is the authoritative source for membership snapshots — the latest snapshot always wins, regardless of same-second random event ID races. The publish_nip43_membership_list path now uses the forced variant. Medium 1 — Missing audit trail: BuzzCommunityService.transfer() now records audit entries for both success and failure, matching the create path. Medium 2 — npub validation: decodeNpub() now requires exactly 32 decoded bytes. A valid-bech32 npub with wrong payload length is correctly rejected as invalid input instead of misclassified as 'transferee not registered'. Tests: 2 new DB tests (OwnerConflict, LimitReached), 1 new create limit test. All existing tests updated for the new expected_owner_pubkey parameter. cargo check + cargo test --lib pass (non-ignored tests). Co-authored-by: Kalvin Chau Signed-off-by: Kalvin Chau --- crates/buzz-db/src/lib.rs | 188 +++++++++++++++++- crates/buzz-db/src/relay_members.rs | 178 +++++++++++++++-- crates/buzz-relay/src/api/operator.rs | 25 ++- .../src/handlers/community_provisioning.rs | 4 + .../buzz-relay/src/handlers/side_effects.rs | 13 +- 5 files changed, 382 insertions(+), 26 deletions(-) diff --git a/crates/buzz-db/src/lib.rs b/crates/buzz-db/src/lib.rs index da6d037636..86ad789a2d 100644 --- a/crates/buzz-db/src/lib.rs +++ b/crates/buzz-db/src/lib.rs @@ -528,11 +528,10 @@ impl Db { /// Atomically creates a community and its initial owner. /// - /// Returns the existing row when the normalized host already has the same - /// current owner, making ambiguous create retries naturally idempotent. - /// Returns `None` when the host exists with a different (or missing) owner. - /// The initial host and owner inserts share one transaction, so callers - /// never observe a partially provisioned community or rotate an owner. + /// Atomically create a community host and bootstrap its initial owner, + /// enforcing the per-owner community limit inside a transaction that holds + /// a per-owner advisory lock. This is the authoritative limit check — kgoose + /// preflight counts are advisory only. pub async fn create_community_with_owner( &self, normalized_host: &str, @@ -540,6 +539,14 @@ impl Db { ) -> Result> { let owner_pubkey = owner_pubkey.to_ascii_lowercase(); let mut tx = self.pool.begin().await?; + + // Serialize on the owner pubkey so concurrent creates to the same + // owner cannot both pass the ownership count check. + sqlx::query("SELECT pg_advisory_xact_lock($1)") + .bind(relay_members::owner_count_advisory_lock_key(&owner_pubkey)) + .execute(&mut *tx) + .await?; + let row = sqlx::query( r#" INSERT INTO communities (host) @@ -555,6 +562,20 @@ impl Db { let (id, host) = if let Some(row) = row { let id: Uuid = row.try_get("id")?; let host: String = row.try_get("host")?; + + // Enforce the limit before inserting the new owner row. + let owned_count: i64 = sqlx::query_scalar( + "SELECT count(*) FROM relay_members WHERE pubkey = $1 AND role = 'owner'", + ) + .bind(&owner_pubkey) + .fetch_one(&mut *tx) + .await?; + + if owned_count >= relay_members::MAX_COMMUNITIES_PER_OWNER { + tx.rollback().await?; + return Ok(None); + } + sqlx::query( "INSERT INTO relay_members (community_id, pubkey, role, added_by) VALUES ($1, $2, 'owner', NULL)", ) @@ -2368,13 +2389,22 @@ impl Db { } /// Atomically transfers ownership of `community` to `new_owner_pubkey`, - /// demoting the previous owner(s) to `member`. + /// demoting the previous owner(s) to `member`. Verifies + /// `expected_owner_pubkey` matches the current owner inside the same + /// transaction to prevent stale-owner races. pub async fn transfer_ownership( &self, community: CommunityId, new_owner_pubkey: &str, + expected_owner_pubkey: &str, ) -> Result { - relay_members::transfer_ownership(&self.pool, community, new_owner_pubkey).await + relay_members::transfer_ownership( + &self.pool, + community, + new_owner_pubkey, + expected_owner_pubkey, + ) + .await } /// Migrates existing `pubkey_allowlist` entries into `relay_members` for `community`. @@ -2787,6 +2817,123 @@ impl Db { )) } + /// Authoritative replacement for relay-generated snapshots (NIP-43 + /// membership lists). + /// + /// Unlike [`replace_addressable_event`], this method does NOT apply + /// stale-write protection based on `created_at` / event-id ordering. The + /// relay is the authoritative source for these events — it always wants + /// the latest snapshot to replace any prior one, regardless of + /// same-second timestamp races on random event IDs. This fixes the bug + /// where a transfer snapshot published in the same second as a create + /// snapshot could be rejected as "stale" purely because its random event + /// ID happened to be lexicographically larger. + /// + /// All other semantics (advisory lock serialization, soft-delete old, + /// insert new, mentions) match `replace_addressable_event`. + pub async fn replace_addressable_event_forced( + &self, + community_id: CommunityId, + event: &nostr::Event, + channel_id: Option, + ) -> Result<(StoredEvent, bool)> { + let kind_i32 = buzz_core::kind::event_kind_i32(event); + let pubkey_bytes = event.pubkey.to_bytes(); + let created_at_secs = event.created_at.as_secs() as i64; + let created_at = chrono::DateTime::from_timestamp(created_at_secs, 0) + .ok_or(DbError::InvalidTimestamp(created_at_secs))?; + + // Same advisory-lock key algorithm as replace_addressable_event. + let lock_key = { + let mut h: u64 = 0xcbf29ce484222325; + for b in community_id.as_uuid().as_bytes() { + h ^= *b as u64; + h = h.wrapping_mul(0x100000001b3); + } + for b in kind_i32.to_le_bytes() { + h ^= b as u64; + h = h.wrapping_mul(0x100000001b3); + } + for b in pubkey_bytes.as_slice() { + h ^= *b as u64; + h = h.wrapping_mul(0x100000001b3); + } + if let Some(ch) = channel_id { + for b in ch.as_bytes() { + h ^= *b as u64; + h = h.wrapping_mul(0x100000001b3); + } + } + h as i64 + }; + + let mut tx = self.pool.begin().await?; + + sqlx::query("SELECT pg_advisory_xact_lock($1)") + .bind(lock_key) + .execute(&mut *tx) + .await?; + + // Soft-delete ALL existing events for this (kind, pubkey, channel_id) + // without any created_at/id comparison. The relay is authoritative. + sqlx::query( + "UPDATE events SET deleted_at = NOW() \ + WHERE community_id = $1 AND kind = $2 AND pubkey = $3 \ + AND channel_id IS NOT DISTINCT FROM $4 \ + AND deleted_at IS NULL", + ) + .bind(community_id.as_uuid()) + .bind(kind_i32) + .bind(pubkey_bytes.as_slice()) + .bind(channel_id) + .execute(&mut *tx) + .await?; + + let sig_bytes = event.sig.serialize(); + let tags_json = serde_json::to_value(&event.tags)?; + let received_at = chrono::Utc::now(); + let d_tag = crate::event::extract_d_tag(event); + + let insert_result = sqlx::query( + "INSERT INTO events (community_id, id, pubkey, created_at, kind, tags, content, sig, received_at, channel_id, d_tag) \ + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) \ + ON CONFLICT DO NOTHING", + ) + .bind(community_id.as_uuid()) + .bind(event.id.as_bytes().as_slice()) + .bind(pubkey_bytes.as_slice()) + .bind(created_at) + .bind(kind_i32) + .bind(&tags_json) + .bind(&event.content) + .bind(sig_bytes.as_slice()) + .bind(received_at) + .bind(channel_id) + .bind(d_tag.as_deref()) + .execute(&mut *tx) + .await?; + + let was_inserted = insert_result.rows_affected() > 0; + if !was_inserted { + tx.rollback().await?; + return Ok(( + StoredEvent::with_received_at(event.clone(), received_at, channel_id, false), + false, + )); + } + + tx.commit().await?; + + if let Err(e) = crate::insert_mentions(&self.pool, community_id, event, channel_id).await { + tracing::warn!(event_id = %event.id, "Failed to insert mentions: {e}"); + } + + Ok(( + StoredEvent::with_received_at(event.clone(), received_at, channel_id, true), + true, + )) + } + /// Atomically replace a NIP-33 parameterized replaceable event (kind 30000–39999). /// /// Keeps only the event with the highest `created_at` per `(kind, pubkey, d_tag)`. @@ -3852,6 +3999,33 @@ mod tests { assert!(post_rotation_retry.is_none()); } + #[tokio::test] + #[ignore = "requires Postgres"] + async fn create_community_with_owner_enforces_per_owner_limit() { + let db = setup_db().await; + let owner = format!("{:064x}", Uuid::new_v4().as_u128()); + + // Create 3 communities for this owner (the max). + for i in 0..3 { + let host = format!("limit-test-{}-{}.example", i, Uuid::new_v4().simple()); + db.create_community_with_owner(&host, &owner) + .await + .expect("create community") + .expect("new host"); + } + + // 4th community for the same owner should fail with None. + let host = format!("limit-test-3-{}.example", Uuid::new_v4().simple()); + let result = db + .create_community_with_owner(&host, &owner) + .await + .expect("create community call"); + assert!( + result.is_none(), + "4th community for the same owner should be rejected by limit" + ); + } + #[tokio::test] #[ignore = "requires Postgres"] async fn concurrent_same_owner_create_returns_the_winning_row_to_both_callers() { diff --git a/crates/buzz-db/src/relay_members.rs b/crates/buzz-db/src/relay_members.rs index 11d8a00c17..0068378330 100644 --- a/crates/buzz-db/src/relay_members.rs +++ b/crates/buzz-db/src/relay_members.rs @@ -294,14 +294,47 @@ pub enum TransferResult { AlreadyOwner, /// No owner row exists for this community (community may not exist). NoOwner, + /// The `expected_owner_pubkey` did not match the current owner. A + /// concurrent transfer or owner rotation has already changed ownership. + /// The caller must NOT retry blindly — re-read ownership and re-evaluate. + OwnerConflict, + /// The transferee already owns the maximum number of communities. + /// Enforced atomically inside the transfer transaction so concurrent + /// transfers to the same recipient cannot both pass the limit. + LimitReached, +} + +/// Maximum number of communities a single pubkey can own. Enforced at the +/// relay layer — the authoritative layer — so that concurrent transfers or +/// transfer-vs-create races cannot both pass a preflight count. +pub const MAX_COMMUNITIES_PER_OWNER: i64 = 3; + +/// Stable advisory-lock key for serializing ownership-granting operations +/// (transfer + create) per recipient pubkey. Uses FNV-1a over the hex pubkey +/// so the same recipient always maps to the same lock across processes. +pub fn owner_count_advisory_lock_key(pubkey_hex: &str) -> i64 { + let mut h: u64 = 0xcbf29ce484222325; // FNV offset basis + for b in pubkey_hex.as_bytes() { + h ^= *b as u64; + h = h.wrapping_mul(0x100000001b3); // FNV prime + } + h as i64 } /// Atomically transfers ownership of `community` to `new_owner_pubkey`. /// /// Runs in a single transaction: -/// 1. Reads existing owner rows to detect no-op and error conditions. -/// 2. Upserts `new_owner_pubkey` as `owner` (insert or promote). -/// 3. Demotes every other owner in this community to `member` — **not** +/// 1. Acquires a transaction-scoped advisory lock on the *transferee* pubkey +/// so that concurrent transfers to the same recipient serialize. The same +/// lock key is used by [`create_community_with_owner_locked`] to prevent +/// transfer-vs-create races. +/// 2. Locks the current owner row `FOR UPDATE` and verifies +/// `expected_owner_pubkey` matches. This prevents a stale-owner race where +/// a delayed/retried request overwrites a completed transfer. +/// 3. Enforces the [`MAX_COMMUNITIES_PER_OWNER`] limit on the transferee by +/// counting owned communities inside the same transaction. +/// 4. Upserts `new_owner_pubkey` as `owner` (insert or promote). +/// 5. Demotes every other owner in this community to `member` — **not** /// `admin`, per product decision: the former owner retains no management /// capabilities. /// @@ -310,14 +343,27 @@ pub async fn transfer_ownership( pool: &PgPool, community: CommunityId, new_owner_pubkey: &str, + expected_owner_pubkey: &str, ) -> Result { let pubkey = new_owner_pubkey.to_ascii_lowercase(); + let expected_owner = expected_owner_pubkey.to_ascii_lowercase(); let mut tx = pool.begin().await?; - // 1. Read existing owners within the transaction so the check and the - // mutation are atomic — no TOCTOU window between reading and writing. + // 1. Serialize on the transferee so concurrent transfers to the same + // recipient cannot both pass the ownership count check. + sqlx::query("SELECT pg_advisory_xact_lock($1)") + .bind(owner_count_advisory_lock_key(&pubkey)) + .execute(&mut *tx) + .await?; + + // 2. Lock the current owner row FOR UPDATE and verify the expected owner. + // FOR UPDATE prevents the stale-owner race: a concurrent transfer that + // already changed the owner will block on this lock until our txn + // completes (or vice versa), and the expected_owner check will fail. let existing_owners: Vec = sqlx::query_scalar( - "SELECT pubkey FROM relay_members WHERE community_id = $1 AND role = 'owner'", + "SELECT pubkey FROM relay_members \ + WHERE community_id = $1 AND role = 'owner' \ + FOR UPDATE", ) .bind(community.as_uuid()) .fetch_all(&mut *tx) @@ -328,6 +374,13 @@ pub async fn transfer_ownership( return Ok(TransferResult::NoOwner); } + // Stale-owner guard: if the current owner doesn't match the expected + // owner, a concurrent transfer or rotation has already changed hands. + if !existing_owners.iter().any(|p| p == &expected_owner) { + tx.rollback().await?; + return Ok(TransferResult::OwnerConflict); + } + // Already the sole owner — no transfer needed. if existing_owners.len() == 1 && existing_owners[0] == pubkey { tx.rollback().await?; @@ -340,7 +393,22 @@ pub async fn transfer_ownership( existing_owners.iter().find(|p| **p != pubkey).cloned() }; - // 2. Upsert the new owner. + // 3. Enforce the transferee's community ownership limit inside the same + // transaction that holds the advisory lock. This is the authoritative + // check — kgoose's preflight count is advisory only. + let owned_count: i64 = sqlx::query_scalar( + "SELECT count(*) FROM relay_members WHERE pubkey = $1 AND role = 'owner'", + ) + .bind(&pubkey) + .fetch_one(&mut *tx) + .await?; + + if owned_count >= MAX_COMMUNITIES_PER_OWNER { + tx.rollback().await?; + return Ok(TransferResult::LimitReached); + } + + // 4. Upsert the new owner. sqlx::query( "INSERT INTO relay_members (community_id, pubkey, role, added_by) \ VALUES ($1, $2, 'owner', NULL) \ @@ -351,7 +419,7 @@ pub async fn transfer_ownership( .execute(&mut *tx) .await?; - // 3. Demote all other owners to member (not admin). + // 5. Demote all other owners to member (not admin). sqlx::query( "UPDATE relay_members SET role = 'member', updated_at = now() \ WHERE community_id = $1 AND role = 'owner' AND pubkey <> $2", @@ -555,7 +623,7 @@ mod tests { .await .expect("bootstrap initial owner"); - let result = transfer_ownership(&pool, community, &new_owner) + let result = transfer_ownership(&pool, community, &new_owner, &old_owner) .await .expect("transfer ownership"); @@ -595,7 +663,7 @@ mod tests { .await .expect("bootstrap owner"); - let result = transfer_ownership(&pool, community, &owner) + let result = transfer_ownership(&pool, community, &owner, &owner) .await .expect("transfer ownership to self"); @@ -617,10 +685,11 @@ mod tests { let pool = setup_pool().await; let community = make_test_community(&pool).await; let new_owner = format!("{:064x}", Uuid::new_v4().as_u128()); + let expected = format!("{:064x}", Uuid::new_v4().as_u128()); // No bootstrap — community exists but has no owner row. - let result = transfer_ownership(&pool, community, &new_owner) + let result = transfer_ownership(&pool, community, &new_owner, &expected) .await .expect("transfer ownership on empty community"); @@ -646,7 +715,7 @@ mod tests { .await .expect("bootstrap owner B"); - transfer_ownership(&pool, community_a, &new_owner) + transfer_ownership(&pool, community_a, &new_owner, &owner_a) .await .expect("transfer A"); @@ -702,7 +771,7 @@ mod tests { .await .expect("add member"); - let result = transfer_ownership(&pool, community, &existing_member) + let result = transfer_ownership(&pool, community, &existing_member, &old_owner) .await .expect("transfer to existing member"); @@ -725,4 +794,87 @@ mod tests { "member" ); } + + /// Transfer returns `OwnerConflict` when `expected_owner_pubkey` doesn't + /// match the current owner — simulates a stale/delayed request after a + /// concurrent transfer has already changed ownership. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn transfer_ownership_returns_owner_conflict_when_expected_mismatches() { + let pool = setup_pool().await; + let community = make_test_community(&pool).await; + let old_owner = format!("{:064x}", Uuid::new_v4().as_u128()); + let new_owner = format!("{:064x}", Uuid::new_v4().as_u128()); + let wrong_expected = format!("{:064x}", Uuid::new_v4().as_u128()); + + bootstrap_owner(&pool, community, &old_owner) + .await + .expect("bootstrap initial owner"); + + // expected_owner_pubkey doesn't match the actual owner — should conflict. + let result = transfer_ownership(&pool, community, &new_owner, &wrong_expected) + .await + .expect("transfer ownership with wrong expected"); + + assert_eq!(result, TransferResult::OwnerConflict); + + // Old owner is still owner — nothing changed. + assert_eq!( + get_relay_member(&pool, community, &old_owner) + .await + .expect("get old owner") + .expect("exists") + .role, + "owner" + ); + // New owner was not added. + assert!( + get_relay_member(&pool, community, &new_owner) + .await + .expect("get new owner") + .is_none(), + "new owner must not be added on conflict" + ); + } + + /// Transfer returns `LimitReached` when the transferee already owns the + /// maximum number of communities. The limit is enforced inside the + /// transfer transaction at the relay layer. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn transfer_ownership_returns_limit_reached_for_maxed_transferee() { + let pool = setup_pool().await; + let owner = format!("{:064x}", Uuid::new_v4().as_u128()); + let transferee = format!("{:064x}", Uuid::new_v4().as_u128()); + + // Give the transferee 3 communities (the max). + for _ in 0..3 { + let c = make_test_community(&pool).await; + bootstrap_owner(&pool, c, &transferee) + .await + .expect("bootstrap transferee community"); + } + + // Create a community owned by `owner` and try to transfer to `transferee`. + let community = make_test_community(&pool).await; + bootstrap_owner(&pool, community, &owner) + .await + .expect("bootstrap owner"); + + let result = transfer_ownership(&pool, community, &transferee, &owner) + .await + .expect("transfer to maxed transferee"); + + assert_eq!(result, TransferResult::LimitReached); + + // Owner is still owner — transfer did not happen. + assert_eq!( + get_relay_member(&pool, community, &owner) + .await + .expect("get owner") + .expect("exists") + .role, + "owner" + ); + } } diff --git a/crates/buzz-relay/src/api/operator.rs b/crates/buzz-relay/src/api/operator.rs index 5dc1e92708..756114c51c 100644 --- a/crates/buzz-relay/src/api/operator.rs +++ b/crates/buzz-relay/src/api/operator.rs @@ -263,11 +263,19 @@ pub async fn transfer_community( ) })?; + let expected_owner_pubkey = + validate_pubkey_hex(&request.expected_owner_pubkey).ok_or_else(|| { + api_error( + StatusCode::BAD_REQUEST, + "invalid expected_owner_pubkey: expected 64-char hex pubkey", + ) + })?; + let community = CommunityId::from_uuid(community_uuid); let result = state .db - .transfer_ownership(community, &new_owner_pubkey) + .transfer_ownership(community, &new_owner_pubkey, &expected_owner_pubkey) .await .map_err(|e| internal_error(&format!("transfer ownership: {e}")))?; @@ -282,6 +290,18 @@ pub async fn transfer_community( "community has no owner to transfer from", )); } + buzz_db::relay_members::TransferResult::OwnerConflict => { + return Err(api_error( + StatusCode::CONFLICT, + "owner_conflict: the current owner no longer matches expected_owner_pubkey", + )); + } + buzz_db::relay_members::TransferResult::LimitReached => { + return Err(api_error( + StatusCode::CONFLICT, + "limit_reached: transferee already owns the maximum number of communities", + )); + } }; // Best-effort NIP-43 membership snapshot publication — same pattern as @@ -743,6 +763,7 @@ mod tests { let transfer_body = serde_json::json!({ "community_id": community_id, "new_owner_pubkey": new_owner_hex, + "expected_owner_pubkey": initial_owner_hex, }) .to_string(); let transfer_url = format!("http://{INGRESS_HOST}/operator/communities/transfer"); @@ -846,6 +867,7 @@ mod tests { let body = serde_json::json!({ "community_id": "not-a-uuid", "new_owner_pubkey": new_owner.public_key().to_hex(), + "expected_owner_pubkey": new_owner.public_key().to_hex(), }) .to_string(); let url = format!("http://{INGRESS_HOST}/operator/communities/transfer"); @@ -879,6 +901,7 @@ mod tests { let body = serde_json::json!({ "community_id": Uuid::new_v4().to_string(), "new_owner_pubkey": "not-a-pubkey", + "expected_owner_pubkey": "not-a-pubkey", }) .to_string(); let url = format!("http://{INGRESS_HOST}/operator/communities/transfer"); diff --git a/crates/buzz-relay/src/handlers/community_provisioning.rs b/crates/buzz-relay/src/handlers/community_provisioning.rs index c8768755fa..ba4e505e46 100644 --- a/crates/buzz-relay/src/handlers/community_provisioning.rs +++ b/crates/buzz-relay/src/handlers/community_provisioning.rs @@ -177,6 +177,10 @@ pub struct TransferCommunityRequest { pub community_id: String, /// 64-char hex pubkey of the new owner. pub new_owner_pubkey: String, + /// 64-char hex pubkey of the current owner. The relay verifies this + /// matches inside the transfer transaction and returns `owner_conflict` + /// if it doesn't. Prevents stale-owner races. + pub expected_owner_pubkey: String, } /// JSON response from `POST /operator/communities/transfer`. diff --git a/crates/buzz-relay/src/handlers/side_effects.rs b/crates/buzz-relay/src/handlers/side_effects.rs index 33a95ee64e..4cef64e646 100644 --- a/crates/buzz-relay/src/handlers/side_effects.rs +++ b/crates/buzz-relay/src/handlers/side_effects.rs @@ -2768,13 +2768,16 @@ pub async fn publish_nip43_membership_list( .map_err(|e| anyhow::anyhow!("failed to sign kind:13534: {e}"))?; // NOTE: kind 13534 is technically a regular event (not in the NIP-16 replaceable - // range), but we intentionally use replace_addressable_event to get replacement - // semantics — only the latest membership snapshot matters. This function keys on - // (kind, pubkey, channel_id) and atomically replaces older events, which is exactly - // what Pyramid (the reference NIP-43 implementation) does with store.ReplaceEvent(). + // range), but we intentionally use replace_addressable_event_forced to get + // replacement semantics — only the latest membership snapshot matters. Unlike + // replace_addressable_event, the forced variant does NOT apply stale-write + // protection based on created_at/event-id ordering, because the relay is the + // authoritative source for these snapshots. This prevents same-second races + // where a transfer snapshot is rejected as "stale" purely because its random + // event ID was lexicographically larger than the prior snapshot's. let (stored, was_inserted) = state .db - .replace_addressable_event(tenant.community(), &event, None) + .replace_addressable_event_forced(tenant.community(), &event, None) .await?; if was_inserted { dispatch_persistent_event( From e0810485074ffb88b2717bf3392402813703f967 Mon Sep 17 00:00:00 2001 From: npub1c4alndp82zyt9veaklm5d965quss79vlhk9awv7qu5erwhmf42qqlvc25c Date: Mon, 13 Jul 2026 17:29:23 -0700 Subject: [PATCH 3/4] fix: move nip43 snapshot read-build-write into one locked transaction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit High (NIP-43 snapshot race): The previous fix acquired the advisory lock only inside replace_addressable_event_forced, after the membership query and event construction had already completed outside the lock. A concurrent publication could read stale members, then overwrite a newer snapshot by arrival order. Fix: Added publish_nip43_membership_locked() which acquires the per-community snapshot lock, reads members, builds the event, and replaces the prior snapshot — all inside one transaction on one database connection. The lock serializes the entire read-build-write cycle. publish_nip43_membership_list now delegates to this method instead of querying members separately. Medium (bootstrap_owner): Added explicit deployment-root authority exception doc comment. bootstrap_owner is called only by startup and legacy operator provisioning, not end-user paths. The per-owner limit is documented as an end-user invariant enforced by create_community_with_owner and transfer_ownership; deployment-root operations may exceed it by design. Validation: cargo check clean, cargo test --lib -p buzz-db 79 passed, cargo test --lib -p buzz-relay 563 passed + 14 ignored. Co-authored-by: Kalvin Chau Signed-off-by: Kalvin Chau --- crates/buzz-db/src/lib.rs | 147 ++++++++++++++++++ crates/buzz-db/src/relay_members.rs | 8 + .../buzz-relay/src/handlers/side_effects.rs | 45 ++---- 3 files changed, 171 insertions(+), 29 deletions(-) diff --git a/crates/buzz-db/src/lib.rs b/crates/buzz-db/src/lib.rs index 86ad789a2d..076e5ddc3a 100644 --- a/crates/buzz-db/src/lib.rs +++ b/crates/buzz-db/src/lib.rs @@ -2934,6 +2934,153 @@ impl Db { )) } + /// Atomically publish a NIP-43 membership snapshot under a single + /// transaction-scoped advisory lock. + /// + /// This method acquires the per-community snapshot lock, reads the + /// current membership, builds the event, and replaces the prior snapshot + /// — all inside one transaction on one database connection. This + /// prevents the stale-snapshot race where a concurrent publication reads + /// older state and overwrites a newer snapshot by arrival order. + /// + /// The lock key matches the one used by + /// `replace_addressable_event_forced` for the same (kind, pubkey, + /// channel_id) tuple, so publications through both code paths serialize + /// against each other. + pub async fn publish_nip43_membership_locked( + &self, + community_id: CommunityId, + relay_keypair: &nostr::Keys, + ) -> Result<(StoredEvent, bool, usize)> { + use nostr::{EventBuilder, Kind, Tag}; + + let kind_i32 = buzz_core::kind::KIND_NIP43_MEMBERSHIP_LIST as i32; + let pubkey_bytes = relay_keypair.public_key().to_bytes(); + + // Same advisory-lock key algorithm as replace_addressable_event. + let lock_key = { + let mut h: u64 = 0xcbf29ce484222325; + for b in community_id.as_uuid().as_bytes() { + h ^= *b as u64; + h = h.wrapping_mul(0x100000001b3); + } + for b in kind_i32.to_le_bytes() { + h ^= b as u64; + h = h.wrapping_mul(0x100000001b3); + } + for b in pubkey_bytes.as_slice() { + h ^= *b as u64; + h = h.wrapping_mul(0x100000001b3); + } + h as i64 + }; + + let mut tx = self.pool.begin().await?; + + // Acquire the per-community snapshot lock BEFORE reading members. + // This serializes the entire read-build-write cycle: a concurrent + // publication will block here until our transaction commits, then + // read the updated membership state. + sqlx::query("SELECT pg_advisory_xact_lock($1)") + .bind(lock_key) + .execute(&mut *tx) + .await?; + + // Read current members inside the locked transaction. + let rows = sqlx::query( + "SELECT pubkey, role FROM relay_members \ + WHERE community_id = $1 ORDER BY created_at ASC", + ) + .bind(community_id.as_uuid()) + .fetch_all(&mut *tx) + .await?; + + let member_count = rows.len(); + + // Build the NIP-43 event from the locked member rows. + let mut tags: Vec = Vec::with_capacity(member_count + 1); + // NIP-70 protected-event marker. + tags.push( + Tag::parse(["-"]) + .map_err(|e| crate::error::DbError::InvalidData(format!("failed to build '-' tag: {e}")))?, + ); + for row in &rows { + let pubkey: String = row.try_get("pubkey")?; + let role: String = row.try_get("role")?; + tags.push( + Tag::parse(["member", &pubkey, &role]) + .map_err(|e| crate::error::DbError::InvalidData(format!("failed to build member tag: {e}")))?, + ); + } + + let event = EventBuilder::new(Kind::Custom(kind_i32 as u16), "") + .tags(tags) + .sign_with_keys(relay_keypair) + .map_err(|e| crate::error::DbError::InvalidData(format!("failed to sign kind:13534: {e}")))?; + + let created_at_secs = event.created_at.as_secs() as i64; + let created_at = chrono::DateTime::from_timestamp(created_at_secs, 0) + .ok_or(DbError::InvalidTimestamp(created_at_secs))?; + let sig_bytes = event.sig.serialize(); + let tags_json = serde_json::to_value(&event.tags)?; + let received_at = chrono::Utc::now(); + let d_tag = crate::event::extract_d_tag(&event); + + // Soft-delete prior snapshots — unconditional, the relay is authoritative. + sqlx::query( + "UPDATE events SET deleted_at = NOW() \ + WHERE community_id = $1 AND kind = $2 AND pubkey = $3 \ + AND channel_id IS NULL \ + AND deleted_at IS NULL", + ) + .bind(community_id.as_uuid()) + .bind(kind_i32) + .bind(pubkey_bytes.as_slice()) + .execute(&mut *tx) + .await?; + + let insert_result = sqlx::query( + "INSERT INTO events (community_id, id, pubkey, created_at, kind, tags, content, sig, received_at, channel_id, d_tag) \ + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) \ + ON CONFLICT DO NOTHING", + ) + .bind(community_id.as_uuid()) + .bind(event.id.as_bytes().as_slice()) + .bind(pubkey_bytes.as_slice()) + .bind(created_at) + .bind(kind_i32) + .bind(&tags_json) + .bind(&event.content) + .bind(sig_bytes.as_slice()) + .bind(received_at) + .bind::>(None) + .bind(d_tag.as_deref()) + .execute(&mut *tx) + .await?; + + let was_inserted = insert_result.rows_affected() > 0; + if !was_inserted { + tx.rollback().await?; + return Ok(( + StoredEvent::with_received_at(event, received_at, None, false), + false, + member_count, + )); + } + + tx.commit().await?; + + if let Err(e) = crate::insert_mentions(&self.pool, community_id, &event, None).await { + tracing::warn!(event_id = %event.id, "Failed to insert mentions: {e}"); + } + + Ok(( + StoredEvent::with_received_at(event, received_at, None, true), + true, + member_count, + )) + } + /// Atomically replace a NIP-33 parameterized replaceable event (kind 30000–39999). /// /// Keeps only the event with the highest `created_at` per `(kind, pubkey, d_tag)`. diff --git a/crates/buzz-db/src/relay_members.rs b/crates/buzz-db/src/relay_members.rs index 0068378330..0be245fe46 100644 --- a/crates/buzz-db/src/relay_members.rs +++ b/crates/buzz-db/src/relay_members.rs @@ -248,6 +248,14 @@ pub async fn update_relay_member_role( /// is never bootstrapped into community B. /// /// Runs in a single transaction. Safe to call at every startup — idempotent. +/// +/// **Deployment-root authority exception:** This function is called only by +/// startup initialization and legacy operator provisioning +/// (`community_provisioning.rs`). It is NOT an end-user path and does NOT +/// enforce the per-owner community limit (`MAX_COMMUNITIES_PER_OWNER`) or +/// acquire the per-recipient advisory lock. The per-owner limit is an +/// end-user invariant enforced by `create_community_with_owner` and +/// `transfer_ownership`; deployment-root operations may exceed it by design. pub async fn bootstrap_owner( pool: &PgPool, community: CommunityId, diff --git a/crates/buzz-relay/src/handlers/side_effects.rs b/crates/buzz-relay/src/handlers/side_effects.rs index 4cef64e646..dc22bdd0a0 100644 --- a/crates/buzz-relay/src/handlers/side_effects.rs +++ b/crates/buzz-relay/src/handlers/side_effects.rs @@ -2743,42 +2743,29 @@ async fn emit_initial_ref_state( /// /// Queries all current relay members and emits a relay-signed, NIP-70-protected /// addressable event listing every member pubkey. Replaces any previous list. +/// +/// The member query, event construction, and replacement all happen inside +/// the same per-community advisory lock held by +/// `publish_nip43_membership_locked`. This prevents a stale snapshot from +/// overwriting a newer one when two publications race: the lock serializes +/// the entire read-build-write cycle, not just the write. pub async fn publish_nip43_membership_list( tenant: &TenantContext, state: &Arc, ) -> anyhow::Result<()> { - let members = state.db.list_relay_members(tenant.community()).await?; let relay_pubkey_hex = state.relay_keypair.public_key().to_hex(); + let community = tenant.community(); - let mut tags: Vec = Vec::with_capacity(members.len() + 1); - - // NIP-70 protected-event marker — prevents re-broadcasting by third parties. - tags.push(Tag::parse(["-"]).map_err(|e| anyhow::anyhow!("failed to build '-' tag: {e}"))?); - - for member in &members { - tags.push( - Tag::parse(["member", &member.pubkey, &member.role]) - .map_err(|e| anyhow::anyhow!("failed to build member tag: {e}"))?, - ); - } - - let event = EventBuilder::new(Kind::Custom(KIND_NIP43_MEMBERSHIP_LIST as u16), "") - .tags(tags) - .sign_with_keys(&state.relay_keypair) - .map_err(|e| anyhow::anyhow!("failed to sign kind:13534: {e}"))?; - - // NOTE: kind 13534 is technically a regular event (not in the NIP-16 replaceable - // range), but we intentionally use replace_addressable_event_forced to get - // replacement semantics — only the latest membership snapshot matters. Unlike - // replace_addressable_event, the forced variant does NOT apply stale-write - // protection based on created_at/event-id ordering, because the relay is the - // authoritative source for these snapshots. This prevents same-second races - // where a transfer snapshot is rejected as "stale" purely because its random - // event ID was lexicographically larger than the prior snapshot's. - let (stored, was_inserted) = state + // The DB method acquires the per-community snapshot lock BEFORE reading + // members, so that the entire read-build-write cycle is serialized. A + // concurrent publication that reads newer state will block until our + // replacement commits, then read the updated membership. This prevents a + // stale snapshot from winning by arrival order. + let (stored, was_inserted, member_count) = state .db - .replace_addressable_event_forced(tenant.community(), &event, None) + .publish_nip43_membership_locked(community, &state.relay_keypair) .await?; + if was_inserted { dispatch_persistent_event( tenant, @@ -2792,7 +2779,7 @@ pub async fn publish_nip43_membership_list( } info!( - member_count = members.len(), + member_count, "NIP-43 membership list published" ); Ok(()) From 4f4871d43918ff16006a17c2ddf79f03e9446066 Mon Sep 17 00:00:00 2001 From: npub122y0pqkertljmedu303rl0aqrj3w8pvu43t6jxm6875lzg6f2pwqegc3xc <5288f082d91aff2de5bc8be23fbfa01ca2e3859cac57a91b7a3fa9f12349505c@sprout-oss.stage.blox.sqprod.co> Date: Mon, 13 Jul 2026 20:29:13 -0700 Subject: [PATCH 4/4] refactor: simplify community ownership transfer Remove the unused forced replacement path, consolidate advisory lock calculation and test fixtures, and distinguish owner quota failures during provisioning. Co-authored-by: npub122y0pqkertljmedu303rl0aqrj3w8pvu43t6jxm6875lzg6f2pwqegc3xc <5288f082d91aff2de5bc8be23fbfa01ca2e3859cac57a91b7a3fa9f12349505c@sprout-oss.stage.blox.sqprod.co> Signed-off-by: npub122y0pqkertljmedu303rl0aqrj3w8pvu43t6jxm6875lzg6f2pwqegc3xc <5288f082d91aff2de5bc8be23fbfa01ca2e3859cac57a91b7a3fa9f12349505c@sprout-oss.stage.blox.sqprod.co> --- crates/buzz-db/src/lib.rs | 338 ++++++------------ crates/buzz-db/src/relay_members.rs | 125 +++---- crates/buzz-relay/src/api/operator.rs | 314 ++++++++-------- .../src/handlers/community_provisioning.rs | 42 +-- .../buzz-relay/src/handlers/side_effects.rs | 5 +- 5 files changed, 336 insertions(+), 488 deletions(-) diff --git a/crates/buzz-db/src/lib.rs b/crates/buzz-db/src/lib.rs index 076e5ddc3a..1f9f830d2e 100644 --- a/crates/buzz-db/src/lib.rs +++ b/crates/buzz-db/src/lib.rs @@ -55,6 +55,33 @@ use uuid::Uuid; use buzz_core::{CommunityId, StoredEvent}; +fn event_replacement_lock_key( + community_id: CommunityId, + kind: i32, + pubkey: &[u8], + coordinate: Option<&[u8]>, +) -> i64 { + let mut hash: u64 = 0xcbf29ce484222325; + let kind_bytes = kind.to_le_bytes(); + for bytes in [ + community_id.as_uuid().as_bytes().as_slice(), + kind_bytes.as_slice(), + pubkey, + ] { + for byte in bytes { + hash ^= *byte as u64; + hash = hash.wrapping_mul(0x100000001b3); + } + } + if let Some(coordinate) = coordinate { + for byte in coordinate { + hash ^= *byte as u64; + hash = hash.wrapping_mul(0x100000001b3); + } + } + hash as i64 +} + /// Extract p-tag mentions from an event and insert into the `event_mentions` table. /// /// Called after event insertion. Failures are logged but do not block event storage. @@ -212,6 +239,17 @@ pub struct CreatedCommunityRecord { pub host: String, } +/// Result of atomically creating a community with its initial owner. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum CreateCommunityWithOwnerResult { + /// The community was created, or an identical retried create found it. + Created(CreatedCommunityRecord), + /// The host already belongs to another owner. + HostExists, + /// The intended owner already owns the maximum number of communities. + LimitReached, +} + /// Community row returned by operator-plane ownership reads. #[derive(Debug, Clone, PartialEq, Eq)] pub struct OwnedCommunityRecord { @@ -528,15 +566,14 @@ impl Db { /// Atomically creates a community and its initial owner. /// - /// Atomically create a community host and bootstrap its initial owner, - /// enforcing the per-owner community limit inside a transaction that holds - /// a per-owner advisory lock. This is the authoritative limit check — kgoose - /// preflight counts are advisory only. + /// Holds a per-owner advisory lock while enforcing the ownership limit. + /// Identical create retries return the original record; host collisions and + /// limit failures remain distinguishable to the operator API. pub async fn create_community_with_owner( &self, normalized_host: &str, owner_pubkey: &str, - ) -> Result> { + ) -> Result { let owner_pubkey = owner_pubkey.to_ascii_lowercase(); let mut tx = self.pool.begin().await?; @@ -573,7 +610,7 @@ impl Db { if owned_count >= relay_members::MAX_COMMUNITIES_PER_OWNER { tx.rollback().await?; - return Ok(None); + return Ok(CreateCommunityWithOwnerResult::LimitReached); } sqlx::query( @@ -601,16 +638,18 @@ impl Db { .await?; let Some(existing) = existing else { tx.rollback().await?; - return Ok(None); + return Ok(CreateCommunityWithOwnerResult::HostExists); }; (existing.try_get("id")?, existing.try_get("host")?) }; tx.commit().await?; - Ok(Some(CreatedCommunityRecord { - id: CommunityId::from_uuid(id), - host, - })) + Ok(CreateCommunityWithOwnerResult::Created( + CreatedCommunityRecord { + id: CommunityId::from_uuid(id), + host, + }, + )) } /// Returns the community that owns a channel, if the channel exists. @@ -2685,31 +2724,13 @@ impl Db { let created_at = chrono::DateTime::from_timestamp(created_at_secs, 0) .ok_or(DbError::InvalidTimestamp(created_at_secs))?; - // Stable advisory-lock key: hash (kind, pubkey, channel_id) to i64. - // Uses FNV-1a for determinism — Rust's DefaultHasher is NOT stable across processes. - // Collisions cause extra serialization, not incorrect behavior. - let lock_key = { - let mut h: u64 = 0xcbf29ce484222325; // FNV offset basis - for b in community_id.as_uuid().as_bytes() { - h ^= *b as u64; - h = h.wrapping_mul(0x100000001b3); - } - for b in kind_i32.to_le_bytes() { - h ^= b as u64; - h = h.wrapping_mul(0x100000001b3); // FNV prime - } - for b in pubkey_bytes.as_slice() { - h ^= *b as u64; - h = h.wrapping_mul(0x100000001b3); - } - if let Some(ch) = channel_id { - for b in ch.as_bytes() { - h ^= *b as u64; - h = h.wrapping_mul(0x100000001b3); - } - } - h as i64 - }; + // Collisions only cause extra serialization; they cannot change behavior. + let lock_key = event_replacement_lock_key( + community_id, + kind_i32, + pubkey_bytes.as_slice(), + channel_id.as_ref().map(|id| id.as_bytes().as_slice()), + ); let mut tx = self.pool.begin().await?; @@ -2817,123 +2838,6 @@ impl Db { )) } - /// Authoritative replacement for relay-generated snapshots (NIP-43 - /// membership lists). - /// - /// Unlike [`replace_addressable_event`], this method does NOT apply - /// stale-write protection based on `created_at` / event-id ordering. The - /// relay is the authoritative source for these events — it always wants - /// the latest snapshot to replace any prior one, regardless of - /// same-second timestamp races on random event IDs. This fixes the bug - /// where a transfer snapshot published in the same second as a create - /// snapshot could be rejected as "stale" purely because its random event - /// ID happened to be lexicographically larger. - /// - /// All other semantics (advisory lock serialization, soft-delete old, - /// insert new, mentions) match `replace_addressable_event`. - pub async fn replace_addressable_event_forced( - &self, - community_id: CommunityId, - event: &nostr::Event, - channel_id: Option, - ) -> Result<(StoredEvent, bool)> { - let kind_i32 = buzz_core::kind::event_kind_i32(event); - let pubkey_bytes = event.pubkey.to_bytes(); - let created_at_secs = event.created_at.as_secs() as i64; - let created_at = chrono::DateTime::from_timestamp(created_at_secs, 0) - .ok_or(DbError::InvalidTimestamp(created_at_secs))?; - - // Same advisory-lock key algorithm as replace_addressable_event. - let lock_key = { - let mut h: u64 = 0xcbf29ce484222325; - for b in community_id.as_uuid().as_bytes() { - h ^= *b as u64; - h = h.wrapping_mul(0x100000001b3); - } - for b in kind_i32.to_le_bytes() { - h ^= b as u64; - h = h.wrapping_mul(0x100000001b3); - } - for b in pubkey_bytes.as_slice() { - h ^= *b as u64; - h = h.wrapping_mul(0x100000001b3); - } - if let Some(ch) = channel_id { - for b in ch.as_bytes() { - h ^= *b as u64; - h = h.wrapping_mul(0x100000001b3); - } - } - h as i64 - }; - - let mut tx = self.pool.begin().await?; - - sqlx::query("SELECT pg_advisory_xact_lock($1)") - .bind(lock_key) - .execute(&mut *tx) - .await?; - - // Soft-delete ALL existing events for this (kind, pubkey, channel_id) - // without any created_at/id comparison. The relay is authoritative. - sqlx::query( - "UPDATE events SET deleted_at = NOW() \ - WHERE community_id = $1 AND kind = $2 AND pubkey = $3 \ - AND channel_id IS NOT DISTINCT FROM $4 \ - AND deleted_at IS NULL", - ) - .bind(community_id.as_uuid()) - .bind(kind_i32) - .bind(pubkey_bytes.as_slice()) - .bind(channel_id) - .execute(&mut *tx) - .await?; - - let sig_bytes = event.sig.serialize(); - let tags_json = serde_json::to_value(&event.tags)?; - let received_at = chrono::Utc::now(); - let d_tag = crate::event::extract_d_tag(event); - - let insert_result = sqlx::query( - "INSERT INTO events (community_id, id, pubkey, created_at, kind, tags, content, sig, received_at, channel_id, d_tag) \ - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) \ - ON CONFLICT DO NOTHING", - ) - .bind(community_id.as_uuid()) - .bind(event.id.as_bytes().as_slice()) - .bind(pubkey_bytes.as_slice()) - .bind(created_at) - .bind(kind_i32) - .bind(&tags_json) - .bind(&event.content) - .bind(sig_bytes.as_slice()) - .bind(received_at) - .bind(channel_id) - .bind(d_tag.as_deref()) - .execute(&mut *tx) - .await?; - - let was_inserted = insert_result.rows_affected() > 0; - if !was_inserted { - tx.rollback().await?; - return Ok(( - StoredEvent::with_received_at(event.clone(), received_at, channel_id, false), - false, - )); - } - - tx.commit().await?; - - if let Err(e) = crate::insert_mentions(&self.pool, community_id, event, channel_id).await { - tracing::warn!(event_id = %event.id, "Failed to insert mentions: {e}"); - } - - Ok(( - StoredEvent::with_received_at(event.clone(), received_at, channel_id, true), - true, - )) - } - /// Atomically publish a NIP-43 membership snapshot under a single /// transaction-scoped advisory lock. /// @@ -2943,10 +2847,6 @@ impl Db { /// prevents the stale-snapshot race where a concurrent publication reads /// older state and overwrites a newer snapshot by arrival order. /// - /// The lock key matches the one used by - /// `replace_addressable_event_forced` for the same (kind, pubkey, - /// channel_id) tuple, so publications through both code paths serialize - /// against each other. pub async fn publish_nip43_membership_locked( &self, community_id: CommunityId, @@ -2957,23 +2857,8 @@ impl Db { let kind_i32 = buzz_core::kind::KIND_NIP43_MEMBERSHIP_LIST as i32; let pubkey_bytes = relay_keypair.public_key().to_bytes(); - // Same advisory-lock key algorithm as replace_addressable_event. - let lock_key = { - let mut h: u64 = 0xcbf29ce484222325; - for b in community_id.as_uuid().as_bytes() { - h ^= *b as u64; - h = h.wrapping_mul(0x100000001b3); - } - for b in kind_i32.to_le_bytes() { - h ^= b as u64; - h = h.wrapping_mul(0x100000001b3); - } - for b in pubkey_bytes.as_slice() { - h ^= *b as u64; - h = h.wrapping_mul(0x100000001b3); - } - h as i64 - }; + let lock_key = + event_replacement_lock_key(community_id, kind_i32, pubkey_bytes.as_slice(), None); let mut tx = self.pool.begin().await?; @@ -3000,23 +2885,23 @@ impl Db { // Build the NIP-43 event from the locked member rows. let mut tags: Vec = Vec::with_capacity(member_count + 1); // NIP-70 protected-event marker. - tags.push( - Tag::parse(["-"]) - .map_err(|e| crate::error::DbError::InvalidData(format!("failed to build '-' tag: {e}")))?, - ); + tags.push(Tag::parse(["-"]).map_err(|e| { + crate::error::DbError::InvalidData(format!("failed to build '-' tag: {e}")) + })?); for row in &rows { let pubkey: String = row.try_get("pubkey")?; let role: String = row.try_get("role")?; - tags.push( - Tag::parse(["member", &pubkey, &role]) - .map_err(|e| crate::error::DbError::InvalidData(format!("failed to build member tag: {e}")))?, - ); + tags.push(Tag::parse(["member", &pubkey, &role]).map_err(|e| { + crate::error::DbError::InvalidData(format!("failed to build member tag: {e}")) + })?); } let event = EventBuilder::new(Kind::Custom(kind_i32 as u16), "") .tags(tags) .sign_with_keys(relay_keypair) - .map_err(|e| crate::error::DbError::InvalidData(format!("failed to sign kind:13534: {e}")))?; + .map_err(|e| { + crate::error::DbError::InvalidData(format!("failed to sign kind:13534: {e}")) + })?; let created_at_secs = event.created_at.as_secs() as i64; let created_at = chrono::DateTime::from_timestamp(created_at_secs, 0) @@ -3113,28 +2998,12 @@ impl Db { let created_at = chrono::DateTime::from_timestamp(created_at_secs, 0) .ok_or(DbError::InvalidTimestamp(created_at_secs))?; - // Stable advisory-lock key: FNV-1a over (kind, pubkey, d_tag). - // Same algorithm as replace_addressable_event — deterministic across processes. - let lock_key = { - let mut h: u64 = 0xcbf29ce484222325; // FNV offset basis - for b in community_id.as_uuid().as_bytes() { - h ^= *b as u64; - h = h.wrapping_mul(0x100000001b3); - } - for b in kind_i32.to_le_bytes() { - h ^= b as u64; - h = h.wrapping_mul(0x100000001b3); - } - for b in pubkey_bytes.as_slice() { - h ^= *b as u64; - h = h.wrapping_mul(0x100000001b3); - } - for b in d_tag.as_bytes() { - h ^= *b as u64; - h = h.wrapping_mul(0x100000001b3); - } - h as i64 - }; + let lock_key = event_replacement_lock_key( + community_id, + kind_i32, + pubkey_bytes.as_slice(), + Some(d_tag.as_bytes()), + ); let mut tx = self.pool.begin().await?; @@ -4102,8 +3971,10 @@ mod tests { let created = db .create_community_with_owner(&host, owner) .await - .expect("create community") - .expect("new host"); + .expect("create community"); + let CreateCommunityWithOwnerResult::Created(created) = created else { + panic!("expected new community"); + }; assert_eq!(created.host, host); let owner_role: Option = sqlx::query_scalar( "SELECT role FROM relay_members WHERE community_id = $1 AND pubkey = $2", @@ -4118,15 +3989,18 @@ mod tests { let retry = db .create_community_with_owner(&host.to_ascii_uppercase(), owner) .await - .expect("same-owner retry") - .expect("existing same-owner community"); - assert_eq!(retry, created, "retry returns the original row"); + .expect("same-owner retry"); + assert_eq!( + retry, + CreateCommunityWithOwnerResult::Created(created.clone()), + "retry returns the original row" + ); let collision = db .create_community_with_owner(&host, other) .await .expect("collision result"); - assert!(collision.is_none()); + assert_eq!(collision, CreateCommunityWithOwnerResult::HostExists); let roles: Vec<(String, String)> = sqlx::query_as( "SELECT pubkey, role FROM relay_members WHERE community_id = $1 ORDER BY pubkey", ) @@ -4143,7 +4017,10 @@ mod tests { .create_community_with_owner(&host, owner) .await .expect("post-rotation retry"); - assert!(post_rotation_retry.is_none()); + assert_eq!( + post_rotation_retry, + CreateCommunityWithOwnerResult::HostExists + ); } #[tokio::test] @@ -4155,21 +4032,27 @@ mod tests { // Create 3 communities for this owner (the max). for i in 0..3 { let host = format!("limit-test-{}-{}.example", i, Uuid::new_v4().simple()); - db.create_community_with_owner(&host, &owner) - .await - .expect("create community") - .expect("new host"); + assert!(matches!( + db.create_community_with_owner(&host, &owner) + .await + .expect("create community"), + CreateCommunityWithOwnerResult::Created(_) + )); } - // 4th community for the same owner should fail with None. let host = format!("limit-test-3-{}.example", Uuid::new_v4().simple()); - let result = db - .create_community_with_owner(&host, &owner) - .await - .expect("create community call"); + assert_eq!( + db.create_community_with_owner(&host, &owner) + .await + .expect("create community call"), + CreateCommunityWithOwnerResult::LimitReached + ); assert!( - result.is_none(), - "4th community for the same owner should be rejected by limit" + db.lookup_community_by_host(&host) + .await + .expect("look up rolled-back fresh host") + .is_none(), + "limit rejection must roll back the fresh community row" ); } @@ -4184,13 +4067,10 @@ mod tests { db.create_community_with_owner(&host, owner), db.create_community_with_owner(&host, owner), ); - let first = first - .expect("first concurrent create") - .expect("first result"); - let second = second - .expect("second concurrent create") - .expect("second result"); + let first = first.expect("first concurrent create"); + let second = second.expect("second concurrent create"); + assert!(matches!(first, CreateCommunityWithOwnerResult::Created(_))); assert_eq!(first, second, "conflict loser re-reads the winning row"); } diff --git a/crates/buzz-db/src/relay_members.rs b/crates/buzz-db/src/relay_members.rs index 0be245fe46..d354b057bd 100644 --- a/crates/buzz-db/src/relay_members.rs +++ b/crates/buzz-db/src/relay_members.rs @@ -334,7 +334,7 @@ pub fn owner_count_advisory_lock_key(pubkey_hex: &str) -> i64 { /// Runs in a single transaction: /// 1. Acquires a transaction-scoped advisory lock on the *transferee* pubkey /// so that concurrent transfers to the same recipient serialize. The same -/// lock key is used by [`create_community_with_owner_locked`] to prevent +/// lock key is also used by `Db::create_community_with_owner` to prevent /// transfer-vs-create races. /// 2. Locks the current owner row `FOR UPDATE` and verifies /// `expected_owner_pubkey` matches. This prevents a stale-owner race where @@ -520,6 +520,30 @@ mod tests { CommunityId::from_uuid(id) } + fn test_pubkey() -> String { + format!("{:064x}", Uuid::new_v4().as_u128()) + } + + async fn assert_role(pool: &PgPool, community: CommunityId, pubkey: &str, role: &str) { + assert_eq!( + get_relay_member(pool, community, pubkey) + .await + .expect("get relay member") + .map(|member| member.role) + .as_deref(), + Some(role) + ); + } + + async fn owned_community(pool: &PgPool) -> (CommunityId, String) { + let community = make_test_community(pool).await; + let owner = test_pubkey(); + bootstrap_owner(pool, community, &owner) + .await + .expect("bootstrap owner"); + (community, owner) + } + /// 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 @@ -533,7 +557,7 @@ mod tests { 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 pubkey = test_pubkey(); let inserted = add_relay_member(&pool, community_a, &pubkey, "member", None) .await @@ -597,7 +621,7 @@ mod tests { 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()); + let owner = test_pubkey(); bootstrap_owner(&pool, community_a, &owner) .await @@ -623,13 +647,8 @@ mod tests { #[ignore = "requires Postgres"] async fn transfer_ownership_demotes_old_owner_to_member() { let pool = setup_pool().await; - let community = make_test_community(&pool).await; - let old_owner = format!("{:064x}", Uuid::new_v4().as_u128()); - let new_owner = format!("{:064x}", Uuid::new_v4().as_u128()); - - bootstrap_owner(&pool, community, &old_owner) - .await - .expect("bootstrap initial owner"); + let (community, old_owner) = owned_community(&pool).await; + let new_owner = test_pubkey(); let result = transfer_ownership(&pool, community, &new_owner, &old_owner) .await @@ -642,21 +661,8 @@ mod tests { } ); - // New owner is owner. - let new_role = get_relay_member(&pool, community, &new_owner) - .await - .expect("get new owner") - .expect("new owner exists") - .role; - assert_eq!(new_role, "owner"); - - // Old owner is member, not admin, not owner. - let old_role = get_relay_member(&pool, community, &old_owner) - .await - .expect("get old owner") - .expect("old owner still exists") - .role; - assert_eq!(old_role, "member"); + assert_role(&pool, community, &new_owner, "owner").await; + assert_role(&pool, community, &old_owner, "member").await; } /// Transferring to the current sole owner is a no-op (`AlreadyOwner`). @@ -664,12 +670,7 @@ mod tests { #[ignore = "requires Postgres"] async fn transfer_ownership_already_owner_is_noop() { let pool = setup_pool().await; - let community = make_test_community(&pool).await; - let owner = format!("{:064x}", Uuid::new_v4().as_u128()); - - bootstrap_owner(&pool, community, &owner) - .await - .expect("bootstrap owner"); + let (community, owner) = owned_community(&pool).await; let result = transfer_ownership(&pool, community, &owner, &owner) .await @@ -677,13 +678,7 @@ mod tests { assert_eq!(result, TransferResult::AlreadyOwner); - // Still owner. - let role = get_relay_member(&pool, community, &owner) - .await - .expect("get owner") - .expect("owner exists") - .role; - assert_eq!(role, "owner"); + assert_role(&pool, community, &owner, "owner").await; } /// Transferring a community with no owner row returns `NoOwner`. @@ -692,8 +687,8 @@ mod tests { async fn transfer_ownership_no_owner_returns_no_owner() { let pool = setup_pool().await; let community = make_test_community(&pool).await; - let new_owner = format!("{:064x}", Uuid::new_v4().as_u128()); - let expected = format!("{:064x}", Uuid::new_v4().as_u128()); + let new_owner = test_pubkey(); + let expected = test_pubkey(); // No bootstrap — community exists but has no owner row. @@ -712,9 +707,9 @@ mod tests { let pool = setup_pool().await; let community_a = make_test_community(&pool).await; let community_b = make_test_community(&pool).await; - let owner_a = format!("{:064x}", Uuid::new_v4().as_u128()); - let owner_b = format!("{:064x}", Uuid::new_v4().as_u128()); - let new_owner = format!("{:064x}", Uuid::new_v4().as_u128()); + let owner_a = test_pubkey(); + let owner_b = test_pubkey(); + let new_owner = test_pubkey(); bootstrap_owner(&pool, community_a, &owner_a) .await @@ -727,33 +722,9 @@ mod tests { .await .expect("transfer A"); - // A: new owner is owner, old owner is member. - assert_eq!( - get_relay_member(&pool, community_a, &new_owner) - .await - .expect("get new owner A") - .expect("exists") - .role, - "owner" - ); - assert_eq!( - get_relay_member(&pool, community_a, &owner_a) - .await - .expect("get old owner A") - .expect("exists") - .role, - "member" - ); - - // B: untouched — owner_b is still owner, new_owner is not a member. - assert_eq!( - get_relay_member(&pool, community_b, &owner_b) - .await - .expect("get owner B") - .expect("exists") - .role, - "owner" - ); + assert_role(&pool, community_a, &new_owner, "owner").await; + assert_role(&pool, community_a, &owner_a, "member").await; + assert_role(&pool, community_b, &owner_b, "owner").await; assert!( !is_relay_member(&pool, community_b, &new_owner) .await @@ -769,8 +740,8 @@ mod tests { async fn transfer_ownership_promotes_existing_member() { let pool = setup_pool().await; let community = make_test_community(&pool).await; - let old_owner = format!("{:064x}", Uuid::new_v4().as_u128()); - let existing_member = format!("{:064x}", Uuid::new_v4().as_u128()); + let old_owner = test_pubkey(); + let existing_member = test_pubkey(); bootstrap_owner(&pool, community, &old_owner) .await @@ -811,9 +782,9 @@ mod tests { async fn transfer_ownership_returns_owner_conflict_when_expected_mismatches() { let pool = setup_pool().await; let community = make_test_community(&pool).await; - let old_owner = format!("{:064x}", Uuid::new_v4().as_u128()); - let new_owner = format!("{:064x}", Uuid::new_v4().as_u128()); - let wrong_expected = format!("{:064x}", Uuid::new_v4().as_u128()); + let old_owner = test_pubkey(); + let new_owner = test_pubkey(); + let wrong_expected = test_pubkey(); bootstrap_owner(&pool, community, &old_owner) .await @@ -852,8 +823,8 @@ mod tests { #[ignore = "requires Postgres"] async fn transfer_ownership_returns_limit_reached_for_maxed_transferee() { let pool = setup_pool().await; - let owner = format!("{:064x}", Uuid::new_v4().as_u128()); - let transferee = format!("{:064x}", Uuid::new_v4().as_u128()); + let owner = test_pubkey(); + let transferee = test_pubkey(); // Give the transferee 3 communities (the max). for _ in 0..3 { diff --git a/crates/buzz-relay/src/api/operator.rs b/crates/buzz-relay/src/api/operator.rs index 756114c51c..55be86cba5 100644 --- a/crates/buzz-relay/src/api/operator.rs +++ b/crates/buzz-relay/src/api/operator.rs @@ -11,7 +11,7 @@ use axum::{ http::{HeaderMap, StatusCode}, response::Json, }; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use serde_json::Value; use uuid::Uuid; @@ -19,7 +19,6 @@ use buzz_core::{CommunityId, TenantContext}; use crate::handlers::community_provisioning::{ normalize_candidate_host, validate_pubkey_hex, ProvisionCommunityRequest, - TransferCommunityRequest, TransferCommunityResponse, }; use crate::state::AppState; @@ -37,6 +36,22 @@ pub struct CommunityAvailabilityQuery { host: String, } +#[derive(Debug, Deserialize)] +struct TransferCommunityRequest { + community_id: String, + new_owner_pubkey: String, + expected_owner_pubkey: String, +} + +#[derive(Debug, Serialize)] +struct TransferCommunityResponse { + community_id: String, + new_owner_pubkey: String, + status: &'static str, + #[serde(skip_serializing_if = "Option::is_none")] + previous_owner: Option, +} + const OPERATOR_REPLAY_SCOPE: &str = "operator-management"; /// Shared deployment-global operator auth prelude. The canonical management @@ -163,7 +178,9 @@ pub async fn provision_community( Err(msg) if msg.starts_with("actor not authorized") => { Err(api_error(StatusCode::FORBIDDEN, &msg)) } - Err(msg) if msg == "community already exists" => Err(api_error(StatusCode::CONFLICT, &msg)), + Err(msg) if msg == "community already exists" || msg.starts_with("limit_reached:") => { + Err(api_error(StatusCode::CONFLICT, &msg)) + } Err(msg) if msg.starts_with("failed to create community:") || msg.starts_with("community provisioned but owner bootstrap failed:") => @@ -388,7 +405,7 @@ mod tests { use tower::ServiceExt; use uuid::Uuid; - use buzz_core::kind::KIND_NIP43_MEMBERSHIP_LIST; + use buzz_core::{kind::KIND_NIP43_MEMBERSHIP_LIST, CommunityId}; use buzz_db::event::EventQuery; use crate::router::build_router; @@ -499,6 +516,85 @@ mod tests { serde_json::from_slice(&bytes).expect("response JSON") } + async fn signed_operator_request( + state: Arc, + keys: &Keys, + method: &str, + path: &str, + body: Option, + ) -> axum::response::Response { + let url = format!("http://{INGRESS_HOST}{path}"); + let auth = nip98_auth_header(keys, &url, method, body.as_deref().map(str::as_bytes)); + let mut request = Request::builder() + .method(method) + .uri(path) + .header(header::HOST, INGRESS_HOST) + .header(header::AUTHORIZATION, auth); + if body.is_some() { + request = request.header(header::CONTENT_TYPE, "application/json"); + } + build_router(state) + .oneshot( + request + .body(body.map_or_else(Body::empty, Body::from)) + .expect("request"), + ) + .await + .expect("response") + } + + async fn provision_community( + state: Arc, + operator: &Keys, + host: &str, + owner: &Keys, + ) -> axum::response::Response { + let body = serde_json::json!({ + "host": host, + "initial_owner_pubkey": owner.public_key().to_hex(), + "create_only": true, + }) + .to_string(); + signed_operator_request(state, operator, "POST", "/operator/communities", Some(body)).await + } + + fn is_member_tag(tag: &Tag, pubkey: &str, role: &str) -> bool { + let values = tag.as_slice(); + values.first().is_some_and(|value| value == "member") + && values.get(1).is_some_and(|value| value == pubkey) + && values.get(2).is_some_and(|value| value == role) + } + + async fn assert_snapshot_roles( + state: &AppState, + community: CommunityId, + expected: &[(&str, &str)], + ) { + let snapshot = state + .db + .query_events(&EventQuery { + kinds: Some(vec![KIND_NIP43_MEMBERSHIP_LIST as i32]), + global_only: true, + limit: Some(1), + ..EventQuery::for_community(community) + }) + .await + .expect("query membership snapshot") + .into_iter() + .next() + .expect("membership snapshot published"); + for &(pubkey, role) in expected { + assert!( + snapshot + .event + .tags + .iter() + .any(|tag| is_member_tag(tag, pubkey, role)), + "missing {role} snapshot tag for {pubkey}" + ); + } + } + #[tokio::test] #[ignore = "requires Postgres"] async fn non_allowlisted_operator_key_gets_403() { @@ -641,28 +737,7 @@ mod tests { return; }; let host = format!("community-{}.example", Uuid::new_v4().simple()); - let body = serde_json::json!({ - "host": host, - "initial_owner_pubkey": owner.public_key().to_hex(), - "create_only": true, - }) - .to_string(); - let url = format!("http://{INGRESS_HOST}/operator/communities"); - let auth = nip98_auth_header(&operator, &url, "POST", Some(body.as_bytes())); - - let response = build_router(state.clone()) - .oneshot( - Request::builder() - .method("POST") - .uri("/operator/communities") - .header(header::HOST, INGRESS_HOST) - .header(header::AUTHORIZATION, auth) - .header(header::CONTENT_TYPE, "application/json") - .body(Body::from(body)) - .expect("request"), - ) - .await - .expect("response"); + let response = provision_community(state.clone(), &operator, &host, &owner).await; assert_eq!(response.status(), StatusCode::OK); let json = read_json(response).await; @@ -686,29 +761,41 @@ mod tests { .expect("owner member exists"); assert_eq!(member.role, "owner"); - let membership_events = state + assert_snapshot_roles(&state, community.id, &[(&owner_hex, "owner")]).await; + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn fresh_host_at_owner_limit_returns_limit_reached_conflict() { + let operator = Keys::generate(); + let owner = Keys::generate(); + let Some(state) = operator_test_state(std::slice::from_ref(&operator)).await else { + return; + }; + + for _ in 0..buzz_db::relay_members::MAX_COMMUNITIES_PER_OWNER { + let host = format!("community-{}.example", Uuid::new_v4().simple()); + assert_eq!( + provision_community(state.clone(), &operator, &host, &owner) + .await + .status(), + StatusCode::OK + ); + } + + let host = format!("community-{}.example", Uuid::new_v4().simple()); + let response = provision_community(state.clone(), &operator, &host, &owner).await; + assert_eq!(response.status(), StatusCode::CONFLICT); + let json = read_json(response).await; + assert!(json["error"] + .as_str() + .is_some_and(|error| error.starts_with("limit_reached:"))); + assert!(state .db - .query_events(&EventQuery { - kinds: Some(vec![KIND_NIP43_MEMBERSHIP_LIST as i32]), - global_only: true, - limit: Some(1), - ..EventQuery::for_community(community.id) - }) + .lookup_community_by_host(&host) .await - .expect("query membership snapshot"); - let snapshot = membership_events - .first() - .expect("membership snapshot published during provisioning"); - assert!(snapshot.event.tags.iter().any(|tag| { - tag.as_slice() - .first() - .is_some_and(|value| value == "member") - && tag - .as_slice() - .get(1) - .is_some_and(|value| value == &owner_hex) - && tag.as_slice().get(2).is_some_and(|value| value == "owner") - })); + .expect("look up rejected fresh host") + .is_none()); } /// Happy path: POST /operator/communities/transfer swaps ownership, demotes @@ -724,30 +811,10 @@ mod tests { return; }; - // Create a community with initial_owner. let host = format!("community-{}.example", Uuid::new_v4().simple()); - let create_body = serde_json::json!({ - "host": host, - "initial_owner_pubkey": initial_owner.public_key().to_hex(), - "create_only": true, - }) - .to_string(); - let create_url = format!("http://{INGRESS_HOST}/operator/communities"); - let create_auth = - nip98_auth_header(&operator, &create_url, "POST", Some(create_body.as_bytes())); - build_router(state.clone()) - .oneshot( - Request::builder() - .method("POST") - .uri("/operator/communities") - .header(header::HOST, INGRESS_HOST) - .header(header::AUTHORIZATION, create_auth) - .header(header::CONTENT_TYPE, "application/json") - .body(Body::from(create_body)) - .expect("create request"), - ) - .await - .expect("create response"); + let create_response = + provision_community(state.clone(), &operator, &host, &initial_owner).await; + assert_eq!(create_response.status(), StatusCode::OK); let community = state .db @@ -759,34 +826,20 @@ mod tests { let initial_owner_hex = initial_owner.public_key().to_hex(); let new_owner_hex = new_owner.public_key().to_hex(); - // Transfer ownership. let transfer_body = serde_json::json!({ "community_id": community_id, "new_owner_pubkey": new_owner_hex, "expected_owner_pubkey": initial_owner_hex, }) .to_string(); - let transfer_url = format!("http://{INGRESS_HOST}/operator/communities/transfer"); - let transfer_auth = nip98_auth_header( + let response = signed_operator_request( + state.clone(), &operator, - &transfer_url, "POST", - Some(transfer_body.as_bytes()), - ); - - let response = build_router(state.clone()) - .oneshot( - Request::builder() - .method("POST") - .uri("/operator/communities/transfer") - .header(header::HOST, INGRESS_HOST) - .header(header::AUTHORIZATION, transfer_auth) - .header(header::CONTENT_TYPE, "application/json") - .body(Body::from(transfer_body)) - .expect("transfer request"), - ) - .await - .expect("transfer response"); + "/operator/communities/transfer", + Some(transfer_body), + ) + .await; assert_eq!(response.status(), StatusCode::OK); let json = read_json(response).await; @@ -826,33 +879,12 @@ mod tests { "member" ); - // NIP-43 snapshot reflects the new roles. - let membership_events = state - .db - .query_events(&EventQuery { - kinds: Some(vec![KIND_NIP43_MEMBERSHIP_LIST as i32]), - global_only: true, - limit: Some(1), - ..EventQuery::for_community(community.id) - }) - .await - .expect("query membership snapshot"); - let snapshot = membership_events - .first() - .expect("membership snapshot published after transfer"); - assert!(snapshot.event.tags.iter().any(|tag| { - tag.as_slice().first().is_some_and(|v| v == "member") - && tag.as_slice().get(1).is_some_and(|v| v == &new_owner_hex) - && tag.as_slice().get(2).is_some_and(|v| v == "owner") - })); - assert!(snapshot.event.tags.iter().any(|tag| { - tag.as_slice().first().is_some_and(|v| v == "member") - && tag - .as_slice() - .get(1) - .is_some_and(|v| v == &initial_owner_hex) - && tag.as_slice().get(2).is_some_and(|v| v == "member") - })); + assert_snapshot_roles( + &state, + community.id, + &[(&new_owner_hex, "owner"), (&initial_owner_hex, "member")], + ) + .await; } /// Transfer with an invalid community_id returns 400. @@ -870,22 +902,14 @@ mod tests { "expected_owner_pubkey": new_owner.public_key().to_hex(), }) .to_string(); - let url = format!("http://{INGRESS_HOST}/operator/communities/transfer"); - let auth = nip98_auth_header(&operator, &url, "POST", Some(body.as_bytes())); - - let response = build_router(state) - .oneshot( - Request::builder() - .method("POST") - .uri("/operator/communities/transfer") - .header(header::HOST, INGRESS_HOST) - .header(header::AUTHORIZATION, auth) - .header(header::CONTENT_TYPE, "application/json") - .body(Body::from(body)) - .expect("request"), - ) - .await - .expect("response"); + let response = signed_operator_request( + state, + &operator, + "POST", + "/operator/communities/transfer", + Some(body), + ) + .await; assert_eq!(response.status(), StatusCode::BAD_REQUEST); } @@ -904,22 +928,14 @@ mod tests { "expected_owner_pubkey": "not-a-pubkey", }) .to_string(); - let url = format!("http://{INGRESS_HOST}/operator/communities/transfer"); - let auth = nip98_auth_header(&operator, &url, "POST", Some(body.as_bytes())); - - let response = build_router(state) - .oneshot( - Request::builder() - .method("POST") - .uri("/operator/communities/transfer") - .header(header::HOST, INGRESS_HOST) - .header(header::AUTHORIZATION, auth) - .header(header::CONTENT_TYPE, "application/json") - .body(Body::from(body)) - .expect("request"), - ) - .await - .expect("response"); + let response = signed_operator_request( + state, + &operator, + "POST", + "/operator/communities/transfer", + Some(body), + ) + .await; assert_eq!(response.status(), StatusCode::BAD_REQUEST); } diff --git a/crates/buzz-relay/src/handlers/community_provisioning.rs b/crates/buzz-relay/src/handlers/community_provisioning.rs index ba4e505e46..3185af8bea 100644 --- a/crates/buzz-relay/src/handlers/community_provisioning.rs +++ b/crates/buzz-relay/src/handlers/community_provisioning.rs @@ -170,33 +170,6 @@ fn validate_domain_labels(domain: &str) -> Result<(), String> { Ok(()) } -/// JSON body for `POST /operator/communities/transfer`. -#[derive(Debug, Deserialize)] -pub struct TransferCommunityRequest { - /// UUID of the community to transfer. - pub community_id: String, - /// 64-char hex pubkey of the new owner. - pub new_owner_pubkey: String, - /// 64-char hex pubkey of the current owner. The relay verifies this - /// matches inside the transfer transaction and returns `owner_conflict` - /// if it doesn't. Prevents stale-owner races. - pub expected_owner_pubkey: String, -} - -/// JSON response from `POST /operator/communities/transfer`. -#[derive(Debug, Serialize)] -pub struct TransferCommunityResponse { - /// UUID of the transferred community. - pub community_id: String, - /// 64-char hex pubkey of the new owner. - pub new_owner_pubkey: String, - /// `"transferred"` or `"already_owner"`. - pub status: &'static str, - /// Hex pubkey of the previous owner, if one existed and was demoted. - #[serde(skip_serializing_if = "Option::is_none")] - pub previous_owner: Option, -} - /// Normalize and validate a host supplied to read-only operator endpoints. /// /// Unlike create, availability checks may accept non-canonical but normalizable @@ -308,12 +281,23 @@ pub async fn provision_community( let owner_hex = initial_owner.as_deref().ok_or_else(|| { "initial_owner_pubkey is required when create_only is true".to_string() })?; - let record = state + let record = match state .db .create_community_with_owner(&request.host, owner_hex) .await .map_err(|e| format!("failed to create community: {e}"))? - .ok_or_else(|| "community already exists".to_string())?; + { + buzz_db::CreateCommunityWithOwnerResult::Created(record) => record, + buzz_db::CreateCommunityWithOwnerResult::HostExists => { + return Err("community already exists".to_string()); + } + buzz_db::CreateCommunityWithOwnerResult::LimitReached => { + return Err( + "limit_reached: owner already owns the maximum number of communities" + .to_string(), + ); + } + }; info!( operator = %operator_hex, diff --git a/crates/buzz-relay/src/handlers/side_effects.rs b/crates/buzz-relay/src/handlers/side_effects.rs index dc22bdd0a0..d017aee14e 100644 --- a/crates/buzz-relay/src/handlers/side_effects.rs +++ b/crates/buzz-relay/src/handlers/side_effects.rs @@ -2778,10 +2778,7 @@ pub async fn publish_nip43_membership_list( .await; } - info!( - member_count, - "NIP-43 membership list published" - ); + info!(member_count, "NIP-43 membership list published"); Ok(()) }