Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<runId>/v1/config` pins `public_base_url` and an
optional `public_base_path` per run, and absolute URLs (checkout
Expand Down
20 changes: 17 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 5 additions & 1 deletion docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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):

Expand Down
61 changes: 55 additions & 6 deletions internal/api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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,
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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) {
Expand Down
131 changes: 131 additions & 0 deletions internal/api/api_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
61 changes: 61 additions & 0 deletions internal/server/run_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand Down
4 changes: 3 additions & 1 deletion web/shared/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
};
}
Expand Down
Loading