= {
'WWW-Authenticate': Challenge.serialize(challenge),
'Cache-Control': 'no-store',
}
- let body: string | null = null
- if (error) {
- headers['Content-Type'] = 'application/problem+json'
- body = JSON.stringify(error.toProblemDetails(challenge.id))
- }
+ const body = (() => {
+ if (options.html && input.headers.get('Accept')?.includes('text/html')) {
+ headers['Content-Type'] = 'text/html; charset=utf-8'
+ const html = String.raw
+ return html`
+
+
+
+
+ Payment Required
+
+
+
+ Payment Required
+
+${Json.stringify(challenge, null, 2)
+ .replace(/&/g, '&')
+ .replace(//g, '>')}
+
+
+ ${options.html.content}
+
+ `
+ }
+ if (error) {
+ headers['Content-Type'] = 'application/problem+json'
+ return JSON.stringify(error.toProblemDetails(challenge.id))
+ }
+ return null
+ })()
return new Response(body, { status: error?.status ?? 402, headers })
},
diff --git a/src/server/internal/html/config.ts b/src/server/internal/html/config.ts
new file mode 100644
index 00000000..4c29208e
--- /dev/null
+++ b/src/server/internal/html/config.ts
@@ -0,0 +1,8 @@
+export type Options = {
+ config: Record
+ content: string
+}
+
+export const dataId = '__MPPX_DATA__'
+
+export const serviceWorkerParam = '__mppx_worker'
diff --git a/src/server/internal/html/serviceWorker.client.ts b/src/server/internal/html/serviceWorker.client.ts
new file mode 100644
index 00000000..f94de3c1
--- /dev/null
+++ b/src/server/internal/html/serviceWorker.client.ts
@@ -0,0 +1,28 @@
+import { serviceWorkerParam } from './config.js'
+
+export async function submitCredential(credential: string): Promise {
+ const url = new URL(location.href)
+ url.searchParams.set(serviceWorkerParam, '')
+
+ const registration = await navigator.serviceWorker.register(url.pathname + url.search)
+
+ const serviceWorker = await new Promise((resolve) => {
+ const mppxWorker = registration.installing ?? registration.waiting ?? registration.active
+ if (mppxWorker?.state === 'activated') return resolve(mppxWorker)
+ const target = mppxWorker ?? registration
+ target.addEventListener('statechange', function handler() {
+ const active = registration.active
+ if (active?.state === 'activated') {
+ target.removeEventListener('statechange', handler)
+ resolve(active)
+ }
+ })
+ })
+
+ await new Promise((resolve) => {
+ const channel = new MessageChannel()
+ channel.port1.onmessage = () => resolve()
+ serviceWorker.postMessage({ credential }, [channel.port2])
+ })
+ location.reload()
+}
diff --git a/src/server/internal/html/serviceWorker.ts b/src/server/internal/html/serviceWorker.ts
new file mode 100644
index 00000000..5810ac50
--- /dev/null
+++ b/src/server/internal/html/serviceWorker.ts
@@ -0,0 +1,27 @@
+const serviceWorker = self as unknown as ServiceWorkerGlobalScope
+
+let credential: string | undefined
+
+serviceWorker.addEventListener('activate', (event) => {
+ event.waitUntil(serviceWorker.clients.claim())
+})
+
+serviceWorker.addEventListener('message', (event) => {
+ if (!event.source) return
+ const value = event.data?.credential
+ if (typeof value !== 'string' || !value.startsWith('Payment ')) return
+ credential = value
+ event.ports[0]?.postMessage('ack')
+})
+
+serviceWorker.addEventListener('fetch', (event) => {
+ if (!credential || event.request.mode !== 'navigate') return
+ if (new URL(event.request.url).origin !== serviceWorker.location.origin) return
+
+ const headers = new Headers(event.request.headers)
+ headers.set('Authorization', credential)
+ credential = undefined
+
+ event.respondWith(fetch(event.request, { headers }))
+ serviceWorker.registration.unregister()
+})
diff --git a/src/server/internal/html/tsconfig.worker.client.json b/src/server/internal/html/tsconfig.worker.client.json
new file mode 100644
index 00000000..aa58380f
--- /dev/null
+++ b/src/server/internal/html/tsconfig.worker.client.json
@@ -0,0 +1,8 @@
+{
+ "extends": "../../../../tsconfig.base.json",
+ "compilerOptions": {
+ "lib": ["es2022", "dom"],
+ "types": []
+ },
+ "include": ["serviceWorker.client.ts"]
+}
diff --git a/src/server/internal/html/tsconfig.worker.json b/src/server/internal/html/tsconfig.worker.json
new file mode 100644
index 00000000..4a7d0075
--- /dev/null
+++ b/src/server/internal/html/tsconfig.worker.json
@@ -0,0 +1,8 @@
+{
+ "extends": "../../../../tsconfig.base.json",
+ "compilerOptions": {
+ "lib": ["es2022", "webworker"],
+ "types": []
+ },
+ "include": ["serviceWorker.ts"]
+}
diff --git a/src/stripe/server/Charge.ts b/src/stripe/server/Charge.ts
index 096f05da..12a8dd7c 100644
--- a/src/stripe/server/Charge.ts
+++ b/src/stripe/server/Charge.ts
@@ -5,6 +5,7 @@ import type { LooseOmit, OneOf } from '../../internal/types.js'
import * as Method from '../../Method.js'
import type { StripeClient } from '../internal/types.js'
import * as Methods from '../Methods.js'
+import { html as htmlContent } from './internal/html.gen.js'
/**
* Creates a Stripe charge method intent for usage on the server.
@@ -38,6 +39,7 @@ export function charge(parameters: p
decimals,
description,
externalId,
+ html,
metadata,
networkId,
paymentMethodTypes,
@@ -59,6 +61,8 @@ export function charge(parameters: p
paymentMethodTypes,
} as unknown as Defaults,
+ html: html ? { config: html, content: htmlContent } : undefined,
+
async verify({ credential }) {
const { challenge } = credential
const { request } = challenge
@@ -108,6 +112,8 @@ export declare namespace charge {
type Defaults = LooseOmit, 'recipient'>
type Parameters = {
+ /** Render payment page when Accept header is text/html (e.g. in browsers) */
+ html?: { createTokenUrl: string; publishableKey: string } | undefined
/** Optional metadata to include in SPT creation requests. */
metadata?: Record | undefined
} & Defaults &
diff --git a/src/stripe/server/internal/html/main.ts b/src/stripe/server/internal/html/main.ts
new file mode 100644
index 00000000..13f7c578
--- /dev/null
+++ b/src/stripe/server/internal/html/main.ts
@@ -0,0 +1,106 @@
+import { loadStripe } from '@stripe/stripe-js/pure'
+
+import type * as Challenge from '../../../../Challenge.js'
+import { stripe } from '../../../../client/index.js'
+import * as Html from '../../../../server/internal/html/config.js'
+import { submitCredential } from '../../../../server/internal/html/serviceWorker.client.js'
+import type { charge as chargeClient } from '../../../../stripe/client/Charge.js'
+import type { charge } from '../../../../stripe/server/Charge.js'
+import type * as Methods from '../../../Methods.js'
+
+const data = JSON.parse(document.getElementById(Html.dataId)!.textContent!) as {
+ config: NonNullable
+ challenge: Challenge.FromMethods<[typeof Methods.charge]>
+}
+
+const root = document.getElementById('root')!
+
+const h2 = document.createElement('h2')
+h2.textContent = 'stripe'
+root.appendChild(h2)
+
+;(async () => {
+ if (import.meta.env.MODE === 'test') {
+ const button = document.createElement('button')
+ button.textContent = 'Pay'
+ root.appendChild(button)
+ button.onclick = async () => {
+ try {
+ button.disabled = true
+ const method = stripe({ createToken })[0]
+ const credential = await method.createCredential({
+ challenge: data.challenge,
+ context: { paymentMethod: 'pm_card_visa' },
+ })
+ await submitCredential(credential)
+ } finally {
+ button.disabled = false
+ }
+ }
+ return
+ }
+
+ const stripeJs = await loadStripe(data.config.publishableKey)
+ if (!stripeJs) throw new Error('Failed to loadStripe')
+
+ const darkQuery = window.matchMedia('(prefers-color-scheme: dark)')
+ const getAppearance = () => ({
+ theme: (darkQuery.matches ? 'night' : 'stripe') as 'night' | 'stripe',
+ })
+
+ const elements = stripeJs.elements({
+ amount: Number(data.challenge.request.amount),
+ appearance: getAppearance(),
+ currency: data.challenge.request.currency as string,
+ mode: 'payment',
+ paymentMethodCreation: 'manual',
+ })
+
+ darkQuery.addEventListener('change', () => {
+ elements.update({ appearance: getAppearance() })
+ })
+
+ const form = document.createElement('form')
+ elements.create('payment').mount(form)
+ root.appendChild(form)
+
+ const button = document.createElement('button')
+ button.textContent = 'Pay'
+ button.type = 'submit'
+ form.appendChild(button)
+
+ form.onsubmit = async (event) => {
+ event.preventDefault()
+ button.disabled = true
+ try {
+ await elements.submit()
+ const { paymentMethod, error } = await stripeJs.createPaymentMethod({ elements })
+ if (error || !paymentMethod) throw error ?? new Error('Failed to create payment method')
+ const method = stripe({ client: stripeJs, createToken })[0]
+ const credential = await method.createCredential({
+ challenge: data.challenge,
+ context: { paymentMethod: paymentMethod.id },
+ })
+ await submitCredential(credential)
+ } finally {
+ button.disabled = false
+ }
+ }
+})()
+
+async function createToken(opts: chargeClient.OnChallengeParameters) {
+ const createTokenUrl = new URL(data.config.createTokenUrl, location.origin)
+ if (createTokenUrl.origin !== location.origin)
+ throw new Error('createTokenUrl must be same-origin')
+ const res = await fetch(createTokenUrl, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(opts),
+ })
+ if (!res.ok) {
+ const text = await res.text().catch(() => '')
+ throw new Error(`Failed to create SPT (${res.status}): ${text}`)
+ }
+ const json = (await res.json()) as { spt: string }
+ return json.spt
+}
diff --git a/src/stripe/server/internal/html/package.json b/src/stripe/server/internal/html/package.json
new file mode 100644
index 00000000..d2c26dfc
--- /dev/null
+++ b/src/stripe/server/internal/html/package.json
@@ -0,0 +1,9 @@
+{
+ "name": "@mppx/stripe-html",
+ "private": true,
+ "type": "module",
+ "dependencies": {
+ "@stripe/stripe-js": "8.9.0",
+ "mppx": "workspace:*"
+ }
+}
diff --git a/src/stripe/server/internal/html/stripe-js-pure.d.ts b/src/stripe/server/internal/html/stripe-js-pure.d.ts
new file mode 100644
index 00000000..25c0e370
--- /dev/null
+++ b/src/stripe/server/internal/html/stripe-js-pure.d.ts
@@ -0,0 +1,7 @@
+declare module '@stripe/stripe-js/pure' {
+ export * from '@stripe/stripe-js'
+
+ export const loadStripe: typeof import('@stripe/stripe-js').loadStripe & {
+ setLoadParameters(parameters: { advancedFraudSignals: boolean }): void
+ }
+}
diff --git a/src/stripe/server/internal/html/tsconfig.json b/src/stripe/server/internal/html/tsconfig.json
new file mode 100644
index 00000000..06388472
--- /dev/null
+++ b/src/stripe/server/internal/html/tsconfig.json
@@ -0,0 +1,8 @@
+{
+ "extends": "../../../../../tsconfig.base.json",
+ "compilerOptions": {
+ "lib": ["es2022", "dom"],
+ "types": []
+ },
+ "include": ["./**/*.ts", "../../../../env.d.ts"]
+}
diff --git a/src/tempo/server/Charge.ts b/src/tempo/server/Charge.ts
index 9d6bfdd2..793be15a 100644
--- a/src/tempo/server/Charge.ts
+++ b/src/tempo/server/Charge.ts
@@ -24,6 +24,7 @@ import * as Proof from '../internal/proof.js'
import * as Selectors from '../internal/selectors.js'
import type * as types from '../internal/types.js'
import * as Methods from '../Methods.js'
+import { html as htmlContent } from './internal/html.gen.js'
/**
* Creates a Tempo charge method intent for usage on the server.
@@ -47,6 +48,7 @@ export function charge(
decimals = defaults.decimals,
description,
externalId,
+ html,
memo,
waitForConfirmation = true,
} = parameters
@@ -74,6 +76,8 @@ export function charge(
recipient,
} as unknown as Defaults,
+ html: html ? { config: {}, content: htmlContent } : undefined,
+
// TODO: dedupe `{charge,session}.request`
async request({ credential, request }) {
const chainId = await (async () => {
@@ -292,6 +296,8 @@ export declare namespace charge {
type Defaults = LooseOmit, 'feePayer' | 'recipient'>
type Parameters = {
+ /** Render payment page when Accept header is text/html (e.g. in browsers) */
+ html?: boolean | undefined
/** Testnet mode. */
testnet?: boolean | undefined
/**
diff --git a/src/tempo/server/internal/html/main.ts b/src/tempo/server/internal/html/main.ts
new file mode 100644
index 00000000..561ce010
--- /dev/null
+++ b/src/tempo/server/internal/html/main.ts
@@ -0,0 +1,71 @@
+import { local, Provider } from 'accounts'
+import { Json } from 'ox'
+import { createClient, custom, http } from 'viem'
+import { tempoModerato, tempoLocalnet } from 'viem/chains'
+
+import type * as Challenge from '../../../../Challenge.js'
+import { tempo } from '../../../../client/index.js'
+import * as Html from '../../../../server/internal/html/config.js'
+import { submitCredential } from '../../../../server/internal/html/serviceWorker.client.js'
+import type * as Methods from '../../../Methods.js'
+
+const data = Json.parse(document.getElementById(Html.dataId)!.textContent) as {
+ challenge: Challenge.FromMethods<[typeof Methods.charge]>
+}
+
+const root = document.getElementById('root')!
+
+const h2 = document.createElement('h2')
+h2.textContent = 'tempo'
+root.appendChild(h2)
+
+const provider = Provider.create({
+ // Dead code eliminated from production bundle (including top-level imports)
+ ...(import.meta.env.MODE === 'test'
+ ? {
+ adapter: local({
+ async loadAccounts() {
+ const { generatePrivateKey } = await import('viem/accounts')
+ const { Account, Actions } = await import('viem/tempo')
+ const privateKey = generatePrivateKey()
+ const account = Account.fromSecp256k1(privateKey)
+ const client = createClient({
+ chain: [tempoModerato, tempoLocalnet].find(
+ (x) => x.id === data.challenge.request.methodDetails?.chainId,
+ ),
+ transport: http(),
+ })
+ await Actions.faucet.fundSync(client, { account })
+ return {
+ accounts: [account],
+ }
+ },
+ }),
+ }
+ : {}),
+ testnet:
+ data.challenge.request.methodDetails?.chainId === tempoModerato.id ||
+ data.challenge.request.methodDetails?.chainId === tempoLocalnet.id,
+})
+
+const button = document.createElement('button')
+button.textContent = 'Continue with Tempo'
+button.onclick = async () => {
+ try {
+ button.disabled = true
+
+ const chain = [...(provider?.chains ?? []), tempoModerato, tempoLocalnet].find(
+ (x) => x.id === data.challenge.request.methodDetails?.chainId,
+ )
+ const client = createClient({ chain, transport: custom(provider) })
+ const result = await provider.request({ method: 'wallet_connect' })
+ const account = result.accounts[0]?.address
+ const method = tempo({ account, getClient: () => client })[0]
+
+ const credential = await method.createCredential({ challenge: data.challenge, context: {} })
+ await submitCredential(credential)
+ } finally {
+ button.disabled = false
+ }
+}
+root.appendChild(button)
diff --git a/src/tempo/server/internal/html/package.json b/src/tempo/server/internal/html/package.json
new file mode 100644
index 00000000..af5ea7e8
--- /dev/null
+++ b/src/tempo/server/internal/html/package.json
@@ -0,0 +1,10 @@
+{
+ "name": "@mppx/tempo-html",
+ "private": true,
+ "type": "module",
+ "dependencies": {
+ "accounts": "https://pkg.pr.new/tempoxyz/accounts@c339a21",
+ "mppx": "workspace:*",
+ "viem": "2.47.5"
+ }
+}
diff --git a/src/tempo/server/internal/html/tsconfig.json b/src/tempo/server/internal/html/tsconfig.json
new file mode 100644
index 00000000..06388472
--- /dev/null
+++ b/src/tempo/server/internal/html/tsconfig.json
@@ -0,0 +1,8 @@
+{
+ "extends": "../../../../../tsconfig.base.json",
+ "compilerOptions": {
+ "lib": ["es2022", "dom"],
+ "types": []
+ },
+ "include": ["./**/*.ts", "../../../../env.d.ts"]
+}
diff --git a/src/tsconfig.json b/src/tsconfig.json
index 924c7ddc..528ff1a6 100644
--- a/src/tsconfig.json
+++ b/src/tsconfig.json
@@ -6,5 +6,5 @@
"types": ["node"]
},
"include": ["./**/*.ts"],
- "exclude": ["./**/*.test.ts", "./**/*.test-d.ts"]
+ "exclude": ["./**/*.test.ts", "./**/*.test-d.ts", "./**/internal/html/**"]
}
diff --git a/test/html/globalSetup.ts b/test/html/globalSetup.ts
new file mode 100644
index 00000000..13f72fb4
--- /dev/null
+++ b/test/html/globalSetup.ts
@@ -0,0 +1,33 @@
+import { execSync } from 'node:child_process'
+
+import type { FullConfig } from '@playwright/test'
+
+export default async function globalSetup(config: FullConfig) {
+ const stripeProject = config.projects.find((project) => project.name === 'stripe')
+ const stripeMode = stripeProject?.use.headless === false ? 'production' : 'test'
+
+ execSync(`pnpm build`, {
+ cwd: new URL('../..', import.meta.url).pathname,
+ env: {
+ ...process.env,
+ STRIPE_HTML_MODE: stripeMode,
+ TEST: '1',
+ },
+ stdio: 'inherit',
+ })
+
+ const port = Number(process.env._MPPX_HTML_PORT)
+ if (!port) throw new Error('Missing _MPPX_HTML_PORT')
+
+ const { startServer } = await import('./server.js')
+ const server = await startServer(port)
+
+ return async () => {
+ await new Promise((resolve, reject) => {
+ server.close((error) => {
+ if (error) reject(error)
+ else resolve()
+ })
+ })
+ }
+}
diff --git a/test/html/playwright.config.ts b/test/html/playwright.config.ts
new file mode 100644
index 00000000..6217d152
--- /dev/null
+++ b/test/html/playwright.config.ts
@@ -0,0 +1,46 @@
+import net from 'node:net'
+
+import { defineConfig } from '@playwright/test'
+
+const port = await getPort('_MPPX_HTML_PORT')
+
+export default defineConfig({
+ globalSetup: './globalSetup.ts',
+ testDir: '.',
+ testMatch: '*.test.ts',
+ timeout: 60_000,
+ retries: 1,
+ reporter: process.env.CI ? [['line'], ['html', { open: 'never' }]] : 'list',
+ use: {
+ headless: !!process.env.CI || true,
+ screenshot: 'only-on-failure',
+ trace: 'retain-on-failure',
+ video: 'retain-on-failure',
+ },
+ projects: [
+ {
+ name: 'tempo',
+ testMatch: 'tempo.test.ts',
+ use: { baseURL: `http://localhost:${port}` },
+ },
+ {
+ name: 'stripe',
+ testMatch: 'stripe.test.ts',
+ use: { baseURL: `http://localhost:${port}` },
+ },
+ ],
+})
+
+async function getPort(envKey: string): Promise {
+ if (process.env[envKey]) return Number(process.env[envKey])
+ const port = await new Promise((resolve, reject) => {
+ const server = net.createServer()
+ server.listen(0, () => {
+ const port = (server.address() as net.AddressInfo).port
+ server.close(() => resolve(port))
+ })
+ server.on('error', reject)
+ })
+ process.env[envKey] = String(port)
+ return port
+}
diff --git a/test/html/server.ts b/test/html/server.ts
new file mode 100644
index 00000000..8097be5d
--- /dev/null
+++ b/test/html/server.ts
@@ -0,0 +1,172 @@
+import * as http from 'node:http'
+
+import { Mppx, Request as ServerRequest, stripe, tempo } from 'mppx/server'
+import { createClient, http as createHttpTransport } from 'viem'
+import { generatePrivateKey, privateKeyToAccount } from 'viem/accounts'
+import { tempoModerato } from 'viem/chains'
+import { Actions } from 'viem/tempo'
+
+export async function startServer(port: number): Promise {
+ const stripePublishableKey = process.env.VITE_STRIPE_PUBLIC_KEY
+ if (!stripePublishableKey) throw new Error('Missing VITE_STRIPE_PUBLIC_KEY')
+ const stripeSecretKey = process.env.VITE_STRIPE_SECRET_KEY
+ if (!stripeSecretKey) throw new Error('Missing VITE_STRIPE_SECRET_KEY')
+
+ const account = privateKeyToAccount(generatePrivateKey())
+ const client = createClient({
+ chain: tempoModerato,
+ pollingInterval: 1_000,
+ transport: createHttpTransport(),
+ })
+ for (let attempt = 1; ; attempt++)
+ try {
+ await Actions.faucet.fundSync(client, { account })
+ break
+ } catch (error) {
+ if (attempt >= 3) throw error
+ }
+
+ const createTokenUrl = '/stripe/create-spt'
+ const mppx = Mppx.create({
+ methods: [
+ stripe.charge({
+ html: {
+ createTokenUrl,
+ publishableKey: stripePublishableKey,
+ },
+ networkId: 'internal',
+ paymentMethodTypes: ['card'],
+ secretKey: stripeSecretKey,
+ }),
+ tempo.charge({
+ account,
+ currency: '0x20c0000000000000000000000000000000000000',
+ feePayer: true,
+ html: true,
+ recipient: account.address,
+ testnet: true,
+ }),
+ ],
+ secretKey: 'test-html-server-secret-key',
+ })
+
+ const server = http.createServer(
+ ServerRequest.toNodeListener(async (request) => {
+ const url = new URL(request.url)
+
+ if (url.pathname === '/tempo/charge') {
+ const result = await mppx.tempo.charge({
+ amount: '0.01',
+ description: 'Random stock photo',
+ })(request)
+
+ if (result.status === 402) return result.challenge
+
+ return result.withReceipt(Response.json({ url: 'https://example.com/photo.jpg' }))
+ }
+
+ if (url.pathname === createTokenUrl) return createSharedPaymentToken(request, stripeSecretKey)
+
+ if (url.pathname === '/stripe/charge') {
+ const result = await mppx.stripe.charge({
+ amount: '1',
+ currency: 'usd',
+ decimals: 2,
+ })(request)
+
+ if (result.status === 402) return result.challenge
+
+ const fortunes = [
+ 'A beautiful, smart, and loving person will come into your life.',
+ 'A dubious friend may be an enemy in camouflage.',
+ 'A faithful friend is a strong defense.',
+ 'A fresh start will put you on your way.',
+ 'A golden egg of opportunity falls into your lap this month.',
+ 'A good time to finish up old tasks.',
+ 'A hunch is creativity trying to tell you something.',
+ 'A lifetime of happiness lies ahead of you.',
+ 'A light heart carries you through all the hard times.',
+ 'A new perspective will come with the new year.',
+ ] as const
+
+ const fortune = fortunes[Math.floor(Math.random() * fortunes.length)]
+ return result.withReceipt(Response.json({ fortune }))
+ }
+
+ return new Response('Not Found', { status: 404 })
+ }),
+ )
+
+ await new Promise((resolve) => server.listen(port, resolve))
+
+ return Object.assign(server, {
+ port,
+ url: `http://localhost:${port}`,
+ }) as HtmlTestServer
+}
+
+type HtmlTestServer = http.Server & {
+ port: number
+ url: string
+}
+
+async function createSharedPaymentToken(request: Request, secretKey: string): Promise {
+ const { paymentMethod, amount, currency, expiresAt, networkId, metadata } =
+ (await request.json()) as {
+ paymentMethod: string
+ amount: string
+ currency: string
+ expiresAt: number
+ networkId?: string
+ metadata?: Record
+ }
+
+ if (metadata?.externalId)
+ return Response.json(
+ { error: 'metadata.externalId is reserved; use credential externalId instead' },
+ { status: 400 },
+ )
+
+ const body = new URLSearchParams({
+ payment_method: paymentMethod,
+ 'usage_limits[currency]': currency,
+ 'usage_limits[max_amount]': amount,
+ 'usage_limits[expires_at]': expiresAt.toString(),
+ })
+ if (networkId) body.set('seller_details[network_id]', networkId)
+ if (metadata)
+ for (const [key, value] of Object.entries(metadata)) body.set(`metadata[${key}]`, value)
+
+ // Test-only endpoint; production SPT flow uses the agent-side issued_tokens API.
+ const createSpt = async (bodyParams: URLSearchParams) =>
+ fetch('https://api.stripe.com/v1/test_helpers/shared_payment/granted_tokens', {
+ method: 'POST',
+ headers: {
+ Authorization: `Basic ${btoa(`${secretKey}:`)}`,
+ 'Content-Type': 'application/x-www-form-urlencoded',
+ },
+ body: bodyParams,
+ })
+
+ let response = await createSpt(body)
+ if (!response.ok) {
+ const error = (await response.json()) as { error: { message: string } }
+ if ((metadata || networkId) && error.error.message.includes('Received unknown parameter')) {
+ const fallbackBody = new URLSearchParams({
+ payment_method: paymentMethod,
+ 'usage_limits[currency]': currency,
+ 'usage_limits[max_amount]': amount,
+ 'usage_limits[expires_at]': expiresAt.toString(),
+ })
+ response = await createSpt(fallbackBody)
+ } else return Response.json({ error: error.error.message }, { status: 500 })
+ }
+
+ if (!response.ok) {
+ const error = (await response.json()) as { error: { message: string } }
+ return Response.json({ error: error.error.message }, { status: 500 })
+ }
+
+ const { id: spt } = (await response.json()) as { id: string }
+ return Response.json({ spt })
+}
diff --git a/test/html/stripe.test.ts b/test/html/stripe.test.ts
new file mode 100644
index 00000000..c3ad6541
--- /dev/null
+++ b/test/html/stripe.test.ts
@@ -0,0 +1,68 @@
+import type { Frame, Page } from '@playwright/test'
+import { expect, test } from '@playwright/test'
+
+test('charge via stripe html payment page', async ({ page }, testInfo) => {
+ test.slow()
+
+ await page.goto('/stripe/charge', {
+ waitUntil: 'domcontentloaded',
+ })
+
+ // Verify 402 payment page rendered
+ await expect(page.locator('h1')).toHaveText('Payment Required')
+ await expect(page.getByRole('button', { name: 'Pay' })).toBeVisible({ timeout: 10_000 })
+
+ if (!testInfo.project.use.headless) {
+ const stripeFrame = await getStripePaymentFrame(page)
+ const numberInput = stripeFrame.locator('[name="number"]')
+ const cardButton = stripeFrame.locator('[data-value="card"]')
+
+ await cardButton.isVisible({ timeout: 90_000 })
+ await cardButton.click()
+ await page.waitForTimeout(1_000)
+
+ await expect(numberInput).toBeVisible({ timeout: 90_000 })
+ await numberInput.fill('4242424242424242')
+ await stripeFrame.locator('[name="expiry"]').fill('12/34')
+ await stripeFrame.locator('[name="cvc"]').fill('123')
+
+ const postalCode = stripeFrame.locator('[name="postalCode"]')
+ await postalCode.isVisible({ timeout: 2_000 })
+ await postalCode.fill('10001')
+
+ await page.waitForTimeout(500)
+ }
+
+ // Submit payment
+ await page.getByRole('button', { name: 'Pay' }).click()
+
+ // Wait for service worker to submit credential and page to reload with paid response
+ await expect(page.locator('body')).toContainText('"fortune":', { timeout: 30_000 })
+})
+
+test('service worker endpoint returns javascript', async ({ page }) => {
+ const response = await page.goto('/stripe/charge?__mppx_worker')
+ expect(response?.headers()['content-type']).toContain('application/javascript')
+ expect(response?.status()).toBe(200)
+})
+
+async function getStripePaymentFrame(page: Page, timeout = 30_000): Promise {
+ const deadline = Date.now() + timeout
+
+ while (Date.now() < deadline) {
+ for (const frame of page.frames()) {
+ if (!frame.name().startsWith('__privateStripeFrame')) continue
+
+ const hasCardButton =
+ (await frame
+ .locator('[data-value="card"]')
+ .count()
+ .catch(() => 0)) > 0
+ if (hasCardButton) return frame
+ }
+
+ await page.waitForTimeout(250)
+ }
+
+ throw new Error('Timed out waiting for Stripe payment frame')
+}
diff --git a/test/html/tempo.test.ts b/test/html/tempo.test.ts
new file mode 100644
index 00000000..07265e2b
--- /dev/null
+++ b/test/html/tempo.test.ts
@@ -0,0 +1,24 @@
+import { expect, test } from '@playwright/test'
+
+test('charge via html payment page', async ({ page }) => {
+ // Navigate to the payment endpoint as a browser
+ await page.goto('/tempo/charge', {
+ waitUntil: 'domcontentloaded',
+ })
+
+ // Verify 402 payment page rendered
+ await expect(page.locator('h1')).toHaveText('Payment Required')
+ await expect(page.getByText('Continue with Tempo')).toBeVisible()
+
+ // Click the pay button (local adapter signs without dialog)
+ await page.getByText('Continue with Tempo').click()
+
+ // Wait for service worker to submit credential and page to reload with paid response
+ await expect(page.locator('body')).toContainText('"url":', { timeout: 30_000 })
+})
+
+test('service worker endpoint returns javascript', async ({ page }) => {
+ const response = await page.goto('/tempo/charge?__mppx_worker')
+ expect(response?.headers()['content-type']).toContain('application/javascript')
+ expect(response?.status()).toBe(200)
+})
diff --git a/tsconfig.json b/tsconfig.json
index bba8fd6d..aac76729 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -6,5 +6,10 @@
"strict": true
},
"files": [],
- "references": [{ "path": "./src" }, { "path": "./test" }]
+ "references": [
+ { "path": "./src" },
+ { "path": "./src/server/internal/html/tsconfig.worker.client.json" },
+ { "path": "./src/server/internal/html/tsconfig.worker.json" },
+ { "path": "./test" }
+ ]
}
diff --git a/vite.config.ts b/vite.config.ts
index 2a43d41b..e48b5f0c 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -41,7 +41,7 @@ export default defineConfig({
name: 'node',
alias,
include: ['src/**/*.test.ts'],
- exclude: ['src/**/*.browser.test.ts', 'src/cli/**/*.test.ts'],
+ exclude: ['**/node_modules/**', 'src/**/*.browser.test.ts', 'src/cli/**/*.test.ts'],
typecheck: {
include: ['src/**/*.test-d.ts'],
},
@@ -109,7 +109,7 @@ export default defineConfig({
'no-control-regex': 'off',
},
settings: {
- polyfills: ['PaymentRequest', 'URLPattern', 'crypto'],
+ polyfills: ['PaymentRequest', 'URLPattern', 'crypto', 'navigator'],
},
overrides: [
{