# Changelog — einherjar/web All notable changes to this module are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This module adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). --- ## [1.4.0] — 2026-08-09 Minor — resolvable request IDs, plus a dependency refresh. ### Added - **`mw.RequestIDFrom(resolve func(*http.Request) string)`** — the resolver sees the request, so a service can continue a correlation ID a client already sent (a distributed trace survives this boundary). The framework provides plumbing only: it does not read a header, choose a header name, or validate the value — that policy is the application's, because a value the framework accepts on a service's behalf may be one that service cannot store. Generation becomes the fallback branch of resolution rather than a separate mode. ### Changed - `mw.RequestID(generator func() string)` is unchanged in signature and behaviour (always generates, ignores inbound); it is now expressed as `RequestIDFrom` with a request-ignoring resolver. - Refreshed dependencies (`go-chi/chi/v5` v5.3.1, `golang.org/x/time` v0.15.0). - Bumped `contracts`, `core` to v1.4.0. ### Notes - An empty resolver result attaches no ID (header omitted, context carries none) rather than a silently-empty value; a resolver that can return "" is a caller error. ## [1.3.0] — 2026-08-08 Minor release carrying a **breaking API change** to CORS configuration. The framework is private with controlled consumers, so this ships in the 1.x line with a loud compile break instead of a v2 module-path (`/v2`) migration. ### Removed - **⚠️ BREAKING: `web.Config.AllowedOrigins` removed.** CORS origins now have a single home: `server.Config.CORSOrigins` (env `EINHERJAR_SERVER_CORS_ORIGINS`). The field was env-backed through v1.1.x and a code-only override in v1.2.0 — reading it after the env tag moved silently served *no* CORS. Removing it turns that runtime trap into a compile error. **Migration:** replace `cfg.Web.AllowedOrigins` with `cfg.Server.CORSOrigins`, and `web.Config{AllowedOrigins: o}` with `web.Config{Server: server.Config{CORSOrigins: o}}` — or just let `web.New` read `EINHERJAR_SERVER_CORS_ORIGINS`. The MCP flags any leftover reference (`validate_snippet` rule `web.allowedorigins-removed`). ### Changed - Bumped `contracts`, `core` to v1.3.0. ## [1.2.0] — 2026-08-08 Minor — CORS configuration moved to its rightful struct; `web.New` made safe-by-default. ### Changed - **`CORSOrigins` now lives on `server.Config`** (env var `EINHERJAR_SERVER_CORS_ORIGINS`), the struct its name advertises — it previously loaded into `web.Config`. `web.Config.AllowedOrigins` remains as a code-only override (no env tag). Wiring via `web.New` or the env var is unaffected. - Bumped `contracts`, `core` to v1.2.0. ### Added - `web.New` logs a warning when no CORS origins are configured, instead of silently disabling CORS. - Package docs (`web`, `web/server`) document when to use `web.New` vs `server.New`, with compiling examples and the env-gated allow-all CORS convention. ## [1.1.3] — 2026-08-08 Patch — CORS documentation discoverability. ### Fixed - `mw.CORS` and `CORSAllowAll` doc comments now document the `"*"` rejection (panic) and the env-gated CORS convention (`local -> CORSAllowAll`, else `mw.CORS(origins)`), so `search_symbols` surfaces it — previously the convention lived only in code comments and the wire example. ### Changed - Bumped `contracts`, `core` to v1.1.3. ## [1.1.2] — 2026-08-08 Patch — CORS wildcard hardening plus documentation fixes. ### Changed - **`mw.CORS` now rejects `"*"` (panics at construction)** instead of silently no-op'ing it. `"*"` matched nothing (exact-match only), so a service passing it ran with CORS effectively off — a silent trap. Fail loud at boot; use `mw.CORSAllowAll()` (development) or list explicit origins. - Bumped `contracts`, `core` to v1.1.2. ### Fixed - README Go examples now compile: `mw.Recover(logger)`, `health.NewHandler(...).ServeHTTP`, and the `mw.CORS` example no longer passes `"*"`. Corrected the `CORSAllowAll` description. ## [1.1.1] — 2026-08-07 Patch — coordinated framework version alignment. ### Changed - Bumped `contracts` and `core` to v1.1.1 (framework version alignment). No code or API changes. ## [1.1.0] — 2026-08-07 Coordinated framework release. Documentation fixes plus the framework version bump (which finally makes the previously-drafted `contracts` v1.1.0 pin real). ### Fixed - **Package doc examples didn't compile.** Verified by compiling the example patterns against the real API: - `mw.Recover()` -> `mw.Recover(logger)` — the recover middleware takes a `logging.Logger` (`server`, `mw` package docs and `server.go`). - `health.NewHandler(logger, …)` -> `health.NewHandler(logger, …).ServeHTTP` — the handler returns `http.Handler`, but chi's `Get` takes `http.HandlerFunc`; same for `NewHandlerWithConfig` (`server`, `web`, `health` package docs). ### Changed - Bumped `contracts` and `core` to v1.1.0 (framework version alignment). --- ## [1.0.0] — 2026-05-28 ### Added #### `server` - `Server` interface — embeds `lifecycle.Component` (from `contracts/lifecycle`) and `chi.Router` (from `go-chi/chi/v5`); any type that satisfies both is directly compatible - `Config` struct — `Host`, `Port`, `ReadTimeout`, `WriteTimeout`, `IdleTimeout`, `ShutdownTimeout`; all fields carry `env:"EINHERJAR_SERVER_*"` and `envDefault` tags (`caarlos0/env` syntax) - `New(logger logging.Logger, cfg Config, opts ...Option) Server` — constructs the unexported `impl` struct; embeds `chi.NewRouter()` - `Option` type + `WithMiddleware(mw ...func(http.Handler) http.Handler) Option` — variadic option for middleware composition - `impl.OnInit()` — applies registered middleware via `chi.Use` - `impl.OnStart()` — binds TCP listener synchronously (`net.Listen`), starts `http.Server.Serve` in a goroutine; port binding failure returns immediately - `impl.OnStop(ctx)` — graceful `http.Server.Shutdown(ctx)` with `ShutdownTimeout` (fallback: `defaultShutdownTimeout = 10s`) - `var _ Server = (*impl)(nil)` — compile-time assertion #### `mw` - `StatusRecorder` struct — wraps `http.ResponseWriter`, captures written status code - `Recover() func(http.Handler) http.Handler` — catches panics, writes 500, logs stack trace via `runtime/debug.Stack()` - `RequestID(generator func() string) func(http.Handler) http.Handler` — injects a request ID via `logz.WithRequestID`; reads existing `X-Request-ID` header if present - `RequestLogger(logger logging.Logger) func(http.Handler) http.Handler` — structured request logging: method, path, status, latency; uses `StatusRecorder` to capture code - `CORS(origins []string) func(http.Handler) http.Handler` — sets `Access-Control-Allow-Origin` for listed origins; supports preflight (`OPTIONS`) - `CORSAllowAll() func(http.Handler) http.Handler` — allows any origin by reflecting the request `Origin` (no `Access-Control-Allow-Credentials`); development only - `RateLimiterStore` interface — `Allow(ctx context.Context, key string) (bool, error)`; pluggable backend; `error` return allows infrastructure failures to surface; fail-open contract: non-nil error allows the request - `InMemoryRateLimiterStore` struct — per-key token bucket via `golang.org/x/time/rate`; `sync.Map` for concurrent access; background goroutine evicts idle entries after 5 minutes via `time.Ticker`; `Allow` always returns `(bool, nil)` - `NewInMemoryRateLimiterStore(rps float64, burst int) *InMemoryRateLimiterStore` - `IPRateLimit(store RateLimiterStore, logger logging.Logger) func(http.Handler) http.Handler` — limits by client IP (`X-Forwarded-For` → `RemoteAddr` fallback); returns 429 JSON on exceeded limit; fails open on store error - `UserRateLimit(store RateLimiterStore, logger logging.Logger) func(http.Handler) http.Handler` — limits by authenticated user ID from `security.FromContext`; falls back to client IP when no identity present; same 429 + fail-open behaviour #### `httputil` - `HandlerFunc` type — `func(w http.ResponseWriter, r *http.Request) error`; implements `http.Handler` via `ServeHTTP` - `Handle[Req, Res any](v valid.Validator, fn func(ctx context.Context, req Req) (Res, error)) http.HandlerFunc` — decodes JSON body, validates struct, calls `fn`, encodes response; 400 on validation failure, mapped status on `*xerrors.Err` - `HandleNoBody[Res any](fn func(ctx context.Context) (Res, error)) http.HandlerFunc` — no body decoding/validation; encodes response directly - `HandleEmpty[Req any](v valid.Validator, fn func(ctx context.Context, req Req) error) http.HandlerFunc` — decodes and validates body, calls `fn`, returns 204 on success - `JSON(w http.ResponseWriter, status int, v any)` — writes JSON response - `NoContent(w http.ResponseWriter)` — writes 204 with no body - `Error(w http.ResponseWriter, err error)` — maps `*xerrors.Err` to HTTP status and writes `{"code":"","message":""}` JSON body; complete 16-code mapping #### `health` - `Config` struct — `CheckTimeout time.Duration` with `env:"EINHERJAR_HEALTH_CHECK_TIMEOUT" envDefault:"5s"` (`caarlos0/env` syntax) - `Response` struct — `Status string`, `Components map[string]ComponentStatus` - `ComponentStatus` struct — `Status`, `Latency` (omitempty), `Error` (omitempty) - `NewHandler(logger logging.Logger, checks ...observability.Checkable) http.Handler` — shorthand with default 5s timeout - `NewHandlerWithConfig(logger logging.Logger, cfg Config, checks ...observability.Checkable) http.Handler` — all checks run concurrently in goroutines with a shared context timeout; results collected via buffered channel; `DOWN` (critical priority) → 503; `DEGRADED` (degraded priority) → 200; `UP` → 200 - Accepts `observability.Checkable` from `contracts` directly — no local redefinition #### Root package (`web`) - `Config` struct — `Server server.Config`, `AllowedOrigins []string` with `env:"EINHERJAR_SERVER_CORS_ORIGINS" envSeparator:","` - `New(logger logging.Logger, cfg ...Config) server.Server` — pre-wires recommended middleware stack: Recover → RequestID (UUID v7 with v4 fallback) → RequestLogger → CORS (only when `AllowedOrigins` non-empty) - Unexported `newRequestID()` — uses `uuid.NewV7()` (time-ordered), falls back to `uuid.NewString()` (v4) on generation error ### Design Notes 1. **Progressive disclosure.** `web.New` is the happy path — one call, all middleware pre-wired, env vars respected. `server.New` is the escape hatch — every choice explicit. Both tiers share the same config structs, env variables, and lifecycle contract. 2. **`RateLimiterStore` interface.** The pluggable backend design lets developers start with `InMemoryRateLimiterStore` (zero extra dependencies) and swap to a distributed store (e.g., `cache-valkey`) at scale without touching middleware wiring. The store satisfies the interface via Go duck typing — `cache-valkey` never imports `web/mw`. 3. **Fail-open rate limiting.** When the store returns an error (e.g., Valkey unavailable), the request is allowed. Availability is preferred over hard enforcement during infrastructure degradation. 4. **`observability.Checkable` from contracts.** `health.NewHandler` accepts `observability.Checkable` directly from `contracts/observability`. Any starter (`db-*`, `cache-*`, `storage-*`) that implements the contracts interface plugs in without an adapter — no `web` import required by those starters. 5. **`last_seen` excluded.** Session tracking is an application-domain concern, not transport-level middleware. It requires knowing which entity to track and where to persist it. Developers who need it can write it in ~15 lines in their own wiring package. Will be revisited if `einherjar/worker` provides a fire-and-forget primitive. 6. **UUID v7 for request IDs.** Time-ordered UUIDs embed a millisecond-precision timestamp, enabling request IDs to sort chronologically in log aggregation systems. UUID v4 fallback ensures ID generation never fails. --- [1.0.0]: https://code.nochebuena.dev/einherjar/web/releases/tag/v1.0.0