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:
Kalvin C
2026-07-15 10:21:06 -07:00
committed by GitHub
parent 2f3fa586f3
commit 6b9641db2b
3 changed files with 241 additions and 0 deletions

View File

@@ -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() {

View File

@@ -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() {

View File

@@ -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),