Routing & Proxy Rules
The gateway maps all /api/v1/<resource> routes to the appropriate downstream service using proxy.To(serviceURL). No business logic lives in the gateway — it's pure routing configuration.
Route Mounting
Every downstream service is mounted at a specific prefix. The gateway strips its own host and forwards the request path intact:
// Simplified routing structure
r.Mount("/api/v1/passages", proxy.To(cfg.ContentServiceURL))
r.Mount("/api/v1/lexicon", proxy.To(cfg.ContentServiceURL))
r.Mount("/api/v1/topics", proxy.To(cfg.ContentServiceURL))
r.Mount("/api/v1/connections", proxy.To(cfg.ContentServiceURL))
r.Mount("/api/v1/search", proxy.To(cfg.ContentServiceURL))
r.Mount("/api/v1/witnesses", proxy.To(cfg.ContentServiceURL))
r.Mount("/api/v1/ai", proxy.To(cfg.AIServiceURL))
r.Mount("/api/v1/billing", proxy.To(cfg.BillingServiceURL))
r.Mount("/api/v1/users", proxy.To(cfg.AuthServiceURL))
r.Mount("/api/v1/notifications", proxy.To(cfg.NotificationsServiceURL))
Route Groups
Routes are organized into two groups based on authentication requirements:
Public Routes (No Auth)
These routes bypass JWT validation entirely:
r.Group(func(r chi.Router) {
r.Get("/health", health.Handler)
r.Get("/ready", health.ReadyHandler)
r.Post("/api/v1/auth/webhook", proxy.To(cfg.AuthServiceURL))
r.Post("/api/v1/billing/webhook", proxy.To(cfg.BillingServiceURL))
})
- Health checks — Required by load balancers and Kubernetes probes
- Webhook endpoints — Clerk and Stripe send payloads directly; they validate via their own signature mechanisms, not JWTs
Authenticated Routes
All other routes require a valid JWT:
r.Group(func(r chi.Router) {
r.Use(authmw.ValidateJWT(cfg))
r.Use(authmw.InjectUserClaims)
// Content routes
r.Mount("/api/v1/passages", proxy.To(cfg.ContentServiceURL))
// ... other content routes
// Plan-gated routes
r.Group(func(r chi.Router) {
r.Use(entitlement.Require("ai_features"))
r.Mount("/api/v1/ai", proxy.To(cfg.AIServiceURL))
})
})
Entitlement Middleware
Some routes are gated behind subscription plans. The gateway checks entitlements via a Redis cache lookup — never a synchronous service call:
func Require(feature string) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
plan := r.Header.Get("X-User-Plan")
cacheKey := fmt.Sprintf("gl:entitlements:%s:%s", plan, feature)
allowed := entitlementCache.Get(cacheKey) // Redis GET, O(1)
if !allowed {
render.JSON(w, r, ErrForbidden)
return
}
next.ServeHTTP(w, r)
})
}
}
Entitlement definitions live in the billing service but are cached in Redis with a 60-second TTL, so the gateway never calls the billing service in the hot path.
Plan-Gated Routes
| Route Prefix | Required Entitlement | Available In |
|---|---|---|
/api/v1/ai/* | ai_features | Scholar, Academic |
/api/v1/witnesses/* | manuscript_witnesses | Scholar, Academic |
Header Injection
The gateway injects headers into every proxied request:
| Header | Source | Purpose |
|---|---|---|
X-Request-ID | Generated by RequestID middleware | Distributed tracing correlation |
X-User-Id | Extracted from validated JWT claims | User identification for downstream services |
X-User-Plan | Extracted from validated JWT claims | Plan tier for entitlement checks |
Rate Limiting Per Route
Rate limits vary by endpoint and plan tier:
| Endpoint | Free Tier | Paid Tier |
|---|---|---|
/api/v1/search/* | 20/min | 200/min |
/api/v1/passages/* | 60/min | 600/min |
/api/v1/lexicon/* | 40/min | 400/min |
/api/v1/ai/* | 5/hour | 50/hour |
Rate limit state is stored in Redis with key format: gl:ratelimit:<userId>:<endpoint>:<window>.
Related Pages
- Gateway Overview — service overview and environment variables
- Middleware Stack — the full middleware chain
- Architecture > Security > Entitlements — plan tiers and feature flags