Skip to main content

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 PrefixRequired EntitlementAvailable In
/api/v1/ai/*ai_featuresScholar, Academic
/api/v1/witnesses/*manuscript_witnessesScholar, Academic

Header Injection

The gateway injects headers into every proxied request:

HeaderSourcePurpose
X-Request-IDGenerated by RequestID middlewareDistributed tracing correlation
X-User-IdExtracted from validated JWT claimsUser identification for downstream services
X-User-PlanExtracted from validated JWT claimsPlan tier for entitlement checks

Rate Limiting Per Route

Rate limits vary by endpoint and plan tier:

EndpointFree TierPaid Tier
/api/v1/search/*20/min200/min
/api/v1/passages/*60/min600/min
/api/v1/lexicon/*40/min400/min
/api/v1/ai/*5/hour50/hour

Rate limit state is stored in Redis with key format: gl:ratelimit:<userId>:<endpoint>:<window>.