//! Deployment-operator HTTP APIs. //! //! These routes are outside the Nostr event data plane. They still use NIP-98 //! request signing and replay protection, but they do not run through event //! ingest, relay membership, channel scoping, storage, or fan-out. use std::sync::Arc; use axum::{ extract::{Query, RawQuery, State}, http::{HeaderMap, StatusCode}, response::Json, }; use serde::{Deserialize, Serialize}; use serde_json::Value; use uuid::Uuid; use buzz_core::{CommunityId, TenantContext}; use crate::handlers::community_provisioning::{ normalize_candidate_host, validate_pubkey_hex, ProvisionCommunityRequest, }; use crate::state::AppState; use super::{api_error, bridge, internal_error}; /// Query parameters for `GET /operator/communities`. #[derive(Debug, Deserialize)] pub struct ListCommunitiesQuery { owner_pubkey: String, } /// Query parameters for `GET /operator/communities/availability`. #[derive(Debug, Deserialize)] pub struct CommunityAvailabilityQuery { host: String, } #[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 /// origin and replay namespace are configuration, never tenant registry state /// or an inbound proxy `Host` header. async fn authorize_operator_request( state: &Arc, headers: &HeaderMap, method: &str, path: &str, raw_query: Option<&str>, body: Option<&[u8]>, ) -> Result)> { let origin = state .config .relay_operator_api_origin .as_deref() .ok_or_else(|| internal_error("operator API origin is not configured"))?; let path_with_query = match raw_query { Some(q) if !q.is_empty() => format!("{path}?{q}"), _ => path.to_string(), }; let url = format!("{origin}{path_with_query}"); let (pubkey, event_id_bytes) = bridge::verify_bridge_auth_with_options( headers, method, &url, body, true, // operator endpoints always require NIP-98; no X-Pubkey dev fallback body.is_some(), )?; check_operator_replay(state, event_id_bytes).await?; let pubkey_hex = pubkey.to_hex(); if !state .config .relay_operator_pubkeys .iter() .any(|pk| pk == &pubkey_hex) { return Err(api_error( StatusCode::FORBIDDEN, "actor not authorized: not a relay operator", )); } Ok(pubkey) } async fn check_operator_replay( state: &AppState, event_id_bytes: [u8; 32], ) -> Result<(), (StatusCode, Json)> { let event_id = nostr::EventId::from_byte_array(event_id_bytes); match state .nip98_replay .try_mark_in_scope( OPERATOR_REPLAY_SCOPE, &event_id, buzz_auth::DEFAULT_REPLAY_TTL_SECS, ) .await { Ok(true) => Ok(()), Ok(false) => Err(api_error( StatusCode::UNAUTHORIZED, "NIP-98: replay detected", )), Err(error) => { tracing::warn!( scope = OPERATOR_REPLAY_SCOPE, error = %error, "operator NIP-98 replay guard failed; rejecting request fail-closed" ); Err(api_error( StatusCode::UNAUTHORIZED, "NIP-98: replay check unavailable", )) } } } /// Create a community host and atomically bootstrap its initial owner. /// /// `POST /operator/communities`, NIP-98 signed by a pubkey in /// `RELAY_OPERATOR_PUBKEYS`, body: /// /// ```json /// { "host": "acme.communities.buzz.xyz", "initial_owner_pubkey": "" } /// ``` /// /// The request is authenticated against `RELAY_OPERATOR_API_ORIGIN` and does /// not bind the inbound host to a tenant. The operator allowlist is the /// authority for this deployment-root control-plane surface. pub async fn provision_community( State(state): State>, headers: HeaderMap, body: axum::body::Bytes, ) -> Result, (StatusCode, Json)> { let pubkey = authorize_operator_request( &state, &headers, "POST", "/operator/communities", None, Some(&body), ) .await?; let request: ProvisionCommunityRequest = serde_json::from_slice(&body).map_err(|e| { api_error( StatusCode::BAD_REQUEST, &format!("invalid provision-community JSON: {e}"), ) })?; match crate::handlers::community_provisioning::provision_community(&state, &pubkey, request) .await { Ok(response) => Ok(Json(serde_json::to_value(response).map_err(|e| { tracing::error!("failed to serialize provision-community response: {e}"); internal_error("operator provision response serialization failed") })?)), Err(msg) if msg.starts_with("actor not authorized") => { Err(api_error(StatusCode::FORBIDDEN, &msg)) } Err(msg) 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:") => { tracing::error!(error = %msg, "operator community persistence failed"); Err(internal_error("operator community persistence failed")) } Err(msg) => Err(api_error(StatusCode::BAD_REQUEST, &msg)), } } /// Owner assertion supplied by the trusted operator client. #[derive(Debug, Deserialize)] pub struct ArchiveCommunityRequest { host: String, owner_pubkey: String, } /// Idempotently archive a community owned by the asserted end-user identity. pub async fn archive_community( State(state): State>, headers: HeaderMap, body: axum::body::Bytes, ) -> Result, (StatusCode, Json)> { const PATH: &str = "/operator/communities/archive"; authorize_operator_request(&state, &headers, "POST", PATH, None, Some(&body)).await?; let request: ArchiveCommunityRequest = serde_json::from_slice(&body).map_err(|e| { api_error( StatusCode::BAD_REQUEST, &format!("invalid archive-community JSON: {e}"), ) })?; let normalized_host = normalize_candidate_host(&request.host) .map_err(|msg| api_error(StatusCode::BAD_REQUEST, &msg))?; let deployment_host = buzz_core::tenant::relay_url_authority(&state.config.relay_url); if normalized_host == deployment_host { return Err(api_error( StatusCode::CONFLICT, "the deployment community cannot be archived", )); } let owner = validate_pubkey_hex(&request.owner_pubkey).ok_or_else(|| { api_error( StatusCode::BAD_REQUEST, "invalid owner_pubkey: expected 64-char hex pubkey", ) })?; let record = state .db .archive_community_owned_by(&normalized_host, &owner, &deployment_host) .await .map_err(|e| internal_error(&format!("archive community: {e}")))? .ok_or_else(|| api_error(StatusCode::NOT_FOUND, "community not found"))?; let tenant = TenantContext::resolved(record.id, &record.host); let closed = match state.disconnect_community_clusterwide(&tenant).await { Ok(closed) => closed, Err(error) => { tracing::warn!(community = %record.id, host = %record.host, %error, "community archived but disconnect propagation is pending"); return Err(( StatusCode::SERVICE_UNAVAILABLE, Json(serde_json::json!({ "community_id": record.id.to_string(), "host": record.host, "archived_at": record.archived_at, "status": "archived", "propagation": "pending", "error": "connection propagation pending — retry this request", })), )); } }; tracing::info!(community = %record.id, host = %record.host, local_connections_closed = closed, "community archived"); Ok(Json(serde_json::json!({ "community_id": record.id.to_string(), "host": record.host, "archived_at": record.archived_at, "status": "archived", }))) } /// Idempotently restore an archived community owned by the asserted end-user identity. pub async fn unarchive_community( State(state): State>, headers: HeaderMap, body: axum::body::Bytes, ) -> Result, (StatusCode, Json)> { const PATH: &str = "/operator/communities/unarchive"; authorize_operator_request(&state, &headers, "POST", PATH, None, Some(&body)).await?; let request: ArchiveCommunityRequest = serde_json::from_slice(&body).map_err(|e| { api_error( StatusCode::BAD_REQUEST, &format!("invalid unarchive-community JSON: {e}"), ) })?; let normalized_host = normalize_candidate_host(&request.host) .map_err(|msg| api_error(StatusCode::BAD_REQUEST, &msg))?; let owner = validate_pubkey_hex(&request.owner_pubkey).ok_or_else(|| { api_error( StatusCode::BAD_REQUEST, "invalid owner_pubkey: expected 64-char hex pubkey", ) })?; let record = state .db .unarchive_community_owned_by(&normalized_host, &owner) .await .map_err(|e| internal_error(&format!("unarchive community: {e}")))? .ok_or_else(|| api_error(StatusCode::NOT_FOUND, "community not found"))?; tracing::info!(community = %record.id, host = %record.host, "community unarchived"); Ok(Json(serde_json::json!({ "community_id": record.id.to_string(), "host": record.host, "archived_at": null, "status": "active", }))) } /// List communities where a pubkey currently holds the `owner` role. pub async fn list_owned_communities( State(state): State>, headers: HeaderMap, RawQuery(raw_query): RawQuery, Query(query): Query, ) -> Result, (StatusCode, Json)> { authorize_operator_request( &state, &headers, "GET", "/operator/communities", raw_query.as_deref(), None, ) .await?; let owner_pubkey = validate_pubkey_hex(&query.owner_pubkey).ok_or_else(|| { api_error( StatusCode::BAD_REQUEST, "invalid owner_pubkey: expected 64-char hex pubkey", ) })?; let rows = state .db .list_communities_owned_by(&owner_pubkey) .await .map_err(|e| internal_error(&format!("list owned communities: {e}")))?; Ok(Json(serde_json::json!({ "owner_pubkey": owner_pubkey, "communities": rows.into_iter().map(|row| serde_json::json!({ "community_id": row.id.to_string(), "host": row.host, "created_at": row.created_at, "archived_at": row.archived_at, })).collect::>(), }))) } /// 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 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, &expected_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", )); } 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 // 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( State(state): State>, headers: HeaderMap, RawQuery(raw_query): RawQuery, Query(query): Query, ) -> Result, (StatusCode, Json)> { authorize_operator_request( &state, &headers, "GET", "/operator/communities/availability", raw_query.as_deref(), None, ) .await?; let normalized_host = normalize_candidate_host(&query.host) .map_err(|msg| api_error(StatusCode::BAD_REQUEST, &msg))?; let existing = state .db .lookup_community_by_host_for_management(&normalized_host) .await .map_err(|e| internal_error(&format!("check community availability: {e}")))?; Ok(Json(serde_json::json!({ "host": query.host, "normalized_host": normalized_host, "available": existing.is_none(), "community_id": existing.map(|record| record.id.to_string()), }))) } #[cfg(test)] mod tests { use std::sync::Arc; use axum::{ body::{to_bytes, Body}, http::{header, Request, StatusCode}, }; use base64::Engine; use nostr::{EventBuilder, Keys, Kind, Tag}; use serde_json::Value; use sha2::{Digest, Sha256}; use tower::ServiceExt; use uuid::Uuid; use buzz_core::{kind::KIND_NIP43_MEMBERSHIP_LIST, CommunityId}; use buzz_db::event::EventQuery; use crate::router::build_router; use crate::state::AppState; struct AlwaysFreshReplayGuard; impl buzz_auth::Nip98ReplayGuard for AlwaysFreshReplayGuard { fn try_mark_in_scope<'a>( &'a self, _scope: &'a str, _event_id: &'a nostr::EventId, _ttl_secs: u64, ) -> std::pin::Pin< Box> + Send + 'a>, > { Box::pin(async { Ok(true) }) } } const INGRESS_HOST: &str = "operator-ingress.example"; fn nip98_auth_header(keys: &Keys, url: &str, method: &str, body: Option<&[u8]>) -> String { let mut tags = vec![ Tag::parse(["u", url]).expect("u tag"), Tag::parse(["method", method]).expect("method tag"), ]; if let Some(body) = body { let hash: [u8; 32] = Sha256::digest(body).into(); let hash_hex = hex::encode(hash); tags.push(Tag::parse(["payload", hash_hex.as_str()]).expect("payload tag")); } let event = EventBuilder::new(Kind::HttpAuth, "") .tags(tags) .sign_with_keys(keys) .expect("sign NIP-98 event"); let event_json = serde_json::to_string(&event).expect("serialize NIP-98 event"); let encoded = base64::engine::general_purpose::STANDARD.encode(event_json.as_bytes()); format!("Nostr {encoded}") } fn nip98_auth_header_without_payload(keys: &Keys, url: &str, method: &str) -> String { let tags = vec![ Tag::parse(["u", url]).expect("u tag"), Tag::parse(["method", method]).expect("method tag"), ]; let event = EventBuilder::new(Kind::HttpAuth, "") .tags(tags) .sign_with_keys(keys) .expect("sign NIP-98 event"); let event_json = serde_json::to_string(&event).expect("serialize NIP-98 event"); let encoded = base64::engine::general_purpose::STANDARD.encode(event_json.as_bytes()); format!("Nostr {encoded}") } async fn operator_test_state(operator_keys: &[Keys]) -> Arc { crate::test_support::test_state_with_config_and_state( |config| { config.relay_url = "wss://tenant.example".to_string(); config.relay_operator_api_origin = Some(format!("http://{INGRESS_HOST}")); config.relay_operator_pubkeys = operator_keys .iter() .map(|keys| keys.public_key().to_hex()) .collect(); config.require_relay_membership = true; }, |state| state.nip98_replay = Arc::new(AlwaysFreshReplayGuard), ) .await } async fn read_json(response: axum::response::Response) -> Value { let bytes = to_bytes(response.into_body(), 1024 * 1024) .await .expect("read response body"); 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] async fn non_allowlisted_operator_key_gets_403() { let operator = Keys::generate(); let outsider = Keys::generate(); let state = operator_test_state(&[operator]).await; let body = format!( r#"{{"host":"community-{}.example"}}"#, Uuid::new_v4().simple() ); let url = format!("http://{INGRESS_HOST}/operator/communities"); let auth = nip98_auth_header(&outsider, &url, "POST", Some(body.as_bytes())); let response = build_router(state) .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"); assert_eq!(response.status(), StatusCode::FORBIDDEN); } #[tokio::test] async fn post_operator_body_requires_payload_tag() { let operator = Keys::generate(); let state = operator_test_state(std::slice::from_ref(&operator)).await; let body = format!( r#"{{"host":"community-{}.example"}}"#, Uuid::new_v4().simple() ); let url = format!("http://{INGRESS_HOST}/operator/communities"); let auth = nip98_auth_header_without_payload(&operator, &url, "POST"); let response = build_router(state) .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"); assert_eq!(response.status(), StatusCode::UNAUTHORIZED); let json = read_json(response).await; assert!( json.get("error") .and_then(Value::as_str) .unwrap_or_default() .contains("missing payload tag"), "unexpected response: {json:?}" ); } #[tokio::test] #[ignore = "requires Postgres; SQLite skip: lookup_community_by_host_for_management — community lifecycle management is PostgreSQL-only"] async fn unmapped_management_host_can_check_availability() { let operator = Keys::generate(); let state = operator_test_state(std::slice::from_ref(&operator)).await; let host = format!("community-{}.example", Uuid::new_v4().simple()); let query = format!("host={host}"); let url = format!("http://{INGRESS_HOST}/operator/communities/availability?{query}"); let auth = nip98_auth_header(&operator, &url, "GET", None); let response = build_router(state) .oneshot( Request::builder() .uri(format!("/operator/communities/availability?{query}")) .header(header::HOST, INGRESS_HOST) .header(header::AUTHORIZATION, auth) .body(Body::empty()) .expect("request"), ) .await .expect("response"); assert_eq!(response.status(), StatusCode::OK); let json = read_json(response).await; assert_eq!(json.get("available").and_then(Value::as_bool), Some(true)); } #[tokio::test] #[ignore = "requires Postgres; SQLite skip: list_communities_owned_by — community lifecycle management is PostgreSQL-only"] async fn unmapped_management_host_can_list_owned_communities() { let operator = Keys::generate(); let owner = Keys::generate(); let state = operator_test_state(std::slice::from_ref(&operator)).await; let owner_hex = owner.public_key().to_hex(); let query = format!("owner_pubkey={owner_hex}"); let url = format!("http://{INGRESS_HOST}/operator/communities?{query}"); let auth = nip98_auth_header(&operator, &url, "GET", None); let response = build_router(state) .oneshot( Request::builder() .uri(format!("/operator/communities?{query}")) .header(header::HOST, INGRESS_HOST) .header(header::AUTHORIZATION, auth) .body(Body::empty()) .expect("request"), ) .await .expect("response"); assert_eq!(response.status(), StatusCode::OK); let json = read_json(response).await; assert_eq!( json.get("owner_pubkey").and_then(Value::as_str), Some(owner_hex.as_str()) ); } #[tokio::test] #[ignore = "requires Postgres; SQLite skip: unarchive_community_owned_by — community lifecycle management is PostgreSQL-only"] async fn unarchive_restores_admission_and_is_idempotent_without_changing_ownership() { let operator = Keys::generate(); let owner = Keys::generate(); let outsider = Keys::generate(); let state = operator_test_state(std::slice::from_ref(&operator)).await; let host = format!("community-{}.example", Uuid::new_v4().simple()); assert_eq!( provision_community(Arc::clone(&state), &operator, &host, &owner) .await .status(), StatusCode::OK ); let owner_hex = owner.public_key().to_hex(); let archived = state .db .archive_community_owned_by(&host, &owner_hex, "protected.example") .await .expect("archive community") .expect("owned community"); assert!(state .db .lookup_community_by_host(&host) .await .expect("archived admission lookup") .is_none()); let request = |host: &str, owner_pubkey: String| { serde_json::json!({ "host": host, "owner_pubkey": owner_pubkey, }) .to_string() }; let wrong_owner = signed_operator_request( Arc::clone(&state), &operator, "POST", "/operator/communities/unarchive", Some(request(&host, outsider.public_key().to_hex())), ) .await; assert_eq!(wrong_owner.status(), StatusCode::NOT_FOUND); assert_eq!(read_json(wrong_owner).await["error"], "community not found"); let unknown = signed_operator_request( Arc::clone(&state), &operator, "POST", "/operator/communities/unarchive", Some(request("missing.example", owner_hex.clone())), ) .await; assert_eq!(unknown.status(), StatusCode::NOT_FOUND); assert_eq!(read_json(unknown).await["error"], "community not found"); for attempt in 0..2 { let response = signed_operator_request( Arc::clone(&state), &operator, "POST", "/operator/communities/unarchive", Some(request(&host, owner_hex.clone())), ) .await; assert_eq!(response.status(), StatusCode::OK, "attempt {attempt}"); let json = read_json(response).await; assert_eq!(json["community_id"], archived.id.to_string()); assert_eq!(json["host"], host); assert!(json["archived_at"].is_null()); assert_eq!(json["status"], "active"); } let active = state .db .lookup_community_by_host(&host) .await .expect("restored admission lookup") .expect("active community"); assert_eq!(active.id, archived.id); let owner_member = state .db .get_relay_member(active.id, &owner_hex) .await .expect("owner lookup") .expect("owner remains"); assert_eq!(owner_member.role, "owner"); } #[tokio::test] #[ignore = "requires Postgres; SQLite skip: archive_community_owned_by — community lifecycle management is PostgreSQL-only"] async fn archive_publish_failure_is_retryable_and_preserves_timestamp() { let operator = Keys::generate(); let owner = Keys::generate(); let state = operator_test_state(std::slice::from_ref(&operator)).await; let host = format!("community-{}.example", Uuid::new_v4().simple()); let owner_hex = owner.public_key().to_hex(); let create_body = serde_json::json!({ "host": host, "initial_owner_pubkey": owner_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())); let create_response = build_router(Arc::clone(&state)) .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"); assert_eq!(create_response.status(), StatusCode::OK); let archive_body = serde_json::json!({ "host": host, "owner_pubkey": owner.public_key().to_hex(), }) .to_string(); let archive_url = format!("http://{INGRESS_HOST}/operator/communities/archive"); let archive_once = |state: Arc| { let auth = nip98_auth_header( &operator, &archive_url, "POST", Some(archive_body.as_bytes()), ); let body = archive_body.clone(); async move { build_router(state) .oneshot( Request::builder() .method("POST") .uri("/operator/communities/archive") .header(header::HOST, INGRESS_HOST) .header(header::AUTHORIZATION, auth) .header(header::CONTENT_TYPE, "application/json") .body(Body::from(body)) .expect("archive request"), ) .await .expect("archive response") } }; let first = archive_once(Arc::clone(&state)).await; assert_eq!(first.status(), StatusCode::SERVICE_UNAVAILABLE); let first_json = read_json(first).await; assert_eq!(first_json["status"], "archived"); assert_eq!(first_json["propagation"], "pending"); let first_archived_at = first_json["archived_at"].clone(); assert!(!first_archived_at.is_null()); assert!(state .db .lookup_community_by_host(&host) .await .expect("active lookup") .is_none()); assert_eq!( state .community_disconnect_publish_attempts .load(std::sync::atomic::Ordering::Relaxed), 1 ); let second = archive_once(Arc::clone(&state)).await; assert_eq!(second.status(), StatusCode::SERVICE_UNAVAILABLE); let second_json = read_json(second).await; assert_eq!(second_json["archived_at"], first_archived_at); assert_eq!( state .community_disconnect_publish_attempts .load(std::sync::atomic::Ordering::Relaxed), 2, "idempotent archive retry must republish the disconnect" ); let owned = state .db .list_communities_owned_by(&owner.public_key().to_hex()) .await .expect("owned communities"); let row = owned .iter() .find(|row| row.host == host) .expect("archived row"); assert_eq!( serde_json::to_value(row.archived_at).expect("timestamp JSON"), first_archived_at ); } #[tokio::test] #[ignore = "requires Postgres; SQLite skip: create_community_with_owner — community lifecycle management is PostgreSQL-only"] async fn happy_path_create_returns_created_and_bootstraps_owner() { let operator = Keys::generate(); let owner = Keys::generate(); let state = operator_test_state(std::slice::from_ref(&operator)).await; let host = format!("community-{}.example", Uuid::new_v4().simple()); let response = provision_community(state.clone(), &operator, &host, &owner).await; assert_eq!(response.status(), StatusCode::OK); let json = read_json(response).await; assert_eq!(json.get("status").and_then(Value::as_str), Some("created")); assert_eq!( json.get("host").and_then(Value::as_str), Some(host.as_str()) ); let community = state .db .lookup_community_by_host(&host) .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_hex) .await .expect("lookup owner role") .expect("owner member exists"); assert_eq!(member.role, "owner"); assert_snapshot_roles(&state, community.id, &[(&owner_hex, "owner")]).await; } #[tokio::test] #[ignore = "requires Postgres; SQLite skip: create_community_with_owner — community lifecycle management is PostgreSQL-only"] async fn fresh_host_at_owner_limit_returns_limit_reached_conflict() { let operator = Keys::generate(); let owner = Keys::generate(); let state = operator_test_state(std::slice::from_ref(&operator)).await; 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 .lookup_community_by_host(&host) .await .expect("look up rejected fresh host") .is_none()); } /// 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; SQLite skip: transfer_ownership — relay membership maintenance is PostgreSQL-only"] 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 state = operator_test_state(std::slice::from_ref(&operator)).await; let host = format!("community-{}.example", Uuid::new_v4().simple()); let create_response = provision_community(state.clone(), &operator, &host, &initial_owner).await; assert_eq!(create_response.status(), StatusCode::OK); 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(); 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 response = signed_operator_request( state.clone(), &operator, "POST", "/operator/communities/transfer", Some(transfer_body), ) .await; 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" ); assert_snapshot_roles( &state, community.id, &[(&new_owner_hex, "owner"), (&initial_owner_hex, "member")], ) .await; } /// Transfer with an invalid community_id returns 400. #[tokio::test] async fn transfer_with_invalid_community_id_returns_400() { let operator = Keys::generate(); let new_owner = Keys::generate(); let state = operator_test_state(std::slice::from_ref(&operator)).await; 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 response = signed_operator_request( state, &operator, "POST", "/operator/communities/transfer", Some(body), ) .await; assert_eq!(response.status(), StatusCode::BAD_REQUEST); } /// Transfer with an invalid new_owner_pubkey returns 400. #[tokio::test] async fn transfer_with_invalid_pubkey_returns_400() { let operator = Keys::generate(); let state = operator_test_state(std::slice::from_ref(&operator)).await; 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 response = signed_operator_request( state, &operator, "POST", "/operator/communities/transfer", Some(body), ) .await; assert_eq!(response.status(), StatusCode::BAD_REQUEST); } }