eleven / messenger Public

home: per-country provisioning — route space create/manage by region (#1083)

Home has always talked to one cloud provisioner (LCHAT_CLOUD_URL/_TOKEN). To
place a user's space in a chosen country (the dev mesh's ie + fi provisioners),
home now optionally knows a set of regions and routes the whole space lifecycle
to the right one.

- Config: LCHAT_CLOUD_REGIONS (JSON [{code,name,url,token}]) +
  LCHAT_CLOUD_DEFAULT_REGION. Empty keeps today's single-region behaviour
  exactly; when set it supersedes LCHAT_CLOUD_URL and s.cloud becomes the
  default region's provisioner (so demo and every fallback are unchanged).
- Create: POST /api/spaces takes an optional "region" — auto-assign to the
  default when omitted, owner override when given, unknown code quietly
  auto-assigns rather than failing. The region is home's routing concern; the
  provisioner still receives only the opaque requester.
- List: GET /api/spaces fans out across every region and merges, so a user sees
  spaces they hold anywhere; best-effort per region (one region down never
  blanks the list, but an all-fail surfaces the gateway error).
- Manage: delete/checkout/portal route by the region label the host encodes
  (<sqid>.<code>.…), so a space is always managed by the cloud that holds it.
- Features: GET /api/features advertises regions (code + name only — never a url
  or token) + default_region, so a create UI can offer the choice. Single-region
  home omits the key.

Back-compat: single-region resolvers all fall through to s.cloud, so every
existing home test passes unchanged. New region_test.go covers the parser,
region-routed create (override + auto-assign + unknown), list fan-out (+ one
region down), host to region management routing, and the features exposure.

The visible create-page region control is a deliberate follow-on slice; today
the choice rides the API "region" field. docs/cloud-provisioner.md updated.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Paul Campbell db2a9cf52b10e335ae89beaba290ca6aef5486d1 parent a7b09b4
4 files changed, +469 −16
  • docs/cloud-provisioner.md +16 −0
  • home/main.go +20 −1
  • home/provision.go +171 −15
  • home/region_test.go +262 −0
