diff --git a/examples/charge-wagmi/server.ts b/examples/charge-wagmi/server.ts index 0b709d93..5654fb61 100644 --- a/examples/charge-wagmi/server.ts +++ b/examples/charge-wagmi/server.ts @@ -1,4 +1,4 @@ -import { Mppx, tempo } from 'mppx/server' +import { Mppx, serviceWorkerResponse, tempo } from 'mppx/server' import { createClient, http } from 'viem' import { generatePrivateKey, privateKeyToAccount } from 'viem/accounts' import { tempoModerato } from 'viem/chains' @@ -20,6 +20,8 @@ const mppx = Mppx.create({ export async function handler(request: Request): Promise { const url = new URL(request.url) + if (url.pathname === '/__mppx_sw.js') return serviceWorkerResponse() + // Free if (url.pathname === '/api/health') return Response.json({ status: 'ok' }) diff --git a/examples/charge/src/server.ts b/examples/charge/src/server.ts index c4b05f01..6f00b47c 100644 --- a/examples/charge/src/server.ts +++ b/examples/charge/src/server.ts @@ -1,4 +1,4 @@ -import { Mppx, tempo } from 'mppx/server' +import { Mppx, serviceWorkerResponse, tempo } from 'mppx/server' import { createClient, http } from 'viem' import { generatePrivateKey, privateKeyToAccount } from 'viem/accounts' import { tempoModerato } from 'viem/chains' @@ -21,6 +21,8 @@ const mppx = Mppx.create({ export async function handler(request: Request): Promise { const url = new URL(request.url) + if (url.pathname === '/__mppx_sw.js') return serviceWorkerResponse() + // Free if (url.pathname === '/api/health') return Response.json({ status: 'ok' }) diff --git a/examples/stripe/src/server.ts b/examples/stripe/src/server.ts index 0aaac6ac..c3521995 100644 --- a/examples/stripe/src/server.ts +++ b/examples/stripe/src/server.ts @@ -1,4 +1,4 @@ -import { Mppx, stripe } from 'mppx/server' +import { Mppx, serviceWorkerResponse, stripe } from 'mppx/server' import Stripe from 'stripe' const secretKey = process.env.VITE_STRIPE_SECRET_KEY! @@ -13,6 +13,8 @@ const mppx = Mppx.create({ networkId: 'internal', // Ensure only card is supported. paymentMethodTypes: ['card'], + // Publishable key for browser HTML payment form. + publishableKey: process.env.VITE_STRIPE_PUBLIC_KEY, }), ], }) @@ -23,6 +25,8 @@ const mppx = Mppx.create({ export async function handler(request: Request): Promise { const url = new URL(request.url) + if (url.pathname === '/__mppx_sw.js') return serviceWorkerResponse() + if (url.pathname === '/api/create-spt') { const { paymentMethod, amount, currency, expiresAt, networkId, metadata } = (await request.json()) as { diff --git a/src/Method.ts b/src/Method.ts index 1b196fad..49881459 100755 --- a/src/Method.ts +++ b/src/Method.ts @@ -74,6 +74,7 @@ export type Server< transportOverride = undefined, > = method & { defaults?: defaults | undefined + html?: string | undefined request?: RequestFn | undefined respond?: RespondFn | undefined transport?: transportOverride | undefined @@ -202,10 +203,11 @@ export function toServer< method: method, options: toServer.Options, ): Server { - const { defaults, request, respond, transport, verify } = options + const { defaults, html, request, respond, transport, verify } = options return { ...method, defaults, + html, request, respond, transport, @@ -220,6 +222,7 @@ export declare namespace toServer { transportOverride extends Transport.AnyTransport | undefined = undefined, > = { defaults?: defaults | undefined + html?: string | undefined request?: RequestFn | undefined respond?: RespondFn | undefined transport?: transportOverride | undefined diff --git a/src/middlewares/elysia.ts b/src/middlewares/elysia.ts index d26a6a0c..7b1e4657 100644 --- a/src/middlewares/elysia.ts +++ b/src/middlewares/elysia.ts @@ -1,5 +1,6 @@ import type { Context } from 'elysia' +import { serviceWorkerResponse } from '../server/Html.js' import * as Mppx_core from '../server/Mppx.js' import * as Mppx_internal from './internal/mppx.js' @@ -61,6 +62,7 @@ export function payment( options: intent extends (options: infer options) => any ? options : never, ): ElysiaHook { return async ({ request, set }) => { + if (new URL(request.url).pathname === '/__mppx_sw.js') return serviceWorkerResponse() const result = await intent(options)(request) if (result.status === 402) return result.challenge const receipt = result.withReceipt(new Response()) diff --git a/src/middlewares/express.ts b/src/middlewares/express.ts index e7de1231..b04157de 100644 --- a/src/middlewares/express.ts +++ b/src/middlewares/express.ts @@ -5,6 +5,7 @@ import type { RequestHandler, } from 'express' +import { serviceWorkerResponse } from '../server/Html.js' import * as Mppx_core from '../server/Mppx.js' import * as Mppx_internal from './internal/mppx.js' @@ -60,6 +61,12 @@ export function payment( options: intent extends (options: infer options) => any ? options : never, ): RequestHandler { return async (req: ExpressRequest, res: ExpressResponse, next: NextFunction) => { + if (req.originalUrl === '/__mppx_sw.js') { + const swRes = serviceWorkerResponse() + res.setHeader('Content-Type', 'application/javascript') + res.send(await swRes.text()) + return + } const request = new Request(`${req.protocol}://${req.hostname}${req.originalUrl}`, { method: req.method, headers: req.headers as Record, diff --git a/src/middlewares/hono.ts b/src/middlewares/hono.ts index 75c1d1ba..1576e6a5 100644 --- a/src/middlewares/hono.ts +++ b/src/middlewares/hono.ts @@ -1,5 +1,6 @@ import type { MiddlewareHandler } from 'hono' +import { serviceWorkerResponse } from '../server/Html.js' import * as Mppx_core from '../server/Mppx.js' import * as Mppx_internal from './internal/mppx.js' @@ -55,6 +56,7 @@ export function payment( options: intent extends (options: infer options) => any ? options : never, ): MiddlewareHandler { return async (c, next) => { + if (new URL(c.req.url).pathname === '/__mppx_sw.js') return serviceWorkerResponse() const result = await intent(options)(c.req.raw) if (result.status === 402) return result.challenge await next() diff --git a/src/middlewares/nextjs.ts b/src/middlewares/nextjs.ts index da85bf3e..688841d0 100644 --- a/src/middlewares/nextjs.ts +++ b/src/middlewares/nextjs.ts @@ -1,3 +1,4 @@ +import { serviceWorkerResponse } from '../server/Html.js' import * as Mppx_core from '../server/Mppx.js' import * as Mppx_internal from './internal/mppx.js' @@ -58,6 +59,7 @@ export function payment( handler: RouteHandler, ): RouteHandler { return async (request) => { + if (new URL(request.url).pathname === '/__mppx_sw.js') return serviceWorkerResponse() const result = await intent(options)(request) if (result.status === 402) return result.challenge const response = await handler(request) diff --git a/src/proxy/Service.ts b/src/proxy/Service.ts index 82ec7d9e..2d27c785 100644 --- a/src/proxy/Service.ts +++ b/src/proxy/Service.ts @@ -271,6 +271,7 @@ function resolvePayment(endpoint: Endpoint): Record | null { name, intent, defaults: _, + html: _h, schema: _s, _canonicalRequest, ...rest diff --git a/src/server/Html.ts b/src/server/Html.ts new file mode 100644 index 00000000..9a18eaf6 --- /dev/null +++ b/src/server/Html.ts @@ -0,0 +1,142 @@ +import type * as Challenge from '../Challenge.js' + +/** Service Worker script that injects a one-shot Authorization header on the next navigation. */ +export const serviceWorkerScript = [ + 'var cred=null;', + 'self.addEventListener("install",function(){self.skipWaiting()});', + 'self.addEventListener("activate",function(e){e.waitUntil(self.clients.claim())});', + 'self.addEventListener("message",function(e){cred=e.data});', + 'self.addEventListener("fetch",function(e){', + ' if(!cred)return;', + ' var h=new Headers(e.request.headers);', + ' h.set("Authorization",cred);', + ' cred=null;', + ' e.respondWith(fetch(new Request(e.request,{headers:h})));', + '});', +].join('') + +/** Returns a Response serving the mppx service worker script. */ +export function serviceWorkerResponse(): Response { + return new Response(serviceWorkerScript, { + headers: { 'Content-Type': 'application/javascript' }, + }) +} + +/** Tagged template for syntax highlighting in editors. */ +export function html(strings: TemplateStringsArray, ...values: unknown[]): string { + return String.raw(strings, ...values) +} + +/** + * Renders a self-contained HTML payment page for a 402 challenge. + * + * The page has three sections: + * 1. **Info** — amount, description, method, realm, expiry from the challenge + * 2. **Core script** — `window.mppx` (challenge + serializeCredential) and `mppx:complete` listener + * 3. **Method HTML** — injected payment-method UI (or a fallback message) + */ +export function render(challenge: Challenge.Challenge, methodHtml?: string | undefined): string { + const challengeJson = JSON.stringify(challenge) + + return ` + + + + + Payment Required + + + +
+

Payment Required

+${challenge.description ? `

${esc(challenge.description)}

\n` : ''}\ +
+ +
+
+
${esc(JSON.stringify(challenge, null, 2))}
+
+
+ + + +${methodHtml ?? '

This payment method does not support browser payments.

'} + +` +} + +/** @internal */ +function esc(str: string): string { + return str + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') +} diff --git a/src/server/Mppx.ts b/src/server/Mppx.ts index 9884b3c4..b0252353 100644 --- a/src/server/Mppx.ts +++ b/src/server/Mppx.ts @@ -153,9 +153,10 @@ export function create< const transport extends Transport.AnyTransport = Transport.Http, >(config: create.Config): Mppx { const { + html = true, realm = Env.get('realm') ?? 'MPP Payment', secretKey = Env.get('secretKey'), - transport = Transport.http() as transport, + transport = Transport.http({ html }) as transport, } = config if (!secretKey) { @@ -233,6 +234,15 @@ export declare namespace create { methods extends Methods = Methods, transport extends Transport.AnyTransport = Transport.Http, > = { + /** + * Serve an HTML payment page to browsers (requests with `Accept: text/html`). + * + * - `true` — use the built-in payment page + * - `(challenge) => string` — custom HTML renderer + * + * Only applies when using the default HTTP transport. + */ + html?: boolean | ((challenge: Challenge.Challenge, methodHtml?: string) => string) | undefined /** Array of configured methods. @example [tempo()] */ methods: methods /** Server realm (e.g., hostname). Auto-detected from environment variables (`MPP_REALM`, `VERCEL_URL`, `RAILWAY_PUBLIC_DOMAIN`, `RENDER_EXTERNAL_HOSTNAME`, `HOST`, `HOSTNAME`), falling back to `"localhost"`. */ @@ -254,6 +264,7 @@ function createMethodFn< // biome-ignore lint/correctness/noUnusedVariables: _ function createMethodFn(parameters: createMethodFn.Parameters): createMethodFn.ReturnType { const { defaults, method, realm, respond, secretKey, transport, verify } = parameters + const methodHtml = (method as Method.AnyServer).html return (options) => { const { description, meta, ...rest } = options @@ -301,6 +312,7 @@ function createMethodFn(parameters: createMethodFn.Parameters): createMethodFn.R challenge, input, error: new Errors.MalformedCredentialError({ reason: credentialError.message }), + methodHtml, }) return { challenge: response, status: 402 } } @@ -311,6 +323,7 @@ function createMethodFn(parameters: createMethodFn.Parameters): createMethodFn.R challenge, input, error: new Errors.PaymentRequiredError({ description }), + methodHtml, }) return { challenge: response, status: 402 } } @@ -325,6 +338,7 @@ function createMethodFn(parameters: createMethodFn.Parameters): createMethodFn.R id: credential.challenge.id, reason: 'challenge was not issued by this server', }), + methodHtml, }) return { challenge: response, status: 402 } } @@ -353,6 +367,7 @@ function createMethodFn(parameters: createMethodFn.Parameters): createMethodFn.R id: credential.challenge.id, reason: `credential ${field} does not match this route's requirements`, }), + methodHtml, }) return { challenge: response, status: 402 } } @@ -385,6 +400,7 @@ function createMethodFn(parameters: createMethodFn.Parameters): createMethodFn.R id: credential.challenge.id, reason: `credential ${field} does not match this route's requirements`, }), + methodHtml, }) return { challenge: response, status: 402 } } @@ -400,6 +416,7 @@ function createMethodFn(parameters: createMethodFn.Parameters): createMethodFn.R error: new Errors.PaymentExpiredError({ expires: credential.challenge.expires, }), + methodHtml, }) return { challenge: response, status: 402 } } @@ -411,6 +428,7 @@ function createMethodFn(parameters: createMethodFn.Parameters): createMethodFn.R challenge, input, error: new Errors.InvalidPayloadError({ reason: (e as Error).message }), + methodHtml, }) return { challenge: response, status: 402 } } @@ -429,6 +447,7 @@ function createMethodFn(parameters: createMethodFn.Parameters): createMethodFn.R challenge, input, error, + methodHtml, }) return { challenge: response, status: 402 } } diff --git a/src/server/Transport.ts b/src/server/Transport.ts index 270fc1e3..431ad4da 100644 --- a/src/server/Transport.ts +++ b/src/server/Transport.ts @@ -4,6 +4,7 @@ import * as Errors from '../Errors.js' 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 './Html.js' export { type McpSdk, mcpSdk } from '../mcp-sdk/server/Transport.js' @@ -31,6 +32,7 @@ export type Transport< challenge: Challenge.Challenge error?: Errors.PaymentError | undefined input: input + methodHtml?: string | undefined }) => challengeOutput | Promise /** Attaches a receipt to a successful response. */ respondReceipt: (options: { @@ -109,7 +111,15 @@ export function from< * - Issues challenges via `WWW-Authenticate` header with 402 status * - Attaches receipts via `Payment-Receipt` header */ -export function http(): Http { +export function http(options?: http.Options): Http { + const htmlOption = options?.html + const renderHtml = + htmlOption === true + ? (challenge: Challenge.Challenge, methodHtml?: string) => Html.render(challenge, methodHtml) + : typeof htmlOption === 'function' + ? htmlOption + : undefined + return from({ name: 'http', @@ -121,14 +131,19 @@ export function http(): Http { return Credential.deserialize(payment) }, - respondChallenge({ challenge, error }) { + respondChallenge({ challenge, error, input, methodHtml }) { const headers: Record = { 'WWW-Authenticate': Challenge.serialize(challenge), 'Cache-Control': 'no-store', } + const acceptsHtml = renderHtml && input.headers.get('Accept')?.includes('text/html') + let body: string | null = null - if (error) { + if (acceptsHtml) { + headers['Content-Type'] = 'text/html; charset=utf-8' + body = renderHtml(challenge, methodHtml) + } else if (error) { headers['Content-Type'] = 'application/problem+json' body = JSON.stringify(error.toProblemDetails(challenge.id)) } @@ -207,6 +222,18 @@ export function mcp() { }) } +export declare namespace http { + type Options = { + /** + * Serve an HTML payment page to browsers (requests with `Accept: text/html`). + * + * - `true` — use the built-in payment page + * - `(challenge) => string` — custom HTML renderer + */ + html?: boolean | ((challenge: Challenge.Challenge, methodHtml?: string) => string) | undefined + } +} + /** @internal */ function mcpErrorCode(error?: Errors.PaymentError): number { if (!error) return core_Mcp.paymentRequiredCode diff --git a/src/server/index.ts b/src/server/index.ts index f87db1c4..42d64970 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -1,4 +1,5 @@ export * as Expires from '../Expires.js' +export { html, serviceWorkerResponse } from './Html.js' export * as Store from '../Store.js' export { stripe, tempo } from './Methods.js' export * as Mppx from './Mppx.js' diff --git a/src/stripe/server/Charge.ts b/src/stripe/server/Charge.ts index 51e16fd2..1a134f5e 100644 --- a/src/stripe/server/Charge.ts +++ b/src/stripe/server/Charge.ts @@ -6,6 +6,7 @@ import { } from '../../Errors.js' import type { LooseOmit, OneOf } from '../../internal/types.js' import * as Method from '../../Method.js' +import { html } from '../../server/Html.js' import type { StripeClient } from '../internal/types.js' import * as Methods from '../Methods.js' @@ -44,6 +45,7 @@ export function charge(parameters: p metadata, networkId, paymentMethodTypes, + publishableKey, } = parameters const client = 'client' in parameters ? parameters.client : undefined @@ -62,6 +64,76 @@ export function charge(parameters: p paymentMethodTypes, } as unknown as Defaults, + ...(publishableKey + ? { + html: html` +
+
+ + +
+ + + `, + } + : {}), + async verify({ credential }) { const { challenge } = credential const { request } = challenge @@ -111,6 +183,8 @@ export declare namespace charge { type Parameters = { /** Optional metadata to include in SPT creation requests. */ metadata?: Record | undefined + /** Stripe publishable key for browser-based HTML payment form. Required when using `html: true` on Mppx.create. */ + publishableKey?: string | undefined } & Defaults & OneOf< | { diff --git a/src/tempo/server/Charge.ts b/src/tempo/server/Charge.ts index 645e6965..9f407ea1 100644 --- a/src/tempo/server/Charge.ts +++ b/src/tempo/server/Charge.ts @@ -13,6 +13,7 @@ import { Abis, Transaction } from 'viem/tempo' import { PaymentExpiredError } from '../../Errors.js' import type { LooseOmit } from '../../internal/types.js' import * as Method from '../../Method.js' +import { html } from '../../server/Html.js' import * as Store from '../../Store.js' import * as Client from '../../viem/Client.js' import * as Account from '../internal/account.js' @@ -68,6 +69,161 @@ export function charge( recipient, } as unknown as Defaults, + html: html` +
+ + + `, + // TODO: dedupe `{charge,session}.request` async request({ credential, request }) { const chainId = await (async () => {