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>
10 files changed,
+1147
−17
billing.go+353 −0cloud.go+26 −7cloud_billing_test.go+374 −0cloud_db.go+111 −8docs/cloud-provisioner.md+54 −1home/main.go+8 −1home/provision.go+42 −0home/provision_test.go+72 −0home/web/pages/index.html+23 −0home/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, ¬null, &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 */ }); |