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(`