eleven / messenger Public

server: accept Stripe's new unprefixed account ids when linking billing (#1118)

A real live connect returned a bare 32-char account id; the acct_-prefix
check (a paste-box-era leftover) 400'd the save and the whole OAuth link
failed silently. Validate shape, never a prefix — the id comes from Stripe
via the broker now.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 11bc6861a1d52d2a4a7bf69dbc4fa968d43ec803 parent 4573533
2 files changed, +65 −4
  • subscriptions_handlers.go +14 −4
  • subscriptions_test.go +51 −0
diff --git a/subscriptions_handlers.go b/subscriptions_handlers.go
index 5628181..d4bb34c 100644
--- a/subscriptions_handlers.go
+++ b/subscriptions_handlers.go
@@ -11,10 +11,15 @@ import (
"encoding/json"
"io"
"net/http"
+ "regexp"
"strings"
"time"
)
+// stripeAccountIDRe accepts both Stripe account id shapes: classic "acct_…"
+// and the newer unprefixed opaque ids — alphanumeric/underscore, sane length.
+var stripeAccountIDRe = regexp.MustCompile(`^[A-Za-z0-9_]{8,64}$`)
+
// supportedCurrency gates prices to 2-decimal currencies the UI renders. The
// whole system treats amounts as minor units (cents/100); a zero-decimal
// currency (JPY, KRW…) would be charged 100× at Stripe. Widen only alongside
@@ -38,8 +43,11 @@ func (s *Server) origin(r *http.Request) string {
return scheme + "://" + r.Host
}
-// postBillingAccount (admin) links the space's Stripe connected account —
-// "acct_…", where members' payments land (the admin is merchant of record).
+// postBillingAccount (admin) links the space's Stripe connected account,
+// where members' payments land (the admin is merchant of record). Classic
+// accounts are "acct_…", but Stripe's newer accounts carry unprefixed opaque
+// ids (seen live 2026-07-18: a fresh live connect returned a bare 32-char
+// id) — so validate shape, never a prefix.
// The id arrives from the OAuth return leg (completeOAuthReturn in shell.js —
// the broker exchanged the code and forwarded only the account id), posted
// under the admin's own session so a callback alone can never link a space.
@@ -56,8 +64,10 @@ func (s *Server) postBillingAccount(w http.ResponseWriter, r *http.Request) {
return
}
acct := strings.TrimSpace(body.Account)
- if acct != "" && !strings.HasPrefix(acct, "acct_") {
- httpError(w, http.StatusBadRequest, "an account id looks like acct_…")
+ // The id comes from Stripe via the broker (the paste box is gone), so the
+ // only job here is rejecting something that plainly isn't an account id.
+ if acct != "" && !stripeAccountIDRe.MatchString(acct) {
+ httpError(w, http.StatusBadRequest, "that doesn't look like a Stripe account id")
return
}
if err := s.db.setConfig("stripe_connect_account", acct); err != nil {
diff --git a/subscriptions_test.go b/subscriptions_test.go
index 0db31ec..a2a7c2f 100644
--- a/subscriptions_test.go
+++ b/subscriptions_test.go
@@ -585,3 +585,54 @@ func TestBillingSweepRepairsLostLifecycle(t *testing.T) {
t.Fatalf("repair kept a membership whose sub the provider no longer knows")
}
}
+
+// TestBillingAccountIDFormats pins the link endpoint's id validation: classic
+// "acct_…" ids and Stripe's newer unprefixed opaque ids both link (a real live
+// connect returned a bare 32-char id, 2026-07-18 — the old acct_-prefix check
+// silently killed the whole OAuth flow), while junk is still rejected.
+func TestBillingAccountIDFormats(t *testing.T) {
+ ts, srv := newTestServer(t)
+
+ u, err := srv.db.createUser("Root")
+ if err != nil {
+ t.Fatalf("createUser: %v", err)
+ }
+ if err := srv.db.createKeyInvite("inv-root", u, "wrap", testClaimHash, 0); err != nil {
+ t.Fatalf("createKeyInvite: %v", err)
+ }
+ tok, ok, err := srv.db.claimKeyInvite("inv-root", "Root", "", testClaimSecret)
+ if err != nil || !ok {
+ t.Fatalf("claim: ok=%v err=%v", ok, err)
+ }
+ if err := srv.db.setUserRole(u, "admin"); err != nil {
+ t.Fatalf("setUserRole: %v", err)
+ }
+
+ link := func(acct string) int {
+ resp, err := http.Post(ts.URL+"/api/admin/billing/account", "application/json",
+ strings.NewReader(`{"token":"`+tok+`","account":"`+acct+`"}`))
+ if err != nil {
+ t.Fatalf("post: %v", err)
+ }
+ resp.Body.Close()
+ return resp.StatusCode
+ }
+
+ for _, good := range []string{"acct_1Trl9YB043v2tsWJ", "IEkvGEF1Q5uY8zvlhjgFZYc3f7lZEzqx", ""} {
+ if code := link(good); code != 200 {
+ t.Fatalf("link %q: %d, want 200", good, code)
+ }
+ }
+ // The last good link ("") cleared it; relink and confirm it round-trips.
+ if code := link("IEkvGEF1Q5uY8zvlhjgFZYc3f7lZEzqx"); code != 200 {
+ t.Fatal("relink failed")
+ }
+ if acct, _, _ := srv.db.getConfig("stripe_connect_account"); acct != "IEkvGEF1Q5uY8zvlhjgFZYc3f7lZEzqx" {
+ t.Fatalf("stored account = %q", acct)
+ }
+ for _, bad := range []string{"short", "has spaces in it", "acct_%3B;drop", strings.Repeat("x", 70)} {
+ if code := link(bad); code != 400 {
+ t.Fatalf("link %q: %d, want 400", bad, code)
+ }
+ }
+}