mppx 0.6.14 → 0.6.16
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/Challenge.d.ts +7 -3
- package/dist/Challenge.d.ts.map +1 -1
- package/dist/Challenge.js +15 -9
- package/dist/Challenge.js.map +1 -1
- package/dist/Credential.d.ts +2 -2
- package/dist/Credential.d.ts.map +1 -1
- package/dist/Credential.js +31 -16
- package/dist/Credential.js.map +1 -1
- package/dist/client/Mppx.d.ts.map +1 -1
- package/dist/client/Mppx.js +3 -0
- package/dist/client/Mppx.js.map +1 -1
- package/dist/client/internal/Fetch.d.ts.map +1 -1
- package/dist/client/internal/Fetch.js +3 -0
- package/dist/client/internal/Fetch.js.map +1 -1
- package/dist/mcp-sdk/client/McpClient.d.ts.map +1 -1
- package/dist/mcp-sdk/client/McpClient.js +3 -0
- package/dist/mcp-sdk/client/McpClient.js.map +1 -1
- package/dist/server/Mppx.d.ts.map +1 -1
- package/dist/server/Mppx.js +19 -9
- package/dist/server/Mppx.js.map +1 -1
- package/dist/stripe/server/internal/html.gen.d.ts +1 -1
- package/dist/stripe/server/internal/html.gen.d.ts.map +1 -1
- package/dist/stripe/server/internal/html.gen.js +1 -1
- package/dist/stripe/server/internal/html.gen.js.map +1 -1
- package/dist/tempo/Proof.d.ts +5 -0
- package/dist/tempo/Proof.d.ts.map +1 -1
- package/dist/tempo/Proof.js +5 -1
- package/dist/tempo/Proof.js.map +1 -1
- package/dist/tempo/internal/proof.d.ts +2 -2
- package/dist/tempo/internal/proof.d.ts.map +1 -1
- package/dist/tempo/internal/proof.js +2 -2
- package/dist/tempo/internal/proof.js.map +1 -1
- package/dist/tempo/server/Charge.d.ts.map +1 -1
- package/dist/tempo/server/Charge.js +57 -17
- package/dist/tempo/server/Charge.js.map +1 -1
- package/dist/tempo/server/internal/html.gen.d.ts +1 -1
- package/dist/tempo/server/internal/html.gen.d.ts.map +1 -1
- package/dist/tempo/server/internal/html.gen.js +1 -1
- package/dist/tempo/server/internal/html.gen.js.map +1 -1
- package/package.json +4 -4
- package/src/Challenge.test.ts +25 -9
- package/src/Challenge.ts +21 -10
- package/src/Credential.test.ts +64 -2
- package/src/Credential.ts +35 -19
- package/src/client/Mppx.test.ts +39 -3
- package/src/client/Mppx.ts +2 -0
- package/src/client/internal/Fetch.test.ts +22 -3
- package/src/client/internal/Fetch.ts +2 -0
- package/src/mcp-sdk/client/McpClient.test.ts +39 -2
- package/src/mcp-sdk/client/McpClient.ts +3 -0
- package/src/middlewares/hono.test.ts +2 -2
- package/src/proxy/Proxy.test.ts +2 -2
- package/src/server/Mppx.test.ts +28 -8
- package/src/server/Mppx.ts +23 -9
- package/src/stripe/server/internal/html/package.json +1 -1
- package/src/stripe/server/internal/html.gen.ts +1 -1
- package/src/tempo/Proof.test-d.ts +4 -0
- package/src/tempo/Proof.test.ts +9 -0
- package/src/tempo/Proof.ts +6 -1
- package/src/tempo/internal/proof.test.ts +4 -4
- package/src/tempo/internal/proof.ts +2 -2
- package/src/tempo/server/Charge.test.ts +476 -0
- package/src/tempo/server/Charge.ts +61 -17
- package/src/tempo/server/internal/html/main.ts +10 -3
- package/src/tempo/server/internal/html/package.json +1 -1
- package/src/tempo/server/internal/html.gen.ts +1 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"html.gen.js","sourceRoot":"","sources":["../../../../src/tempo/server/internal/html.gen.ts"],"names":[],"mappings":"AAAA,2BAA2B;AAC3B,MAAM,CAAC,MAAM,IAAI,GAAG,
|
|
1
|
+
{"version":3,"file":"html.gen.js","sourceRoot":"","sources":["../../../../src/tempo/server/internal/html.gen.ts"],"names":[],"mappings":"AAAA,2BAA2B;AAC3B,MAAM,CAAC,MAAM,IAAI,GAAG,206tBAA206tB,CAAA"}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mppx",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "0.6.
|
|
4
|
+
"version": "0.6.16",
|
|
5
5
|
"main": "./dist/index.js",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"files": [
|
|
@@ -105,7 +105,7 @@
|
|
|
105
105
|
"@modelcontextprotocol/sdk": ">=1.25.0",
|
|
106
106
|
"elysia": ">=1",
|
|
107
107
|
"express": ">=5",
|
|
108
|
-
"hono": ">=4.12.
|
|
108
|
+
"hono": ">=4.12.16",
|
|
109
109
|
"viem": ">=2.47.5"
|
|
110
110
|
},
|
|
111
111
|
"peerDependenciesMeta": {
|
|
@@ -123,8 +123,8 @@
|
|
|
123
123
|
}
|
|
124
124
|
},
|
|
125
125
|
"dependencies": {
|
|
126
|
-
"incur": "^0.
|
|
127
|
-
"ox": "0.14.
|
|
126
|
+
"incur": "^0.4.5",
|
|
127
|
+
"ox": "0.14.20",
|
|
128
128
|
"zod": "^4.3.6"
|
|
129
129
|
},
|
|
130
130
|
"repository": {
|
package/src/Challenge.test.ts
CHANGED
|
@@ -693,7 +693,8 @@ describe('opaque', () => {
|
|
|
693
693
|
meta: { pi: 'pi_3abc123XYZ' },
|
|
694
694
|
})
|
|
695
695
|
|
|
696
|
-
expect(challenge.
|
|
696
|
+
expect(challenge.meta).toEqual({ pi: 'pi_3abc123XYZ' })
|
|
697
|
+
expect(challenge.opaque).toBe('eyJwaSI6InBpXzNhYmMxMjNYWVoifQ')
|
|
697
698
|
expect((challenge.request as Record<string, unknown>).opaque).toBeUndefined()
|
|
698
699
|
})
|
|
699
700
|
|
|
@@ -710,10 +711,11 @@ describe('opaque', () => {
|
|
|
710
711
|
meta: { payment_intent: 'pi_3abc123XYZ' },
|
|
711
712
|
})
|
|
712
713
|
|
|
713
|
-
expect(challenge.
|
|
714
|
+
expect(challenge.meta).toEqual({ payment_intent: 'pi_3abc123XYZ' })
|
|
715
|
+
expect(challenge.opaque).toBe('eyJwYXltZW50X2ludGVudCI6InBpXzNhYmMxMjNYWVoifQ')
|
|
714
716
|
})
|
|
715
717
|
|
|
716
|
-
test('behavior: challenge
|
|
718
|
+
test('behavior: challenge opaque and meta are undefined when no meta', () => {
|
|
717
719
|
const challenge = Challenge.from({
|
|
718
720
|
id: 'abc123',
|
|
719
721
|
realm: 'api.example.com',
|
|
@@ -723,9 +725,10 @@ describe('opaque', () => {
|
|
|
723
725
|
})
|
|
724
726
|
|
|
725
727
|
expect(challenge.opaque).toBeUndefined()
|
|
728
|
+
expect(challenge.meta).toBeUndefined()
|
|
726
729
|
})
|
|
727
730
|
|
|
728
|
-
test('behavior: opaque roundtrips through serialize/deserialize', () => {
|
|
731
|
+
test('behavior: opaque roundtrips through serialize/deserialize as a raw string', () => {
|
|
729
732
|
const original = Challenge.from({
|
|
730
733
|
id: 'abc123',
|
|
731
734
|
realm: 'api.example.com',
|
|
@@ -738,10 +741,21 @@ describe('opaque', () => {
|
|
|
738
741
|
const header = Challenge.serialize(original)
|
|
739
742
|
const deserialized = Challenge.deserialize(header)
|
|
740
743
|
|
|
741
|
-
expect(deserialized.opaque).
|
|
744
|
+
expect(deserialized.opaque).toBe('eyJkZXBvc2l0IjoiZGVwXzQ1NiIsInBpIjoicGlfM2FiYzEyM1hZWiJ9')
|
|
745
|
+
expect(deserialized.meta).toBeUndefined()
|
|
742
746
|
})
|
|
743
747
|
|
|
744
|
-
test('behavior:
|
|
748
|
+
test('behavior: deserialize does not parse non-json opaque', () => {
|
|
749
|
+
const header =
|
|
750
|
+
'Payment id="abc123", realm="api.example.com", method="tempo", intent="charge", request="eyJhbW91bnQiOiIxMDAwMDAwIn0", opaque="eXkgd3Jvbmc"'
|
|
751
|
+
|
|
752
|
+
const challenge = Challenge.deserialize(header)
|
|
753
|
+
|
|
754
|
+
expect(challenge.opaque).toBe('eXkgd3Jvbmc')
|
|
755
|
+
expect(challenge.meta).toBeUndefined()
|
|
756
|
+
})
|
|
757
|
+
|
|
758
|
+
test('behavior: meta with empty object produces opaque string and meta: {}', () => {
|
|
745
759
|
const challenge = Challenge.from({
|
|
746
760
|
id: 'abc123',
|
|
747
761
|
realm: 'api.example.com',
|
|
@@ -751,7 +765,8 @@ describe('opaque', () => {
|
|
|
751
765
|
meta: {},
|
|
752
766
|
})
|
|
753
767
|
|
|
754
|
-
expect(challenge.
|
|
768
|
+
expect(challenge.meta).toEqual({})
|
|
769
|
+
expect(challenge.opaque).toBe('e30')
|
|
755
770
|
})
|
|
756
771
|
|
|
757
772
|
test('hmac: opaque affects challenge ID', () => {
|
|
@@ -844,7 +859,8 @@ describe('opaque', () => {
|
|
|
844
859
|
|
|
845
860
|
const tampered = {
|
|
846
861
|
...challenge,
|
|
847
|
-
|
|
862
|
+
meta: { pi: 'pi_TAMPERED' },
|
|
863
|
+
opaque: 'eyJwaSI6InBpX1RBTVBFUkVEIn0',
|
|
848
864
|
}
|
|
849
865
|
expect(Challenge.verify(tampered, { secretKey: 'my-secret' })).toBe(false)
|
|
850
866
|
})
|
|
@@ -863,7 +879,7 @@ describe('opaque', () => {
|
|
|
863
879
|
},
|
|
864
880
|
})
|
|
865
881
|
|
|
866
|
-
expect(challenge.
|
|
882
|
+
expect(challenge.meta).toEqual({
|
|
867
883
|
payment_intent: 'pi_3abc123XYZ',
|
|
868
884
|
customer: 'cus_xyz',
|
|
869
885
|
session_id: 'sess_abc',
|
package/src/Challenge.ts
CHANGED
|
@@ -29,8 +29,10 @@ export const Schema = z.object({
|
|
|
29
29
|
intent: z.string(),
|
|
30
30
|
/** Payment method (e.g., "tempo", "stripe"). */
|
|
31
31
|
method: z.string(),
|
|
32
|
-
/** Optional server-defined correlation data.
|
|
33
|
-
|
|
32
|
+
/** Optional parsed server-defined correlation data. */
|
|
33
|
+
meta: z.optional(z.record(z.string(), z.string())),
|
|
34
|
+
/** Optional server-defined correlation data. Raw base64url string; clients MUST NOT modify. */
|
|
35
|
+
opaque: z.optional(z.string()),
|
|
34
36
|
/** Server realm (e.g., hostname). */
|
|
35
37
|
realm: z.string(),
|
|
36
38
|
/** Method-specific request data. */
|
|
@@ -128,8 +130,13 @@ export function from<
|
|
|
128
130
|
} = parameters
|
|
129
131
|
|
|
130
132
|
const expires = parameters.expires as string
|
|
133
|
+
const opaque =
|
|
134
|
+
parameters.opaque ?? (meta !== undefined ? PaymentRequest.serialize(meta) : undefined)
|
|
131
135
|
const id = secretKey
|
|
132
|
-
? computeId(
|
|
136
|
+
? computeId(
|
|
137
|
+
{ ...parameters, expires, ...(meta !== undefined && { meta }), opaque },
|
|
138
|
+
{ secretKey },
|
|
139
|
+
)
|
|
133
140
|
: (parameters as { id: string }).id
|
|
134
141
|
|
|
135
142
|
return Schema.parse({
|
|
@@ -141,7 +148,8 @@ export function from<
|
|
|
141
148
|
...(description && { description }),
|
|
142
149
|
...(digest && { digest }),
|
|
143
150
|
...(expires && { expires }),
|
|
144
|
-
...(meta && {
|
|
151
|
+
...(meta !== undefined && { meta }),
|
|
152
|
+
...(opaque !== undefined && { opaque }),
|
|
145
153
|
}) as from.ReturnType<parameters, methods>
|
|
146
154
|
}
|
|
147
155
|
|
|
@@ -170,6 +178,8 @@ export declare namespace from {
|
|
|
170
178
|
intent: string
|
|
171
179
|
/** Optional server-defined correlation data (serialized as `opaque` on the challenge). Flat string-to-string map; clients MUST NOT modify. */
|
|
172
180
|
meta?: Record<string, string> | undefined
|
|
181
|
+
/** Optional raw base64url-encoded server-defined correlation data. Clients MUST NOT modify. */
|
|
182
|
+
opaque?: string | undefined
|
|
173
183
|
/** Payment method (e.g., "tempo", "stripe"). */
|
|
174
184
|
method: string
|
|
175
185
|
/** Server realm (e.g., hostname). */
|
|
@@ -294,8 +304,9 @@ export function serialize(challenge: Challenge): string {
|
|
|
294
304
|
if (challenge.description !== undefined) parts.push(`description="${challenge.description}"`)
|
|
295
305
|
if (challenge.digest !== undefined) parts.push(`digest="${challenge.digest}"`)
|
|
296
306
|
if (challenge.expires !== undefined) parts.push(`expires="${challenge.expires}"`)
|
|
297
|
-
if (challenge.opaque !== undefined)
|
|
298
|
-
|
|
307
|
+
if (challenge.opaque !== undefined) parts.push(`opaque="${challenge.opaque}"`)
|
|
308
|
+
else if (challenge.meta !== undefined)
|
|
309
|
+
parts.push(`opaque="${PaymentRequest.serialize(challenge.meta)}"`)
|
|
299
310
|
|
|
300
311
|
return `Payment ${parts.join(', ')}`
|
|
301
312
|
}
|
|
@@ -335,7 +346,7 @@ export function deserialize<const methods extends readonly Method.Method[] | und
|
|
|
335
346
|
{
|
|
336
347
|
...rest,
|
|
337
348
|
request: PaymentRequest.deserialize(request),
|
|
338
|
-
...(opaque && {
|
|
349
|
+
...(opaque && { opaque }),
|
|
339
350
|
} as from.Parameters,
|
|
340
351
|
options,
|
|
341
352
|
)
|
|
@@ -604,9 +615,9 @@ export declare namespace verify {
|
|
|
604
615
|
}
|
|
605
616
|
}
|
|
606
617
|
|
|
607
|
-
/**
|
|
618
|
+
/** Extracts parsed server-defined correlation data from a challenge when available. */
|
|
608
619
|
export function meta(challenge: Challenge): Record<string, string> | undefined {
|
|
609
|
-
return challenge.
|
|
620
|
+
return challenge.meta
|
|
610
621
|
}
|
|
611
622
|
|
|
612
623
|
/**
|
|
@@ -631,7 +642,7 @@ function idBindingInput(challenge: Omit<Challenge, 'id'>): string {
|
|
|
631
642
|
PaymentRequest.serialize(challenge.request),
|
|
632
643
|
challenge.expires ?? '',
|
|
633
644
|
challenge.digest ?? '',
|
|
634
|
-
challenge.opaque ? PaymentRequest.serialize(challenge.
|
|
645
|
+
challenge.opaque ?? (challenge.meta ? PaymentRequest.serialize(challenge.meta) : ''),
|
|
635
646
|
].join('|')
|
|
636
647
|
}
|
|
637
648
|
|
package/src/Credential.test.ts
CHANGED
|
@@ -111,6 +111,24 @@ describe('serialize', () => {
|
|
|
111
111
|
|
|
112
112
|
expect(parsed.challenge.opaque).toBe('eyJwaSI6InBpXzNhYmMxMjNYWVoifQ')
|
|
113
113
|
})
|
|
114
|
+
|
|
115
|
+
test('behavior: echoes raw opaque unchanged', () => {
|
|
116
|
+
const rawChallenge = Challenge.deserialize(
|
|
117
|
+
'Payment id="opaque123", realm="api.example.com", method="tempo", intent="charge", request="eyJhbW91bnQiOiIxMDAwIn0", opaque="eXkgd3Jvbmc"',
|
|
118
|
+
)
|
|
119
|
+
const credential = Credential.from({
|
|
120
|
+
challenge: rawChallenge,
|
|
121
|
+
payload: { signature: '0x1234' },
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
const header = Credential.serialize(credential)
|
|
125
|
+
const encoded = header.replace(/^Payment\s+/i, '')
|
|
126
|
+
const parsed = JSON.parse(Base64.toString(encoded)) as {
|
|
127
|
+
challenge: { opaque?: unknown }
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
expect(parsed.challenge.opaque).toBe('eXkgd3Jvbmc')
|
|
131
|
+
})
|
|
114
132
|
})
|
|
115
133
|
|
|
116
134
|
describe('deserialize', () => {
|
|
@@ -175,7 +193,31 @@ describe('deserialize', () => {
|
|
|
175
193
|
|
|
176
194
|
const credential = Credential.deserialize(`Payment ${encoded}`)
|
|
177
195
|
|
|
178
|
-
expect(credential.challenge.opaque).
|
|
196
|
+
expect(credential.challenge.opaque).toBe('eyJwaSI6InBpXzNhYmMxMjNYWVoifQ')
|
|
197
|
+
expect(credential.challenge.meta).toBeUndefined()
|
|
198
|
+
expect(credential.challenge.request).toEqual({ amount: '1000' })
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
test('behavior: preserves non-json opaque string credentials', () => {
|
|
202
|
+
const encoded = Base64.fromString(
|
|
203
|
+
JSON.stringify({
|
|
204
|
+
challenge: {
|
|
205
|
+
id: 'opaque123',
|
|
206
|
+
intent: 'charge',
|
|
207
|
+
method: 'tempo',
|
|
208
|
+
opaque: 'eXkgd3Jvbmc',
|
|
209
|
+
realm: 'api.example.com',
|
|
210
|
+
request: 'eyJhbW91bnQiOiIxMDAwIn0',
|
|
211
|
+
},
|
|
212
|
+
payload: { signature: '0x1234' },
|
|
213
|
+
}),
|
|
214
|
+
{ pad: false, url: true },
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
const credential = Credential.deserialize(`Payment ${encoded}`)
|
|
218
|
+
|
|
219
|
+
expect(credential.challenge.opaque).toBe('eXkgd3Jvbmc')
|
|
220
|
+
expect(credential.challenge.meta).toBeUndefined()
|
|
179
221
|
expect(credential.challenge.request).toEqual({ amount: '1000' })
|
|
180
222
|
})
|
|
181
223
|
|
|
@@ -197,7 +239,27 @@ describe('deserialize', () => {
|
|
|
197
239
|
|
|
198
240
|
const credential = Credential.deserialize(`Payment ${encoded}`)
|
|
199
241
|
|
|
200
|
-
expect(credential.challenge.opaque).
|
|
242
|
+
expect(credential.challenge.opaque).toBe('eyJwaSI6InBpXzNhYmMxMjNYWVoifQ')
|
|
243
|
+
expect(credential.challenge.meta).toEqual({ pi: 'pi_3abc123XYZ' })
|
|
244
|
+
})
|
|
245
|
+
|
|
246
|
+
test('error: rejects legacy object-shaped opaque with non-string values', () => {
|
|
247
|
+
const encoded = Base64.fromString(
|
|
248
|
+
JSON.stringify({
|
|
249
|
+
challenge: {
|
|
250
|
+
id: 'opaque123',
|
|
251
|
+
intent: 'charge',
|
|
252
|
+
method: 'tempo',
|
|
253
|
+
opaque: { pi: 123 },
|
|
254
|
+
realm: 'api.example.com',
|
|
255
|
+
request: 'eyJhbW91bnQiOiIxMDAwIn0',
|
|
256
|
+
},
|
|
257
|
+
payload: { signature: '0x1234' },
|
|
258
|
+
}),
|
|
259
|
+
{ pad: false, url: true },
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
expect(() => Credential.deserialize(`Payment ${encoded}`)).toThrow('Invalid base64url or JSON.')
|
|
201
263
|
})
|
|
202
264
|
|
|
203
265
|
test('error: throws for missing Payment scheme', () => {
|
package/src/Credential.ts
CHANGED
|
@@ -63,27 +63,20 @@ export function deserialize<payload = unknown>(value: string): Credential<payloa
|
|
|
63
63
|
try {
|
|
64
64
|
const json = Base64.toString(prefixMatch[1])
|
|
65
65
|
const parsed = JSON.parse(json) as {
|
|
66
|
-
challenge: Omit<Challenge.Challenge, 'opaque' | 'request'> & {
|
|
67
|
-
opaque?:
|
|
66
|
+
challenge: Omit<Challenge.Challenge, 'meta' | 'opaque' | 'request'> & {
|
|
67
|
+
opaque?: unknown
|
|
68
68
|
request: string
|
|
69
69
|
}
|
|
70
70
|
payload: payload
|
|
71
71
|
source?: string
|
|
72
72
|
}
|
|
73
|
+
const { opaque: challengeOpaque, request, ...challengeFields } = parsed.challenge
|
|
74
|
+
const { meta, opaque } = normalizeCredentialOpaque(challengeOpaque)
|
|
73
75
|
const challenge = Challenge.Schema.parse({
|
|
74
|
-
...
|
|
75
|
-
...(
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
// `opaque` as an expanded JSON object in credentials, but the Payment
|
|
79
|
-
// auth spec requires clients to return the original base64url string
|
|
80
|
-
// unchanged in the credential challenge object.
|
|
81
|
-
opaque:
|
|
82
|
-
typeof parsed.challenge.opaque === 'string'
|
|
83
|
-
? (PaymentRequest.deserialize(parsed.challenge.opaque) as Record<string, string>)
|
|
84
|
-
: parsed.challenge.opaque,
|
|
85
|
-
}),
|
|
86
|
-
request: PaymentRequest.deserialize(parsed.challenge.request),
|
|
76
|
+
...challengeFields,
|
|
77
|
+
...(meta !== undefined && { meta }),
|
|
78
|
+
...(opaque !== undefined && { opaque }),
|
|
79
|
+
request: PaymentRequest.deserialize(request),
|
|
87
80
|
})
|
|
88
81
|
return {
|
|
89
82
|
challenge,
|
|
@@ -95,6 +88,28 @@ export function deserialize<payload = unknown>(value: string): Credential<payloa
|
|
|
95
88
|
}
|
|
96
89
|
}
|
|
97
90
|
|
|
91
|
+
function normalizeCredentialOpaque(value: unknown): {
|
|
92
|
+
meta?: Record<string, string> | undefined
|
|
93
|
+
opaque?: string | undefined
|
|
94
|
+
} {
|
|
95
|
+
if (value === undefined) return {}
|
|
96
|
+
if (typeof value === 'string') return { opaque: value }
|
|
97
|
+
if (!isStringRecord(value)) throw new Error('Invalid opaque.')
|
|
98
|
+
|
|
99
|
+
// Older mppx clients emitted credential challenge `opaque` as an expanded
|
|
100
|
+
// object. Keep accepting that legacy shape, but normalize it back to the
|
|
101
|
+
// spec-required base64url string.
|
|
102
|
+
return {
|
|
103
|
+
meta: value,
|
|
104
|
+
opaque: PaymentRequest.serialize(value),
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function isStringRecord(value: unknown): value is Record<string, string> {
|
|
109
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) return false
|
|
110
|
+
return Object.values(value).every((entry) => typeof entry === 'string')
|
|
111
|
+
}
|
|
112
|
+
|
|
98
113
|
/**
|
|
99
114
|
* Creates a credential from the given parameters.
|
|
100
115
|
*
|
|
@@ -156,8 +171,8 @@ export function fromRequest<payload = unknown>(request: Request): Credential<pay
|
|
|
156
171
|
|
|
157
172
|
/**
|
|
158
173
|
* Serializes a credential to the Authorization header format.
|
|
159
|
-
* When present, `challenge.opaque` is
|
|
160
|
-
* by the Payment auth credential format.
|
|
174
|
+
* When present, `challenge.opaque` is emitted unchanged as the base64url string
|
|
175
|
+
* required by the Payment auth credential format.
|
|
161
176
|
*
|
|
162
177
|
* @param credential - The credential to serialize.
|
|
163
178
|
* @returns A string suitable for the Authorization header value.
|
|
@@ -171,11 +186,12 @@ export function fromRequest<payload = unknown>(request: Request): Credential<pay
|
|
|
171
186
|
* ```
|
|
172
187
|
*/
|
|
173
188
|
export function serialize(credential: Credential): string {
|
|
174
|
-
const { opaque, request, ...challenge } = credential.challenge
|
|
189
|
+
const { meta, opaque, request, ...challenge } = credential.challenge
|
|
190
|
+
const wireOpaque = opaque ?? (meta !== undefined ? PaymentRequest.serialize(meta) : undefined)
|
|
175
191
|
const wire = {
|
|
176
192
|
challenge: {
|
|
177
193
|
...challenge,
|
|
178
|
-
...(
|
|
194
|
+
...(wireOpaque !== undefined && { opaque: wireOpaque }),
|
|
179
195
|
request: PaymentRequest.serialize(request),
|
|
180
196
|
},
|
|
181
197
|
payload: credential.payload,
|
package/src/client/Mppx.test.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import { Challenge, Credential, Mcp, Method, Receipt } from 'mppx'
|
|
1
|
+
import { Challenge, Credential, Errors, Mcp, Method, Receipt } from 'mppx'
|
|
2
2
|
import { Mppx, Transport, tempo } from 'mppx/client'
|
|
3
3
|
import { Mppx as Mppx_server, tempo as tempo_server } from 'mppx/server'
|
|
4
4
|
import { Methods } from 'mppx/tempo'
|
|
5
|
-
import { afterEach, describe, expect, test } from 'vp/test'
|
|
5
|
+
import { afterEach, describe, expect, test, vi } from 'vp/test'
|
|
6
6
|
import * as Http from '~test/Http.js'
|
|
7
7
|
import { accounts, asset, client } from '~test/tempo/viem.js'
|
|
8
8
|
|
|
@@ -134,6 +134,42 @@ describe('createCredential', () => {
|
|
|
134
134
|
)
|
|
135
135
|
})
|
|
136
136
|
|
|
137
|
+
test('behavior: rejects expired challenges before creating credential', async () => {
|
|
138
|
+
const createCredential = vi.fn(async ({ challenge }) =>
|
|
139
|
+
Credential.serialize({
|
|
140
|
+
challenge,
|
|
141
|
+
payload: { signature: '0xsignature', type: 'transaction' },
|
|
142
|
+
}),
|
|
143
|
+
)
|
|
144
|
+
const method = Method.toClient(Methods.charge, { createCredential })
|
|
145
|
+
const mppx = Mppx.create({
|
|
146
|
+
polyfill: false,
|
|
147
|
+
methods: [method],
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
const challenge = Challenge.fromMethod(Methods.charge, {
|
|
151
|
+
realm,
|
|
152
|
+
secretKey,
|
|
153
|
+
expires: new Date(Date.now() - 60_000).toISOString(),
|
|
154
|
+
request: {
|
|
155
|
+
amount: '1000',
|
|
156
|
+
currency: '0x1234567890123456789012345678901234567890',
|
|
157
|
+
decimals: 6,
|
|
158
|
+
recipient: '0x1234567890123456789012345678901234567890',
|
|
159
|
+
},
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
const response = new Response(null, {
|
|
163
|
+
status: 402,
|
|
164
|
+
headers: {
|
|
165
|
+
'WWW-Authenticate': Challenge.serialize(challenge),
|
|
166
|
+
},
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
await expect(mppx.createCredential(response)).rejects.toThrow(Errors.PaymentExpiredError)
|
|
170
|
+
expect(createCredential).not.toHaveBeenCalled()
|
|
171
|
+
})
|
|
172
|
+
|
|
137
173
|
test('behavior: routes to correct method with multiple methods', async () => {
|
|
138
174
|
const stripeCharge = Method.from({
|
|
139
175
|
name: 'stripe',
|
|
@@ -165,7 +201,6 @@ describe('createCredential', () => {
|
|
|
165
201
|
realm,
|
|
166
202
|
method: 'stripe',
|
|
167
203
|
intent: 'charge',
|
|
168
|
-
expires: new Date(Date.now() + 60_000).toISOString(),
|
|
169
204
|
request: {
|
|
170
205
|
amount: '2000',
|
|
171
206
|
currency: '0xabcd',
|
|
@@ -294,6 +329,7 @@ describe('createCredential', () => {
|
|
|
294
329
|
realm,
|
|
295
330
|
method: 'stripe',
|
|
296
331
|
intent: 'charge',
|
|
332
|
+
expires: new Date(Date.now() + 60_000).toISOString(),
|
|
297
333
|
request: {
|
|
298
334
|
amount: '2000',
|
|
299
335
|
currency: '0xabcd',
|
package/src/client/Mppx.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type * as Challenge from '../Challenge.js'
|
|
2
|
+
import * as Expires from '../Expires.js'
|
|
2
3
|
import * as AcceptPayment from '../internal/AcceptPayment.js'
|
|
3
4
|
import type * as Method from '../Method.js'
|
|
4
5
|
import type * as z from '../zod.js'
|
|
@@ -106,6 +107,7 @@ export function create<
|
|
|
106
107
|
)
|
|
107
108
|
|
|
108
109
|
const { challenge, method: mi } = selected
|
|
110
|
+
if (challenge.expires) Expires.assert(challenge.expires, challenge.id)
|
|
109
111
|
|
|
110
112
|
const parsedContext =
|
|
111
113
|
mi.context && context !== undefined ? mi.context.parse(context) : undefined
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Receipt } from 'mppx'
|
|
1
|
+
import { Errors, Receipt } from 'mppx'
|
|
2
2
|
import { tempo } from 'mppx/client'
|
|
3
3
|
import { Mppx as Mppx_server, tempo as tempo_server } from 'mppx/server'
|
|
4
4
|
import { createClient, defineChain } from 'viem'
|
|
@@ -335,14 +335,15 @@ const noopMethod = {
|
|
|
335
335
|
} as any
|
|
336
336
|
|
|
337
337
|
/** Builds a valid 402 response with a WWW-Authenticate header. */
|
|
338
|
-
function make402(overrides?: {
|
|
338
|
+
function make402(overrides?: { expires?: string; intent?: string; method?: string }) {
|
|
339
339
|
const method = overrides?.method ?? 'test'
|
|
340
340
|
const intent = overrides?.intent ?? 'test'
|
|
341
|
+
const expires = overrides?.expires ? `, expires="${overrides.expires}"` : ''
|
|
341
342
|
const request = btoa(JSON.stringify({ amount: '1' }))
|
|
342
343
|
.replace(/\+/g, '-')
|
|
343
344
|
.replace(/\//g, '_')
|
|
344
345
|
.replace(/=+$/, '')
|
|
345
|
-
const header = `Payment id="abc", realm="test", method="${method}", intent="${intent}", request="${request}"`
|
|
346
|
+
const header = `Payment id="abc", realm="test", method="${method}", intent="${intent}", request="${request}"${expires}`
|
|
346
347
|
return new Response(null, {
|
|
347
348
|
status: 402,
|
|
348
349
|
headers: { 'WWW-Authenticate': header },
|
|
@@ -603,6 +604,24 @@ describe('Fetch.from: init passthrough (non-402)', () => {
|
|
|
603
604
|
})
|
|
604
605
|
|
|
605
606
|
describe('Fetch.from: 402 retry path', () => {
|
|
607
|
+
test('rejects expired challenges before hooks or credential creation', async () => {
|
|
608
|
+
const createCredential = vi.fn(async () => 'credential')
|
|
609
|
+
const onChallenge = vi.fn(async () => undefined)
|
|
610
|
+
const mockFetch = vi.fn(async () =>
|
|
611
|
+
make402({ expires: new Date(Date.now() - 60_000).toISOString() }),
|
|
612
|
+
)
|
|
613
|
+
const fetch = Fetch.from({
|
|
614
|
+
fetch: mockFetch as typeof globalThis.fetch,
|
|
615
|
+
methods: [{ ...noopMethod, createCredential }],
|
|
616
|
+
onChallenge,
|
|
617
|
+
})
|
|
618
|
+
|
|
619
|
+
await expect(fetch('https://example.com/api')).rejects.toThrow(Errors.PaymentExpiredError)
|
|
620
|
+
expect(onChallenge).not.toHaveBeenCalled()
|
|
621
|
+
expect(createCredential).not.toHaveBeenCalled()
|
|
622
|
+
expect(mockFetch).toHaveBeenCalledTimes(1)
|
|
623
|
+
})
|
|
624
|
+
|
|
606
625
|
test('strips context from init on retry', async () => {
|
|
607
626
|
const calls: { init: RequestInit | undefined }[] = []
|
|
608
627
|
let callCount = 0
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import * as Challenge from '../../Challenge.js'
|
|
2
|
+
import * as Expires from '../../Expires.js'
|
|
2
3
|
import * as AcceptPayment from '../../internal/AcceptPayment.js'
|
|
3
4
|
import type * as Method from '../../Method.js'
|
|
4
5
|
import type * as z from '../../zod.js'
|
|
@@ -80,6 +81,7 @@ export function from<const methods extends readonly Method.AnyClient[]>(
|
|
|
80
81
|
)
|
|
81
82
|
|
|
82
83
|
const { challenge, method: mi } = selected
|
|
84
|
+
if (challenge.expires) Expires.assert(challenge.expires, challenge.id)
|
|
83
85
|
|
|
84
86
|
const onChallengeCredential = onChallenge
|
|
85
87
|
? await onChallenge(challenge, {
|
|
@@ -2,11 +2,12 @@ import { Client } from '@modelcontextprotocol/sdk/client/index.js'
|
|
|
2
2
|
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'
|
|
3
3
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
|
|
4
4
|
import { McpError } from '@modelcontextprotocol/sdk/types.js'
|
|
5
|
-
import { Challenge, Mcp as core_Mcp } from 'mppx'
|
|
5
|
+
import { Challenge, Credential, Errors, Mcp as core_Mcp, Method } from 'mppx'
|
|
6
6
|
import { tempo as tempo_client } from 'mppx/client'
|
|
7
7
|
import { Mppx as Mppx_server, tempo as tempo_server } from 'mppx/server'
|
|
8
|
+
import { Methods } from 'mppx/tempo'
|
|
8
9
|
import { createClient } from 'viem'
|
|
9
|
-
import { afterEach, beforeEach, describe, expect, test } from 'vp/test'
|
|
10
|
+
import { afterEach, beforeEach, describe, expect, test, vi } from 'vp/test'
|
|
10
11
|
import { accounts, asset, chain, http, client as testClient } from '~test/tempo/viem.js'
|
|
11
12
|
|
|
12
13
|
import * as McpServer_transport from '../server/Transport.js'
|
|
@@ -187,6 +188,42 @@ describe('McpClient.wrap', () => {
|
|
|
187
188
|
'No compatible payment method. Server offers: unknown_method.charge. Client has: tempo.charge',
|
|
188
189
|
)
|
|
189
190
|
})
|
|
191
|
+
|
|
192
|
+
test('behavior: rejects expired challenges before creating credential', async () => {
|
|
193
|
+
const challenge = Challenge.fromMethod(Methods.charge, {
|
|
194
|
+
realm,
|
|
195
|
+
secretKey,
|
|
196
|
+
expires: new Date(Date.now() - 60_000).toISOString(),
|
|
197
|
+
request: {
|
|
198
|
+
amount: '1',
|
|
199
|
+
currency: asset,
|
|
200
|
+
decimals: 6,
|
|
201
|
+
recipient: accounts[0].address,
|
|
202
|
+
},
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
server.registerTool('expired_tool', { description: 'Tool' }, async () => {
|
|
206
|
+
throw new McpError(core_Mcp.paymentRequiredCode, 'Payment Required', {
|
|
207
|
+
httpStatus: 402,
|
|
208
|
+
challenges: [challenge],
|
|
209
|
+
})
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
const createCredential = vi.fn(async ({ challenge }) =>
|
|
213
|
+
Credential.serialize({
|
|
214
|
+
challenge,
|
|
215
|
+
payload: { signature: '0xsignature', type: 'transaction' },
|
|
216
|
+
}),
|
|
217
|
+
)
|
|
218
|
+
const mcp = McpClient.wrap(client, {
|
|
219
|
+
methods: [Method.toClient(Methods.charge, { createCredential })],
|
|
220
|
+
})
|
|
221
|
+
|
|
222
|
+
await expect(mcp.callTool({ name: 'expired_tool', arguments: {} })).rejects.toThrow(
|
|
223
|
+
Errors.PaymentExpiredError,
|
|
224
|
+
)
|
|
225
|
+
expect(createCredential).not.toHaveBeenCalled()
|
|
226
|
+
})
|
|
190
227
|
})
|
|
191
228
|
|
|
192
229
|
describe('isPaymentRequiredError', () => {
|
|
@@ -3,6 +3,7 @@ import type { McpError } from '@modelcontextprotocol/sdk/types.js'
|
|
|
3
3
|
|
|
4
4
|
import type * as Challenge from '../../Challenge.js'
|
|
5
5
|
import * as Credential from '../../Credential.js'
|
|
6
|
+
import * as Expires from '../../Expires.js'
|
|
6
7
|
import * as AcceptPayment from '../../internal/AcceptPayment.js'
|
|
7
8
|
import * as core_Mcp from '../../Mcp.js'
|
|
8
9
|
import type * as Method from '../../Method.js'
|
|
@@ -186,6 +187,8 @@ async function createCredential<methods extends readonly Method.AnyClient[]>(
|
|
|
186
187
|
`No method found for "${challenge.method}.${challenge.intent}". Available: ${methods.map((m) => `${m.name}.${m.intent}`).join(', ')}`,
|
|
187
188
|
)
|
|
188
189
|
|
|
190
|
+
if (challenge.expires) Expires.assert(challenge.expires, challenge.id)
|
|
191
|
+
|
|
189
192
|
const parsedContext = mi.context && context !== undefined ? mi.context.parse(context) : undefined
|
|
190
193
|
return mi.createCredential(
|
|
191
194
|
parsedContext !== undefined ? { challenge, context: parsedContext } : ({ challenge } as never),
|
|
@@ -180,7 +180,7 @@ describe('scope binding', () => {
|
|
|
180
180
|
expect(challengeResponse.status).toBe(402)
|
|
181
181
|
|
|
182
182
|
const challenge = Challenge.fromResponse(challengeResponse)
|
|
183
|
-
expect(challenge.opaque).
|
|
183
|
+
expect(challenge.opaque).toBe('eyJfbXBweF9zY29wZSI6IkdFVCAvYWxwaGEvOmlkIn0')
|
|
184
184
|
|
|
185
185
|
const credential = Credential.from({ challenge, payload: { token: 'valid' } })
|
|
186
186
|
const replay = await fetch(`${server.url}/beta/1`, {
|
|
@@ -207,7 +207,7 @@ describe('scope binding', () => {
|
|
|
207
207
|
expect(challengeResponse.status).toBe(402)
|
|
208
208
|
|
|
209
209
|
const challenge = Challenge.fromResponse(challengeResponse)
|
|
210
|
-
expect(challenge.opaque).
|
|
210
|
+
expect(challenge.opaque).toBe('eyJfbXBweF9zY29wZSI6InNoYXJlZC1zY29wZSJ9')
|
|
211
211
|
|
|
212
212
|
const credential = Credential.from({ challenge, payload: { token: 'valid' } })
|
|
213
213
|
const replay = await fetch(`${server.url}/beta/2`, {
|
package/src/proxy/Proxy.test.ts
CHANGED
|
@@ -771,7 +771,7 @@ describe('create', () => {
|
|
|
771
771
|
expect(challengeResponse.status).toBe(402)
|
|
772
772
|
|
|
773
773
|
const challenge = Challenge.fromResponse(challengeResponse)
|
|
774
|
-
expect(challenge.opaque).
|
|
774
|
+
expect(challenge.opaque).toBe('eyJfbXBweF9zY29wZSI6IkdFVCAvYXBpL3YxL2FscGhhIn0')
|
|
775
775
|
|
|
776
776
|
const credential = Credential.from({ challenge, payload: { token: 'valid' } })
|
|
777
777
|
const replay = await fetch(`${proxyServer.url}/api/v1/beta`, {
|
|
@@ -813,7 +813,7 @@ describe('create', () => {
|
|
|
813
813
|
expect(challengeResponse.status).toBe(402)
|
|
814
814
|
|
|
815
815
|
const challenge = Challenge.fromResponse(challengeResponse)
|
|
816
|
-
expect(challenge.opaque).
|
|
816
|
+
expect(challenge.opaque).toBe('eyJfbXBweF9zY29wZSI6InNoYXJlZC1zY29wZSJ9')
|
|
817
817
|
|
|
818
818
|
const credential = Credential.from({ challenge, payload: { token: 'valid' } })
|
|
819
819
|
const replay = await fetch(`${proxyServer.url}/api/v1/beta`, {
|