feat: raise hosted community limit to fifty (#8186)

## 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>
This commit is contained in:
Kalvin C
2026-10-08 15:40:20 -07:00
committed by GitHub
parent a8fe2b612c
commit 8f66f7051c
12 changed files with 281 additions and 49 deletions

View File

@@ -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());

View File

@@ -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"
);
}
}

View File

@@ -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.",
);
});

View File

@@ -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<HostedCommunityAccou
return {
identity: identityResponse.identity ?? null,
communities: communitiesResponse.communities ?? [],
canCreate: communitiesResponse.can_create !== false,
};
}

View File

@@ -9,7 +9,6 @@ import {
createHostedCommunity,
deleteBuilderlabIdentity,
getBuilderlabAuth,
HOSTED_COMMUNITY_LIMIT,
HOSTED_COMMUNITY_SUFFIX,
hostedCommunityErrorMessage,
hostedCommunityRelayUrl,
@@ -19,7 +18,6 @@ import {
usableBoundIdentityNpub,
VALID_HOSTED_COMMUNITY_NAME,
type BuilderlabAuth,
type HostedCommunity,
type HostedNostrIdentity,
} from "@/features/communities/hostedCommunityApi";
import { useCommunityOnboarding } from "@/features/onboarding/communityOnboarding";
@@ -47,7 +45,7 @@ export function HostedCommunityCreateFlow({
const [identity, setIdentity] = React.useState<HostedNostrIdentity | null>(
null,
);
const [communities, setCommunities] = React.useState<HostedCommunity[]>([]);
const [canCreate, setCanCreate] = React.useState(true);
const [name, setName] = React.useState("");
const [availability, setAvailability] = React.useState<boolean | null>(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

View File

@@ -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<HostedCommunity[]>([]);
const [canCreate, setCanCreate] = React.useState(true);
const [showCreate, setShowCreate] = React.useState(false);
const [name, setName] = React.useState("");
const [availability, setAvailability] = React.useState<boolean | null>(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

View File

@@ -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<BuilderlabAuth | null>(null);
const [communities, setCommunities] = React.useState<HostedCommunity[]>([]);
const [quotaUsed, setQuotaUsed] = React.useState<number | null>(null);
const [quotaLimit, setQuotaLimit] = React.useState<number | null>(null);
const [canCreate, setCanCreate] = React.useState(true);
const [identity, setIdentity] = React.useState<NostrIdentity | null>(null);
const [name, setName] = React.useState("");
const [availability, setAvailability] = React.useState<boolean | null>(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<AvailabilityResponse>(
@@ -438,7 +443,7 @@ export function HostedCommunitiesSettingsCard() {
};
const busy = action != null;
const atCommunityLimit = communities.length >= MAX_COMMUNITIES;
const atCommunityLimit = !canCreate;
return (
<section className="space-y-6" data-testid="hosted-communities-settings">
@@ -592,7 +597,8 @@ export function HostedCommunitiesSettingsCard() {
<h3 className="font-medium">
Your communities
<span className="ml-2 text-xs font-normal text-muted-foreground">
{communities.length} of {MAX_COMMUNITIES} used
{quotaUsed ?? communities.length}
{quotaLimit === null ? " used" : ` of ${quotaLimit} used`}
</span>
</h3>
<Button
@@ -667,9 +673,9 @@ export function HostedCommunitiesSettingsCard() {
</div>
{atCommunityLimit ? (
<p className="text-sm text-muted-foreground">
You&apos;ve reached the limit of {MAX_COMMUNITIES} hosted
communities. Transfer one to free up a slot before creating
another.
You&apos;ve reached your hosted community creation limit.
Transferring a community may free an active slot; deleted
community addresses remain reserved.
</p>
) : null}
<div className="flex max-w-xl items-center gap-2">

View File

@@ -255,6 +255,11 @@ type E2eConfig = {
normalized_host?: string;
archived_at?: string | null;
}>;
builderlabQuota?: {
quota_used?: number;
quota_limit?: number;
can_create?: boolean;
};
/** Override the community returned after hosted creation. */
builderlabCreatedCommunity?: {
id?: string;
@@ -12609,6 +12614,7 @@ export function maybeInstallE2eTauriMocks() {
case "list_builderlab_communities":
return {
communities: activeConfig?.mock?.builderlabCommunities ?? [],
...activeConfig?.mock?.builderlabQuota,
};
case "check_builderlab_community_name":
return {

View File

@@ -118,6 +118,61 @@ test("identity: padded same-key hex is a ready create flow, not a mismatch", asy
).toHaveCount(0);
});
test("create flow follows server permission with five owned communities", async ({
page,
}) => {
await installMockBridge(
page,
{
builderlabAuth: {
email: "owner@example.com",
expiresAt: "2099-01-01T00:00:00Z",
},
builderlabIdentity: { pubkey_hex: DEFAULT_MOCK_PUBKEY },
builderlabCommunities: Array.from({ length: 5 }, (_, index) => ({
id: `owned-${index}`,
})),
builderlabQuota: { quota_used: 5, quota_limit: 50, can_create: true },
},
{ skipCommunitySeed: true },
);
await page.reload();
await page.getByTestId("community-rail-add").click();
await page.getByTestId("add-community-create").click();
await page.getByTestId("hosted-community-create-name").fill("new-team");
await expect(
page.getByTestId("hosted-community-create-submit"),
).toBeEnabled();
});
test("create flow blocks when lifetime quota is exhausted", async ({
page,
}) => {
await installMockBridge(
page,
{
builderlabAuth: {
email: "owner@example.com",
expiresAt: "2099-01-01T00:00:00Z",
},
builderlabIdentity: { pubkey_hex: DEFAULT_MOCK_PUBKEY },
builderlabCommunities: [],
builderlabQuota: { quota_used: 0, quota_limit: 50, can_create: false },
},
{ skipCommunitySeed: true },
);
await page.reload();
await page.getByTestId("community-rail-add").click();
await page.getByTestId("add-community-create").click();
await expect(page.getByTestId("hosted-community-create-name")).toBeDisabled();
await expect(
page.getByTestId("hosted-community-create-submit"),
).toBeDisabled();
await expect(
page.getByText(/reached your hosted community creation limit/),
).toBeVisible();
});
test("capture: add-community choices", async ({ page }) => {
const dialog = page.getByTestId("add-community-dialog");
await dialog.waitFor();

View File

@@ -311,3 +311,63 @@ test("capture: community icon picker sits beside its hosted community", async ({
path: `${OUTDIR}/01-community-icon-row.png`,
});
});
test("server quota controls the create gate, not the visible list length", async ({
page,
}) => {
await installMockBridge(page, {
builderlabAuth: {
email: "owner@example.com",
expiresAt: "2099-01-01T00:00:00Z",
},
builderlabIdentity: { pubkey_hex: DEFAULT_MOCK_PUBKEY },
builderlabCommunities: Array.from({ length: 5 }, (_, index) => ({
id: `community-${index}`,
name: `Community ${index}`,
normalized_host: `community-${index}.communities.buzz.xyz`,
})),
builderlabQuota: { quota_used: 5, quota_limit: 50, can_create: true },
});
await page.goto("/");
await openSettings(page, "hosted-communities");
await expect(page.getByText("5 of 50 used")).toBeVisible();
await expect(page.getByLabel("Community address")).toBeEnabled();
// A lifetime tombstone can exhaust creation even when active usage is low.
await installMockBridge(page, {
builderlabAuth: {
email: "owner@example.com",
expiresAt: "2099-01-01T00:00:00Z",
},
builderlabIdentity: { pubkey_hex: DEFAULT_MOCK_PUBKEY },
builderlabCommunities: [],
builderlabQuota: { quota_used: 0, quota_limit: 50, can_create: false },
});
await page.reload();
await expect(page.getByTestId("hosted-communities-settings")).toBeVisible();
await expect(page.getByText("0 of 50 used")).toBeVisible();
await expect(page.getByLabel("Community address")).toBeDisabled();
await expect(
page.getByText(/reached your hosted community creation limit/),
).toBeVisible();
});
test("missing server quota does not impose a guessed limit", async ({
page,
}) => {
await installMockBridge(page, {
builderlabAuth: {
email: "owner@example.com",
expiresAt: "2099-01-01T00:00:00Z",
},
builderlabIdentity: { pubkey_hex: DEFAULT_MOCK_PUBKEY },
builderlabCommunities: Array.from({ length: 20 }, (_, index) => ({
id: `community-${index}`,
normalized_host: `community-${index}.communities.buzz.xyz`,
})),
});
await page.goto("/");
await openSettings(page, "hosted-communities");
await expect(page.getByText("20 used")).toBeVisible();
await expect(page.getByLabel("Community address")).toBeEnabled();
});

View File

@@ -180,6 +180,11 @@ type MockBridgeOptions = {
normalized_host?: string;
archived_at?: string | null;
}>;
builderlabQuota?: {
quota_used?: number;
quota_limit?: number;
can_create?: boolean;
};
acpRuntimesCatalog?: Record<string, unknown>[];
/** Catalog returned after a successful mocked install. */
acpRuntimesCatalogAfterInstall?: Record<string, unknown>[];

View File

@@ -191,17 +191,21 @@ 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
(`BUZZ_MAX_COMMUNITIES_PER_OWNER`, default 50). 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
completed ones, capped at an absolute 50 regardless of the active limit.
Lifetime usage includes live ownership, so no owner can hold more than 50
live communities; a `BUZZ_MAX_COMMUNITIES_PER_OWNER` above 50 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.
create-then-delete host squatting. At the stock 50/50 caps, every completed
owner deletion permanently reduces the maximum number of live communities
that owner can subsequently hold: 50 active slots are only possible with no
prior owner deletions. Raising the active override cannot restore those slots.
Aborted deletions restore the community and count only through its live
membership.
Owner-list responses carry `quota_used` (active), `quota_limit` (active), and
`can_create` (both caps). `can_create: false` is the only signal a client needs: