carlos / platform Public

console: a server no longer competes with its operator for budget

Task 5, issue #220 consequence 1: rate limits keyed on the principal.

principalKey (svcprincipal.go) keys a person on their address, byte for
byte as before, and a service on its NAME (not its token hash), so
rotating a credential does not hand it a fresh budget. Three pickers —
allowCreate, allowWrite, allowShip — choose the person or service
limiter from the principal so no call site has to remember which one
it is talking to; allowShip's person branch stays keyed on the token
hash exactly as handleAPIShip already documented.

Service budgets: svcCreateBurst=100/day, svcWriteBurst=120/min,
svcShipBurst=60/hour. Converted every write/create call site that
resolves through apiAccount/apiAccountApp/apiFleet across apicli.go,
apiconfig.go, apiinstances.go, apiledger.go, apiretention.go,
apistore.go, apiversion.go and fleets.go. Left untouched, deliberately:
handleCLINewAccount (/auth/cli/accounts never sees a service
principal), handleAPIShip's person branch (still token-keyed, per its
own comment), s.cli.instances.allow (already token-keyed,
cliInstanceBurst explains why), and the /auth/cli/ limiters (start,
poll, pollIP, guessAddr, guessIP).

The domains doors (attach/detach) are exempt from the write budget
rather than uplifted: wildcard DNS means a Pages hub makes no per-repo
hostname call at all. domainsGate is instanceGate with the budget step
removed. The hazard the exemption has to survive is bounded instead:
wildFailBurst=20/min counts only FAILED wildcard-enable attempts,
after the CNAME lookup fails, so a succeeding enable never meets it and
an idempotent re-attach (enabling=false) skips it entirely.
setDomainCapabilities now takes the Principal instead of a bare
audit-actor string, deriving the audit string with auditActor at the
point it's needed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016UimU7fAfeAoBMm6HKCPwQ
Paul Campbell 1dedcf12ab1a5ba74edce7305664787f095afc02 parent 9ebbe88
13 files changed, +504 −75
  • internal/console/apicli.go +45 −30
  • internal/console/apiconfig.go +15 −10
  • internal/console/apidomains.go +46 −4
  • internal/console/apidomains_test.go +91 −0
  • internal/console/apiinstances.go +4 −2
  • internal/console/apiledger.go +15 −11
  • internal/console/apiretention.go +2 −1
  • internal/console/apistore.go +10 −6
  • internal/console/apiversion.go +5 −4
  • internal/console/authcli.go +39 −0
  • internal/console/authcli_test.go +163 −0
  • internal/console/fleets.go +15 −7
  • internal/console/svcprincipal.go +54 −0
