From 945014d3596d9a2cf4fb1aaef40a3a6702af0867 Mon Sep 17 00:00:00 2001 From: midagedev Date: Wed, 13 May 2026 14:35:27 +0900 Subject: [PATCH] Support public base path deployment --- README.md | 35 +++++ docs/COMPATIBILITY.md | 7 +- docs/README.md | 1 + docs/RELEASE_CHECKLIST.md | 5 + docs/decisions/0003-public-base-path.md | 47 +++++++ docs/runbooks/local-dev.md | 40 ++++++ internal/api/api.go | 32 ++++- internal/config/config.go | 49 +++++++ internal/config/config_test.go | 19 ++- internal/server/server.go | 86 +++++++++++- internal/server/server_test.go | 166 ++++++++++++++++++++++++ specs/000-product/tasks.md | 1 + tests/web-smoke/smoke-web.mjs | 19 ++- vite.config.ts | 40 +++++- web/checkout/index.html | 2 +- web/checkout/main.tsx | 3 +- web/dashboard/index.html | 2 +- web/dashboard/main.tsx | 5 +- web/portal/index.html | 2 +- web/portal/main.tsx | 5 +- web/shared/api.ts | 3 +- web/shared/basePath.ts | 24 ++++ web/shared/components.tsx | 9 +- web/shared/data.ts | 4 +- 24 files changed, 568 insertions(+), 38 deletions(-) create mode 100644 docs/decisions/0003-public-base-path.md create mode 100644 web/shared/basePath.ts diff --git a/README.md b/README.md index eeddefd..33fb32c 100644 --- a/README.md +++ b/README.md @@ -129,6 +129,41 @@ Image tags: Images are published for `linux/amd64` and `linux/arm64`. +## Reverse Proxy Base Path + +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: + +```bash +PUBLIC_BASE_PATH=/billtap +# or, to override only this app when a shared stack sets PUBLIC_BASE_PATH: +BILLTAP_PUBLIC_BASE_PATH=/billtap +``` + +Set `BILLTAP_PUBLIC_BASE_URL` to the browser-visible origin without the path +prefix when Billtap generates hosted checkout and portal URLs: + +```bash +BILLTAP_PUBLIC_BASE_URL=https://localhost:8081 \ +PUBLIC_BASE_PATH=/billtap \ +go run ./cmd/billtap +``` + +The server also honors `X-Forwarded-Prefix`, so a proxy can strip `/billtap` +before forwarding while Billtap still generates prefixed `Location` headers and +hosted URLs. Browser links, static assets, dashboard API calls, and Stripe-like +calls are prefix-aware: + +```text +/billtap/app/dashboard/ +/billtap/app/assets/... +/billtap/api/diagnostics +/billtap/v1/customers +``` + ## Fixture And Assertion APIs Billtap includes local integration-test helpers: diff --git a/docs/COMPATIBILITY.md b/docs/COMPATIBILITY.md index ca4c7a1..e55f4ae 100644 --- a/docs/COMPATIBILITY.md +++ b/docs/COMPATIBILITY.md @@ -87,7 +87,10 @@ documentation before it counts as implemented. ## Supported Stripe-Like API Subset -Base path: `/v1` +Base path: `/v1`. When `PUBLIC_BASE_PATH` or `BILLTAP_PUBLIC_BASE_PATH` is set, +or a proxy sends `X-Forwarded-Prefix`, the same API is available below that +browser-facing prefix, such as `/billtap/v1`. Internal service traffic can keep +using the unprefixed service URL. | Resource | Endpoints | Level | Scope | | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | @@ -97,7 +100,7 @@ Base path: `/v1` | Prices | `POST /v1/prices`, `GET /v1/prices`, `GET /v1/prices/{id}`, `POST /v1/prices/{id}`, `GET /v1/prices/search` | Supported | Create, list, retrieve, update, and search prices. Supports `product`, `currency`, `unit_amount`, `lookup_key`, recurring interval fields, `active`, and metadata. List supports `product`, `active`, `type=recurring`, and `type=one_time`. Search supports a measured Stripe Search Query Language subset for `active`, `type`, `lookup_key`, and metadata equality clauses joined by `AND`, including one-time price lookup by `lookup_key`. | | Coupons | `POST /v1/coupons`, `GET /v1/coupons`, `GET /v1/coupons/{id}`, `POST /v1/coupons/{id}`, `DELETE /v1/coupons/{id}` | Partial | Create/list/retrieve local coupon evidence for SDK and fixture smoke paths. Amount-off, percent-off, duration, metadata, and deletion markers are modeled. A bounded single-discount subset applies coupons to customer defaults, checkout sessions, subscriptions, invoice preview math, and renewal invoices; redemption limits and expiration analytics are not modeled. | | Promotion codes | `POST /v1/promotion_codes`, `GET /v1/promotion_codes`, `GET /v1/promotion_codes/{id}`, `POST /v1/promotion_codes/{id}` | Partial | Create/list/retrieve local promotion-code evidence linked to a local coupon. List supports `code`, `coupon`, `customer`, and `active` filters. Promotion codes can be applied through `discounts[0][promotion_code]`; minimum amounts, expiration rules, and promotion analytics are not modeled. | -| Checkout sessions | `POST /v1/checkout/sessions`, `GET /v1/checkout/sessions`, `GET /v1/checkout/sessions/{id}` | Supported | Creates subscription-mode sandbox checkout sessions from request line items and hosted Billtap URLs. The Stripe-style session response leaves `line_items` unexpanded. Accepts Stripe SDK form params `allow_promotion_codes`, `discounts[0][coupon]`, `discounts[0][promotion_code]`, and `subscription_data[trial_period_days]`; trial checkout creates local `trialing` subscription evidence. Hosted URLs use the request host by default, or `BILLTAP_PUBLIC_BASE_URL` when configured for container-to-host browser flows. | +| Checkout sessions | `POST /v1/checkout/sessions`, `GET /v1/checkout/sessions`, `GET /v1/checkout/sessions/{id}` | Supported | Creates subscription-mode sandbox checkout sessions from request line items and hosted Billtap URLs. The Stripe-style session response leaves `line_items` unexpanded. Accepts Stripe SDK form params `allow_promotion_codes`, `discounts[0][coupon]`, `discounts[0][promotion_code]`, and `subscription_data[trial_period_days]`; trial checkout creates local `trialing` subscription evidence. Hosted URLs use the request host by default, or `BILLTAP_PUBLIC_BASE_URL` when configured for container-to-host browser flows. `PUBLIC_BASE_PATH`, `BILLTAP_PUBLIC_BASE_PATH`, and `X-Forwarded-Prefix` are reflected in hosted browser URLs. | | Checkout completion | `POST /v1/checkout/sessions/{id}/complete`, `POST /api/checkout/sessions/{id}/complete` | Billtap-specific | Completes a sandbox checkout and creates subscription, invoice, payment intent, timeline, and checkout webhook evidence. Supports success plus deterministic failure aliases such as `card_declined`, `insufficient_funds`, `expired_card`, `incorrect_cvc`, `processing_error`, `authentication_required`, `payment_pending`, `canceled`, and documented Stripe test PaymentMethod IDs such as `pm_card_visa_chargeDeclined`. | | Billing portal sessions | `POST /v1/billing_portal/sessions` | Partial | Returns a Stripe-like `billing_portal.session` object and Billtap portal URL for a known customer, including `flow`, `locale`, `on_behalf_of`, `return_url`, and `livemode` fields. Accepts `customer`, `return_url`, optional `configuration`, `locale`, `on_behalf_of`, and `flow_data`, with enum and required nested-field validation for the modeled portal flows. The hosted portal can simulate saved payment-method updates and subscription cancellation, then redirect to `return_url`; full Stripe-hosted portal configuration rendering is not modeled. | | Subscriptions | `POST /v1/subscriptions`, `GET /v1/subscriptions`, `GET /v1/subscriptions/search`, `GET /v1/subscriptions/{id}`, `POST /v1/subscriptions/{id}`, `DELETE /v1/subscriptions/{id}`, `POST /v1/subscriptions/{id}/resume`, nested `GET/POST/DELETE /v1/customers/{id}/subscriptions...`, `GET/DELETE /v1/subscriptions/{id}/discount` | Partial | Create/list/retrieve subscriptions through the local checkout-completion state path. Update supports item replacement, metadata merge, single coupon or promotion-code discount application, `test_clock`, `cancel_at_period_end`, and `pause_collection` evidence; resume clears local pause evidence and emits `customer.subscription.updated`. Top-level and nested customer lists support status and metadata equality filters such as `metadata[billtap_fixture_ref]`. Search supports `id`, `customer`, `status`, and metadata equality clauses joined by `AND`. Delete performs immediate sandbox cancellation. Test-clock and scenario clock advances can activate due trials, renew active periods with bounded discount math, fail configured renewals from subscription or customer metadata, and cancel period-end subscriptions in the local billing graph. | diff --git a/docs/README.md b/docs/README.md index 2f62853..30d6515 100644 --- a/docs/README.md +++ b/docs/README.md @@ -45,5 +45,6 @@ This directory contains the public product, architecture, testing, and release n - `decisions/0001-project-shape.md` - `decisions/0002-stack.md` +- `decisions/0003-public-base-path.md` Internal adoption notes and original company-specific validation material are preserved locally under `.private/`, which is intentionally ignored by Git. diff --git a/docs/RELEASE_CHECKLIST.md b/docs/RELEASE_CHECKLIST.md index f0d1b6e..79fd4bd 100644 --- a/docs/RELEASE_CHECKLIST.md +++ b/docs/RELEASE_CHECKLIST.md @@ -48,6 +48,11 @@ release notes whenever compatibility coverage changes. - Start Billtap from the local binary with `BILLTAP_STATIC_DIR=dist/app`. - Start the sample app with `PORT=3300 npm --prefix examples/sample-app start` before CLI scenario smoke checks. - Open `/app/dashboard/`, `/app/checkout/`, and `/app/portal/`. +- Start Billtap with `PUBLIC_BASE_PATH=/billtap` and confirm + `/billtap/app/dashboard/`, `/billtap/api/diagnostics`, `/billtap/v1/customers`, + and a generated checkout session URL are all prefix-aware. +- Send a request through a proxy or curl with `X-Forwarded-Prefix: /billtap` and + confirm redirect `Location` headers include `/billtap`. - Create customer, product, price, and checkout session through `/v1`. - Complete checkout through `/api/checkout/sessions/{id}/complete`. - Verify `/api/timeline`, `/api/objects`, and `/api/delivery-attempts`. diff --git a/docs/decisions/0003-public-base-path.md b/docs/decisions/0003-public-base-path.md new file mode 100644 index 0000000..83c5bd1 --- /dev/null +++ b/docs/decisions/0003-public-base-path.md @@ -0,0 +1,47 @@ +# Decision 0003: Browser-Facing Public Base Path + +## Status + +Accepted + +## Context + +Billtap may run behind a shared reverse proxy where the browser-visible URL is +path-prefixed, for example `https://localhost:8081/billtap`, while internal +container traffic still uses the service URL `http://billtap:8080`. Requiring +the proxy to rewrite HTML and JavaScript assets is brittle and makes local +multi-app stacks harder to reason about. + +## Decision + +Billtap supports a browser-facing public base path through: + +- `PUBLIC_BASE_PATH`, a generic multi-app stack variable +- `BILLTAP_PUBLIC_BASE_PATH`, a Billtap-specific override +- `X-Forwarded-Prefix`, for proxies that strip the prefix before forwarding + +The configured or forwarded path is used for: + +- app links and static asset URLs +- dashboard API calls +- Stripe-like `/v1` browser calls +- redirect `Location` headers +- hosted checkout and portal URLs + +`BILLTAP_PUBLIC_BASE_URL` remains the browser-visible origin. If both +`BILLTAP_PUBLIC_BASE_URL=https://localhost:8081` and +`PUBLIC_BASE_PATH=/billtap` are configured, generated hosted URLs use +`https://localhost:8081/billtap/...`. + +Internal service-to-service traffic remains unprefixed and should continue to +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 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 9a3cebd..ac2720d 100644 --- a/docs/runbooks/local-dev.md +++ b/docs/runbooks/local-dev.md @@ -46,6 +46,46 @@ go run ./cmd/billtap Billtap from a container or private network name, but the browser needs checkout and portal URLs that resolve from the host. +## Shared Reverse Proxy Path + +Use `PUBLIC_BASE_PATH` when Billtap is mounted below a shared browser origin: + +```bash +PUBLIC_BASE_PATH=/billtap \ +BILLTAP_PUBLIC_BASE_URL=https://localhost:8081 \ +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. + +With `PUBLIC_BASE_PATH=/billtap`, browser-facing paths are: + +```text +https://localhost:8081/billtap/app/dashboard/ +https://localhost:8081/billtap/app/checkout/ +https://localhost:8081/billtap/app/portal/ +https://localhost:8081/billtap/api/diagnostics +https://localhost:8081/billtap/v1/customers +``` + +Internal compose traffic should continue to use the service URL: + +```text +http://billtap:8080/v1 +``` + +If the reverse proxy strips the prefix before forwarding, pass: + +```text +X-Forwarded-Prefix: /billtap +``` + +Billtap uses that header for redirects, hosted checkout and portal URLs, and +browser-visible links. This avoids HTML or JavaScript response rewrites in the +proxy. + ## Docker ```bash diff --git a/internal/api/api.go b/internal/api/api.go index c6b61b3..05f8e80 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -6444,22 +6444,40 @@ func absoluteURL(r *http.Request, path string, publicBase string) string { if strings.HasPrefix(path, "http://") || strings.HasPrefix(path, "https://") { return path } + if path == "" { + path = "/" + } + if !strings.HasPrefix(path, "/") { + path = "/" + path + } if publicBase != "" { - if path == "" { - return publicBase - } - if strings.HasPrefix(path, "/") { - return publicBase + path - } - return publicBase + "/" + path + return publicBase + path } scheme := "http" if r.TLS != nil { scheme = "https" } + if prefix := requestForwardedPrefix(r); prefix != "" { + path = prefix + path + } return scheme + "://" + r.Host + path } +func requestForwardedPrefix(r *http.Request) string { + raw := strings.TrimSpace(strings.Split(r.Header.Get("X-Forwarded-Prefix"), ",")[0]) + if raw == "" || raw == "/" || strings.Contains(raw, "://") || strings.ContainsAny(raw, "?#") { + return "" + } + if !strings.HasPrefix(raw, "/") { + raw = "/" + raw + } + raw = strings.TrimRight(raw, "/") + if strings.Contains(raw, "//") || strings.Contains(raw, "/../") || strings.HasSuffix(raw, "/..") { + return "" + } + return raw +} + func (h *Handler) emitCheckoutWebhooks(r *http.Request, result map[string]any) []webhooks.Event { if h.webhooks == nil { return nil diff --git a/internal/config/config.go b/internal/config/config.go index 046e7b6..4b4590f 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -6,6 +6,7 @@ import ( "fmt" "os" "strconv" + "strings" ) const ( @@ -13,6 +14,8 @@ const ( envAddr = "BILLTAP_ADDR" envDatabaseURL = "BILLTAP_DATABASE_URL" envStaticDir = "BILLTAP_STATIC_DIR" + envPublicBasePath = "PUBLIC_BASE_PATH" + envBilltapPublicBasePath = "BILLTAP_PUBLIC_BASE_PATH" envPublicBaseURL = "BILLTAP_PUBLIC_BASE_URL" envEnvironment = "BILLTAP_ENV" envRelayMode = "BILLTAP_RELAY_MODE" @@ -34,6 +37,7 @@ type Config struct { Addr string `json:"addr"` DatabaseURL string `json:"database_url"` StaticDir string `json:"static_dir"` + PublicBasePath string `json:"public_base_path"` PublicBaseURL string `json:"public_base_url"` Environment string `json:"environment"` RelayMode bool `json:"relay_mode"` @@ -90,6 +94,12 @@ func LoadWithLookup(path string, lookup LookupFunc) (Config, error) { if value, ok := lookup(envStaticDir); ok { cfg.StaticDir = value } + if value, ok := lookup(envPublicBasePath); ok { + cfg.PublicBasePath = value + } + if value, ok := lookup(envBilltapPublicBasePath); ok { + cfg.PublicBasePath = value + } if value, ok := lookup(envPublicBaseURL); ok { cfg.PublicBaseURL = value } @@ -118,6 +128,11 @@ func LoadWithLookup(path string, lookup LookupFunc) (Config, error) { if cfg.RelayMode { cfg.RawPayloadStorage = RawPayloadMetadataOnly } + publicBasePath, err := ValidatePublicBasePath(cfg.PublicBasePath) + if err != nil { + return Config{}, err + } + cfg.PublicBasePath = publicBasePath if err := cfg.Validate(); err != nil { return Config{}, err @@ -135,6 +150,9 @@ func (c Config) Validate() error { if c.StaticDir == "" { return errors.New("static_dir is required") } + if _, err := ValidatePublicBasePath(c.PublicBasePath); err != nil { + return err + } if c.Environment == "" { return errors.New("environment is required") } @@ -181,6 +199,9 @@ func merge(base Config, override Config) Config { if override.StaticDir != "" { base.StaticDir = override.StaticDir } + if override.PublicBasePath != "" { + base.PublicBasePath = override.PublicBasePath + } if override.PublicBaseURL != "" { base.PublicBaseURL = override.PublicBaseURL } @@ -205,6 +226,34 @@ func merge(base Config, override Config) Config { return base } +func NormalizePublicBasePath(value string) string { + normalized, _ := ValidatePublicBasePath(value) + return normalized +} + +func ValidatePublicBasePath(value string) (string, error) { + value = strings.TrimSpace(value) + if value == "" || value == "/" { + return "", nil + } + if strings.Contains(value, "://") || strings.ContainsAny(value, "?#") { + return "", errors.New("public_base_path must be a URL path, not a full URL") + } + if !strings.HasPrefix(value, "/") { + value = "/" + value + } + value = strings.TrimRight(value, "/") + for _, part := range strings.Split(value, "/") { + if part == ".." || part == "." { + return "", errors.New("public_base_path cannot contain dot path segments") + } + } + if strings.Contains(value, "//") { + return "", errors.New("public_base_path cannot contain empty path segments") + } + return value, nil +} + func parseBool(value string) bool { switch value { case "1", "true", "TRUE", "True", "yes", "YES", "on", "ON": diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 4193efa..ac425ec 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -32,7 +32,7 @@ func TestLoadDefaults(t *testing.T) { func TestLoadConfigFileThenEnvOverrides(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "billtap.json") - body := `{"addr":":9000","database_url":"file.db","static_dir":"static","public_base_url":"https://billtap.example.test","environment":"test","raw_payload_storage":"metadata_only","retention_days":7,"webhook_signature_header":"Billtap-Signature","webhook_api_version":"2025-03-31.basil"}` + body := `{"addr":":9000","database_url":"file.db","static_dir":"static","public_base_path":"/file-prefix","public_base_url":"https://billtap.example.test","environment":"test","raw_payload_storage":"metadata_only","retention_days":7,"webhook_signature_header":"Billtap-Signature","webhook_api_version":"2025-03-31.basil"}` if err := os.WriteFile(path, []byte(body), 0o600); err != nil { t.Fatalf("write config file: %v", err) } @@ -40,6 +40,8 @@ func TestLoadConfigFileThenEnvOverrides(t *testing.T) { env := map[string]string{ envAddr: ":9100", envDatabaseURL: ":memory:", + envPublicBasePath: "generic-prefix", + envBilltapPublicBasePath: "/billtap/", envPublicBaseURL: "http://127.0.0.1:18080", envWebhookSignatureHeader: "Stripe-Signature", envWebhookAPIVersion: "2025-12-15.clover", @@ -61,6 +63,9 @@ func TestLoadConfigFileThenEnvOverrides(t *testing.T) { if cfg.StaticDir != "static" { t.Fatalf("StaticDir = %q, want file value", cfg.StaticDir) } + if cfg.PublicBasePath != "/billtap" { + t.Fatalf("PublicBasePath = %q, want normalized app-specific env override", cfg.PublicBasePath) + } if cfg.PublicBaseURL != "http://127.0.0.1:18080" { t.Fatalf("PublicBaseURL = %q, want env override", cfg.PublicBaseURL) } @@ -75,6 +80,18 @@ func TestLoadConfigFileThenEnvOverrides(t *testing.T) { } } +func TestLoadRejectsInvalidPublicBasePath(t *testing.T) { + _, err := LoadWithLookup("", func(key string) (string, bool) { + if key == envPublicBasePath { + return "https://example.test/billtap", true + } + return "", false + }) + if err == nil { + t.Fatal("LoadWithLookup succeeded, want public_base_path validation error") + } +} + func TestRelayModeDisablesRawPayloadStorage(t *testing.T) { env := map[string]string{ envRelayMode: "true", diff --git a/internal/server/server.go b/internal/server/server.go index ee90e04..4cd9a35 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -32,12 +32,29 @@ func New(opts Options) http.Handler { store: opts.Store, mux: http.NewServeMux(), } + s.cfg.PublicBasePath = config.NormalizePublicBasePath(s.cfg.PublicBasePath) s.routes() return s } func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { - s.mux.ServeHTTP(w, r) + basePath := s.requestBasePath(r) + if basePath == "" { + s.mux.ServeHTTP(w, r) + return + } + r2 := r.Clone(r.Context()) + r2.Header = r.Header.Clone() + if r2.Header.Get("X-Forwarded-Prefix") == "" { + r2.Header.Set("X-Forwarded-Prefix", basePath) + } + u := *r.URL + if stripped, ok := stripBasePath(u.Path, basePath); ok { + u.Path = stripped + u.RawPath = "" + } + r2.URL = &u + s.mux.ServeHTTP(w, r2) } func (s *Server) routes() { @@ -59,7 +76,7 @@ func (s *Server) routes() { Billing: billing.NewService(repo), Webhooks: webhookService, Diagnostics: diagnosticsService, - PublicBaseURL: s.cfg.PublicBaseURL, + PublicBaseURL: publicBaseURLWithPath(s.cfg.PublicBaseURL, s.cfg.PublicBasePath), }) s.mux.Handle("/v1/", handler) s.mux.Handle("/api/", handler) @@ -85,7 +102,7 @@ func (s *Server) handleRoot(w http.ResponseWriter, r *http.Request) { methodNotAllowed(w) return } - http.Redirect(w, r, "/app/dashboard/", http.StatusFound) + http.Redirect(w, r, s.prefixedPath(r, "/app/dashboard/"), http.StatusFound) } func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) { @@ -142,7 +159,8 @@ func (s *Server) handleApp(w http.ResponseWriter, r *http.Request) { if r.Method == http.MethodHead { return } - _, _ = w.Write([]byte(`Billtap
`)) + assetPath := s.prefixedPath(r, "/assets/app.js") + _, _ = w.Write([]byte(`Billtap
`)) } func (s *Server) handleHostedCheckout(w http.ResponseWriter, r *http.Request) { @@ -154,7 +172,7 @@ func (s *Server) handleHostedCheckout(w http.ResponseWriter, r *http.Request) { return } sessionID := strings.Trim(strings.TrimPrefix(strings.TrimPrefix(r.URL.Path, "/checkout"), "/"), "/") - target := "/app/checkout/" + target := s.prefixedPath(r, "/app/checkout/") if sessionID != "" { target += "?session_id=" + sessionID } else if r.URL.RawQuery != "" { @@ -172,7 +190,7 @@ func (s *Server) handleHostedPortal(w http.ResponseWriter, r *http.Request) { return } customerID := strings.Trim(strings.TrimPrefix(strings.TrimPrefix(r.URL.Path, "/portal"), "/"), "/") - target := "/app/portal/" + target := s.prefixedPath(r, "/app/portal/") if customerID != "" { target += "?customer_id=" + customerID } else if r.URL.RawQuery != "" { @@ -181,6 +199,62 @@ func (s *Server) handleHostedPortal(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, target, http.StatusFound) } +func (s *Server) requestBasePath(r *http.Request) string { + if prefix := forwardedPrefix(r); prefix != "" { + return prefix + } + return config.NormalizePublicBasePath(s.cfg.PublicBasePath) +} + +func forwardedPrefix(r *http.Request) string { + raw := strings.TrimSpace(strings.Split(r.Header.Get("X-Forwarded-Prefix"), ",")[0]) + return config.NormalizePublicBasePath(raw) +} + +func stripBasePath(path string, basePath string) (string, bool) { + if basePath == "" { + return path, false + } + if path == basePath { + return "/", true + } + if strings.HasPrefix(path, basePath+"/") { + stripped := strings.TrimPrefix(path, basePath) + if stripped == "" { + return "/", true + } + return stripped, true + } + return path, false +} + +func (s *Server) prefixedPath(r *http.Request, path string) string { + return joinURLPath(s.requestBasePath(r), path) +} + +func joinURLPath(basePath string, path string) string { + basePath = config.NormalizePublicBasePath(basePath) + if path == "" { + path = "/" + } + if !strings.HasPrefix(path, "/") { + path = "/" + path + } + if basePath == "" { + return path + } + return basePath + path +} + +func publicBaseURLWithPath(baseURL string, basePath string) string { + baseURL = strings.TrimRight(strings.TrimSpace(baseURL), "/") + basePath = config.NormalizePublicBasePath(basePath) + if baseURL == "" || basePath == "" || strings.HasSuffix(baseURL, basePath) { + return baseURL + } + return baseURL + basePath +} + func (s *Server) handleAssets(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet && r.Method != http.MethodHead { methodNotAllowed(w) diff --git a/internal/server/server_test.go b/internal/server/server_test.go index bc5755a..7ed249e 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -1,11 +1,14 @@ package server import ( + "context" "encoding/json" "net/http" "net/http/httptest" + "net/url" "os" "path/filepath" + "strings" "testing" "github.com/hckim/billtap/internal/config" @@ -102,6 +105,57 @@ func TestHostedRoutesWithoutTrailingSlashRedirectToApps(t *testing.T) { } } +func TestPublicBasePathPrefixesBrowserRoutes(t *testing.T) { + handler := New(Options{ + Config: config.Config{Addr: ":0", DatabaseURL: ":memory:", StaticDir: "web/dist", Environment: "test", PublicBasePath: "/billtap"}, + Store: storage.NewMemoryStore(), + }) + + for _, tt := range []struct { + path string + want string + }{ + {path: "/billtap", want: "/billtap/app/dashboard/"}, + {path: "/billtap/checkout?session_id=cs_test_123", want: "/billtap/app/checkout/?session_id=cs_test_123"}, + {path: "/checkout?session_id=cs_test_456", want: "/billtap/app/checkout/?session_id=cs_test_456"}, + } { + req := httptest.NewRequest(http.MethodGet, tt.path, nil) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + if rec.Code != http.StatusFound { + t.Fatalf("%s status = %d, want %d", tt.path, rec.Code, http.StatusFound) + } + if got := rec.Header().Get("Location"); got != tt.want { + t.Fatalf("%s Location = %q, want %q", tt.path, got, tt.want) + } + } + + req := httptest.NewRequest(http.MethodGet, "/billtap/healthz", nil) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("prefixed health status = %d, want %d", rec.Code, http.StatusOK) + } +} + +func TestForwardedPrefixDrivesRedirects(t *testing.T) { + handler := New(Options{ + Config: config.Config{Addr: ":0", DatabaseURL: ":memory:", StaticDir: "web/dist", Environment: "test"}, + Store: storage.NewMemoryStore(), + }) + + req := httptest.NewRequest(http.MethodGet, "/portal?customer_id=cus_test_123", nil) + req.Header.Set("X-Forwarded-Prefix", "/billtap") + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + if rec.Code != http.StatusFound { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusFound) + } + if got := rec.Header().Get("Location"); got != "/billtap/app/portal/?customer_id=cus_test_123" { + t.Fatalf("Location = %q, want prefixed portal URL", got) + } +} + func TestBuiltReactAppServing(t *testing.T) { staticDir := t.TempDir() if err := os.MkdirAll(filepath.Join(staticDir, "dashboard"), 0o755); err != nil { @@ -131,3 +185,115 @@ func TestBuiltReactAppServing(t *testing.T) { } } } + +func TestPublicBasePathPrefixesAPISessionURLs(t *testing.T) { + handler := newSQLiteBackedServer(t, config.Config{ + Addr: ":0", + DatabaseURL: ":memory:", + StaticDir: "web/dist", + Environment: "test", + PublicBasePath: "/billtap", + PublicBaseURL: "https://localhost:8081", + }) + + customer := postForm[struct { + ID string `json:"id"` + }](t, handler, "/billtap/v1/customers", map[string]string{"email": "buyer@example.test"}) + product := postForm[struct { + ID string `json:"id"` + }](t, handler, "/billtap/v1/products", map[string]string{"name": "Team"}) + price := postForm[struct { + ID string `json:"id"` + }](t, handler, "/billtap/v1/prices", map[string]string{ + "product": product.ID, + "currency": "usd", + "unit_amount": "9900", + "recurring[interval]": "month", + }) + session := postForm[struct { + URL string `json:"url"` + }](t, handler, "/billtap/v1/checkout/sessions", map[string]string{ + "customer": customer.ID, + "line_items[0][price]": price.ID, + "line_items[0][quantity]": "1", + }) + if got, want := session.URL, "https://localhost:8081/billtap/checkout/"; len(got) < len(want) || got[:len(want)] != want { + t.Fatalf("checkout session URL = %q, want prefix %q", got, want) + } +} + +func TestForwardedPrefixPrefixesAPISessionURLs(t *testing.T) { + handler := newSQLiteBackedServer(t, config.Config{ + Addr: ":0", + DatabaseURL: ":memory:", + StaticDir: "web/dist", + Environment: "test", + }) + headers := map[string]string{"X-Forwarded-Prefix": "/billtap"} + + customer := postFormWithHeaders[struct { + ID string `json:"id"` + }](t, handler, "/v1/customers", map[string]string{"email": "buyer@example.test"}, headers) + product := postFormWithHeaders[struct { + ID string `json:"id"` + }](t, handler, "/v1/products", map[string]string{"name": "Team"}, headers) + price := postFormWithHeaders[struct { + ID string `json:"id"` + }](t, handler, "/v1/prices", map[string]string{ + "product": product.ID, + "currency": "usd", + "unit_amount": "9900", + "recurring[interval]": "month", + }, headers) + session := postFormWithHeaders[struct { + URL string `json:"url"` + }](t, handler, "/v1/checkout/sessions", map[string]string{ + "customer": customer.ID, + "line_items[0][price]": price.ID, + "line_items[0][quantity]": "1", + }, headers) + if got, want := session.URL, "http://example.com/billtap/checkout/"; len(got) < len(want) || got[:len(want)] != want { + t.Fatalf("checkout session URL = %q, want prefix %q", got, want) + } +} + +func postForm[T any](t *testing.T, handler http.Handler, path string, values map[string]string) T { + return postFormWithHeaders[T](t, handler, path, values, nil) +} + +func postFormWithHeaders[T any](t *testing.T, handler http.Handler, path string, values map[string]string, headers map[string]string) T { + t.Helper() + form := make(url.Values) + for key, value := range values { + form.Set(key, value) + } + req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + for key, value := range headers { + req.Header.Set(key, value) + } + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + if rec.Code < 200 || rec.Code >= 300 { + t.Fatalf("POST %s status = %d body = %s", path, rec.Code, rec.Body.String()) + } + var out T + if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil { + t.Fatalf("decode POST %s response: %v body=%s", path, err, rec.Body.String()) + } + return out +} + +func newSQLiteBackedServer(t *testing.T, cfg config.Config) http.Handler { + t.Helper() + store, err := storage.OpenSQLite(context.Background(), filepath.Join(t.TempDir(), "billtap.db")) + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + t.Cleanup(func() { + if err := store.Close(); err != nil { + t.Fatalf("close store: %v", err) + } + }) + return New(Options{Config: cfg, Store: store}) +} diff --git a/specs/000-product/tasks.md b/specs/000-product/tasks.md index cc3160e..41c4838 100644 --- a/specs/000-product/tasks.md +++ b/specs/000-product/tasks.md @@ -268,6 +268,7 @@ Gate: - [x] T143 Add optional scheduled/manual Stripe OpenAPI inventory workflow artifacts - [x] T144 Capture the public simulation capacity backlog for regression-driven fixture and scenario expansion - [x] T145 Expand customer history, subscription pause/resume, and payment-method attach/detach simulation routes +- [x] T146 Add browser-facing public base path and forwarded-prefix support Suggested agents: diff --git a/tests/web-smoke/smoke-web.mjs b/tests/web-smoke/smoke-web.mjs index cc56af4..56b156c 100644 --- a/tests/web-smoke/smoke-web.mjs +++ b/tests/web-smoke/smoke-web.mjs @@ -10,6 +10,7 @@ const repoRoot = resolve(testDir, "../.."); const smokeDir = resolve(repoRoot, ".billtap/web-smoke"); const binaryPath = resolve(smokeDir, "billtap"); const timeoutMs = Number(process.env.BILLTAP_WEB_SMOKE_TIMEOUT_MS ?? 30_000); +const publicBasePath = normalizePublicBasePath(process.env.BILLTAP_PUBLIC_BASE_PATH || process.env.PUBLIC_BASE_PATH || ""); async function main() { await mkdir(smokeDir, { recursive: true }); @@ -59,22 +60,34 @@ function smokeChecks(seed) { return [ { name: "dashboard", - path: "/app/dashboard/", + path: withPublicBasePath("/app/dashboard/"), texts: ["Debug workspace", "Billing objects", "Object timeline"], }, { name: "checkout", - path: `/app/checkout/?session_id=${encodeURIComponent(seed.checkoutSession.id)}`, + path: withPublicBasePath(`/app/checkout/?session_id=${encodeURIComponent(seed.checkoutSession.id)}`), texts: ["Checkout session", "Outcome selector", "Complete checkout"], }, { name: "portal", - path: `/app/portal/?customer_id=${encodeURIComponent(seed.customer.id)}`, + path: withPublicBasePath(`/app/portal/?customer_id=${encodeURIComponent(seed.customer.id)}`), texts: ["Subscription management", "Current subscription", "Cancellation"], }, ]; } +function normalizePublicBasePath(value) { + const trimmed = value.trim(); + if (!trimmed || trimmed === "/") return ""; + const withLeading = trimmed.startsWith("/") ? trimmed : `/${trimmed}`; + return withLeading.replace(/\/+$/, ""); +} + +function withPublicBasePath(path) { + if (!publicBasePath) return path; + return `${publicBasePath}${path.startsWith("/") ? path : `/${path}`}`; +} + async function seedBillingData(baseURL) { const customer = await postForm(`${baseURL}/v1/customers`, { email: "web-smoke@example.test", diff --git a/vite.config.ts b/vite.config.ts index 6f365b4..b3bdeed 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -5,6 +5,8 @@ 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 { @@ -13,12 +15,13 @@ function appPathDevFallback(): Plugin { configureServer(server) { server.middlewares.use((req, _res, next) => { const [pathname, query] = req.url?.split("?") ?? ["", ""]; - const match = pathname.match(/^\/app\/(checkout|dashboard|portal)\/?$/); + const appPath = stripBasePath(pathname, publicBasePath); + const match = appPath.match(/^\/app\/(checkout|dashboard|portal)\/?$/); if (match && appRoutes.has(match[1])) { - req.url = `/app/${match[1]}/index.html${query ? `?${query}` : ""}`; - } else if (pathname === "/app/" || pathname === "/app") { - req.url = `/app/dashboard/index.html${query ? `?${query}` : ""}`; + req.url = `${appBasePath}${match[1]}/index.html${query ? `?${query}` : ""}`; + } else if (appPath === "/app/" || appPath === "/app") { + req.url = `${appBasePath}dashboard/index.html${query ? `?${query}` : ""}`; } next(); @@ -27,9 +30,36 @@ function appPathDevFallback(): Plugin { }; } +function firstNonEmpty(...values: Array): string { + for (const value of values) { + if (value?.trim()) return value; + } + return ""; +} + +function normalizePublicBasePath(value: string): string { + const trimmed = value.trim(); + if (!trimmed || trimmed === "/") return ""; + const withLeading = trimmed.startsWith("/") ? trimmed : `/${trimmed}`; + 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 "/"; + if (pathname.startsWith(`${basePath}/`)) return pathname.slice(basePath.length); + return pathname; +} + export default defineConfig({ root: "web", - base: "/app/", + base: appBasePath, plugins: [react(), appPathDevFallback()], server: { host: "127.0.0.1", diff --git a/web/checkout/index.html b/web/checkout/index.html index 6bcfc8f..f71b735 100644 --- a/web/checkout/index.html +++ b/web/checkout/index.html @@ -3,7 +3,7 @@ - + Billtap Checkout diff --git a/web/checkout/main.tsx b/web/checkout/main.tsx index beefc68..82c0690 100644 --- a/web/checkout/main.tsx +++ b/web/checkout/main.tsx @@ -21,6 +21,7 @@ import { type CheckoutOutcomeId, type StatusTone, } from "../shared/data"; +import { appHref } from "../shared/basePath"; import "../shared/styles.css"; const paymentMethods = [ @@ -146,7 +147,7 @@ function CheckoutApp() { title="Checkout session" meta={`${session.id} · ${session.customer}`} actions={ - + View timeline } diff --git a/web/dashboard/index.html b/web/dashboard/index.html index be021af..67eda5e 100644 --- a/web/dashboard/index.html +++ b/web/dashboard/index.html @@ -3,7 +3,7 @@ - + Billtap Dashboard diff --git a/web/dashboard/main.tsx b/web/dashboard/main.tsx index a05fe16..102ccdf 100644 --- a/web/dashboard/main.tsx +++ b/web/dashboard/main.tsx @@ -28,6 +28,7 @@ import { type TimelineEntry, type WebhookAttempt, } from "../shared/data"; +import { appHref } from "../shared/basePath"; import "../shared/styles.css"; const objectTabs: Array<{ id: DashboardObjectType; label: string }> = [ @@ -135,10 +136,10 @@ function DashboardApp() { meta={`${dashboard.billingSnapshot.workspace} · ${source === "api" ? "API timeline" : "fixture timeline"}`} actions={ <> - + Open checkout - + Open portal diff --git a/web/portal/index.html b/web/portal/index.html index 0371f7f..8ae50ce 100644 --- a/web/portal/index.html +++ b/web/portal/index.html @@ -3,7 +3,7 @@ - + Billtap Portal diff --git a/web/portal/main.tsx b/web/portal/main.tsx index a02fafd..b023783 100644 --- a/web/portal/main.tsx +++ b/web/portal/main.tsx @@ -21,6 +21,7 @@ import { type PortalPaymentOutcome, } from "../shared/api"; import { billingSnapshot, invoices, portalPlans } from "../shared/data"; +import { appHref } from "../shared/basePath"; import "../shared/styles.css"; const securePaymentFrame = ` @@ -157,7 +158,7 @@ function PortalApp() { Return to business - + View evidence @@ -378,7 +379,7 @@ function PortalApp() { function getPortalReturnUrl(location: Location = window.location): string { const params = new URLSearchParams(location.search); - return params.get("return_url") ?? params.get("returnUrl") ?? "/app/dashboard/"; + return params.get("return_url") ?? params.get("returnUrl") ?? appHref("dashboard/"); } function shouldRedirectAfterPortalAction(action: string, location: Location = window.location): boolean { diff --git a/web/shared/api.ts b/web/shared/api.ts index 116053e..8c7852f 100644 --- a/web/shared/api.ts +++ b/web/shared/api.ts @@ -17,6 +17,7 @@ import { type TimelineEntry, type WebhookAttempt, } from "./data"; +import { apiHref } from "./basePath"; export type DataSource = "api" | "fixture"; @@ -775,7 +776,7 @@ async function postJSON(path: string, body: unknown): Promise { } async function requestJSON(path: string, init: RequestInit): Promise { - const response = await fetch(path, { + const response = await fetch(apiHref(path), { ...init, headers: { Accept: "application/json", diff --git a/web/shared/basePath.ts b/web/shared/basePath.ts new file mode 100644 index 0000000..ce473e6 --- /dev/null +++ b/web/shared/basePath.ts @@ -0,0 +1,24 @@ +const appBase = normalizeBase(import.meta.env.BASE_URL || "/app/"); +const publicBasePath = appBase.endsWith("/app/") ? appBase.slice(0, -"/app/".length) : appBase.replace(/\/$/, ""); + +export function appHref(path = ""): string { + return joinBase(appBase, path); +} + +export function apiHref(path: string): string { + return joinBase(publicBasePath, path); +} + +function normalizeBase(value: string): string { + const trimmed = value.trim(); + if (!trimmed || trimmed === "/") return "/app/"; + const withLeading = trimmed.startsWith("/") ? trimmed : `/${trimmed}`; + return withLeading.endsWith("/") ? withLeading : `${withLeading}/`; +} + +function joinBase(base: string, path: string): string { + const normalizedPath = path.startsWith("/") ? path : `/${path}`; + const normalizedBase = base.endsWith("/") ? base.slice(0, -1) : base; + if (!normalizedBase) return normalizedPath; + return `${normalizedBase}${normalizedPath}`; +} diff --git a/web/shared/components.tsx b/web/shared/components.tsx index 7e9978b..738ddb7 100644 --- a/web/shared/components.tsx +++ b/web/shared/components.tsx @@ -1,4 +1,5 @@ import type { ReactNode } from "react"; +import { appHref } from "./basePath"; import type { StatusTone, TimelineEntry } from "./data"; type AppShellProps = { @@ -7,9 +8,9 @@ type AppShellProps = { }; const navItems = [ - { id: "dashboard", label: "Dashboard", href: "/app/dashboard/" }, - { id: "checkout", label: "Checkout", href: "/app/checkout/" }, - { id: "portal", label: "Portal", href: "/app/portal/" }, + { id: "dashboard", label: "Dashboard", href: appHref("dashboard/") }, + { id: "checkout", label: "Checkout", href: appHref("checkout/") }, + { id: "portal", label: "Portal", href: appHref("portal/") }, ] as const; export function AppShell({ active, children }: AppShellProps) { @@ -18,7 +19,7 @@ export function AppShell({ active, children }: AppShellProps) { return (