Release v1.0.0
Some checks failed
CI / Migrations, tests, build, and audit (push) Failing after 10m7s
CI / Production gate and container images (push) Successful in 20m35s

This commit is contained in:
2026-07-31 14:21:43 +08:00
commit 36f466ba11
187 changed files with 98907 additions and 0 deletions

305
scripts/stripe-smoke.mjs Executable file
View File

@@ -0,0 +1,305 @@
#!/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
})
}