feat(relay): add GET /operator/communities/{community_id}

Sponsoring a community from a workspace needs to confirm that the
community exists and is still active, but the operator plane could only
look communities up by host or by owner.

Add an operator-authenticated read by id that returns the community's
id, host, and whether its deletion_state is still active, backed by a
new Db::get_community_by_id that reads any lifecycle state. Also
document the required expected_owner_pubkey on the transfer endpoint,
and mark the existing NIP-FI deny-test ES256 key in router.rs as a
sadscan false positive so commits touching router.rs pass the hook.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Signed-off-by: Shu Hui <shu@squareup.com>
This commit is contained in:
Shu Hui
2026-10-15 10:19:12 +11:00
parent 9ade5d1ad1
commit 106b3e6463
4 changed files with 207 additions and 6 deletions

View File

@@ -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};

View File

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

View File

@@ -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<Arc<AppState>>,
headers: HeaderMap,
Path(community_id): Path<String>,
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
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": "<uuid>", "new_owner_pubkey": "<hex>" }
/// { "community_id": "<uuid>", "new_owner_pubkey": "<hex>", "expected_owner_pubkey": "<hex>" }
/// ```
///
/// 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

View File

@@ -366,6 +366,10 @@ pub fn build_router(state: Arc<AppState>) -> 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";