From 582e9a24e39e7894d7302473e3d7c3a9f9ae9a03 Mon Sep 17 00:00:00 2001 From: Manas Srivastava Date: Sat, 30 May 2026 10:47:59 +0530 Subject: [PATCH] =?UTF-8?q?fix(api):=20/capabilities=20terminal-tier=20mar?= =?UTF-8?q?ker=20=E2=80=94=20null=20upgrade=5Furl=20+=20is=5Fterminal=5Fti?= =?UTF-8?q?er=20(DOG-26)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Today every tier including Team returns upgrade_url="https://instanode.dev/pricing/". Team is the top of the rank ladder — there is nothing to upgrade to. SDKs/dashboards rendering an Upgrade CTA on the Team plan show a button that loops back to the pricing page with no action. Now: top-rank tier emits upgrade_url=null + is_terminal_tier=true. Every other tier keeps the pricing URL + is_terminal_tier=false. Clients can suppress the Upgrade CTA via the boolean (or "URL is null" check) without string-matching "team" — adding an enterprise tier above team tomorrow Just Works. Done via rank-iterating the live registry (capabilities.go computes terminalRank as entries[len-1].rank after the rank sort), so the contract is anchored to plans.Rank, not to a hardcoded tier name. OpenAPI: upgrade_url is now ["string", "null"]; is_terminal_tier is a required boolean. Both schema changes are non-breaking — existing clients that read upgrade_url as a string get null for Team only. Coverage block: Symptom: upgrade_url for terminal tier (Team) loops to pricing Enumeration: grep upgrade_url + UpgradeURL in api/internal/handlers/ for capabilities-specific sites (1 emit + 1 test + 1 openapi) Sites found: 3 Sites touched: 3 (capabilities.go emit, capabilities_test.go assert, openapi.go schema + required list) Coverage test: TestCapabilities_TerminalTierUpgradeURLIsNull — registry-iterating (rule 18): asserts entries[len-1] has IsTerminalTier=true + UpgradeURL=nil, and every other tier is the inverse. Adding a new top tier automatically shifts the assertion target. Live verified: pending merge + auto-deploy + curl /api/v1/capabilities | jq '.tiers[] | {tier, upgrade_url, is_terminal_tier}' Co-Authored-By: Claude Opus 4.7 (1M context) --- internal/handlers/capabilities.go | 40 +++++++++++++++++++-- internal/handlers/capabilities_test.go | 50 ++++++++++++++++++++++++-- internal/handlers/openapi.go | 5 +-- 3 files changed, 89 insertions(+), 6 deletions(-) diff --git a/internal/handlers/capabilities.go b/internal/handlers/capabilities.go index 24ca8fbc..3af82735 100644 --- a/internal/handlers/capabilities.go +++ b/internal/handlers/capabilities.go @@ -49,7 +49,19 @@ type tierCapabilities struct { RPOMinutes int `json:"rpo_minutes"` RTOMinutes int `json:"rto_minutes"` AnnualDiscountPercent int `json:"annual_discount_percent"` - UpgradeURL string `json:"upgrade_url"` + // 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"` + // 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"` } // capabilityResourceTypes is the list of service types the /capabilities @@ -128,6 +140,20 @@ func (h *CapabilitiesHandler) Get(c *fiber.Ctx) error { return entries[i].name < entries[j].name }) + // terminalRank — the highest rank in the entries slice. Used to mark + // the top tier as terminal (upgrade_url=null, is_terminal_tier=true). + // Computed after the rank-sort so we don't have to re-walk the slice; + // entries[len-1] has the highest rank by construction. + terminalRank := -1 + if len(entries) > 0 { + terminalRank = entries[len(entries)-1].rank + } + + // upgradeURLStr — pointer-to-string so we can emit explicit null for + // the terminal tier. Hoisted to a local so we don't take the address + // of a package-level const (Go doesn't allow that). + upgradeURLStr := upgradeURL + out := make([]tierCapabilities, 0, len(entries)) for _, e := range entries { storage := map[string]int{} @@ -137,6 +163,15 @@ func (h *CapabilitiesHandler) Get(c *fiber.Ctx) error { conns[rt] = h.plans.ConnectionsLimit(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 + // is true so SDKs/dashboards rendering an "Upgrade" CTA can + // suppress it without string-matching "team". + isTerminal := e.rank == terminalRank + var upgrade *string + if !isTerminal { + upgrade = &upgradeURLStr + } out = append(out, tierCapabilities{ Tier: e.name, DisplayName: e.plan.DisplayName, @@ -151,7 +186,8 @@ func (h *CapabilitiesHandler) Get(c *fiber.Ctx) error { RPOMinutes: h.plans.RPOMinutes(e.name), RTOMinutes: h.plans.RTOMinutes(e.name), AnnualDiscountPercent: annualDiscountPercent(all, e.name), - UpgradeURL: upgradeURL, + UpgradeURL: upgrade, + IsTerminalTier: isTerminal, }) } diff --git a/internal/handlers/capabilities_test.go b/internal/handlers/capabilities_test.go index 526ed3b2..66704653 100644 --- a/internal/handlers/capabilities_test.go +++ b/internal/handlers/capabilities_test.go @@ -39,7 +39,9 @@ type capabilityTier struct { BackupRestoreEnabled bool `json:"backup_restore_enabled"` ManualBackupsPerDay int `json:"manual_backups_per_day"` AnnualDiscountPercent int `json:"annual_discount_percent"` - UpgradeURL string `json:"upgrade_url"` + // DOG-26: UpgradeURL is *string — terminal tier (Team) emits null. + UpgradeURL *string `json:"upgrade_url"` + IsTerminalTier bool `json:"is_terminal_tier"` } // newCapabilitiesApp wires a minimal Fiber app with the /capabilities @@ -165,7 +167,17 @@ func TestCapabilities_DerivesPriceFromPlanRegistry(t *testing.T) { assert.Equal(t, c.wantPriceUSD, got.PriceUSDMonthly, "%s price_usd_monthly", c.tier) assert.Equal(t, c.wantPaid, got.PaidFromDayOne, "%s paid_from_day_one", c.tier) assert.Equal(t, c.wantDisplay, got.DisplayName, "%s display_name", c.tier) - assert.Equal(t, "https://instanode.dev/pricing/", got.UpgradeURL, "%s upgrade_url", c.tier) + // DOG-26: terminal tier (Team — top of the rank ladder) emits + // upgrade_url=null + is_terminal_tier=true. Every other tier + // emits the pricing URL. + if c.tier == "team" { + assert.Nil(t, got.UpgradeURL, "%s upgrade_url must be null (terminal tier)", c.tier) + assert.True(t, got.IsTerminalTier, "%s is_terminal_tier must be true", c.tier) + } else { + require.NotNil(t, got.UpgradeURL, "%s upgrade_url must be non-null", c.tier) + assert.Equal(t, "https://instanode.dev/pricing/", *got.UpgradeURL, "%s upgrade_url", c.tier) + assert.False(t, got.IsTerminalTier, "%s is_terminal_tier must be false (non-terminal)", c.tier) + } } } @@ -237,6 +249,40 @@ func TestCapabilities_SkipsYearlyVariants(t *testing.T) { } } +// TestCapabilities_TerminalTierUpgradeURLIsNull pins DOG-26: the top tier +// in the rank ladder (Team today) emits upgrade_url=null + is_terminal_tier= +// true. Every non-terminal tier emits the pricing URL + is_terminal_tier=false. +// +// Registry-iterating per CLAUDE.md rule 18: tomorrow's plans.yaml + rank.go +// addition (e.g. an `enterprise` tier above team) automatically shifts the +// terminal marker — this test reads the live rank ordering rather than +// hardcoding "team" as the terminal name, so adding a new top tier doesn't +// require touching this assertion. +func TestCapabilities_TerminalTierUpgradeURLIsNull(t *testing.T) { + reg := plans.Default() + app := newCapabilitiesApp(t, reg) + _, body := callCapabilities(t, app) + require.NotEmpty(t, body.Tiers, "expected at least one tier") + + // Last row in the rank-sorted slice is the terminal tier by + // construction (capabilities.go sorts entries by rank ascending). + terminal := body.Tiers[len(body.Tiers)-1] + assert.True(t, terminal.IsTerminalTier, + "DOG-26: top-of-ladder tier (%q) must have is_terminal_tier=true", terminal.Tier) + assert.Nil(t, terminal.UpgradeURL, + "DOG-26: top-of-ladder tier (%q) must have upgrade_url=null — nothing to upgrade to", terminal.Tier) + + // Every other row must be non-terminal with a populated URL. + for _, tr := range body.Tiers[:len(body.Tiers)-1] { + assert.False(t, tr.IsTerminalTier, + "DOG-26: non-terminal tier %q must have is_terminal_tier=false", tr.Tier) + require.NotNil(t, tr.UpgradeURL, + "DOG-26: non-terminal tier %q must have a non-null upgrade_url", tr.Tier) + assert.Equal(t, "https://instanode.dev/pricing/", *tr.UpgradeURL, + "non-terminal tier %q upgrade_url", tr.Tier) + } +} + // TestCapabilities_AnnualDiscountFromYAML — when a {tier}_yearly variant // exists in the registry, the canonical tier reports a non-zero // annual_discount_percent computed from (1 - yearly/(monthly*12)). diff --git a/internal/handlers/openapi.go b/internal/handlers/openapi.go index bf802afe..0974a734 100644 --- a/internal/handlers/openapi.go +++ b/internal/handlers/openapi.go @@ -3246,9 +3246,10 @@ const openAPISpec = `{ "rpo_minutes": { "type": "integer", "description": "Recovery Point Objective in minutes — the maximum window of data loss a restore can incur. 0 means no backup/RPO guarantee for the tier." }, "rto_minutes": { "type": "integer", "description": "Recovery Time Objective in minutes — the target time to restore service after an incident. 0 means no RTO guarantee for the tier." }, "annual_discount_percent": { "type": "integer", "description": "Discount percent of the {tier}_yearly variant vs 12x the monthly. 0 when no yearly variant exists." }, - "upgrade_url": { "type": "string", "format": "uri" } + "upgrade_url": { "type": ["string", "null"], "format": "uri", "description": "Pricing/upgrade URL for non-terminal tiers. null for the terminal tier (Team today) — there is nothing to upgrade to. Pairs with is_terminal_tier; SDKs/dashboards rendering an Upgrade CTA should suppress when null. DOG-26 (QA 2026-05-29)." }, + "is_terminal_tier": { "type": "boolean", "description": "True for the top tier in the rank ladder (Team today). When true, upgrade_url is null. Lets clients render an Upgrade CTA conditionally without string-matching tier names. DOG-26." } }, - "required": ["tier", "display_name", "price_usd_monthly", "paid_from_day_one", "storage_limit_mb", "connections_limit", "deployments_apps", "upgrade_url"] + "required": ["tier", "display_name", "price_usd_monthly", "paid_from_day_one", "storage_limit_mb", "connections_limit", "deployments_apps", "upgrade_url", "is_terminal_tier"] }, "CapabilitiesResponse": { "type": "object",