eleven / ocho Public

server+home: billing rails — Stripe Checkout upgrades for cloud spaces (#628)

The rails from #562, no price book: one subscription price upgrades one
space. Cloud gains POST /api/instances/{host}/checkout (caller-token +
ledger-ownership gated, mints a Checkout Session with no customer fields)
and POST /api/stripe/webhook (HMAC signature-gated; checkout.session.completed
flips the space to paid, customer.subscription.* track it — past_due keeps
access through dunning, deletion drops back to free). The ledger stores two
opaque Stripe ids, an entitlement flag, and paid_until — never an email,
name, or card detail.

Paid spaces are never reap candidates (filtered in the ledger query itself).
Home proxies the checkout with same-origin return URLs; its page shows a
quiet hover-reveal Upgrade pill on spaces this account created (only when
the provisioner advertises billing) and a Supporter badge once paid.

All-or-nothing env config (LCHAT_STRIPE_SECRET_KEY / _WEBHOOK_SECRET /
_PRICE_ID); unset = billing off everywhere, so this ships dormant.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 1507971889dc767e6a3ff0115f025ba7155915b7 parent 15684df
10 files changed, +1147 −17
  • billing.go +353 −0
  • cloud.go +26 −7
  • cloud_billing_test.go +374 −0
  • cloud_db.go +111 −8
  • docs/cloud-provisioner.md +54 −1
  • home/main.go +8 −1
  • home/provision.go +42 −0
  • home/provision_test.go +72 −0
  • home/web/pages/index.html +23 −0
  • home/web/static/home.js +84 −0
diff --git a/billing.go b/billing.go
new file mode 100644
index 0000000..1207e68
--- /dev/null
+++ b/billing.go
@@ -0,0 +1,353 @@
+package main
+
+// Billing rails for cloud-provisioned spaces (Stripe Checkout + webhooks).
+//
+// This is deliberately just the rails — no price book, no tiers, no metering
+// (that's issues #561/#562). One subscription price upgrades one space; the
+// entitlement lands as a flag on the space's ledger row, where the reaper (and
+// later, per-space limits) read it.
+//
+// Privacy invariants (see docs/cloud-provisioner.md § Billing):
+// - We store two opaque Stripe ids, a flag, and a period end. Never an email,
+// name, address, or card detail — Stripe holds those; payment deanonymizes
+// only on Stripe's side (#562's honest residual).
+// - Checkout Sessions are created WITHOUT customer details; the only thing we
+// tell Stripe about a space is its numeric ledger id (client_reference_id +
+// subscription metadata), which is what routes webhook events back to it.
+// - The webhook is the one unauthenticated-by-token public endpoint here; its
+// gate is the signature check (HMAC over the raw payload, à la Stripe).
+//
+// No Stripe SDK: the surface we use is one form-encoded POST and one HMAC
+// verification, which isn't worth a dependency in a single-binary repo.
+
+import (
+ "crypto/hmac"
+ "crypto/sha256"
+ "database/sql"
+ "encoding/hex"
+ "encoding/json"
+ "fmt"
+ "io"
+ "log"
+ "net/http"
+ "net/url"
+ "strconv"
+ "strings"
+ "time"
+)
+
+// stripeBilling holds the cloud service's Stripe configuration. nil on a
+// deployment that doesn't offer upgrades — billing stays strictly additive.
+type stripeBilling struct {
+ apiBase string // https://api.stripe.com (tests point it at a stub)
+ key string // secret (restricted) API key
+ webhookSecret string // signing secret for the webhook endpoint
+ priceID string // the one subscription price (slice 1: no price book)
+ client *http.Client
+}
+
+// newStripeBilling wires billing from config. All-empty → nil (billing off);
+// partial config is a deployment mistake and errors rather than half-working.
+func newStripeBilling(key, webhookSecret, priceID, apiBase string) (*stripeBilling, error) {
+ if key == "" && webhookSecret == "" && priceID == "" {
+ return nil, nil
+ }
+ if key == "" || webhookSecret == "" || priceID == "" {
+ return nil, fmt.Errorf("partial Stripe config: LCHAT_STRIPE_SECRET_KEY, LCHAT_STRIPE_WEBHOOK_SECRET and LCHAT_STRIPE_PRICE_ID must all be set (or none)")
+ }
+ return &stripeBilling{
+ apiBase: strings.TrimRight(apiBase, "/"),
+ key: key,
+ webhookSecret: webhookSecret,
+ priceID: priceID,
+ client: &http.Client{Timeout: 30 * time.Second},
+ }, nil
+}
+
+// checkoutHandler: POST /api/instances/{host}/checkout — mint a Stripe Checkout
+// link to upgrade one space. Caller-token gated like every provisioning route,
+// and ownership-checked against the ledger exactly like deletion: only the
+// (caller, requester) pair that created a space can start a checkout for it.
+func (s *cloudServer) checkoutHandler(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 upgrades")
+ return
+ }
+ host := strings.ToLower(strings.TrimSpace(r.PathValue("host")))
+ var req struct {
+ Requester string `json:"requester"`
+ 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 == "" || !validRedirectURL(req.SuccessURL) || !validRedirectURL(req.CancelURL) {
+ writeControlErr(w, http.StatusBadRequest, "bad_request", "requester, success_url and cancel_url required")
+ return
+ }
+ id, owned, err := s.db.spaceOwned(callerID, req.Requester, host)
+ if err != nil {
+ writeControlErr(w, http.StatusInternalServerError, "checkout_failed", "db error")
+ return
+ }
+ if !owned {
+ writeControlErr(w, http.StatusNotFound, "not_found", "no such space for this account")
+ return
+ }
+ if ent, err := s.db.spaceEntitlement(id); err != nil {
+ writeControlErr(w, http.StatusInternalServerError, "checkout_failed", "db error")
+ return
+ } else if ent != entFree {
+ writeControlErr(w, http.StatusConflict, "already_upgraded", "this space is already upgraded")
+ return
+ }
+ checkoutURL, err := s.billing.createCheckoutSession(id, req.SuccessURL, req.CancelURL)
+ if err != nil {
+ log.Printf("cloud: checkout for %s (id %d): %v", host, id, err)
+ writeControlErr(w, http.StatusBadGateway, "checkout_failed", "couldn't start the checkout — please try again")
+ return
+ }
+ writeJSON(w, http.StatusOK, map[string]any{"url": checkoutURL})
+}
+
+// validRedirectURL admits the http(s) URLs a checkout may bounce back to. The
+// caller is a trusted home server, so this is belt-and-suspenders, not policy.
+func validRedirectURL(raw string) bool {
+ u, err := url.Parse(raw)
+ return err == nil && (u.Scheme == "https" || u.Scheme == "http") && u.Host != ""
+}
+
+// createCheckoutSession asks Stripe for a hosted Checkout page. The space's
+// ledger id rides in client_reference_id (routes checkout.session.completed)
+// and in the subscription's metadata (routes every later subscription event).
+func (b *stripeBilling) createCheckoutSession(spaceID int64, successURL, cancelURL string) (string, error) {
+ id := strconv.FormatInt(spaceID, 10)
+ form := url.Values{}
+ form.Set("mode", "subscription")
+ form.Set("line_items[0][price]", b.priceID)
+ form.Set("line_items[0][quantity]", "1")
+ form.Set("client_reference_id", id)
+ form.Set("subscription_data[metadata][space_id]", id)
+ form.Set("success_url", successURL)
+ form.Set("cancel_url", cancelURL)
+
+ req, err := http.NewRequest("POST", b.apiBase+"/v1/checkout/sessions", strings.NewReader(form.Encode()))
+ if err != nil {
+ 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
+ }
+ defer resp.Body.Close()
+ var body struct {
+ 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)
+ }
+ if resp.StatusCode != http.StatusOK {
+ 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 body.URL, nil
+}
+
+// ---- webhook -------------------------------------------------------------------
+
+// stripeSigTolerance bounds how stale a webhook's signed timestamp may be —
+// Stripe's own recommended replay window.
+const stripeSigTolerance = 5 * time.Minute
+
+// stripeWebhookHandler: POST /api/stripe/webhook — the events that move a
+// space's entitlement. Public (Stripe's servers call it); the signature IS the
+// auth. Unknown event types 200 so the endpoint's Stripe-side config can list
+// more than we handle; a DB failure 500s so Stripe retries; an event for a
+// space we can't find 200s (retrying can never make it findable).
+func (s *cloudServer) stripeWebhookHandler(w http.ResponseWriter, r *http.Request) {
+ if s.billing == nil {
+ writeControlErr(w, http.StatusNotImplemented, "billing_unavailable", "billing is not configured")
+ return
+ }
+ payload, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
+ if err != nil {
+ writeControlErr(w, http.StatusBadRequest, "bad_request", "unreadable body")
+ return
+ }
+ if !verifyStripeSignature(payload, r.Header.Get("Stripe-Signature"), s.billing.webhookSecret, time.Now()) {
+ writeControlErr(w, http.StatusBadRequest, "bad_signature", "webhook signature verification failed")
+ return
+ }
+ var ev struct {
+ Type string `json:"type"`
+ Data struct {
+ Object json.RawMessage `json:"object"`
+ } `json:"data"`
+ }
+ if err := json.Unmarshal(payload, &ev); err != nil {
+ writeControlErr(w, http.StatusBadRequest, "bad_request", "invalid event JSON")
+ return
+ }
+
+ switch {
+ case ev.Type == "checkout.session.completed":
+ err = s.handleCheckoutCompleted(ev.Data.Object)
+ case strings.HasPrefix(ev.Type, "customer.subscription."):
+ err = s.handleSubscriptionEvent(ev.Type, ev.Data.Object)
+ default:
+ // Not ours — acknowledged, never retried.
+ }
+ if err != nil {
+ log.Printf("cloud: webhook %s: %v", ev.Type, err)
+ writeControlErr(w, http.StatusInternalServerError, "webhook_failed", "event processing failed")
+ return
+ }
+ writeJSON(w, http.StatusOK, map[string]any{"received": true})
+}
+
+// handleCheckoutCompleted flips a space to paid the moment its checkout
+// finishes, recording the opaque customer + subscription ids that route every
+// later event.
+func (s *cloudServer) handleCheckoutCompleted(raw json.RawMessage) error {
+ var obj struct {
+ Mode string `json:"mode"`
+ ClientReferenceID string `json:"client_reference_id"`
+ Customer string `json:"customer"`
+ Subscription string `json:"subscription"`
+ }
+ if err := json.Unmarshal(raw, &obj); err != nil {
+ return err
+ }
+ if obj.Mode != "subscription" {
+ return nil // not an upgrade checkout
+ }
+ id, err := strconv.ParseInt(obj.ClientReferenceID, 10, 64)
+ if err != nil {
+ log.Printf("cloud: webhook checkout.session.completed without a space reference (%q) — ignoring", obj.ClientReferenceID)
+ return nil
+ }
+ switch err := s.db.markSpacePaid(id, obj.Customer, obj.Subscription); err {
+ case nil:
+ log.Printf("cloud: space id %d upgraded (checkout complete)", id)
+ return nil
+ case sql.ErrNoRows:
+ log.Printf("cloud: webhook checkout for unknown space id %d — ignoring", id)
+ return nil // deleted since checkout began; retrying won't resurrect it
+ default:
+ return err
+ }
+}
+
+// handleSubscriptionEvent tracks the subscription for the life of the upgrade:
+// updates refresh the paid flag + period end, deletion drops the space back to
+// free. The reaper never sees paid spaces (spacesOlderThan filters them), and a
+// lapsed space just becomes an ordinary free one — lapse handling beyond that
+// (backup-link mail via Stripe) is later work, by design.
+func (s *cloudServer) handleSubscriptionEvent(evType string, raw json.RawMessage) error {
+ var obj struct {
+ ID string `json:"id"`
+ Status string `json:"status"`
+ Metadata map[string]string `json:"metadata"`
+ // Stripe API versions ≥2025-03-31 moved current_period_end from the
+ // subscription to its items; accept either shape.
+ CurrentPeriodEnd int64 `json:"current_period_end"`
+ Items struct {
+ Data []struct {
+ CurrentPeriodEnd int64 `json:"current_period_end"`
+ } `json:"data"`
+ } `json:"items"`
+ }
+ if err := json.Unmarshal(raw, &obj); err != nil {
+ return err
+ }
+
+ id, err := strconv.ParseInt(obj.Metadata["space_id"], 10, 64)
+ if err != nil {
+ var ok bool
+ if id, ok, err = s.db.findSpaceBySubscription(obj.ID); err != nil {
+ return err
+ } else if !ok {
+ log.Printf("cloud: webhook %s for unknown subscription %s — ignoring", evType, obj.ID)
+ return nil
+ }
+ }
+
+ ent := entFree
+ if evType != "customer.subscription.deleted" {
+ // past_due keeps access through Stripe's dunning window — losing the
+ // space the day a card expires would be hostile; unpaid/canceled ends it.
+ switch obj.Status {
+ case "active", "trialing", "past_due":
+ ent = entPaid
+ }
+ }
+ paidUntil := obj.CurrentPeriodEnd
+ for _, item := range obj.Items.Data {
+ if item.CurrentPeriodEnd > paidUntil {
+ paidUntil = item.CurrentPeriodEnd
+ }
+ }
+
+ switch err := s.db.setEntitlement(id, ent, paidUntil); err {
+ case nil:
+ log.Printf("cloud: space id %d entitlement → %s (subscription %s)", id, ent, obj.Status)
+ return nil
+ case sql.ErrNoRows:
+ log.Printf("cloud: webhook %s for unknown space id %d — ignoring", evType, id)
+ return nil
+ default:
+ return err
+ }
+}
+
+// verifyStripeSignature checks a Stripe-Signature header against the raw
+// payload: HMAC-SHA256(secret, "<t>.<payload>") must match one of the v1
+// signatures, and t must be within the replay tolerance of now. Pure (now is a
+// parameter) so tests can pin time.
+func verifyStripeSignature(payload []byte, header, secret string, now time.Time) bool {
+ var ts string
+ var sigs [][]byte
+ for _, part := range strings.Split(header, ",") {
+ k, v, ok := strings.Cut(strings.TrimSpace(part), "=")
+ if !ok {
+ continue
+ }
+ switch k {
+ case "t":
+ ts = v
+ case "v1":
+ if sig, err := hex.DecodeString(v); err == nil {
+ sigs = append(sigs, sig)
+ }
+ }
+ }
+ t, err := strconv.ParseInt(ts, 10, 64)
+ if err != nil || len(sigs) == 0 {
+ return false
+ }
+ if d := now.Sub(time.Unix(t, 0)); d > stripeSigTolerance || d < -stripeSigTolerance {
+ return false
+ }
+ mac := hmac.New(sha256.New, []byte(secret))
+ mac.Write([]byte(ts))
+ mac.Write([]byte("."))
+ mac.Write(payload)
+ want := mac.Sum(nil)
+ for _, sig := range sigs {
+ if hmac.Equal(sig, want) {
+ return true
+ }
+ }
+ return false
+}
diff --git a/cloud.go b/cloud.go
index 340e66d..04dc9d6 100644
--- a/cloud.go
+++ b/cloud.go
@@ -75,12 +75,13 @@ type cloudServer struct {
db *cloudDB
adminHash string // sha256 of LCHAT_ADMIN_TOKEN; gates token mint/list/revoke
controlToken string
- control *http.Client // dials the control daemon's unix socket
- baseDomain string // spaces land at <slug>.<baseDomain>
- quota int // max spaces per (caller, requester)
- reapAfter time.Duration // reclaim never-claimed spaces older than this (0 = reaper off)
- reapDryRun bool // log what the reaper would reclaim, delete nothing
- reapMax int // max reclaims per sweep (blast-radius cap; 0 = unlimited)
+ control *http.Client // dials the control daemon's unix socket
+ baseDomain string // spaces land at <slug>.<baseDomain>
+ quota int // max spaces per (caller, requester)
+ reapAfter time.Duration // reclaim never-claimed spaces older than this (0 = reaper off)
+ reapDryRun bool // log what the reaper would reclaim, delete nothing
+ reapMax int // max reclaims per sweep (blast-radius cap; 0 = unlimited)
+ billing *stripeBilling // optional space upgrades (billing.go); nil = not offered
}
func (s *cloudServer) handler() http.Handler {
@@ -89,6 +90,10 @@ func (s *cloudServer) handler() http.Handler {
mux.HandleFunc("POST /api/instances", s.callerAuth(s.createSpaceHandler))
mux.HandleFunc("GET /api/instances", s.callerAuth(s.listSpacesHandler))
mux.HandleFunc("DELETE /api/instances/{host}", s.callerAuth(s.deleteSpaceHandler))
+ // 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))
+ mux.HandleFunc("POST /api/stripe/webhook", s.stripeWebhookHandler)
// Admin surface: mint / list / revoke caller tokens at runtime (mirrors the
// push relay's admin API).
mux.HandleFunc("POST /api/admin/tokens", s.admin(s.mintTokenHandler))
@@ -292,7 +297,8 @@ func (s *cloudServer) listSpacesHandler(w http.ResponseWriter, r *http.Request)
writeControlErr(w, http.StatusInternalServerError, "list_failed", "db error")
return
}
- writeJSON(w, http.StatusOK, map[string]any{"spaces": spaces})
+ // billing tells the client whether an Upgrade affordance would go anywhere.
+ writeJSON(w, http.StatusOK, map[string]any{"spaces": spaces, "billing": s.billing != nil})
}
// ---- admin: caller tokens ---------------------------------------------------
@@ -386,6 +392,15 @@ func runCloud(args []string) {
if err != nil {
die("cloud: open db: %v", err)
}
+ billing, err := newStripeBilling(
+ os.Getenv("LCHAT_STRIPE_SECRET_KEY"),
+ os.Getenv("LCHAT_STRIPE_WEBHOOK_SECRET"),
+ os.Getenv("LCHAT_STRIPE_PRICE_ID"),
+ envOr("LCHAT_STRIPE_API_BASE", "https://api.stripe.com"),
+ )
+ if err != nil {
+ die("cloud: %v", err)
+ }
s := &cloudServer{
db: db,
@@ -397,6 +412,10 @@ func runCloud(args []string) {
reapAfter: time.Duration(*reapAfterDays) * 24 * time.Hour,
reapDryRun: *reapDryRun,
reapMax: *reapMax,
+ billing: billing,
+ }
+ if billing != nil {
+ log.Printf("cloud: billing on (price %s)", billing.priceID)
}
go s.reapLoop(time.Duration(*reapIntervalHours) * time.Hour)
ln, where, err := listen(*addr, *socketPath)
diff --git a/cloud_billing_test.go b/cloud_billing_test.go
new file mode 100644
index 0000000..5d741ff
--- /dev/null
+++ b/cloud_billing_test.go
@@ -0,0 +1,374 @@
+package main
+
+import (
+ "bytes"
+ "crypto/hmac"
+ "crypto/sha256"
+ "encoding/hex"
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "strings"
+ "testing"
+ "time"
+)
+
+// fakeStripe stands in for api.stripe.com: it captures the checkout-session
+// form and answers with a hosted-page URL, so the cloud server exercises its
+// real HTTP path.
+func fakeStripe(t *testing.T) (*httptest.Server, *url.Values) {
+ t.Helper()
+ captured := &url.Values{}
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path != "/v1/checkout/sessions" {
+ w.WriteHeader(http.StatusNotFound)
+ return
+ }
+ if err := r.ParseForm(); err != nil {
+ w.WriteHeader(http.StatusBadRequest)
+ return
+ }
+ *captured = r.PostForm
+ writeJSON(w, http.StatusOK, map[string]any{
+ "id": "cs_test_123", "url": "https://checkout.stripe.com/c/pay/cs_test_123",
+ })
+ }))
+ t.Cleanup(srv.Close)
+ return srv, captured
+}
+
+// billingTestCloud is newTestCloud plus a configured stripeBilling pointed at
+// the fake.
+func billingTestCloud(t *testing.T) (*httptest.Server, *cloudServer, *url.Values) {
+ t.Helper()
+ stripe, captured := fakeStripe(t)
+ srv, s := newTestCloud(t, okControl(t))
+ s.billing = &stripeBilling{
+ apiBase: stripe.URL,
+ key: "sk_test_secret",
+ webhookSecret: "whsec_test_secret",
+ priceID: "price_test_123",
+ client: http.DefaultClient,
+ }
+ return srv, s, captured
+}
+
+// postCheckout starts a checkout for host as (token, requester).
+func postCheckout(t *testing.T, srv *httptest.Server, token, host, requester string) *http.Response {
+ t.Helper()
+ body, _ := json.Marshal(map[string]string{
+ "requester": requester,
+ "success_url": "https://home.example.com/?upgraded=" + host,
+ "cancel_url": "https://home.example.com/",
+ })
+ req, _ := http.NewRequest("POST", srv.URL+"/api/instances/"+host+"/checkout", bytes.NewReader(body))
+ if token != "" {
+ 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
+}
+
+// stripeSign builds a valid Stripe-Signature header for payload at time now.
+func stripeSign(payload []byte, secret string, now time.Time) string {
+ ts := fmt.Sprintf("%d", now.Unix())
+ mac := hmac.New(sha256.New, []byte(secret))
+ mac.Write([]byte(ts + "."))
+ mac.Write(payload)
+ return "t=" + ts + ",v1=" + hex.EncodeToString(mac.Sum(nil))
+}
+
+// postWebhook delivers an event payload with the given signature header.
+func postWebhook(t *testing.T, srv *httptest.Server, payload []byte, sig string) *http.Response {
+ t.Helper()
+ req, _ := http.NewRequest("POST", srv.URL+"/api/stripe/webhook", bytes.NewReader(payload))
+ req.Header.Set("Stripe-Signature", sig)
+ resp, err := http.DefaultClient.Do(req)
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { resp.Body.Close() })
+ return resp
+}
+
+func TestCheckoutMintsSession(t *testing.T) {
+ srv, _, captured := billingTestCloud(t)
+ tok := mintCaller(t, srv, "home")
+ host, _ := decodeBody(t, createSpace(t, srv, tok, "user-1"))["host"].(string)
+
+ resp := postCheckout(t, srv, tok, host, "user-1")
+ if resp.StatusCode != http.StatusOK {
+ t.Fatalf("status = %d, want 200", resp.StatusCode)
+ }
+ if u, _ := decodeBody(t, resp)["url"].(string); !strings.Contains(u, "checkout.stripe.com") {
+ t.Errorf("url = %q, want the hosted checkout page", u)
+ }
+
+ // The session carried the space's ledger id (id 1 — fresh DB) in both
+ // routing slots, the one price, and NO customer details of any kind.
+ f := *captured
+ if f.Get("mode") != "subscription" || f.Get("line_items[0][price]") != "price_test_123" {
+ t.Errorf("form = %v", f)
+ }
+ if f.Get("client_reference_id") != "1" || f.Get("subscription_data[metadata][space_id]") != "1" {
+ t.Errorf("space id routing: client_reference_id=%q metadata=%q, want 1/1",
+ f.Get("client_reference_id"), f.Get("subscription_data[metadata][space_id]"))
+ }
+ if !strings.Contains(f.Get("success_url"), "upgraded="+host) {
+ t.Errorf("success_url = %q", f.Get("success_url"))
+ }
+ for k := range f {
+ if strings.Contains(k, "customer") || strings.Contains(k, "email") {
+ t.Errorf("checkout form leaks a customer field: %s", k)
+ }
+ }
+}
+
+func TestCheckoutGates(t *testing.T) {
+ srv, s, _ := billingTestCloud(t)
+ tok := mintCaller(t, srv, "home")
+ host, _ := decodeBody(t, createSpace(t, srv, tok, "user-1"))["host"].(string)
+
+ // No caller token → 401.
+ if r := postCheckout(t, srv, "", host, "user-1"); r.StatusCode != http.StatusUnauthorized {
+ t.Errorf("no token: %d, want 401", r.StatusCode)
+ }
+ // Ownership is the gate: someone else's requester id → 404.
+ if r := postCheckout(t, srv, tok, host, "someone-else"); r.StatusCode != http.StatusNotFound {
+ t.Errorf("wrong requester: %d, want 404", r.StatusCode)
+ }
+ // Missing redirect URLs → 400.
+ req, _ := http.NewRequest("POST", srv.URL+"/api/instances/"+host+"/checkout",
+ strings.NewReader(`{"requester":"user-1"}`))
+ req.Header.Set("Authorization", "Bearer "+tok)
+ resp, err := http.DefaultClient.Do(req)
+ if err != nil {
+ t.Fatal(err)
+ }
+ resp.Body.Close()
+ if resp.StatusCode != http.StatusBadRequest {
+ t.Errorf("no urls: %d, want 400", resp.StatusCode)
+ }
+ // Already upgraded → 409.
+ label, _, _ := strings.Cut(host, ".")
+ id, _ := spaceID(label)
+ if err := s.db.markSpacePaid(id, "cus_x", "sub_x"); err != nil {
+ t.Fatal(err)
+ }
+ if r := postCheckout(t, srv, tok, host, "user-1"); r.StatusCode != http.StatusConflict {
+ t.Errorf("already paid: %d, want 409", r.StatusCode)
+ }
+ // Billing off → 501 (and the webhook too).
+ s.billing = nil
+ if r := postCheckout(t, srv, tok, host, "user-1"); r.StatusCode != http.StatusNotImplemented {
+ t.Errorf("billing off: %d, want 501", r.StatusCode)
+ }
+ if r := postWebhook(t, srv, []byte("{}"), "t=1,v1=00"); r.StatusCode != http.StatusNotImplemented {
+ t.Errorf("webhook, billing off: %d, want 501", r.StatusCode)
+ }
+}
+
+func TestWebhookRejectsBadSignatures(t *testing.T) {
+ srv, _, _ := billingTestCloud(t)
+ payload := []byte(`{"type":"checkout.session.completed","data":{"object":{}}}`)
+
+ // No header / garbage header / wrong secret / stale timestamp → all 400.
+ if r := postWebhook(t, srv, payload, ""); r.StatusCode != http.StatusBadRequest {
+ t.Errorf("no header: %d, want 400", r.StatusCode)
+ }
+ if r := postWebhook(t, srv, payload, "not-a-header"); r.StatusCode != http.StatusBadRequest {
+ t.Errorf("garbage header: %d, want 400", r.StatusCode)
+ }
+ if r := postWebhook(t, srv, payload, stripeSign(payload, "whsec_WRONG", time.Now())); r.StatusCode != http.StatusBadRequest {
+ t.Errorf("wrong secret: %d, want 400", r.StatusCode)
+ }
+ stale := stripeSign(payload, "whsec_test_secret", time.Now().Add(-time.Hour))
+ if r := postWebhook(t, srv, payload, stale); r.StatusCode != http.StatusBadRequest {
+ t.Errorf("stale timestamp: %d, want 400 (replay window)", r.StatusCode)
+ }
+ // And the real thing → 200.
+ if r := postWebhook(t, srv, payload, stripeSign(payload, "whsec_test_secret", time.Now())); r.StatusCode != http.StatusOK {
+ t.Errorf("valid signature: %d, want 200", r.StatusCode)
+ }
+}
+
+func TestWebhookEntitlementLifecycle(t *testing.T) {
+ srv, s, _ := billingTestCloud(t)
+ tok := mintCaller(t, srv, "home")
+ host, _ := decodeBody(t, createSpace(t, srv, tok, "user-1"))["host"].(string)
+
+ deliver := func(event string) {
+ t.Helper()
+ payload := []byte(event)
+ if r := postWebhook(t, srv, payload, stripeSign(payload, "whsec_test_secret", time.Now())); r.StatusCode != http.StatusOK {
+ t.Fatalf("webhook: %d, want 200", r.StatusCode)
+ }
+ }
+ entitlement := func() Space {
+ t.Helper()
+ spaces, err := s.db.listSpaces(mustCallerID(t, s, "home"), "user-1")
+ if err != nil || len(spaces) != 1 {
+ t.Fatalf("listSpaces: %v (%d rows)", err, len(spaces))
+ }
+ return spaces[0]
+ }
+
+ if got := entitlement(); got.Entitlement != entFree {
+ t.Fatalf("fresh space entitlement = %q, want free", got.Entitlement)
+ }
+
+ // Checkout completes → paid, ids recorded.
+ deliver(`{"type":"checkout.session.completed","data":{"object":{
+ "mode":"subscription","client_reference_id":"1","customer":"cus_9","subscription":"sub_9"}}}`)
+ if got := entitlement(); got.Entitlement != entPaid {
+ t.Fatalf("after checkout: entitlement = %q, want paid", got.Entitlement)
+ }
+
+ // A subscription update (routed by metadata) refreshes the period end —
+ // item-level current_period_end (2025+ API shape).
+ deliver(`{"type":"customer.subscription.updated","data":{"object":{
+ "id":"sub_9","status":"active","metadata":{"space_id":"1"},
+ "items":{"data":[{"current_period_end":1900000000}]}}}}`)
+ if got := entitlement(); got.Entitlement != entPaid || got.PaidUntil != 1900000000 {
+ t.Fatalf("after update: %+v, want paid until 1900000000", got)
+ }
+
+ // An update WITHOUT metadata still routes — by the stored subscription id —
+ // and a top-level current_period_end (pre-2025 shape) works too.
+ deliver(`{"type":"customer.subscription.updated","data":{"object":{
+ "id":"sub_9","status":"active","current_period_end":1900000099}}}`)
+ if got := entitlement(); got.PaidUntil != 1900000099 {
+ t.Fatalf("fallback routing: paid_until = %d, want 1900000099", got.PaidUntil)
+ }
+
+ // Dunning keeps the space paid; a dead subscription drops it to free.
+ deliver(`{"type":"customer.subscription.updated","data":{"object":{
+ "id":"sub_9","status":"past_due","metadata":{"space_id":"1"},"current_period_end":1900000099}}}`)
+ if got := entitlement(); got.Entitlement != entPaid {
+ t.Fatalf("past_due should keep access, got %q", got.Entitlement)
+ }
+ deliver(`{"type":"customer.subscription.deleted","data":{"object":{
+ "id":"sub_9","status":"canceled","metadata":{"space_id":"1"},"current_period_end":1900000099}}}`)
+ if got := entitlement(); got.Entitlement != entFree {
+ t.Fatalf("after deletion: entitlement = %q, want free", got.Entitlement)
+ }
+
+ // Events for spaces that don't exist are acknowledged, never retried.
+ deliver(`{"type":"checkout.session.completed","data":{"object":{
+ "mode":"subscription","client_reference_id":"999","customer":"c","subscription":"s"}}}`)
+ deliver(`{"type":"customer.subscription.updated","data":{"object":{
+ "id":"sub_unknown","status":"active"}}}`)
+ // Unknown event types too.
+ deliver(`{"type":"invoice.paid","data":{"object":{}}}`)
+
+ _ = host // exercised above via createSpace
+}
+
+func TestVerifyStripeSignature(t *testing.T) {
+ payload := []byte(`{"hello":"world"}`)
+ secret := "whsec_abc"
+ now := time.Unix(1_900_000_000, 0)
+
+ good := stripeSign(payload, secret, now)
+ if !verifyStripeSignature(payload, good, secret, now) {
+ t.Error("valid signature rejected")
+ }
+ // Tolerance: inside ±5m passes, outside fails (either direction).
+ if !verifyStripeSignature(payload, good, secret, now.Add(4*time.Minute)) {
+ t.Error("signature inside the window rejected")
+ }
+ if verifyStripeSignature(payload, good, secret, now.Add(6*time.Minute)) {
+ t.Error("stale signature accepted (replay window)")
+ }
+ if verifyStripeSignature(payload, good, secret, now.Add(-6*time.Minute)) {
+ t.Error("future-dated signature accepted")
+ }
+ // Tampered payload / wrong secret / v0-only fail.
+ if verifyStripeSignature([]byte(`{"hello":"tampered"}`), good, secret, now) {
+ t.Error("tampered payload accepted")
+ }
+ if verifyStripeSignature(payload, good, "whsec_other", now) {
+ t.Error("wrong secret accepted")
+ }
+ v0 := strings.Replace(good, "v1=", "v0=", 1)
+ if verifyStripeSignature(payload, v0, secret, now) {
+ t.Error("v0-only header accepted")
+ }
+ // An extra bogus v1 alongside the real one still passes (Stripe sends
+ // multiples during secret rolls).
+ multi := good + ",v1=" + strings.Repeat("00", 32)
+ if !verifyStripeSignature(payload, multi, secret, now) {
+ t.Error("multiple v1 signatures: the valid one should win")
+ }
+}
+
+// TestReaperNeverSeesPaidSpaces pins the strongest billing invariant: a paid
+// space is not even a reap CANDIDATE, no matter how old or empty.
+func TestReaperNeverSeesPaidSpaces(t *testing.T) {
+ db := openTestCloudDB(t)
+ now := time.Unix(2_000_000_000, 0)
+ old := now.Add(-20 * 24 * time.Hour).Unix()
+ paidID := insertSpaceRow(t, db, "paid.11ch.at", old)
+ freeID := insertSpaceRow(t, db, "free.11ch.at", old)
+ if err := db.markSpacePaid(paidID, "cus_1", "sub_1"); err != nil {
+ t.Fatal(err)
+ }
+
+ cands, err := db.spacesOlderThan(now.Unix())
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(cands) != 1 || cands[0].ID != freeID {
+ t.Fatalf("candidates = %+v, want only the free space (id %d)", cands, freeID)
+ }
+
+ // And end-to-end: a sweep deletes the free one, never the paid one — even
+ // though both are old, never set up, single-member, contentless.
+ deleted := map[string]bool{}
+ sock := reapControl(t,
+ map[string]int{"paid.11ch.at": 1, "free.11ch.at": 1},
+ nil, nil, deleted, nil)
+ s := &cloudServer{db: db, control: controlClient(sock), controlToken: "t", reapAfter: 14 * 24 * time.Hour}
+ if reaped, _ := s.reapOnce(now); reaped != 1 {
+ t.Fatalf("reaped = %d, want 1", reaped)
+ }
+ if deleted["paid.11ch.at"] {
+ t.Error("a paid space must NEVER be reaped")
+ }
+ if !deleted["free.11ch.at"] {
+ t.Error("the free never-claimed space should have been reaped")
+ }
+}
+
+// TestBillingMigrationOverOldDB proves the billing columns land additively on a
+// pre-billing spaces table (the deployed cloud.db shape).
+func TestBillingMigrationOverOldDB(t *testing.T) {
+ db := openTestCloudDB(t) // migrated schema
+ // Simulate re-open over existing data (migrations must be no-ops then).
+ id := insertSpaceRow(t, db, "existing.11ch.at", 100)
+ if err := db.migrate(); err != nil {
+ t.Fatalf("re-migrate: %v", err)
+ }
+ ent, err := db.spaceEntitlement(id)
+ if err != nil || ent != entFree {
+ t.Fatalf("existing row entitlement = %q (%v), want free", ent, err)
+ }
+}
+
+func TestNewStripeBillingConfig(t *testing.T) {
+ if b, err := newStripeBilling("", "", "", "https://api.stripe.com"); b != nil || err != nil {
+ t.Errorf("all-empty: (%v, %v), want (nil, nil) — billing off", b, err)
+ }
+ if _, err := newStripeBilling("sk_x", "", "", "https://api.stripe.com"); err == nil {
+ t.Error("partial config must error, not half-work")
+ }
+ if b, err := newStripeBilling("sk_x", "whsec_x", "price_x", "https://api.stripe.com/"); err != nil || b == nil || b.apiBase != "https://api.stripe.com" {
+ t.Errorf("full config: (%+v, %v)", b, err)
+ }
+}
diff --git a/cloud_db.go b/cloud_db.go
index f44a493..e682e8d 100644
--- a/cloud_db.go
+++ b/cloud_db.go
@@ -62,7 +62,10 @@ CREATE TABLE IF NOT EXISTS spaces (
);`); err != nil {
return err
}
- return db.migrateSpacesID()
+ if err := db.migrateSpacesID(); err != nil {
+ return err
+ }
+ return db.migrateBilling()
}
// migrateSpacesID rebuilds a spaces table created under the original host-PK
@@ -107,6 +110,40 @@ DROP TABLE spaces_old;`)
return err
}
+// migrateBilling adds the billing columns to spaces (see billing.go). This is
+// the ENTIRE billing data footprint, by design: two opaque Stripe ids, a flag,
+// and a period end — never an email, name, or card detail (Stripe holds those;
+// see docs/cloud-provisioner.md § Billing).
+func (db *cloudDB) migrateBilling() error {
+ rows, err := db.sql.Query(`PRAGMA table_info(spaces)`)
+ if err != nil {
+ return err
+ }
+ hasBilling := false
+ for rows.Next() {
+ var cid, notnull, pk int
+ var name, ctype string
+ var dflt sql.NullString
+ if err := rows.Scan(&cid, &name, &ctype, &notnull, &dflt, &pk); err != nil {
+ rows.Close()
+ return err
+ }
+ if name == "entitlement" {
+ hasBilling = true
+ }
+ }
+ rows.Close()
+ if hasBilling {
+ return nil
+ }
+ _, err = db.sql.Exec(`
+ALTER TABLE spaces ADD COLUMN stripe_customer_id TEXT;
+ALTER TABLE spaces ADD COLUMN stripe_subscription_id TEXT;
+ALTER TABLE spaces ADD COLUMN entitlement TEXT NOT NULL DEFAULT 'free';
+ALTER TABLE spaces ADD COLUMN paid_until INTEGER NOT NULL DEFAULT 0;`)
+ return err
+}
+
// mintCallerToken creates a caller token, stores only its hash, and returns the
// plaintext (shown once).
func (db *cloudDB) mintCallerToken(label string) (id, token string, err error) {
@@ -229,11 +266,14 @@ type reapCandidate struct {
// spacesOlderThan returns finalized spaces created at/before cutoff (unix secs),
// oldest first — the reaper's candidate set. A NULL host (create in flight or
-// rolled back) is never a candidate.
+// rolled back) is never a candidate, and neither is a paid space: someone paying
+// for a space is the strongest possible "claimed" signal, so it's excluded here
+// at the source rather than trusted to a downstream guard.
func (db *cloudDB) spacesOlderThan(cutoff int64) ([]reapCandidate, error) {
rows, err := db.sql.Query(
`SELECT id, host, created_at FROM spaces
- WHERE host IS NOT NULL AND created_at <= ? ORDER BY created_at`, cutoff)
+ WHERE host IS NOT NULL AND created_at <= ? AND entitlement = 'free'
+ ORDER BY created_at`, cutoff)
if err != nil {
return nil, err
}
@@ -269,16 +309,18 @@ func (db *cloudDB) spaceOwned(callerID, requester, host string) (int64, bool, er
// Space is one provisioned instance in a requester's listing.
type Space struct {
- Host string `json:"host"`
- URL string `json:"url"`
- CreatedAt int64 `json:"created_at"`
+ Host string `json:"host"`
+ URL string `json:"url"`
+ CreatedAt int64 `json:"created_at"`
+ Entitlement string `json:"entitlement"`
+ PaidUntil int64 `json:"paid_until,omitempty"`
}
// listSpaces returns a requester's finalized spaces (a NULL host is a create
// still in flight or one that failed before rollback — not shown).
func (db *cloudDB) listSpaces(callerID, requester string) ([]Space, error) {
rows, err := db.sql.Query(
- `SELECT host, created_at FROM spaces
+ `SELECT host, created_at, entitlement, paid_until FROM spaces
WHERE caller_id = ? AND requester = ? AND host IS NOT NULL ORDER BY id`,
callerID, requester,
)
@@ -289,7 +331,7 @@ func (db *cloudDB) listSpaces(callerID, requester string) ([]Space, error) {
out := []Space{}
for rows.Next() {
var s Space
- if err := rows.Scan(&s.Host, &s.CreatedAt); err != nil {
+ if err := rows.Scan(&s.Host, &s.CreatedAt, &s.Entitlement, &s.PaidUntil); err != nil {
return nil, err
}
s.URL = "https://" + s.Host
@@ -297,3 +339,64 @@ func (db *cloudDB) listSpaces(callerID, requester string) ([]Space, error) {
}
return out, rows.Err()
}
+
+// ---- billing state (see billing.go) -------------------------------------------
+
+const (
+ entFree = "free"
+ entPaid = "paid"
+)
+
+// spaceEntitlement reads one space's entitlement flag.
+func (db *cloudDB) spaceEntitlement(id int64) (string, error) {
+ var ent string
+ err := db.sql.QueryRow(`SELECT entitlement FROM spaces WHERE id = ?`, id).Scan(&ent)
+ return ent, err
+}
+
+// markSpacePaid records a completed checkout: the opaque Stripe ids plus the
+// paid flag. paid_until is left alone — the subscription event that follows the
+// checkout carries the period end (see handleSubscriptionEvent).
+func (db *cloudDB) markSpacePaid(id int64, customerID, subscriptionID string) error {
+ res, err := db.sql.Exec(
+ `UPDATE spaces SET stripe_customer_id = ?, stripe_subscription_id = ?, entitlement = ? WHERE id = ?`,
+ customerID, subscriptionID, entPaid, id,
+ )
+ if err != nil {
+ return err
+ }
+ if n, _ := res.RowsAffected(); n == 0 {
+ return sql.ErrNoRows
+ }
+ return nil
+}
+
+// setEntitlement flips a space's entitlement + subscription period end.
+func (db *cloudDB) setEntitlement(id int64, ent string, paidUntil int64) error {
+ res, err := db.sql.Exec(
+ `UPDATE spaces SET entitlement = ?, paid_until = ? WHERE id = ?`, ent, paidUntil, id,
+ )
+ if err != nil {
+ return err
+ }
+ if n, _ := res.RowsAffected(); n == 0 {
+ return sql.ErrNoRows
+ }
+ return nil
+}
+
+// findSpaceBySubscription maps a Stripe subscription id back to its space — the
+// fallback for subscription events that arrive without space_id metadata.
+func (db *cloudDB) findSpaceBySubscription(subscriptionID string) (int64, bool, error) {
+ var id int64
+ err := db.sql.QueryRow(
+ `SELECT id FROM spaces WHERE stripe_subscription_id = ?`, subscriptionID,
+ ).Scan(&id)
+ if err == sql.ErrNoRows {
+ return 0, false, nil
+ }
+ if err != nil {
+ return 0, false, err
+ }
+ return id, true, nil
+}
diff --git a/docs/cloud-provisioner.md b/docs/cloud-provisioner.md
index c820d75..b5cbe11 100644
--- a/docs/cloud-provisioner.md
+++ b/docs/cloud-provisioner.md
@@ -137,7 +137,13 @@ POST /api/instances bearer: caller token {requester: <opaque id>}
201 {host, url, setup_link}
409 {error: quota_exceeded} per (caller, requester) quota, default 3
503 {error: at_capacity, message} the box is full
-GET /api/instances?requester=… this requester's spaces
+GET /api/instances?requester=… this requester's spaces (+ entitlement, and
+ a top-level billing flag)
+POST /api/instances/{host}/checkout bearer: caller token
+ {requester, success_url, cancel_url}
+ 200 {url} the hosted Stripe Checkout page
+ 404 not yours | 409 already_upgraded | 501 billing not configured
+POST /api/stripe/webhook signature-gated (Stripe calls it; see Billing)
POST /api/admin/tokens bearer: admin token {label} → {id, token} (shown once)
GET /api/admin/tokens list (never the token values)
DELETE /api/admin/tokens/{id} revoke
@@ -162,6 +168,8 @@ GET /api/spaces bearer: device link token
→ the hosts this user provisioned (drives the "Delete space" affordance)
DELETE /api/spaces/{host} X-Ceremony-Proof: fresh passkey ceremony ONLY
→ relays cloud's 200 / 404
+POST /api/spaces/{host}/checkout bearer: device link token
+ → relays cloud's 200 {url} / 404 / 409 / 501; return URLs point back at home
```
Creation and listing are link-token-authed (additive/reads) and creation is
@@ -239,6 +247,51 @@ window: `LCHAT_TRASH_KEEP_DAYS` (opt-in — 0/unset keeps trash forever) removes
`/data/trash/<host>-<unix>` entries older than that, swept daily. Keep it well
above the reap threshold so a mistaken reap stays recoverable for a while.
+## Billing (Stripe) — the rails, not the price book
+
+Optional space upgrades (`billing.go`): a person who created a space can pay a
+subscription to upgrade it. This is deliberately **just the rails** — one price,
+one flag — the tiers, numbers, and metering live in issues #561/#562 and layer
+on later without reworking any of this.
+
+**The flow.** The home page shows a quiet *Upgrade* on spaces this account
+created (only when the provisioner advertises billing). Tapping it asks home →
+cloud for a Stripe **Checkout Session**; the browser goes to Stripe's hosted
+page and comes back to home (`?upgraded=<host>`). Stripe then confirms through
+the **webhook** — `checkout.session.completed` flips the space's ledger row to
+`paid`, and `customer.subscription.updated/deleted` track it for the life of the
+subscription (`past_due` keeps access through Stripe's dunning window; deletion
+drops the space back to `free`). The row's numeric ledger id rides in the
+session's `client_reference_id` and the subscription's metadata — that's how
+events route back to a space.
+
+**What the flag does today:** a paid space is **never a reap candidate**
+(excluded in the ledger query itself, the strongest of the guards above), and
+the entitlement is returned on space listings so clients can render it. That's
+all — per-space limits read the same flag when #561 lands.
+
+**Privacy (the point):** the ledger stores two opaque Stripe ids
+(`stripe_customer_id`, `stripe_subscription_id`), the `entitlement` flag, and
+`paid_until`. **Never an email, name, address, or card detail** — Stripe holds
+those on its side; paying deanonymizes you *to Stripe*, not to the ledger
+(#562's documented residual). Checkout Sessions are created with no customer
+fields at all. The webhook is the one public unauthenticated-by-token endpoint;
+its gate is Stripe's HMAC signature over the raw payload (±5 min replay window).
+
+**Config** (`/data/cloud.env`; all-or-nothing — partial config refuses to start):
+
+```
+LCHAT_STRIPE_SECRET_KEY restricted API key (write: Checkout Sessions; read: nothing else needed)
+LCHAT_STRIPE_WEBHOOK_SECRET the webhook endpoint's signing secret
+LCHAT_STRIPE_PRICE_ID the one subscription price (test or live)
+LCHAT_STRIPE_API_BASE default https://api.stripe.com (tests point it at a stub)
+```
+
+Unset = billing off: checkout answers 501, listings say `billing: false`, the
+UI shows nothing. The Stripe-side webhook endpoint must subscribe to
+`checkout.session.completed` and `customer.subscription.*`, pointed at
+`https://<cloud host>/api/stripe/webhook`.
+
## Enabling it on a box
```sh
diff --git a/home/main.go b/home/main.go
index 61dc829..922bafb 100644
--- a/home/main.go
+++ b/home/main.go
@@ -50,7 +50,10 @@ type Server struct {
limiter *rateLimiter
// cloud is the optional space provisioner client (see provision.go); nil
// means this home server doesn't offer space creation.
- cloud *cloudProvisioner
+ cloud *cloudProvisioner
+ // origin is this server's public origin (also the WebAuthn RP origin) —
+ // used to build the checkout return URLs.
+ origin string
page []byte
enrollPage []byte
restorePage []byte
@@ -181,6 +184,7 @@ func newServer(store vaultStore, origin string, stateSecret []byte) (*Server, er
keys: keys,
limiter: newRateLimiter(10, 10.0/60), // burst of 10, refills 10/minute
page: page,
+ origin: strings.TrimRight(origin, "/"),
}, nil
}
@@ -230,6 +234,9 @@ func (s *Server) handler() http.Handler {
mux.HandleFunc("GET /api/spaces", s.listSpacesHandler)
// …and permanent deletion of one — ceremony-gated like every destructive op.
mux.HandleFunc("DELETE /api/spaces/{host}", s.deleteSpaceHandler)
+ // Upgrading a space (a Stripe Checkout round trip) is additive, so it takes
+ // the link token; rate-limited because each call mints a checkout session.
+ mux.HandleFunc("POST /api/spaces/{host}/checkout", s.rateLimited(s.checkoutSpaceHandler))
// Devices: list with a link token; revoke only with a fresh ceremony. The
// asymmetry is deliberate — a stolen link token must never reduce access.
diff --git a/home/provision.go b/home/provision.go
index 31c576e..3c14e57 100644
--- a/home/provision.go
+++ b/home/provision.go
@@ -161,6 +161,48 @@ func (s *Server) deleteSpaceHandler(w http.ResponseWriter, r *http.Request) {
}
}
+// checkoutSpaceHandler: POST /api/spaces/{host}/checkout — ask the provisioner
+// for a Stripe Checkout link to upgrade a space this user created. Link-token
+// auth (paying is additive, not destructive — same bar as creating); ownership
+// is enforced downstream by the provisioner's ledger. The success/cancel URLs
+// land back on this page, so the checkout is a same-tab round trip.
+func (s *Server) checkoutSpaceHandler(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 upgrades",
+ })
+ return
+ }
+ host := r.PathValue("host")
+ payload, _ := json.Marshal(map[string]string{
+ "requester": uid,
+ "success_url": s.origin + "/?upgraded=" + url.QueryEscape(host),
+ "cancel_url": s.origin + "/",
+ })
+ status, body, err := s.cloud.do("POST", "/api/instances/"+url.PathEscape(host)+"/checkout", payload)
+ if err != nil {
+ writeJSON(w, http.StatusBadGateway, map[string]any{
+ "error": "checkout_failed", "message": "could not reach the space provisioner",
+ })
+ return
+ }
+ // Relay the provisioner's stable codes (200 link, 404 not yours, 409 already
+ // upgraded, 501 no billing); flatten anything else.
+ switch status {
+ case http.StatusOK, http.StatusNotFound, http.StatusConflict, http.StatusNotImplemented:
+ writeJSON(w, status, body)
+ default:
+ writeJSON(w, http.StatusBadGateway, map[string]any{
+ "error": "checkout_failed", "message": "couldn't start the upgrade",
+ })
+ }
+}
+
// createSpace forwards one provisioning request; the user id is the quota key
// (opaque to the provisioner — random, no email or name anywhere).
func (c *cloudProvisioner) createSpace(requester string) (int, map[string]any, error) {
diff --git a/home/provision_test.go b/home/provision_test.go
index ef7f8ad..f0a4fea 100644
--- a/home/provision_test.go
+++ b/home/provision_test.go
@@ -217,6 +217,78 @@ func TestCreateSpaceFlattensUnexpectedStatus(t *testing.T) {
}
}
+func TestCheckoutSpaceProxies(t *testing.T) {
+ ts, s := newTestServer(t)
+ uid, token := seedUser(t, s)
+
+ post := func(tok string) *http.Response {
+ req, _ := http.NewRequest("POST", ts.URL+"/api/spaces/a4f.example.com/checkout", nil)
+ if tok != "" {
+ 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
+ }
+
+ // No auth → 401; no provisioner → 501.
+ if r := post(""); r.StatusCode != http.StatusUnauthorized {
+ t.Fatalf("no auth: %d, want 401", r.StatusCode)
+ }
+ if r := post(token); r.StatusCode != http.StatusNotImplemented {
+ t.Fatalf("unconfigured: %d, want 501", r.StatusCode)
+ }
+
+ cloud, captured, capturedBody := fakeCloud(t, http.StatusOK, map[string]any{
+ "url": "https://checkout.stripe.com/c/pay/cs_x",
+ })
+ s.cloud = newCloudProvisioner(cloud.URL, "caller-token")
+
+ resp := post(token)
+ if resp.StatusCode != http.StatusOK {
+ t.Fatalf("status = %d, want 200", resp.StatusCode)
+ }
+ var m map[string]any
+ json.NewDecoder(resp.Body).Decode(&m)
+ if !strings.Contains(m["url"].(string), "checkout.stripe.com") {
+ t.Errorf("relayed url = %v", m["url"])
+ }
+
+ // Home forwarded the uid as requester and return URLs on its own origin —
+ // success carries the host back so the page can thank inline.
+ if captured.URL.Path != "/api/instances/a4f.example.com/checkout" {
+ t.Errorf("cloud saw path %s", captured.URL.Path)
+ }
+ var sent map[string]string
+ json.Unmarshal(*capturedBody, &sent)
+ if sent["requester"] != uid {
+ t.Errorf("requester = %q, want %s", sent["requester"], uid)
+ }
+ if !strings.HasPrefix(sent["success_url"], "http://localhost:1748/?upgraded=") ||
+ !strings.Contains(sent["success_url"], "a4f.example.com") {
+ t.Errorf("success_url = %q", sent["success_url"])
+ }
+ if sent["cancel_url"] != "http://localhost:1748/" {
+ t.Errorf("cancel_url = %q", sent["cancel_url"])
+ }
+
+ // Stable downstream codes relay as-is (409 already upgraded)…
+ conflict, _, _ := fakeCloud(t, http.StatusConflict, map[string]any{"error": "already_upgraded"})
+ s.cloud = newCloudProvisioner(conflict.URL, "caller-token")
+ if r := post(token); r.StatusCode != http.StatusConflict {
+ t.Errorf("conflict relay: %d, want 409", r.StatusCode)
+ }
+ // …anything unexpected flattens to 502.
+ weird, _, _ := fakeCloud(t, http.StatusTeapot, map[string]any{"error": "internal-detail"})
+ s.cloud = newCloudProvisioner(weird.URL, "caller-token")
+ if r := post(token); r.StatusCode != http.StatusBadGateway {
+ t.Errorf("weird relay: %d, want 502", r.StatusCode)
+ }
+}
+
func TestDeleteSpaceRequiresFreshCeremony(t *testing.T) {
ts, s := newTestServer(t)
uid, linkToken := seedUser(t, s)
diff --git a/home/web/pages/index.html b/home/web/pages/index.html
index 6f0d5e4..6e86a74 100644
--- a/home/web/pages/index.html
+++ b/home/web/pages/index.html
@@ -215,6 +215,29 @@
@media (hover: none) { .row-archive { opacity: .4; } }
@media (prefers-reduced-motion: reduce) { .row-archive { transition: none; } }
+ /* The per-row Upgrade: a quiet pill that reveals on hover like the archive
+ glyph — shown only on spaces this account created, when the provisioner
+ offers billing. Always focusable (focus reveals it), ≥24px tall. */
+ .row-upgrade {
+ flex: none; font: inherit; font-size: .72rem; font-weight: 600; cursor: pointer;
+ min-height: 24px; padding: 3px 10px; border-radius: 999px;
+ color: var(--muted); background: none; border: 1px solid var(--border);
+ opacity: 0; transition: opacity .12s ease, color .12s ease, border-color .12s ease;
+ }
+ .row-upgrade:hover { color: var(--accent); border-color: var(--accent); }
+ .row-upgrade:focus-visible { opacity: 1; outline: 2px solid var(--accent); outline-offset: 1px; }
+ .row-upgrade:disabled { opacity: .4; cursor: default; }
+ @media (hover: hover) { ul.servers li:hover .row-upgrade { opacity: .7; } }
+ @media (hover: none) { .row-upgrade { opacity: .55; } }
+ @media (prefers-reduced-motion: reduce) { .row-upgrade { transition: none; } }
+ /* The Supporter badge on an upgraded space's row. */
+ .supporter-badge {
+ align-self: flex-start; margin-top: 2px;
+ font-size: .62rem; font-weight: 700; text-transform: uppercase; letter-spacing: .05em;
+ color: var(--accent); border: 1px solid color-mix(in srgb, var(--accent) 40%, transparent);
+ border-radius: 999px; padding: 1px 7px;
+ }
+
/* The inline "Archived <name> · Undo" note — occupies layout, never a toast. */
.undo-note {
display: flex; align-items: baseline; gap: 6px; flex-wrap: wrap;
diff --git a/home/web/static/home.js b/home/web/static/home.js
index 4c88174..581100a 100644
--- a/home/web/static/home.js
+++ b/home/web/static/home.js
@@ -75,6 +75,16 @@ function labelOf(s) { return s.name || hostOf(s.url); }
// /api/features). The create-a-space controls only appear when it's both
// enabled here and you're signed in.
let spacesEnabled = false;
+// provisioned: host → row from GET /api/spaces — which listed spaces THIS
+// account created, plus their entitlement. billingEnabled mirrors the
+// provisioner's flag; both drive the quiet Upgrade affordance + Supporter
+// badge. null = not fetched yet (fetched lazily, once per sign-in).
+let provisioned = null;
+let billingEnabled = false;
+// A checkout round trip lands back here with ?upgraded=<host>; consumed (once)
+// by the first showVault after return.
+let upgradedHost = new URLSearchParams(location.search).get("upgraded") || "";
+if (upgradedHost) history.replaceState(null, "", location.pathname + location.hash);
// Ceremony options are pre-fetched so the button click reaches
// navigator.credentials.* synchronously — Safari drops the user activation
@@ -209,6 +219,7 @@ function signOut() {
sessionStorage.removeItem("home_cred");
vault = null;
dekKey = null;
+ provisioned = null; // the ledger read is per-sign-in
$("vault").classList.add("hidden");
$("create").classList.add("hidden");
$("archive").classList.add("hidden");
@@ -281,6 +292,32 @@ function showVault() {
}
if (archived.length) box.append(archivedButton(archived.length));
+
+ // The ledger read behind the Upgrade affordance — lazy, once per sign-in.
+ if (spacesEnabled && provisioned === null && state.token) refreshProvisioned();
+
+ // Back from a completed checkout: thank inline, then re-read the ledger a few
+ // seconds later — the webhook that flips the badge can lag the redirect.
+ if (upgradedHost) {
+ vaultStatus("Payment received — " + upgradedHost + " is upgraded. Thank you!", "ok");
+ upgradedHost = "";
+ setTimeout(() => { provisioned = null; if (vault && !$("vault").classList.contains("hidden")) showVault(); }, 5000);
+ }
+}
+
+// refreshProvisioned learns which spaces this account created and their
+// entitlement (the provisioner's ledger, via home). No answer just means no
+// Upgrade affordance — the list renders fine without it.
+function refreshProvisioned() {
+ provisioned = {}; // in flight — re-renders don't refetch
+ apiJSON("/api/spaces", { headers: { Authorization: "Bearer " + state.token } })
+ .then((r) => {
+ billingEnabled = !!r.billing;
+ provisioned = {};
+ for (const sp of r.spaces || []) provisioned[sp.host] = sp;
+ if (vault && !$("vault").classList.contains("hidden")) showVault();
+ })
+ .catch(() => { /* ledger unreachable — render without the affordance */ });
}
// ---- archive: undo banner + entry point --------------------------------------
@@ -408,8 +445,28 @@ function serverRow(s, sortable) {
url.textContent = s.url;
main.append(name, url);
+ // Billing state, for spaces this account created (the provisioner's ledger):
+ // upgraded rows carry a quiet Supporter badge; free ones offer Upgrade.
+ const ledger = billingEnabled && provisioned ? provisioned[hostOf(s.url)] : null;
+ if (ledger && ledger.entitlement === "paid") {
+ const badge = document.createElement("span");
+ badge.className = "supporter-badge";
+ badge.textContent = "Supporter";
+ main.append(badge);
+ }
+
li.append(star, main);
+ if (ledger && ledger.entitlement !== "paid") {
+ const up = document.createElement("button");
+ up.type = "button";
+ up.className = "row-upgrade";
+ up.textContent = "Upgrade";
+ up.setAttribute("aria-label", "Upgrade " + labelOf(s));
+ up.addEventListener("click", () => startUpgrade(hostOf(s.url), up));
+ li.append(up);
+ }
+
// Archive: a quiet box glyph that reveals on hover (like the grip). One tap
// sets it aside (reversible via the inline Undo); no confirmation — the archive
// is where the permanent, strongly-worded delete lives.
@@ -564,6 +621,30 @@ async function persistVault(status = vaultStatus) {
}
}
+// ---- upgrade a space ----------------------------------------------------------
+
+// startUpgrade begins the checkout round trip for a space this account created:
+// home mints a Stripe Checkout link (ownership enforced by the provisioner's
+// ledger) and we follow it in the same tab — Stripe brings you back here with
+// ?upgraded=<host> when it's done, and Cancel comes straight back too.
+async function startUpgrade(host, btn) {
+ btn.disabled = true;
+ vaultStatus("Opening checkout…", "busy");
+ try {
+ const res = await fetch("/api/spaces/" + encodeURIComponent(host) + "/checkout", {
+ method: "POST",
+ headers: { Authorization: "Bearer " + state.token },
+ });
+ const body = await res.json().catch(() => ({}));
+ if (!res.ok) throw new Error(body.message || body.error || `Couldn't start the upgrade (HTTP ${res.status}).`);
+ if (!body.url) throw new Error("The checkout link didn't come back — try again.");
+ location.href = body.url;
+ } catch (e) {
+ vaultStatus(e.message || "Couldn't start the upgrade.", "error");
+ btn.disabled = false;
+ }
+}
+
// ---- create a new space -------------------------------------------------------
// requestSpace is the real create call, fired by the fader the moment it snaps
@@ -1050,6 +1131,9 @@ apiJSON("/api/features")
.then((f) => {
spacesEnabled = !!f.spaces;
if (vault) $("open-create").classList.toggle("hidden", !canCreateSpace());
+ // If the list rendered before this answer, re-render: the lazy ledger read
+ // (Upgrade affordance) only triggers once spacesEnabled is known.
+ if (vault && !$("vault").classList.contains("hidden")) showVault();
})
.catch(() => { /* older home server without the endpoint: leave the + hidden */ });