mirror of
https://github.com/block/buzz.git
synced 2026-10-27 04:30:52 +00:00
feat(relay): add community unarchive endpoint (#1908)
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:
@@ -299,6 +299,15 @@ pub struct ArchivedCommunityRecord {
|
||||
pub archived_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// Community row returned by an owner-authorized unarchive operation.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct UnarchivedCommunityRecord {
|
||||
/// Stable server-resolved community id.
|
||||
pub id: CommunityId,
|
||||
/// Reserved canonical host restored to active admission.
|
||||
pub host: String,
|
||||
}
|
||||
|
||||
/// Token summary returned by [`Db::list_active_tokens`].
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TokenSummary {
|
||||
@@ -781,6 +790,35 @@ impl Db {
|
||||
.transpose()
|
||||
}
|
||||
|
||||
/// Idempotently restores a community when the asserted pubkey is its current owner.
|
||||
pub async fn unarchive_community_owned_by(
|
||||
&self,
|
||||
normalized_host: &str,
|
||||
owner_pubkey: &str,
|
||||
) -> Result<Option<UnarchivedCommunityRecord>> {
|
||||
let row = sqlx::query(
|
||||
r#"UPDATE communities c
|
||||
SET archived_at = NULL
|
||||
FROM relay_members rm
|
||||
WHERE lower(c.host) = lower($1)
|
||||
AND rm.community_id = c.id
|
||||
AND lower(rm.pubkey) = lower($2)
|
||||
AND rm.role = 'owner'
|
||||
RETURNING c.id, c.host"#,
|
||||
)
|
||||
.bind(normalized_host)
|
||||
.bind(owner_pubkey)
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
row.map(|row| {
|
||||
Ok(UnarchivedCommunityRecord {
|
||||
id: CommunityId::from_uuid(row.try_get("id")?),
|
||||
host: row.try_get("host")?,
|
||||
})
|
||||
})
|
||||
.transpose()
|
||||
}
|
||||
|
||||
/// Returns the community that owns a channel, if the channel exists.
|
||||
///
|
||||
/// Internal relay producers use this to derive tenant context from the row
|
||||
@@ -4421,6 +4459,77 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "requires Postgres"]
|
||||
async fn unarchive_community_owned_by_restores_admission_idempotently() {
|
||||
let db = setup_db().await;
|
||||
let host = format!("unarchive-{}.example", Uuid::new_v4().simple());
|
||||
let owner = format!("{}{}", Uuid::new_v4().simple(), Uuid::new_v4().simple());
|
||||
let outsider = format!("{}{}", Uuid::new_v4().simple(), Uuid::new_v4().simple());
|
||||
let created = db
|
||||
.create_community_with_owner(&host, &owner)
|
||||
.await
|
||||
.expect("create community");
|
||||
let CreateCommunityWithOwnerResult::Created(created) = created else {
|
||||
panic!("expected new community");
|
||||
};
|
||||
|
||||
let archived = db
|
||||
.archive_community_owned_by(&host, &owner, "protected.example")
|
||||
.await
|
||||
.expect("archive community")
|
||||
.expect("owned community");
|
||||
assert_eq!(archived.id, created.id);
|
||||
assert!(
|
||||
db.lookup_community_by_host(&host)
|
||||
.await
|
||||
.expect("active lookup")
|
||||
.is_none(),
|
||||
"archived communities must fail admission"
|
||||
);
|
||||
assert!(db
|
||||
.unarchive_community_owned_by(&host, &outsider)
|
||||
.await
|
||||
.expect("wrong-owner unarchive")
|
||||
.is_none());
|
||||
assert!(db
|
||||
.unarchive_community_owned_by("missing.example", &owner)
|
||||
.await
|
||||
.expect("unknown-host unarchive")
|
||||
.is_none());
|
||||
|
||||
let restored = db
|
||||
.unarchive_community_owned_by(&host.to_ascii_uppercase(), &owner)
|
||||
.await
|
||||
.expect("unarchive community")
|
||||
.expect("owned community");
|
||||
assert_eq!(restored.id, created.id);
|
||||
assert_eq!(restored.host, host);
|
||||
assert_eq!(
|
||||
db.lookup_community_by_host(&host)
|
||||
.await
|
||||
.expect("restored lookup")
|
||||
.expect("active community")
|
||||
.id,
|
||||
created.id
|
||||
);
|
||||
assert_eq!(
|
||||
db.get_relay_member(created.id, &owner)
|
||||
.await
|
||||
.expect("owner lookup")
|
||||
.expect("owner remains")
|
||||
.role,
|
||||
"owner"
|
||||
);
|
||||
|
||||
let retry = db
|
||||
.unarchive_community_owned_by(&host, &owner)
|
||||
.await
|
||||
.expect("idempotent retry")
|
||||
.expect("owned community");
|
||||
assert_eq!(retry, restored);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "requires Postgres"]
|
||||
async fn create_community_with_owner_enforces_per_owner_limit() {
|
||||
|
||||
@@ -261,6 +261,43 @@ pub async fn archive_community(
|
||||
})))
|
||||
}
|
||||
|
||||
/// 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>>,
|
||||
@@ -798,6 +835,97 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[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() {
|
||||
|
||||
@@ -69,6 +69,10 @@ pub fn build_router(state: Arc<AppState>) -> Router {
|
||||
"/operator/communities/archive",
|
||||
post(api::operator::archive_community),
|
||||
)
|
||||
.route(
|
||||
"/operator/communities/unarchive",
|
||||
post(api::operator::unarchive_community),
|
||||
)
|
||||
.route(
|
||||
"/operator/communities/availability",
|
||||
get(api::operator::community_availability),
|
||||
|
||||
Reference in New Issue
Block a user