mirror of
https://github.com/block/buzz.git
synced 2026-10-27 04:30:52 +00:00
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:
@@ -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,
|
||||
|
||||
@@ -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(_)
|
||||
|
||||
@@ -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(|| {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user