carlos / platform Public

console: new-app region picker with @auto, browser guess route (nearest-region §10, §9.5)

The browser door validates an explicit region through the API door's
validateExplicitRegion and skips it when the switch is off (§9.6). C24
renders as a link to db-ip.com when GeoIP is configured.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev a506407342dd1ffe27efe246ae9242e0fd9c800d parent 69a8422
4 files changed, +403 −3
  • internal/console/regionpicker.go +97 −0
  • internal/console/regionpicker_test.go +258 −0
  • internal/console/server.go +19 −2
  • internal/console/templates/app_new.html +29 −1
diff --git a/internal/console/regionpicker.go b/internal/console/regionpicker.go
new file mode 100644
index 000000000..bdba50856
--- /dev/null
+++ b/internal/console/regionpicker.go
@@ -0,0 +1,97 @@
+package console
+
+import (
+ "context"
+ "crypto/subtle"
+ "fmt"
+ "net"
+ "net/http"
+ "strings"
+)
+
+// autoRegion is the picker's "let the server guess" value. "@" is outside
+// the placement-code grammar, so no real region collides with it (codex
+// R2-3).
+const autoRegion = "@auto"
+
+type regionOption struct{ Value, Label string }
+
+type regionPickerView struct {
+ Auto string // the @auto option's label (C3)
+ Options []regionOption
+ Attribution bool // C24, when the IP step is configured
+}
+
+// newAppPicker builds the picker, or nil: switch off, an edges read
+// failure, or fewer than two eligible regions (§10). The server's own
+// guess is IP, then home; the page script relabels @auto with the
+// browser's timezone guess.
+func (s *Server) newAppPicker(ctx context.Context, ip net.IP) *regionPickerView {
+ if !s.appRegionsOn() {
+ return nil
+ }
+ files, err := readEdgesSnapshot(ctx, s.cfg.Releases)
+ if err != nil {
+ return nil
+ }
+ cands := s.regionCands(files)
+ if eligibleCount(cands) < 2 {
+ return nil
+ }
+ g, _ := guessRegion(cands, func(string) bool { return true }, guessInput{IP: ip}, s.cfg.GeoIP)
+ v := &regionPickerView{
+ Auto: fmt.Sprintf(regionCopy.AutoOption, s.placeOf(g.Code, cands)),
+ Attribution: s.cfg.GeoIP != nil,
+ }
+ for _, c := range cands {
+ if c.Eligible {
+ v.Options = append(v.Options, regionOption{Value: s.homeRegionOut(c.Code), Label: s.placeOf(c.Code, cands)})
+ }
+ }
+ return v
+}
+
+// pickedRegion turns the form's region field into a regionReq. ok=false
+// means the submitted code is not an acceptable region NOW (C5). It is the
+// API door's check (validateExplicitRegion), because claimApp does not
+// re-validate an explicit region; with the switch off an explicit region is
+// neither validated nor refused (spec §9.6, as apicli.go does).
+func (s *Server) pickedRegion(ctx context.Context, r *http.Request) (regionReq, bool) {
+ rq := regionReq{tz: r.FormValue("tz"), ip: s.personIP(r)}
+ v := strings.TrimSpace(r.FormValue("region"))
+ if v == "" || v == autoRegion || !s.appRegionsOn() {
+ return rq, true
+ }
+ code, ref := s.validateExplicitRegion(ctx, v)
+ if ref != nil {
+ return rq, false
+ }
+ rq.explicit, rq.hasExplicit = code, true
+ return rq, true
+}
+
+// handleNewAppRegion is the page script's guess (spec §9.5): POST with csrf
+// and tz, session-gated, writing nothing. The hints are read from the body,
+// used for this one guess and neither logged nor echoed (spec §6.5).
+func (s *Server) handleNewAppRegion(w http.ResponseWriter, r *http.Request) {
+ _, raw, _, _, ok := s.account(w, r)
+ if !ok {
+ return
+ }
+ if subtle.ConstantTimeCompare([]byte(r.FormValue("csrf")), []byte(raw)) != 1 {
+ jsonError(w, http.StatusForbidden, "Cross-site request blocked.")
+ return
+ }
+ files, err := readEdgesSnapshot(r.Context(), s.cfg.Releases)
+ cands := homeOnlyCandidates()
+ if err == nil {
+ cands = s.regionCands(files)
+ }
+ g, ok := guessRegion(cands, func(string) bool { return true }, guessInput{TZ: r.FormValue("tz"), IP: s.personIP(r)}, s.cfg.GeoIP)
+ if !ok {
+ g = guessResult{"", "default"}
+ }
+ writeJSON(w, http.StatusOK, map[string]string{
+ "code": s.homeRegionOut(g.Code), "name": s.placeOf(g.Code, cands), "source": g.Source,
+ })
+}
diff --git a/internal/console/regionpicker_test.go b/internal/console/regionpicker_test.go
new file mode 100644
index 000000000..2a36efc90
--- /dev/null
+++ b/internal/console/regionpicker_test.go
@@ -0,0 +1,258 @@
+package console
+
+import (
+ "bytes"
+ "encoding/json"
+ "html"
+ "log"
+ "net/http"
+ "net/url"
+ "os"
+ "regexp"
+ "strings"
+ "testing"
+
+ "github.com/carlosframework/platform/internal/geo"
+)
+
+func has(body, plain string) bool { return strings.Contains(body, html.EscapeString(plain)) }
+
+// newAppFormGolden is the base tree's own rendering of the create-app form
+// (the branch base, switch off), with the session's CSRF value blanked.
+const newAppFormGolden = `<form class="signin" method="post" action="/a/bab/apps/new">
+ <input type="hidden" name="csrf" value="CSRF">
+ <label class="visually-hidden" for="app-name">App name</label>
+ <input id="app-name" type="text" name="name" placeholder="my-app" autofocus
+ required pattern="[A-Za-z][A-Za-z0-9-]{0,31}" maxlength="32">
+ <button type="submit">Create app</button>
+</form>`
+
+// newAppForm is the rendered <form>…</form>, CSRF value blanked.
+func newAppForm(t *testing.T, body string) string {
+ t.Helper()
+ i := strings.Index(body, `<form class="signin"`)
+ if i < 0 {
+ t.Fatalf("no create-app form in %s", body)
+ }
+ j := strings.Index(body[i:], "</form>") + i + len("</form>")
+ return regexp.MustCompile(`name="csrf" value="[^"]*"`).ReplaceAllString(body[i:j], `name="csrf" value="CSRF"`)
+}
+
+// codex plan-R4 3, spec §10 and §16: whenever there is no picker, the form
+// is today's form byte for byte: with the switch off, and with the switch
+// on but only one eligible region.
+func TestNewAppPageWithoutPickerIsUnchanged(t *testing.T) {
+ off := newConsoleFixture(t)
+ one, _ := regionFixture(t)
+ setEdges(t, one, map[string]string{"uk-box": ukNoKey}) // the home alone is eligible
+ for name, fx := range map[string]*consoleFixture{"switch off": off, "one eligible region": one} {
+ _, _, body := get(t, signInPaul(t, fx), fx.u("/apps/new"))
+ if got := newAppForm(t, body); got != newAppFormGolden {
+ t.Errorf("%s: form =\n%q\nwant\n%q", name, got, newAppFormGolden)
+ }
+ }
+}
+
+func TestNewAppPickerAndAutoChoice(t *testing.T) {
+ fx, _ := regionFixture(t)
+ c, csrf := signInAs(t, fx, "paul@keymail.dev", "code-picker")
+ _, _, body := get(t, c, fx.u("/apps/new"))
+ for _, want := range []string{`name="region"`, `value="@auto"`, `value="uk"`, `value="ie"`} {
+ if !strings.Contains(body, want) {
+ t.Fatalf("picker missing %q", want)
+ }
+ }
+ if !has(body, ukPlace) || !has(body, regionCopy.PickerLabel) || !has(body, regionCopy.PickerHelp) {
+ t.Fatal("picker missing its copy")
+ }
+ if status, _, _ := postForm(t, c, fx.u("/apps/new"), url.Values{"csrf": {csrf}, "name": {"auto1"}, "region": {"@auto"}, "tz": {"Europe/London"}}); status != http.StatusSeeOther {
+ t.Fatalf("@auto post = %d", status)
+ }
+ if r, src, _ := fx.st.AppRegion(OpsAccountID, "auto1"); r != "uk" || src != "timezone" {
+ t.Fatalf("@auto = %q %q", r, src)
+ }
+ postForm(t, c, fx.u("/apps/new"), url.Values{"csrf": {csrf}, "name": {"chose"}, "region": {"ie"}, "tz": {"Europe/London"}})
+ if r, src, _ := fx.st.AppRegion(OpsAccountID, "chose"); r != "" || src != "chosen" {
+ t.Fatalf("explicit ie = %q %q", r, src)
+ }
+}
+
+// codex R2-3: a home NAMED "auto" is a region, not the sentinel.
+func TestRegionNamedAutoIsNotTheSentinel(t *testing.T) {
+ fx, _ := regionFixture(t)
+ fx.srv.cfg.HomeRegion = "auto"
+ c, csrf := signInAs(t, fx, "paul@keymail.dev", "code-auto-name")
+ postForm(t, c, fx.u("/apps/new"), url.Values{"csrf": {csrf}, "name": {"homeauto"}, "region": {"auto"}})
+ if r, src, _ := fx.st.AppRegion(OpsAccountID, "homeauto"); r != "" || src != "chosen" {
+ t.Fatalf("explicit home named auto = %q %q", r, src)
+ }
+}
+
+// Review Focus 3: uk's box loses the secrets key between render and POST.
+func TestNewAppPostWithANoLongerEligibleRegion(t *testing.T) {
+ fx, _ := regionFixture(t)
+ c, csrf := signInAs(t, fx, "paul@keymail.dev", "code-stale")
+ if _, _, body := get(t, c, fx.u("/apps/new")); !strings.Contains(body, `value="uk"`) {
+ t.Fatal("precondition: uk offered")
+ }
+ setEdges(t, fx, map[string]string{"uk-box": ukNoKey})
+ status, loc, _ := postForm(t, c, fx.u("/apps/new"), url.Values{"csrf": {csrf}, "name": {"stale"}, "region": {"uk"}})
+ if status != http.StatusSeeOther || loc != fx.path("/apps/new") {
+ t.Fatalf("stale region = %d %q, want back to the form", status, loc)
+ }
+ if _, _, body := get(t, c, fx.u("/apps/new")); !has(body, regionCopy.Unavailable) {
+ t.Fatal("no C5 flash")
+ }
+ if exists, _ := fx.st.HasAppIn(OpsAccountID, "stale"); exists {
+ t.Fatal("a refused region still created the app")
+ }
+}
+
+func TestNewAppRegionRouteNeedsSessionAndCSRF(t *testing.T) {
+ fx, _ := regionFixture(t)
+ c, csrf := signInAs(t, fx, "paul@keymail.dev", "code-route")
+ status, _, body := postForm(t, c, fx.u("/apps/new/region"), url.Values{"csrf": {csrf}, "tz": {"Europe/London"}})
+ var res map[string]string
+ if err := json.Unmarshal([]byte(body), &res); err != nil {
+ t.Fatalf("guess route body %q: %v", body, err)
+ }
+ if status != http.StatusOK || res["code"] != "uk" || res["source"] != "timezone" || res["name"] != ukPlace {
+ t.Fatalf("guess route = %d %v", status, res)
+ }
+ if status, _, _ := postForm(t, c, fx.u("/apps/new/region"), url.Values{"csrf": {"wrong"}, "tz": {"Europe/London"}}); status != http.StatusForbidden {
+ t.Fatalf("bad csrf = %d, want 403", status)
+ }
+ if status, _, _ := postForm(t, newBrowser(t), fx.u("/apps/new/region"), url.Values{"tz": {"Europe/London"}}); status == http.StatusOK {
+ t.Fatal("no session got a guess")
+ }
+}
+
+// §9.6 as 69a84224 made it: with the switch off the browser door neither
+// validates nor refuses an explicit region (resolveNewAppRegion stores the
+// home as "default"), exactly as the API door does.
+func TestNewAppExplicitRegionSkippedWithSwitchOff(t *testing.T) {
+ fx, _ := regionFixture(t)
+ fx.srv.cfg.AppRegions = false
+ c, csrf := signInAs(t, fx, "paul@keymail.dev", "code-off")
+ for _, region := range []string{"uk", "mars"} {
+ name := "off-" + region
+ status, loc, _ := postForm(t, c, fx.u("/apps/new"), url.Values{"csrf": {csrf}, "name": {name}, "region": {region}})
+ if status != http.StatusSeeOther || loc == fx.path("/apps/new") {
+ t.Fatalf("switch off, region %q: %d %q, want the app created", region, status, loc)
+ }
+ if r, src, _ := fx.st.AppRegion(OpsAccountID, name); r != "" || src != "default" {
+ t.Fatalf("switch off, region %q stored %q %q, want \"\" default", region, r, src)
+ }
+ }
+}
+
+// With the switch on, a code that is not a region at all is refused with
+// C5 too, and nothing is created.
+func TestNewAppUnknownRegionIsRefused(t *testing.T) {
+ fx, _ := regionFixture(t)
+ c, csrf := signInAs(t, fx, "paul@keymail.dev", "code-unknown")
+ status, loc, _ := postForm(t, c, fx.u("/apps/new"), url.Values{"csrf": {csrf}, "name": {"nowhere"}, "region": {"mars!"}})
+ if status != http.StatusSeeOther || loc != fx.path("/apps/new") {
+ t.Fatalf("unknown region = %d %q", status, loc)
+ }
+ if _, _, body := get(t, c, fx.u("/apps/new")); !has(body, regionCopy.Unavailable) {
+ t.Fatal("no C5 flash")
+ }
+ if exists, _ := fx.st.HasAppIn(OpsAccountID, "nowhere"); exists {
+ t.Fatal("an unknown region still created the app")
+ }
+}
+
+// The picker's preselected option is "@auto", never a region code, so an
+// untouched form is not an explicit choice (adoption must never move).
+func TestNewAppPickerPreselectsAuto(t *testing.T) {
+ fx, _ := regionFixture(t)
+ status, _, body := get(t, signInPaul(t, fx), fx.u("/apps/new"))
+ if status != http.StatusOK {
+ t.Fatalf("form = %d", status)
+ }
+ if !strings.Contains(body, `<option value="@auto" selected>`) {
+ t.Fatalf("@auto is not the preselected option: %s", body)
+ }
+ if n := strings.Count(body, " selected>"); n != 1 {
+ t.Fatalf("%d selected options, want exactly 1", n)
+ }
+ if !strings.Contains(body, `id="app-region"`) || !strings.Contains(body, `name="tz"`) {
+ t.Fatal("picker has no region select or tz hint")
+ }
+ // The hints travel in POST bodies, never in a URL.
+ if !strings.Contains(body, `method: "POST"`) {
+ t.Fatal("the guess script does not POST")
+ }
+ if strings.Contains(body, "?tz=") || strings.Contains(body, "&tz=") {
+ t.Fatal("tz in a URL")
+ }
+}
+
+// C24 (Paul's ruling for DB-IP's licence): a link to db-ip.com carrying the
+// approved text, shown only when GeoIP is configured.
+func TestNewAppAttributionOnlyWithGeoIP(t *testing.T) {
+ fx, _ := regionFixture(t)
+ c := signInPaul(t, fx)
+ status, _, body := get(t, c, fx.u("/apps/new"))
+ if status != http.StatusOK || !strings.Contains(body, `id="app-region"`) {
+ t.Fatalf("precondition: picker page = %d", status)
+ }
+ if strings.Contains(body, "db-ip.com") || has(body, regionCopy.Attribution) {
+ t.Fatal("attribution shown with no GeoIP")
+ }
+ fx.srv.cfg.GeoIP = fakeLocator{"81.2.69.142": geo.Point{Lat: 51.5, Lon: -0.12}}
+ _, _, body = get(t, c, fx.u("/apps/new"))
+ want := `<a href="https://db-ip.com">` + html.EscapeString("IP Geolocation by DB-IP") + `</a>`
+ if !strings.Contains(body, want) {
+ t.Fatalf("attribution link %q not in page", want)
+ }
+}
+
+// §6.5: the timezone and the address are never echoed, stored or logged.
+// PF9: assert the route answers 2xx before any negative check, and key the
+// page checks on an element id.
+func TestRegionGuessHintsNeverLeak(t *testing.T) {
+ fx, _ := regionFixture(t)
+ fx.srv.cfg.GeoIP = fakeLocator{"81.2.69.142": geo.Point{Lat: 51.5, Lon: -0.12}}
+ var logged bytes.Buffer
+ log.SetOutput(&logged)
+ t.Cleanup(func() { log.SetOutput(os.Stderr) })
+ c, csrf := signInAs(t, fx, "paul@keymail.dev", "code-leak")
+
+ const tz, ip = "Europe/London", "81.2.69.142"
+ req, err := http.NewRequest(http.MethodPost, fx.u("/apps/new/region"), strings.NewReader(url.Values{"csrf": {csrf}, "tz": {tz}}.Encode()))
+ if err != nil {
+ t.Fatal(err)
+ }
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ req.Header.Set("X-Forwarded-For", ip)
+ res, err := c.Do(req)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var buf bytes.Buffer
+ buf.ReadFrom(res.Body)
+ res.Body.Close()
+ if res.StatusCode < 200 || res.StatusCode > 299 {
+ t.Fatalf("guess route = %d %s", res.StatusCode, buf.String())
+ }
+ if strings.Contains(buf.String(), tz) || strings.Contains(buf.String(), ip) {
+ t.Fatalf("guess answer echoes a hint: %s", buf.String())
+ }
+ if strings.Contains(logged.String(), tz) || strings.Contains(logged.String(), ip) {
+ t.Fatalf("a hint was logged: %s", logged.String())
+ }
+ // The form page carries no hint, and the tz field starts empty (the
+ // script fills it in the browser).
+ status, _, body := get(t, c, fx.u("/apps/new"))
+ if status != http.StatusOK {
+ t.Fatalf("form = %d", status)
+ }
+ if !strings.Contains(body, `id="app-region"`) || !strings.Contains(body, `<input type="hidden" name="tz" value="">`) {
+ t.Fatal("picker page without its empty tz field")
+ }
+ if strings.Contains(body, tz) || strings.Contains(body, ip) {
+ t.Fatal("form page echoes a hint")
+ }
+}
diff --git a/internal/console/server.go b/internal/console/server.go
index 0c3a38f71..ec11a244a 100644
--- a/internal/console/server.go
+++ b/internal/console/server.go
@@ -1206,6 +1206,7 @@ func NewServer(cfg Config, st *Store) (*Server, error) {
// never be viewed.
s.mux.HandleFunc("GET /a/{sqid}/apps/new", s.handleNewAppForm)
s.mux.HandleFunc("POST /a/{sqid}/apps/new", s.handleNewApp)
+ s.mux.HandleFunc("POST /a/{sqid}/apps/new/region", s.handleNewAppRegion)
s.mux.HandleFunc("GET /a/{sqid}/apps/{app}", s.handleApp)
s.mux.HandleFunc("GET /a/{sqid}/apps/{app}/releases", s.handleAppReleases)
s.mux.HandleFunc("GET /a/{sqid}/apps/{app}/deployments", s.handleAppReleases)
@@ -1404,6 +1405,8 @@ func normalizeHost(host string) string {
// page is the one bag every template reads from; unused fields are ignored.
type page struct {
+ RegionPicker *regionPickerView // new-app page: nil = no picker
+
AccountStore *accountStoreView
StoreQueue *storeQueueView
StorePending *int
@@ -3659,6 +3662,7 @@ func (s *Server) handleNewAppForm(w http.ResponseWriter, r *http.Request) {
p := s.accountPage(w, r, addr, raw, acct, role)
p.Title = "Create an app"
p.ActiveNav = "apps"
+ p.RegionPicker = s.newAppPicker(r.Context(), s.personIP(r))
s.render(w, http.StatusOK, "app_new", p)
}
@@ -3688,10 +3692,17 @@ func (s *Server) handleNewApp(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, formPage, http.StatusSeeOther)
}
name := normalizeAppName(r.FormValue("name"))
+ // 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, regionOK := s.pickedRegion(r.Context(), r)
+ if !regionOK {
+ fail(regionCopy.Unavailable)
+ return
+ }
// 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, regionReq{})
+ outcome, _, held := s.claimApp(r.Context(), acct, name, addr, "", role == RoleOwner, rq)
switch outcome {
case claimInvalid:
fail("Invalid app name — lowercase letters, digits, and dashes, starting with a letter.")
@@ -3708,7 +3719,13 @@ func (s *Server) handleNewApp(w http.ResponseWriter, r *http.Request) {
case claimCreateFailed:
fail("Could not create the app. Please try again.")
case claimOK:
- s.audit(acct, name, addr, "create", "created app "+name)
+ detail := "created app " + name
+ if s.appRegionsOn() {
+ region, _, _ := s.store.AppRegion(acct.ID, name)
+ files, _ := readEdgesSnapshot(r.Context(), s.cfg.Releases)
+ detail = fmt.Sprintf(regionCopy.AuditCreated, name, s.placeOf(region, s.regionCands(files)))
+ }
+ s.audit(acct, name, addr, "create", detail)
s.setFlash(w, "ok", "Created app "+name+" — it's yours.")
http.Redirect(w, r, home+"apps/"+url.PathEscape(name), http.StatusSeeOther)
}
diff --git a/internal/console/templates/app_new.html b/internal/console/templates/app_new.html
index 02d232e8d..879ecd4c9 100644
--- a/internal/console/templates/app_new.html
+++ b/internal/console/templates/app_new.html
@@ -10,7 +10,35 @@
<input type="hidden" name="csrf" value="{{.CSRF}}">
<label class="visually-hidden" for="app-name">App name</label>
<input id="app-name" type="text" name="name" placeholder="my-app" autofocus
- required pattern="[A-Za-z][A-Za-z0-9-]{0,31}" maxlength="32">
+ required pattern="[A-Za-z][A-Za-z0-9-]{0,31}" maxlength="32">{{with .RegionPicker}}
+ <label for="app-region">{{$.RegionCopy.PickerLabel}}</label>
+ <select id="app-region" name="region">
+ <option value="@auto" selected>{{.Auto}}</option>
+ {{range .Options}}<option value="{{.Value}}">{{.Label}}</option>{{end}}
+ </select>
+ <input type="hidden" name="tz" value="">
+ <p class="small">{{$.RegionCopy.PickerHelp}}</p>
+ {{if .Attribution}}<p class="small"><a href="{{$.RegionCopy.AttributionURL}}">{{$.RegionCopy.Attribution}}</a></p>{{end}}
+ <script>
+ // Enhancement only: with JS off the server's IP-or-home guess stands.
+ // Fills the tz hint and relabels ONLY the @auto option; its value stays
+ // "@auto" so the server still knows the person did not choose.
+ (function () {
+ var f = document.currentScript.closest("form"), sel = f.elements.region, tz = "";
+ var label = {{printf $.RegionCopy.AutoOption "%PLACE%"}};
+ try { tz = Intl.DateTimeFormat().resolvedOptions().timeZone || ""; } catch (e) {}
+ f.elements.tz.value = tz;
+ if (!tz) return;
+ var body = new URLSearchParams({ csrf: f.elements.csrf.value, tz: tz });
+ fetch("{{$.Base}}/apps/new/region", { method: "POST", body: body, credentials: "same-origin" })
+ .then(function (r) { return r.ok ? r.json() : null; })
+ .then(function (g) {
+ if (!g || sel.value !== "@auto") return;
+ sel.options[0].text = label.replace("%PLACE%", g.name);
+ })
+ .catch(function () {});
+ })();
+ </script>{{end}}
<button type="submit">Create app</button>
</form>
<p class="small">Names are lowercase letters, digits, and dashes, starting with a letter — unique within this account, not across the platform.</p>