diff --git a/README.md b/README.md index 33fb32c..b684d6c 100644 --- a/README.md +++ b/README.md @@ -135,7 +135,7 @@ Billtap can run behind a shared browser origin such as `https://localhost:8081/billtap` while keeping internal service-to-service calls on the unprefixed container URL, such as `http://billtap:8080`. -Set one of these before building or starting Billtap: +Set one of these before starting Billtap: ```bash PUBLIC_BASE_PATH=/billtap @@ -164,6 +164,9 @@ calls are prefix-aware: /billtap/v1/customers ``` +The published GHCR image is runtime-prefix safe. You do not need to rebuild the +frontend for each mount path. + ## Fixture And Assertion APIs Billtap includes local integration-test helpers: diff --git a/docs/decisions/0003-public-base-path.md b/docs/decisions/0003-public-base-path.md index 83c5bd1..01a75bc 100644 --- a/docs/decisions/0003-public-base-path.md +++ b/docs/decisions/0003-public-base-path.md @@ -38,10 +38,11 @@ use URLs such as `http://billtap:8080/v1`. ## Consequences -The Vite app is built with `/app/` under the public base path, so assets resolve -without proxy rewrites. Server routes accept both prefixed and unprefixed paths -when a configured public base path is present, which preserves internal compose -traffic while allowing browser requests under the prefix. +The Vite app uses relative static asset paths and derives the runtime public +prefix from the current browser URL. The published image therefore does not need +to be rebuilt for each mount path. Server routes accept both prefixed and +unprefixed paths when a configured public base path is present, which preserves +internal compose traffic while allowing browser requests under the prefix. The public base path is validated as a URL path. Full URLs, query strings, fragments, dot path segments, and empty path segments are rejected. diff --git a/docs/runbooks/local-dev.md b/docs/runbooks/local-dev.md index ac2720d..0173e1d 100644 --- a/docs/runbooks/local-dev.md +++ b/docs/runbooks/local-dev.md @@ -58,7 +58,9 @@ go run ./cmd/billtap `BILLTAP_PUBLIC_BASE_PATH` is also supported and takes precedence over `PUBLIC_BASE_PATH` when a multi-app stack needs a Billtap-specific override. -The path must be a URL path, not a full URL. +The path must be a URL path, not a full URL. Published container images are +runtime-prefix safe, so the same image can be mounted at `/`, `/billtap`, or a +different proxy path without rebuilding frontend assets. With `PUBLIC_BASE_PATH=/billtap`, browser-facing paths are: diff --git a/internal/server/server.go b/internal/server/server.go index 4cd9a35..3a45ce2 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -168,9 +168,6 @@ func (s *Server) handleHostedCheckout(w http.ResponseWriter, r *http.Request) { methodNotAllowed(w) return } - if s.cfg.StaticDir != "" && serveFileIfExists(w, r, filepath.Join(s.cfg.StaticDir, "checkout", "index.html")) { - return - } sessionID := strings.Trim(strings.TrimPrefix(strings.TrimPrefix(r.URL.Path, "/checkout"), "/"), "/") target := s.prefixedPath(r, "/app/checkout/") if sessionID != "" { @@ -186,9 +183,6 @@ func (s *Server) handleHostedPortal(w http.ResponseWriter, r *http.Request) { methodNotAllowed(w) return } - if s.cfg.StaticDir != "" && serveFileIfExists(w, r, filepath.Join(s.cfg.StaticDir, "portal", "index.html")) { - return - } customerID := strings.Trim(strings.TrimPrefix(strings.TrimPrefix(r.URL.Path, "/portal"), "/"), "/") target := s.prefixedPath(r, "/app/portal/") if customerID != "" { diff --git a/internal/server/server_test.go b/internal/server/server_test.go index 7ed249e..84cf33e 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -184,6 +184,16 @@ func TestBuiltReactAppServing(t *testing.T) { t.Fatalf("%s status = %d, want %d", path, rec.Code, http.StatusOK) } } + + req := httptest.NewRequest(http.MethodGet, "/checkout?session_id=cs_test_123", nil) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + if rec.Code != http.StatusFound { + t.Fatalf("built checkout status = %d, want %d", rec.Code, http.StatusFound) + } + if got := rec.Header().Get("Location"); got != "/app/checkout/?session_id=cs_test_123" { + t.Fatalf("built checkout Location = %q, want app redirect", got) + } } func TestPublicBasePathPrefixesAPISessionURLs(t *testing.T) { diff --git a/tests/web-smoke/smoke-web.mjs b/tests/web-smoke/smoke-web.mjs index 56b156c..6857f4e 100644 --- a/tests/web-smoke/smoke-web.mjs +++ b/tests/web-smoke/smoke-web.mjs @@ -47,6 +47,7 @@ async function main() { try { await waitForReady(`${baseURL}/healthz`, server); const seed = await seedBillingData(baseURL); + await verifyHostedRedirect(seed.checkoutSession.url, withPublicBasePath(`/app/checkout/?session_id=${encodeURIComponent(seed.checkoutSession.id)}`)); const checks = smokeChecks(seed); await runBrowserSmoke(baseURL, checks); console.log(`web smoke passed: ${checks.map((check) => check.path).join(", ")}`); @@ -126,6 +127,21 @@ async function seedBillingData(baseURL) { return { customer, product, price, checkoutSession, portalSession }; } +async function verifyHostedRedirect(url, expectedPathAndQuery) { + const response = await fetch(url, { redirect: "manual" }); + if (response.status !== 302) { + throw new Error(`${url} returned ${response.status}, want hosted redirect`); + } + const location = response.headers.get("location"); + if (!location) { + throw new Error(`${url} did not return a Location header`); + } + const resolved = new URL(location, url); + if (`${resolved.pathname}${resolved.search}` !== expectedPathAndQuery) { + throw new Error(`${url} redirected to ${resolved.pathname}${resolved.search}, want ${expectedPathAndQuery}`); + } +} + async function postForm(url, values) { const body = new URLSearchParams(); for (const [key, value] of Object.entries(values)) { diff --git a/vite.config.ts b/vite.config.ts index b3bdeed..eb68933 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -6,7 +6,6 @@ import { defineConfig, type Plugin } from "vite"; const appRoutes = new Set(["checkout", "dashboard", "portal"]); const projectRoot = fileURLToPath(new URL(".", import.meta.url)); const publicBasePath = normalizePublicBasePath(firstNonEmpty(process.env.BILLTAP_PUBLIC_BASE_PATH, process.env.PUBLIC_BASE_PATH)); -const appBasePath = joinBasePath(publicBasePath, "/app/"); function appPathDevFallback(): Plugin { return { @@ -19,9 +18,9 @@ function appPathDevFallback(): Plugin { const match = appPath.match(/^\/app\/(checkout|dashboard|portal)\/?$/); if (match && appRoutes.has(match[1])) { - req.url = `${appBasePath}${match[1]}/index.html${query ? `?${query}` : ""}`; + req.url = `/${match[1]}/index.html${query ? `?${query}` : ""}`; } else if (appPath === "/app/" || appPath === "/app") { - req.url = `${appBasePath}dashboard/index.html${query ? `?${query}` : ""}`; + req.url = `/dashboard/index.html${query ? `?${query}` : ""}`; } next(); @@ -44,12 +43,6 @@ function normalizePublicBasePath(value: string): string { return withLeading.replace(/\/+$/, ""); } -function joinBasePath(basePath: string, path: string): string { - const normalizedPath = path.startsWith("/") ? path : `/${path}`; - if (!basePath) return normalizedPath; - return `${basePath}${normalizedPath}`; -} - function stripBasePath(pathname: string, basePath: string): string { if (!basePath) return pathname; if (pathname === basePath) return "/"; @@ -59,7 +52,7 @@ function stripBasePath(pathname: string, basePath: string): string { export default defineConfig({ root: "web", - base: appBasePath, + base: "./", plugins: [react(), appPathDevFallback()], server: { host: "127.0.0.1", diff --git a/web/checkout/index.html b/web/checkout/index.html index f71b735..9a41a00 100644 --- a/web/checkout/index.html +++ b/web/checkout/index.html @@ -3,7 +3,7 @@
- +