diff --git a/internal/handlers/capabilities.go b/internal/handlers/capabilities.go index 3af82735..8bfdec72 100644 --- a/internal/handlers/capabilities.go +++ b/internal/handlers/capabilities.go @@ -191,6 +191,23 @@ func (h *CapabilitiesHandler) Get(c *fiber.Ctx) error { }) } + // BUG-API-039 / BUG-API-311 (QA 2026-05-29): /api/v1/capabilities + // is dashboard-hit on every nav (sidebar tile counts, billing card, + // settings page render). The response only changes when + // api/plans.yaml is edited and the binary is redeployed — so the + // tier matrix is *immutable for the life of the running pod*. + // Without a Cache-Control hint each dashboard nav re-fetched the + // full ~4 KB matrix; sidebar fanout meant 4-6 redundant fetches per + // nav (BUG-DASH-016 noise). A `max-age=60` directive lets the + // browser fetch cache + intermediaries serve the response from the + // edge for a minute, cutting tile-render latency without hiding a + // real tier change for longer than one rule-23 deploy cycle (the + // build-SHA flip invalidates the proxy cache via /healthz polling). + // `public` because the tier matrix is the same for every caller + // (no per-user discrimination); `must-revalidate` so stale-while- + // revalidate proxies still re-fetch on expiry rather than serving + // indefinitely-stale rows after an extended offline period. + c.Set(fiber.HeaderCacheControl, "public, max-age=60, must-revalidate") return c.JSON(fiber.Map{ "ok": true, "tiers": out, diff --git a/internal/handlers/capabilities_test.go b/internal/handlers/capabilities_test.go index 66704653..3efc62aa 100644 --- a/internal/handlers/capabilities_test.go +++ b/internal/handlers/capabilities_test.go @@ -283,6 +283,30 @@ func TestCapabilities_TerminalTierUpgradeURLIsNull(t *testing.T) { } } +// TestCapabilities_CacheControlPublicMaxAge60 pins BUG-API-039 / +// BUG-API-311: /api/v1/capabilities is dashboard-hit on every nav +// (sidebar tiles, billing card, settings) and the tier matrix is +// immutable for the life of the running pod (only changes on a +// plans.yaml edit + redeploy). Without a Cache-Control hint each nav +// re-fetched the full ~4 KB matrix; sidebar fanout meant 4-6 redundant +// fetches per nav (BUG-DASH-016). +// +// Pin `public, max-age=60, must-revalidate` so the browser/edge cache +// serves the matrix for a minute while still re-validating on expiry. +// A future deletion of the c.Set call fails this assertion before merge. +func TestCapabilities_CacheControlPublicMaxAge60(t *testing.T) { + reg := plans.Default() + app := newCapabilitiesApp(t, reg) + req := httptest.NewRequest(http.MethodGet, "/api/v1/capabilities", nil) + resp, err := app.Test(req, -1) + require.NoError(t, err) + defer resp.Body.Close() + require.Equal(t, http.StatusOK, resp.StatusCode) + require.Equal(t, "public, max-age=60, must-revalidate", + resp.Header.Get("Cache-Control"), + "BUG-API-039/311: /api/v1/capabilities must stamp Cache-Control: public, max-age=60, must-revalidate so dashboard nav fanout doesn't re-fetch the same immutable matrix every navigation") +} + // 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)).