diff --git a/canvas/src/app/orgs/page.tsx b/canvas/src/app/orgs/page.tsx index 5f1787d6..29a32632 100644 --- a/canvas/src/app/orgs/page.tsx +++ b/canvas/src/app/orgs/page.tsx @@ -352,7 +352,8 @@ function CreateOrgForm({ onCreated }: { onCreated: (slug: string) => void }) { }); if (!res.ok) { const body = await res.text(); - throw new Error(`${res.status}: ${body}`); + console.error(`[orgs] create ${res.status}: ${body}`); + throw new Error(`Failed to create organization (${res.status})`); } onCreated(slug); } catch (e) { diff --git a/canvas/src/lib/__tests__/billing.test.ts b/canvas/src/lib/__tests__/billing.test.ts index 9572296c..69c0e1bc 100644 --- a/canvas/src/lib/__tests__/billing.test.ts +++ b/canvas/src/lib/__tests__/billing.test.ts @@ -70,7 +70,7 @@ describe("startCheckout", () => { expect(body.cancel_url).toContain("checkout=cancel"); }); - it("throws with the body text on non-2xx so the UI can surface it", async () => { + it("throws with status code on non-2xx; body is logged not surfaced", async () => { (global.fetch as ReturnType).mockResolvedValue({ ok: false, status: 402, @@ -78,8 +78,11 @@ describe("startCheckout", () => { json: async () => ({}), }); + // Status code must appear so callers know what happened. await expect(startCheckout("starter", "acme")).rejects.toThrow(/402/); - await expect(startCheckout("starter", "acme")).rejects.toThrow(/payment required/); + // Body text must NOT appear — it may contain Stripe API detail. + await expect(startCheckout("starter", "acme")).rejects.toThrow(/checkout failed/); + await expect(startCheckout("starter", "acme")).rejects.not.toThrow(/payment required/); }); it("sends users to /orgs on success, back to current page on cancel", async () => { diff --git a/canvas/src/lib/billing.ts b/canvas/src/lib/billing.ts index 35f9833d..c9260e61 100644 --- a/canvas/src/lib/billing.ts +++ b/canvas/src/lib/billing.ts @@ -120,8 +120,12 @@ export async function startCheckout( }), }); if (!res.ok) { - const text = await res.text(); - throw new Error(`checkout: ${res.status} ${text}`); + // Never embed res.text() in the thrown error — the response body + // may contain Stripe API error detail (e.g. invalid key, card decline + // message, raw Stripe envelope) that should not reach the client. + const detail = await res.text(); + console.error(`[billing] checkout ${res.status}: ${detail}`); + throw new Error(`checkout failed (${res.status})`); } return res.json(); } @@ -141,8 +145,12 @@ export async function openBillingPortal(orgSlug: string): Promise { body: JSON.stringify({ org_slug: orgSlug, return_url: returnUrl }), }); if (!res.ok) { - const text = await res.text(); - throw new Error(`portal: ${res.status} ${text}`); + // Never embed res.text() in the thrown error — the response body + // may contain Stripe API error detail (e.g. invalid key, card decline + // message, raw Stripe envelope) that should not reach the client. + const detail = await res.text(); + console.error(`[billing] portal ${res.status}: ${detail}`); + throw new Error(`portal failed (${res.status})`); } const data = (await res.json()) as { url: string }; return data.url;