Skip to content

Commit 9536014

Browse files
authored
feat: migrate discovery payment info to offers (#384)
* feat: migrate discovery payment info to offers * fix: pin uuid to address audit failure * fix: correct changeset frontmatter
1 parent c1e3574 commit 9536014

14 files changed

Lines changed: 310 additions & 74 deletions

.changeset/tiny-dingos-type.md

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+
Added canonical discovery output using `x-payment-info.offers[]` while continuing to accept the legacy flat shorthand during validation and parsing.

pnpm-lock.yaml

Lines changed: 9 additions & 20 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pnpm-workspace.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,5 +44,6 @@ overrides:
4444
brace-expansion@<5.0.5: '>=5.0.5'
4545
lodash@<=4.17.23: '>=4.18.0'
4646
protobufjs@<7.5.5: '7.5.5'
47+
uuid@<14.0.0: '14.0.0'
4748

4849
nodeOptions: '--disable-warning=ExperimentalWarning --disable-warning=DeprecationWarning'

src/discovery/Discovery.test.ts

Lines changed: 66 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,26 @@
11
import { DiscoveryDocument, PaymentInfo, ServiceInfo } from './Discovery.js'
22

33
describe('PaymentInfo', () => {
4-
test('parses a valid charge payment info', () => {
4+
test('normalizes legacy shorthand to offers', () => {
55
const result = PaymentInfo.safeParse({
66
amount: '1000',
77
intent: 'charge',
88
method: 'tempo',
99
})
1010
expect(result.success).toBe(true)
11-
expect(result.data).toEqual({ amount: '1000', intent: 'charge', method: 'tempo' })
11+
expect(result.data).toEqual({
12+
offers: [{ amount: '1000', intent: 'charge', method: 'tempo' }],
13+
})
14+
})
15+
16+
test('parses offers format without modification', () => {
17+
const result = PaymentInfo.safeParse({
18+
offers: [{ amount: '1000', intent: 'charge', method: 'tempo' }],
19+
})
20+
expect(result.success).toBe(true)
21+
expect(result.data).toEqual({
22+
offers: [{ amount: '1000', intent: 'charge', method: 'tempo' }],
23+
})
1224
})
1325

1426
test('parses a session with null amount', () => {
@@ -18,7 +30,7 @@ describe('PaymentInfo', () => {
1830
method: 'tempo',
1931
})
2032
expect(result.success).toBe(true)
21-
expect(result.data?.amount).toBeNull()
33+
expect(result.data?.offers[0]?.amount).toBeNull()
2234
})
2335

2436
test('accepts custom intents', () => {
@@ -28,7 +40,7 @@ describe('PaymentInfo', () => {
2840
method: 'tempo',
2941
})
3042
expect(result.success).toBe(true)
31-
expect(result.data?.intent).toBe('subscribe')
43+
expect(result.data?.offers[0]?.intent).toBe('subscribe')
3244
})
3345

3446
test('rejects invalid amount pattern', () => {
@@ -40,13 +52,36 @@ describe('PaymentInfo', () => {
4052
expect(result.success).toBe(false)
4153
})
4254

55+
test('rejects mixed shorthand and offers shapes', () => {
56+
const result = PaymentInfo.safeParse({
57+
amount: '100',
58+
offers: [{ amount: '100', intent: 'charge', method: 'tempo' }],
59+
})
60+
expect(result.success).toBe(false)
61+
})
62+
63+
test('rejects empty offers arrays', () => {
64+
const result = PaymentInfo.safeParse({ offers: [] })
65+
expect(result.success).toBe(false)
66+
})
67+
68+
test('rejects malformed offers', () => {
69+
const result = PaymentInfo.safeParse({
70+
offers: [{ amount: '01', intent: 'charge', method: 'tempo' }],
71+
})
72+
expect(result.success).toBe(false)
73+
})
74+
4375
test('accepts x402 format with unknown fields', () => {
4476
const result = PaymentInfo.safeParse({
4577
price: '0.54',
4678
pricingMode: 'fixed',
4779
protocols: ['x402', 'mpp'],
4880
})
4981
expect(result.success).toBe(true)
82+
expect(result.data).toEqual({
83+
offers: [{ price: '0.54', pricingMode: 'fixed', protocols: ['x402', 'mpp'] }],
84+
})
5085
})
5186
})
5287

@@ -118,6 +153,33 @@ describe('DiscoveryDocument', () => {
118153
},
119154
})
120155
expect(result.success).toBe(true)
156+
expect(result.data?.paths?.['/search']?.post?.['x-payment-info']).toEqual({
157+
offers: [{ amount: '100', intent: 'charge', method: 'tempo' }],
158+
})
159+
})
160+
161+
test('normalizes offers-based discovery documents', () => {
162+
const result = DiscoveryDocument.safeParse({
163+
info: { title: 'Test', version: '1.0.0' },
164+
openapi: '3.1.0',
165+
paths: {
166+
'/search': {
167+
post: {
168+
'x-payment-info': {
169+
offers: [{ amount: '100', intent: 'charge', method: 'tempo' }],
170+
},
171+
responses: {
172+
'200': { description: 'OK' },
173+
'402': { description: 'Payment Required' },
174+
},
175+
},
176+
},
177+
},
178+
})
179+
expect(result.success).toBe(true)
180+
expect(result.data?.paths?.['/search']?.post?.['x-payment-info']).toEqual({
181+
offers: [{ amount: '100', intent: 'charge', method: 'tempo' }],
182+
})
121183
})
122184

123185
test('accepts path items with summary, parameters, and extensions', () => {

src/discovery/Discovery.ts

Lines changed: 40 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,13 @@
11
import * as z from '../zod.js'
22

33
const uriOrPathPattern = /^([a-zA-Z][a-zA-Z\d+.-]*:\/\/\S+|\/\S*)$/
4+
const paymentInfoFieldNames = new Set(['amount', 'currency', 'description', 'intent', 'method'])
45

56
function uriOrPath() {
67
return z.string().check(z.regex(uriOrPathPattern, 'Invalid URI or path'))
78
}
89

9-
/**
10-
* Schema for the `x-payment-info` OpenAPI extension on an operation.
11-
*
12-
* Only validates spec-defined fields when present; unknown fields are ignored.
13-
* Discovery is advisory only. Runtime 402 challenges remain authoritative.
14-
*/
15-
export const PaymentInfo = z.looseObject({
10+
const PaymentOffer = z.looseObject({
1611
amount: z.optional(
1712
z.union([z.null(), z.string().check(z.regex(/^(0|[1-9][0-9]*)$/, 'Invalid amount'))]),
1813
),
@@ -21,6 +16,44 @@ export const PaymentInfo = z.looseObject({
2116
intent: z.optional(z.string()),
2217
method: z.optional(z.string()),
2318
})
19+
20+
/**
21+
* Schema for the `x-payment-info` OpenAPI extension on an operation.
22+
*
23+
* Only validates spec-defined fields when present; unknown fields are ignored.
24+
* Discovery is advisory only. Runtime 402 challenges remain authoritative.
25+
*/
26+
export const PaymentInfo = z.pipe(
27+
z
28+
.looseObject({
29+
amount: z.optional(
30+
z.union([z.null(), z.string().check(z.regex(/^(0|[1-9][0-9]*)$/, 'Invalid amount'))]),
31+
),
32+
currency: z.optional(z.string()),
33+
description: z.optional(z.string()),
34+
intent: z.optional(z.string()),
35+
method: z.optional(z.string()),
36+
offers: z.optional(z.array(PaymentOffer).check(z.minLength(1))),
37+
})
38+
.check(
39+
z.refine(
40+
(value) =>
41+
value.offers === undefined || Object.keys(value).every((key) => key === 'offers'),
42+
'Cannot mix offers with flat payment info fields',
43+
),
44+
),
45+
z.transform((value) => {
46+
if (value.offers) return { offers: value.offers }
47+
48+
const offer: Record<string, unknown> = {}
49+
for (const [key, field] of Object.entries(value)) {
50+
if (key === 'offers') continue
51+
if (paymentInfoFieldNames.has(key) || field !== undefined) offer[key] = field
52+
}
53+
54+
return { offers: [offer] }
55+
}),
56+
)
2457
export type PaymentInfo = z.infer<typeof PaymentInfo>
2558

2659
const ServiceDocs = z.looseObject({

0 commit comments

Comments
 (0)