carlos / platform Public

console: resolve the app region inside claimApp; API create takes region and timezone (nearest-region §5.5, §8.1, §9.1, R9)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 28e42e5882fac07f170ceddfbae73662493a7a03 parent eff34e1
6 files changed, +416 −13
  • internal/console/apicli.go +31 −5
  • internal/console/apicli_test.go +1 −1
  • internal/console/apps_claim_test.go +4 −4
  • internal/console/regionclaim.go +142 −0
  • internal/console/regionclaim_test.go +228 −0
  • internal/console/server.go +10 −3
diff --git a/internal/console/apicli.go b/internal/console/apicli.go
index 805bea56d..a4b740f63 100644
--- a/internal/console/apicli.go
+++ b/internal/console/apicli.go
@@ -361,6 +361,8 @@ func (s *Server) handleAPICreateApp(w http.ResponseWriter, r *http.Request) {
Place string `json:"place"`
ControlPlane bool `json:"control_plane"`
BuildProbe string `json:"build_probe"`
+ Region string `json:"region"`
+ Timezone string `json:"timezone"`
}
if !readJSON(w, r, &req) {
return
@@ -407,12 +409,23 @@ func (s *Server) handleAPICreateApp(w http.ResponseWriter, r *http.Request) {
return
}
}
+ // The region is resolved inside claimApp, once it knows the claim is not
+ // an adoption. The timezone and the address only ever feed that guess.
+ rq := regionReq{tz: req.Timezone, ip: s.personIP(r)}
+ if strings.TrimSpace(req.Region) != "" {
+ code, ref := s.validateExplicitRegion(r.Context(), req.Region)
+ if ref != nil {
+ jsonError(w, ref.Status, ref.Msg)
+ return
+ }
+ rq.explicit, rq.hasExplicit = code, true
+ }
// ownerClaim: a PERSON who owns this account may adopt objects already
// under its own prefix (#79). A service principal never may — it holds no
// account role, and handing a CI credential the power to adopt release
// history is exactly the widening svcrole exists to prevent.
ownerClaim := p.Kind == PrincipalUser && acct.Role == RoleOwner
- outcome, placed, held := s.claimApp(r.Context(), acct, name, addr, place, ownerClaim)
+ outcome, placed, held := s.claimApp(r.Context(), acct, name, addr, place, ownerClaim, rq)
switch outcome {
case claimInvalid:
jsonError(w, http.StatusBadRequest,
@@ -458,12 +471,25 @@ func (s *Server) handleAPICreateApp(w http.ResponseWriter, r *http.Request) {
if err != nil {
log.Printf("console: claim %s/%s: read back flags: %v", acct.Sqid, name, err)
}
+ gotRegion, gotSource, err := s.store.AppRegion(acct.ID, name)
+ if err != nil {
+ log.Printf("console: claim %s/%s: read back region: %v", acct.Sqid, name, err)
+ }
+ files, ferr := readEdgesSnapshot(r.Context(), s.cfg.Releases)
+ cands := homeOnlyCandidates()
+ if ferr == nil {
+ cands = s.regionCands(files)
+ }
writeJSON(w, http.StatusOK, map[string]any{
"account": acct.Sqid, "app": name,
- "url": s.cfg.Origin + "/a/" + url.PathEscape(acct.Sqid) + "/apps/" + url.PathEscape(name),
- "placement": placed,
- "control_plane": gotCP,
- "build_probe": gotProbe,
+ "url": s.cfg.Origin + "/a/" + url.PathEscape(acct.Sqid) + "/apps/" + url.PathEscape(name),
+ "placement": placed,
+ "control_plane": gotCP,
+ "build_probe": gotProbe,
+ "region": s.homeRegionOut(gotRegion),
+ "region_name": s.placeOf(gotRegion, cands),
+ "region_source": gotSource,
+ "regions_available": eligibleCount(cands),
})
default:
// Any future claimOutcome lands here rather than silently 200ing
diff --git a/internal/console/apicli_test.go b/internal/console/apicli_test.go
index 3b61d6e36..33a57533e 100644
--- a/internal/console/apicli_test.go
+++ b/internal/console/apicli_test.go
@@ -2501,7 +2501,7 @@ func TestClaimAppPlacementWriteFailureDoesNotUndoClaim(t *testing.T) {
}
acct := &Account{ID: OpsAccountID, Sqid: fx.sqid}
- outcome, placed, _ := fx.srv.claimApp(context.Background(), acct, "titogo", "paul@keymail.dev", "fleet/pi", false)
+ outcome, placed, _ := fx.srv.claimApp(context.Background(), acct, "titogo", "paul@keymail.dev", "fleet/pi", false, regionReq{})
if outcome != claimOK {
t.Fatalf("claimApp outcome = %v, want claimOK — the claim itself must still succeed", outcome)
}
diff --git a/internal/console/apps_claim_test.go b/internal/console/apps_claim_test.go
index aa958c0dd..ec0b0c60d 100644
--- a/internal/console/apps_claim_test.go
+++ b/internal/console/apps_claim_test.go
@@ -115,7 +115,7 @@ func TestOwnerClaimsAppWhoseObjectsExist(t *testing.T) {
fx := newConsoleFixture(t)
seedBucketApp(t, fx, "bab", "console")
acct := opsAccount(t, fx)
- outcome, _, _ := fx.srv.claimApp(context.Background(), acct, "console", "paul@keymail.dev", "", true)
+ outcome, _, _ := fx.srv.claimApp(context.Background(), acct, "console", "paul@keymail.dev", "", true, regionReq{})
if outcome != claimOK {
t.Fatalf("owner claim over existing objects = %v, want claimOK", outcome)
}
@@ -131,7 +131,7 @@ func TestNonOwnerStillRefusedOverExistingObjects(t *testing.T) {
fx := newConsoleFixture(t)
seedBucketApp(t, fx, "bab", "console")
acct := opsAccount(t, fx)
- outcome, _, _ := fx.srv.claimApp(context.Background(), acct, "console", "member@keymail.dev", "", false)
+ outcome, _, _ := fx.srv.claimApp(context.Background(), acct, "console", "member@keymail.dev", "", false, regionReq{})
if outcome != claimTaken {
t.Fatalf("non-owner claim over existing objects = %v, want claimTaken", outcome)
}
@@ -147,7 +147,7 @@ func TestLegacyPrefixStillBlocksAnOwner(t *testing.T) {
t.Fatal(err)
}
acct := opsAccount(t, fx)
- outcome, _, _ := fx.srv.claimApp(context.Background(), acct, "oldsite", "paul@keymail.dev", "", true)
+ outcome, _, _ := fx.srv.claimApp(context.Background(), acct, "oldsite", "paul@keymail.dev", "", true, regionReq{})
if outcome != claimTaken {
t.Fatalf("owner claim over a LEGACY prefix = %v, want claimTaken — those objects are owed a migrate", outcome)
}
@@ -162,7 +162,7 @@ func TestGrantStillBlocksAnOwner(t *testing.T) {
t.Fatal(err)
}
acct := opsAccount(t, fx)
- outcome, _, _ := fx.srv.claimApp(context.Background(), acct, "derp", "paul@keymail.dev", "", true)
+ outcome, _, _ := fx.srv.claimApp(context.Background(), acct, "derp", "paul@keymail.dev", "", true, regionReq{})
if outcome != claimTaken {
t.Fatalf("owner claim over a GRANTED name = %v, want claimTaken", outcome)
}
diff --git a/internal/console/regionclaim.go b/internal/console/regionclaim.go
new file mode 100644
index 000000000..e5c32cafc
--- /dev/null
+++ b/internal/console/regionclaim.go
@@ -0,0 +1,142 @@
+package console
+
+import (
+ "context"
+ "net"
+ "net/http"
+ "sort"
+ "strings"
+
+ "github.com/carlosframework/platform/internal/geo"
+ "github.com/carlosframework/platform/internal/provision"
+)
+
+// regionReq is what a create door hands claimApp: never a resolved region.
+// The guess is resolved INSIDE claimApp, after it knows whether this is an
+// adoption (spec §5.5). ip is request-local and only set for a person. The
+// timezone and the address go into the guess and nowhere else: never
+// logged, stored, audited or echoed (spec §6.5).
+type regionReq struct {
+ explicit string // a person's choice, record vocabulary
+ hasExplicit bool
+ tz string
+ ip net.IP // request-local, person requests only
+}
+
+// validateExplicitRegion checks a person's explicit region as the instance
+// door checks one (spec §8.1): ValidRegion; armed and hosting unless the
+// gate is dormant; not provably without the secrets key (R9). The home's
+// name normalizes to "". A refusal lists exactly the codes this same check
+// would accept now (codex plan-R4 2).
+func (s *Server) validateExplicitRegion(ctx context.Context, raw string) (string, *doorRefusal) {
+ code := s.homeRegionIn(strings.TrimSpace(raw))
+ if code == "" {
+ return "", nil
+ }
+ gate := s.readExplicitGate(ctx)
+ switch gate.refusal(code) {
+ case "":
+ return code, nil
+ case "unknown":
+ return "", &doorRefusal{http.StatusBadRequest, msgRegionUnknown(code, s.explicitChoices(gate))}
+ case "not-armed":
+ return "", &doorRefusal{http.StatusBadRequest, msgRegionNotArmed(code, s.explicitChoices(gate))}
+ }
+ return "", &doorRefusal{http.StatusBadRequest, msgNoSecretsKey(code)}
+}
+
+// explicitGate is one read of what an explicit app region must pass.
+type explicitGate struct {
+ caps map[string]placementCapability
+ published bool
+ cands []regionCandidate // nil when the edges read failed: R9 cannot refuse
+}
+
+func (s *Server) readExplicitGate(ctx context.Context) explicitGate {
+ g := explicitGate{}
+ g.caps, g.published = placements(ctx, s.cfg.Releases)
+ if snap, err := readEdgesSnapshot(ctx, s.cfg.Releases); err == nil {
+ g.cands = s.regionCands(snap)
+ }
+ return g
+}
+
+// refusal is "" when an explicit, record-vocabulary code passes, else
+// which bar it fails: "unknown", "not-armed" or "no-key".
+func (g explicitGate) refusal(code string) string {
+ switch {
+ case code == "":
+ return ""
+ case !provision.ValidRegion(code):
+ return "unknown"
+ case g.published && !(g.caps[code].Reachable && g.caps[code].Hosting):
+ return "not-armed"
+ }
+ if c, ok := findCandidate(g.cands, code); ok && c.NoKeyProven {
+ return "no-key"
+ }
+ return ""
+}
+
+// explicitChoices is the home's name plus every published code that
+// refusal passes, sorted: never a code this door would refuse.
+func (s *Server) explicitChoices(g explicitGate) []string {
+ set := map[string]bool{}
+ if s.cfg.HomeRegion != "" {
+ set[s.cfg.HomeRegion] = true
+ }
+ for code := range g.caps {
+ if c := s.homeRegionIn(code); c != "" && g.refusal(c) == "" {
+ set[c] = true
+ }
+ }
+ out := make([]string, 0, len(set))
+ for c := range set {
+ out = append(out, c)
+ }
+ sort.Strings(out)
+ return out
+}
+
+// resolveNewAppRegion is spec §5.5 and §5.6 for a NEW row. A guess must
+// pass the same predicate that builds the C18 and C19 lists
+// (explicitGate.refusal), so no door ever stores a region another door
+// would refuse to list.
+func (s *Server) resolveNewAppRegion(ctx context.Context, rq regionReq, adopting bool) (region, source string) {
+ if !s.appRegionsOn() {
+ return "", "default"
+ }
+ if rq.hasExplicit {
+ return rq.explicit, "chosen"
+ }
+ if adopting {
+ return "", "default"
+ }
+ g := s.readExplicitGate(ctx)
+ if g.cands == nil {
+ return "", "default"
+ }
+ guess, ok := guessRegion(g.cands, func(code string) bool { return g.refusal(code) == "" },
+ guessInput{TZ: rq.tz, IP: rq.ip}, s.cfg.GeoIP)
+ if !ok {
+ return "", "default"
+ }
+ return guess.Code, guess.Source
+}
+
+// placeOf names a record-vocabulary code: its AWS region's place, else the
+// code under its name.
+func (s *Server) placeOf(code string, cands []regionCandidate) string {
+ c, _ := findCandidate(cands, code)
+ return geo.PlaceName(c.AWSRegion, s.homeRegionOut(code))
+}
+
+// personIP is the request's own address, for a person only (spec §6.2).
+// A service or app principal is a server; its address says nothing about
+// where a person is.
+func (s *Server) personIP(r *http.Request) net.IP {
+ if p, ok := principalFrom(r.Context()); ok && p.Kind != PrincipalUser {
+ return nil
+ }
+ return net.ParseIP(s.clientIP(r))
+}
diff --git a/internal/console/regionclaim_test.go b/internal/console/regionclaim_test.go
new file mode 100644
index 000000000..7f3914d7e
--- /dev/null
+++ b/internal/console/regionclaim_test.go
@@ -0,0 +1,228 @@
+package console
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/geo"
+ "github.com/carlosframework/platform/internal/registry"
+ "github.com/carlosframework/platform/internal/svcrole"
+)
+
+func regionFixture(t *testing.T) (*consoleFixture, string) {
+ t.Helper()
+ fx := newConsoleFixture(t)
+ fx.srv.cfg.AppRegions, fx.srv.cfg.HomeRegion, fx.srv.cfg.SecretsPub = true, "ie", testKeyPub
+ setEdges(t, fx, map[string]string{"ie-box": homeBox, "uk-box": ukEligible})
+ return fx, cliToken(t, fx, "paul@keymail.dev", "code-region-claim")
+}
+
+func newAppURL(fx *consoleFixture) string { return fx.ts.URL + "/api/cli/apps/bab/new" }
+
+// postJSONFrom is postJSON with an X-Forwarded-For, which the console
+// trusts from the loopback edge (authcli.go clientIP).
+func postJSONFrom(t *testing.T, u string, body any, bearer, xff string) (int, map[string]any) {
+ t.Helper()
+ raw, _ := json.Marshal(body)
+ req, err := http.NewRequest(http.MethodPost, u, bytes.NewReader(raw))
+ if err != nil {
+ t.Fatal(err)
+ }
+ req.Header.Set("Content-Type", "application/json")
+ req.Header.Set("Authorization", "Bearer "+bearer)
+ req.Header.Set("X-Forwarded-For", xff)
+ return doJSON(t, req)
+}
+
+func errOf(res map[string]any) string { s, _ := res["error"].(string); return s }
+
+var ukPlace = geo.PlaceName("eu-west-2", "uk")
+
+func TestAPICreateAppResolvesRegion(t *testing.T) {
+ fx, token := regionFixture(t)
+ status, res := postJSON(t, newAppURL(fx), map[string]string{"name": "blog", "timezone": "Europe/London"}, token)
+ if status != http.StatusOK || res["region"] != "uk" || res["region_source"] != "timezone" ||
+ res["region_name"] != ukPlace || res["regions_available"] != float64(2) {
+ t.Fatalf("create = %d %v", status, res)
+ }
+ if r, src, _ := fx.st.AppRegion(OpsAccountID, "blog"); r != "uk" || src != "timezone" {
+ t.Fatalf("stored %q %q", r, src)
+ }
+ // An old CLI's body, from loopback with no forwarded address.
+ if _, res = postJSON(t, newAppURL(fx), map[string]string{"name": "old"}, token); res["region"] != "ie" || res["region_source"] != "default" {
+ t.Fatalf("old body = %v", res)
+ }
+ // An explicit home name is a choice, stored as "".
+ postJSON(t, newAppURL(fx), map[string]string{"name": "athome", "region": "ie"}, token)
+ if r, src, _ := fx.st.AppRegion(OpsAccountID, "athome"); r != "" || src != "chosen" {
+ t.Fatalf("explicit home = %q %q", r, src)
+ }
+}
+
+// The old-body path through the real door: a person's forwarded address
+// is located; a service principal's never is (spec §6.2).
+func TestAPICreateAppLocatesOnlyAPerson(t *testing.T) {
+ fx, token := regionFixture(t)
+ fx.srv.cfg.GeoIP = fakeLocator{"81.2.69.142": {Lat: 51.5, Lon: -0.12}}
+ if status, res := postJSONFrom(t, newAppURL(fx), map[string]string{"name": "person"}, token, "81.2.69.142"); status != http.StatusOK ||
+ res["region"] != "uk" || res["region_source"] != "ip" {
+ t.Fatalf("person = %d %v", status, res)
+ }
+ svc := serviceToken(t, fx.st, OpsAccountID, "hub", "", svcrole.Publish)
+ if status, res := postJSONFrom(t, newAppURL(fx), map[string]string{"name": "hubapp"}, svc, "81.2.69.142"); status != http.StatusOK ||
+ res["region"] != "ie" || res["region_source"] != "default" {
+ t.Fatalf("service = %d %v, want no IP step", status, res)
+ }
+}
+
+func TestAPICreateAppRefusesBadExplicitRegions(t *testing.T) {
+ fx, token := regionFixture(t)
+ // nz cannot host; au's current-writer box lacks the key (R9).
+ auNoKey := edgeJSON("au-box", "9.9.9.8", "ap-southeast-2", "au", edgeOpts{hib: bp(true), shared: bp(true), inbound: bp(true)})
+ setEdges(t, fx, map[string]string{"nz-box": nzNoHosting, "au-box": auNoKey})
+ // codex plan-R4 2: each list names exactly what this door would take.
+ choices := []string{"ie", "uk"}
+ cases := []struct{ name, region, want string }{
+ {"xa", "Bad/Code", msgRegionUnknown("Bad/Code", choices)},
+ {"xb", "nz", msgRegionNotArmed("nz", choices)},
+ {"xc", "au", msgNoSecretsKey("au")},
+ }
+ for _, c := range cases {
+ status, res := postJSON(t, newAppURL(fx), map[string]string{"name": c.name, "region": c.region}, token)
+ if status != http.StatusBadRequest || errOf(res) != c.want {
+ t.Errorf("region %q = %d %q, want 400 %q", c.region, status, errOf(res), c.want)
+ }
+ if has, _ := fx.st.HasAppIn(OpsAccountID, c.name); has {
+ t.Errorf("a refused region still claimed %s", c.name)
+ }
+ }
+ // Every advertised choice is accepted by the same door, same fixture.
+ for i, code := range choices {
+ name := fmt.Sprintf("alt%d", i)
+ if status, res := postJSON(t, newAppURL(fx), map[string]string{"name": name, "region": code}, token); status != http.StatusOK {
+ t.Errorf("advertised %q refused: %d %v", code, status, res)
+ }
+ }
+ // R9: a region whose current-writer boxes lack the key.
+ setEdges(t, fx, map[string]string{"uk-box": ukNoKey})
+ if status, res := postJSON(t, newAppURL(fx), map[string]string{"name": "xd", "region": "uk"}, token); status != http.StatusBadRequest || errOf(res) != msgNoSecretsKey("uk") {
+ t.Fatalf("keyless uk = %d %v, want C31", status, res)
+ }
+}
+
+func TestAPICreateAppSwitchOffStoresTheHome(t *testing.T) {
+ fx, token := regionFixture(t)
+ fx.srv.cfg.AppRegions = false
+ _, res := postJSON(t, newAppURL(fx), map[string]string{"name": "off", "region": "uk", "timezone": "Europe/London"}, token)
+ if r, src, _ := fx.st.AppRegion(OpsAccountID, "off"); r != "" || src != "default" || res["region"] != "ie" {
+ t.Fatalf("switch off = %q %q %v", r, src, res)
+ }
+ fx.srv.cfg.AppRegions, fx.srv.cfg.HomeRegion = true, ""
+ // PF9: the second half must not pass on a failed create.
+ if status, res := postJSON(t, newAppURL(fx), map[string]string{"name": "unnamed", "region": "uk"}, token); status != http.StatusOK {
+ t.Fatalf("unnamed-home create = %d %v", status, res)
+ }
+ if r, _, _ := fx.st.AppRegion(OpsAccountID, "unnamed"); r != "" {
+ t.Fatalf("unnamed home stored %q", r)
+ }
+}
+
+func TestClaimAdoptionDiscardsTheGuess(t *testing.T) {
+ fx, _ := regionFixture(t)
+ acct := &Account{ID: OpsAccountID, Sqid: "bab"}
+ seedBucketApp(t, fx, "bab", "oldsite")
+ if out, _, _ := fx.srv.claimApp(context.Background(), acct, "oldsite", "paul@keymail.dev", "", true, regionReq{tz: "Europe/London"}); out != claimOK {
+ t.Fatalf("adoption = %v", out)
+ }
+ if r, src, _ := fx.st.AppRegion(OpsAccountID, "oldsite"); r != "" || src != "default" {
+ t.Fatalf("adoption with a guess = %q %q", r, src)
+ }
+ seedBucketApp(t, fx, "bab", "oldsite2")
+ fx.srv.claimApp(context.Background(), acct, "oldsite2", "paul@keymail.dev", "", true, regionReq{explicit: "uk", hasExplicit: true})
+ if r, src, _ := fx.st.AppRegion(OpsAccountID, "oldsite2"); r != "uk" || src != "chosen" {
+ t.Fatalf("adoption with an explicit choice = %q %q", r, src)
+ }
+}
+
+// codex plan-R6 1, spec §6.5: every successful creation logs exactly one
+// region line, naming what was stored, whatever the switch says: on, off,
+// and on with an unnamed home (which keeps assignment off).
+func TestClaimLogsOneRegionLineInEverySwitchState(t *testing.T) {
+ for _, c := range []struct {
+ name, app, home string
+ on bool
+ stored, source string
+ line string
+ }{
+ {"assignment on", "on1", "ie", true, "uk", "chosen", "console: app region bab/on1: uk (chosen)"},
+ {"assignment off", "off1", "ie", false, "", "default", "console: app region bab/off1: ie (default)"},
+ {"on, home unnamed", "unnamed1", "", true, "", "default", "console: app region bab/unnamed1: (default)"},
+ } {
+ fx, token := regionFixture(t)
+ fx.srv.cfg.AppRegions, fx.srv.cfg.HomeRegion = c.on, c.home
+ var logs bytes.Buffer
+ restore := captureLog(&logs)
+ status, res := postJSON(t, newAppURL(fx), map[string]string{"name": c.app, "region": "uk"}, token)
+ restore()
+ if status != http.StatusOK {
+ t.Fatalf("%s: create = %d %v", c.name, status, res)
+ }
+ if r, src, _ := fx.st.AppRegion(OpsAccountID, c.app); r != c.stored || src != c.source {
+ t.Fatalf("%s: stored %q %q, want %q %q", c.name, r, src, c.stored, c.source)
+ }
+ if n := strings.Count(logs.String(), "console: app region bab/"+c.app+":"); n != 1 || !strings.Contains(logs.String(), c.line+"\n") {
+ t.Errorf("%s: %d region line(s) in %q, want exactly %q", c.name, n, logs.String(), c.line)
+ }
+ }
+}
+
+// Review Focus 5: a competing insert that lands AFTER the region was
+// resolved loses the claim: no row of ours, no region log line.
+func TestClaimLostAfterResolutionWritesNothing(t *testing.T) {
+ fx, token := regionFixture(t)
+ fx.srv.beforeClaimInsert = func() {
+ if _, err := fx.st.CreateAppInRegion(OpsAccountID, "racy", "rival@k.dev", "", "chosen", time.Now()); err != nil {
+ t.Error(err)
+ }
+ }
+ var logs bytes.Buffer
+ restore := captureLog(&logs)
+ status, _ := postJSON(t, newAppURL(fx), map[string]string{"name": "racy", "region": "uk"}, token)
+ restore()
+ if status != http.StatusConflict {
+ t.Fatalf("lost race = %d, want 409", status)
+ }
+ if r, src, _ := fx.st.AppRegion(OpsAccountID, "racy"); r != "" || src != "chosen" {
+ t.Fatalf("the rival's row changed: %q %q", r, src)
+ }
+ if strings.Contains(logs.String(), "app region bab/racy") {
+ t.Fatalf("a lost claim logged a region line: %s", logs.String())
+ }
+}
+
+// Review Focus 5: a held apex refuses before resolution, so nothing is
+// written or logged.
+func TestClaimHeldApexWritesNothing(t *testing.T) {
+ fx, token := regionFixture(t)
+ fx.srv.cfg.AppsDomain = "oncarlos.test"
+ fx.addRoute(t, registry.Route{Host: "beta." + fx.sqid + ".oncarlos.test", App: "kass", Account: fx.sqid, Kind: "instance"})
+ var logs bytes.Buffer
+ restore := captureLog(&logs)
+ status, _ := postJSON(t, newAppURL(fx), map[string]string{"name": "beta", "timezone": "Europe/London"}, token)
+ restore()
+ if status != http.StatusConflict {
+ t.Fatalf("held apex = %d, want 409", status)
+ }
+ if has, _ := fx.st.HasAppIn(OpsAccountID, "beta"); has {
+ t.Fatal("beta was created")
+ }
+ if strings.Contains(logs.String(), "app region bab/beta") {
+ t.Fatalf("a refused claim logged a region line: %s", logs.String())
+ }
+}
diff --git a/internal/console/server.go b/internal/console/server.go
index 08434c849..0c3a38f71 100644
--- a/internal/console/server.go
+++ b/internal/console/server.go
@@ -3524,7 +3524,7 @@ func ownerExempt(prefix, ownPrefix string) bool { return prefix == ownPrefix }
//
// held is set only with claimApexHeld: who holds the name's platform apex, so
// a door can say so without asking again.
-func (s *Server) claimApp(ctx context.Context, acct *Account, name, addr, placement string, ownerClaim bool) (outcome claimOutcome, placed string, held *apexHold) {
+func (s *Server) claimApp(ctx context.Context, acct *Account, name, addr, placement string, ownerClaim bool, rq regionReq) (outcome claimOutcome, placed string, held *apexHold) {
if !release.ValidApp(name) {
return claimInvalid, "", nil
}
@@ -3608,13 +3608,20 @@ func (s *Server) claimApp(ctx context.Context, acct *Account, name, addr, placem
}
return claimTaken, "", nil
}
- created, err := s.store.CreateAppIn(acct.ID, name, addr, time.Now())
+ region, source := s.resolveNewAppRegion(ctx, rq, adoptingExistingApp)
+ if s.beforeClaimInsert != nil {
+ s.beforeClaimInsert()
+ }
+ created, err := s.store.CreateAppInRegion(acct.ID, name, addr, region, source, time.Now())
if err != nil {
return claimCreateFailed, "", nil
}
if !created { // lost a claim race after the checks
return claimTaken, "", nil
}
+ // Spec §6.5: one line per creation, in every switch state (codex
+ // plan-R6 1). Only reached once the row is ours.
+ log.Printf("console: app region %s/%s: %s (%s)", acct.Sqid, name, s.homeRegionOut(region), source)
if placement != "" {
if err := s.store.SetAppPlacement(acct.ID, name, placement); err != nil {
// Best-effort, like ensureAccountManifest below: the claim itself
@@ -3684,7 +3691,7 @@ func (s *Server) handleNewApp(w http.ResponseWriter, r *http.Request) {
// The browser door never places at create time, so the second return
// (what actually stuck) is always "" here and there is nothing to echo
// it to — this form has no placement concept at all.
- outcome, _, held := s.claimApp(r.Context(), acct, name, addr, "", role == RoleOwner)
+ outcome, _, held := s.claimApp(r.Context(), acct, name, addr, "", role == RoleOwner, regionReq{})
switch outcome {
case claimInvalid:
fail("Invalid app name — lowercase letters, digits, and dashes, starting with a letter.")