306 lines
13 KiB
JavaScript
Executable File
306 lines
13 KiB
JavaScript
Executable File
#!/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 <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
|
|
})
|
|
}
|