---
id: w7/m3/t002
title: "Per-caller token-bucket middleware at the shared mux (REST + GraphQL + MCP)"
worker: worker7
status: done
estimate: 60m
depends_on: [w7/m3/t001]
---

## Objective

One rate-limiting middleware at bex-api's shared router: token-bucket per caller — keyed by API-key/OAuth2-client id for key auth, Kratos session/identity id for sessions, and client IP for unauthenticated paths **per t001's recorded client-IP decision** (as of 2026-07-11, per-IP keying does not work on prod; see Context) — returning the t001-captured Render-shaped 429 + `Retry-After` when exhausted. One middleware covers REST, GraphQL, and MCP because they hang off the same mux.

## Context

- The HTTP surface is assembled in `lego/backend/internal/api` (`:8090`); auth context (key/session identity) is resolved there per docs/ADR012-auth.md — the limiter keys off the resolved caller, falling back to IP.
- `golang.org/x/time/rate` per-key limiters in a map with idle eviction is the standard shape; no external store (single-replica bex-api today — note the multi-replica caveat in the code where it bites).
- Exemptions: the HMAC git webhook (`/v1/webhooks/git`) and health/metrics endpoints get their own (higher or separate) budget so platform traffic can't be starved by tenant traffic.
- **Client IP is currently a lie on prod (2026-07-11):** the sole ingress is the Hetzner LB with proxy protocol OFF (`deploy/gitops/overlays/prod/values/traefik.values.yaml` — `uses-proxyprotocol: "false"`) and no `forwardedHeaders`/`proxyProtocol` trust configured anywhere in deploy/gitops. `r.RemoteAddr` is Traefik's pod IP and X-Forwarded-For carries the LB-SNAT address, so every anonymous caller — including pre-auth 401 traffic — lands in **one bucket**, and one abuser 429s them all. Implement t001's recorded decision: (a) flip the LB annotation to `uses-proxyprotocol: "true"`, set Traefik `ports.web/websecure.proxyProtocol.trustedIPs` to the LB's private hop on the 10.10.0.0/16 CAPH net, and have the limiter trust X-Forwarded-For only from Traefik — code prerequisite: edit `deploy/gitops/overlays/prod/values/traefik.values.yaml` (while there, fix its stale header comment: it still describes the pre-rebuild 10.0.0.0/16 net; the rebuilt CAPH-owned `bex` net is 10.10.0.0/16, `infra/clusterapi/overlays/hetzner-caph/cluster.yaml:619`); or (b) keep one shared anonymous bucket, size it generously, and document the divergence. Neither is the banned static-CIDR firewall (DO_NOT_DO.md line 19 concerns :22/:6443 source allowlists).
- New substrate makes trusted headers sound for the first time: node public ports are :22-only and the LB is the sole path to bex-api, so a Traefik-set forwarded header / proxy-protocol client IP is a trustworthy limiter key once enabled — the classic spoofed-XFF-via-direct-access bypass is structurally closed.
- GraphQL surfaces the 429 as a top-level error with an extension code; MCP surfaces it **at the layer t001 records** (transport-level HTTP 429 on the `/mcp` POST vs in-dispatch tool error) — implement that decision, don't assume both; an outer mux middleware alone can only produce the transport-level form.

## Steps

1. Implement the keyed limiter + middleware; wire it into the router ahead of the adapters (MCP limiting at whichever layer t001 recorded).
2. Apply per-class budgets (default API, webhook, unauthenticated) from t001's defaults.
3. Return the Render-shaped envelope + `Retry-After`; log limited requests with caller key (no secrets).

## Files

- `lego/backend/internal/api/` (new `ratelimit.go` + router wiring)

## Acceptance criteria

- [ ] Hammering with one key yields 429 + `Retry-After` while a second key stays 200
- [ ] REST, GraphQL, and MCP all surface the limit per the t001 mapping
- [ ] Webhook + health endpoints keep their own budget

## Out of scope

- Body-size/query caps (t003); env knobs (t004); distributed limiting across replicas (note-worthy only when bex-api scales out)
