Documentation
¶
Overview ¶
Package middleware provides the commodity HTTP middlewares and Connect interceptors that every forge-generated service ships with.
Why a library ¶
These used to be scaffolded into each project's pkg/middleware as static files. Field evidence across downstream repos showed the copies stayed byte-identical to the templates — zero project intent, pure photocopies of security-relevant code that drifted and never received fixes. The code now lives here, versioned with forge, and the project keeps ONE thin pkg/middleware file wiring the things projects actually customize: the auth validator, the identity enricher, the unauthenticated allow-list, and dev-claims behaviour (see forge/pkg/authn for that policy surface).
What's here ¶
HTTP middlewares (outermost layer, wrap the whole mux):
- CORSMiddleware — spec-correct CORS with wildcard/credentials guard rails.
- SecurityHeadersMiddleware — OWASP security response headers (CSP, nosniff, Referrer-Policy, Permissions-Policy, HSTS).
- RequestIDMiddleware — per-request correlation ID, trusted from the inbound X-Request-Id or minted fresh; shares pkg/observe's context key so Connect log records inherit the ID.
- HTTPStack — recovery + logging + audit for plain HTTP routes (webhooks, OAuth callbacks, REST) that bypass the Connect chain.
- HTTPAuth — Bearer-token auth for plain HTTP routes.
- IdempotencyMiddleware — HTTP-level Idempotency-Key replay.
Connect interceptors (project chain, after serverkit's canonical observe.DefaultMiddlewares):
- AuditInterceptor — one audit log record per RPC (who, what, when, result, duration), routed via log_type=audit.
- RateLimitInterceptor — per-caller token-bucket rate limiting keyed by claim subject (falling back to peer IP).
- IdempotencyInterceptor — RPC-level Idempotency-Key replay.
Plus Redact, a struct→map helper for logging payloads without leaking PII.
Claims access ¶
Several middlewares read the authenticated principal. The claims CONTEXT KEY is owned by the project (its pkg/middleware), so anything claims-aware takes the project's ClaimsFromContext as a callback. Passing nil degrades gracefully (anonymous audit entries, IP-keyed rate limits).
The authentication MECHANISM lives in forge/pkg/authn. Access-control decisions and row scoping stay handler logic the project writes (its own ClaimsFromContext helpers + WHERE clauses).
Index ¶
- Constants
- Variables
- func AuditInterceptor(logger *slog.Logger, claimsFrom ClaimsLookup) connect.Interceptor
- func AuditInterceptorWithSink(logger *slog.Logger, claimsFrom ClaimsLookup, sink AuditSink) connect.Interceptor
- func CORSMiddleware(allowOrigins []string, allowCredentials bool, opts ...CORSOption) func(http.Handler) http.Handler
- func ContextWithRequestID(ctx context.Context, id string) context.Context
- func DevCORSMiddleware(allowCredentials bool, opts ...CORSOption) func(http.Handler) http.Handler
- func HTTPAuth(authenticate func(token string) (*auth.Claims, error), ...) func(http.Handler) http.Handler
- func HTTPStack(logger *slog.Logger, claimsFrom ClaimsLookup) func(http.Handler) http.Handler
- func HeaderList(csv string) []string
- func IdempotencyInterceptor(opts IdempotencyOptions) connect.Interceptor
- func IdempotencyMiddleware(cfg IdempotencyConfig) func(http.Handler) http.Handler
- func RateLimitInterceptor(opts RateLimitOptions, claimsFrom ClaimsLookup) connect.Interceptor
- func Redact(v any, fields ...string) map[string]any
- func RequestIDFromContext(ctx context.Context) string
- func RequestIDMiddleware() func(http.Handler) http.Handler
- func SecurityHeadersMiddleware(cfg SecurityHeadersConfig) func(http.Handler) http.Handler
- func VerifyHMACSHA256(body []byte, signature, secret string) bool
- func WebhookEventID(headers http.Header) string
- type AuditEvent
- type AuditSink
- type CORSOption
- type ClaimsLookup
- type DedupeConfig
- type DedupeStore
- type IdempotencyConfig
- type IdempotencyOptions
- type RateLimitOptions
- type SecurityHeadersConfig
- type WebhookEvent
Constants ¶
const DefaultWebhookMaxBodySize int64 = 1 << 20 // 1MiB
DefaultWebhookMaxBodySize bounds how much of a webhook payload a handler reads. Webhook senders are unauthenticated until the signature is checked — and the signature is computed over the body — so the read has to be bounded BEFORE anything is trusted:
body, err := io.ReadAll(io.LimitReader(r.Body, middleware.DefaultWebhookMaxBodySize))
const IdempotencyKeyHeader = "Idempotency-Key"
IdempotencyKeyHeader is the canonical header carrying the client-supplied idempotency key. Methods annotated with idempotency_key=true in the proto options SHOULD require this header; if absent the request proceeds without deduplication.
const RequestIDHeader = observe.RequestIDHeader
RequestIDHeader is the canonical header carrying the per-request correlation ID on both the inbound request and the outbound response. It mirrors observe.RequestIDHeader so the HTTP layer (this middleware) and the Connect layer (observe's interceptors) stay in agreement.
Variables ¶
var DefaultCORSAllowHeaders = []string{
"Content-Type", "Connect-Protocol-Version", "Connect-Timeout-Ms",
"Authorization", "Traceparent", "Tracestate", "Baggage", "X-Request-Id", "Forge-Via",
}
DefaultCORSAllowHeaders are the request headers every forge server admits cross-origin: Connect's protocol headers, the bearer token, the trace context and request id the generated clients attach, and Forge-Via, which names the client to the activity stream (forge/pkg/activity.ViaHeader).
The trace context is all three W3C headers the browser SDK's propagator can write — traceparent, tracestate AND baggage (@reliantlabs/forge-web-runtime installs HyperDX, whose propagator is trace-context plus baggage). A header missing here does not lose one trace: the preflight refuses it, and the browser then refuses EVERY cross-origin RPC that carries it.
var DefaultCORSExposeHeaders = []string{"Connect-Protocol-Version", svcerr.ReasonHeader, accesstoken.ScopesHeader, observe.TraceResponseHeader}
DefaultCORSExposeHeaders are the response headers every forge server lets a cross-origin client read: Connect's protocol version, the forge error-reason header the generated frontend runtime keys typed errors on, the scopes a scope_missing refusal names (accesstoken.ScopesHeader), and traceresponse, which names the server span the runtime stamps on errors and reports.
Functions ¶
func AuditInterceptor ¶
func AuditInterceptor(logger *slog.Logger, claimsFrom ClaimsLookup) connect.Interceptor
AuditInterceptor creates a Connect interceptor that produces audit log entries for every RPC call. Audit logs capture: who made the call (user ID — the email goes to the durable sink only, never to slog), what procedure was called, when, the result (success/error code), the duration, and the OTel trace id when the request carries a span.
Audit records are emitted with log_type=audit on a child logger, so they can be routed to a dedicated audit sink (separate file, SIEM, compliance database) independent of operational logs. The record message is "audit.event" ([auditMessage]) — INFO on success, WARN on error.
claimsFrom is the project's ClaimsFromContext (the project owns the claims context key — see ClaimsLookup). When nil, or when no claims are present (unauthenticated request), the user is logged as "anonymous".
This is the slog-only constructor; it is fully backward compatible. For compliance-grade durable persistence (so projects can delete a hand-rolled DB-persisting interceptor), use AuditInterceptorWithSink to additionally fan each event out to an AuditSink.
func AuditInterceptorWithSink ¶
func AuditInterceptorWithSink(logger *slog.Logger, claimsFrom ClaimsLookup, sink AuditSink) connect.Interceptor
AuditInterceptorWithSink is AuditInterceptor plus a durable sink. Every RPC is logged to slog exactly as the slog-only variant does AND dispatched to sink.Record on a fresh bounded-timeout context off the request path, so the sink never blocks the RPC response. Passing a nil sink yields behavior identical to AuditInterceptor.
func CORSMiddleware ¶
func CORSMiddleware(allowOrigins []string, allowCredentials bool, opts ...CORSOption) func(http.Handler) http.Handler
CORSMiddleware returns an HTTP middleware that applies the CORS policy described by allowOrigins and allowCredentials.
Semantics (per the Fetch / CORS specs):
When the request has no Origin header, no CORS headers are written and the request is passed through. Emitting ACAO with an empty value (or "*") on a same-origin request is a well-known footgun: the browser never needed CORS on it in the first place, and some caches key on the ACAO value.
When allowOrigins contains "*" AND allowCredentials is true, this middleware panics. The combination is spec-invalid (browsers reject it) and must be caught at config validation time (the scaffolded config.Validate does). The panic is a belt-and- suspenders guard if validation is bypassed.
When allowOrigins contains "*" and credentials are disabled, the request Origin is echoed back rather than a literal "*". Echoing keeps the response compatible with future credentialed callers (no silent breakage) and still satisfies "*" semantically.
When specific origins are listed, an exact (case-insensitive) match on the Origin header is required. No match → no CORS headers (the browser will block the response).
Vary: Origin is always set so intermediate caches key on the Origin.
The allowed request headers and the exposed response headers default to what Connect, forge's frontend runtime and tracing need (DefaultCORSAllowHeaders, DefaultCORSExposeHeaders). A project whose client sends or reads more extends them with WithAllowHeaders / WithExposeHeaders — see CORSOption for why that has to be the middleware's job.
func ContextWithRequestID ¶
ContextWithRequestID returns a copy of ctx with the given request ID attached. Exposed so tests and non-HTTP entrypoints can propagate the value into code paths that read RequestIDFromContext.
func DevCORSMiddleware ¶
DevCORSMiddleware returns an HTTP middleware that reflects ANY request Origin back to the caller — an allow-all CORS policy intended for LOCAL DEVELOPMENT ONLY. It exists so a freshly-forged product's browser frontend works cross-origin out of the box, without the author first hand-curating a CORS_ORIGINS allow-list.
Why this instead of passing "*" to CORSMiddleware:
Credential-safe. It always echoes the CONCRETE request Origin into Access-Control-Allow-Origin (never the literal "*"), so pairing it with Access-Control-Allow-Credentials: true stays spec-valid. The "*" path in CORSMiddleware, by contrast, panics when credentials are enabled (the wildcard+credentials combination is spec-invalid and rejected in prod).
Unambiguously dev-scoped. The generated server selects it ONLY when ENVIRONMENT=development, so the permissive behavior can never leak into a production build the way a stray "*" in a prod allow-list could.
It MUST NOT be wired outside development. Reflecting every origin (especially with credentials) is exactly the footgun CORSMiddleware guards against for real deployments.
It takes the same header options as CORSMiddleware and applies them the same way. Reflecting whatever headers a dev request asks for would make dev pass a header that production then refuses — the exact gap that let a web client header take a deployed app down — so dev and production allow one list.
func HTTPAuth ¶
func HTTPAuth( authenticate func(token string) (*auth.Claims, error), withClaims func(ctx context.Context, claims *auth.Claims) context.Context, ) func(http.Handler) http.Handler
HTTPAuth returns HTTP middleware that validates Bearer tokens and populates the context with claims, mirroring the Connect auth interceptor for plain HTTP routes.
authenticate validates the raw token and returns the claims — pass the project's ValidateToken. withClaims stashes them on the context — pass the project's ContextWithClaims (the project owns the claims context key). If authenticate is nil (dev mode), all requests are allowed through untouched.
func HTTPStack ¶
HTTPStack returns HTTP middleware that applies recovery, logging, and audit to plain HTTP handlers. This provides the same cross-cutting concerns as the Connect interceptors for routes that cannot use the Connect protocol (e.g. webhooks, OAuth callbacks, REST endpoints).
claimsFrom is the project's ClaimsFromContext (see ClaimsLookup); nil produces anonymous audit entries.
Auth is NOT included because REST routes often have different auth requirements (e.g. webhook signature verification instead of JWT). Use HTTPAuth separately for routes that need Bearer authentication.
func HeaderList ¶
HeaderList splits a comma-separated header list (a config value) into names, dropping blanks. For WithAllowHeaders / WithExposeHeaders.
func IdempotencyInterceptor ¶
func IdempotencyInterceptor(opts IdempotencyOptions) connect.Interceptor
IdempotencyInterceptor returns a Connect interceptor that deduplicates requests carrying an Idempotency-Key header. If the same key is seen within the TTL window, the cached response (or error) is returned without calling the handler again.
Keys are scoped per-procedure so the same key on different RPCs is treated as distinct. For the HTTP-layer equivalent (REST/webhook routes) see IdempotencyMiddleware.
When opts.CacheSize <= 0 the interceptor is disabled and nil is returned.
func IdempotencyMiddleware ¶
func IdempotencyMiddleware(cfg IdempotencyConfig) func(http.Handler) http.Handler
IdempotencyMiddleware returns an http.Handler that deduplicates requests carrying the same key. If a request with that key has already been served (and is still cached), the cached response is replayed without invoking the downstream handler.
By default the key is the Idempotency-Key header and every completed response is cached; IdempotencyConfig.Key and IdempotencyConfig.ShouldCache override each half independently.
Usage:
mux := http.NewServeMux()
cfg := middleware.IdempotencyConfig{CacheSize: 500, TTL: 30 * time.Minute}
handler := middleware.IdempotencyMiddleware(cfg)(mux)
The cache is bounded (LRU over CacheSize entries) and PER-PROCESS: two replicas each keep their own, so a duplicate routed to the other replica is served fresh. Replay also happens BEFORE the wrapped handler runs, so a route that authenticates inside the handler (a webhook verifying its signature) answers unauthenticated duplicates from the cache. When either property matters, deduplicate inside the handler instead — see DedupeStore for the boolean-shaped seam that swaps for a table.
func RateLimitInterceptor ¶
func RateLimitInterceptor(opts RateLimitOptions, claimsFrom ClaimsLookup) connect.Interceptor
RateLimitInterceptor returns a Connect interceptor that enforces a per-key token-bucket rate limit. Keys are derived in this order:
- authenticated claim subject (claims.UserID) via claimsFrom, if available
- peer IP from the Connect request/stream peer address
When opts.Rps <= 0 the interceptor is disabled and this function returns nil. Memory is bounded by an LRU cache of up to rateLimitCacheSize limiters; idle keys are evicted in LRU order so the interceptor is safe to expose to anonymous traffic. claimsFrom may be nil (all callers are then keyed by peer IP).
func Redact ¶
Redact accepts a struct (or pointer to struct) and a set of field names to mask. It returns a map[string]any mirroring the struct's exported fields, with the named fields replaced by "[REDACTED]".
Non-struct inputs return nil. Unexported fields are silently skipped.
Example:
type User struct {
ID string
Email string
Name string
}
out := middleware.Redact(User{ID: "1", Email: "a@b.c", Name: "Alice"}, "Email")
// out == map[string]any{"ID": "1", "Email": "[REDACTED]", "Name": "Alice"}
func RequestIDFromContext ¶
RequestIDFromContext returns the request ID attached to ctx, or "" if none is set. Useful for handlers and downstream logging sites that want to include the ID on custom log lines. Nil ctx returns "" (never panics) so callers in CLI/worker contexts can share the same helper.
The storage is pkg/observe's context key: a request ID set by this HTTP middleware is visible to observe.LoggingInterceptor (and vice versa) without a header round-trip.
func RequestIDMiddleware ¶
RequestIDMiddleware is an HTTP middleware that ensures every request carries a correlation ID. It must be wired ahead of the logging middleware so log records inherit the generated ID.
Behavior:
If the inbound request has a non-empty RequestIDHeader, that value is trusted and reused. This lets edge proxies and upstream services stitch together a single trace across hops without coordination.
Otherwise a 16-byte crypto/rand hex token is minted — the same shape observe.RequestIDInterceptor mints at the Connect layer, so IDs look uniform whichever layer assigned them. crypto/rand keeps IDs unpredictable to third parties that might otherwise enumerate them.
The chosen ID is exposed to downstream middleware/handlers via the request context (RequestIDFromContext), stamped back onto the INBOUND request header, and echoed on the response header so the client can log it for later correlation.
This middleware OWNS the response echo for every request that passes through it. observe.RequestIDInterceptor, which runs inside it on the Connect path, adopts the context value and deliberately writes no response header of its own — otherwise connect merges its header into the one already written here and the response carries two conflicting X-Request-Id values.
The inbound stamp exists for the same reason: handlers and interceptors that reach for the request header rather than the context must see the SAME id, not an empty string that invites them to mint another.
func SecurityHeadersMiddleware ¶
func SecurityHeadersMiddleware(cfg SecurityHeadersConfig) func(http.Handler) http.Handler
SecurityHeadersMiddleware returns an HTTP middleware that sets OWASP-recommended security response headers:
- Content-Security-Policy — locks down what the browser may load
- X-Content-Type-Options: nosniff — prevents MIME sniffing
- Referrer-Policy — limits Referer leakage
- Permissions-Policy — opts out of powerful browser APIs
- Strict-Transport-Security — forces HTTPS (production only)
Headers relevant to health/pprof endpoints (health checks don't need CSP stringency, but we still set nosniff/referrer/permissions there for consistency and defense in depth). Callers wire this in at the outermost HTTP layer so every response benefits — including /healthz, /readyz, /metrics, and REST/webhook routes that bypass the Connect interceptor chain.
When cfg.Enabled is false, this becomes a no-op.
func VerifyHMACSHA256 ¶
VerifyHMACSHA256 reports whether signature is a valid HMAC-SHA256 of body under secret. signature is hex, with or without the "sha256=" prefix some providers (GitHub) prepend, in either letter case.
The comparison is constant-time, and a malformed signature is a plain false rather than an error, so call sites stay a single `if`:
if !middleware.VerifyHMACSHA256(body, r.Header.Get("X-Signature-256"), secret) {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
An empty secret always fails: an unconfigured secret must not silently turn signature verification into a no-op.
func WebhookEventID ¶
WebhookEventID returns the sender's unique delivery ID from the first recognised header, or "" when none is present.
Providers do not agree on a header, and none of them use the Idempotency-Key convention that REST clients follow, so this covers the common ones: the generic X-Event-ID / X-Webhook-ID / X-Request-ID spellings plus Stripe-Event-Id and X-GitHub-Delivery. A provider that stamps something else is one header read away — pull it directly and fall back to this:
id := r.Header.Get("X-Acme-Delivery")
if id == "" {
id = middleware.WebhookEventID(r.Header)
}
Types ¶
type AuditEvent ¶
type AuditEvent struct {
// Timestamp is the wall-clock time the RPC started.
Timestamp time.Time
// Procedure is the fully-qualified Connect procedure (e.g.
// "/pkg.Service/Method").
Procedure string
// PeerAddress is the remote peer address.
PeerAddress string
// UserID is the authenticated user id, or "anonymous" when no
// claims were present.
UserID string
// Email is the authenticated user email, empty when anonymous.
Email string
// Duration is the measured RPC duration.
Duration time.Duration
// Status is "ok" on success or "error" on failure.
Status string
// ErrorCode is the connect.Code string on failure, empty on success.
ErrorCode string
// ErrorMessage is the error string on failure, empty on success.
ErrorMessage string
// TraceID is the OTel trace id (hex), empty when the request carries
// no span context.
TraceID string
// RequestID is the per-request correlation id (the X-Request-Id the
// client was handed), empty when the request reached the interceptor
// without one. It is what makes the audit trail JOINABLE: without it
// a customer-quoted request id finds the operational "rpc completed"
// line but nothing in the audit record, so "what did this call
// actually do, and who made it" cannot be answered from the id alone.
// TraceID does not substitute — it is empty whenever no OTLP/tracing
// span exists, which is the default configuration.
RequestID string
}
AuditEvent is the full, structured record of a single RPC, handed to an AuditSink for durable persistence. It carries every field forge emits to slog plus the OTel trace correlation id, so a sink can write a complete compliance record without re-deriving anything from the request context.
The field set is intentionally a superset that lets a thin adapter satisfy a richer store. Status is "ok" or "error"; ErrorCode is the connect.Code string (e.g. "permission_denied") and is empty on success; TraceID is the hex OTel trace id, empty when the request carries no span.
type AuditSink ¶
type AuditSink interface {
Record(ctx context.Context, e AuditEvent)
}
AuditSink is a durable destination for audit events (a database, an append-only file, a SIEM forwarder). The interceptor calls [Record] for every RPC AFTER it has emitted the slog record.
Record is invoked on a fresh, bounded-timeout context off the request path — the interceptor never blocks the RPC response on the sink. A sink implementation may therefore treat Record as synchronous: do its write, honor ctx cancellation, and surface its own errors via its own logger. Record has no error return precisely so the interceptor stays fire-and-forget; the sink owns failure handling.
A thin adapter satisfies AuditSink over any richer store. For example, control-plane's audit store has the signature:
// pkg/audit.Store Log(ctx context.Context, entry Entry) error
which an adapter wraps directly:
type storeSink struct{ store pkgaudit.Store; log *slog.Logger }
func (s storeSink) Record(ctx context.Context, e middleware.AuditEvent) {
entry := pkgaudit.Entry{
Timestamp: e.Timestamp,
Procedure: e.Procedure,
PeerAddress: e.PeerAddress,
UserID: e.UserID,
Email: e.Email,
DurationMs: int(e.Duration.Milliseconds()),
Status: e.Status,
ErrorCode: e.ErrorCode,
ErrorMessage: e.ErrorMessage,
Metadata: map[string]string{"trace_id": e.TraceID},
}
if err := s.store.Log(ctx, entry); err != nil {
s.log.Error("audit db write failed", "error", err, "procedure", e.Procedure)
}
}
type CORSOption ¶
type CORSOption func(*corsHeaderLists)
CORSOption extends a CORS policy beyond the defaults.
WHY THE MIDDLEWARE HAS TO OFFER THIS. The preflight is answered HERE, before any of the project's own middleware runs, so a project could not add a header to it from the outside: a web client header that is not on the list makes the browser refuse every RPC, and the only remedy was to stop sending the header. A project declares its extra headers instead (the scaffolded cors_allow_headers / cors_expose_headers config), and the defaults stay.
func WithAllowHeaders ¶
func WithAllowHeaders(headers ...string) CORSOption
WithAllowHeaders admits more request headers in the preflight.
func WithExposeHeaders ¶
func WithExposeHeaders(headers ...string) CORSOption
WithExposeHeaders lets a cross-origin client read more response headers.
type ClaimsLookup ¶
ClaimsLookup resolves the authenticated principal from a request context. The claims context key is owned by the project's pkg/middleware, so claims-aware interceptors in this library take the project's ClaimsFromContext as a callback. nil is allowed and means "no claims available" (the interceptors degrade gracefully).
type DedupeConfig ¶
type DedupeConfig struct {
// MaxKeys bounds how many keys are remembered at once. The oldest
// (least recently touched) key is dropped when the bound is reached,
// so a flood of unique keys can never grow the process's memory.
// Default: 1000.
MaxKeys int
// TTL is how long a marked key stays marked. Size it to the sender's
// retry window: a provider that retries a failed delivery for 24h
// needs a 24h TTL to still recognise the redelivery as a duplicate.
// Default: 1 hour.
TTL time.Duration
}
DedupeConfig configures a DedupeStore. The zero value is a working default: 1000 keys, 1h TTL.
type DedupeStore ¶
type DedupeStore struct {
// contains filtered or unexported fields
}
DedupeStore answers "have I already handled this?" for a stream of opaque keys. It is the boolean-shaped counterpart to IdempotencyMiddleware: the middleware replays a cached RESPONSE from in front of a handler, a DedupeStore lets the handler itself decide, which is what you want when the check has to happen after some other gate (verifying a webhook signature) or when the answer must be marked only once processing actually succeeded.
if store.Seen(eventID) { return } // already handled — ack and stop
if err := process(evt); err != nil { return err }
store.Mark(eventID) // only after success, so a retry
// of a FAILED delivery re-runs
The store is bounded and PER-PROCESS. Two replicas each keep their own, so a duplicate delivery routed to the other replica is processed again. When at-most-once has to hold across replicas, keep the same call shape and back it with shared storage — a table with a unique constraint on the key, where Seen is a SELECT and Mark an INSERT.
Safe for concurrent use. Seen and Mark are separate operations, so two deliveries of the same key arriving at once can both observe "not seen" and both process; a unique constraint in shared storage is what closes that window for good.
func NewDedupeStore ¶
func NewDedupeStore(cfg DedupeConfig) *DedupeStore
NewDedupeStore returns a bounded, TTL'd key set. See DedupeConfig for the defaults applied to zero fields.
func (*DedupeStore) Len ¶
func (s *DedupeStore) Len() int
Len returns how many keys are currently tracked, including any whose TTL has lapsed but that eviction has not yet reclaimed. Exposed for tests and diagnostics that assert the bound holds.
func (*DedupeStore) Mark ¶
func (s *DedupeStore) Mark(key string)
Mark records key as handled for the configured TTL. Marking a key that is already present refreshes its expiry. An empty key is ignored.
func (*DedupeStore) Seen ¶
func (s *DedupeStore) Seen(key string) bool
Seen reports whether key was marked within the TTL window. An empty key is never seen — callers that cannot derive a key must fall through to processing rather than silently dropping the work.
type IdempotencyConfig ¶
type IdempotencyConfig struct {
// CacheSize is the maximum number of idempotency keys to track.
// Default: 1000.
CacheSize int
// TTL is how long a cached response is kept. After this duration the
// key is evicted and a duplicate request is treated as new.
// Default: 1 hour.
TTL time.Duration
// Key derives the deduplication key from the inbound request.
// Returning "" opts that request out of deduplication entirely: it is
// passed straight through and its response is never cached.
//
// nil reads the IdempotencyKeyHeader — the client-driven convention
// REST/RPC callers follow. Supply a Key when the sender does not
// control that header: webhook providers each stamp their own event ID
// (Stripe-Event-Id, X-GitHub-Delivery, …), and callback routes may key
// off a path value.
//
// cfg.Key = func(r *http.Request) string {
// return middleware.WebhookEventID(r.Header)
// }
Key func(*http.Request) string
// ShouldCache decides, from the status code the handler produced,
// whether that response is stored for replay. nil caches every
// completed response.
//
// Supply a predicate whenever the sender RETRIES on failure. Caching a
// 500 replays that 500 for the whole TTL, so every retry the sender
// makes is answered from the cache and the request is never actually
// reprocessed:
//
// cfg.ShouldCache = func(status int) bool { return status < 400 }
ShouldCache func(statusCode int) bool
}
IdempotencyConfig controls the behavior of the idempotency middleware.
The zero value is a working default: 1000 keys, 1h TTL, keyed off the Idempotency-Key header, caching every completed response.
type IdempotencyOptions ¶
IdempotencyOptions configures the RPC idempotency interceptor.
- CacheSize: maximum number of cached responses (default 1000)
- TTL: how long a cached response is valid (default 1h)
CacheSize <= 0 disables the interceptor — IdempotencyInterceptor returns nil and callers should skip appending it to the chain.
type RateLimitOptions ¶
RateLimitOptions tunes the per-key token-bucket rate limiter.
- Rps: steady-state requests-per-second per key
- Burst: maximum burst allowed before throttling (raised to Rps when lower)
Rps <= 0 disables rate limiting — RateLimitInterceptor returns nil in that case and callers should skip appending the interceptor to the chain.
type SecurityHeadersConfig ¶
type SecurityHeadersConfig struct {
// Enabled disables the middleware entirely when false. Allows operators
// to opt out without unwinding the handler chain.
Enabled bool
// Production toggles headers that only make sense when served over
// HTTPS in a real deployment (currently: Strict-Transport-Security).
// Typically mirrors cfg.Environment != "development".
Production bool
// ContentSecurityPolicy overrides the default CSP. The default is
// "default-src 'none'; frame-ancestors 'none'; base-uri 'none'" which
// is appropriate for an API server that does not serve HTML. If the
// server also serves HTML (e.g. an admin UI), supply a CSP that
// permits the required sources explicitly.
ContentSecurityPolicy string
// PermissionsPolicy overrides the default Permissions-Policy. The
// default denies access to camera, microphone, geolocation,
// interest-cohort (FLoC), payment, and USB.
PermissionsPolicy string
// ReferrerPolicy overrides the default Referrer-Policy
// ("strict-origin-when-cross-origin").
ReferrerPolicy string
// HSTSMaxAge overrides the default Strict-Transport-Security max-age
// (2 years, per MDN / OWASP guidance). Only emitted when Production is
// true. Set to a negative value to disable HSTS even in production.
HSTSMaxAge int
}
SecurityHeadersConfig controls which headers SecurityHeadersMiddleware sets and the exact values used. Zero-value fields fall back to sensible OWASP defaults suitable for a Connect/HTTP API server.
func DefaultSecurityHeadersConfig ¶
func DefaultSecurityHeadersConfig() SecurityHeadersConfig
DefaultSecurityHeadersConfig returns a SecurityHeadersConfig with OWASP defaults suitable for an API server. Callers should toggle Enabled and Production based on their runtime config.
type WebhookEvent ¶
type WebhookEvent struct {
// ID uniquely identifies this delivery and is the deduplication key
// (see WebhookEventID and DedupeStore).
ID string
// Source names the webhook the delivery arrived on.
Source string
// Headers are the inbound request headers.
Headers http.Header
// Body is the raw, size-limited request body the signature covered.
Body []byte
// ReceivedAt is when the delivery was accepted.
ReceivedAt time.Time
}
WebhookEvent is one received webhook delivery, assembled by a handler after the signature checks out and handed to the processing function. It carries the raw body rather than a parsed payload because the shape is provider-specific — unmarshal Body into your own type.