Files
buzz/crates/buzz-relay/src/api/operator.rs
T

1253 lines
44 KiB
Rust

//! 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<String>,
}
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<AppState>,
headers: &HeaderMap,
method: &str,
path: &str,
raw_query: Option<&str>,
body: Option<&[u8]>,
) -> Result<nostr::PublicKey, (StatusCode, Json<Value>)> {
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<Value>)> {
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": "<hex>" }
/// ```
///
/// 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<Arc<AppState>>,
headers: HeaderMap,
body: axum::body::Bytes,
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
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<Arc<AppState>>,
headers: HeaderMap,
body: axum::body::Bytes,
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
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<Arc<AppState>>,
headers: HeaderMap,
body: axum::body::Bytes,
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
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<Arc<AppState>>,
headers: HeaderMap,
RawQuery(raw_query): RawQuery,
Query(query): Query<ListCommunitiesQuery>,
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
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::<Vec<_>>(),
})))
}
/// 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": "<uuid>", "new_owner_pubkey": "<hex>" }
/// ```
///
/// 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<Arc<AppState>>,
headers: HeaderMap,
body: axum::body::Bytes,
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
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<Arc<AppState>>,
headers: HeaderMap,
RawQuery(raw_query): RawQuery,
Query(query): Query<CommunityAvailabilityQuery>,
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
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<dyn std::future::Future<Output = Result<bool, buzz_auth::AuthError>> + Send + 'a>,
> {
Box::pin(async { Ok(true) })
}
}
const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; // sadscan:disable np.postgres.1
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]) -> Option<Arc<AppState>> {
let mut config = crate::config::Config::from_env().ok()?;
config.database_url = TEST_DB_URL.to_string();
config.redis_url = "redis://127.0.0.1:1".to_string();
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;
let pool = sqlx::PgPool::connect(TEST_DB_URL).await.ok()?;
let db = buzz_db::Db::from_pool(pool.clone());
let redis_pool = deadpool_redis::Config::from_url(&config.redis_url)
.create_pool(Some(deadpool_redis::Runtime::Tokio1))
.ok()?;
let pubsub = Arc::new(
buzz_pubsub::PubSubManager::new(&config.redis_url, redis_pool.clone())
.await
.ok()?,
);
let audit = buzz_audit::AuditService::new(pool.clone());
let auth = buzz_auth::AuthService::new(config.auth.clone());
let search = buzz_search::SearchService::new(pool.clone());
let workflow_engine = Arc::new(buzz_workflow::WorkflowEngine::new(
db.clone(),
buzz_workflow::WorkflowConfig::default(),
));
let media_storage = buzz_media::MediaStorage::new(&config.media).ok()?;
let (mut state, _audit_shutdown) = AppState::new(
config,
db,
redis_pool,
audit,
pubsub,
auth,
search,
workflow_engine,
Keys::generate(),
media_storage,
);
state.nip98_replay = Arc::new(AlwaysFreshReplayGuard);
Some(Arc::new(state))
}
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<AppState>,
keys: &Keys,
method: &str,
path: &str,
body: Option<String>,
) -> 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<AppState>,
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() {
let operator = Keys::generate();
let outsider = Keys::generate();
let Some(state) = operator_test_state(&[operator]).await else {
return;
};
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]
#[ignore = "requires Postgres"]
async fn post_operator_body_requires_payload_tag() {
let operator = Keys::generate();
let Some(state) = operator_test_state(std::slice::from_ref(&operator)).await else {
return;
};
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"]
async fn unmapped_management_host_can_check_availability() {
let operator = 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 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"]
async fn unmapped_management_host_can_list_owned_communities() {
let operator = Keys::generate();
let owner = Keys::generate();
let Some(state) = operator_test_state(std::slice::from_ref(&operator)).await else {
return;
};
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"]
async fn unarchive_restores_admission_and_is_idempotent_without_changing_ownership() {
let operator = Keys::generate();
let owner = Keys::generate();
let outsider = 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());
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"]
async fn archive_publish_failure_is_retryable_and_preserves_timestamp() {
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 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<AppState>| {
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"]
async fn happy_path_create_returns_created_and_bootstraps_owner() {
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 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"]
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
.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"]
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;
};
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]
#[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(),
"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]
#[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",
"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);
}
}