eleven / messenger Public

home: restore the price book in the /api/spaces list (dropped by the #1083 fan-out) (#1295)

The region fan-out rewrite merged only spaces, so home.js's r.billing was
always absent — no Upgrade UI, no band picker, single-region production
included. Billing now rides from the default region's cloud (any region's
book as fallback), pinned by TestListSpacesCarriesBilling.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 9b2d5338ec99fe99246009f070dfbb381351ad8d parent 48687a9
2 files changed, +106 −2
  • home/provision.go +14 −2
  • home/region_test.go +92 −0
diff --git a/home/provision.go b/home/provision.go
index 73fe29c..740bad4 100644
--- a/home/provision.go
+++ b/home/provision.go
@@ -321,7 +321,12 @@ func (s *Server) listSpacesHandler(w http.ResponseWriter, r *http.Request) {
// region we tried failed, surface the gateway error rather than an empty list
// that would read as "you have no spaces".
path := "/api/instances?requester=" + url.QueryEscape(uid)
- spaces, gotOne, sawErr := []any{}, false, false
+ // billing carries the tier picker's price book. The default region's cloud
+ // is its source of truth (checkout still validates per host downstream);
+ // any region's book is the fallback so one unreachable region doesn't hide
+ // the Upgrade UI. (The fan-out rewrite in #1083 dropped this key entirely,
+ // which silently removed the Upgrade affordance — home.js reads r.billing.)
+ spaces, billing, gotOne, sawErr := []any{}, any(nil), false, false
for _, c := range clouds {
status, body, err := c.do("GET", path, nil)
if err != nil || status != http.StatusOK {
@@ -332,12 +337,19 @@ func (s *Server) listSpacesHandler(w http.ResponseWriter, r *http.Request) {
if arr, ok := body["spaces"].([]any); ok {
spaces = append(spaces, arr...)
}
+ if b, ok := body["billing"]; ok && b != nil && b != false && (billing == nil || c == s.cloud) {
+ billing = b
+ }
}
if !gotOne && sawErr {
writeJSON(w, http.StatusBadGateway, map[string]any{"error": "list_failed"})
return
}
- writeJSON(w, http.StatusOK, map[string]any{"spaces": spaces})
+ out := map[string]any{"spaces": spaces}
+ if billing != nil {
+ out["billing"] = billing
+ }
+ writeJSON(w, http.StatusOK, out)
}
// deleteSpaceHandler: DELETE /api/spaces/{host} — permanently deprovision a
diff --git a/home/region_test.go b/home/region_test.go
index 68933f7..1c742ef 100644
--- a/home/region_test.go
+++ b/home/region_test.go
@@ -181,6 +181,98 @@ func TestListSpacesSurvivesOneRegionDown(t *testing.T) {
}
}
+// listSpaces returns the decoded /api/spaces response body.
+func listSpaces(t *testing.T, ts *httptest.Server, token string) map[string]any {
+ t.Helper()
+ req, _ := http.NewRequest("GET", ts.URL+"/api/spaces", nil)
+ req.Header.Set("Authorization", "Bearer "+token)
+ resp, err := http.DefaultClient.Do(req)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusOK {
+ t.Fatalf("list status = %d, want 200", resp.StatusCode)
+ }
+ var m map[string]any
+ json.NewDecoder(resp.Body).Decode(&m)
+ return m
+}
+
+// tierKey digs the first tier's key out of a list response's billing block.
+func tierKey(t *testing.T, m map[string]any) string {
+ t.Helper()
+ b, ok := m["billing"].(map[string]any)
+ if !ok {
+ t.Fatalf("no billing in list response: %v", m)
+ }
+ tiers, _ := b["tiers"].([]any)
+ if len(tiers) == 0 {
+ t.Fatalf("billing has no tiers: %v", b)
+ }
+ first, _ := tiers[0].(map[string]any)
+ key, _ := first["key"].(string)
+ return key
+}
+
+func TestListSpacesCarriesBilling(t *testing.T) {
+ ts, s := newTestServer(t)
+ _, token := seedUser(t, s)
+
+ ieCloud, _, _ := fakeCloud(t, http.StatusOK, map[string]any{
+ "spaces": []map[string]any{{"host": "a.ie.example.com"}},
+ "billing": map[string]any{"tiers": []map[string]any{{"key": "family"}}},
+ })
+ fiCloud, _, _ := fakeCloud(t, http.StatusOK, map[string]any{
+ "spaces": []map[string]any{},
+ "billing": map[string]any{"tiers": []map[string]any{{"key": "fi-only"}}},
+ })
+
+ // The production shape: one unpartitioned cloud with a price book. The book
+ // must ride the list response — home.js reads r.billing, and dropping it
+ // silently removes the Upgrade UI (the #1083 regression this test pins).
+ s.cloud = newCloudProvisioner(ieCloud.URL, "tok")
+ if got := tierKey(t, listSpaces(t, ts, token)); got != "family" {
+ t.Errorf("single region: tier = %q, want family", got)
+ }
+
+ // Partitioned: the DEFAULT region's book wins, wherever it sits in the
+ // fan-out order (ie deliberately listed last here).
+ s.regions = []cloudRegion{
+ {Code: "fi", Name: "Finland", prov: newCloudProvisioner(fiCloud.URL, "fi-tok")},
+ {Code: "ie", Name: "Ireland", prov: newCloudProvisioner(ieCloud.URL, "ie-tok")},
+ }
+ s.defaultRegion = "ie"
+ s.cloud = s.cloudForRegion("ie")
+ if got := tierKey(t, listSpaces(t, ts, token)); got != "family" {
+ t.Errorf("two regions: tier = %q, want the default region's family", got)
+ }
+
+ // Default region billing-less (reports false) → another region's book is
+ // the fallback rather than no Upgrade UI at all.
+ noBilling, _, _ := fakeCloud(t, http.StatusOK, map[string]any{
+ "spaces": []map[string]any{}, "billing": false,
+ })
+ s.regions = []cloudRegion{
+ {Code: "ie", Name: "Ireland", prov: newCloudProvisioner(noBilling.URL, "ie-tok")},
+ {Code: "fi", Name: "Finland", prov: newCloudProvisioner(fiCloud.URL, "fi-tok")},
+ }
+ s.defaultRegion = "ie"
+ s.cloud = s.cloudForRegion("ie")
+ if got := tierKey(t, listSpaces(t, ts, token)); got != "fi-only" {
+ t.Errorf("default billing-less: tier = %q, want the fi fallback", got)
+ }
+
+ // No cloud offers billing → the key is absent, not false/null (home.js
+ // treats absence as billing off).
+ s.regions = nil
+ s.defaultRegion = ""
+ s.cloud = newCloudProvisioner(noBilling.URL, "tok")
+ if m := listSpaces(t, ts, token); m["billing"] != nil {
+ t.Errorf("billing-less: billing = %v, want absent", m["billing"])
+ }
+}
+
func TestManageRoutesByHostRegion(t *testing.T) {
ts, s := newTestServer(t)
uid, _ := seedUser(t, s)