feat(pricing): Community listing requires the Pro entitlement (#768) - #874
Merged
Merged
Conversation
Listing a pod to Community is a paid capability. Gated on `entitlements.pro` rather than on payment state directly, so the check is identical however billing is wired later — today an admin sets the flag, the same shape as the invite-code path that already gates `cloudAgents`. Reverses the permission #872 shipped an hour ago, deliberately and at Sam's call: that PR moved listing from admin-only to any-owner, which was the right fix for the inert creation choice and the wrong permanent home for a paid capability. The route, the invariants and the audit trail are unchanged; only who may promote moves. DEMOTION IS NEVER GATED, and that asymmetry is the point. A user whose subscription lapses must always be able to make their own room private again. Paywalling the exit would turn a billing event into an unwanted permanent public disclosure — the pod stays world-readable until they pay. Free users keep `private` on demand, forever. Admins bypass, matching every other entitlement check. A user with no entitlements object at all reads as free rather than throwing (pinned). Explicitly NOT gated: BYO agents. "Agents you bring connect free and unlimited" is the standing promise on the pricing page, and a per-agent cap is precisely the model this pricing exists to avoid. The paid tier adds hosted seats and unlimited history; it never subtracts what free already had. 19/19 on the route, including that a free owner is refused without any write landing, that a missing entitlements object is treated as free, and that a free owner can still always demote.
The pricing section described a metered-CI-minutes model we are not building
and did not mention the two limits that now exist in code. Rewritten in both
locales to match the tier as decided:
Cloud free — unlimited BYO agents, private/invited pods, 30 DAYS of
message history (pgRetentionService, live), no card
Pro $12 — everything free has, unlimited history, Community listing
(gated on entitlements.pro, #874), 10 hosted agent seats
marked "coming soon, included when it lands", priority support
Three deliberate choices.
**"Flat. However many agents you run — we never charge per agent."** The
nearest competitor charges per seat AND meters each agent at 0.1 seat, so a
user with three agents pays more there than here. Leading with the sticker
price invites the wrong comparison; leading with FLAT states the actual
difference, and it is the same difference the headline "Humans are seats.
Agents never are" already claims.
**Hosted seats listed but marked coming soon.** They are in the plan the
buyer is choosing, and the gate lifts later — saying so is honest and keeps
the tier legible. Saying nothing would make Pro read as "history plus a
listing toggle."
**30-day history is now stated on the free card.** It has been enforced by
the retention cron for months and appeared nowhere in the product. A limit
users discover by losing data is the worst way to learn it (see #862, where
it silently emptied our own landing-page showroom).
Dropped the "metered agent-hours pool" item: it described billing that does
not exist, and pricing copy that promises a mechanism nobody built is a
phantom contract on the most consequential page.
en/zh-CN key parity asserted; landing suites green; typecheck clean.
Completes the tier plumbing and fixes a live layout bug found while checking
the pricing pages.
**Tier data structure.** `entitlements.pro` lands beside the existing
`cloudAgents` flag — the model comment already said future entitlements slot
in there. `/api/auth/user` returns the whole doc, so the client learns its
tier with no new endpoint.
**Granting it.** `PATCH /api/admin/users/:userId/entitlements` already
existed but hard-coded `cloudAgents`; it now takes either key or both. Until
billing exists, this route IS the subscription.
The property worth the tests: a partial patch never clears the key it did not
name. Revoking a lapsed subscription must not silently switch off someone's
hosted agents, and granting Pro must not wipe `cloudAgents`. Both directions
pinned, plus non-boolean rejection, bots refused, and a pre-existing account
with no `entitlements` object at all.
**/compare rendered as a ~110px ribbon**, live. `.v2-compare__head` carried
both `.v2-landing__section` — whose padding is
`max(24px, calc((100% - 1120px) / 2))` — and its own `max-width: 920px`.
Percentage padding resolves against the PARENT, so on a wide viewport that is
200-400px per side; capping the border box at 920px left a sliver for text.
The landing page never hit it because its sections do not set max-width on
the element that carries the padding.
Fixed by constraining the CHILDREN instead, and guarded in
v2-layout-invariants — the rule is: never put max-width on the element that
carries the centring padding.
Test-mock note: this route uses CJS `require('express-rate-limit')` while
routes/pods.ts uses the ESM default import, so the limiter mock has to be
callable and carry `.default`. Cost a failing suite; documented inline.
11 new admin-route tests, 23 layout invariants, typecheck clean both sides.
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.
Listing a pod to Community becomes a paid capability, gated on
entitlements.pro.Deliberately reverses the permission #872 shipped an hour ago, at Sam's call. That PR moved listing from admin-only to any-owner — the right fix for the inert creation choice, the wrong permanent home for a paid capability. The route, its invariants and its audit trail are unchanged; only who may promote moves.
Design
Gated on the entitlement, not on payment state. The check is identical however billing is wired later — today an admin sets the flag, the same shape as the invite-code path that already gates
cloudAgents.entitlementswas already a nested object with a comment saying future entitlements slot in beside it; this is that.Demotion is never gated, and the asymmetry is the point. A user whose subscription lapses must always be able to make their own room private again. Paywalling the exit turns a billing event into an unwanted permanent public disclosure — the pod would stay world-readable until they pay. Free users keep
privateon demand, forever.402, not 403, with a message that says the pod stays private and existing content is unaffected. The failure mode to avoid is a user thinking they lost something.
Admins bypass, matching every other entitlement check. A user with no
entitlementsobject at all reads as free rather than throwing — pinned by a test, since that is the shape every pre-existing account has until it is migrated.Explicitly not gated: BYO agents
"Agents you bring connect free and unlimited" is the standing promise on the live pricing page, and a per-agent cap is precisely the model this pricing exists to avoid. The paid tier adds hosted seats and unlimited history; it never subtracts what free already had. Any future gate that caps BYO agent count contradicts the product's own landing copy and should be rejected on sight.
Verification
19/19 on the route. New cases: a free owner is refused with no write landing (both flags and
saveasserted untouched), a missingentitlementsobject is treated as free, a free owner can still always demote, a Pro owner promotes normally, an admin withoutprostill bypasses.One test-hygiene fix included:
jest.clearAllMocks()resets calls but keeps implementations, so a free-user mock was leaking into the following test. Made explicit rather than order-dependent.Not in this PR
The rest of the tier — 30-day retention notice at login, the 7-day warning, the what's-included announcement, hosted agent seats, and any billing integration. Those need the pricing decisions settled first; this is only the gate on the one capability that was about to ship ungated.