Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions plans/plans.go
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,27 @@ type Limits struct {
// VectorConnections is the maximum concurrent connections per pgvector
// database. Mirrors PostgresConnections.
VectorConnections int `yaml:"vector_connections"`

// CustomDomainsMax is the maximum number of custom domains a team may
// bind across all their stacks. -1 means unlimited; 0 means the feature
// is not available on this tier (paired with Features.CustomDomains=false).
//
// Introduced 2026-05-14 (FIX-G) to close the per-count gap: previously
// the only gate on /api/v1/stacks/:slug/domains was the boolean
// Features.CustomDomains flag, which let any Hobby Plus+ team add an
// unbounded number of hostnames. The cap is enforced in
// api/internal/handlers/custom_domain.go before the create-row write.
// Tier ladder (mirrors plans.yaml):
//
// anonymous / free / hobby = 0 (feature off — boolean gate trips first)
// hobby_plus = 1 (first tier with the feature)
// growth = 3
// pro = 5
// team = 50 (effectively unlimited for dashboards)
//
// Keeping it in Limits (not Features) lets ops change the cap per tier
// in plans.yaml without redeploying the handler.
CustomDomainsMax int `yaml:"custom_domains_max"`
}

// Features describes the boolean capabilities unlocked by a plan tier.
Expand Down Expand Up @@ -368,6 +389,26 @@ func (r *Registry) CustomDomainsAllowed(tier string) bool {
return r.Get(tier).Features.CustomDomains
}

// CustomDomainsMaxLimit returns the maximum number of custom domains a team
// on the given tier may bind across their stacks. -1 means unlimited; 0
// means the feature is not enabled (CustomDomainsAllowed will also be false
// for that tier — the boolean gate trips first in the handler).
//
// Introduced alongside the Limits.CustomDomainsMax field (FIX-G). Callers
// should pair this with the boolean check:
//
// if !r.CustomDomainsAllowed(tier) { return 402 upgrade_required }
// if max := r.CustomDomainsMaxLimit(tier); max >= 0 && count >= max {
// return 402 limit_reached
// }
func (r *Registry) CustomDomainsMaxLimit(tier string) int {
p := r.Get(tier)
if p == nil {
return 0
}
return p.Limits.CustomDomainsMax
}

