carlos / platform Public

console: fix rate-limit review findings C32/C33/C34 and Risk A/minors

C32: principalKey mirrors auditActor's legacy-app-token branch — a
kind='app' row (Name=="") now keys on App, not on the empty name, so
titogo's several per-app routers no longer share one wildFail/write
bucket per account.

C33: rateLimiter gains tripped(key, now), a read-only peek. The
wildcard-enable path now peeks wildFailTripped before the live DNS
round trip and only consumes (allowWildFail) after an actual failure,
so a tripped principal stops costing the console outbound DNS instead
of merely getting a different status code for it.

C34: domainsGate keeps a convergence-rate ceiling (domainsCeilingBurst,
120/min, keyed on the principal, via allowDomains) even though attach/
detach are exempt from the write/instance workload budgets — the
exemption proves the legitimate workload needs no budget, not that no
budget is safe, and every effective attach/detach bumps control/serial
which every box reacts to within 2s.

Risk A: comment at the first call site explaining why discarding
principalFrom's ok is safe (cliRoute's resolve-before-handler
ordering) and what breaks if it ever isn't (one global bucket keyed on
).

Minors: each service-budget constant now argues its own number instead
of sharing prose that didn't reach svcWriteBurst and conflicted with
svcShipBurst; wildFail gets its own allowWildFail/wildFailTripped
pickers so no call site reaches into s.cli by hand.

New tests: TestDomainsCeilingBoundsConvergenceRate,
TestLegacyAppTokensHaveSeparateWildFailBudgets,
TestTwoNamedServicesHaveSeparateBudgets.
TestWildcardEnableIsNotRateLimited's volume drops to
domainsCeilingBurst/2 so it demonstrates the write/instance exemption
without also exercising the new ceiling it doesn't test.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016UimU7fAfeAoBMm6HKCPwQ
Paul Campbell pushed by paul@keymail.dev 5dd1226b63c5c110e29be50a143015a0a96e72b1 parent 1dedcf1
6 files changed, +311 −43
  • internal/console/apicli.go +11 −0
  • internal/console/apidomains.go +51 −19
  • internal/console/apidomains_test.go +87 −3
  • internal/console/authcli.go +87 −17
  • internal/console/authcli_test.go +27 −0
  • internal/console/svcprincipal.go +48 −4
