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
22 changes: 22 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,17 @@ type Config struct {
// Off → /deploy/new rejects source=git with 501; tarball/image unaffected.
DeploySourceGitEnabled bool

// ResourceCountCapsEnabled gates per-service resource-count enforcement
// (Task #55). Default FALSE: when off, the count-check block in every
// provision handler (db/vector/cache/nosql/storage) is skipped entirely —
// zero behavior change, so shipping the caps cannot surprise-break an
// existing heavy tenant with a 402. When on, each handler counts the team's
// active resources of that type and rejects over-cap provisions with 402 +
// agent_action, mirroring the always-on queue_count cap. Enabling it is an
// operator action (kubectl set env RESOURCE_COUNT_CAPS_ENABLED=true) after a
// usage audit so no current tenant is over the new per-tier caps.
ResourceCountCapsEnabled bool

// GitHub App (P4) — install-once push-to-deploy + short-lived installation
// tokens for private-repo clones. Distinct from the GitHub OAuth *login* app
// above (GitHubClientID/Secret). GitHubAppEnabled gates the whole feature:
Expand Down Expand Up @@ -501,6 +512,17 @@ func Load() *Config {
cfg.DeploySourceGitEnabled = false
}

// RESOURCE_COUNT_CAPS_ENABLED: default FALSE (Task #55). Off → the per-service
// count-check block in every provision handler is skipped (zero behavior
// change). On → over-cap provisions get 402. Operator action after a usage
// audit so no current tenant is retroactively over a new per-tier cap.
switch strings.ToLower(strings.TrimSpace(os.Getenv("RESOURCE_COUNT_CAPS_ENABLED"))) {
case "true", "1", "yes":
cfg.ResourceCountCapsEnabled = true
default:
cfg.ResourceCountCapsEnabled = false
}

// GITHUB_APP_ENABLED: default FALSE (off until the operator registers the
// App and provisions GITHUB_APP_* secrets — see infra/GITHUB-APP-RUNBOOK.md).
switch strings.ToLower(strings.TrimSpace(os.Getenv("GITHUB_APP_ENABLED"))) {
Expand Down
16 changes: 16 additions & 0 deletions internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ func allKeys() []string {
"METRICS_TOKEN", "DASHBOARD_BASE_URL", "API_PUBLIC_URL",
"DELETION_CONFIRMATION_TTL_MINUTES", "FAMILY_BINDINGS_ENABLED",
"DEPLOY_SOURCE_IMAGE_ENABLED", "DEPLOY_SOURCE_GIT_ENABLED",
"RESOURCE_COUNT_CAPS_ENABLED",
"GITHUB_APP_ENABLED", "GITHUB_APP_ID", "GITHUB_APP_SLUG", "GITHUB_APP_PRIVATE_KEY",
"GITHUB_APP_WEBHOOK_SECRET", "GITHUB_APP_CLIENT_ID", "GITHUB_APP_CLIENT_SECRET",
"BREVO_WEBHOOK_SECRET", "SES_SNS_SUBSCRIPTION_ARN",
Expand Down Expand Up @@ -387,6 +388,21 @@ func TestLoad_DeploySourceGitEnabled(t *testing.T) {
}
}

func TestLoad_ResourceCountCapsEnabled(t *testing.T) {
for _, val := range []string{"true", "1", "yes", "TRUE", " Yes "} {
applyBaselineEnv(t, map[string]string{"RESOURCE_COUNT_CAPS_ENABLED": val})
if !Load().ResourceCountCapsEnabled {
t.Errorf("RESOURCE_COUNT_CAPS_ENABLED=%q should enable", val)
}
}
for _, val := range []string{"false", "0", "no", "maybe", ""} {
applyBaselineEnv(t, map[string]string{"RESOURCE_COUNT_CAPS_ENABLED": val})
if Load().ResourceCountCapsEnabled {
t.Errorf("RESOURCE_COUNT_CAPS_ENABLED=%q should stay disabled (default OFF)", val)
}
}
}

func TestLoad_GitHubAppEnabled(t *testing.T) {
// When enabling the App, Load() fails closed unless the webhook secret +
// private key + app id are present (review HIGH-1), so set them here.
Expand Down
13 changes: 13 additions & 0 deletions internal/handlers/billing_usage.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,13 @@ type usageMetric struct {
LimitBytes int64 `json:"limit_bytes,omitempty"`
Count int `json:"count,omitempty"`
Limit int `json:"limit,omitempty"`
// CountLimit is the per-tier resource-COUNT cap (Task #55) for the
// byte-metered storage services (postgres/redis/mongodb), where `Limit`
// already carries no value (those use LimitBytes). It lets the dashboard
// show "3 / 5 databases" alongside "120 MB / 1024 MB". For the
// count-metered services (deployments/webhooks/vault/members) the existing
// Count/Limit pair is unchanged. -1 means unlimited.
CountLimit int `json:"count_limit,omitempty"`
}

// GetUsage handles GET /api/v1/billing/usage.
Expand Down Expand Up @@ -152,9 +159,15 @@ func (h *BillingUsageHandler) computeUsage(ctx context.Context, teamID uuid.UUID
return usageSummary{}, sumErr
}
limitMB := h.plans.StorageLimitMB(tier, svc)
// Task #55: also surface the active-resource COUNT + per-tier count cap
// so the dashboard can render "3 / 5 databases" next to the byte gauge.
// Best-effort: a count error must not fail the byte rows.
count, _ := models.CountActiveResourcesByTeamAndType(ctx, h.db, teamID, svc)
usage[svc] = usageMetric{
Bytes: bytes,
LimitBytes: mbToBytes(limitMB),
Count: count,
CountLimit: h.plans.ResourceCountLimit(tier, svc),
}
}

Expand Down
6 changes: 6 additions & 0 deletions internal/handlers/cache.go
Original file line number Diff line number Diff line change
Expand Up @@ -333,6 +333,12 @@ func (h *CacheHandler) newCacheAuthenticated(
tier = "growth"
}

// Task #55: per-tier redis count cap (flag-gated, default OFF). Redis is the
// binding COGS constraint ($6.50/GB) so this is the most-conservative cap.
if handled, capErr := h.enforceResourceCountCap(c, teamUUID, team.PlanTier, models.ResourceTypeRedis, requestID); handled {
return capErr
}

parentRootID, perr := resolveFamilyParent(c, h.db, parentResourceID, teamUUID, models.ResourceTypeRedis, env)
if perr != nil {
return perr
Expand Down
52 changes: 37 additions & 15 deletions internal/handlers/capabilities.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,36 +32,43 @@ func NewCapabilitiesHandler(p *plans.Registry) *CapabilitiesHandler {
}

type tierCapabilities struct {
Tier string `json:"tier"`
DisplayName string `json:"display_name"`
PriceUSDMonthly int `json:"price_usd_monthly"`
PaidFromDayOne bool `json:"paid_from_day_one"`
StorageLimitMB map[string]int `json:"storage_limit_mb"`
ConnectionsLimit map[string]int `json:"connections_limit"`
Deployments int `json:"deployments_apps"`
BackupRetentionDays int `json:"backup_retention_days"`
BackupRestoreEnabled bool `json:"backup_restore_enabled"`
ManualBackupsPerDay int `json:"manual_backups_per_day"`
Tier string `json:"tier"`
DisplayName string `json:"display_name"`
PriceUSDMonthly int `json:"price_usd_monthly"`
PaidFromDayOne bool `json:"paid_from_day_one"`
StorageLimitMB map[string]int `json:"storage_limit_mb"`
ConnectionsLimit map[string]int `json:"connections_limit"`
// ResourceCountLimit is the per-service max number of active resources a
// team may hold (Task #55). Keyed by the same service strings as
// StorageLimitMB. -1 means unlimited; a positive value is the hard cap.
// Enforcement is flag-gated (RESOURCE_COUNT_CAPS_ENABLED) — this surface
// always advertises the cap so an agent can plan around it even while the
// operator hasn't yet flipped enforcement on.
ResourceCountLimit map[string]int `json:"resource_count_limit"`
Deployments int `json:"deployments_apps"`
BackupRetentionDays int `json:"backup_retention_days"`
BackupRestoreEnabled bool `json:"backup_restore_enabled"`
ManualBackupsPerDay int `json:"manual_backups_per_day"`
// RPOMinutes / RTOMinutes — FIX-H #Q50 (B36). 0 means
// "not promised" (no scheduled backups / no self-serve restore on
// the tier). Lets an agent reason about durability requirements
// per-tier without a second round-trip.
RPOMinutes int `json:"rpo_minutes"`
RTOMinutes int `json:"rto_minutes"`
AnnualDiscountPercent int `json:"annual_discount_percent"`
RPOMinutes int `json:"rpo_minutes"`
RTOMinutes int `json:"rto_minutes"`
AnnualDiscountPercent int `json:"annual_discount_percent"`
// UpgradeURL — pointer so the terminal tier (Team — there is nothing
// to upgrade to) emits an explicit JSON `null` instead of the pricing
// URL. DOG-26 (QA 2026-05-29): every tier including Team used to
// return the pricing page, which would have SDKs / dashboards
// rendering an "Upgrade" CTA on the Team plan with no destination.
// `null` is the contract-stable terminal-tier marker; a non-null
// string is the "click here to upgrade" signal.
UpgradeURL *string `json:"upgrade_url"`
UpgradeURL *string `json:"upgrade_url"`
// IsTerminalTier — explicit boolean so clients don't have to encode
// the "is upgrade_url null" check at every render site. True for the
// top tier (Team today), false for everything below. Pairs with
// UpgradeURL — when IsTerminalTier=true, UpgradeURL is null.
IsTerminalTier bool `json:"is_terminal_tier"`
IsTerminalTier bool `json:"is_terminal_tier"`
}

// capabilityResourceTypes is the list of service types the /capabilities
Expand All @@ -71,6 +78,13 @@ var capabilityResourceTypes = []string{
"postgres", "redis", "mongodb", "queue", "storage", "webhook", "vector",
}

// countCapResourceTypes is the set of services that carry a per-tier
// resource-COUNT cap (Task #55). Webhook is omitted — it is byte/request-capped
// via webhook_requests_stored, not count-capped. Order is contract-stable.
var countCapResourceTypes = []string{
"postgres", "vector", "redis", "mongodb", "storage", "queue",
}

// upgradeURL is the marketing pricing page that every tier row in the
// /capabilities response points back to. Hoisted to a package const so
// the URL fragment isn't scattered as a string literal across the handler.
Expand Down Expand Up @@ -158,10 +172,17 @@ func (h *CapabilitiesHandler) Get(c *fiber.Ctx) error {
for _, e := range entries {
storage := map[string]int{}
conns := map[string]int{}
counts := map[string]int{}
for _, rt := range capabilityResourceTypes {
storage[rt] = h.plans.StorageLimitMB(e.name, rt)
conns[rt] = h.plans.ConnectionsLimit(e.name, rt)
}
// Task #55: per-service resource-count caps. Only the count-capped
// services appear (webhook is byte-capped via webhook_requests_stored,
// not count-capped). ResourceCountLimit returns -1 for unlimited.
for _, rt := range countCapResourceTypes {
counts[rt] = h.plans.ResourceCountLimit(e.name, rt)
}
priceUSD := e.plan.PriceMonthly / 100 // cents → dollars
// DOG-26: terminal tier marker — top of the rank ladder has
// nothing to upgrade to. upgrade_url is null + is_terminal_tier
Expand All @@ -179,6 +200,7 @@ func (h *CapabilitiesHandler) Get(c *fiber.Ctx) error {
PaidFromDayOne: priceUSD > 0,
StorageLimitMB: storage,
ConnectionsLimit: conns,
ResourceCountLimit: counts,
Deployments: h.plans.DeploymentsAppsLimit(e.name),
BackupRetentionDays: h.plans.BackupRetentionDays(e.name),
BackupRestoreEnabled: h.plans.BackupRestoreEnabled(e.name),
Expand Down
39 changes: 39 additions & 0 deletions internal/handlers/capabilities_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ type capabilityTier struct {
PaidFromDayOne bool `json:"paid_from_day_one"`
StorageLimitMB map[string]int `json:"storage_limit_mb"`
ConnectionsLimit map[string]int `json:"connections_limit"`
ResourceCountLimit map[string]int `json:"resource_count_limit"`
Deployments int `json:"deployments_apps"`
BackupRetentionDays int `json:"backup_retention_days"`
BackupRestoreEnabled bool `json:"backup_restore_enabled"`
Expand Down Expand Up @@ -214,6 +215,44 @@ func TestCapabilities_LimitsResolveFromRegistry(t *testing.T) {
assert.Equal(t, 5, hp.ManualBackupsPerDay, "hobby_plus manual backups/day")
}

// TestCapabilities_SurfacesResourceCountLimit is the Task #55 rule-18 surface
// guard: GET /api/v1/capabilities must expose resource_count_limit for EVERY
// count-capped service on every paid tier, with the value matching the live
// registry. Iterates the registry rather than hand-typing tiers so a new tier or
// service can't silently ship without the cap appearing on the public matrix.
func TestCapabilities_SurfacesResourceCountLimit(t *testing.T) {
reg := plans.Default()
app := newCapabilitiesApp(t, reg)
_, body := callCapabilities(t, app)
require.NotEmpty(t, body.Tiers)

countServices := []string{"postgres", "vector", "redis", "mongodb", "storage", "queue"}
for _, tier := range body.Tiers {
require.NotNil(t, tier.ResourceCountLimit,
"tier %q must carry resource_count_limit", tier.Tier)
for _, svc := range countServices {
got, ok := tier.ResourceCountLimit[svc]
require.True(t, ok, "tier %q resource_count_limit must include %q", tier.Tier, svc)
assert.Equal(t, reg.ResourceCountLimit(tier.Tier, svc), got,
"tier %q %s count limit must match the registry", tier.Tier, svc)
}
// Webhook is request-capped, not count-capped — must NOT appear.
_, hasWebhook := tier.ResourceCountLimit["webhook"]
assert.False(t, hasWebhook, "webhook must not appear in resource_count_limit (it is request-capped)")
}

// Spot-pin a couple of binding values so a loosened cap is a visible diff.
for _, tier := range body.Tiers {
switch tier.Tier {
case "pro":
assert.Equal(t, 3, tier.ResourceCountLimit["redis"], "pro redis_count")
assert.Equal(t, 5, tier.ResourceCountLimit["postgres"], "pro postgres_count")
case "team":
assert.Equal(t, 4, tier.ResourceCountLimit["redis"], "team redis_count")
}
}
}

// TestCapabilities_PlansUnavailable — when the registry pointer is nil
// (boot-time failure in dev with no fallback), the handler must return
// 503 instead of panicking. Lifted contract from the original handler.
Expand Down
6 changes: 6 additions & 0 deletions internal/handlers/db.go
Original file line number Diff line number Diff line change
Expand Up @@ -381,6 +381,12 @@ func (h *DBHandler) newDBAuthenticated(
tier = "growth"
}

// Task #55: per-tier postgres count cap (flag-gated, default OFF — inert
// unless RESOURCE_COUNT_CAPS_ENABLED). Mirrors queue.go's A6 block.
if handled, capErr := h.enforceResourceCountCap(c, teamUUID, team.PlanTier, models.ResourceTypePostgres, requestID); handled {
return capErr
}

// Family-link validation runs BEFORE provisioning so a cross-team /
// cross-type / duplicate-twin parent_resource_id never causes us to
// create-then-fail (which would leak a database we can't link).
Expand Down
5 changes: 5 additions & 0 deletions internal/handlers/nosql.go
Original file line number Diff line number Diff line change
Expand Up @@ -326,6 +326,11 @@ func (h *NoSQLHandler) newNoSQLAuthenticated(
tier = "growth"
}

// Task #55: per-tier mongodb count cap (flag-gated, default OFF). Mirrors queue.go.
if handled, capErr := h.enforceResourceCountCap(c, teamUUID, team.PlanTier, models.ResourceTypeMongoDB, requestID); handled {
return capErr
}

parentRootID, perr := resolveFamilyParent(c, h.db, parentResourceID, teamUUID, models.ResourceTypeMongoDB, env)
if perr != nil {
return perr
Expand Down
6 changes: 4 additions & 2 deletions internal/handlers/openapi.go
Original file line number Diff line number Diff line change
Expand Up @@ -3277,8 +3277,9 @@ const openAPISpec = `{
"properties": {
"bytes": { "type": "integer", "format": "int64", "description": "Current storage usage in bytes. Present on postgres/redis/mongodb." },
"limit_bytes": { "type": "integer", "format": "int64", "description": "Storage cap in bytes (plans.yaml storage_mb × 1024 × 1024). -1 = unlimited." },
"count": { "type": "integer", "description": "Current count. Present on deployments/webhooks/vault/members." },
"limit": { "type": "integer", "description": "Count cap from plans.yaml. -1 = unlimited." }
"count": { "type": "integer", "description": "Current count. Present on deployments/webhooks/vault/members, and (Task #55) on postgres/redis/mongodb as the active-resource count alongside bytes." },
"limit": { "type": "integer", "description": "Count cap from plans.yaml. -1 = unlimited." },
"count_limit": { "type": "integer", "description": "Task #55: per-tier resource-COUNT cap for the byte-metered storage services (postgres/redis/mongodb), where the limit field is unused. -1 = unlimited. Enforcement is flag-gated (RESOURCE_COUNT_CAPS_ENABLED) but the cap is always advertised." }
}
},
"TeamSummaryResponse": {
Expand Down Expand Up @@ -3368,6 +3369,7 @@ const openAPISpec = `{
"paid_from_day_one": { "type": "boolean", "description": "True iff price_usd_monthly > 0. Mirrors project policy: no trial — paid tiers are paid from signup." },
"storage_limit_mb": { "type": "object", "additionalProperties": { "type": "integer" }, "description": "Per-service storage cap in MB. Keys: postgres, redis, mongodb, queue, storage, webhook, vector. -1 sentinel means 'unlimited'." },
"connections_limit": { "type": "object", "additionalProperties": { "type": "integer" }, "description": "Per-service concurrent-connection cap. Keys mirror storage_limit_mb. -1 = unlimited." },
"resource_count_limit": { "type": "object", "additionalProperties": { "type": "integer" }, "description": "Task #55: per-service max number of active resources a team may hold. Keys: postgres, vector, redis, mongodb, storage, queue (webhook is request-capped, not count-capped). -1 = unlimited. Enforcement is flag-gated (RESOURCE_COUNT_CAPS_ENABLED) but the cap is always advertised so an agent can plan around it." },
"deployments_apps": { "type": "integer", "description": "Max number of /deploy/new apps allowed. -1 = unlimited." },
"backup_retention_days": { "type": "integer" },
"backup_restore_enabled": { "type": "boolean" },
Expand Down
Loading
Loading