From 467c86ae2b22ec51e5dd88775f01a819ffd55be0 Mon Sep 17 00:00:00 2001 From: steebchen Date: Sun, 22 Feb 2026 12:51:46 +0000 Subject: [PATCH] fix: lazily initialize Stripe and fix PostHog placeholder to prevent startup crashes PostHog and Stripe constructors now validate their arguments more strictly, causing the API and worker to crash at module load time when env vars are missing. Supervisord kept restarting them silently, masking the failures. - PostHog: use "phc_placeholder" instead of "key" to pass non-empty string validation - Stripe: lazy-initialize via getStripe() so the client is only created on first use Co-Authored-By: Claude Opus 4.6 --- apps/api/src/posthog.ts | 14 +++++--- apps/api/src/routes/dev-plans.ts | 51 ++++++++++++++++------------ apps/api/src/routes/payments.ts | 33 +++++++++++------- apps/api/src/routes/subscriptions.ts | 16 ++++----- apps/api/src/stripe.ts | 17 +++++----- apps/worker/src/worker.ts | 20 ++++++++--- 6 files changed, 94 insertions(+), 57 deletions(-) diff --git a/apps/api/src/posthog.ts b/apps/api/src/posthog.ts index 1514503204..1c70ce75f6 100644 --- a/apps/api/src/posthog.ts +++ b/apps/api/src/posthog.ts @@ -1,6 +1,12 @@ import { PostHog } from "posthog-node"; -export const posthog = new PostHog(process.env.POSTHOG_KEY ?? "key", { - host: process.env.POSTHOG_HOST ?? "none", - disabled: !process.env.POSTHOG_KEY || !process.env.POSTHOG_HOST, -}); +const posthogDisabled = !process.env.POSTHOG_KEY || !process.env.POSTHOG_HOST; + +// PostHog requires a non-empty API key even when disabled, so we use a placeholder +export const posthog = new PostHog( + process.env.POSTHOG_KEY ?? "phc_placeholder", + { + host: process.env.POSTHOG_HOST ?? "https://localhost", + disabled: posthogDisabled, + }, +); diff --git a/apps/api/src/routes/dev-plans.ts b/apps/api/src/routes/dev-plans.ts index 1e8d970fe0..05a2351705 100644 --- a/apps/api/src/routes/dev-plans.ts +++ b/apps/api/src/routes/dev-plans.ts @@ -13,7 +13,7 @@ import { type DevPlanTier, } from "@llmgateway/shared"; -import { stripe } from "./payments.js"; +import { getStripe } from "./payments.js"; import type { ServerTypes } from "@/vars.js"; @@ -243,7 +243,7 @@ devPlans.openapi(subscribe, async (c) => { try { const stripeCustomerId = await ensureStripeCustomer(personalOrg.id); - const session = await stripe.checkout.sessions.create({ + const session = await getStripe().checkout.sessions.create({ customer: stripeCustomerId, mode: "subscription", line_items: [ @@ -356,9 +356,12 @@ devPlans.openapi(cancel, async (c) => { } try { - await stripe.subscriptions.update(personalOrg.devPlanStripeSubscriptionId, { - cancel_at_period_end: true, - }); + await getStripe().subscriptions.update( + personalOrg.devPlanStripeSubscriptionId, + { + cancel_at_period_end: true, + }, + ); await logAuditEvent({ organizationId: personalOrg.id, @@ -444,7 +447,7 @@ devPlans.openapi(resume, async (c) => { } try { - const subscription = await stripe.subscriptions.retrieve( + const subscription = await getStripe().subscriptions.retrieve( personalOrg.devPlanStripeSubscriptionId, ); @@ -454,9 +457,12 @@ devPlans.openapi(resume, async (c) => { }); } - await stripe.subscriptions.update(personalOrg.devPlanStripeSubscriptionId, { - cancel_at_period_end: false, - }); + await getStripe().subscriptions.update( + personalOrg.devPlanStripeSubscriptionId, + { + cancel_at_period_end: false, + }, + ); await logAuditEvent({ organizationId: personalOrg.id, @@ -566,24 +572,27 @@ devPlans.openapi(changeTier, async (c) => { } try { - const subscription = await stripe.subscriptions.retrieve( + const subscription = await getStripe().subscriptions.retrieve( personalOrg.devPlanStripeSubscriptionId, ); // Update subscription with new tier - await stripe.subscriptions.update(personalOrg.devPlanStripeSubscriptionId, { - items: [ - { - id: subscription.items.data[0].id, - price: newPriceId, + await getStripe().subscriptions.update( + personalOrg.devPlanStripeSubscriptionId, + { + items: [ + { + id: subscription.items.data[0].id, + price: newPriceId, + }, + ], + proration_behavior: "create_prorations", + metadata: { + ...subscription.metadata, + devPlan: newTier, }, - ], - proration_behavior: "create_prorations", - metadata: { - ...subscription.metadata, - devPlan: newTier, }, - }); + ); // Update local database immediately const newCreditsLimit = getDevPlanCreditsLimit(newTier); diff --git a/apps/api/src/routes/payments.ts b/apps/api/src/routes/payments.ts index eba27a0f65..b36c8c0a1e 100644 --- a/apps/api/src/routes/payments.ts +++ b/apps/api/src/routes/payments.ts @@ -11,12 +11,21 @@ import { calculateFees } from "@llmgateway/shared"; import type { ServerTypes } from "@/vars.js"; -export const stripe = new Stripe( - process.env.STRIPE_SECRET_KEY ?? "sk_test_123", - { - apiVersion: "2025-04-30.basil", - }, -); +let _stripe: Stripe | null = null; + +export function getStripe(): Stripe { + if (!_stripe) { + if (!process.env.STRIPE_SECRET_KEY) { + throw new Error( + "STRIPE_SECRET_KEY environment variable is required for Stripe operations", + ); + } + _stripe = new Stripe(process.env.STRIPE_SECRET_KEY, { + apiVersion: "2025-04-30.basil", + }); + } + return _stripe; +} export const payments = new OpenAPIHono(); @@ -90,7 +99,7 @@ payments.openapi(createPaymentIntent, async (c) => { amount, }); - const paymentIntent = await stripe.paymentIntents.create({ + const paymentIntent = await getStripe().paymentIntents.create({ amount: Math.round(feeBreakdown.totalAmount * 100), currency: "usd", description: `Credit purchase for ${amount} USD (including fees)`, @@ -161,7 +170,7 @@ payments.openapi(createSetupIntent, async (c) => { const organizationId = userOrganization.organization.id; - const setupIntent = await stripe.setupIntents.create({ + const setupIntent = await getStripe().setupIntents.create({ usage: "off_session", metadata: { organizationId, @@ -236,7 +245,7 @@ payments.openapi(getPaymentMethods, async (c) => { const enhancedPaymentMethods = await Promise.all( paymentMethods.map(async (pm) => { - const stripePaymentMethod = await stripe.paymentMethods.retrieve( + const stripePaymentMethod = await getStripe().paymentMethods.retrieve( pm.stripePaymentMethodId, ); @@ -424,13 +433,13 @@ payments.openapi(deletePaymentMethod, async (c) => { // Get card details before deleting for audit log let cardLast4: string | undefined; try { - const stripePaymentMethod = await stripe.paymentMethods.retrieve( + const stripePaymentMethod = await getStripe().paymentMethods.retrieve( paymentMethod.stripePaymentMethodId, ); cardLast4 = stripePaymentMethod.card?.last4; } catch {} - await stripe.paymentMethods.detach(paymentMethod.stripePaymentMethodId); + await getStripe().paymentMethods.detach(paymentMethod.stripePaymentMethodId); await db.delete(tables.paymentMethod).where(eq(tables.paymentMethod.id, id)); @@ -544,7 +553,7 @@ payments.openapi(topUpWithSavedMethod, async (c) => { amount, }); - const paymentIntent = await stripe.paymentIntents.create({ + const paymentIntent = await getStripe().paymentIntents.create({ amount: Math.round(feeBreakdown.totalAmount * 100), currency: "usd", description: `Credit purchase for ${amount} USD (including fees)`, diff --git a/apps/api/src/routes/subscriptions.ts b/apps/api/src/routes/subscriptions.ts index 7227696792..8ddb57e826 100644 --- a/apps/api/src/routes/subscriptions.ts +++ b/apps/api/src/routes/subscriptions.ts @@ -8,7 +8,7 @@ import { logAuditEvent } from "@llmgateway/audit"; import { db } from "@llmgateway/db"; import { logger } from "@llmgateway/logger"; -import { stripe } from "./payments.js"; +import { getStripe } from "./payments.js"; import type { ServerTypes } from "@/vars.js"; @@ -117,7 +117,7 @@ subscriptions.openapi(createProSubscription, async (c) => { } // Create Stripe Checkout session - const session = await stripe.checkout.sessions.create({ + const session = await getStripe().checkout.sessions.create({ customer: stripeCustomerId, mode: "subscription", line_items: [ @@ -234,7 +234,7 @@ subscriptions.openapi(cancelProSubscription, async (c) => { try { // Cancel the subscription at the end of the current period - await stripe.subscriptions.update(organization.stripeSubscriptionId, { + await getStripe().subscriptions.update(organization.stripeSubscriptionId, { cancel_at_period_end: true, }); @@ -324,7 +324,7 @@ subscriptions.openapi(resumeProSubscription, async (c) => { try { // Check if subscription is actually cancelled - const subscription = await stripe.subscriptions.retrieve( + const subscription = await getStripe().subscriptions.retrieve( organization.stripeSubscriptionId, ); @@ -335,7 +335,7 @@ subscriptions.openapi(resumeProSubscription, async (c) => { } // Resume the subscription by setting cancel_at_period_end to false - await stripe.subscriptions.update(organization.stripeSubscriptionId, { + await getStripe().subscriptions.update(organization.stripeSubscriptionId, { cancel_at_period_end: false, }); @@ -425,7 +425,7 @@ subscriptions.openapi(upgradeToYearlyPlan, async (c) => { try { // Get current subscription to check if it's already yearly - const subscription = await stripe.subscriptions.retrieve( + const subscription = await getStripe().subscriptions.retrieve( organization.stripeSubscriptionId, ); @@ -445,7 +445,7 @@ subscriptions.openapi(upgradeToYearlyPlan, async (c) => { } // Update subscription to yearly plan - await stripe.subscriptions.update(organization.stripeSubscriptionId, { + await getStripe().subscriptions.update(organization.stripeSubscriptionId, { items: [ { id: subscription.items.data[0].id, @@ -533,7 +533,7 @@ subscriptions.openapi(getSubscriptionStatus, async (c) => { let billingCycle: "monthly" | "yearly" | null = null; if (organization.stripeSubscriptionId) { try { - const subscription = await stripe.subscriptions.retrieve( + const subscription = await getStripe().subscriptions.retrieve( organization.stripeSubscriptionId, ); const currentPriceId = subscription.items.data[0]?.price.id; diff --git a/apps/api/src/stripe.ts b/apps/api/src/stripe.ts index ffb1d1b62f..7026185e54 100644 --- a/apps/api/src/stripe.ts +++ b/apps/api/src/stripe.ts @@ -7,7 +7,7 @@ import { logger } from "@llmgateway/logger"; import { getDevPlanCreditsLimit, type DevPlanTier } from "@llmgateway/shared"; import { posthog } from "./posthog.js"; -import { stripe } from "./routes/payments.js"; +import { getStripe } from "./routes/payments.js"; import { notifyCreditsPurchased } from "./utils/discord.js"; import { generatePaymentFailureEmailHtml, @@ -34,7 +34,7 @@ export async function ensureStripeCustomer( let stripeCustomerId = organization.stripeCustomerId; if (!stripeCustomerId) { - const customer = await stripe.customers.create({ + const customer = await getStripe().customers.create({ email: organization.billingEmail, metadata: { organizationId, @@ -50,7 +50,7 @@ export async function ensureStripeCustomer( .where(eq(tables.organization.id, organizationId)); } else { // Update existing customer email if billingEmail has changed - await stripe.customers.update(stripeCustomerId, { + await getStripe().customers.update(stripeCustomerId, { email: organization.billingEmail, }); } @@ -95,7 +95,7 @@ async function resolveOrganizationFromStripeEvent(eventData: { // 3. Try to get from subscription metadata if subscription ID is available if (!organizationId && eventData.subscription) { try { - const stripeSubscription = await stripe.subscriptions.retrieve( + const stripeSubscription = await getStripe().subscriptions.retrieve( eventData.subscription, ); if (stripeSubscription.metadata?.organizationId) { @@ -188,7 +188,7 @@ stripeRoutes.openapi(webhookHandler, async (c) => { const body = await c.req.raw.text(); const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET ?? ""; - const event = stripe.webhooks.constructEvent(body, sig, webhookSecret); + const event = getStripe().webhooks.constructEvent(body, sig, webhookSecret); logger.info(JSON.stringify({ kind: "stripe-event", payload: event })); @@ -904,7 +904,7 @@ async function handleChargeRefunded(event: Stripe.ChargeRefundedEvent) { } // Fetch refunds for this charge since they're not expanded in webhook events - const refundsResponse = await stripe.refunds.list({ + const refundsResponse = await getStripe().refunds.list({ charge: charge.id, limit: 1, }); @@ -1030,11 +1030,12 @@ async function handleSetupIntentSucceeded( const paymentMethodId = typeof payment_method === "string" ? payment_method : payment_method.id; - await stripe.paymentMethods.attach(paymentMethodId, { + await getStripe().paymentMethods.attach(paymentMethodId, { customer: stripeCustomerId, }); - const paymentMethod = await stripe.paymentMethods.retrieve(paymentMethodId); + const paymentMethod = + await getStripe().paymentMethods.retrieve(paymentMethodId); const existingPaymentMethods = await db.query.paymentMethod.findMany({ where: { diff --git a/apps/worker/src/worker.ts b/apps/worker/src/worker.ts index cc96c4483f..b9d169c711 100644 --- a/apps/worker/src/worker.ts +++ b/apps/worker/src/worker.ts @@ -42,9 +42,21 @@ import { syncProvidersAndModels } from "./services/sync-models.js"; const CURRENT_MINUTE_HISTORY_INTERVAL_SECONDS = Number(process.env.CURRENT_MINUTE_HISTORY_INTERVAL_SECONDS) || 5; -const stripe = new Stripe(process.env.STRIPE_SECRET_KEY ?? "sk_test_123", { - apiVersion: "2025-04-30.basil", -}); +let _stripe: Stripe | null = null; + +function getStripe(): Stripe { + if (!_stripe) { + if (!process.env.STRIPE_SECRET_KEY) { + throw new Error( + "STRIPE_SECRET_KEY environment variable is required for Stripe operations", + ); + } + _stripe = new Stripe(process.env.STRIPE_SECRET_KEY, { + apiVersion: "2025-04-30.basil", + }); + } + return _stripe; +} const AUTO_TOPUP_LOCK_KEY = "auto_topup_check"; const CREDIT_PROCESSING_LOCK_KEY = "credit_processing"; @@ -273,7 +285,7 @@ async function processAutoTopUp(): Promise { ); try { - const paymentIntent = await stripe.paymentIntents.create({ + const paymentIntent = await getStripe().paymentIntents.create({ amount: Math.round(feeBreakdown.totalAmount * 100), currency: "usd", description: `Auto top-up for ${topUpAmount} USD (total: ${feeBreakdown.totalAmount} including fees)`,