Skip to content

fix(server): /admin subpath deployment (redirect loop, health/metrics, cookie) #118

Description

@edjcd

Summary

VITE_BASE_PATH is documented as the supported way to serve the admin panel under a URL subpath, but a subpath deployment does not currently work end-to-end. This PR fixes the three server-side issues that block it and documents the deployment shape it enables: admin panel on /admin of the same hostname as LibreChat — e.g. https://chat.example.com/ for LibreChat and https://chat.example.com/admin/ for the panel, with one hostname, one certificate, and one DNS record.

Most of the plumbing is already in place. The build, the client router, static asset serving, and metrics path normalisation all respect VITE_BASE_PATH. What breaks is the server routing layer and a few related concerns (health checks, cookie scope, OAuth callback URL).


Problem

What already works

  • vite.config.tsbase: process.env.VITE_BASE_PATH || '/'
  • src/router.tsxbasepath: import.meta.env.VITE_BASE_PATH || '/'
  • server.tsbuildStaticRoutes() serves assets at `${BASE_PATH}/${path}`
  • src/server/metrics.tsnormalizeMetricsPath() strips BASE_PATH from metric labels
  • Dockerfile exposes ARG VITE_BASE_PATH

What does not

# Issue Location
1 Redirect loop on the base path server.ts
2 /health and /metrics not mounted under BASE_PATH server.ts
3 Session cookie scoped to / instead of BASE_PATH src/server/session.ts
4 OpenID callback URL may omit BASE_PATH src/server/utils/oauth.ts
5 Build↔runtime coupling undocumented README.md, .env.example

Root cause

1. Redirect loop

routes: {
  ...(await buildStaticRoutes()),
  '/metrics': (req) => metricsResponse(req),
  '/health': () => new Response('ok'),
  ...(BASE_PATH ? { [`${BASE_PATH}`]: () => Response.redirect(`${BASE_PATH}/`, 302) } : {}),
  '/*': async (req) => { /* SSR handler */ },
}

Two problems in the [BASE_PATH] route:

  • The route registered for /admin also matches /admin/ in the Bun.serve route matcher, so the redirect target re-enters the same handler → ERR_TOO_MANY_REDIRECTS.
  • Response.redirect() receives a relative URL; the Fetch spec requires an absolute URL.

The redirect is also unnecessary: TanStack Router is configured with basepath, so /admin (no trailing slash) already resolves through the '/*' SSR handler.

2. Health and metrics unreachable behind a subpath proxy

/health and /metrics are mounted at the origin root only, while static assets are served under BASE_PATH. When a reverse proxy forwards only ${BASE_PATH}/* to the panel:

  • GET /admin/health falls through to '/*' and returns SSR HTML (status 200, body is not ok)
  • GET /health is never routed to the panel by the proxy
  • Prometheus scraping through the ingress stops working

3. Session cookie leaks to sibling apps on a shared hostname

On a shared hostname, the panel and LibreChat share an origin and cookie jar. The admin session cookie is currently emitted with Path=/, so it is sent on every LibreChat request (/api/*, uploads, SSE) even though only the panel can use it.


Proposed changes

server.ts

  • Extract base-path normalisation into a shared helper (leading slash enforced, trailing slash stripped, '/''').
  • Remove the self-matching [BASE_PATH] redirect route. Move trailing-slash canonicalisation into the '/*' handler using an explicit 308 + Location header (no relative Response.redirect()).
  • Mount /health and /metrics under BASE_PATH in addition to the existing root mounts (backwards compatible with direct container probes).
  • Warn at startup when runtime VITE_BASE_PATH does not match the value baked into the client bundle at build time.

src/server/session.ts

  • Set the session cookie Path to BASE_PATH || '/'.

src/server/utils/oauth.ts

  • Build the OpenID redirect URI with BASE_PATH prepended so the callback lands on ${BASE_PATH}/auth/openid/callback.

Docs

  • README.md: "Serving under a subpath" section — build-time/runtime coupling, nginx location example, Kubernetes Gateway API HTTPRoute example, ADMIN_PANEL_URL on the LibreChat side, probe/scrape paths.
  • .env.example: expand the VITE_BASE_PATH comment.
  • docker-compose.yml: commented VITE_BASE_PATH build arg + env pair showing both places that must agree.

No behaviour change at the default

With VITE_BASE_PATH unset, BASE_PATH is '': subpath-prefixed routes are not registered, canonicalisation is skipped, cookie path stays /. Root deployments are unaffected.


Change Type

  • Bug fix (non-breaking change which fixes an issue)
  • Documentation update

Test plan

Before (current main)

docker build -t admin-panel:subpath --build-arg VITE_BASE_PATH=/admin .
docker run --rm -p 3000:3000 \
  -e SESSION_SECRET="$(openssl rand -hex 32)" \
  -e VITE_BASE_PATH=/admin \
  -e VITE_API_BASE_URL=http://host.docker.internal:3080 \
  -e SESSION_COOKIE_SECURE=false \
  admin-panel:subpath

curl -sI http://localhost:3000/admin           # 302 loop
curl -s  http://localhost:3000/admin/health   # SSR HTML, not "ok"
curl -sI http://localhost:3000/admin/ | grep -i set-cookie   # Path=/

After

curl -sI http://localhost:3000/admin           # 308 → /admin/ (single hop)
curl -sI http://localhost:3000/admin/            # 200 text/html
curl -s  http://localhost:3000/admin/health      # ok
curl -s  http://localhost:3000/health            # ok (unchanged)
curl -s -H 'Authorization: Bearer $ADMIN_PANEL_METRICS_SECRET' \
     http://localhost:3000/admin/metrics | head
curl -sI http://localhost:3000/admin/ | grep -i set-cookie   # Path=/admin

Shared-hostname scenario

LibreChat on /, panel on /admin, reverse proxy in front:

  1. https://chat.example.com/ → LibreChat loads.
  2. https://chat.example.com/admin → single redirect to /admin/, panel loads, assets from /admin/assets/*.
  3. Email/password login → session established, no redirect loop.
  4. OpenID SSO with ADMIN_PANEL_URL=https://chat.example.com/admin → callback at /admin/auth/openid/callback, PKCE succeeds.
  5. Direct navigation to /admin/access (SSR) → renders.
  6. Admin session cookie is not sent on /api/* requests.
  7. Rebuild without VITE_BASE_PATH → root deployment unchanged.

Automated

  • src/server/utils/url.test.ts: base-path normalisation (undefined, '', /, admin, /admin, /admin/)
  • Route tests: /admin/health and /admin/metrics when BASE_PATH=/admin
  • Session tests: cookie Path when BASE_PATH is set

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions