carlos / platform Public

settings-5 task 5: fix round 1 (stale-form baseline, rate-limit copy)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev f0f070d1efe0fed4a17709ff0cf90142ae6f5185 parent 9900efb
5 files changed, +223 −61
  • internal/console/browserupdates.go +31 −25
  • internal/console/browserupdates_test.go +187 −32
  • internal/console/claim_invariant_fixtures_test.go +1 −1
  • internal/console/templates/app_instance.html +3 −3
  • internal/updatewords/words_surfaces.go +1 −0
diff --git a/internal/console/browserupdates.go b/internal/console/browserupdates.go
index 9a2fe25ba..2be8828f7 100644
--- a/internal/console/browserupdates.go
+++ b/internal/console/browserupdates.go
@@ -10,8 +10,9 @@
// disabled (Astra I3 #8): plan 3's gate refuses only turning manual, a major
// hold or a pin ON while some box runs an old binary, and clearing must stay
// expressible. A disabled input posts nothing, so the form says what it
-// rendered instead: form=updates marks a submission, and auto_shown=1 marks a
-// checkbox that was there to be unticked.
+// rendered instead: form=updates marks a submission, and each live control
+// carries a hidden baseline of the value it showed (was_auto, was_major,
+// was_pin), so only what the member changed is sent.
package console
import (
@@ -146,38 +147,45 @@ func reasonSentence(u *updatesJoinView) string {
// updatesFormRequest is the browser form as plan 3's PUT body: only the
// controls the member changed (Astra I3 #8). Re-sending an unchanged
// "manual" would count as enabling at plan 3's gate and refuse a save that
-// only clears a pin. A post without form=updates sends nothing, and the
-// checkbox is read only when auto_shown says it was rendered, so an omitted
+// only clears a pin.
+//
+// "Changed" is measured against what the member saw, not the record at post
+// time (review round 1, R15): each live control carries a hidden baseline of
+// the value it was rendered with (was_auto, was_major, was_pin), and a
+// control is sent only when its posted value differs from its own baseline.
+// Otherwise a stale page would silently undo another member's change: a pin
+// cleared through the CLI comes back when the page that still showed it
+// saves an unrelated untick. A control whose baseline is missing sends
+// nothing, and a post without form=updates sends nothing at all. was_auto
+// also says the checkbox was rendered ("1" ticked, "0" not), so an omitted
// checkbox is never read as manual (R10).
-func updatesFormRequest(f url.Values, v updatesView) map[string]any {
+func updatesFormRequest(f url.Values) map[string]any {
req := map[string]any{}
if f.Get("form") != "updates" {
return req
}
- if f.Get("auto_shown") == "1" {
- want := release.UpdatesManual
- if f.Get("auto") == "1" {
- want = release.UpdatesAuto
- }
- if want != v.Settings.Updates {
- req["updates"] = want
+ if was := f.Get("was_auto"); was == "1" || was == "0" {
+ if now := f.Get("auto") == "1"; now != (was == "1") {
+ req["updates"] = release.UpdatesManual
+ if now {
+ req["updates"] = release.UpdatesAuto
+ }
}
}
- if f.Has("major") {
- cur := ""
- if v.Explicit.Major != nil {
- cur = strconv.Itoa(*v.Explicit.Major)
- }
- if m := strings.TrimSpace(f.Get("major")); m != cur {
- if n, err := strconv.Atoi(m); err == nil {
+ if f.Has("major") && f.Has("was_major") {
+ m, was := strings.TrimSpace(f.Get("major")), strings.TrimSpace(f.Get("was_major"))
+ n, err := strconv.Atoi(m)
+ w, werr := strconv.Atoi(was)
+ if sameNumber := err == nil && werr == nil && n == w; m != was && !sameNumber {
+ if err == nil {
req["major"] = float64(n) // a JSON number, as plan 3's door reads one
} else {
req["major"] = m // "" clears; anything else gets plan 3's bad-major refusal
}
}
}
- if f.Has("pin") {
- if p := strings.TrimSpace(f.Get("pin")); p != v.Explicit.Pin {
+ if f.Has("pin") && f.Has("was_pin") {
+ if p := strings.TrimSpace(f.Get("pin")); p != strings.TrimSpace(f.Get("was_pin")) {
req["pin"] = p
}
}
@@ -229,9 +237,7 @@ func (s *Server) updatesFormGate(w http.ResponseWriter, r *http.Request, ctx con
}
t.rec, t.etag, t.policy = rec, etag, policy
if !s.allowWrite(Principal{Kind: PrincipalUser, Address: addr}, time.Now()) {
- // The words the settings API doors answer their own budget with: no
- // page draft exists for this refusal yet.
- s.updatesFlash(w, r, t.back, "err", mainGateCopy().tooMany("instance"))
+ s.updatesFlash(w, r, t.back, "err", updatewords.Text("settings.page.rate-limited"))
return updatesFormTarget{}, false
}
return t, true
@@ -252,7 +258,7 @@ func (s *Server) handleBrowserUpdates(w http.ResponseWriter, r *http.Request) {
return
}
// An empty request is a no-op save: applyUpdates writes nothing for it.
- req := updatesFormRequest(r.PostForm, updatesViewFor(*t.rec, t.policy))
+ req := updatesFormRequest(r.PostForm)
_, st, msg := s.applyUpdates(ctx, t.acct, t.app, t.addr, t.rec, t.etag, t.policy, req)
if st != http.StatusOK {
s.updatesFlash(w, r, t.back, "err", msg)
diff --git a/internal/console/browserupdates_test.go b/internal/console/browserupdates_test.go
index 32c316bbc..3b1458598 100644
--- a/internal/console/browserupdates_test.go
+++ b/internal/console/browserupdates_test.go
@@ -3,10 +3,13 @@ package console
import (
"context"
"fmt"
+ "html"
"html/template"
"net/http"
"net/url"
"os"
+ "regexp"
+ "slices"
"strings"
"testing"
"time"
@@ -59,6 +62,37 @@ func updatesSection(body string) string {
return body[i : i+j]
}
+var (
+ formInputTag = regexp.MustCompile(`<input [^>]*>`)
+ formInputAttr = regexp.MustCompile(`([a-z_]+)="([^"]*)"`)
+)
+
+// browserForm is what a browser submits from the Updates section's Save form
+// as rendered, untouched: every hidden, text and number input with its
+// value, and a checkbox only when it is checked. A test edits the result the
+// way a member edits the form.
+func browserForm(t *testing.T, sec string) url.Values {
+ t.Helper()
+ i := strings.Index(sec, `<form method="post"`)
+ if i < 0 {
+ t.Fatalf("no Save form in:\n%s", sec)
+ }
+ form := sec[i:]
+ form = form[:strings.Index(form, "</form>")]
+ out := url.Values{}
+ for _, tag := range formInputTag.FindAllString(form, -1) {
+ attrs := map[string]string{}
+ for _, m := range formInputAttr.FindAllStringSubmatch(tag, -1) {
+ attrs[m[1]] = html.UnescapeString(m[2])
+ }
+ if attrs["type"] == "checkbox" && !strings.Contains(tag, " checked") {
+ continue
+ }
+ out.Add(attrs["name"], attrs["value"])
+ }
+ return out
+}
+
// boxesEnforce rewrites box1, the deployment's only box, keeping its held
// rollout report and adding heldHost to its routes, so the box join has an
// update view for the host. on says whether box1's binary applies update
@@ -158,7 +192,7 @@ func TestPageSaveGoesThroughTheGate(t *testing.T) {
fx := updatesPageFixture(t)
fx.boxesEnforce(true)
c := signInPaul(t, fx.consoleFixture)
- manual := url.Values{"form": {"updates"}, "auto_shown": {"1"}} // the checkbox unticked
+ manual := url.Values{"form": {"updates"}, "was_auto": {"1"}} // the page showed auto; the checkbox unticked
bad := url.Values{"csrf": {"wrong"}}
for k, v := range manual {
bad[k] = v
@@ -368,18 +402,20 @@ func TestClearingWorksFromThePageWhileTheGateIsClosed(t *testing.T) {
if strings.Contains(sec, "disabled") {
t.Fatal("a settings control is disabled while the gate is closed")
}
- for _, want := range []string{`name="auto"`, `name="auto_shown"`, `name="major" value="2"`, `name="pin" value="v1"`, esc(updatewords.Text("settings.page.refused-until-boxes"))} {
+ for _, want := range []string{`name="auto"`, `name="was_auto" value="0"`, `name="major" value="2"`, `name="was_major" value="2"`,
+ `name="pin" value="v1"`, `name="was_pin" value="v1"`, esc(updatewords.Text("settings.page.refused-until-boxes"))} {
if !strings.Contains(sec, want) {
t.Fatalf("section lacks %q:\n%s", want, sec)
}
}
- csrf := sessionToken(t, fx.consoleFixture, c)
- post := func(v url.Values) {
+ // save loads the page, makes one edit to its Save form and posts the
+ // whole form back, baselines and all, as the browser does.
+ save := func(edit func(url.Values)) {
t.Helper()
- v.Set("csrf", csrf)
- v.Set("form", "updates")
- v.Set("auto_shown", "1")
- code, loc, _ := postForm(t, c, fx.u("/apps/kass/instances/"+heldHost+"/updates"), v)
+ _, page := fx.getPage(c, fx.u("/apps/kass/instances/"+heldHost))
+ f := browserForm(t, updatesSection(page))
+ edit(f)
+ code, loc, _ := postForm(t, c, fx.u("/apps/kass/instances/"+heldHost+"/updates"), f)
if code != http.StatusSeeOther {
t.Fatalf("save = %d", code)
}
@@ -387,66 +423,81 @@ func TestClearingWorksFromThePageWhileTheGateIsClosed(t *testing.T) {
t.Fatalf("save refused:\n%s", page)
}
}
- post(url.Values{"major": {"2"}, "pin": {""}}) // clear the pin; still manual (box unchecked), major unchanged
+ save(func(f url.Values) { f.Set("pin", "") }) // clear the pin; still manual, major unchanged
if r := fx.record(heldHost); r.Pin != "" || r.Updates != "manual" || r.Major == nil || *r.Major != 2 {
t.Fatalf("clearing the pin: %+v", r)
}
- post(url.Values{"major": {""}, "pin": {""}}) // clear the major hold
+ save(func(f url.Values) { f.Set("major", "") }) // clear the major hold
if r := fx.record(heldHost); r.Major != nil || r.Updates != "manual" {
t.Fatalf("clearing major: %+v", r)
}
- post(url.Values{"auto": {"1"}, "major": {""}, "pin": {""}}) // auto back on
+ save(func(f url.Values) { f.Set("auto", "1") }) // auto back on
if r := fx.record(heldHost); r.Updates != "auto" {
t.Fatalf("switching auto on: %+v", r)
}
}
func TestAnOmittedCheckboxIsNotManual(t *testing.T) {
- v := updatesView{}
- v.Settings.Updates = "auto"
- if req := updatesFormRequest(url.Values{"csrf": {"x"}}, v); len(req) != 0 {
+ form := func(kv ...string) url.Values {
+ f := url.Values{"form": {"updates"}}
+ for i := 0; i < len(kv); i += 2 {
+ f.Add(kv[i], kv[i+1])
+ }
+ return f
+ }
+ if req := updatesFormRequest(url.Values{"csrf": {"x"}, "was_auto": {"1"}, "pin": {"v2"}, "was_pin": {""}}); len(req) != 0 {
t.Fatalf("a post without the form marker sent %v", req)
}
- if req := updatesFormRequest(url.Values{"form": {"updates"}, "auto_shown": {"1"}, "auto": {"1"}, "pin": {""}}, v); len(req) != 0 {
+ if req := updatesFormRequest(form("was_auto", "1", "auto", "1", "pin", "", "was_pin", "")); len(req) != 0 {
t.Fatalf("an unchanged form sent %v", req)
}
- if req := updatesFormRequest(url.Values{"form": {"updates"}}, v); len(req) != 0 {
+ if req := updatesFormRequest(form()); len(req) != 0 {
t.Fatalf("a form with no checkbox shown sent %v", req)
}
- if req := updatesFormRequest(url.Values{"form": {"updates"}, "auto": {"1"}}, v); len(req) != 0 {
+ if req := updatesFormRequest(form("auto", "1")); len(req) != 0 {
t.Fatalf("an auto with no checkbox shown sent %v", req)
}
- if req := updatesFormRequest(url.Values{"form": {"updates"}, "auto_shown": {"1"}}, v); req["updates"] != "manual" || len(req) != 1 {
- t.Fatalf("unchecking auto sent %v", req)
+ if req := updatesFormRequest(form("was_auto", "yes")); len(req) != 0 {
+ t.Fatalf("a malformed baseline sent %v", req)
}
- two := 2
- v.Explicit.Major, v.Explicit.Pin = &two, "v1"
- f := url.Values{"form": {"updates"}, "major": {" 2 "}, "pin": {" v1 "}}
- if req := updatesFormRequest(f, v); len(req) != 0 {
+ if req := updatesFormRequest(form("was_auto", "1")); req["updates"] != "manual" || len(req) != 1 {
+ t.Fatalf("unticking auto sent %v", req)
+ }
+ if req := updatesFormRequest(form("was_auto", "0", "auto", "1")); req["updates"] != "auto" || len(req) != 1 {
+ t.Fatalf("ticking auto sent %v", req)
+ }
+ if req := updatesFormRequest(form("was_auto", "0")); len(req) != 0 {
+ t.Fatalf("a box left unticked sent %v", req)
+ }
+ if req := updatesFormRequest(form("major", " 2 ", "was_major", "2", "pin", " v1 ", "was_pin", "v1")); len(req) != 0 {
t.Fatalf("an unchanged major and pin sent %v", req)
}
- f = url.Values{"form": {"updates"}, "major": {"3"}, "pin": {""}}
- if req := updatesFormRequest(f, v); req["major"] != float64(3) || req["pin"] != "" || len(req) != 2 {
+ if req := updatesFormRequest(form("major", "02", "was_major", "2")); len(req) != 0 {
+ t.Fatalf("the same major written differently sent %v", req)
+ }
+ if req := updatesFormRequest(form("major", "3", "was_major", "2", "pin", "", "was_pin", "v1")); req["major"] != float64(3) || req["pin"] != "" || len(req) != 2 {
t.Fatalf("a new major and a cleared pin sent %v", req)
}
- f = url.Values{"form": {"updates"}, "major": {""}}
- if req := updatesFormRequest(f, v); req["major"] != "" || len(req) != 1 {
+ if req := updatesFormRequest(form("major", "", "was_major", "2")); req["major"] != "" || len(req) != 1 {
t.Fatalf("a cleared major sent %v", req)
}
- f = url.Values{"form": {"updates"}, "major": {"two"}}
- if req := updatesFormRequest(f, v); req["major"] != "two" {
+ if req := updatesFormRequest(form("major", "two", "was_major", "")); req["major"] != "two" {
t.Fatalf("a bad major sent %v", req)
}
+ // A control with no baseline was not rendered live: it sends nothing.
+ if req := updatesFormRequest(form("major", "3", "pin", "v2")); len(req) != 0 {
+ t.Fatalf("controls with no baseline sent %v", req)
+ }
}
-// R10: a form without auto_shown (the checkbox was not rendered, because the
+// R10: a form without was_auto (the checkbox was not rendered, because the
// app keeps updates) never reads the absent checkbox as manual.
func TestAnUnshownCheckboxIsNeverReadAsManual(t *testing.T) {
fx := updatesPageFixture(t)
fx.boxesEnforce(true)
c := signInPaul(t, fx.consoleFixture)
csrf := sessionToken(t, fx.consoleFixture, c)
- code, _, _ := postForm(t, c, fx.u("/apps/kass/instances/"+heldHost+"/updates"), url.Values{"csrf": {csrf}, "form": {"updates"}, "pin": {""}})
+ code, _, _ := postForm(t, c, fx.u("/apps/kass/instances/"+heldHost+"/updates"), url.Values{"csrf": {csrf}, "form": {"updates"}, "pin": {""}, "was_pin": {""}})
if r := fx.record(heldHost); code != http.StatusSeeOther || r.Updates != "" {
t.Fatalf("save = %d, record %+v", code, r)
}
@@ -461,7 +512,7 @@ func TestKeptSettingsAreReadOnly(t *testing.T) {
c := signInPaul(t, fx.consoleFixture)
_, body := fx.getPage(c, fx.u("/apps/kass/instances/"+heldHost))
sec := updatesSection(body)
- for _, bad := range []string{`name="auto"`, `name="auto_shown"`, `name="major"`, `name="pin"`, `name="form"`, esc(updatewords.Text("settings.page.save"))} {
+ for _, bad := range []string{`name="auto"`, `name="was_auto"`, `name="major"`, `name="was_major"`, `name="pin"`, `name="was_pin"`, `name="form"`, esc(updatewords.Text("settings.page.save"))} {
if strings.Contains(sec, bad) {
t.Errorf("kept settings section has %q", bad)
}
@@ -477,6 +528,110 @@ func TestKeptSettingsAreReadOnly(t *testing.T) {
}
}
+// staleSave posts f to the Save door and fails on anything but a clean save.
+func (fx *pageFx) staleSave(c *http.Client, f url.Values) {
+ fx.t.Helper()
+ code, loc, _ := postForm(fx.t, c, fx.u("/apps/kass/instances/"+heldHost+"/updates"), f)
+ if code != http.StatusSeeOther {
+ fx.t.Fatalf("save = %d", code)
+ }
+ if _, page := fx.getPage(c, loc); !strings.Contains(page, esc(updatewords.Text("settings.page.saved"))) {
+ fx.t.Fatalf("save not saved:\n%s", page)
+ }
+}
+
+// updatesAudit is every instance-updates activity line for kass.
+func (fx *pageFx) updatesAudit() []string {
+ fx.t.Helper()
+ acts, err := fx.st.Events(OpsAccountID, "kass", 50)
+ if err != nil {
+ fx.t.Fatal(err)
+ }
+ var out []string
+ for _, a := range acts {
+ if a.Action == "instance-updates" {
+ out = append(out, a.Detail)
+ }
+ }
+ return out
+}
+
+// Review round 1 (R15): the form sends what the member changed on the page
+// they saw, not what differs from the record at post time. Member A's page
+// shows the instance pinned to v1; member B clears the pin behind it; A
+// unticks auto and saves. The pin A never touched stays cleared.
+func TestAStalePageDoesNotUndoAnotherMembersChange(t *testing.T) {
+ fx := updatesPageFixture(t)
+ fx.grant("updates", "major", "pin")
+ fx.boxesEnforce(true)
+ fx.pin(heldHost, "v1")
+ c := signInPaul(t, fx.consoleFixture)
+ _, body := fx.getPage(c, fx.u("/apps/kass/instances/"+heldHost))
+ f := browserForm(t, updatesSection(body))
+ if f.Get("pin") != "v1" || f.Get("auto") != "1" {
+ t.Fatalf("rendered form %v", f)
+ }
+ fx.pin(heldHost, "") // member B, through the CLI, after A's page loaded
+ f.Del("auto") // A unticks auto
+ fx.staleSave(c, f)
+ if r := fx.record(heldHost); r.Updates != release.UpdatesManual || r.Pin != "" {
+ t.Fatalf("record after a stale save: updates %q pin %q", r.Updates, r.Pin)
+ }
+ want := []string{fmt.Sprintf(updatewords.Text("settings.console.audit-set"), heldHost, "updates manual")}
+ if got := fx.updatesAudit(); !slices.Equal(got, want) {
+ t.Fatalf("audit = %q, want %q", got, want)
+ }
+}
+
+// The auto variant: A's page shows the instance manual and pinned; B switches
+// it to auto behind it; A only clears the pin. The unticked box A left alone
+// does not switch the instance back to manual.
+func TestAStalePageDoesNotUndoAnotherMembersSwitchToAuto(t *testing.T) {
+ fx := updatesPageFixture(t)
+ fx.grant("updates", "major", "pin")
+ fx.boxesEnforce(true)
+ fx.setRecordSettings(heldHost, func(r *provision.Record) { r.Updates, r.Pin = release.UpdatesManual, "v1" })
+ c := signInPaul(t, fx.consoleFixture)
+ _, body := fx.getPage(c, fx.u("/apps/kass/instances/"+heldHost))
+ f := browserForm(t, updatesSection(body))
+ if f.Get("pin") != "v1" || f.Has("auto") {
+ t.Fatalf("rendered form %v", f)
+ }
+ fx.setRecordUpdates(heldHost, release.UpdatesAuto) // member B
+ f.Set("pin", "") // A clears the pin
+ fx.staleSave(c, f)
+ if r := fx.record(heldHost); r.Updates != release.UpdatesAuto || r.Pin != "" {
+ t.Fatalf("record after a stale save: updates %q pin %q", r.Updates, r.Pin)
+ }
+ want := []string{fmt.Sprintf(updatewords.Text("settings.console.audit-set"), heldHost, "pin cleared")}
+ if got := fx.updatesAudit(); !slices.Equal(got, want) {
+ t.Fatalf("audit = %q, want %q", got, want)
+ }
+}
+
+// Review round 1 (R16): both forms spend the shared write budget, and a
+// refusal is the page's reviewed words, not the API's.
+func TestPageSaveAndUpdateNowAreRateLimited(t *testing.T) {
+ fx := updatesPageFixture(t)
+ fx.srv.cli.write = newRateLimiter(1, time.Minute)
+ c := signInPaul(t, fx.consoleFixture)
+ csrf := sessionToken(t, fx.consoleFixture, c)
+ limited := `<p class="banner error">` + esc(updatewords.Text("settings.page.rate-limited")) + `</p>`
+ save := url.Values{"csrf": {csrf}, "form": {"updates"}}
+ _, loc, _ := postForm(t, c, fx.u("/apps/kass/instances/"+heldHost+"/updates"), save)
+ if _, page := fx.getPage(c, loc); !strings.Contains(page, esc(updatewords.Text("settings.page.saved"))) {
+ t.Fatalf("first save:\n%s", page)
+ }
+ _, loc, _ = postForm(t, c, fx.u("/apps/kass/instances/"+heldHost+"/updates"), save)
+ if _, page := fx.getPage(c, loc); !strings.Contains(page, limited) {
+ t.Fatalf("second save lacks %q:\n%s", limited, page)
+ }
+ _, loc, _ = postForm(t, c, fx.u("/apps/kass/instances/"+heldHost+"/updates/install"), url.Values{"csrf": {csrf}})
+ if _, page := fx.getPage(c, loc); !strings.Contains(page, limited) || fx.record(heldHost).InstallRequest != nil {
+ t.Fatalf("Update now on a spent budget lacks %q or wrote %+v:\n%s", limited, fx.record(heldHost).InstallRequest, page)
+ }
+}
+
// TestUpdatesSectionPreview writes the instance page, Updates section and
// all, to $UPDATEWORDS_PREVIEW for Paul's copy review: an owner's view of a
// pinned instance on stable whose app grants updates, major and pin, with a
diff --git a/internal/console/claim_invariant_fixtures_test.go b/internal/console/claim_invariant_fixtures_test.go
index 7d26b5f2d..3901d6b5b 100644
--- a/internal/console/claim_invariant_fixtures_test.go
+++ b/internal/console/claim_invariant_fixtures_test.go
@@ -973,7 +973,7 @@ var claimFixtures = map[string]claimFixture{
t.Fatal(err)
}
},
- do: claimForm("/a/bab/apps/titogo/instances/acme.go.tito.io/updates", "form", "updates", "auto_shown", "1"),
+ do: claimForm("/a/bab/apps/titogo/instances/acme.go.tito.io/updates", "form", "updates", "was_auto", "1"),
check: func(t *testing.T, cx *claimCase, open bool) string {
rec, err := provision.GetRecord(context.Background(), cx.rel, "bab", "titogo", "acme.go.tito.io")
if err != nil {
diff --git a/internal/console/templates/app_instance.html b/internal/console/templates/app_instance.html
index 6ba7a8f65..04847d79c 100644
--- a/internal/console/templates/app_instance.html
+++ b/internal/console/templates/app_instance.html
@@ -42,11 +42,11 @@
{{if .HasForm}}<form method="post" action="{{$.Base}}/apps/{{$.AppName}}/instances/{{$.InstanceDetail.Row.Host}}/updates">
<input type="hidden" name="csrf" value="{{$.CSRF}}">
<input type="hidden" name="form" value="updates">{{end}}
- <div>{{if .AutoLive}}<label><input type="checkbox" name="auto" value="1"{{if eq .UpdatesWord "auto"}} checked{{end}}> {{$.UpdatesText "settings.page.auto-label"}}</label><input type="hidden" name="auto_shown" value="1">{{else}}{{$.UpdatesText "settings.page.auto-label"}}: <code>{{.UpdatesWord}}</code> <span class="small">{{$.UpdatesText "settings.page.kept"}}</span>{{end}}</div>
+ <div>{{if .AutoLive}}<label><input type="checkbox" name="auto" value="1"{{if eq .UpdatesWord "auto"}} checked{{end}}> {{$.UpdatesText "settings.page.auto-label"}}</label><input type="hidden" name="was_auto" value="{{if eq .UpdatesWord "auto"}}1{{else}}0{{end}}">{{else}}{{$.UpdatesText "settings.page.auto-label"}}: <code>{{.UpdatesWord}}</code> <span class="small">{{$.UpdatesText "settings.page.kept"}}</span>{{end}}</div>
<div>{{$.UpdatesText "settings.page.channel-label"}}: <code>{{.Channel}}</code>{{if .KeptChannel}} <span class="small">{{$.UpdatesText "settings.page.kept"}}</span>{{end}}
<div class="small">{{$.UpdatesText "settings.page.channel-hint"}}</div></div>
- {{if .ShowMajor}}<div>{{if .MajorLive}}<label>{{$.UpdatesText "settings.page.major-label"}} <input type="number" min="0" name="major" value="{{.MajorInput}}"></label>{{else}}{{$.UpdatesText "settings.page.major-label"}}: <code>{{.MajorShown}}</code> <span class="small">{{$.UpdatesText "settings.page.kept"}}</span>{{end}}</div>{{end}}
- <div>{{if .PinLive}}<label>{{$.UpdatesText "settings.page.pin-label"}} <input type="text" name="pin" value="{{.PinInput}}"></label>{{else}}{{$.UpdatesText "settings.page.pin-label"}}: <code>{{.PinShown}}</code> <span class="small">{{$.UpdatesText "settings.page.kept"}}</span>{{end}}</div>
+ {{if .ShowMajor}}<div>{{if .MajorLive}}<label>{{$.UpdatesText "settings.page.major-label"}} <input type="number" min="0" name="major" value="{{.MajorInput}}"></label><input type="hidden" name="was_major" value="{{.MajorInput}}">{{else}}{{$.UpdatesText "settings.page.major-label"}}: <code>{{.MajorShown}}</code> <span class="small">{{$.UpdatesText "settings.page.kept"}}</span>{{end}}</div>{{end}}
+ <div>{{if .PinLive}}<label>{{$.UpdatesText "settings.page.pin-label"}} <input type="text" name="pin" value="{{.PinInput}}"></label><input type="hidden" name="was_pin" value="{{.PinInput}}">{{else}}{{$.UpdatesText "settings.page.pin-label"}}: <code>{{.PinShown}}</code> <span class="small">{{$.UpdatesText "settings.page.kept"}}</span>{{end}}</div>
{{if .HasForm}}<button type="submit" class="btn-sm">{{$.UpdatesText "settings.page.save"}}</button>
</form>
{{if .Refusing}}<p class="small">{{$.UpdatesText "settings.page.refused-until-boxes"}}</p>{{end}}{{end}}
diff --git a/internal/updatewords/words_surfaces.go b/internal/updatewords/words_surfaces.go
index e40f6c786..717c249ee 100644
--- a/internal/updatewords/words_surfaces.go
+++ b/internal/updatewords/words_surfaces.go
@@ -61,6 +61,7 @@ func init() {
Draft{ID: "settings.page.refused-until-boxes", Text: "Manual updates, major holds and pins can be turned on once every box runs a release that applies them", Context: "Note shown under the form while the console refuses those settings. The controls stay usable: turning auto updates back on and clearing a major hold or a pin always work; only switching these on is refused."},
Draft{ID: "settings.page.saved", Text: "Update settings saved.", Context: "Green notice after Save."},
Draft{ID: "settings.page.requested", Text: "Update requested. The box installs the build and it runs on the next start.", Context: "Green notice after Update now."},
+ Draft{ID: "settings.page.rate-limited", Text: "Too many update requests. Try again in a minute.", Context: "Error notice on the instance page when a member saves update settings or presses Update now too often in a short time."},
// flags and usage
Draft{ID: "settings.cli.bad-major-flag", Text: "--major and --default-major take a whole number, 0 or more, or empty to clear", Context: "Refusal for a major flag that is not a number."},
Draft{ID: "settings.cli.set-flag-updates", Text: "auto or manual; empty restores the app's default", Context: "Help text for carlos instances set --updates."},