fix(desktop): derive the hosted-community limit from the relay instead of hardcoding 5 - #4810
Open
TLXyloph wants to merge 6 commits into
Open
fix(desktop): derive the hosted-community limit from the relay instead of hardcoding 5#4810TLXyloph wants to merge 6 commits into
TLXyloph wants to merge 6 commits into
Conversation
…or responses The relay derives the effective per-owner community limit from BUZZ_MAX_COMMUNITIES_PER_OWNER (buzz_db::relay_members:: max_communities_per_owner), but clients had no way to learn the effective value, so the desktop hardcoded 5 and drifted in both directions once a deployment overrode the default (block#4160). Transmit the limit on the wire: - ProvisionCommunityResponse gains max_communities_per_owner, populated at both construction sites (create_only and legacy convergence). - GET /operator/communities includes max_communities_per_owner alongside the owned-community list. - Both limit_reached rejections (create and transfer) embed the effective number after the routing prefix via a shared limit_reached_error() helper; the starts_with("limit_reached:") 409 routing is unchanged. - Document BUZZ_MAX_COMMUNITIES_PER_OWNER in .env.example and cross-reference the desktop fallback from the server-side const. Additive and backward compatible: no consumer of these responses uses deny_unknown_fields, and the limit_reached: prefix is preserved. Also fixes fresh_host_at_owner_limit_returns_limit_reached_conflict to loop over the env-aware max_communities_per_owner() instead of the const, so the test still provisions up to the effective limit under a live BUZZ_MAX_COMMUNITIES_PER_OWNER override. Refs block#4160 Claude-Session: https://claude.ai/code/session_012GSiyYe13zNJBFwxajcXeW Signed-off-by: Samvrith <samvrith@gmail.com>
…d of a hardcoded 5 The relay enforces an env-configurable per-owner limit (BUZZ_MAX_COMMUNITIES_PER_OWNER), but the desktop pinned HOSTED_COMMUNITY_LIMIT = 5 into its gates and copy, so the UI drifted in both directions the moment a deployment overrode the default: it kept gating users below a raised limit and stopped gating below a lowered one (block#4160). - New pure resolver hostedCommunityLimit.ts: a positive-integer max_communities_per_owner from the server response wins; anything else falls back to DEFAULT_HOSTED_COMMUNITY_LIMIT (the old constant, now fallback-only), mirroring the relay's own env-parse fallback rules. - loadHostedCommunityAccount() resolves communityLimit from the list response; HostedCommunitiesSettingsCard's standalone loader resolves it at its own ingestion site. - Every gate and copy site in HostedCommunityCreateFlow, HostedCommunityOnboarding, and HostedCommunitiesSettingsCard reads the resolved limit; hostedCommunityErrorMessage() accepts the effective limit as an optional 4th parameter (existing 3-arg call sites keep the default) and the limit-bearing create/transfer error paths thread it. - Colocated node:test coverage pins the resolver's fallback rules and the gating regression in both directions (limit 7 does not gate 5 owned; limit 3 gates 3 owned). Until Builderlab forwards max_communities_per_owner from the relay list response, the resolver falls back to the default of 5 and visible behavior is unchanged; this change fixes the mechanism end-to-end on the Buzz side. Refs block#4160 Claude-Session: https://claude.ai/code/session_012GSiyYe13zNJBFwxajcXeW Signed-off-by: Samvrith <samvrith@gmail.com>
… wire contract Signed-off-by: Samvrith <samvrith@gmail.com>
…imit sticky Signed-off-by: Samvrith <samvrith@gmail.com>
…hook Signed-off-by: Samvrith <samvrith@gmail.com>
… wire contract Signed-off-by: Samvrith <samvrith@gmail.com>
TLXyloph
force-pushed
the
fix/issue-4160
branch
from
August 4, 2026 23:16
72d0411 to
379d2da
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Intent
Resolve #4160: the desktop hardcodes the hosted-community per-owner limit at 5, so deployments that override BUZZ_MAX_COMMUNITIES_PER_OWNER show wrong gating and copy in both directions; expose the relay's effective limit on operator responses and have the desktop resolve it with a safe fallback.
What Changed
limit_reached409 bodies (create and transfer) now carrymax_communities_per_owner, the effectiveBUZZ_MAX_COMMUNITIES_PER_OWNERvalue, as a structured field beside the unchangedlimit_reached:message prefix that 409 routing keys off. The env var is documented in.env.exampleand theTESTING.mdconfig table.HOSTED_COMMUNITY_LIMIT = 5in favor of a pure resolver (hostedCommunityLimit.ts) plus auseHostedCommunityLimithook shared by all three hosted-community surfaces (onboarding, create flow, settings card): gating and copy read the relay-reported number, a rejection's number is adopted immediately, and a response that omits the field leaves a known-good limit in place rather than resetting to the default. Limit-reached copy is centralized in one helper, and transfer rejections now name the recipient — the relay rejects those on the transferee's quota, not the requester's. Note the soft dependency: the desktop reaches the relay through Builderlab, which must forwardmax_communities_per_owner; until it does, every surface falls back to 5 and behaves exactly as before.node:testresolver/copy cases, a non-Postgres unit test that asserts and prints the 409/200 wire bodies under varying env values, two Postgres-gated operator handler tests (they self-skip without a database), and a 6-capture Playwright spec covering above/below-default limits, the no-limit fallback, create and transfer 409 adoption, and the gated create dialog — with mock-bridge support for the new limit and rejection payloads.Risk Assessment
✅ Low: The wire change is purely additive with no in-repo consumers to break, the desktop change is fully backward-compatible through a documented default fallback (worst case it behaves exactly as before), the enforced and reported limits provably come from the same source, and the refactored shared loader is byte-identical to the code it replaced — the only open item is a cosmetic dead re-export.
Testing
Ran the change's own unit tests (22 desktop resolver/copy tests, relay provisioning and buzz-db limit tests) plus the full 4217-test desktop suite as a regression guard for the shared E2E bridge edit, then produced product-level evidence at both ends: a relay transcript showing
max_communities_per_owneron the real 200 provision body and 409limit_reachedbody trackingBUZZ_MAX_COMMUNITIES_PER_OWNER(7, 3) and falling back to 5 for0/abcwith the error string unchanged, and six screenshots from a new Playwright spec showing the settings card and add-community dialog gating and wording themselves from the relay's number — ungated at "5 of 7 used", gated at "3 of 3 used", unchanged "5 of 5 used" when the field is absent, re-gated by a create 409, and a transfer 409 worded about the recipient. Existing builderlab smoke specs and the 61-test onboarding integration spec still pass. Everything passed; the only gap is the Postgres-backed operator tests, which self-skip in this environment (no Docker/Postgres)./var/folders/sr/98ws056j0msc_fv3snynykbc0000gn/T/no-mistakes-evidence/01KZ78TRBV8WKJAXR1F2JGY931/screenshots/01-limit-above-default-ungated.png)/var/folders/sr/98ws056j0msc_fv3snynykbc0000gn/T/no-mistakes-evidence/01KZ78TRBV8WKJAXR1F2JGY931/screenshots/02-limit-below-default-gated.png)/var/folders/sr/98ws056j0msc_fv3snynykbc0000gn/T/no-mistakes-evidence/01KZ78TRBV8WKJAXR1F2JGY931/screenshots/03-no-reported-limit-falls-back.png)/var/folders/sr/98ws056j0msc_fv3snynykbc0000gn/T/no-mistakes-evidence/01KZ78TRBV8WKJAXR1F2JGY931/screenshots/04-create-rejection-adopts-limit.png)/var/folders/sr/98ws056j0msc_fv3snynykbc0000gn/T/no-mistakes-evidence/01KZ78TRBV8WKJAXR1F2JGY931/screenshots/05-transfer-rejection-names-recipient.png)/var/folders/sr/98ws056j0msc_fv3snynykbc0000gn/T/no-mistakes-evidence/01KZ78TRBV8WKJAXR1F2JGY931/screenshots/06-create-flow-gated.png)Evidence: Relay wire contract under BUZZ_MAX_COMMUNITIES_PER_OWNER overrides
BUZZ_MAX_COMMUNITIES_PER_OWNER=<unset> -> HTTP 409 {"error":"limit_reached: owner already owns the maximum number of communities","max_communities_per_owner":5} BUZZ_MAX_COMMUNITIES_PER_OWNER=<unset> -> HTTP 200 {"community_id":"b8f7f4a0-0000-0000-0000-000000000000","host":"acme.communities.buzz.xyz","status":"created","max_communities_per_owner":5} BUZZ_MAX_COMMUNITIES_PER_OWNER=7 -> HTTP 409 {"error":"limit_reached: owner already owns the maximum number of communities","max_communities_per_owner":7} BUZZ_MAX_COMMUNITIES_PER_OWNER=7 -> HTTP 200 {"community_id":"b8f7f4a0-0000-0000-0000-000000000000","host":"acme.communities.buzz.xyz","status":"created","max_communities_per_owner":7} BUZZ_MAX_COMMUNITIES_PER_OWNER=3 -> HTTP 409 {"error":"limit_reached: owner already owns the maximum number of communities","max_communities_per_owner":3} BUZZ_MAX_COMMUNITIES_PER_OWNER=3 -> HTTP 200 {"community_id":"b8f7f4a0-0000-0000-0000-000000000000","host":"acme.communities.buzz.xyz","status":"created","max_communities_per_owner":3} BUZZ_MAX_COMMUNITIES_PER_OWNER=0 -> HTTP 409 {"error":"limit_reached: owner already owns the maximum number of communities","max_communities_per_owner":5} BUZZ_MAX_COMMUNITIES_PER_OWNER=abc -> HTTP 200 {"community_id":"b8f7f4a0-0000-0000-0000-000000000000","host":"acme.communities.buzz.xyz","status":"created","max_communities_per_owner":5}Pipeline
Updates from git push no-mistakes
✅ **intent** - passed
✅ No issues found.
✅ **Rebase** - passed
✅ No issues found.
desktop/src/features/communities/hostedCommunityApi.ts:51- The desktop readsmax_communities_per_owneroff the Builderlab response, but the desktop never talks to the relay's operator API —list_builderlab_communitiesposts tohttps://app.builderlab.xyz/api/goose/v1/buzz/communities/list(desktop/src-tauri/src/builderlab.rs:550), and Builderlab reshapes the relay payload (desktop seesid/name/slug/normalized_host; the relay emitscommunity_id/host). Unless Builderlab (out of this repo) is updated to forward the field under this exact snake_case name, every desktop surface still resolves to the hardcoded 5 and issue Desktop hardcodes HOSTED_COMMUNITY_LIMIT = 5 while the relay enforces the env-configurable max_communities_per_owner() — UI drifts in both directions once BUZZ_MAX_COMMUNITIES_PER_OWNER is set #4160's repro is unchanged. Issue Desktop hardcodes HOSTED_COMMUNITY_LIMIT = 5 while the relay enforces the env-configurable max_communities_per_owner() — UI drifts in both directions once BUZZ_MAX_COMMUNITIES_PER_OWNER is set #4160 explicitly names this hop as the caveat it could not close; this branch neither closes nor documents it. Confirm the Builderlab-side change is landed/planned, or note the dependency in the PR.crates/buzz-relay/src/handlers/community_provisioning.rs:82-limit_reached_errorappends(N)to an operator-API error string that the out-of-repo Builderlab service normalizes into thelimit_reachedcode the desktop maps. If that normalizer matches the message by equality rather than prefix, it stops emittinglimit_reachedand the desktop falls through toerror?.message ?? fallback— a raw relay string instead of the friendly copy. Meanwhile no in-repo client reads the embedded number:hostedCommunityErrorMessagelooks upmessages[error.code]first, soerror.message(and the(N)) is never rendered when the code is known. As written this is wire-visible risk with no consumer; consider carrying the number as a structured field on the 409 body instead.desktop/src/features/communities/hostedCommunityApi.ts:66-HostedCommunityMutationResponse.max_communities_per_owneris declared and documented but never read —createHostedCommunity/transfer callers keep the load-timecommunityLimitand pass that into the error copy, so a limit changed since page load renders the stale number. Either wire it (careful:resolveHostedCommunityLimitfalls back to 5, so an absent field on a mutation response would clobber a valid 7 from the list call — fall back to the current state instead) or drop the field.desktop/src/features/settings/ui/HostedCommunitiesSettingsCard.tsx:117-loadAccounthere re-implementsloadHostedCommunityAccount(hostedCommunityApi.ts:125) — same two invokes, same error gating — so this change had to add theresolveHostedCommunityLimitcall in two places. Having the settings card consume the shared loader (which already returnscommunityLimit) removes the duplication and the drift risk for the next field added to the account payload.desktop/src/features/communities/hostedCommunityApi.ts:82- The newcommunityLimitparameter defaults toDEFAULT_HOSTED_COMMUNITY_LIMIT, so any call site that can surfacelimit_reachedand forgets the argument silently renders "the limit of 5" — reintroducing the exact hardcoded-default defect class Desktop hardcodes HOSTED_COMMUNITY_LIMIT = 5 while the relay enforces the env-configurable max_communities_per_owner() — UI drifts in both directions once BUZZ_MAX_COMMUNITIES_PER_OWNER is set #4160 is about, invisibly to review. Today's omissions are safe (availability/identity/archive paths can't returnlimit_reached), but making the parameter required turns future omissions into compile errors.🔧 Fix: harden hosted-community limit resolution and 409 wire contract
3 infos still open:
desktop/src/features/communities/ui/HostedCommunityCreateFlow.tsx:386- The newhostedCommunityLimitReachedMessagehelper (hostedCommunityLimit.ts:76) exists to own the limit-reached copy, but three UI surfaces still inline their own copy of the same sentence: HostedCommunityCreateFlow.tsx:386, HostedCommunityOnboarding.tsx:367, and HostedCommunitiesSettingsCard.tsx:608. They have already drifted from the helper — the inline versions use a typographic apostrophe (You’ve) while the helper uses a straight one (You've) — and they bypass the helper's no-number fallback entirely. Routing all three through the helper removes the drift; since every surface holdscommunityLimitas a seeded number, output is identical apart from the apostrophe glyph, so pick one glyph (the typographic one matches the current UI) and put it in the helper.desktop/src/features/communities/hostedCommunityLimit.ts:76-hostedCommunityLimitReachedMessage's doc says it "names a number only when the caller resolved one from a server response", andlimit_reached_copy_omits_the_number_when_unresolvedpins that. But no product path can reach the number-less branch: every surface seedscommunityLimitstate withDEFAULT_HOSTED_COMMUNITY_LIMIT(HostedCommunityCreateFlow.tsx:49, HostedCommunityOnboarding.tsx:88, HostedCommunitiesSettingsCard.tsx:73) and always passes it, and the only call sites that omit the argument (identity bind/unbind, availability) cannot returnlimit_reached. So on any deployment where Builderlab does not forward the field, users are still told "the limit of 5" — the same fabricated number Desktop hardcodes HOSTED_COMMUNITY_LIMIT = 5 while the relay enforces the env-configurable max_communities_per_owner() — UI drifts in both directions once BUZZ_MAX_COMMUNITIES_PER_OWNER is set #4160 is about. That is a deliberate and reasonable back-compat tradeoff, but the "never invent a number" guarantee is not end-to-end; it is a tested-but-unreached branch.desktop/src/features/communities/hostedCommunityApi.ts:166-loadHostedCommunityAccountresolves with the implicitDEFAULT_HOSTED_COMMUNITY_LIMITfallback, so a list response that omitsmax_communities_per_ownerresets the limit to 5. That contradicts the invariant documented one file over onHostedCommunityMutationResponse("an omitted field never clobbers a known one") and undoes the adopt-on-rejection logic: after a 409 reports 7 and the UI adopts it, the nextloadAccount()(fired on connect-identity, successful transfer, successful create, or sign-in) drops it back to 5 if the list payload omits the field. This only bites if Builderlab forwards the field on mutation/409 bodies but not on list — plausible given it reshapes each endpoint separately, though narrow in practice. If the sticky semantics are intended, thread the current value in (e.g.setCommunityLimit(prev => resolveHostedCommunityLimit(resp, prev))at the call sites); if list is meant to be authoritative, drop the "never clobbers" wording so the two paths do not read as contradictory.🔧 Fix: centralize limit-reached copy and keep adopted limit sticky
2 issues (1 warning, 1 info) still open:
desktop/src/features/settings/ui/HostedCommunitiesSettingsCard.tsx:288- The transfer path now threads the effective limit into a message that names the wrong party. The relay rejects a transfer withlimit_reached: transferee already owns the maximum number of communities(operator.rs:444), buthostedCommunityErrorMessagemaps codelimit_reachedto "You’ve reached the limit of N hosted communities." — so an owner giving away a community is told they are at their own limit, and the adjacent settings copy then advises "Transfer one to free up a slot," which is exactly what they just tried. The wrong-subject wording is pre-existing, but this change sharpens it: it now asserts the deployment's real number with confidence, and adopts that number intocommunityLimitstate from a rejection that was never about this user. Because Builderlab collapses both relay details onto the singlelimit_reachedcode, the client cannot tell create-rejection from transfer-rejection today — distinguishing them needs a separate error code (or a transfer-specific fallback string passed at this call site, e.g. "That person has reached their limit of hosted communities."). Product-copy decision, so flagging rather than changing.desktop/src/features/communities/ui/HostedCommunityOnboarding.tsx:319- The same three-part limit plumbing is now copy-pasted across all three hosted-community surfaces: theuseState(DEFAULT_HOSTED_COMMUNITY_LIMIT)seed, thesetCommunityLimit((previous) => account.communityLimit ?? previous)line inloadAccount, and theconst limit = resolveHostedCommunityLimit(response, communityLimit); setCommunityLimit(limit);adopt-on-rejection block — HostedCommunityOnboarding.tsx:90/105/319, HostedCommunityCreateFlow.tsx:51/66/249, HostedCommunitiesSettingsCard.tsx:75/90/288+370 (the settings card carries it twice). The prior rounds already centralized the copy and the account loader; this is the remaining duplicate, and it is the part that encodes the subtlenext ?? previousstickiness invariant, so a fourth surface is the likely place it gets written as a plainsetCommunityLimit(resolveHostedCommunityLimit(response))and silently loses stickiness. A smalluseHostedCommunityLimit()hook returning{ communityLimit, applyFromAccount, adoptFromResponse }collapses all four sites; it needs its own file sincehostedCommunityLimit.tsdeliberately stays value-import-free fornode:test.🔧 Fix: fix transfer limit copy subject and share limit hook
1 info still open:
desktop/src/features/communities/hostedCommunityApi.ts:9- The re-export block re-exports five symbols from./hostedCommunityLimit, but onlyhostedCommunityLimitReachedMessageis ever consumed through this module (the three UI surfaces).DEFAULT_HOSTED_COMMUNITY_LIMIT,readHostedCommunityLimit,resolveHostedCommunityLimit, andHostedCommunityLimitSubjectare imported directly from./hostedCommunityLimitat their only call sites (useHostedCommunityLimit.ts:8-9, andhostedCommunityApi.tsitself via the separate import on lines 3-7) — consumers passlimitSubject: "transferee"as a string literal and never name the type. Beyond dead surface, this cuts against the point of the round-3 hook: it publishesresolveHostedCommunityLimiton the module every hosted-community surface already imports, so a fourth surface can reach the raw resolver from@/features/communities/hostedCommunityApiand re-implement thenext ?? previousstickiness by hand instead of usinguseHostedCommunityLimit. Narrow the re-export tohostedCommunityLimitReachedMessage.crates/buzz-relay/src/api/operator.rs:1130- The two Postgres-backed operator tests added by this change (list_and_provision_responses_report_effective_owner_limit,limit_reached_rejections_report_effective_limit) could not actually execute here — no Docker daemon and no local Postgres/Redis. They are not skipped loudly:operator_test_statereturnsNoneon connection failure and each test returns early, socargo test -- --ignoredreports them as passing after ~30s of connection attempts without asserting anything. Their end-to-end handler coverage is only real on a gate that has Postgres. I compensated with a non-Postgres unit test that asserts and prints the same 409/200 bodies under varying env values.desktop/tests/e2e/hosted-community-limit-screenshots.spec.ts:50- The new Playwright spec flaked twice during early runs: the app rendered a blank page and the test timed out waiting foropen-settings(once on the 5th test, once on the 1st, in different runs). It never reproduced afterwards — 28 consecutive passes across repeat, cold-server and clean-rebuild runs — and the failures coincided with a cold Chromium install under load, so it reads as a first-load/boot race in this environment rather than a product issue. CI'sretries: 2covers it.desktop/tests/e2e/hosted-community-limit-screenshots.spec.ts- new test file written by agent: desktop/tests/e2e/hosted-community-limit-screenshots.spec.tsnode --import ./test-loader.mjs --experimental-strip-types --test "src/features/communities/hostedCommunityLimit.test.mjs"— 22 resolver/copy tests added by the changepnpm test(desktop unit suite, 4217 tests) after modifying the shared E2E bridgecargo test -q -p buzz-relay --lib community_provisioningandcargo test -q -p buzz-db --lib relay_membersBUZZ_MAX_COMMUNITIES_PER_OWNER={unset,7,3,0,abc} cargo test -q -p buzz-relay --lib api::operator::tests::limit_reached_conflict_body_reports_the_deployment_limit -- --nocapture— new test printing the real 409 + 200 bodies per env valuepnpm build:e2e && pnpm exec playwright test --project=smoke tests/e2e/hosted-community-limit-screenshots.spec.ts— new 6-capture spec covering limit above/below default, no-limit fallback, create-409 adoption, gated create dialog, transfer-409 recipient copypnpm exec playwright test --project=smoke tests/e2e/hosted-communities-settings-screenshots.spec.ts tests/e2e/add-community-screenshots.spec.ts— regression check on existing builderlab specs after the bridge changepnpm exec playwright test --project=integration tests/e2e/onboarding.spec.ts— 61 tests, covers the hosted-community onboarding surfacescargo test -q -p buzz-relay --lib -- --ignored api::operator::tests::list_and_provision_responses_report_effective_owner_limit api::operator::tests::limit_reached_rejections_report_effective_limit— attempted; self-skipped with no Postgresshasum -a 256over the captured PNGs to confirm every screenshot shows a distinct stateAGENTS.md:20- The desktop only sees the relay'smax_communities_per_ownerif Builderlab (app.builderlab.xyz, an external service outside this repo) forwards the field through/v1/buzz/communities/*. That requirement is recorded only in a desktop code comment (desktop/src/features/communities/hostedCommunityLimit.ts:14-19); no repo-level doc states it, and AGENTS.md's Ecosystem table does not list Builderlab at all, so a Builderlab maintainer has no documented cue that the fix depends on their hop. Where to record this (AGENTS.md ecosystem table, a docs/ page, or an issue against the Builderlab repo) is a human call — I have no link or ownership info for that service, and until it forwards the field every desktop resolution silently falls back to 5.✅ **Lint** - passed
✅ No issues found.
✅ **Push** - passed
✅ No issues found.