// VaultMaxEntries returns the per-team vault entry cap for the given tier.
// -1 means unlimited; 0 means vault is not available on this tier.
func (r *Registry) VaultMaxEntries(tier string) int {
Expand Down Expand Up @@ -472,6 +513,7 @@ plans:
backup_retention_days: 0
backup_restore_enabled: false
manual_backups_per_day: 0
custom_domains_max: 0
features:
alerts: false
custom_domains: false
Expand Down Expand Up @@ -505,6 +547,7 @@ plans:
backup_retention_days: 0
backup_restore_enabled: false
manual_backups_per_day: 0
custom_domains_max: 0
features:
alerts: false
custom_domains: false
Expand Down Expand Up @@ -533,6 +576,7 @@ plans:
backup_retention_days: 7
backup_restore_enabled: false
manual_backups_per_day: 1
custom_domains_max: 0
features:
alerts: true
custom_domains: false
Expand Down Expand Up @@ -569,6 +613,7 @@ plans:
backup_retention_days: 14
backup_restore_enabled: true
manual_backups_per_day: 5
custom_domains_max: 1
features:
alerts: true
custom_domains: true
Expand Down Expand Up @@ -604,6 +649,7 @@ plans:
backup_retention_days: 14
backup_restore_enabled: true
manual_backups_per_day: 5
custom_domains_max: 1
features:
alerts: true
custom_domains: true
Expand Down Expand Up @@ -643,6 +689,7 @@ plans:
backup_retention_days: 7
backup_restore_enabled: false
manual_backups_per_day: 1
custom_domains_max: 0
features:
alerts: true
custom_domains: false
Expand Down Expand Up @@ -671,6 +718,7 @@ plans:
backup_retention_days: 30
backup_restore_enabled: true
manual_backups_per_day: 100
custom_domains_max: 5
features:
alerts: true
custom_domains: true
Expand Down Expand Up @@ -701,6 +749,7 @@ plans:
backup_retention_days: 30
backup_restore_enabled: true
manual_backups_per_day: 100
custom_domains_max: 5
features:
alerts: true
custom_domains: true
Expand Down Expand Up @@ -729,6 +778,7 @@ plans:
backup_retention_days: 90
backup_restore_enabled: true
manual_backups_per_day: 1000
custom_domains_max: 50
features:
alerts: true
custom_domains: true
Expand Down Expand Up @@ -759,6 +809,7 @@ plans:
backup_retention_days: 90
backup_restore_enabled: true
manual_backups_per_day: 1000
custom_domains_max: 50
features:
alerts: true
custom_domains: true
Expand Down Expand Up @@ -787,6 +838,7 @@ plans:
backup_retention_days: 30
backup_restore_enabled: true
manual_backups_per_day: 100
custom_domains_max: 3
features:
alerts: true
custom_domains: true
Expand Down
50 changes: 50 additions & 0 deletions plans/plans_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -472,6 +472,56 @@ func TestHobbyPlus_TierMatrix(t *testing.T) {
assert.False(t, p.Features.Dedicated)
}

// TestCustomDomainsMaxLimit — FIX-G (2026-05-14) locks the per-tier
// custom-domain cap so the limit can't silently drift. The cap is paired
// with the boolean Features.CustomDomains gate: tiers where the boolean
// is false MUST also have CustomDomainsMax == 0 (the handler trips the
// boolean first, so a non-zero number on a false-feature tier would be
// dead code at best and a confusing API contract at worst).
func TestCustomDomainsMaxLimit(t *testing.T) {
r := plans.Default()
cases := []struct {
tier string
want int
reason string
}{
{"anonymous", 0, "anonymous has no custom-domain feature"},
{"free", 0, "free mirrors anonymous"},
{"hobby", 0, "hobby is below the custom-domain unlock"},
{"hobby_yearly", 0, "hobby_yearly mirrors hobby"},
{"hobby_plus", 1, "hobby_plus is the first tier with custom domains — single hostname"},
{"hobby_plus_yearly", 1, "hobby_plus_yearly mirrors hobby_plus"},
{"growth", 3, "growth allows 3 hostnames — sits between hobby_plus and pro"},
{"pro", 5, "pro allows 5 hostnames"},
{"pro_yearly", 5, "pro_yearly mirrors pro"},
{"team", 50, "team allows 50 hostnames (effectively unlimited for dashboards)"},
{"team_yearly", 50, "team_yearly mirrors team"},
}
for _, c := range cases {
assert.Equal(t, c.want, r.CustomDomainsMaxLimit(c.tier),
"CustomDomainsMaxLimit(%q) — %s", c.tier, c.reason)
}
}

// TestCustomDomainsMax_PairedWithBooleanFlag guards the invariant that
// any tier with custom_domains_max > 0 must also have features.custom_domains:true,
// and any tier with custom_domains_max == 0 must have features.custom_domains:false.
// Drift between the two is a code smell — the handler trips the boolean
// first, so an inconsistent pair means either a dead cap or an unreachable
// allowance.
func TestCustomDomainsMax_PairedWithBooleanFlag(t *testing.T) {
r := plans.Default()
for name, p := range r.All() {
switch {
case p.Features.CustomDomains && p.Limits.CustomDomainsMax == 0:
t.Errorf("tier %q has features.custom_domains=true but custom_domains_max=0 — feature is unreachable", name)
case !p.Features.CustomDomains && p.Limits.CustomDomainsMax > 0:
t.Errorf("tier %q has features.custom_domains=false but custom_domains_max=%d — cap is unreachable (boolean gate trips first)",
name, p.Limits.CustomDomainsMax)
}
}
}

// writeTempYAML writes content to a temp file and returns its path.
func writeTempYAML(t *testing.T, content string) string {
t.Helper()
Expand Down