mppx 0.5.1 → 0.5.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +13 -0
- package/dist/Credential.d.ts +12 -0
- package/dist/Credential.d.ts.map +1 -1
- package/dist/Credential.js +22 -4
- package/dist/Credential.js.map +1 -1
- package/dist/Method.d.ts +4 -0
- package/dist/Method.d.ts.map +1 -1
- package/dist/Method.js +2 -1
- package/dist/Method.js.map +1 -1
- package/dist/proxy/Proxy.d.ts.map +1 -1
- package/dist/proxy/Proxy.js +52 -8
- package/dist/proxy/Proxy.js.map +1 -1
- package/dist/proxy/internal/Route.d.ts.map +1 -1
- package/dist/proxy/internal/Route.js +7 -3
- package/dist/proxy/internal/Route.js.map +1 -1
- package/dist/server/Mppx.d.ts.map +1 -1
- package/dist/server/Mppx.js +90 -71
- package/dist/server/Mppx.js.map +1 -1
- package/dist/server/Transport.d.ts +5 -1
- package/dist/server/Transport.d.ts.map +1 -1
- package/dist/server/Transport.js +52 -7
- package/dist/server/Transport.js.map +1 -1
- package/dist/server/internal/html/config.d.ts +7 -0
- package/dist/server/internal/html/config.d.ts.map +1 -0
- package/dist/server/internal/html/config.js +3 -0
- package/dist/server/internal/html/config.js.map +1 -0
- package/dist/server/internal/html/serviceWorker.gen.d.ts +2 -0
- package/dist/server/internal/html/serviceWorker.gen.d.ts.map +1 -0
- package/dist/server/internal/html/serviceWorker.gen.js +3 -0
- package/dist/server/internal/html/serviceWorker.gen.js.map +1 -0
- package/dist/stripe/server/Charge.d.ts +5 -0
- package/dist/stripe/server/Charge.d.ts.map +1 -1
- package/dist/stripe/server/Charge.js +14 -6
- package/dist/stripe/server/Charge.js.map +1 -1
- package/dist/stripe/server/internal/html.gen.d.ts +2 -0
- package/dist/stripe/server/internal/html.gen.d.ts.map +1 -0
- package/dist/stripe/server/internal/html.gen.js +3 -0
- package/dist/stripe/server/internal/html.gen.js.map +1 -0
- package/dist/tempo/server/Charge.d.ts +2 -0
- package/dist/tempo/server/Charge.d.ts.map +1 -1
- package/dist/tempo/server/Charge.js +15 -9
- package/dist/tempo/server/Charge.js.map +1 -1
- package/dist/tempo/server/Session.d.ts.map +1 -1
- package/dist/tempo/server/Session.js +3 -2
- package/dist/tempo/server/Session.js.map +1 -1
- package/dist/tempo/server/internal/html.gen.d.ts +2 -0
- package/dist/tempo/server/internal/html.gen.d.ts.map +1 -0
- package/dist/tempo/server/internal/html.gen.js +3 -0
- package/dist/tempo/server/internal/html.gen.js.map +1 -0
- package/dist/tempo/server/internal/transport.d.ts +1 -1
- package/dist/tempo/server/internal/transport.d.ts.map +1 -1
- package/dist/tempo/server/internal/transport.js +45 -58
- package/dist/tempo/server/internal/transport.js.map +1 -1
- package/package.json +2 -2
- package/src/Credential.ts +28 -4
- package/src/Method.ts +6 -1
- package/src/env.d.ts +1 -0
- package/src/mcp-sdk/server/Transport.test.ts +6 -0
- package/src/proxy/Proxy.test.ts +188 -1
- package/src/proxy/Proxy.ts +58 -9
- package/src/proxy/internal/Route.test.ts +9 -0
- package/src/proxy/internal/Route.ts +5 -2
- package/src/server/Mppx.test.ts +171 -18
- package/src/server/Mppx.ts +120 -79
- package/src/server/Transport.test.ts +16 -2
- package/src/server/Transport.ts +61 -7
- package/src/server/internal/html/config.ts +8 -0
- package/src/server/internal/html/serviceWorker.client.ts +28 -0
- package/src/server/internal/html/serviceWorker.gen.ts +2 -0
- package/src/server/internal/html/serviceWorker.ts +27 -0
- package/src/server/internal/html/tsconfig.worker.client.json +8 -0
- package/src/server/internal/html/tsconfig.worker.json +8 -0
- package/src/stripe/server/Charge.ts +19 -5
- package/src/stripe/server/internal/html/main.ts +106 -0
- package/src/stripe/server/internal/html/node_modules/.bin/mppx.src +21 -0
- package/src/stripe/server/internal/html/package.json +9 -0
- package/src/stripe/server/internal/html/stripe-js-pure.d.ts +7 -0
- package/src/stripe/server/internal/html/tsconfig.json +8 -0
- package/src/stripe/server/internal/html.gen.ts +2 -0
- package/src/tempo/server/Charge.ts +20 -8
- package/src/tempo/server/Session.ts +3 -2
- package/src/tempo/server/internal/html/main.ts +71 -0
- package/src/tempo/server/internal/html/node_modules/.bin/mppx.src +21 -0
- package/src/tempo/server/internal/html/package.json +10 -0
- package/src/tempo/server/internal/html/tsconfig.json +8 -0
- package/src/tempo/server/internal/html.gen.ts +2 -0
- package/src/tempo/server/internal/transport.test.ts +37 -31
- package/src/tempo/server/internal/transport.ts +44 -58
- package/src/tsconfig.json +1 -1
|
@@ -42,13 +42,16 @@ export function matchPath(
|
|
|
42
42
|
path: string,
|
|
43
43
|
filter?: (value: unknown) => boolean,
|
|
44
44
|
): { key: string; value: unknown } | null {
|
|
45
|
+
let match: { key: string; value: unknown } | null = null
|
|
45
46
|
for (const [key, value] of Object.entries(routes)) {
|
|
46
47
|
if (filter && !filter(value)) continue
|
|
47
48
|
const { pattern } = parseRouteKey(key)
|
|
48
49
|
const urlPattern = new URLPattern({ pathname: pattern })
|
|
49
|
-
if (urlPattern.test({ pathname: path }))
|
|
50
|
+
if (!urlPattern.test({ pathname: path })) continue
|
|
51
|
+
if (match) return null
|
|
52
|
+
match = { key, value }
|
|
50
53
|
}
|
|
51
|
-
return
|
|
54
|
+
return match
|
|
52
55
|
}
|
|
53
56
|
|
|
54
57
|
function parseRouteKey(key: string): { method: string | undefined; pattern: string } {
|
package/src/server/Mppx.test.ts
CHANGED
|
@@ -95,6 +95,36 @@ describe('request handler', () => {
|
|
|
95
95
|
`)
|
|
96
96
|
})
|
|
97
97
|
|
|
98
|
+
test('returns sanitized malformed credential error for unexpected transport failures', async () => {
|
|
99
|
+
const baseTransport = Transport.http()
|
|
100
|
+
const transport = Transport.from({
|
|
101
|
+
...baseTransport,
|
|
102
|
+
name: 'leaking-http',
|
|
103
|
+
getCredential() {
|
|
104
|
+
throw new Error('request to https://rpc.example.com/?key=secret-key failed')
|
|
105
|
+
},
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
const result = await Mppx.create({ methods: [method], realm, secretKey, transport }).charge({
|
|
109
|
+
amount: '1000',
|
|
110
|
+
currency: asset,
|
|
111
|
+
expires: new Date(Date.now() + 60_000).toISOString(),
|
|
112
|
+
recipient: accounts[0].address,
|
|
113
|
+
})(
|
|
114
|
+
new Request('https://example.com/resource', {
|
|
115
|
+
headers: { Authorization: 'Payment invalid' },
|
|
116
|
+
}),
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
expect(result.status).toBe(402)
|
|
120
|
+
if (result.status !== 402) throw new Error()
|
|
121
|
+
|
|
122
|
+
const body = (await result.challenge.json()) as { detail: string }
|
|
123
|
+
expect(body.detail).toBe('Credential is malformed.')
|
|
124
|
+
expect(body.detail).not.toContain('secret-key')
|
|
125
|
+
expect(body.detail).not.toContain('rpc.example.com')
|
|
126
|
+
})
|
|
127
|
+
|
|
98
128
|
test('returns 402 when challenge ID mismatch', async () => {
|
|
99
129
|
const wrongChallenge = Challenge.from({
|
|
100
130
|
id: 'wrong-id',
|
|
@@ -181,7 +211,7 @@ describe('request handler', () => {
|
|
|
181
211
|
expect(body.detail).toContain('does not match')
|
|
182
212
|
})
|
|
183
213
|
|
|
184
|
-
test('topUp credential
|
|
214
|
+
test('topUp credential is rejected when replayed across routes with different amounts', async () => {
|
|
185
215
|
// Use a session method whose schema defines action: 'topUp'
|
|
186
216
|
const sessionMethod = Method.from({
|
|
187
217
|
name: 'mock',
|
|
@@ -231,7 +261,7 @@ describe('request handler', () => {
|
|
|
231
261
|
payload: { action: 'topUp', token: 'valid' },
|
|
232
262
|
})
|
|
233
263
|
|
|
234
|
-
// Present it at the "expensive" route — topUp
|
|
264
|
+
// Present it at the "expensive" route — topUp must still match scope.
|
|
235
265
|
const expensiveHandle = handler['mock/session']({
|
|
236
266
|
amount: '1000000',
|
|
237
267
|
currency: asset,
|
|
@@ -244,16 +274,13 @@ describe('request handler', () => {
|
|
|
244
274
|
}),
|
|
245
275
|
)
|
|
246
276
|
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
const body = (await result.challenge.json()) as { detail?: string }
|
|
252
|
-
expect(body.detail).not.toContain('does not match')
|
|
253
|
-
}
|
|
277
|
+
expect(result.status).toBe(402)
|
|
278
|
+
if (result.status !== 402) throw new Error()
|
|
279
|
+
const body = (await result.challenge.json()) as { detail?: string }
|
|
280
|
+
expect(body.detail).toContain('does not match')
|
|
254
281
|
})
|
|
255
282
|
|
|
256
|
-
test('voucher credential
|
|
283
|
+
test('voucher credential is rejected when replayed across routes with different amounts', async () => {
|
|
257
284
|
const sessionMethod = Method.from({
|
|
258
285
|
name: 'mock',
|
|
259
286
|
intent: 'session',
|
|
@@ -307,8 +334,8 @@ describe('request handler', () => {
|
|
|
307
334
|
payload: { action: 'voucher', cumulativeAmount: '500', signature: '0xabc' },
|
|
308
335
|
})
|
|
309
336
|
|
|
310
|
-
// Present it at the same route but with a higher price — voucher
|
|
311
|
-
//
|
|
337
|
+
// Present it at the same route but with a higher price — voucher must
|
|
338
|
+
// still match the original priced scope.
|
|
312
339
|
const expensiveHandle = handler['mock/session']({
|
|
313
340
|
amount: '1000000',
|
|
314
341
|
currency: asset,
|
|
@@ -321,11 +348,10 @@ describe('request handler', () => {
|
|
|
321
348
|
}),
|
|
322
349
|
)
|
|
323
350
|
|
|
324
|
-
|
|
325
|
-
if (result.status
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
}
|
|
351
|
+
expect(result.status).toBe(402)
|
|
352
|
+
if (result.status !== 402) throw new Error()
|
|
353
|
+
const body = (await result.challenge.json()) as { detail?: string }
|
|
354
|
+
expect(body.detail).toContain('does not match')
|
|
329
355
|
})
|
|
330
356
|
|
|
331
357
|
test('rejects charge credential with injected action: topUp (cross-route bypass attempt)', async () => {
|
|
@@ -552,7 +578,63 @@ describe('request handler', () => {
|
|
|
552
578
|
"type": "https://paymentauth.org/problems/invalid-payload",
|
|
553
579
|
}
|
|
554
580
|
`)
|
|
555
|
-
expect(body.detail).
|
|
581
|
+
expect(body.detail).toBe('Credential payload is invalid.')
|
|
582
|
+
expect(body.detail).not.toContain('invalidField')
|
|
583
|
+
})
|
|
584
|
+
|
|
585
|
+
test('returns sanitized verification error for unexpected verifier failures', async () => {
|
|
586
|
+
const leakingMethod = Method.toServer(
|
|
587
|
+
Method.from({
|
|
588
|
+
name: 'mock',
|
|
589
|
+
intent: 'charge',
|
|
590
|
+
schema: {
|
|
591
|
+
credential: {
|
|
592
|
+
payload: z.object({ token: z.string() }),
|
|
593
|
+
},
|
|
594
|
+
request: z.object({
|
|
595
|
+
amount: z.string(),
|
|
596
|
+
currency: z.string(),
|
|
597
|
+
recipient: z.string(),
|
|
598
|
+
}),
|
|
599
|
+
},
|
|
600
|
+
}),
|
|
601
|
+
{
|
|
602
|
+
async verify() {
|
|
603
|
+
throw new Error('request to https://mainnet.infura.io/v3/secret-key failed')
|
|
604
|
+
},
|
|
605
|
+
},
|
|
606
|
+
)
|
|
607
|
+
|
|
608
|
+
const handle = Mppx.create({ methods: [leakingMethod], realm, secretKey })['mock/charge']({
|
|
609
|
+
amount: '1000',
|
|
610
|
+
currency: asset,
|
|
611
|
+
expires: new Date(Date.now() + 60_000).toISOString(),
|
|
612
|
+
recipient: accounts[0].address,
|
|
613
|
+
})
|
|
614
|
+
|
|
615
|
+
const firstResult = await handle(new Request('https://example.com/resource'))
|
|
616
|
+
expect(firstResult.status).toBe(402)
|
|
617
|
+
if (firstResult.status !== 402) throw new Error()
|
|
618
|
+
|
|
619
|
+
const challenge = Challenge.fromResponse(firstResult.challenge)
|
|
620
|
+
const credential = Credential.from({
|
|
621
|
+
challenge,
|
|
622
|
+
payload: { token: 'valid' },
|
|
623
|
+
})
|
|
624
|
+
|
|
625
|
+
const result = await handle(
|
|
626
|
+
new Request('https://example.com/resource', {
|
|
627
|
+
headers: { Authorization: Credential.serialize(credential) },
|
|
628
|
+
}),
|
|
629
|
+
)
|
|
630
|
+
|
|
631
|
+
expect(result.status).toBe(402)
|
|
632
|
+
if (result.status !== 402) throw new Error()
|
|
633
|
+
|
|
634
|
+
const body = (await result.challenge.json()) as { detail: string }
|
|
635
|
+
expect(body.detail).toBe('Payment verification failed.')
|
|
636
|
+
expect(body.detail).not.toContain('infura')
|
|
637
|
+
expect(body.detail).not.toContain('secret-key')
|
|
556
638
|
})
|
|
557
639
|
})
|
|
558
640
|
|
|
@@ -1724,6 +1806,77 @@ describe('cross-route credential replay via scope binding flaw', () => {
|
|
|
1724
1806
|
|
|
1725
1807
|
expect(result.status).toBe(402)
|
|
1726
1808
|
})
|
|
1809
|
+
|
|
1810
|
+
test('compose dispatch includes methodDetails memo/splits binding', async () => {
|
|
1811
|
+
const splitsMethod = Method.from({
|
|
1812
|
+
name: 'mock',
|
|
1813
|
+
intent: 'charge',
|
|
1814
|
+
schema: {
|
|
1815
|
+
credential: { payload: z.object({ token: z.string() }) },
|
|
1816
|
+
request: z.pipe(
|
|
1817
|
+
z.object({
|
|
1818
|
+
amount: z.string(),
|
|
1819
|
+
currency: z.string(),
|
|
1820
|
+
decimals: z.number(),
|
|
1821
|
+
recipient: z.string(),
|
|
1822
|
+
splits: z.optional(z.array(z.object({ amount: z.string(), recipient: z.string() }))),
|
|
1823
|
+
}),
|
|
1824
|
+
z.transform(({ amount, currency, decimals, recipient, splits }) => ({
|
|
1825
|
+
methodDetails: {
|
|
1826
|
+
amount: String(Number(amount) * 10 ** decimals),
|
|
1827
|
+
currency,
|
|
1828
|
+
recipient,
|
|
1829
|
+
...(splits && { splits }),
|
|
1830
|
+
},
|
|
1831
|
+
})),
|
|
1832
|
+
),
|
|
1833
|
+
},
|
|
1834
|
+
})
|
|
1835
|
+
|
|
1836
|
+
const splitsServerMethod = Method.toServer(splitsMethod, {
|
|
1837
|
+
async verify() {
|
|
1838
|
+
return mockReceipt()
|
|
1839
|
+
},
|
|
1840
|
+
})
|
|
1841
|
+
|
|
1842
|
+
const handler = Mppx.create({ methods: [splitsServerMethod], realm, secretKey })
|
|
1843
|
+
|
|
1844
|
+
const noSplitsHandle = handler.charge({
|
|
1845
|
+
amount: '1',
|
|
1846
|
+
currency: '0x0000000000000000000000000000000000000001',
|
|
1847
|
+
decimals: 6,
|
|
1848
|
+
expires: new Date(Date.now() + 60_000).toISOString(),
|
|
1849
|
+
recipient: '0x0000000000000000000000000000000000000002',
|
|
1850
|
+
})
|
|
1851
|
+
const splitsHandle = handler.charge({
|
|
1852
|
+
amount: '1',
|
|
1853
|
+
currency: '0x0000000000000000000000000000000000000001',
|
|
1854
|
+
decimals: 6,
|
|
1855
|
+
expires: new Date(Date.now() + 60_000).toISOString(),
|
|
1856
|
+
recipient: '0x0000000000000000000000000000000000000002',
|
|
1857
|
+
splits: [{ amount: '0.2', recipient: '0x0000000000000000000000000000000000000003' }],
|
|
1858
|
+
})
|
|
1859
|
+
|
|
1860
|
+
const composed = Mppx.compose(noSplitsHandle, splitsHandle)
|
|
1861
|
+
const firstResult = await composed(new Request('https://example.com/resource'))
|
|
1862
|
+
expect(firstResult.status).toBe(402)
|
|
1863
|
+
if (firstResult.status !== 402) throw new Error()
|
|
1864
|
+
|
|
1865
|
+
const challenges = Challenge.fromResponseList(firstResult.challenge)
|
|
1866
|
+
const noSplitsChallenge = challenges[0]!
|
|
1867
|
+
const credential = Credential.from({
|
|
1868
|
+
challenge: noSplitsChallenge,
|
|
1869
|
+
payload: { token: 'valid' },
|
|
1870
|
+
})
|
|
1871
|
+
|
|
1872
|
+
const result = await composed(
|
|
1873
|
+
new Request('https://example.com/resource', {
|
|
1874
|
+
headers: { Authorization: Credential.serialize(credential) },
|
|
1875
|
+
}),
|
|
1876
|
+
)
|
|
1877
|
+
|
|
1878
|
+
expect(result.status).toBe(200)
|
|
1879
|
+
})
|
|
1727
1880
|
})
|
|
1728
1881
|
|
|
1729
1882
|
describe('withReceipt', () => {
|
package/src/server/Mppx.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { IncomingMessage, ServerResponse } from 'node:http'
|
|
2
|
+
import { isDeepStrictEqual } from 'node:util'
|
|
2
3
|
|
|
3
4
|
import * as Challenge from '../Challenge.js'
|
|
4
5
|
import * as Credential from '../Credential.js'
|
|
@@ -300,10 +301,12 @@ function createMethodFn(parameters: createMethodFn.Parameters): createMethodFn.R
|
|
|
300
301
|
|
|
301
302
|
// Credential was provided but malformed
|
|
302
303
|
if (credentialError) {
|
|
304
|
+
const reason = getSafeCredentialReason(credentialError)
|
|
303
305
|
const response = await transport.respondChallenge({
|
|
304
306
|
challenge,
|
|
305
307
|
input,
|
|
306
|
-
error: new Errors.MalformedCredentialError({ reason:
|
|
308
|
+
error: new Errors.MalformedCredentialError(reason ? { reason } : {}),
|
|
309
|
+
html: method.html,
|
|
307
310
|
})
|
|
308
311
|
return { challenge: response, status: 402 }
|
|
309
312
|
}
|
|
@@ -314,6 +317,7 @@ function createMethodFn(parameters: createMethodFn.Parameters): createMethodFn.R
|
|
|
314
317
|
challenge,
|
|
315
318
|
input,
|
|
316
319
|
error: new Errors.PaymentRequiredError({ description }),
|
|
320
|
+
html: method.html,
|
|
317
321
|
})
|
|
318
322
|
return { challenge: response, status: 402 }
|
|
319
323
|
}
|
|
@@ -328,6 +332,7 @@ function createMethodFn(parameters: createMethodFn.Parameters): createMethodFn.R
|
|
|
328
332
|
id: credential.challenge.id,
|
|
329
333
|
reason: 'challenge was not issued by this server',
|
|
330
334
|
}),
|
|
335
|
+
html: method.html,
|
|
331
336
|
})
|
|
332
337
|
return { challenge: response, status: 402 }
|
|
333
338
|
}
|
|
@@ -339,13 +344,6 @@ function createMethodFn(parameters: createMethodFn.Parameters): createMethodFn.R
|
|
|
339
344
|
// Note: we compare specific payment parameters rather than the full
|
|
340
345
|
// request because the `request` hook may produce credential-dependent
|
|
341
346
|
// output (e.g. `feePayer` differs between 402 and credential calls).
|
|
342
|
-
//
|
|
343
|
-
// Skip this check for topUp and voucher actions: the route's
|
|
344
|
-
// `request` hook may produce a different amount because these
|
|
345
|
-
// requests carry no application body (e.g. no model field for
|
|
346
|
-
// dynamic pricing). The credential echoes a challenge obtained
|
|
347
|
-
// from the original request which had the correct amount; the
|
|
348
|
-
// on-chain voucher signature is the real validation.
|
|
349
347
|
{
|
|
350
348
|
for (const field of ['method', 'intent', 'realm'] as const) {
|
|
351
349
|
if (credential.challenge[field] !== challenge[field]) {
|
|
@@ -356,65 +354,26 @@ function createMethodFn(parameters: createMethodFn.Parameters): createMethodFn.R
|
|
|
356
354
|
id: credential.challenge.id,
|
|
357
355
|
reason: `credential ${field} does not match this route's requirements`,
|
|
358
356
|
}),
|
|
357
|
+
html: method.html,
|
|
359
358
|
})
|
|
360
359
|
return { challenge: response, status: 402 }
|
|
361
360
|
}
|
|
362
361
|
}
|
|
363
362
|
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
for (const field of ['amount', 'currency', 'recipient'] as const) {
|
|
379
|
-
const routeVal = routeReq[field] ?? routeDetails[field]
|
|
380
|
-
if (
|
|
381
|
-
routeVal !== undefined &&
|
|
382
|
-
String(routeVal) !== String(echoedReq[field] ?? echoedDetails[field])
|
|
383
|
-
) {
|
|
384
|
-
const response = await transport.respondChallenge({
|
|
385
|
-
challenge,
|
|
386
|
-
input,
|
|
387
|
-
error: new Errors.InvalidChallengeError({
|
|
388
|
-
id: credential.challenge.id,
|
|
389
|
-
reason: `credential ${field} does not match this route's requirements`,
|
|
390
|
-
}),
|
|
391
|
-
})
|
|
392
|
-
return { challenge: response, status: 402 }
|
|
393
|
-
}
|
|
394
|
-
}
|
|
395
|
-
|
|
396
|
-
// Compare payment-relevant methodDetails fields (memo, splits).
|
|
397
|
-
// These are excluded from the top-level field check above but
|
|
398
|
-
// affect verification semantics — a credential issued for a
|
|
399
|
-
// no-splits route must not be accepted on a splits route.
|
|
400
|
-
for (const field of ['memo', 'splits'] as const) {
|
|
401
|
-
const routeVal = routeDetails[field]
|
|
402
|
-
const echoedVal = echoedDetails[field]
|
|
403
|
-
if (
|
|
404
|
-
routeVal !== undefined &&
|
|
405
|
-
JSON.stringify(routeVal) !== JSON.stringify(echoedVal)
|
|
406
|
-
) {
|
|
407
|
-
const response = await transport.respondChallenge({
|
|
408
|
-
challenge,
|
|
409
|
-
input,
|
|
410
|
-
error: new Errors.InvalidChallengeError({
|
|
411
|
-
id: credential.challenge.id,
|
|
412
|
-
reason: `credential ${field} does not match this route's requirements`,
|
|
413
|
-
}),
|
|
414
|
-
})
|
|
415
|
-
return { challenge: response, status: 402 }
|
|
416
|
-
}
|
|
417
|
-
}
|
|
363
|
+
const mismatch = getRequestBindingMismatch(
|
|
364
|
+
challenge.request as Record<string, unknown>,
|
|
365
|
+
credential.challenge.request as Record<string, unknown>,
|
|
366
|
+
)
|
|
367
|
+
if (mismatch) {
|
|
368
|
+
const response = await transport.respondChallenge({
|
|
369
|
+
challenge,
|
|
370
|
+
input,
|
|
371
|
+
error: new Errors.InvalidChallengeError({
|
|
372
|
+
id: credential.challenge.id,
|
|
373
|
+
reason: `credential ${mismatch} does not match this route's requirements`,
|
|
374
|
+
}),
|
|
375
|
+
})
|
|
376
|
+
return { challenge: response, status: 402 }
|
|
418
377
|
}
|
|
419
378
|
}
|
|
420
379
|
|
|
@@ -432,11 +391,11 @@ function createMethodFn(parameters: createMethodFn.Parameters): createMethodFn.R
|
|
|
432
391
|
// Validate payload structure against method schema
|
|
433
392
|
try {
|
|
434
393
|
method.schema.credential.payload.parse(credential.payload)
|
|
435
|
-
} catch
|
|
394
|
+
} catch {
|
|
436
395
|
const response = await transport.respondChallenge({
|
|
437
396
|
challenge,
|
|
438
397
|
input,
|
|
439
|
-
error: new Errors.InvalidPayloadError(
|
|
398
|
+
error: new Errors.InvalidPayloadError(),
|
|
440
399
|
})
|
|
441
400
|
return { challenge: response, status: 402 }
|
|
442
401
|
}
|
|
@@ -447,10 +406,9 @@ function createMethodFn(parameters: createMethodFn.Parameters): createMethodFn.R
|
|
|
447
406
|
try {
|
|
448
407
|
receiptData = await verify({ credential, request } as never)
|
|
449
408
|
} catch (e) {
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
: new Errors.VerificationFailedError({ reason: (e as Error).message })
|
|
409
|
+
if (!(e instanceof Errors.PaymentError))
|
|
410
|
+
console.error('mppx: internal verification error', e)
|
|
411
|
+
const error = e instanceof Errors.PaymentError ? e : new Errors.VerificationFailedError()
|
|
454
412
|
const response = await transport.respondChallenge({
|
|
455
413
|
challenge,
|
|
456
414
|
input,
|
|
@@ -473,6 +431,8 @@ function createMethodFn(parameters: createMethodFn.Parameters): createMethodFn.R
|
|
|
473
431
|
withReceipt<response>(response?: response) {
|
|
474
432
|
if (managementResponse) {
|
|
475
433
|
return transport.respondReceipt({
|
|
434
|
+
credential,
|
|
435
|
+
input,
|
|
476
436
|
receipt: receiptData,
|
|
477
437
|
response: managementResponse as never,
|
|
478
438
|
challengeId: credential.challenge.id,
|
|
@@ -480,6 +440,8 @@ function createMethodFn(parameters: createMethodFn.Parameters): createMethodFn.R
|
|
|
480
440
|
}
|
|
481
441
|
if (!response) throw new Error('withReceipt() requires a response argument')
|
|
482
442
|
return transport.respondReceipt({
|
|
443
|
+
credential,
|
|
444
|
+
input,
|
|
483
445
|
receipt: receiptData,
|
|
484
446
|
response: response as never,
|
|
485
447
|
challengeId: credential.challenge.id,
|
|
@@ -501,6 +463,13 @@ function createMethodFn(parameters: createMethodFn.Parameters): createMethodFn.R
|
|
|
501
463
|
}
|
|
502
464
|
}
|
|
503
465
|
|
|
466
|
+
function getSafeCredentialReason(error: unknown): string | undefined {
|
|
467
|
+
if (error instanceof Credential.InvalidCredentialEncodingError) return error.message
|
|
468
|
+
if (error instanceof Credential.MissingAuthorizationHeaderError) return error.message
|
|
469
|
+
if (error instanceof Credential.MissingPaymentSchemeError) return error.message
|
|
470
|
+
return undefined
|
|
471
|
+
}
|
|
472
|
+
|
|
504
473
|
declare namespace createMethodFn {
|
|
505
474
|
type Parameters<
|
|
506
475
|
method extends Method.Method = Method.Method,
|
|
@@ -552,6 +521,88 @@ function resolveRealmFromRequest(input: unknown): string {
|
|
|
552
521
|
return defaultRealm
|
|
553
522
|
}
|
|
554
523
|
|
|
524
|
+
type RequestBindingField = 'amount' | 'currency' | 'recipient' | 'chainId' | 'memo' | 'splits'
|
|
525
|
+
|
|
526
|
+
const requestBindingFields = [
|
|
527
|
+
'amount',
|
|
528
|
+
'currency',
|
|
529
|
+
'recipient',
|
|
530
|
+
'chainId',
|
|
531
|
+
'memo',
|
|
532
|
+
'splits',
|
|
533
|
+
] as const satisfies readonly RequestBindingField[]
|
|
534
|
+
|
|
535
|
+
type RequestBinding = Partial<Record<RequestBindingField, unknown>>
|
|
536
|
+
|
|
537
|
+
function getRequestBindingMismatch(
|
|
538
|
+
expectedRequest: Record<string, unknown>,
|
|
539
|
+
actualRequest: Record<string, unknown>,
|
|
540
|
+
): RequestBindingField | undefined {
|
|
541
|
+
const expected = getRequestBinding(expectedRequest)
|
|
542
|
+
const actual = getRequestBinding(actualRequest)
|
|
543
|
+
|
|
544
|
+
return requestBindingFields.find(
|
|
545
|
+
(field) => !requestBindingValuesMatch(field, expected[field], actual[field]),
|
|
546
|
+
)
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
function getRequestBinding(request: Record<string, unknown>): RequestBinding {
|
|
550
|
+
const methodDetails = (request.methodDetails ?? {}) as Record<string, unknown>
|
|
551
|
+
|
|
552
|
+
return {
|
|
553
|
+
amount: request.amount ?? methodDetails.amount,
|
|
554
|
+
currency: request.currency ?? methodDetails.currency,
|
|
555
|
+
recipient: request.recipient ?? methodDetails.recipient,
|
|
556
|
+
chainId: request.chainId ?? methodDetails.chainId,
|
|
557
|
+
memo: methodDetails.memo,
|
|
558
|
+
splits: methodDetails.splits,
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
function requestBindingValuesMatch(
|
|
563
|
+
field: RequestBindingField,
|
|
564
|
+
expected: unknown,
|
|
565
|
+
actual: unknown,
|
|
566
|
+
): boolean {
|
|
567
|
+
return isDeepStrictEqual(
|
|
568
|
+
normalizeRequestBindingValue(field, expected),
|
|
569
|
+
normalizeRequestBindingValue(field, actual),
|
|
570
|
+
)
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
function normalizeRequestBindingValue(field: RequestBindingField, value: unknown): unknown {
|
|
574
|
+
switch (field) {
|
|
575
|
+
case 'memo':
|
|
576
|
+
return normalizeHex(value)
|
|
577
|
+
case 'splits':
|
|
578
|
+
return normalizeComparable(value)
|
|
579
|
+
default:
|
|
580
|
+
return normalizeScalar(value)
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
function normalizeScalar(value: unknown): string | undefined {
|
|
585
|
+
return value === undefined ? undefined : String(value)
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
function normalizeHex(value: unknown): unknown {
|
|
589
|
+
return typeof value === 'string' && value.startsWith('0x') ? value.toLowerCase() : value
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
function normalizeComparable(value: unknown): unknown {
|
|
593
|
+
if (Array.isArray(value)) return value.map(normalizeComparable)
|
|
594
|
+
|
|
595
|
+
if (value && typeof value === 'object') {
|
|
596
|
+
return Object.fromEntries(
|
|
597
|
+
Object.entries(value as Record<string, unknown>)
|
|
598
|
+
.sort(([left], [right]) => left.localeCompare(right))
|
|
599
|
+
.map(([key, nested]) => [key, normalizeComparable(nested)]),
|
|
600
|
+
)
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
return normalizeHex(value)
|
|
604
|
+
}
|
|
605
|
+
|
|
555
606
|
export type MethodFn<
|
|
556
607
|
method extends Method.Method,
|
|
557
608
|
transport extends Transport.AnyTransport,
|
|
@@ -668,7 +719,6 @@ export function compose(
|
|
|
668
719
|
if (credential) {
|
|
669
720
|
const { method: credMethod, intent: credIntent } = credential.challenge
|
|
670
721
|
const credReq = credential.challenge.request as Record<string, unknown>
|
|
671
|
-
const credDetails = (credReq.methodDetails ?? {}) as Record<string, unknown>
|
|
672
722
|
|
|
673
723
|
// Filter by name+intent, then narrow by comparing stable request fields
|
|
674
724
|
// from the echoed challenge against each handler's canonical request.
|
|
@@ -680,16 +730,7 @@ export function compose(
|
|
|
680
730
|
if (!meta || meta.name !== credMethod || meta.intent !== credIntent) return false
|
|
681
731
|
const canonical = meta._canonicalRequest
|
|
682
732
|
if (!canonical) return true
|
|
683
|
-
|
|
684
|
-
for (const field of ['amount', 'currency', 'recipient', 'chainId'] as const) {
|
|
685
|
-
const canonicalVal = canonical[field] ?? canonicalDetails[field]
|
|
686
|
-
if (
|
|
687
|
-
canonicalVal !== undefined &&
|
|
688
|
-
String(canonicalVal) !== String(credReq[field] ?? credDetails[field])
|
|
689
|
-
)
|
|
690
|
-
return false
|
|
691
|
-
}
|
|
692
|
-
return true
|
|
733
|
+
return !getRequestBindingMismatch(canonical, credReq)
|
|
693
734
|
})
|
|
694
735
|
|
|
695
736
|
const match =
|
|
@@ -135,6 +135,8 @@ describe('http', () => {
|
|
|
135
135
|
const originalResponse = new Response('OK', { status: 200 })
|
|
136
136
|
|
|
137
137
|
const response = transport.respondReceipt({
|
|
138
|
+
credential,
|
|
139
|
+
input: new Request('https://example.com'),
|
|
138
140
|
receipt,
|
|
139
141
|
response: originalResponse,
|
|
140
142
|
challengeId: challenge.id,
|
|
@@ -252,7 +254,13 @@ describe('mcp', () => {
|
|
|
252
254
|
}
|
|
253
255
|
|
|
254
256
|
expect(
|
|
255
|
-
transport.respondReceipt({
|
|
257
|
+
transport.respondReceipt({
|
|
258
|
+
credential,
|
|
259
|
+
input: mcpRequest,
|
|
260
|
+
receipt,
|
|
261
|
+
response: successResponse,
|
|
262
|
+
challengeId: challenge.id,
|
|
263
|
+
}),
|
|
256
264
|
).toMatchInlineSnapshot(`
|
|
257
265
|
{
|
|
258
266
|
"id": 1,
|
|
@@ -285,7 +293,13 @@ describe('mcp', () => {
|
|
|
285
293
|
}
|
|
286
294
|
|
|
287
295
|
expect(
|
|
288
|
-
transport.respondReceipt({
|
|
296
|
+
transport.respondReceipt({
|
|
297
|
+
credential,
|
|
298
|
+
input: mcpRequest,
|
|
299
|
+
receipt,
|
|
300
|
+
response: errorResponse,
|
|
301
|
+
challengeId: challenge.id,
|
|
302
|
+
}),
|
|
289
303
|
).toBe(errorResponse)
|
|
290
304
|
})
|
|
291
305
|
})
|