From 322279fa697df05cdc110faeaacaeeef2430c595 Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 29 Sep 2026 15:55:03 +0000 Subject: [PATCH 1/6] fix(deletion): require sole owner at admission Signed-off-by: Codex Co-authored-by: Codex --- ARCHITECTURE.md | 4 +- crates/buzz-db/src/store/deletion.rs | 269 +++++++++++++++++++++++++- crates/buzz-relay/src/api/operator.rs | 4 +- 3 files changed, 264 insertions(+), 13 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index c11db7c7d32..664f4554c0a 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -16,7 +16,7 @@ community rather than overriding it. Deployment-root community management uses operator-signed NIP-98 HTTP requests. `POST /operator/communities/delete` accepts only an exact normalized, archived -community whose asserted pubkey is still its owner. The caller supplies the +community whose asserted pubkey is its sole current owner. The caller supplies the request UUID as the stable correlation/idempotency identity; the durable row records operator-attested owner intent, mediating operator, and acknowledgement version. Admission returns `202` at the `submitted` stage and performs no @@ -31,7 +31,7 @@ Owner consent on this path is asserted, not proven. The mediating operator authenticates the owner and collects the deletion acknowledgement out of band, upstream of the relay; the request itself carries only the operator's NIP-98 signature. The relay verifies operator authority and that the asserted pubkey -is still the community's owner, then records the owner pubkey, mediating +is the community's sole current owner, then records the owner pubkey, mediating operator pubkey, and acknowledgement version as durable provenance for that upstream ceremony. No owner-signed attestation is required or checked, and owners have no self-service cancellation. Recovery is a privileged abort, diff --git a/crates/buzz-db/src/store/deletion.rs b/crates/buzz-db/src/store/deletion.rs index 96629dcdeb4..66a441ae0c0 100644 --- a/crates/buzz-db/src/store/deletion.rs +++ b/crates/buzz-db/src/store/deletion.rs @@ -861,7 +861,7 @@ impl DeletionStore { /// The owner's consent arrives as the calling operator's assertion: the /// operator authenticated the owner and collected the acknowledgement /// upstream. This layer records that provenance and checks that - /// `owner_pubkey` is still the community's owner; it never verifies an + /// `owner_pubkey` is the community's sole current owner; it never verifies an /// owner-signed attestation. pub async fn admit_owner_request( &self, @@ -923,16 +923,14 @@ impl DeletionStore { return Ok(OwnerDeletionAdmission::LifecycleConflict); } - let owner_exists = sqlx::query_scalar::<_, String>( + let current_owners: Vec = sqlx::query_scalar( "SELECT pubkey FROM relay_members \ - WHERE community_id = $1 AND pubkey = $2 AND role = 'owner' FOR UPDATE", + WHERE community_id = $1 AND role = 'owner' ORDER BY pubkey FOR UPDATE", ) .bind(community_id) - .bind(&owner_pubkey) - .fetch_optional(&mut *tx) - .await? - .is_some(); - if !owner_exists { + .fetch_all(&mut *tx) + .await?; + if current_owners.len() != 1 || current_owners.first() != Some(&owner_pubkey) { tx.rollback().await?; return Ok(OwnerDeletionAdmission::NotFoundOrNotOwner); } @@ -1497,7 +1495,7 @@ impl DeletionStore { || !owner_authority_matches { return Err(DbError::DeletionSafety(format!( - "owner deletion {} community is no longer archived under the admitted owner", + "owner deletion {} community archive, lifecycle, or sole-owner authority drifted", token.request_id ))); } @@ -4725,6 +4723,53 @@ mod postgres_tests { )); } + #[tokio::test] + #[ignore = "requires Postgres"] + async fn owner_admission_rejects_legacy_co_owners_without_persisting_a_request() { + let (db, store) = store().await; + let (host, owner, community) = archived_owned_community(&db).await; + let extra_owner = "f".repeat(64); + assert!( + extra_owner > owner, + "extra owner must sort after the admitted owner" + ); + sqlx::query( + "INSERT INTO relay_members (community_id, pubkey, role) VALUES ($1, $2, 'owner')", + ) + .bind(community.as_uuid()) + .bind(&extra_owner) + .execute(&db.pool) + .await + .expect("seed legacy co-owner"); + + assert_eq!( + store + .admit_owner_request( + &host, + &owner, + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + 1, + Uuid::new_v4(), + ) + .await + .expect("legacy co-owner admission result"), + OwnerDeletionAdmission::NotFoundOrNotOwner, + ); + let request_count: i64 = sqlx::query_scalar( + "SELECT count(*) FROM community_deletion_requests WHERE community_id = $1", + ) + .bind(community.as_uuid()) + .fetch_one(&db.pool) + .await + .expect("count deletion requests"); + assert_eq!(request_count, 0, "failed admission must not persist intent"); + assert_eq!( + membership_roles(&db, community).await.len(), + 2, + "failed admission must not alter either legacy owner", + ); + } + #[tokio::test] #[ignore = "requires Postgres"] async fn accepted_owner_deletion_blocks_legacy_owner_convergence_without_membership_change() { @@ -5743,6 +5788,212 @@ mod postgres_tests { ); } + #[derive(Clone, Copy, Debug)] + enum OwnerPreparationAuthorityDrift { + ReplacedSoleOwner, + ExtraCoOwner, + InactiveDeletionState, + DeletedAtSet, + } + + async fn assert_owner_preparation_rejects_authority_drift( + drift: OwnerPreparationAuthorityDrift, + ) { + let (db, store) = store().await; + let (host, owner, community) = archived_owned_community(&db).await; + let operator = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + let request_id = Uuid::new_v4(); + store + .admit_owner_request(&host, &owner, operator, 1, request_id) + .await + .expect("admit sole owner's intent"); + let claim = store + .claim_specific_owner_submission(request_id, "preparer", DEFAULT_LEASE_DURATION) + .await + .expect("claim owner submission") + .expect("owner submission is preparable"); + let inventory = FrozenInventory { + schema: store + .inventory_schema(community) + .await + .expect("schema inventory"), + storage: empty_storage_manifest(community), + }; + let extra_owner = "f".repeat(64); + assert!( + extra_owner > owner, + "extra owner must sort after admitted owner" + ); + + match drift { + OwnerPreparationAuthorityDrift::ReplacedSoleOwner => { + sqlx::query( + "UPDATE relay_members SET role = 'member' \ + WHERE community_id = $1 AND pubkey = $2 AND role = 'owner'", + ) + .bind(community.as_uuid()) + .bind(&owner) + .execute(&db.pool) + .await + .expect("remove admitted owner's authority"); + sqlx::query( + "INSERT INTO relay_members (community_id, pubkey, role) \ + VALUES ($1, $2, 'owner')", + ) + .bind(community.as_uuid()) + .bind(&extra_owner) + .execute(&db.pool) + .await + .expect("install different sole owner"); + } + OwnerPreparationAuthorityDrift::ExtraCoOwner => { + sqlx::query( + "INSERT INTO relay_members (community_id, pubkey, role) \ + VALUES ($1, $2, 'owner')", + ) + .bind(community.as_uuid()) + .bind(&extra_owner) + .execute(&db.pool) + .await + .expect("install later-sorting legacy co-owner"); + } + OwnerPreparationAuthorityDrift::InactiveDeletionState + | OwnerPreparationAuthorityDrift::DeletedAtSet => { + let mut tx = db.pool.begin().await.expect("open fixture transaction"); + sqlx::query("SELECT set_config('buzz.deletion_executor_community', $1, true)") + .bind(community.as_uuid().to_string()) + .execute(&mut *tx) + .await + .expect("scope fixture to this community"); + sqlx::query("SELECT set_config('buzz.deletion_fence_generation', '0', true)") + .execute(&mut *tx) + .await + .expect("scope fixture generation"); + let statement = match drift { + OwnerPreparationAuthorityDrift::InactiveDeletionState => { + "UPDATE communities SET deletion_state = 'quiescing' WHERE id = $1" + } + OwnerPreparationAuthorityDrift::DeletedAtSet => { + "UPDATE communities SET deleted_at = now() WHERE id = $1" + } + _ => unreachable!("matched only lifecycle fixture variants"), + }; + sqlx::query(statement) + .bind(community.as_uuid()) + .execute(&mut *tx) + .await + .expect("establish independent lifecycle drift"); + tx.commit().await.expect("commit fixture drift"); + } + } + + let current_owners: Vec = sqlx::query_scalar( + "SELECT pubkey FROM relay_members WHERE community_id = $1 AND role = 'owner' \ + ORDER BY pubkey", + ) + .bind(community.as_uuid()) + .fetch_all(&db.pool) + .await + .expect("inspect current owners"); + let (archived_at, deletion_state, deleted_at): ( + Option>, + String, + Option>, + ) = sqlx::query_as( + "SELECT archived_at, deletion_state, deleted_at FROM communities WHERE id = $1", + ) + .bind(community.as_uuid()) + .fetch_one(&db.pool) + .await + .expect("inspect lifecycle fixture"); + assert!(archived_at.is_some(), "archive guard must remain satisfied"); + match drift { + OwnerPreparationAuthorityDrift::ReplacedSoleOwner => { + assert_eq!(current_owners, vec![extra_owner]); + assert_eq!(deletion_state, "active"); + assert!(deleted_at.is_none()); + } + OwnerPreparationAuthorityDrift::ExtraCoOwner => { + assert_eq!(current_owners, vec![owner, extra_owner]); + assert_eq!(deletion_state, "active"); + assert!(deleted_at.is_none()); + } + OwnerPreparationAuthorityDrift::InactiveDeletionState => { + assert_eq!(current_owners, vec![owner]); + assert_eq!(deletion_state, "quiescing"); + assert!(deleted_at.is_none()); + } + OwnerPreparationAuthorityDrift::DeletedAtSet => { + assert_eq!(current_owners, vec![owner]); + assert_eq!(deletion_state, "active"); + assert!(deleted_at.is_some()); + } + } + + let before = store + .get(request_id) + .await + .expect("request before preparation"); + assert_eq!(before.stage, DeletionStage::Submitted); + assert!(before.inventory_digest.is_none()); + let failure = store + .complete_owner_preparation(&claim.lease, &inventory) + .await + .expect_err("drift must prevent automatic approval"); + assert!(failure.to_string().contains("owner deletion"), "{failure}"); + assert_eq!( + store + .get(request_id) + .await + .expect("request after rejection"), + before + ); + let approvals: i64 = sqlx::query_scalar( + "SELECT count(*) FROM community_deletion_approvals WHERE request_id = $1", + ) + .bind(request_id) + .fetch_one(&db.pool) + .await + .expect("count approvals after rejection"); + assert_eq!(approvals, 0, "no digest-bound approval may be recorded"); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn owner_preparation_rejects_different_sole_owner_before_approval() { + assert_owner_preparation_rejects_authority_drift( + OwnerPreparationAuthorityDrift::ReplacedSoleOwner, + ) + .await; + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn owner_preparation_rejects_later_co_owner_before_approval() { + assert_owner_preparation_rejects_authority_drift( + OwnerPreparationAuthorityDrift::ExtraCoOwner, + ) + .await; + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn owner_preparation_rejects_inactive_state_before_approval() { + assert_owner_preparation_rejects_authority_drift( + OwnerPreparationAuthorityDrift::InactiveDeletionState, + ) + .await; + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn owner_preparation_rejects_deleted_at_before_approval() { + assert_owner_preparation_rejects_authority_drift( + OwnerPreparationAuthorityDrift::DeletedAtSet, + ) + .await; + } + #[tokio::test] #[ignore = "requires Postgres"] async fn stale_owner_preparation_generation_cannot_freeze_or_approve() { diff --git a/crates/buzz-relay/src/api/operator.rs b/crates/buzz-relay/src/api/operator.rs index 062a08cd43c..9f9135b495a 100644 --- a/crates/buzz-relay/src/api/operator.rs +++ b/crates/buzz-relay/src/api/operator.rs @@ -329,7 +329,7 @@ pub struct ArchiveCommunityRequest { owner_pubkey: String, } -/// Authenticated owner intent mediated by a trusted deployment operator. +/// Operator-attested owner intent mediated by a trusted deployment operator. #[derive(Debug, Deserialize)] pub struct DeleteCommunityRequest { host: String, @@ -449,7 +449,7 @@ pub async fn unarchive_community( }))) } -/// Persist authenticated owner deletion intent without executing deletion work. +/// Persist operator-attested owner deletion intent without executing deletion work. /// /// `POST /operator/communities/delete`, NIP-98 signed by a pubkey in /// `RELAY_OPERATOR_PUBKEYS`, body: From ea6fba8d2684a5ba9f77668fedc1588c6b616b66 Mon Sep 17 00:00:00 2001 From: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz> Date: Wed, 30 Sep 2026 10:50:56 -0400 Subject: [PATCH 2/6] fix(relay): stable limit_reached code and #7969 review doc nits Give create and transfer-in limit_reached rejections a stable code, keeping the message prefix for older clients. Document that an unsupported acknowledgement version returns 400 before the tuple comparison, that active-cap overrides above the lifetime cap are unreachable, and the sole-current-owner guard on the delete handler. Signed-off-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz> --- crates/buzz-db/src/store/relay_members.rs | 7 +- crates/buzz-relay/src/api/operator.rs | 122 ++++++++++++++++++---- docs/operator-community-deletion.md | 14 ++- 3 files changed, 116 insertions(+), 27 deletions(-) diff --git a/crates/buzz-db/src/store/relay_members.rs b/crates/buzz-db/src/store/relay_members.rs index f3ce399dd7e..ca1e426cf63 100644 --- a/crates/buzz-db/src/store/relay_members.rs +++ b/crates/buzz-db/src/store/relay_members.rs @@ -570,8 +570,11 @@ pub const MAX_COMMUNITIES_PER_OWNER: i64 = 5; /// /// Reads `BUZZ_MAX_COMMUNITIES_PER_OWNER` once (cached for the process /// lifetime); a missing, unparsable, or non-positive value falls back to -/// [`MAX_COMMUNITIES_PER_OWNER`]. Lets multi-tenant operators raise the cap -/// without a source change while keeping the stock default for everyone else. +/// [`MAX_COMMUNITIES_PER_OWNER`]. Lets multi-tenant operators raise the active +/// cap without a source change while keeping the stock default for everyone +/// else. [`MAX_LIFETIME_COMMUNITIES_PER_OWNER`] still applies and counts live +/// ownership, so values above it are unreachable: no owner can hold more live +/// communities than the lifetime cap allows. pub fn max_communities_per_owner() -> i64 { static LIMIT: std::sync::OnceLock = std::sync::OnceLock::new(); *LIMIT.get_or_init(|| { diff --git a/crates/buzz-relay/src/api/operator.rs b/crates/buzz-relay/src/api/operator.rs index 9f9135b495a..973eebc7b5e 100644 --- a/crates/buzz-relay/src/api/operator.rs +++ b/crates/buzz-relay/src/api/operator.rs @@ -24,7 +24,7 @@ use crate::state::AppState; use super::{api_error, bridge, internal_error}; -fn deletion_api_error( +fn coded_api_error( status: StatusCode, code: &'static str, message: &str, @@ -304,11 +304,10 @@ pub async fn provision_community( 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:") - || msg.starts_with("owner_conflict:") => - { + Err(msg) if msg.starts_with("limit_reached:") => { + Err(coded_api_error(StatusCode::CONFLICT, "limit_reached", &msg)) + } + Err(msg) if msg == "community already exists" || msg.starts_with("owner_conflict:") => { Err(api_error(StatusCode::CONFLICT, &msg)) } Err(msg) @@ -430,7 +429,7 @@ pub async fn unarchive_community( let record = match result { buzz_db::UnarchiveCommunityResult::Unarchived(record) => record, buzz_db::UnarchiveCommunityResult::DeletionPending => { - return Err(deletion_api_error( + return Err(coded_api_error( StatusCode::CONFLICT, "deletion_lifecycle_conflict", "community deletion is pending", @@ -470,13 +469,16 @@ pub async fn unarchive_community( /// Resubmitting the same UUID with the same host, owner, and acknowledgement /// version returns `202` with that request's current `status`, at any stage and /// even after membership purge, and never admits new work. Callers recover an -/// ambiguous submission by resending it; a different tuple under a known UUID -/// is `409 deletion_request_conflict`. +/// ambiguous submission by resending it; a different host or owner under a +/// known UUID is `409 deletion_request_conflict`. An unsupported +/// acknowledgement version is rejected before the UUID lookup with +/// `400 unsupported_acknowledgement_version`, even for a known UUID. /// /// Owner consent is asserted by the operator, not proven to the relay. The /// operator authenticates the owner and collects the acknowledgement upstream; /// this request carries only the operator's NIP-98 signature. Authorization is -/// therefore operator authority plus "the asserted pubkey is still the owner". +/// therefore operator authority plus "the asserted pubkey is the community's +/// sole current owner". /// `owner_pubkey` and `acknowledgement_version` are recorded as provenance for /// that upstream ceremony, not verified as cryptographic owner consent. pub async fn delete_community( @@ -488,16 +490,16 @@ pub async fn delete_community( let operator = authorize_operator_request(&state, &headers, "POST", PATH, None, Some(&body)).await?; let request: DeleteCommunityRequest = serde_json::from_slice(&body).map_err(|e| { - deletion_api_error( + coded_api_error( StatusCode::BAD_REQUEST, "invalid_request", &format!("invalid delete-community JSON: {e}"), ) })?; let normalized_host = normalize_candidate_host(&request.host) - .map_err(|msg| deletion_api_error(StatusCode::BAD_REQUEST, "invalid_request", &msg))?; + .map_err(|msg| coded_api_error(StatusCode::BAD_REQUEST, "invalid_request", &msg))?; if normalized_host != request.host { - return Err(deletion_api_error( + return Err(coded_api_error( StatusCode::BAD_REQUEST, "invalid_request", "host must use its exact canonical authority spelling", @@ -505,14 +507,14 @@ pub async fn delete_community( } let deployment_host = buzz_core::tenant::relay_url_authority(&state.config.relay_url); if normalized_host == deployment_host { - return Err(deletion_api_error( + return Err(coded_api_error( StatusCode::CONFLICT, "protected_community", "the deployment community cannot be deleted", )); } let owner = validate_pubkey_hex(&request.owner_pubkey).ok_or_else(|| { - deletion_api_error( + coded_api_error( StatusCode::BAD_REQUEST, "invalid_request", "invalid owner_pubkey: expected 64-char hex pubkey", @@ -534,35 +536,35 @@ pub async fn delete_community( let accepted = match admission { buzz_db::deletion::OwnerDeletionAdmission::Accepted(request) => request, buzz_db::deletion::OwnerDeletionAdmission::NotFoundOrNotOwner => { - return Err(deletion_api_error( + return Err(coded_api_error( StatusCode::NOT_FOUND, "community_not_found", "community not found", )); } buzz_db::deletion::OwnerDeletionAdmission::NotArchived => { - return Err(deletion_api_error( + return Err(coded_api_error( StatusCode::CONFLICT, "community_not_archived", "community must be archived before deletion", )); } buzz_db::deletion::OwnerDeletionAdmission::LifecycleConflict => { - return Err(deletion_api_error( + return Err(coded_api_error( StatusCode::CONFLICT, "deletion_lifecycle_conflict", "community deletion lifecycle is already active", )); } buzz_db::deletion::OwnerDeletionAdmission::RequestConflict => { - return Err(deletion_api_error( + return Err(coded_api_error( StatusCode::CONFLICT, "deletion_request_conflict", "deletion request conflicts with existing intent", )); } buzz_db::deletion::OwnerDeletionAdmission::UnsupportedAcknowledgementVersion => { - return Err(deletion_api_error( + return Err(coded_api_error( StatusCode::BAD_REQUEST, "unsupported_acknowledgement_version", "unsupported acknowledgement_version", @@ -713,15 +715,16 @@ pub async fn transfer_community( )); } buzz_db::relay_members::TransferResult::DeletionPending => { - return Err(deletion_api_error( + return Err(coded_api_error( StatusCode::CONFLICT, "deletion_lifecycle_conflict", "community deletion is pending", )); } buzz_db::relay_members::TransferResult::LimitReached => { - return Err(api_error( + return Err(coded_api_error( StatusCode::CONFLICT, + "limit_reached", "limit_reached: transferee has reached the community limit", )); } @@ -2544,6 +2547,7 @@ mod postgres_tests { let response = provision_community(state.clone(), &operator, &host, &owner).await; assert_eq!(response.status(), StatusCode::CONFLICT); let json = read_json(response).await; + assert_eq!(json["code"], "limit_reached"); assert!(json["error"] .as_str() .is_some_and(|error| error.starts_with("limit_reached:"))); @@ -2644,6 +2648,80 @@ mod postgres_tests { .await; } + #[tokio::test] + #[ignore = "requires Postgres"] + async fn transfer_to_owner_at_limit_returns_coded_limit_reached_without_mutation() { + let operator = Keys::generate(); + let initial_owner = Keys::generate(); + let transferee = 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, &transferee) + .await + .status(), + StatusCode::OK + ); + } + let host = format!("community-{}.example", Uuid::new_v4().simple()); + assert_eq!( + provision_community(state.clone(), &operator, &host, &initial_owner) + .await + .status(), + StatusCode::OK + ); + let community = state + .db + .lookup_community_by_host(&host) + .await + .expect("lookup community") + .expect("community exists"); + let initial_owner_hex = initial_owner.public_key().to_hex(); + let transferee_hex = transferee.public_key().to_hex(); + + let transfer_body = serde_json::json!({ + "community_id": community.id.to_string(), + "new_owner_pubkey": transferee_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::CONFLICT); + let json = read_json(response).await; + assert_eq!(json["code"], "limit_reached"); + assert!(json["error"] + .as_str() + .is_some_and(|error| error.starts_with("limit_reached:"))); + assert_eq!( + state + .db + .get_relay_member(community.id, &initial_owner_hex) + .await + .expect("get initial owner") + .expect("initial owner exists") + .role, + "owner" + ); + assert!(state + .db + .get_relay_member(community.id, &transferee_hex) + .await + .expect("get transferee") + .is_none()); + } + #[tokio::test] #[ignore = "requires Postgres"] async fn transfer_pending_deletion_returns_stable_conflict_without_mutation() { diff --git a/docs/operator-community-deletion.md b/docs/operator-community-deletion.md index d4689006d58..cefc411e6f0 100644 --- a/docs/operator-community-deletion.md +++ b/docs/operator-community-deletion.md @@ -155,7 +155,10 @@ Admission is idempotent on the request UUID. Resending the same UUID with the same host, owner, and acknowledgement version returns `202` with that request's current `status` at any stage, including after membership purge, and admits no new work. Clients recover an ambiguous submission by resending it. The same -UUID with a different tuple returns `409 deletion_request_conflict`. +UUID with a different host or owner returns `409 deletion_request_conflict`. +An unsupported acknowledgement version is the exception: it is rejected before +the UUID lookup with `400 unsupported_acknowledgement_version`, even for a +known UUID. The acknowledgement version is a compile-time constant (`OWNER_DELETION_ACKNOWLEDGEMENT_VERSION`), not operator configuration. @@ -169,14 +172,19 @@ until none remain in a non-terminal stage. ## Owner quota -The relay enforces two per-owner caps on create and on transfer-in, both as -`limit_reached`: +The relay enforces two per-owner caps on create and on transfer-in. Either +rejects with `409` and `code: "limit_reached"` (the `error` message keeps its +`limit_reached:` prefix for older clients): - **Active:** live ownership plus incomplete owner deletions (`BUZZ_MAX_COMMUNITIES_PER_OWNER`, default 5). A deletion keeps its slot until logical completion records `completed_at`. - **Lifetime:** live ownership plus every non-aborted owner deletion, including completed ones, capped at an absolute 20 regardless of the active limit. + Lifetime usage includes live ownership, so no owner can hold more than 20 + live communities; a `BUZZ_MAX_COMMUNITIES_PER_OWNER` above 20 is + unreachable, and owner lists would report a `quota_limit` the owner can + never reach. Deleted communities keep their hosts as permanent tombstones, so this bounds create-then-delete host squatting. Aborted deletions restore the community and count only through its live membership. From a0fc97dd725b22c35c0f18c58d7941e2beb7819a Mon Sep 17 00:00:00 2001 From: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz> Date: Wed, 30 Sep 2026 10:59:15 -0400 Subject: [PATCH 3/6] docs(deletion): full 409 conflict set and co-owner 404; pin drift guard in test Address Legolas P2s on #7966: the UUID conflict docs name every non-converging case, the runbook explains that legacy co-owned communities are rejected as community_not_found, and the preparation drift test matches the sole-owner authority guard instead of any owner deletion error. Signed-off-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz> --- crates/buzz-db/src/store/deletion.rs | 6 +++++- crates/buzz-relay/src/api/operator.rs | 5 +++-- docs/operator-community-deletion.md | 9 +++++++-- 3 files changed, 15 insertions(+), 5 deletions(-) diff --git a/crates/buzz-db/src/store/deletion.rs b/crates/buzz-db/src/store/deletion.rs index 66a441ae0c0..3d4a1f6461c 100644 --- a/crates/buzz-db/src/store/deletion.rs +++ b/crates/buzz-db/src/store/deletion.rs @@ -5940,7 +5940,11 @@ mod postgres_tests { .complete_owner_preparation(&claim.lease, &inventory) .await .expect_err("drift must prevent automatic approval"); - assert!(failure.to_string().contains("owner deletion"), "{failure}"); + assert!( + matches!(failure, DbError::DeletionSafety(_)) + && failure.to_string().contains("sole-owner authority drifted"), + "{failure}" + ); assert_eq!( store .get(request_id) diff --git a/crates/buzz-relay/src/api/operator.rs b/crates/buzz-relay/src/api/operator.rs index 973eebc7b5e..d27fe12a13e 100644 --- a/crates/buzz-relay/src/api/operator.rs +++ b/crates/buzz-relay/src/api/operator.rs @@ -469,8 +469,9 @@ pub async fn unarchive_community( /// Resubmitting the same UUID with the same host, owner, and acknowledgement /// version returns `202` with that request's current `status`, at any stage and /// even after membership purge, and never admits new work. Callers recover an -/// ambiguous submission by resending it; a different host or owner under a -/// known UUID is `409 deletion_request_conflict`. An unsupported +/// ambiguous submission by resending it; any other owner request under a known +/// UUID (different host, owner, or stored acknowledgement version, or a UUID +/// held by an operator-origin request) is `409 deletion_request_conflict`. An unsupported /// acknowledgement version is rejected before the UUID lookup with /// `400 unsupported_acknowledgement_version`, even for a known UUID. /// diff --git a/docs/operator-community-deletion.md b/docs/operator-community-deletion.md index cefc411e6f0..f013a039f62 100644 --- a/docs/operator-community-deletion.md +++ b/docs/operator-community-deletion.md @@ -149,13 +149,18 @@ has no human approval step or cooling-off period: operator-attested owner intent is prepared automatically under privileged policy and becomes immediately eligible for execution. Transient preparation failures use the existing retry schedule; permanent or exhausted failures block durably. Owner-facing -admission has no cancellation endpoint. +admission has no cancellation endpoint. Admission requires the asserted owner +to be the community's sole current owner: a legacy community with more than +one owner row is rejected as `404 community_not_found`, indistinguishable from +a missing host. Converge ownership with a transfer first. Admission is idempotent on the request UUID. Resending the same UUID with the same host, owner, and acknowledgement version returns `202` with that request's current `status` at any stage, including after membership purge, and admits no new work. Clients recover an ambiguous submission by resending it. The same -UUID with a different host or owner returns `409 deletion_request_conflict`. +UUID used for any other request returns `409 deletion_request_conflict`: a +different host, owner, or stored acknowledgement version, or a UUID held by an +operator-origin request. An unsupported acknowledgement version is the exception: it is rejected before the UUID lookup with `400 unsupported_acknowledgement_version`, even for a known UUID. From aa639067a1f98798f977f153e651af2405eccfa8 Mon Sep 17 00:00:00 2001 From: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz> Date: Wed, 30 Sep 2026 11:04:03 -0400 Subject: [PATCH 4/6] docs(deletion): correct co-owner recovery sequence for archived communities Signed-off-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz> --- docs/operator-community-deletion.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/operator-community-deletion.md b/docs/operator-community-deletion.md index f013a039f62..02748531b18 100644 --- a/docs/operator-community-deletion.md +++ b/docs/operator-community-deletion.md @@ -152,7 +152,9 @@ schedule; permanent or exhausted failures block durably. Owner-facing admission has no cancellation endpoint. Admission requires the asserted owner to be the community's sole current owner: a legacy community with more than one owner row is rejected as `404 community_not_found`, indistinguishable from -a missing host. Converge ownership with a transfer first. +a missing host. Converge ownership first: transfer rejects archived communities, +so unarchive, transfer to the intended owner (a self-transfer demotes the other +owner rows; the transferee's quota still applies), re-archive, then resubmit. Admission is idempotent on the request UUID. Resending the same UUID with the same host, owner, and acknowledgement version returns `202` with that request's From ab0cd71307c53c4655ad7d7d4259284712f7b1ec Mon Sep 17 00:00:00 2001 From: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz> Date: Wed, 30 Sep 2026 15:22:51 +0000 Subject: [PATCH 5/6] fix(relay): bind owner deletion community_id to host Signed-off-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz> Co-authored-by: Codex --- crates/buzz-db/src/store/deletion.rs | 31 ++++ crates/buzz-relay/src/api/operator.rs | 194 +++++++++++++++++++++++++- docs/operator-community-deletion.md | 5 + 3 files changed, 229 insertions(+), 1 deletion(-) diff --git a/crates/buzz-db/src/store/deletion.rs b/crates/buzz-db/src/store/deletion.rs index 3d4a1f6461c..c3a51fd170e 100644 --- a/crates/buzz-db/src/store/deletion.rs +++ b/crates/buzz-db/src/store/deletion.rs @@ -332,6 +332,8 @@ pub enum OwnerDeletionAdmission { NotArchived, /// The community is already quiescing, fenced, or deleted. LifecycleConflict, + /// The asserted community UUID does not match the host's community. + CommunityIdMismatch, /// The request UUID targets different intent, or another active request exists. RequestConflict, /// The owner acknowledgement contract is not supported. @@ -870,6 +872,27 @@ impl DeletionStore { mediating_operator_pubkey: &str, acknowledgement_version: i32, request_id: Uuid, + ) -> Result { + self.admit_owner_request_with_community_id( + normalized_community_host, + owner_pubkey, + mediating_operator_pubkey, + acknowledgement_version, + request_id, + None, + ) + .await + } + + /// Admit owner intent, rejecting an optional community UUID that does not belong to the host. + pub async fn admit_owner_request_with_community_id( + &self, + normalized_community_host: &str, + owner_pubkey: &str, + mediating_operator_pubkey: &str, + acknowledgement_version: i32, + request_id: Uuid, + expected_community_id: Option, ) -> Result { if acknowledgement_version != OWNER_DELETION_ACKNOWLEDGEMENT_VERSION { return Ok(OwnerDeletionAdmission::UnsupportedAcknowledgementVersion); @@ -891,6 +914,10 @@ impl DeletionStore { .await? { let existing = row_to_request(row)?; + if expected_community_id.is_some_and(|id| id != *existing.community_id.as_uuid()) { + tx.rollback().await?; + return Ok(OwnerDeletionAdmission::CommunityIdMismatch); + } let converges = existing.community_host == normalized_community_host && existing.request_origin == DeletionRequestOrigin::Owner && existing.owner_pubkey.as_deref() == Some(owner_pubkey.as_str()) @@ -915,6 +942,10 @@ impl DeletionStore { return Ok(OwnerDeletionAdmission::NotFoundOrNotOwner); }; let community_id: Uuid = target.try_get("id")?; + if expected_community_id.is_some_and(|id| id != community_id) { + tx.rollback().await?; + return Ok(OwnerDeletionAdmission::CommunityIdMismatch); + } let canonical_host: String = target.try_get("host")?; let deletion_state: String = target.try_get("deletion_state")?; let deleted_at: Option> = target.try_get("deleted_at")?; diff --git a/crates/buzz-relay/src/api/operator.rs b/crates/buzz-relay/src/api/operator.rs index d27fe12a13e..a4a90741f1d 100644 --- a/crates/buzz-relay/src/api/operator.rs +++ b/crates/buzz-relay/src/api/operator.rs @@ -332,6 +332,7 @@ pub struct ArchiveCommunityRequest { #[derive(Debug, Deserialize)] pub struct DeleteCommunityRequest { host: String, + community_id: Option, owner_pubkey: String, request_id: Uuid, acknowledgement_version: i32, @@ -456,6 +457,7 @@ pub async fn unarchive_community( /// ```json /// { /// "host": "archived.communities.example", +/// "community_id": "", /// "owner_pubkey": "<64-char hex>", /// "request_id": "", /// "acknowledgement_version": 1 @@ -474,6 +476,9 @@ pub async fn unarchive_community( /// held by an operator-origin request) is `409 deletion_request_conflict`. An unsupported /// acknowledgement version is rejected before the UUID lookup with /// `400 unsupported_acknowledgement_version`, even for a known UUID. +/// If supplied, `community_id` must identify the community bound to `host`; +/// a mismatch returns `409 community_id_mismatch`, including on UUID replay. +/// A malformed UUID returns `400 invalid_request`. /// /// Owner consent is asserted by the operator, not proven to the relay. The /// operator authenticates the owner and collects the acknowledgement upstream; @@ -525,12 +530,13 @@ pub async fn delete_community( let admission = state .db .deletion_store() - .admit_owner_request( + .admit_owner_request_with_community_id( &normalized_host, &owner, &operator, request.acknowledgement_version, request.request_id, + request.community_id, ) .await .map_err(|error| internal_error(&format!("admit owner deletion request: {error}")))?; @@ -557,6 +563,13 @@ pub async fn delete_community( "community deletion lifecycle is already active", )); } + buzz_db::deletion::OwnerDeletionAdmission::CommunityIdMismatch => { + return Err(coded_api_error( + StatusCode::CONFLICT, + "community_id_mismatch", + "community_id does not match the community resolved for host", + )); + } buzz_db::deletion::OwnerDeletionAdmission::RequestConflict => { return Err(coded_api_error( StatusCode::CONFLICT, @@ -1173,6 +1186,185 @@ mod postgres_tests { ); } + #[tokio::test] + #[ignore = "requires Postgres"] + async fn owner_delete_community_id_confirms_host_on_admission_and_replay() { + 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 created = provision_community(Arc::clone(&state), &operator, &host, &owner).await; + assert_eq!(created.status(), StatusCode::OK); + let community_id: Uuid = read_json(created).await["community_id"] + .as_str() + .expect("community id") + .parse() + .expect("valid community id"); + archive_for_owner_deletion(&state, &host, &owner).await; + let pool = state.db.pool(); + let lifecycle_before: ( + Option>, + String, + Option>, + ) = sqlx::query_as( + "SELECT archived_at, deletion_state, deleted_at FROM communities WHERE id = $1", + ) + .bind(community_id) + .fetch_one(pool) + .await + .expect("archived lifecycle"); + assert!(lifecycle_before.0.is_some()); + let owner_before = state + .db + .get_relay_member( + CommunityId::from_uuid(community_id), + &owner.public_key().to_hex(), + ) + .await + .expect("read owner") + .expect("owner exists") + .role; + let request_id = Uuid::new_v4(); + let body = |community_id: Value| { + serde_json::json!({ + "host": host, + "community_id": community_id, + "owner_pubkey": owner.public_key().to_hex(), + "request_id": request_id, + "acknowledgement_version": 1, + "ignored_extension": "forward-compatible", + }) + .to_string() + }; + let mismatch = signed_operator_request( + Arc::clone(&state), + &operator, + "POST", + "/operator/communities/delete", + Some(body(serde_json::json!(Uuid::new_v4()))), + ) + .await; + assert_eq!(mismatch.status(), StatusCode::CONFLICT); + assert_eq!(read_json(mismatch).await["code"], "community_id_mismatch"); + assert_no_persisted_request(&state, request_id, "mismatched community id").await; + let count: i64 = sqlx::query_scalar( + "SELECT count(*) FROM community_deletion_requests WHERE community_id = $1", + ) + .bind(community_id) + .fetch_one(pool) + .await + .expect("count rejected requests"); + assert_eq!(count, 0); + let lifecycle_after: ( + Option>, + String, + Option>, + ) = sqlx::query_as( + "SELECT archived_at, deletion_state, deleted_at FROM communities WHERE id = $1", + ) + .bind(community_id) + .fetch_one(pool) + .await + .expect("unchanged lifecycle"); + assert_eq!(lifecycle_after, lifecycle_before); + assert_eq!( + state + .db + .get_relay_member( + CommunityId::from_uuid(community_id), + &owner.public_key().to_hex() + ) + .await + .expect("read unchanged owner") + .expect("owner still exists") + .role, + owner_before + ); + + let malformed = signed_operator_request( + Arc::clone(&state), + &operator, + "POST", + "/operator/communities/delete", + Some(body(serde_json::json!("not-a-uuid"))), + ) + .await; + assert_eq!(malformed.status(), StatusCode::BAD_REQUEST); + assert_eq!(read_json(malformed).await["code"], "invalid_request"); + assert_no_persisted_request(&state, request_id, "malformed community id").await; + + let matched = signed_operator_request( + Arc::clone(&state), + &operator, + "POST", + "/operator/communities/delete", + Some(body(serde_json::json!(community_id))), + ) + .await; + assert_eq!(matched.status(), StatusCode::ACCEPTED); + assert_eq!( + read_json(matched).await["community_id"], + community_id.to_string() + ); + let admitted = state + .db + .deletion_store() + .get(request_id) + .await + .expect("admitted request"); + let replay = signed_operator_request( + Arc::clone(&state), + &operator, + "POST", + "/operator/communities/delete", + Some(body(serde_json::json!(Uuid::new_v4()))), + ) + .await; + assert_eq!(replay.status(), StatusCode::CONFLICT); + assert_eq!(read_json(replay).await["code"], "community_id_mismatch"); + assert_eq!( + state + .db + .deletion_store() + .get(request_id) + .await + .expect("unchanged request"), + admitted + ); + + let other_host = format!("community-{}.example", Uuid::new_v4().simple()); + assert_eq!( + provision_community(Arc::clone(&state), &operator, &other_host, &owner) + .await + .status(), + StatusCode::OK + ); + archive_for_owner_deletion(&state, &other_host, &owner).await; + let absent_id = Uuid::new_v4(); + let absent = signed_operator_request( + Arc::clone(&state), + &operator, + "POST", + "/operator/communities/delete", + Some(owner_delete_body(&other_host, &owner, absent_id)), + ) + .await; + assert_eq!(absent.status(), StatusCode::ACCEPTED); + assert_eq!(read_json(absent).await["request_id"], absent_id.to_string()); + assert_eq!( + state + .db + .deletion_store() + .get(absent_id) + .await + .expect("legacy request") + .stage, + buzz_db::deletion::DeletionStage::Submitted + ); + } + #[tokio::test] #[ignore = "requires Postgres"] async fn owner_delete_admission_requires_exact_canonical_host_without_mutation() { diff --git a/docs/operator-community-deletion.md b/docs/operator-community-deletion.md index 02748531b18..de3af7b44d1 100644 --- a/docs/operator-community-deletion.md +++ b/docs/operator-community-deletion.md @@ -163,6 +163,11 @@ new work. Clients recover an ambiguous submission by resending it. The same UUID used for any other request returns `409 deletion_request_conflict`: a different host, owner, or stored acknowledgement version, or a UUID held by an operator-origin request. +The optional `community_id` UUID binds the request to the community resolved +from `host` without changing the host-derived authority. A different UUID +returns `409 community_id_mismatch` before admission or on replay, with no +mutation. A malformed UUID returns `400 invalid_request`; omitting the field +preserves existing clients. An unsupported acknowledgement version is the exception: it is rejected before the UUID lookup with `400 unsupported_acknowledgement_version`, even for a known UUID. From 766eb07fd91eb76a879fdc72ba6535fb7a017787 Mon Sep 17 00:00:00 2001 From: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz> Date: Wed, 30 Sep 2026 12:12:35 -0400 Subject: [PATCH 6/6] fix(relay): scope owner deletion community_id replay check to the same host A known request UUID resent for a different host is a request conflict, not a community_id mismatch, even when community_id names that other host. The replay mismatch check now applies only to a stored request for the same host, and the fresh-path check runs after sole-owner authority so non-owners see the same 404 as an unknown host. Fold admit_owner_request_with_community_id into admit_owner_request with an expected_community_id parameter, and pin the UUID-collision, matching replay and non-owner cases in operator tests. Update the runbook. Signed-off-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz> --- crates/buzz-db/src/store/deletion.rs | 88 +++++++++++------------- crates/buzz-deletion/src/lib.rs | 2 +- crates/buzz-relay/src/api/operator.rs | 98 +++++++++++++++++++++++++-- docs/operator-community-deletion.md | 8 ++- 4 files changed, 140 insertions(+), 56 deletions(-) diff --git a/crates/buzz-db/src/store/deletion.rs b/crates/buzz-db/src/store/deletion.rs index c3a51fd170e..3121cd50c97 100644 --- a/crates/buzz-db/src/store/deletion.rs +++ b/crates/buzz-db/src/store/deletion.rs @@ -865,6 +865,11 @@ impl DeletionStore { /// upstream. This layer records that provenance and checks that /// `owner_pubkey` is the community's sole current owner; it never verifies an /// owner-signed attestation. + /// + /// `expected_community_id`, when present, must name the host's community. + /// It is checked only after sole-owner authority is proven (so non-owners + /// see the same not-found as an unknown host) and, on replay, only against + /// a stored request for the same host. pub async fn admit_owner_request( &self, normalized_community_host: &str, @@ -872,26 +877,6 @@ impl DeletionStore { mediating_operator_pubkey: &str, acknowledgement_version: i32, request_id: Uuid, - ) -> Result { - self.admit_owner_request_with_community_id( - normalized_community_host, - owner_pubkey, - mediating_operator_pubkey, - acknowledgement_version, - request_id, - None, - ) - .await - } - - /// Admit owner intent, rejecting an optional community UUID that does not belong to the host. - pub async fn admit_owner_request_with_community_id( - &self, - normalized_community_host: &str, - owner_pubkey: &str, - mediating_operator_pubkey: &str, - acknowledgement_version: i32, - request_id: Uuid, expected_community_id: Option, ) -> Result { if acknowledgement_version != OWNER_DELETION_ACKNOWLEDGEMENT_VERSION { @@ -914,7 +899,9 @@ impl DeletionStore { .await? { let existing = row_to_request(row)?; - if expected_community_id.is_some_and(|id| id != *existing.community_id.as_uuid()) { + if existing.community_host == normalized_community_host + && expected_community_id.is_some_and(|id| id != *existing.community_id.as_uuid()) + { tx.rollback().await?; return Ok(OwnerDeletionAdmission::CommunityIdMismatch); } @@ -942,10 +929,6 @@ impl DeletionStore { return Ok(OwnerDeletionAdmission::NotFoundOrNotOwner); }; let community_id: Uuid = target.try_get("id")?; - if expected_community_id.is_some_and(|id| id != community_id) { - tx.rollback().await?; - return Ok(OwnerDeletionAdmission::CommunityIdMismatch); - } let canonical_host: String = target.try_get("host")?; let deletion_state: String = target.try_get("deletion_state")?; let deleted_at: Option> = target.try_get("deleted_at")?; @@ -965,6 +948,12 @@ impl DeletionStore { tx.rollback().await?; return Ok(OwnerDeletionAdmission::NotFoundOrNotOwner); } + // Only after sole-owner authority is proven, so non-owners still see the + // same 404 as an unknown host whatever `community_id` they assert. + if expected_community_id.is_some_and(|id| id != community_id) { + tx.rollback().await?; + return Ok(OwnerDeletionAdmission::CommunityIdMismatch); + } if target .try_get::>, _>("archived_at")? .is_none() @@ -4679,7 +4668,7 @@ mod postgres_tests { let request_id = Uuid::new_v4(); let admitted = store - .admit_owner_request(&host, &owner, operator, 1, request_id) + .admit_owner_request(&host, &owner, operator, 1, request_id, None) .await .expect("admit archived owner request"); let OwnerDeletionAdmission::Accepted(request) = admitted else { @@ -4723,7 +4712,7 @@ mod postgres_tests { assert_eq!( store - .admit_owner_request(&host, &owner, operator, 1, Uuid::new_v4()) + .admit_owner_request(&host, &owner, operator, 1, Uuid::new_v4(), None) .await .expect("non-archived admission result"), OwnerDeletionAdmission::NotArchived @@ -4734,7 +4723,7 @@ mod postgres_tests { .expect("owned community"); assert_eq!( store - .admit_owner_request(&host, &outsider, operator, 1, Uuid::new_v4()) + .admit_owner_request(&host, &outsider, operator, 1, Uuid::new_v4(), None) .await .expect("non-owner admission result"), OwnerDeletionAdmission::NotFoundOrNotOwner @@ -4747,7 +4736,7 @@ mod postgres_tests { ); assert!(matches!( store - .admit_owner_request(&host, &owner, operator, 1, Uuid::new_v4()) + .admit_owner_request(&host, &owner, operator, 1, Uuid::new_v4(), None) .await .expect("current-owner admission result"), OwnerDeletionAdmission::Accepted(_) @@ -4781,6 +4770,7 @@ mod postgres_tests { "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", 1, Uuid::new_v4(), + None, ) .await .expect("legacy co-owner admission result"), @@ -4811,7 +4801,7 @@ mod postgres_tests { let operator = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; assert!(matches!( store - .admit_owner_request(&host, &owner, operator, 1, Uuid::new_v4()) + .admit_owner_request(&host, &owner, operator, 1, Uuid::new_v4(), None) .await .expect("admit owner request"), OwnerDeletionAdmission::Accepted(_) @@ -4840,6 +4830,7 @@ mod postgres_tests { "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", 1, Uuid::new_v4(), + None, ) .await .expect("admit owner deletion") @@ -4942,6 +4933,7 @@ mod postgres_tests { "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", 1, request_id, + None, ) .await } @@ -4994,6 +4986,7 @@ mod postgres_tests { "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", 1, request_id, + None, ) .await } @@ -5043,6 +5036,7 @@ mod postgres_tests { "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", 1, request_id, + None, ) .await } @@ -5082,7 +5076,7 @@ mod postgres_tests { let operator = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; let request_id = Uuid::new_v4(); let OwnerDeletionAdmission::Accepted(first) = store - .admit_owner_request(&host, &owner, operator, 1, request_id) + .admit_owner_request(&host, &owner, operator, 1, request_id, None) .await .expect("first admission") else { @@ -5101,7 +5095,7 @@ mod postgres_tests { .expect("advance request"); let OwnerDeletionAdmission::Accepted(replayed) = store - .admit_owner_request(&host, &owner, operator, 1, request_id) + .admit_owner_request(&host, &owner, operator, 1, request_id, None) .await .expect("replay admission") else { @@ -5111,7 +5105,7 @@ mod postgres_tests { assert_eq!(replayed.stage, DeletionStage::Inventoried); assert_eq!( store - .admit_owner_request(&other_host, &other_owner, operator, 1, request_id) + .admit_owner_request(&other_host, &other_owner, operator, 1, request_id, None) .await .expect("retargeting result"), OwnerDeletionAdmission::RequestConflict @@ -5126,8 +5120,8 @@ mod postgres_tests { let operator = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; let request_id = Uuid::new_v4(); let (first, second) = tokio::join!( - store.admit_owner_request(&host, &owner, operator, 1, request_id), - store.admit_owner_request(&host, &owner, operator, 1, request_id), + store.admit_owner_request(&host, &owner, operator, 1, request_id, None), + store.admit_owner_request(&host, &owner, operator, 1, request_id, None), ); let accepted_id = |result: Result| { let OwnerDeletionAdmission::Accepted(request) = result.expect("admission") else { @@ -5157,7 +5151,7 @@ mod postgres_tests { let new_owner = format!("{}{}", Uuid::new_v4().simple(), Uuid::new_v4().simple()); let operator = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; let OwnerDeletionAdmission::Accepted(_) = store - .admit_owner_request(&host, &owner, operator, 1, Uuid::new_v4()) + .admit_owner_request(&host, &owner, operator, 1, Uuid::new_v4(), None) .await .expect("admit owner request") else { @@ -5213,7 +5207,7 @@ mod postgres_tests { let operator = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; let request_id = Uuid::new_v4(); let OwnerDeletionAdmission::Accepted(request) = store - .admit_owner_request(&host, &owner, operator, 1, request_id) + .admit_owner_request(&host, &owner, operator, 1, request_id, None) .await .expect("admit owner request") else { @@ -5294,7 +5288,7 @@ mod postgres_tests { let operator = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; let request_id = Uuid::new_v4(); store - .admit_owner_request(&host, &owner, operator, 1, request_id) + .admit_owner_request(&host, &owner, operator, 1, request_id, None) .await .expect("admit owner request"); let claim = store @@ -5410,7 +5404,7 @@ mod postgres_tests { let operator = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; let request_id = Uuid::new_v4(); let OwnerDeletionAdmission::Accepted(owner_request) = store - .admit_owner_request(&owner_host, &owner, operator, 1, request_id) + .admit_owner_request(&owner_host, &owner, operator, 1, request_id, None) .await .expect("admit owner request") else { @@ -5485,7 +5479,7 @@ mod postgres_tests { let operator = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; let request_id = Uuid::new_v4(); store - .admit_owner_request(&host, &owner, operator, 1, request_id) + .admit_owner_request(&host, &owner, operator, 1, request_id, None) .await .expect("admit owner request"); sqlx::query( @@ -5521,7 +5515,7 @@ mod postgres_tests { let operator = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; let request_id = Uuid::new_v4(); store - .admit_owner_request(&host, &owner, operator, 1, request_id) + .admit_owner_request(&host, &owner, operator, 1, request_id, None) .await .expect("admit owner request"); let claim = store @@ -5588,7 +5582,7 @@ mod postgres_tests { let operator = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; let request_id = Uuid::new_v4(); store - .admit_owner_request(&host, &owner, operator, 1, request_id) + .admit_owner_request(&host, &owner, operator, 1, request_id, None) .await .expect("admit owner request"); let pending_quota = db @@ -5752,7 +5746,7 @@ mod postgres_tests { let operator = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; let request_id = Uuid::new_v4(); store - .admit_owner_request(&host, &owner, operator, 1, request_id) + .admit_owner_request(&host, &owner, operator, 1, request_id, None) .await .expect("admit owner request"); let claim = store @@ -5835,7 +5829,7 @@ mod postgres_tests { let operator = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; let request_id = Uuid::new_v4(); store - .admit_owner_request(&host, &owner, operator, 1, request_id) + .admit_owner_request(&host, &owner, operator, 1, request_id, None) .await .expect("admit sole owner's intent"); let claim = store @@ -6037,7 +6031,7 @@ mod postgres_tests { let operator = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; let request_id = Uuid::new_v4(); store - .admit_owner_request(&host, &owner, operator, 1, request_id) + .admit_owner_request(&host, &owner, operator, 1, request_id, None) .await .expect("admit owner request"); let stale = store @@ -6089,7 +6083,7 @@ mod postgres_tests { let operator = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; let request_id = Uuid::new_v4(); store - .admit_owner_request(&host, &owner, operator, 1, request_id) + .admit_owner_request(&host, &owner, operator, 1, request_id, None) .await .expect("admit owner request"); let claim = store @@ -6156,7 +6150,7 @@ mod postgres_tests { .is_some()); assert!(matches!( store - .admit_owner_request(&host, &owner, operator, 1, Uuid::new_v4()) + .admit_owner_request(&host, &owner, operator, 1, Uuid::new_v4(), None) .await .expect("fresh owner request after recovery abort"), OwnerDeletionAdmission::Accepted(_) diff --git a/crates/buzz-deletion/src/lib.rs b/crates/buzz-deletion/src/lib.rs index 65fd6e9dec9..9e96f647157 100644 --- a/crates/buzz-deletion/src/lib.rs +++ b/crates/buzz-deletion/src/lib.rs @@ -1970,7 +1970,7 @@ mod postgres_tests { .expect("owned community"); let operator = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; let buzz_db::deletion::OwnerDeletionAdmission::Accepted(request) = store - .admit_owner_request(&host, &owner, operator, 1, Uuid::new_v4()) + .admit_owner_request(&host, &owner, operator, 1, Uuid::new_v4(), None) .await .expect("admit owner request") else { diff --git a/crates/buzz-relay/src/api/operator.rs b/crates/buzz-relay/src/api/operator.rs index a4a90741f1d..78c4c3eeffb 100644 --- a/crates/buzz-relay/src/api/operator.rs +++ b/crates/buzz-relay/src/api/operator.rs @@ -530,7 +530,7 @@ pub async fn delete_community( let admission = state .db .deletion_store() - .admit_owner_request_with_community_id( + .admit_owner_request( &normalized_host, &owner, &operator, @@ -1238,6 +1238,30 @@ mod postgres_tests { }) .to_string() }; + // A non-owner must see the same 404 as an unknown host, even with a wrong id. + let stranger_mismatch = signed_operator_request( + Arc::clone(&state), + &operator, + "POST", + "/operator/communities/delete", + Some( + serde_json::json!({ + "host": host, + "community_id": Uuid::new_v4(), + "owner_pubkey": Keys::generate().public_key().to_hex(), + "request_id": request_id, + "acknowledgement_version": 1, + }) + .to_string(), + ), + ) + .await; + assert_eq!(stranger_mismatch.status(), StatusCode::NOT_FOUND); + assert_eq!( + read_json(stranger_mismatch).await["code"], + "community_not_found" + ); + assert_no_persisted_request(&state, request_id, "non-owner mismatched id").await; let mismatch = signed_operator_request( Arc::clone(&state), &operator, @@ -1333,15 +1357,79 @@ mod postgres_tests { .expect("unchanged request"), admitted ); + let matched_replay = signed_operator_request( + Arc::clone(&state), + &operator, + "POST", + "/operator/communities/delete", + Some(body(serde_json::json!(community_id))), + ) + .await; + assert_eq!(matched_replay.status(), StatusCode::ACCEPTED); + let matched_replay = read_json(matched_replay).await; + assert_eq!(matched_replay["request_id"], request_id.to_string()); + assert_eq!(matched_replay["community_id"], community_id.to_string()); + assert_eq!( + state + .db + .deletion_store() + .get(request_id) + .await + .expect("replayed request"), + admitted + ); let other_host = format!("community-{}.example", Uuid::new_v4().simple()); + let other_created = + provision_community(Arc::clone(&state), &operator, &other_host, &owner).await; + assert_eq!(other_created.status(), StatusCode::OK); + let other_id: Uuid = read_json(other_created).await["community_id"] + .as_str() + .expect("other community id") + .parse() + .expect("valid other community id"); + archive_for_owner_deletion(&state, &other_host, &owner).await; + // A known request id reused for a different host is a request conflict, + // even when `community_id` correctly names that other host. + let collision = signed_operator_request( + Arc::clone(&state), + &operator, + "POST", + "/operator/communities/delete", + Some( + serde_json::json!({ + "host": other_host, + "community_id": other_id, + "owner_pubkey": owner.public_key().to_hex(), + "request_id": request_id, + "acknowledgement_version": 1, + }) + .to_string(), + ), + ) + .await; + assert_eq!(collision.status(), StatusCode::CONFLICT); assert_eq!( - provision_community(Arc::clone(&state), &operator, &other_host, &owner) + read_json(collision).await["code"], + "deletion_request_conflict" + ); + assert_eq!( + state + .db + .deletion_store() + .get(request_id) .await - .status(), - StatusCode::OK + .expect("request unchanged by collision"), + admitted ); - archive_for_owner_deletion(&state, &other_host, &owner).await; + let other_count: i64 = sqlx::query_scalar( + "SELECT count(*) FROM community_deletion_requests WHERE community_id = $1", + ) + .bind(other_id) + .fetch_one(pool) + .await + .expect("count other-host requests"); + assert_eq!(other_count, 0); let absent_id = Uuid::new_v4(); let absent = signed_operator_request( Arc::clone(&state), diff --git a/docs/operator-community-deletion.md b/docs/operator-community-deletion.md index de3af7b44d1..cb1ec951431 100644 --- a/docs/operator-community-deletion.md +++ b/docs/operator-community-deletion.md @@ -165,9 +165,11 @@ different host, owner, or stored acknowledgement version, or a UUID held by an operator-origin request. The optional `community_id` UUID binds the request to the community resolved from `host` without changing the host-derived authority. A different UUID -returns `409 community_id_mismatch` before admission or on replay, with no -mutation. A malformed UUID returns `400 invalid_request`; omitting the field -preserves existing clients. +returns `409 community_id_mismatch` with no mutation: on a fresh submission +only after sole-owner authority is proven (a non-owner still gets `404`), and +on replay only for a stored request with the same host (a different host is +the `409 deletion_request_conflict` above). A malformed UUID returns +`400 invalid_request`; omitting the field preserves existing clients. An unsupported acknowledgement version is the exception: it is rejected before the UUID lookup with `400 unsupported_acknowledgement_version`, even for a known UUID.