mirror of
https://github.com/block/buzz.git
synced 2026-10-27 12:40:30 +00:00
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:
@@ -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());
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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've reached the limit of {MAX_COMMUNITIES} hosted
|
||||
communities. Transfer one to free up a slot before creating
|
||||
another.
|
||||
You'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">
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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>[];
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user