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.
@@ -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"}
@@ -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;AAuH7G,wBAAgB,8BAA8B,IAAI,IAAI,CAErD;AA+8BD;;;;;;;;;;;;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,CAusF9F"}
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"}
@@ -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)" });