Skip to content
Draft
Show file tree
Hide file tree
Changes from 2 commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
a4144b5
feat: add html payment pages for browser 402 responses
tmm Mar 23, 2026
5c6eaf9
Merge branch 'main' of https://github.com/wevm/mppx into tmm/html-pay…
tmm Mar 23, 2026
7f390e6
chore: wip
tmm Mar 25, 2026
6884389
refactor: setup
tmm Mar 25, 2026
d22a758
chore: up
tmm Mar 25, 2026
a1ad0b6
chore: tweaks
tmm Mar 25, 2026
3ea13c3
chore: up
tmm Mar 25, 2026
feed1c4
chore: up
tmm Mar 25, 2026
f572e5f
chore: renames
tmm Mar 25, 2026
4c4bd41
wip: up
tmm Mar 26, 2026
ac05e22
chore: merge main, resolve conflicts
tmm Mar 26, 2026
a56dbed
chore: merge main, resolve middleware import conflicts
tmm Mar 26, 2026
bf6d8b2
chore: up
tmm Mar 26, 2026
8572f0b
wip: public api
tmm Mar 26, 2026
651ce54
chore: merge
tmm Mar 26, 2026
9396c6b
refactor: default off
tmm Mar 26, 2026
e999bae
chore: up
tmm Mar 26, 2026
af2f30d
chore: up
tmm Mar 26, 2026
efa6daf
chore: up
tmm Mar 26, 2026
24e783f
chore: format
tmm Mar 26, 2026
d0b758e
chore: up
tmm Mar 26, 2026
ef30de2
chore: up
tmm Mar 27, 2026
be99bf2
chore: up
tmm Mar 27, 2026
97ec923
chore: up
tmm Mar 27, 2026
6796dc6
refactor: internals
tmm Mar 27, 2026
cc43d3a
revert: remove unnecessary middleware changes
tmm Mar 27, 2026
c49e551
chore: up
tmm Mar 27, 2026
0856dd9
chore: tweaks
tmm Mar 27, 2026
7deec00
chore: tweaks
tmm Mar 27, 2026
520e076
ci: fix
tmm Mar 27, 2026
fe6ccf2
chore: up
tmm Mar 27, 2026
a52d9eb
chore: up
tmm Mar 27, 2026
3b6c8e7
chore: up
tmm Mar 27, 2026
5c31648
refactor: api design
tmm Mar 27, 2026
54ab328
chore: tweaks
tmm Mar 27, 2026
93a37e5
Merge remote-tracking branch 'origin/main' into tmm/html-payment-pages
tmm Mar 27, 2026
afe8ae9
chore: audit
tmm Mar 27, 2026
1b78777
fix: hide tempo disconnect button after submit
tmm Mar 27, 2026
30da017
Merge remote-tracking branch 'origin/main' into tmm/html-payment-pages
tmm Mar 27, 2026
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
4 changes: 3 additions & 1 deletion examples/charge-wagmi/server.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -20,6 +20,8 @@ const mppx = Mppx.create({
export async function handler(request: Request): Promise<Response | null> {
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' })

Expand Down
4 changes: 3 additions & 1 deletion examples/charge/src/server.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -21,6 +21,8 @@ const mppx = Mppx.create({
export async function handler(request: Request): Promise<Response | null> {
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' })

Expand Down
6 changes: 5 additions & 1 deletion examples/stripe/src/server.ts
Original file line number Diff line number Diff line change
@@ -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!
Expand All @@ -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,
}),
],
})
Expand All @@ -23,6 +25,8 @@ const mppx = Mppx.create({
export async function handler(request: Request): Promise<Response | null> {
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 {
Expand Down
5 changes: 4 additions & 1 deletion src/Method.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ export type Server<
transportOverride = undefined,
> = method & {
defaults?: defaults | undefined
html?: string | undefined
request?: RequestFn<method> | undefined
respond?: RespondFn<method> | undefined
transport?: transportOverride | undefined
Expand Down Expand Up @@ -202,10 +203,11 @@ export function toServer<
method: method,
options: toServer.Options<method, defaults, transportOverride>,
): Server<method, defaults, transportOverride> {
const { defaults, request, respond, transport, verify } = options
const { defaults, html, request, respond, transport, verify } = options
return {
...method,
defaults,
html,
request,
respond,
transport,
Expand All @@ -220,6 +222,7 @@ export declare namespace toServer {
transportOverride extends Transport.AnyTransport | undefined = undefined,
> = {
defaults?: defaults | undefined
html?: string | undefined
request?: RequestFn<method> | undefined
respond?: RespondFn<method> | undefined
transport?: transportOverride | undefined
Expand Down
2 changes: 2 additions & 0 deletions src/middlewares/elysia.ts
Original file line number Diff line number Diff line change
@@ -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'

Expand Down Expand Up @@ -61,6 +62,7 @@ export function payment<const intent extends Mppx_internal.AnyMethodFn>(
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())
Expand Down
7 changes: 7 additions & 0 deletions src/middlewares/express.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -60,6 +61,12 @@ export function payment<const intent extends Mppx_internal.AnyMethodFn>(
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<string, string>,
Expand Down
2 changes: 2 additions & 0 deletions src/middlewares/hono.ts
Original file line number Diff line number Diff line change
@@ -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'

Expand Down Expand Up @@ -55,6 +56,7 @@ export function payment<const intent extends Mppx_internal.AnyMethodFn>(
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()
Expand Down
2 changes: 2 additions & 0 deletions src/middlewares/nextjs.ts
Original file line number Diff line number Diff line change
@@ -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'

Expand Down Expand Up @@ -58,6 +59,7 @@ export function payment<const intent extends Mppx_internal.AnyMethodFn>(
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)
Expand Down
1 change: 1 addition & 0 deletions src/proxy/Service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -271,6 +271,7 @@ function resolvePayment(endpoint: Endpoint): Record<string, unknown> | null {
name,
intent,
defaults: _,
html: _h,
schema: _s,
_canonicalRequest,
...rest
Expand Down
142 changes: 142 additions & 0 deletions src/server/Html.ts
Original file line number Diff line number Diff line change
@@ -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 `<!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>html{color-scheme:light dark}</style>
</head>
<body>
<header>
<h1>Payment Required</h1>
${challenge.description ? ` <p>${esc(challenge.description)}</p>\n` : ''}\
</header>

<main>
<section>
<pre>${esc(JSON.stringify(challenge, null, 2))}</pre>
</section>
</main>

<script>
window.mppx = Object.freeze({
challenge: ${challengeJson},

serializeCredential: function(payload, source) {
var wire = {
challenge: Object.assign({}, mppx.challenge, {
request: base64url(JSON.stringify(sortKeys(mppx.challenge.request)))
}),
payload: payload
};
if (source) wire.source = source;
return 'Payment ' + base64url(JSON.stringify(wire));
}
});

function base64url(str) {
return btoa(str).replace(/\\+/g, '-').replace(/\\//g, '_').replace(/=+$/, '');
}

function sortKeys(obj) {
var sorted = {};
Object.keys(obj).sort().forEach(function(k) {
var v = obj[k];
sorted[k] = (v && typeof v === 'object' && !Array.isArray(v)) ? sortKeys(v) : v;
});
return sorted;
}

function activateSw(reg) {
var sw = reg.installing || reg.waiting || reg.active;
return new Promise(function(resolve) {
if (sw.state === 'activated') return resolve();
sw.addEventListener('statechange', function() {
if (sw.state === 'activated') resolve();
});
});
}

addEventListener('mppx:complete', function(e) {
var statusEl = document.getElementById('status');
var authorization = e.detail;
statusEl.textContent = 'Verifying payment...';
statusEl.style.color = '';

// Try server-hosted SW, then fetch+blob fallback
navigator.serviceWorker.register('/__mppx_sw.js').then(activateSw).then(function() {
navigator.serviceWorker.controller.postMessage(authorization);
window.location.reload();
}).catch(function() {
fetch(window.location.href, {
headers: { 'Authorization': authorization }
}).then(function(res) {
if (!res.ok) {
statusEl.textContent = 'Verification failed (' + res.status + ')';
statusEl.style.color = 'red';
return;
}
return res.blob().then(function(blob) {
window.location = URL.createObjectURL(blob);
});
}).catch(function(err) {
statusEl.textContent = err.message || 'Request failed';
statusEl.style.color = 'red';
});
});
});
</script>

${methodHtml ?? ' <p>This payment method does not support browser payments.</p>'}
</body>
</html>`
}

/** @internal */
function esc(str: string): string {
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
}
21 changes: 20 additions & 1 deletion src/server/Mppx.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,9 +153,10 @@ export function create<
const transport extends Transport.AnyTransport = Transport.Http,
>(config: create.Config<methods, transport>): Mppx<methods, transport> {
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) {
Expand Down Expand Up @@ -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"`. */
Expand All @@ -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
Expand Down Expand Up @@ -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 }
}
Expand All @@ -311,6 +323,7 @@ function createMethodFn(parameters: createMethodFn.Parameters): createMethodFn.R
challenge,
input,
error: new Errors.PaymentRequiredError({ description }),
methodHtml,
})
return { challenge: response, status: 402 }
}
Expand All @@ -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 }
}
Expand Down Expand Up @@ -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 }
}
Expand Down Expand Up @@ -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 }
}
Expand All @@ -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 }
}
Expand All @@ -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 }
}
Expand All @@ -429,6 +447,7 @@ function createMethodFn(parameters: createMethodFn.Parameters): createMethodFn.R
challenge,
input,
error,
methodHtml,
})
return { challenge: response, status: 402 }
}
Expand Down
Loading
Loading