diff --git a/docs/cloud-provisioner.md b/docs/cloud-provisioner.md
index 92fa580..6454b14 100644
--- a/docs/cloud-provisioner.md
+++ b/docs/cloud-provisioner.md
@@ -203,6 +203,22 @@ POST /api/spaces/{host}/portal bearer: device link token
→ relays cloud's 200 {url} / 404 / 409 / 501 (Manage on a paid space's row)
```
+🤖 **Per-country provisioning (optional, `LCHAT_CLOUD_REGIONS`).** By default
+home talks to one provisioner (`LCHAT_CLOUD_URL` + `LCHAT_CLOUD_TOKEN`). Setting
+`LCHAT_CLOUD_REGIONS` — a JSON array of `{code,name,url,token}` — plus
+`LCHAT_CLOUD_DEFAULT_REGION` lets a signed-in user's space be placed in one of
+several country provisioners (the dev mesh runs `ie` + `fi`; see
+`docs/dev-mesh.md`). It supersedes the single `LCHAT_CLOUD_URL`; the default
+region's provisioner also backs demo and every host-agnostic fallback. Routing:
+`POST /api/spaces` takes an optional **`region`** (empty = auto-assign to the
+default; an unknown code quietly auto-assigns rather than failing); `GET
+/api/spaces` fans out across every region and merges, so a user sees spaces they
+hold anywhere; host-specific calls (delete, checkout, portal) route by the region
+label the host encodes (`<sqid>.<code>.…`). `GET /api/features` advertises the
+regions — `code` + `name` only, never a url or token — so the create UI can offer
+the choice; single-region home omits the key. (The create-page region control is
+a follow-on; today the choice rides the `region` field.)
+
Creation and listing are link-token-authed (additive/reads) and creation is
rate-limited per IP; the real abuse bound is the provisioner's quota. **With
`LCHAT_POW_BITS` set on home (default off), creation also demands hashcash**:
diff --git a/home/main.go b/home/main.go
index 617302d..8147c9b 100644
--- a/home/main.go
+++ b/home/main.go
@@ -50,8 +50,16 @@ type Server struct {
// surface of an open, anonymous registration endpoint.
limiter *rateLimiter
// cloud is the optional space provisioner client (see provision.go); nil
- // means this home server doesn't offer space creation.
+ // means this home server doesn't offer space creation. When regions is
+ // non-empty (per-country provisioning), this points at the DEFAULT region's
+ // provisioner, so demo and every host-agnostic fallback keep working.
cloud *cloudProvisioner
+ // regions, when LCHAT_CLOUD_REGIONS names more than one provisioner, lets a
+ // signed-in user's new space be placed per country (docs/dev-mesh.md). Empty
+ // means single-region: s.cloud is the only provisioner. defaultRegion is the
+ // region code a create with no explicit choice lands in.
+ regions []cloudRegion
+ defaultRegion string
// pow gates space creation behind hashcash when LCHAT_POW_BITS > 0
// (pow.go); nil = no gate.
pow *powGate
@@ -116,6 +124,17 @@ func main() {
if s.cloud = newCloudProvisioner(*cloudURL, *cloudToken); s.cloud != nil {
log.Printf("space provisioning via %s", s.cloud.url)
}
+ // Per-country provisioning: LCHAT_CLOUD_REGIONS (a JSON array of
+ // {code,name,url,token}) plus LCHAT_CLOUD_DEFAULT_REGION. When set it
+ // supersedes the single LCHAT_CLOUD_URL — s.cloud becomes the default
+ // region's provisioner so demo and fallbacks are unchanged.
+ if regions, defaultCode, err := parseCloudRegions(os.Getenv("LCHAT_CLOUD_REGIONS"), os.Getenv("LCHAT_CLOUD_DEFAULT_REGION")); err != nil {
+ log.Fatalf("cloud regions: %v", err)
+ } else if len(regions) > 0 {
+ s.regions, s.defaultRegion = regions, defaultCode
+ s.cloud = s.cloudForRegion(defaultCode)
+ log.Printf("space provisioning across %d regions (default %s)", len(regions), defaultCode)
+ }
if bits, _ := strconv.Atoi(envOr("LCHAT_POW_BITS", "0")); bits > 0 {
s.pow = newPowGate(bits, s.keys)
log.Printf("space creation gated by proof of work (%d bits)", bits)
diff --git a/home/provision.go b/home/provision.go
index f5ceb40..9e80661 100644
--- a/home/provision.go
+++ b/home/provision.go
@@ -16,6 +16,7 @@ package main
import (
"bytes"
"encoding/json"
+ "fmt"
"io"
"net/http"
"net/url"
@@ -44,14 +45,141 @@ func newCloudProvisioner(url, token string) *cloudProvisioner {
}
}
+// cloudRegion is one provisioner home can place a space in, keyed by a short
+// code that also appears as the second DNS label of the hosts it serves
+// (e.g. "ie" in "8oe.ie.dev.11ch.at"). A home configured with more than one
+// (LCHAT_CLOUD_REGIONS) provisions per country; with none, the single
+// LCHAT_CLOUD_URL provisioner (s.cloud) is the only region.
+type cloudRegion struct {
+ Code string // "ie" — matches the host's region label and the API `region`
+ Name string // "Ireland" — the only user-facing string (shown in the picker)
+ prov *cloudProvisioner
+}
+
+// regionConfig is the JSON shape of one LCHAT_CLOUD_REGIONS entry. token stays
+// server-side; only Code/Name are ever exposed (featuresHandler).
+type regionConfig struct {
+ Code string `json:"code"`
+ Name string `json:"name"`
+ URL string `json:"url"`
+ Token string `json:"token"`
+}
+
+// parseCloudRegions reads LCHAT_CLOUD_REGIONS (a JSON array of regionConfig) and
+// LCHAT_CLOUD_DEFAULT_REGION. Empty JSON → no regions (single-region mode, the
+// caller keeps its LCHAT_CLOUD_URL provisioner). The default code must name one
+// of the configured regions; empty defaults to the first.
+func parseCloudRegions(regionsJSON, defaultCode string) ([]cloudRegion, string, error) {
+ regionsJSON = strings.TrimSpace(regionsJSON)
+ if regionsJSON == "" {
+ return nil, "", nil
+ }
+ var cfgs []regionConfig
+ if err := json.Unmarshal([]byte(regionsJSON), &cfgs); err != nil {
+ return nil, "", fmt.Errorf("parse LCHAT_CLOUD_REGIONS: %w", err)
+ }
+ regions := make([]cloudRegion, 0, len(cfgs))
+ seen := map[string]bool{}
+ for _, c := range cfgs {
+ if c.Code == "" {
+ return nil, "", fmt.Errorf("a region is missing its code")
+ }
+ if seen[c.Code] {
+ return nil, "", fmt.Errorf("region %q is listed twice", c.Code)
+ }
+ seen[c.Code] = true
+ prov := newCloudProvisioner(c.URL, c.Token)
+ if prov == nil {
+ return nil, "", fmt.Errorf("region %q needs both url and token", c.Code)
+ }
+ name := c.Name
+ if name == "" {
+ name = c.Code
+ }
+ regions = append(regions, cloudRegion{Code: c.Code, Name: name, prov: prov})
+ }
+ if defaultCode == "" {
+ defaultCode = regions[0].Code
+ } else if !seen[defaultCode] {
+ return nil, "", fmt.Errorf("LCHAT_CLOUD_DEFAULT_REGION %q is not one of the configured regions", defaultCode)
+ }
+ return regions, defaultCode, nil
+}
+
+// cloudForRegion returns the provisioner for a region code, falling back to the
+// default provisioner (s.cloud) when the code is empty, unknown, or when home is
+// running single-region. So an unrecognised owner choice quietly auto-assigns
+// rather than failing.
+func (s *Server) cloudForRegion(code string) *cloudProvisioner {
+ for i := range s.regions {
+ if s.regions[i].Code == code {
+ return s.regions[i].prov
+ }
+ }
+ return s.cloud
+}
+
+// regionCodeForHost extracts the region a host lives in from its name — the
+// label after the leftmost (space-id) label, e.g. "8oe.ie.dev.11ch.at" → "ie" —
+// but only when that names a configured region. Single-region / prod hosts
+// ("8oe.11ch.at") carry no region label, so this returns "" and callers use the
+// default provisioner.
+func (s *Server) regionCodeForHost(host string) string {
+ labels := strings.Split(host, ".")
+ if len(labels) < 2 {
+ return ""
+ }
+ for i := range s.regions {
+ if s.regions[i].Code == labels[1] {
+ return labels[1]
+ }
+ }
+ return ""
+}
+
+// cloudForHost routes a host-specific operation (delete, checkout, portal) to
+// the provisioner of the region the host lives in, defaulting to s.cloud.
+func (s *Server) cloudForHost(host string) *cloudProvisioner {
+ return s.cloudForRegion(s.regionCodeForHost(host))
+}
+
+// allClouds is every distinct provisioner home can reach — all regions, or just
+// the single default when unpartitioned — used to fan a listing out so a user
+// sees the spaces they hold in every region.
+func (s *Server) allClouds() []*cloudProvisioner {
+ if len(s.regions) == 0 {
+ if s.cloud == nil {
+ return nil
+ }
+ return []*cloudProvisioner{s.cloud}
+ }
+ out := make([]*cloudProvisioner, 0, len(s.regions))
+ for i := range s.regions {
+ out = append(out, s.regions[i].prov)
+ }
+ return out
+}
+
// featuresHandler advertises which optional features this home server offers,
// so the page can hide UI it can't serve. Public: it reveals only a boolean,
// never a token or endpoint that would work without one.
func (s *Server) featuresHandler(w http.ResponseWriter, r *http.Request) {
- writeJSON(w, http.StatusOK, map[string]any{
+ out := map[string]any{
"spaces": s.cloud != nil,
"demo": s.demo != nil && s.cloud != nil,
- })
+ }
+ // When home provisions per country, advertise the regions — code + name
+ // only, never a url or token — and the default, so the create UI can offer
+ // the choice. Absent → the client shows no region control (single-region).
+ if len(s.regions) > 0 {
+ regs := make([]map[string]string, len(s.regions))
+ for i, rg := range s.regions {
+ regs[i] = map[string]string{"code": rg.Code, "name": rg.Name}
+ }
+ out["regions"] = regs
+ out["default_region"] = s.defaultRegion
+ }
+ writeJSON(w, http.StatusOK, out)
}
// challengeHandler: POST /api/spaces/challenge — mint a proof-of-work
@@ -108,15 +236,21 @@ func (s *Server) createSpaceHandler(w http.ResponseWriter, r *http.Request) {
})
return
}
+ // One read of the (optional) body: the region choice and, when the gate is
+ // on, the solved proof of work ride together. A missing or empty body
+ // decodes to the zero value — auto-assign the region, and (pow on) fall to
+ // pow_required below.
+ var req struct {
+ Region string `json:"region"`
+ Challenge string `json:"challenge"`
+ Nonce string `json:"nonce"`
+ }
+ _ = json.NewDecoder(io.LimitReader(r.Body, 8<<10)).Decode(&req)
// The proof-of-work gate (pow.go): when enabled, creation carries a solved
// challenge. Checked AFTER the cheap gates so an ineligible caller never
// burns CPU for a refusal, and before the provisioner is ever dialed.
if s.pow != nil {
- var req struct {
- Challenge string `json:"challenge"`
- Nonce string `json:"nonce"`
- }
- if err := json.NewDecoder(io.LimitReader(r.Body, 8<<10)).Decode(&req); err != nil || req.Challenge == "" {
+ if req.Challenge == "" {
writeJSON(w, http.StatusForbidden, map[string]any{
"error": "pow_required", "message": "This home server asks for a little proof of work first — try again.",
})
@@ -129,7 +263,10 @@ func (s *Server) createSpaceHandler(w http.ResponseWriter, r *http.Request) {
return
}
}
- status, body, err := s.cloud.createSpace(uid)
+ // Owner override: an explicit region places the space there; empty
+ // auto-assigns to the default region. An unknown code quietly falls back to
+ // the default (cloudForRegion) rather than failing the create.
+ status, body, err := s.cloudForRegion(req.Region).createSpace(uid)
if err != nil {
writeJSON(w, http.StatusBadGateway, map[string]any{
"error": "create_failed", "message": "could not reach the space provisioner",
@@ -158,16 +295,35 @@ func (s *Server) listSpacesHandler(w http.ResponseWriter, r *http.Request) {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
- if s.cloud == nil {
+ clouds := s.allClouds()
+ if len(clouds) == 0 {
writeJSON(w, http.StatusOK, map[string]any{"spaces": []any{}})
return
}
- status, body, err := s.cloud.do("GET", "/api/instances?requester="+url.QueryEscape(uid), nil)
- if err != nil || status != http.StatusOK {
+ // Fan the read out across every region and merge — a user's spaces may live
+ // in more than one. Each host encodes its own region, so the client needs no
+ // hint back; management calls route by host (cloudForHost). Best-effort per
+ // region: one region being unreachable must not blank the list, but if every
+ // 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
+ for _, c := range clouds {
+ status, body, err := c.do("GET", path, nil)
+ if err != nil || status != http.StatusOK {
+ sawErr = true
+ continue
+ }
+ gotOne = true
+ if arr, ok := body["spaces"].([]any); ok {
+ spaces = append(spaces, arr...)
+ }
+ }
+ if !gotOne && sawErr {
writeJSON(w, http.StatusBadGateway, map[string]any{"error": "list_failed"})
return
}
- writeJSON(w, http.StatusOK, body)
+ writeJSON(w, http.StatusOK, map[string]any{"spaces": spaces})
}
// deleteSpaceHandler: DELETE /api/spaces/{host} — permanently deprovision a
@@ -188,7 +344,7 @@ func (s *Server) deleteSpaceHandler(w http.ResponseWriter, r *http.Request) {
return
}
host := r.PathValue("host")
- status, body, err := s.cloud.do("DELETE",
+ status, body, err := s.cloudForHost(host).do("DELETE",
"/api/instances/"+url.PathEscape(host)+"?requester="+url.QueryEscape(uid), nil)
if err != nil {
writeJSON(w, http.StatusBadGateway, map[string]any{
@@ -245,7 +401,7 @@ func (s *Server) checkoutSpaceHandler(w http.ResponseWriter, r *http.Request) {
"success_url": s.origin + "/?upgraded=" + url.QueryEscape(host),
"cancel_url": s.origin + "/",
})
- status, body, err := s.cloud.do("POST", "/api/instances/"+url.PathEscape(host)+"/checkout", payload)
+ status, body, err := s.cloudForHost(host).do("POST", "/api/instances/"+url.PathEscape(host)+"/checkout", payload)
if err != nil {
writeJSON(w, http.StatusBadGateway, map[string]any{
"error": "checkout_failed", "message": "could not reach the space provisioner",
@@ -285,7 +441,7 @@ func (s *Server) portalSpaceHandler(w http.ResponseWriter, r *http.Request) {
"requester": uid,
"return_url": s.origin + "/",
})
- status, body, err := s.cloud.do("POST", "/api/instances/"+url.PathEscape(host)+"/portal", payload)
+ status, body, err := s.cloudForHost(host).do("POST", "/api/instances/"+url.PathEscape(host)+"/portal", payload)
if err != nil {
writeJSON(w, http.StatusBadGateway, map[string]any{
"error": "portal_failed", "message": "could not reach the space provisioner",
diff --git a/home/region_test.go b/home/region_test.go
new file mode 100644
index 0000000..68933f7
--- /dev/null
+++ b/home/region_test.go
@@ -0,0 +1,262 @@
+package main
+
+import (
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+)
+
+// twoRegions wires s with an ie + fi provisioner pointed at the given fake
+// clouds, default ie — the per-country shape the dev mesh runs.
+func twoRegions(s *Server, ieURL, fiURL string) {
+ s.regions = []cloudRegion{
+ {Code: "ie", Name: "Ireland", prov: newCloudProvisioner(ieURL, "ie-tok")},
+ {Code: "fi", Name: "Finland", prov: newCloudProvisioner(fiURL, "fi-tok")},
+ }
+ s.defaultRegion = "ie"
+ s.cloud = s.cloudForRegion("ie")
+}
+
+func postSpacesJSON(t *testing.T, ts *httptest.Server, token, body string) *http.Response {
+ t.Helper()
+ req, _ := http.NewRequest("POST", ts.URL+"/api/spaces", strings.NewReader(body))
+ req.Header.Set("Authorization", "Bearer "+token)
+ req.Header.Set("Content-Type", "application/json")
+ resp, err := http.DefaultClient.Do(req)
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { resp.Body.Close() })
+ return resp
+}
+
+func createdHost(t *testing.T, resp *http.Response) string {
+ t.Helper()
+ if resp.StatusCode != http.StatusCreated {
+ t.Fatalf("create status = %d, want 201", resp.StatusCode)
+ }
+ var m map[string]any
+ json.NewDecoder(resp.Body).Decode(&m)
+ host, _ := m["host"].(string)
+ return host
+}
+
+func TestParseCloudRegions(t *testing.T) {
+ // Empty → single-region: no regions, no default, no error.
+ if regs, def, err := parseCloudRegions("", ""); err != nil || regs != nil || def != "" {
+ t.Errorf("empty: got regs=%v def=%q err=%v", regs, def, err)
+ }
+
+ j := `[{"code":"ie","name":"Ireland","url":"https://cloud.ie","token":"a"},{"code":"fi","name":"Finland","url":"https://cloud.fi","token":"b"}]`
+ regs, def, err := parseCloudRegions(j, "fi")
+ if err != nil {
+ t.Fatalf("parse: %v", err)
+ }
+ if len(regs) != 2 || def != "fi" {
+ t.Fatalf("regs=%d default=%q", len(regs), def)
+ }
+ if regs[0].Code != "ie" || regs[0].Name != "Ireland" || regs[0].prov == nil {
+ t.Errorf("regs[0] = %+v", regs[0])
+ }
+
+ // Empty default → the first region.
+ if _, def2, err := parseCloudRegions(j, ""); err != nil || def2 != "ie" {
+ t.Errorf("empty default = %q (err %v), want ie", def2, err)
+ }
+ // A missing name defaults to the code.
+ if regs3, _, err := parseCloudRegions(`[{"code":"nl","url":"https://c","token":"t"}]`, ""); err != nil || regs3[0].Name != "nl" {
+ t.Errorf("missing name should default to code: %+v (err %v)", regs3, err)
+ }
+
+ for _, bad := range []struct{ j, def, why string }{
+ {`[{"code":"ie","url":"https://c"}]`, "", "missing token"},
+ {`[{"code":"ie","name":"x","token":"t"}]`, "", "missing url"},
+ {`[{"code":"ie","url":"u","token":"t"}]`, "zz", "unknown default"},
+ {`[{"code":"ie","url":"u","token":"t"},{"code":"ie","url":"u","token":"t"}]`, "", "duplicate code"},
+ {`not json`, "", "bad json"},
+ {`[{"url":"u","token":"t"}]`, "", "missing code"},
+ } {
+ if _, _, err := parseCloudRegions(bad.j, bad.def); err == nil {
+ t.Errorf("%s: expected an error, got nil", bad.why)
+ }
+ }
+}
+
+func TestCreateSpaceRoutesByRegion(t *testing.T) {
+ ts, s := newTestServer(t)
+ _, token := seedMember(t, s)
+
+ // Each region's fake reports a host in its own country, so the create
+ // response alone says which provisioner handled it — no capture ordering.
+ ieCloud, _, _ := fakeCloud(t, http.StatusCreated, map[string]any{
+ "host": "x.ie.example.com", "setup_link": "https://x.ie.example.com/join#S",
+ })
+ fiCloud, fiCaptured, fiBody := fakeCloud(t, http.StatusCreated, map[string]any{
+ "host": "x.fi.example.com", "setup_link": "https://x.fi.example.com/join#S",
+ })
+ twoRegions(s, ieCloud.URL, fiCloud.URL)
+
+ // Owner override: an explicit region places the space there.
+ if host := createdHost(t, postSpacesJSON(t, ts, token, `{"region":"fi"}`)); host != "x.fi.example.com" {
+ t.Errorf("region=fi landed on %q, want the fi cloud", host)
+ }
+ if got := fiCaptured.Header.Get("Authorization"); got != "Bearer fi-tok" {
+ t.Errorf("fi create used %q, want the fi region's own token", got)
+ }
+ // The region is home's routing concern — the provisioner still receives only
+ // the opaque requester, nothing else.
+ var sent map[string]string
+ json.Unmarshal(*fiBody, &sent)
+ if _, leaked := sent["region"]; leaked || len(sent) != 1 {
+ t.Errorf("provisioner saw %v, want just requester", sent)
+ }
+
+ // Auto-assign: no region → the default region (ie).
+ if host := createdHost(t, postSpacesJSON(t, ts, token, `{}`)); host != "x.ie.example.com" {
+ t.Errorf("no region landed on %q, want the default ie cloud", host)
+ }
+ // An unknown region quietly auto-assigns rather than failing.
+ if host := createdHost(t, postSpacesJSON(t, ts, token, `{"region":"zz"}`)); host != "x.ie.example.com" {
+ t.Errorf("unknown region landed on %q, want the default ie cloud", host)
+ }
+}
+
+func TestListSpacesFansOutAcrossRegions(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"}},
+ })
+ fiCloud, _, _ := fakeCloud(t, http.StatusOK, map[string]any{
+ "spaces": []map[string]any{{"host": "b.fi.example.com"}},
+ })
+ twoRegions(s, ieCloud.URL, fiCloud.URL)
+
+ 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()
+ var m struct{ Spaces []struct{ Host string } }
+ json.NewDecoder(resp.Body).Decode(&m)
+ if len(m.Spaces) != 2 {
+ t.Fatalf("merged spaces = %d, want 2 (one per region): %+v", len(m.Spaces), m.Spaces)
+ }
+ hosts := map[string]bool{m.Spaces[0].Host: true, m.Spaces[1].Host: true}
+ if !hosts["a.ie.example.com"] || !hosts["b.fi.example.com"] {
+ t.Errorf("merged hosts = %+v, want both regions", m.Spaces)
+ }
+}
+
+func TestListSpacesSurvivesOneRegionDown(t *testing.T) {
+ ts, s := newTestServer(t)
+ _, token := seedUser(t, s)
+
+ up, _, _ := fakeCloud(t, http.StatusOK, map[string]any{
+ "spaces": []map[string]any{{"host": "a.ie.example.com"}},
+ })
+ down, _, _ := fakeCloud(t, http.StatusBadGateway, map[string]any{"error": "boom"})
+ twoRegions(s, up.URL, down.URL) // ie up, fi failing
+
+ 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()
+ // One region up → still 200 with that region's spaces (best-effort).
+ if resp.StatusCode != http.StatusOK {
+ t.Fatalf("one region down: status = %d, want 200", resp.StatusCode)
+ }
+ var m struct{ Spaces []struct{ Host string } }
+ json.NewDecoder(resp.Body).Decode(&m)
+ if len(m.Spaces) != 1 || m.Spaces[0].Host != "a.ie.example.com" {
+ t.Errorf("spaces = %+v, want just the healthy region's", m.Spaces)
+ }
+}
+
+func TestManageRoutesByHostRegion(t *testing.T) {
+ ts, s := newTestServer(t)
+ uid, _ := seedUser(t, s)
+
+ ieCloud, ieCap, _ := fakeCloud(t, http.StatusOK, map[string]any{"ok": true})
+ fiCloud, fiCap, _ := fakeCloud(t, http.StatusOK, map[string]any{"ok": true})
+ twoRegions(s, ieCloud.URL, fiCloud.URL)
+
+ del := func(host string) {
+ req, _ := http.NewRequest("DELETE", ts.URL+"/api/spaces/"+host, nil)
+ req.Header.Set("X-Ceremony-Proof", s.keys.mintCeremonyProof(uid))
+ resp, err := http.DefaultClient.Do(req)
+ if err != nil {
+ t.Fatal(err)
+ }
+ resp.Body.Close()
+ if resp.StatusCode != http.StatusOK {
+ t.Fatalf("delete %s = %d, want 200", host, resp.StatusCode)
+ }
+ }
+
+ // A .fi host routes to the fi provisioner, and not the ie one.
+ del("y.fi.example.com")
+ if fiCap.Method != "DELETE" {
+ t.Error("delete of a .fi host did not reach the fi provisioner")
+ }
+ if ieCap.Method != "" {
+ t.Error("delete of a .fi host wrongly reached the ie provisioner")
+ }
+ // A .ie host routes to the ie provisioner.
+ del("z.ie.example.com")
+ if ieCap.Method != "DELETE" {
+ t.Error("delete of a .ie host did not reach the ie provisioner")
+ }
+}
+
+func TestFeaturesExposesRegions(t *testing.T) {
+ ts, s := newTestServer(t)
+
+ // Single-region: no regions key at all.
+ s.cloud = newCloudProvisioner("https://cloud.example.com", "tok")
+ var f map[string]any
+ getJSON(t, ts.URL+"/api/features", &f)
+ if _, ok := f["regions"]; ok {
+ t.Errorf("single-region features must not carry a regions key: %v", f)
+ }
+
+ // Multi-region: regions + default_region, code+name only — never url/token.
+ twoRegions(s, "https://cloud.ie", "https://cloud.fi")
+ var f2 map[string]any
+ getJSON(t, ts.URL+"/api/features", &f2)
+ if f2["default_region"] != "ie" {
+ t.Errorf("default_region = %v, want ie", f2["default_region"])
+ }
+ regs, ok := f2["regions"].([]any)
+ if !ok || len(regs) != 2 {
+ t.Fatalf("regions = %v, want 2", f2["regions"])
+ }
+ first, _ := regs[0].(map[string]any)
+ if first["code"] != "ie" || first["name"] != "Ireland" {
+ t.Errorf("region[0] = %v", first)
+ }
+ if _, leaked := first["url"]; leaked {
+ t.Errorf("features leaked a region url: %v", first)
+ }
+ if _, leaked := first["token"]; leaked {
+ t.Errorf("features leaked a region token: %v", first)
+ }
+}
+
+func getJSON(t *testing.T, url string, v any) {
+ t.Helper()
+ resp, err := http.Get(url)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer resp.Body.Close()
+ json.NewDecoder(resp.Body).Decode(v)
+}