fix(deletion): require sole owner at admission (#7966)

Follow-up to #7830: reject owner-origin deletion admission for a legacy
co-owned community before creating a request. Under the existing
community-row lock, admission locks all current owner memberships in
pubkey order and requires exactly one matching asserted owner.
Existing-request idempotent replay remains unchanged; completion retains
its independent post-lock revalidation.

**New optional operator field: `community_id`** (`ab0cd713`,
`766eb07f`). Owner-origin `POST /operator/communities/delete` now
accepts an optional `community_id` that binds the request to the host's
community:
- **Fresh submission:** checked after sole-owner authority is proven, so
a non-owner still gets `404 community_not_found`. A mismatch returns
`409 community_id_mismatch` and writes no request row.
- **Replay:** checked against the stored request, and only when the
stored request is for the same host. A mismatch returns `409
community_id_mismatch`, and the stored request is unchanged. A known
UUID sent with a different host goes through the existing convergence
check and returns `409 deletion_request_conflict`.
- **Omitted:** behaves as before. `admit_owner_request` takes
`expected_community_id: Option<Uuid>` rather than a second public entry
point, and existing callers pass `None`.
- `docs/operator-community-deletion.md` documents the ordering.

Also folds in the non-blocking review nits from #7969 (wpfleger96, at
`e21151f4`):
- **`limit_reached` has a stable `code`.** Create
(`provision_community`) and transfer-in now return `409` with `code:
"limit_reached"`, matching the other coded lifecycle errors. The `error`
message keeps its `limit_reached:` prefix, so clients that match on the
message still work. The provision limit test now asserts the code, and a
new PostgreSQL test covers a transfer to an owner at the limit (coded
409, no membership change).
- **Docs: an ack-version mismatch is a 400, not a 409.**
`docs/operator-community-deletion.md` and the `delete_community` handler
doc now say a changed host or owner under a known UUID is `409
deletion_request_conflict`, and an unsupported acknowledgement version
is rejected first with `400 unsupported_acknowledgement_version`.
- **Docs: active-cap overrides above the lifetime cap can't be
reached.** The `max_communities_per_owner` doc and the quota section now
say `MAX_LIFETIME_COMMUNITIES_PER_OWNER` (20) counts live ownership, so
`BUZZ_MAX_COMMUNITIES_PER_OWNER` above 20 can't be reached.
- **Legolas P2s:**
- The UUID-conflict docs now list every 409 case, including a stored ack
version that doesn't match and a UUID held by an operator-origin
request.
- The runbook now says a legacy co-owned community is rejected as `404
community_not_found` and should be converged with a transfer first.
- The preparation drift test now pins `DeletionSafety` plus "sole-owner
authority drifted".
- **Guard wording:** the `delete_community` handler doc now says "sole
current owner", matching the admission check.

What got simpler: admission and automatic preparation now enforce the
same sole-owner authority, so no accepted-but-unexecutable co-owned
request needs a new recovery path. `deletion_api_error` is renamed to
`coded_api_error`, since it now carries non-deletion codes too. There's
one helper for coded operator errors instead of a second one.

Rebased onto main after #7969 merged (`d7a35afa`). The sole-owner commit
applied cleanly.

Earlier focused Blox verification (at the pre-rebase head `161e9857`):
the new admission regression was red before the change and green after
it. Five temporary, restored mutants each failed its matching
production-seam regression. At the current head `766eb07f`, the relay
`api::operator::` suite passes 23/0 on Blox. Each new `community_id`
guard, removed one at a time, fails its own regression test. CI owns the
full suites.

Remaining debt: the separate P2-A purge/completion membership/community
lock-order cycle is unchanged.

Generated with Codex

---------

Signed-off-by: Codex <noreply@openai.com>
Signed-off-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz>
Co-authored-by: Codex <noreply@openai.com>
Co-authored-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz>
This commit is contained in:
Luke Tornquist
2026-09-30 15:04:23 -04:00
committed by GitHub
parent 965e1997b5
commit 53a12100b3
6 changed files with 727 additions and 63 deletions

View File

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

View File