diff --git a/internal/console/apicli.go b/internal/console/apicli.go
index 096d0ef1a..900d5cde0 100644
--- a/internal/console/apicli.go
+++ b/internal/console/apicli.go
@@ -139,9 +139,12 @@ func (s *Server) serviceBinding(p Principal) (*Account, bool) {
// It is the SHARED BODY, not a convenience for some future constituency.
// Three callers today: apiAccountAppBy and handleAPICreateApp, which both
// need the typed principal to check an app binding, and apiAccount, which
-// renders the audit actor for everyone else. Spec §4's principalKey moves
-// the rate limiters onto it in a later task; that is a consequence of this
-// shape, not the reason for it.
+// renders the audit actor for everyone else. Task 5's rate limiters
+// (allowCreate/allowWrite/allowShip, svcprincipal.go's principalKey) are the
+// budget-key constituency this shape was built for — they read the
+// principal back via principalFrom on the request context instead of
+// calling this a fourth time, the same principal cliRoute already resolved,
+// cheaper than resolving the account segment all over again.
//
// A SERVICE's scope is a BINDING, not a membership set, so the segment is
// matched as a sqid ONLY. It holds no memberships, so there is no set within
@@ -344,9 +347,11 @@ func (s *Server) handleAPICreateApp(w http.ResponseWriter, r *http.Request) {
return
}
addr := auditActor(p, acct)
- // Keyed on the ADDRESS, like every write budget here: a token-keyed
- // counter is one the holder of several tokens resets at will.
- if !s.cli.create.allow(addr, time.Now()) {
+ // allowCreate picks the person or the service create budget from the
+ // principal (svcprincipal.go) — a person keyed on the ADDRESS as before
+ // (a token-keyed counter is one the holder of several tokens resets at
+ // will), a service on its NAME, own budget from the operator's.
+ if !s.allowCreate(p, time.Now()) {
jsonError(w, http.StatusTooManyRequests,
"app creation limit reached for today — use the console's New app form")
return
@@ -565,13 +570,15 @@ func (s *Server) handleAPIPromote(w http.ResponseWriter, r *http.Request) {
if !ok {
return
}
- // Keyed on the ADDRESS, not the token: the principal a write budget
- // belongs to is the PERSON, not the terminal they happen to be typing on
- // — the same key handleCLINewAccount already uses on this limiter.
- // Keying by token instead would give someone with several live tokens an
- // independent budget per token, which is the limit doing less than it
- // should, not more.
- if !s.cli.write.allow(addr, time.Now()) {
+ 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
+ // token — the same key handleCLINewAccount already uses on this
+ // limiter: keying by token instead would give someone with several live
+ // tokens an independent budget per token, which is the limit doing less
+ // than it should, not more. A service is keyed on its NAME, its own
+ // budget separate from the operator who minted it (issue #220).
+ if !s.allowWrite(p, time.Now()) {
jsonError(w, http.StatusTooManyRequests, "too many requests — wait a minute and try again")
return
}
@@ -688,13 +695,15 @@ func (s *Server) handleAPIRollback(w http.ResponseWriter, r *http.Request) {
if !ok {
return
}
- // Keyed on the ADDRESS, not the token: the principal a write budget
- // belongs to is the PERSON, not the terminal they happen to be typing on
- // — the same key handleCLINewAccount already uses on this limiter.
- // Keying by token instead would give someone with several live tokens an
- // independent budget per token, which is the limit doing less than it
- // should, not more.
- if !s.cli.write.allow(addr, time.Now()) {
+ 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
+ // token — the same key handleCLINewAccount already uses on this
+ // limiter: keying by token instead would give someone with several live
+ // tokens an independent budget per token, which is the limit doing less
+ // than it should, not more. A service is keyed on its NAME, its own
+ // budget separate from the operator who minted it (issue #220).
+ if !s.allowWrite(p, time.Now()) {
jsonError(w, http.StatusTooManyRequests, "too many requests — wait a minute and try again")
return
}
@@ -797,7 +806,8 @@ func (s *Server) handleAPIRestart(w http.ResponseWriter, r *http.Request) {
if !ok {
return
}
- if !s.cli.write.allow(addr, time.Now()) {
+ p, _ := principalFrom(r.Context())
+ if !s.allowWrite(p, time.Now()) {
jsonError(w, http.StatusTooManyRequests, "too many requests — wait a minute and try again")
return
}
@@ -872,7 +882,8 @@ func (s *Server) handleAPITrashApp(w http.ResponseWriter, r *http.Request) {
if !ok {
return
}
- if !s.cli.write.allow(addr, time.Now()) {
+ p, _ := principalFrom(r.Context())
+ if !s.allowWrite(p, time.Now()) {
jsonError(w, http.StatusTooManyRequests, "too many requests — wait a minute and try again")
return
}
@@ -982,7 +993,8 @@ func (s *Server) handleAPIRestoreApp(w http.ResponseWriter, r *http.Request) {
if !ok {
return
}
- if !s.cli.write.allow(addr, time.Now()) {
+ p, _ := principalFrom(r.Context())
+ if !s.allowWrite(p, time.Now()) {
jsonError(w, http.StatusTooManyRequests, "too many requests — wait a minute and try again")
return
}
@@ -1198,13 +1210,16 @@ func (s *Server) handleAPIShip(w http.ResponseWriter, r *http.Request) {
if !ok {
return
}
- // Keyed on the TOKEN, not the address: ships are rare and heavy — bytes
- // on the wire, an object written per artifact — so a token is the
- // natural principal for an upload, unlike a write budget. Keying by
- // address instead would make a laptop and a CI runner sharing one
- // human's identity share one shipping budget between them, which is the
- // limit doing less than it should, not more.
- if !s.cli.ship.allow(hashToken(bearer(r)), time.Now()) {
+ p, _ := principalFrom(r.Context())
+ // allowShip picks the budget: a person stays keyed on the TOKEN, not the
+ // address — ships are rare and heavy (bytes on the wire, an object
+ // written per artifact), so a token is the natural principal for an
+ // upload, unlike a write budget. Keying by address instead would make a
+ // laptop and a CI runner sharing one human's identity share one
+ // shipping budget between them, which is the limit doing less than it
+ // should, not more. A service gets its own budget, keyed on its NAME
+ // (svcprincipal.go), separate from the operator who minted it.
+ if !s.allowShip(p, r, time.Now()) {
jsonError(w, http.StatusTooManyRequests, "too many uploads — wait a while and try again")
return
}
diff --git a/internal/console/apiconfig.go b/internal/console/apiconfig.go
index 31ec0179b..722b97bb3 100644
--- a/internal/console/apiconfig.go
+++ b/internal/console/apiconfig.go
@@ -55,18 +55,21 @@ const configStoreTimeout = 15 * time.Second
// (256MiB) is where real bytes belong.
const configPutMaxBytes = 256 << 10
-// configWriteAllowed is the write budget both MUTATING doors pass, in the
-// same shape and with the same key as promote/rollback/new-account/ledgers.
+// configWriteAllowed is the write budget both MUTATING doors pass, via
+// allowWrite (svcprincipal.go), the same picker every other write door here
+// uses.
//
-// Keyed on the ADDRESS, not the token: the principal a write budget belongs
-// to is the PERSON, not the terminal they happen to be typing on. Keying by
-// token instead would give someone with several live tokens an independent
-// budget per token, which is the limit doing less than it should, not more.
+// A person is keyed on the ADDRESS, not the token: the principal a write
+// budget belongs to is the PERSON, not the terminal they happen to be
+// typing on. Keying by token instead would give someone with several live
+// tokens an independent budget per token, which is the limit doing less
+// than it should, not more. A service is keyed on its NAME, its own budget
+// separate from the operator who minted it (issue #220).
//
// The GET door is deliberately unlimited, matching its read-only siblings:
// an exhausted write budget must not blind a CLI to what it already wrote.
-func (s *Server) configWriteAllowed(w http.ResponseWriter, addr string) bool {
- if !s.cli.write.allow(addr, time.Now()) {
+func (s *Server) configWriteAllowed(w http.ResponseWriter, p Principal) bool {
+ if !s.allowWrite(p, time.Now()) {
jsonError(w, http.StatusTooManyRequests, "too many requests — wait a minute and try again")
return false
}
@@ -278,7 +281,8 @@ func (s *Server) handleAPIConfigPut(w http.ResponseWriter, r *http.Request) {
if !ok {
return
}
- if !s.configWriteAllowed(w, addr) {
+ p, _ := principalFrom(r.Context())
+ if !s.configWriteAllowed(w, p) {
return
}
// No s.cfg.Key check, unlike promote/rollback: config manifests are not
@@ -489,7 +493,8 @@ func (s *Server) handleAPIConfigSync(w http.ResponseWriter, r *http.Request) {
if !ok {
return
}
- if !s.configWriteAllowed(w, addr) {
+ p, _ := principalFrom(r.Context())
+ if !s.configWriteAllowed(w, p) {
return
}
ctx, cancel := context.WithTimeout(r.Context(), configStoreTimeout)
diff --git a/internal/console/apidomains.go b/internal/console/apidomains.go
index 6b6d3b492..7e06e2642 100644
--- a/internal/console/apidomains.go
+++ b/internal/console/apidomains.go
@@ -135,6 +135,34 @@ func (s *Server) resolveAliasRoute(ctx context.Context, w http.ResponseWriter, a
return "", false
}
+// 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.
+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())
+ // 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.
+ if s.cfg.Releases == nil {
+ jsonError(w, http.StatusServiceUnavailable,
+ "no deployment bucket on this host — the console is running without one")
+ return Principal{}, nil, "", false
+ }
+ return p, acct, app, true
+}
+
// handleAPIDomainAttach is the point of the feature: one claim written to the
// bucket, in the spec's refusal order — gate, body, hostname, deny list,
// route, write. Idempotent by design (the titogo contract): re-attaching a
@@ -151,10 +179,11 @@ func (s *Server) resolveAliasRoute(ctx context.Context, w http.ResponseWriter, a
// route. route and kind together are refused: an alias target and a
// first-route mint are two different instructions.
func (s *Server) handleAPIDomainAttach(w http.ResponseWriter, r *http.Request) {
- principal, acct, app, ok := s.instanceGate(w, r)
+ p, acct, app, ok := s.domainsGate(w, r)
if !ok {
return
}
+ principal := auditActor(p, acct)
var in struct {
Host string `json:"host"`
Route string `json:"route"`
@@ -268,7 +297,7 @@ func (s *Server) handleAPIDomainAttach(w http.ResponseWriter, r *http.Request) {
// the same "absent means unchanged" contract the CLI's fs.Visit-gated
// flags rely on.
if in.Wildcard != nil || in.Catchall != nil {
- if !s.setDomainCapabilities(w, r, ctx, acct, app, principal, host, in.Wildcard, in.Catchall) {
+ if !s.setDomainCapabilities(w, r, ctx, acct, app, p, host, in.Wildcard, in.Catchall) {
return
}
}
@@ -284,7 +313,8 @@ func (s *Server) handleAPIDomainAttach(w http.ResponseWriter, r *http.Request) {
// capabilities as the baseline and overrides only the field(s) the caller
// actually named, so a caller flipping catchall alone cannot accidentally
// clear an already-enabled wildcard, and vice versa.
-func (s *Server) setDomainCapabilities(w http.ResponseWriter, r *http.Request, ctx context.Context, acct *Account, app, principal, host string, wantWildcard, wantCatchall *bool) (ok bool) {
+func (s *Server) setDomainCapabilities(w http.ResponseWriter, r *http.Request, ctx context.Context, acct *Account, app string, p Principal, host string, wantWildcard, wantCatchall *bool) (ok bool) {
+ principal := auditActor(p, acct)
cur, err := domains.GetClaim(ctx, s.cfg.Releases, host)
if err != nil {
log.Printf("console: api domains capabilities %s/%s %s: read after attach: %v", acct.Sqid, app, host, err)
@@ -325,6 +355,17 @@ func (s *Server) setDomainCapabilities(w http.ResponseWriter, r *http.Request, c
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")
+ return false
+ }
jsonError(w, http.StatusUnprocessableEntity,
"create CNAME _acme-challenge."+host+" -> _acme-challenge."+host+"."+s.delegationZone+", then retry")
return false
@@ -388,10 +429,11 @@ func (s *Server) delegationCNAMEDelegated(ctx context.Context, host string) bool
// nothing for a box to converge onto, and an audit row per probe would let a
// prober write to someone else's account log.
func (s *Server) handleAPIDomainDetach(w http.ResponseWriter, r *http.Request) {
- principal, acct, app, ok := s.instanceGate(w, r)
+ p, acct, app, ok := s.domainsGate(w, r)
if !ok {
return
}
+ principal := auditActor(p, acct)
// Shape-checked before the key builder, not after: a {host} segment
// arrives percent-decoded, so "..%2f..%2finstances.json" reaches this
// handler as a path with separators in it, and ClaimKey would happily
diff --git a/internal/console/apidomains_test.go b/internal/console/apidomains_test.go
index 3127077bc..b21875fa9 100644
--- a/internal/console/apidomains_test.go
+++ b/internal/console/apidomains_test.go
@@ -30,6 +30,7 @@ import (
"github.com/carlosframework/platform/internal/registry"
"github.com/carlosframework/platform/internal/release"
"github.com/carlosframework/platform/internal/status"
+ "github.com/carlosframework/platform/internal/svcrole"
)
func domainsURL(fx *consoleFixture, acct, app string) string {
@@ -1089,3 +1090,93 @@ func TestDelegationZoneInvalidAtConstruction(t *testing.T) {
t.Fatalf("422 body = %s, want it to mention no delegation zone", out)
}
}
+
+// ---- wildcard exemption from the write/instance budgets (Task 5, spec §4a)
+
+// 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.
+func TestWildcardEnableIsNotRateLimited(t *testing.T) {
+ const zone = "acme.example"
+ 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++ {
+ 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}`)
+ if code == http.StatusTooManyRequests {
+ t.Fatalf("attach %d = 429: %s", i, out)
+ }
+ if code != http.StatusOK {
+ t.Fatalf("attach %d = %d: %s", i, code, out)
+ }
+ }
+}
+
+// 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
+// forever. A SUCCEEDING enable is once-per-domain and exempt (the test
+// above); a FAILING one is bounded at wildFailBurst per principal per
+// minute.
+func TestFailedWildcardEnableIsBounded(t *testing.T) {
+ const zone = "acme.example"
+ fx, _ := instanceFixture(t, "code-wc-bounded")
+ svc := serviceToken(t, fx.st, OpsAccountID, "hub", "titogo", svcrole.Publish)
+ fx.srv.delegationZone = zone
+ fx.srv.lookupCNAME = func(context.Context, string) (string, error) {
+ return "", errors.New("not found") // no _acme-challenge CNAME anywhere
+ }
+ var saw429 bool
+ for i := 0; i < wildFailBurst+5; i++ {
+ code, _, _ := rawReq(t, http.MethodPost, domainsURL(fx, "bab", "titogo"), svc,
+ `{"host":"retry.pages.example","route":"hub.pages.example","wildcard":true}`)
+ if code == http.StatusTooManyRequests {
+ saw429 = true
+ break
+ }
+ if code != http.StatusUnprocessableEntity {
+ t.Fatalf("failing attach %d = %d, want 422 (until the bound trips)", i, code)
+ }
+ }
+ if !saw429 {
+ t.Error("unbounded failing wildcard enables; want a 429 after wildFailBurst")
+ }
+}
+
+// TestIdempotentReattachTouchesNeitherBudget: titogo's router re-attaches
+// every hostname with its capability flags on every reload pass. `enabling`
+// is a false->true transition, so a re-send of wildcard:true on an
+// already-wildcard claim skips the DNS lookup entirely — it must skip the
+// failure bound with it, even once the resolver goes dark.
+func TestIdempotentReattachTouchesNeitherBudget(t *testing.T) {
+ const zone = "acme.example"
+ fx, _ := instanceFixture(t, "code-wc-idempotent")
+ svc := serviceToken(t, fx.st, OpsAccountID, "hub", "titogo", svcrole.Instance)
+ fx.srv.delegationZone = zone
+ const host = "one.pages.example"
+ fx.srv.lookupCNAME = func(_ context.Context, name string) (string, error) {
+ return name + "." + zone, nil
+ }
+ code, _, out := rawReq(t, http.MethodPost, domainsURL(fx, "bab", "titogo"), svc,
+ `{"host":"`+host+`","route":"hub.pages.example","wildcard":true,"catchall":true}`)
+ if code != http.StatusOK {
+ t.Fatalf("first attach = %d: %s", code, out)
+ }
+ fx.srv.lookupCNAME = func(context.Context, string) (string, error) {
+ return "", errors.New("resolver is dark") // a re-attach must not care
+ }
+ for i := 0; i < wildFailBurst*2; i++ {
+ code, _, out := rawReq(t, http.MethodPost, domainsURL(fx, "bab", "titogo"), svc,
+ `{"host":"`+host+`","route":"hub.pages.example","wildcard":true,"catchall":true}`)
+ if code != http.StatusOK {
+ t.Fatalf("re-attach %d = %d: %s", i, code, out)
+ }
+ }
+}
diff --git a/internal/console/apiinstances.go b/internal/console/apiinstances.go
index e4e63eabe..445b30597 100644
--- a/internal/console/apiinstances.go
+++ b/internal/console/apiinstances.go
@@ -854,7 +854,8 @@ func (s *Server) handleAPIInstanceToken(w http.ResponseWriter, r *http.Request)
if !ok {
return
}
- if !s.cli.write.allow(addr, time.Now()) {
+ p, _ := principalFrom(r.Context())
+ if !s.allowWrite(p, time.Now()) {
jsonError(w, http.StatusTooManyRequests, "too many requests — wait a minute and try again")
return
}
@@ -932,7 +933,8 @@ func (s *Server) handleAPIInstanceEnable(w http.ResponseWriter, r *http.Request)
if !ok {
return
}
- if !s.cli.write.allow(addr, time.Now()) {
+ p, _ := principalFrom(r.Context())
+ if !s.allowWrite(p, time.Now()) {
jsonError(w, http.StatusTooManyRequests, "too many requests — wait a minute and try again")
return
}
diff --git a/internal/console/apiledger.go b/internal/console/apiledger.go
index d6f32a48e..1b3c683fb 100644
--- a/internal/console/apiledger.go
+++ b/internal/console/apiledger.go
@@ -44,13 +44,14 @@ import (
// cancels immediately — this only adds a ceiling.
const ledgerStoreTimeout = 15 * time.Second
-// ledgerWriteAllowed is the write budget every ledger WRITE door passes, in
-// the same shape and with the same key as promote/rollback/new-account: the
-// principal a write budget belongs to is the PERSON, not the terminal they
-// happen to be typing on. The two GET doors below are unlimited, matching
-// their read-only siblings (channels, apps).
-func (s *Server) ledgerWriteAllowed(w http.ResponseWriter, addr string) bool {
- if !s.cli.write.allow(addr, time.Now()) {
+// ledgerWriteAllowed is the write budget every ledger WRITE door passes, via
+// allowWrite (svcprincipal.go): a person is keyed on the ADDRESS, the same
+// principal a write budget has always belonged to, not the terminal they
+// happen to be typing on; a service is keyed on its NAME, its own budget
+// separate from the operator who minted it. The two GET doors below are
+// unlimited, matching their read-only siblings (channels, apps).
+func (s *Server) ledgerWriteAllowed(w http.ResponseWriter, p Principal) bool {
+ if !s.allowWrite(p, time.Now()) {
jsonError(w, http.StatusTooManyRequests, "too many requests — wait a minute and try again")
return false
}
@@ -78,7 +79,8 @@ func (s *Server) handleAPILedgerAppend(w http.ResponseWriter, r *http.Request) {
if !ok {
return
}
- if !s.ledgerWriteAllowed(w, addr) {
+ p, _ := principalFrom(r.Context())
+ if !s.ledgerWriteAllowed(w, p) {
return
}
name := r.PathValue("ledger")
@@ -161,11 +163,12 @@ func (s *Server) handleAPILedgerHead(w http.ResponseWriter, r *http.Request) {
// and sharing that two-slot semaphore would let blob traffic starve the
// deploy path.
func (s *Server) handleAPILedgerBlob(w http.ResponseWriter, r *http.Request) {
- addr, acct, app, ok := s.apiAccountApp(w, r)
+ _, acct, app, ok := s.apiAccountApp(w, r)
if !ok {
return
}
- if !s.ledgerWriteAllowed(w, addr) {
+ p, _ := principalFrom(r.Context())
+ if !s.ledgerWriteAllowed(w, p) {
return
}
name := r.PathValue("ledger")
@@ -291,7 +294,8 @@ func (s *Server) handleAPILedgerPublish(w http.ResponseWriter, r *http.Request)
if !ok {
return
}
- if !s.ledgerWriteAllowed(w, addr) {
+ p, _ := principalFrom(r.Context())
+ if !s.ledgerWriteAllowed(w, p) {
return
}
var in ledger.Manifest
diff --git a/internal/console/apiretention.go b/internal/console/apiretention.go
index edd321fb2..e13d064f2 100644
--- a/internal/console/apiretention.go
+++ b/internal/console/apiretention.go
@@ -94,7 +94,8 @@ func (s *Server) handleAPIRetentionSet(w http.ResponseWriter, r *http.Request) {
if !ok {
return
}
- if !s.cli.write.allow(addr, time.Now()) {
+ p, _ := principalFrom(r.Context())
+ if !s.allowWrite(p, time.Now()) {
jsonError(w, http.StatusTooManyRequests, "too many requests — wait a minute and try again")
return
}
diff --git a/internal/console/apistore.go b/internal/console/apistore.go
index 2581750f1..631d7a81a 100644
--- a/internal/console/apistore.go
+++ b/internal/console/apistore.go
@@ -182,7 +182,8 @@ func (s *Server) handleAPIStoreCreate(w http.ResponseWriter, r *http.Request) {
if !ok {
return
}
- if !s.cli.write.allow(addr, time.Now()) {
+ p, _ := principalFrom(r.Context())
+ if !s.allowWrite(p, time.Now()) {
jsonError(w, http.StatusTooManyRequests, "too many requests — wait a minute and try again")
return
}
@@ -389,8 +390,8 @@ func (s *Server) storeOperator(addr string) bool {
// this request: the member's declaration still stands and the sentence names
// the variable that would wire it up, which is the only thing the person
// reading it can do about it.
-func (s *Server) storeWalkGates(w http.ResponseWriter, addr string) bool {
- if !s.cli.write.allow(addr, time.Now()) {
+func (s *Server) storeWalkGates(w http.ResponseWriter, p Principal) bool {
+ if !s.allowWrite(p, time.Now()) {
jsonError(w, http.StatusTooManyRequests, "too many requests — wait a minute and try again")
return false
}
@@ -511,7 +512,8 @@ func (s *Server) handleAPIStoreGrant(w http.ResponseWriter, r *http.Request) {
"store grants need a deployment operator (CARLOS_STORE_OPERATORS)")
return
}
- if !s.storeWalkGates(w, addr) {
+ p, _ := principalFrom(r.Context())
+ if !s.storeWalkGates(w, p) {
return
}
pub, ok := s.storeSealKey(w)
@@ -583,7 +585,8 @@ func (s *Server) handleAPIStoreRotate(w http.ResponseWriter, r *http.Request) {
if !ok {
return
}
- if !s.storeWalkGates(w, addr) {
+ p, _ := principalFrom(r.Context())
+ if !s.storeWalkGates(w, p) {
return
}
pub, ok := s.storeSealKey(w)
@@ -652,7 +655,8 @@ func (s *Server) handleAPIStoreRotateFinish(w http.ResponseWriter, r *http.Reque
if !ok {
return
}
- if !s.storeWalkGates(w, addr) {
+ p, _ := principalFrom(r.Context())
+ if !s.storeWalkGates(w, p) {
return
}
ctx, cancel := context.WithTimeout(r.Context(), storeRotateTimeout)
diff --git a/internal/console/apiversion.go b/internal/console/apiversion.go
index 32038b9ae..a511be848 100644
--- a/internal/console/apiversion.go
+++ b/internal/console/apiversion.go
@@ -66,10 +66,11 @@ func (s *Server) handleAPIVersionTargetSet(w http.ResponseWriter, r *http.Reques
if !ok {
return
}
- // Keyed on the ADDRESS, not the token: the principal a write budget
- // belongs to is the PERSON, not the terminal they happen to be typing on
- // — the same key handleAPIPromote's budget uses.
- if !s.cli.write.allow(addr, time.Now()) {
+ p, _ := principalFrom(r.Context())
+ // allowWrite picks the person or the service write budget from the
+ // principal (svcprincipal.go) — the same key handleAPIPromote's budget
+ // uses.
+ if !s.allowWrite(p, time.Now()) {
jsonError(w, http.StatusTooManyRequests, "too many requests — wait a minute and try again")
return
}
diff --git a/internal/console/authcli.go b/internal/console/authcli.go
index 9b2180fc8..f367b2fb2 100644
--- a/internal/console/authcli.go
+++ b/internal/console/authcli.go
@@ -107,6 +107,29 @@ const (
// would trip the wholesale map drop and reset every budget. Orders of
// magnitude beyond this fleet; recorded, not defended.
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.
+ //
+ // 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.
+ svcCreateBurst = 100 // per day, the cliCreateWindow
+ svcWriteBurst = 120 // per minute, the cliRateWindow
+ 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 = 20
)
// rateLimiter is a fixed-window counter, keyed by whatever the caller thinks
@@ -172,6 +195,17 @@ func (l *rateLimiter) allow(key string, now time.Time) bool {
// Server so a test gets a fresh set per fixture.
type cliLimits struct {
start, poll, pollIP, guessAddr, guessIP, write, ship, instances, create *rateLimiter
+ // svcCreate, svcWrite and svcShip are the SERVICE side of create/write/
+ // ship — separate counters, separate keys (principalKey's NAME, not the
+ // person's ADDRESS or the ship-person's token hash), so a credential's
+ // budget is never the operator's to spend (issue #220 consequence 1).
+ // allowCreate/allowWrite/allowShip (svcprincipal.go) pick between the
+ // person and the service limiter so no call site has to.
+ svcCreate, svcWrite, svcShip *rateLimiter
+ // wildFail bounds only FAILED wildcard-enable attempts — see
+ // wildFailBurst. The domains doors are otherwise exempt from write/
+ // instances entirely (spec §4a).
+ wildFail *rateLimiter
}
func newCLILimits() *cliLimits {
@@ -197,6 +231,11 @@ func newCLILimits() *cliLimits {
// Create gets its own budget on its own (day-long) window, keyed by
// address — the stolen-token ceiling handleAPICreateApp explains.
create: newRateLimiter(cliCreateBurst, cliCreateWindow),
+
+ svcCreate: newRateLimiter(svcCreateBurst, cliCreateWindow),
+ svcWrite: newRateLimiter(svcWriteBurst, cliRateWindow),
+ svcShip: newRateLimiter(svcShipBurst, cliShipWindow),
+ wildFail: newRateLimiter(wildFailBurst, cliRateWindow),
}
}
diff --git a/internal/console/authcli_test.go b/internal/console/authcli_test.go
index 248de3b53..f83d5701e 100644
--- a/internal/console/authcli_test.go
+++ b/internal/console/authcli_test.go
@@ -3,12 +3,15 @@ package console
import (
"bytes"
"encoding/json"
+ "fmt"
"io"
"net/http"
"net/url"
"strings"
"testing"
"time"
+
+ "github.com/carlosframework/platform/internal/svcrole"
)
// postJSON is how the CLI talks to the console: a JSON body, an optional
@@ -552,3 +555,163 @@ func TestCLITokensPageRendersAppTokenHonestly(t *testing.T) {
t.Errorf("the user token's expiry no longer renders:\n%s", html)
}
}
+
+// ---- rate limits keyed on the principal (Task 5, issue #220 consequence 1) -
+
+// TestServiceBudgetIsNotItsOperatorsBudget is the fix for consequence 1: a
+// server holding a credential must not draw down the budget of the human who
+// minted it. The service exhausts its OWN create budget (svcCreateBurst);
+// its minter's own budget is untouched.
+func TestServiceBudgetIsNotItsOperatorsBudget(t *testing.T) {
+ fx := newConsoleFixture(t)
+ svc := serviceToken(t, fx.st, OpsAccountID, "hub", "", svcrole.Publish)
+
+ for i := 0; i < svcCreateBurst; i++ {
+ name := fmt.Sprintf("svc-a%d", i)
+ if status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/new", map[string]string{"name": name}, svc); status != http.StatusOK {
+ t.Fatalf("service create %d = %d %v", i, status, out)
+ }
+ }
+ status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/new", map[string]string{"name": "svc-over"}, svc)
+ if status != http.StatusTooManyRequests {
+ t.Fatalf("service create over budget = %d %v, want 429", status, out)
+ }
+ // The human who minted it has spent nothing.
+ status, out = postJSON(t, fx.ts.URL+"/api/cli/apps/bab/new", map[string]string{"name": "mine"}, cliToken(t, fx, "paul@keymail.dev", "code-svc-budget-human"))
+ if status != http.StatusOK {
+ t.Fatalf("human create after service exhausted its own budget = %d %v, want 200", status, out)
+ }
+}
+
+// TestRotationDoesNotResetTheBudget: the create budget is keyed on the
+// service's NAME (principalKey), not its token hash — rotating mid-flood
+// must buy the caller nothing.
+func TestRotationDoesNotResetTheBudget(t *testing.T) {
+ fx := newConsoleFixture(t)
+ tok := serviceToken(t, fx.st, OpsAccountID, "hub", "", svcrole.Publish)
+ for i := 0; i < svcCreateBurst; i++ {
+ name := fmt.Sprintf("rot-a%d", i)
+ if status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/new", map[string]string{"name": name}, tok); status != http.StatusOK {
+ t.Fatalf("create %d = %d %v", i, status, out)
+ }
+ }
+ fresh, err := fx.st.RotateServicePrincipal(OpsAccountID, "hub", time.Now())
+ if err != nil {
+ t.Fatalf("rotate: %v", err)
+ }
+ status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/new", map[string]string{"name": "after-rotate"}, fresh)
+ if status != http.StatusTooManyRequests {
+ t.Errorf("create after rotate = %d %v, want 429 — rotation must not reset the budget", status, out)
+ }
+}
+
+// TestPersonBudgetsAreUnchanged pins that no human's create limit moved: the
+// same cliCreateBurst as before this change, keyed on the same address.
+func TestPersonBudgetsAreUnchanged(t *testing.T) {
+ fx := newConsoleFixture(t)
+ if err := fx.st.AddMember(OpsAccountID, "alice@keymail.dev", RoleMember, time.Now()); err != nil {
+ t.Fatal(err)
+ }
+ tok := cliToken(t, fx, "alice@keymail.dev", "code-person-budget")
+ for i := 0; i < cliCreateBurst; i++ {
+ name := fmt.Sprintf("person-a%d", i)
+ if status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/new", map[string]string{"name": name}, tok); status != http.StatusOK {
+ t.Fatalf("create %d = %d %v", i, status, out)
+ }
+ }
+ status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/new", map[string]string{"name": "person-over"}, tok)
+ if status != http.StatusTooManyRequests {
+ t.Errorf("create %d+1 = %d %v, want 429 at the unchanged human limit", cliCreateBurst, 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
+// that can get the wrong limiter, not the picker itself.
+func TestServiceWriteBudgetIsOwnAndSeparateFromHuman(t *testing.T) {
+ fx := newConsoleFixture(t)
+ now := time.Now()
+ if _, err := fx.st.CreateAppIn(OpsAccountID, "titogo", "paul@keymail.dev", now); err != nil {
+ t.Fatal(err)
+ }
+ svc := serviceToken(t, fx.st, OpsAccountID, "operator", "titogo", svcrole.Operate)
+ for i := 0; i < svcWriteBurst; i++ {
+ if status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/titogo/restart", map[string]string{}, svc); status != http.StatusOK {
+ t.Fatalf("service restart %d = %d %v", i, status, out)
+ }
+ }
+ if status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/titogo/restart", map[string]string{}, svc); status != http.StatusTooManyRequests {
+ t.Fatalf("service restart over budget = %d %v, want 429", status, out)
+ }
+ human := cliToken(t, fx, "paul@keymail.dev", "code-write-budget-human")
+ if status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/titogo/restart", map[string]string{}, human); status != http.StatusOK {
+ t.Fatalf("human restart after service exhausted its own budget = %d %v, want 200", status, out)
+ }
+}
+
+// TestServiceShipBudgetIsOwnAndKeyedByName: ship's service branch gets its
+// own budget (svcShipBurst) keyed the same way every other service limiter
+// is (principalKey — the NAME), unlike the person branch below.
+func TestServiceShipBudgetIsOwnAndKeyedByName(t *testing.T) {
+ fx := newConsoleFixture(t)
+ now := time.Now()
+ if _, err := fx.st.CreateAppIn(OpsAccountID, "titogo", "paul@keymail.dev", now); err != nil {
+ t.Fatal(err)
+ }
+ svc := serviceToken(t, fx.st, OpsAccountID, "shipper", "titogo", svcrole.Publish)
+ for i := 0; i < svcShipBurst; i++ {
+ v := fmt.Sprintf("v%03d", i)
+ status, out := postShip(t, fx.ts.URL+"/api/cli/apps/bab/titogo/releases", svc,
+ []shipPart{metaPart(t, v, "binary"), {name: "linux-arm64", body: []byte("x")}})
+ if status != http.StatusCreated {
+ t.Fatalf("service ship %d = %d %v", i, status, out)
+ }
+ }
+ status, out := postShip(t, fx.ts.URL+"/api/cli/apps/bab/titogo/releases", svc,
+ []shipPart{metaPart(t, "over-budget", "binary"), {name: "linux-arm64", body: []byte("x")}})
+ if status != http.StatusTooManyRequests {
+ t.Fatalf("service ship over budget = %d %v, want 429", status, out)
+ }
+ fresh, err := fx.st.RotateServicePrincipal(OpsAccountID, "shipper", time.Now())
+ if err != nil {
+ t.Fatalf("rotate: %v", err)
+ }
+ status, out = postShip(t, fx.ts.URL+"/api/cli/apps/bab/titogo/releases", fresh,
+ []shipPart{metaPart(t, "after-rotate", "binary"), {name: "linux-arm64", body: []byte("x")}})
+ if status != http.StatusTooManyRequests {
+ t.Fatalf("service ship after rotate = %d %v, want 429 — rotation must not reset the budget", status, out)
+ }
+}
+
+// TestPersonShipBudgetStaysKeyedByToken pins the one branch the brief says
+// must NOT move: a person's ship budget stays keyed on the TOKEN HASH, not
+// the address (handleAPIShip's own comment) — a second token for the same
+// address gets a FRESH ship budget, unlike every other write door here.
+func TestPersonShipBudgetStaysKeyedByToken(t *testing.T) {
+ fx := newConsoleFixture(t)
+ now := time.Now()
+ if _, err := fx.st.CreateAppIn(OpsAccountID, "titogo", "paul@keymail.dev", now); err != nil {
+ t.Fatal(err)
+ }
+ tok1 := cliToken(t, fx, "paul@keymail.dev", "code-ship-key-1")
+ for i := 0; i < cliShipBurst; i++ {
+ v := fmt.Sprintf("p%03d", i)
+ status, out := postShip(t, fx.ts.URL+"/api/cli/apps/bab/titogo/releases", tok1,
+ []shipPart{metaPart(t, v, "binary"), {name: "linux-arm64", body: []byte("x")}})
+ if status != http.StatusCreated {
+ t.Fatalf("ship %d = %d %v", i, status, out)
+ }
+ }
+ status, out := postShip(t, fx.ts.URL+"/api/cli/apps/bab/titogo/releases", tok1,
+ []shipPart{metaPart(t, "over", "binary"), {name: "linux-arm64", body: []byte("x")}})
+ if status != http.StatusTooManyRequests {
+ t.Fatalf("ship over budget = %d %v, want 429", status, out)
+ }
+ tok2 := cliToken(t, fx, "paul@keymail.dev", "code-ship-key-2")
+ status, out = postShip(t, fx.ts.URL+"/api/cli/apps/bab/titogo/releases", tok2,
+ []shipPart{metaPart(t, "fresh-token", "binary"), {name: "linux-arm64", body: []byte("x")}})
+ if status != http.StatusCreated {
+ t.Fatalf("second token same address = %d %v, want 201 (ship stays token-keyed)", status, out)
+ }
+}
diff --git a/internal/console/fleets.go b/internal/console/fleets.go
index 5d010fd6f..cafa9cc09 100644
--- a/internal/console/fleets.go
+++ b/internal/console/fleets.go
@@ -157,13 +157,16 @@ func (s *Server) handleAPIFleetNew(w http.ResponseWriter, r *http.Request) {
if !ok {
return
}
+ p, _ := principalFrom(r.Context())
// The fleets namespace is flat and global with no delete door (see this
// file's package doc comment) — the same "a claimed name is a permanent
// row, not a pointer the next call overwrites" shape apps' create budget
// exists for, so this spends the SAME scarce per-day budget
// (handleAPICreateApp), not the ordinary per-minute write budget every
- // other fleet door here uses.
- if !s.cli.create.allow(addr, time.Now()) {
+ // other fleet door here uses. Every fleets door is registered with the
+ // zero role Set, so only a person ever reaches here — allowCreate is
+ // used anyway to keep one idiom.
+ if !s.allowCreate(p, time.Now()) {
jsonError(w, http.StatusTooManyRequests,
"fleet creation limit reached for today — try again tomorrow")
return
@@ -258,7 +261,8 @@ func (s *Server) handleAPIFleetAddBox(w http.ResponseWriter, r *http.Request) {
if !ok {
return
}
- if !s.cli.write.allow(addr, time.Now()) {
+ p, _ := principalFrom(r.Context())
+ if !s.allowWrite(p, time.Now()) {
jsonError(w, http.StatusTooManyRequests, "too many requests — wait a minute and try again")
return
}
@@ -318,7 +322,8 @@ func (s *Server) handleAPIFleetRotateBox(w http.ResponseWriter, r *http.Request)
if !ok {
return
}
- if !s.cli.write.allow(addr, time.Now()) {
+ p, _ := principalFrom(r.Context())
+ if !s.allowWrite(p, time.Now()) {
jsonError(w, http.StatusTooManyRequests, "too many requests — wait a minute and try again")
return
}
@@ -373,7 +378,8 @@ func (s *Server) handleAPIFleetTagBox(w http.ResponseWriter, r *http.Request) {
if !ok {
return
}
- if !s.cli.write.allow(addr, time.Now()) {
+ p, _ := principalFrom(r.Context())
+ if !s.allowWrite(p, time.Now()) {
jsonError(w, http.StatusTooManyRequests, "too many requests — wait a minute and try again")
return
}
@@ -418,7 +424,8 @@ func (s *Server) handleAPIFleetDetachBox(w http.ResponseWriter, r *http.Request)
if !ok {
return
}
- if !s.cli.write.allow(addr, time.Now()) {
+ p, _ := principalFrom(r.Context())
+ if !s.allowWrite(p, time.Now()) {
jsonError(w, http.StatusTooManyRequests, "too many requests — wait a minute and try again")
return
}
@@ -658,7 +665,8 @@ func (s *Server) handleAPIPlace(w http.ResponseWriter, r *http.Request) {
if !ok {
return
}
- if !s.cli.write.allow(addr, time.Now()) {
+ p, _ := principalFrom(r.Context())
+ if !s.allowWrite(p, time.Now()) {
jsonError(w, http.StatusTooManyRequests, "too many requests — wait a minute and try again")
return
}
diff --git a/internal/console/svcprincipal.go b/internal/console/svcprincipal.go
index 330aeae02..8bd3d0212 100644
--- a/internal/console/svcprincipal.go
+++ b/internal/console/svcprincipal.go
@@ -22,6 +22,8 @@ import (
"crypto/subtle"
"errors"
"fmt"
+ "net/http"
+ "strconv"
"time"
"github.com/carlosframework/platform/internal/svcrole"
@@ -323,3 +325,55 @@ WHERE token_hash = ? AND account_id = ? AND revoked_at = 0`, now.Unix(), id, acc
}
return nil
}
+
+// ---- rate limits keyed on the principal (Task 5, issue #220 consequence 1)
+
+// 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.
+//
+// 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
+// the Principal without a further lookup.
+func principalKey(p Principal) string {
+ if p.Kind == PrincipalUser {
+ return p.Address
+ }
+ return "service:" + strconv.FormatInt(p.AccountID, 10) + "/" + p.Name
+}
+
+// allowCreate, allowWrite and allowShip pick the person or the service
+// limiter from the principal, so that no call site has to remember which
+// one it is talking to — a call site that got it wrong would be a server
+// drawing down an operator's budget, which is the bug this whole change is
+// about.
+func (s *Server) allowCreate(p Principal, now time.Time) bool {
+ if p.Kind == PrincipalUser {
+ return s.cli.create.allow(principalKey(p), now)
+ }
+ return s.cli.svcCreate.allow(principalKey(p), now)
+}
+
+func (s *Server) allowWrite(p Principal, now time.Time) bool {
+ if p.Kind == PrincipalUser {
+ return s.cli.write.allow(principalKey(p), now)
+ }
+ return s.cli.svcWrite.allow(principalKey(p), now)
+}
+
+// allowShip is the one picker that does NOT key a person on principalKey:
+// the person branch keeps handleAPIShip's existing key, the TOKEN HASH, not
+// the address — a laptop and a CI runner sharing one human's identity share
+// one shipping budget between them otherwise, which is the limit doing less
+// than it should. That reasoning is unchanged by this task; only the
+// service branch is new.
+func (s *Server) allowShip(p Principal, r *http.Request, now time.Time) bool {
+ if p.Kind == PrincipalUser {
+ return s.cli.ship.allow(hashToken(bearer(r)), now)
+ }
+ return s.cli.svcShip.allow(principalKey(p), now)
+}