Web Proxy Gateway — Browser-to-API Bridge
Audiences: developer, internal
Two layers serve the same goal: the Next.js
proxy.tsruns once per browser request (locale + trigram + session gate), then every/api/v1/*call is intercepted by a catch-all Route Handler that re-authenticates with NestJS using an API key plusX-Gateway-*user-context headers. The browser never talks toddx-apidirectly.
Business Purpose
The browser is the most attacker-controlled surface in the stack. Letting it
speak JWTs directly to NestJS — across 87 domain API clients, 11 role portals,
and three locales — multiplies the rotation, audit, and CORS footprint by an
order of magnitude. The Trusted Gateway Pattern collapses that to a single
seam: one Next.js process holds the gateway API key, one Route Handler
classifies every request, one set of X-Gateway-* headers carries identity.
The browser only ever sees https://acm.dudoxx.com/api/v1/... on the same
origin — no CORS, no token in localStorage, no per-call JWT to leak.
Two practical wins: tenant routing is automatic (the trigram subdomain acm.
becomes X-Clinic-ID: acme-clinic), and we can re-point, load-balance, or
circuit-break the NestJS pool without browser deploys (see
[[ha-strategy]]).
Audiences
- Investor: a single Next.js gateway is one-tenth the auth surface of "every service exposes its own JWT endpoint". It's how 11 portals, 3 locales, and 39 subprojects share one identity story without ten OWASP risks per release.
- Clinical buyer: when the doctor logs in to
acm.dudoxx.com, every browser call carries the clinic tag automatically — there is no scenario where a clinic-A user can accidentally fetch clinic-B data because the gateway re-derives the tenant on every request, not from a cookie. - Developer/partner: explains the two distinct gateways (proxy vs Route
Handler), why
middleware.tsdoes not exist, how the BackendPool absorbs failover, and where to add user-context headers. - Internal (ops/support): documents the trigram regex, the 5-minute slug cache, the gateway API key, and the pool failure modes for on-call.
Architecture
Two gateways, not one
Browser │ (1) request to https://acm.dudoxx.com/en/portal/doctor/patients ▼ proxy.ts ← Next.js Edge/Node middleware │ • locale routing (next-intl) │ • trigram extraction + slug resolution → X-Clinic-ID │ • session-cookie gate (Auth.js v5) │ • cleanup of deprecated NextAuth v4 cookies ▼ Next.js page render (Server Component) ← may call backendGet() directly │ │ (2) browser later POSTs to /api/v1/patients ▼ app/api/v1/[[...path]]/route.ts ← catch-all Route Handler │ • RBAC dispatch (apiDispatcher + middleware chain) │ • build X-Gateway-* headers from session │ • forward through BackendPool to ddx-api ▼ ddx-api (NestJS 11, port 6100) │ • verifies X-API-Key │ • trusts X-Gateway-User-* claims │ • final RBAC + tenant authorization
The two gateways have non-overlapping responsibilities:
| Gateway | When it runs | Strips/adds | RBAC? |
|---|---|---|---|
proxy.ts | Every browser request (page or API) | Locale, trigram, deprecated cookies | No — only "logged in?" |
app/api/v1/[[...path]]/route.ts | Only /api/v1/* browser fetches | X-API-Key, X-Gateway-* headers | Yes — full middleware chain |
proxy.ts — pre-render gate
The proxy at ddx-web/src/proxy.ts:248-319 does five things in order:
- Legacy URL rewrite (
:251-263): the deprecated tab-slug form/en/portal/doctor/patients/abc/vitals301-redirects to?tab=vitals. Slated for removal 2026-Q3 (proxy.ts:141-143). - Skip non-page paths (
:266-272):/_next,/api, anything with a file extension passes through unchanged. - Host-based clinic resolution (
:277-285): if noX-Clinic-IDis already set, extract the trigram from theHostheader and callresolveTrigramToSlug(:72-113). The slug becomesX-Clinic-ID. - Route classification (
:288-294): protected (/portal,/dashboard,/patients,/appointments,/settings,/profile—:119-126), auth (/auth/loginetc. —:129-133), or public (/public/form—:136-138). - Session gate (
:307-316): unauthenticated requests to protected routes redirect to/<locale>/auth/login?callbackUrl=...; otherwise delegate tointlMiddlewarefor locale handling.
The trigram regex is strict (ddx-web/src/proxy.ts:35): ^([a-z]{3}[0-9]?)\.dudoxx\.[a-z]+$.
This means localhost, dudoxx.com (apex), www.dudoxx.com, and
acme.dudoxx.com (4 letters) never inject a header — they fall through to
the explicit X-Clinic-ID that app/api/v1/[[...path]]/route.ts derives from
the session.
app/api/v1/[[...path]]/route.ts — per-API-call gate
The catch-all Route Handler at ddx-web/src/app/api/v1/[[...path]]/route.ts:32-282:
- Resolves the wildcard path (
:37-41). - Calls
auth()to get the Auth.js session; extractsclinicSlug(preferred) ororganizationId(:44-47). - Parses the body — JSON for normal requests, raw
FormDatafor multipart uploads (:63-103). - Builds gateway headers:
X-API-Key,X-Clinic-ID(or'platform'forSUPER_ADMIN),X-Gateway-Source, plusX-Gateway-User-{ID,Email,Role, Firstname,Lastname,Orgs,FHIR-Patient-ID,FHIR-Practitioner-ID, FHIR-Organization-ID}(:116-173). - Dispatches through
apiDispatcher(middleware chain: business logging, RBAC, audit —:185). - Pass-through handling: 204 No Content (
:206-211), SSE streams (:213-219), binary bodies (:227-253), JSON otherwise (:255-258).
Server-side equivalents — backendFetch
Server Components call backendGet/Post/Patch/Put/Delete from
ddx-web/src/lib/api/backend-client.ts:419-464 — these wrap backendFetch
(:208-414), which builds the same gateway headers (buildGatewayHeaders,
:124-172) and runs through pool.executeWithFailover (:244-395). Because
the function is 'server-only' (:30), the gateway API key never leaks to a
client bundle.
The pool unwraps the global NestJS ResponseTransformInterceptor envelope
(unwrapEnvelope, :180-197) so Server Components see the inner data
shape, plus pagination/extras when present.
BackendPool — load balancing + failover
getPool() from lib/api/backend-pool/ (re-exported at
ddx-web/src/lib/api/backend-client.ts:814) is a singleton with:
- Weighted round-robin node selection (
getBackendUrl()returns the chosen base URL). - Fast (5s) and full (30s) health checks.
- Per-node circuit breaker — failed nodes are isolated and re-checked.
- Automatic failover with retry via
executeWithFailover(each retry attempts a different healthy node). - Metrics via
getPoolMetrics()(:819-821) andgetPoolHealthSummary()(:826-832) for/api/system/health.
Tech Stack & Choices
| Layer | Choice | Why |
|---|---|---|
| Edge runtime | Next.js 16 proxy.ts (NOT middleware.ts) | Project convention; creating middleware.ts silently double-mounts. |
| Locale routing | next-intl v4 createIntlMiddleware with localePrefix: 'always' | Locale is always in the URL; deterministic. |
| Session validation | Auth.js v5 (next-auth 5.0.0-beta.30) JWT strategy | proxy.ts only checks for a session cookie (>10 bytes); route.ts decodes via auth(). |
| API authentication | X-API-Key shared secret + X-Gateway-* user context | Avoids per-call JWT validation in NestJS hot path; gateway is the only entity that needs JWT. |
| Tenant tag | X-Clinic-ID — slug (preferred) or UUID, 'platform' for SUPER_ADMIN | Backend treats slug and UUID interchangeably (route.ts:47); 'platform' is the explicit cross-tenant override. |
| Trigram resolution | In-memory cache, 5-min TTL, 750ms timeout, null sentinel on miss | One slow lookup must not bounce 100 RPS to the API. |
| Forbidden | middleware.ts, browser → :6100 direct, NEXT_PUBLIC_API_URL in client code | Each would break the gateway invariant. |
Rejected:
- Per-call JWT to NestJS (would force NestJS to revalidate signatures on every request; the API key short-circuits that).
- CORS-allowed direct browser → NestJS (would expose JWTs to
localStorageXSS, and force CORS on 87 endpoints). - Trusting
request.headers.get('host')raw (Host header is attacker-controlled — must go throughextractTrigramFromHostper the warning atddx-web/src/proxy.ts:31-34). - Middleware-based RBAC (would split policy between
proxy.tsandroute.ts; instead, all RBAC lives in the dispatcher and backend).
Data Flow
(1) Page render, acm.dudoxx.com/en/portal/doctor/dashboard
- Browser hits the URL.
proxy.ts:51-57extracts trigramacm.resolveTrigramToSlug('ACM')(:72-113) callsGET /organizations/by-trigram/ACMwithX-API-Key. Cache-hit ~0ms, miss ~50ms (750ms abort). SetsX-Clinic-ID: acme-clinicfor downstream.hasSessionCookie(:221-229) confirms an Auth.js v5 cookie >10 bytes.intlMiddlewareresolves the locale segment.- Next.js renders the page Server Component — any
backendGet()inside it re-builds the fullX-Gateway-*set and goes through the pool.
(2) Browser API call, POST /api/v1/visits
proxy.tsshort-circuits at:266-272(path starts with/api).app/api/v1/[[...path]]/route.ts:handlerruns:auth()decodes the session.gatewayHeadersis built withX-API-Key,X-Clinic-ID(slug or'platform'), and 9X-Gateway-User-*fields.apiDispatcher.dispatchruns the RBAC chain.
- The middleware chain forwards to ddx-api via
BackendPool. - ddx-api's
GatewayApiKeyGuardverifiesX-API-Key, theGatewayUserContextinterceptor materialises the user fromX-Gateway-User-*headers (see [[auth-api-token]]). - Response: 204 → no body; SSE → streaming; binary (PDF/PNG/audio/video) → pass-through; otherwise JSON envelope.
(3) SSE subscription
Standalone Route Handlers under ddx-web/src/app/api/sse/ open a streaming
connection to ddx-api with Cache-Control: no-cache, no-transform,
Connection: keep-alive, X-Accel-Buffering: no (cf. the ddx-deepsearch
catch-all at ddx-deepsearch/src/app/api/v1/[...path]/route.ts:87-92 — same
pattern). The gateway preserves the original text/event-stream
content type.
Implicated Code
ddx-web/src/proxy.ts:35— strict trigram regex (apex/www/non-3-letter rejected).ddx-web/src/proxy.ts:51-57—extractTrigramFromHost(port stripped, lowercased).ddx-web/src/proxy.ts:63-65—TRIGRAM_SLUG_CACHE, 5-min TTL.ddx-web/src/proxy.ts:72-113—resolveTrigramToSlug(750ms abort,null-on-failure, never throws).ddx-web/src/proxy.ts:115-138—LOCALE_PATTERN,PROTECTED_ROUTES,AUTH_ROUTES,PUBLIC_ROUTES.ddx-web/src/proxy.ts:163-165— legacyPATIENT_TAB_PATH_PATTERN(slated for 2026-Q3 removal —:141-143).ddx-web/src/proxy.ts:248-319—proxy()main handler.ddx-web/src/proxy.ts:323-327—matcherexcludes/api,/_next/static,/_next/image,/favicon.ico, and any path with a literal..ddx-web/src/app/api/v1/[[...path]]/route.ts:32-282— catch-all Route Handler.ddx-web/src/app/api/v1/[[...path]]/route.ts:44-47— clinic ID preference (slug over UUID).ddx-web/src/app/api/v1/[[...path]]/route.ts:116-173— gateway header construction;X-Clinic-ID: platformfor SUPER_ADMIN (:134-139).ddx-web/src/app/api/v1/[[...path]]/route.ts:213-219— SSE pass-through.ddx-web/src/app/api/v1/[[...path]]/route.ts:227-253— binary pass-through (PDF, images, downloads).ddx-web/src/app/api/v1/[[...path]]/route.ts:331-335—dynamic = 'force-dynamic',runtime = 'nodejs',maxDuration = 180.ddx-web/src/lib/api/backend-client.ts:30—import 'server-only';enforces server-only at bundler level.ddx-web/src/lib/api/backend-client.ts:62-69—getPoolBackendUrl()guarantees/api/v1suffix.ddx-web/src/lib/api/backend-client.ts:124-172—buildGatewayHeaders(mirror of route-handler logic for SC consumers).ddx-web/src/lib/api/backend-client.ts:180-197—unwrapEnvelope(handles the{meta,data,pagination}ResponseTransformInterceptor shape).ddx-web/src/lib/api/backend-client.ts:229-235— SUPER_ADMIN clinic ID override.ddx-web/src/lib/api/backend-client.ts:244-395—pool.executeWithFailoverloop.ddx-web/src/lib/api/backend-client.ts:481-617—backendPublicFetchfor unauthenticated endpoints (invitation validation, public forms); strips callerx-next-cache-tagand converts tonext.tagsforrevalidateTag.ddx-web/src/lib/api/backend-client.ts:660-792—backendFetchRawfor streaming binary downloads (PDF/DOCX exports), no pool failover.ddx-web/src/lib/api/backend-pool/— BackendPool implementation.
Operational Notes
- Never create
middleware.ts—ddx-web/CLAUDE.md:48-49documents the project convention. Next.js 16 picks up both files and double-mounts; the symptom is two locale redirects per page load. - Trigram cache miss is sticky —
resolveTrigramToSlugcachesnullon lookup failure for 5 minutes (:97). If you've just created a new organization, hosts that hit a worker mid-cache-miss will not injectX-Clinic-IDuntil TTL expires. Restart the Next.js process or wait. X-Clinic-IDis advisory — the proxy only injects when the trigram resolves; the catch-all Route Handler always sets it from the session. The backend re-derives from the JWT — never trust either header in a cross-tenant check.- SUPER_ADMIN clinic-id is
'platform'— both gateways overrideX-Clinic-IDto the literal'platform'for SUPER_ADMIN (route.ts:134-139,backend-client.ts:229-232); a SUPER_ADMIN's JWTorganizationIdis ignored. Tenant-scoped queries on'platform'must intentionally short-circuit to cross-tenant mode. - Gateway API key env vars —
GATEWAY_API_KEY_NEXTJSis primary; falls back to the first entry ofAPI_KEYS(backend-client.ts:82,route.ts:116). Both must match an entry in ddx-api'sAPI_KEYSenv. Rotation = update both env files + restart both processes. - 204 No Content — DELETE responses go through a special branch
(
route.ts:206-211); attemptingresponse.json()on a 204 from a client crashes. Domain API clients must treat 204 as success. - maxDuration: 180 (
route.ts:335) — covers OCR ingestion, AI document generation, and long FHIR bundle uploads. Increase carefully — Vercel Pro caps at 300 (irrelevant here, self-hosted) but PM2 timeouts may bite. - Binary downloads —
backendFetchRaw(backend-client.ts:660) is the Server Component path for PDF/DOCX downloads. Route handlers underapp/api/v1/documents/.../previewshould pass through binary bodies via the catch-all (:227-253) — never JSON-stringify a Buffer. - iframe-embedded document preview — ddx-deepsearch's catch-all
(
ddx-deepsearch/src/app/api/v1/[...path]/route.ts:99-108) removesX-Frame-Optionsand setsContent-Security-Policy: frame-ancestors 'self'for PDF/image/audio/video content types so the in-app viewer can embed. Replicate the same exception in any future viewer. x-next-cache-tagconvention — callers pass this header tobackendPublicFetch; the function strips it before forwarding and converts to Next.jsnext.tagssorevalidateTagactually wires up (backend-client.ts:497-519). Forgetting this makes cache invalidation a silent no-op.- No CORS configuration anywhere — every browser fetch is same-origin
(
acm.dudoxx.com → /api/v1/*). If you ever see a CORS error in dev tools, someone added a direct backend URL — fail the PR.
Related Topics
- Multi-Portal Architecture — the 11-portal shell that sits on top of this gateway.
- i18n — Multi-Lingual Interface — how
next-intl'slocalePrefix: 'always'interacts with the proxy. - Auth — JWT — session token
generation (Auth.js v5) and the JWT shape that lands in
X-Gateway-User-*. - Auth — API Token — the
X-API-Keyhalf of the Trusted Gateway Pattern. - Tenant Isolation — what
the backend does with
X-Clinic-ID. - SSE Event Engine — the
streaming path that uses the same gateway with
text/event-streamheaders. - HA Strategy — BackendPool node topology, health checks, and circuit breaker tuning.