middleware

package
v0.3.2 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 25, 2026 License: MIT Imports: 32 Imported by: 0

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

Constants

This section is empty.

Variables

This section is empty.

Functions

func AuditCoverage added in v0.2.0

func AuditCoverage(isExempt func(method, routePattern string) bool) func(http.Handler) http.Handler

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:

  1. 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.

  2. 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

func AuditUncoveredMutationsByRoute() map[string]float64

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

func CORS(allowedOrigins []string) func(http.Handler) http.Handler

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

func CSRFGuard(allowedOrigins []string) func(http.Handler) http.Handler

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

func CleanExpired(ctx context.Context, db *postgres.DB) (int, error)

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

func EncodeCursor(id string, createdAt time.Time) string

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

func Idempotency(db *postgres.DB) func(http.Handler) http.Handler

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 Metrics

func Metrics() func(http.Handler) http.Handler

Metrics returns middleware that records HTTP request metrics.

func MetricsAuth

func MetricsAuth(next http.Handler) http.Handler

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

func MetricsHandler() http.Handler

MetricsHandler returns the Prometheus metrics HTTP handler.

func ParseTrustedProxies

func ParseTrustedProxies(spec string) []*net.IPNet

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

func RecordParkedInvoices(mode, disposition string, n int)

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

func RecordReconcilerSweep(reconciler, mode string, advanced, errs int)

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

func RecordScheduledCleanup(table string, rows int)

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

func RegisterQueueDepthGauges(count func(query string) (float64, error))

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

func RequestID(next http.Handler) http.Handler

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

func SecurityHeaders() func(http.Handler) http.Handler

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

func Tracing() func(http.Handler) http.Handler

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

func TrustedRealIP(trusted []*net.IPNet) func(http.Handler) http.Handler

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

type Cursor struct {
	ID        string    `json:"id"`
	CreatedAt time.Time `json:"created_at"`
}

Cursor is an opaque pagination token that encodes the position in a result set.

func DecodeCursor

func DecodeCursor(token string) (Cursor, error)

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

func (*IdempotencyCleaner) Cleanup

func (c *IdempotencyCleaner) Cleanup(ctx context.Context) (int, error)

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

func NewRateLimiter(rdb *goredis.Client, name string, rate int, window time.Duration) *RateLimiter

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

func (rl *RateLimiter) AllowKey(ctx context.Context, key string) (int, time.Time, bool)

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.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL