Edge and Console Routing

HybridInference ships two long-running HTTP services: the FastAPI gateway (apps/backend) and a Next.js console (apps/frontend). Only one of them needs to be reachable from the internet.

The console owns the public path table. It is the process that terminates public traffic, and its rewrites() configuration in apps/frontend/next.config.js decides which paths are proxied on to FastAPI (or to another service) and which it answers itself. Anything not named there is served by the console’s own pages.

This is worth stating plainly because it is easy to get wrong from the outside: adding a public route is an edit to next.config.js, not to whatever reverse proxy or tunnel happens to sit in front. A path missing from that file is answered by the console’s HTML 404 page — which reads to an API client as “the gateway is down” rather than “this path is not forwarded”.

client ──▶ (your edge: CDN / tunnel / reverse proxy)
              │
              ▼
        Next.js console ──┬──▶ FastAPI gateway   (rewrites, table below)
                          ├──▶ cloud agent       (rewrites, when configured)
                          ├──▶ pgAdmin           (route handler, admin-gated)
                          └──▶ its own pages     (everything else)

The path table

The table below is generated from the rewrites() function in apps/frontend/next.config.js. That file is the source of truth; if the two disagree, the file is right. Regenerate this section when you change it.

Destination hosts come from environment variables. Next resolves rewrites() at build time and writes the result into .next/routes-manifest.json, so these are build args, not runtime environment: a value supplied only at docker run is read by nothing, and the symptom is the old table continuing to serve while every variable looks correctly set in docker inspect. See the frontend service’s build.args in deploy/docker/docker-compose.yml.

Variable

Default

Points at

BACKEND_INTERNAL_URL

http://backend:8080

the FastAPI gateway

AGENT_WEB_INTERNAL_URL

(unset)

the standalone cloud agent’s web app

AGENT_CONTROL_PLANE_INTERNAL_URL

(unset)

that agent’s control-plane API

PGADMIN_INTERNAL_URL

http://pgadmin:80

pgAdmin (used by the route handler, not a rewrite)

beforeFiles — the cloud agent proxy

Emitted only when both AGENT_WEB_INTERNAL_URL and AGENT_CONTROL_PLANE_INTERNAL_URL are set. With either unset the array is empty and /agents has no route at all.

Source

Destination

Note

/agents/api/:path*

${AGENT_CONTROL_PLANE_INTERNAL_URL}/:path*

prefix stripped — the control plane serves its routes at its own root

/agents/:path*

${AGENT_WEB_INTERNAL_URL}/agents/:path*

prefix kept — that app is built with basePath=/agents and generates links carrying it

/agents

${AGENT_WEB_INTERNAL_URL}/agents

bare prefix

Two ordering facts are load-bearing here:

  • /agents/api/:path* must precede /agents/:path*. The first is a prefix of the second, so with the order reversed every API call is answered with the web app’s HTML.

  • These are beforeFiles, not a flat array. A rewrite returned in a flat array is afterFiles, which Next checks after filesystem routes — beforeFiles keeps the proxy authoritative even if a page later appears under that prefix.

afterFiles — the gateway

Every destination below is ${BACKEND_INTERNAL_URL} plus the same path.

Source

Serves

/v1/:path*

the OpenAI-compatible API surface

/anthropic/:path*

the Anthropic Messages surface

/auth/:path*

authentication routes

/user/:path*

the user dashboard API

/admin/:path*

the admin API

/internal/verify-grafana

(see note below)

/internal/verify-admin

cookie-session admin check (used by the pgAdmin handler)

/internal/playground/:path*

the admin-only model playground the console’s dashboard calls

/internal/model-catalog

model catalog read

/internal/users/:userId/status

single-user status read

/internal/agent-grants

mint an inference grant (POST)

/internal/agent-grants/:path*

renew / usage / revoke for one grant

/health

health check

/site-updates

the public site banner

/site-config

public deployment identity, consumed by the console’s SiteConfigProvider

Note on /internal: the entries are named individually rather than forwarded as a blanket /internal/:path*, and that is deliberate. The prefix is shared — /internal/verify-* authenticate a browser session by cookie — so a blanket rule would publish whatever route lands under /internal next without anyone deciding it should be reachable from outside. /internal/agent-grants is the one exception granted a whole sub-prefix, because every route on that router carries a dispatch-token dependency at the router level and so is authorized by construction.

Note on /internal/verify-grafana: the rewrite exists, but no route matching that path is registered in apps/backend at this revision. It forwards to a gateway 404.

Why /pgadmin is a route handler and not a rewrite

/pgadmin is the interesting case, and the reason this page exists.

pgAdmin must only be reachable by an admin. A rewrite cannot authenticate — it is a static mapping evaluated before any of your code runs, with no way to call out, inspect a session, or refuse. So /pgadmin is not in the table at all. It is an app route, apps/frontend/src/app/pgadmin/[[...path]]/route.ts, which is a filesystem route and therefore wins over afterFiles rewrites. The handler proxies to pgAdmin itself, after checking the caller.

The handler is a compact worked example of an authenticated reverse proxy in a Next.js route handler, and every one of its decisions generalizes:

  • Fail closed. verifyAdmin() calls GET /internal/verify-admin on the gateway with the caller’s cookie and a 5-second timeout. Only an explicit 200 admits. A backend that is down, slow, or answering something unexpected denies. It assumes it is the only thing standing in front of a database console, because it cannot tell whether pgAdmin has its own login (that depends on PGADMIN_CONFIG_SERVER_MODE, which defaults to False).

  • Strip the console’s own session cookie before forwarding. pgAdmin has no use for it, and forwarding a session credential to a proxied app is how those leak.

  • Drop hop-by-hop headers (RFC 9110 §7.6.1) plus host and content-length, which fetch derives from the outgoing request. Drop content-encoding on the way back, because fetch already decompressed the body. Re-split Set-Cookie with getSetCookie()Headers.forEach folds repeated values into one string.

  • Fold upstream-absolute redirects back to bare paths. When a request arrives without a trailing slash a route requires, Werkzeug builds an absolute redirect from the Host it saw — here the internal container name, which resolves nowhere in a browser. foldUpstreamRedirect() rewrites those to a path, matching on hostname rather than origin so it holds whatever port is on the URL.

  • Redirect with a bare path, not an absolute URL. Behind a tunnel the app sees its own bind address as Host, so new URL('/login', request.nextUrl) renders as https://0.0.0.0:3001/login and strands the browser. The handler writes the Location header by hand, since NextResponse.redirect() accepts only absolute URLs.

The trailing-slash interaction

skipTrailingSlashRedirect: true is set globally in next.config.js, and it has to be, for this one path. Next normalizes a trailing slash away by redirecting; pgAdmin (Flask) adds one back the same way. Left on, the two bounce a browser between them forever the first time anyone opens /pgadmin/browser/. The proxied path has to reach pgAdmin exactly as the browser asked for it.

Turning that flag off globally would change every other URL on the site, so apps/frontend/src/middleware.ts reimplements the redirect for everything except the /pgadmin prefix — a 308 to the slash-less path, built from new URL(request.url) rather than nextUrl.clone() (a cloned NextURL remembers the incoming trailing slash and re-serializes it, redirecting the request to exactly where it already is).

Adding a public path

  1. Add the rule to rewrites() in apps/frontend/next.config.js. Put it in afterFiles unless it must outrank a filesystem route.

  2. Name it specifically. Prefer /prefix/thing over /prefix/:path* unless every current and future route under that prefix is authorized by construction.

  3. If the path needs a check the destination cannot make for itself, it is a route handler, not a rewrite.

  4. Rebuild the console. The rewrite table is baked into .next/routes-manifest.json at build time, so a backend-only deploy will not pick the change up. Adopting or rolling back the cloud agent proxy is likewise a rebuild, not an environment change.

  5. Regenerate the table above.