Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/thin-turtles-greet.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'mppx': patch
---

Added theming to automatic HTML payment links.
2 changes: 1 addition & 1 deletion examples/stripe/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@ const mppx = Mppx.create({
stripe.charge({
client: stripeClient,
html: {
publishableKey: process.env.VITE_STRIPE_PUBLIC_KEY!,
createTokenUrl: '/api/create-spt',
publishableKey: process.env.VITE_STRIPE_PUBLIC_KEY!,
},
// Stripe Business Network profile ID.
networkId: 'internal',
Expand Down
11 changes: 5 additions & 6 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

216 changes: 216 additions & 0 deletions src/server/Transport.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,222 @@ describe('http', () => {
})
})

describe('respondChallenge html', () => {
const htmlOptions = {
config: { foo: 'bar' },
content: '<script src="/pay.js"></script>',
formatAmount: () => '$10.00',
text: undefined,
theme: undefined,
} satisfies Parameters<Transport.Http['respondChallenge']>[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('<!doctype html>')
expect(body).toContain('<title>Payment Required</title>')
expect(body).toContain('$10.00')
expect(body).toContain('Payment Required')
expect(body).toContain('<script src="/pay.js"></script>')
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(/<p class="mppx-summary-description"/)
})

test('applies custom text', 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,
text: { title: 'Pay Up', paymentRequired: 'Gotta Pay' },
},
})

const body = await response.text()
expect(body).toContain('<title>Pay Up</title>')
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(
/<script id="__MPPX_DATA__" type="application\/json">\s*([\s\S]*?)\s*<\/script>/,
)
expect(dataMatch).not.toBeNull()

const data = JSON.parse(dataMatch?.[1]?.replace(/\\u003c/g, '<') ?? '')
expect(data.config).toEqual({ foo: 'bar' })
expect(data.challenge.id).toBe(challenge.id)
expect(data.challenge.method).toBe('tempo')
expect(data.text.paymentRequired).toBe('Payment Required')
})

test('sanitizes html in formatted amount', 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,
formatAmount: () => '<script>alert("xss")</script>',
},
})

const body = await response.text()
expect(body).not.toContain('<script>alert("xss")</script>')
expect(body).toContain('&lt;script&gt;')
})
})

describe('respondChallenge with error status codes', () => {
test('BadRequestError returns 400', async () => {
const transport = Transport.http()
Expand Down
71 changes: 47 additions & 24 deletions src/server/Transport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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))
Expand All @@ -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`<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Payment Required</title>
<style>
:root {
color-scheme: dark light;
}
</style>
<meta name="robots" content="noindex" />
<meta name="color-scheme" content="${theme.colorScheme}" />
<title>${text.title}</title>
${Html.favicon(theme, challenge.realm)} ${Html.font(theme)} ${Html.style(theme)}
</head>
<body>
<h1>Payment Required</h1>
<pre>
${Json.stringify(challenge, null, 2)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')}</pre
>
<div id="root"></div>
<script id="${Html.dataId}" type="application/json">
${Json.stringify({ config: options.html.config, challenge }).replace(
/</g,
'\\u003c',
)}
</script>
${options.html.content}
<main>
<header class="${Html.classNames.header}">
${Html.logo(theme)}
<span>${text.paymentRequired}</span>
</header>
<section class="${Html.classNames.summary}" aria-label="Payment summary">
<h1 class="${Html.classNames.summaryAmount}">${Html.sanitize(amount)}</h1>
${challenge.description
? `<p class="${Html.classNames.summaryDescription}">${Html.sanitize(challenge.description)}</p>`
: ''}
${challenge.expires
? `<p class="${Html.classNames.summaryExpires}">${text.expires} <time datetime="${new Date(challenge.expires).toISOString()}">${new Date(challenge.expires).toLocaleString()}</time></p>`
: ''}
</section>
<div id="${Html.rootId}" aria-label="Payment form"></div>
<script id="${Html.dataId}" type="application/json">
${Json.stringify({
config: options.html.config,
challenge,
text,
theme,
} satisfies Html.Data).replace(/</g, '\\u003c')}
</script>
${options.html.content}
</main>
</body>
</html> `
}
Expand Down
Loading
Loading