@@ -351,6 +351,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.
@@ -880,8 +882,13 @@ 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.
///
/// `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,
@@ -889,6 +896,7 @@ impl DeletionStore {
mediating_operator_pubkey: &str,
acknowledgement_version: i32,
request_id: Uuid,
expected_community_id: Option<Uuid>,
) -> Result<OwnerDeletionAdmission> {
if acknowledgement_version != OWNER_DELETION_ACKNOWLEDGEMENT_VERSION {
return Ok(OwnerDeletionAdmission::UnsupportedAcknowledgementVersion);
@@ -910,6 +918,12 @@ impl DeletionStore {
.await?
{
let existing = row_to_request(row)?;
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);
}
let converges = existing.community_host == normalized_community_host
&& existing.request_origin == DeletionRequestOrigin::Owner
&& existing.owner_pubkey.as_deref() == Some(owner_pubkey.as_str())
@@ -942,19 +956,23 @@ impl DeletionStore {
return Ok(OwnerDeletionAdmission::LifecycleConflict);
}
let owner_exists = sqlx::query_scalar::<_, String>(
let current_owners: Vec<String> = 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);
}
// 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::<Option<DateTime<Utc>>, _>("archived_at")?
.is_none()
@@ -1524,7 +1542,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
)));
}
@@ -4701,7 +4719,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 {
@@ -4745,7 +4763,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
@@ -4756,7 +4774,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
@@ -4769,13 +4787,61 @@ 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(_)
));
}
#[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(),
None,
)
.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() {
@@ -4786,7 +4852,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(_)
@@ -4815,6 +4881,7 @@ mod postgres_tests {
"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
1,
Uuid::new_v4(),
None,
)
.await
.expect("admit owner deletion")
@@ -4917,6 +4984,7 @@ mod postgres_tests {
"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
1,
request_id,
None,
)
.await
}
@@ -4969,6 +5037,7 @@ mod postgres_tests {
"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
1,
request_id,
None,
)
.await
}
@@ -5018,6 +5087,7 @@ mod postgres_tests {
"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
1,
request_id,
None,
)
.await
}
@@ -5057,7 +5127,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 {
@@ -5076,7 +5146,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 {
@@ -5086,7 +5156,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
@@ -5101,8 +5171,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<OwnerDeletionAdmission>| {
let OwnerDeletionAdmission::Accepted(request) = result.expect("admission") else {
@@ -5132,7 +5202,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 {
@@ -5188,7 +5258,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 {
@@ -5269,7 +5339,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
@@ -5385,7 +5455,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 {
@@ -5460,7 +5530,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(
@@ -5496,7 +5566,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
@@ -5563,7 +5633,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
@@ -5727,7 +5797,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
@@ -5794,6 +5864,216 @@ 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, None)
.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<String> = 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<DateTime<Utc>>,
String,
Option<DateTime<Utc>>,
) = 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!(
matches!(failure, DbError::DeletionSafety(_))
&& failure.to_string().contains("sole-owner authority drifted"),
"{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() {
@@ -5802,7 +6082,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
@@ -5854,7 +6134,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
@@ -5921,7 +6201,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(_)

View File

@@ -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<i64> = std::sync::OnceLock::new();
*LIMIT.get_or_init(|| {

View File

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

View File

@@ -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)
@@ -329,10 +328,11 @@ 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,
community_id: Option<Uuid>,
owner_pubkey: String,
request_id: Uuid,
acknowledgement_version: i32,
@@ -430,7 +430,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",
@@ -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:
@@ -457,6 +457,7 @@ pub async fn unarchive_community(
/// ```json
/// {
/// "host": "archived.communities.example",
/// "community_id": "<optional community UUID>",
/// "owner_pubkey": "<64-char hex>",
/// "request_id": "<stable UUID>",
/// "acknowledgement_version": 1
@@ -470,13 +471,20 @@ 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; 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.
/// 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;
/// 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 +496,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 +513,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",
@@ -528,41 +536,49 @@ pub async fn delete_community(
&operator,
request.acknowledgement_version,
request.request_id,
request.community_id,
)
.await
.map_err(|error| internal_error(&format!("admit owner deletion request: {error}")))?;
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::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(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 +729,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",
));
}
@@ -1169,6 +1186,273 @@ 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<chrono::DateTime<chrono::Utc>>,
String,
Option<chrono::DateTime<chrono::Utc>>,
) = 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()
};
// 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,
"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<chrono::DateTime<chrono::Utc>>,
String,
Option<chrono::DateTime<chrono::Utc>>,
) = 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 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!(
read_json(collision).await["code"],
"deletion_request_conflict"
);
assert_eq!(
state
.db
.deletion_store()
.get(request_id)
.await
.expect("request unchanged by collision"),
admitted
);
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),
&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() {
@@ -2544,6 +2828,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 +2929,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() {

View File

@@ -149,13 +149,30 @@ 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 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
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 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` 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.
The acknowledgement version is a compile-time constant
(`OWNER_DELETION_ACKNOWLEDGEMENT_VERSION`), not operator configuration.
@@ -169,14 +186,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.