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>
2 files changed,
+65
−4
subscriptions_handlers.go+14 −4subscriptions_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) |
| + } |
| + } |
| +} |