From 2a051a404dcde42dddbff2a0b33f717ffe9cf999 Mon Sep 17 00:00:00 2001 From: Lee Salminen Date: Sat, 25 Jul 2026 10:24:39 -0400 Subject: [PATCH] feat(relay): make per-owner community limit configurable via BUZZ_MAX_COMMUNITIES_PER_OWNER (#2599) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #2600 ## Summary Self-hosted multi-tenant deployments (one relay serving many communities via host-based tenancy) routinely need more than three communities owned by the same operator identity. `MAX_COMMUNITIES_PER_OWNER` is currently a hardcoded const, and hitting it surfaces as a `limit_reached` 409 from `POST /operator/communities` — which provisioning UIs tend to mislabel (mine reported it as "subdomain already taken"). This makes the limit configurable per deployment: - New env var `BUZZ_MAX_COMMUNITIES_PER_OWNER` — read once per process, must parse as a positive integer; missing/invalid/non-positive values fall back to the existing default of **3**, so current deployments are unaffected. - Enforcement locations are unchanged and stay in the authoritative relay-layer checks: community provisioning (`create_community_with_owner`) and ownership transfer (inside the advisory-lock transaction). - Parse/fallback rules are extracted into a pure helper (`effective_owner_limit`) with unit tests, keeping the cached getter trivial. ## Test plan - `cargo test -p buzz-db --lib` — new `owner_limit_*` tests cover default, invalid, non-positive, and positive-override cases. (Pre-existing unrelated failure on clean main: `replica_fence::tests::fence_starts_closed_and_opens_on_advance`, tracked in #2369.) - `cargo clippy -p buzz-db --all-targets` and `cargo fmt` clean. - Deployed on my multi-tenant relay (vibecode.casa) with `BUZZ_MAX_COMMUNITIES_PER_OWNER=100`: provisioning a 4th community for the same owner succeeds; without the var the stock limit of 3 still applies. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Signed-off-by: Lee Salminen --- crates/buzz-db/src/lib.rs | 2 +- crates/buzz-db/src/relay_members.rs | 58 +++++++++++++++++++++++++++-- 2 files changed, 56 insertions(+), 4 deletions(-) diff --git a/crates/buzz-db/src/lib.rs b/crates/buzz-db/src/lib.rs index fdd72c3c3..9c63b2e8a 100644 --- a/crates/buzz-db/src/lib.rs +++ b/crates/buzz-db/src/lib.rs @@ -898,7 +898,7 @@ impl Db { .fetch_one(&mut *tx) .await?; - if owned_count >= relay_members::MAX_COMMUNITIES_PER_OWNER { + if owned_count >= relay_members::max_communities_per_owner() { tx.rollback().await?; return Ok(CreateCommunityWithOwnerResult::LimitReached); } diff --git a/crates/buzz-db/src/relay_members.rs b/crates/buzz-db/src/relay_members.rs index 3bd114fda..3805745f9 100644 --- a/crates/buzz-db/src/relay_members.rs +++ b/crates/buzz-db/src/relay_members.rs @@ -373,11 +373,37 @@ pub enum TransferResult { LimitReached, } -/// Maximum number of communities a single pubkey can own. Enforced at the -/// relay layer — the authoritative layer — so that concurrent transfers or +/// Default maximum number of communities a single pubkey can own. Enforced at +/// the relay layer — the authoritative layer — so that concurrent transfers or /// transfer-vs-create races cannot both pass a preflight count. pub const MAX_COMMUNITIES_PER_OWNER: i64 = 3; +/// Effective per-owner community limit for this deployment. +/// +/// 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. +pub fn max_communities_per_owner() -> i64 { + static LIMIT: std::sync::OnceLock = std::sync::OnceLock::new(); + *LIMIT.get_or_init(|| { + effective_owner_limit( + std::env::var("BUZZ_MAX_COMMUNITIES_PER_OWNER") + .ok() + .as_deref(), + ) + }) +} + +/// Pure resolution of the owner limit from a raw env value — extracted from +/// [`max_communities_per_owner`] so the parse/fallback rules are testable +/// without process-global env state. +fn effective_owner_limit(raw: Option<&str>) -> i64 { + raw.and_then(|value| value.trim().parse::().ok()) + .filter(|limit| *limit > 0) + .unwrap_or(MAX_COMMUNITIES_PER_OWNER) +} + /// Stable advisory-lock key for serializing ownership-granting operations /// (transfer + create) per recipient pubkey. Uses FNV-1a over the hex pubkey /// so the same recipient always maps to the same lock across processes. @@ -472,7 +498,7 @@ pub async fn transfer_ownership( .fetch_one(&mut *tx) .await?; - if owned_count >= MAX_COMMUNITIES_PER_OWNER { + if owned_count >= max_communities_per_owner() { tx.rollback().await?; return Ok(TransferResult::LimitReached); } @@ -555,6 +581,32 @@ pub async fn backfill_from_allowlist(pool: &PgPool, community: CommunityId) -> R #[cfg(test)] mod tests { + #[test] + fn owner_limit_defaults_when_unset_or_invalid() { + assert_eq!( + super::effective_owner_limit(None), + super::MAX_COMMUNITIES_PER_OWNER + ); + assert_eq!( + super::effective_owner_limit(Some("not-a-number")), + super::MAX_COMMUNITIES_PER_OWNER + ); + assert_eq!( + super::effective_owner_limit(Some("0")), + super::MAX_COMMUNITIES_PER_OWNER + ); + assert_eq!( + super::effective_owner_limit(Some("-5")), + super::MAX_COMMUNITIES_PER_OWNER + ); + } + + #[test] + fn owner_limit_honors_positive_override() { + assert_eq!(super::effective_owner_limit(Some("100")), 100); + assert_eq!(super::effective_owner_limit(Some(" 12 ")), 12); + } + use super::*; use uuid::Uuid;