diff --git a/CHANGELOG.md b/CHANGELOG.md index 66ac5cc..5c0d7eb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,15 @@ ## Unreleased +- Hosted pages now repoint caller-provided localhost redirect targets at the + run's configured public origin: when a run has a `public_base_url`, the + hosted checkout "Return to app" link and billing portal return + link/redirect swap the scheme/host/port of `localhost`/`127.0.0.1` + `success_url`/`return_url` values for the run origin (path and query kept), + surfaced via the `billtap_return_url` extension field and the portal URL + query while stored sessions, `success_url`, and portal `return_url` + response fields keep the caller's original values. External domains and + unconfigured runs are untouched. - Added run-scoped public base URLs so several proxied stacks can share one Billtap server: `POST /runs//v1/config` pins `public_base_url` and an optional `public_base_path` per run, and absolute URLs (checkout diff --git a/README.md b/README.md index f92a5fb..67723a2 100644 --- a/README.md +++ b/README.md @@ -245,11 +245,25 @@ The base of generated absolute URLs resolves in this order: request host — exactly the previous behaviour, so the default run and single-stack setups are unchanged. -Caller-provided `success_url` and `cancel_url` values are never rewritten. The -per-run base lives in memory with the run's API handler: it survives until the -run is deleted or the server restarts, so seed it together with the run's +The per-run base lives in memory with the run's API handler: it survives until +the run is deleted or the server restarts, so seed it together with the run's catalog and webhooks. +When a run has a `public_base_url`, hosted pages also repoint caller-provided +**localhost redirect targets** at that run's origin. Consumers often store one +static redirect URL (for example `https://localhost:8080/checkout-success`) +while each CI job listens on its own port; the hosted checkout "Return to app" +link and the billing portal return link/redirect then swap only the +scheme/host/port for the run's origin, keeping path and query: + +- The stored session is untouched: `GET /v1/checkout/sessions/{id}` keeps + `success_url` exactly as created and exposes the rewritten link as the + extension field `billtap_return_url` (also returned beside the session in + the completion response). Portal responses keep `return_url` as provided and + embed the rewritten target only in the hosted `url` query. +- Only `localhost` and `127.0.0.1` hosts are rewritten; external domains are + never touched. Runs without a `public_base_url` keep redirects unchanged. + Fixture packs can also be applied directly to a run: ```bash diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 561aceb..baf7324 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -82,7 +82,11 @@ portal URLs) prefer the run's base, then an `X-Billtap-Public-Base-Url` request header, then the forwarded proxy origin for run-scoped requests, and finally the global `BILLTAP_PUBLIC_BASE_URL`, which keeps the default run unchanged. The setting lives in memory with the run's API handler and is dropped on run -deletion or server restart. +deletion or server restart. When a run pins a base, hosted pages also repoint +caller-provided localhost redirect targets (checkout `success_url`, portal +`return_url`) at the run's origin — surfaced through the `billtap_return_url` +extension field and the portal URL query, while stored sessions and the +Stripe-shaped response fields keep the caller's original values. Tables (per run): diff --git a/internal/api/api.go b/internal/api/api.go index d05061f..f96e029 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -47,10 +47,11 @@ type Handler struct { // Run-scoped public base configured through /v1/config. Each run owns one // Handler instance, so the values live here like the other per-run // in-memory state (idempotency keys, local evidence). - runConfigMu sync.RWMutex - runPublicBaseURL string - runPublicBasePath string - runPublicBase string // runPublicBaseURL combined with runPublicBasePath + runConfigMu sync.RWMutex + runPublicBaseURL string + runPublicBasePath string + runPublicBase string // runPublicBaseURL combined with runPublicBasePath + runPublicBaseOrigin string // scheme://host of runPublicBaseURL, no path } func New(opts Options) http.Handler { @@ -1625,7 +1626,13 @@ func (h *Handler) handleCheckoutSession(w http.ResponseWriter, r *http.Request) if err == nil { session.URL = h.absoluteURL(r, session.URL) } - writeResult(w, stripeCheckoutSession(session), err) + payload := stripeCheckoutSession(session) + // The hosted page prefers this extension field for its "Return to app" + // link; success_url itself stays exactly as the caller stored it. + if rewritten := h.rewriteRunLocalRedirect(session.SuccessURL); rewritten != session.SuccessURL { + payload["billtap_return_url"] = rewritten + } + writeResult(w, payload, err) } func (h *Handler) handleBillingPortalSessions(w http.ResponseWriter, r *http.Request) { @@ -1659,7 +1666,7 @@ func (h *Handler) handleBillingPortalSessions(w http.ResponseWriter, r *http.Req "locale": emptyToNil(p.string("locale")), "on_behalf_of": emptyToNil(p.string("on_behalf_of")), "return_url": returnURL, - "url": h.absoluteURL(r, billingPortalSessionPath(customerID, sessionID, returnURL, flowType, billingPortalSessionSubscriptionID(p))), + "url": h.absoluteURL(r, billingPortalSessionPath(customerID, sessionID, h.rewriteRunLocalRedirect(returnURL), flowType, billingPortalSessionSubscriptionID(p))), "created": time.Now().UTC().Unix(), "livemode": false, } @@ -1734,6 +1741,12 @@ func (h *Handler) completeCheckout(w http.ResponseWriter, r *http.Request, id st } session.URL = h.absoluteURL(r, session.URL) result := map[string]any{"session": session} + // Sibling key on purpose: webhook payloads and evidence keep reading the + // untouched session struct, while the hosted page uses this for its + // post-payment "Return to app" link. + if rewritten := h.rewriteRunLocalRedirect(session.SuccessURL); rewritten != session.SuccessURL { + result["billtap_return_url"] = rewritten + } if session.SubscriptionID != "" { if sub, err := h.billing.GetSubscription(r.Context(), session.SubscriptionID); err == nil { result["subscription"] = sub @@ -6805,6 +6818,42 @@ func (h *Handler) setRunPublicBase(baseURL string, basePath string) { h.runPublicBaseURL = baseURL h.runPublicBasePath = basePath h.runPublicBase = config.PublicBaseURLWithPath(baseURL, basePath) + h.runPublicBaseOrigin = "" + if parsed, err := url.Parse(baseURL); err == nil && parsed.Host != "" { + h.runPublicBaseOrigin = parsed.Scheme + "://" + parsed.Host + } +} + +// rewriteRunLocalRedirect repoints a caller-provided localhost redirect target +// (checkout success_url, portal return_url) at the run's configured public +// origin, keeping path and query. Consumers often share one static redirect +// URL across CI jobs while each job listens on its own port; only the +// scheme/host/port change, only for localhost/127.0.0.1 targets, and only when +// the run pinned a public base — other hosts and unconfigured runs keep the +// caller's value. +func (h *Handler) rewriteRunLocalRedirect(raw string) string { + h.runConfigMu.RLock() + origin := h.runPublicBaseOrigin + h.runConfigMu.RUnlock() + if origin == "" || raw == "" { + return raw + } + target, err := url.Parse(raw) + if err != nil || (target.Scheme != "http" && target.Scheme != "https") { + return raw + } + switch strings.ToLower(target.Hostname()) { + case "localhost", "127.0.0.1": + default: + return raw + } + scheme, hostPort, ok := strings.Cut(origin, "://") + if !ok { + return raw + } + target.Scheme = scheme + target.Host = hostPort + return target.String() } func (h *Handler) writeRunConfig(w http.ResponseWriter, r *http.Request) { diff --git a/internal/api/api_test.go b/internal/api/api_test.go index e3d1f32..ba18baa 100644 --- a/internal/api/api_test.go +++ b/internal/api/api_test.go @@ -190,6 +190,137 @@ func TestRunConfigEndpointControlsPublicBase(t *testing.T) { } } +func TestRunConfiguredBaseRewritesLocalRedirects(t *testing.T) { + handler := newTestHandlerWithOptions(t, Options{PublicBaseURL: "http://127.0.0.1:18080"}) + + postForm[runConfigResponse](t, handler, "/v1/config", url.Values{ + "public_base_url": {"https://localhost:18689"}, + "public_base_path": {"/billtap"}, + }) + + customer := postForm[billing.Customer](t, handler, "/v1/customers", url.Values{ + "email": {"redirect-rewrite@example.test"}, + }) + product := postForm[billing.Product](t, handler, "/v1/products", url.Values{"name": {"Team"}}) + price := postForm[billing.Price](t, handler, "/v1/prices", url.Values{ + "product": {product.ID}, + "currency": {"usd"}, + "unit_amount": {"9900"}, + "recurring[interval]": {"month"}, + }) + created := postForm[billing.CheckoutSession](t, handler, "/v1/checkout/sessions", url.Values{ + "customer": {customer.ID}, + "line_items[0][price]": {price.ID}, + "line_items[0][quantity]": {"1"}, + "success_url": {"https://localhost:8080/checkout-success?step=done"}, + "cancel_url": {"https://localhost:8080/checkout-cancel"}, + }) + + // Retrieve keeps the stored success_url and adds the rewritten link for the + // hosted page: only the origin changes, the public base path is not added. + fetched := getJSON[struct { + SuccessURL string `json:"success_url"` + BilltapReturnURL string `json:"billtap_return_url"` + }](t, handler, "/v1/checkout/sessions/"+created.ID) + if fetched.SuccessURL != "https://localhost:8080/checkout-success?step=done" { + t.Fatalf("retrieved success_url = %q, want stored caller value", fetched.SuccessURL) + } + if fetched.BilltapReturnURL != "https://localhost:18689/checkout-success?step=done" { + t.Fatalf("billtap_return_url = %q, want run origin with caller path/query", fetched.BilltapReturnURL) + } + + completion := postJSON[struct { + Session billing.CheckoutSession `json:"session"` + BilltapReturnURL string `json:"billtap_return_url"` + }](t, handler, "/api/checkout/sessions/"+created.ID+"/complete", map[string]string{ + "outcome": "payment_succeeded", + }) + if completion.Session.SuccessURL != "https://localhost:8080/checkout-success?step=done" { + t.Fatalf("completed success_url = %q, want stored caller value", completion.Session.SuccessURL) + } + if completion.BilltapReturnURL != "https://localhost:18689/checkout-success?step=done" { + t.Fatalf("completion billtap_return_url = %q, want rewritten link", completion.BilltapReturnURL) + } + + // Portal: the response field keeps the caller value, the hosted URL query + // carries the rewritten target. 127.0.0.1 rewrites too, including scheme. + portal := postForm[struct { + URL string `json:"url"` + ReturnURL string `json:"return_url"` + }](t, handler, "/v1/billing_portal/sessions", url.Values{ + "customer": {customer.ID}, + "return_url": {"http://127.0.0.1:3000/dashboard?tab=billing"}, + }) + if portal.ReturnURL != "http://127.0.0.1:3000/dashboard?tab=billing" { + t.Fatalf("portal return_url = %q, want caller value", portal.ReturnURL) + } + parsed, err := url.Parse(portal.URL) + if err != nil { + t.Fatalf("parse portal url %q: %v", portal.URL, err) + } + if got := parsed.Query().Get("return_url"); got != "https://localhost:18689/dashboard?tab=billing" { + t.Fatalf("portal url return_url query = %q, want rewritten target", got) + } +} + +func TestLocalRedirectRewriteSkipsExternalAndUnconfigured(t *testing.T) { + seedSession := func(handler http.Handler, successURL string) (string, string) { + customer := postForm[billing.Customer](t, handler, "/v1/customers", url.Values{ + "email": {"redirect-skip@example.test"}, + }) + product := postForm[billing.Product](t, handler, "/v1/products", url.Values{"name": {"Team"}}) + price := postForm[billing.Price](t, handler, "/v1/prices", url.Values{ + "product": {product.ID}, + "currency": {"usd"}, + "unit_amount": {"9900"}, + "recurring[interval]": {"month"}, + }) + session := postForm[billing.CheckoutSession](t, handler, "/v1/checkout/sessions", url.Values{ + "customer": {customer.ID}, + "line_items[0][price]": {price.ID}, + "line_items[0][quantity]": {"1"}, + "success_url": {successURL}, + }) + return session.ID, customer.ID + } + + // External hosts are never rewritten, even with a run base configured. + configured := newTestHandlerWithOptions(t, Options{PublicBaseURL: "http://127.0.0.1:18080"}) + postForm[runConfigResponse](t, configured, "/v1/config", url.Values{ + "public_base_url": {"https://localhost:18689"}, + }) + externalID, _ := seedSession(configured, "https://accounts.example.com/checkout-success") + external := getJSON[map[string]any](t, configured, "/v1/checkout/sessions/"+externalID) + if _, ok := external["billtap_return_url"]; ok { + t.Fatalf("external success_url got billtap_return_url = %v, want none", external["billtap_return_url"]) + } + + // Without a run config, localhost redirects stay untouched even though the + // global public base is set. + unconfigured := newTestHandlerWithOptions(t, Options{PublicBaseURL: "http://127.0.0.1:18080"}) + localID, customerID := seedSession(unconfigured, "https://localhost:8080/checkout-success") + local := getJSON[map[string]any](t, unconfigured, "/v1/checkout/sessions/"+localID) + if _, ok := local["billtap_return_url"]; ok { + t.Fatalf("unconfigured run got billtap_return_url = %v, want none", local["billtap_return_url"]) + } + if got := local["success_url"]; got != "https://localhost:8080/checkout-success" { + t.Fatalf("unconfigured success_url = %v, want caller value", got) + } + portal := postForm[struct { + URL string `json:"url"` + }](t, unconfigured, "/v1/billing_portal/sessions", url.Values{ + "customer": {customerID}, + "return_url": {"https://localhost:8080/dashboard"}, + }) + parsed, err := url.Parse(portal.URL) + if err != nil { + t.Fatalf("parse portal url %q: %v", portal.URL, err) + } + if got := parsed.Query().Get("return_url"); got != "https://localhost:8080/dashboard" { + t.Fatalf("unconfigured portal return_url query = %q, want caller value", got) + } +} + func TestRunConfigRejectsInvalidPublicBase(t *testing.T) { handler := newTestHandler(t) diff --git a/internal/server/run_test.go b/internal/server/run_test.go index 4c0f3f1..ac82b6d 100644 --- a/internal/server/run_test.go +++ b/internal/server/run_test.go @@ -479,6 +479,67 @@ func TestRunScopedPublicBaseURLsPerRun(t *testing.T) { } } +func TestRunScopedRedirectRewriteFollowsRunConfig(t *testing.T) { + srv := newRunServerWithPublicBase(t, "https://localhost:8080") + + postForm[map[string]any](t, srv, "/runs/run-a/v1/config", map[string]string{ + "public_base_url": "https://localhost:18689", + }) + + seedSession := func(runPrefix string) string { + customer := postForm[struct { + ID string `json:"id"` + }](t, srv, runPrefix+"/v1/customers", map[string]string{"email": "buyer@example.test"}) + product := postForm[struct { + ID string `json:"id"` + }](t, srv, runPrefix+"/v1/products", map[string]string{"name": "Team"}) + price := postForm[struct { + ID string `json:"id"` + }](t, srv, runPrefix+"/v1/prices", map[string]string{ + "product": product.ID, + "currency": "usd", + "unit_amount": "9900", + "recurring[interval]": "month", + }) + session := postForm[struct { + ID string `json:"id"` + }](t, srv, runPrefix+"/v1/checkout/sessions", map[string]string{ + "customer": customer.ID, + "line_items[0][price]": price.ID, + "line_items[0][quantity]": "1", + "success_url": "https://localhost:8080/checkout-success", + }) + return session.ID + } + + getSession := func(path string) map[string]any { + req := httptest.NewRequest(http.MethodGet, path, nil) + rec := httptest.NewRecorder() + srv.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("GET %s status = %d body = %s", path, rec.Code, rec.Body.String()) + } + var out map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil { + t.Fatalf("decode %s: %v body=%s", path, err, rec.Body.String()) + } + return out + } + + sessionA := getSession("/runs/run-a/v1/checkout/sessions/" + seedSession("/runs/run-a")) + if got := sessionA["success_url"]; got != "https://localhost:8080/checkout-success" { + t.Fatalf("run-a success_url = %v, want caller value", got) + } + if got := sessionA["billtap_return_url"]; got != "https://localhost:18689/checkout-success" { + t.Fatalf("run-a billtap_return_url = %v, want rewritten run origin", got) + } + + sessionB := getSession("/runs/run-b/v1/checkout/sessions/" + seedSession("/runs/run-b")) + if _, ok := sessionB["billtap_return_url"]; ok { + t.Fatalf("run-b billtap_return_url = %v, want none for unconfigured run", sessionB["billtap_return_url"]) + } +} + func TestRunScopedForwardedOriginBeatsGlobalBase(t *testing.T) { srv := newRunServerWithPublicBase(t, "https://localhost:8080") headers := map[string]string{ diff --git a/web/shared/api.ts b/web/shared/api.ts index 8c7852f..e8aba67 100644 --- a/web/shared/api.ts +++ b/web/shared/api.ts @@ -852,7 +852,9 @@ function normalizeCheckoutSession(value: unknown, fallbackId: string): CheckoutS readObjectId(session.payment_intent) ?? readString(paymentIntent, ["id"], fixtureSession.paymentIntentId), paymentIntentStatus: readString(paymentIntent, ["status"], fixtureSession.paymentIntentStatus), - returnUrl: readString(session, ["return_url", "returnUrl", "success_url", "successUrl"], fixtureSession.returnUrl), + returnUrl: + readString(root, ["billtap_return_url", "billtapReturnUrl"], "") || + readString(session, ["billtap_return_url", "return_url", "returnUrl", "success_url", "successUrl"], fixtureSession.returnUrl), lineItems: normalizeLineItems(root, session), }; }