Skip to content
Draft
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
4 changes: 4 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,10 @@ Changeset descriptions **must use past tense** (e.g. "Added support for …", "F

The changelog is auto-generated from changesets during `changeset version`.

## JSDoc

All public interfaces — exported functions, types, classes, constants, and namespace members — **must** have JSDoc comments. Keep descriptions concise (one sentence is fine). Include `@param` / `@returns` only when the meaning isn't obvious from the name and type.

## Commands

```bash
Expand Down
1 change: 1 addition & 0 deletions examples/charge-wagmi/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ export async function handler(request: Request): Promise<Response | null> {
})(request)

if (result.status === 402) return result.challenge
if (result.status === 'pending') return result.response

const res = await fetch('https://picsum.photos/1024/1024')
const photoUrl = res.url
Expand Down
1 change: 1 addition & 0 deletions examples/charge/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ export async function handler(request: Request): Promise<Response | null> {
})(request)

if (result.status === 402) return result.challenge
if (result.status === 'pending') return result.response

const res = await fetch('https://picsum.photos/1024/1024')
const photoUrl = res.url
Expand Down
1 change: 1 addition & 0 deletions examples/session/multi-fetch/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,7 @@ export async function handler(request: Request): Promise<Response | null> {
// Return the challenge response (402 + WWW-Authenticate header) to the client.
// The client's session will automatically parse this, open a channel, and retry.
if (result.status === 402) return result.challenge
if (result.status === 'pending') return result.response

// If we get here, the credential was valid — the client paid for this request.
// Generate the content they paid for.
Expand Down
1 change: 1 addition & 0 deletions examples/session/sse/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,7 @@ export async function handler(request: Request): Promise<Response | null> {
// `result.challenge` is a Response object with the WWW-Authenticate header
// containing the base64url-encoded challenge parameters.
if (result.status === 402) return result.challenge
if (result.status === 'pending') return result.response

// Phases 2–4: `withReceipt` handles everything.
//
Expand Down
1 change: 1 addition & 0 deletions examples/session/ws/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,7 @@ export async function handler(request: Request): Promise<Response | null> {
// verified here if you choose to mix transports.
const result = await route(request)
if (result.status === 402) return result.challenge
if (result.status === 'pending') return result.response

// Once a route has already been paid over HTTP, return a simple message so
// it is obvious that WebSocket is the intended content path for this demo.
Expand Down
1 change: 1 addition & 0 deletions examples/stripe/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@ export async function handler(request: Request): Promise<Response | null> {
})(request)

if (result.status === 402) return result.challenge
if (result.status === 'pending') return result.response

const fortune = fortunes[Math.floor(Math.random() * fortunes.length)]!
return result.withReceipt(Response.json({ fortune }))
Expand Down
55 changes: 53 additions & 2 deletions src/Method.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,10 +130,12 @@ export type Server<
defaults extends ExactPartial<z.input<method['schema']['request']>> = {},
transportOverride = undefined,
> = method & {
authorize?: AuthorizeFn<method> | undefined
defaults?: defaults | undefined
html?: Html.Options | undefined
request?: RequestFn<method> | undefined
respond?: RespondFn<method> | undefined
stableBinding?: StableBindingFn<method> | undefined
transport?: transportOverride | undefined
verify: VerifyFn<method>
}
Expand All @@ -155,10 +157,55 @@ export type RequestFn<method extends Method> = (
options: RequestContext<method>,
) => MaybePromise<z.input<method['schema']['request']>>

/**
* Optional authorization hook for a server-side method.
*
* Called after request normalization but before the 402 challenge path. This lets
* a server grant access based on existing application state (for example, an
* active subscription) without requiring a fresh `Payment` credential.
*
* **HTTP-only.** The `input` parameter is a Fetch `Request`; non-HTTP transports
* do not invoke this hook.
*/
export type AuthorizeFn<method extends Method> = (parameters: {
challenge: Challenge.Challenge<
z.output<method['schema']['request']>,
method['intent'],
method['name']
>
input: globalThis.Request
request: z.output<method['schema']['request']>
}) => MaybePromise<AuthorizeResult | PendingResult | undefined>

export type AuthorizeResult = {
receipt: Receipt.Receipt
response?: globalThis.Response | undefined
}

/**
* A non-402 response returned before payment capture has fully settled.
*
* Methods can use this for async activation/capture flows that need to hand the
* client off to a processor or pollable resource before a receipt exists.
*/
export type PendingResult = {
response: globalThis.Response
}

/**
* Produces the stable request fields used to bind credentials to a route.
*
* Methods can override this to opt into additional request fields beyond the
* default amount/currency/recipient binding used by generic methods.
*/
export type StableBindingFn<method extends Method> = (
request: z.output<method['schema']['request']>,
) => Record<string, unknown>

/** Verification function for a single method. */
export type VerifyFn<method extends Method> = (
parameters: VerifyContext<method>,
) => Promise<Receipt.Receipt>
) => Promise<Receipt.Receipt | PendingResult>

/**
* Optional respond function for a server-side method.
Expand Down Expand Up @@ -251,13 +298,15 @@ export function toServer<
method: method,
options: toServer.Options<method, defaults, transportOverride>,
): Server<method, defaults, transportOverride> {
const { defaults, html, request, respond, transport, verify } = options
const { authorize, defaults, html, request, respond, stableBinding, transport, verify } = options
return {
...method,
authorize,
defaults,
html,
request,
respond,
stableBinding,
transport,
verify,
} as Server<method, defaults, transportOverride>
Expand All @@ -269,10 +318,12 @@ export declare namespace toServer {
defaults extends RequestDefaults<method> = {},
transportOverride extends Transport.AnyTransport | undefined = undefined,
> = {
authorize?: AuthorizeFn<method> | undefined
defaults?: defaults | undefined
html?: Html.Options | undefined
request?: RequestFn<method> | undefined
respond?: RespondFn<method> | undefined
stableBinding?: StableBindingFn<method> | undefined
transport?: transportOverride | undefined
verify: VerifyFn<method>
}
Expand Down
2 changes: 2 additions & 0 deletions src/Receipt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ export const Schema = z.object({
reference: z.string(),
/** Optional external reference ID echoed from the credential payload. */
externalId: z.optional(z.string()),
/** Optional server-issued subscription identifier for recurring payments. */
subscriptionId: z.optional(z.string()),
/** Payment status. Always "success" — failures use 402 + Problem Details. */
status: z.literal('success'),
/** RFC 3339 settlement timestamp. */
Expand Down
1 change: 1 addition & 0 deletions src/client/Methods.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export { stripe } from '../stripe/client/index.js'
export { subscription } from '../tempo/client/Subscription.js'
export { tempo } from '../tempo/client/index.js'
export { session } from '../tempo/client/Session.js'
1 change: 1 addition & 0 deletions src/mcp-sdk/client/McpClient.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ describe('McpClient.wrap', () => {
})(extra)

if (result.status === 402) throw result.challenge
if (result.status === 'pending') throw result.response

return result.withReceipt({
content: [{ type: 'text' as const, text: 'Premium tool executed' }],
Expand Down
8 changes: 8 additions & 0 deletions src/middlewares/express.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,14 @@ export function payment<const intent extends Mppx_internal.AnyMethodFn>(
return
}

if (result.status === 'pending') {
const response = result.response as Response
res.status(response.status)
for (const [key, value] of response.headers) res.setHeader(key, value)
res.send(await response.text())
return
}

const originalJson = res.json.bind(res)
res.json = (body: any) => {
const wrapped = result.withReceipt(Response.json(body))
Expand Down
1 change: 1 addition & 0 deletions src/middlewares/hono.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ export function payment<const intent extends Mppx_internal.AnyMethodFn>(
: c.req.raw
const result = await intent(options)(request)
if (result.status === 402) return result.challenge
if (result.status === 'pending') return result.response as Response
await next()
c.res = result.withReceipt(c.res)
}
Expand Down
1 change: 1 addition & 0 deletions src/middlewares/nextjs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ export function payment<const intent extends Mppx_internal.AnyMethodFn>(
return async (request) => {
const result = await intent(options)(request)
if (result.status === 402) return result.challenge
if (result.status === 'pending') return result.response as Response
const response = await handler(request)
return result.withReceipt(response)
}
Expand Down
1 change: 1 addition & 0 deletions src/proxy/Proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,7 @@ export function create(config: create.Config): Proxy {
getConfiguredScope(handler) ? request : Scope.attach(request, scope),
)
if (result.status === 402) return result.challenge
if (result.status === 'pending') return result.response

const managementResponse = (() => {
try {
Expand Down
1 change: 1 addition & 0 deletions src/proxy/Service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ export type IntentHandler = (input: Request) => Promise<IntentResult>
/** Result of an intent handler — either a 402 challenge or a 200 with receipt attachment. */
export type IntentResult =
| { challenge: Response; status: 402 }
| { response: Response; status: 'pending' }
| { status: 200; withReceipt: <response>(response: response) => response }

/** Context passed to `rewriteRequest`/`rewriteResponse` hooks, including any per-endpoint options. */
Expand Down
160 changes: 160 additions & 0 deletions src/server/Mppx.authorize.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
import { Challenge, Credential, Method, z } from 'mppx'
import { Mppx } from 'mppx/server'
import { describe, expect, test } from 'vp/test'

const realm = 'api.example.com'
const secretKey = 'test-secret-key'

function successReceipt(method = 'mock') {
return {
method,
reference: 'ref-1',
status: 'success',
timestamp: '2025-01-01T00:00:00.000Z',
} as const
}

describe('authorize hook', () => {
test('grants access without a Payment credential', async () => {
const method = Method.toServer(
Method.from({
name: 'mock',
intent: 'subscription',
schema: {
credential: { payload: z.object({ token: z.string() }) },
request: z.object({ amount: z.string() }),
},
}),
{
async authorize() {
return { receipt: successReceipt() }
},
async verify() {
return successReceipt()
},
},
)

const handler = Mppx.create({ methods: [method], realm, secretKey })
const result = await handler['mock/subscription']({ amount: '1' })(
new Request('https://example.com/resource'),
)

expect(result.status).toBe(200)
if (result.status !== 200) throw new Error('expected authorize success')

const response = result.withReceipt(new Response('OK'))
expect(response.headers.get('Payment-Receipt')).toBeTruthy()
})

test('compose evaluates authorize hooks sequentially on no-credential requests', async () => {
const calls: string[] = []
const createMethod = (
name: 'alpha' | 'beta',
authorizeResult?: ReturnType<typeof successReceipt>,
) =>
Method.toServer(
Method.from({
name,
intent: 'charge',
schema: {
credential: { payload: z.object({ token: z.string() }) },
request: z.object({ amount: z.string() }),
},
}),
{
async authorize() {
calls.push(`${name}:start`)
await new Promise((resolve) => setTimeout(resolve, 0))
calls.push(`${name}:end`)
return authorizeResult ? { receipt: authorizeResult } : undefined
},
async verify() {
return successReceipt(name)
},
},
)

const alpha = createMethod('alpha')
const beta = createMethod('beta', successReceipt('beta'))
const handler = Mppx.create({ methods: [alpha, beta], realm, secretKey })

const result = await handler.compose(
[alpha, { amount: '1' }],
[beta, { amount: '1' }],
)(new Request('https://example.com/resource'))

expect(result.status).toBe(200)
expect(calls).toEqual(['alpha:start', 'alpha:end', 'beta:start', 'beta:end'])
})

test('stableBinding can reject mismatched subscription routes', async () => {
const method = Method.toServer(
Method.from({
name: 'mock',
intent: 'subscription',
schema: {
credential: { payload: z.object({ token: z.string() }) },
request: z.object({
amount: z.string(),
chainId: z.optional(z.number()),
currency: z.string(),
periodSeconds: z.string(),
recipient: z.string(),
subscriptionExpires: z.string(),
}),
},
}),
{
stableBinding(request) {
return {
amount: request.amount,
chainId: request.chainId,
currency: request.currency,
periodSeconds: request.periodSeconds,
recipient: request.recipient,
subscriptionExpires: request.subscriptionExpires,
}
},
async verify() {
return successReceipt()
},
},
)

const handler = Mppx.create({ methods: [method], realm, secretKey })
const first = await handler['mock/subscription']({
amount: '1',
currency: 'usd',
periodSeconds: '30',
recipient: 'alice',
subscriptionExpires: '2026-01-01T00:00:00Z',
})(new Request('https://example.com/cheap'))

expect(first.status).toBe(402)
if (first.status !== 402) throw new Error('expected challenge')

const credential = Credential.from({
challenge: Challenge.fromResponse(first.challenge),
payload: { token: 'ok' },
})

const second = await handler['mock/subscription']({
amount: '1',
currency: 'usd',
periodSeconds: '60',
recipient: 'alice',
subscriptionExpires: '2026-01-01T00:00:00Z',
})(
new Request('https://example.com/expensive', {
headers: { Authorization: Credential.serialize(credential) },
}),
)

expect(second.status).toBe(402)
if (second.status !== 402) throw new Error('expected mismatch challenge')

const body = (await second.challenge.json()) as { detail: string }
expect(body.detail).toContain('periodSeconds')
})
})
Loading
Loading