From 8f66f7051c68560feb27dbc74b069c851bc018bd Mon Sep 17 00:00:00 2001 From: Kalvin C Date: Thu, 8 Oct 2026 15:40:20 -0700 Subject: [PATCH] feat: raise hosted community limit to fifty (#8186) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Raise the stock relay active ownership quota from 5 to 50 for creation and ownership transfer. Raise the absolute lifetime quota from 20 to 50 reserved hosts; completed owner deletions still permanently consume lifetime capacity to bound create/delete host squatting. - Honor the owner-list `can_create` projection across Desktop settings, create flow, and onboarding instead of imposing a client-side count. Display server `quota_used` / `quota_limit` when available; when the projection is missing, let the server's `limit_reached` decide. - Cover 49→50 and 50→51 admissions, deleted-owner lifetime exhaustion, and rendered create gates under 5/50, 0/50 exhausted, and missing projections. ## Verification - Desktop full `pnpm test` and `pnpm typecheck` pass after the 50-cap update; `cargo fmt --all -- --check` passes. Four targeted Playwright cases and `pnpm build:e2e` passed for the original dynamic-quota change at the previous commit, before changing test fixtures from 20 to 50. - `cargo test -p buzz-db --lib` cannot compile here: macOS dyld rejects the `sqlx_macros` dylib with `mis-aligned LINKEDIT string pool` on this host before the crate's tests run. CI must validate the DB package. ## Rollout / manual validation - Confirm `BUZZ_MAX_COMMUNITIES_PER_OWNER` is unset or 50 on **every** production relay replica; this per-process cached override takes precedence over the stock default. - Confirm Builderlab's list/create/transfer proxy passes the relay's `quota_used`, `quota_limit`, `can_create`, and `limit_reached` without its own 5-community gate. Its backing implementation is not in this repo and was not verified here. - Human test: with an owner at 49 live communities, create the fiftieth, verify the fifty-first returns `limit_reached`; transfer into an owner at 49 then at 50 and confirm the same boundary. Check a deleted-owner account at 0 active/50 lifetime displays the generic limit and disables creation. This manual check remains outstanding. --------- Signed-off-by: am <6e30cd56c30e030cd31bb0939b94a7c257c9a09d5ba2d92cf2735da45629f248@buzz.block.builderlab.xyz> Co-authored-by: am <6e30cd56c30e030cd31bb0939b94a7c257c9a09d5ba2d92cf2735da45629f248@buzz.block.builderlab.xyz> --- crates/buzz-db/src/store/community.rs | 19 +++++- crates/buzz-db/src/store/relay_members.rs | 67 ++++++++++++++----- .../communities/hostedCommunityApi.test.mjs | 27 ++++++++ .../communities/hostedCommunityApi.ts | 25 ++++++- .../ui/HostedCommunityCreateFlow.tsx | 12 ++-- .../ui/HostedCommunityOnboarding.tsx | 8 ++- .../ui/HostedCommunitiesSettingsCard.tsx | 30 +++++---- desktop/src/testing/e2eBridge.ts | 6 ++ .../e2e/add-community-screenshots.spec.ts | 55 +++++++++++++++ ...d-communities-settings-screenshots.spec.ts | 60 +++++++++++++++++ desktop/tests/helpers/bridge.ts | 5 ++ docs/operator-community-deletion.md | 16 +++-- 12 files changed, 281 insertions(+), 49 deletions(-) diff --git a/crates/buzz-db/src/store/community.rs b/crates/buzz-db/src/store/community.rs index 353162f22e..6cf43ee7c5 100644 --- a/crates/buzz-db/src/store/community.rs +++ b/crates/buzz-db/src/store/community.rs @@ -1452,7 +1452,7 @@ mod postgres_tests { let db = setup_db().await; let owner = format!("{:064x}", Uuid::new_v4().as_u128()); - // Fill the configured default ownership limit. + // The fiftieth create succeeds, then the fifty-first is rejected. for i in 0..crate::relay_members::MAX_COMMUNITIES_PER_OWNER { let host = format!("limit-test-{}-{}.example", i, Uuid::new_v4().simple()); assert!(matches!( @@ -1463,6 +1463,16 @@ mod postgres_tests { )); } + let page = db + .list_communities_owned_by(&owner) + .await + .expect("owner quota at fifty"); + assert_eq!( + page.quota_used, + crate::relay_members::MAX_COMMUNITIES_PER_OWNER + ); + assert!(!page.can_create); + let host = format!("limit-test-overflow-{}.example", Uuid::new_v4().simple()); assert_eq!( db.create_community_with_owner(&host, &owner) @@ -1566,7 +1576,7 @@ mod postgres_tests { let recipient = format!("{}{}", Uuid::new_v4().simple(), Uuid::new_v4().simple()); let source_owner = format!("{}{}", Uuid::new_v4().simple(), Uuid::new_v4().simple()); - for index in 0..3 { + for index in 0..(crate::relay_members::MAX_COMMUNITIES_PER_OWNER - 2) { let host = format!("quota-live-{index}-{}.example", Uuid::new_v4().simple()); assert!(matches!( db.create_community_with_owner(&host, &recipient) @@ -1687,6 +1697,11 @@ mod postgres_tests { .await .expect("owner list at lifetime cap"); assert_eq!(page.quota_used, 0, "completed deletions free active slots"); + assert_eq!( + page.quota_limit, + crate::relay_members::max_communities_per_owner(), + "active limit is shown separately" + ); assert!(!page.can_create, "the lifetime cap still blocks creation"); let host = format!("lifetime-overflow-{}.example", Uuid::new_v4().simple()); diff --git a/crates/buzz-db/src/store/relay_members.rs b/crates/buzz-db/src/store/relay_members.rs index bee941df0b..9a13e5aa47 100644 --- a/crates/buzz-db/src/store/relay_members.rs +++ b/crates/buzz-db/src/store/relay_members.rs @@ -564,7 +564,7 @@ pub enum ProvisionOwnerResult { /// 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 = 5; +pub const MAX_COMMUNITIES_PER_OWNER: i64 = 50; /// Effective per-owner community limit for this deployment. /// @@ -611,7 +611,7 @@ pub fn owner_count_advisory_lock_key(pubkey_hex: &str) -> i64 { /// communities whose tombstones permanently retain their hosts. Bounds /// create-then-delete host squatting. Absolute: it does not scale with /// `BUZZ_MAX_COMMUNITIES_PER_OWNER`. -pub const MAX_LIFETIME_COMMUNITIES_PER_OWNER: i64 = 20; +pub const MAX_LIFETIME_COMMUNITIES_PER_OWNER: i64 = 50; /// One owner's quota usage, read inside the admitting transaction. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -1307,7 +1307,8 @@ mod postgres_tests { fn owner_limit_defaults_when_unset_or_invalid() { assert_eq!( super::effective_owner_limit(None), - super::MAX_COMMUNITIES_PER_OWNER + 50, + "stock deployment permits 50 active communities" ); assert_eq!( super::effective_owner_limit(Some("not-a-number")), @@ -1751,34 +1752,66 @@ mod postgres_tests { let owner = test_pubkey(); let transferee = test_pubkey(); - // Fill the configured default ownership limit. - for _ in 0..MAX_COMMUNITIES_PER_OWNER { + // At 49 ownerships, the fiftieth transfer succeeds; at 50, the + // fifty-first is rejected by the same transaction-side admission. + for _ in 0..(MAX_COMMUNITIES_PER_OWNER - 1) { let c = make_test_community(&pool).await; bootstrap_owner(&pool, c, &transferee) .await .expect("bootstrap transferee community"); } - // Create a community owned by `owner` and try to transfer to `transferee`. - let community = make_test_community(&pool).await; - bootstrap_owner(&pool, community, &owner) + let fiftieth = make_test_community(&pool).await; + bootstrap_owner(&pool, fiftieth, &owner) .await .expect("bootstrap owner"); - - let result = transfer_ownership(&pool, community, &transferee, &owner) - .await - .expect("transfer to maxed transferee"); - - assert_eq!(result, TransferResult::LimitReached); - - // Owner is still owner — transfer did not happen. + assert!(matches!( + transfer_ownership(&pool, fiftieth, &transferee, &owner) + .await + .expect("fiftieth transfer"), + TransferResult::Transferred { .. } + )); assert_eq!( - get_relay_member(&pool, community, &owner) + get_relay_member(&pool, fiftieth, &transferee) + .await + .expect("get transferee") + .expect("exists") + .role, + "owner" + ); + assert_eq!( + get_relay_member(&pool, fiftieth, &owner) + .await + .expect("get previous owner") + .expect("exists") + .role, + "member" + ); + + let fifty_first = make_test_community(&pool).await; + bootstrap_owner(&pool, fifty_first, &owner) + .await + .expect("bootstrap owner"); + assert_eq!( + transfer_ownership(&pool, fifty_first, &transferee, &owner) + .await + .expect("fifty-first transfer"), + TransferResult::LimitReached + ); + assert_eq!( + get_relay_member(&pool, fifty_first, &owner) .await .expect("get owner") .expect("exists") .role, "owner" ); + assert!( + get_relay_member(&pool, fifty_first, &transferee) + .await + .expect("get transferee") + .is_none(), + "rejected transfer must not add transferee" + ); } } diff --git a/desktop/src/features/communities/hostedCommunityApi.test.mjs b/desktop/src/features/communities/hostedCommunityApi.test.mjs index 0af05143df..7a894cb878 100644 --- a/desktop/src/features/communities/hostedCommunityApi.test.mjs +++ b/desktop/src/features/communities/hostedCommunityApi.test.mjs @@ -14,6 +14,9 @@ import test from "node:test"; import { npubEncode } from "nostr-tools/nip19"; import { + hostedCommunityErrorMessage, + hostedCommunityQuotaLimit, + hostedCommunityQuotaUsed, normalizedBoundKeyHex, usableBoundIdentityNpub, } from "./hostedCommunityApi.ts"; @@ -96,3 +99,27 @@ test("the string normalizer rejects the same non-key values directly", () => { assert.equal(normalizedBoundKeyHex("f".repeat(65)), null); assert.equal(normalizedBoundKeyHex(" "), null); }); + +test("quota used accepts zero but rejects missing and invalid projections", () => { + assert.equal(hostedCommunityQuotaUsed(undefined), null); + assert.equal(hostedCommunityQuotaUsed(-1), null); + assert.equal(hostedCommunityQuotaUsed(0), 0); + assert.equal(hostedCommunityQuotaUsed(49), 49); + assert.equal(hostedCommunityQuotaUsed(1.5), null); + assert.equal(hostedCommunityQuotaUsed(Number.MAX_SAFE_INTEGER + 1), null); +}); + +test("quota projection never invents a fixed client limit", () => { + assert.equal(hostedCommunityQuotaLimit(undefined), null); + assert.equal(hostedCommunityQuotaLimit(0), null); + assert.equal(hostedCommunityQuotaLimit(5), 5); + assert.equal(hostedCommunityQuotaLimit(50), 50); + assert.equal( + hostedCommunityErrorMessage( + { code: "limit_reached" }, + undefined, + "fallback", + ), + "You’ve reached your hosted community creation limit.", + ); +}); diff --git a/desktop/src/features/communities/hostedCommunityApi.ts b/desktop/src/features/communities/hostedCommunityApi.ts index ebf5857c6e..4cd4dca9ec 100644 --- a/desktop/src/features/communities/hostedCommunityApi.ts +++ b/desktop/src/features/communities/hostedCommunityApi.ts @@ -3,7 +3,6 @@ import { invoke } from "@tauri-apps/api/core"; import { safeNpub } from "@/shared/lib/nostrUtils"; export const HOSTED_COMMUNITY_SUFFIX = "communities.buzz.xyz"; -export const HOSTED_COMMUNITY_LIMIT = 5; export const VALID_HOSTED_COMMUNITY_NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; export type BuilderlabAuth = { @@ -40,6 +39,9 @@ export type HostedCommunity = { export type HostedCommunitiesResponse = { communities?: HostedCommunity[]; + quota_used?: number; + quota_limit?: number; + can_create?: boolean; error?: HostedCommunityApiError; correlation_id?: string; }; @@ -59,9 +61,27 @@ export type HostedCommunityMutationResponse = { export type HostedCommunityAccount = { communities: HostedCommunity[]; + canCreate: boolean; identity: HostedNostrIdentity | null; }; +/** A missing projection must not invent a client-side quota; create remains server-authoritative. */ +export function hostedCommunityQuotaLimit( + value: number | undefined, +): number | null { + return typeof value === "number" && Number.isSafeInteger(value) && value > 0 + ? value + : null; +} + +export function hostedCommunityQuotaUsed( + value: number | undefined, +): number | null { + return typeof value === "number" && Number.isSafeInteger(value) && value >= 0 + ? value + : null; +} + export function hostedCommunityErrorMessage( error: HostedCommunityApiError | undefined, correlationId: string | undefined, @@ -71,7 +91,7 @@ export function hostedCommunityErrorMessage( missing_mapping: "Connect your Buzz identity before creating a community.", invalid_name: "Use lowercase letters, numbers, and hyphens.", taken: "That Buzz address is already taken.", - limit_reached: `You've reached the limit of ${HOSTED_COMMUNITY_LIMIT} hosted communities.`, + limit_reached: "You’ve reached your hosted community creation limit.", relay_unavailable: "Community provisioning is temporarily unavailable.", identity_already_bound: "This Builderlab account is connected to another Buzz identity.", @@ -187,6 +207,7 @@ export async function loadHostedCommunityAccount(): Promise( null, ); - const [communities, setCommunities] = React.useState([]); + const [canCreate, setCanCreate] = React.useState(true); const [name, setName] = React.useState(""); const [availability, setAvailability] = React.useState(null); const [checkingName, setCheckingName] = React.useState(false); @@ -60,7 +58,7 @@ export function HostedCommunityCreateFlow({ const loadAccount = React.useCallback(async () => { const account = await loadHostedCommunityAccount(); setIdentity(account.identity); - setCommunities(account.communities); + setCanCreate(account.canCreate); }, []); React.useEffect(() => { @@ -147,7 +145,7 @@ export function HostedCommunityCreateFlow({ await clearBuilderlabAuth(); setAuth(null); setIdentity(null); - setCommunities([]); + setCanCreate(true); }); // Identity rows display npubs derived from the same key the mismatch gate @@ -209,7 +207,7 @@ export function HostedCommunityCreateFlow({ const validName = normalizedName.length <= 63 && VALID_HOSTED_COMMUNITY_NAME.test(normalizedName); - const atCommunityLimit = communities.length >= HOSTED_COMMUNITY_LIMIT; + const atCommunityLimit = !canCreate; const ready = Boolean(auth && usableBoundIdentity && !identityMismatch); React.useEffect(() => { @@ -400,7 +398,7 @@ export function HostedCommunityCreateFlow({ } const feedback = atCommunityLimit - ? `You’ve reached the limit of ${HOSTED_COMMUNITY_LIMIT} hosted communities.` + ? "You’ve reached your hosted community creation limit." : name && !validName ? "Use lowercase letters, numbers, and single hyphens." : checkingName diff --git a/desktop/src/features/communities/ui/HostedCommunityOnboarding.tsx b/desktop/src/features/communities/ui/HostedCommunityOnboarding.tsx index 5f9578bf13..c050e7520e 100644 --- a/desktop/src/features/communities/ui/HostedCommunityOnboarding.tsx +++ b/desktop/src/features/communities/ui/HostedCommunityOnboarding.tsx @@ -10,7 +10,6 @@ import { createHostedCommunity, deleteBuilderlabIdentity, getBuilderlabAuth, - HOSTED_COMMUNITY_LIMIT, HOSTED_COMMUNITY_SUFFIX, hostedCommunityErrorMessage, hostedCommunityRelayUrl, @@ -87,6 +86,7 @@ export function HostedCommunityOnboarding({ null, ); const [communities, setCommunities] = React.useState([]); + const [canCreate, setCanCreate] = React.useState(true); const [showCreate, setShowCreate] = React.useState(false); const [name, setName] = React.useState(""); const [availability, setAvailability] = React.useState(null); @@ -100,6 +100,7 @@ export function HostedCommunityOnboarding({ const account = await loadHostedCommunityAccount(); setIdentity(account.identity); setCommunities(account.communities); + setCanCreate(account.canCreate); }, []); React.useEffect(() => { @@ -170,6 +171,7 @@ export function HostedCommunityOnboarding({ setAuth(null); setIdentity(null); setCommunities([]); + setCanCreate(true); setShowCreate(false); setName(""); setAvailability(null); @@ -259,7 +261,7 @@ export function HostedCommunityOnboarding({ const validName = normalizedName.length <= 63 && VALID_HOSTED_COMMUNITY_NAME.test(normalizedName); - const atCommunityLimit = communities.length >= HOSTED_COMMUNITY_LIMIT; + const atCommunityLimit = !canCreate; const hasCommunities = activeCommunities.length > 0; React.useEffect(() => { @@ -385,7 +387,7 @@ export function HostedCommunityOnboarding({ ) : null; const creationFeedback = atCommunityLimit - ? `You’ve reached the limit of ${HOSTED_COMMUNITY_LIMIT} hosted communities.` + ? "You’ve reached your hosted community creation limit." : name && !validName ? "Use lowercase letters, numbers, and single hyphens." : checkingName diff --git a/desktop/src/features/settings/ui/HostedCommunitiesSettingsCard.tsx b/desktop/src/features/settings/ui/HostedCommunitiesSettingsCard.tsx index a9097671c0..f8e2e55118 100644 --- a/desktop/src/features/settings/ui/HostedCommunitiesSettingsCard.tsx +++ b/desktop/src/features/settings/ui/HostedCommunitiesSettingsCard.tsx @@ -15,9 +15,10 @@ import { import { useIdentityQuery } from "@/shared/api/hooks"; import { - HOSTED_COMMUNITY_LIMIT as MAX_COMMUNITIES, HOSTED_COMMUNITY_SUFFIX as HOST_SUFFIX, hostedCommunityErrorMessage as errorMessage, + hostedCommunityQuotaLimit, + hostedCommunityQuotaUsed, hostedCommunityRelayUrl as relayUrl, normalizedBoundKeyHex, usableBoundIdentityNpub, @@ -72,6 +73,9 @@ export function HostedCommunitiesSettingsCard() { const localPubkey = useIdentityQuery().data?.pubkey ?? null; const [auth, setAuth] = React.useState(null); const [communities, setCommunities] = React.useState([]); + const [quotaUsed, setQuotaUsed] = React.useState(null); + const [quotaLimit, setQuotaLimit] = React.useState(null); + const [canCreate, setCanCreate] = React.useState(true); const [identity, setIdentity] = React.useState(null); const [name, setName] = React.useState(""); const [availability, setAvailability] = React.useState(null); @@ -113,6 +117,9 @@ export function HostedCommunitiesSettingsCard() { } setIdentity(identityResponse.identity ?? null); setCommunities(communitiesResponse.communities ?? []); + setQuotaUsed(hostedCommunityQuotaUsed(communitiesResponse.quota_used)); + setQuotaLimit(hostedCommunityQuotaLimit(communitiesResponse.quota_limit)); + setCanCreate(communitiesResponse.can_create !== false); }, []); React.useEffect(() => { @@ -163,6 +170,9 @@ export function HostedCommunitiesSettingsCard() { setAuth(null); setIdentity(null); setCommunities([]); + setQuotaUsed(null); + setQuotaLimit(null); + setCanCreate(true); setName(""); setAvailability(null); }); @@ -382,12 +392,7 @@ export function HostedCommunitiesSettingsCard() { const createCommunity = (event: React.FormEvent) => { event.preventDefault(); - if ( - !validName || - !usableBoundIdentity || - identityMismatch || - communities.length >= MAX_COMMUNITIES - ) + if (!validName || !usableBoundIdentity || identityMismatch || !canCreate) return; void run("Creating community…", async () => { const availabilityResponse = await invoke( @@ -438,7 +443,7 @@ export function HostedCommunitiesSettingsCard() { }; const busy = action != null; - const atCommunityLimit = communities.length >= MAX_COMMUNITIES; + const atCommunityLimit = !canCreate; return (
@@ -592,7 +597,8 @@ export function HostedCommunitiesSettingsCard() {

Your communities - {communities.length} of {MAX_COMMUNITIES} used + {quotaUsed ?? communities.length} + {quotaLimit === null ? " used" : ` of ${quotaLimit} used`}