carlos / platform Public

console: rate limit fields on the instance settings page (#358)

A Rate limit section beside the password gate: four number inputs and
Save per instance, Reset to defaults, path rules listed read-only. The
writer checks through checkRateLimit like the PATCH, keeps existing path
rules on a set, and audits as the gate does.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 8f7cd322a4d0c23fed4615f421af25b03f5d3400 parent 45fa41d
6 files changed, +409 −1
  • internal/console/appsettings.go +158 −1
  • internal/console/appsettings_ratelimit_test.go +197 −0
  • internal/console/claim_invariant_fixtures_test.go +9 −0
  • internal/console/server.go +2 −0
  • internal/console/templates/app_settings.html +42 −0
  • internal/console/testdata/claim-routes.txt +1 −0
diff --git a/internal/console/appsettings.go b/internal/console/appsettings.go
index 3a0f37384..05f5a6ed6 100644
--- a/internal/console/appsettings.go
+++ b/internal/console/appsettings.go
@@ -36,6 +36,7 @@ import (
"net/url"
"os"
"sort"
+ "strconv"
"strings"
"time"
@@ -45,6 +46,7 @@ import (
"github.com/carlosframework/platform/internal/mail"
"github.com/carlosframework/platform/internal/objstore"
"github.com/carlosframework/platform/internal/provision"
+ "github.com/carlosframework/platform/internal/ratelimit"
"github.com/carlosframework/platform/internal/release"
)
@@ -180,6 +182,28 @@ type SettingsView struct {
type GateRow struct {
Host string
Gated bool
+
+ // The rate-limit section's view of the same record. The four numbers
+ // are the owner's own settings as text, "" when unset, so an input's
+ // value is empty and its placeholder shows the default. Paths are the
+ // record's path rules, read-only here: the CLI edits them.
+ BusyAt, Share, Rate, Burst string
+ Paths []RateLimitPathRow
+}
+
+// RateLimitPathRow is one path rule on the rate-limit section: its prefix
+// and the numbers it sets, in share, rate, burst order, unset ones left out.
+type RateLimitPathRow struct {
+ Prefix string
+ Numbers string
+}
+
+// rateLimitField is a setting as an input's value: "" for unset.
+func rateLimitField(n int) string {
+ if n == 0 {
+ return ""
+ }
+ return strconv.Itoa(n)
}
// StoreCard is the Settings tab's view of apps/<acct>/<app>/store.json (issue
@@ -395,7 +419,21 @@ func (s *Server) settingsGates(ctx context.Context, acct *Account, app string) (
}
rows := make([]GateRow, 0, len(recs))
for _, rec := range recs {
- rows = append(rows, GateRow{Host: rec.Host, Gated: rec.Gate != ""})
+ row := GateRow{Host: rec.Host, Gated: rec.Gate != ""}
+ if rl := rec.RateLimit; rl != nil {
+ row.BusyAt, row.Share = rateLimitField(rl.BusyAt), rateLimitField(rl.ClientShare)
+ row.Rate, row.Burst = rateLimitField(rl.Rate), rateLimitField(rl.Burst)
+ for _, p := range rl.Paths {
+ var nums []string
+ for _, n := range []int{p.ClientShare, p.Rate, p.Burst} {
+ if n != 0 {
+ nums = append(nums, strconv.Itoa(n))
+ }
+ }
+ row.Paths = append(row.Paths, RateLimitPathRow{Prefix: p.Prefix, Numbers: strings.Join(nums, " ")})
+ }
+ }
+ rows = append(rows, row)
}
sort.Slice(rows, func(i, j int) bool { return rows[i].Host < rows[j].Host })
return rows, ""
@@ -1004,6 +1042,125 @@ func (s *Server) handleAppGate(w http.ResponseWriter, r *http.Request) {
back("ok", "Password set on "+host+".")
}
+// The rate-limit form's flash lines, approved copy (ids ui.ratelimit.saved,
+// .cleared and .failed in the copy review of 2026-10-24, #358), with {host}
+// where the instance goes. A refusal over the plan's ceiling is not here: it
+// is checkRateLimit's own sentence, shown as it is.
+const (
+ rateLimitSavedCopy = "Rate limit saved on {host}."
+ rateLimitClearedCopy = "Rate limit back to defaults on {host}."
+ rateLimitFailedCopy = "Could not save the rate limit. Try again."
+)
+
+// handleAppRateLimit is the Settings page's rate-limit form: set the four
+// numbers on one instance host, or reset them. Member-gated like the gate and
+// the API PATCH it mirrors, with the gate's CSRF check, read-mutate-write
+// sequence and audit row. The numbers are checked by checkRateLimit, the
+// PATCH's own check, so both doors refuse the same values in the same
+// words. Path rules are not on the form and survive a set untouched; reset
+// drops them with the rest.
+func (s *Server) handleAppRateLimit(w http.ResponseWriter, r *http.Request) {
+ addr, raw, acct, _, app, ok := s.accountApp(w, r)
+ if !ok {
+ return
+ }
+ if subtle.ConstantTimeCompare([]byte(r.FormValue("csrf")), []byte(raw)) != 1 {
+ s.renderMessage(w, r, http.StatusForbidden, "Refused", "Cross-site request blocked.")
+ return
+ }
+ back := func(kind, msg string) {
+ s.setFlash(w, kind, msg)
+ http.Redirect(w, r, appSettingsURL(acct.Sqid, app, "domains"),
+ http.StatusSeeOther)
+ }
+ if s.cfg.Releases == nil {
+ back("err", rateLimitFailedCopy)
+ return
+ }
+ host := strings.TrimSpace(r.FormValue("host"))
+ if !provision.ValidHostShape(host) {
+ http.NotFound(w, r)
+ return
+ }
+ var nums ratelimit.Settings
+ clearing := false
+ switch r.FormValue("action") {
+ case "clear":
+ clearing = true
+ case "set":
+ // An empty input is unset, which is 0: the default applies.
+ var vals [4]int
+ for i, name := range []string{"busy_at", "client_share", "rate", "burst"} {
+ v := strings.TrimSpace(r.FormValue(name))
+ if v == "" {
+ continue
+ }
+ n, err := strconv.Atoi(v)
+ if err != nil {
+ back("err", rateLimitFailedCopy)
+ return
+ }
+ vals[i] = n
+ }
+ nums = ratelimit.Settings{BusyAt: vals[0], Rule: ratelimit.Rule{ClientShare: vals[1], Rate: vals[2], Burst: vals[3]}}
+ default:
+ back("err", rateLimitFailedCopy)
+ return
+ }
+ ctx, cancel := context.WithTimeout(r.Context(), instanceStoreTimeout)
+ defer cancel()
+ rec, etag, err := provision.GetRecordETag(ctx, s.cfg.Releases, acct.Sqid, app, host)
+ if errors.Is(err, os.ErrNotExist) {
+ http.NotFound(w, r)
+ return
+ }
+ if err != nil {
+ log.Printf("console: rate limit read %s/%s/%s: %v", acct.Sqid, app, host, err)
+ back("err", rateLimitFailedCopy)
+ return
+ }
+ if clearing {
+ rec.RateLimit = nil
+ } else {
+ if rec.RateLimit != nil {
+ nums.Paths = rec.RateLimit.Paths
+ }
+ if err := s.checkRateLimit(acct, nums); err != nil {
+ if isRateLimitRefusal(err) {
+ back("err", err.Error())
+ return
+ }
+ log.Printf("console: rate limit check %s/%s/%s: %v", acct.Sqid, app, host, err)
+ back("err", rateLimitFailedCopy)
+ return
+ }
+ if nums.Canonical() == "" {
+ rec.RateLimit = nil
+ } else {
+ rec.RateLimit = &nums
+ }
+ }
+ if err := provision.ValidateShape(*rec); err != nil {
+ log.Printf("console: rate limit shape %s/%s/%s: %v", acct.Sqid, app, host, err)
+ back("err", rateLimitFailedCopy)
+ return
+ }
+ if err := provision.UpdateRecord(ctx, s.cfg.Releases, *rec, etag); err != nil {
+ log.Printf("console: rate limit write %s/%s/%s: %v", acct.Sqid, app, host, err)
+ back("err", rateLimitFailedCopy)
+ return
+ }
+ if clearing {
+ s.audit(acct, app, addr, "rate-limit", "rate limit cleared on "+host+" (console)")
+ s.bumpSerial(ctx)
+ back("ok", strings.ReplaceAll(rateLimitClearedCopy, "{host}", host))
+ return
+ }
+ s.audit(acct, app, addr, "rate-limit", "rate limit set on "+host+" (console)")
+ s.bumpSerial(ctx)
+ back("ok", strings.ReplaceAll(rateLimitSavedCopy, "{host}", host))
+}
+
// handleAppDelete puts the app in the trash: the deletion-policy marker in
// the bucket, then this console's row — the same order and the same clock
// discipline as the API door (handleAPITrashApp), behind the account-owner
diff --git a/internal/console/appsettings_ratelimit_test.go b/internal/console/appsettings_ratelimit_test.go
new file mode 100644
index 000000000..d6fc09bee
--- /dev/null
+++ b/internal/console/appsettings_ratelimit_test.go
@@ -0,0 +1,197 @@
+package console
+
+import (
+ "context"
+ "net/http"
+ "net/url"
+ "reflect"
+ "strings"
+ "testing"
+
+ "github.com/carlosframework/platform/internal/provision"
+ "github.com/carlosframework/platform/internal/ratelimit"
+)
+
+// rlSettingsFixture is the gate test's fixture with one instance, a signed-up
+// browser and its CSRF token.
+func rlSettingsFixture(t *testing.T, code string) (*consoleFixture, *http.Client, string) {
+ t.Helper()
+ fx, token := instanceFixture(t, code)
+ letStateUpstreams(t, fx, "alice@keymail.dev")
+ enableInstances(t, fx, "titogo", sharedPoolManifest())
+ if status, _, out := rawReq(t, http.MethodPost, instancesURL(fx, "bab", "titogo"), token,
+ `{"host":"rl.go.tito.io","region":"ie","addrs":["127.0.0.1:7001"]}`); status != http.StatusCreated {
+ t.Fatalf("create = %d %s", status, out)
+ }
+ c := newBrowser(t)
+ signUp(t, fx, c, code+"-browser")
+ return fx, c, sessionToken(t, fx, c)
+}
+
+func rlSettingsRecord(t *testing.T, fx *consoleFixture) *provision.Record {
+ t.Helper()
+ rec, err := provision.GetRecord(context.Background(), fx.rel, "bab", "titogo", "rl.go.tito.io")
+ if err != nil {
+ t.Fatal(err)
+ }
+ return rec
+}
+
+func rlForm(csrf, action string, kv ...string) url.Values {
+ v := url.Values{"csrf": {csrf}, "host": {"rl.go.tito.io"}, "action": {action}}
+ for i := 0; i+1 < len(kv); i += 2 {
+ v.Set(kv[i], kv[i+1])
+ }
+ return v
+}
+
+func TestAppSettingsRateLimitForm(t *testing.T) {
+ fx, c, csrf := rlSettingsFixture(t, "code-settings-rl")
+ page := fx.u("/apps/titogo/settings/domains")
+ _, _, body := get(t, c, page)
+ for _, want := range []string{
+ "<h2>Rate limit</h2>",
+ "Turns away the addresses sending the most at once, and only while the instance is busy. The backstop is the exception: it stops a flood from one address at any time.",
+ "Busy at (requests in flight)",
+ "Per address while busy (requests in flight)",
+ "Backstop (requests a second per address)",
+ "Backstop burst",
+ "Path rules, such as different numbers for /api/, are set with carlos ratelimit.",
+ "Reset to defaults",
+ } {
+ if !strings.Contains(body, want) {
+ t.Fatalf("settings page lacks %q: %s", want, body)
+ }
+ }
+
+ status, loc, _ := postForm(t, c, fx.u("/apps/titogo/ratelimit"),
+ rlForm(csrf, "set", "busy_at", "", "client_share", "", "rate", "500", "burst", ""))
+ if status != http.StatusSeeOther || loc != fx.path("/apps/titogo/settings/domains") {
+ t.Fatalf("set = %d -> %q", status, loc)
+ }
+ rec := rlSettingsRecord(t, fx)
+ if rec.RateLimit == nil || !reflect.DeepEqual(*rec.RateLimit, ratelimit.Settings{Rule: ratelimit.Rule{Rate: 500}}) {
+ t.Fatalf("stored rate limit = %+v, want rate 500 only", rec.RateLimit)
+ }
+ _, _, body = get(t, c, page)
+ if !strings.Contains(body, "Rate limit saved on rl.go.tito.io.") || !strings.Contains(body, `value="500"`) {
+ t.Fatalf("after set: %s", body)
+ }
+ events, _ := fx.st.Events(OpsAccountID, "titogo", 20)
+ var sawSet bool
+ for _, e := range events {
+ if e.Detail == "rate limit set on rl.go.tito.io (console)" {
+ sawSet = true
+ }
+ }
+ if !sawSet {
+ t.Fatalf("no audit row for the set: %+v", events)
+ }
+
+ // All four fields filled in.
+ postForm(t, c, fx.u("/apps/titogo/ratelimit"),
+ rlForm(csrf, "set", "busy_at", "32", "client_share", "4", "rate", "100", "burst", "200"))
+ want := ratelimit.Settings{BusyAt: 32, Rule: ratelimit.Rule{ClientShare: 4, Rate: 100, Burst: 200}}
+ if rec := rlSettingsRecord(t, fx); rec.RateLimit == nil || !reflect.DeepEqual(*rec.RateLimit, want) {
+ t.Fatalf("stored rate limit = %+v, want %+v", rec.RateLimit, want)
+ }
+
+ status, _, _ = postForm(t, c, fx.u("/apps/titogo/ratelimit"), rlForm(csrf, "clear"))
+ if status != http.StatusSeeOther {
+ t.Fatalf("clear = %d", status)
+ }
+ if rec := rlSettingsRecord(t, fx); rec.RateLimit != nil {
+ t.Fatalf("rate limit after clear = %+v, want nil", rec.RateLimit)
+ }
+ _, _, body = get(t, c, page)
+ if !strings.Contains(body, "Rate limit back to defaults on rl.go.tito.io.") {
+ t.Fatalf("after clear: %s", body)
+ }
+ events, _ = fx.st.Events(OpsAccountID, "titogo", 20)
+ var sawClear bool
+ for _, e := range events {
+ if e.Detail == "rate limit cleared on rl.go.tito.io (console)" {
+ sawClear = true
+ }
+ }
+ if !sawClear {
+ t.Fatalf("no audit row for the clear: %+v", events)
+ }
+}
+
+// The page edits the four numbers and nothing else: path rules stay as the
+// CLI left them through a set, and only Reset drops them.
+func TestAppSettingsRateLimitKeepsPaths(t *testing.T) {
+ fx, c, csrf := rlSettingsFixture(t, "code-settings-rl-paths")
+ rec := rlSettingsRecord(t, fx)
+ rec.RateLimit = &ratelimit.Settings{BusyAt: 10, Paths: []ratelimit.PathRule{{Prefix: "/api/", Rule: ratelimit.Rule{Rate: 77}}}}
+ _, etag, err := provision.GetRecordETag(context.Background(), fx.rel, "bab", "titogo", rec.Host)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := provision.UpdateRecord(context.Background(), fx.rel, *rec, etag); err != nil {
+ t.Fatal(err)
+ }
+ _, _, body := get(t, c, fx.u("/apps/titogo/settings/domains"))
+ if !strings.Contains(body, "/api/") || !strings.Contains(body, "77") {
+ t.Fatalf("page does not list the path rule: %s", body)
+ }
+
+ postForm(t, c, fx.u("/apps/titogo/ratelimit"),
+ rlForm(csrf, "set", "busy_at", "", "client_share", "", "rate", "300", "burst", ""))
+ want := ratelimit.Settings{Rule: ratelimit.Rule{Rate: 300},
+ Paths: []ratelimit.PathRule{{Prefix: "/api/", Rule: ratelimit.Rule{Rate: 77}}}}
+ if got := rlSettingsRecord(t, fx); got.RateLimit == nil || !reflect.DeepEqual(*got.RateLimit, want) {
+ t.Fatalf("stored = %+v, want %+v", got.RateLimit, want)
+ }
+
+ // Every field empty keeps the path rules too.
+ postForm(t, c, fx.u("/apps/titogo/ratelimit"),
+ rlForm(csrf, "set", "busy_at", "", "client_share", "", "rate", "", "burst", ""))
+ want = ratelimit.Settings{Paths: want.Paths}
+ if got := rlSettingsRecord(t, fx); got.RateLimit == nil || !reflect.DeepEqual(*got.RateLimit, want) {
+ t.Fatalf("stored = %+v, want %+v", got.RateLimit, want)
+ }
+
+ postForm(t, c, fx.u("/apps/titogo/ratelimit"), rlForm(csrf, "clear"))
+ if got := rlSettingsRecord(t, fx); got.RateLimit != nil {
+ t.Fatalf("clear left %+v", got.RateLimit)
+ }
+}
+
+func TestAppSettingsRateLimitOverCeiling(t *testing.T) {
+ fx, c, csrf := rlSettingsFixture(t, "code-settings-rl-ceiling")
+ fx.srv.cfg.PlanCatalog = rlCatalog(t)
+
+ status, loc, _ := postForm(t, c, fx.u("/apps/titogo/ratelimit"),
+ rlForm(csrf, "set", "busy_at", "", "client_share", "", "rate", "500", "burst", ""))
+ if status != http.StatusSeeOther || loc != fx.path("/apps/titogo/settings/domains") {
+ t.Fatalf("over-ceiling set = %d -> %q", status, loc)
+ }
+ _, _, body := get(t, c, fx.u("/apps/titogo/settings/domains"))
+ if !strings.Contains(body, "The backstop rate can be at most 400 on this account&#39;s plan.") {
+ t.Fatalf("page lacks the ceiling sentence: %s", body)
+ }
+ if rec := rlSettingsRecord(t, fx); rec.RateLimit != nil {
+ t.Fatalf("an over-ceiling value was stored: %+v", rec.RateLimit)
+ }
+
+ // At the ceiling is fine.
+ postForm(t, c, fx.u("/apps/titogo/ratelimit"),
+ rlForm(csrf, "set", "busy_at", "", "client_share", "", "rate", "400", "burst", ""))
+ if rec := rlSettingsRecord(t, fx); rec.RateLimit == nil || rec.RateLimit.Rate != 400 {
+ t.Fatalf("stored = %+v, want rate 400", rec.RateLimit)
+ }
+}
+
+func TestAppSettingsRateLimitNeedsCSRF(t *testing.T) {
+ fx, c, _ := rlSettingsFixture(t, "code-settings-rl-csrf")
+ status, _, body := postForm(t, c, fx.u("/apps/titogo/ratelimit"),
+ rlForm("not-the-token", "set", "rate", "500"))
+ if status != http.StatusForbidden || !strings.Contains(body, "Cross-site request blocked.") {
+ t.Fatalf("got %d %s", status, body)
+ }
+ if rec := rlSettingsRecord(t, fx); rec.RateLimit != nil {
+ t.Fatalf("a refused post stored %+v", rec.RateLimit)
+ }
+}
diff --git a/internal/console/claim_invariant_fixtures_test.go b/internal/console/claim_invariant_fixtures_test.go
index 7b2a81a7f..77e1de8f7 100644
--- a/internal/console/claim_invariant_fixtures_test.go
+++ b/internal/console/claim_invariant_fixtures_test.go
@@ -1174,6 +1174,15 @@ var claimFixtures = map[string]claimFixture{
}},
// Rename is refused for every app on an enabled console
// (claimUnsupported), recordless titogo included.
+ "route POST /a/{sqid}/apps/{app}/ratelimit": {door: &claimDoor{
+ apps: claimRefs("bab/titogo"),
+ setup: func(t *testing.T, cx *claimCase) {
+ // The rate limit lives on an instance record, like the gate.
+ cx.instanceWorld(t)
+ cx.instance(t, `{"host":"acme.go.tito.io","region":"ie"}`)
+ },
+ do: claimForm("/a/bab/apps/titogo/ratelimit", "host", "acme.go.tito.io", "action", "set", "rate", "500"),
+ }},
"route POST /a/{sqid}/apps/{app}/rename": {refused: &claimRefusalFixture{
apps: claimRefs("bab/titogo", "+bab/titogo-renamed"),
do: claimRefusedForm("/a/bab/apps/titogo/rename", "to", "titogo-renamed", "confirmed", "1"),
diff --git a/internal/console/server.go b/internal/console/server.go
index e7c940087..a4499cf04 100644
--- a/internal/console/server.go
+++ b/internal/console/server.go
@@ -1559,6 +1559,8 @@ func NewServer(cfg Config, st *Store) (*Server, error) {
// The password-gate form (appsettings.go's handleAppGate): member-gated,
// unlike the owner-only writes above it.
s.registerRoute("POST /a/{sqid}/apps/{app}/gate", s.handleAppGate)
+ // The rate-limit form beside it (handleAppRateLimit): member-gated too.
+ s.registerRoute("POST /a/{sqid}/apps/{app}/ratelimit", s.handleAppRateLimit)
// The Deploys card's form (appswap.go): member-gated, like the gate.
s.registerRoute("POST /a/{sqid}/apps/{app}/swap", s.handleAppSwap)
s.registerRoute("POST /a/{sqid}/apps/{app}/rename", s.handleAppRename)
diff --git a/internal/console/templates/app_settings.html b/internal/console/templates/app_settings.html
index 7c36cd1cb..ae0b0539b 100644
--- a/internal/console/templates/app_settings.html
+++ b/internal/console/templates/app_settings.html
@@ -135,6 +135,48 @@ page asking for it and stay signed in for a week. It is set per instance.</p>
</div>
{{else}}<p class="small">No instances yet. A password gate is set per instance.</p>{{end}}
+<h2>Rate limit</h2>
+<p class="small">Turns away the addresses sending the most at once, and only while the instance is busy. The backstop is the exception: it stops a flood from one address at any time.</p>
+{{if .Settings.GatesUnavailable}}<p class="small">{{.Settings.GatesUnavailable}}</p>
+{{else if .Settings.Gates}}
+<div class="tablewrap">
+<table>
+ {{range $i, $g := .Settings.Gates}}
+ <tr>
+ <td><code>{{$g.Host}}</code></td>
+ <td>
+ <form method="post" action="{{$.Base}}/apps/{{$.Detail.App}}/ratelimit" class="settings-row">
+ <input type="hidden" name="csrf" value="{{$.CSRF}}">
+ <input type="hidden" name="host" value="{{$g.Host}}">
+ <input type="hidden" name="action" value="set">
+ <label for="rl-busy-{{$i}}">Busy at (requests in flight)<span class="visually-hidden"> {{$g.Host}}</span></label>
+ <input type="number" id="rl-busy-{{$i}}" name="busy_at" min="0" max="1000000" value="{{$g.BusyAt}}" placeholder="64">
+ <label for="rl-share-{{$i}}">Per address while busy (requests in flight)<span class="visually-hidden"> {{$g.Host}}</span></label>
+ <input type="number" id="rl-share-{{$i}}" name="client_share" min="0" max="1000000" value="{{$g.Share}}" placeholder="16">
+ <label for="rl-rate-{{$i}}">Backstop (requests a second per address)<span class="visually-hidden"> {{$g.Host}}</span></label>
+ <input type="number" id="rl-rate-{{$i}}" name="rate" min="0" max="1000000" value="{{$g.Rate}}" placeholder="300">
+ <label for="rl-burst-{{$i}}">Backstop burst<span class="visually-hidden"> {{$g.Host}}</span></label>
+ <input type="number" id="rl-burst-{{$i}}" name="burst" min="0" max="1000000" value="{{$g.Burst}}" placeholder="1000">
+ <button type="submit" class="btn-sm">Save</button>
+ </form>
+ <form method="post" action="{{$.Base}}/apps/{{$.Detail.App}}/ratelimit" class="settings-row">
+ <input type="hidden" name="csrf" value="{{$.CSRF}}">
+ <input type="hidden" name="host" value="{{$g.Host}}">
+ <input type="hidden" name="action" value="clear">
+ <button type="submit" class="btn-sm btn-danger">Reset to defaults</button>
+ </form>
+ {{if $g.Paths}}
+ <p class="small">Path rules, such as different numbers for /api/, are set with carlos ratelimit.</p>
+ <ul class="small">{{range $g.Paths}}<li><code>{{.Prefix}}</code> {{.Numbers}}</li>{{end}}</ul>
+ {{end}}
+ </td>
+ </tr>
+ {{end}}
+</table>
+</div>
+<p class="small">Path rules, such as different numbers for /api/, are set with carlos ratelimit.</p>
+{{end}}
+
{{end}}
{{if eq .SettingsSection "general"}}
{{if and .Settings .Settings.MismatchTarget}}
diff --git a/internal/console/testdata/claim-routes.txt b/internal/console/testdata/claim-routes.txt
index 1f17e0834..89c780455 100644
--- a/internal/console/testdata/claim-routes.txt
+++ b/internal/console/testdata/claim-routes.txt
@@ -138,6 +138,7 @@ route POST /a/{sqid}/apps/{app}/pipeline
route POST /a/{sqid}/apps/{app}/place
route POST /a/{sqid}/apps/{app}/production
route POST /a/{sqid}/apps/{app}/promote
+route POST /a/{sqid}/apps/{app}/ratelimit
route POST /a/{sqid}/apps/{app}/rename
route POST /a/{sqid}/apps/{app}/restart
route POST /a/{sqid}/apps/{app}/rollback