feat(server): serve UI and API under a configurable URL path prefix (--ui-base-path)#254
feat(server): serve UI and API under a configurable URL path prefix (--ui-base-path)#254basnijholt wants to merge 2 commits into
Conversation
Add --ui-base-path (env AGENT_VAULT_UI_BASE_PATH) so the server mounts its entire surface under a prefix (e.g. /vault) behind a reverse proxy that passes the path through unmodified — no sub_filter or path rewriting. The Vite build now uses relative asset URLs (base "./") and index.html carries a <base href="/" /> placeholder that the server rewrites once at startup; the SPA derives its TanStack Router basepath and /v1 API prefix from that tag at runtime, so one published image works for any prefix. Hashed assets are never rewritten, keeping immutable caching valid. With the option unset, index.html is served byte-for-byte as built. Session cookies are scoped to the prefix, generated URLs (invites, approval links, OAuth redirects, av_addr) include it, GET / redirects to the prefix, and GET /health stays at the root for platform probes. Also fixes /fonts/ and /favicon.* never being routed (webfonts and favicons 404ed even at the root). Web tests run under vitest (npm test).
|
| Filename | Overview |
|---|---|
| internal/server/basepath.go | New file implementing base-path normalization and index.html injection; solid validation but doesn't reject HTML-unsafe characters like <, >, ". |
| internal/server/server.go | Core routing changes mount the full surface under the prefix and overwrite httpServer.Handler; the strings.HasSuffix double-append guard for baseURL is fragile. |
| internal/server/handle_auth.go | All sessionCookie call sites updated to the new method-receiver form; cookie Path now scoped to the prefix. |
| internal/server/handle_spa.go | SPA handler now reads the pre-templated indexHTML field set at startup instead of re-reading the embedded file on every request. |
| web/src/lib/basePath.ts | New utility reads <base href> from the DOM at module load and exports basePath and joinBasePath helpers. |
| web/src/lib/api.ts | Central apiFetch now prepends the base path to all root-relative API URLs. |
| cmd/server.go | New --ui-base-path flag wired through the foreground, detached, and re-exec paths; flag value normalized before use. |
| web/src/router.tsx | TanStack Router now receives the runtime-derived basepath, so in-app navigation and redirects are prefix-aware. |
| web/index.html | Adds the <base href="/" /> placeholder tag and switches favicon links to relative URLs. |
| internal/server/basepath_test.go | Comprehensive tests for normalization, injection, routing, cookie scoping, and baseURL construction. |
| docs/self-hosting/path-prefix.mdx | New doc with nginx/Caddy examples, CLI usage, and a verification checklist. |
Reviews (1): Last reviewed commit: "feat(server): serve UI and API under a c..." | Re-trigger Greptile
| if strings.ContainsAny(p, " ?#") { | ||
| return "", fmt.Errorf("invalid UI base path %q: must not contain spaces, '?' or '#'", p) | ||
| } |
There was a problem hiding this comment.
NormalizeBasePath rejects spaces, ?, and #, but not " (double-quote). Because injectBasePath concatenates basePath directly into an HTML attribute value without escaping, a path like /my"app would produce <base href="/my"app/" /> — the browser terminates the href attribute at the second ", so the injected base tag points to /my instead of the intended path and the SPA silently fails to load under the prefix.
| if strings.ContainsAny(p, " ?#") { | |
| return "", fmt.Errorf("invalid UI base path %q: must not contain spaces, '?' or '#'", p) | |
| } | |
| if strings.ContainsAny(p, " ?#\"<>") { | |
| return "", fmt.Errorf("invalid UI base path %q: must not contain spaces, '?', '#', '\"', '<', or '>'", p) | |
| } |
There was a problem hiding this comment.
Good catch — fixed in a7445ba. NormalizeBasePath now allowlists unreserved URL characters plus / instead of blacklisting, which rejects ", <, %, etc. Added test cases for them. (The value is operator-supplied config rather than request data, but the allowlist removes the splice concern entirely.)
| baseURL = strings.TrimRight(baseURL, "/") | ||
| if uiBasePath != "" && !strings.HasSuffix(baseURL, uiBasePath) { | ||
| baseURL += uiBasePath | ||
| } |
There was a problem hiding this comment.
Fragile double-append guard may silently misconfigure
baseURL
strings.HasSuffix does a raw byte comparison, not a path-segment comparison. The guard is technically correct today — after strings.TrimRight(baseURL, "/") the only way baseURL ends with uiBasePath is when the prefix is already present — but it operates on the full URL string rather than on a parsed path component, making the intent opaque and brittle for future callers. Comparing via a parsed *url.URL path would be more explicit and robust.
Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!
There was a problem hiding this comment.
The guard is byte-wise, but uiBasePath always starts with / (enforced by NormalizeBasePath) and baseURL is right-trimmed of slashes first, so a suffix match can only occur at a path-segment boundary — https://example.com/myvault does not end with /vault. Documented this on the guard in a7445ba.
…ters The previous blacklist (space, '?', '#') missed HTML-attribute-special characters like '"' and '<'; the value is spliced into the <base href> attribute, cookie paths, and redirect targets. Allowlist unreserved URL characters plus '/' instead. Also document why the baseURL HasSuffix guard can only match at a path-segment boundary.
Closes #253
Summary
Adds first-class support for serving the web UI (and
/v1API) under a URL path prefix behind a reverse proxy —agent-vault server --ui-base-path /vault(envAGENT_VAULT_UI_BASE_PATH). Today this requires a content-rewriting proxy (sub_filteron the minified bundle, hand-injected router basepath, manual cache-busting) that silently breaks on every rebuild; see #253 for the full pain.Design — path passthrough, runtime injection:
http.StripPrefix); the proxy forwards the path unmodified.X-Forwarded-Prefixis deliberately not honored — the prefix is part of the real request path.GET /redirects to the prefix;GET /healthstays at the root for platform probes (and--detach's readiness poll).base: "./");index.htmlcarries a<base href="/" />placeholder that the server rewrites once at startup (internal/server/basepath.go). The SPA reads the<base>tag at boot to set TanStack Router'sbasepathand prefix all/v1calls — no inline script, so thescript-src 'self'CSP is untouched, and one published image works for any prefix.index.htmlever varies; content-hashed assets are served byte-for-byte, keeping immutable caching valid. With the option unset,index.htmlis served byte-identical to the build output (regression-tested).Path={prefix}/;s.baseURLgets the prefix appended (no double-append) so invite emails, proposal approval links, OAuth redirect/redirect_uri, and theav_addrhanded to agents all include it. The CLI already joins paths onto--address, so--address https://host/vaultworks as-is. The MITM ingress (separate listener) is unaffected.Drive-by fix: the mux never routed
/fonts/or/favicon.*— webfonts and favicons 404ed even at the domain root. Now served.Most of the diff is tests, docs, and
package-lock.jsonchurn from adding vitest; the runtime change is ~130 lines of Go and ~40 lines of TS. Happy to split the vitest setup or the static-file fix into separate PRs if preferred.Type of change
/fonts/,/favicon.*404)Test plan
make test— full suite with-race -count=1and CI's stubbed-webdist setup;golangci-lintv2.11 clean for the changed code;go mod tidyno-op;npm ci && npm run buildgreen)NormalizeBasePathvalidation,index.htmlinjection (incl. root-mode byte-identity), full-stack routing under the prefix (API + SPA + redirects + unprefixed-404 + root/health), cookiePathon login/logout, baseURL prefix-append;--ui-base-pathflag smoke test incmd/cmd_test.gonpm testinweb/)Ran the built binary with
--ui-base-path /vaultin a throwaway$HOMEand verified with curl/CLI: index and deep SPA routes serve<base href="/vault/">; hashed JS/CSS, favicons, and fonts return 200 under the prefix;/→ 302/vault/; unprefixed/v1/status→ 404; register → login setsPath=/vault/; vault list, credential set/list/delete, and scoped-token minting all work; agent-mode CLI works withAGENT_VAULT_ADDR=http://…/vault(vault discover);av_addrcomes back with the prefix. Root-mode and Vite dev server regression-checked the same way. Docs include a rewrite-free nginx location block (docs/self-hosting/path-prefix.mdx) and a deployment verification checklist.Note for operators: OAuth apps registered against a previously root-mounted instance need their redirect URI updated to include the prefix, since
redirect_uriderives from the (now prefixed) base URL.Security checklist
/health, which already existed)NormalizeBasePathrejects empty/./..segments and?/#/whitespace; the value is operator-supplied config, not request data)<base href>is built from the validated operator flag only, never from request input, so no header/URL-driven base-tag injection; CSP unchanged (script-src 'self', no inline script); cookie scoping tightened to the prefixAI disclaimer
I used Claude Fable 5 for help with the development.