Documentation
¶
Overview ¶
Package middleware provides HTTP middleware for the Velox API server. Includes rate limiting, idempotency keys, Prometheus metrics, CORS, cursor-based pagination helpers, and structured request validation.
Index ¶
- func AuditCoverage(isExempt func(method, routePattern string) bool) func(http.Handler) http.Handler
- func AuditUncoveredMutationsByRoute() map[string]float64
- func CORS(allowedOrigins []string) func(http.Handler) http.Handler
- func CSRFGuard(allowedOrigins []string) func(http.Handler) http.Handler
- func CleanExpired(ctx context.Context, db *postgres.DB) (int, error)
- func EncodeCursor(id string, createdAt time.Time) string
- func ErrorJSON(w http.ResponseWriter, status int, errType, message string)
- func Idempotency(db *postgres.DB) func(http.Handler) http.Handler
- func Metrics() func(http.Handler) http.Handler
- func MetricsAuth(next http.Handler) http.Handler
- func MetricsHandler() http.Handler
- func ParseTrustedProxies(spec string) []*net.IPNet
- func RecordAutoChargeRetry(result string)
- func RecordBillingCycle(generated int)
- func RecordBillingCycleDuration(seconds float64)
- func RecordBillingCycleError()
- func RecordCreditOperation(opType string)
- func RecordDunningRun(outcome string)
- func RecordParkedInvoices(mode, disposition string, n int)
- func RecordParkedSearchError(mode, class string)
- func RecordPaymentCharge(result string)
- func RecordReconcilerSweep(reconciler, mode string, advanced, errs int)
- func RecordScheduledCleanup(table string, rows int)
- func RecordSchedulerTick()
- func RecordStripeBreakerState(state string)
- func RecordUsageIngested(count int)
- func RecordWebhookDelivery(status string)
- func RegisterQueueDepthGauges(count func(query string) (float64, error))
- func RequestID(next http.Handler) http.Handler
- func RequireOneOf(v *ValidationErrors, field, value string, allowed []string)
- func RequirePositive(v *ValidationErrors, field string, value int64)
- func RequireString(v *ValidationErrors, field, value string) string
- func SecurityHeaders() func(http.Handler) http.Handler
- func SplitRateLimit(match func(*http.Request) bool, special, base *RateLimiter) func(http.Handler) http.Handler
- func Tracing() func(http.Handler) http.Handler
- func TrustedRealIP(trusted []*net.IPNet) func(http.Handler) http.Handler
- type Cursor
- type IdempotencyCleaner
- type PageParams
- type PageResponse
- type RateLimiter
- type ValidationError
- type ValidationErrors
Constants ¶
This section is empty.
Variables ¶
This section is empty.
Functions ¶
func AuditCoverage ¶ added in v0.2.0
AuditCoverage returns a middleware that DETECTS uncovered mutations. It is a PURE OBSERVER.
It replaces the AuditLog catch-all, which "guaranteed" coverage by writing a row for any mutating /v1 request no handler had claimed — inferring the action and resource from the URL and sniffing the response body for a label. That invented false permanent records in an append-only compliance log (a pricing rule deleted from a meter was recorded as "deleted meter X"), and it covered only the /v1 block, so /v1/auth, /v1/tenants, /v1/public/*, /v1/webhooks and /v1/bootstrap got nothing at all (ADR-090, RC1 + RC2). Coverage is now DECLARED (the route-audit registry) and EMITTED in the business transaction (audit.Logger.LogInTx). This middleware only reports when reality diverges.
Two invariants, both load-bearing:
It NEVER MUTATES THE RESPONSE. No status swap, no body rewrite, no error injection. The catch-all's response-swapping ancestor (the per-tenant fail-closed 503) is what caused the idempotency-poisoning bug in ADR-089: the business tx had ALREADY committed, so the 503 was a lie — and the Idempotency layer cached that lie for 24h, stranding the real response and inviting a fresh-key double-mutation. Telemetry does not get to overwrite a committed money mutation's answer. Ever.
It NEVER BUFFERS THE BODY. chi's WrapResponseWriter captures the status with a pass-through Write (only Tee() buffers — we never call it) and preserves http.Flusher / http.Hijacker / io.ReaderFrom through typed wrappers. The catch-all buffered every response to sniff a label out of it, and its buffer implemented none of those interfaces — which is why the CSV exports, on a route given a 5-minute timeout precisely BECAUSE it streams, silently accumulated in memory instead of streaming.
isExempt resolves (method, canonical route pattern) against the registry; it is injected because the registry lives in package api (which imports this package).
func AuditUncoveredMutationsByRoute ¶ added in v0.2.0
AuditUncoveredMutationsByRoute snapshots the uncovered-mutation counter, keyed by route pattern.
It exists so a sibling package's end-to-end suite can assert ZERO uncovered mutations across every route it drives (internal/api's TestMain) — the runtime half of ADR-090's coverage model, complementing the registry's build-time two-way diff. Operators read this signal from Prometheus; this accessor is the in-process equivalent, and it is deliberately narrow: it collects THIS counter only. Gathering the default registry instead would execute every registered collector, including the queue-depth GaugeFuncs, which open a database transaction per scrape.
func CORS ¶
CORS returns middleware that handles Cross-Origin Resource Sharing. Allows browser-based frontends to call the Velox API.
func CSRFGuard ¶ added in v0.2.0
CSRFGuard is the server-side CSRF defense for the cookie-authenticated dashboard surface. It rejects a state-changing request that a *browser* initiated from a site other than the trusted dashboard, before the request can spend the operator's ambient session cookie.
Why this exists (and why CORS did not already cover it) ¶
CSRF is an ambient-authority problem: the browser attaches velox_session to every request to the API automatically, so any page the operator visits can cause a request that carries it. SameSite=Lax narrows this (it withholds the cookie from most cross-site requests) but is not sufficient on its own — it still permits top-level GET navigation, and it does nothing about a cookie being *set* cross-site, which is how the login-fixation variant worked (an attacker's cross-site POST to /v1/auth/login minted and set THEIR session in the victim's browser). The existing CORS middleware is not a defense here at all: it governs whether the attacker's JS may *read the response*, never whether the request *executes* — a form POST or any "simple request" runs the handler regardless of CORS, and a login-fixation attacker never needs to read anything, the Set-Cookie side effect already happened.
The rule ¶
For unsafe methods (POST/PUT/PATCH/DELETE), unless the request carries an Authorization: Bearer header, the request must be same-site:
- A Bearer request is EXEMPT **only when it carries no session cookie**. A bearer key is a non-ambient credential — an attacker cannot make a victim's browser attach it — so the SDK/server-to-server path (bearer, no cookie) is CSRF-immune and legitimately sends no Origin. But a request bearing BOTH a bearer header AND a session cookie is not a legitimate shape (the SDK has no cookie; the dashboard sends no bearer): it is the "add a junk Authorization header to trip the exemption, let the ambient cookie authenticate" move, which a permissive (CORS `*`) config would otherwise let through a preflight. Such a request is origin-checked, not exempted, so the dodge fails regardless of CORS config.
- Otherwise the browser's Sec-Fetch-Site (which page JS cannot forge) is the primary signal: same-origin and none pass; cross-site is rejected; **same-site passes only if its Origin is an allowlisted dashboard origin** — so a sibling subdomain (subdomain takeover, a user-content host) cannot ride the same-site label plus the Lax cookie into a mutation. Sec-Fetch- Site is absent only on pre-2020 browsers; there we fall back to the same Origin allowlist (the CORS_ALLOWED_ORIGINS list, so the two can't drift).
- A request carrying NEITHER Sec-Fetch-Site NOR Origin is allowed. This is deliberately not fail-closed: a genuine cross-site attack requires a browser, and every browser that can run the dashboard sets at least one of those headers on a cross-site unsafe request — page JS cannot remove them. So "neither present" means the caller is not a browser (curl, an SDK, a server job); it holds no ambient cookie and cannot be the CSRF vector. Failing closed here would only break non-browser callers for no gain.
This guard belongs ONLY on the dashboard-serving route groups (/v1/auth and the cookie-or-bearer /v1 group). It must not wrap the Stripe webhook group (HMAC-authenticated, server-to-server, no Origin) or the unauthenticated public payment/invoice surfaces (they carry no operator session to abuse).
func CleanExpired ¶
CleanExpired removes expired idempotency keys across all tenants and returns the number of rows deleted. Runs cross-tenant by design (a background scheduler, not a per-request path), so it uses TxBypass to sidestep the tenant_isolation policy.
func EncodeCursor ¶
EncodeCursor converts a cursor to a base64 string for use in API responses.
func ErrorJSON ¶
func ErrorJSON(w http.ResponseWriter, status int, errType, message string)
ErrorJSON is a helper that returns a Stripe-style error response for idempotency key misuse.
func Idempotency ¶
Idempotency returns middleware that caches responses for POST/PUT/PATCH requests that include an Idempotency-Key header. If a request with the same key has been processed before, the cached response is returned.
Stripe-compatible enforcement:
- Same key + same (method, path, body) → replay cached response (sets Idempotent-Replayed: true header).
- Same key + different (method, path, body) → 422 idempotency_error (protects against client bugs that recycle a key across operations — e.g., retrying POST /invoices with a changed amount under the old key).
func MetricsAuth ¶
MetricsAuth protects the /metrics endpoint with a bearer token. If METRICS_TOKEN is not set, /metrics is open (backward compatible for dev). In production, set METRICS_TOKEN and configure Prometheus to send it.
func MetricsHandler ¶
MetricsHandler returns the Prometheus metrics HTTP handler.
func ParseTrustedProxies ¶
ParseTrustedProxies parses a comma-separated TRUST_PROXY spec of CIDRs and/or bare IPs into networks. A bare IP becomes a /32 (IPv4) or /128 (IPv6). Blank/invalid entries are skipped. Empty input yields an empty slice, which means "trust no proxy" — forwarding headers are then never honored.
func RecordAutoChargeRetry ¶
func RecordAutoChargeRetry(result string)
RecordAutoChargeRetry records an auto-charge retry result ("succeeded" or "failed").
func RecordBillingCycle ¶
func RecordBillingCycle(generated int)
RecordBillingCycle records billing cycle metrics.
func RecordBillingCycleDuration ¶
func RecordBillingCycleDuration(seconds float64)
RecordBillingCycleDuration records how long a billing cycle took.
func RecordBillingCycleError ¶
func RecordBillingCycleError()
RecordBillingCycleError increments the billing cycle error counter.
func RecordCreditOperation ¶
func RecordCreditOperation(opType string)
RecordCreditOperation records a credit operation by type ("grant", "usage", "expiry", "adjustment").
func RecordDunningRun ¶
func RecordDunningRun(outcome string)
RecordDunningRun records a processed dunning run outcome ("succeeded" or "failed"). The outcome split backs the runbook's dunning-machinery alert on {outcome="failed"} — without it, failed runs had zero metric visibility.
func RecordParkedInvoices ¶ added in v0.2.0
RecordParkedInvoices publishes the count of invoices parked by ADR-107 for the given mode. Called from the payment reconciler each tick — the sweep that deliberately no longer PROCESSES them is still the right place to REPORT them, so excluding them from the queue did not also make them invisible.
func RecordParkedSearchError ¶ added in v0.2.0
func RecordParkedSearchError(mode, class string)
RecordParkedSearchError counts ADR-108 search-and-adopt failures by class. "not_offered" is the provider refusing the Search API for an account (those tenants' parked invoices cannot self-resolve — the one CRITICAL log names it); "transient" is rate limits / 5xx / network, which retry after the cool-off. Distinct from the parked gauge so "search never works here" pages differently from "nothing is parked".
func RecordPaymentCharge ¶
func RecordPaymentCharge(result string)
RecordPaymentCharge records a payment charge result ("succeeded" or "failed").
func RecordReconcilerSweep ¶
RecordReconcilerSweep records one recovery-reconciler sweep: a 'run' tick (always, so operators can alert on a reconciler that stops running), plus the items advanced and per-row errors this tick. `mode` is "live"/"test". Powers per-reconciler dashboards/alerts (e.g. a stuck tax-reversal backlog) — previously only auto-charge was metered.
func RecordScheduledCleanup ¶
RecordScheduledCleanup records rows purged by a scheduled cleanup task. Label values match the target table (e.g. "idempotency_keys", "payment_tokens") so operators can alert per-table on sudden surges.
func RecordSchedulerTick ¶
func RecordSchedulerTick()
RecordSchedulerTick stamps the scheduler-liveness gauge; called by the same hook that feeds /health/ready.
func RecordStripeBreakerState ¶
func RecordStripeBreakerState(state string)
RecordStripeBreakerState updates the global breaker state gauge. Called from the breaker's OnStateChange hook. Values mirror gobreaker's semantics: 0=closed (normal), 1=half_open (probing), 2=open (rejecting).
func RecordUsageIngested ¶
func RecordUsageIngested(count int)
RecordUsageIngested records usage event ingestion.
func RecordWebhookDelivery ¶
func RecordWebhookDelivery(status string)
RecordWebhookDelivery records a webhook delivery status ("succeeded", "failed", or "pending").
func RegisterQueueDepthGauges ¶
RegisterQueueDepthGauges exports the queue-depth gauges the shipped Grafana dashboard and runbook alert on. Pre-2026-07-06 the dashboard queried velox_email_outbox_pending / velox_webhook_outbox_pending / velox_dunning_active_runs masked by `OR vector(0)` — gauges the binary never exported, so the panels flatlined at 0 through a real backlog.
Gauges resolve at SCRAPE time via one COUNT each (cheap: partial/status indexes; 3s cap). A query failure reports -1 — visibly wrong rather than a healthy-looking 0.
func RequestID ¶ added in v0.2.0
RequestID replaces chi's middleware.RequestID. It ALWAYS mints the id server-side and NEVER honours an inbound header.
chi's version does this (chi/v5 middleware/request_id.go):
requestID := r.Header.Get("X-Request-Id") // <- client-controlled
if requestID == "" { requestID = <generated> }
which means any caller could choose the request_id that lands on their own audit_log rows — the column the audit UI presents as forensic correlation evidence, and the value support uses to join a customer's report back to server logs. An attacker could set it to a constant to make their actions unjoinable, collide it with an innocent tenant's traffic, or forge a value that "proves" an action came from somewhere it didn't. Correlation evidence an adversary can write is not evidence. CloudTrail's eventID, Stripe's request id, and GCP's insertId are all server-minted for exactly this reason.
Why we drop the inbound value entirely instead of keeping it for log correlation under a second key:
- Nothing consumes it as INPUT. (The name still appears in the repo — in tests that forge the header to prove it is ignored, and in the docs that record this decision. An earlier version of this comment told you to grep and promised zero hits, which stopped being true the moment the regression test landed. Do not re-add a claim about grep output; state the property.) Historically the reason was: grep across the repo, web-v2 and docs: there are no hits. Velox's published contract is the Velox-Request-Id RESPONSE header (respond.go), which the dashboard captures (web-v2 lib/api.ts) and the docs point support at. That contract is unchanged.
- Cross-service trace continuity belongs to W3C Trace Context, which mw.Tracing() (otelhttp) propagates from inbound headers. Be precise about what that buys today: tracing is a NO-OP unless OTEL_EXPORTER_OTLP_ENDPOINT is set (internal/platform/telemetry), so on a default deployment there is no cross-service correlation channel at all. That is a real, if small, ACCEPTED LOSS: a caller that wants to correlate its own request with a Velox audit row must now read the Velox-Request-Id response header rather than dictate the id up front. We take that trade because an inbound X-Request-Id would be a second, weaker, UNAUTHENTICATED channel — and it is the one that lands in the compliance log. Closure trigger: a caller with a concrete cross-service correlation need → wire OTLP, not a client-chosen id.
- Recording the client's string anywhere on the row — even under an honestly-named metadata key — puts unverified client input into a permanent append-only compliance record. The audit redesign's rule is that nothing unverified enters that log; a key nobody reads is not worth the exception.
The id is stored under chi's own RequestIDKey, so chimw.GetReqID(ctx) — the accessor audit.Logger, telemetry.ContextHandler, respond.go and payment/stripe all already call — keeps working unchanged, and there is exactly one place where a request id is born.
func RequireOneOf ¶
func RequireOneOf(v *ValidationErrors, field, value string, allowed []string)
func RequirePositive ¶
func RequirePositive(v *ValidationErrors, field string, value int64)
func RequireString ¶
func RequireString(v *ValidationErrors, field, value string) string
func SecurityHeaders ¶
SecurityHeaders adds standard security headers to all responses. These are defense-in-depth measures that enterprise security audits expect.
func SplitRateLimit ¶
func SplitRateLimit(match func(*http.Request) bool, special, base *RateLimiter) func(http.Handler) http.Handler
SplitRateLimit routes each request to one of two limiters: `special` when match(r) is true, `base` otherwise. Backs the ingest-vs-CRUD split — the ingest surface (usage events + LiteLLM callbacks, one POST per LLM call) needs a bucket orders of magnitude larger than the operator-CRUD default, and squeezing it through the general bucket silently dropped revenue data (LiteLLM retries only on 5xx, so every 429 was a permanently lost event).
func Tracing ¶
Tracing returns middleware that creates OpenTelemetry spans for each HTTP request. Propagates trace context from incoming headers (W3C Trace Context). When tracing is disabled (noop provider), this adds negligible overhead.
func TrustedRealIP ¶
TrustedRealIP rewrites r.RemoteAddr to the real client IP from X-Forwarded-For / X-Real-IP, but ONLY when the immediate TCP peer (r.RemoteAddr at entry) is one of the configured trusted proxies. When the peer is untrusted — or no proxies are configured — the raw peer address is kept, so a client behind no proxy cannot forge a forwarding header to rotate its per-IP rate-limit bucket (enumeration bypass) or pin a victim's IP (DoS).
Replaces chi's middleware.RealIP, which trusted those headers unconditionally.
Types ¶
type Cursor ¶
Cursor is an opaque pagination token that encodes the position in a result set.
func DecodeCursor ¶
DecodeCursor parses a cursor token from a request.
type IdempotencyCleaner ¶
type IdempotencyCleaner struct {
// contains filtered or unexported fields
}
IdempotencyCleaner adapts CleanExpired to the scheduler's cleaner interface (Cleanup(ctx) (int, error)), matching the shape used by payment.TokenService. Keeps scheduler wiring identical across cleanup tasks, and avoids the scheduler package importing anything from middleware.
func NewIdempotencyCleaner ¶
func NewIdempotencyCleaner(db *postgres.DB) *IdempotencyCleaner
type PageParams ¶
type PageParams struct {
Limit int
Cursor string // Opaque cursor token (after=...)
Offset int // Fallback offset-based pagination
}
PageParams extracts standard pagination parameters from a request.
func ParsePageParams ¶
func ParsePageParams(r *http.Request) PageParams
ParsePageParams extracts pagination parameters from query string. Supports both cursor-based (?after=token) and offset-based (?offset=N).
type PageResponse ¶
type PageResponse struct {
Data any `json:"data"`
HasMore bool `json:"has_more"`
NextCursor string `json:"next_cursor,omitempty"`
TotalCount int `json:"total_count,omitempty"`
}
PageResponse wraps a list response with pagination metadata. Matches Stripe's list response format.
func NewPageResponse ¶
func NewPageResponse(data any, count, limit int, lastID string, lastCreatedAt time.Time) PageResponse
NewPageResponse creates a paginated response. If len(items) > limit, has_more is true and the last item provides the cursor.
type RateLimiter ¶
type RateLimiter struct {
// contains filtered or unexported fields
}
RateLimiter implements distributed rate limiting using the GCRA (Generic Cell Rate Algorithm) backed by Redis. GCRA is a leaky-bucket variant that smooths traffic instead of allowing burst-then-block at window boundaries.
Powered by go-redis/redis_rate — the official rate limiting companion for go-redis, used across the go-redis ecosystem.
func NewRateLimiter ¶
NewRateLimiter creates a Redis-backed GCRA rate limiter. name namespaces the limiter's Redis keys so distinct limiters with different parameters don't collide on a shared bucket (e.g. "general", "hosted_invoice", "setup_link"). Example: NewRateLimiter(rdb, "general", 100, time.Minute) = 100 requests per minute with smooth distribution (no boundary bursts). If rdb is nil, the limiter fails open (all requests allowed).
func (*RateLimiter) AllowKey ¶
AllowKey is the exported variant of allow() for callers that need to enforce a custom-keyed bucket from within a handler (vs. as middleware). Used by paymentmethods.send-setup-email to enforce a per-customer cooldown — prevents double-click double-emails. Returns (remaining, resetAt, allowed); same fail-open / fail-closed semantics as the middleware path.
func (*RateLimiter) Middleware ¶
func (rl *RateLimiter) Middleware() func(http.Handler) http.Handler
Middleware returns chi-compatible rate limiting middleware. Keys by tenant ID (from auth context) or IP address for unauthenticated requests.
func (*RateLimiter) SetFailClosed ¶
func (rl *RateLimiter) SetFailClosed(v bool)
SetFailClosed controls what happens when Redis is unreachable or unconfigured. Default (false) — fail open: allow all requests. Appropriate for local/dev. true — fail closed: return 429 for every non-infra request. Use in production, where availability without rate limiting is a DDoS vector.
type ValidationError ¶
type ValidationError struct {
Field string `json:"field"`
Message string `json:"message"`
Code string `json:"code"`
}
ValidationError represents a Stripe-style field validation error.
type ValidationErrors ¶
type ValidationErrors struct {
Errors []ValidationError `json:"errors"`
}
ValidationErrors collects multiple field errors.
func (*ValidationErrors) Add ¶
func (v *ValidationErrors) Add(field, message, code string)
func (*ValidationErrors) HasErrors ¶
func (v *ValidationErrors) HasErrors() bool
func (*ValidationErrors) WriteResponse ¶
func (v *ValidationErrors) WriteResponse(w http.ResponseWriter)
WriteResponse writes validation errors as a Stripe-style error response.