Skip to content

Commit 232712e

Browse files
authored
feat: allow preparing requests without payment (#897)
* feat: allow preparing requests without payment * fix: require strict request preparation option * chore: release request preparation as patch
1 parent ca7ad43 commit 232712e

4 files changed

Lines changed: 110 additions & 35 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'mppx': patch
3+
---
4+
5+
Allowed `prepareRequest` to return responses that do not require payment by default and added a strict `requirePayment` option.

src/client/Mppx.test-d.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,19 @@ describe('Mppx', () => {
7474
expectTypeOf(prepared.request).toEqualTypeOf<Request>()
7575
expectTypeOf(prepared.response).toEqualTypeOf<Response>()
7676
expectTypeOf(prepared.redirects).toEqualTypeOf<readonly Mppx.PreparedRequest.Redirect[]>()
77-
expectTypeOf(prepared.pay({ account: {} as Account })).toEqualTypeOf<Promise<Response>>()
77+
expectTypeOf(prepared.payment).toEqualTypeOf<
78+
Mppx.PreparedRequest.Payment<readonly [typeof method]> | undefined
79+
>()
80+
81+
const required = await mppx.prepareRequest('https://example.com/resource', undefined, {
82+
requirePayment: true,
83+
})
84+
expectTypeOf(required.payment.pay({ account: {} as Account })).toEqualTypeOf<
85+
Promise<Response>
86+
>()
87+
88+
// @ts-expect-error requirePayment must be passed before the result can be narrowed
89+
await mppx.prepareRequest<true>('https://example.com/resource')
7890
})
7991

8092
test('uses custom transport request and response types', async () => {

src/client/Mppx.test.ts

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -412,7 +412,7 @@ describe('prepareRequest', () => {
412412
expect(Object.isFrozen(prepared)).toBe(true)
413413
expect(Object.isFrozen(prepared.redirects)).toBe(true)
414414

415-
const response = await prepared.pay()
415+
const response = await prepared.payment!.pay()
416416

417417
expect(response.status).toBe(307)
418418
expect(requests).toHaveLength(3)
@@ -485,7 +485,7 @@ describe('prepareRequest', () => {
485485
headers: { accept: 'application/json, text/event-stream' },
486486
method: 'POST',
487487
})
488-
await prepared.pay()
488+
await prepared.payment!.pay()
489489

490490
const paidBody = JSON.parse(await requests[1]!.clone().text())
491491
expect(paidBody.params._meta[Mcp.credentialMetaKey]).toBeDefined()
@@ -575,12 +575,32 @@ describe('prepareRequest', () => {
575575
MethodChallenge.register(mppx.methods[0]!, prepare)
576576
const prepared = await mppx.prepareRequest('https://shop.example/resource')
577577

578-
await prepared.createCredential()
578+
await prepared.payment!.createCredential()
579579

580580
expect(prepare).toHaveBeenCalledOnce()
581581
expect(prepare.mock.calls[0]?.[0].input).toBeInstanceOf(Request)
582582
})
583583

584+
test('behavior: returns responses that do not require payment', async () => {
585+
const response = new Response('available', { status: 200 })
586+
const mppx = setup(vi.fn(async () => response) as typeof globalThis.fetch)
587+
588+
const prepared = await mppx.prepareRequest('https://shop.example/resource')
589+
590+
expect(prepared.response).toBe(response)
591+
expect(prepared.payment).toBeUndefined()
592+
})
593+
594+
test('error: optionally requires a payment response', async () => {
595+
const mppx = setup(
596+
vi.fn(async () => new Response('available', { status: 200 })) as typeof globalThis.fetch,
597+
)
598+
599+
await expect(
600+
mppx.prepareRequest('https://shop.example/resource', undefined, { requirePayment: true }),
601+
).rejects.toThrow('Response does not require payment.')
602+
})
603+
584604
test('error: explains opaque browser redirects', async () => {
585605
const opaqueRedirect = {
586606
headers: new Headers(),

src/client/Mppx.ts

Lines changed: 69 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -40,21 +40,32 @@ export type PreparedPayment<
4040
) => Transport.RequestOf<transport>
4141
}>
4242

43-
/** A payment challenge prepared together with the exact HTTP request that produced it. */
44-
export type PreparedRequest<methods extends readonly Method.AnyClient[]> = Readonly<
45-
PreparedPayment<methods, Transport.Transport<RequestInit, Response>> & {
46-
/** Exact request that returned the selected payment challenge. */
47-
request: Request
48-
/** Payment-required response returned for {@link request}. */
49-
response: Response
50-
/** Redirects followed before receiving the payment challenge. */
51-
redirects: readonly PreparedRequest.Redirect[]
52-
/** Creates and sends a credential to the prepared request without following redirects. */
53-
pay: (context?: AnyContextFor<methods> | undefined) => Promise<Response>
54-
}
55-
>
43+
/** An HTTP request prepared together with its response and optional payment. */
44+
export type PreparedRequest<
45+
methods extends readonly Method.AnyClient[],
46+
requirePayment extends boolean = false,
47+
> = Readonly<{
48+
/** Exact request that produced {@link response}. */
49+
request: Request
50+
/** Response returned for {@link request}. */
51+
response: Response
52+
/** Redirects followed before receiving {@link response}. */
53+
redirects: readonly PreparedRequest.Redirect[]
54+
/** Selected payment when the response requires payment. */
55+
payment: requirePayment extends true
56+
? PreparedRequest.Payment<methods>
57+
: PreparedRequest.Payment<methods> | undefined
58+
}>
5659

5760
export declare namespace PreparedRequest {
61+
/** A request-bound payment that can be inspected or paid. */
62+
type Payment<methods extends readonly Method.AnyClient[]> = Readonly<
63+
PreparedPayment<methods, Transport.Transport<RequestInit, Response>> & {
64+
/** Creates and sends a credential to the prepared request without following redirects. */
65+
pay: (context?: AnyContextFor<methods> | undefined) => Promise<Response>
66+
}
67+
>
68+
5869
/** A redirect followed while discovering a payment challenge. */
5970
type Redirect = Readonly<{
6071
from: string
@@ -96,16 +107,26 @@ export type Mppx<
96107
options?: preparePayment.Options<FlattenMethods<methods>, transport> | undefined,
97108
) => Promise<PreparedPayment<FlattenMethods<methods>, transport>>
98109
/**
99-
* Follows safe pre-payment redirects and prepares the challenge with the exact request that
100-
* produced it. Credential-bearing requests never follow redirects. Requires a runtime that
101-
* exposes manual redirect responses; browsers return opaque redirects and are not supported.
110+
* Follows safe pre-payment redirects and returns the response with its exact request. When the
111+
* response requires payment, prepares its selected challenge without creating a credential.
112+
* Credential-bearing requests never follow redirects. Requires a runtime that exposes manual
113+
* redirect responses; browsers return opaque redirects and are not supported.
102114
*/
103115
prepareRequest: transport extends Transport.Transport<RequestInit, Response>
104-
? (
105-
input: RequestInfo | URL,
106-
init?: RequestInit | undefined,
107-
options?: prepareRequest.Options<FlattenMethods<methods>> | undefined,
108-
) => Promise<PreparedRequest<FlattenMethods<methods>>>
116+
? {
117+
<const requirePayment extends boolean>(
118+
input: RequestInfo | URL,
119+
init: RequestInit | undefined,
120+
options: prepareRequest.Options<FlattenMethods<methods>, requirePayment> & {
121+
requirePayment: requirePayment
122+
},
123+
): Promise<PreparedRequest<FlattenMethods<methods>, requirePayment>>
124+
(
125+
input: RequestInfo | URL,
126+
init?: RequestInit | undefined,
127+
options?: prepareRequest.Options<FlattenMethods<methods>> | undefined,
128+
): Promise<PreparedRequest<FlattenMethods<methods>>>
129+
}
109130
: never
110131
/** Creates a credential from a payment-required response by routing to the correct method. */
111132
createCredential: (
@@ -376,9 +397,9 @@ export function create<
376397
async function prepareRequest(
377398
input: RequestInfo | URL,
378399
init?: RequestInit,
379-
options?: prepareRequest.Options<FlattenMethods<methods>>,
380-
): Promise<PreparedRequest<FlattenMethods<methods>>> {
381-
const { maxRedirects = 20, ...paymentOptions } = options ?? {}
400+
options?: prepareRequest.Options<FlattenMethods<methods>, boolean>,
401+
): Promise<PreparedRequest<FlattenMethods<methods>, boolean>> {
402+
const { maxRedirects = 20, requirePayment = false, ...paymentOptions } = options ?? {}
382403
const preparedHttp = await prepareHttpRequest({
383404
acceptPayment: acceptPayment.header,
384405
acceptPaymentPolicy,
@@ -389,8 +410,17 @@ export function create<
389410
signer: attestationSigner,
390411
})
391412
const requestInit = requestToInit(preparedHttp.replayRequest, preparedHttp.body)
392-
if (!(await transport.isPaymentRequired(preparedHttp.response as never, requestInit as never)))
393-
throw new Error('Response does not require payment.')
413+
if (
414+
!(await transport.isPaymentRequired(preparedHttp.response as never, requestInit as never))
415+
) {
416+
if (requirePayment) throw new Error('Response does not require payment.')
417+
return Object.freeze({
418+
payment: undefined,
419+
request: preparedHttp.request,
420+
response: preparedHttp.response,
421+
redirects: preparedHttp.redirects,
422+
})
423+
}
394424

395425
const payment = (await preparePayment(preparedHttp.response as never, {
396426
...paymentOptions,
@@ -412,12 +442,9 @@ export function create<
412442
return payment.createCredential(context)
413443
})
414444

415-
return Object.freeze({
445+
const preparedPayment = Object.freeze({
416446
...payment,
417447
createCredential: createRequestCredential,
418-
request: preparedHttp.request,
419-
response: preparedHttp.response,
420-
redirects: preparedHttp.redirects,
421448
async pay(context?: AnyContextFor<FlattenMethods<methods>>) {
422449
const credential = await createRequestCredential(context)
423450
const paidInit = payment.setCredential(
@@ -430,6 +457,12 @@ export function create<
430457
})
431458
},
432459
})
460+
return Object.freeze({
461+
payment: preparedPayment,
462+
request: preparedHttp.request,
463+
response: preparedHttp.response,
464+
redirects: preparedHttp.redirects,
465+
})
433466
}
434467

435468
return {
@@ -465,12 +498,17 @@ export declare namespace preparePayment {
465498

466499
export declare namespace prepareRequest {
467500
/** Options for preparing a request-bound payment. */
468-
type Options<methods extends readonly Method.AnyClient[] = readonly Method.AnyClient[]> = Omit<
501+
type Options<
502+
methods extends readonly Method.AnyClient[] = readonly Method.AnyClient[],
503+
requirePayment extends boolean = false,
504+
> = Omit<
469505
preparePayment.Options<methods, Transport.Transport<RequestInit, Response>>,
470506
'request'
471507
> & {
472508
/** Maximum redirects followed before rejecting the request. @default 20 */
473509
maxRedirects?: number | undefined
510+
/** Throw when the response does not require payment. @default false */
511+
requirePayment?: requirePayment | undefined
474512
}
475513
}
476514

0 commit comments

Comments
 (0)