diff --git a/crates/buzz-db/src/lib.rs b/crates/buzz-db/src/lib.rs index 2f306c35d0..6c9cd711ee 100644 --- a/crates/buzz-db/src/lib.rs +++ b/crates/buzz-db/src/lib.rs @@ -75,9 +75,9 @@ pub use store::{ pub use allowlist::AllowlistEntry; pub use api_token::{ApiTokenRecord, TokenSummary}; pub use community::{ - ArchivedCommunityRecord, CommunityRecord, CreateCommunityWithOwnerResult, - CreatedCommunityRecord, EnsuredCommunityRecord, OwnedCommunityRecord, UnarchiveCommunityResult, - UnarchivedCommunityRecord, + ArchivedCommunityRecord, CommunityLifecycleRecord, CommunityRecord, + CreateCommunityWithOwnerResult, CreatedCommunityRecord, EnsuredCommunityRecord, + OwnedCommunityRecord, UnarchiveCommunityResult, UnarchivedCommunityRecord, }; pub use error::{DbError, Result}; pub use event::{ChannelHeadPrecondition, ChannelHeadWriteStatus}; diff --git a/crates/buzz-db/src/store/community.rs b/crates/buzz-db/src/store/community.rs index e084d8ed17..6db37bcba8 100644 --- a/crates/buzz-db/src/store/community.rs +++ b/crates/buzz-db/src/store/community.rs @@ -74,6 +74,18 @@ pub struct OwnedCommunitiesPage { pub can_create: bool, } +/// Community row returned by [`Db::get_community_by_id`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CommunityLifecycleRecord { + /// Stable server-resolved community id. + pub id: CommunityId, + /// Normalized host that maps to this community. + pub host: String, + /// Whether `deletion_state` is `active`, i.e. no deletion is executing or + /// complete. Archival alone does not clear it. + pub active: bool, +} + /// Community row returned by an owner-authorized archive operation. #[derive(Debug, Clone, PartialEq, Eq)] pub struct ArchivedCommunityRecord { @@ -291,6 +303,38 @@ impl Db { .transpose() } + /// Returns a community by id regardless of lifecycle state. + /// Operator-plane only. + #[datastore_span(name = "get_community_by_id", system = "postgresql")] + pub async fn get_community_by_id( + &self, + community_id: CommunityId, + ) -> Result> { + let mut connection = crate::observability::acquire_writer( + &self.pool, + crate::observability::WriterOperation::Authorization, + ) + .await?; + let row = sqlx::query( + r#" + SELECT id, host, deletion_state = 'active' AS active + FROM communities + WHERE id = $1 + "#, + ) + .bind(community_id.as_uuid()) + .fetch_optional(&mut *connection) + .await?; + row.map(|row| { + Ok(CommunityLifecycleRecord { + id: CommunityId::from_uuid(row.try_get("id")?), + host: row.try_get("host")?, + active: row.try_get("active")?, + }) + }) + .transpose() + } + /// Lists communities where `owner_pubkey` currently holds the `owner` role. /// /// This is an operator-plane helper, not a tenant-scoped data-plane read: @@ -885,6 +929,7 @@ mod postgres_tests { "with_community_archive_fence", "with_inactive_community_fence", "lookup_community_by_host_for_management", + "get_community_by_id", "list_communities_owned_by", "lookup_community_host", "get_community_icon", @@ -928,6 +973,7 @@ mod postgres_tests { "CreatedCommunityRecord", "OwnedCommunityRecord", "OwnedCommunitiesPage", + "CommunityLifecycleRecord", "ArchivedCommunityRecord", "UnarchivedCommunityRecord", ]; @@ -949,6 +995,7 @@ mod postgres_tests { "concurrent_same_owner_create_returns_the_winning_row_to_both_callers", "ensure_configured_community_reports_insert_winner", "list_communities_owned_by_returns_only_owner_rows", + "get_community_by_id_reads_any_lifecycle", "communities_of_channels_present_for_existing_absent_for_missing", ]; for test in moved_tests { @@ -1543,6 +1590,48 @@ mod postgres_tests { assert!(owned.can_create); } + #[tokio::test] + #[ignore = "requires Postgres"] + async fn get_community_by_id_reads_any_lifecycle() { + let db = setup_db().await; + assert_eq!( + db.get_community_by_id(CommunityId::from_uuid(Uuid::new_v4())) + .await + .expect("read missing community"), + None + ); + + async fn is_active(db: &Db, community: CommunityId) -> bool { + db.get_community_by_id(community) + .await + .expect("read community") + .expect("community still readable") + .active + } + + let community = CommunityId::from_uuid(make_community(&db.pool).await); + let record = db + .get_community_by_id(community) + .await + .expect("read active community") + .expect("community exists"); + assert_eq!(record.id, community); + assert!(record.active); + + sqlx::query("UPDATE communities SET archived_at = now() WHERE id = $1") + .bind(community.as_uuid()) + .execute(&db.pool) + .await + .expect("archive community"); + assert!(is_active(&db, community).await, "archived"); + + for label in ["quiescing", "tombstone"] { + let community = CommunityId::from_uuid(make_community(&db.pool).await); + crate::test_support::set_deletion_state(&db.pool, *community.as_uuid(), label).await; + assert!(!is_active(&db, community).await, "{label}"); + } + } + #[tokio::test] #[ignore = "requires Postgres"] async fn owner_quota_serializes_concurrent_create_and_transfer_with_reservation() { diff --git a/crates/buzz-relay/src/api/operator.rs b/crates/buzz-relay/src/api/operator.rs index 4a1fb717c1..046ea3108b 100644 --- a/crates/buzz-relay/src/api/operator.rs +++ b/crates/buzz-relay/src/api/operator.rs @@ -7,7 +7,7 @@ use std::sync::Arc; use axum::{ - extract::{Query, RawQuery, State}, + extract::{Path, Query, RawQuery, State}, http::{HeaderMap, StatusCode}, response::Json, }; @@ -644,13 +644,55 @@ pub async fn list_owned_communities( }))) } +/// Read one community by id, regardless of lifecycle state. +/// +/// `GET /operator/communities/{community_id}`, NIP-98 signed by a pubkey in +/// `RELAY_OPERATOR_PUBKEYS`. Returns `404` when no community has that id. +/// `active` is false once deletion is executing or complete; archival alone +/// does not clear it. +pub async fn get_community( + State(state): State>, + headers: HeaderMap, + Path(community_id): Path, +) -> Result, (StatusCode, Json)> { + authorize_operator_request( + &state, + &headers, + "GET", + &format!("/operator/communities/{community_id}"), + None, + None, + ) + .await?; + + let community_uuid = Uuid::parse_str(&community_id).map_err(|e| { + api_error( + StatusCode::BAD_REQUEST, + &format!("invalid community_id: {e}"), + ) + })?; + + let record = state + .db + .get_community_by_id(CommunityId::from_uuid(community_uuid)) + .await + .map_err(|e| internal_error(&format!("get community: {e}")))? + .ok_or_else(|| api_error(StatusCode::NOT_FOUND, "community not found"))?; + + Ok(Json(serde_json::json!({ + "community_id": record.id.to_string(), + "host": record.host, + "active": record.active, + }))) +} + /// 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": "" } +/// { "community_id": "", "new_owner_pubkey": "", "expected_owner_pubkey": "" } /// ``` /// /// The previous owner is demoted to `member` (not `admin`). The transfer is @@ -3543,6 +3585,72 @@ mod postgres_tests { assert_eq!(response.status(), StatusCode::BAD_REQUEST); } + /// Reading a community by id returns its lifecycle snapshot without the + /// owner, including after archival, and binds the NIP-98 signature to the + /// id path. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn get_community_returns_lifecycle_for_any_state() { + let operator = Keys::generate(); + let owner = Keys::generate(); + let Some(state) = operator_test_state(std::slice::from_ref(&operator)).await else { + return; + }; + let host = format!("community-{}.example", Uuid::new_v4().simple()); + let create_response = provision_community(state.clone(), &operator, &host, &owner).await; + assert_eq!(create_response.status(), StatusCode::OK); + let community_id = read_json(create_response).await["community_id"] + .as_str() + .expect("community_id") + .to_string(); + let path = format!("/operator/communities/{community_id}"); + + let response = signed_operator_request(state.clone(), &operator, "GET", &path, None).await; + assert_eq!(response.status(), StatusCode::OK); + let json = read_json(response).await; + assert_eq!(json["community_id"], community_id.as_str()); + assert_eq!(json["host"], host.as_str()); + assert_eq!(json["active"], true); + assert!(json.get("owner_pubkey").is_none()); + assert!(json.get("created_at").is_none()); + + archive_for_owner_deletion(&state, &host, &owner).await; + let response = signed_operator_request(state.clone(), &operator, "GET", &path, None).await; + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(read_json(response).await["active"], true); + + let other_url = format!( + "http://{INGRESS_HOST}/operator/communities/{}", + Uuid::new_v4() + ); + let other_auth = nip98_auth_header(&operator, &other_url, "GET", None); + let response = operator_request_with_auth(state, "GET", &path, None, other_auth).await; + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn get_community_rejects_unknown_and_malformed_ids() { + let operator = Keys::generate(); + let Some(state) = operator_test_state(std::slice::from_ref(&operator)).await else { + return; + }; + let unknown = format!("/operator/communities/{}", Uuid::new_v4()); + let response = + signed_operator_request(state.clone(), &operator, "GET", &unknown, None).await; + assert_eq!(response.status(), StatusCode::NOT_FOUND); + + let response = signed_operator_request( + state, + &operator, + "GET", + "/operator/communities/not-a-uuid", + None, + ) + .await; + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + } + /// Regression for the RELAY_OPERATOR_API_ORIGIN decoupling: with the /// operator allowlist set but no origin configured (the shape an /// admin-console-only operator boots in), the provisioning endpoints must diff --git a/crates/buzz-relay/src/router.rs b/crates/buzz-relay/src/router.rs index 6349f15c66..8c64121825 100644 --- a/crates/buzz-relay/src/router.rs +++ b/crates/buzz-relay/src/router.rs @@ -366,6 +366,10 @@ pub fn build_router(state: Arc) -> Router { "/operator/communities/transfer", post(api::operator::transfer_community), ) + .route( + "/operator/communities/{community_id}", + get(api::operator::get_community), + ) // Relay invites: mint (owner/admin) + claim (membership-gate exempt) .route("/api/invites", post(api::invites::mint_invite)) .route("/api/join-policy", get(api::invites::join_policy)) @@ -3835,7 +3839,7 @@ mod tests { // ── NIP-FI S4 deny witnesses ── // ES256 key pair — same as command.rs / api/nip_fi.rs test material. const DENY_TEST_PRIVATE_KEY_PEM: &str = - "-----BEGIN PRIVATE KEY-----\nMIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgcnxDM4EiirH9dHUE\nWZc759TX4s5PAn8kO5ovXSnGxCWhRANCAARFb6ZnsfkqOOXyEhj3KBQphGKF4vTa\nzhebbavbZ1ZoklqkF1cGg+jTO7rONAVEzXvXUWtV6CdDV+rybiVmFP2w\n-----END PRIVATE KEY-----\n"; + "-----BEGIN PRIVATE KEY-----\nMIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgcnxDM4EiirH9dHUE\nWZc759TX4s5PAn8kO5ovXSnGxCWhRANCAARFb6ZnsfkqOOXyEhj3KBQphGKF4vTa\nzhebbavbZ1ZoklqkF1cGg+jTO7rONAVEzXvXUWtV6CdDV+rybiVmFP2w\n-----END PRIVATE KEY-----\n"; // sadscan:disable np.pem.1 kingfisher.privkey.2 -- test-only ES256 key const DENY_TEST_ISS: &str = "https://nip-fi-deny-test.example.com"; const DENY_TEST_AUD: &str = "https://relay.example";