server: pay-before-create — checkout + claim endpoints (cloud + home) (#1329)
* server: seed a new space's display name through the create chain
The create flow will ask for the name up front (pay-gated create, next
slice), so the whole chain learns to carry it: home POST /api/spaces {name}
→ cloud → controld → add-instance → LCHAT_GROUP_NAME in the instance env →
the instance names itself at first boot (applySeededName, seed-only — an
already-named space ignores it, and the setup conversation already offers
'Keep …' for a pre-set name). Sanitized at the control seam (control chars
would inject env-file lines) and capped at 60 everywhere the instance caps
it. Operator parity: add-instance --group-name.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* server: pay-before-create — checkout + claim endpoints (cloud + home)
The paid branch of the tier-picker create flow. Nothing is provisioned until
the money is real: checkout-create reserves a pending allocation (entitlement
'pending' — no quota, invisible to sweeps, pruned once its session can no
longer complete) and mints a Checkout Session bound to it; claim-create
verifies the session with Stripe and only then runs the normal provisioning
tail, carrying the chosen name (which never goes to Stripe — it waits on our
row). Idempotent claims, no rollback of paid rows, setup link parked in
memory for a lost-response retry. Home forwards both with the same gates as
the free create (member gate + proof of work on the mint).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
7 files changed,
+898
−9
billing.go+292 −9cloud.go+17 −0cloud_billing_test.go+288 −0cloud_db.go+90 −0home/main.go+5 −0home/provision.go+153 −0home/provision_test.go+53 −0
diff --git a/billing.go b/billing.go| index 50af797..d3af66f 100644 |
| --- a/billing.go |
| +++ b/billing.go |
| @@ -295,7 +295,7 @@ func (s *cloudServer) checkoutHandler(w http.ResponseWriter, r *http.Request) { |
| writeControlErr(w, http.StatusConflict, "already_upgraded", "this space is already upgraded") |
| return |
| } |
| - checkoutURL, err := s.billing.createCheckoutSession(id, tier, band, price, req.SuccessURL, req.CancelURL) |
| + _, checkoutURL, err := s.billing.createCheckoutSession(id, tier, band, price, req.SuccessURL, req.CancelURL, false) |
| if err != nil { |
| log.Printf("cloud: checkout for %s (id %d, tier %s, band %s): %v", host, id, tier.Key, band, err) |
| writeControlErr(w, http.StatusBadGateway, "checkout_failed", "couldn't start the checkout — please try again") |
| @@ -304,6 +304,240 @@ func (s *cloudServer) checkoutHandler(w http.ResponseWriter, r *http.Request) { |
| writeJSON(w, http.StatusOK, map[string]any{"url": checkoutURL}) |
| } |
| +// ---- pay-before-create ----------------------------------------------------------- |
| +// |
| +// The create flow's paid branch: nothing is provisioned until the money is |
| +// real. checkout-create reserves a pending allocation (entitlement 'pending' — |
| +// no quota, no sweeps, prunable) and mints a Checkout Session pointing at it; |
| +// claim-create verifies the session with Stripe and only then runs the normal |
| +// provisioning tail. The webhook needs no new arm: checkout.session.completed |
| +// marks the pending row paid by client_reference_id exactly as it does an |
| +// upgrade, and the push helpers already skip host-less rows. A paid row is |
| +// never rolled back on a provisioning failure — the claim just retries. |
| + |
| +// storeClaimLink parks a freshly-minted setup link under its session id; |
| +// takeClaimLink retrieves it without consuming (a flaky network may retry the |
| +// same claim more than once). Entries expire after an hour — past that the |
| +// admin link recovery path is the operator's, as with any lost setup link. |
| +func (s *cloudServer) storeClaimLink(session, link string) { |
| + s.claimLinksMu.Lock() |
| + defer s.claimLinksMu.Unlock() |
| + if s.claimLinks == nil { |
| + s.claimLinks = map[string]claimLink{} |
| + } |
| + for k, v := range s.claimLinks { |
| + if time.Since(v.At) > time.Hour { |
| + delete(s.claimLinks, k) |
| + } |
| + } |
| + s.claimLinks[session] = claimLink{Link: link, At: time.Now()} |
| +} |
| + |
| +func (s *cloudServer) takeClaimLink(session string) string { |
| + s.claimLinksMu.Lock() |
| + defer s.claimLinksMu.Unlock() |
| + v, ok := s.claimLinks[session] |
| + if !ok || time.Since(v.At) > time.Hour { |
| + return "" |
| + } |
| + return v.Link |
| +} |
| + |
| +// checkoutCreateHandler: POST /api/instances/checkout-create — start a paid |
| +// space's life as a Checkout Session bound to a pending allocation. Same |
| +// validation surface as the upgrade checkout plus the create-time extras |
| +// (name); the fleet create budget counts these, or session spam would |
| +// sidestep the breaker. |
| +func (s *cloudServer) checkoutCreateHandler(w http.ResponseWriter, r *http.Request) { |
| + callerID := r.Context().Value(callerKey{}).(string) |
| + if s.billing == nil { |
| + writeControlErr(w, http.StatusNotImplemented, "billing_unavailable", "this provisioner does not offer paid spaces") |
| + return |
| + } |
| + var req struct { |
| + Requester string `json:"requester"` |
| + Tier string `json:"tier"` |
| + Band string `json:"band"` |
| + Name string `json:"name"` |
| + SuccessURL string `json:"success_url"` |
| + CancelURL string `json:"cancel_url"` |
| + } |
| + if err := json.NewDecoder(io.LimitReader(r.Body, 64<<10)).Decode(&req); err != nil { |
| + writeControlErr(w, http.StatusBadRequest, "bad_request", "invalid JSON body") |
| + return |
| + } |
| + req.Requester = strings.TrimSpace(req.Requester) |
| + if req.Requester == "" || len(req.Requester) > 128 || !validRedirectURL(req.SuccessURL) || !validRedirectURL(req.CancelURL) { |
| + writeControlErr(w, http.StatusBadRequest, "bad_request", "requester, success_url and cancel_url required") |
| + return |
| + } |
| + req.Name = strings.TrimSpace(req.Name) |
| + if len(req.Name) > 60 { |
| + writeControlErr(w, http.StatusBadRequest, "bad_request", "name must be at most 60 characters") |
| + return |
| + } |
| + tier, ok := s.billing.tier(req.Tier) |
| + if !ok { |
| + writeControlErr(w, http.StatusBadRequest, "bad_request", "unknown tier") |
| + return |
| + } |
| + band := strings.ToLower(strings.TrimSpace(req.Band)) |
| + if band == "" { |
| + band = bandBase |
| + } |
| + price, _, ok := bandPrice(tier, band) |
| + if !ok { |
| + writeControlErr(w, http.StatusBadRequest, "bad_request", "unknown band for this tier") |
| + return |
| + } |
| + // The same trailing-24h breaker as the free path — a pending allocation IS |
| + // a create for budget purposes. |
| + if s.createBudget > 0 { |
| + if n, err := s.db.createsSince(time.Now().Add(-24 * time.Hour).Unix()); err == nil && n >= s.createBudget { |
| + log.Printf("cloud: creation budget exhausted (%d/%d in 24h) — refusing paid create", n, s.createBudget) |
| + writeControlErr(w, http.StatusServiceUnavailable, "at_capacity", spaceUnavailableMsg) |
| + return |
| + } |
| + } |
| + // Housekeeping on the way in: allocations whose checkout expired long ago |
| + // (sessions die at 24h; 48h leaves margin) go quietly. |
| + if n, err := s.db.prunePendingCreates(time.Now().Add(-48 * time.Hour).Unix()); err == nil && n > 0 { |
| + log.Printf("cloud: pruned %d expired pending create(s)", n) |
| + } |
| + id, err := s.db.allocatePendingPaidSpace(callerID, req.Requester, req.Name) |
| + if err != nil { |
| + writeControlErr(w, http.StatusInternalServerError, "checkout_failed", "db error") |
| + return |
| + } |
| + session, checkoutURL, err := s.billing.createCheckoutSession(id, tier, band, price, req.SuccessURL, req.CancelURL, true) |
| + if err != nil { |
| + s.db.deleteSpaceByID(id) // nothing bought yet — roll the allocation back |
| + log.Printf("cloud: checkout-create (id %d, tier %s, band %s): %v", id, tier.Key, band, err) |
| + writeControlErr(w, http.StatusBadGateway, "checkout_failed", "couldn't start the checkout — please try again") |
| + return |
| + } |
| + if err := s.db.setCheckoutSession(id, session); err != nil { |
| + log.Printf("cloud: record session for id %d: %v", id, err) // audit trail only — the claim re-derives everything from Stripe |
| + } |
| + log.Printf("cloud: checkout-create session for id %d (tier %s, band %s, caller %.8s…)", id, tier.Key, band, callerID) |
| + writeJSON(w, http.StatusOK, map[string]any{"url": checkoutURL}) |
| +} |
| + |
| +// claimCreateHandler: POST /api/instances/claim-create — turn a completed |
| +// pay-first checkout into a live space. Everything trusted comes from Stripe |
| +// (the caller only names a session); ownership is the pending row's |
| +// (caller, requester) pair. Idempotent: a provisioned row answers 200 with its |
| +// host (and the parked setup link when the first response was lost); a |
| +// provisioning failure keeps the paid row so a retry finishes the job — money |
| +// is never rolled back. |
| +func (s *cloudServer) claimCreateHandler(w http.ResponseWriter, r *http.Request) { |
| + callerID := r.Context().Value(callerKey{}).(string) |
| + if s.billing == nil { |
| + writeControlErr(w, http.StatusNotImplemented, "billing_unavailable", "this provisioner does not offer paid spaces") |
| + return |
| + } |
| + var req struct { |
| + Requester string `json:"requester"` |
| + SessionID string `json:"session_id"` |
| + } |
| + if err := json.NewDecoder(io.LimitReader(r.Body, 8<<10)).Decode(&req); err != nil { |
| + writeControlErr(w, http.StatusBadRequest, "bad_request", "invalid JSON body") |
| + return |
| + } |
| + req.Requester = strings.TrimSpace(req.Requester) |
| + req.SessionID = strings.TrimSpace(req.SessionID) |
| + if req.Requester == "" || req.SessionID == "" { |
| + writeControlErr(w, http.StatusBadRequest, "bad_request", "requester and session_id required") |
| + return |
| + } |
| + sess, err := s.billing.retrieveCheckoutSession(req.SessionID) |
| + if err != nil { |
| + log.Printf("cloud: claim-create retrieve %s: %v", req.SessionID, err) |
| + writeControlErr(w, http.StatusBadGateway, "claim_failed", "couldn't confirm the payment — please try again") |
| + return |
| + } |
| + // Only a session WE minted for a pending create may provision: the marker |
| + // keeps an upgrade session (same shape, real payment) from ever creating a |
| + // second space. |
| + id, perr := strconv.ParseInt(sess.ClientReferenceID, 10, 64) |
| + if perr != nil || sess.Metadata["pending_create"] != "1" { |
| + writeControlErr(w, http.StatusNotFound, "not_found", "no pending space for this checkout") |
| + return |
| + } |
| + row, ok, err := s.db.pendingSpaceByID(id) |
| + if err != nil { |
| + writeControlErr(w, http.StatusInternalServerError, "claim_failed", "db error") |
| + return |
| + } |
| + if !ok || row.CallerID != callerID || row.Requester != req.Requester { |
| + writeControlErr(w, http.StatusNotFound, "not_found", "no pending space for this checkout") |
| + return |
| + } |
| + if row.Host != "" { |
| + // Already provisioned (an earlier claim finished) — answer with what |
| + // exists. The parked setup link rides along when we still hold it. |
| + out := map[string]any{"host": row.Host, "url": "https://" + row.Host} |
| + if link := s.takeClaimLink(req.SessionID); link != "" { |
| + out["setup_link"] = link |
| + } |
| + writeJSON(w, http.StatusOK, out) |
| + return |
| + } |
| + if sess.PaymentStatus != "paid" { |
| + writeControlErr(w, http.StatusPaymentRequired, "not_paid", "the checkout hasn't completed — finish paying first") |
| + return |
| + } |
| + // Record what the checkout bought. The webhook usually got here first |
| + // (markSpacePaid is idempotent either way); doing it ourselves means the |
| + // claim never waits on webhook delivery. |
| + if err := s.db.markSpacePaid(id, sess.Customer, sess.Subscription, sess.Metadata["tier"], sess.Metadata["band"]); err != nil && err != sql.ErrNoRows { |
| + writeControlErr(w, http.StatusInternalServerError, "claim_failed", "db error") |
| + return |
| + } |
| + // The normal provisioning tail — but no rollback on failure: the payment |
| + // is real, so the row (entitlement paid, host NULL) survives every error |
| + // below and the next claim retries the whole tail. |
| + hostID, err := s.spaceHostID(r.Context(), id) |
| + if err != nil { |
| + log.Printf("cloud: claim-create id ledger for %d: %v", id, err) |
| + writeControlErr(w, http.StatusServiceUnavailable, "claim_failed", claimRetryMsg) |
| + return |
| + } |
| + slug, err := spaceSlug(hostID) |
| + if err != nil { |
| + log.Printf("cloud: claim-create slug for %d: %v", id, err) |
| + writeControlErr(w, http.StatusInternalServerError, "claim_failed", claimRetryMsg) |
| + return |
| + } |
| + host := slug + "." + s.baseDomain |
| + status, body, err := s.controlCreate(host, entPaid, row.PendingName) |
| + if err != nil || status != http.StatusCreated { |
| + log.Printf("cloud: claim-create control for %s (id %d): status %d, %v", host, id, status, err) |
| + writeControlErr(w, http.StatusBadGateway, "claim_failed", claimRetryMsg) |
| + return |
| + } |
| + if err := s.db.finalizeSpace(id, host); err != nil { |
| + log.Printf("cloud: claim-create finalize %s (id %d): %v", host, id, err) |
| + } |
| + log.Printf("cloud: claim-create provisioned %s (id %d, caller %.8s…)", host, id, callerID) |
| + s.pushQuota(id, host, entPaid, sess.Metadata["tier"]) |
| + if s.usageURL != "" { |
| + s.pushMeterToken(id, host) |
| + } |
| + if link, _ := body["setup_link"].(string); link != "" { |
| + s.storeClaimLink(req.SessionID, link) |
| + } |
| + writeJSON(w, http.StatusCreated, map[string]any{ |
| + "host": host, |
| + "url": body["url"], |
| + "setup_link": body["setup_link"], |
| + }) |
| +} |
| + |
| +// claimRetryMsg is the one voice for every retriable claim failure: the money |
| +// is safe, the space is coming, nothing needs undoing. |
| +const claimRetryMsg = "your payment is confirmed but the space couldn't be set up just now — try again in a moment" |
| + |
| // portalHandler: POST /api/instances/{host}/portal — mint a Stripe Billing |
| // Portal session for one UPGRADED space, so its payer can cancel, change |
| // card, or read invoices on Stripe's hosted page. Same gates as checkout: |
| @@ -445,8 +679,11 @@ func validRedirectURL(raw string) bool { |
| // ledger id rides in client_reference_id (routes checkout.session.completed) |
| // and in the subscription's metadata (routes every later subscription event); |
| // the tier + band keys ride in the session metadata so completion records what |
| -// was bought. price is the band-resolved Stripe price id. |
| -func (b *stripeBilling) createCheckoutSession(spaceID int64, tier billingTier, band, price, successURL, cancelURL string) (string, error) { |
| +// was bought. price is the band-resolved Stripe price id. pendingCreate marks |
| +// a pay-first session (the row is an allocation, not a space yet) — the claim |
| +// path demands the marker so an upgrade session can never provision anything. |
| +// Returns the session's id and hosted-page URL. |
| +func (b *stripeBilling) createCheckoutSession(spaceID int64, tier billingTier, band, price, successURL, cancelURL string, pendingCreate bool) (sessionID, sessionURL string, err error) { |
| id := strconv.FormatInt(spaceID, 10) |
| form := url.Values{} |
| form.Set("mode", "subscription") |
| @@ -455,6 +692,9 @@ func (b *stripeBilling) createCheckoutSession(spaceID int64, tier billingTier, b |
| form.Set("client_reference_id", id) |
| form.Set("metadata[tier]", tier.Key) |
| form.Set("metadata[band]", band) |
| + if pendingCreate { |
| + form.Set("metadata[pending_create]", "1") |
| + } |
| form.Set("subscription_data[metadata][space_id]", id) |
| form.Set("subscription_data[metadata][tier]", tier.Key) |
| form.Set("subscription_data[metadata][band]", band) |
| @@ -477,31 +717,74 @@ func (b *stripeBilling) createCheckoutSession(spaceID int64, tier billingTier, b |
| req, err := http.NewRequest("POST", b.apiBase+"/v1/checkout/sessions", strings.NewReader(form.Encode())) |
| if err != nil { |
| - return "", err |
| + return "", "", err |
| } |
| req.Header.Set("Authorization", "Bearer "+b.key) |
| req.Header.Set("Content-Type", "application/x-www-form-urlencoded") |
| resp, err := b.client.Do(req) |
| if err != nil { |
| - return "", err |
| + return "", "", err |
| } |
| defer resp.Body.Close() |
| var body struct { |
| + ID string `json:"id"` |
| URL string `json:"url"` |
| Error struct { |
| Message string `json:"message"` |
| } `json:"error"` |
| } |
| if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&body); err != nil { |
| - return "", fmt.Errorf("stripe: HTTP %d (unparseable body)", resp.StatusCode) |
| + return "", "", fmt.Errorf("stripe: HTTP %d (unparseable body)", resp.StatusCode) |
| } |
| if resp.StatusCode != http.StatusOK { |
| - return "", fmt.Errorf("stripe: HTTP %d: %s", resp.StatusCode, body.Error.Message) |
| + return "", "", fmt.Errorf("stripe: HTTP %d: %s", resp.StatusCode, body.Error.Message) |
| } |
| if body.URL == "" { |
| - return "", fmt.Errorf("stripe: session created but no url in response") |
| + return "", "", fmt.Errorf("stripe: session created but no url in response") |
| } |
| - return body.URL, nil |
| + return body.ID, body.URL, nil |
| +} |
| + |
| +// stripeCheckoutSession is the slice of a retrieved Checkout Session the claim |
| +// path needs: is it paid, which allocation is it for, and what did it buy. |
| +type stripeCheckoutSession struct { |
| + ID string `json:"id"` |
| + Mode string `json:"mode"` |
| + PaymentStatus string `json:"payment_status"` |
| + ClientReferenceID string `json:"client_reference_id"` |
| + Customer string `json:"customer"` |
| + Subscription string `json:"subscription"` |
| + Metadata map[string]string `json:"metadata"` |
| +} |
| + |
| +// retrieveCheckoutSession reads one session back from Stripe — the claim's |
| +// authority. The caller only ever supplies the session ID; everything trusted |
| +// (payment state, which row, what was bought) comes signed by this fetch. |
| +func (b *stripeBilling) retrieveCheckoutSession(sessionID string) (stripeCheckoutSession, error) { |
| + var sess stripeCheckoutSession |
| + req, err := http.NewRequest("GET", b.apiBase+"/v1/checkout/sessions/"+url.PathEscape(sessionID), nil) |
| + if err != nil { |
| + return sess, err |
| + } |
| + req.Header.Set("Authorization", "Bearer "+b.key) |
| + resp, err := b.client.Do(req) |
| + if err != nil { |
| + return sess, err |
| + } |
| + defer resp.Body.Close() |
| + var body struct { |
| + stripeCheckoutSession |
| + Error struct { |
| + Message string `json:"message"` |
| + } `json:"error"` |
| + } |
| + if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&body); err != nil { |
| + return sess, fmt.Errorf("stripe: HTTP %d (unparseable body)", resp.StatusCode) |
| + } |
| + if resp.StatusCode != http.StatusOK || body.ID == "" { |
| + return sess, fmt.Errorf("stripe: HTTP %d: %s", resp.StatusCode, body.Error.Message) |
| + } |
| + return body.stripeCheckoutSession, nil |
| } |
| // ---- webhook ------------------------------------------------------------------- |
diff --git a/cloud.go b/cloud.go| index 70382aa..f90e177 100644 |
| --- a/cloud.go |
| +++ b/cloud.go |
| @@ -27,6 +27,7 @@ import ( |
| sqids "github.com/sqids/sqids-go" |
| "lchat/internal/idalloc" |
| + "sync" |
| ) |
| // spaceUnavailableMsg is the member-facing "we're full" message, relayed |
| @@ -124,6 +125,18 @@ type cloudServer struct { |
| aiRates map[string]aiRates |
| aiAllowanceEUR float64 // LCHAT_CLOUD_AI_ALLOWANCE_EUR |
| aiPoolEUR float64 // LCHAT_CLOUD_AI_POOL_EUR |
| + // claimLinks parks a pay-first create's one-time setup link between the |
| + // provision and the redirect landing (billing.go claimCreateHandler) so a |
| + // claim retry after a dropped response can still hand it over. Memory only, |
| + // short-lived, single process — the link is never persisted anywhere. |
| + claimLinksMu sync.Mutex |
| + claimLinks map[string]claimLink |
| +} |
| + |
| +// claimLink is one parked setup link (claimLinks); At bounds its life. |
| +type claimLink struct { |
| + Link string |
| + At time.Time |
| } |
| func (s *cloudServer) handler() http.Handler { |
| @@ -135,6 +148,10 @@ func (s *cloudServer) handler() http.Handler { |
| // Billing (optional — 501 unless Stripe is configured; see billing.go). The |
| // webhook is signature-gated rather than token-gated: Stripe calls it. |
| mux.HandleFunc("POST /api/instances/{host}/checkout", s.callerAuth(s.checkoutHandler)) |
| + // Pay-before-create (billing.go): mint a checkout for a space that doesn't |
| + // exist yet, then provision it once the payment confirms. |
| + mux.HandleFunc("POST /api/instances/checkout-create", s.callerAuth(s.checkoutCreateHandler)) |
| + mux.HandleFunc("POST /api/instances/claim-create", s.callerAuth(s.claimCreateHandler)) |
| // Claiming a demo (demo.go) — additive, so caller-token gated like creation. |
| mux.HandleFunc("POST /api/instances/{host}/claim", s.callerAuth(s.claimDemoHandler)) |
| // The countdown banner's live "still a demo?" check (demo.go) — read-only, |
diff --git a/cloud_billing_test.go b/cloud_billing_test.go| index 9643a93..d7f6cdc 100644 |
| --- a/cloud_billing_test.go |
| +++ b/cloud_billing_test.go |
| @@ -556,6 +556,294 @@ func TestPortalSession(t *testing.T) { |
| } |
| } |
| +// ---- pay-before-create ----------------------------------------------------------- |
| + |
| +// payFirstRig: a cloud with billing pointed at a Stripe stub that mints |
| +// checkout sessions (capturing the form) and serves one retrievable session |
| +// (mutable — tests shape it per step), plus a control that records the last |
| +// create and can be told to fail n times. |
| +func payFirstRig(t *testing.T) (srv *httptest.Server, s *cloudServer, form *url.Values, sess *map[string]any, lastCreate *controlCreateReq, ctlFails *int) { |
| + t.Helper() |
| + form = &url.Values{} |
| + sess = &map[string]any{} |
| + stripe := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + switch { |
| + case r.Method == "POST" && r.URL.Path == "/v1/checkout/sessions": |
| + r.ParseForm() |
| + *form = r.PostForm |
| + writeJSON(w, http.StatusOK, map[string]any{ |
| + "id": "cs_pf_1", "url": "https://checkout.stripe.com/c/pay/cs_pf_1", |
| + }) |
| + case r.Method == "GET" && strings.HasPrefix(r.URL.Path, "/v1/checkout/sessions/"): |
| + writeJSON(w, http.StatusOK, *sess) |
| + default: |
| + w.WriteHeader(http.StatusNotFound) |
| + } |
| + })) |
| + t.Cleanup(stripe.Close) |
| + lastCreate = &controlCreateReq{} |
| + ctlFails = new(int) |
| + ctl := fakeControl(t, func(w http.ResponseWriter, r *http.Request) { |
| + var req controlCreateReq |
| + json.NewDecoder(r.Body).Decode(&req) |
| + *lastCreate = req |
| + if *ctlFails > 0 { |
| + *ctlFails-- |
| + writeControlErr(w, http.StatusInternalServerError, "create_failed", "boom") |
| + return |
| + } |
| + writeJSON(w, http.StatusCreated, map[string]any{ |
| + "host": req.Host, "url": "https://" + req.Host, "setup_link": "https://" + req.Host + "/join/setup#PF", |
| + }) |
| + }) |
| + srv, s = newTestCloud(t, ctl) |
| + s.billing = &stripeBilling{ |
| + apiBase: stripe.URL, key: "sk_test", webhookSecret: "whsec_test", |
| + tiers: []billingTier{{Key: "family", Name: "Family", Label: "€5.50/month", Price: "price_family_m"}}, |
| + client: http.DefaultClient, |
| + } |
| + return |
| +} |
| + |
| +// postCloud posts a JSON body to a caller-token-gated cloud endpoint. |
| +func postCloud(t *testing.T, srv *httptest.Server, tok, path string, body map[string]string) *http.Response { |
| + t.Helper() |
| + payload, _ := json.Marshal(body) |
| + req, _ := http.NewRequest("POST", srv.URL+path, bytes.NewReader(payload)) |
| + req.Header.Set("Authorization", "Bearer "+tok) |
| + resp, err := http.DefaultClient.Do(req) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + t.Cleanup(func() { resp.Body.Close() }) |
| + return resp |
| +} |
| + |
| +// paidSessionFor shapes the stub's retrievable session as a completed pay-first |
| +// checkout for ledger row id. |
| +func paidSessionFor(id int64) map[string]any { |
| + return map[string]any{ |
| + "id": "cs_pf_1", "mode": "subscription", "payment_status": "paid", |
| + "client_reference_id": fmt.Sprintf("%d", id), |
| + "customer": "cus_pf", "subscription": "sub_pf", |
| + "metadata": map[string]string{"pending_create": "1", "tier": "family", "band": "base"}, |
| + } |
| +} |
| + |
| +func TestCheckoutCreateMintsPendingSession(t *testing.T) { |
| + srv, s, form, _, _, _ := payFirstRig(t) |
| + tok := mintCaller(t, srv, "home") |
| + |
| + r := postCloud(t, srv, tok, "/api/instances/checkout-create", map[string]string{ |
| + "requester": "user-1", "tier": "family", "name": " Book Club ", |
| + "success_url": "https://home.example.com/?paidcreate={CHECKOUT_SESSION_ID}", |
| + "cancel_url": "https://home.example.com/", |
| + }) |
| + if r.StatusCode != http.StatusOK { |
| + t.Fatalf("checkout-create: %d, want 200", r.StatusCode) |
| + } |
| + if u, _ := decodeBody(t, r)["url"].(string); !strings.Contains(u, "checkout.stripe.com") { |
| + t.Errorf("url = %q", u) |
| + } |
| + f := *form |
| + if f.Get("metadata[pending_create]") != "1" || f.Get("client_reference_id") != "1" || f.Get("line_items[0][price]") != "price_family_m" { |
| + t.Errorf("session form = %v", f) |
| + } |
| + // No name in Stripe's metadata — it stays in OUR ledger, on the pending row. |
| + for k, vs := range f { |
| + for _, v := range vs { |
| + if strings.Contains(v, "Book Club") { |
| + t.Errorf("space name leaked to Stripe via %s=%q", k, v) |
| + } |
| + } |
| + } |
| + row, ok, err := s.db.pendingSpaceByID(1) |
| + if err != nil || !ok { |
| + t.Fatalf("pending row: %v %v", ok, err) |
| + } |
| + if row.Entitlement != entPending || row.PendingName != "Book Club" || row.Host != "" { |
| + t.Errorf("row = %+v, want a pending, host-less allocation named Book Club", row) |
| + } |
| + |
| + // Validation: an oversized name and an unknown tier never reach Stripe. |
| + if r := postCloud(t, srv, tok, "/api/instances/checkout-create", map[string]string{ |
| + "requester": "user-1", "tier": "family", "name": strings.Repeat("x", 61), |
| + "success_url": "https://h/", "cancel_url": "https://h/", |
| + }); r.StatusCode != http.StatusBadRequest { |
| + t.Errorf("61-char name: %d, want 400", r.StatusCode) |
| + } |
| + if r := postCloud(t, srv, tok, "/api/instances/checkout-create", map[string]string{ |
| + "requester": "user-1", "tier": "platinum", |
| + "success_url": "https://h/", "cancel_url": "https://h/", |
| + }); r.StatusCode != http.StatusBadRequest { |
| + t.Errorf("unknown tier: %d, want 400", r.StatusCode) |
| + } |
| +} |
| + |
| +func TestClaimCreateProvisions(t *testing.T) { |
| + srv, s, _, sess, lastCreate, _ := payFirstRig(t) |
| + tok := mintCaller(t, srv, "home") |
| + if r := postCloud(t, srv, tok, "/api/instances/checkout-create", map[string]string{ |
| + "requester": "user-1", "tier": "family", "name": "Book Club", |
| + "success_url": "https://h/?paidcreate={CHECKOUT_SESSION_ID}", "cancel_url": "https://h/", |
| + }); r.StatusCode != http.StatusOK { |
| + t.Fatalf("checkout-create: %d", r.StatusCode) |
| + } |
| + *sess = paidSessionFor(1) |
| + |
| + r := postCloud(t, srv, tok, "/api/instances/claim-create", map[string]string{ |
| + "requester": "user-1", "session_id": "cs_pf_1", |
| + }) |
| + if r.StatusCode != http.StatusCreated { |
| + t.Fatalf("claim: %d, want 201", r.StatusCode) |
| + } |
| + body := decodeBody(t, r) |
| + host, _ := body["host"].(string) |
| + if host == "" || !strings.HasSuffix(host, ".example.com") { |
| + t.Fatalf("host = %q", host) |
| + } |
| + if link, _ := body["setup_link"].(string); !strings.HasPrefix(link, "https://"+host+"/join/setup#") { |
| + t.Errorf("setup_link = %q", link) |
| + } |
| + // The chosen name rode into the create; the row is a finalized paid space. |
| + if lastCreate.Name != "Book Club" || lastCreate.Entitlement != entPaid { |
| + t.Errorf("control saw %+v, want name Book Club, entitlement paid", *lastCreate) |
| + } |
| + row, _, _ := s.db.pendingSpaceByID(1) |
| + if row.Host != host || row.Entitlement != entPaid { |
| + t.Errorf("row after claim = %+v", row) |
| + } |
| + |
| + // Idempotent: a second claim answers 200 with the host and the parked link. |
| + r2 := postCloud(t, srv, tok, "/api/instances/claim-create", map[string]string{ |
| + "requester": "user-1", "session_id": "cs_pf_1", |
| + }) |
| + if r2.StatusCode != http.StatusOK { |
| + t.Fatalf("re-claim: %d, want 200", r2.StatusCode) |
| + } |
| + b2 := decodeBody(t, r2) |
| + if b2["host"] != host || b2["setup_link"] == nil { |
| + t.Errorf("re-claim body = %v, want host + parked setup_link", b2) |
| + } |
| +} |
| + |
| +func TestClaimCreateGates(t *testing.T) { |
| + srv, _, _, sess, _, _ := payFirstRig(t) |
| + tok := mintCaller(t, srv, "home") |
| + if r := postCloud(t, srv, tok, "/api/instances/checkout-create", map[string]string{ |
| + "requester": "user-1", "tier": "family", |
| + "success_url": "https://h/", "cancel_url": "https://h/", |
| + }); r.StatusCode != http.StatusOK { |
| + t.Fatalf("checkout-create: %d", r.StatusCode) |
| + } |
| + |
| + // Unpaid session → 402, nothing provisioned. |
| + unpaid := paidSessionFor(1) |
| + unpaid["payment_status"] = "unpaid" |
| + *sess = unpaid |
| + if r := postCloud(t, srv, tok, "/api/instances/claim-create", map[string]string{ |
| + "requester": "user-1", "session_id": "cs_pf_1", |
| + }); r.StatusCode != http.StatusPaymentRequired { |
| + t.Errorf("unpaid claim: %d, want 402", r.StatusCode) |
| + } |
| + |
| + // Someone else's requester → 404 (ownership is the pending row's pair). |
| + *sess = paidSessionFor(1) |
| + if r := postCloud(t, srv, tok, "/api/instances/claim-create", map[string]string{ |
| + "requester": "someone-else", "session_id": "cs_pf_1", |
| + }); r.StatusCode != http.StatusNotFound { |
| + t.Errorf("wrong requester: %d, want 404", r.StatusCode) |
| + } |
| + |
| + // A session without the pending_create marker (an upgrade checkout) can |
| + // never provision — even fully paid. |
| + upgrade := paidSessionFor(1) |
| + upgrade["metadata"] = map[string]string{"tier": "family", "band": "base"} |
| + *sess = upgrade |
| + if r := postCloud(t, srv, tok, "/api/instances/claim-create", map[string]string{ |
| + "requester": "user-1", "session_id": "cs_pf_1", |
| + }); r.StatusCode != http.StatusNotFound { |
| + t.Errorf("upgrade-session claim: %d, want 404", r.StatusCode) |
| + } |
| +} |
| + |
| +func TestClaimCreateRetriesAfterControlFailure(t *testing.T) { |
| + srv, s, _, sess, _, ctlFails := payFirstRig(t) |
| + tok := mintCaller(t, srv, "home") |
| + if r := postCloud(t, srv, tok, "/api/instances/checkout-create", map[string]string{ |
| + "requester": "user-1", "tier": "family", "name": "Book Club", |
| + "success_url": "https://h/", "cancel_url": "https://h/", |
| + }); r.StatusCode != http.StatusOK { |
| + t.Fatalf("checkout-create: %d", r.StatusCode) |
| + } |
| + *sess = paidSessionFor(1) |
| + |
| + // First claim hits a control failure: 502, but the PAID row survives — |
| + // money is never rolled back. |
| + *ctlFails = 1 |
| + if r := postCloud(t, srv, tok, "/api/instances/claim-create", map[string]string{ |
| + "requester": "user-1", "session_id": "cs_pf_1", |
| + }); r.StatusCode != http.StatusBadGateway { |
| + t.Fatalf("claim with failing control: %d, want 502", r.StatusCode) |
| + } |
| + row, ok, _ := s.db.pendingSpaceByID(1) |
| + if !ok || row.Entitlement != entPaid || row.Host != "" { |
| + t.Fatalf("row after failed claim = %+v (ok=%v), want kept, paid, host-less", row, ok) |
| + } |
| + |
| + // The retry finishes the job. |
| + if r := postCloud(t, srv, tok, "/api/instances/claim-create", map[string]string{ |
| + "requester": "user-1", "session_id": "cs_pf_1", |
| + }); r.StatusCode != http.StatusCreated { |
| + t.Errorf("retry claim: %d, want 201", r.StatusCode) |
| + } |
| +} |
| + |
| +func TestPrunePendingCreates(t *testing.T) { |
| + _, s, _, _, _, _ := payFirstRig(t) |
| + old := time.Now().Add(-72 * time.Hour).Unix() |
| + |
| + stale, err := s.db.allocatePendingPaidSpace("c1", "user-1", "Stale") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + paidStuck, err := s.db.allocatePendingPaidSpace("c1", "user-1", "Paid but unclaimed") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := s.db.markSpacePaid(paidStuck, "cus_1", "sub_1", "family", ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + fresh, err := s.db.allocatePendingPaidSpace("c1", "user-1", "Fresh") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + // Backdate the first two past the cutoff. |
| + for _, id := range []int64{stale, paidStuck} { |
| + if _, err := s.db.sql.Exec(`UPDATE spaces SET created_at = ? WHERE id = ?`, old, id); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + |
| + n, err := s.db.prunePendingCreates(time.Now().Add(-48 * time.Hour).Unix()) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if n != 1 { |
| + t.Errorf("pruned %d, want 1 (only the stale pending row)", n) |
| + } |
| + if _, ok, _ := s.db.pendingSpaceByID(stale); ok { |
| + t.Error("stale pending row survived the prune") |
| + } |
| + // A paid-but-unclaimed row is money — never pruned; a fresh pending stays. |
| + if _, ok, _ := s.db.pendingSpaceByID(paidStuck); !ok { |
| + t.Error("paid row was pruned") |
| + } |
| + if _, ok, _ := s.db.pendingSpaceByID(fresh); !ok { |
| + t.Error("fresh pending row was pruned") |
| + } |
| +} |
| + |
| // ---- destroy cancels the subscription ------------------------------------------ |
| // destroyRig builds a cloud whose control answers create + delete and whose |
diff --git a/cloud_db.go b/cloud_db.go| index f07f831..a86faa5 100644 |
| --- a/cloud_db.go |
| +++ b/cloud_db.go |
| @@ -327,6 +327,18 @@ ALTER TABLE spaces ADD COLUMN paid_until INTEGER NOT NULL DEFAULT 0;`); err != n |
| return err |
| } |
| } |
| + // Pay-before-create (the tier picker in the create flow): the Checkout |
| + // Session bound to a pending allocation, and the name chosen at create — |
| + // held here (never in Stripe metadata) until the claim provisions it. |
| + if has, err := db.spacesHasColumn("checkout_session_id"); err != nil { |
| + return err |
| + } else if !has { |
| + if _, err := db.sql.Exec(` |
| +ALTER TABLE spaces ADD COLUMN checkout_session_id TEXT; |
| +ALTER TABLE spaces ADD COLUMN pending_name TEXT NOT NULL DEFAULT ''`); err != nil { |
| + return err |
| + } |
| + } |
| return nil |
| } |
| @@ -503,6 +515,79 @@ func (db *cloudDB) allocateDemoSpace(callerID, requester string) (int64, error) |
| return id, tx.Commit() |
| } |
| +// allocatePendingPaidSpace reserves a pay-first row (the tier picker's paid |
| +// branch): entitlement 'pending' keeps it outside the free quota and every |
| +// sweep, and the chosen display name waits here for the claim. It still feeds |
| +// creates_log — minting checkouts is a create for the budget breaker's |
| +// purposes, or session spam would sidestep it. |
| +func (db *cloudDB) allocatePendingPaidSpace(callerID, requester, name string) (int64, error) { |
| + tx, err := db.sql.Begin() |
| + if err != nil { |
| + return 0, err |
| + } |
| + defer tx.Rollback() |
| + now := time.Now().Unix() |
| + res, err := tx.Exec( |
| + `INSERT INTO spaces(host, caller_id, requester, created_at, entitlement, pending_name) VALUES(NULL, ?, ?, ?, ?, ?)`, |
| + callerID, requester, now, entPending, name, |
| + ) |
| + if err != nil { |
| + return 0, err |
| + } |
| + if _, err := tx.Exec(`INSERT INTO creates_log(created_at) VALUES(?)`, now); err != nil { |
| + return 0, err |
| + } |
| + id, err := res.LastInsertId() |
| + if err != nil { |
| + return 0, err |
| + } |
| + return id, tx.Commit() |
| +} |
| + |
| +// setCheckoutSession binds the Stripe Checkout Session to its pending row — |
| +// audit and debugging; the claim's authority is the session Stripe returns. |
| +func (db *cloudDB) setCheckoutSession(id int64, session string) error { |
| + _, err := db.sql.Exec(`UPDATE spaces SET checkout_session_id = ? WHERE id = ?`, session, id) |
| + return err |
| +} |
| + |
| +// pendingSpace reads the claim path's view of one allocation: who it belongs |
| +// to, whether it's provisioned yet, and the name chosen at create. |
| +type pendingSpace struct { |
| + ID int64 |
| + CallerID string |
| + Requester string |
| + Host string // "" until provisioned |
| + Entitlement string |
| + PendingName string |
| +} |
| + |
| +func (db *cloudDB) pendingSpaceByID(id int64) (pendingSpace, bool, error) { |
| + p := pendingSpace{ID: id} |
| + err := db.sql.QueryRow( |
| + `SELECT caller_id, requester, COALESCE(host, ''), entitlement, pending_name FROM spaces WHERE id = ?`, id, |
| + ).Scan(&p.CallerID, &p.Requester, &p.Host, &p.Entitlement, &p.PendingName) |
| + if err == sql.ErrNoRows { |
| + return pendingSpace{}, false, nil |
| + } |
| + if err != nil { |
| + return pendingSpace{}, false, err |
| + } |
| + return p, true, nil |
| +} |
| + |
| +// prunePendingCreates drops pending allocations whose checkout can no longer |
| +// complete (Stripe sessions expire after 24h; the cutoff arrives well past |
| +// that). A PAID row is never pending, so money is never pruned. |
| +func (db *cloudDB) prunePendingCreates(cutoff int64) (int64, error) { |
| + res, err := db.sql.Exec( |
| + `DELETE FROM spaces WHERE entitlement = ? AND host IS NULL AND created_at <= ?`, entPending, cutoff) |
| + if err != nil { |
| + return 0, err |
| + } |
| + return res.RowsAffected() |
| +} |
| + |
| // countDemos counts every live demo row, pending creates included — the |
| // live-demo cap must see a create the moment it's reserved, not once it's up. |
| func (db *cloudDB) countDemos() (int, error) { |
| @@ -683,6 +768,11 @@ const ( |
| entFree = "free" |
| entPaid = "paid" |
| entDemo = "demo" // 24h try-it spaces (docs/demo-spaces.md, demo.go) |
| + // entPending marks a pay-first allocation awaiting its checkout: not a |
| + // space yet (host NULL, never listed, never quota-counted, invisible to |
| + // the reaper's free-only sweep) — it either becomes paid at claim time or |
| + // is pruned once its checkout can no longer complete. |
| + entPending = "pending" |
| ) |
| // spaceEntitlement reads one space's entitlement flag. |
diff --git a/home/main.go b/home/main.go| index 2104055..6f90a9c 100644 |
| --- a/home/main.go |
| +++ b/home/main.go |
| @@ -468,6 +468,11 @@ func (s *Server) handler() http.Handler { |
| // server doesn't offer it (public — it reveals only whether the feature is on). |
| mux.HandleFunc("GET /api/features", s.featuresHandler) |
| mux.HandleFunc("POST /api/spaces", s.rateLimited(s.createSpaceHandler)) |
| + // Pay-before-create (provision.go): mint the Checkout for a paid space, |
| + // then claim it into existence once Stripe confirms. Both rate-limited — |
| + // one mints sessions, the other dials Stripe + can provision. |
| + mux.HandleFunc("POST /api/spaces/checkout-create", s.rateLimited(s.checkoutCreateHandler)) |
| + mux.HandleFunc("POST /api/spaces/claim-create", s.rateLimited(s.claimCreateHandler)) |
| // The proof-of-work challenge behind creation (cheap to mint; rate-limited |
| // with the same budget as the creates it gates). |
| mux.HandleFunc("POST /api/spaces/challenge", s.rateLimited(s.challengeHandler)) |
diff --git a/home/provision.go b/home/provision.go| index 27ab55c..558dbda 100644 |
| --- a/home/provision.go |
| +++ b/home/provision.go |
| @@ -406,6 +406,159 @@ func (s *Server) deleteSpaceHandler(w http.ResponseWriter, r *http.Request) { |
| } |
| } |
| +// checkoutCreateHandler: POST /api/spaces/checkout-create {name, tier, band, |
| +// region, challenge, nonce} — start a PAID space: the provisioner mints a |
| +// Stripe Checkout link bound to a pending allocation, and the space is only |
| +// provisioned once the payment confirms (claimCreateHandler). Same gates as |
| +// the free create — link token, the member gate, and the proof of work: a |
| +// checkout mint is priced like a create, not like a read. |
| +func (s *Server) checkoutCreateHandler(w http.ResponseWriter, r *http.Request) { |
| + uid, ok := s.linkUser(r) |
| + if !ok { |
| + http.Error(w, "unauthorized", http.StatusUnauthorized) |
| + return |
| + } |
| + if s.cloud == nil { |
| + writeJSON(w, http.StatusNotImplemented, map[string]any{ |
| + "error": "unavailable", "message": "this home server does not offer space creation", |
| + }) |
| + return |
| + } |
| + if _, _, hasBlob, err := s.store.getBlob(uid); err != nil { |
| + writeJSON(w, http.StatusInternalServerError, map[string]any{ |
| + "error": "create_failed", "message": "could not check your account", |
| + }) |
| + return |
| + } else if !hasBlob { |
| + writeJSON(w, http.StatusForbidden, map[string]any{ |
| + "error": "not_eligible", |
| + "message": "You need at least one Eleven space before you can create a new one.", |
| + }) |
| + return |
| + } |
| + var req struct { |
| + Region string `json:"region"` |
| + Name string `json:"name"` |
| + Tier string `json:"tier"` |
| + Band string `json:"band"` |
| + Challenge string `json:"challenge"` |
| + Nonce string `json:"nonce"` |
| + } |
| + _ = json.NewDecoder(io.LimitReader(r.Body, 8<<10)).Decode(&req) |
| + if s.pow != nil { |
| + if req.Challenge == "" { |
| + writeJSON(w, http.StatusForbidden, map[string]any{ |
| + "error": "pow_required", "message": "This home server asks for a little proof of work first — try again.", |
| + }) |
| + return |
| + } |
| + if err := s.pow.verify(uid, req.Challenge, req.Nonce); err != nil { |
| + writeJSON(w, http.StatusForbidden, map[string]any{ |
| + "error": "pow_failed", "message": "The proof of work didn't check out — try again.", |
| + }) |
| + return |
| + } |
| + } |
| + if strings.TrimSpace(req.Tier) == "" { |
| + writeJSON(w, http.StatusBadRequest, map[string]any{ |
| + "error": "bad_request", "message": "tier required", |
| + }) |
| + return |
| + } |
| + name := strings.TrimSpace(req.Name) |
| + if len(name) > 60 { |
| + writeJSON(w, http.StatusBadRequest, map[string]any{ |
| + "error": "bad_request", "message": "The space name can be at most 60 characters.", |
| + }) |
| + return |
| + } |
| + // Stripe substitutes {CHECKOUT_SESSION_ID} into the redirect, which is how |
| + // the landing page knows what to claim; the region rides along so the claim |
| + // dials the same cloud that minted the session. |
| + successURL := s.origin + "/?paidcreate={CHECKOUT_SESSION_ID}" |
| + if region := strings.TrimSpace(req.Region); region != "" { |
| + successURL += "&pcregion=" + url.QueryEscape(region) |
| + } |
| + payload, _ := json.Marshal(map[string]string{ |
| + "requester": uid, |
| + "tier": req.Tier, |
| + "band": req.Band, |
| + "name": name, |
| + "success_url": successURL, |
| + "cancel_url": s.origin + "/", |
| + }) |
| + status, body, err := s.cloudForRegion(req.Region).do("POST", "/api/instances/checkout-create", payload) |
| + if err != nil { |
| + writeJSON(w, http.StatusBadGateway, map[string]any{ |
| + "error": "checkout_failed", "message": "could not reach the space provisioner", |
| + }) |
| + return |
| + } |
| + switch status { |
| + case http.StatusOK, http.StatusBadRequest, http.StatusNotImplemented, http.StatusServiceUnavailable: |
| + writeJSON(w, status, body) |
| + default: |
| + writeJSON(w, http.StatusBadGateway, map[string]any{ |
| + "error": "checkout_failed", "message": "couldn't start the checkout — please try again", |
| + }) |
| + } |
| +} |
| + |
| +// claimCreateHandler: POST /api/spaces/claim-create {session_id, region} — |
| +// finish a pay-first create after the Stripe redirect (or any later visit: |
| +// the claim is idempotent downstream). Link-token auth: the session id is |
| +// worthless to anyone but the pending row's owner, which the provisioner |
| +// re-checks against its ledger. |
| +func (s *Server) claimCreateHandler(w http.ResponseWriter, r *http.Request) { |
| + uid, ok := s.linkUser(r) |
| + if !ok { |
| + http.Error(w, "unauthorized", http.StatusUnauthorized) |
| + return |
| + } |
| + if s.cloud == nil { |
| + writeJSON(w, http.StatusNotImplemented, map[string]any{ |
| + "error": "unavailable", "message": "this home server does not offer space creation", |
| + }) |
| + return |
| + } |
| + var req struct { |
| + Region string `json:"region"` |
| + SessionID string `json:"session_id"` |
| + } |
| + _ = json.NewDecoder(io.LimitReader(r.Body, 8<<10)).Decode(&req) |
| + if strings.TrimSpace(req.SessionID) == "" { |
| + writeJSON(w, http.StatusBadRequest, map[string]any{ |
| + "error": "bad_request", "message": "session_id required", |
| + }) |
| + return |
| + } |
| + payload, _ := json.Marshal(map[string]string{ |
| + "requester": uid, |
| + "session_id": strings.TrimSpace(req.SessionID), |
| + }) |
| + status, body, err := s.cloudForRegion(req.Region).do("POST", "/api/instances/claim-create", payload) |
| + if err != nil { |
| + writeJSON(w, http.StatusBadGateway, map[string]any{ |
| + "error": "claim_failed", "message": "could not reach the space provisioner", |
| + }) |
| + return |
| + } |
| + switch status { |
| + case http.StatusOK, http.StatusCreated, http.StatusPaymentRequired, http.StatusNotFound, http.StatusNotImplemented: |
| + writeJSON(w, status, body) |
| + default: |
| + // Keep the provisioner's message when it has one — "payment confirmed, |
| + // try again in a moment" must reach the person waiting. |
| + msg, _ := body["message"].(string) |
| + if msg == "" { |
| + msg = "the space couldn't be set up just now — try again in a moment" |
| + } |
| + writeJSON(w, http.StatusBadGateway, map[string]any{ |
| + "error": "claim_failed", "message": msg, |
| + }) |
| + } |
| +} |
| + |
| // checkoutSpaceHandler: POST /api/spaces/{host}/checkout {tier} — ask the |
| // provisioner for a Stripe Checkout link to upgrade a space this user created, |
| // at the chosen tier (validated downstream against the provisioner's price |
diff --git a/home/provision_test.go b/home/provision_test.go| index 2d04389..1e5261e 100644 |
| --- a/home/provision_test.go |
| +++ b/home/provision_test.go |
| @@ -454,3 +454,56 @@ func TestCreateSpaceForwardsName(t *testing.T) { |
| t.Errorf("61-char name: %d, want 400", r.StatusCode) |
| } |
| } |
| + |
| +// The pay-first pair forwards with the session-id template in the redirect and |
| +// relays the provisioner's answers (including the claim's retriable message). |
| +func TestCheckoutCreateAndClaimForward(t *testing.T) { |
| + ts, s := newTestServer(t) |
| + uid, token := seedMember(t, s) |
| + cloud, captured, capturedBody := fakeCloud(t, http.StatusOK, map[string]any{ |
| + "url": "https://checkout.stripe.com/c/pay/cs_1", |
| + }) |
| + s.cloud = newCloudProvisioner(cloud.URL, "caller-token") |
| + |
| + post := func(path, body string) *http.Response { |
| + req, _ := http.NewRequest("POST", ts.URL+path, strings.NewReader(body)) |
| + req.Header.Set("Authorization", "Bearer "+token) |
| + resp, err := http.DefaultClient.Do(req) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + t.Cleanup(func() { resp.Body.Close() }) |
| + return resp |
| + } |
| + |
| + if r := post("/api/spaces/checkout-create", `{"tier":"family","band":"base","name":"Book Club"}`); r.StatusCode != http.StatusOK { |
| + t.Fatalf("checkout-create: %d, want 200", r.StatusCode) |
| + } |
| + if captured.URL.Path != "/api/instances/checkout-create" { |
| + t.Errorf("forwarded to %s", captured.URL.Path) |
| + } |
| + var sent map[string]string |
| + json.Unmarshal(*capturedBody, &sent) |
| + if sent["requester"] != uid || sent["tier"] != "family" || sent["name"] != "Book Club" { |
| + t.Errorf("forwarded body = %v", sent) |
| + } |
| + if !strings.Contains(sent["success_url"], "?paidcreate={CHECKOUT_SESSION_ID}") { |
| + t.Errorf("success_url = %q, want the session-id template", sent["success_url"]) |
| + } |
| + |
| + // Missing tier → 400 before any dial; claim without a session likewise. |
| + if r := post("/api/spaces/checkout-create", `{"name":"X"}`); r.StatusCode != http.StatusBadRequest { |
| + t.Errorf("no tier: %d, want 400", r.StatusCode) |
| + } |
| + if r := post("/api/spaces/claim-create", `{}`); r.StatusCode != http.StatusBadRequest { |
| + t.Errorf("no session: %d, want 400", r.StatusCode) |
| + } |
| + |
| + if r := post("/api/spaces/claim-create", `{"session_id":"cs_1"}`); r.StatusCode != http.StatusOK { |
| + t.Fatalf("claim relay: %d, want 200", r.StatusCode) |
| + } |
| + json.Unmarshal(*capturedBody, &sent) |
| + if sent["requester"] != uid || sent["session_id"] != "cs_1" { |
| + t.Errorf("claim body = %v", sent) |
| + } |
| +} |