/** * createProxy — core factory for the stripe-mpp-proxy library. * * Configure which payment methods to accept (stripe / tempo / base) and * the proxy handles the underlying protocols automatically. */ import crypto from 'node:crypto' import type { IncomingMessage, ServerResponse } from 'node:http' import Stripe from 'stripe' import { Mppx, stripe as stripeMppx, tempo as tempoMppx } from 'mppx/server' import type { StripeConfig, TempoConfig, BaseConfig, RoutePriceConfig } from './config.js' import { matchRoute, resolveStripePrice, resolveTempoPrice, resolveBasePrice, } from './config.js' import { forwardToOrigin, nodeToWebRequest, webToNodeResponse } from './forward.js' import { handleCreateSpt } from './spt.js' import { buildBaseChallengeHeader, buildBaseReceiptHeader, resolveBaseRouteConfig, verifyBasePayment, } from './x402.js' import { findOrCreateStripeCustomer } from './stripe-customer.js' import { validateRequest } from './validate.js' // --------------------------------------------------------------------------- // Lightweight TTL set — replaces the node-cache peer dependency for the // deposit address cache. A Map is sufficient for single-process // servers; swap for Redis in multi-instance deployments. // --------------------------------------------------------------------------- function createTtlSet(ttlMs: number) { const store = new Map() return { has(key: string): boolean { const exp = store.get(key) if (exp === undefined) return false if (Date.now() > exp) { store.delete(key); return false } return true }, add(key: string): void { store.set(key, Date.now() + ttlMs) }, } } // --------------------------------------------------------------------------- // Public types // --------------------------------------------------------------------------- /** * A named upstream service mounted at `/{id}/*`. * Each service has its own origin and route table while sharing the proxy's * payment method configuration. */ export type ServiceConfig = { /** Upstream base URL for this service */ origin: string /** Per-route pricing for this service */ routes: RoutePriceConfig[] } export type ProxyOptions = { /** Accept Stripe card / Link payments */ stripe?: StripeConfig /** Accept Tempo crypto (PATH stablecoin) payments */ tempo?: TempoConfig /** Accept Base USDC payments */ base?: BaseConfig /** * Named services to proxy, each mounted at `/{serviceId}/*`. * Requests to `/{serviceId}/...` are matched against the service's route * table and forwarded to its origin with the prefix stripped. * * @example * services: { * openai: { origin: 'https://api.openai.com', routes: [...] }, * anthropic: { origin: 'https://api.anthropic.com', routes: [...] }, * } */ services: Record /** * Human-readable name for this proxy (surfaced in /llms.txt and /__stripe-proxy/discover). */ title?: string /** * Short description of what this proxy provides (surfaced in /llms.txt). */ description?: string /** * When true, requests to routes not listed in a matched service's `routes` * return 404 instead of being forwarded for free. * * Defaults to false. Enable in production to prevent newly-added upstream * endpoints from being exposed without a payment gate. */ blockUnlistedRoutes?: boolean /** * Static key/value pairs attached to every Stripe PaymentIntent created by * the proxy. Client-supplied `_meta.payment_intent_metadata` is merged on * top, so these values can be overridden per-request. * * Useful for tagging all PIs with your tenant ID, environment, or service * name regardless of what the caller sends. */ paymentMetadata?: Record /** * When false, the proxy skips Stripe customer reconciliation entirely. * Defaults to true (reconcile on every successful crypto payment). */ createCustomers?: boolean /** * Path for the test SPT-creation endpoint. * Defaults to '/api/stripe-proxy/create-spt'. Set null to disable. */ sptEndpoint?: string | null } /** `createStripeProxy` accepts the same options but requires `stripe` */ export type StripeProxyOptions = ProxyOptions & { stripe: StripeConfig } export type StripeProxy = { /** * Web-standard fetch handler. * Works with Bun, Hono, Next.js, Cloudflare Workers, and Deno. */ fetch: (req: Request) => Promise /** Node.js `http.createServer` listener */ listener: (req: IncomingMessage, res: ServerResponse) => Promise /** Express-compatible middleware factory */ express: () => (req: ExpressRequest, res: ExpressResponse, next: ExpressNextFn) => Promise /** Next.js App Router handler map — spread into route.ts exports */ nextjs: () => Record Promise> } type ExpressRequest = IncomingMessage & { url: string; method: string } type ExpressResponse = ServerResponse type ExpressNextFn = (err?: unknown) => void // PATH_USD canonical token address on Tempo const PATH_USD = '0x20c0000000000000000000000000000000000000' // --------------------------------------------------------------------------- // Factory // --------------------------------------------------------------------------- export function createProxy(options: ProxyOptions): StripeProxy { const { stripe: stripeOpts, tempo: tempoOpts, base: baseOpts } = options const sptEndpoint = options.sptEndpoint === undefined ? '/api/stripe-proxy/create-spt' : options.sptEndpoint if (!stripeOpts && !tempoOpts && !baseOpts) { throw new Error('createProxy requires at least one payment method (stripe, tempo, or base)') } if (!options.services || Object.keys(options.services).length === 0) { throw new Error('createProxy requires at least one entry in services') } const networkId = stripeOpts?.networkId ?? process.env.STRIPE_NETWORK_ID ?? 'internal' const paymentMethodTypes = stripeOpts?.paymentMethodTypes ?? ['card', 'link'] // Per-instance secret for MPP challenge binding // https://mpp.dev/protocol/challenges#challenge-binding const mppSecretKey = crypto.randomBytes(32).toString('base64') // Stripe SDK client — used for card payments and crypto deposit addresses let stripeClient: Stripe | null = null if (stripeOpts) { // @ts-expect-error – 2026-03-04.preview not yet in stable typings stripeClient = new Stripe(stripeOpts.secretKey, { apiVersion: '2026-03-04.preview' }) } // Deposit address cache (TTL 5 min). // Maps address (lowercase) → true so retry requests can be validated. // Use Redis in production; the inline TTL set is fine for single-process. const depositCache = createTtlSet(5 * 60 * 1000) // Static mppx instance for Stripe-only configs (no dynamic recipient needed) // eslint-disable-next-line @typescript-eslint/no-explicit-any let staticMppx: any = null if (stripeOpts && !tempoOpts && stripeClient) { staticMppx = Mppx.create({ methods: [stripeMppx.charge({ client: stripeClient, networkId, paymentMethodTypes })], secretKey: mppSecretKey, }) } // ------------------------------------------------------------------------- // Stripe PaymentIntent → crypto deposit address // ------------------------------------------------------------------------- async function getDepositAddress( amountUsd: number, network: 'tempo' | 'base', paymentIntentMetadata?: Record, ): Promise<`0x${string}`> { if (!stripeClient) throw new Error('Stripe must be configured to use crypto deposit addresses') const amountInCents = Math.round(amountUsd * 100) // The crypto payment method and deposit options are in the 2026-03-04.preview // API — cast params to bypass the stable typings (mirrors stripe-samples). // eslint-disable-next-line @typescript-eslint/no-explicit-any const pi = await stripeClient.paymentIntents.create({ amount: amountInCents, currency: 'usd', payment_method_types: ['crypto'], payment_method_data: { type: 'crypto' }, payment_method_options: { crypto: { mode: 'deposit', deposit_options: { networks: [network] }, }, }, confirm: true, ...(paymentIntentMetadata ? { metadata: paymentIntentMetadata } : {}), // eslint-disable-next-line @typescript-eslint/no-explicit-any } as any) as unknown as { id: string; next_action: Record | null } if (!pi.next_action || !('crypto_display_details' in pi.next_action)) { throw new Error(`PaymentIntent ${String(pi.id)} did not return crypto deposit details`) } const details = pi.next_action['crypto_display_details'] as { deposit_addresses: Record } const address = details.deposit_addresses[network]?.address if (!address) throw new Error(`No ${network} deposit address in PaymentIntent ${String(pi.id)}`) console.log(`[proxy] PaymentIntent ${String(pi.id)} → ${network} deposit ${address}`) depositCache.add(address.toLowerCase()) return address.toLowerCase() as `0x${string}` } // Extract a cached Tempo recipient from an existing MPP credential so we // don't create a new PaymentIntent on every retry request. function extractCachedTempoRecipient(req: Request): `0x${string}` | null { const authHeader = req.headers.get('authorization') ?? '' if (!authHeader.toLowerCase().startsWith('payment ')) return null try { const raw = authHeader.slice('payment '.length) const decoded = JSON.parse(Buffer.from(raw, 'base64url').toString()) const recipient = decoded?.challenge?.request?.recipient as string | undefined if (recipient && depositCache.has(recipient.toLowerCase())) { return recipient.toLowerCase() as `0x${string}` } } catch { /* malformed credential — let mppx reject it */ } return null } // Safely coerce an unknown value to a flat Record for use as // Stripe metadata (Stripe requires both keys and values to be strings). function extractRecordMetadata(raw: unknown): Record | undefined { if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return undefined const result: Record = {} for (const [k, v] of Object.entries(raw as Record)) { if (typeof v === 'string') result[k] = v } return Object.keys(result).length > 0 ? result : undefined } // Extract _meta fields from the request body (best effort — never throws). // Used to feed email/phone into Stripe customer reconciliation. async function extractMeta(req: Request): Promise | null> { if (['GET', 'HEAD', 'DELETE', 'OPTIONS'].includes(req.method.toUpperCase())) return null try { const body = await req.clone().json() as Record return (body['_meta'] as Record) ?? null } catch { return null } } // Reconcile a Stripe customer for a successful crypto payment (fire-and-log; // never blocks or fails the proxied response). async function reconcileCustomer( cryptoAddress: string | undefined, meta: Record | null, paymentMethod: 'tempo' | 'base', ): Promise { if (!stripeClient || options.createCustomers === false) return try { const customer = await findOrCreateStripeCustomer(stripeClient, { cryptoAddress, email: meta?.['email'] as string | undefined, phone: meta?.['phone'] as string | undefined, paymentMethod, customerMetadata: extractRecordMetadata(meta?.['customer_metadata']), }) if (customer) { console.log(`[proxy] Stripe customer ${customer.id} reconciled (${paymentMethod}, address=${cryptoAddress ?? 'n/a'})`) } } catch (err) { console.error('[proxy] Customer reconciliation failed (non-fatal):', err) } } // ------------------------------------------------------------------------- // Per-request mppx instance builder (needed when Tempo is configured) // ------------------------------------------------------------------------- // eslint-disable-next-line @typescript-eslint/no-explicit-any function buildMppx(tempoRecipient?: `0x${string}`): any { // eslint-disable-next-line @typescript-eslint/no-explicit-any const methods: any[] = [] if (stripeOpts && stripeClient) { methods.push(stripeMppx.charge({ client: stripeClient, networkId, paymentMethodTypes })) } if (tempoOpts && tempoRecipient) { methods.push( tempoMppx.charge({ currency: tempoOpts.currency ?? PATH_USD, recipient: tempoRecipient, testnet: tempoOpts.testnet ?? false, }), ) } return Mppx.create({ methods, secretKey: mppSecretKey }) } // ------------------------------------------------------------------------- // _meta field validation // ------------------------------------------------------------------------- async function validateMetaFields(req: Request, route: RoutePriceConfig): Promise { const { requireBillingAddress, requireEmail, requirePhone } = route if (!requireBillingAddress && !requireEmail && !requirePhone) return null // Methods without a body cannot carry _meta — skip validation if (['GET', 'HEAD', 'DELETE', 'OPTIONS'].includes(req.method.toUpperCase())) return null let meta: Record | undefined try { const body = await req.clone().json() as Record meta = body['_meta'] as Record | undefined } catch { return Response.json( { error: 'Request body must be valid JSON when required _meta fields are configured' }, { status: 400 }, ) } const missing: string[] = [] if (requireBillingAddress) { const addr = meta?.['billing_address'] as Record | undefined const requiredAddrKeys = ['line1', 'city', 'state', 'postal_code', 'country'] if (!addr || requiredAddrKeys.some((k) => !addr[k])) { missing.push('_meta.billing_address (must include line1, city, state, postal_code, country)') } } if (requireEmail && !meta?.['email']) missing.push('_meta.email') if (requirePhone && !meta?.['phone']) missing.push('_meta.phone') if (missing.length > 0) { return Response.json( { error: `Missing required fields: ${missing.join('; ')}` }, { status: 400 }, ) } return null } // ------------------------------------------------------------------------- // Multi-origin service routing helpers // ------------------------------------------------------------------------- function matchServicePrefix(pathname: string): { id: string service: ServiceConfig strippedPath: string } | null { if (!options.services) return null for (const [id, service] of Object.entries(options.services)) { const prefix = `/${id}` if (pathname === prefix || pathname.startsWith(`${prefix}/`)) { return { id, service, strippedPath: pathname.slice(prefix.length) || '/' } } } return null } // ------------------------------------------------------------------------- // Discovery / llms.txt helpers // ------------------------------------------------------------------------- function serializeRoutes(activeRoutes: RoutePriceConfig[]) { return activeRoutes.map((r) => { const sp = stripeOpts ? resolveStripePrice(r) : null const tp = tempoOpts ? resolveTempoPrice(r) : null const bp = baseOpts ? resolveBasePrice(r) : null const displayPrice = sp?.amount ?? tp?.amount ?? bp?.amount ?? null const displayCurrency = sp ? (sp.currency ?? 'usd') : (bp ? 'usd' : null) return { method: r.method, path: r.path, description: r.description ?? null, free: r.free ?? false, price: displayPrice, currency: displayCurrency, requires: { email: r.requireEmail ?? false, phone: r.requirePhone ?? false, billingAddress: r.requireBillingAddress ?? false, }, } }) } function buildDiscoveryPayload() { const services: Record = {} for (const [id, svc] of Object.entries(options.services)) { services[id] = { origin: svc.origin, routes: serializeRoutes(svc.routes) } } return { title: options.title ?? 'Payment-gated API', description: options.description ?? null, services, } } function buildLlmsTxt(): string { const title = options.title ?? 'Payment-gated API' const description = options.description ? `\n${options.description}\n` : '' const serviceBlocks = Object.entries(options.services).map(([id, svc]) => { const routeBlocks = svc.routes.map((r) => { const lines: string[] = [`### ${r.method} ${r.path}`] if (r.description) lines.push(r.description) if (r.free) { lines.push('Price: free') } else { const sp = stripeOpts ? resolveStripePrice(r) : null const tp = tempoOpts ? resolveTempoPrice(r) : null const bp = baseOpts ? resolveBasePrice(r) : null const displayAmount = sp?.amount ?? tp?.amount ?? bp?.amount const displayCurrency = sp ? (sp.currency ?? 'usd').toUpperCase() : 'USD' if (displayAmount) lines.push(`Price: $${displayAmount} ${displayCurrency}`) } const required: string[] = [] if (r.requireEmail) required.push('_meta.email') if (r.requirePhone) required.push('_meta.phone') if (r.requireBillingAddress) required.push('_meta.billing_address') if (required.length) lines.push(`Requires: ${required.join(', ')}`) return lines.join('\n') }).join('\n\n') return [`## Service: ${id}`, `Origin: ${svc.origin}`, '', routeBlocks].join('\n') }).join('\n\n') return [ `# ${title}`, description, serviceBlocks, '', '## Discovery Endpoints', '- GET /__stripe-proxy/discover — JSON pricing and billing requirements', '- GET /__stripe-proxy/health — Liveness check', ].join('\n') } // ------------------------------------------------------------------------- // Core fetch handler // ------------------------------------------------------------------------- async function fetchHandler(req: Request): Promise { const url = new URL(req.url) // SPT creation for browser / test clients (Stripe test mode only) if (sptEndpoint !== null && url.pathname === sptEndpoint && req.method === 'POST') { if (!stripeOpts) return Response.json({ error: 'Stripe not configured' }, { status: 400 }) return handleCreateSpt(req, stripeOpts.secretKey, networkId) } // Liveness probe if (url.pathname === '/__stripe-proxy/health') { const services: Record = {} for (const [id, svc] of Object.entries(options.services)) { services[id] = { origin: svc.origin, routes: svc.routes.map((r) => ({ method: r.method, path: r.path })) } } return Response.json({ status: 'ok', services }) } // Full discovery — pricing, billing requirements, and description if (url.pathname === '/__stripe-proxy/discover') { return Response.json(buildDiscoveryPayload()) } // LLM / human-readable pricing doc if (url.pathname === '/llms.txt') { return new Response(buildLlmsTxt(), { headers: { 'Content-Type': 'text/plain; charset=utf-8' }, }) } // ----------------------------------------------------------------------- // Service prefix routing — every request must match a named service. // ----------------------------------------------------------------------- const serviceMatch = matchServicePrefix(url.pathname) if (!serviceMatch) { return Response.json({ error: 'Not found' }, { status: 404 }) } const activeOrigin = serviceMatch.service.origin const activeRoutes = serviceMatch.service.routes const activePath = serviceMatch.strippedPath const pathOverride = activePath const priced = matchRoute(req.method, activePath, activeRoutes) if (!priced) { if (options.blockUnlistedRoutes) { return Response.json({ error: 'Not found' }, { status: 404 }) } return forwardToOrigin(req, activeOrigin, { pathOverride }) } // Explicitly-free route — forward without a payment gate if (priced.free) { return forwardToOrigin(req, activeOrigin, { pathOverride }) } if (priced.schema) { const schemaError = await validateRequest(req, priced.schema) if (schemaError) return schemaError } const metaError = await validateMetaFields(req, priced) if (metaError) return metaError // ----------------------------------------------------------------------- // Base (USDC) payment — checked first since it uses a separate header // ----------------------------------------------------------------------- const basePaymentHeader = req.headers.get('x-payment') const basePrice = baseOpts ? resolveBasePrice(priced) : null if (basePaymentHeader && basePrice && baseOpts) { const routeCfg = resolveBaseRouteConfig(basePrice, baseOpts) const resourceUrl = new URL(activePath + url.search, activeOrigin).toString() const verification = await verifyBasePayment( basePaymentHeader, { ...routeCfg, resource: resourceUrl, description: priced.description }, routeCfg.facilitatorUrl, ) if (!verification.success) { return new Response(JSON.stringify({ error: `Payment rejected: ${verification.error}` }), { status: 402, headers: { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' }, }) } const meta = await extractMeta(req) void reconcileCustomer(verification.payer, meta, 'base') const originResponse = await forwardToOrigin(req, activeOrigin, { pathOverride }) return new Response(originResponse.body, { status: originResponse.status, headers: new Headers([ ...Array.from(originResponse.headers.entries()), ['x-payment-response', buildBaseReceiptHeader(verification.payer ?? '', routeCfg.network)], ]), }) } // ----------------------------------------------------------------------- // MPP payment (Stripe card + Tempo crypto) // ----------------------------------------------------------------------- const stripePrice = stripeOpts ? resolveStripePrice(priced) : null const tempoPrice = tempoOpts ? resolveTempoPrice(priced) : null const hasMpp = stripePrice !== null || tempoPrice !== null if (hasMpp) { // Extract meta early so payment_intent_metadata is available before // getDepositAddress creates the Stripe PaymentIntent. const meta = await extractMeta(req) // Proxy-level metadata is the base; client metadata layers on top. const paymentIntentMetadata = { ...options.paymentMetadata, ...extractRecordMetadata(meta?.['payment_intent_metadata']), } // eslint-disable-next-line @typescript-eslint/no-explicit-any let mppxInstance: any let tempoRecipient: `0x${string}` | undefined if (tempoOpts && tempoPrice) { const cached = extractCachedTempoRecipient(req) if (cached) { tempoRecipient = cached } else { const piMeta = Object.keys(paymentIntentMetadata).length > 0 ? paymentIntentMetadata : undefined tempoRecipient = await getDepositAddress(parseFloat(tempoPrice.amount), 'tempo', piMeta) } mppxInstance = buildMppx(tempoRecipient) } else { mppxInstance = staticMppx! } // eslint-disable-next-line @typescript-eslint/no-explicit-any const composeArgs: any[] = [] if (stripePrice) { composeArgs.push(mppxInstance.stripe.charge({ amount: stripePrice.amount, currency: stripePrice.currency })) } if (tempoPrice && tempoRecipient) { composeArgs.push(mppxInstance.tempo.charge({ amount: tempoPrice.amount, recipient: tempoRecipient })) } const result = await Mppx.compose(...composeArgs)(req) if (result.status !== 402) { if (tempoOpts && tempoPrice) { // mppx result may expose the payer address; fall back to undefined // eslint-disable-next-line @typescript-eslint/no-explicit-any const payerAddress = (result as any)?.payer as string | undefined void reconcileCustomer(payerAddress, meta, 'tempo') } return result.withReceipt(await forwardToOrigin(req, activeOrigin, { pathOverride })) } // 402 — if Base is also configured for this route, add its challenge // header so the agent can choose either payment method. if (basePrice && baseOpts) { const routeCfg = resolveBaseRouteConfig(basePrice, baseOpts) const resourceUrl = new URL(activePath + url.search, activeOrigin).toString() const mppHeaders = new Headers(result.challenge.headers) mppHeaders.set( 'x-payment-required', buildBaseChallengeHeader({ ...routeCfg, resource: resourceUrl, description: priced.description }), ) return new Response(result.challenge.body, { status: 402, headers: mppHeaders }) } return result.challenge } // ----------------------------------------------------------------------- // Base-only route (no MPP price configured for this route) // ----------------------------------------------------------------------- if (basePrice && baseOpts) { const routeCfg = resolveBaseRouteConfig(basePrice, baseOpts) const resourceUrl = new URL(activePath + url.search, activeOrigin).toString() return new Response(null, { status: 402, headers: { 'x-payment-required': buildBaseChallengeHeader({ ...routeCfg, resource: resourceUrl, description: priced.description, }), 'cache-control': 'no-store', }, }) } // No payment configured for this route — forward to origin return forwardToOrigin(req, activeOrigin, { pathOverride }) } // ------------------------------------------------------------------------- // Framework adapters — all delegate to fetchHandler // ------------------------------------------------------------------------- async function listenerHandler(nodeReq: IncomingMessage, nodeRes: ServerResponse): Promise { try { const webReq = await nodeToWebRequest(nodeReq) const webRes = await fetchHandler(webReq) await webToNodeResponse(webRes, nodeRes) } catch (err) { console.error('[stripe-mpp-proxy] Unhandled error:', err) nodeRes.writeHead(500, { 'Content-Type': 'application/json' }) nodeRes.end(JSON.stringify({ error: 'Internal proxy error' })) } } function expressMiddleware() { return async (req: ExpressRequest, res: ExpressResponse, next: ExpressNextFn): Promise => { try { const webRes = await fetchHandler(await nodeToWebRequest(req as IncomingMessage)) await webToNodeResponse(webRes, res as ServerResponse) } catch (err) { next(err) } } } function nextjsHandlers(): Record Promise> { const h = (req: Request) => fetchHandler(req) return { GET: h, POST: h, PUT: h, PATCH: h, DELETE: h, HEAD: h, OPTIONS: h } } return { fetch: fetchHandler, listener: listenerHandler, express: expressMiddleware, nextjs: nextjsHandlers } } /** Convenience alias — identical to `createProxy` but requires `stripe`. */ export function createStripeProxy(options: StripeProxyOptions): StripeProxy { return createProxy(options) }