fix(relay): publish membership snapshot on provisioning (#1761)

Signed-off-by: npub1dccv64krpcpse5cmkzfeh998cftungyatw3djt8jwdw6g43f7fyqzzmrf7 <6e30cd56c30e030cd31bb0939b94a7c257c9a09d5ba2d92cf2735da45629f248@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: npub1dccv64krpcpse5cmkzfeh998cftungyatw3djt8jwdw6g43f7fyqzzmrf7 <6e30cd56c30e030cd31bb0939b94a7c257c9a09d5ba2d92cf2735da45629f248@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
Kalvin C
2026-07-13 08:22:44 -07:00
committed by GitHub
co-authored by npub1dccv64krpcpse5cmkzfeh998cftungyatw3djt8jwdw6g43f7fyqzzmrf7
parent bea507d8aa
commit 0950d392b7
2 changed files with 63 additions and 3 deletions
+31 -1
View File
@@ -260,6 +260,9 @@ mod tests {
use tower::ServiceExt;
use uuid::Uuid;
use buzz_core::kind::KIND_NIP43_MEMBERSHIP_LIST;
use buzz_db::event::EventQuery;
use crate::router::build_router;
use crate::state::AppState;
@@ -324,6 +327,7 @@ mod tests {
.iter()
.map(|keys| keys.public_key().to_hex())
.collect();
config.require_relay_membership = true;
let pool = sqlx::PgPool::connect(TEST_DB_URL).await.ok()?;
let db = buzz_db::Db::from_pool(pool.clone());
@@ -512,6 +516,7 @@ mod tests {
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");
@@ -544,12 +549,37 @@ mod tests {
.await
.expect("lookup community")
.expect("community exists");
let owner_hex = owner.public_key().to_hex();
let member = state
.db
.get_relay_member(community.id, &owner.public_key().to_hex())
.get_relay_member(community.id, &owner_hex)
.await
.expect("lookup owner role")
.expect("owner member exists");
assert_eq!(member.role, "owner");
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 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")
}));
}
}
@@ -28,9 +28,9 @@
use std::sync::Arc;
use serde::{Deserialize, Serialize};
use tracing::info;
use tracing::{info, warn};
use buzz_core::tenant::normalize_host;
use buzz_core::tenant::{normalize_host, TenantContext};
use url::{Host, Url};
use crate::state::AppState;
@@ -201,6 +201,34 @@ pub(crate) fn normalize_candidate_host(host: &str) -> Result<String, String> {
Ok(normalized)
}
/// Publish the event-backed membership view after owner persistence.
///
/// Provisioning is already committed when this runs. Publication therefore
/// remains best-effort, matching every other membership mutation path: turning
/// a stored success into an HTTP failure would make create-only retries report
/// a misleading conflict while leaving clients without a repair path.
async fn publish_membership_snapshot_if_required(
state: &Arc<AppState>,
community: buzz_core::CommunityId,
host: &str,
) {
if !state.config.require_relay_membership {
return;
}
let tenant = TenantContext::resolved(community, host);
if let Err(error) =
crate::handlers::side_effects::publish_nip43_membership_list(&tenant, state).await
{
warn!(
community = %community,
host,
error = %error,
"community provisioned but NIP-43 membership snapshot publication failed"
);
}
}
/// Validate and execute a relay-operator community provisioning request.
///
/// The caller is an HTTP operator endpoint, not the Nostr event ingest path.
@@ -267,6 +295,7 @@ pub async fn provision_community(
owner = %owner_hex,
"community created via operator endpoint"
);
publish_membership_snapshot_if_required(state, record.id, &record.host).await;
return Ok(ProvisionCommunityResponse {
community_id: record.id.to_string(),
host: record.host,
@@ -290,6 +319,7 @@ pub async fn provision_community(
.bootstrap_owner(record.id, owner_hex)
.await
.map_err(|e| format!("community provisioned but owner bootstrap failed: {e}"))?;
publish_membership_snapshot_if_required(state, record.id, &record.host).await;
}
info!(