diff --git a/internal/console/apicli.go b/internal/console/apicli.go
index 900d5cde0..8c29fe390 100644
--- a/internal/console/apicli.go
+++ b/internal/console/apicli.go
@@ -570,6 +570,17 @@ func (s *Server) handleAPIPromote(w http.ResponseWriter, r *http.Request) {
if !ok {
return
}
+ // ok discarded here, and at every other write/create/ship/domains call
+ // site in this package that reads a principal back off the context:
+ // apiAccountApp/apiAccount/apiFleet above already called
+ // apiPrincipalAccount, principalFrom's only writer, so ok is already
+ // true by the time this line runs — cliRoute stashed the principal on
+ // the context before the handler ever started (Task 5 review, Risk A).
+ // If that ordering were ever broken, the zero Principal reads as
+ // PrincipalUser with Address "", which keys the PERSON limiter on the
+ // empty string for every caller alike — turning, say, the create
+ // door's 10-a-day-per-address budget into ONE shared bucket of 10
+ // creates a day for the entire deployment, not a per-caller refusal.
p, _ := principalFrom(r.Context())
// allowWrite picks the person or the service write budget from the
// principal (svcprincipal.go). A person is keyed on the ADDRESS, not the
diff --git a/internal/console/apidomains.go b/internal/console/apidomains.go
index 7e06e2642..df0236c1e 100644
--- a/internal/console/apidomains.go
+++ b/internal/console/apidomains.go
@@ -136,22 +136,37 @@ func (s *Server) resolveAliasRoute(ctx context.Context, w http.ResponseWriter, a
}
// domainsGate is instanceGate's principal, binding and readiness checks with
-// the instance-verb BUDGET step removed (spec §4a). Wildcard DNS changes the
-// budget question: a Pages hub with wildcard DNS makes NO per-repo hostname
-// call at all — one claim on the hub host with both flags, and every repo's
-// subdomain serves off the catchall — so gating attach and detach on the
-// instance family's per-minute request budget would uplift a call that
-// makes no per-repo call at all. The one thing on this path that IS bounded
-// is the FAILING half of a wildcard enable, and it is bounded separately,
-// inside setDomainCapabilities (wildFailBurst) — a live, sequential DNS
-// round trip on the request path is a hazard whether or not the ordinary
-// write budget would have caught it.
+// the instance-verb WORKLOAD budget step replaced by a CONVERGENCE-RATE
+// ceiling (spec §4a, sharpened by Task 5 review C34). Wildcard DNS changes
+// the WORKLOAD question: a Pages hub with wildcard DNS makes NO per-repo
+// hostname call at all — one claim on the hub host with both flags, and
+// every repo's subdomain serves off the catchall — so gating attach and
+// detach on the instance family's per-minute request budget would uplift a
+// call that makes no per-repo call at all. That is the whole of what the
+// exemption establishes: the legitimate workload does not NEED a budget. It
+// does not establish that no budget is safe — every EFFECTIVE attach or
+// detach audits and bumps control/serial, which every box polls at 2s and
+// reacts to with a full pass, so an unlimited exempt door would let one
+// authenticated credential drive fleet-wide convergence at request rate.
+// allowDomains (domainsCeilingBurst, svcprincipal.go) is that ceiling: high
+// enough that no real caller — including the exempt wildcard workload
+// itself — ever notices it, low enough that it still caps how fast one
+// credential can make every box look at the world again.
+//
+// The other thing bounded on this path is the FAILING half of a wildcard
+// enable, bounded separately, inside setDomainCapabilities (wildFailBurst)
+// — a live, sequential DNS round trip on the request path is a hazard
+// whether or not this ceiling would eventually have caught it too.
func (s *Server) domainsGate(w http.ResponseWriter, r *http.Request) (Principal, *Account, string, bool) {
_, acct, app, ok := s.apiAccountApp(w, r)
if !ok {
return Principal{}, nil, "", false
}
p, _ := principalFrom(r.Context())
+ if !s.allowDomains(p, time.Now()) {
+ jsonError(w, http.StatusTooManyRequests, "too many requests — wait a minute and try again")
+ return Principal{}, nil, "", false
+ }
// A console with no deployment bucket configured can answer none of
// these — instanceGate's own check, kept here since this gate stops
// short of calling it.
@@ -304,6 +319,12 @@ func (s *Server) handleAPIDomainAttach(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"host": host, "route": route, "created": created})
}
+// wildFailMsg is the ONE 429 body both the peek (wildFailTripped) and the
+// consume (allowWildFail) refusals in setDomainCapabilities answer with — a
+// caller must not be able to tell which of the two turned it away, since
+// both mean exactly the same thing to them: stop retrying for a minute.
+const wildFailMsg = "too many wildcard attempts with no _acme-challenge CNAME in place — wait a minute and retry"
+
// setDomainCapabilities applies an attach body's optional wildcard/catchall
// flip AFTER the underlying claim attach above has already succeeded and
// established ownership. ok=false means a response has already been
@@ -354,16 +375,27 @@ func (s *Server) setDomainCapabilities(w http.ResponseWriter, r *http.Request, c
jsonError(w, http.StatusUnprocessableEntity, "deployment has no delegation zone configured")
return false
}
+ // PEEKED before the DNS round trip (C33 review): once a principal
+ // has already tripped wildFail, running the lookup again buys the
+ // caller nothing but costs the console another live, sequential
+ // outbound DNS call. A caller who has never failed never trips
+ // this — wildFailTripped only ever reads TRUE from a count that
+ // CONSUMED (below) put there — so this can never turn away a
+ // legitimate, exempt caller.
+ if s.wildFailTripped(p, time.Now()) {
+ jsonError(w, http.StatusTooManyRequests, wildFailMsg)
+ return false
+ }
if !s.delegationCNAMEDelegated(ctx, host) {
- // Bounded ONLY on the failing path (spec §4a). A succeeding
- // enable is once-per-domain and exempt (domainsGate skips the
- // write budget entirely); a failure is a live, sequential DNS
- // round trip a caller can repeat forever. Counted AFTER the
- // lookup, so the bound is on FAILURES rather than on attempts —
- // a caller whose CNAME is correct never meets it at all.
- if !s.cli.wildFail.allow(principalKey(p), time.Now()) {
- jsonError(w, http.StatusTooManyRequests,
- "too many wildcard attempts with no _acme-challenge CNAME in place — wait a minute and retry")
+ // CONSUMED only here, on an ACTUAL failure (spec §4a). A
+ // succeeding enable is once-per-domain and exempt (domainsGate
+ // carries only the convergence ceiling, not a workload budget);
+ // a failure is what the peek above exists to eventually stop
+ // paying for. Counted AFTER the lookup, so the bound is on
+ // FAILURES rather than on attempts — a caller whose CNAME is
+ // correct never meets it at all.
+ if !s.allowWildFail(p, time.Now()) {
+ jsonError(w, http.StatusTooManyRequests, wildFailMsg)
return false
}
jsonError(w, http.StatusUnprocessableEntity,
diff --git a/internal/console/apidomains_test.go b/internal/console/apidomains_test.go
index b21875fa9..a7a4fa59b 100644
--- a/internal/console/apidomains_test.go
+++ b/internal/console/apidomains_test.go
@@ -1096,17 +1096,21 @@ func TestDelegationZoneInvalidAtConstruction(t *testing.T) {
// TestWildcardEnableIsNotRateLimited: a Pages hub with wildcard DNS makes no
// per-repo hostname call at all — one claim on the hub host with both flags,
// every repo's subdomain serves off the catchall. So the domains doors are
-// EXEMPT from the write budget rather than uplifted: far more than
-// svcWriteBurst SUCCEEDING wildcard enables in the window must never 429.
+// EXEMPT from the write/instance WORKLOAD budgets rather than uplifted —
+// this loop stays well under domainsCeilingBurst (the separate CONVERGENCE-
+// RATE ceiling that does still apply, see TestDomainsCeilingBoundsConvergence
+// below) precisely to isolate that claim: every one of these must succeed
+// for a reason that has nothing to do with hitting any door's cap.
func TestWildcardEnableIsNotRateLimited(t *testing.T) {
const zone = "acme.example"
+ const attempts = domainsCeilingBurst / 2
fx, _ := instanceFixture(t, "code-wc-exempt")
svc := serviceToken(t, fx.st, OpsAccountID, "hub", "titogo", svcrole.Publish)
fx.srv.delegationZone = zone
fx.srv.lookupCNAME = func(_ context.Context, name string) (string, error) {
return name + "." + zone, nil
}
- for i := 0; i < svcWriteBurst*3; i++ {
+ for i := 0; i < attempts; i++ {
host := fmt.Sprintf("r%d.pages.example", i)
code, _, out := rawReq(t, http.MethodPost, domainsURL(fx, "bab", "titogo"), svc,
`{"host":"`+host+`","route":"hub.pages.example","wildcard":true,"catchall":true}`)
@@ -1119,6 +1123,35 @@ func TestWildcardEnableIsNotRateLimited(t *testing.T) {
}
}
+// TestDomainsCeilingBoundsConvergenceRate is C34's correction: the
+// exemption above establishes that the legitimate wildcard workload does
+// not NEED a budget, not that no budget is safe. Every effective attach
+// audits and bumps control/serial, which every box polls at 2s and reacts
+// to with a full pass, so domainsGate keeps a convergence-rate ceiling
+// (domainsCeilingBurst, keyed on the principal) even though it dropped the
+// write/instance budgets. Plain, non-wildcard attaches are enough to show
+// it — the ceiling applies to the door, not to the wildcard flags.
+func TestDomainsCeilingBoundsConvergenceRate(t *testing.T) {
+ fx, _ := instanceFixture(t, "code-wc-ceiling")
+ svc := serviceToken(t, fx.st, OpsAccountID, "hub", "titogo", svcrole.Publish)
+ var saw429 bool
+ for i := 0; i < domainsCeilingBurst+5; i++ {
+ host := fmt.Sprintf("c%d.pages.example", i)
+ code, _, out := rawReq(t, http.MethodPost, domainsURL(fx, "bab", "titogo"), svc,
+ `{"host":"`+host+`","route":"hub.pages.example"}`)
+ if code == http.StatusTooManyRequests {
+ saw429 = true
+ break
+ }
+ if code != http.StatusOK {
+ t.Fatalf("attach %d = %d: %s", i, code, out)
+ }
+ }
+ if !saw429 {
+ t.Error("unbounded attaches from one credential; want a 429 after domainsCeilingBurst")
+ }
+}
+
// TestFailedWildcardEnableIsBounded is the hazard the exemption has to
// survive: the CNAME check is a live, sequential DNS round trip on the
// request path, and a caller whose CNAME is not there yet can retry
@@ -1180,3 +1213,54 @@ func TestIdempotentReattachTouchesNeitherBudget(t *testing.T) {
}
}
}
+
+// TestLegacyAppTokensHaveSeparateWildFailBudgets is C32's fix, made
+// concrete: titogo's routers each hold their OWN app token (kind='app',
+// Name==""), often bound to different apps in the same account. Before the
+// fix, principalKey collapsed every one of them onto "service:<id>/" —
+// whichever app it was bound to — so one router's mistyped CNAME could 429
+// every other router's wildcard enable in the account. auditActor already
+// keys this exact row on App for the same reason; principalKey must agree.
+func TestLegacyAppTokensHaveSeparateWildFailBudgets(t *testing.T) {
+ const zone = "acme.example"
+ fx, _ := instanceFixture(t, "code-wc-legacy-sep")
+ now := time.Now()
+ if _, err := fx.st.CreateAppIn(OpsAccountID, "widgets", "paul@keymail.dev", now); err != nil {
+ t.Fatal(err)
+ }
+ tokTitogo, err := fx.st.NewAppToken(OpsAccountID, "titogo", "paul@keymail.dev", "router-titogo", now)
+ if err != nil {
+ t.Fatalf("NewAppToken(titogo): %v", err)
+ }
+ tokWidgets, err := fx.st.NewAppToken(OpsAccountID, "widgets", "paul@keymail.dev", "router-widgets", now)
+ if err != nil {
+ t.Fatalf("NewAppToken(widgets): %v", err)
+ }
+ fx.srv.delegationZone = zone
+ fx.srv.lookupCNAME = func(context.Context, string) (string, error) {
+ return "", errors.New("not found") // no _acme-challenge CNAME anywhere
+ }
+ // Exhaust titogo's app token's wildFail budget.
+ var saw429 bool
+ for i := 0; i < wildFailBurst+5; i++ {
+ code, _, _ := rawReq(t, http.MethodPost, domainsURL(fx, "bab", "titogo"), tokTitogo,
+ `{"host":"retry.pages.example","route":"hub.pages.example","wildcard":true}`)
+ if code == http.StatusTooManyRequests {
+ saw429 = true
+ break
+ }
+ if code != http.StatusUnprocessableEntity {
+ t.Fatalf("titogo failing attach %d = %d, want 422 (until the bound trips)", i, code)
+ }
+ }
+ if !saw429 {
+ t.Fatal("titogo's app token never tripped wildFail — the setup is broken, not the fix")
+ }
+ // widgets' app token — a DIFFERENT app, same account — must be
+ // untouched: a single failure answers 422, never 429.
+ code, _, out := rawReq(t, http.MethodPost, domainsURL(fx, "bab", "widgets"), tokWidgets,
+ `{"host":"retry2.pages.example","route":"hub.pages.example","wildcard":true}`)
+ if code != http.StatusUnprocessableEntity {
+ t.Fatalf("widgets' app token after titogo's exhausted its budget = %d: %s, want 422 (separate budget)", code, out)
+ }
+}
diff --git a/internal/console/authcli.go b/internal/console/authcli.go
index f367b2fb2..141ef2f45 100644
--- a/internal/console/authcli.go
+++ b/internal/console/authcli.go
@@ -109,27 +109,58 @@ const (
cliCreateBurst = 10
// Service-principal budgets (Task 5, issue #220 consequence 1). A hub is
- // not a person, and the numbers above are calibrated for one. These are
- // a judgement, not a derivation: 100 repos enabling Pages in a day, and
- // a push every 30 seconds sustained.
+ // not a person, and each number below is its own judgement calibrated
+ // for what a legitimate AUTOMATED caller does — not a derivation from
+ // the person numbers above, and not one shared story: read each
+ // comment on its own rather than assuming the others generalize it.
//
- // They are keyed on the principal NAME (principalKey), not the token
- // hash: rotating a credential must not hand it a fresh budget, or a
- // rotation mid-flood would be the limit doing nothing.
+ // All three are keyed on the principal NAME (principalKey), not the
+ // token hash: rotating a credential must not hand it a fresh budget, or
+ // a rotation mid-flood would be the limit doing nothing.
+
+ // svcCreateBurst: 100 repos enabling Pages (or otherwise claiming an
+ // app) in one day — ten times cliCreateBurst's per-person ceiling,
+ // because one hub credential legitimately claims on behalf of many
+ // repos where one person claims only for themselves.
svcCreateBurst = 100 // per day, the cliCreateWindow
- svcWriteBurst = 120 // per minute, the cliRateWindow
- svcShipBurst = 60 // per hour, the cliShipWindow
+ // svcWriteBurst: a mutation roughly every half second sustained for a
+ // full minute — four times cliWriteBurst's per-person ceiling, roomy
+ // for a CI fleet's shared credential running many promotes, rollbacks
+ // and restarts back to back.
+ svcWriteBurst = 120 // per minute, the cliRateWindow
+ // svcShipBurst: one ship roughly every minute sustained for a full
+ // hour — six times cliShipBurst's per-token ceiling, for a busy CI
+ // fleet cutting many releases in a burst rather than one human's
+ // occasional deploy.
+ svcShipBurst = 60 // per hour, the cliShipWindow
- // wildFailBurst bounds FAILED wildcard enables per principal per minute.
- // The successful path is exempt (spec §4a): with wildcard DNS a Pages
- // hub makes no per-repo hostname call at all, and an enable is
- // genuinely once-per-domain. But the enabling gate does a LIVE,
- // SEQUENTIAL DNS round trip on the request path, and a caller whose
- // _acme-challenge CNAME is not there yet can retry forever — so the
- // failures are bounded even though the successes are not. Roomy enough
- // for a resolver blip, closed enough that an exempt door is not a way to
- // make the console do unbounded outbound DNS.
+ // wildFailBurst bounds FAILED wildcard enables per principal per
+ // minute — PEEKED before the DNS round trip and CONSUMED only after it
+ // fails (rateLimiter.tripped / .allow, C33 review), so a caller whose
+ // CNAME is correct never accumulates a failure and never meets it,
+ // while a retrying caller stops costing the console a live outbound
+ // DNS lookup once it has tripped. The successful path is exempt
+ // entirely (spec §4a): with wildcard DNS a Pages hub makes no per-repo
+ // hostname call at all, and an enable is genuinely once-per-domain.
+ // Roomy enough for a resolver blip, closed enough that an exempt door
+ // is not a way to make the console do unbounded outbound DNS.
wildFailBurst = 20
+
+ // domainsCeilingBurst is NOT a workload budget — attach/detach are
+ // exempt from the write/instance budgets by design (spec §4a: a
+ // wildcard hub makes near-zero per-repo calls, so there is no
+ // legitimate workload to bound). It is a CONVERGENCE-RATE governor
+ // instead: every EFFECTIVE attach or detach audits and bumps
+ // control/serial, and boxes poll that serial at 2s and run a full pass
+ // on every move, so an unlimited exempt door would let one
+ // authenticated credential drive fleet-wide convergence at request
+ // rate — an alternating attach/detach loop on one host, for instance.
+ // 120/min is svcWriteBurst's own scale and 2x
+ // TestWildcardEnableIsNotRateLimited's per-minute volume, so it stays
+ // "not rate limited" in every sense a real caller experiences; it
+ // exists purely to cap how fast one credential can make every box look
+ // at the world again, not to throttle the exempt workload.
+ domainsCeilingBurst = 120 // per minute, the cliRateWindow, keyed on the principal
)
// rateLimiter is a fixed-window counter, keyed by whatever the caller thinks
@@ -191,6 +222,37 @@ func (l *rateLimiter) allow(key string, now time.Time) bool {
return w.n <= l.max
}
+// tripped is allow's READ-ONLY half: it reports whether key is ALREADY over
+// budget, in the current window, without recording a hit. It exists for a
+// caller that wants to skip an expensive operation ENTIRELY once the budget
+// is spent, rather than pay for the operation and only then be refused
+// (Task 5 review, C33) — a live outbound DNS round trip is the case that
+// motivated it, but the shape is general.
+//
+// Same sweep, same reason as allow: an expired window must read as fresh
+// here too, or a peek and the allow that follows it a moment later could
+// disagree about whether the caller is still bounded. It does NOT create an
+// entry for a key it has never seen and does NOT run allow's over-the-hard-
+// bound wholesale drop — a peek must never be the thing that evicts every
+// OTHER key's count, which a bare read has no business doing.
+func (l *rateLimiter) tripped(key string, now time.Time) bool {
+ l.mu.Lock()
+ defer l.mu.Unlock()
+ if now.Sub(l.lastSweep) >= l.window {
+ for k, w := range l.hits {
+ if now.Sub(w.start) > l.window {
+ delete(l.hits, k)
+ }
+ }
+ l.lastSweep = now
+ }
+ w := l.hits[key]
+ if w == nil || now.Sub(w.start) > l.window {
+ return false
+ }
+ return w.n >= l.max
+}
+
// cliLimits is the set of limiters the CLI endpoints share, hung off the
// Server so a test gets a fresh set per fixture.
type cliLimits struct {
@@ -206,6 +268,12 @@ type cliLimits struct {
// wildFailBurst. The domains doors are otherwise exempt from write/
// instances entirely (spec §4a).
wildFail *rateLimiter
+ // domainsCeiling is the convergence-rate governor attach/detach keep
+ // even though they are exempt from write/instances — see
+ // domainsCeilingBurst. One limiter, one key shape (principalKey),
+ // regardless of person or service: the exemption never depended on
+ // which kind of caller it was, so neither does this ceiling.
+ domainsCeiling *rateLimiter
}
func newCLILimits() *cliLimits {
@@ -236,6 +304,8 @@ func newCLILimits() *cliLimits {
svcWrite: newRateLimiter(svcWriteBurst, cliRateWindow),
svcShip: newRateLimiter(svcShipBurst, cliShipWindow),
wildFail: newRateLimiter(wildFailBurst, cliRateWindow),
+
+ domainsCeiling: newRateLimiter(domainsCeilingBurst, cliRateWindow),
}
}
diff --git a/internal/console/authcli_test.go b/internal/console/authcli_test.go
index f83d5701e..9a46329f5 100644
--- a/internal/console/authcli_test.go
+++ b/internal/console/authcli_test.go
@@ -625,6 +625,33 @@ func TestPersonBudgetsAreUnchanged(t *testing.T) {
}
}
+// TestTwoNamedServicesHaveSeparateBudgets is Minor 5 (Task 5 review): two
+// DIFFERENT named services in one account must not share a budget just
+// because they share an account — closing this alongside C32's empty-name
+// collapse, since a naive fix for one (keying on account id alone) could
+// still fail the other. Exhausting "hub-a"'s create budget must leave
+// "hub-b"'s untouched.
+func TestTwoNamedServicesHaveSeparateBudgets(t *testing.T) {
+ fx := newConsoleFixture(t)
+ svcA := serviceToken(t, fx.st, OpsAccountID, "hub-a", "", svcrole.Publish)
+ svcB := serviceToken(t, fx.st, OpsAccountID, "hub-b", "", svcrole.Publish)
+ for i := 0; i < svcCreateBurst; i++ {
+ name := fmt.Sprintf("hub-a-%d", i)
+ if status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/new", map[string]string{"name": name}, svcA); status != http.StatusOK {
+ t.Fatalf("hub-a create %d = %d %v", i, status, out)
+ }
+ }
+ status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/new", map[string]string{"name": "hub-a-over"}, svcA)
+ if status != http.StatusTooManyRequests {
+ t.Fatalf("hub-a over budget = %d %v, want 429", status, out)
+ }
+ // hub-b, a DIFFERENT named service in the SAME account, is untouched.
+ status, out = postJSON(t, fx.ts.URL+"/api/cli/apps/bab/new", map[string]string{"name": "hub-b-fresh"}, svcB)
+ if status != http.StatusOK {
+ t.Fatalf("hub-b create after hub-a exhausted its own budget = %d %v, want 200", status, out)
+ }
+}
+
// TestServiceWriteBudgetIsOwnAndSeparateFromHuman: the write budget's own
// service picker (svcWriteBurst), exercised through a real write door
// (restart) rather than the picker in isolation — the call site is the part
diff --git a/internal/console/svcprincipal.go b/internal/console/svcprincipal.go
index 8bd3d0212..43dc06f1f 100644
--- a/internal/console/svcprincipal.go
+++ b/internal/console/svcprincipal.go
@@ -331,10 +331,21 @@ WHERE token_hash = ? AND account_id = ? AND revoked_at = 0`, now.Unix(), id, acc
// principalKey is what a rate limiter counts against.
//
// A person keys on their ADDRESS, byte for byte as before — no human's
-// limits move in this change. A service keys on its NAME, not on its token
-// hash, so that rotating a credential does not hand it a fresh budget: the
-// name is stable across a secret change precisely so budgets and audit rows
-// stay continuous.
+// limits move in this change. A NAMED service keys on its NAME, not on its
+// token hash, so that rotating a credential does not hand it a fresh
+// budget: the name is stable across a secret change precisely so budgets
+// and audit rows stay continuous.
+//
+// A legacy kind='app' row carries no name (Name == ""), and mirrors
+// auditActor's own branch for exactly the same row: without this branch,
+// EVERY app token in an account — whichever app it is bound to — would
+// collapse onto the single key "service:<id>/", sharing one budget between
+// credentials that have nothing to do with each other. Concretely: titogo's
+// routers each hold their own app token, several bound to different apps in
+// the same account; a mistyped CNAME on one router's wildFail budget must
+// not 429 every other router's wildcard enable. Keying on App instead of
+// Name gives each of them back its own bucket, the same scope App already
+// carries for authorization.
//
// The account id rather than the sqid because this string is never shown to
// anyone — it is a map key, and the id is what the resolver already has on
@@ -343,6 +354,9 @@ func principalKey(p Principal) string {
if p.Kind == PrincipalUser {
return p.Address
}
+ if p.Name == "" {
+ return "app:" + strconv.FormatInt(p.AccountID, 10) + "/" + p.App
+ }
return "service:" + strconv.FormatInt(p.AccountID, 10) + "/" + p.Name
}
@@ -377,3 +391,33 @@ func (s *Server) allowShip(p Principal, r *http.Request, now time.Time) bool {
}
return s.cli.svcShip.allow(principalKey(p), now)
}
+
+// allowWildFail and wildFailTripped are wildFail's own picker pair — the
+// task's premise is that no call site reaches into s.cli by hand to choose
+// a limiter, and wildFail is no exception even though it is a single
+// counter shared by every principal kind rather than a person/service pair
+// like the three above.
+//
+// wildFail's caller needs BOTH a peek and a consume (C33 review): a live,
+// sequential DNS round trip on the request path must not run at all once a
+// principal has already tripped the bound, but only a FAILED lookup may
+// ever add to the count — a caller whose CNAME is correct must never
+// accumulate toward it no matter how many times it (harmlessly) succeeds.
+func (s *Server) allowWildFail(p Principal, now time.Time) bool {
+ return s.cli.wildFail.allow(principalKey(p), now)
+}
+
+func (s *Server) wildFailTripped(p Principal, now time.Time) bool {
+ return s.cli.wildFail.tripped(principalKey(p), now)
+}
+
+// allowDomains is the attach/detach convergence-rate ceiling
+// (domainsCeilingBurst) — not a workload budget (domains doors are exempt
+// from write/instances, spec §4a), a cap on how fast one credential can
+// make every box look at control/serial again. One limiter for every
+// principal kind, the same shape as allowWildFail and for the same reason:
+// the exemption this ceiling sits behind never depended on person vs.
+// service, so the ceiling behind it does not either.
+func (s *Server) allowDomains(p Principal, now time.Time) bool {
+ return s.cli.domainsCeiling.allow(principalKey(p), now)
+}