brains-mcp 1.91.1 → 1.93.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/billing/stripe.d.ts +124 -0
- package/dist/billing/stripe.d.ts.map +1 -0
- package/dist/billing/stripe.js +172 -0
- package/dist/billing/stripe.js.map +1 -0
- package/dist/billing/webhook.d.ts +65 -0
- package/dist/billing/webhook.d.ts.map +1 -0
- package/dist/billing/webhook.js +69 -0
- package/dist/billing/webhook.js.map +1 -0
- package/dist/httpServer.d.ts.map +1 -1
- package/dist/httpServer.js +129 -0
- package/dist/httpServer.js.map +1 -1
- package/package.json +2 -1
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stripe Checkout, the path from a chosen tier to a hosted payment page
|
|
3
|
+
* (GH #1057).
|
|
4
|
+
*
|
|
5
|
+
* This is the first money-handling code in the repo. Three rules shape it.
|
|
6
|
+
*
|
|
7
|
+
* **Price ids come from the environment, never from the request.** A caller
|
|
8
|
+
* sends a tier name. This module turns that into a Price id. If the browser
|
|
9
|
+
* could name a price, anyone could buy the $39 tier at a price of their own
|
|
10
|
+
* making. Test mode and live mode also carry different ids, so hard-coding one
|
|
11
|
+
* would make the deploy that flips modes a code change.
|
|
12
|
+
*
|
|
13
|
+
* **Return URLs are built here, never taken from the request.** A
|
|
14
|
+
* caller-supplied `success_url` is an open redirect wearing a payment page as
|
|
15
|
+
* a disguise.
|
|
16
|
+
*
|
|
17
|
+
* **The session carries the subject, not the tier.** `client_reference_id`
|
|
18
|
+
* holds the Brains subject so the webhook in GH #1059 knows whose tier to
|
|
19
|
+
* write. The tier itself is read back from the Price id on the completed
|
|
20
|
+
* subscription, so a tampered session cannot grant a tier nobody paid for.
|
|
21
|
+
*/
|
|
22
|
+
import Stripe from "stripe";
|
|
23
|
+
import type { BillingTier } from "./tierStore.js";
|
|
24
|
+
/** The three tiers a customer can buy. The others are assigned by seedUsers. */
|
|
25
|
+
export declare const PAID_TIERS: readonly ["solo", "pro", "unlimited"];
|
|
26
|
+
export type PaidTier = (typeof PAID_TIERS)[number];
|
|
27
|
+
export declare function isPaidTier(value: unknown): value is PaidTier;
|
|
28
|
+
/** Every paid tier widens to a BillingTier, so tierStore can store it unchanged. */
|
|
29
|
+
export declare function paidTierToBillingTier(tier: PaidTier): BillingTier;
|
|
30
|
+
export interface StripeConfig {
|
|
31
|
+
secretKey: string;
|
|
32
|
+
prices: Record<PaidTier, string>;
|
|
33
|
+
}
|
|
34
|
+
export type StripeConfigResult = {
|
|
35
|
+
ok: true;
|
|
36
|
+
config: StripeConfig;
|
|
37
|
+
} | {
|
|
38
|
+
ok: false;
|
|
39
|
+
missing: string[];
|
|
40
|
+
};
|
|
41
|
+
/**
|
|
42
|
+
* Read the Stripe configuration out of an environment.
|
|
43
|
+
*
|
|
44
|
+
* Returns the names of the variables that are missing rather than throwing.
|
|
45
|
+
* The server must keep serving wiki traffic when billing is not configured,
|
|
46
|
+
* which is the normal state of a local run and of any self-hosted instance.
|
|
47
|
+
*/
|
|
48
|
+
export declare function resolveStripeConfig(env?: NodeJS.ProcessEnv): StripeConfigResult;
|
|
49
|
+
/**
|
|
50
|
+
* Build the URLs Stripe sends the customer back to.
|
|
51
|
+
*
|
|
52
|
+
* `webUrl` is the server's own `WEB_URL`, so neither value can be influenced
|
|
53
|
+
* by the caller. Stripe replaces `{CHECKOUT_SESSION_ID}` itself.
|
|
54
|
+
*/
|
|
55
|
+
export declare function checkoutReturnUrls(webUrl: string, tier: PaidTier): {
|
|
56
|
+
successUrl: string;
|
|
57
|
+
cancelUrl: string;
|
|
58
|
+
};
|
|
59
|
+
export interface CheckoutSessionRequest {
|
|
60
|
+
tier: PaidTier;
|
|
61
|
+
/** The Brains subject. Travels on the session so the webhook can resolve it. */
|
|
62
|
+
subject: string;
|
|
63
|
+
/** Prefills the Stripe payment page. Optional, because not every auth mode has one. */
|
|
64
|
+
email?: string;
|
|
65
|
+
config: StripeConfig;
|
|
66
|
+
webUrl: string;
|
|
67
|
+
/** Injected in tests. Defaults to a real Stripe client. */
|
|
68
|
+
stripe?: Pick<Stripe, "checkout">;
|
|
69
|
+
}
|
|
70
|
+
export interface CheckoutSessionResult {
|
|
71
|
+
id: string;
|
|
72
|
+
url: string;
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Create a Stripe Checkout Session and return the URL to send the browser to.
|
|
76
|
+
*
|
|
77
|
+
* Throws when Stripe rejects the call or returns a session with no URL. The
|
|
78
|
+
* caller turns that into a 502, because a failure here is Stripe's side and
|
|
79
|
+
* not the customer's mistake.
|
|
80
|
+
*/
|
|
81
|
+
export declare function createCheckoutSession(req: CheckoutSessionRequest): Promise<CheckoutSessionResult>;
|
|
82
|
+
/**
|
|
83
|
+
* Resolve the tier a Stripe subscription entitles its owner to.
|
|
84
|
+
*
|
|
85
|
+
* Status drives this before price does. A subscription whose price says "pro"
|
|
86
|
+
* but whose status says `canceled` entitles nobody to anything.
|
|
87
|
+
*
|
|
88
|
+
* `past_due` deliberately keeps the paid tier. Stripe retries a failed payment
|
|
89
|
+
* for days before giving up, and cutting a paying customer off at the first
|
|
90
|
+
* failed charge is a worse outcome than a few days of unpaid access. Stripe
|
|
91
|
+
* moves the subscription to `canceled` or `unpaid` when it stops retrying, and
|
|
92
|
+
* that is where access ends.
|
|
93
|
+
*/
|
|
94
|
+
export declare function tierForSubscription(subscription: {
|
|
95
|
+
status: string;
|
|
96
|
+
items: {
|
|
97
|
+
data: Array<{
|
|
98
|
+
price: {
|
|
99
|
+
id: string;
|
|
100
|
+
};
|
|
101
|
+
}>;
|
|
102
|
+
};
|
|
103
|
+
}, prices: Record<PaidTier, string>): BillingTier;
|
|
104
|
+
/** The Brains subject a subscription belongs to, or null when it carries none. */
|
|
105
|
+
export declare function subjectForSubscription(subscription: {
|
|
106
|
+
metadata?: Record<string, string> | null;
|
|
107
|
+
}): string | null;
|
|
108
|
+
/** Event types that change a tier. Everything else is acknowledged and ignored. */
|
|
109
|
+
export declare const HANDLED_WEBHOOK_EVENTS: readonly ["checkout.session.completed", "customer.subscription.updated", "customer.subscription.deleted"];
|
|
110
|
+
export declare function isHandledWebhookEvent(type: string): boolean;
|
|
111
|
+
/**
|
|
112
|
+
* The subscription id an event points at, or null when there is none to read.
|
|
113
|
+
*
|
|
114
|
+
* A `checkout.session.completed` for a subscription carries the subscription as
|
|
115
|
+
* an id or as an expanded object. The two subscription events carry the object
|
|
116
|
+
* itself.
|
|
117
|
+
*/
|
|
118
|
+
export declare function subscriptionIdForEvent(event: {
|
|
119
|
+
type: string;
|
|
120
|
+
data: {
|
|
121
|
+
object: unknown;
|
|
122
|
+
};
|
|
123
|
+
}): string | null;
|
|
124
|
+
//# sourceMappingURL=stripe.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"stripe.d.ts","sourceRoot":"","sources":["../../src/billing/stripe.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;AAEH,OAAO,MAAM,MAAM,QAAQ,CAAC;AAC5B,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,gBAAgB,CAAC;AAElD,gFAAgF;AAChF,eAAO,MAAM,UAAU,uCAAwC,CAAC;AAEhE,MAAM,MAAM,QAAQ,GAAG,CAAC,OAAO,UAAU,CAAC,CAAC,MAAM,CAAC,CAAC;AAEnD,wBAAgB,UAAU,CAAC,KAAK,EAAE,OAAO,GAAG,KAAK,IAAI,QAAQ,CAE5D;AAED,oFAAoF;AACpF,wBAAgB,qBAAqB,CAAC,IAAI,EAAE,QAAQ,GAAG,WAAW,CAEjE;AASD,MAAM,WAAW,YAAY;IAC3B,SAAS,EAAE,MAAM,CAAC;IAClB,MAAM,EAAE,MAAM,CAAC,QAAQ,EAAE,MAAM,CAAC,CAAC;CAClC;AAED,MAAM,MAAM,kBAAkB,GAC1B;IAAE,EAAE,EAAE,IAAI,CAAC;IAAC,MAAM,EAAE,YAAY,CAAA;CAAE,GAClC;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,OAAO,EAAE,MAAM,EAAE,CAAA;CAAE,CAAC;AAErC;;;;;;GAMG;AACH,wBAAgB,mBAAmB,CAAC,GAAG,GAAE,MAAM,CAAC,UAAwB,GAAG,kBAAkB,CAmB5F;AAED;;;;;GAKG;AACH,wBAAgB,kBAAkB,CAAC,MAAM,EAAE,MAAM,EAAE,IAAI,EAAE,QAAQ,GAAG;IAAE,UAAU,EAAE,MAAM,CAAC;IAAC,SAAS,EAAE,MAAM,CAAA;CAAE,CAM5G;AAED,MAAM,WAAW,sBAAsB;IACrC,IAAI,EAAE,QAAQ,CAAC;IACf,gFAAgF;IAChF,OAAO,EAAE,MAAM,CAAC;IAChB,uFAAuF;IACvF,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,MAAM,EAAE,YAAY,CAAC;IACrB,MAAM,EAAE,MAAM,CAAC;IACf,2DAA2D;IAC3D,MAAM,CAAC,EAAE,IAAI,CAAC,MAAM,EAAE,UAAU,CAAC,CAAC;CACnC;AAED,MAAM,WAAW,qBAAqB;IACpC,EAAE,EAAE,MAAM,CAAC;IACX,GAAG,EAAE,MAAM,CAAC;CACb;AAED;;;;;;GAMG;AACH,wBAAsB,qBAAqB,CAAC,GAAG,EAAE,sBAAsB,GAAG,OAAO,CAAC,qBAAqB,CAAC,CAsBvG;AAID;;;;;;;;;;;GAWG;AACH,wBAAgB,mBAAmB,CACjC,YAAY,EAAE;IAAE,MAAM,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE;QAAE,IAAI,EAAE,KAAK,CAAC;YAAE,KAAK,EAAE;gBAAE,EAAE,EAAE,MAAM,CAAA;aAAE,CAAA;SAAE,CAAC,CAAA;KAAE,CAAA;CAAE,EACnF,MAAM,EAAE,MAAM,CAAC,QAAQ,EAAE,MAAM,CAAC,GAC/B,WAAW,CAcb;AAED,kFAAkF;AAClF,wBAAgB,sBAAsB,CAAC,YAAY,EAAE;IAAE,QAAQ,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,GAAG,IAAI,CAAA;CAAE,GAAG,MAAM,GAAG,IAAI,CAGhH;AAED,mFAAmF;AACnF,eAAO,MAAM,sBAAsB,2GAIzB,CAAC;AAEX,wBAAgB,qBAAqB,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAE3D;AAED;;;;;;GAMG;AACH,wBAAgB,sBAAsB,CAAC,KAAK,EAAE;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE;QAAE,MAAM,EAAE,OAAO,CAAA;KAAE,CAAA;CAAE,GAAG,MAAM,GAAG,IAAI,CAgBxG"}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stripe Checkout, the path from a chosen tier to a hosted payment page
|
|
3
|
+
* (GH #1057).
|
|
4
|
+
*
|
|
5
|
+
* This is the first money-handling code in the repo. Three rules shape it.
|
|
6
|
+
*
|
|
7
|
+
* **Price ids come from the environment, never from the request.** A caller
|
|
8
|
+
* sends a tier name. This module turns that into a Price id. If the browser
|
|
9
|
+
* could name a price, anyone could buy the $39 tier at a price of their own
|
|
10
|
+
* making. Test mode and live mode also carry different ids, so hard-coding one
|
|
11
|
+
* would make the deploy that flips modes a code change.
|
|
12
|
+
*
|
|
13
|
+
* **Return URLs are built here, never taken from the request.** A
|
|
14
|
+
* caller-supplied `success_url` is an open redirect wearing a payment page as
|
|
15
|
+
* a disguise.
|
|
16
|
+
*
|
|
17
|
+
* **The session carries the subject, not the tier.** `client_reference_id`
|
|
18
|
+
* holds the Brains subject so the webhook in GH #1059 knows whose tier to
|
|
19
|
+
* write. The tier itself is read back from the Price id on the completed
|
|
20
|
+
* subscription, so a tampered session cannot grant a tier nobody paid for.
|
|
21
|
+
*/
|
|
22
|
+
import Stripe from "stripe";
|
|
23
|
+
/** The three tiers a customer can buy. The others are assigned by seedUsers. */
|
|
24
|
+
export const PAID_TIERS = ["solo", "pro", "unlimited"];
|
|
25
|
+
export function isPaidTier(value) {
|
|
26
|
+
return typeof value === "string" && PAID_TIERS.includes(value);
|
|
27
|
+
}
|
|
28
|
+
/** Every paid tier widens to a BillingTier, so tierStore can store it unchanged. */
|
|
29
|
+
export function paidTierToBillingTier(tier) {
|
|
30
|
+
return tier;
|
|
31
|
+
}
|
|
32
|
+
/** Environment variable that holds the Price id for each paid tier. */
|
|
33
|
+
const PRICE_ENV_VARS = {
|
|
34
|
+
solo: "STRIPE_PRICE_SOLO",
|
|
35
|
+
pro: "STRIPE_PRICE_PRO",
|
|
36
|
+
unlimited: "STRIPE_PRICE_UNLIMITED",
|
|
37
|
+
};
|
|
38
|
+
/**
|
|
39
|
+
* Read the Stripe configuration out of an environment.
|
|
40
|
+
*
|
|
41
|
+
* Returns the names of the variables that are missing rather than throwing.
|
|
42
|
+
* The server must keep serving wiki traffic when billing is not configured,
|
|
43
|
+
* which is the normal state of a local run and of any self-hosted instance.
|
|
44
|
+
*/
|
|
45
|
+
export function resolveStripeConfig(env = process.env) {
|
|
46
|
+
const missing = [];
|
|
47
|
+
const secretKey = (env.STRIPE_SECRET_KEY ?? "").trim();
|
|
48
|
+
if (!secretKey)
|
|
49
|
+
missing.push("STRIPE_SECRET_KEY");
|
|
50
|
+
const prices = {};
|
|
51
|
+
for (const tier of PAID_TIERS) {
|
|
52
|
+
const name = PRICE_ENV_VARS[tier];
|
|
53
|
+
const value = (env[name] ?? "").trim();
|
|
54
|
+
if (!value) {
|
|
55
|
+
missing.push(name);
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
prices[tier] = value;
|
|
59
|
+
}
|
|
60
|
+
if (missing.length > 0)
|
|
61
|
+
return { ok: false, missing };
|
|
62
|
+
return { ok: true, config: { secretKey, prices } };
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Build the URLs Stripe sends the customer back to.
|
|
66
|
+
*
|
|
67
|
+
* `webUrl` is the server's own `WEB_URL`, so neither value can be influenced
|
|
68
|
+
* by the caller. Stripe replaces `{CHECKOUT_SESSION_ID}` itself.
|
|
69
|
+
*/
|
|
70
|
+
export function checkoutReturnUrls(webUrl, tier) {
|
|
71
|
+
const base = webUrl.replace(/\/$/, "");
|
|
72
|
+
return {
|
|
73
|
+
successUrl: `${base}/dashboard?checkout=success&session_id={CHECKOUT_SESSION_ID}`,
|
|
74
|
+
cancelUrl: `${base}/checkout?plan=${tier}&checkout=cancelled`,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Create a Stripe Checkout Session and return the URL to send the browser to.
|
|
79
|
+
*
|
|
80
|
+
* Throws when Stripe rejects the call or returns a session with no URL. The
|
|
81
|
+
* caller turns that into a 502, because a failure here is Stripe's side and
|
|
82
|
+
* not the customer's mistake.
|
|
83
|
+
*/
|
|
84
|
+
export async function createCheckoutSession(req) {
|
|
85
|
+
const client = req.stripe ?? new Stripe(req.config.secretKey);
|
|
86
|
+
const { successUrl, cancelUrl } = checkoutReturnUrls(req.webUrl, req.tier);
|
|
87
|
+
const session = await client.checkout.sessions.create({
|
|
88
|
+
mode: "subscription",
|
|
89
|
+
line_items: [{ price: req.config.prices[req.tier], quantity: 1 }],
|
|
90
|
+
success_url: successUrl,
|
|
91
|
+
cancel_url: cancelUrl,
|
|
92
|
+
client_reference_id: req.subject,
|
|
93
|
+
...(req.email ? { customer_email: req.email } : {}),
|
|
94
|
+
// Read back by the webhook. client_reference_id is not copied onto the
|
|
95
|
+
// subscription, and subscription.updated events arrive with no session
|
|
96
|
+
// attached, so the subject has to live on the subscription too (GH #1059).
|
|
97
|
+
subscription_data: { metadata: { brains_subject: req.subject, brains_tier: req.tier } },
|
|
98
|
+
metadata: { brains_subject: req.subject, brains_tier: req.tier },
|
|
99
|
+
});
|
|
100
|
+
if (!session.url) {
|
|
101
|
+
throw new Error(`Stripe returned session ${session.id} with no URL`);
|
|
102
|
+
}
|
|
103
|
+
return { id: session.id, url: session.url };
|
|
104
|
+
}
|
|
105
|
+
// ── Webhook (GH #1059) ──────────────────────────────────────────────────────
|
|
106
|
+
/**
|
|
107
|
+
* Resolve the tier a Stripe subscription entitles its owner to.
|
|
108
|
+
*
|
|
109
|
+
* Status drives this before price does. A subscription whose price says "pro"
|
|
110
|
+
* but whose status says `canceled` entitles nobody to anything.
|
|
111
|
+
*
|
|
112
|
+
* `past_due` deliberately keeps the paid tier. Stripe retries a failed payment
|
|
113
|
+
* for days before giving up, and cutting a paying customer off at the first
|
|
114
|
+
* failed charge is a worse outcome than a few days of unpaid access. Stripe
|
|
115
|
+
* moves the subscription to `canceled` or `unpaid` when it stops retrying, and
|
|
116
|
+
* that is where access ends.
|
|
117
|
+
*/
|
|
118
|
+
export function tierForSubscription(subscription, prices) {
|
|
119
|
+
const PAYING_STATUSES = ["active", "trialing", "past_due"];
|
|
120
|
+
if (!PAYING_STATUSES.includes(subscription.status))
|
|
121
|
+
return "free";
|
|
122
|
+
const priceId = subscription.items.data[0]?.price?.id;
|
|
123
|
+
if (!priceId)
|
|
124
|
+
return "free";
|
|
125
|
+
for (const tier of PAID_TIERS) {
|
|
126
|
+
if (prices[tier] === priceId)
|
|
127
|
+
return tier;
|
|
128
|
+
}
|
|
129
|
+
// A price we do not recognise is not a licence to guess. This happens when a
|
|
130
|
+
// Price is created in the dashboard and the environment variable is not
|
|
131
|
+
// updated, so it is logged by the caller rather than silently granting a tier.
|
|
132
|
+
return "free";
|
|
133
|
+
}
|
|
134
|
+
/** The Brains subject a subscription belongs to, or null when it carries none. */
|
|
135
|
+
export function subjectForSubscription(subscription) {
|
|
136
|
+
const subject = subscription.metadata?.brains_subject;
|
|
137
|
+
return typeof subject === "string" && subject.trim() !== "" ? subject.trim() : null;
|
|
138
|
+
}
|
|
139
|
+
/** Event types that change a tier. Everything else is acknowledged and ignored. */
|
|
140
|
+
export const HANDLED_WEBHOOK_EVENTS = [
|
|
141
|
+
"checkout.session.completed",
|
|
142
|
+
"customer.subscription.updated",
|
|
143
|
+
"customer.subscription.deleted",
|
|
144
|
+
];
|
|
145
|
+
export function isHandledWebhookEvent(type) {
|
|
146
|
+
return HANDLED_WEBHOOK_EVENTS.includes(type);
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* The subscription id an event points at, or null when there is none to read.
|
|
150
|
+
*
|
|
151
|
+
* A `checkout.session.completed` for a subscription carries the subscription as
|
|
152
|
+
* an id or as an expanded object. The two subscription events carry the object
|
|
153
|
+
* itself.
|
|
154
|
+
*/
|
|
155
|
+
export function subscriptionIdForEvent(event) {
|
|
156
|
+
const object = event.data.object;
|
|
157
|
+
if (!object)
|
|
158
|
+
return null;
|
|
159
|
+
if (event.type === "checkout.session.completed") {
|
|
160
|
+
const subscription = object.subscription;
|
|
161
|
+
if (typeof subscription === "string")
|
|
162
|
+
return subscription;
|
|
163
|
+
if (subscription && typeof subscription === "object") {
|
|
164
|
+
const id = subscription.id;
|
|
165
|
+
return typeof id === "string" ? id : null;
|
|
166
|
+
}
|
|
167
|
+
return null;
|
|
168
|
+
}
|
|
169
|
+
const id = object.id;
|
|
170
|
+
return typeof id === "string" ? id : null;
|
|
171
|
+
}
|
|
172
|
+
//# sourceMappingURL=stripe.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"stripe.js","sourceRoot":"","sources":["../../src/billing/stripe.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stripe webhook handling (GH #1059).
|
|
3
|
+
*
|
|
4
|
+
* This is where money becomes a tier. Stripe delivers an event, and
|
|
5
|
+
* `tierStore.set()` is called for the subject that paid.
|
|
6
|
+
*
|
|
7
|
+
* ## Why this reads the subscription back instead of trusting the event
|
|
8
|
+
*
|
|
9
|
+
* Stripe retries a webhook until it gets a 2xx, and it does not promise
|
|
10
|
+
* delivery order. The usual fix is a table of processed event ids plus a
|
|
11
|
+
* version comparison, so a replayed or late event cannot undo a newer one.
|
|
12
|
+
*
|
|
13
|
+
* This takes a different route. On any handled event it reads the
|
|
14
|
+
* **current** subscription from Stripe and writes the tier that matches it.
|
|
15
|
+
* The consequences:
|
|
16
|
+
*
|
|
17
|
+
* - A duplicate delivery writes the same value twice. Idempotent by
|
|
18
|
+
* construction, with no event-id bookkeeping.
|
|
19
|
+
* - A late `subscription.deleted` that arrives after a newer
|
|
20
|
+
* `subscription.updated` reads a live subscription and writes the paid tier.
|
|
21
|
+
* It cannot strand a paying customer on `free`, which is the failure the
|
|
22
|
+
* ordering guard exists to prevent.
|
|
23
|
+
* - No table, so no migration. That matters here specifically: production runs
|
|
24
|
+
* migrations only inside the manual `Deploy Production` dispatch while
|
|
25
|
+
* Railway deploys code on every push to `main`, so a table and its writer in
|
|
26
|
+
* one change would work on staging and fail on production.
|
|
27
|
+
*
|
|
28
|
+
* The cost is one extra Stripe API call per event. At the volume a $9 to $39
|
|
29
|
+
* product generates that is not a consideration.
|
|
30
|
+
*/
|
|
31
|
+
import type Stripe from "stripe";
|
|
32
|
+
import type { TierStore } from "./tierStore.js";
|
|
33
|
+
import { type StripeConfig } from "./stripe.js";
|
|
34
|
+
export type WebhookOutcome =
|
|
35
|
+
/** Event type we do not act on. Acknowledged so Stripe stops retrying. */
|
|
36
|
+
{
|
|
37
|
+
status: "ignored";
|
|
38
|
+
reason: string;
|
|
39
|
+
}
|
|
40
|
+
/** A tier was written. */
|
|
41
|
+
| {
|
|
42
|
+
status: "applied";
|
|
43
|
+
subject: string;
|
|
44
|
+
tier: string;
|
|
45
|
+
subscriptionId: string;
|
|
46
|
+
}
|
|
47
|
+
/** Something is wrong with the event itself. Retrying will not fix it. */
|
|
48
|
+
| {
|
|
49
|
+
status: "rejected";
|
|
50
|
+
reason: string;
|
|
51
|
+
}
|
|
52
|
+
/** A transient failure. The caller returns 5xx so Stripe retries. */
|
|
53
|
+
| {
|
|
54
|
+
status: "failed";
|
|
55
|
+
reason: string;
|
|
56
|
+
};
|
|
57
|
+
export interface HandleWebhookDeps {
|
|
58
|
+
event: Stripe.Event;
|
|
59
|
+
config: StripeConfig;
|
|
60
|
+
tierStore: TierStore;
|
|
61
|
+
/** Reads the current subscription. Injected so tests need no network. */
|
|
62
|
+
retrieveSubscription: (id: string) => Promise<Stripe.Subscription>;
|
|
63
|
+
}
|
|
64
|
+
export declare function handleStripeWebhookEvent(deps: HandleWebhookDeps): Promise<WebhookOutcome>;
|
|
65
|
+
//# sourceMappingURL=webhook.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"webhook.d.ts","sourceRoot":"","sources":["../../src/billing/webhook.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6BG;AAEH,OAAO,KAAK,MAAM,MAAM,QAAQ,CAAC;AACjC,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,gBAAgB,CAAC;AAChD,OAAO,EAKL,KAAK,YAAY,EAClB,MAAM,aAAa,CAAC;AAErB,MAAM,MAAM,cAAc;AACxB,0EAA0E;AACxE;IAAE,MAAM,EAAE,SAAS,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE;AACvC,0BAA0B;GACxB;IAAE,MAAM,EAAE,SAAS,CAAC;IAAC,OAAO,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAC;IAAC,cAAc,EAAE,MAAM,CAAA;CAAE;AAC9E,0EAA0E;GACxE;IAAE,MAAM,EAAE,UAAU,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE;AACxC,qEAAqE;GACnE;IAAE,MAAM,EAAE,QAAQ,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,CAAC;AAEzC,MAAM,WAAW,iBAAiB;IAChC,KAAK,EAAE,MAAM,CAAC,KAAK,CAAC;IACpB,MAAM,EAAE,YAAY,CAAC;IACrB,SAAS,EAAE,SAAS,CAAC;IACrB,yEAAyE;IACzE,oBAAoB,EAAE,CAAC,EAAE,EAAE,MAAM,KAAK,OAAO,CAAC,MAAM,CAAC,YAAY,CAAC,CAAC;CACpE;AAED,wBAAsB,wBAAwB,CAAC,IAAI,EAAE,iBAAiB,GAAG,OAAO,CAAC,cAAc,CAAC,CAyC/F"}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stripe webhook handling (GH #1059).
|
|
3
|
+
*
|
|
4
|
+
* This is where money becomes a tier. Stripe delivers an event, and
|
|
5
|
+
* `tierStore.set()` is called for the subject that paid.
|
|
6
|
+
*
|
|
7
|
+
* ## Why this reads the subscription back instead of trusting the event
|
|
8
|
+
*
|
|
9
|
+
* Stripe retries a webhook until it gets a 2xx, and it does not promise
|
|
10
|
+
* delivery order. The usual fix is a table of processed event ids plus a
|
|
11
|
+
* version comparison, so a replayed or late event cannot undo a newer one.
|
|
12
|
+
*
|
|
13
|
+
* This takes a different route. On any handled event it reads the
|
|
14
|
+
* **current** subscription from Stripe and writes the tier that matches it.
|
|
15
|
+
* The consequences:
|
|
16
|
+
*
|
|
17
|
+
* - A duplicate delivery writes the same value twice. Idempotent by
|
|
18
|
+
* construction, with no event-id bookkeeping.
|
|
19
|
+
* - A late `subscription.deleted` that arrives after a newer
|
|
20
|
+
* `subscription.updated` reads a live subscription and writes the paid tier.
|
|
21
|
+
* It cannot strand a paying customer on `free`, which is the failure the
|
|
22
|
+
* ordering guard exists to prevent.
|
|
23
|
+
* - No table, so no migration. That matters here specifically: production runs
|
|
24
|
+
* migrations only inside the manual `Deploy Production` dispatch while
|
|
25
|
+
* Railway deploys code on every push to `main`, so a table and its writer in
|
|
26
|
+
* one change would work on staging and fail on production.
|
|
27
|
+
*
|
|
28
|
+
* The cost is one extra Stripe API call per event. At the volume a $9 to $39
|
|
29
|
+
* product generates that is not a consideration.
|
|
30
|
+
*/
|
|
31
|
+
import { isHandledWebhookEvent, subjectForSubscription, subscriptionIdForEvent, tierForSubscription, } from "./stripe.js";
|
|
32
|
+
export async function handleStripeWebhookEvent(deps) {
|
|
33
|
+
const { event, config, tierStore, retrieveSubscription } = deps;
|
|
34
|
+
if (!isHandledWebhookEvent(event.type)) {
|
|
35
|
+
return { status: "ignored", reason: `unhandled event type ${event.type}` };
|
|
36
|
+
}
|
|
37
|
+
const subscriptionId = subscriptionIdForEvent(event);
|
|
38
|
+
if (!subscriptionId) {
|
|
39
|
+
// A one-off Checkout Session has no subscription. Brains sells nothing
|
|
40
|
+
// one-off today, so this is noise rather than an error.
|
|
41
|
+
return { status: "ignored", reason: `event ${event.id} names no subscription` };
|
|
42
|
+
}
|
|
43
|
+
let subscription;
|
|
44
|
+
try {
|
|
45
|
+
subscription = await retrieveSubscription(subscriptionId);
|
|
46
|
+
}
|
|
47
|
+
catch (err) {
|
|
48
|
+
// Could be Stripe being briefly unavailable. Ask for a retry.
|
|
49
|
+
return {
|
|
50
|
+
status: "failed",
|
|
51
|
+
reason: `could not read subscription ${subscriptionId}: ${err instanceof Error ? err.message : String(err)}`,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
const subject = subjectForSubscription(subscription);
|
|
55
|
+
if (!subject) {
|
|
56
|
+
// Every subscription Brains creates carries brains_subject. One without it
|
|
57
|
+
// was made by hand in the dashboard, and guessing an owner would be worse
|
|
58
|
+
// than refusing. Retrying cannot add the metadata, so this is rejected and
|
|
59
|
+
// not failed.
|
|
60
|
+
return { status: "rejected", reason: `subscription ${subscriptionId} carries no brains_subject` };
|
|
61
|
+
}
|
|
62
|
+
const tier = tierForSubscription(subscription, config.prices);
|
|
63
|
+
const written = await tierStore.set(subject, tier, "stripe-webhook");
|
|
64
|
+
if (!written) {
|
|
65
|
+
return { status: "failed", reason: `tierStore.set failed for ${subject}` };
|
|
66
|
+
}
|
|
67
|
+
return { status: "applied", subject, tier, subscriptionId };
|
|
68
|
+
}
|
|
69
|
+
//# sourceMappingURL=webhook.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"webhook.js","sourceRoot":"","sources":["../../src/billing/webhook.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6BG;AAIH,OAAO,EACL,qBAAqB,EACrB,sBAAsB,EACtB,sBAAsB,EACtB,mBAAmB,GAEpB,MAAM,aAAa,CAAC;AAoBrB,MAAM,CAAC,KAAK,UAAU,wBAAwB,CAAC,IAAuB;IACpE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,SAAS,EAAE,oBAAoB,EAAE,GAAG,IAAI,CAAC;IAEhE,IAAI,CAAC,qBAAqB,CAAC,KAAK,CAAC,IAAI,CAAC,EAAE,CAAC;QACvC,OAAO,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,EAAE,wBAAwB,KAAK,CAAC,IAAI,EAAE,EAAE,CAAC;IAC7E,CAAC;IAED,MAAM,cAAc,GAAG,sBAAsB,CAAC,KAAK,CAAC,CAAC;IACrD,IAAI,CAAC,cAAc,EAAE,CAAC;QACpB,uEAAuE;QACvE,wDAAwD;QACxD,OAAO,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,EAAE,SAAS,KAAK,CAAC,EAAE,wBAAwB,EAAE,CAAC;IAClF,CAAC;IAED,IAAI,YAAiC,CAAC;IACtC,IAAI,CAAC;QACH,YAAY,GAAG,MAAM,oBAAoB,CAAC,cAAc,CAAC,CAAC;IAC5D,CAAC;IAAC,OAAO,GAAG,EAAE,CAAC;QACb,8DAA8D;QAC9D,OAAO;YACL,MAAM,EAAE,QAAQ;YAChB,MAAM,EAAE,+BAA+B,cAAc,KAAK,GAAG,YAAY,KAAK,CAAC,CAAC,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,GAAG,CAAC,EAAE;SAC7G,CAAC;IACJ,CAAC;IAED,MAAM,OAAO,GAAG,sBAAsB,CAAC,YAAY,CAAC,CAAC;IACrD,IAAI,CAAC,OAAO,EAAE,CAAC;QACb,2EAA2E;QAC3E,0EAA0E;QAC1E,2EAA2E;QAC3E,cAAc;QACd,OAAO,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,EAAE,gBAAgB,cAAc,4BAA4B,EAAE,CAAC;IACpG,CAAC;IAED,MAAM,IAAI,GAAG,mBAAmB,CAAC,YAAY,EAAE,MAAM,CAAC,MAAM,CAAC,CAAC;IAC9D,MAAM,OAAO,GAAG,MAAM,SAAS,CAAC,GAAG,CAAC,OAAO,EAAE,IAAI,EAAE,gBAAgB,CAAC,CAAC;IACrE,IAAI,CAAC,OAAO,EAAE,CAAC;QACb,OAAO,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,EAAE,4BAA4B,OAAO,EAAE,EAAE,CAAC;IAC7E,CAAC;IAED,OAAO,EAAE,MAAM,EAAE,SAAS,EAAE,OAAO,EAAE,IAAI,EAAE,cAAc,EAAE,CAAC;AAC9D,CAAC"}
|
package/dist/httpServer.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"httpServer.d.ts","sourceRoot":"","sources":["../src/httpServer.ts"],"names":[],"mappings":"AAAA,OAAO,IAAI,MAAM,MAAM,CAAC;AAQxB,OAAO,EAAsD,KAAK,aAAa,EAAE,MAAM,qBAAqB,CAAC;
|
|
1
|
+
{"version":3,"file":"httpServer.d.ts","sourceRoot":"","sources":["../src/httpServer.ts"],"names":[],"mappings":"AAAA,OAAO,IAAI,MAAM,MAAM,CAAC;AAQxB,OAAO,EAAsD,KAAK,aAAa,EAAE,MAAM,qBAAqB,CAAC;AA2H7G,wBAAgB,8BAA8B,IAAI,IAAI,CAErD;AAi9BD;;;;;;;;;;;;GAYG;AACH,wBAAgB,sBAAsB,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,CAI9D;AAgsBD,wBAAsB,eAAe,CAAC,KAAK,EAAE,aAAa,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,MAAM,CAAC,CA20F9F"}
|
package/dist/httpServer.js
CHANGED
|
@@ -44,6 +44,10 @@ import { getDigestPreference, upsertDigestPreference, updateDigestEnabled, } fro
|
|
|
44
44
|
import { DEFAULT_ACCESS_CONTEXT, getUserAccessLevel, isHiveModeEnabled, } from "./governance/accessControl.js";
|
|
45
45
|
import { seedUsers, purgeSeedUsers, isSeedEnabled, isGodModeSubject } from "./admin/seedUsers.js";
|
|
46
46
|
import { deleteAccount } from "./auth/deleteAccount.js";
|
|
47
|
+
import { createCheckoutSession, isPaidTier, resolveStripeConfig } from "./billing/stripe.js";
|
|
48
|
+
import Stripe from "stripe";
|
|
49
|
+
import { handleStripeWebhookEvent } from "./billing/webhook.js";
|
|
50
|
+
import { getTierStore } from "./billing/tierStore.js";
|
|
47
51
|
const MAX_BODY_BYTES = 1024 * 1024;
|
|
48
52
|
// GH #898: single declared cap shared by both import-zip routes — the raw
|
|
49
53
|
// multipart upload and the JSON route's zipData/zipPath body — so the two
|
|
@@ -198,6 +202,8 @@ function classifyRouteForRateLimit(method, pathname) {
|
|
|
198
202
|
}
|
|
199
203
|
if (method === "POST" && (pathname === "/api/v1/pages/batch" || pathname === "/api/v1/import" || pathname === "/api/v1/links/extract" || pathname === "/api/v1/links/suggest" || pathname === "/api/v1/pages/delete-batch"))
|
|
200
204
|
return "batch";
|
|
205
|
+
if (method === "POST" && pathname === "/api/v1/billing/webhook")
|
|
206
|
+
return null;
|
|
201
207
|
if (method === "POST" && pathname === "/chat")
|
|
202
208
|
return "chat";
|
|
203
209
|
if (method === "POST" || method === "PUT" || method === "PATCH" || method === "DELETE")
|
|
@@ -349,6 +355,7 @@ function isPublicRoute(method, pathname) {
|
|
|
349
355
|
(method === "GET" && pathname === "/chat-ui") ||
|
|
350
356
|
(method === "GET" && pathname === "/connect") ||
|
|
351
357
|
(method === "POST" && pathname === "/api/inbound-email") ||
|
|
358
|
+
(method === "POST" && pathname === "/api/v1/billing/webhook") ||
|
|
352
359
|
(method === "GET" && pathname.startsWith("/assets/css/")) ||
|
|
353
360
|
(method === "GET" && pathname.startsWith("/assets/img/")) ||
|
|
354
361
|
(method === "GET" && pathname === "/openapi.json") ||
|
|
@@ -2744,6 +2751,128 @@ export async function startHttpServer(drive, port) {
|
|
|
2744
2751
|
json(req, res, 200, { ok: true, tier, pageCount: currentCount, pageLimit: limit });
|
|
2745
2752
|
return;
|
|
2746
2753
|
}
|
|
2754
|
+
if (method === "POST" && pathname === "/api/v1/billing/webhook") {
|
|
2755
|
+
const stripeConfig = resolveStripeConfig();
|
|
2756
|
+
const webhookSecret = (process.env.STRIPE_WEBHOOK_SECRET ?? "").trim();
|
|
2757
|
+
if (!stripeConfig.ok || !webhookSecret) {
|
|
2758
|
+
const missing = [...(stripeConfig.ok ? [] : stripeConfig.missing), ...(webhookSecret ? [] : ["STRIPE_WEBHOOK_SECRET"])];
|
|
2759
|
+
console.error("[billing] webhook unavailable, missing:", missing.join(", "));
|
|
2760
|
+
// 503 and not 500. Stripe retries a 5xx, which is right: the operator
|
|
2761
|
+
// may be mid-deploy, and the event should not be lost.
|
|
2762
|
+
json(req, res, 503, { ok: false, error: "Billing is not configured on this server" });
|
|
2763
|
+
return;
|
|
2764
|
+
}
|
|
2765
|
+
// Signature verification runs over the exact bytes Stripe sent. Parsing
|
|
2766
|
+
// and re-serialising changes them and the signature stops matching.
|
|
2767
|
+
const rawBody = await readBodyBuffer(req, 1_000_000, "billing/webhook").catch(() => null);
|
|
2768
|
+
const signature = req.headers["stripe-signature"];
|
|
2769
|
+
if (!rawBody || typeof signature !== "string") {
|
|
2770
|
+
json(req, res, 400, { ok: false, error: "Missing body or stripe-signature header" });
|
|
2771
|
+
return;
|
|
2772
|
+
}
|
|
2773
|
+
const stripeClient = new Stripe(stripeConfig.config.secretKey);
|
|
2774
|
+
let event;
|
|
2775
|
+
try {
|
|
2776
|
+
event = stripeClient.webhooks.constructEvent(rawBody, signature, webhookSecret);
|
|
2777
|
+
}
|
|
2778
|
+
catch (err) {
|
|
2779
|
+
// An unverified payload is not from Stripe, or the secret is wrong.
|
|
2780
|
+
// Either way a retry changes nothing, so this is 400 and not 5xx.
|
|
2781
|
+
console.error("[billing] webhook signature rejected:", err instanceof Error ? err.message : err);
|
|
2782
|
+
json(req, res, 400, { ok: false, error: "Signature verification failed" });
|
|
2783
|
+
return;
|
|
2784
|
+
}
|
|
2785
|
+
const outcome = await handleStripeWebhookEvent({
|
|
2786
|
+
event,
|
|
2787
|
+
config: stripeConfig.config,
|
|
2788
|
+
tierStore: getTierStore(),
|
|
2789
|
+
retrieveSubscription: (id) => stripeClient.subscriptions.retrieve(id),
|
|
2790
|
+
}).catch((err) => ({
|
|
2791
|
+
status: "failed",
|
|
2792
|
+
reason: err instanceof Error ? err.message : String(err),
|
|
2793
|
+
}));
|
|
2794
|
+
switch (outcome.status) {
|
|
2795
|
+
case "applied":
|
|
2796
|
+
console.log(`[billing] ${event.type} set tier ${outcome.tier} for ${outcome.subject}`);
|
|
2797
|
+
json(req, res, 200, { ok: true, applied: true });
|
|
2798
|
+
return;
|
|
2799
|
+
case "ignored":
|
|
2800
|
+
json(req, res, 200, { ok: true, applied: false });
|
|
2801
|
+
return;
|
|
2802
|
+
case "rejected":
|
|
2803
|
+
// 200 on purpose. The event is wrong in a way no retry can fix, and
|
|
2804
|
+
// a 4xx here only makes Stripe redeliver it for days.
|
|
2805
|
+
console.error(`[billing] webhook rejected: ${outcome.reason}`);
|
|
2806
|
+
json(req, res, 200, { ok: true, applied: false });
|
|
2807
|
+
return;
|
|
2808
|
+
case "failed":
|
|
2809
|
+
console.error(`[billing] webhook failed, asking Stripe to retry: ${outcome.reason}`);
|
|
2810
|
+
json(req, res, 500, { ok: false, error: "Could not apply the event" });
|
|
2811
|
+
return;
|
|
2812
|
+
}
|
|
2813
|
+
}
|
|
2814
|
+
if (method === "POST" && pathname === "/api/v1/billing/checkout") {
|
|
2815
|
+
// Checkout must be tied to a person, because the webhook writes a tier
|
|
2816
|
+
// against this subject. An api-key or connector-key caller is a shared
|
|
2817
|
+
// machine credential with no user behind it, and a PAT belongs to an
|
|
2818
|
+
// agent rather than to a browser that can follow a redirect.
|
|
2819
|
+
if (authorization.mode !== "supabase" && authorization.mode !== "oauth") {
|
|
2820
|
+
json(req, res, 403, {
|
|
2821
|
+
ok: false,
|
|
2822
|
+
error: "Checkout requires a user session (Supabase or OAuth auth)",
|
|
2823
|
+
});
|
|
2824
|
+
return;
|
|
2825
|
+
}
|
|
2826
|
+
const stripeConfig = resolveStripeConfig();
|
|
2827
|
+
if (!stripeConfig.ok) {
|
|
2828
|
+
// Name the variables. A self-hosted or local instance reaching this
|
|
2829
|
+
// line has simply not set up billing, and a bare 500 sends the
|
|
2830
|
+
// operator reading source code.
|
|
2831
|
+
console.error("[billing] checkout unavailable, missing:", stripeConfig.missing.join(", "));
|
|
2832
|
+
json(req, res, 503, {
|
|
2833
|
+
ok: false,
|
|
2834
|
+
error: "Billing is not configured on this server",
|
|
2835
|
+
missing: stripeConfig.missing,
|
|
2836
|
+
});
|
|
2837
|
+
return;
|
|
2838
|
+
}
|
|
2839
|
+
const web = webUrl();
|
|
2840
|
+
if (!web) {
|
|
2841
|
+
console.error("[billing] checkout unavailable, missing: WEB_URL");
|
|
2842
|
+
json(req, res, 503, {
|
|
2843
|
+
ok: false,
|
|
2844
|
+
error: "Billing is not configured on this server",
|
|
2845
|
+
missing: ["WEB_URL"],
|
|
2846
|
+
});
|
|
2847
|
+
return;
|
|
2848
|
+
}
|
|
2849
|
+
const payload = (await readJsonBody(req, 4096, "billing/checkout").catch(() => null));
|
|
2850
|
+
const tier = payload?.tier;
|
|
2851
|
+
if (!isPaidTier(tier)) {
|
|
2852
|
+
json(req, res, 400, {
|
|
2853
|
+
ok: false,
|
|
2854
|
+
error: "Unknown tier",
|
|
2855
|
+
detail: "tier must be one of: solo, pro, unlimited",
|
|
2856
|
+
});
|
|
2857
|
+
return;
|
|
2858
|
+
}
|
|
2859
|
+
try {
|
|
2860
|
+
const session = await createCheckoutSession({
|
|
2861
|
+
tier,
|
|
2862
|
+
subject: authorization.subject,
|
|
2863
|
+
config: stripeConfig.config,
|
|
2864
|
+
webUrl: web,
|
|
2865
|
+
});
|
|
2866
|
+
json(req, res, 200, { ok: true, url: session.url, sessionId: session.id });
|
|
2867
|
+
}
|
|
2868
|
+
catch (err) {
|
|
2869
|
+
// Never echo the Stripe error to the browser. It can carry account
|
|
2870
|
+
// detail, and the customer cannot act on it either way.
|
|
2871
|
+
console.error("[billing] checkout session failed:", err instanceof Error ? err.message : err);
|
|
2872
|
+
json(req, res, 502, { ok: false, error: "Could not start checkout. Try again shortly." });
|
|
2873
|
+
}
|
|
2874
|
+
return;
|
|
2875
|
+
}
|
|
2747
2876
|
if (method === "DELETE" && pathname === "/api/v1/account") {
|
|
2748
2877
|
if (authorization.mode !== "supabase" && authorization.mode !== "oauth") {
|
|
2749
2878
|
json(req, res, 403, { ok: false, error: "Account deletion requires a user session (Supabase or OAuth auth)" });
|