mppx 0.5.1 → 0.5.4
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 +20 -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/cli/cli.d.ts.map +1 -1
- package/dist/cli/cli.js +11 -9
- package/dist/cli/cli.js.map +1 -1
- package/dist/cli/plugins/tempo.d.ts.map +1 -1
- package/dist/cli/plugins/tempo.js +3 -3
- package/dist/cli/plugins/tempo.js.map +1 -1
- package/dist/cli/utils.d.ts +2 -0
- package/dist/cli/utils.d.ts.map +1 -1
- package/dist/cli/utils.js +10 -5
- package/dist/cli/utils.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 +71 -7
- package/dist/server/Transport.js.map +1 -1
- package/dist/server/internal/html/config.d.ts +144 -0
- package/dist/server/internal/html/config.d.ts.map +1 -0
- package/dist/server/internal/html/config.js +303 -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/internal/types.d.ts +6 -0
- package/dist/stripe/internal/types.d.ts.map +1 -1
- package/dist/stripe/server/Charge.d.ts +30 -16
- package/dist/stripe/server/Charge.d.ts.map +1 -1
- package/dist/stripe/server/Charge.js +35 -6
- package/dist/stripe/server/Charge.js.map +1 -1
- package/dist/stripe/server/internal/html/types.d.ts +2 -0
- package/dist/stripe/server/internal/html/types.d.ts.map +1 -0
- package/dist/stripe/server/internal/html/types.js +2 -0
- package/dist/stripe/server/internal/html/types.js.map +1 -0
- 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 +33 -26
- package/dist/tempo/server/Charge.d.ts.map +1 -1
- package/dist/tempo/server/Charge.js +46 -11
- 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/cli/cli.ts +11 -8
- package/src/cli/plugins/tempo.ts +3 -2
- package/src/cli/utils.test.ts +64 -0
- package/src/cli/utils.ts +10 -4
- 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 +232 -2
- package/src/server/Transport.ts +84 -7
- package/src/server/internal/html/config.ts +414 -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/internal/types.ts +20 -0
- package/src/stripe/server/Charge.ts +62 -6
- package/src/stripe/server/internal/html/main.ts +174 -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/types.ts +5 -0
- package/src/stripe/server/internal/html.gen.ts +2 -0
- package/src/tempo/server/Charge.ts +64 -10
- package/src/tempo/server/Session.ts +3 -2
- package/src/tempo/server/internal/html/main.ts +111 -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
package/src/proxy/Proxy.test.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Receipt } from 'mppx'
|
|
1
|
+
import { Challenge, Credential, Method, Receipt, z } from 'mppx'
|
|
2
2
|
import { Mppx as Mppx_client, tempo as tempo_client } from 'mppx/client'
|
|
3
3
|
import { Mppx as Mppx_server, tempo as tempo_server } from 'mppx/server'
|
|
4
4
|
import { afterEach, describe, expect, test } from 'vp/test'
|
|
@@ -439,6 +439,193 @@ describe('create', () => {
|
|
|
439
439
|
expect(res.status).toBe(402)
|
|
440
440
|
})
|
|
441
441
|
|
|
442
|
+
test('behavior: management POST uses credential method binding to disambiguate same-path paid routes', async () => {
|
|
443
|
+
const alpha = Method.from({
|
|
444
|
+
name: 'alpha',
|
|
445
|
+
intent: 'charge',
|
|
446
|
+
schema: {
|
|
447
|
+
credential: { payload: z.object({ token: z.string() }) },
|
|
448
|
+
request: z.object({ amount: z.string() }),
|
|
449
|
+
},
|
|
450
|
+
})
|
|
451
|
+
const beta = Method.from({
|
|
452
|
+
name: 'beta',
|
|
453
|
+
intent: 'charge',
|
|
454
|
+
schema: {
|
|
455
|
+
credential: { payload: z.object({ token: z.string() }) },
|
|
456
|
+
request: z.object({ amount: z.string() }),
|
|
457
|
+
},
|
|
458
|
+
})
|
|
459
|
+
|
|
460
|
+
const handler = Mppx_server.create({
|
|
461
|
+
methods: [
|
|
462
|
+
Method.toServer(alpha, {
|
|
463
|
+
async verify() {
|
|
464
|
+
return Receipt.from({
|
|
465
|
+
method: 'alpha',
|
|
466
|
+
status: 'success',
|
|
467
|
+
timestamp: new Date().toISOString(),
|
|
468
|
+
reference: 'alpha-reference',
|
|
469
|
+
})
|
|
470
|
+
},
|
|
471
|
+
respond() {
|
|
472
|
+
return new Response(null, { status: 204 })
|
|
473
|
+
},
|
|
474
|
+
}),
|
|
475
|
+
Method.toServer(beta, {
|
|
476
|
+
async verify() {
|
|
477
|
+
return Receipt.from({
|
|
478
|
+
method: 'beta',
|
|
479
|
+
status: 'success',
|
|
480
|
+
timestamp: new Date().toISOString(),
|
|
481
|
+
reference: 'beta-reference',
|
|
482
|
+
})
|
|
483
|
+
},
|
|
484
|
+
respond() {
|
|
485
|
+
return new Response(null, { status: 205 })
|
|
486
|
+
},
|
|
487
|
+
}),
|
|
488
|
+
],
|
|
489
|
+
secretKey,
|
|
490
|
+
})
|
|
491
|
+
|
|
492
|
+
const proxy = ApiProxy.create({
|
|
493
|
+
services: [
|
|
494
|
+
Service.from('api', {
|
|
495
|
+
baseUrl: 'https://example.com',
|
|
496
|
+
routes: {
|
|
497
|
+
'GET /v1/stream': handler['alpha/charge']({ amount: '1' }),
|
|
498
|
+
'PATCH /v1/stream': handler['beta/charge']({ amount: '1' }),
|
|
499
|
+
},
|
|
500
|
+
}),
|
|
501
|
+
],
|
|
502
|
+
})
|
|
503
|
+
proxyServer = await Http.createServer(proxy.listener)
|
|
504
|
+
|
|
505
|
+
const challengeResponse = await fetch(`${proxyServer.url}/api/v1/stream`)
|
|
506
|
+
expect(challengeResponse.status).toBe(402)
|
|
507
|
+
|
|
508
|
+
const challenge = Challenge.fromResponse(challengeResponse)
|
|
509
|
+
const authorization = Credential.serialize(
|
|
510
|
+
Credential.from({
|
|
511
|
+
challenge,
|
|
512
|
+
payload: { token: 'ok' },
|
|
513
|
+
}),
|
|
514
|
+
)
|
|
515
|
+
|
|
516
|
+
const res = await fetch(`${proxyServer.url}/api/v1/stream`, {
|
|
517
|
+
method: 'POST',
|
|
518
|
+
headers: { Authorization: authorization },
|
|
519
|
+
})
|
|
520
|
+
|
|
521
|
+
expect(res.status).toBe(204)
|
|
522
|
+
})
|
|
523
|
+
|
|
524
|
+
test('behavior: exact-match management POST does not forward upstream', async () => {
|
|
525
|
+
let upstreamRequests = 0
|
|
526
|
+
upstream = await createUpstream(() => {
|
|
527
|
+
upstreamRequests += 1
|
|
528
|
+
return Response.json({ ok: true })
|
|
529
|
+
})
|
|
530
|
+
|
|
531
|
+
const method = Method.from({
|
|
532
|
+
name: 'mock',
|
|
533
|
+
intent: 'charge',
|
|
534
|
+
schema: {
|
|
535
|
+
credential: { payload: z.object({ token: z.string() }) },
|
|
536
|
+
request: z.object({ amount: z.string() }),
|
|
537
|
+
},
|
|
538
|
+
})
|
|
539
|
+
const handler = Mppx_server.create({
|
|
540
|
+
methods: [
|
|
541
|
+
Method.toServer(method, {
|
|
542
|
+
async verify() {
|
|
543
|
+
return Receipt.from({
|
|
544
|
+
method: 'mock',
|
|
545
|
+
status: 'success',
|
|
546
|
+
timestamp: new Date().toISOString(),
|
|
547
|
+
reference: 'mock-reference',
|
|
548
|
+
})
|
|
549
|
+
},
|
|
550
|
+
respond() {
|
|
551
|
+
return new Response(null, { status: 204 })
|
|
552
|
+
},
|
|
553
|
+
}),
|
|
554
|
+
],
|
|
555
|
+
secretKey,
|
|
556
|
+
})
|
|
557
|
+
|
|
558
|
+
const proxy = ApiProxy.create({
|
|
559
|
+
services: [
|
|
560
|
+
Service.from('api', {
|
|
561
|
+
baseUrl: upstream.url,
|
|
562
|
+
routes: {
|
|
563
|
+
'POST /v1/stream': handler['mock/charge']({ amount: '1' }),
|
|
564
|
+
},
|
|
565
|
+
}),
|
|
566
|
+
],
|
|
567
|
+
})
|
|
568
|
+
proxyServer = await Http.createServer(proxy.listener)
|
|
569
|
+
|
|
570
|
+
const challengeResponse = await fetch(`${proxyServer.url}/api/v1/stream`, { method: 'POST' })
|
|
571
|
+
expect(challengeResponse.status).toBe(402)
|
|
572
|
+
|
|
573
|
+
const challenge = Challenge.fromResponse(challengeResponse)
|
|
574
|
+
const authorization = Credential.serialize(
|
|
575
|
+
Credential.from({
|
|
576
|
+
challenge,
|
|
577
|
+
payload: { token: 'ok' },
|
|
578
|
+
}),
|
|
579
|
+
)
|
|
580
|
+
const res = await fetch(`${proxyServer.url}/api/v1/stream`, {
|
|
581
|
+
method: 'POST',
|
|
582
|
+
headers: { Authorization: authorization },
|
|
583
|
+
})
|
|
584
|
+
|
|
585
|
+
expect(res.status).toBe(204)
|
|
586
|
+
expect(upstreamRequests).toBe(0)
|
|
587
|
+
})
|
|
588
|
+
|
|
589
|
+
test('behavior: paid GET fallback does not forward POST upstream', async () => {
|
|
590
|
+
let upstreamRequests = 0
|
|
591
|
+
upstream = await createUpstream(async (req) => {
|
|
592
|
+
upstreamRequests += 1
|
|
593
|
+
return Response.json({
|
|
594
|
+
body: await req.json(),
|
|
595
|
+
method: req.method,
|
|
596
|
+
})
|
|
597
|
+
})
|
|
598
|
+
|
|
599
|
+
const proxy = ApiProxy.create({
|
|
600
|
+
services: [
|
|
601
|
+
Service.from('api', {
|
|
602
|
+
baseUrl: upstream.url,
|
|
603
|
+
bearer: 'sk-upstream-key',
|
|
604
|
+
routes: { 'GET /v1/messages': mppx_server.charge({ amount: '1', decimals: 6 }) },
|
|
605
|
+
}),
|
|
606
|
+
],
|
|
607
|
+
})
|
|
608
|
+
proxyServer = await Http.createServer(proxy.listener)
|
|
609
|
+
|
|
610
|
+
const challengeResponse = await fetch(`${proxyServer.url}/api/v1/messages`)
|
|
611
|
+
expect(challengeResponse.status).toBe(402)
|
|
612
|
+
|
|
613
|
+
const authorization = await mppx_client.createCredential(challengeResponse)
|
|
614
|
+
expect(Credential.extractPaymentScheme(authorization)).toBeTruthy()
|
|
615
|
+
|
|
616
|
+
const res = await fetch(`${proxyServer.url}/api/v1/messages`, {
|
|
617
|
+
method: 'POST',
|
|
618
|
+
headers: {
|
|
619
|
+
Authorization: authorization,
|
|
620
|
+
'Content-Type': 'application/json',
|
|
621
|
+
},
|
|
622
|
+
body: JSON.stringify({ prompt: 'hello' }),
|
|
623
|
+
})
|
|
624
|
+
|
|
625
|
+
expect(res.status).toBe(405)
|
|
626
|
+
expect(upstreamRequests).toBe(0)
|
|
627
|
+
})
|
|
628
|
+
|
|
442
629
|
test('behavior: POST to unregistered method does not fall back to free GET route', async () => {
|
|
443
630
|
upstream = await createUpstream(() => Response.json({ ok: true }))
|
|
444
631
|
const proxy = ApiProxy.create({
|
package/src/proxy/Proxy.ts
CHANGED
|
@@ -2,6 +2,7 @@ import type * as http from 'node:http'
|
|
|
2
2
|
|
|
3
3
|
import { createFetchProxy } from '@remix-run/fetch-proxy'
|
|
4
4
|
|
|
5
|
+
import * as Credential from '../Credential.js'
|
|
5
6
|
import { generateProxy } from '../discovery/OpenApi.js'
|
|
6
7
|
import * as Request from '../server/Request.js'
|
|
7
8
|
import * as Headers from './internal/Headers.js'
|
|
@@ -106,19 +107,26 @@ export function create(config: create.Config): Proxy {
|
|
|
106
107
|
|
|
107
108
|
const { service, proxy } = entry
|
|
108
109
|
|
|
109
|
-
const
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
110
|
+
const exactMatch = Route.match(service.routes, request.method, upstreamPath)
|
|
111
|
+
const fallbackBinding =
|
|
112
|
+
!exactMatch && request.method === 'POST' && request.headers.has('authorization')
|
|
113
|
+
? getPaymentBinding(request)
|
|
114
|
+
: null
|
|
115
|
+
const fallbackMatch =
|
|
116
|
+
!exactMatch && request.method === 'POST' && request.headers.has('authorization')
|
|
117
|
+
? // Management POSTs (e.g. session close) may target a path whose route
|
|
118
|
+
// is registered for a different HTTP method (e.g. GET). Fall back to
|
|
119
|
+
// path-only matching so the payment handler can process the action.
|
|
120
|
+
// When the credential parses cleanly, also bind on payment method+intent
|
|
121
|
+
// so same-path paid routes can coexist without sharing credentials.
|
|
122
|
+
Route.matchPath(
|
|
116
123
|
service.routes,
|
|
117
124
|
upstreamPath,
|
|
118
125
|
// skip free routes (e.g. `'GET /foo/bar': true`)
|
|
119
|
-
(endpoint) => endpoint !== true,
|
|
126
|
+
(endpoint) => endpoint !== true && matchesPaymentBinding(endpoint, fallbackBinding),
|
|
120
127
|
)
|
|
121
|
-
: null
|
|
128
|
+
: null
|
|
129
|
+
const matched = exactMatch ?? fallbackMatch
|
|
122
130
|
if (!matched) return new Response('Not Found', { status: 404 })
|
|
123
131
|
|
|
124
132
|
const endpoint = matched.value as Service.Endpoint
|
|
@@ -130,6 +138,22 @@ export function create(config: create.Config): Proxy {
|
|
|
130
138
|
const result = await handler(request)
|
|
131
139
|
if (result.status === 402) return result.challenge
|
|
132
140
|
|
|
141
|
+
const managementResponse = (() => {
|
|
142
|
+
try {
|
|
143
|
+
return (result.withReceipt as () => Response)()
|
|
144
|
+
} catch (error) {
|
|
145
|
+
if (
|
|
146
|
+
error instanceof Error &&
|
|
147
|
+
error.message === 'withReceipt() requires a response argument'
|
|
148
|
+
)
|
|
149
|
+
return null
|
|
150
|
+
throw error
|
|
151
|
+
}
|
|
152
|
+
})()
|
|
153
|
+
|
|
154
|
+
if (managementResponse) return managementResponse
|
|
155
|
+
if (fallbackMatch) return new Response('Method Not Allowed', { status: 405 })
|
|
156
|
+
|
|
133
157
|
const options = Service.getOptions(endpoint)
|
|
134
158
|
const upstreamRes = await proxyUpstream({
|
|
135
159
|
request,
|
|
@@ -246,3 +270,28 @@ function withBasePath(basePath: string | undefined, path: string) {
|
|
|
246
270
|
const trimmed = normalized.endsWith('/') ? normalized.slice(0, -1) : normalized
|
|
247
271
|
return `${trimmed}${path}`
|
|
248
272
|
}
|
|
273
|
+
|
|
274
|
+
type PaymentBinding = {
|
|
275
|
+
intent: string
|
|
276
|
+
method: string
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function getPaymentBinding(request: Request): PaymentBinding | null {
|
|
280
|
+
try {
|
|
281
|
+
const credential = Credential.fromRequest(request)
|
|
282
|
+
return {
|
|
283
|
+
intent: credential.challenge.intent,
|
|
284
|
+
method: credential.challenge.method,
|
|
285
|
+
}
|
|
286
|
+
} catch {
|
|
287
|
+
return null
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function matchesPaymentBinding(endpoint: unknown, binding: PaymentBinding | null): boolean {
|
|
292
|
+
if (endpoint === true) return false
|
|
293
|
+
if (!binding) return true
|
|
294
|
+
const payment = Service.paymentOf(endpoint as Exclude<Service.Endpoint, true>)
|
|
295
|
+
if (!payment) return true
|
|
296
|
+
return payment.method === binding.method && payment.intent === binding.intent
|
|
297
|
+
}
|
|
@@ -193,6 +193,15 @@ describe('matchPath', () => {
|
|
|
193
193
|
expect(result).toMatchObject({ key: 'POST /v1/*' })
|
|
194
194
|
})
|
|
195
195
|
|
|
196
|
+
// TODO (brendanryan): Relax this if `matchPath()` gains method-aware disambiguation.
|
|
197
|
+
test('error: returns null when multiple paid routes share the same path', () => {
|
|
198
|
+
const routes = {
|
|
199
|
+
'GET /v1/stream': { pay: () => {} },
|
|
200
|
+
'POST /v1/stream': { pay: () => {} },
|
|
201
|
+
}
|
|
202
|
+
expect(Route.matchPath(routes, '/v1/stream', paidOnly)).toBeNull()
|
|
203
|
+
})
|
|
204
|
+
|
|
196
205
|
test('error: returns null when all routes are free', () => {
|
|
197
206
|
const routes = {
|
|
198
207
|
'GET /v1/models': true,
|
|
@@ -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', () => {
|