[0]['html']
+
+ test('returns html when Accept includes text/html', async () => {
+ const transport = Transport.http()
+ const request = new Request('https://example.com', {
+ headers: { Accept: 'text/html' },
+ })
+
+ const response = await transport.respondChallenge({
+ challenge,
+ input: request,
+ html: htmlOptions,
+ })
+
+ expect(response.status).toBe(402)
+ expect(response.headers.get('Content-Type')).toBe('text/html; charset=utf-8')
+ expect(response.headers.get('WWW-Authenticate')).toContain('Payment')
+ expect(response.headers.get('Cache-Control')).toBe('no-store')
+
+ const body = await response.text()
+ expect(body).toContain('')
+ expect(body).toContain('Payment Required')
+ expect(body).toContain('$10.00')
+ expect(body).toContain('Payment Required')
+ expect(body).toContain('')
+ expect(body).toContain('__MPPX_DATA__')
+ })
+
+ test('returns service worker script when __mppx_worker param is set', async () => {
+ const transport = Transport.http()
+ const request = new Request('https://example.com?__mppx_worker')
+
+ const response = await transport.respondChallenge({
+ challenge,
+ input: request,
+ html: htmlOptions,
+ })
+
+ expect(response.status).toBe(200)
+ expect(response.headers.get('Content-Type')).toBe('application/javascript')
+ expect(response.headers.get('Cache-Control')).toBe('no-store')
+
+ const body = await response.text()
+ expect(body).toContain('addEventListener')
+ })
+
+ test('does not return html when Accept does not include text/html', async () => {
+ const transport = Transport.http()
+ const request = new Request('https://example.com', {
+ headers: { Accept: 'application/json' },
+ })
+
+ const response = await transport.respondChallenge({
+ challenge,
+ input: request,
+ html: htmlOptions,
+ })
+
+ expect(response.status).toBe(402)
+ expect(response.headers.get('Content-Type')).toBeNull()
+ expect(await response.text()).toBe('')
+ })
+
+ test('renders description when challenge has one', async () => {
+ const transport = Transport.http()
+ const request = new Request('https://example.com', {
+ headers: { Accept: 'text/html' },
+ })
+
+ const challengeWithDescription = {
+ ...challenge,
+ description: 'Access to premium content',
+ }
+
+ const response = await transport.respondChallenge({
+ challenge: challengeWithDescription,
+ input: request,
+ html: htmlOptions,
+ })
+
+ const body = await response.text()
+ expect(body).toContain('Access to premium content')
+ expect(body).toContain('mppx-summary-description')
+ })
+
+ test('renders expires when challenge has one', async () => {
+ const transport = Transport.http()
+ const request = new Request('https://example.com', {
+ headers: { Accept: 'text/html' },
+ })
+
+ const response = await transport.respondChallenge({
+ challenge,
+ input: request,
+ html: htmlOptions,
+ })
+
+ const body = await response.text()
+ expect(body).toContain('Expires at')
+ expect(body).toContain('2025-01-01T00:00:00.000Z')
+ expect(body).toContain('mppx-summary-expires')
+ })
+
+ test('does not render description when challenge lacks one', async () => {
+ const transport = Transport.http()
+ const request = new Request('https://example.com', {
+ headers: { Accept: 'text/html' },
+ })
+
+ const challengeNoDescription = { ...challenge }
+ delete (challengeNoDescription as any).description
+
+ const response = await transport.respondChallenge({
+ challenge: challengeNoDescription,
+ input: request,
+ html: htmlOptions,
+ })
+
+ const body = await response.text()
+ expect(body).not.toMatch(/ {
+ const transport = Transport.http()
+ const request = new Request('https://example.com', {
+ headers: { Accept: 'text/html' },
+ })
+
+ const response = await transport.respondChallenge({
+ challenge,
+ input: request,
+ html: {
+ ...htmlOptions,
+ text: { title: 'Pay Up', paymentRequired: 'Gotta Pay' },
+ },
+ })
+
+ const body = await response.text()
+ expect(body).toContain('
Pay Up')
+ expect(body).toContain('Gotta Pay')
+ })
+
+ test('applies custom theme logo', async () => {
+ const transport = Transport.http()
+ const request = new Request('https://example.com', {
+ headers: { Accept: 'text/html' },
+ })
+
+ const response = await transport.respondChallenge({
+ challenge,
+ input: request,
+ html: {
+ ...htmlOptions,
+ theme: { logo: 'https://example.com/logo.png' },
+ },
+ })
+
+ const body = await response.text()
+ expect(body).toContain('https://example.com/logo.png')
+ expect(body).toContain('mppx-logo')
+ })
+
+ test('embeds config and challenge in data script', async () => {
+ const transport = Transport.http()
+ const request = new Request('https://example.com', {
+ headers: { Accept: 'text/html' },
+ })
+
+ const response = await transport.respondChallenge({
+ challenge,
+ input: request,
+ html: htmlOptions,
+ })
+
+ const body = await response.text()
+ // Extract the JSON data from the script tag
+ const dataMatch = body.match(
+ /',
+ },
+ })
+
+ const body = await response.text()
+ expect(body).not.toContain('')
+ expect(body).toContain('<script>')
+ })
+ })
+
describe('respondChallenge with error status codes', () => {
test('BadRequestError returns 400', async () => {
const transport = Transport.http()
diff --git a/src/server/Transport.ts b/src/server/Transport.ts
index 21fe77c8..08ea6bbe 100644
--- a/src/server/Transport.ts
+++ b/src/server/Transport.ts
@@ -7,6 +7,7 @@ import type { Distribute, UnionToIntersection } from '../internal/types.js'
import * as core_Mcp from '../Mcp.js'
import * as Receipt from '../Receipt.js'
import * as Html from './internal/html/config.js'
+import { html } from './internal/html/config.js'
import { serviceWorker } from './internal/html/serviceWorker.gen.js'
export { type McpSdk, mcpSdk } from '../mcp-sdk/server/Transport.js'
@@ -126,7 +127,7 @@ export function http(): Http {
return Credential.deserialize(payment)
},
- respondChallenge(options) {
+ async respondChallenge(options) {
const { challenge, error, input } = options
if (options.html && new URL(input.url).searchParams.has(Html.serviceWorkerParam))
@@ -143,38 +144,60 @@ export function http(): Http {
'Cache-Control': 'no-store',
}
- const body = (() => {
+ const body = await (async () => {
if (options.html && input.headers.get('Accept')?.includes('text/html')) {
headers['Content-Type'] = 'text/html; charset=utf-8'
- const html = String.raw
+
+ const theme = Html.mergeDefined(
+ {
+ favicon: undefined as Html.Theme['favicon'],
+ fontUrl: undefined as Html.Theme['fontUrl'],
+ logo: undefined as Html.Theme['logo'],
+ ...Html.defaultTheme,
+ },
+ (options.html.theme as never) ?? {},
+ )
+ const text = Html.sanitizeRecord(
+ Html.mergeDefined(Html.defaultText, (options.html.text as never) ?? {}),
+ )
+ const amount = await options.html.formatAmount(challenge.request)
+
return html`
- Payment Required
-
+
+
+ ${text.title}
+ ${Html.favicon(theme, challenge.realm)} ${Html.font(theme)} ${Html.style(theme)}
- Payment Required
-
-${Json.stringify(challenge, null, 2)
- .replace(/&/g, '&')
- .replace(//g, '>')}
-
-
- ${options.html.content}
+
+
+
+ ${Html.sanitize(amount)}
+ ${challenge.description
+ ? `${Html.sanitize(challenge.description)}
`
+ : ''}
+ ${challenge.expires
+ ? `${text.expires}
`
+ : ''}
+
+
+
+ ${options.html.content}
+
`
}
diff --git a/src/server/internal/html/config.ts b/src/server/internal/html/config.ts
index 4c29208e..fde68327 100644
--- a/src/server/internal/html/config.ts
+++ b/src/server/internal/html/config.ts
@@ -1,8 +1,414 @@
+import type * as Challenge from '../../../Challenge.js'
+import type * as Method from '../../../Method.js'
+
export type Options = {
config: Record
content: string
+ formatAmount: (request: any) => string | Promise
+ text: Text | undefined
+ theme: Theme | undefined
+}
+
+export type Data<
+ method extends Method.Method = Method.Method,
+ config extends Record = {},
+> = {
+ config: config
+ challenge: Challenge.FromMethods<[method]>
+ text: { [k in keyof Text]-?: NonNullable }
+ theme: {
+ [k in keyof Omit]-?: NonNullable
+ }
}
export const dataId = '__MPPX_DATA__'
+export const errorId = 'root_error'
+
+export const rootId = 'root'
+
export const serviceWorkerParam = '__mppx_worker'
+
+export const classNames = {
+ error: 'mppx-error',
+ header: 'mppx-header',
+ logo: 'mppx-logo',
+ logoColorScheme: (colorScheme: string) =>
+ colorScheme === 'dark' || colorScheme === 'light'
+ ? `${classNames.logo}--${colorScheme}`
+ : undefined,
+ summary: 'mppx-summary',
+ summaryAmount: 'mppx-summary-amount',
+ summaryDescription: 'mppx-summary-description',
+ summaryExpires: 'mppx-summary-expires',
+}
+
+export function sanitize(str: string): string {
+ return str
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"')
+ .replace(/'/g, ''')
+}
+
+export function sanitizeRecord>(record: type): type {
+ return Object.fromEntries(
+ Object.entries(record).map(([key, value]) => [key, sanitize(value)]),
+ ) as type
+}
+
+export const html = String.raw
+
+class CssVar {
+ readonly name: string
+ constructor(token: string) {
+ this.name = `--mppx-${token}`
+ }
+ toString() {
+ return `var(${this.name})`
+ }
+}
+
+export const vars = {
+ accent: new CssVar('accent'),
+ background: new CssVar('background'),
+ border: new CssVar('border'),
+ foreground: new CssVar('foreground'),
+ muted: new CssVar('muted'),
+ negative: new CssVar('negative'),
+ positive: new CssVar('positive'),
+ surface: new CssVar('surface'),
+ fontFamily: new CssVar('font-family'),
+ fontSizeBase: new CssVar('font-size-base'),
+ radius: new CssVar('radius'),
+ spacingUnit: new CssVar('spacing-unit'),
+} as const
+
+export function font(theme: Theme) {
+ if (!theme.fontUrl) return ''
+ return html`
+ `
+}
+
+export function style(theme: {
+ [k in keyof Omit]-?: NonNullable
+}) {
+ const colors = Object.fromEntries(
+ colorTokens.map((name) => [name, resolveColor(theme[name], defaultTheme[name])]),
+ ) as Record<(typeof colorTokens)[number], readonly [light: string, dark: string]>
+ const lightVars = colorTokens
+ .map((token) => `${vars[token].name}: ${colors[token][0]};`)
+ .join('\n ')
+ const darkVars = colorTokens
+ .map((token) => `${vars[token].name}: ${colors[token][1]};`)
+ .join('\n ')
+ const isLightOnly = theme.colorScheme === 'light'
+ const isDarkOnly = theme.colorScheme === 'dark'
+ const rootVars = isDarkOnly ? darkVars : lightVars
+ const darkMedia =
+ !isLightOnly && !isDarkOnly
+ ? `\n @media (prefers-color-scheme: dark) {\n :root {\n ${darkVars}\n }\n }`
+ : ''
+ return html`
+
+ `
+}
+
+export function showError(message: string) {
+ const existing = document.getElementById(errorId)
+ if (existing) {
+ existing.textContent = message
+ return
+ }
+ const el = document.createElement('p')
+ el.id = errorId
+ el.className = classNames.error
+ el.role = 'alert'
+ el.textContent = message
+ document.getElementById(rootId)?.after(el)
+}
+
+export function favicon(theme: Theme, realm: string) {
+ if (typeof theme.favicon === 'string')
+ return html``
+ if (typeof theme.favicon === 'object') {
+ return html`
+ `
+ }
+ // Fallback: use host's favicon via Google S2 service
+ try {
+ const domain = new URL(realm).hostname
+ return html``
+ } catch {
+ return ''
+ }
+}
+
+export function logo(value: Theme) {
+ if (typeof value.logo === 'undefined') return ''
+ if (typeof value.logo === 'string')
+ return html`
`
+ return Object.entries(value.logo)
+ .map(
+ (entry) =>
+ html`
`,
+ )
+ .join('\n')
+}
+
+export type Text = {
+ /** Prefix for the expiry line. @default 'Expires at' */
+ expires?: string | undefined
+ /** Pay button label. @default 'Pay' */
+ pay?: string | undefined
+ /** Badge label. @default 'Payment Required' */
+ paymentRequired?: string | undefined
+ /** Page title. @default text.paymentRequired */
+ title?: string | undefined
+}
+
+export const defaultText = {
+ expires: 'Expires at',
+ pay: 'Pay',
+ paymentRequired: 'Payment Required',
+ title: 'Payment Required',
+} as const satisfies Required
+
+export type Theme = {
+ /** Color scheme. @default 'light dark' */
+ colorScheme?: 'light' | 'dark' | 'light dark' | undefined
+ /** Font family. @default 'system-ui, -apple-system, sans-serif' */
+ fontFamily?: string | undefined
+ /** Base font size. @default '16px' */
+ fontSizeBase?: string | undefined
+ /** Font URL to inject (e.g. Google Fonts ``). */
+ fontUrl?: string | undefined
+ /** Favicon URL. Light/dark variants supported. Falls back to host's favicon via Google S2 service. */
+ favicon?: string | { light: string; dark: string } | undefined
+ /** Logo URL shown in header. Light/dark variants supported. */
+ logo?: string | { light: string; dark: string } | undefined
+ /** Border radius. @default '6px' */
+ radius?: string | undefined
+ /** The base spacing unit that all other spacing is derived from. Increase or decrease this value to make your layout more or less spacious. @default '2px' */
+ spacingUnit?: string | undefined
+
+ /** Accent color (buttons, links). @default ['#171717', '#ededed'] */
+ accent?: LightDark | undefined
+ /** Page background. @default ['#ffffff', '#0a0a0a'] */
+ background?: LightDark | undefined
+ /** Border color. @default ['#e5e5e5', '#2e2e2e'] */
+ border?: LightDark | undefined
+ /** Primary text/content color. @default ['#0a0a0a', '#ededed'] */
+ foreground?: LightDark | undefined
+ /** Secondary/muted text. @default ['#666666', '#a1a1a1'] */
+ muted?: LightDark | undefined
+ /** Error/danger color. @default ['#e5484d', '#e5484d'] */
+ negative?: LightDark | undefined
+ /** Success color. @default ['#30a46c', '#30a46c'] */
+ positive?: LightDark | undefined
+ /** Input/card surface. @default ['#f5f5f5', '#1a1a1a'] */
+ surface?: LightDark | undefined
+}
+
+export type LightDark = string | readonly [light: string, dark: string]
+
+export const defaultTheme = {
+ colorScheme: 'light dark',
+ fontFamily: 'system-ui, -apple-system, sans-serif',
+ fontSizeBase: '16px',
+ radius: '6px',
+ spacingUnit: '2px',
+
+ accent: ['#171717', '#ededed'],
+ background: ['#ffffff', '#0a0a0a'],
+ border: ['#e5e5e5', '#2e2e2e'],
+ foreground: ['#0a0a0a', '#ededed'],
+ muted: ['#666666', '#a1a1a1'],
+ negative: ['#e5484d', '#e5484d'],
+ positive: ['#30a46c', '#30a46c'],
+ surface: ['#f5f5f5', '#1a1a1a'],
+} as const satisfies Required>
+
+export const colorTokens = [
+ 'accent',
+ 'negative',
+ 'positive',
+ 'background',
+ 'foreground',
+ 'muted',
+ 'surface',
+ 'border',
+] as const satisfies readonly (keyof typeof defaultTheme)[]
+
+export function resolveColor(
+ value: Theme[(typeof colorTokens)[number]] | undefined,
+ fallback: readonly [string, string],
+): readonly [light: string, dark: string] {
+ if (!value) return fallback
+ if (typeof value === 'string') return [value, value]
+ return value
+}
+
+export function mergeDefined(defaults: type, value: DeepPartial | undefined): type {
+ if (value === undefined) return defaults
+ if (!isPlainObject(defaults) || !isPlainObject(value)) return (value ?? defaults) as type
+
+ const result: Record = { ...defaults }
+
+ for (const [key, nextValue] of Object.entries(value)) {
+ if (nextValue === undefined) continue
+
+ const currentValue = result[key]
+
+ result[key] =
+ isPlainObject(currentValue) && isPlainObject(nextValue)
+ ? mergeDefined(currentValue, nextValue)
+ : nextValue
+ }
+
+ return result as type
+}
+type DeepPartial = {
+ [key in keyof type]?: type[key] extends readonly unknown[]
+ ? type[key] | undefined
+ : type[key] extends object
+ ? DeepPartial | undefined
+ : type[key] | undefined
+}
+
+function isPlainObject(value: unknown): value is Record {
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
+}
+
+// Slimmed down Tailwind preflight
+// https://github.com/tailwindlabs/tailwindcss/blob/main/packages/tailwindcss/preflight.css
+const reset = html`
+ *, ::after, ::before, ::backdrop, ::file-selector-button { box-sizing: border-box; margin: 0;
+ padding: 0; border: 0 solid; border-color: ${vars.border}; } html, :host { line-height: 1.5;
+ -webkit-text-size-adjust: 100%; tab-size: 4; -webkit-tap-highlight-color: transparent; } h1, h2,
+ h3, h4, h5, h6 { font-size: inherit; font-weight: inherit; } a { color: inherit;
+ -webkit-text-decoration: inherit; text-decoration: inherit; } b, strong { font-weight: bolder; }
+ code, kbd, samp, pre { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas,
+ 'Liberation Mono', 'Courier New', monospace; font-size: 1em; } small { font-size: 80%; } ol, ul,
+ menu { list-style: none; } img, svg, video, canvas, audio, iframe, embed, object { display: block;
+ vertical-align: middle; } img, video { max-width: 100%; height: auto; } button, input, select,
+ optgroup, textarea, ::file-selector-button { font: inherit; font-feature-settings: inherit;
+ font-variation-settings: inherit; letter-spacing: inherit; color: inherit; border-radius: 0;
+ background-color: transparent; opacity: 1; } ::file-selector-button { margin-inline-end: 4px; }
+ ::placeholder { opacity: 1; } @supports (not (-webkit-appearance: -apple-pay-button)) or
+ (contain-intrinsic-size: 1px) { ::placeholder { color: color-mix(in oklab, currentcolor 50%,
+ transparent); } } textarea { resize: vertical; } ::-webkit-search-decoration { -webkit-appearance:
+ none; } :-moz-ui-invalid { box-shadow: none; } button, input:where([type='button'],
+ [type='reset'], [type='submit']), ::file-selector-button { appearance: button; }
+ ::-webkit-inner-spin-button, ::-webkit-outer-spin-button { height: auto; }
+ [hidden]:where(:not([hidden='until-found'])) { display: none !important; }
+`
diff --git a/src/stripe/internal/types.ts b/src/stripe/internal/types.ts
index c36bf59b..94bfc014 100644
--- a/src/stripe/internal/types.ts
+++ b/src/stripe/internal/types.ts
@@ -1,3 +1,5 @@
+import * as StripeJsTypes from '../../stripe/server/internal/html/types.js'
+
/**
* Duck-typed interface for the Stripe Node SDK (`stripe` npm package).
* Matches the subset of the API used by mppx for server-side payment verification.
@@ -24,3 +26,21 @@ export type StripeJs = {
createPaymentMethod(...args: any[]): Promise>
elements(...args: any[]): unknown
}
+
+export type CreatePaymentMethodFromElements = Omit<
+ StripeJsTypes.CreatePaymentMethodFromElements,
+ 'elements'
+> & {}
+
+export type StripeElementsOptionsMode = Omit<
+ Extract,
+ | 'amount'
+ | 'currency'
+ | 'mode'
+ | 'excludedPaymentMethodTypes'
+ | 'paymentMethodCreation'
+ | 'paymentMethodTypes'
+ | 'payment_method_types'
+> & {}
+
+export type StripePaymentElementOptions = StripeJsTypes.StripePaymentElementOptions
diff --git a/src/stripe/server/Charge.ts b/src/stripe/server/Charge.ts
index 12a8dd7c..ec3777ca 100644
--- a/src/stripe/server/Charge.ts
+++ b/src/stripe/server/Charge.ts
@@ -3,7 +3,14 @@ import { PaymentActionRequiredError, VerificationFailedError } from '../../Error
import * as Expires from '../../Expires.js'
import type { LooseOmit, OneOf } from '../../internal/types.js'
import * as Method from '../../Method.js'
-import type { StripeClient } from '../internal/types.js'
+import type * as Html from '../../server/internal/html/config.ts'
+import type * as z from '../../zod.js'
+import type {
+ StripeClient,
+ CreatePaymentMethodFromElements,
+ StripeElementsOptionsMode,
+ StripePaymentElementOptions,
+} from '../internal/types.js'
import * as Methods from '../Methods.js'
import { html as htmlContent } from './internal/html.gen.js'
@@ -39,7 +46,7 @@ export function charge(parameters: p
decimals,
description,
externalId,
- html,
+ html: { text: htmlText, theme: htmlTheme, ...htmlConfig } = {},
metadata,
networkId,
paymentMethodTypes,
@@ -61,7 +68,28 @@ export function charge(parameters: p
paymentMethodTypes,
} as unknown as Defaults,
- html: html ? { config: html, content: htmlContent } : undefined,
+ html:
+ 'publishableKey' in htmlConfig && htmlConfig.publishableKey && htmlConfig.createTokenUrl
+ ? {
+ config: htmlConfig,
+ content: htmlContent,
+ formatAmount: (request: z.output) => {
+ try {
+ const formatter = new Intl.NumberFormat('en', {
+ style: 'currency',
+ currency: request.currency,
+ currencyDisplay: 'narrowSymbol',
+ })
+ const decimals = formatter.resolvedOptions().maximumFractionDigits ?? 2
+ return formatter.format(Number(request.amount) / 10 ** decimals)
+ } catch {
+ return `${request.currency}${request.amount}`
+ }
+ },
+ text: htmlText,
+ theme: htmlTheme,
+ }
+ : undefined,
async verify({ credential }) {
const { challenge } = credential
@@ -113,7 +141,21 @@ export declare namespace charge {
type Parameters = {
/** Render payment page when Accept header is text/html (e.g. in browsers) */
- html?: { createTokenUrl: string; publishableKey: string } | undefined
+ html?:
+ | {
+ createTokenUrl: string
+ elements?:
+ | {
+ options?: StripeElementsOptionsMode | undefined
+ paymentOptions?: StripePaymentElementOptions | undefined
+ createPaymentMethodOptions?: CreatePaymentMethodFromElements | undefined
+ }
+ | undefined
+ publishableKey: string
+ text?: Html.Text
+ theme?: Html.Theme
+ }
+ | 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
index 13f7c578..bc230d48 100644
--- a/src/stripe/server/internal/html/main.ts
+++ b/src/stripe/server/internal/html/main.ts
@@ -1,6 +1,7 @@
+import type { Appearance } from '@stripe/stripe-js'
import { loadStripe } from '@stripe/stripe-js/pure'
+import { Json } from 'ox'
-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'
@@ -8,21 +9,45 @@ 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 dataElement = document.getElementById(Html.dataId)!
+const data = Json.parse(dataElement.textContent) as Html.Data<
+ typeof Methods.charge,
+ NonNullable
+>
-const root = document.getElementById('root')!
+const root = document.getElementById(Html.rootId)!
-const h2 = document.createElement('h2')
-h2.textContent = 'stripe'
-root.appendChild(h2)
+const css = String.raw
+const style = document.createElement('style')
+style.textContent = css`
+ form {
+ display: flex;
+ flex-direction: column;
+ gap: calc(${Html.vars.spacingUnit} * 8);
+ }
+ button {
+ background: ${Html.vars.accent};
+ border-radius: ${Html.vars.radius};
+ color: ${Html.vars.background};
+ cursor: pointer;
+ font-weight: 500;
+ padding: calc(${Html.vars.spacingUnit} * 4) calc(${Html.vars.spacingUnit} * 8);
+ width: 100%;
+ }
+ button:hover:not(:disabled) {
+ opacity: 0.85;
+ }
+ button:disabled {
+ cursor: default;
+ opacity: 0.5;
+ }
+`
+root.append(style)
;(async () => {
if (import.meta.env.MODE === 'test') {
const button = document.createElement('button')
- button.textContent = 'Pay'
+ button.textContent = data.text.pay
root.appendChild(button)
button.onclick = async () => {
try {
@@ -33,6 +58,8 @@ root.appendChild(h2)
context: { paymentMethod: 'pm_card_visa' },
})
await submitCredential(credential)
+ } catch (e) {
+ Html.showError(e instanceof Error ? e.message : 'Payment failed')
} finally {
button.disabled = false
}
@@ -44,16 +71,48 @@ root.appendChild(h2)
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 getAppearance = () => {
+ const theme = (() => {
+ if (data.config.elements?.options?.appearance?.theme)
+ return data.config.elements?.options?.appearance?.theme
+ switch (data.theme.colorScheme) {
+ case 'light dark':
+ return (darkQuery.matches ? 'night' : 'stripe') as 'night' | 'stripe'
+ case 'light':
+ return 'stripe' as const
+ case 'dark':
+ return 'night' as const
+ }
+ })()
+ const resolvedColorSchemeIndex = darkQuery.matches ? 1 : 0
+ return Html.mergeDefined(
+ {
+ disableAnimations: true,
+ theme,
+ variables: {
+ borderRadius: data.theme.radius,
+ colorBackground: data.theme.surface[resolvedColorSchemeIndex],
+ colorDanger: data.theme.negative[resolvedColorSchemeIndex],
+ colorPrimary: data.theme.accent[resolvedColorSchemeIndex],
+ colorText: data.theme.foreground[resolvedColorSchemeIndex],
+ colorTextSecondary: data.theme.muted[resolvedColorSchemeIndex],
+ fontSizeBase: data.theme.fontSizeBase,
+ fontFamily: data.theme.fontFamily,
+ spacingUnit: data.theme.spacingUnit,
+ },
+ } satisfies Appearance,
+ (data.config.elements?.options?.appearance as never) ?? {},
+ )
+ }
const elements = stripeJs.elements({
- amount: Number(data.challenge.request.amount),
appearance: getAppearance(),
- currency: data.challenge.request.currency as string,
+ ...data.config.elements?.options,
+ amount: Number(data.challenge.request.amount),
+ currency: data.challenge.request.currency,
mode: 'payment',
paymentMethodCreation: 'manual',
+ paymentMethodTypes: data.challenge.request.methodDetails.paymentMethodTypes,
})
darkQuery.addEventListener('change', () => {
@@ -61,27 +120,34 @@ root.appendChild(h2)
})
const form = document.createElement('form')
- elements.create('payment').mount(form)
+ elements.create('payment', data.config.elements?.paymentOptions).mount(form)
root.appendChild(form)
const button = document.createElement('button')
- button.textContent = 'Pay'
+ button.textContent = data.text.pay
button.type = 'submit'
form.appendChild(button)
form.onsubmit = async (event) => {
event.preventDefault()
+ document.getElementById(Html.errorId)?.remove()
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 { paymentMethod, error: stripeError } = await stripeJs.createPaymentMethod({
+ ...data.config.elements?.createPaymentMethodOptions,
+ elements,
+ })
+ if (stripeError || !paymentMethod)
+ throw stripeError ?? 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)
+ } catch (e) {
+ Html.showError(e instanceof Error ? e.message : 'Payment failed')
} finally {
button.disabled = false
}
@@ -104,3 +170,5 @@ async function createToken(opts: chargeClient.OnChallengeParameters) {
const json = (await res.json()) as { spt: string }
return json.spt
}
+
+dataElement.remove()
diff --git a/src/stripe/server/internal/html/types.ts b/src/stripe/server/internal/html/types.ts
new file mode 100644
index 00000000..90d27207
--- /dev/null
+++ b/src/stripe/server/internal/html/types.ts
@@ -0,0 +1,5 @@
+export type {
+ CreatePaymentMethodFromElements,
+ StripeElementsOptionsMode,
+ StripePaymentElementOptions,
+} from '@stripe/stripe-js'
diff --git a/src/tempo/server/Charge.ts b/src/tempo/server/Charge.ts
index 793be15a..ce816c91 100644
--- a/src/tempo/server/Charge.ts
+++ b/src/tempo/server/Charge.ts
@@ -1,4 +1,10 @@
-import { decodeFunctionData, keccak256, parseEventLogs, type TransactionReceipt } from 'viem'
+import {
+ decodeFunctionData,
+ formatUnits,
+ keccak256,
+ parseEventLogs,
+ type TransactionReceipt,
+} from 'viem'
import {
getTransactionReceipt,
sendRawTransaction,
@@ -8,13 +14,15 @@ import {
call as viem_call,
} from 'viem/actions'
import { tempo as tempo_chain } from 'viem/chains'
-import { Abis, Transaction } from 'viem/tempo'
+import { Abis, Actions, Transaction } from 'viem/tempo'
import * as Expires from '../../Expires.js'
import type { LooseOmit, NoExtraKeys } from '../../internal/types.js'
import * as Method from '../../Method.js'
+import type * as Html from '../../server/internal/html/config.ts'
import * as Store from '../../Store.js'
import * as Client from '../../viem/Client.js'
+import type * as z from '../../zod.js'
import * as Account from '../internal/account.js'
import * as TempoAddress from '../internal/address.js'
import * as Charge_internal from '../internal/charge.js'
@@ -76,7 +84,35 @@ export function charge(
recipient,
} as unknown as Defaults,
- html: html ? { config: {}, content: htmlContent } : undefined,
+ html: html
+ ? {
+ config: {},
+ content: htmlContent,
+ formatAmount: async (request: z.output) => {
+ try {
+ const chainId = request.methodDetails?.chainId
+ if (chainId === undefined) throw new Error('no chainId')
+ const client = await getClient({ chainId })
+ const metadata = await Actions.token.getMetadata(client, {
+ token: request.currency as `0x${string}`,
+ })
+ const symbol =
+ new Intl.NumberFormat('en', {
+ style: 'currency',
+ currency: metadata.currency,
+ currencyDisplay: 'narrowSymbol',
+ })
+ .formatToParts(0)
+ .find((p) => p.type === 'currency')?.value ?? metadata.currency
+ return `${symbol}${formatUnits(BigInt(request.amount), metadata.decimals)}`
+ } catch {
+ return `$${request.amount}`
+ }
+ },
+ text: typeof html === 'object' ? html.text : undefined,
+ theme: typeof html === 'object' ? html.theme : undefined,
+ }
+ : undefined,
// TODO: dedupe `{charge,session}.request`
async request({ credential, request }) {
@@ -297,7 +333,13 @@ export declare namespace charge {
type Parameters = {
/** Render payment page when Accept header is text/html (e.g. in browsers) */
- html?: boolean | undefined
+ html?:
+ | boolean
+ | {
+ text?: Html.Text
+ theme?: Html.Theme
+ }
+ | undefined
/** Testnet mode. */
testnet?: boolean | undefined
/**
diff --git a/src/tempo/server/internal/html/main.ts b/src/tempo/server/internal/html/main.ts
index 561ce010..3d8c9f56 100644
--- a/src/tempo/server/internal/html/main.ts
+++ b/src/tempo/server/internal/html/main.ts
@@ -3,21 +3,50 @@ 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 dataElement = document.getElementById(Html.dataId)!
+const data = Json.parse(dataElement.textContent) as Html.Data
-const root = document.getElementById('root')!
+const root = document.getElementById(Html.rootId)!
-const h2 = document.createElement('h2')
-h2.textContent = 'tempo'
-root.appendChild(h2)
+const css = String.raw
+const style = document.createElement('style')
+style.textContent = css`
+ form {
+ display: flex;
+ flex-direction: column;
+ gap: calc(${Html.vars.spacingUnit} * 8);
+ }
+ button {
+ background: ${Html.vars.accent};
+ border-radius: ${Html.vars.radius};
+ color: ${Html.vars.background};
+ cursor: pointer;
+ font-weight: 500;
+ padding: calc(${Html.vars.spacingUnit} * 4) calc(${Html.vars.spacingUnit} * 8);
+ width: 100%;
+ }
+ button:hover:not(:disabled) {
+ opacity: 0.85;
+ }
+ button:disabled {
+ cursor: default;
+ opacity: 0.5;
+ }
+ button svg {
+ display: inline;
+ fill: currentColor;
+ height: 0.85em;
+ transform: translateY(0.05em);
+ vertical-align: baseline;
+ width: auto;
+ }
+`
+root.append(style)
const provider = Provider.create({
// Dead code eliminated from production bundle (including top-level imports)
@@ -49,23 +78,34 @@ const provider = Provider.create({
})
const button = document.createElement('button')
-button.textContent = 'Continue with Tempo'
+button.innerHTML =
+ 'Continue with '
button.onclick = async () => {
try {
+ document.getElementById(Html.errorId)?.remove()
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 account = await (async () => {
+ const accounts = await provider.request({ method: 'eth_accounts' })
+ if (accounts.length > 0) return accounts.at(0)
+ const result = await provider.request({ method: 'wallet_connect' })
+ return result.accounts[0]?.address
+ })()
const method = tempo({ account, getClient: () => client })[0]
const credential = await method.createCredential({ challenge: data.challenge, context: {} })
await submitCredential(credential)
+ } catch (e) {
+ const message = e instanceof Error && 'shortMessage' in e ? (e as any).shortMessage : undefined
+ Html.showError(message ?? (e instanceof Error ? e.message : 'Payment failed'))
} finally {
button.disabled = false
}
}
root.appendChild(button)
+
+dataElement.remove()
diff --git a/src/tempo/server/internal/html/package.json b/src/tempo/server/internal/html/package.json
index af5ea7e8..ccf2f198 100644
--- a/src/tempo/server/internal/html/package.json
+++ b/src/tempo/server/internal/html/package.json
@@ -3,7 +3,7 @@
"private": true,
"type": "module",
"dependencies": {
- "accounts": "https://pkg.pr.new/tempoxyz/accounts@c339a21",
+ "accounts": "0.4.12",
"mppx": "workspace:*",
"viem": "2.47.5"
}
diff --git a/test/html/stripe.test.ts b/test/html/stripe.test.ts
index c3ad6541..cf057891 100644
--- a/test/html/stripe.test.ts
+++ b/test/html/stripe.test.ts
@@ -9,7 +9,7 @@ test('charge via stripe html payment page', async ({ page }, testInfo) => {
})
// Verify 402 payment page rendered
- await expect(page.locator('h1')).toHaveText('Payment Required')
+ await expect(page.getByText('Payment Required')).toBeVisible()
await expect(page.getByRole('button', { name: 'Pay' })).toBeVisible({ timeout: 10_000 })
if (!testInfo.project.use.headless) {
@@ -33,8 +33,8 @@ test('charge via stripe html payment page', async ({ page }, testInfo) => {
await page.waitForTimeout(500)
}
- // Submit payment
- await page.getByRole('button', { name: 'Pay' }).click()
+ // Submit payment (force needed — Stripe Link overlay can intercept click)
+ await page.getByRole('button', { name: 'Pay' }).click({ force: true })
// Wait for service worker to submit credential and page to reload with paid response
await expect(page.locator('body')).toContainText('"fortune":', { timeout: 30_000 })
diff --git a/test/html/tempo.test.ts b/test/html/tempo.test.ts
index 07265e2b..b9f03bfa 100644
--- a/test/html/tempo.test.ts
+++ b/test/html/tempo.test.ts
@@ -1,17 +1,17 @@
import { expect, test } from '@playwright/test'
-test('charge via html payment page', async ({ page }) => {
+test('charge via tempo 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()
+ await expect(page.getByText('Payment Required')).toBeVisible()
+ await expect(page.getByRole('button', { name: /continue with tempo/i })).toBeVisible()
// Click the pay button (local adapter signs without dialog)
- await page.getByText('Continue with Tempo').click()
+ await page.getByRole('button', { name: /continue with tempo/i }).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 })