#!/usr/bin/env node import { resolve } from 'node:path' import { fileURLToPath } from 'node:url' import Stripe from 'stripe' const scriptPath = fileURLToPath(import.meta.url) export const requiredStripeWebhookEvents = [ 'checkout.session.completed', 'customer.subscription.created', 'customer.subscription.updated', 'customer.subscription.deleted', ] export class StripeSmokeError extends Error { constructor(message, options = {}) { super(message, options) this.name = 'StripeSmokeError' } } function required(value, name) { if (typeof value !== 'string' || value.trim() === '') throw new StripeSmokeError(`${name} is required`) return value.trim() } function integerValue(value, name, minimum, maximum) { const parsed = Number(value) if (!Number.isInteger(parsed) || parsed < minimum || parsed > maximum) { throw new StripeSmokeError(`${name} must be an integer from ${minimum} to ${maximum}`) } return parsed } function booleanValue(value) { return ['1', 'true', 'yes', 'on'].includes(String(value ?? '').trim().toLowerCase()) } export function resolveStripeAppOrigin(value, options = {}) { let url try { url = new URL(required(value, 'FRAMEFLOW_STRIPE_APP_ORIGIN')) } catch (error) { if (error instanceof StripeSmokeError) throw error throw new StripeSmokeError('FRAMEFLOW_STRIPE_APP_ORIGIN must be a valid URL') } if (url.username || url.password || url.search || url.hash || url.pathname !== '/') { throw new StripeSmokeError('Stripe app origin must be a bare origin without credentials, path, query, or fragment') } if (!['http:', 'https:'].includes(url.protocol)) throw new StripeSmokeError('Stripe app origin must use HTTP or HTTPS') const localHostnames = new Set(['localhost', '127.0.0.1', '::1', '[::1]']) if (url.protocol === 'http:' && (!options.allowHttp || !localHostnames.has(url.hostname))) { throw new StripeSmokeError('HTTPS is required; HTTP can only be enabled explicitly for localhost') } return url.origin } export function usage() { return `Usage: npm run smoke:stripe -- [options] Performs read-only Stripe account, recurring Price, Product, Customer Portal, and webhook-endpoint configuration checks. It never creates a customer, Checkout Session, Portal Session, charge, subscription, or webhook event. Required environment: STRIPE_SECRET_KEY STRIPE_WEBHOOK_SECRET STRIPE_PRO_PRICE_ID STRIPE_STUDIO_PRICE_ID FRAMEFLOW_STRIPE_APP_ORIGIN (falls back to API_PUBLIC_ORIGIN or WEB_ORIGIN) Optional environment: STRIPE_PORTAL_CONFIGURATION_ID FRAMEFLOW_STRIPE_SMOKE_TIMEOUT_SECONDS=30 Options: --app-origin --timeout-seconds <10-120> --allow-http Loopback development only --require-live Reject test keys and test-mode Stripe resources --json --help The signing secret is never sent or printed. Stripe does not expose webhook signing secrets through its API, so a passing report proves endpoint/event configuration but not that STRIPE_WEBHOOK_SECRET matches that endpoint. ` } export function parseStripeSmokeOptions(argv = process.argv.slice(2), environment = process.env) { const values = {} const flags = new Set() const valueOptions = new Set(['--app-origin', '--timeout-seconds']) const flagOptions = new Set(['--allow-http', '--require-live', '--json', '--help']) for (let index = 0; index < argv.length; index += 1) { const argument = argv[index] if (valueOptions.has(argument)) { const value = argv[index + 1] if (!value || value.startsWith('--')) throw new StripeSmokeError(`${argument} requires a value`) values[argument] = value index += 1 } else if (flagOptions.has(argument)) { flags.add(argument) } else { throw new StripeSmokeError(`Unknown option: ${argument}`) } } if (flags.has('--help')) return { help: true } const secretKey = required(environment.STRIPE_SECRET_KEY, 'STRIPE_SECRET_KEY') const keyMatch = /^(?:sk|rk)_(test|live)_/.exec(secretKey) if (!keyMatch) throw new StripeSmokeError('STRIPE_SECRET_KEY must be a Stripe secret or restricted key') const mode = keyMatch[1] const requireLive = flags.has('--require-live') || booleanValue(environment.FRAMEFLOW_STRIPE_REQUIRE_LIVE) if (requireLive && mode !== 'live') throw new StripeSmokeError('--require-live requires a live-mode Stripe key') const webhookSecret = required(environment.STRIPE_WEBHOOK_SECRET, 'STRIPE_WEBHOOK_SECRET') if (!webhookSecret.startsWith('whsec_')) throw new StripeSmokeError('STRIPE_WEBHOOK_SECRET must use the Stripe whsec_ format') const proPriceId = required(environment.STRIPE_PRO_PRICE_ID, 'STRIPE_PRO_PRICE_ID') const studioPriceId = required(environment.STRIPE_STUDIO_PRICE_ID, 'STRIPE_STUDIO_PRICE_ID') if (!proPriceId.startsWith('price_') || !studioPriceId.startsWith('price_')) { throw new StripeSmokeError('Stripe plan IDs must use the price_ format') } if (proPriceId === studioPriceId) throw new StripeSmokeError('PRO and STUDIO must use different Stripe Price IDs') const portalConfigurationId = environment.STRIPE_PORTAL_CONFIGURATION_ID?.trim() || null if (portalConfigurationId && !portalConfigurationId.startsWith('bpc_')) { throw new StripeSmokeError('STRIPE_PORTAL_CONFIGURATION_ID must use the bpc_ format') } const allowHttp = flags.has('--allow-http') || booleanValue(environment.FRAMEFLOW_STRIPE_ALLOW_HTTP) const originValue = values['--app-origin'] ?? environment.FRAMEFLOW_STRIPE_APP_ORIGIN ?? environment.API_PUBLIC_ORIGIN ?? environment.WEB_ORIGIN?.split(',')[0]?.trim() const timeoutSeconds = integerValue(values['--timeout-seconds'] ?? environment.FRAMEFLOW_STRIPE_SMOKE_TIMEOUT_SECONDS ?? '30', 'timeout-seconds', 10, 120) return { secretKey, webhookSecretConfigured: true, proPriceId, studioPriceId, portalConfigurationId, appOrigin: resolveStripeAppOrigin(originValue, { allowHttp }), mode, requireLive, timeoutMs: timeoutSeconds * 1000, json: flags.has('--json'), } } async function listStripeCollection(fetchPage, parameters = {}) { const rows = [] let startingAfter for (let page = 0; page < 20; page += 1) { const response = await fetchPage({ ...parameters, limit: 100, ...(startingAfter ? { starting_after: startingAfter } : {}) }) if (!response || !Array.isArray(response.data)) throw new StripeSmokeError('Stripe returned an invalid list response') rows.push(...response.data) if (!response.has_more) return rows startingAfter = response.data.at(-1)?.id if (!startingAfter) throw new StripeSmokeError('Stripe pagination returned no continuation ID') } throw new StripeSmokeError('Stripe list exceeded the 2,000-resource verification limit') } function assertMode(resource, mode, label) { if (typeof resource?.livemode !== 'boolean') throw new StripeSmokeError(`${label} did not report livemode`) if (resource.livemode !== (mode === 'live')) throw new StripeSmokeError(`${label} does not match the Stripe key mode`) } async function verifyPrice(client, priceId, plan, mode) { const price = await client.prices.retrieve(priceId, { expand: ['product'] }) if (price?.object !== 'price' || price.id !== priceId) throw new StripeSmokeError(`${plan} Stripe Price could not be retrieved`) assertMode(price, mode, `${plan} Price`) if (price.active !== true) throw new StripeSmokeError(`${plan} Stripe Price is not active`) if (price.type !== 'recurring' || !price.recurring) throw new StripeSmokeError(`${plan} Stripe Price must be recurring`) if (price.recurring.usage_type !== 'licensed') throw new StripeSmokeError(`${plan} Stripe Price must use licensed recurring usage`) const product = price.product if (!product || typeof product === 'string' || product.deleted || product.object !== 'product') { throw new StripeSmokeError(`${plan} Stripe Product was not expanded or has been deleted`) } if (product.active !== true) throw new StripeSmokeError(`${plan} Stripe Product is not active`) assertMode(product, mode, `${plan} Product`) return { plan, priceId: price.id, productId: product.id, productName: product.name, currency: price.currency, unitAmount: price.unit_amount, billingScheme: price.billing_scheme, interval: price.recurring.interval, intervalCount: price.recurring.interval_count, } } async function verifyPortal(client, configurationId, mode) { let configuration if (configurationId) { configuration = await client.billingPortal.configurations.retrieve(configurationId) } else { const configurations = await listStripeCollection( (parameters) => client.billingPortal.configurations.list(parameters), { active: true }, ) configuration = configurations.find((candidate) => candidate.is_default === true) } if (!configuration || configuration.object !== 'billing_portal.configuration') { throw new StripeSmokeError('No active default Stripe Customer Portal configuration was found') } if (configuration.active !== true) throw new StripeSmokeError('Stripe Customer Portal configuration is not active') assertMode(configuration, mode, 'Customer Portal configuration') return { id: configuration.id, isDefault: configuration.is_default === true, active: true, } } async function verifyWebhook(client, appOrigin, mode) { const expectedUrl = `${appOrigin}/api/v1/billing/webhooks/stripe` const endpoints = await listStripeCollection((parameters) => client.webhookEndpoints.list(parameters)) const endpoint = endpoints.find((candidate) => candidate.url === expectedUrl && candidate.status === 'enabled') if (!endpoint) throw new StripeSmokeError(`No enabled Stripe webhook endpoint matches ${expectedUrl}`) assertMode(endpoint, mode, 'Webhook endpoint') const enabledEvents = new Set(endpoint.enabled_events ?? []) const missingEvents = enabledEvents.has('*') ? [] : requiredStripeWebhookEvents.filter((event) => !enabledEvents.has(event)) if (missingEvents.length > 0) { throw new StripeSmokeError(`Stripe webhook endpoint is missing events: ${missingEvents.join(', ')}`) } return { id: endpoint.id, url: endpoint.url, status: endpoint.status, requiredEvents: requiredStripeWebhookEvents, } } export async function runStripeSmoke(options, dependencies = {}) { const client = dependencies.client ?? new Stripe(options.secretKey, { maxNetworkRetries: 1, timeout: options.timeoutMs, }) const account = await client.accounts.retrieve() if (!account || account.object !== 'account' || typeof account.id !== 'string') { throw new StripeSmokeError('Stripe account could not be retrieved') } if (options.requireLive && account.charges_enabled !== true) { throw new StripeSmokeError('Stripe live charges are not enabled for this account') } if (options.requireLive && account.details_submitted !== true) { throw new StripeSmokeError('Stripe account onboarding details are incomplete') } const [pro, studio, portal, webhook] = await Promise.all([ verifyPrice(client, options.proPriceId, 'PRO', options.mode), verifyPrice(client, options.studioPriceId, 'STUDIO', options.mode), verifyPortal(client, options.portalConfigurationId, options.mode), verifyWebhook(client, options.appOrigin, options.mode), ]) return { status: 'passed', mode: options.mode, account: { id: account.id, country: account.country ?? null, defaultCurrency: account.default_currency ?? null, chargesEnabled: account.charges_enabled === true, payoutsEnabled: account.payouts_enabled === true, detailsSubmitted: account.details_submitted === true, }, prices: { PRO: pro, STUDIO: studio }, portal, webhook, webhookSigningSecretConfigured: options.webhookSecretConfigured, webhookSigningSecretMatchedToEndpoint: false, sideEffectsCreated: false, } } function safeError(error) { const message = error instanceof Error ? error.message : String(error) return message.replace(/(?:sk|rk)_(?:test|live)_[a-zA-Z0-9]+|whsec_[a-zA-Z0-9]+/g, '[redacted]').slice(0, 1000) } async function main() { const options = parseStripeSmokeOptions() if (options.help) { process.stdout.write(usage()) return } const report = await runStripeSmoke(options) if (options.json) { process.stdout.write(`${JSON.stringify(report, null, 2)}\n`) return } process.stdout.write(`Stripe read-only smoke passed in ${report.mode} mode.\nAccount: ${report.account.id}\nWebhook: ${report.webhook.url}\nNo Stripe resources were created. Run a signed webhook acceptance event before launch.\n`) } if (process.argv[1] && resolve(process.argv[1]) === scriptPath) { main().catch((error) => { process.stderr.write(`Stripe smoke failed: ${safeError(error)}\n`) process.exitCode = 1 }) }