carlos / platform Public

console: app_default with every reason, place names, POST regions/guess (nearest-region §9.3, §9.4)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 1f8ca0a1c1ea368aa04b8e8650a7249cb26720b9 parent 9e81410
5 files changed, +541 −13
  • internal/console/apiregions.go +23 −5
  • internal/console/authcli.go +3 −0
  • internal/console/regiondefault.go +154 −0
  • internal/console/regiondefault_test.go +348 −0
  • internal/console/svcroutes_test.go +13 −8
diff --git a/internal/console/apiregions.go b/internal/console/apiregions.go
index 4c106bdd5..2ce670b63 100644
--- a/internal/console/apiregions.go
+++ b/internal/console/apiregions.go
@@ -25,31 +25,49 @@ import (
// no named home and no placement-publishing boxes answers empty lists, and
// consumers hide their pickers.
//
+// Nearest-region additions (§9.3): each entry may carry aws_region and
+// name, and the answer carries app_default (regiondefault.go). regions and
+// default are unchanged for every older reader.
+//
// The instance family's gate, deliberately: the answer is deployment
// topology (which regions exist), the same information the create door's
// refusal message already hands the same bearer.
func (s *Server) handleAPIRegions(w http.ResponseWriter, r *http.Request) {
- _, _, _, ok := s.instanceGate(w, r)
+ _, acct, app, ok := s.instanceGate(w, r)
if !ok {
return
}
type region struct {
- Code string `json:"code"`
- Armed bool `json:"armed"`
+ Code string `json:"code"`
+ Armed bool `json:"armed"`
+ AWSRegion string `json:"aws_region,omitempty"`
+ Name string `json:"name,omitempty"`
}
set, _ := armedPlacements(r.Context(), s.cfg.Releases)
+ cands := s.candsOrHome(r.Context())
+ named := func(code string) region {
+ c, _ := findCandidate(cands, s.homeRegionIn(code))
+ return region{Code: code, AWSRegion: c.AWSRegion, Name: s.placeOf(s.homeRegionIn(code), cands)}
+ }
out := make([]region, 0, len(set)+1)
if s.cfg.HomeRegion != "" {
- out = append(out, region{Code: s.cfg.HomeRegion, Armed: true})
+ h := named(s.cfg.HomeRegion)
+ h.Armed = true
+ out = append(out, h)
// A box that publishes the home's code explicitly (a future coded
// home) must not appear twice.
delete(set, s.cfg.HomeRegion)
}
for code, armed := range set {
- out = append(out, region{Code: code, Armed: armed})
+ e := named(code)
+ e.Armed = armed
+ out = append(out, e)
}
sort.Slice(out, func(i, j int) bool { return out[i].Code < out[j].Code })
+ // regions and default are what older clients and titogo read; they stay
+ // as they were. app_default is the nearest-region addition (§9.3).
writeJSON(w, http.StatusOK, map[string]any{
"regions": out, "default": s.cfg.HomeRegion,
+ "app_default": s.appDefault(r.Context(), acct, app),
})
}
diff --git a/internal/console/authcli.go b/internal/console/authcli.go
index bbd5d466b..ac070342f 100644
--- a/internal/console/authcli.go
+++ b/internal/console/authcli.go
@@ -589,6 +589,9 @@ func (s *Server) registerCLIRoutes() {
// The armed-regions answer for consumer pickers (issue #346) — the
// same gate and role as the list beside it; see handleAPIRegions.
s.cliRoute("GET /api/cli/apps/{account}/{app}/regions", svcrole.Allow(svcrole.Instance), s.handleAPIRegions)
+ // The per-caller region guess (nearest-region spec §9.4): POST, so the
+ // hints never ride a URL a redirect could echo.
+ s.cliRoute("POST /api/cli/apps/{account}/{app}/regions/guess", svcrole.Allow(svcrole.Instance), s.handleAPIRegionGuess)
// Moving a route between channels (channelmove.go). The zero Set, for
// the manifest door's reason: this decides what a route SERVES, and a
// credential the app itself holds must not be able to walk its own route
diff --git a/internal/console/regiondefault.go b/internal/console/regiondefault.go
new file mode 100644
index 000000000..e2428ed9e
--- /dev/null
+++ b/internal/console/regiondefault.go
@@ -0,0 +1,154 @@
+package console
+
+import (
+ "context"
+ "encoding/json"
+ "net"
+ "net/http"
+ "strings"
+
+ "github.com/carlosframework/platform/internal/provision"
+)
+
+// appDefaultView is §9.3's app_default. region is the STORED region (what
+// deploy --region compares against); effective is where a no-region create
+// lands: the fleet itself for a placed app, never the platform home
+// (codex R2-4). RegionKnown and PlacementKnown say which facts were READ:
+// an unread fact is left empty, never filled with the home (codex
+// plan-R2 3; spec R9a). InstanceDomain is the domain instance hosts take,
+// so canary --region can name its host before any write (codex plan-R3 1).
+type appDefaultView struct {
+ Region string `json:"region"`
+ Name string `json:"name"`
+ Source string `json:"source"`
+ Effective string `json:"effective"`
+ Available *bool `json:"available,omitempty"`
+ Reason string `json:"reason,omitempty"`
+ RegionKnown bool `json:"region_known"`
+ PlacementKnown bool `json:"placement_known"`
+ InstanceDomain string `json:"instance_domain,omitempty"`
+}
+
+func boolp(b bool) *bool { return &b }
+
+func (s *Server) appDefault(ctx context.Context, acct *Account, app string) appDefaultView {
+ stored, source, rerr := s.appRegion(acct.ID, app)
+ snap, ferr := readEdgesSnapshot(ctx, s.cfg.Releases)
+ cands := homeOnlyCandidates()
+ if ferr == nil {
+ cands = s.regionCands(snap)
+ }
+ pol := s.appPolicy(ctx, acct, app)
+ v := appDefaultView{RegionKnown: rerr == nil, PlacementKnown: pol.State != policyUnreadable}
+ switch pol.State {
+ case policyKnown:
+ v.InstanceDomain = pol.M.Domain
+ case policyAbsent:
+ v.InstanceDomain = s.platformInstanceDomain(acct)
+ }
+ if rerr == nil {
+ v.Region, v.Name, v.Source, v.Effective = s.homeRegionOut(stored), s.placeOf(stored, cands), source, s.homeRegionOut(stored)
+ }
+ switch {
+ case pol.State == policyUnreadable:
+ // policy-unreadable is reserved for placement_known being false
+ // (spec §9.3).
+ v.Reason = "policy-unreadable"
+ return v
+ case pol.Placed != "":
+ // R9a: with the stored region unread, effective stays empty even
+ // though the fleet is known; reason still says fleet (codex
+ // plan-R4 1).
+ v.Reason = "fleet"
+ if rerr == nil {
+ v.Effective = pol.Placed
+ }
+ return v
+ case pol.pooled():
+ // A no-region pooled create lands on the home, so it is available
+ // only when the home passes the allow-list (codex plan-R3 2). R9a
+ // again: nothing about effective is stated when the region is unread.
+ v.Reason = "pooled"
+ if rerr == nil {
+ v.Effective, v.Available = s.homeRegionOut(""), boolp(pol.allows(""))
+ }
+ return v
+ case rerr != nil:
+ // The stored region is unread, so where a create lands is unknown.
+ // Nothing is stated about it, and no reason either: the placement
+ // WAS read (controller ruling PF7).
+ return v
+ case ferr != nil:
+ v.Reason = "edges-unreadable"
+ return v
+ }
+ reason := "absent"
+ if c, ok := findCandidate(cands, stored); ok {
+ reason = c.unavailableReason()
+ }
+ if reason == "" && !pol.allows(stored) {
+ reason = "not-allowed"
+ }
+ v.Reason, v.Available = reason, boolp(reason == "")
+ return v
+}
+
+// platformInstanceDomain is the domain the instances-manifest PUT stamps on
+// a NEW manifest (handleAPIInstancesEnable): "" when this console has no
+// valid platform apps domain, which that PUT refuses. With a manifest, its
+// own domain governs instead.
+func (s *Server) platformInstanceDomain(acct *Account) string {
+ if s.cfg.AppsDomain == "" {
+ return ""
+ }
+ d := acct.Sqid + "." + s.cfg.AppsDomain
+ if !provision.ValidHostShape(d) {
+ return ""
+ }
+ return d
+}
+
+// handleAPIRegionGuess is POST …/regions/guess (spec §9.4). Hints come only
+// in the body, so no redirect can echo them. A malformed body is read as
+// no hints; a person's request with no ip uses its own address, a service
+// principal's never does.
+//
+// A fleet-placed app gets no guess (codex R4-1), and a failed placement
+// lookup is an explicit 502, never a guess from a policy that was not read.
+func (s *Server) handleAPIRegionGuess(w http.ResponseWriter, r *http.Request) {
+ _, acct, app, ok := s.instanceGate(w, r)
+ if !ok {
+ return
+ }
+ var body struct {
+ TZ string `json:"tz"`
+ IP string `json:"ip"`
+ }
+ _ = json.NewDecoder(http.MaxBytesReader(w, r.Body, 64<<10)).Decode(&body)
+ pol := s.appPolicy(r.Context(), acct, app)
+ if pol.State == policyUnreadable {
+ jsonError(w, http.StatusBadGateway, msgPolicyUnreadable)
+ return
+ }
+ if pol.Placed != "" || pol.pooled() {
+ writeJSON(w, http.StatusOK, map[string]any{"guess": nil})
+ return
+ }
+ ip := net.ParseIP(strings.TrimSpace(body.IP))
+ if ip == nil {
+ ip = s.personIP(r)
+ }
+ files, err := readEdgesSnapshot(r.Context(), s.cfg.Releases)
+ cands := homeOnlyCandidates()
+ if err == nil {
+ cands = s.regionCands(files)
+ }
+ g, ok := guessRegion(cands, pol.allows, guessInput{TZ: body.TZ, IP: ip}, s.cfg.GeoIP)
+ if !ok {
+ writeJSON(w, http.StatusOK, map[string]any{"guess": nil})
+ return
+ }
+ writeJSON(w, http.StatusOK, map[string]any{"guess": map[string]string{
+ "code": s.homeRegionOut(g.Code), "name": s.placeOf(g.Code, cands), "source": g.Source,
+ }})
+}
diff --git a/internal/console/regiondefault_test.go b/internal/console/regiondefault_test.go
new file mode 100644
index 000000000..ef45cb603
--- /dev/null
+++ b/internal/console/regiondefault_test.go
@@ -0,0 +1,348 @@
+package console
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "io"
+ "net/http"
+ "strings"
+ "testing"
+
+ "github.com/carlosframework/platform/internal/geo"
+ "github.com/carlosframework/platform/internal/provision"
+)
+
+func regionsURL(fx *consoleFixture) string { return fx.ts.URL + "/api/cli/apps/bab/titogo/regions" }
+func guessURL(fx *consoleFixture) string { return regionsURL(fx) + "/guess" }
+
+// corruptManifest replaces the manifest with unparsable bytes, under its
+// current etag (Put with "" is create-only and would not replace it).
+func corruptManifest(t *testing.T, fx *consoleFixture) {
+ t.Helper()
+ key := provision.ManifestKey("bab", "titogo")
+ _, etag, err := fx.rel.Get(context.Background(), key)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := fx.rel.Put(context.Background(), key, []byte("{not json"), etag); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func TestRegionsKeepsLegacyFieldsAndAddsAppDefault(t *testing.T) {
+ fx, token, _ := fillFixture(t, "uk")
+ status, _, out := rawReq(t, http.MethodGet, regionsURL(fx), token, "")
+ var res struct {
+ Default string `json:"default"`
+ AppDefault appDefaultView `json:"app_default"`
+ }
+ if err := json.Unmarshal([]byte(out), &res); err != nil || status != http.StatusOK || res.Default != "ie" {
+ t.Fatalf("legacy default = %d %s", status, out)
+ }
+ if d := res.AppDefault; d.Region != "uk" || d.Effective != "uk" || d.Available == nil || !*d.Available || d.Name != ukPlace {
+ t.Fatalf("app_default = %+v", d)
+ }
+ if !strings.Contains(out, `"aws_region":"eu-west-2"`) || !strings.Contains(out, ukPlace) {
+ t.Fatalf("entries lack names: %s", out)
+ }
+}
+
+// Every reason §9.3 names, each from its own condition (codex plan-R1 12).
+func TestAppDefaultReasons(t *testing.T) {
+ acct := &Account{ID: OpsAccountID, Sqid: "bab"}
+ cases := []struct {
+ name string
+ setup func(t *testing.T, fx *consoleFixture)
+ reason string
+ available *bool
+ effective string
+ }{
+ {"available", func(*testing.T, *consoleFixture) {}, "", bp(true), "uk"},
+ {"absent", func(t *testing.T, fx *consoleFixture) { fx.rel.Delete(t.Context(), "control/edges/uk-box.json") }, "absent", bp(false), "uk"},
+ {"not-hosting", func(t *testing.T, fx *consoleFixture) {
+ setEdges(t, fx, map[string]string{"uk-box": edgeJSON("uk-box", "1.2.3.4", "eu-west-2", "uk", edgeOpts{hib: bp(false), shared: bp(true), inbound: bp(true), fp: testKeyFP})})
+ }, "not-hosting", bp(false), "uk"},
+ {"no-secrets-key", func(t *testing.T, fx *consoleFixture) { setEdges(t, fx, map[string]string{"uk-box": ukNoKey}) }, "no-secrets-key", bp(false), "uk"},
+ {"not-eligible", func(t *testing.T, fx *consoleFixture) {
+ setEdges(t, fx, map[string]string{"uk-box": edgeJSON("uk-box", "1.2.3.4", "eu-west-2", "uk", edgeOpts{hib: bp(true), shared: bp(false), inbound: bp(true), fp: testKeyFP})})
+ }, "not-eligible", bp(false), "uk"},
+ {"not-allowed", func(t *testing.T, fx *consoleFixture) {
+ m := titoManifest()
+ m.Pool, m.Regions = "", []string{"au"}
+ enableInstances(t, fx, "titogo", m)
+ }, "not-allowed", bp(false), "uk"},
+ {"pooled", func(t *testing.T, fx *consoleFixture) {
+ m := titoManifest()
+ m.Pool, m.Regions = "tito", nil
+ enableInstances(t, fx, "titogo", m)
+ }, "pooled", bp(true), "ie"},
+ {"fleet", func(t *testing.T, fx *consoleFixture) { fx.st.SetAppPlacement(OpsAccountID, "titogo", "fleet/acme") }, "fleet", nil, "fleet/acme"},
+ {"policy-unreadable", corruptManifest, "policy-unreadable", nil, "uk"},
+ {"edges-unreadable", func(t *testing.T, fx *consoleFixture) { fx.srv.cfg.Releases = failingList{fx.rel} }, "edges-unreadable", nil, "uk"},
+ }
+ for _, c := range cases {
+ fx, _, _ := fillFixture(t, "uk")
+ c.setup(t, fx)
+ d := fx.srv.appDefault(t.Context(), acct, "titogo")
+ okAvail := (c.available == nil && d.Available == nil) || (c.available != nil && d.Available != nil && *c.available == *d.Available)
+ if d.Reason != c.reason || !okAvail || d.Effective != c.effective || d.Region != "uk" {
+ t.Errorf("%s: %+v, want reason %q effective %q available %v", c.name, d, c.reason, c.effective, c.available)
+ }
+ }
+}
+
+// codex plan-R2 3: a fact the console could not read is never presented as
+// known: no home-valued region, no non-fleet effective.
+func TestAppDefaultKnownFlags(t *testing.T) {
+ acct := &Account{ID: OpsAccountID, Sqid: "bab"}
+ fx, _, _ := fillFixture(t, "uk")
+ if d := fx.srv.appDefault(t.Context(), acct, "titogo"); !d.RegionKnown || !d.PlacementKnown || d.Region != "uk" {
+ t.Fatalf("readable = %+v", d)
+ }
+ fx.srv.appRegionOf = func(int64, string) (string, string, error) { return "", "", errors.New("db gone") }
+ if d := fx.srv.appDefault(t.Context(), acct, "titogo"); d.RegionKnown || !d.PlacementKnown || d.Region != "" || d.Effective != "" || d.Available != nil {
+ t.Fatalf("unreadable region = %+v, want region unknown and nothing presented as fact", d)
+ }
+ fx2, _, _ := fillFixture(t, "uk")
+ corruptManifest(t, fx2)
+ if d := fx2.srv.appDefault(t.Context(), acct, "titogo"); !d.RegionKnown || d.PlacementKnown || d.Reason != "policy-unreadable" {
+ t.Fatalf("unreadable policy = %+v, want placement unknown", d)
+ }
+}
+
+// codex plan-R3 2: a pooled default is available only when the create door
+// would take it. A no-region pooled create lands on the home, so the home
+// must pass the manifest's allow-list.
+func TestPooledAvailabilityMatchesTheCreateDoor(t *testing.T) {
+ acct := &Account{ID: OpsAccountID, Sqid: "bab"}
+ for _, c := range []struct {
+ name string
+ regions []string
+ avail bool
+ }{
+ {"no allow-list", nil, true},
+ {"allow-list without the home", []string{"uk"}, false},
+ } {
+ fx, token, u := fillFixture(t, "uk")
+ m := titoManifest()
+ m.Pool, m.Regions = "tito", c.regions
+ enableInstances(t, fx, "titogo", m)
+ d := fx.srv.appDefault(t.Context(), acct, "titogo")
+ if d.Reason != "pooled" || d.Effective != "ie" || d.Available == nil || *d.Available != c.avail {
+ t.Fatalf("%s: app_default = %+v, want pooled, ie, available %v", c.name, d, c.avail)
+ }
+ status, _, out := create(t, u, token, `{"host":"p.go.tito.io"}`)
+ if created := status == http.StatusCreated; created != c.avail {
+ t.Fatalf("%s: create = %d %s, but app_default said available %v", c.name, status, out, c.avail)
+ }
+ }
+}
+
+// codex plan-R3 1: instance_domain is the domain the instances-manifest PUT
+// stamps on hosts, so the CLI can name a canary's host before any write.
+func TestAppDefaultInstanceDomain(t *testing.T) {
+ acct := &Account{ID: OpsAccountID, Sqid: "bab"}
+ fx, _, _ := fillFixture(t, "uk")
+ if d := fx.srv.appDefault(t.Context(), acct, "titogo"); d.InstanceDomain != "go.tito.io" {
+ t.Fatalf("with a manifest = %q, want the manifest's go.tito.io", d.InstanceDomain)
+ }
+ corruptManifest(t, fx)
+ if d := fx.srv.appDefault(t.Context(), acct, "titogo"); d.InstanceDomain != "" {
+ t.Fatalf("unreadable manifest = %q, want none", d.InstanceDomain)
+ }
+ fx2, token := instanceFixture(t, "code-inst-domain")
+ fx2.srv.cfg.AppsDomain = ""
+ if d := fx2.srv.appDefault(t.Context(), acct, "titogo"); d.InstanceDomain != "" {
+ t.Fatalf("no apps domain = %q, want none (the PUT refuses)", d.InstanceDomain)
+ }
+ fx2.srv.cfg.AppsDomain = "oncarlos.test"
+ before := fx2.srv.appDefault(t.Context(), acct, "titogo").InstanceDomain
+ status, _, out := rawReq(t, http.MethodPut, fx2.ts.URL+"/api/cli/apps/bab/titogo/instances-manifest", token, `{}`)
+ var m struct {
+ Domain string `json:"domain"`
+ }
+ if err := json.Unmarshal([]byte(out), &m); err != nil || status != http.StatusOK {
+ t.Fatalf("enable = %d %s", status, out)
+ }
+ if before == "" || before != m.Domain {
+ t.Fatalf("before the PUT instance_domain = %q, the PUT stamped %q", before, m.Domain)
+ }
+}
+
+// codex plan-R4 1, spec R9a: an unread stored region leaves region, name,
+// source and effective empty and available omitted, even when a fleet or a
+// pooled manifest would otherwise decide them. The placement the console
+// did read stays visible in reason.
+func TestAppDefaultUnreadRegionHidesEffective(t *testing.T) {
+ acct := &Account{ID: OpsAccountID, Sqid: "bab"}
+ for _, c := range []struct {
+ name string
+ setup func(t *testing.T, fx *consoleFixture)
+ reason string
+ }{
+ {"pooled", func(t *testing.T, fx *consoleFixture) {
+ m := titoManifest()
+ m.Pool, m.Regions = "tito", nil
+ enableInstances(t, fx, "titogo", m)
+ }, "pooled"},
+ {"fleet", func(t *testing.T, fx *consoleFixture) {
+ if err := fx.st.SetAppPlacement(OpsAccountID, "titogo", "fleet/acme"); err != nil {
+ t.Fatal(err)
+ }
+ }, "fleet"},
+ } {
+ fx, _, _ := fillFixture(t, "uk")
+ c.setup(t, fx)
+ fx.srv.appRegionOf = func(int64, string) (string, string, error) { return "", "", errors.New("db gone") }
+ d := fx.srv.appDefault(t.Context(), acct, "titogo")
+ if d.RegionKnown || !d.PlacementKnown || d.Region != "" || d.Name != "" || d.Source != "" ||
+ d.Effective != "" || d.Available != nil || d.Reason != c.reason {
+ t.Errorf("%s with an unread region = %+v, want only placement_known and reason %q", c.name, d, c.reason)
+ }
+ }
+}
+
+func TestRegionGuessEndpoint(t *testing.T) {
+ fx, token, _ := fillFixture(t, "")
+ status, _, out := rawReq(t, http.MethodPost, guessURL(fx), token, `{"tz":"Europe/London"}`)
+ if status != http.StatusOK || !strings.Contains(out, `"code":"uk"`) || !strings.Contains(out, `"source":"timezone"`) {
+ t.Fatalf("guess = %d %s", status, out)
+ }
+ // Review Focus 4: garbage hints are absent, never an error.
+ status, _, out = rawReq(t, http.MethodPost, guessURL(fx), token, `{"ip":"not-an-ip","tz":"`+strings.Repeat("a", 10<<10)+`"}`)
+ if status != http.StatusOK || !strings.Contains(out, `"source":"default"`) {
+ t.Fatalf("garbage = %d %s", status, out)
+ }
+ status, _, out = rawReq(t, http.MethodPost, guessURL(fx), token, `{not json`)
+ if status != http.StatusOK || !strings.Contains(out, `"source":"default"`) {
+ t.Fatalf("malformed body = %d %s", status, out)
+ }
+ fx.st.SetAppPlacement(OpsAccountID, "titogo", "fleet/acme")
+ if _, _, out := rawReq(t, http.MethodPost, guessURL(fx), token, `{"tz":"Europe/London"}`); !strings.Contains(out, `"guess":null`) {
+ t.Fatalf("fleet guess = %s (codex R4-1)", out)
+ }
+}
+
+func TestRegionGuessPooledNullAndUnreadable502(t *testing.T) {
+ fx, token, _ := fillFixture(t, "")
+ m := titoManifest()
+ m.Pool = "tito"
+ enableInstances(t, fx, "titogo", m)
+ if _, _, out := rawReq(t, http.MethodPost, guessURL(fx), token, `{"tz":"Europe/London"}`); !strings.Contains(out, `"guess":null`) {
+ t.Fatalf("pooled guess = %s", out)
+ }
+ corruptManifest(t, fx)
+ if status, _, out := rawReq(t, http.MethodPost, guessURL(fx), token, `{"tz":"Europe/London"}`); status != http.StatusBadGateway || !strings.Contains(out, msgPolicyUnreadable) {
+ t.Fatalf("unreadable manifest = %d %s, want 502 C27", status, out)
+ }
+ fx2, token2, _ := fillFixture(t, "")
+ fx2.srv.cfg.Registry = t.TempDir() // a directory: the registry will not open
+ if status, _, out := rawReq(t, http.MethodPost, guessURL(fx2), token2, `{"tz":"Europe/London"}`); status != http.StatusBadGateway || !strings.Contains(out, msgPolicyUnreadable) {
+ t.Fatalf("unreadable placement = %d %s, want 502 C27", status, out)
+ }
+}
+
+// codex 8: with a canonical-host redirect configured, a guess POST's hints
+// appear in no Location, no body, no log and no audit row. PF9: the endpoint
+// must first answer 2xx (a 4xx would leak nothing for the wrong reason), and
+// the no-timezone run drives the GeoIP locator path with the IP hint.
+func TestRegionGuessAPIHintsNeverLeak(t *testing.T) {
+ for _, c := range []struct {
+ name, hints string
+ secrets []string
+ wantSource string
+ }{
+ {"timezone and ip", `{"tz":"Pacific/Chatham","ip":"203.0.113.77"}`, []string{"Chatham", "203.0.113.77"}, "timezone"},
+ {"ip only, no timezone", `{"ip":"203.0.113.77"}`, []string{"203.0.113.77"}, "ip"},
+ } {
+ fx, token, _ := fillFixture(t, "")
+ fx.srv.cfg.GeoIP = fakeLocator{"203.0.113.77": geo.Point{Lat: 51.5, Lon: -0.12}}
+ fx.srv.redirects = map[string]bool{"old.example": true}
+ var logs strings.Builder
+ restore := captureLog(&logs)
+ req, _ := http.NewRequest(http.MethodPost, guessURL(fx), strings.NewReader(c.hints))
+ req.Host = "old.example"
+ req.Header.Set("Authorization", "Bearer "+token)
+ res, err := (&http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}).Do(req)
+ if err != nil {
+ restore()
+ t.Fatal(err)
+ }
+ body, _ := io.ReadAll(res.Body)
+ res.Body.Close()
+ everything := res.Header.Get("Location") + string(body)
+ status, _, out := rawReq(t, http.MethodPost, guessURL(fx), token, c.hints)
+ restore()
+ if status/100 != 2 || !strings.Contains(out, `"source":"`+c.wantSource+`"`) {
+ t.Fatalf("%s: guess = %d %s, want 2xx from source %q", c.name, status, out, c.wantSource)
+ }
+ everything += out + logs.String()
+ events, err := fx.st.Events(OpsAccountID, "titogo", 50)
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, e := range events {
+ everything += e.Detail + e.Message
+ }
+ for _, secret := range c.secrets {
+ if strings.Contains(everything, secret) {
+ t.Fatalf("%s: hint %q leaked: %s", c.name, secret, everything)
+ }
+ }
+ }
+}
+
+// PF7: when ONLY the stored region is unread, reason stays empty.
+// policy-unreadable is reserved for placement_known being false (spec §9.3).
+func TestAppDefaultUnreadRegionAloneLeavesReasonEmpty(t *testing.T) {
+ acct := &Account{ID: OpsAccountID, Sqid: "bab"}
+ fx, _, _ := fillFixture(t, "uk")
+ fx.srv.appRegionOf = func(int64, string) (string, string, error) { return "", "", errors.New("db gone") }
+ d := fx.srv.appDefault(t.Context(), acct, "titogo")
+ if d.RegionKnown || !d.PlacementKnown || d.Reason != "" || d.Effective != "" || d.Available != nil || d.Region != "" {
+ t.Fatalf("unread region alone = %+v, want empty reason, effective and available", d)
+ }
+}
+
+// PF10: the legacy fields older clients and titogo read are unchanged for an
+// app with no stored region. The base answer was
+//
+// {"default":"ie","regions":[{"code":"ie","armed":true},{"code":"uk","armed":true}]}
+//
+// Entries gain aws_region and name (the brief's additive fields), so each
+// entry is compared on the two keys it had, in the order it had them.
+func TestRegionsLegacyFieldsGolden(t *testing.T) {
+ fx, token, _ := fillFixture(t, "")
+ status, _, out := rawReq(t, http.MethodGet, regionsURL(fx), token, "")
+ var res struct {
+ Default json.RawMessage `json:"default"`
+ Regions []json.RawMessage `json:"regions"`
+ }
+ if err := json.Unmarshal([]byte(out), &res); err != nil || status != http.StatusOK {
+ t.Fatalf("regions = %d %s", status, out)
+ }
+ var legacy []struct {
+ Code string `json:"code"`
+ Armed bool `json:"armed"`
+ }
+ for _, raw := range res.Regions {
+ var e struct {
+ Code string `json:"code"`
+ Armed bool `json:"armed"`
+ }
+ if err := json.Unmarshal(raw, &e); err != nil {
+ t.Fatal(err)
+ }
+ legacy = append(legacy, e)
+ }
+ got, _ := json.Marshal(legacy)
+ if string(got) != `[{"code":"ie","armed":true},{"code":"uk","armed":true}]` || string(res.Default) != `"ie"` {
+ t.Fatalf("legacy fields drifted: regions %s default %s", got, res.Default)
+ }
+ for _, raw := range res.Regions {
+ if !strings.HasPrefix(string(raw), `{"code":"`) || !strings.Contains(string(raw), `","armed":true`) {
+ t.Fatalf("entry %s lost its legacy key order", raw)
+ }
+ }
+}
diff --git a/internal/console/svcroutes_test.go b/internal/console/svcroutes_test.go
index b966f2d64..add8583c4 100644
--- a/internal/console/svcroutes_test.go
+++ b/internal/console/svcroutes_test.go
@@ -355,14 +355,19 @@ func TestNoRoleReachesAMintFleetOrPolicyDoor(t *testing.T) {
// read these very rows through GET .../errors, so granting it here widens
// nothing — refusing it would be the change.
var exemptFromLegacyPin = map[string]bool{
- "GET /api/cli/apps": true,
- "GET /api/cli/apps/{account}/{app}/errors": true,
- "GET /api/cli/apps/{account}/{app}/availability": true,
- "GET /api/cli/apps/{account}/{app}/analytics": true,
- "GET /api/cli/apps/{account}/{app}/domains/{host}": true,
- "PUT /api/cli/apps/{account}/{app}/domains/{host}/paths": true,
- "GET /api/cli/apps/{account}/{app}/features": true,
- "GET /api/cli/apps/{account}/{app}/regions": true,
+ "GET /api/cli/apps": true,
+ "GET /api/cli/apps/{account}/{app}/errors": true,
+ "GET /api/cli/apps/{account}/{app}/availability": true,
+ "GET /api/cli/apps/{account}/{app}/analytics": true,
+ "GET /api/cli/apps/{account}/{app}/domains/{host}": true,
+ "PUT /api/cli/apps/{account}/{app}/domains/{host}/paths": true,
+ "GET /api/cli/apps/{account}/{app}/features": true,
+ "GET /api/cli/apps/{account}/{app}/regions": true,
+ // The region guess (nearest-region spec §9.4) stands on GET .../regions'
+ // footing: the same deployment topology, filtered by the app's own
+ // policy, for a hint the caller supplies. It did not exist before
+ // service principals, and titogo's per-instance picker is its consumer.
+ "POST /api/cli/apps/{account}/{app}/regions/guess": true,
"GET /api/cli/apps/{account}/{app}/instances/{host}/move": true,
"POST /api/cli/apps/{account}/{app}/instances/{host}/move": true,
"POST /api/cli/apps/{account}/{app}/instances/{host}/move/cut": true,