08-portals-and-i18nwave: W5filled42 citations

Web Proxy Gateway — Browser-to-API Bridge

Audiences: developer, internal

Web Proxy Gateway — Browser-to-API Bridge

Two layers serve the same goal: the Next.js proxy.ts runs 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 plus X-Gateway-* user-context headers. The browser never talks to ddx-api directly.

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.ts does 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

diagram
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:

GatewayWhen it runsStrips/addsRBAC?
proxy.tsEvery browser request (page or API)Locale, trigram, deprecated cookiesNo — only "logged in?"
app/api/v1/[[...path]]/route.tsOnly /api/v1/* browser fetchesX-API-Key, X-Gateway-* headersYes — full middleware chain

proxy.ts — pre-render gate

The proxy at ddx-web/src/proxy.ts:248-319 does five things in order:

  1. Legacy URL rewrite (:251-263): the deprecated tab-slug form /en/portal/doctor/patients/abc/vitals 301-redirects to ?tab=vitals. Slated for removal 2026-Q3 (proxy.ts:141-143).
  2. Skip non-page paths (:266-272): /_next, /api, anything with a file extension passes through unchanged.
  3. Host-based clinic resolution (:277-285): if no X-Clinic-ID is already set, extract the trigram from the Host header and call resolveTrigramToSlug (:72-113). The slug becomes X-Clinic-ID.
  4. Route classification (:288-294): protected (/portal, /dashboard, /patients, /appointments, /settings, /profile:119-126), auth (/auth/login etc. — :129-133), or public (/public/form:136-138).
  5. Session gate (:307-316): unauthenticated requests to protected routes redirect to /<locale>/auth/login?callbackUrl=...; otherwise delegate to intlMiddleware for 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:

  1. Resolves the wildcard path (:37-41).
  2. Calls auth() to get the Auth.js session; extracts clinicSlug (preferred) or organizationId (:44-47).
  3. Parses the body — JSON for normal requests, raw FormData for multipart uploads (:63-103).
  4. Builds gateway headers: X-API-Key, X-Clinic-ID (or 'platform' for SUPER_ADMIN), X-Gateway-Source, plus X-Gateway-User-{ID,Email,Role, Firstname,Lastname,Orgs,FHIR-Patient-ID,FHIR-Practitioner-ID, FHIR-Organization-ID} (:116-173).
  5. Dispatches through apiDispatcher (middleware chain: business logging, RBAC, audit — :185).
  6. 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) and getPoolHealthSummary() (:826-832) for /api/system/health.

Tech Stack & Choices

LayerChoiceWhy
Edge runtimeNext.js 16 proxy.ts (NOT middleware.ts)Project convention; creating middleware.ts silently double-mounts.
Locale routingnext-intl v4 createIntlMiddleware with localePrefix: 'always'Locale is always in the URL; deterministic.
Session validationAuth.js v5 (next-auth 5.0.0-beta.30) JWT strategyproxy.ts only checks for a session cookie (>10 bytes); route.ts decodes via auth().
API authenticationX-API-Key shared secret + X-Gateway-* user contextAvoids per-call JWT validation in NestJS hot path; gateway is the only entity that needs JWT.
Tenant tagX-Clinic-ID — slug (preferred) or UUID, 'platform' for SUPER_ADMINBackend treats slug and UUID interchangeably (route.ts:47); 'platform' is the explicit cross-tenant override.
Trigram resolutionIn-memory cache, 5-min TTL, 750ms timeout, null sentinel on missOne slow lookup must not bounce 100 RPS to the API.
Forbiddenmiddleware.ts, browser → :6100 direct, NEXT_PUBLIC_API_URL in client codeEach 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 localStorage XSS, and force CORS on 87 endpoints).
  • Trusting request.headers.get('host') raw (Host header is attacker-controlled — must go through extractTrigramFromHost per the warning at ddx-web/src/proxy.ts:31-34).
  • Middleware-based RBAC (would split policy between proxy.ts and route.ts; instead, all RBAC lives in the dispatcher and backend).

Data Flow

(1) Page render, acm.dudoxx.com/en/portal/doctor/dashboard

  1. Browser hits the URL.
  2. proxy.ts:51-57 extracts trigram acm.
  3. resolveTrigramToSlug('ACM') (:72-113) calls GET /organizations/by-trigram/ACM with X-API-Key. Cache-hit ~0ms, miss ~50ms (750ms abort). Sets X-Clinic-ID: acme-clinic for downstream.
  4. hasSessionCookie (:221-229) confirms an Auth.js v5 cookie >10 bytes.
  5. intlMiddleware resolves the locale segment.
  6. Next.js renders the page Server Component — any backendGet() inside it re-builds the full X-Gateway-* set and goes through the pool.

(2) Browser API call, POST /api/v1/visits

  1. proxy.ts short-circuits at :266-272 (path starts with /api).
  2. app/api/v1/[[...path]]/route.ts:handler runs:
    • auth() decodes the session.
    • gatewayHeaders is built with X-API-Key, X-Clinic-ID (slug or 'platform'), and 9 X-Gateway-User-* fields.
    • apiDispatcher.dispatch runs the RBAC chain.
  3. The middleware chain forwards to ddx-api via BackendPool.
  4. ddx-api's GatewayApiKeyGuard verifies X-API-Key, the GatewayUserContext interceptor materialises the user from X-Gateway-User-* headers (see [[auth-api-token]]).
  5. 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-57extractTrigramFromHost (port stripped, lowercased).
  • ddx-web/src/proxy.ts:63-65TRIGRAM_SLUG_CACHE, 5-min TTL.
  • ddx-web/src/proxy.ts:72-113resolveTrigramToSlug (750ms abort, null-on-failure, never throws).
  • ddx-web/src/proxy.ts:115-138LOCALE_PATTERN, PROTECTED_ROUTES, AUTH_ROUTES, PUBLIC_ROUTES.
  • ddx-web/src/proxy.ts:163-165 — legacy PATIENT_TAB_PATH_PATTERN (slated for 2026-Q3 removal — :141-143).
  • ddx-web/src/proxy.ts:248-319proxy() main handler.
  • ddx-web/src/proxy.ts:323-327matcher excludes /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: platform for 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-335dynamic = 'force-dynamic', runtime = 'nodejs', maxDuration = 180.
  • ddx-web/src/lib/api/backend-client.ts:30import 'server-only'; enforces server-only at bundler level.
  • ddx-web/src/lib/api/backend-client.ts:62-69getPoolBackendUrl() guarantees /api/v1 suffix.
  • ddx-web/src/lib/api/backend-client.ts:124-172buildGatewayHeaders (mirror of route-handler logic for SC consumers).
  • ddx-web/src/lib/api/backend-client.ts:180-197unwrapEnvelope (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-395pool.executeWithFailover loop.
  • ddx-web/src/lib/api/backend-client.ts:481-617backendPublicFetch for unauthenticated endpoints (invitation validation, public forms); strips caller x-next-cache-tag and converts to next.tags for revalidateTag.
  • ddx-web/src/lib/api/backend-client.ts:660-792backendFetchRaw for streaming binary downloads (PDF/DOCX exports), no pool failover.
  • ddx-web/src/lib/api/backend-pool/ — BackendPool implementation.

Operational Notes

  • Never create middleware.tsddx-web/CLAUDE.md:48-49 documents 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 stickyresolveTrigramToSlug caches null on lookup failure for 5 minutes (:97). If you've just created a new organization, hosts that hit a worker mid-cache-miss will not inject X-Clinic-ID until TTL expires. Restart the Next.js process or wait.
  • X-Clinic-ID is 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 override X-Clinic-ID to the literal 'platform' for SUPER_ADMIN (route.ts:134-139, backend-client.ts:229-232); a SUPER_ADMIN's JWT organizationId is ignored. Tenant-scoped queries on 'platform' must intentionally short-circuit to cross-tenant mode.
  • Gateway API key env varsGATEWAY_API_KEY_NEXTJS is primary; falls back to the first entry of API_KEYS (backend-client.ts:82, route.ts:116). Both must match an entry in ddx-api's API_KEYS env. Rotation = update both env files + restart both processes.
  • 204 No Content — DELETE responses go through a special branch (route.ts:206-211); attempting response.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 downloadsbackendFetchRaw (backend-client.ts:660) is the Server Component path for PDF/DOCX downloads. Route handlers under app/api/v1/documents/.../preview should 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) removes X-Frame-Options and sets Content-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-tag convention — callers pass this header to backendPublicFetch; the function strips it before forwarding and converts to Next.js next.tags so revalidateTag actually 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.
    Web Proxy Gateway — Browser-to-API Bridge — Dudoxx Docs | Dudoxx