{"_id":"@derian-cordoba/api-gateway","_rev":"5-864dc9d01f50055c83ef066aafd12494","name":"@derian-cordoba/api-gateway","dist-tags":{"latest":"2.0.0"},"versions":{"1.0.0":{"name":"@derian-cordoba/api-gateway","version":"1.0.0","keywords":["api-gateway","gateway","proxy","reverse-proxy","express","rate-limiting","typescript","nodejs"],"author":{"name":"derian-cordoba"},"license":"MIT","_id":"@derian-cordoba/api-gateway@1.0.0","maintainers":[{"name":"derian-cordoba","email":"derianricardo451@gmail.com"}],"bin":{"api-gateway":"dist/src/apps/api-gateway/index.js"},"dist":{"shasum":"99052d141347e1caee1c2d402741d5e3395416bc","tarball":"https://registry.npmjs.org/@derian-cordoba/api-gateway/-/api-gateway-1.0.0.tgz","fileCount":29,"integrity":"sha512-VLNFgEYSU+sgNlnDrKbGU1nY7RskVlvCOQcxrpuNG6EC/aMb6G4jOInwdSCts2Co2m06mFb/99IQ+1IP9TD1vg==","signatures":[{"sig":"MEYCIQCdmIOzA8Qk2lTfaCbdcSg+XFl3or0aDLbaofMYNUTcswIhAMSKP4PYzioXD67eoArKSpx8VE+0bYHfbAtFKb4uKqiH","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"unpackedSize":40738},"main":"./dist/src/apps/api-gateway/index.js","types":"./dist/src/apps/api-gateway/index.d.ts","engines":{"node":">=18.0.0","pnpm":">=10.0.0"},"gitHead":"8a7b9f61103f4f421525715074474c7703b19237","scripts":{"dev":"NODE_ENV=development ts-node-dev --ignore-watch node_modules --respawn --transpile-only src/apps/api-gateway/index.ts","test":"vitest run","build":"rm -rf ./dist && tsc -p tsconfig.prod.json","start":"node dist/src/apps/api-gateway/index.js","example":"bash examples/basic/run.sh","prepare":"pnpm build","postbuild":"chmod +x dist/src/apps/api-gateway/index.js","test:watch":"vitest"},"_npmUser":{"name":"derian-cordoba","email":"derianricardo451@gmail.com"},"overrides":{"rimraf":"^6.0.1"},"_npmVersion":"11.16.0","description":"A generic, configuration-driven HTTP API gateway with per-route rate limiting, structured logging, and security headers","directories":{},"_nodeVersion":"24.12.0","dependencies":{"zod":"^4.4.3","cors":"^2.8.5","pino":"^10.3.1","dotenv":"^16.5.0","helmet":"^8.1.0","express":"^5.1.0","pino-http":"^11.0.0","compression":"^1.8.0","http-status-codes":"^2.3.0","express-rate-limit":"^8.5.2","http-proxy-middleware":"^3.0.5"},"_hasShrinkwrap":false,"packageManager":"pnpm@10.6.5","devDependencies":{"vitest":"^4.1.9","supertest":"^7.2.2","typescript":"^5.8.3","@types/cors":"^2.8.18","@types/node":"^22.15.21","pino-pretty":"^13.1.3","ts-node-dev":"^2.0.0","@types/express":"^5.0.2","@types/supertest":"^7.2.0","@types/compression":"^1.8.0"},"_npmOperationalInternal":{"tmp":"tmp/api-gateway_1.0.0_1781837369417_0.10406907670097887","host":"s3://npm-registry-packages-npm-production"}},"1.1.0":{"name":"@derian-cordoba/api-gateway","version":"1.1.0","keywords":["api-gateway","gateway","proxy","reverse-proxy","express","rate-limiting","typescript","nodejs"],"author":{"name":"derian-cordoba"},"license":"MIT","_id":"@derian-cordoba/api-gateway@1.1.0","maintainers":[{"name":"derian-cordoba","email":"derianricardo451@gmail.com"}],"bin":{"api-gateway":"dist/src/apps/api-gateway/index.js"},"dist":{"shasum":"b3cefb77facd7d988cddd0b7d54eeab81d265a8c","tarball":"https://registry.npmjs.org/@derian-cordoba/api-gateway/-/api-gateway-1.1.0.tgz","fileCount":39,"integrity":"sha512-MCA2jQk57uPTtlbpN5rc+wm0OoAcrHAXLg8vYDWjn1PwzIac9Mp2WbuxbLDosPo/Z14Irr7u0nB2rvG4Pia6NQ==","signatures":[{"sig":"MEQCIH0u23zzZ4zX1fujTy1ite1nktLQY74LOJ28WC6Zb7kTAiB//7SKiLzd2JaSF3OYpVboqcLVqeMkRVJ/HaRK+SaoRw==","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"unpackedSize":53045},"main":"./dist/src/apps/api-gateway/index.js","types":"./dist/src/apps/api-gateway/index.d.ts","engines":{"node":">=18.0.0","pnpm":">=10.0.0"},"gitHead":"05dfeb904a6525d34d61b2d85a301ca960189723","scripts":{"dev":"NODE_ENV=development ts-node-dev --ignore-watch node_modules --respawn --transpile-only src/apps/api-gateway/index.ts","test":"vitest run","build":"rm -rf ./dist && tsc -p tsconfig.prod.json","start":"node dist/src/apps/api-gateway/index.js","example":"bash examples/run.sh","prepare":"pnpm build","postbuild":"chmod +x dist/src/apps/api-gateway/index.js","test:watch":"vitest"},"_npmUser":{"name":"derian-cordoba","email":"derianricardo451@gmail.com"},"overrides":{"rimraf":"^6.0.1"},"_npmVersion":"11.16.0","description":"A generic, configuration-driven HTTP API gateway with per-route rate limiting, structured logging, and security headers","directories":{},"_nodeVersion":"24.12.0","dependencies":{"zod":"^4.4.3","cors":"^2.8.5","pino":"^10.3.1","dotenv":"^16.5.0","helmet":"^8.1.0","express":"^5.1.0","pino-http":"^11.0.0","compression":"^1.8.0","jsonwebtoken":"^9.0.3","http-status-codes":"^2.3.0","express-rate-limit":"^8.5.2","http-proxy-middleware":"^3.0.5"},"_hasShrinkwrap":false,"packageManager":"pnpm@10.6.5","devDependencies":{"vitest":"^4.1.9","supertest":"^7.2.2","typescript":"^5.8.3","@types/cors":"^2.8.18","@types/node":"^22.15.21","pino-pretty":"^13.1.3","ts-node-dev":"^2.0.0","@types/express":"^5.0.2","@types/supertest":"^7.2.0","@types/compression":"^1.8.0","@types/jsonwebtoken":"^9.0.10"},"_npmOperationalInternal":{"tmp":"tmp/api-gateway_1.1.0_1782010973056_0.28377078329571725","host":"s3://npm-registry-packages-npm-production"}},"1.2.0":{"name":"@derian-cordoba/api-gateway","version":"1.2.0","keywords":["api-gateway","gateway","proxy","reverse-proxy","express","rate-limiting","typescript","nodejs"],"author":{"name":"derian-cordoba"},"license":"MIT","_id":"@derian-cordoba/api-gateway@1.2.0","maintainers":[{"name":"derian-cordoba","email":"derianricardo451@gmail.com"}],"bin":{"api-gateway":"dist/src/apps/api-gateway/index.js"},"dist":{"shasum":"2cc576cb96c30765fe538813ec8c8b739f39b56a","tarball":"https://registry.npmjs.org/@derian-cordoba/api-gateway/-/api-gateway-1.2.0.tgz","fileCount":63,"integrity":"sha512-Bdfrxjw/JDBR6g8CmVsDgWjKHP243I0CTCDLQYWbNyOnjguXuLkPDwSE64itrHERXjyey2e1Aw7Q9qptz5WBww==","signatures":[{"sig":"MEQCIGozm2rvi5RiLVD2zf2zsnKsOXlOIt1fvxrF02JLwyNNAiBOt24eaCtON0BJUaU1FMYtyZbuZ88Tw5JOF+kUffyWcw==","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"unpackedSize":111119},"main":"./dist/src/apps/api-gateway/index.js","types":"./dist/src/apps/api-gateway/index.d.ts","engines":{"node":">=18.0.0","pnpm":">=10.0.0"},"gitHead":"3334e8e56a5ddc3e209b021990469bb4be77c55e","scripts":{"dev":"NODE_ENV=development ts-node-dev --ignore-watch node_modules --respawn --transpile-only src/apps/api-gateway/index.ts","test":"vitest run","build":"rm -rf ./dist && tsc -p tsconfig.prod.json","start":"node dist/src/apps/api-gateway/index.js","example":"bash examples/run.sh","prepare":"pnpm build","postbuild":"chmod +x dist/src/apps/api-gateway/index.js","test:watch":"vitest"},"_npmUser":{"name":"derian-cordoba","email":"derianricardo451@gmail.com"},"overrides":{"rimraf":"^6.0.1"},"_npmVersion":"11.16.0","description":"A generic, configuration-driven HTTP API gateway with per-route rate limiting, structured logging, and security headers","directories":{},"_nodeVersion":"24.12.0","dependencies":{"zod":"^4.4.3","cors":"^2.8.5","pino":"^10.3.1","dotenv":"^16.5.0","helmet":"^8.1.0","express":"^5.1.0","pino-http":"^11.0.0","compression":"^1.8.0","jsonwebtoken":"^9.0.3","http-status-codes":"^2.3.0","express-rate-limit":"^8.5.2","http-proxy-middleware":"^3.0.5"},"_hasShrinkwrap":false,"packageManager":"pnpm@10.6.5","devDependencies":{"vitest":"^4.1.9","supertest":"^7.2.2","typescript":"^5.8.3","@types/cors":"^2.8.18","@types/node":"^22.15.21","pino-pretty":"^13.1.3","ts-node-dev":"^2.0.0","@types/express":"^5.0.2","@types/supertest":"^7.2.0","@types/compression":"^1.8.0","@types/jsonwebtoken":"^9.0.10"},"_npmOperationalInternal":{"tmp":"tmp/api-gateway_1.2.0_1784247776698_0.6297079368379006","host":"s3://npm-registry-packages-npm-production"}},"1.3.0":{"name":"@derian-cordoba/api-gateway","version":"1.3.0","keywords":["api-gateway","gateway","proxy","reverse-proxy","express","rate-limiting","typescript","nodejs"],"author":{"name":"derian-cordoba"},"license":"MIT","_id":"@derian-cordoba/api-gateway@1.3.0","maintainers":[{"name":"derian-cordoba","email":"derianricardo451@gmail.com"}],"bin":{"api-gateway":"dist/src/apps/api-gateway/index.js"},"dist":{"shasum":"ccb6fb37f0b7d24aa706c640926add8559d484d0","tarball":"https://registry.npmjs.org/@derian-cordoba/api-gateway/-/api-gateway-1.3.0.tgz","fileCount":139,"integrity":"sha512-RW09cb0nYanE3GuB+Rv9HXELOziOOWlue907HiWO0LTMPq7Yd/ofZwNk1fAeRSO21ej3xGPbxGPYMQgmFQnfkQ==","signatures":[{"sig":"MEUCIQDiyKo0qAEPuVgEq+Xp6m+u5PCG284DFC3HJF3pssuvOgIgfpY4gf2Qn1VDgZIM3sL0AL+vMJSGFy8VQM3BdN+oxmE=","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"unpackedSize":212663},"main":"./dist/src/apps/api-gateway/index.js","types":"./dist/src/apps/api-gateway/index.d.ts","engines":{"node":">=18.0.0","pnpm":">=10.0.0"},"gitHead":"fc6fab3d67df7101c389275a2d9f796e297a48a6","scripts":{"dev":"NODE_ENV=development ts-node-dev --ignore-watch node_modules --respawn --transpile-only src/apps/api-gateway/index.ts","test":"vitest run","build":"rm -rf ./dist && tsc -p tsconfig.prod.json","start":"node dist/src/apps/api-gateway/index.js","example":"bash examples/run.sh","prepare":"pnpm build","postbuild":"chmod +x dist/src/apps/api-gateway/index.js","test:watch":"vitest"},"_npmUser":{"name":"derian-cordoba","email":"derianricardo451@gmail.com"},"overrides":{"rimraf":"^6.0.1"},"_npmVersion":"11.16.0","description":"A generic, configuration-driven HTTP API gateway with per-route rate limiting, structured logging, and security headers","directories":{},"_nodeVersion":"24.12.0","dependencies":{"zod":"^4.4.3","cors":"^2.8.5","pino":"^10.3.1","dotenv":"^16.5.0","helmet":"^8.1.0","express":"^5.1.0","pino-http":"^11.0.0","compression":"^1.8.0","prom-client":"^15.1.3","jsonwebtoken":"^9.0.3","http-status-codes":"^2.3.0","express-rate-limit":"^8.5.2","http-proxy-middleware":"^3.0.5"},"_hasShrinkwrap":false,"packageManager":"pnpm@10.6.5","devDependencies":{"vitest":"^4.1.9","supertest":"^7.2.2","typescript":"^5.8.3","@types/cors":"^2.8.18","@types/node":"^22.15.21","pino-pretty":"^13.1.3","ts-node-dev":"^2.0.0","@types/express":"^5.0.2","@types/supertest":"^7.2.0","@types/compression":"^1.8.0","@types/jsonwebtoken":"^9.0.10"},"_npmOperationalInternal":{"tmp":"tmp/api-gateway_1.3.0_1784612689972_0.15320513541522285","host":"s3://npm-registry-packages-npm-production"}},"2.0.0":{"_id":"@derian-cordoba/api-gateway@2.0.0","bin":{"api-gateway":"dist/src/apps/api-gateway/index.js"},"dist":{"shasum":"67e8b856e8023548135a187407f582291be63f58","tarball":"https://registry.npmjs.org/@derian-cordoba/api-gateway/-/api-gateway-2.0.0.tgz","fileCount":273,"integrity":"sha512-W4vt+EMOCkubHaBMac7UNm2p40toezkJPbIEa5V+ZWMDQbrwdhJnlAHRtq6HbyywJQERF8aKO1C8dDLNh6RPsg==","signatures":[{"sig":"MEQCIH/O+lsLjZLVxT0OFtv7SHBHdj5nOOlv3Vp1M36xdJwlAiB1B8p+joKv/fEgtQqZk/gYcWKct+t/eMMViHDzRrGcqQ==","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"},{"keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U","sig":"MEYCIQD7ghBT/9WDgJ0LvhWmMZCs0ufdvHyPdFGErFMrDSJFNQIhAI13qBcXp1Z/DSVjEV6pobkAcOoPArY1jd0I7R0Ur66l"}],"unpackedSize":416961},"main":"./dist/src/apps/api-gateway/public-api.js","name":"@derian-cordoba/api-gateway","types":"./dist/src/apps/api-gateway/public-api.d.ts","author":{"name":"derian-cordoba"},"engines":{"node":">=20.9.0","pnpm":">=10.0.0"},"exports":{".":{"types":"./dist/src/apps/api-gateway/public-api.d.ts","import":"./dist/src/apps/api-gateway/public-api.js","require":"./dist/src/apps/api-gateway/public-api.js"}},"gitHead":"74d715404fbbfb3d4175a7584d1c47e954e7314c","license":"MIT","scripts":{"dev":"NODE_ENV=development ts-node-dev --ignore-watch node_modules --respawn --transpile-only src/apps/api-gateway/index.ts","test":"vitest run","build":"rm -rf ./dist && tsc -p tsconfig.prod.json","start":"node dist/src/apps/api-gateway/index.js","example":"bash examples/run.sh","prepare":"pnpm build","build:all":"pnpm build:gateway && pnpm build:dashboard","postbuild":"chmod +x dist/src/apps/api-gateway/index.js","test:watch":"vitest","dev:gateway":"pnpm dev","build:gateway":"rm -rf ./dist && tsc -p tsconfig.prod.json","dev:dashboard":"pnpm --dir src/apps/dashboard dev","lint:dashboard":"pnpm --dir src/apps/dashboard lint","test:dashboard":"pnpm --dir src/apps/dashboard test","build:dashboard":"pnpm --dir src/apps/dashboard build","lint:dashboard:fix":"pnpm --dir src/apps/dashboard lint:fix","test:dashboard-api":"pnpm --dir src/apps/dashboard test:api"},"version":"2.0.0","_npmUser":{"name":"derian-cordoba","email":"derianricardo451@gmail.com"},"keywords":["api-gateway","gateway","proxy","reverse-proxy","express","rate-limiting","typescript","nodejs"],"overrides":{"rimraf":"^6.0.1"},"_npmVersion":"11.16.0","description":"A generic, configuration-driven HTTP API gateway with per-route rate limiting, structured logging, and security headers","directories":{},"maintainers":[{"name":"derian-cordoba","email":"derianricardo451@gmail.com"}],"_nodeVersion":"24.12.0","dependencies":{"zod":"^4.4.3","cors":"^2.8.5","pino":"^10.3.1","dotenv":"^16.5.0","helmet":"^8.1.0","express":"^5.1.0","pino-http":"^11.0.0","compression":"^1.8.0","prom-client":"^15.1.3","jsonwebtoken":"^9.0.3","http-status-codes":"^2.3.0","express-rate-limit":"^8.5.2","http-proxy-middleware":"^3.0.5"},"_hasShrinkwrap":false,"packageManager":"pnpm@10.6.5","devDependencies":{"vitest":"^4.1.9","supertest":"^7.2.2","typescript":"^5.8.3","@types/cors":"^2.8.18","@types/node":"^22.15.21","pino-pretty":"^13.1.3","ts-node-dev":"^2.0.0","@types/express":"^5.0.2","@types/supertest":"^7.2.0","@types/compression":"^1.8.0","@types/jsonwebtoken":"^9.0.10"},"_npmOperationalInternal":{"host":"s3://npm-registry-packages-npm-production","tmp":"tmp/api-gateway_2.0.0_1790059107342_0.3172719441971321"}}},"time":{"created":"2026-06-19T02:49:29.316Z","modified":"2026-09-22T06:38:27.631Z","1.0.0":"2026-06-19T02:49:29.602Z","1.1.0":"2026-06-21T03:02:53.214Z","1.2.0":"2026-07-17T00:22:56.828Z","1.3.0":"2026-07-21T05:44:50.108Z","2.0.0":"2026-09-22T06:38:27.474Z"},"author":{"name":"derian-cordoba"},"license":"MIT","keywords":["api-gateway","gateway","proxy","reverse-proxy","express","rate-limiting","typescript","nodejs"],"description":"A generic, configuration-driven HTTP API gateway with per-route rate limiting, structured logging, and security headers","maintainers":[{"name":"derian-cordoba","email":"derianricardo451@gmail.com"}],"readme":"# API Gateway\n\nA generic, configuration-driven HTTP API gateway. Routes incoming requests to upstream services via a JSON config file or environment variable, with per-route load balancing, rate limiting, authentication, circuit breaking, IP filtering, request ID propagation, request timeouts, WebSocket proxying, retry with backoff, response caching, Prometheus metrics, header transformation, route-level CORS, OAuth 2.0 token introspection, structured logging, and full security headers out of the box.\n\n---\n\n## Table of Contents\n\n- [Features](#features)\n- [Requirements](#requirements)\n- [Getting Started](#getting-started)\n- [Dashboard](#dashboard)\n- [Configuration](#configuration)\n  - [Environment Variables](#environment-variables)\n  - [Route Configuration](#route-configuration)\n- [Authentication](#authentication)\n  - [JWT](#jwt)\n  - [API Key](#api-key)\n  - [Basic Auth](#basic-auth)\n  - [OAuth 2.0 Token Introspection](#oauth-20-token-introspection)\n  - [JWKS and Forwarded Claims](#jwks-and-forwarded-claims)\n  - [Authentication Failure Limiting](#authentication-failure-limiting)\n- [Request Validation](#request-validation)\n- [Webhook Verification](#webhook-verification)\n- [Upstream Request Signing](#upstream-request-signing)\n- [Traffic Mirroring](#traffic-mirroring)\n- [Request Timeout per Route](#request-timeout-per-route)\n- [Circuit Breaker](#circuit-breaker)\n- [Load Balancing](#load-balancing)\n- [WebSocket Proxying](#websocket-proxying)\n- [Request ID Propagation](#request-id-propagation)\n- [IP Allowlist / Blocklist](#ip-allowlist--blocklist)\n- [Retry with Backoff](#retry-with-backoff)\n- [Response Caching](#response-caching)\n- [Prometheus Metrics](#prometheus-metrics)\n- [Header Transformation](#header-transformation)\n- [Route-Level CORS Override](#route-level-cors-override)\n- [Hot Config Reload](#hot-config-reload)\n- [Running the Gateway](#running-the-gateway)\n- [Health Check](#health-check)\n- [Project Structure](#project-structure)\n- [Example Projects](#example-projects)\n- [Architecture](#architecture)\n- [Guides](#guides)\n  - [Custom Auth Strategy](#custom-auth-strategy)\n  - [Pluggable Cache Backend](#pluggable-cache-backend)\n  - [Distributed Circuit Breaker](#distributed-circuit-breaker)\n  - [Deployment](#deployment)\n\n---\n\n## Features\n\n- **Configuration-driven routing** — define proxy routes in a JSON file, an environment variable, or both; changes take effect on restart with zero code changes\n- **Per-route authentication** — protect any route with a JWT Bearer token (HMAC or RSA/EC), an API key, or HTTP Basic Auth; set `enabled: false` to bypass with zero overhead\n- **Per-route rate limiting** — each route can declare its own `max` requests / `windowMs` window, enforced by `express-rate-limit`\n- **Per-route circuit breaker** — automatically stops forwarding to a failing upstream after a configurable failure threshold, returning `503` until the service recovers; prevents cascading failures across your stack\n- **Request ID propagation** — every request receives a `X-Request-ID` header (generated UUID v4 if absent, forwarded unchanged if already set); the same ID appears in the response header, every gateway log line, and the request forwarded to the upstream — enabling end-to-end request tracing with no external infrastructure\n- **IP allowlist / blocklist** — per-route IPv4 and CIDR-range filtering; deny list is evaluated first, allow list restricts access to specified addresses only; IPv4-mapped IPv6 addresses are normalised automatically\n- **Load balancing** — distribute traffic across multiple upstream targets with four strategies: `round-robin` (default), `weighted` (proportional weight per target), `least-connections` (always forwards to the least-busy upstream), and `sticky` (session-affinity — routes a given client to the same upstream on every request); fully composable with auth, rate limiting, and the circuit breaker\n- **Per-route request timeout** — set `proxy.timeout` on any route to cap how long the gateway waits for an upstream response; slow upstreams receive a `504 Gateway Timeout` and the upstream connection is aborted\n- **WebSocket proxying** — enable `ws: true` on any route to proxy WebSocket upgrade requests transparently; all subsequent frames are tunnelled to the upstream without additional configuration\n- **Startup validation** — route config is validated with Zod at boot time; the process exits with a descriptive error rather than silently misbehaving\n- **Structured logging** — `pino` + `pino-http` emit newline-delimited JSON in production and human-readable output (via `pino-pretty`) in development\n- **Security headers** — full `helmet` defaults applied to every response (`CSP`, `HSTS`, `X-Frame-Options`, `X-Content-Type-Options`, etc.)\n- **Configurable CORS** — origins, methods, and allowed headers controlled via environment variables\n- **Health check endpoint** — `GET /health` returns uptime, version, and timestamp; always available regardless of configured routes\n- **Optional URL prefix** — mount all routes under a shared prefix (e.g. `/api/v1`) via `GATEWAY_PREFIX`\n- **Body forwarding** — JSON bodies on `POST`, `PUT`, and `PATCH` requests are correctly forwarded to upstreams (`fixRequestBody`)\n- **Retry with backoff** — configurable retries for HTTP failures and network errors; the route backend currently uses fixed delays, while exponential and jitter strategy classes are available for custom executor composition\n- **In-memory response caching** — cache upstream responses per route with a configurable TTL; cache hits bypass the upstream entirely and return the stored response with an `X-Cache: HIT` header; configurable by HTTP method and status code\n- **Prometheus metrics endpoint** — `GET /metrics` exposes `gateway_requests_total`, `gateway_request_duration_seconds`, `gateway_upstream_errors_total`, and `gateway_cache_hits_total` in Prometheus text format; labelled by route and method for easy dashboarding\n- **Per-route header transformation** — add, override, or remove individual headers on the outgoing upstream request and/or the response returned to the client; no code changes needed when onboarding a new upstream with different header conventions\n- **Route-level CORS override** — each route can declare its own CORS policy (origin, methods, allowed headers, credentials, preflight `maxAge`) that takes precedence over the global configuration; preflight `OPTIONS` requests are handled entirely by the gateway for routes that have a cors block\n- **OAuth 2.0 token introspection** — fourth auth strategy that validates opaque Bearer tokens by calling an RFC 7662 introspection endpoint; the gateway authenticates to the introspection endpoint using HTTP Basic auth with configurable `clientId` / `clientSecret`\n- **Hot config reload** — edit the routes JSON file (or send `SIGHUP`) and the gateway picks up the new routing table immediately, with no process restart and no dropped connections; built-in 300 ms debounce prevents churn on rapid saves\n- **Visual configuration dashboard** — manage every route feature through a Next.js UI backed by revision-safe, atomic updates to the local JSON route file\n- **Graceful shutdown** — `SIGINT` and `uncaughtException` handlers stop the server cleanly before exiting\n- **Request validation** — require JSON fields, restrict content types, and reject declared body lengths above a per-route limit before forwarding\n- **Webhook verification** — GitHub, Stripe, and custom HMAC signature verification using separate provider strategies and typed configuration\n- **Upstream request signing** — sign forwarded bodies with HMAC-SHA256 through the retry backend\n- **Traffic mirroring** — copy a configurable percentage of primary traffic to a shadow target through the retry backend\n- **Extended authentication** — JWKS public-key lookup, forwarding verified JWT claims to headers, OAuth introspection caching, and per-IP authentication failure limits\n- **Failure handling** — configurable circuit-open and retry-error responses, method-aware HTTP-status retries, and optional in-flight request collapsing\n- **Runnable examples** — 20 standalone projects with shared HTTP helpers, centralized startup and cleanup, local JavaScript services, and HTTP smoke checks\n\n---\n\n## Requirements\n\n- Node.js 20.9+\n- pnpm 10+\n\n---\n\n## Getting Started\n\n```bash\n# 1. Clone and install\ngit clone <repo-url>\ncd api-gateway\npnpm install\n\n# 2. Create your env file\ncp .env.example .env\n\n# 3. Create a routes config (see Route Configuration below)\ncp examples/basic/routes.json routes.json   # or write your own\n\n# 4. Start in development mode (hot-reload)\npnpm dev\n```\n\n---\n\n## Dashboard\n\nThe Next.js dashboard lives entirely in `src/apps/dashboard`. It provides visual editors for proxy targets, load balancing and mirroring, upstream signing, request validation, webhook verification, authentication, rate limiting, circuit breaking and fallbacks, retries, caching, IPv4/IPv6 filtering, header transforms, and route-level CORS.\n\nRun the gateway and dashboard in separate terminals:\n\n```bash\npnpm dev:gateway\npnpm dev:dashboard\n```\n\nThe dashboard is available at `http://localhost:3001` and uses the same `routes.json` file as the gateway. Copy the dashboard environment example when you need a different file path or access token:\n\n```bash\ncp src/apps/dashboard/.env.example src/apps/dashboard/.env.local\n```\n\nDashboard writes are validated by the gateway's Zod schemas, guarded by a configuration revision, written through a temporary file, and atomically renamed. The gateway watches the containing directory so these atomic updates activate without restarting either application.\n\nSet `DASHBOARD_TOKEN` outside local development. The browser token can then be entered on the dashboard Settings page; it is stored only in that browser.\n\nDashboard commands:\n\n```bash\npnpm dev:dashboard\npnpm build:dashboard\npnpm lint:dashboard\npnpm lint:dashboard:fix\npnpm --dir src/apps/dashboard test\npnpm test:dashboard-api\n```\n\n`test:dashboard-api` builds the dashboard, starts it on port `3101` with an isolated temporary copy of the mock route fixture, and exercises every dashboard API with curl. Override the port with `DASHBOARD_TEST_PORT` if needed. The test never reads or writes the project's real `routes.json`.\n\n---\n\n## Configuration\n\n### Environment Variables\n\nCopy `.env.example` to `.env` and edit as needed.\n\n#### Server\n\n| Variable | Default | Description |\n|---|---|---|\n| `GATEWAY_PORT` | `3000` | Port the gateway listens on. Takes priority over `PORT`. |\n| `PORT` | `3000` | Fallback port when `GATEWAY_PORT` is not set. |\n| `GATEWAY_PREFIX` | _(none)_ | Optional path prefix for all routes. Example: `/api/v1` makes proxy routes reachable at `/api/v1/<baseURL>` and the health check at `/api/v1/health`. |\n\n#### Logging\n\n| Variable | Default | Description |\n|---|---|---|\n| `NODE_ENV` | `development` | Set to `production` to disable `pino-pretty` and emit newline-delimited JSON. |\n| `LOG_LEVEL` | `info` | Pino log level: `trace` · `debug` · `info` · `warn` · `error` · `fatal`. |\n\n#### CORS\n\n| Variable | Default | Description |\n|---|---|---|\n| `CORS_ORIGINS` | `*` | Comma-separated list of allowed origins. Use `*` to allow all. |\n| `CORS_METHODS` | `GET,POST,PUT,DELETE,PATCH,OPTIONS` | Comma-separated list of allowed HTTP methods. |\n| `CORS_HEADERS` | `Content-Type,Authorization` | Comma-separated list of allowed request headers. |\n\n#### Routes\n\n| Variable | Default | Description |\n|---|---|---|\n| `ROUTES_FILE_PATH` | `routes.json` | Path to the JSON route config file, relative to `process.cwd()`. |\n| `ROUTES` | _(none)_ | Inline route definitions as a JSON array. Merged with `ROUTES_FILE_PATH`. Useful for containerised deployments where injecting a file is inconvenient. |\n\n#### Authentication\n\n| Variable | Default | Description |\n|---|---|---|\n| `JWT_SECRET` | _(none)_ | Fallback HMAC signing secret used when a JWT route has no inline `secret` field. |\n| `JWT_PUBLIC_KEY` | _(none)_ | Fallback PEM public key used when a JWT route has no inline `publicKey` field. Takes precedence over `JWT_SECRET`. |\n\n#### Tunable proxy defaults\n\nThese variables override hardcoded defaults without requiring route-level configuration changes.\n\n| Variable | Default | Description |\n|---|---|---|\n| `METRICS_HISTOGRAM_BUCKETS` | `0.005,0.01,...,10` | Comma-separated histogram bucket boundaries (seconds) for `gateway_request_duration_seconds`. |\n| `ROUTES_DEBOUNCE_MS` | `300` | Milliseconds to debounce file-watcher events before reloading routes. |\n| `RETRY_BACKOFF_MULTIPLIER` | `2` | Base multiplier for exponential backoff (`delay × multiplier^attempt`). |\n| `CIRCUIT_BREAKER_SUCCESS_THRESHOLD` | `1` | Default `successThreshold` applied when not set in a route's `circuitBreaker` block. |\n| `CACHE_DEFAULT_METHODS` | `GET,HEAD` | Comma-separated HTTP methods cached when a route's `cache` block omits `methods`. |\n| `CACHE_DEFAULT_STATUS_CODES` | `200,203,204` | Comma-separated status codes cached when a route's `cache` block omits `statusCodes`. |\n\n---\n\n### Route Configuration\n\nRoutes are defined as a JSON array. Each entry is a **Gateway** object:\n\n```ts\n{\n  baseURL:         string          // required — path prefix to match, must start with \"/\"\n  proxy:           Proxy          // required — upstream proxy settings (single target or load-balanced targets)\n  rateLimit?:      RateLimit      // optional — per-route rate limiting\n  auth?:           Auth           // optional — per-route authentication (JWT, API key, Basic, OAuth2)\n  circuitBreaker?: CircuitBreaker // optional — per-route circuit breaker\n  ipFilter?:       IpFilter       // optional — per-route IP allowlist / blocklist\n  retry?:          Retry          // optional — per-route retry with backoff\n  cache?:          Cache          // optional — per-route in-memory response caching\n  headers?:        Headers        // optional — per-route request / response header transforms\n  cors?:           RouteCors      // optional — per-route CORS policy (overrides global)\n  validation?:     ValidationConfig // optional — body fields, content type, and declared size\n  webhook?:        WebhookConfig  // optional — inbound provider signature verification\n}\n```\n\n#### `Proxy`\n\nExactly one of `target` or `targets` must be provided.\n\n| Field | Type | Required | Description |\n|---|---|---|---|\n| `target` | `string` | ✅ (or `targets`) | Single upstream URL. Mutually exclusive with `targets`. |\n| `targets` | `WeightedTarget[]` | ✅ (or `target`) | Two or more upstream URLs for load balancing. Mutually exclusive with `target`. |\n| `strategy` | `\"round-robin\" \\| \"weighted\" \\| \"least-connections\" \\| \"sticky\"` | — | Load-balancing strategy. Only valid with `targets`. Defaults to `\"round-robin\"`. |\n| `stickyKey` | `string` | — | Required for `\"sticky\"`; accepts `\"ip\"`, `\"header:<name>\"`, `\"jwt:<claim>\"`, `\"cookie:<name>\"`, or `\"query:<name>\"`. There is no schema default. |\n| `ws` | `boolean` | — | Enable WebSocket proxying for this route. |\n| `changeOrigin` | `boolean` | — | Rewrite the `Host` header to the target origin. |\n| `pathRewrite` | `{ [pattern]: replacement }` | — | Regex path rewrite rules applied before forwarding. |\n| `headers` | `{ [name]: value }` | — | Extra headers added to every forwarded request. |\n| `isSecure` | `boolean` | — | Verify the upstream TLS certificate. |\n| `method` | `string` | — | Override the HTTP method forwarded to the upstream. |\n| `timeout` | `number` | — | Maximum milliseconds to wait for an upstream response. Exceeding this limit returns `504 Gateway Timeout` and aborts the upstream connection. |\n| `upstreamAuth` | `{ type: \"hmac-sha256\"; secret: string; header?: string }` | — | Sign the forwarded body; default header is `x-gateway-signature`. Requires the retry backend. See [Upstream Request Signing](#upstream-request-signing). |\n| `mirror` | `{ target: string; percentage?: number }` | — | Shadow target URL and sampling percentage (0–100, default 100). Requires the retry backend. See [Traffic Mirroring](#traffic-mirroring). |\n\n**`WeightedTarget`**\n\n| Field | Type | Required | Description |\n|---|---|---|---|\n| `url` | `string` | ✅ | Upstream URL for this target. |\n| `weight` | `number` | — | Relative weight for the `\"weighted\"` strategy. Higher values receive proportionally more traffic. Defaults to `1`. Ignored by other strategies. |\n\n#### `RateLimit`\n\n| Field | Type | Required | Description |\n|---|---|---|---|\n| `max` | `number` | ✅ | Maximum number of requests allowed per window. |\n| `windowMs` | `number` | ✅ | Time window in milliseconds. |\n| `statusCode` | `number (400–599)` | — | HTTP status returned when the limit is exceeded (default: `429`). |\n| `message` | `string` | — | Response message when the limit is exceeded (default: `\"Too many requests\"`). |\n| `keyBy` | `string` | — | Key-derivation strategy for rate-limit bucketing (see table below). Defaults to client IP. |\n\n**`keyBy` values**\n\n| Value | Description |\n|---|---|\n| `\"ip\"` | Client IP address (default). |\n| `\"query:<name>\"` | String query parameter (e.g. `\"query:tenant\"`); falls back to IP when unavailable. |\n| `\"header:<name>\"` | Value of the named request header (e.g. `\"header:X-API-Key\"`). |\n| `\"jwt:<claim>\"` | Claim extracted from the decoded JWT payload (e.g. `\"jwt:sub\"`). Falls back to IP when the token or claim is absent. |\n| `\"cookie:<name>\"` | Value of the named cookie (e.g. `\"cookie:session_id\"`). Requires `cookie-parser` to be mounted. Falls back to IP when the cookie is absent. |\n\nResponses include standard `RateLimit-*` headers (RFC draft-8).\n\n#### `Auth`\n\nAdds authentication middleware to a route. When `enabled` is `false` the middleware is a no-op passthrough — no overhead, no token check.\n\nFour strategies are supported: `jwt`, `apiKey`, `basicAuth`, and [oauth2](#oauth-20-token-introspection). All support optional `authRateLimit: { max, windowMs }`; see [Authentication Failure Limiting](#authentication-failure-limiting).\n\n**`\"jwt\"` — Bearer token validation**\n\n| Field | Type | Required | Description |\n|---|---|---|---|\n| `enabled` | `boolean` | ✅ | `true` to enforce, `false` to bypass. |\n| `strategy` | `\"jwt\"` | ✅ | — |\n| `secret` | `string` | — | Shared secret for HMAC algorithms (HS256, HS384, HS512). Falls back to `JWT_SECRET` env var. |\n| `publicKey` | `string` | — | PEM-encoded public key or X.509 certificate for asymmetric algorithms (RS256, RS384, RS512, ES256 …). Falls back to `JWT_PUBLIC_KEY` env var. Takes precedence over `secret` when both are present. |\n| `algorithms` | `string[]` | — | Explicit algorithm allowlist. Defaults to `[\"RS256\"]` when `publicKey` is used, `[\"HS256\"]` otherwise. Recommended to prevent algorithm-confusion attacks. |\n| `jwksUri` | `string` | — | JWKS endpoint for public-key lookup using the token header's `kid`. Takes precedence over static keys; asymmetric default algorithm is `RS256`. |\n| `forwardClaims` | `Record<string, string>` | — | Map verified claim names to outgoing header names, e.g. `{ \"sub\": \"X-User-ID\" }`. Values are converted to strings. |\n\nFor enabled JWT routes loaded from JSON, the schema requires at least one inline `secret`, `publicKey`, or `jwksUri`. Runtime environment fallbacks alone do not satisfy this validation rule.\n\n**`\"apiKey\"` — Header-based API key**\n\n| Field | Type | Required | Description |\n|---|---|---|---|\n| `enabled` | `boolean` | ✅ | `true` to enforce, `false` to bypass. |\n| `strategy` | `\"apiKey\"` | ✅ | — |\n| `keys` | `string[]` | ✅ | List of valid API keys. At least one entry required. |\n| `header` | `string` | — | Header name to read the key from (default: `x-api-key`). |\n\n**`\"basicAuth\"` — HTTP Basic Authentication**\n\n| Field | Type | Required | Description |\n|---|---|---|---|\n| `enabled` | `boolean` | ✅ | `true` to enforce, `false` to bypass. |\n| `strategy` | `\"basicAuth\"` | ✅ | — |\n| `credentials` | `{ username: string; password: string }[]` | ✅ | List of valid username/password pairs. At least one entry required. Credentials are compared using a timing-safe algorithm. |\n| `realm` | `string` | — | Value for the `WWW-Authenticate` response header (default: `\"API Gateway\"`). |\n\n#### `CircuitBreaker`\n\nStops forwarding requests to a failing upstream after a configurable number of consecutive failures and returns `503 Service Unavailable` until the upstream recovers. See [Circuit Breaker](#circuit-breaker) for a full explanation.\n\n| Field | Type | Required | Description |\n|---|---|---|---|\n| `threshold` | `number` | ✅ | Consecutive failures before the circuit opens. Must be a positive integer. |\n| `timeout` | `number` | ✅ | Milliseconds the circuit stays open before transitioning to half-open and sending a probe request. |\n| `successThreshold` | `number` | — | Consecutive probe successes required to close the circuit (default: `1`). |\n| `healthCheck` | `HealthCheck` | — | Active health-check probe that pings a URL while the circuit is open to accelerate recovery. |\n| `fallback` | `{ status?: number; body?: unknown; headers?: Record<string, string> }` | — | Response when the circuit rejects a request. Status defaults to 503; an omitted body produces an empty response. `Retry-After` is still set unless overridden by fallback headers. |\n\n**`HealthCheck`**\n\n| Field | Type | Required | Description |\n|---|---|---|---|\n| `url` | `string` | ✅ | URL to probe with `GET`. A 2xx response counts as a success. |\n| `intervalMs` | `number` | ✅ | Milliseconds between probes. |\n| `timeoutMs` | `number` | — | Timeout for each probe request in milliseconds (default: `5000`). |\n\n#### `IpFilter`\n\nRestricts access to a route based on the client's IP address. At least one of `allow` or `deny` must be provided. See [IP Allowlist / Blocklist](#ip-allowlist--blocklist) for a full explanation.\n\n| Field | Type | Required | Description |\n|---|---|---|---|\n| `allow` | `string[]` | — | IPv4 addresses or CIDR ranges that are explicitly allowed. When set, only listed IPs can access the route. At least one entry required. |\n| `deny` | `string[]` | — | IPv4 addresses or CIDR ranges that are explicitly blocked. Evaluated before `allow` — a match returns `403` immediately. At least one entry required. |\n\nBoth fields accept plain IPv4 addresses (`192.168.1.1`) and CIDR notation (`10.0.0.0/8`). IPv4-mapped IPv6 addresses (`::ffff:192.168.1.1`) are normalised to their IPv4 form before matching, so you never need to list both forms.\n\nNative IPv6 addresses and CIDRs are also supported, such as `::1` and `2001:db8::/32`; allow and deny lists can contain both address families.\n\n#### `Retry`\n\nAutomatically retries failed upstream requests (5xx responses or network errors) before returning a failure to the client. The upstream is called up to `attempts + 1` times total. See [Retry with Backoff](#retry-with-backoff) for a full explanation.\n\n| Field | Type | Required | Description |\n|---|---|---|---|\n| `attempts` | `number (1–10)` | ✅ | Maximum number of retry attempts after the first failure. |\n| `delay` | `number` | ✅ | Base delay in milliseconds between retries. |\n| `backoff` | `\"fixed\" \\| \"exponential\" \\| \"exponential-jitter\"` | — | Accepted strategy names; see the current backend limitation under [Backoff strategies](#backoff-strategies). |\n| `retryOn` | `number[]` | — | Explicit list of HTTP status codes that should trigger a retry (e.g. `[500, 502, 503]`). When omitted, all 5xx responses are retried. Each code must be in the 400–599 range. |\n| `retryMethods` | `string[]` | — | Methods eligible for HTTP-status retries; defaults to `GET`, `HEAD`, and `OPTIONS`. Network errors currently retry independently of this list. |\n| `fallback` | `{ status?: number; body?: unknown }` | — | Response for thrown proxy/retry errors. Status defaults to 502. Does not replace a final HTTP error response returned by the upstream. |\n| `collapseRequests` | `boolean` | — | Share concurrent `GET`, `HEAD`, or `OPTIONS` work by method and URL. Defaults to false. Headers and caller identity are not part of the key. |\n\n#### `Cache`\n\nCaches successful upstream responses in memory per route. Cache hits bypass the upstream entirely. See [Response Caching](#response-caching) for a full explanation.\n\n| Field | Type | Required | Description |\n|---|---|---|---|\n| `ttl` | `number` | ✅ | Time-to-live in milliseconds. |\n| `methods` | `string[]` | — | HTTP methods to cache. Defaults to `[\"GET\", \"HEAD\"]`. |\n| `statusCodes` | `number[]` | — | HTTP status codes to cache. Defaults to `[200, 203, 204]`. |\n\n#### `Headers`\n\nTransforms request headers before forwarding to the upstream and/or response headers before returning to the client. See [Header Transformation](#header-transformation) for a full explanation.\n\n```ts\n{\n  request?: {\n    set?:    Record<string, string>  // add or override headers sent to the upstream\n    remove?: string[]                // remove headers before forwarding\n  }\n  response?: {\n    set?:    Record<string, string>  // add or override headers returned to the client\n    remove?: string[]                // remove headers before returning to the client\n  }\n}\n```\n\nAt least one of `set` or `remove` must be present inside each transform block; at least one of `request` or `response` must be present in the `headers` object.\n\n#### `RouteCors`\n\nOverrides the global CORS policy for a specific route, including preflight `OPTIONS` handling. Routes without a `cors` block inherit the global config and forward `OPTIONS` to the upstream. See [Route-Level CORS Override](#route-level-cors-override) for a full explanation.\n\n| Field | Type | Required | Description |\n|---|---|---|---|\n| `origin` | `string \\| string[] \\| boolean` | ✅ | Allowed origins. A single domain string, an array of domains, `true` to reflect the request `Origin` header, or `false` to disable CORS for this route. |\n| `methods` | `string[]` | — | Allowed HTTP methods. Defaults to the global `CORS_METHODS` config. |\n| `allowedHeaders` | `string[]` | — | Allowed request headers. Defaults to the global `CORS_HEADERS` config. |\n| `credentials` | `boolean` | — | Whether to allow credentials (cookies, `Authorization` header). When `true`, `origin` must not be `\"*\"`. |\n| `maxAge` | `number` | — | Seconds the browser may cache the preflight response (`Access-Control-Max-Age`). |\n\n#### Example `routes.json`\n\n```json\n[\n  {\n    \"baseURL\": \"/users\",\n    \"proxy\": {\n      \"target\": \"http://users-service:3001\",\n      \"changeOrigin\": true,\n      \"pathRewrite\": { \"^/users\": \"\" }\n    },\n    \"rateLimit\": {\n      \"max\": 100,\n      \"windowMs\": 60000,\n      \"statusCode\": 429,\n      \"message\": \"Too many requests. Please try again in a minute.\"\n    }\n  },\n  {\n    \"baseURL\": \"/orders\",\n    \"proxy\": {\n      \"target\": \"http://orders-service:3002\",\n      \"changeOrigin\": true,\n      \"pathRewrite\": { \"^/orders\": \"\" }\n    },\n    \"auth\": {\n      \"enabled\": true,\n      \"strategy\": \"jwt\",\n      \"secret\": \"example-signing-secret\"\n    }\n  },\n  {\n    \"baseURL\": \"/reports\",\n    \"proxy\": {\n      \"target\": \"http://reports-service:3003\",\n      \"changeOrigin\": true,\n      \"pathRewrite\": { \"^/reports\": \"\" }\n    },\n    \"auth\": {\n      \"enabled\": true,\n      \"strategy\": \"apiKey\",\n      \"keys\": [\"key-service-alpha-123\", \"key-service-beta-456\"]\n    }\n  },\n  {\n    \"baseURL\": \"/payments\",\n    \"proxy\": {\n      \"target\": \"http://payments-service:3004\",\n      \"changeOrigin\": true,\n      \"pathRewrite\": { \"^/payments\": \"\" }\n    },\n    \"circuitBreaker\": {\n      \"threshold\": 5,\n      \"timeout\": 30000,\n      \"successThreshold\": 1\n    }\n  },\n  {\n    \"baseURL\": \"/internal/metrics\",\n    \"proxy\": {\n      \"target\": \"http://metrics-service:3005\",\n      \"changeOrigin\": true,\n      \"pathRewrite\": { \"^/internal/metrics\": \"\" }\n    },\n    \"ipFilter\": {\n      \"allow\": [\"10.0.0.0/8\", \"172.16.0.0/12\"]\n    }\n  },\n  {\n    \"baseURL\": \"/catalog\",\n    \"proxy\": {\n      \"targets\": [\n        { \"url\": \"http://catalog-a:3006\", \"weight\": 1 },\n        { \"url\": \"http://catalog-b:3007\", \"weight\": 2 }\n      ],\n      \"strategy\": \"weighted\",\n      \"changeOrigin\": true,\n      \"pathRewrite\": { \"^/catalog\": \"\" }\n    }\n  },\n  {\n    \"baseURL\": \"/realtime\",\n    \"proxy\": {\n      \"target\": \"http://ws-service:3008\",\n      \"changeOrigin\": true,\n      \"ws\": true\n    }\n  },\n  {\n    \"baseURL\": \"/admin\",\n    \"proxy\": {\n      \"target\": \"http://admin-service:3009\",\n      \"changeOrigin\": true,\n      \"pathRewrite\": { \"^/admin\": \"\" }\n    },\n    \"auth\": {\n      \"enabled\": true,\n      \"strategy\": \"basicAuth\",\n      \"credentials\": [\n        { \"username\": \"alice\", \"password\": \"s3cr3t\" }\n      ],\n      \"realm\": \"Admin Panel\"\n    }\n  },\n  {\n    \"baseURL\": \"/external\",\n    \"proxy\": {\n      \"target\": \"http://third-party-api:443\",\n      \"changeOrigin\": true,\n      \"pathRewrite\": { \"^/external\": \"\" },\n      \"timeout\": 5000\n    }\n  }\n]\n```\n\nConfig is validated with [Zod](https://zod.dev) at startup. If any route is invalid the process exits immediately with a detailed per-field error message.\n\nRoutes are loaded from two sources at startup and **merged**:\n\n1. `ROUTES_FILE_PATH` — JSON file on disk (missing file is a warning, not an error)\n2. `ROUTES` — JSON array in an environment variable\n\n---\n\n## Authentication\n\nAuthentication is optional and configured per route via the `auth` field. The middleware is applied before rate limiting and proxying. When `enabled: false` the handler is a single no-op function — zero overhead on unprotected routes.\n\n### JWT\n\nProtect a route with a Bearer token. The gateway validates the token signature; your upstream receives the request only if verification passes.\n\n```json\n{\n  \"baseURL\": \"/orders\",\n  \"proxy\": { \"target\": \"http://orders-service:3002\", \"changeOrigin\": true },\n  \"auth\": {\n    \"enabled\": true,\n    \"strategy\": \"jwt\",\n    \"secret\": \"super-secret-key-change-in-production\"\n  }\n}\n```\n\nWhen `jwksUri` is configured, keys are resolved from that endpoint using the token's `kid`. Otherwise, the signing key is resolved in this order:\n\n1. `publicKey` field in the route config (PEM — use for RS256 / ES256)\n2. `JWT_PUBLIC_KEY` environment variable\n3. `secret` field in the route config (string — use for HS256)\n4. `JWT_SECRET` environment variable\n\n`publicKey` takes precedence over `secret` for static-key verification. Enabled JSON routes must explicitly include `secret`, `publicKey`, or `jwksUri` to pass startup validation; environment variables alone are insufficient.\n\n**HMAC (HS256) — shared secret:**\n\n```bash\n# .env\nJWT_SECRET=super-secret-key-change-in-production\n```\n\n```bash\nTOKEN=$(curl -s -X POST http://localhost:3000/auth/login \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"username\":\"alice\",\"password\":\"password123\"}' | jq -r '.token')\n\ncurl http://localhost:3000/orders \\\n  -H \"Authorization: Bearer $TOKEN\"\n```\n\n**RSA (RS256) — public/private key pair:**\n\n```json\n{\n  \"auth\": {\n    \"enabled\": true,\n    \"strategy\": \"jwt\",\n    \"publicKey\": \"-----BEGIN PUBLIC KEY-----\\nMIIBIjAN...\\n-----END PUBLIC KEY-----\",\n    \"algorithms\": [\"RS256\"]\n  }\n}\n```\n\n### API Key\n\nProtect a route with a pre-shared key delivered in a request header.\n\n```json\n{\n  \"baseURL\": \"/reports\",\n  \"proxy\": { \"target\": \"http://reports-service:3003\", \"changeOrigin\": true },\n  \"auth\": {\n    \"enabled\": true,\n    \"strategy\": \"apiKey\",\n    \"header\": \"x-api-key\",\n    \"keys\": [\"key-service-alpha-123\", \"key-service-beta-456\"]\n  }\n}\n```\n\n```bash\n# Valid key → 200\ncurl http://localhost:3000/reports \\\n  -H \"x-api-key: key-service-alpha-123\"\n\n# Missing or wrong key → 401\ncurl http://localhost:3000/reports\n```\n\nMultiple keys in `keys` let you rotate credentials without downtime — add the new key, deploy, then remove the old one.\n\n### Basic Auth\n\nProtect a route with HTTP Basic Authentication. The gateway decodes the `Authorization: Basic <base64>` header, checks the username/password pair against the configured list, and returns `401` with a `WWW-Authenticate` header if the credentials are missing or wrong. Credential comparison is timing-safe to prevent enumeration attacks.\n\n```json\n{\n  \"baseURL\": \"/admin\",\n  \"proxy\": { \"target\": \"http://admin-service:3010\", \"changeOrigin\": true },\n  \"auth\": {\n    \"enabled\": true,\n    \"strategy\": \"basicAuth\",\n    \"credentials\": [\n      { \"username\": \"alice\", \"password\": \"s3cr3t\" },\n      { \"username\": \"deploy-bot\", \"password\": \"ci-token-xyz\" }\n    ],\n    \"realm\": \"Admin Panel\"\n  }\n}\n```\n\n```bash\n# Valid credentials → 200\ncurl http://localhost:3000/admin \\\n  -u alice:s3cr3t\n\n# Wrong password → 401 + WWW-Authenticate header\ncurl -si http://localhost:3000/admin \\\n  -u alice:wrongpassword | head -3\n# HTTP/1.1 401 Unauthorized\n# WWW-Authenticate: Basic realm=\"Admin Panel\"\n\n# No credentials → 401\ncurl -si http://localhost:3000/admin | head -3\n# HTTP/1.1 401 Unauthorized\n# WWW-Authenticate: Basic realm=\"Admin Panel\"\n```\n\n> **Note:** HTTP Basic Auth transmits credentials in Base64, which is trivially reversible. Always use it behind TLS in production (`HTTPS`).\n\nMultiple credential pairs in `credentials` let you issue per-client credentials and revoke them individually without changing every consumer.\n\n### OAuth 2.0 Token Introspection\n\nValidate opaque Bearer tokens by calling an RFC 7662 token introspection endpoint. The gateway posts the token to the configured `introspectionUrl`, authenticates itself with HTTP Basic auth using `clientId`/`clientSecret`, and allows the request through only when the introspection response returns `active: true`.\n\n```json\n{\n  \"baseURL\": \"/protected\",\n  \"proxy\": { \"target\": \"http://api-service:3010\", \"changeOrigin\": true },\n  \"auth\": {\n    \"enabled\": true,\n    \"strategy\": \"oauth2\",\n    \"introspectionUrl\": \"https://auth.example.com/oauth/introspect\",\n    \"clientId\": \"gateway-client\",\n    \"clientSecret\": \"s3cr3t\"\n  }\n}\n```\n\n| Field | Type | Required | Description |\n|---|---|---|---|\n| `enabled` | `boolean` | ✅ | `true` to enforce, `false` to bypass. |\n| `strategy` | `\"oauth2\"` | ✅ | — |\n| `introspectionUrl` | `string` | ✅ | Full URL of the RFC 7662 introspection endpoint. |\n| `clientId` | `string` | ✅ | Client ID used for HTTP Basic auth against the introspection endpoint. |\n| `clientSecret` | `string` | ✅ | Client secret used for HTTP Basic auth against the introspection endpoint. |\n| `tokenTypeHint` | `string` | — | `token_type_hint` parameter sent with the introspection request (default: `\"access_token\"`). |\n| `introspectionCacheTtlMs` | `number` | — | Positive TTL in milliseconds for successful introspection results. Inactive results are not cached; a previously active token may remain accepted until its cached result expires, even after revocation or token expiry. Omit to introspect every request. |\n\n**How it works:**\n\n1. The gateway extracts the `Bearer <token>` from the `Authorization` header.\n2. It POSTs `token=<opaque_token>&token_type_hint=access_token` to `introspectionUrl`.\n3. The request to the introspection endpoint carries `Authorization: Basic base64(clientId:clientSecret)`.\n4. If the endpoint responds with `{ \"active\": true }`, the request is forwarded to the upstream.\n5. Any other response — `active: false`, a non-2xx HTTP status, or a network error — returns `401 Unauthorized` to the client.\n\n```bash\n# 1. Get a token from your auth server (issuer-specific, not proxied through the gateway here)\nTOKEN=$(curl -s -X POST https://auth.example.com/login \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"username\":\"alice\",\"password\":\"s3cr3t\"}' | jq -r '.access_token')\n\n# 2. Access the protected route\ncurl http://localhost:3000/protected \\\n  -H \"Authorization: Bearer $TOKEN\"\n\n# 3. Missing or forged token → 401\ncurl http://localhost:3000/protected \\\n  -H \"Authorization: Bearer fake-token\"\n```\n\n> **Tip:** Use `introspectionCacheTtlMs` to avoid hammering your auth server on high-traffic routes. Set it to a value shorter than your token expiry (e.g. 60 seconds) to keep revocation lag acceptable.\n\n---\n\n### JWKS and Forwarded Claims\n\nUse a JWKS endpoint instead of embedding a public key, and map verified claims into upstream headers:\n\n```json\n{\n  \"baseURL\": \"/identity\",\n  \"proxy\": { \"target\": \"http://identity-service:3001\" },\n  \"auth\": {\n    \"enabled\": true,\n    \"strategy\": \"jwt\",\n    \"jwksUri\": \"https://issuer.example.com/.well-known/jwks.json\",\n    \"algorithms\": [\"RS256\"],\n    \"forwardClaims\": { \"sub\": \"X-User-ID\", \"tenant\": \"X-Tenant-ID\" }\n  }\n}\n```\n\nTokens must carry a `kid` header matching a published key. Keys are cached in-process. An unknown `kid` triggers a refresh subject to a 60-second cooldown; cached keys have no fixed expiry. `jwksUri` takes precedence over `publicKey` and `secret`.\n\nClaim forwarding runs after verification. Present, non-null claims overwrite the mapped request headers; missing claims leave existing headers unchanged. An upstream should not treat a mapped header as proof that a missing claim was verified. `headers.request` transforms run later and can override forwarded values.\n\n### Authentication Failure Limiting\n\nEvery auth strategy accepts `authRateLimit`:\n\n```json\n{\n  \"baseURL\": \"/admin\",\n  \"proxy\": { \"target\": \"http://admin-service:3001\" },\n  \"auth\": {\n    \"enabled\": true,\n    \"strategy\": \"basicAuth\",\n    \"credentials\": [{ \"username\": \"alice\", \"password\": \"example-password\" }],\n    \"authRateLimit\": { \"max\": 5, \"windowMs\": 60000 }\n  }\n}\n```\n\nThe route tracks JSON 401 responses by client IP. After five failures, subsequent requests from that IP return 429 until the window expires, including requests with valid credentials. This is separate from ordinary `rateLimit`, which counts requests. Counters live in-process per route and reset when the middleware is rebuilt. Try [the Basic Auth example](examples/basic-auth/).\n\n## Request Validation\n\nThe optional `validation` block runs before webhook verification and authentication:\n\n```json\n{\n  \"baseURL\": \"/contacts\",\n  \"proxy\": { \"target\": \"http://localhost:4071\" },\n  \"validation\": {\n    \"allowedContentTypes\": [\"application/json\"],\n    \"requiredFields\": [\"name\", \"email\"],\n    \"maxBodyBytes\": 1024\n  }\n}\n```\n\n| Field | Behavior | Rejection status |\n| --- | --- | --- |\n| `allowedContentTypes` | Case-insensitive substring match against the Content-Type header; an empty list disables this check | 415 |\n| `requiredFields` | Requires an object body with non-null, defined top-level fields; does not validate field types or nested paths | 422 |\n| `maxBodyBytes` | Positive integer compared against Content-Length when that header is present | 413 |\n\nChecks run in the order above except that size is checked before required fields. They apply to every request on the route, including GET requests. The per-route size check does not measure chunked bodies; the Express parser's own limits still apply. See [the validation example](examples/validation/).\n\n## Webhook Verification\n\nSet `webhook` to verify incoming signatures before forwarding. The gateway retains raw body bytes through its body parser and compares signatures with a timing-safe helper.\n\n```json\n{\n  \"baseURL\": \"/webhooks/github\",\n  \"proxy\": { \"target\": \"http://localhost:4072\" },\n  \"webhook\": { \"provider\": \"github\", \"secret\": \"example-webhook-secret\" }\n}\n```\n\n| Provider | Signature header | Signed content and encoding |\n| --- | --- | --- |\n| `github` | `x-hub-signature-256` | HMAC-SHA256 of raw body; header value `sha256=<hex>` |\n| `stripe` | `stripe-signature` | HMAC-SHA256 of `<timestamp>.<UTF-8 body>`; header `t=<timestamp>,v1=<hex>` |\n| `custom` | Required `headerName`, normalized to lowercase | Raw-body HMAC hex digest using `hashAlgorithm` (default `sha256`) |\n\nAll providers require a nonempty `secret`. GitHub and Stripe use fixed headers and algorithms; optional `headerName` and `hashAlgorithm` values are ignored for those providers. Custom configuration looks like:\n\n```json\n{\n  \"provider\": \"custom\",\n  \"secret\": \"example-webhook-secret\",\n  \"headerName\": \"X-Example-Signature\",\n  \"hashAlgorithm\": \"sha256\"\n}\n```\n\nMissing or invalid signatures return 401. Unavailable raw body returns 400. Stripe currently checks the first `t` and `v1` entries only and does not enforce timestamp freshness or replay prevention.\n\nThe library exports `WebhookConfig` as a discriminated union of `GitHubWebhookConfig`, `StripeWebhookConfig`, and `CustomWebhookConfig`, plus `WebhookProvider`. Provider verifier classes own header and signature rules; `WebhookMiddlewareFactory` delegates through a verifier resolver.\n\nRun `bash examples/run.sh webhook`, then `node examples/webhook/send.js github` (or `stripe` / `custom`). The [example](examples/webhook/) includes its own upstream event receiver.\n\n## Upstream Request Signing\n\n`proxy.upstreamAuth` signs the outgoing request body so the upstream can check that it was sent by a holder of the shared secret:\n\n```json\n{\n  \"baseURL\": \"/signed\",\n  \"proxy\": {\n    \"target\": \"http://localhost:4074\",\n    \"pathRewrite\": { \"^/signed\": \"\" },\n    \"upstreamAuth\": {\n      \"type\": \"hmac-sha256\",\n      \"secret\": \"example-upstream-secret\",\n      \"header\": \"x-gateway-signature\"\n    }\n  },\n  \"retry\": { \"attempts\": 1, \"delay\": 0 }\n}\n```\n\n`type` and `secret` are required. The optional header defaults to `x-gateway-signature` and carries a lowercase hex HMAC-SHA256 digest with no prefix. The signature covers the bytes serialized for the upstream, which may differ from the original JSON formatting. It does not cover the URL, method, or timestamp.\n\nSigning is currently implemented only by the retry backend: a `retry` block is required, and this backend does not proxy WebSocket upgrades. `attempts: 1` allows one retry after the initial request; it does not disable retries. See [the signing example](examples/upstream-signing/) for an upstream that checks signatures and rejects unsigned direct requests.\n\n## Traffic Mirroring\n\n`proxy.mirror` copies traffic to another upstream for shadow testing:\n\n```json\n{\n  \"baseURL\": \"/mirrored\",\n  \"proxy\": {\n    \"target\": \"http://localhost:4075\",\n    \"pathRewrite\": { \"^/mirrored\": \"\" },\n    \"mirror\": { \"target\": \"http://localhost:4076\", \"percentage\": 100 }\n  },\n  \"retry\": { \"attempts\": 1, \"delay\": 0 }\n}\n```\n\n`target` is required; `percentage` accepts 0–100 and defaults to 100. The retry backend dispatches the mirror after writing a returned primary response. Thrown primary errors do not trigger mirroring. The client does not wait for the shadow response, and mirror failures are logged at debug level.\n\nMirror requests use the route's path rewrite and serialized body. They do not apply `proxy.upstreamAuth` or `headers.request` transforms; incoming request headers are otherwise forwarded after transport header filtering. Choose a shadow target that is appropriate for that data. Like signing, mirroring requires a `retry` block and does not support WebSocket upgrades.\n\nThe [mirroring example](examples/traffic-mirroring/) has separate primary and shadow JavaScript services. Send a request to `/mirrored`, then inspect `http://localhost:4076/stats` to see the shadow request count.\n\n## Request Timeout per Route\n\nSet `proxy.timeout` on any route to limit how long the gateway waits for the upstream to respond. When the deadline is exceeded, the gateway immediately returns `504 Gateway Timeout` to the client and cancels the upstream connection — preventing a slow or hung upstream from holding sockets open indefinitely.\n\n### Configuration\n\n```json\n{\n  \"baseURL\": \"/external-api\",\n  \"proxy\": {\n    \"target\": \"http://slow-third-party:8080\",\n    \"changeOrigin\": true,\n    \"pathRewrite\": { \"^/external-api\": \"\" },\n    \"timeout\": 5000\n  }\n}\n```\n\n`timeout` is measured in milliseconds and applies to the total time waiting for the upstream to begin sending a response. Once the upstream starts streaming, the timer is cleared.\n\n### Response when timeout is exceeded\n\n```\nHTTP/1.1 504 Gateway Timeout\nContent-Type: application/json\n\n{\n  \"error\": \"Gateway Timeout\",\n  \"message\": \"Upstream did not respond within 5000ms\"\n}\n```\n\nThe upstream connection is also aborted server-side, so resources are freed immediately.\n\n### Combining with the circuit breaker\n\nTimeout and circuit breaking work independently and can be applied to the same route:\n\n```json\n{\n  \"baseURL\": \"/payments\",\n  \"proxy\": {\n    \"target\": \"http://payments-service:3004\",\n    \"changeOrigin\": true,\n    \"timeout\": 3000\n  },\n  \"circuitBreaker\": {\n    \"threshold\": 5,\n    \"timeout\": 30000\n  }\n}\n```\n\nA request that times out counts as a circuit-breaker failure. After `threshold` timeouts the circuit opens and subsequent requests are rejected with `503` without even reaching the upstream.\n\n---\n\n## Circuit Breaker\n\nThe circuit breaker protects your gateway from cascading failures. When an upstream service becomes unhealthy, the gateway detects the pattern, opens the circuit, and rejects subsequent requests immediately — without adding load to an already-struggling upstream.\n\n### State machine\n\n```\n              threshold failures\n  CLOSED ────────────────────────► OPEN\n    ▲                                │\n    │ successThreshold successes     │ timeout elapses\n    │                                ▼\n  HALF-OPEN ◄─────────────────── (probe)\n              1 probe request let through\n```\n\n| State | Behaviour |\n|---|---|\n| **CLOSED** | Normal operation. Failures are counted; successful responses reset the counter. |\n| **OPEN** | All requests are rejected immediately with `503 Service Unavailable` and a `Retry-After` header. The upstream is not contacted. |\n| **HALF-OPEN** | After `timeout` ms the circuit allows one probe request through. A successful response closes the circuit; any failure re-opens it with a fresh timeout. |\n\nBoth **5xx HTTP responses** and **network-level errors** (e.g. `ECONNREFUSED`, `ETIMEDOUT`) count as failures.\n\n### Configuration\n\n```json\n{\n  \"baseURL\": \"/payments\",\n  \"proxy\": {\n    \"target\": \"http://payments-service:3004\",\n    \"changeOrigin\": true,\n    \"pathRewrite\": { \"^/payments\": \"\" }\n  },\n  \"circuitBreaker\": {\n    \"threshold\": 5,\n    \"timeout\": 30000,\n    \"successThreshold\": 1\n  }\n}\n```\n\n### Responses\n\n**Circuit OPEN — `503 Service Unavailable`:**\n\n```\nHTTP/1.1 503 Service Unavailable\nRetry-After: 28\nContent-Type: application/json\n\n{\n  \"error\": \"Service Unavailable\",\n  \"message\": \"Circuit breaker open — upstream is not responding\"\n}\n```\n\nThe `Retry-After` header tells clients how many seconds remain before the circuit transitions to half-open.\n\n**Upstream network error — `502 Bad Gateway`:**\n\n```json\n{\n  \"error\": \"Bad Gateway\",\n  \"message\": \"Upstream service is unavailable\"\n}\n```\n\n### Gateway log output\n\nState transitions are logged at the `warn` / `info` level so you can observe the circuit breaker lifecycle without instrumenting your upstreams:\n\n```\nWARN  Circuit breaker opened, upstream failing   { baseURL: \"/payments\", retryAfterSeconds: 30 }\nWARN  Circuit breaker half-open, probing upstream { baseURL: \"/payments\" }\nINFO  Circuit breaker closed, upstream recovered  { baseURL: \"/payments\" }\n```\n\n### Combining with rate limiting\n\nCircuit breaking and rate limiting are independent and can be applied to the same route. Rate limiting runs first:\n\n```json\n{\n  \"baseURL\": \"/payments\",\n  \"proxy\": { \"target\": \"http://payments-service:3004\", \"changeOrigin\": true },\n  \"rateLimit\": { \"max\": 200, \"windowMs\": 60000 },\n  \"circuitBreaker\": { \"threshold\": 5, \"timeout\": 30000 }\n}\n```\n\n---\n\n## Load Balancing\n\nDistribute traffic across multiple upstream instances by replacing the single `proxy.target` string with a `proxy.targets` array. Four strategies are supported.\n\n### Strategies\n\n| Strategy | Behaviour |\n|---|---|\n| `round-robin` (default) | Cycles through targets in order: A → B → C → A → … |\n| `weighted` | Each target carries traffic proportional to its `weight`. A target with `weight: 2` receives twice as many requests as one with `weight: 1`. The cycle is deterministic (not random). |\n| `least-connections` | Always forwards to the target with the fewest active connections. Best for routes where upstream processing time varies significantly. |\n| `sticky` | Routes a given client to the same upstream on every request (session affinity). The client is identified by the `stickyKey` spec (`\"ip\"`, `\"header:<name>\"`, `\"jwt:<claim>\"`, or `\"cookie:<name>\"`). On first contact the target is chosen by round-robin and stored in-process; subsequent requests from the same client always go to that target. Mappings are in-process only and reset on gateway restart. |\n\n### Configuration\n\n**Round-robin (3 equal instances):**\n\n```json\n{\n  \"baseURL\": \"/catalog\",\n  \"proxy\": {\n    \"targets\": [\n      { \"url\": \"http://catalog-a:3001\" },\n      { \"url\": \"http://catalog-b:3002\" },\n      { \"url\": \"http://catalog-c:3003\" }\n    ],\n    \"changeOrigin\": true,\n    \"pathRewrite\": { \"^/catalog\": \"\" }\n  }\n}\n```\n\n**Weighted (B carries twice as much traffic as A or C):**\n\n```json\n{\n  \"baseURL\": \"/catalog\",\n  \"proxy\": {\n    \"targets\": [\n      { \"url\": \"http://catalog-a:3001\", \"weight\": 1 },\n      { \"url\": \"http://catalog-b:3002\", \"weight\": 2 },\n      { \"url\": \"http://catalog-c:3003\", \"weight\": 1 }\n    ],\n    \"strategy\": \"weighted\",\n    \"changeOrigin\": true\n  }\n}\n```\n\n**Least-connections:**\n\n```json\n{\n  \"baseURL\": \"/api\",\n  \"proxy\": {\n    \"targets\": [\n      { \"url\": \"http://api-1:3001\" },\n      { \"url\": \"http://api-2:3002\" }\n    ],\n    \"strategy\": \"least-connections\",\n    \"changeOrigin\": true\n  }\n}\n```\n\n**Sticky sessions (session affinity):**\n\n```json\n{\n  \"baseURL\": \"/checkout\",\n  \"proxy\": {\n    \"targets\": [\n      { \"url\": \"http://checkout-a:3001\" },\n      { \"url\": \"http://checkout-b:3002\" }\n    ],\n    \"strategy\": \"sticky\",\n    \"stickyKey\": \"cookie:session_id\",\n    \"changeOrigin\": true\n  }\n}\n```\n\n`stickyKey` is required for sticky routes and accepts five formats:\n\n| Format | Example | Behaviour |\n|---|---|---|\n| `\"ip\"` | `\"ip\"` | Client IP address. |\n| `\"header:<name>\"` | `\"header:X-Session-ID\"` | Value of the named request header. |\n| `\"jwt:<claim>\"` | `\"jwt:sub\"` | Claim extracted from the decoded JWT Bearer token. |\n| `\"cookie:<name>\"` | `\"cookie:session_id\"` | Value of the named cookie. |\n| `\"query:<name>\"` | `\"query:session\"` | String query parameter; repeated or structured values are ignored. |\n\nCookie keys require middleware that populates `req.cookies`; the standalone gateway does not install `cookie-parser`. Sticky mappings reset on restart or route reload. JWT key extraction decodes claims; use JWT authentication on the route when the key must come from a verified token.\n\nWhen the key source is absent (no cookie, no header, anonymous request) the gateway falls back to round-robin for that single request and pins the chosen target if a key becomes available on subsequent calls.\n\n### Composing with other features\n\nLoad balancing is fully composable with every other per-route feature:\n\n```json\n{\n  \"baseURL\": \"/api\",\n  \"proxy\": {\n    \"targets\": [\n      { \"url\": \"http://api-1:3001\", \"weight\": 2 },\n      { \"url\": \"http://api-2:3002\", \"weight\": 1 }\n    ],\n    \"strategy\": \"weighted\",\n    \"changeOrigin\": true\n  },\n  \"rateLimit\": { \"max\": 100, \"windowMs\": 60000 },\n  \"auth\": { \"enabled\": true, \"strategy\": \"apiKey\", \"keys\": [\"svc-key-xyz\"] },\n  \"circuitBreaker\": { \"threshold\": 5, \"timeout\": 30000 }\n}\n```\n\nThe circuit breaker wraps the load-balanced pool as a whole. If the breaker opens, all targets are bypassed until recovery.\n\n---\n\n## WebSocket Proxying\n\nEnable WebSocket proxying by adding `\"ws\": true` to any proxy configuration. The gateway intercepts the HTTP Upgrade handshake on the raw TCP server and tunnels all subsequent WebSocket frames transparently to the upstream.\n\n### Configuration\n\n```json\n{\n  \"baseURL\": \"/chat\",\n  \"proxy\": {\n    \"target\": \"http://chat-service:4000\",\n    \"changeOrigin\": true,\n    \"pathRewrite\": { \"^/chat\": \"\" },\n    \"ws\": true\n  }\n}\n```\n\n### How it works\n\nStandard HTTP requests to `/chat` are proxied normally. When a client sends an HTTP Upgrade request, the gateway attaches the proxy middleware's upgrade handler to the raw HTTP server's `upgrade` event, so WebSocket connections are forwarded at the transport level without involving Express middleware.\n\n### Usage\n\n```bash\n# Connect through the gateway (requires wscat: npm i -g wscat)\nwscat -c ws://localhost:3000/chat\n\n# Inspect the raw upgrade handshake\ncurl -si http://localhost:3000/chat \\\n  -H \"Upgrade: websocket\" \\\n  -H \"Connection: Upgrade\" \\\n  -H \"Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\" \\\n  -H \"Sec-WebSocket-Version: 13\" | head -6\n\n# HTTP/1.1 101 Switching Protocols\n# Upgrade: websocket\n# Connection: Upgrade\n# Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=\n```\n\n### Notes\n\n- `ws: true` can be combined with `pathRewrite`, `headers`, `changeOrigin`, and `timeout`.\n- WebSocket and load balancing can be combined. Note that WebSocket connections are long-lived; `least-connections` is the most effective strategy in that case since it naturally directs new connections to the least-loaded instance.\n- Auth, rate limiting, and IP filtering apply only to the initial HTTP Upgrade request. Once the connection is established, frames flow directly through the proxy.\n\n---\n\n## Request ID Propagation\n\nEvery request that passes through the gateway is assigned a unique `X-Request-ID` header. This ID ties together the gateway log line, the request forwarded to the upstream, and the response returned to the caller — letting you trace any individual request across your entire stack with a single value.\n\n### Behaviour\n\n| Scenario | Result |\n|---|---|\n| Client sends no `X-Request-ID` header | Gateway generates a UUID v4 and injects it |\n| Client sends `X-Request-ID: <value>` | Gateway forwards the existing value unchanged |\n\nIn both cases the final ID is:\n- set on `req.headers` so it is forwarded to the upstream in the proxy request\n- echoed in the `X-Request-ID` **response** header so callers can log it\n- used as `req.id` in every `pino-http` log line for that request\n\nNo route configuration is required — propagation is automatic for every route.\n\n### Gateway log correlation\n\n```\nINFO  incoming request  { \"req\": { \"id\": \"f47ac10b-58cc-4372-a567-0e02b2c3d479\", \"method\": \"GET\", \"url\": \"/orders\" } }\nINFO  request completed { \"req\": { \"id\": \"f47ac10b-58cc-4372-a567-0e02b2c3d479\" }, \"res\": { \"statusCode\": 200 } }\n```\n\n### Usage\n\n```bash\n# Gateway generates a UUID — echoed in the response header\ncurl -si http://localhost:3000/inventory | grep -i x-request-id\n# X-Request-ID: f47ac10b-58cc-4372-a567-0e02b2c3d479\n\n# Supply your own ID — forwarded unchanged\ncurl -si http://localhost:3000/inventory \\\n  -H \"X-Request-ID: my-trace-abc-123\" | grep -i x-request-id\n# X-Request-ID: my-trace-abc-123\n```\n\n---\n\n## IP Allowlist / Blocklist\n\nRestrict access to any route by the client's IP address. Exact IPv4 and IPv6 addresses and CIDR ranges are supported. `deny` and `allow` can be combined on the same route.\n\n### Evaluation order\n\n```\n1. deny  — if the client IP matches any deny entry → 403 Forbidden (stop)\n2. allow — if set and the client IP does not match any allow entry → 403 Forbidden (stop)\n3.        — request is forwarded to the upstream\n```\n\nWhen only `deny` is configured every IP passes except those explicitly blocked.  \nWhen only `allow` is configured only listed IPs pass.  \nWhen both are present `deny` takes precedence.\n\n### Configuration\n\n```json\n{\n  \"baseURL\": \"/internal/metrics\",\n  \"proxy\": { \"target\": \"http://metrics-service:3005\", \"changeOrigin\": true },\n  \"ipFilter\": {\n    \"allow\": [\"10.0.0.0/8\", \"172.16.0.0/12\"]\n  }\n}\n```\n\n```json\n{\n  \"baseURL\": \"/public-api\",\n  \"proxy\": { \"target\": \"http://api-service:3006\", \"changeOrigin\": true },\n  \"ipFilter\": {\n    \"deny\": [\"203.0.113.0/24\"]\n  }\n}\n```\n\n### Response when blocked\n\n```\nHTTP/1.1 403 Forbidden\nContent-Type: application/json\n\n{\n  \"error\": \"Forbidden\",\n  \"message\": \"Your IP address is not permitted to access this resource\"\n}\n```\n\nThe upstream never receives the request — filtering happens in the gateway middleware before the proxy is invoked.\n\n### IPv6 normalisation\n\nIPv4-mapped IPv6 addresses (`::ffff:192.168.1.1`) are silently normalised to their IPv4 form before matching. You only need to list the IPv4 address in the config — both forms are covered automatically.\n\n### Combining with other features\n\nIP filtering runs after per-route CORS and before validation, authentication, and rate limiting. A blocked request never reaches the auth check and does not count against rate limit counters.\n\n```json\n{\n  \"baseURL\": \"/admin\",\n  \"proxy\": { \"target\": \"http://admin-service:3007\", \"changeOrigin\": true },\n  \"ipFilter\": { \"allow\": [\"10.0.0.0/8\"] },\n  \"auth\": { \"enabled\": true, \"strategy\": \"apiKey\", \"keys\": [\"admin-key-xyz\"] },\n  \"rateLimit\": { \"max\": 50, \"windowMs\": 60000 }\n}\n```\n\n---\n\n## Retry with Backoff\n\nAutomatically retry failed upstream requests before returning a failure to the client. The gateway buffers the full upstream response on each attempt — it never streams a 5xx to the client — so retries are completely transparent. Both HTTP 5xx responses and network-level errors (e.g. `ECONNREFUSED`) trigger a retry.\n\n### Configuration\n\n```json\n{\n  \"baseURL\": \"/inventory\",\n  \"proxy\": {\n    \"target\": \"http://inventory-service:3001\",\n    \"changeOrigin\": true,\n    \"pathRewrite\": { \"^/inventory\": \"\" }\n  },\n  \"retry\": {\n    \"attempts\": 3,\n    \"delay\": 200,\n    \"backoff\": \"exponential\"\n  }\n}\n```\n\nWith `attempts: 3`, the gateway calls the upstream up to 4 times total (1 initial + 3 retries), subject to HTTP method eligibility. See the backend limitation below for delay behavior.\n\n### Backoff strategies\n\n| Strategy | Delay formula | Example (`delay: 200`) |\n|---|---|---|\n| `\"fixed\"` | `delay` | 200 ms, 200 ms, 200 ms |\n| `\"exponential\"` | `delay × 2^n` | 200 ms, 400 ms, 800 ms |\n| `\"exponential-jitter\"` | Uniform random value below `delay × 2^n` | Below 200 ms, 400 ms, 800 ms |\n\nThe exponential strategies use `RETRY_BACKOFF_MULTIPLIER` (default 2). These strategy classes exist, but the current route backend constructs `RetryExecutor` with its default `FixedBackoff`; selecting `backoff` in route JSON does not yet change the actual delays. A custom executor can receive an explicit strategy.\n\n### Retry methods, fallback, and request collapsing\n\nBy default, only GET, HEAD, and OPTIONS responses are eligible for HTTP-status retries. Set `retryMethods` to opt other methods in. Network errors currently retry regardless of `retryMethods`, so this option is not a guarantee against repeating a write after a transport failure.\n\n```json\n{\n  \"baseURL\": \"/public-catalog\",\n  \"proxy\": { \"target\": \"http://catalog-service:3001\" },\n  \"retry\": {\n    \"attempts\": 2,\n    \"delay\": 100,\n    \"retryOn\": [502, 503, 504],\n    \"retryMethods\": [\"GET\", \"HEAD\"],\n    \"collapseRequests\": true,\n    \"fallback\": {\n      \"status\": 503,\n      \"body\": { \"message\": \"Catalog temporarily unavailable\" }\n    }\n  }\n}\n```\n\n`fallback` handles thrown proxy/retry errors, such as exhausted network failures. A final HTTP response from the upstream is forwarded, even when its status is retryable; it does not activate this fallback. Circuit-breaker `fallback` is separate and applies when its guard rejects a request.\n\n`collapseRequests` shares one in-flight execution among concurrent GET, HEAD, or OPTIONS requests with the same method and URL. It is not response caching. The key excludes authorization, cookies, headers, and body, so enable it only where those differences cannot change the response, such as a public catalog. Shared requests also use the initiating execution's abort signal.\n\n### Response when all attempts fail\n\n```\nHTTP/1.1 500 Internal Server Error\nContent-Type: application/json\n\n{\n  \"error\": \"Bad Gateway\",\n  \"message\": \"Upstream returned 503\"\n}\n```\n\nThe status code mirrors the last upstream failure. The client receives no indication of how many retries occurred.\n\n### Composing with load balancing and circuit breaker\n\n```json\n{\n  \"baseURL\": \"/api\",\n  \"proxy\": {\n    \"targets\": [\n      { \"url\": \"http://api-1:3001\" },\n      { \"url\": \"http://api-2:3002\" }\n    ],\n    \"strategy\": \"round-robin\"\n  },\n  \"retry\": { \"attempts\": 2, \"delay\": 100, \"backoff\": \"exponential\" },\n  \"circuitBreaker\": { \"threshold\": 5, \"timeout\": 30000 }\n}\n```\n\nEach retry attempt selects the next target from the load balancer independently. Circuit-breaker failures are recorded on every failing attempt.\n\n> **Note:** WebSocket routes (`ws: true`) do not support retry — the connection upgrade is a one-shot handshake.\n\n---\n\n## Response Caching\n\nCache successful upstream responses in memory per route. Once cached, subsequent requests matching the same method and URL are served from the cache without hitting the upstream at all — reducing latency and upstream load for read-heavy endpoints.\n\n### Configuration\n\n```json\n{\n  \"baseURL\": \"/catalog\",\n  \"proxy\": {\n    \"target\": \"http://catalog-service:3002\",\n    \"changeOrigin\": true,\n    \"pathRewrite\": { \"^/catalog\": \"\" }\n  },\n  \"cache\": {\n    \"ttl\": 30000,\n    \"methods\": [\"GET\", \"HEAD\"],\n    \"statusCodes\": [200]\n  }\n}\n```\n\n### Options\n\n| Field | Default | Description |\n|---|---|---|\n| `ttl` | — _(required)_ | Time-to-live in milliseconds. The cached entry is evicted on the next access after TTL expires. |\n| `methods` | `[\"GET\", \"HEAD\"]` | HTTP methods to cache. |\n| `statusCodes` | `[200, 203, 204]` | Upstream status codes to cache. Non-matching responses are always forwarded without caching. |\n\n### Cache key\n\nThe cache key is `METHOD:originalURL`. Each route maintains its own independent cache store, so `/catalog` and `/catalog/1` have separate entries even when they share the same route.\n\n### Response headers\n\n| Header | Value | Description |\n|---|---|---|\n| `X-Cache` | `MISS` | First request — served from upstream and stored. |\n| `X-Cache` | `HIT` | Subsequent requests — served from cache; upstream not contacted. |\n\n```bash\n# First request — cache MISS (~200 ms upstream latency)\ncurl -si http://localhost:3000/catalog | grep X-Cache\n# X-Cache: MISS\n\n# Subsequent requests — cache HIT (< 5 ms)\ncurl -si http://localhost:3000/catalog | grep X-Cache\n# X-Cache: HIT\n\n# POST bypasses the cache (not in `methods`)\ncurl -s -X POST http://localhost:3000/catalog \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"name\":\"Widget\"}' | head -1\n```\n\n---\n\n## Prometheus Metrics\n\nThe gateway exposes a `GET /metrics` endpoint in Prometheus text format. Scrape it with any Prometheus-compatible monitoring stack (Prometheus, Grafana, Datadog Agent, etc.).\n\n```\nGET /metrics\n```\n\n### Metrics\n\n| Metric | Type | Labels | Description |\n|---|---|---|---|\n| `gateway_requests_total` | Counter | `route`, `method`, `status_code` | Total number of requests processed per route. |\n| `gateway_request_duration_seconds` | Histogram | `route`, `method` | End-to-end request latency in seconds (client → upstream → client). |\n| `gateway_upstream_errors_total` | Counter | `route`, `error_type` | Upstream errors (5xx responses or network errors) per route. |\n| `gateway_cache_hits_total` | Counter | `route` | Number of responses served from the in-memory cache per route. |\n\n### Example\n\n```bash\n# View all gateway metrics\ncurl -s http://localhost:3000/metrics | grep '^gateway_'\n\n# Filter to a specific metric\ncurl -s http://localhost:3000/metrics | grep gateway_requests_total\n# gateway_requests_total{route=\"/orders\",method=\"GET\",status_code=\"200\"} 42\n# gateway_requests_total{route=\"/orders\",method=\"POST\",status_code=\"201\"} 7\n```\n\n### Prometheus scrape config\n\n```yaml\nscrape_configs:\n  - job_name: api-gateway\n    static_configs:\n      - targets: ['localhost:3000']\n    metrics_path: /metrics\n```\n\nIf `GATEWAY_PREFIX` is set, the endpoint is available at `<GATEWAY_PREFIX>/metrics`.\n\n---\n\n## Header Transformation\n\nAdd, override, or remove individual headers on the outgoing upstream request and/or the response returned to the client — without touching any upstream code.\n\n### Configuration\n\n```json\n{\n  \"baseURL\": \"/api\",\n  \"proxy\": {\n    \"target\": \"http://api-service:3001\",\n    \"changeOrigin\": true\n  },\n  \"headers\": {\n    \"request\": {\n      \"set\": {\n        \"X-Forwarded-By\": \"api-gateway\",\n        \"X-Api-Version\": \"2\",\n        \"X-Internal-Token\": \"secret-only-upstreams-see\"\n      },\n      \"remove\": [\"User-Agent\", \"X-Powered-By\"]\n    },\n    \"response\": {\n      \"set\": {\n        \"X-Frame-Options\": \"DENY\",\n        \"Cache-Control\": \"no-store\"\n      },\n      \"remove\": [\"Server\", \"X-Powered-By\"]\n    }\n  }\n}\n```\n\n### `headers.request`\n\nApplied to every request before it is forwarded to the upstream.\n\n| Sub-field | Type | Description |\n|---|---|---|\n| `set` | `Record<string, string>` | Headers to add or override. Applied after all client headers are copied, so these always win. |\n| `remove` | `string[]` | Headers to strip before forwarding. Header names are case-insensitive. |\n\n### `headers.response`\n\nApplied to every response from the upstream before it is returned to the client.\n\n| Sub-field | Type | Description |\n|---|---|---|\n| `set` | `Record<string, string>` | Headers to add or override on the client-facing response. |\n| `remove` | `string[]` | Headers to strip from the upstream response before returning to the client. |\n\n### Common use cases\n\n- **Add an internal secret header** the upstream trusts but the client never sends.\n- **Strip `Server` / `X-Powered-By`** to avoid leaking implementation details.\n- **Inject `X-Frame-Options`** or other security headers on responses from upstreams that don't set them.\n- **Normalise versioning** with `X-Api-Version` across a mixed fleet of upstreams.\n\nHeader transforms are fully composable with all other per-route features (auth, rate limiting, caching, retry, etc.).\n\n---\n\n## Route-Level CORS Override\n\nThe global CORS policy (configured via `CORS_ORIGINS`, `CORS_METHODS`, `CORS_HEADE","readmeFilename":"README.md"}